Skip to content

Commit 66ebdae

Browse files
authored
Milestone 12 (part): benchmarks vs cgo pg_query_go + allocation profiling (#9)
1 parent 6c85244 commit 66ebdae

8 files changed

Lines changed: 571 additions & 8 deletions

File tree

‎.gitignore‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# Built cgo oracle binary (go build ./... in oracle/)
2+
oracle/oracle

‎PLAN.md‎

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -434,7 +434,9 @@ gate; the corpus harness exists before the first line of the lexer.
434434
scheduled CI job; `go test -race` over the parallel corpus run;
435435
benchmarks vs pg_query_go (cgo) and wasilibs (wasm) — expect wins from
436436
no cgo crossings and true in-process parallelism; memory profiling on
437-
the stress queries.
437+
the stress queries. *Benchmarks + memory profiling landed 2026-08-17;
438+
see "As-built notes (milestone 12, benchmarks + profiling)". Fuzzing,
439+
`-race`, and the wasilibs comparison remain.*
438440
13. **sqlc integration** (in the sqlc repo). Replace
439441
`parse_default.go`/`parse_wasi.go` with one unconditional file; swap the
440442
import path in `convert.go` et al.; drop `wasilibs/go-pgquery`,
@@ -722,6 +724,45 @@ Measured at the pin, where the plan's estimates differ:
722724
`TestParsePlPgSQL` now runs green; no entry point returns
723725
not-implemented anymore.
724726

727+
### As-built notes (milestone 12, benchmarks + profiling)
728+
729+
- The root `benchmark_test.go` mirrors pg_query_go v6.2.2's benchmark file
730+
name-for-name (same queries, same globals trick), with additions: JSON /
731+
Scan variants and `*Stress` benchmarks over the corpus's largest input,
732+
the 1.1 MB multi-VALUES INSERT (fingerprint suite, case 073).
733+
`oracle/benchmark_test.go` is the cgo twin — identical names and inputs
734+
against the pinned pg_query_go — so a run of each diffs directly:
735+
`benchstat cgo.txt pure.txt` (after normalizing the `pkg:` line, since
736+
the two files live in different modules).
737+
- Measured (4 vCPU Xeon 2.8 GHz, go1.24.7): raw parse is 3–10% *faster*
738+
than cgo single-threaded and 29% faster on the stress query; `Scan` is
739+
at parity. Small-query parallel parse runs 6–26% *behind* cgo on this
740+
box: the Go side pays GC for every AST node while libpg_query's arena
741+
allocations are invisible to Go accounting, and the cgo crossing itself
742+
parallelizes fine. Normalize is 1.6–3.6× slower, fingerprint 4.3–6.3×,
743+
ParseToJSON ~5× — all three are protobuf-reflection-driven walks, so
744+
the standing option of generated per-node emitters is where that time
745+
would come back if those entry points ever matter to a consumer. (cgo's
746+
1 alloc/op in these tables is just the Go-side result copy; C-side
747+
allocations are not observable, so allocs/op is only meaningful within
748+
the pure-Go column.)
749+
- Profiling (alloc_space on the stress parse) found two fixable hotspots,
750+
both landed with the benchmarks. The parser's token buffer regrew via
751+
append doubling — 72% of all bytes allocated — and is now pre-sized to
752+
`len(input)/3` (the stress case's ~3 bytes/token is the dense extreme),
753+
cutting stress parse from 92 MB to 39 MB and 185 ms to 146 ms per op.
754+
The fingerprint walk re-sorted each node's field descriptors on every
755+
visit; the per-type order is static and now memoized (−28–31% time,
756+
−25% allocs).
757+
- What remains is the tree itself: ~2 allocations per AST node (the node
758+
struct plus its `ast.Node` oneof wrapper) and the final protobuf
759+
marshal — 20–138 allocs for the upstream benchmark queries. That is the
760+
cost of the pg_query_go-compatible protobuf AST, not overhead to
761+
engineer away.
762+
- Still open from the milestone: difftest mutation fuzzing as a scheduled
763+
CI job, `go test -race` over the parallel corpus run, and the wasilibs
764+
(wasm) comparison.
765+
725766
## Regeneration (the PostgreSQL-upgrade story)
726767

727768
Everything derived is derived by committed tooling from the pin:

‎benchmark_test.go‎

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
// Benchmarks mirroring pg_query_go v6.2.2's benchmark_test.go: same
2+
// benchmark names, same inputs, so `benchstat` can diff a run of this file
3+
// against a run of oracle/benchmark_test.go (the cgo twin) directly.
4+
//
5+
// The Stress benchmarks use the largest corpus input — the 1.1 MB
6+
// multi-VALUES INSERT (fingerprint suite, case 073) — the same query
7+
// PLAN.md's milestone 12 names for memory profiling.
8+
package oliphant_test
9+
10+
import (
11+
"testing"
12+
13+
pg_query "github.com/sqlc-dev/oliphant"
14+
"github.com/sqlc-dev/oliphant/internal/testfile"
15+
"github.com/sqlc-dev/oliphant/parser"
16+
)
17+
18+
// Prevent compiler optimizations by assigning all results to global variables
19+
// (same trick as upstream's benchmark file).
20+
var (
21+
benchErr error
22+
resultStr []byte
23+
resultS string
24+
resultRes *pg_query.ParseResult
25+
)
26+
27+
const stressCaseFile = "parser/testdata/fingerprint/libpg_query.test"
28+
29+
// stressInput returns the 1.1 MB INSERT (case 073 of the fingerprint suite).
30+
func stressInput(b *testing.B) string {
31+
b.Helper()
32+
cases, err := testfile.Read(stressCaseFile)
33+
if err != nil {
34+
b.Fatal(err)
35+
}
36+
big := 0
37+
for i := range cases {
38+
if len(cases[i].Input) > len(cases[big].Input) {
39+
big = i
40+
}
41+
}
42+
return cases[big].Input
43+
}
44+
45+
func benchmarkParse(input string, b *testing.B) {
46+
for i := 0; i < b.N; i++ {
47+
resultRes, benchErr = pg_query.Parse(input)
48+
if benchErr != nil {
49+
b.Errorf("Benchmark produced error %s\n\n", benchErr)
50+
}
51+
}
52+
}
53+
54+
func benchmarkParseParallel(input string, b *testing.B) {
55+
b.RunParallel(func(pb *testing.PB) {
56+
for pb.Next() {
57+
_, err := pg_query.Parse(input)
58+
if err != nil {
59+
b.Errorf("Benchmark produced error %s\n\n", err)
60+
}
61+
}
62+
})
63+
}
64+
65+
func benchmarkRawParse(input string, b *testing.B) {
66+
for i := 0; i < b.N; i++ {
67+
resultStr, benchErr = parser.ParseToProtobuf(input)
68+
if benchErr != nil {
69+
b.Errorf("Benchmark produced error %s\n\n", benchErr)
70+
}
71+
if len(resultStr) == 0 {
72+
b.Errorf("Benchmark produced empty result\n\n")
73+
}
74+
}
75+
}
76+
77+
func benchmarkRawParseParallel(input string, b *testing.B) {
78+
b.RunParallel(func(pb *testing.PB) {
79+
var str []byte
80+
var err error
81+
for pb.Next() {
82+
str, err = parser.ParseToProtobuf(input)
83+
if err != nil {
84+
b.Errorf("Benchmark produced error %s\n\n", err)
85+
}
86+
if len(str) == 0 {
87+
b.Errorf("Benchmark produced empty result\n\n")
88+
}
89+
}
90+
})
91+
}
92+
93+
func benchmarkParseToJSON(input string, b *testing.B) {
94+
for i := 0; i < b.N; i++ {
95+
resultS, benchErr = pg_query.ParseToJSON(input)
96+
if benchErr != nil {
97+
b.Errorf("Benchmark produced error %s\n\n", benchErr)
98+
}
99+
}
100+
}
101+
102+
func benchmarkScan(input string, b *testing.B) {
103+
for i := 0; i < b.N; i++ {
104+
_, benchErr = pg_query.Scan(input)
105+
if benchErr != nil {
106+
b.Errorf("Benchmark produced error %s\n\n", benchErr)
107+
}
108+
}
109+
}
110+
111+
func benchmarkFingerprint(input string, b *testing.B) {
112+
var str string
113+
for i := 0; i < b.N; i++ {
114+
str, benchErr = pg_query.Fingerprint(input)
115+
if benchErr != nil {
116+
b.Errorf("Benchmark produced error %s\n\n", benchErr)
117+
}
118+
if str == "" {
119+
b.Errorf("Benchmark produced empty result\n\n")
120+
}
121+
}
122+
}
123+
124+
func benchmarkNormalize(input string, b *testing.B) {
125+
for i := 0; i < b.N; i++ {
126+
resultS, benchErr = pg_query.Normalize(input)
127+
if benchErr != nil {
128+
b.Errorf("Benchmark produced error %s\n\n", benchErr)
129+
}
130+
if resultS == "" {
131+
b.Errorf("Benchmark produced empty result\n\n")
132+
}
133+
}
134+
}
135+
136+
func BenchmarkParseSelect1(b *testing.B) {
137+
benchmarkParse("SELECT 1", b)
138+
}
139+
func BenchmarkParseSelect2(b *testing.B) {
140+
benchmarkParse("SELECT 1 FROM x WHERE y IN ('a', 'b', 'c')", b)
141+
}
142+
func BenchmarkParseCreateTable(b *testing.B) {
143+
benchmarkParse("CREATE TABLE types (a float(2), b float(49), c NUMERIC(2, 3), d character(4), e char(5), f varchar(6), g character varying(7))", b)
144+
}
145+
146+
func BenchmarkParseSelect1Parallel(b *testing.B) {
147+
benchmarkParseParallel("SELECT 1", b)
148+
}
149+
func BenchmarkParseSelect2Parallel(b *testing.B) {
150+
benchmarkParseParallel("SELECT 1 FROM x WHERE y IN ('a', 'b', 'c')", b)
151+
}
152+
func BenchmarkParseCreateTableParallel(b *testing.B) {
153+
benchmarkParseParallel("CREATE TABLE types (a float(2), b float(49), c NUMERIC(2, 3), d character(4), e char(5), f varchar(6), g character varying(7))", b)
154+
}
155+
156+
func BenchmarkRawParseSelect1(b *testing.B) {
157+
benchmarkRawParse("SELECT 1", b)
158+
}
159+
func BenchmarkRawParseSelect2(b *testing.B) {
160+
benchmarkRawParse("SELECT 1 FROM x WHERE y IN ('a', 'b', 'c')", b)
161+
}
162+
func BenchmarkRawParseCreateTable(b *testing.B) {
163+
benchmarkRawParse("CREATE TABLE types (a float(2), b float(49), c NUMERIC(2, 3), d character(4), e char(5), f varchar(6), g character varying(7))", b)
164+
}
165+
166+
func BenchmarkRawParseSelect1Parallel(b *testing.B) {
167+
benchmarkRawParseParallel("SELECT 1", b)
168+
}
169+
func BenchmarkRawParseSelect2Parallel(b *testing.B) {
170+
benchmarkRawParseParallel("SELECT 1 FROM x WHERE y IN ('a', 'b', 'c')", b)
171+
}
172+
func BenchmarkRawParseCreateTableParallel(b *testing.B) {
173+
benchmarkRawParseParallel("CREATE TABLE types (a float(2), b float(49), c NUMERIC(2, 3), d character(4), e char(5), f varchar(6), g character varying(7))", b)
174+
}
175+
176+
func BenchmarkFingerprintSelect1(b *testing.B) {
177+
benchmarkFingerprint("SELECT 1", b)
178+
}
179+
func BenchmarkFingerprintSelect2(b *testing.B) {
180+
benchmarkFingerprint("SELECT 1 FROM x WHERE y IN ('a', 'b', 'c')", b)
181+
}
182+
func BenchmarkFingerprintCreateTable(b *testing.B) {
183+
benchmarkFingerprint("CREATE TABLE types (a float(2), b float(49), c NUMERIC(2, 3), d character(4), e char(5), f varchar(6), g character varying(7))", b)
184+
}
185+
186+
func BenchmarkNormalizeSelect1(b *testing.B) {
187+
benchmarkNormalize("SELECT 1", b)
188+
}
189+
func BenchmarkNormalizeSelect2(b *testing.B) {
190+
benchmarkNormalize("SELECT 1 FROM x WHERE y IN ('a', 'b', 'c')", b)
191+
}
192+
func BenchmarkNormalizeCreateTable(b *testing.B) {
193+
benchmarkNormalize("CREATE TABLE types (a float(2), b float(49), c NUMERIC(2, 3), d character(4), e char(5), f varchar(6), g character varying(7))", b)
194+
}
195+
196+
// --- beyond the upstream set ---
197+
198+
func BenchmarkParseToJSONSelect2(b *testing.B) {
199+
benchmarkParseToJSON("SELECT 1 FROM x WHERE y IN ('a', 'b', 'c')", b)
200+
}
201+
func BenchmarkScanSelect2(b *testing.B) {
202+
benchmarkScan("SELECT 1 FROM x WHERE y IN ('a', 'b', 'c')", b)
203+
}
204+
205+
func BenchmarkRawParseStress(b *testing.B) {
206+
input := stressInput(b)
207+
b.SetBytes(int64(len(input)))
208+
b.ResetTimer()
209+
benchmarkRawParse(input, b)
210+
}
211+
func BenchmarkRawParseStressParallel(b *testing.B) {
212+
input := stressInput(b)
213+
b.SetBytes(int64(len(input)))
214+
b.ResetTimer()
215+
benchmarkRawParseParallel(input, b)
216+
}
217+
func BenchmarkFingerprintStress(b *testing.B) {
218+
input := stressInput(b)
219+
b.SetBytes(int64(len(input)))
220+
b.ResetTimer()
221+
benchmarkFingerprint(input, b)
222+
}
223+
func BenchmarkNormalizeStress(b *testing.B) {
224+
input := stressInput(b)
225+
b.SetBytes(int64(len(input)))
226+
b.ResetTimer()
227+
benchmarkNormalize(input, b)
228+
}
229+
func BenchmarkScanStress(b *testing.B) {
230+
input := stressInput(b)
231+
b.SetBytes(int64(len(input)))
232+
b.ResetTimer()
233+
benchmarkScan(input, b)
234+
}

‎internal/fingerprint/fingerprint.go‎

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,34 @@ package fingerprint
2222
import (
2323
"sort"
2424
"strconv"
25+
"sync"
2526

2627
"google.golang.org/protobuf/reflect/protoreflect"
2728

2829
"github.com/sqlc-dev/oliphant/ast"
2930
"github.com/sqlc-dev/oliphant/internal/xxh3"
3031
)
3132

33+
// fieldOrderCache memoizes each message type's alphabetical field order
34+
// (keyed by full name): the order is static per type, and re-sorting on
35+
// every node visit dominated the fingerprint walk's time and allocations.
36+
var fieldOrderCache sync.Map // protoreflect.FullName -> []protoreflect.FieldDescriptor
37+
38+
// sortedFields returns d's fields in alphabetical (C generator) order.
39+
func sortedFields(d protoreflect.MessageDescriptor) []protoreflect.FieldDescriptor {
40+
if v, ok := fieldOrderCache.Load(d.FullName()); ok {
41+
return v.([]protoreflect.FieldDescriptor)
42+
}
43+
fds := d.Fields()
44+
order := make([]protoreflect.FieldDescriptor, fds.Len())
45+
for i := 0; i < fds.Len(); i++ {
46+
order[i] = fds.Get(i)
47+
}
48+
sort.Slice(order, func(a, b int) bool { return order[a].JSONName() < order[b].JSONName() })
49+
fieldOrderCache.Store(d.FullName(), order)
50+
return order
51+
}
52+
3253
// fingerprintVersion is PG_QUERY_FINGERPRINT_VERSION, the XXH3 seed.
3354
const fingerprintVersion = 3
3455

@@ -286,11 +307,7 @@ func (ctx *context) fields(m protoreflect.Message, parentType, fieldName string,
286307
}
287308

288309
fds := d.Fields()
289-
order := make([]protoreflect.FieldDescriptor, fds.Len())
290-
for i := 0; i < fds.Len(); i++ {
291-
order[i] = fds.Get(i)
292-
}
293-
sort.Slice(order, func(a, b int) bool { return order[a].JSONName() < order[b].JSONName() })
310+
order := sortedFields(d)
294311

295312
for _, fd := range order {
296313
name := fd.JSONName()

‎internal/parse/modes.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ func ParseWithMode(input string, mode Mode) (res *ast.ParseResult, err *lexer.Er
2929
return Parse(input)
3030
}
3131
s := lexer.New(input)
32-
p := &parser{src: s.Input(), filter: lexer.NewFilter(s)}
32+
p := &parser{src: s.Input(), filter: lexer.NewFilter(s), toks: make([]lexer.Token, 0, tokenCap(input))}
3333
defer func() {
3434
if r := recover(); r != nil {
3535
if b, ok := r.(bail); ok {

‎internal/parse/parser.go‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,20 @@ type parser struct {
3232
// returns through several hundred productions would bury the grammar.
3333
type bail struct{ err *lexer.Error }
3434

35+
// tokenCap sizes the token buffer up front: SQL averages a handful of bytes
36+
// per token, and the dense extreme (large VALUES parameter lists, the corpus
37+
// stress case) runs ~3 bytes/token, so len/3 keeps even pathological inputs
38+
// from regrowing the buffer — append-regrowth otherwise dominates
39+
// alloc_space on large statements — while short queries over-allocate at
40+
// most a few hundred transient bytes.
41+
func tokenCap(input string) int {
42+
return len(input)/3 + 8
43+
}
44+
3545
// Parse is raw_parser for RAW_PARSE_DEFAULT: parse_toplevel/stmtmulti.
3646
func Parse(input string) (res *ast.ParseResult, err *lexer.Error) {
3747
s := lexer.New(input)
38-
p := &parser{src: s.Input(), filter: lexer.NewFilter(s)}
48+
p := &parser{src: s.Input(), filter: lexer.NewFilter(s), toks: make([]lexer.Token, 0, tokenCap(input))}
3949
defer func() {
4050
if r := recover(); r != nil {
4151
if b, ok := r.(bail); ok {

0 commit comments

Comments
 (0)