Lexer: return a struct-of-arrays TokenList (BC break, ~26% faster lexing) - #314
Lexer: return a struct-of-arrays TokenList (BC break, ~26% faster lexing)#314JanTvrdik wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR introduces a new TokenList “struct-of-arrays” token representation and updates core parser/printer code to consume it, enabling significantly faster lexing and reduced memory usage at the cost of a major BC break (changing Lexer::tokenize() and TokenIterator token APIs).
Changes:
- Change
Lexer::tokenize()to returnTokenList(parallel arrays +count) instead oflist<array{string,int,int}>. - Update
TokenIteratorandPrinterto useTokenListfields (values/types/lines/count) rather than tuple offsets. - Adjust tests to access token values from
TokenList.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/PHPStan/Parser/TypeParserTest.php | Updates test logic to read token text from TokenList->values. |
| src/Printer/Printer.php | Switches format-preserving printing token reads to TokenList fields and uses count. |
| src/Parser/TokenIterator.php | Updates iterator internals and public API to accept/return TokenList. |
| src/Lexer/TokenList.php | Adds the new TokenList data structure backing the new token representation. |
| src/Lexer/Lexer.php | Produces TokenList from regex matches (values reuse + types/lines arrays). |
Suppressed comments (1)
src/Lexer/TokenList.php:38
- If TokenList implements \Countable, it should provide the required count() method (ideally returning the cached count).
$this->count = count($values);
}
}
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| public function getTokens(): TokenList | ||
| { | ||
| return $this->tokens; | ||
| } |
|
|
||
| $content = ''; | ||
| for ($i = $startIndex; $i <= $endIndex; $i++) { | ||
| $content .= $tokensArray[$i][Lexer::VALUE_OFFSET]; | ||
| $content .= $tokensArray->values[$i]; | ||
| } |
5436ef3 to
324f21d
Compare
|
CI is as expected: the two red checks are The BC checker lists exactly the three signatures the description names, and nothing else: Everything else is green, including |
c0e6d7e to
2327a11
Compare
Since 2.3.5, tokenize() extracts the matches with PREG_PATTERN_ORDER but
still copies the values out of $matches[0] one at a time to build a tuple per
token. Holding the tokens as three parallel packed arrays instead removes
that last per-token allocation: $matches[0] simply becomes the values array
of the TokenList, and only the types and the lines are filled in a loop.
Measured against 2.3.5 over a corpus of 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 bytes/token
2.3.5 235ms 777ms 269
+ TokenList 175ms 671ms 124
-26% -14% -54%
Under opcache with the tracing JIT the same comparison is -28% and -18%.
Tokens and printed ASTs stay byte-identical over the whole corpus.
This is an API break: tokenize() no longer returns an array, TokenIterator
takes and returns a TokenList, and anything that indexes the token list
itself has to follow. TokenList implements Countable so that at least
count($tokens) keeps working.
Lexer::VALUE_OFFSET, TYPE_OFFSET and LINE_OFFSET are dropped rather than
left behind: they index into a tuple that no longer exists, so keeping them
would only let code compile that cannot work. Call sites that pass the tokens straight into a
TokenIterator -- which is every one of them in phpstan-src -- need no change.
The one consumer in this repository that does index tokens,
tools/phplrt/Fuzzer/TokenStream, is migrated here.
2327a11 to
2869a63
Compare
Follow-up to #313, now merged and released as 2.3.5. Rebased onto
2.3.x, sothis is one commit against current master. This one is a BC break and needs a
major, which #313 deliberately was not.
The BC break
Those six, and nothing else — the BC checker on this PR reports exactly them.
TokenListholds three parallel packed arrays plus a count:TokenListimplementsCountable, socount($tokens)written against thearray keeps working. The
$countproperty is the fast path for hot loops; themethod exists for the migration.
The three offset constants are dropped rather than left behind. They index into
a tuple that no longer exists, so keeping them would only let code compile that
cannot work — a fatal at the point of use is worse than a missing constant the
migration can grep for.
Who has to change:
$tokens[$i][Lexer::TYPE_OFFSET]becomes
$tokens->types[$i]. This is the whole migration.TokenIterator— nothing to do.new TokenIterator($lexer->tokenize($s))keeps compiling, because both ends ofthe break move together.
This repository has consumers of both kinds, which makes a good worked
example. The five
new TokenIterator($lexer->tokenize(...))call sites needednothing.
tools/phplrt/Fuzzer/TokenStreamindexes tokens and is migrated here:mostly
$tokens[$i][Lexer::TYPE_OFFSET]→$tokens->types[$i]. Splitting thetuple also let three of its helpers narrow from the whole token list to just the
types array they actually read, which is the sort of tidy-up the new shape
invites. That file is not covered by
make check, so the grammar CI jobs caughtthe migration rather than my local run — worth knowing if you go looking for
other consumers.
For calibration I also grepped phpstan-src: all five phpdoc-parser call sites
there (
PhpDocStringResolver,TypeStringResolver,InvalidPHPStanDocTagRule,InvalidPhpDocTagValueRule,PhpDocEditor) are the second kind and need noedit, and nothing in it indexes a phpdoc-parser token. I could only survey the
consumers on my disk, though — you will have a far better sense of what else
reaches into the token array.
Why
Since 2.3.5
tokenize()extracts the matches withPREG_PATTERN_ORDER, but itstill copies the values out of
$matches[0]one at a time to build a tuple pertoken. With three parallel arrays that last per-token allocation disappears:
$matches[0]becomes the values array of theTokenList, and only the typesand the lines are filled in a loop.
Numbers
Same corpus as #313 — 37 150 unique docblocks, 8.38 MiB, 2 355 859 tokens — one
core,
php -n, minimum of 15 rounds with the variants interleaved.With opcache and the tracing JIT: −28% lexing and −18% parse, so the gain holds
up there too.
For scale against the release before #313 landed: in a single interleaved run of
all three, 2.3.4 lexed the same corpus in 416 ms and parsed it in 1002 ms
against 2.3.5's 249 ms / 822 ms and this PR's 184 ms / 692 ms — so #313 and this
PR together are −56% lexing and −31% parse.
The memory figure is the tokens of the whole corpus held at once: 605 MiB → 277
MiB. Three packed arrays cost 16 bytes per token per array; a packed array of
three zvals costs an array header plus a rounded-up slot allocation.
Behaviour
Tokens and the ASTs printed from them are byte-identical to 2.3.5 over all
37 150 docblocks (xxh128 over both). On PHP 8.5, with the
make grammars-installtoolchain in place so nothing is skipped: 7747 tests pass, including the grammar
sync and fuzzer suites, plus phpcs, PHPStan and the no-capturing-groups guard
test added in #313, which applies unchanged.
Alternative I measured and discarded
Tokenobjects instead of tuples, the other obvious way to shrink a token:+21% lexing, +8% parse, 164 bytes/token.
new Token(...)is a userland callwhere the array literal is one opcode, and the ~2% the parser gains from reading
properties instead of indexing arrays does not pay for it. Same migration cost
as this PR, less than half the payoff — so if a token-representation break is
worth taking at all, this is the one to take.