Skip to content

Lexer: extract matches with PREG_PATTERN_ORDER (~40% faster lexing) - #313

Merged
ondrejmirtes merged 2 commits into
2.3.xfrom
lexer-preg-pattern-order
Aug 31, 2026
Merged

Lexer: extract matches with PREG_PATTERN_ORDER (~40% faster lexing)#313
ondrejmirtes merged 2 commits into
2.3.xfrom
lexer-preg-pattern-order

Conversation

@JanTvrdik

Copy link
Copy Markdown
Collaborator

Lexer::tokenize() spends most of its time building match arrays that it throws
away 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 returns
list<array{string, int, int}>.

1. The numeric patterns hold 12 capturing groups nobody reads

TOKEN_FLOAT and TOKEN_INTEGER use (_[0-9]++)* for the _ digit
separators — 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_ORDER allocates one array per token

With the groups gone, PREG_SET_ORDER still allocates a two-entry array per
match — the whole match and the MARK — which tokenize() reads out and drops
immediately. PREG_PATTERN_ORDER instead collects the whole PHPDoc into two
arrays: $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:

lexing
baseline
non-capturing groups only −7%
PREG_PATTERN_ORDER only (groups left capturing) −7%
both −41%

PREG_PATTERN_ORDER with capturing groups present is no better than the status
quo, 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 OSS
libraries, one full vendor tree (nikic/php-parser, nette, symfony, react,
phpunit, jetbrains/phpstorm-stubs, doctrine, twig), phpstan-src/src, and this
library. Pinned to one core, php -n, minimum of 15 rounds with the variants
interleaved.

lexing parse (lex + PhpDocParser)
PHP 8.1 −42%
PHP 8.4 −40%
PHP 8.5 −40% −19%
PHP 8.5, lines+indexes+comments −19%
PHP 8.5, opcache + tracing JIT −45% −23%

Lexing 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 check passes 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 the
TOKEN_END token, 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:

lexing parse bytes/token
Token objects instead of tuples +21% +8% 164 (−39%)
struct-of-arrays TokenList −56% −31% 124 (−54%)

Token objects trade 21% lexing time for 39% less memory (new Token(...) is a
userland 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 the
values 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 an
issue with the details if either is worth discussing.

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.
Copilot AI lite review requested due to automatic review settings August 31, 2026 14:39

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 from PREG_SET_ORDER to default PREG_PATTERN_ORDER and 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 $matches contains 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.

@ondrejmirtes

Copy link
Copy Markdown
Member

Yes, pleasen open an issue about future gains coupled with BC breaks.

@ondrejmirtes
ondrejmirtes merged commit 148ceff into 2.3.x Aug 31, 2026
61 of 62 checks passed
@ondrejmirtes

Copy link
Copy Markdown
Member

Thank you!

@ondrejmirtes
ondrejmirtes deleted the lexer-preg-pattern-order branch August 31, 2026 16:05
@SerafimArts

Copy link
Copy Markdown

Token objects trade 21% lexing time for 39% less memory (new Token(...) is a
userland call where the array literal is one opcode; the parser does get ~2%
faster from reading properties instead of indexing arrays, but not enough).

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants