Profile the parser, and make the token stream cheap to build - #4
Merged
Conversation
Nobody had ever read the benchmarks. Profiling says the cost is in two
places, and neither is where the code was written as though it were.
A token slice is the largest single allocation a parse makes, and Token
carried its own spelling: a string field, exactly src[Pos:End], so
redundant with the offsets already there. The redundancy was not free.
The pointer in it costs a write barrier per token to build and makes the
whole slice something the garbage collector has to walk afterwards.
Deriving the text with a method instead takes Token from 40 bytes to 24
and out of the collector's sight entirely, which a microbenchmark of the
two shapes puts at 2.7x on the cost of producing one. Every caller has
the source in hand -- it just lexed it -- so nothing has to be threaded
anywhere new.
Keyword lookup was a Go map, and hashing the spelling of every
identifier was a fifth of the lexer. It is now indexed on the length and
the first letter, which are free to compute and between them very nearly
a perfect hash: 147 keywords in 93 groups, the largest of four. The
candidates are compared byte by byte, upper-casing as they go, so
nothing is allocated or copied. SQLite's keywordCode() hashes the first
byte, the last byte and the length, but mixes them with a remainder mod
a prime, and a parser reading a keyword every few bytes should not be
dividing.
Three smaller ones. The renderer sized its strings.Builder from nothing
and grew it four times for a short statement; a node's span says how
long the output will be, within a few bytes. The whitespace-and-comments
divisor for the token slice was set to four by guess, and the corpus
says three: 93% of cases in one allocation rather than 80%. And a "--"
comment or a lone vertical tab used to send every parse through a second
pass over all its tokens to resolve WINDOW, OVER and FILTER; noting
whether any of the three turned up costs a comparison per token instead.
Lex/Join 4336ns -> 2413ns -44%
Lex/Simple 547ns -> 403ns -26%
Render/Simple 269ns -> 165ns -39% 4 allocs -> 1
Render/Join 1223ns -> 789ns -36% 7 allocs -> 1
Parse/Join 10609ns -> 8373ns -21%
Parse/Window 9839ns -> 8680ns -12%
Corpus (21k) 362us -> 313us -14% 377KB -> 279KB
One thing that looked obvious and was not: slab-allocating the AST nodes
took Join from 109 allocations to 60 and did not move the clock at all,
because it traded object count for pointer-bearing bytes and bytes are
what cost. It is not in this commit.
Corpus, round trip, spans and snapshots unchanged, and 3,085,106
difftest mutations agree with SQLite as before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JzBeCg7rjweVW3uGPg5G7T
The usage section was a single call with no imports and no output, which shows the entry point but not what you get back. The example is now a whole program: parse, walk the tree for tables and bind parameters, and handle a rejection. Its output is what it actually prints. Also a pkg.go.dev badge and links to the four packages, and the corpus count in Status, which still said 20,971 from before extraction widened to every script in the pinned tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JzBeCg7rjweVW3uGPg5G7T
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.
The benchmarks landed with the parser but nobody had read them. This is the first profiling pass.
Lex/JoinLex/WindowLex/SimpleRender/SimpleRender/JoinRender/WindowParse/JoinParse/WindowParse/SimpleCorpus(21,326 cases)The two that mattered
token.Tokencarried its own spelling.Textwas exactlysrc[Pos:End]— redundant with the offsets already in the struct, and the redundancy was not free. A string field puts a pointer inToken, which costs a write barrier per token to build the slice and leaves the whole slice as something the garbage collector has to walk afterwards. A token slice is the largest single allocation a parse makes.Textis now a method taking the source. That takesTokenfrom 40 bytes to 24 and out of the collector's sight entirely. A microbenchmark of the two shapes, building a 90-token slice:Every caller has the source in hand — it just lexed it — so nothing had to be threaded anywhere new.
Keyword lookup was a Go map. Hashing the spelling of every identifier was a fifth of the lexer:
mapaccess2_faststrplusaeshashbody. It is now indexed on length and first letter, which are free to compute and between them very nearly a perfect hash — 147 keywords into 93 groups, the largest of four, 1.58 candidates on average. Each candidate is compared byte by byte, upper-casing as it goes, so nothing is allocated or copied and there is no buffer.SQLite's own
keywordCode()hashes the first byte, the last byte and the length, but mixes them with a remainder mod a prime. Under power-of-two masking that hash degrades badly (73 probes worst case at 256 slots), and a parser reading a keyword every few bytes should not be doing a division.Three smaller ones
strings.Builderfrom nothing and grew it four times for a short statement. A node's span says how long the output will be within a few bytes, since rendering rewrites the whitespace and nothing else.len(src)/4by guess. Measured over the corpus, SQL runs to 3.7 bytes per token, and a divisor of three fits 93% of cases in one allocation where four fits 80%. Checked against theCorpusbenchmark rather than the four handpicked queries, which flip depending on which side of the threshold they happen to land.WINDOW,OVERandFILTERneeds a second pass over every token, and almost no SQL contains any of the three. Noting whether one turned up costs a comparison per token instead.What did not work
Slab-allocating the AST nodes.
parseQualifiedRefandidentOfare 59% of the objects a SELECT allocates, so batching them looked obvious — it tookParse/Joinfrom 109 allocations to 60 and did not move the clock at all, while raising memory 20%. Go's small-object allocator is already cheap; what costs is pointer-bearing bytes, and a slab adds them. That result is what pointed at theTokenchange, so it earned its keep, but it is not in this branch.Interning the AST's strings with
uniqueis worse still, for the same underlying reason: those strings are views into the input and never allocate, so there is nothing to deduplicate, andunique.Makecosts ~54 ns a call — more than building the node it would go in.Behaviour
Unchanged, and checked rather than assumed:
cmd/difftest: 3,085,034 mutations over the corpus and SQLite'sfuzzdataseeds, 0 disagreementsFuzzParse: 7,022,062 executions, no failuresTestLookupis new. It walks every keyword in three casings and the shapes the index has to reject before it can subscript anything — too short, too long, first byte not a letter. The corpus reachesLookupconstantly but only through whatever spellings SQLite's test suite happens to use, so a group pointing at the wrong run of the table could have gone unnoticed for any keyword that suite never writes.One API break
token.Token.Textis a method now, not a field. That is what bought the 2.7x. Cheap while nothing outside this repo consumes the token stream; worth doing before something does.Generated by Claude Code