Skip to content

Commit fd7eb53

Browse files
ajitpratap0claude
andauthored
perf(tokenizer): build tokens in place instead of copying them by value [W2-10] (#926)
nextToken and the token readers it dispatches to now write the token into its slot of the output slice through an out pointer, instead of returning a 40 B models.Token that was spilled, copied into a 72 B TokenWithSpan and appended. On amd64 each of those copies reloaded a just-spilled struct with 16-byte loads that cannot be store-forwarded (C0: ~39% of amd64 lexer time on those lines). The slow reference readers and the prefixed/Unicode-escape readers keep their by-value signatures and are bridged with a field-by-field storeToken. A failed slot is zeroed so tokBuf[len:cap] stays zero. Token output, spans and errors are unchanged (goldens pass). Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 533148d commit fd7eb53

7 files changed

Lines changed: 420 additions & 161 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
5656
- **Tokenizer**: keyword and compound-keyword-start lookup now uses a single fused-hash open-addressed table (one FNV-1a hash computed while upper-casing, one probe) instead of two Go map lookups per identifier. The table is filled in a fixed order (hot keywords first, then sorted) so its probe layout is deterministic across processes. No behaviour change; token output is identical [W2-01].
5757
- **Tokenizer**: token Start/End locations are now computed from a cached current-line window (a range check and a subtraction) instead of two calls to the non-inlinable position lookup per token; out-of-window offsets take the existing lookup. Token positions are identical, including after a UTF-8 BOM and across pooled-tokenizer reuse (guarded by an oracle test). [W2-02]
5858
- **Tokenizer**: `@param` and SQL Server `@@var` token values are now slices of the input instead of `"@"+name` concatenations, removing one string allocation per parameter (SQL Server real-world allocs/op -90% to -100%, MariaDB/MySQL/Snowflake -50% to -67% on the large cells). Names that `readIdentifier` treats specially (`U&` escapes, a single letter before a quote, compound-keyword starts such as `@group by`, non-ASCII names) keep the previous path, so token types, values and spans are identical (pinned by a golden over 9 dialects). [W2-03]
59+
- **Tokenizer**: tokens are now built in place. The scanner and its token readers write each token's fields directly into its slot of the output slice instead of returning a 40-byte `models.Token` by value that was then copied into a 72-byte `models.TokenWithSpan` and appended. On amd64 those copies reloaded just-spilled structs with 16-byte loads that cannot be store-forwarded. Token output, spans and errors are identical (token goldens unchanged); no API or behaviour change. [W2-10]
5960
- **Pools**: the tokenizer, parser and AST pools now count Get/Put through precreated, cache-line padded atomic counters (`internal/poolstats`) instead of an RWMutex-guarded map keyed by pool name, removing a lock, map lookup and string hash from every pool operation. `metrics.GetPoolStats` and `metrics.ResetPoolStats` behave exactly as before; named custom pools keep the map path. [W1-01]
6061
- **Parser**: token preprocessing now takes a type-gated fast path. Only identifier, keyword, aggregate and compound tokens take the normalisation path; every other token is appended with a single copy. Output is identical (guarded by an old-versus-new differential test); no API or behaviour change.
6162
- **Parser**: the parser's current token is now a pointer into its token slice (with a per-parser zero EOF sentinel) instead of a 72-byte copy made on every `advance`, and `peekToken` returns a pointer, which removes most GC write-barrier work and struct copying from the parse loop. Parse results, errors and positions are byte-identical (behaviour goldens plus a recovery-path differential); no API change. A zero-value `&Parser{}` keeps working. [W3-02]

‎pkg/sql/tokenizer/position_tracking_test.go‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,8 @@ func tokenByteBounds(t *testing.T, tkz *Tokenizer, input []byte) [][2]int {
104104
break
105105
}
106106
start := skipTriviaBytes(input, tkz.pos.Index)
107-
if _, err := tkz.nextToken(); err != nil {
107+
var tok models.Token
108+
if err := tkz.nextToken(&tok); err != nil {
108109
t.Fatalf("replay nextToken: %v", err)
109110
}
110111
bounds = append(bounds, [2]int{start, tkz.pos.Index})

‎pkg/sql/tokenizer/prefixed_string.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,8 @@ func (t *Tokenizer) readPrefixedString(letter byte) (tok models.Token, ok bool,
7171
}
7272
switch letter {
7373
case 'N', 'X', 'B':
74-
inner, err := t.readQuotedString('\'')
75-
if err != nil {
74+
var inner models.Token
75+
if err := t.readQuotedString(&inner, '\''); err != nil {
7676
return models.Token{}, true, err
7777
}
7878
typ := models.TokenTypeNationalStringLiteral

‎pkg/sql/tokenizer/quoted_zero_copy_test.go‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -494,11 +494,12 @@ func assertQuotedFastMatchesSlow(t testing.TB, d keywords.SQLDialect, s string)
494494
break
495495
}
496496
kind := quotedReaderKind(slow)
497-
ft, ferr := fast.nextToken()
497+
var ft models.Token
498+
ferr := fast.nextToken(&ft)
498499
var st models.Token
499500
var serr error
500501
if kind == "" {
501-
st, serr = slow.nextToken()
502+
serr = slow.nextToken(&st)
502503
} else {
503504
st, serr = readQuotedSlowRef(slow, kind)
504505
}

‎pkg/sql/tokenizer/token_out.go‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// Copyright 2026 GoSQLX Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package tokenizer
16+
17+
import "github.com/ajitpratap0/GoSQLX/pkg/models"
18+
19+
// Token readers on the scan path (nextToken and the read* functions it
20+
// dispatches to) write the token they read into out, which points at the
21+
// token's slot in the output slice, instead of returning it by value.
22+
//
23+
// Returning the 40-byte models.Token by value, then copying it into a 72-byte
24+
// models.TokenWithSpan and appending that, cost several struct copies per
25+
// token. On amd64 each copy reloads, with 16-byte loads, a struct that was
26+
// just spilled with 8-byte stores; those loads cannot be store-forwarded and
27+
// stall. Writing the fields through a pointer into the final slot removes
28+
// every intermediate copy.
29+
//
30+
// Readers write *out only on success. On error *out is left as it was or
31+
// partly written; the caller discards the slot.
32+
33+
// setToken stores v in *out and returns nil. It inlines, and the compiler
34+
// then builds a composite literal argument directly in *out.
35+
func setToken(out *models.Token, v models.Token) error {
36+
*out = v
37+
return nil
38+
}
39+
40+
// storeToken bridges a by-value reader (the slow reference readers and the
41+
// rarely used prefixed and Unicode-escape readers) into an out parameter.
42+
// It copies field by field: a whole-struct copy would reload the spilled
43+
// result with the 16-byte loads that stall on amd64.
44+
func storeToken(out *models.Token, v models.Token, err error) error {
45+
if err != nil {
46+
return err
47+
}
48+
out.Type = v.Type
49+
out.Value = v.Value
50+
out.Word = v.Word
51+
out.Long = v.Long
52+
out.Quote = v.Quote
53+
return nil
54+
}
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
// Copyright 2026 GoSQLX Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package tokenizer
16+
17+
import (
18+
"errors"
19+
"reflect"
20+
"strings"
21+
"testing"
22+
23+
"github.com/ajitpratap0/GoSQLX/internal/tokscratch"
24+
"github.com/ajitpratap0/GoSQLX/pkg/models"
25+
"github.com/ajitpratap0/GoSQLX/pkg/sql/keywords"
26+
)
27+
28+
// TestStoreToken_CopiesEveryField guards storeToken's field-by-field copy: a
29+
// field added to models.Token must be copied too, or tokens from the bridged
30+
// readers would silently lose it.
31+
func TestStoreToken_CopiesEveryField(t *testing.T) {
32+
var v models.Token
33+
rv := reflect.ValueOf(&v).Elem()
34+
for i := 0; i < rv.NumField(); i++ {
35+
f := rv.Field(i)
36+
switch f.Kind() {
37+
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
38+
f.SetInt(int64(i + 1))
39+
case reflect.String:
40+
f.SetString("v")
41+
case reflect.Bool:
42+
f.SetBool(true)
43+
case reflect.Pointer:
44+
f.Set(reflect.New(f.Type().Elem()))
45+
default:
46+
t.Fatalf("models.Token.%s has kind %s: extend this test and storeToken", rv.Type().Field(i).Name, f.Kind())
47+
}
48+
}
49+
50+
var out models.Token
51+
if err := storeToken(&out, v, nil); err != nil {
52+
t.Fatalf("storeToken: %v", err)
53+
}
54+
if !reflect.DeepEqual(out, v) {
55+
t.Fatalf("storeToken copied %+v, want %+v", out, v)
56+
}
57+
58+
want := errors.New("boom")
59+
out = models.Token{}
60+
if err := storeToken(&out, v, want); err != want {
61+
t.Fatalf("storeToken error = %v, want %v", err, want)
62+
}
63+
if out != (models.Token{}) {
64+
t.Fatalf("storeToken wrote %+v on error, want no write", out)
65+
}
66+
}
67+
68+
// TestTokenize_GrowsPastEstimate covers the slot growth path: "{}" pairs are
69+
// one prescan word but two tokens each, so the capacity estimate falls short
70+
// and the token slice must grow while tokens are built in place.
71+
func TestTokenize_GrowsPastEstimate(t *testing.T) {
72+
const pairs = 200
73+
sql := strings.Repeat("{}", pairs)
74+
75+
probe, err := New()
76+
if err != nil {
77+
t.Fatalf("New: %v", err)
78+
}
79+
probe.load([]byte(sql), sql)
80+
if est := probe.prescan(); est >= 2*pairs+1 {
81+
t.Fatalf("precondition: prescan estimate %d covers all %d tokens", est, 2*pairs+1)
82+
}
83+
84+
check := func(t *testing.T, got []models.TokenWithSpan) {
85+
t.Helper()
86+
if len(got) != 2*pairs+1 {
87+
t.Fatalf("got %d tokens, want %d", len(got), 2*pairs+1)
88+
}
89+
for i, tw := range got[:2*pairs] {
90+
want := models.TokenWithSpan{
91+
Token: models.Token{Type: models.TokenTypeLBrace, Value: "{"},
92+
Start: models.Location{Line: 1, Column: i + 1},
93+
End: models.Location{Line: 1, Column: i + 2},
94+
}
95+
if i%2 == 1 {
96+
want.Token = models.Token{Type: models.TokenTypeRBrace, Value: "}"}
97+
}
98+
if tw != want {
99+
t.Fatalf("token %d = %+v, want %+v", i, tw, want)
100+
}
101+
}
102+
if eof := got[2*pairs].Token; eof.Type != models.TokenTypeEOF {
103+
t.Fatalf("last token = %+v, want EOF", eof)
104+
}
105+
}
106+
107+
t.Run("public", func(t *testing.T) {
108+
tkz := GetTokenizer()
109+
defer PutTokenizer(tkz)
110+
got, err := tkz.TokenizeString(sql)
111+
if err != nil {
112+
t.Fatalf("TokenizeString: %v", err)
113+
}
114+
check(t, got)
115+
})
116+
t.Run("scratch", func(t *testing.T) {
117+
tkz := GetTokenizer()
118+
defer PutTokenizer(tkz)
119+
for round := 0; round < 2; round++ { // the second round reuses tokBuf
120+
got, err := tokscratch.String(tkz, sql)
121+
if err != nil {
122+
t.Fatalf("round %d: %v", round, err)
123+
}
124+
check(t, got)
125+
}
126+
})
127+
}
128+
129+
// TestTokenize_ErrorLeavesSpareSlotsZero checks the tokBuf invariant
130+
// (tokBuf[len:cap] is zero) after a reader fails in the slot it was given,
131+
// for each reader family, and that the next call on the same tokenizer still
132+
// matches the public API exactly. The tokenize loop zeroes a failed slot, so
133+
// the invariant holds even for a reader that writes part of a token before
134+
// it fails.
135+
func TestTokenize_ErrorLeavesSpareSlotsZero(t *testing.T) {
136+
tests := []struct {
137+
name string
138+
dialect keywords.SQLDialect
139+
sql string
140+
}{
141+
{"single quoted string", "", "SELECT a, 'unterminated"},
142+
{"double quoted identifier", "", `SELECT a, "unterminated`},
143+
{"backtick identifier", keywords.DialectMySQL, "SELECT a, `unterminated"},
144+
{"bracket identifier", keywords.DialectSQLServer, "SELECT a, [unterminated"},
145+
{"unicode escape unsupported", keywords.DialectOracle, "SELECT a, U&'x'"},
146+
{"unicode escape bad", keywords.DialectPostgreSQL, `SELECT a, U&'\zz'`},
147+
{"escape string", keywords.DialectPostgreSQL, `SELECT a, E'unterminated`},
148+
{"national string", keywords.DialectMySQL, "SELECT a, N'unterminated"},
149+
{"triple quoted string", keywords.DialectBigQuery, "SELECT a, '''unterminated"},
150+
{"invalid number", "", "SELECT a, 1abc"},
151+
{"unexpected character", "", "SELECT a, §"},
152+
}
153+
const after = "SELECT b, 'ok', \"q\", 1.5 FROM t WHERE c >= 2"
154+
for _, tc := range tests {
155+
t.Run(tc.name, func(t *testing.T) {
156+
tkz := GetTokenizer()
157+
defer PutTokenizer(tkz)
158+
tkz.SetDialect(tc.dialect)
159+
pub, err := NewWithDialect(tc.dialect)
160+
if err != nil {
161+
t.Fatalf("NewWithDialect: %v", err)
162+
}
163+
164+
_, wantErr := pub.TokenizeString(tc.sql)
165+
if wantErr == nil {
166+
t.Fatalf("precondition: %q tokenizes without error", tc.sql)
167+
}
168+
_, gotErr := tokscratch.String(tkz, tc.sql)
169+
if gotErr == nil || gotErr.Error() != wantErr.Error() {
170+
t.Fatalf("scratch error = %v, want %v", gotErr, wantErr)
171+
}
172+
spare := tkz.tokBuf[len(tkz.tokBuf):cap(tkz.tokBuf)]
173+
for i := range spare {
174+
if spare[i] != (models.TokenWithSpan{}) {
175+
t.Fatalf("tokBuf[%d] = %+v after the error, want zero", len(tkz.tokBuf)+i, spare[i])
176+
}
177+
}
178+
179+
want, err := pub.TokenizeString(after)
180+
if err != nil {
181+
t.Fatalf("public TokenizeString: %v", err)
182+
}
183+
got, err := tokscratch.String(tkz, after)
184+
if err != nil {
185+
t.Fatalf("scratch after the error: %v", err)
186+
}
187+
if !reflect.DeepEqual(got, want) {
188+
t.Fatalf("scratch after the error:\n got %+v\nwant %+v", got, want)
189+
}
190+
})
191+
}
192+
}

0 commit comments

Comments
 (0)