From 76b6b4519292c08c0774a7839df46c776c29dffa Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 14:18:25 +0000 Subject: [PATCH] Optimize parser hot paths: token window, lexer scanning, and allocations A profiling pass over the parse benchmarks, changing no observable behavior: - rd_parser: cache a pointer to the current token's window slot (invalidated only by advance/rewind, the sole movers of i), making tok()/cur() call-free field loads; split at() into a fast path plus a fill() slow path; write lexed tokens into the window slot in place instead of building a large rdToken and copying it in. - lexer: scan identifier/digit/whitespace runs with dedicated table-driven loops instead of a per-byte closure call through incAsLongAs. - parse_func: allocate a ColumnNameExpr and its ColumnName as one block; column references are the most-allocated node in typical queries. - ast: SetText skips allocating the lazy-conversion sync.Once when the conversion is provably the identity (no quote characters and an identity encoding), so Text() serves the original text directly. Benchmarks (go1.26, count=6 medians): SysbenchSelect 3.33us -> 2.80us (-16%, 20 -> 15 allocs), ParseComplex 147us -> 112us (-24%, 592 -> 468 allocs), ParseSimple 10.0us -> 8.4us (-16%, 56 -> 49 allocs). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014LQ3NoipqqU17kRVkBcDRY --- ast/base.go | 32 +++++++++++++++++++++ parser/lexer.go | 68 +++++++++++++++++++++++++++++++++++++------- parser/misc.go | 4 +++ parser/parse_func.go | 12 ++++++-- parser/rd_parser.go | 54 ++++++++++++++++++++++++++++------- 5 files changed, 147 insertions(+), 23 deletions(-) diff --git a/ast/base.go b/ast/base.go index 5636ddb..4877fec 100644 --- a/ast/base.go +++ b/ast/base.go @@ -15,6 +15,7 @@ package ast import ( "bytes" + "strings" "sync" "unicode" "unicode/utf8" @@ -52,9 +53,40 @@ func (n *node) OriginTextPosition() int { func (n *node) SetText(enc charset.Encoding, text string) { n.enc = enc n.text = text + if textConversionIsNoop(enc, text) { + // Text() would return text unchanged, so skip allocating the + // lazy-conversion Once and let Text() return n.text directly. + n.once = nil + return + } n.once = &sync.Once{} } +// textConversionIsNoop reports whether convertBinaryStringLiterals would +// provably return text unchanged, so that Text() can serve n.text without +// a per-node sync.Once. It must stay conservative: false only means the +// lazy path decides at Text() time. +// +// With no quote characters, convertBinaryStringLiterals is exactly +// enc.Transform(nil, text, OpDecodeReplace). That is the identity for the +// binary and latin1 encodings, and for the utf8 encoding when text is +// valid UTF-8. +func textConversionIsNoop(enc charset.Encoding, text string) bool { + if enc == nil { + return true + } + if strings.IndexByte(text, '\'') >= 0 || strings.IndexByte(text, '"') >= 0 { + return false + } + switch enc { + case charset.EncodingBinImpl, charset.EncodingLatin1Impl: + return true + case charset.EncodingUTF8Impl: + return utf8.ValidString(text) + } + return false +} + // SetNoBackslashEscapes marks that the SQL mode NO_BACKSLASH_ESCAPES was active // when this node was parsed, so backslash is not treated as an escape character // in string literals diff --git a/parser/lexer.go b/parser/lexer.go index 6959429..d13cc8e 100644 --- a/parser/lexer.go +++ b/parser/lexer.go @@ -393,15 +393,31 @@ func (*Scanner) handleIdent(lval *yySymType) int { return underscoreCS } +// isWhitespaceTable[b] == unicode.IsSpace(rune(b)) for every byte value. +var isWhitespaceTable = func() (t [256]bool) { + for i := range t { + t[i] = unicode.IsSpace(rune(i)) + } + return +}() + func (s *Scanner) skipWhitespace() byte { - return s.r.incAsLongAs(func(b byte) bool { - return unicode.IsSpace(rune(b)) - }) + r := &s.r + for { + ch := r.peek() + if !isWhitespaceTable[ch] { + return ch + } + if r.eof() { + return 0 + } + r.inc() + } } func (s *Scanner) scan() (tok int, pos Pos, lit string) { ch0 := s.r.peek() - if unicode.IsSpace(rune(ch0)) { + if isWhitespaceTable[ch0] { ch0 = s.skipWhitespace() } pos = s.r.pos() @@ -658,7 +674,7 @@ func startWithAt(s *Scanner) (tok int, pos Pos, lit string) { func scanIdentifier(s *Scanner) (int, Pos, string) { pos := s.r.pos() - s.r.incAsLongAs(isIdentChar) + s.r.incIdent() return identifier, pos, s.r.data(&pos) } @@ -841,7 +857,7 @@ func startWithNumber(s *Scanner) (tok int, pos Pos, lit string) { p2 := s.r.pos() // 0x, 0x7fz3 are identifier if p1 == p2 || isDigit(s.r.peek()) { - s.r.incAsLongAs(isIdentChar) + s.r.incIdent() return identifier, pos, s.r.data(&pos) } tok = hexLit @@ -852,14 +868,14 @@ func startWithNumber(s *Scanner) (tok int, pos Pos, lit string) { p2 := s.r.pos() // 0b, 0b123, 0b1ab are identifier if p1 == p2 || isDigit(s.r.peek()) { - s.r.incAsLongAs(isIdentChar) + s.r.incIdent() return identifier, pos, s.r.data(&pos) } tok = bitLit case ch1 == '.': return s.scanFloat(&pos) case ch1 == 'B': - s.r.incAsLongAs(isIdentChar) + s.r.incIdent() return identifier, pos, s.r.data(&pos) } } @@ -872,7 +888,7 @@ func startWithNumber(s *Scanner) (tok int, pos Pos, lit string) { // Identifiers may begin with a digit but unless quoted may not consist solely of digits. if !s.r.eof() && isIdentChar(ch0) { - s.r.incAsLongAs(isIdentChar) + s.r.incIdent() return identifier, pos, s.r.data(&pos) } lit = s.r.data(&pos) @@ -940,7 +956,7 @@ func (s *Scanner) scanFloat(beg *Pos) (tok int, pos Pos, lit string) { // 9e9e = 9e9(float) + e(identifier) // 9est = 9est(identifier) s.r.updatePos(*beg) - s.r.incAsLongAs(isIdentChar) + s.r.incIdent() tok = identifier } } else { @@ -952,7 +968,7 @@ func (s *Scanner) scanFloat(beg *Pos) (tok int, pos Pos, lit string) { func (s *Scanner) scanDigits() string { pos := s.r.pos() - s.r.incAsLongAs(isDigit) + s.r.incDigits() return s.r.data(&pos) } @@ -1100,6 +1116,36 @@ func (r *reader) incAsLongAs(fn func(b byte) bool) byte { } } +// incIdent is incAsLongAs(isIdentChar) without the per-byte closure call: +// identifier characters never include '\n', so only Offset and Col move. +// It returns the byte that stopped the scan (0 at EOF). +func (r *reader) incIdent() byte { + i := r.p.Offset + for i < r.l && isIdentCharTable[r.s[i]] { + i++ + } + r.p.Col += i - r.p.Offset + r.p.Offset = i + if i >= r.l { + return 0 + } + return r.s[i] +} + +// incDigits is incAsLongAs(isDigit) with the same fast shape as incIdent. +func (r *reader) incDigits() byte { + i := r.p.Offset + for i < r.l && r.s[i] >= '0' && r.s[i] <= '9' { + i++ + } + r.p.Col += i - r.p.Offset + r.p.Offset = i + if i >= r.l { + return 0 + } + return r.s[i] +} + // skipRune skip mb character, return true indicate something has been skipped. func (r *reader) skipRune(enc charset.Encoding) bool { if r.s[r.p.Offset] <= unicode.MaxASCII { diff --git a/parser/misc.go b/parser/misc.go index 7504473..a9b5e55 100644 --- a/parser/misc.go +++ b/parser/misc.go @@ -43,10 +43,14 @@ func isInCorrectIdentifierName(name string) bool { // Initialize a lookup table for isUserVarChar var isUserVarCharTable [256]bool +// Lookup table for isIdentChar, used by the lexer's hottest scanning loop. +var isIdentCharTable [256]bool + func init() { for i := range 256 { ch := byte(i) isUserVarCharTable[i] = isLetter(ch) || isDigit(ch) || ch == '_' || ch == '$' || ch == '.' || isIdentExtend(ch) + isIdentCharTable[i] = isIdentChar(ch) } } diff --git a/parser/parse_func.go b/parser/parse_func.go index 9c97ce5..52b06b7 100644 --- a/parser/parse_func.go +++ b/parser/parse_func.go @@ -695,7 +695,14 @@ func (r *rdParser) parseSimpleIdentAtom(start int) ast.ExprNode { Args: args, }, start) } - name := &ast.ColumnName{} + // The expr and its ColumnName are allocated as one block: column + // references are the most-allocated node in typical queries, and the + // two objects always live and die together. + block := &struct { + expr ast.ColumnNameExpr + name ast.ColumnName + }{} + name := &block.name if r.tok() == int('.') && isIdentifierTok(r.la(1)) { r.advance() second := r.parseIdentifier() @@ -711,7 +718,8 @@ func (r *rdParser) parseSimpleIdentAtom(start int) ast.ExprNode { } else { name.Name = ast.NewCIStr(first) } - col := &ast.ColumnNameExpr{Name: name} + col := &block.expr + col.Name = name r.setOrigin(col, start) switch r.tok() { case jss: diff --git a/parser/rd_parser.go b/parser/rd_parser.go index 71742db..7786221 100644 --- a/parser/rd_parser.go +++ b/parser/rd_parser.go @@ -68,6 +68,13 @@ type rdParser struct { done bool marks []int // absolute indices pinned by speculative parses + // c points at the current token's window slot, kept in sync by + // advance and rewind (the only movers of i) so that cur and tok are + // call-free field loads. Slot pointers stay valid across window + // growth (the old backing array keeps the same values) and are + // recomputed after compaction, which rewrites slots in place. + c *rdToken + // stmtStart mirrors Scanner.stmtStartPos bookkeeping for stmtText(). stmtStart int result []ast.StmtNode @@ -137,43 +144,67 @@ func (parser *Parser) newRDScanner(sql string) *Scanner { func (r *rdParser) lexOne() { var v yySymType tok := r.sc.Lex(&v) - t := rdToken{tok: tok, lit: v.ident, item: v.item, offset: v.offset} + // Extend the window and fill the slot in place rather than building + // an rdToken and copying it in: the struct is large and this is the + // hottest allocation-free path in the parser. Recycled slots hold + // stale tokens, so every field is (re)assigned. + if len(r.win) == cap(r.win) { + r.win = append(r.win, rdToken{}) + } else { + r.win = r.win[:len(r.win)+1] + } + t := &r.win[len(r.win)-1] + t.tok, t.lit, t.item, t.offset = tok, v.ident, v.item, v.offset p := r.sc.r.pos() t.endOffset, t.endLine, t.endCol = p.Offset, p.Line, p.Col if tok == hintComment { t.hintPos = r.sc.lastHintPos + } else { + t.hintPos = Pos{} } if len(r.sc.errs) > 0 { - // A lexing problem already recorded its error(s). + // A lexing problem already recorded its error(s). The parse is + // abandoned, so the token left in the window is harmless. panic(rdLexError{}) } if tok == invalid { // The parser side of an invalid token is a plain syntax error at // its position. - panic(rdSyntaxError{offset: t.offset, err: r.buildSyntaxError(&t)}) + panic(rdSyntaxError{offset: t.offset, err: r.buildSyntaxError(t)}) } - r.win = append(r.win, t) if tok == 0 { r.done = true } } // at returns the token at absolute index abs, lexing forward as needed. -// Past EOF it returns the EOF token. +// Past EOF it returns the EOF token. The in-window fast path is kept +// small enough to inline into tok/la/cur, which are the parser's hottest +// calls. func (r *rdParser) at(abs int) *rdToken { + idx := abs - r.base + if idx >= len(r.win) { + idx = r.fill(abs) + } + return &r.win[idx] +} + +// fill lexes until the window covers abs (or EOF) and returns the window +// index to read. +func (r *rdParser) fill(abs int) int { for abs-r.base >= len(r.win) { if r.done { - return &r.win[len(r.win)-1] + return len(r.win) - 1 } r.lexOne() } - return &r.win[abs-r.base] + return abs - r.base } -func (r *rdParser) cur() *rdToken { return r.at(r.i) } +func (r *rdParser) cur() *rdToken { return r.c } // tok returns the current token id (0 at EOF). -func (r *rdParser) tok() int { return r.at(r.i).tok } +func (r *rdParser) tok() int { return r.c.tok } // la returns the token id k positions ahead (la(0) == tok()). func (r *rdParser) la(k int) int { return r.at(r.i + k).tok } @@ -181,7 +212,7 @@ func (r *rdParser) la(k int) int { return r.at(r.i + k).tok } // advance moves past the current token and opportunistically drops window // tokens that no active mark or the cursor can reach again. func (r *rdParser) advance() { - if t := r.at(r.i); t.tok != 0 { + if r.c.tok != 0 { r.i++ } low := r.i @@ -193,6 +224,7 @@ func (r *rdParser) advance() { r.win = r.win[:n] r.base = low } + r.c = r.at(r.i) } // mark pins the current position for a speculative parse. Every mark is @@ -208,6 +240,7 @@ func (r *rdParser) unmark() { func (r *rdParser) rewind(m int) { r.i = m + r.c = r.at(m) r.unmark() } @@ -273,6 +306,7 @@ func (parser *Parser) parseRD(sql string) (stmts []ast.StmtNode, warns []error, } stmts, err = nil, lexErrs[0] }) + r.c = r.at(r.i) r.parseStatementList() lexWarns, lexErrs := r.sc.Errors()