Version: Edit on GitHub

Performance Best-Practice Guide

When developing a high traffic web application, it is a common practice to focus on performance measures and incorporate changes to make the application even more performant. A highly optimized website drives more traffic at zero additional cost and increases the user experience a lot. To optimize the website performance, we recommend below tools and configuration. The load test performance benchmarks are noted in each section, based on ApacheBench commandline tool, for insights on comparing the average loading time of a page, before and after applying these changes.

Before we start with the testing, it is important to turn off development & debug mode by setting the following environment variables to replicate the production environment scenario:

APP_ENV=prod
APP_DEBUG=0
PIMCORE_DEV_MODE=0

PHP 8 Opcache & JIT compiler

As you know, PHP is an interpreted language, which means the interpreter parses, compiles and executes the code (opcode) everytime, when executing PHP scripts. This may result in wastage of CPU resources and execution time. This is where the OPcache extension comes in to play:

“OPcache improves PHP performance by storing precompiled script bytecode in shared memory, thereby removing the need for PHP to load and parse scripts on each request.”

PHP 8 has introduced the support for Just-In-Time Compilation (JIT), which has a great potential to additionally speed things up significantly. The JIT compiler requires Opcache to be enabled to work.

By default, JIT compiler is turned off in PHP 8. To enable it, add the following configuration to your php.ini:

opcache.enable=1 # enables the Opcache for PHP server
opcache.enable_cli=1 # enables the Opcache for CLI mode
opcache.jit_buffer_size=256M # JIT buffer memory size. 0 value disables the JIT compiler.  

Benchmarks:

On running command, ab -n 100 -c 20 http://localhost/en

  • Before Changes (OPcache on but JIT off)
Time taken for tests:   1.061 seconds
Time per request:       212.203 [ms] (mean)
Time per request:       10.610 [ms] (mean, across all concurrent requests)

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.3      0       1
Processing:    65  182  41.2    205     225
Waiting:       64  182  41.3    205     224
Total:         66  183  41.0    205     225
  • After Changes
Time taken for tests:   1.008 seconds
Time per request:       201.617 [ms] (mean)
Time per request:       10.081 [ms] (mean, across all concurrent requests)

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.4      0       1
Processing:    65  175  36.9    181     241
Waiting:       65  174  36.9    180     240
Total:         66  175  36.7    181     241

Symfony Performance Best Practices

As Pimcore is based on Symfony framework for backend requests processing, it is highly recommended to comply with Symfony Performance Best Practices.

Benchmarks:

On running command, ab -n 100 -c 20 http://localhost/en

  • Before Changes
Time taken for tests:   1.100 seconds
Time per request:       219.904 [ms] (mean)
Time per request:       10.995 [ms] (mean, across all concurrent requests)

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.4      0       2
Processing:    70  194  44.6    200     289
Waiting:       69  193  44.6    200     289
Total:         70  194  44.4    200     290
  • After Changes
Time taken for tests:   1.042 seconds
Time per request:       208.337 [ms] (mean)
Time per request:       10.417 [ms] (mean, across all concurrent requests)

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.4      0       1
Processing:    56  182  37.1    189     254
Waiting:       55  182  37.1    189     254
Total:         56  182  36.9    189     255

Opcache Preloading

Opcache preloading was introduced in PHP 7.4, a feature that could improve the performance of your application significantly, by preloading commonly used framework files and keeping the generated byte-code in memory.

In order to enable OPcache preloading, configure these settings in your php.ini:

opcache.preload_user=www-data # user that runs PHP(-FPM)
opcache.preload=/var/www/html/var/cache/prod/App_KernelProdContainer.preload.php # path to preload file in project's /var/cache directory

Note: memory_limit should be set to at least 200M when using preloading with Pimcore.

It is also possible to mark which classes should be preloaded or not by using Symfony service tags container.preload & container.no_preload.

Benchmarks:

On running command, ab -n 100 -c 20 http://localhost/en/shop/Products/Cars/Economy-Cars/Fiat-500~p104

  • Before Changes
Time taken for tests:   0.577 seconds
Time per request:       115.394 [ms] (mean)
Time per request:       5.770 [ms] (mean, across all concurrent requests)

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.3      0       1
Processing:    35   99  23.1    112     127
Waiting:       33   97  23.1    109     125
Total:         35   99  22.9    112     127
  • After Changes
Time taken for tests:   0.468 seconds
Time per request:       93.522 [ms] (mean)
Time per request:       4.676 [ms] (mean, across all concurrent requests)

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.3      0       1
Processing:    32   80  17.8     79     111
Waiting:       30   78  17.7     76     110
Total:         33   81  17.6     79     111

MySQL/MariaDB Optimizations

