Optimize Lexer::readString - #1948
Conversation
Lexer::readString previously decoded and validated one UTF-8 character at a time via Lexer::readChar(). Replace this with a bulk scan using strcspn() to copy runs of bytes that need no special handling (i.e. anything other than a quote, backslash, or control character) in a single native call, falling back to the original per-character logic only for quotes, line terminators, control characters, and escape sequences. Care is taken to keep $this->position (a decoded-character count, not a byte count) accurate for multi-byte UTF-8 content. TAB (0x09), a legal, unescaped SourceCharacter per the GraphQL spec, is excluded from the stop-byte set so it is scanned through like any other ordinary character instead of being individually inspected. Visitor::visitInParallel previously called extractVisitFn() to resolve the enter/leave callback for the current visitor and node kind on every single node, for every wrapped visitor - an O(nodes x visitors) number of array lookups, even though a callback's identity for a given (visitor, kind, direction) triple never changes during a traversal. Since NodeKind exposes a small, fixed set of kinds, precompute this mapping once per call (O(visitors x kinds)) instead. Additionally, replace func_get_args() plus argument unpacking in the enter/leave dispatcher closures with the five explicit, typed parameters they are always called with (matching Visitor::visit()'s single call site), avoiding func_get_args()'s per-call overhead. Add a regression test for the TAB-handling edge case described above.
There was a problem hiding this comment.
Pull request overview
This PR introduces targeted performance optimizations in two hot-path components of the library: lexing string literals and running multiple validation visitors in a single traversal.
Changes:
- Optimize
Lexer::readString()by bulk-scanning “ordinary” bytes viastrcspn()and only falling back to per-character handling for escape/control/terminator cases (explicitly allowing unescaped TAB per spec). - Optimize
Visitor::visitInParallel()by precomputing per-(visitor, kind, direction) callbacks once per traversal and avoidingfunc_get_args()/argument unpacking in the dispatcher. - Add a regression test ensuring strings containing an unescaped TAB are lexed correctly.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| tests/Language/LexerTest.php | Adds regression coverage for unescaped TAB in string literals. |
| src/Language/Visitor.php | Reduces per-node overhead in parallel visitor dispatch by caching callbacks and avoiding func_get_args(). |
| src/Language/Lexer.php | Speeds up string literal lexing by scanning runs of non-special bytes in bulk while preserving escape/control handling. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
The enter/leave callback caches were previously prefilled only for NodeKind constants, silently skipping generic and kind-specific visitors for Nodes with custom (non-NodeKind) kinds. Switch to lazy, per-direction caches that resolve and store a callback (or `false` as a "no callback" sentinel) on first use, keyed by any kind string. Also fixes a pre-existing undefined-array-key warning in visit() when traversing a Node with a custom kind. Add regression tests covering generic and kind-specific visitors for a custom Node kind.
spawnia
left a comment
There was a problem hiding this comment.
Are those two independent changes? Please split if so.
- Use a HEREDOC for the tab-character test source, deriving the expected value from it via substr() so input and expectation can't drift apart. - Type-hint $path/$ancestors as array in visitInParallel()'s enter/leave closures, and document $key/$parent via @phpstan-param since PHP 7.4 doesn't support union types. - Add @var docblocks describing the shape of the enter/leave callback caches. - Replace the lazily-built $stopBytes runtime computation in Lexer::readString() with a precomputed STRING_STOP_BYTES constant.
The visit() undefined-array-key warning fix for custom Node kinds, along with its regression tests, has been split out into a separate PR (webonyx#1952) since it is unrelated to the Lexer/Visitor optimization work here.
|
Thanks for the review! I've pushed two updates:
This PR is now scoped solely to the |
|
I would like the Lexer and Visitor optimizations as separate PRs. |
The Visitor::visitInParallel() enter/leave callback caching optimization has been split out into a separate PR (webonyx#1953), so it can be reviewed independently of the Lexer::readString optimization here.
|
The |
PHP's switch uses loose comparison, so `case null` also matches code 0, causing a backslash followed by a raw NUL byte to be treated as EOF instead of an invalid escape sequence. Check for EOF with a strict comparison before the switch instead.
|
I looked into the null value comparison and it's a real, reachable bug, though it pre-dates this PR (the The problem: Impact: For input containing Per the GraphQL spec, a NUL byte is not a valid raw string character and has no dedicated escape ( Fix: Added an explicit Added a regression test ( |
spawnia
left a comment
There was a problem hiding this comment.
Comment.
The PR is a sound performance optimization with no correctness bugs. The three surviving comments are minor improvements: a simplification opportunity (mb_strlen over preg_match_all), an open reviewer request for named constants, and a test hygiene suggestion. None rise to the level of requesting changes.
|
Reviewed at 031cb52 (standard, full) 🤖 Posted by Claude Code |
|
Replaced |
Cover the single-char escape sequences (\/, \b, \f, \n, \r, \t), the & punctuator token, and multi-byte UTF-8 in readChar(). 🤖 Generated with Claude Code
|
Thanks @OpaqueRock! Released as v15.36.0. |
Summary
Bulk-scan runs of ordinary bytes with
strcspn()instead of decoding/validating one UTF-8 character at a time viareadChar(). Falls back to per-character logic only for quotes, line terminators, control characters, and escape sequences. TAB (0x09) is excluded from the stop-byte set since it is a legal, unescaped SourceCharacter.Why
readString()is on the hot path for every request in a GraphQL server. The per-call overhead of character-by-character decoding compounds across large queries with many string literals.Benchmarks
Measured with an isolated benchmark harness (PHP 8.3.6, x86_64, Xdebug disabled, 500 iterations averaged over 3 runs):
Parser::parse(Lexer)Testing
readChar(), and the&punctuator — achieving 100% line coverage on Lexer.php