Skip to content

perf: add ParseBuffer, a zero-copy path for contiguous input - #57

Draft
efritz wants to merge 2 commits into
tree-sitter:masterfrom
efritz:perf-parse-buffer
Draft

perf: add ParseBuffer, a zero-copy path for contiguous input#57
efritz wants to merge 2 commits into
tree-sitter:masterfrom
efritz:perf-parse-buffer

Conversation

@efritz

@efritz efritz commented Aug 23, 2026

Copy link
Copy Markdown

Parser.Parse takes a []byte the caller already holds contiguously, but reaches the C parser through the chunked TSInput callback. Its callback returns the entire remaining document on each invocation, and readUTF8 then allocates a Go string copy of that remainder plus a C copy of it, retaining every C copy in payload.cStrings until the parse completes:

//export readUTF8
func readUTF8(_payload unsafe.Pointer, ...) *C.char {
	payload := pointer.Restore(_payload).(*payload[byte])
	payload.text = payload.callback(int(byteIndex), ...)   // text[i:] — the whole remainder
	*bytesRead = C.uint32_t(len(payload.text))
	strbytes := C.CString(string(payload.text))            // Go copy, then C copy
	payload.cStrings = append(payload.cStrings, strbytes)
	return strbytes
}

For a caller that has the whole document in one slice — which is what Parse's own signature implies — none of that is necessary. ts_parser_parse_string exists for exactly this case and was not exposed by the binding.

ParseBuffer calls it with the caller's bytes directly. The allocation profile goes from O(document size) to O(1):

goos: darwin / goarch: arm64 / cpu: Apple M4 Pro
                     │  Parse                │  ParseBuffer          │
4 KiB                │  5,008 B/op   6 allocs │      8 B/op  1 alloc  │
64 KiB               │ 73,872 B/op   6 allocs │      8 B/op  1 alloc  │
512 KiB              │ 532,627 B/op  6 allocs │      8 B/op  1 alloc  │

Note Parse's B/op tracks the document size almost exactly — that is the one full Go-heap copy per parse, before the C-side malloc that is not counted here.

Wall time is unchanged, and I want to be precise rather than overclaim: across n=6 at 200 iterations each, ParseBuffer measured 1–3% slower on this machine. With GOGC=off to remove GC as a variable the gap narrows to ~1.3% with overlapping ranges, so I read it as equivalent within noise. A plausible reason it does not come out ahead in a micro-benchmark: the copying path writes a fresh 512 KiB buffer inside the timed loop, which warms it in cache immediately before tree-sitter reads it, while ParseBuffer re-reads a buffer that may have been evicted between iterations. A caller that touches each document once — the case this is for — gets no such warming and pays the copy outright.

So this is a memory and GC-pressure change, not a throughput one. In the workload that motivated it (a code indexer parsing ~6 M files per run, concurrently, with peak heap in the hundreds of MB) removing a full copy of every file from the Go heap is worth having on its own.

Scope

Deliberately narrower than Parse rather than a replacement for it:

  • It cannot parse a document the caller does not hold contiguously in memory — the chunked callback's actual purpose.
  • It takes no ParseOptions, so it offers neither a progress callback nor cancellation. ParseWithOptions remains the route for those.

Parse is untouched, so nothing changes for existing callers.

Memory safety

Passing a Go pointer to C is permitted here because ts_parser_parse_string does not retain it: tree-sitter records byte offsets and the returned TSTree holds no reference to the source text. runtime.KeepAlive pins the slice for the duration of the call, which is the only window C holds the pointer. TestParseBufferTreeOutlivesBuffer overwrites the source buffer after parsing and asserts the tree is unchanged, which pins that claim rather than leaving it as a comment.

Tests

TestParseBufferMatchesParse asserts the two paths produce identical S-expressions, end bytes, and end positions across empty, trivial, typical, syntactically-invalid, large multi-chunk, multi-byte-rune, and embedded-NUL input. The last two are where a length-in-bytes C API is most likely to diverge from a NUL-terminated one. TestParseBufferWithoutLanguage checks it returns nil rather than panicking where Parse also returns nil.

Full suite passes locally (CGO_ENABLED=1 go test ./..., submodule initialized).

efritz and others added 2 commits August 23, 2026 16:00
Parser.Parse reaches the C parser through the chunked TSInput callback, and its
callback returns the entire remaining document on each invocation. readUTF8 then
allocates a Go string copy of that remainder and a second C copy of it, retaining
every C copy until the parse completes. For the common case — a caller that
already holds the whole document in one slice — those copies are pure overhead.

On a parse-heavy workload the cost is visible in the allocation profile: parsing
a 6.85 MB corpus of Go source allocated 7.35 MB, i.e. one full Go-heap copy of
every file, before any of the C-side mallocs.

ParseBuffer calls ts_parser_parse_string with the caller's bytes directly. No
callback into Go, no copies. Tree-sitter records byte offsets rather than
retaining the text, so the returned Tree depends on neither the lifetime nor the
contents of the buffer; runtime.KeepAlive pins it for the duration of the call,
which is the only window C holds the pointer, and that is what makes passing a Go
pointer here legal.

Deliberately narrower than Parse rather than a replacement: it cannot parse a
document the caller does not hold contiguously, and it takes no ParseOptions, so
it offers neither a progress callback nor cancellation. ParseWithOptions remains
the route for those.

TestParseBufferMatchesParse asserts the two paths produce identical
S-expressions, end bytes, and end positions across empty, trivial, typical,
syntactically-invalid, large multi-chunk, multi-byte-rune, and embedded-NUL
input. The last two are the cases a length-in-bytes C API is most likely to get
wrong. TestParseBufferTreeOutlivesBuffer overwrites the source after parsing to
pin the no-retention claim, and TestParseBufferWithoutLanguage checks it returns
nil rather than panicking where Parse also returns nil.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Documents the allocation difference the new path exists for, at three document
sizes, so the O(document) versus O(1) claim is reproducible rather than asserted.
Sizes matter here because Parse's cost scales with the document rather than with
the node count: its callback returns the entire remaining input on each
invocation and readUTF8 copies it twice, so a small fixture would hide it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant