Optimize Lexer::readString and Visitor::visitInParallel#1948
Open
OpaqueRock wants to merge 1 commit into
Open
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.
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.
Summary
This PR contains two related performance optimizations for
Lexer::readString()andVisitor::visitInParallel():Lexer::readString(): bulk-scan runs of "boring" bytes withstrcspn()instead of decoding/validating one UTF-8 character at a time viareadChar(). Falls back to the existing per-character logic only for quotes, line terminators, control characters, and escape sequences. TAB (0x09) is excluded from the stop-byte set, since it's a legal, unescapedSourceCharacterthat can simply be scanned through like any other ordinary character.Visitor::visitInParallel():(visitor, kind, direction)once per call, instead of re-resolving it viaextractVisitFn()on every single node for every wrapped visitor.func_get_args()+ argument unpacking in the enter/leave dispatcher closures with the five explicit, typed parameters they are always called with (verified againstVisitor::visit()'s single call site), avoidingfunc_get_args()'s per-call overhead.Why
Parsing and validating GraphQL documents is on the hot path for every request in a GraphQL server.
readString()andvisitInParallel()(used heavily during validation, where many rules are visited together in one pass) are both called very frequently, so their per-call overhead compounds across large schemas/queries.Testing
composer test(1990 tests, 21728 assertions - no failures; the 1 pre-existing warning is from an unrelated, intentionalExecutorLazySchemaTestwarning-assertion test).composer stan(PHPStan) passes with no errors.composer php-cs-fixerpasses with no remaining issues.composer rector(dry-run) reports no changes needed in any file touched by this PR.testLexesStringsWithUnescapedTabCharacter) covering the TAB case mentioned above.Visitor::visitInParallel()'s traversal order/results are unchanged.Benchmarks
Measured with an isolated benchmark harness (not included in this PR) that repeatedly parses and visits a synthetic ~19KB query containing a mix of plain, escaped, and Unicode string literals, 500 iterations each, with Xdebug disabled (PHP 8.3.6, x86_64). Compared directly against the current
upstream/master(3 runs each, averaged):Parser::parse(Lexer)Visitor::visit(parallel)Real-world gains will vary by query/schema shape and string content; this benchmark specifically isolates the two changed functions from surrounding framework overhead. The
Lexer::readString()improvement scales with string literal length, so queries containing long inline string literals (e.g. large text blocks) should see a proportionally larger benefit than this benchmark's average.Risk assessment
substr()/string-concatenation approach as before, just with fewer, larger operations instead of many single-character ones.