Lexer: extract matches with PREG_PATTERN_ORDER (~40% faster lexing) - #313
Conversation
The float and integer token patterns hold 12 capturing groups, one per '_' digit-separator repetition. Nothing ever reads them, but preg_match_all() collects every group of the pattern for every token it finds, so each token of every PHPDoc carries 12 unused entries. On its own this is worth about 7% of lexing time. It is mainly a precondition for the next commit: switching to PREG_PATTERN_ORDER while these groups are still capturing is also only worth ~7%, because PREG_PATTERN_ORDER then has to build 12 arrays of one entry per token instead. Together the two changes are worth ~40%. The added test pins the property, which nothing else would catch.
PREG_SET_ORDER allocates one array per match. Now that the token patterns
have no capturing groups those arrays hold two entries each -- the whole
match and the MARK -- and tokenize() reads both out and drops the array
again, so lexing pays one array allocation per token for nothing.
PREG_PATTERN_ORDER collects the whole PHPDoc into two arrays instead:
$matches[0] holds the values and $matches['MARK'] the token types. Two array
headers per PHPDoc rather than one per token.
Measured over 37150 unique docblocks (8.38 MiB, 2.36M tokens) collected from
20 OSS libraries, one full vendor tree, phpstan-src and this library:
lexing parse (lex + PhpDocParser)
PHP 8.1 -42%
PHP 8.4 -40%
PHP 8.5 -40% -19%
tokenize() still returns list<array{string, int, int}>, and both the tokens
and the ASTs printed from them are byte-identical to before over the whole
corpus.
There was a problem hiding this comment.
Pull request overview
Optimizes PHPStan\PhpDocParser\Lexer\Lexer::tokenize() by reducing preg_match_all() allocations while preserving the existing list<array{string, int, int}> return type and token stream semantics.
Changes:
- Switch
preg_match_all()usage fromPREG_SET_ORDERto defaultPREG_PATTERN_ORDERand iterate over$matches[0]+$matches['MARK']to avoid per-token match array allocations. - Update numeric token regexes (
TOKEN_FLOAT,TOKEN_INTEGER) to remove capturing groups by converting them to non-capturing groups. - Add a regression test that asserts the lexer regexp produces no capturing groups (so
$matchescontains only[0, 'MARK']).
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
src/Lexer/Lexer.php |
Uses PREG_PATTERN_ORDER match layout and removes capturing groups from numeric token patterns to reduce allocation overhead. |
tests/PHPStan/Lexer/LexerTest.php |
Adds a test to prevent reintroducing capturing groups into token patterns, preserving the performance win. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
Yes, pleasen open an issue about future gains coupled with BC breaks. |
|
Thank you! |
If you want to create objects, I recommend using prototypes and thus bypassing the constructor call, like: $prototype = new Token();
foreach ($values as $value) {
$token = clone $prototype;
$token->id = $xxx;
$token->value = $yyy;
$token->zzz = $zzz;
$tokens[] = $token;
}Like this: https://github.com/phplrt/phplrt/blob/4.x/libs/components/lexer/src/Internal/Tokenizer.php#L160-L172 This is a hack that can significantly improve the performance of creating objects on hot paths. |
Lexer::tokenize()spends most of its time building match arrays that it throwsaway again. Two changes, which only pay off together, take ~40% off lexing and
~19% off end-to-end PHPDoc parsing. No API change:
tokenize()still returnslist<array{string, int, int}>.1. The numeric patterns hold 12 capturing groups nobody reads
TOKEN_FLOATandTOKEN_INTEGERuse(_[0-9]++)*for the_digitseparators — 12 capturing groups between them. Nothing reads them, but
preg_match_all()collects every group of the pattern for every token it finds,so each token carries 12 unused entries. Made them non-capturing.
2.
PREG_SET_ORDERallocates one array per tokenWith the groups gone,
PREG_SET_ORDERstill allocates a two-entry array permatch — the whole match and the
MARK— whichtokenize()reads out and dropsimmediately.
PREG_PATTERN_ORDERinstead collects the whole PHPDoc into twoarrays:
$matches[0]for the values,$matches['MARK']for the token types.Two array headers per PHPDoc rather than one per token.
The two are multiplicative rather than additive, which is why they are one PR:
PREG_PATTERN_ORDERonly (groups left capturing)PREG_PATTERN_ORDERwith capturing groups present is no better than the statusquo, because it then has to build one array per group of one entry per token.
That makes a capturing group in a token pattern a silent 35-percentage-point
regression, so
LexerTest::testTokenPatternsHaveNoCapturingGroups()pins it.Numbers
Corpus: 37 150 unique docblocks, 8.38 MiB, 2 355 859 tokens, collected by
tokenizing 10 001 PHP files and keeping every unique
T_DOC_COMMENT— 20 OSSlibraries, one full vendor tree (nikic/php-parser, nette, symfony, react,
phpunit, jetbrains/phpstorm-stubs, doctrine, twig),
phpstan-src/src, and thislibrary. Pinned to one core,
php -n, minimum of 15 rounds with the variantsinterleaved.
PhpDocParser)lines+indexes+commentsLexing is ~41% of total parse time in the baseline, which is why a lexer-only
change moves the end-to-end number this much.
Behaviour
Tokens are byte-identical to before, and so are the ASTs printed from them, over
all 37 150 docblocks (xxh128 over both).
make checkpasses on PHP 8.5:1750 tests, phpcs, PHPStan.
The only behavioural edge case is a subject that produces zero matches — an
empty string — where
$matches['MARK']is absent; that returns just theTOKEN_ENDtoken, as before.Not in this PR
While benchmarking I also tried changing the token representation, in case it is
of interest for a future major:
Tokenobjects instead of tuplesTokenListTokenobjects trade 21% lexing time for 39% less memory (new Token(...)is auserland call where the array literal is one opcode; the parser does get ~2%
faster from reading properties instead of indexing arrays, but not enough).
Three parallel packed arrays win on both axes, because
$matches[0]becomes thevalues array with no per-token allocation at all — but that one changes the
return type of
tokenize(), so PHPStan would have to follow. Happy to open anissue with the details if either is worth discussing.