perf: add ParseBuffer, a zero-copy path for contiguous input - #57
Draft
efritz wants to merge 2 commits into
Draft
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Parser.Parsetakes a[]bytethe caller already holds contiguously, but reaches the C parser through the chunkedTSInputcallback. Its callback returns the entire remaining document on each invocation, andreadUTF8then allocates a Go string copy of that remainder plus a C copy of it, retaining every C copy inpayload.cStringsuntil the parse completes: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_stringexists for exactly this case and was not exposed by the binding.ParseBuffercalls it with the caller's bytes directly. The allocation profile goes from O(document size) to O(1):Note
Parse'sB/optracks the document size almost exactly — that is the one full Go-heap copy per parse, before the C-sidemallocthat is not counted here.Wall time is unchanged, and I want to be precise rather than overclaim: across n=6 at 200 iterations each,
ParseBuffermeasured 1–3% slower on this machine. WithGOGC=offto 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, whileParseBufferre-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
Parserather than a replacement for it:ParseOptions, so it offers neither a progress callback nor cancellation.ParseWithOptionsremains the route for those.Parseis untouched, so nothing changes for existing callers.Memory safety
Passing a Go pointer to C is permitted here because
ts_parser_parse_stringdoes not retain it: tree-sitter records byte offsets and the returnedTSTreeholds no reference to the source text.runtime.KeepAlivepins the slice for the duration of the call, which is the only window C holds the pointer.TestParseBufferTreeOutlivesBufferoverwrites the source buffer after parsing and asserts the tree is unchanged, which pins that claim rather than leaving it as a comment.Tests
TestParseBufferMatchesParseasserts 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.TestParseBufferWithoutLanguagechecks it returnsnilrather than panicking whereParsealso returnsnil.Full suite passes locally (
CGO_ENABLED=1 go test ./..., submodule initialized).