Pimcore uses Data Access Object or DAO pattern to isolate the business logic from low level data access operations. These data access operations play a vital role in performance of Pimcore application features requiring read & write to/from the database. Therefore, it is important to tune your database configuration to be performant and to exactly fit the needs of your application.

MySQL Composite Index

If you are using Data Objects or custom database tables for data persistence, then it is beneficial to optimize these tables to have composite indices, so that query optimizer uses the composite index for queries that matches all columns in the index rather than searching the complete table. You can configure composite indices for your data objects in the class definition under General Settings.

MySQL Buffer Pool

The buffer pool is an area in main memory where InnoDB caches table and index data as it is accessed. The main purpose of buffer pool is to improve the response time of data retrieval. The server system variable innodb_buffer_pool_size can be set from 70-80% of the total available memory on a dedicated database server with only or primarily InnoDB tables.

Please follow this discussion to decide buffer pool size and update my.cnf:

[mysqld]
    innodb_buffer_pool_size=5G # needs to be adjusted according to your data

MySQL Query Cache

MySQL server features a Query Cache. When enabled, the query cache stores SELECT statements together with the retrieved record set in memory, then if another identical query is received, the server can retrieve the results from the query cache rather than parsing and executing the same query again.

  • you can check if query cache is enabled with command SHOW VARIABLES LIKE 'have_query_cache';.
  • In order to enable the query caching, update my.cnf:
[mysqld] 
query_cache_type=1 
query_cache_size = 10M 
query_cache_limit=256K
  • you can also use Mysql Tuning script for more optimizations mysqltuner.pl

Benchmarks:

On running command, ab -n 100 -c 20 http://localhost/en/shop/Products/Cars/Economy-Cars~c547

  • Before Changes
Time taken for tests:   2.515 seconds
Time per request:       503.024 [ms] (mean)
Time per request:       25.151 [ms] (mean, across all concurrent requests)

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.3      0       1
Processing:   124  453 116.0    457     727
Waiting:      121  448 115.3    453     724
Total:        124  454 115.9    458     727
  • After Changes
Time taken for tests:   2.251 seconds
Time per request:       450.187 [ms] (mean)
Time per request:       22.509 [ms] (mean, across all concurrent requests)

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.3      0       1
Processing:   123  401  83.0    413     542
Waiting:      120  397  82.5    408     539
Total:        123  401  82.8    413     542

Pimcore Caching (Redis)

Pimcore uses extensively caches for different types of data. The primary cache is a pure object cache where every element (document, asset, object) in Pimcore is cached as it is (serialized objects).

This Primary Cache is utilizing the Pimcore\Cache interface to store the objects. Pimcore\Cache utilizes a Pimcore\Cache\Core\CoreCacheHandler to apply Pimcore's caching logic on top of a PSR-6 cache implementation which needs to implement cache tagging. Pimcore uses the pimcore.cache.pool Symfony cache pool, you can configure it according to your needs, but it's crucial that the pool supports tags.

By default, Pimcore uses the Doctrine connection and write to your DB's cache_items tables. You can also utilize available cache adapters from Symfony, however, Pimcore recommends to use Redis adapter for a performance boost with optional clustering and fail-over support.

Configure Redis adapter for Pimcore\Cache using these settings:

# config/cache.yaml
framework:
    cache:
        pools:
            pimcore.cache.pool:
                public: true
                tags: true
                default_lifetime: 31536000  # 1 year
                adapter: pimcore.cache.adapter.redis_tag_aware
                provider: 'redis://localhost'

Benchmarks:

On running command, ab -n 100 -c 20 http://localhost/en/shop/Products/Cars~c390

  • Before Changes

Time taken for tests:   6.495 seconds
Time per request:       1299.007 [ms] (mean)
Time per request:       64.950 [ms] (mean, across all concurrent requests)

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.4      0       3
Processing:   386 1178 209.6   1222    1550
Waiting:      383 1169 208.5   1215    1539
Total:        386 1178 209.6   1222    1551
  • After Changes
Time taken for tests:   5.560 seconds
Time per request:       1111.956 [ms] (mean)
Time per request:       55.598 [ms] (mean, across all concurrent requests)

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.3      0       2
Processing:   314 1021 196.1   1010    2106
Waiting:      312 1010 192.8    998    2080
Total:        315 1021 196.0   1011    2107

Pimcore Full Page Cache

Pimcore full page cache enables you to cache the content returned by a controller action. With this cache, the same content does not need to be generated each and every time the same controller action is invoked. A request processed through the MVC framework is cached and stored in the cache pool, so on following request calls the response is served directly from the cache.

You can configure output page cache in config.yaml, as:

pimcore:
    full_page_cache:
        enabled: true
        lifetime: 120
        exclude_cookie: 'pimcore_admin_sid'
        exclude_patterns: '@^/test/de@'

Benchmarks:

