Repository navigation
[3.x] Speed up initializing the lexer - #5002
Open
andreybolonin wants to merge 1 commit into
Open
andreybolonin wants to merge 1 commit into
andreybolonin wants to merge 1 commit into
Conversation
stof
approved these changes
Oct 19, 2026
andreybolonin
force-pushed
the
speed-up-lexer-operator-regex
branch
from
October 19, 2026 18:07
2a1175d to
b43b539
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Lexer::getOperatorRegex()currently callsarray_merge()for every expression parser, copying the tokens collected so far on every iteration. Collect the per-parser token arrays first and merge them once, avoiding repeated copying of the accumulated list.The existing merge semantics, token ordering, duplicate handling, longest-operator-first sorting, and regex construction are preserved. Add coverage for overlapping operator names, shared aliases, and built-in operators alongside custom operators.
Performance
Measured against
428a16179, with PHP 8.5.11 CLI on macOS arm64, CLI OPcache disabled and JIT disabled. The default environment has 48 expression parsers, 49 operator tokens, 46 unique operator tokens. Both versions run in the same PHP process. Each result is the median of 9 batches, alternating before/after execution order, after 20 warm-up calls per version. Times below are microseconds per tokenize call, not batch totals.The workload repeats this 118-byte template 0, 1, 10, 100, or 1,000 times:
First tokenization of a new lexer, reusing an already initialized environment:
The saving is approximately 3 µs per lexer initialization. For the empty source, the nine batch averages ranged from 23.740–25.281 µs before and 20.748–21.359 µs after. For the 118-byte source, they ranged from 46.883–48.055 µs before and 43.876–44.883 µs after. On larger sources, tokenization dominates and the difference is within run-to-run variation; this is an initialization optimization, not a throughput improvement proportional to template size.
Repeated tokenization with an already initialized lexer (same batch sizes):
There is no meaningful improvement on this path because operator regex initialization happens once per lexer instance. A separate test including a new environment + a new lexer + first tokenization of the 118-byte source measured 110.267 → 107.215 µs/call (−2.77%), with 2,000 calls per batch. These local measurements do not claim an equivalent improvement in full rendering or cached-template execution.
Validation
vendor/bin/simple-phpunit— 2,872 tests, 7,017 assertions, 4 skipped, no failures (existing legacy deprecation notices reported).vendor/bin/phpstan analyse --no-progress— no errors.vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --dry-run --diff src/Lexer.php tests/LexerTest.php— no changes needed.Reproduce the benchmark and differential fixture check
Run from the repository root after installing Composer dependencies. Save the PHP script below as
/tmp/twig-lexer-benchmark.php, then run:git show 428a16179:src/Lexer.php > /tmp/twig-lexer-before.php php -d opcache.enable_cli=0 /tmp/twig-lexer-benchmark.phpThe base lexer is renamed in memory so both versions can run in the same process. OPcache is explicitly disabled for this comparison. The script also prints the minimum/maximum batch averages and checks the fixture corpus.
Memory measurements
Compared
428a16179with this MR's exact head in separate, fresh PHP processes (not two renamed classes loaded together). PHP 8.5.11 CLI on macOS arm64, JIT disabled, with CLI OPcache both off and on. Five independent processes per version/workload/OPcache mode, alternating execution order; values below are medians.Inputs, the environment, and extension initialization are set up before the measured interval.
memory_reset_peak_usage()resets the peak immediately before the operation. Reported used-heap peaks come frommemory_get_peak_usage(false); reserved peaks come frommemory_get_peak_usage(true). Absolute peaks include the existing baseline heap; “incremental peak” subtracts the heap immediately before the operation. Result objects/strings remain alive when memory is sampled. Output verification happens afterward, so its serialization buffers are excluded. Process max RSS includes startup/setup and is sampled before verification. OPcache shared usage is reported separately because it is not part of the PHP used-heap metric.All before/after output hashes matched. These are memory measurements only; process startup is excluded from the earlier time benchmarks.
Full first tokenization, using the same 118-byte template repeated 0, 1, 10, 100, or 1,000 times as in the time benchmark. Every value in this table is identical before and after:
Isolated
getOperatorRegex(), invoked once via reflection on a new lexer with expression parsers already initialized:With the default 48 parsers, batching the token arrays increases this isolated temporary peak by 6,952 B without OPcache / 6,696 B with OPcache (about 6.8 / 6.5 KiB). The extra arrays are transient: retained heap after this method is 896 B in both versions. The larger peak later in full tokenization is unchanged. Full empty-source tokenization with 100 and 1,000 additional parsers also had identical used-heap and reserved peaks before/after.
After a warm-up followed by 100 tokenizations of an 11,800-byte source on the same lexer, retained heap relative to the warm-up baseline is 96 B in both versions (the final token stream is held); incremental peak is 128 B in both versions. This workload shows no additional accumulation introduced by the change.
OPcache-reported shared used memory is 64 B higher in the after-version processes. Peak reserved PHP memory is unchanged in every tested workload.
Process max RSS for the largest full-operation workload (includes startup/setup; five fresh processes per side):
RSS has page-level/process-startup variation and should not be used to attribute byte-sized differences to this patch; the allocator/retained-heap figures above show the allocation differences directly.
Reproduce the memory benchmark
Install Composer dependencies, save the PHP and Python scripts below as
/tmp/twig-memory-probe.phpand/tmp/twig-memory-run.py, then run from the repository root:The runner launches 420 fresh PHP processes in total across both MRs. It loads the original class name from the selected snapshot before the Composer autoloader can load it, so each process contains exactly one implementation of the measured class. All other source files must match the MR base; the time/memory changes in these two MRs are confined to the selected Compiler/Lexer methods. Inputs and dependencies outside those classes are shared identically across each comparison. PHP 8.2+ is needed for
memory_reset_peak_usage(); the reported results use PHP 8.5.11. The scripts save all individual measurements to/tmp/twig-memory-results.jsonand print per-case medians.