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 benchmarks landed with the parser but nobody had read them. This is the first profiling pass.
The two that mattered
token.Token carried its own spelling. Text was exactly src[Pos:End] — redundant with the offsets already in the struct, and the redundancy was not free. A string field puts a pointer in Token, 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.
Text is now a method taking the source. That takes Token from 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_faststr plus aeshashbody. 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
What did not work
Slab-allocating the AST nodes. parseQualifiedRef and identOf are 59% of the objects a SELECT allocates, so batching them looked obvious — it took Parse/Join from 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 the Token change, so it earned its keep, but it is not in this branch.
Interning the AST's strings with unique is worse still, for the same underlying reason: those strings are views into the input and never allocate, so there is nothing to deduplicate, and unique.Make costs ~54 ns a call — more than building the node it would go in.
Behaviour
Unchanged, and checked rather than assumed:
TestLookup is 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 reaches Lookup constantly 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.Text is 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