On running command, ab -n 100 -c 20 http://localhost/en

  • Before Changes
Time taken for tests:   0.704 seconds
Time per request:       140.810 [ms] (mean)
Time per request:       7.040 [ms] (mean, across all concurrent requests)

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.3      0       1
Processing:    45  120  27.7    123     166
Waiting:       45  120  27.7    122     165
Total:         46  120  27.5    123     166
  • After Changes
Time taken for tests:   0.646 seconds
Time per request:       129.298 [ms] (mean)
Time per request:       6.465 [ms] (mean, across all concurrent requests)

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.3      0       1
Processing:    45  115  26.0    116     167
Waiting:       45  114  26.0    116     166
Total:         46  115  25.9    116     167

Important notice about sessions
Only use sessions when really necessary. The Pimcore full-page cache detects the usage of sessions in the code and disables itself if necessary.

Static-Page-Generator

Pimcore offers a static page generator service, which generates static HTML files out of pages (documents). These static files can be delivered directly by Apache/Nginx instead of going through complete PHP driven MVC cycle on a frontend request. This feature is not recommended for a page with dynamic content i.e. news listing, product pages, and so on, where content changes frequently.

Enable Static Page generator for a Document:

To enable automatic static page generation for a document, go to Document -> Settings -> Static Page Generator. Mark enable checkbox and define optional lifetime for static pages (which regenerates static page after lifetime) and save document.

Benchmarks:

On running command, ab -n 100 -c 20 http://localhost/en/Magazine

  • Before Changes
Time taken for tests:   0.803 seconds
Time per request:       160.651 [ms] (mean)
Time per request:       8.033 [ms] (mean, across all concurrent requests)

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   1.6      0      15
Processing:    55  140  47.6    129     274
Waiting:       53  136  46.8    127     268
Total:         57  141  47.7    130     274
  • After Changes
Time taken for tests:   0.033 seconds
Time per request:       6.686 [ms] (mean)
Time per request:       0.334 [ms] (mean, across all concurrent requests)

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.4      0       1
Processing:     2    5   1.5      5      10
Waiting:        2    5   1.6      5      10
Total:          3    6   1.3      6      10

In-Template Caching

Pimcore provides a Twig extension pimcore_cache for in-template caching, which is used to cache some parts directly in the template, independent of the global caching functionality. This can be useful for templates which needs a lot of calculation or require a huge amount of objects e.g., Navigation.

You can cache part of a template like:

{% set cache = pimcore_cache("main_navigation_cache", 60) %}
{% if not cache.start() %}
    {% set navStartNode = document.getProperty('navigation_root') %}

    {% if not navStartNode is instanceof('\\Pimcore\\Model\\Document') %}
        {% set navStartNode = pimcore_document(1) %}
    {% endif %}

    {% set mainNavigation =  app_navigation_data_links(document, navStartNode) %}
    <div class="container">
        ...
        {{
            pimcore_render_nav(mainNavigation, 'menu', 'renderMenu', {
                maxDepth: 2,
                ulClass: {
                    0: 'navbar-nav menu-links ml-4 m-auto',
                    1: 'dropdown dropdown-menu',
                    'default': 'dropdown-menu dropdown-submenu'
                }
            })
        }}
        ...
    </div>
{% endif %}

Read more about pimcore_cache here.

Benchmarks:

On running command, ab -n 100 -c 20 http://localhost/en/Magazine

  • Before Changes
Time taken for tests:   0.808 seconds
Time per request:       161.603 [ms] (mean)
Time per request:       8.080 [ms] (mean, across all concurrent requests)

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.3      0       1
Processing:    48  137  35.8    135     207
Waiting:       45  133  35.4    130     204
Total:         49  137  35.7    135     207
  • After Changes
Time taken for tests:   0.644 seconds
Time per request:       128.881 [ms] (mean)
Time per request:       6.444 [ms] (mean, across all concurrent requests)

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.3      0       1
Processing:    41  113  28.6    114     175
Waiting:       34  109  28.3    111     169
Total:         42  113  28.4    114     175

Varnish Cache

The Varnish Cache is a so-called caching reverse proxy, that is placed in front of a server, which is responsible for delivering website content. On initial request, the varnish cache saves a copy of the server reponse in memory. In the event of subsequent requests, the response is sent directly from the memory, bypassing the server. This leads to faster response time, as no processing is required on the server side.

Note: Pimcore sends the right headers for Varnish if full-page cache is enabled

Edge Side Includes (ESI)

Edge Side Includes is a markup to manage page fragments, which can be cached and merged into one page. These fragments can have individual cache policies and can be shared on multiple pages.

Varnish supports 2 main ESI tags, which are:

esi:include
esi:remove

Symfony has a built-in support for ESI. Read Here. Read more about Varnish.