At Symfony, we're obsessed with performance, and Symfony 8.2 has plenty of examples. Over the last few weeks, Nicolas Grekas profiled the Symfony Demo application and a custom API project, turning every finding into a pull request.
The result is faster web pages, APIs, console commands and cache builds, in production and development.
Faster APIs
APIs that serialize and deserialize lots of objects see some of the biggest gains. When normalizing objects, the Serializer computes each attribute's context once, avoids copying the normalized array for each attribute and fetches the discriminator mapping once per object instead of once per attribute. On an API application that returns lists of 150 to 900 objects with serialization groups and a name converter, list routes are 14% to 24% faster (PR #66383).
Reading a property with PropertyAccess now takes 40% fewer PHP instructions,
so those list routes run another 6% to 8% fewer instructions (PR #66397).
The snake_case name converter also memoizes its results instead of running a
regular expression for every attribute of every object, which makes list routes
2% to 5% faster (PR #66384).
Denormalizing (e.g. when using #[MapRequestPayload]) also got faster:
- The property accessor is only created for classes that use
#[SerializedPath], instead of once per denormalized object (PR #66398); - Constructor parameters are checked before calling PropertyInfo, which avoids some lookups when denormalizing readonly DTOs. POST requests run 4% to 6% fewer PHP instructions (PR #66399);
- Class files are no longer read on each request to resolve the types of constructor parameters (PR #66400);
- The property info of the classes mapped by the Serializer and the Validator is now warmed up into a single cache file instead of being computed and stored one entry at a time. POST requests are 4% to 5% faster in production (PR #66386).
PropertyInfo and TypeInfo were also optimized. Constructor docblocks are now parsed once per class instead of once per property. Since property info isn't cached in debug mode, this makes dev POST requests 24% to 38% faster (10 to 14 ms each) (PR #66382). Class docblocks are also parsed once per type context instead of five or six times (PR #66401).
Other improvements:
- The Validator reuses the
IntlDateFormatterused to format dates in constraint violation messages instead of creating a new one each time (PR #66396); - The
ReflectionExtractorcreates its inflector and type resolver only when needed, so a request that serializes one object loads 10 fewer classes (PR #66402); - Exceptions are logged without turning them into
FlattenExceptionobjects first. Logging a 422 response now takes 70 µs instead of 125 µs (PR #66392).
Finally, client errors (4xx HTTP responses) are now logged at the warning
level instead of error (PR #66391). This better reflects a client mistake
and also saves work: with the default Monolog recipe (fingers_crossed with
action_level: error), 400, 403 and 422 responses no longer flush the request's
log buffer. A 422 response from #[MapRequestPayload] is now 3% to 4% faster.
Note
This change modifies the level of some log messages. If you rely on them
(e.g. in alerts), check PR #66391 for the details. You can still
configure the log level per exception using framework.exceptions
or the #[WithLogLevel] attribute.
Faster Production Requests
The gains extend beyond APIs:
- The
PhpFilesAdapterof the Cache component no longer tries to include files that don't exist. A failed include costs about 10 µs, while checking for the file takes 1 µs. On the Symfony Demo with a read-only cache directory and without APCu, this saves 830 µs per request (PR #66347); ChainAdapter::get()returns hits of its first adapter directly, without creating two closures each time (PR #66346);- The runtime mode is no longer resolved on web requests, where it's not needed. This removes 8 of the 18 environment variable lookups of a production request in the Symfony Demo (PR #66367);
- The HtmlSanitizer no longer parses inputs that don't contain
<or&. Sanitizing a short plain text string (very common when using thesanitize_htmloption in form fields) is now 5 times cheaper and no longer allocates 1.1 MB of memory on each call (PR #66389).
Faster Development Environment
You spend much of your day in the development environment, so it deserves some attention too. The profiler's Config, Security, Request and Cache data collectors now defer their static work (versions, bundles, role hierarchy, cURL commands, etc.) until profiles are saved. With PHP-FPM, that happens after the response is sent, saving up to 0.6 ms per collector per page (PR #66365, PR #66371, PR #66372, PR #66364).
The Serializer data collector also aggregates nested calls as they happen, instead of storing them all until the end. This saves 5 MB of memory on a dev request that serializes 1,000 orders (PR #66390).
If you use AssetMapper:
- Serving an asset with the dev server no longer builds the profiler and Twig. On the Symfony Demo, each asset is served in 6.3 ms instead of 7.0 ms (PR #66373);
- Each cached asset is loaded once per request and from a single file, instead of reading and unserializing two files each time it's needed. Rendering the importmap of the Symfony Demo in dev now takes 2.4 ms instead of 4.7 ms (PR #66369).
Faster Cache Warmup and Container Builds
Faster builds help on every deploy, when you run cache:clear and
cache:warmup, and in dev, whenever a config change rebuilds the container:
- XLIFF translation files are first validated against a much smaller schema
that compiles 10 times faster (the full schema is still used when needed).
On the Symfony Demo, a cold
cache:warmupgoes from 1.77 s to 1.57 s and a dev request after editing a translation file goes from 82 ms to 67 ms (PR #66375); - The Twig cache warmer only compiles the form themes that your app can use, instead of all the 13 themes included in Symfony. On the Symfony Demo, that's 10 fewer themes and a cold cache warmup that takes 2.11 s instead of 2.27 s (PR #66381). Templates listed twice under different names are also compiled only once (PR #66370);
- Dumping the container for debug only clones the service definitions that hold env vars (22 out of 1,344 in the Symfony Demo), instead of all of them. This step goes from 49 ms to 14 ms (PR #66343);
- The
XmlDumperskips encoding plain ASCII values, which was almost 30% of the dump time (PR #66379); - The configuration trees used to generate the
reference.phpandschema.jsonfiles are now built once instead of twice (PR #66378).
Other Performance Improvements
Symfony 8.2 also finish the response before the profiler saves its data, making dev pages end 11 to 13 ms sooner with PHP-FPM (PR #66330). Editing a translation file no longer rebuilds the container either, cutting the next request from about 790 ms to 85 ms (PR #66332).
This work also improved other projects: Twig traverses nodes faster (Twig PR #4976)
and TwigComponent pre-lexes templates faster (Symfony UX PR #3934). It even
reached PHP itself and Lexbor, the HTML5 parser behind PHP's Dom\HTMLDocument:
new ArrayIterator()andnew ArrayObject(), which Twig nodes, Symfony bags and Doctrine collections create in theirgetIterator()methods, now run about 180 fewer instructions per call (PHP PR #23946);- Lexbor no longer allocates 8 times the memory it needs for some of its internal arrays, saving about 10 KB per parse (Lexbor PR #428).
Some other proposals are still under review:
htmlspecialchars()returns its input when there's nothing to encode, which is the most common case in templates. On the Symfony Demo, which calls it 711 times per request, it becomes about 3.5 times cheaper (PHP PR #23957). Checking for UTF-8 first when resolving the charset makes it another 40% cheaper on short strings (PHP PR #23961);ArrayObjectandArrayIteratorshare the given arrays (copy-on-write) instead of copying them, so aforeachover these collections no longer duplicates their items (PHP PR #23945). Creating an iterator while others are alive (e.g. in a recursive walk of Twig nodes) is also faster (PHP PR #23947);- Lexbor preallocates less memory, so parsing a small document needs about 200 KB instead of 1 MB (Lexbor PR #427).
Why Care About Sub-Millisecond Improvements?
Some of the changes mentioned above save 70 microseconds per request (that's 2,000 times faster than an average human blink). Why care about those? Because in a mature project like Symfony, you can't find one magic change that makes everything twice as fast. That's why we keep making it faster one small improvement at a time.
Upgrade to Symfony 8.2 when it's released in November to get all of this without changing a line of your application. If profiling your own application reveals something slow in Symfony, please tell us. We care about every microsecond.
These improvements are wonderful! One question I have though - I see many improvements which basically boil down to "compute only once needed - on-demand", however we are mainly running Symfony in worker mode. Does Symfony 8.2 now offer warming up these on-demand changes so, we have peak and stable performance across the worker lifetime? With 8.2 I understand these changes as that workers will constantly load additional stuff and run these paths when new things arrive, rather than load everything all at once, which we would prefer in the worker mode.
@Rostislav Lazy doesn't mean recomputed: in worker mode, what's built on first use stays in memory for the rest of the worker's life, as before. So that worker mode isn't getting any slower.