Skip to content

Optimize Lexer::readString and Visitor::visitInParallel#1948

Open
OpaqueRock wants to merge 1 commit into
webonyx:masterfrom
OpaqueRock:optimize-input-processing
Open

Optimize Lexer::readString and Visitor::visitInParallel#1948
OpaqueRock wants to merge 1 commit into
webonyx:masterfrom
OpaqueRock:optimize-input-processing

Conversation

@OpaqueRock

Copy link
Copy Markdown

Summary

This PR contains two related performance optimizations for Lexer::readString() and Visitor::visitInParallel():

  1. Lexer::readString(): bulk-scan runs of "boring" bytes with strcspn() instead of decoding/validating one UTF-8 character at a time via readChar(). 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, unescaped SourceCharacter that can simply be scanned through like any other ordinary character.
  2. Visitor::visitInParallel():
    • Precompute the enter/leave callback per (visitor, kind, direction) once per call, instead of re-resolving it via extractVisitFn() on every single node for every wrapped visitor.
    • Replace func_get_args() + argument unpacking in the enter/leave dispatcher closures with the five explicit, typed parameters they are always called with (verified against Visitor::visit()'s single call site), avoiding func_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() and visitInParallel() (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

  • Full existing test suite passes: composer test (1990 tests, 21728 assertions - no failures; the 1 pre-existing warning is from an unrelated, intentional ExecutorLazySchemaTest warning-assertion test).
  • composer stan (PHPStan) passes with no errors.
  • composer php-cs-fixer passes with no remaining issues.
  • composer rector (dry-run) reports no changes needed in any file touched by this PR.
  • New regression test (testLexesStringsWithUnescapedTabCharacter) covering the TAB case mentioned above.
  • Manually verified via an isolated correctness test corpus (literal TAB in various positions, all standard escape sequences, multi-byte UTF-8 content, and boundary/error cases such as unterminated strings, invalid control characters, and invalid escape sequences) that output is byte-identical to the unpatched implementation across all cases, and that 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):

Metric master (avg) This PR (avg) Time reduced Speedup
Parser::parse (Lexer) 10,907 us/op 4,026 us/op 63.1% 2.71x
Visitor::visit (parallel) 6,670 us/op 4,023 us/op 39.7% 1.66x
Combined total (1000 ops) 8.79s 4.02s 54.2% 2.18x

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

  • No increase in memory usage: the optimized code uses the same substr()/string-concatenation approach as before, just with fewer, larger operations instead of many single-character ones.
  • No new unbounded loops, recursion, or additional failure modes were introduced.
  • Performance is not worse than baseline for any input shape tested, including inputs designed to defeat the bulk-scan fast path (e.g. strings consisting entirely of escape sequences or control characters).

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.
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.

1 participant