Skip to content

Add ReusableState for allocation-free incremental FST walks - #27

Open
oc-engteam wants to merge 1 commit into
blevesearch:masterfrom
oc-engteam:add-reusable-state-decode
Open

Add ReusableState for allocation-free incremental FST walks#27
oc-engteam wants to merge 1 commit into
blevesearch:masterfrom
oc-engteam:add-reusable-state-decode

Conversation

@oc-engteam

Copy link
Copy Markdown

Motivation

AcceptWithVal / IsMatchWithVal decode each source state with a nil prealloc, so they allocate a fresh fstStateV1 on every transition. Callers that walk the FST one byte at a time over many overlapping prefixes — e.g. building a CJK segmentation DAG that restarts the walk from every input position — pay one allocation per transition.

In a real FST-backed CJK tokenizer (replacing a cedar-trie dictionary with a vellum FST), a memprofile showed decoderV1.stateAt accounting for ~69% of all allocations; threading a reusable state removed them and made tokenization ~2× faster wall-clock (allocs/op 1756→396, B/op 232K→22K).

Change

fstStateV1 only references the FST data (no owned slices), so a single reusable instance can back an entire walk. This PR adds:

  • ReusableState — an opaque, caller-owned decode buffer (NewReusableState()).
  • AcceptWithValState(addr, b, rs) and IsMatchWithValState(addr, rs) — identical behavior to the existing methods, but decode into rs instead of allocating.

The existing API is untouched; this is purely additive. A ReusableState is not safe for concurrent use (documented), matching the per-goroutine pattern of Reader.

Tests

  • TestReusableStateMatchesStock — the *State variants return identical (output, final) to AcceptWithVal/IsMatchWithVal across matching, prefix, and absent keys, reusing one ReusableState across keys.
  • TestReusableStateNoAlloctesting.AllocsPerRun of a full walk is 0.

Disclosure: drafted with AI assistance; reviewed and tested locally (go test green, gofmt clean).

AcceptWithVal/IsMatchWithVal decode each source state with a nil prealloc, so
they allocate a fresh fstStateV1 on every transition. Callers that walk the FST
one byte at a time over many overlapping prefixes — e.g. building a CJK
segmentation DAG that restarts from every input position — therefore allocate
once per transition. In a real FST-backed CJK tokenizer this was ~69% of all
allocations (and ~2x the wall-clock once removed).

Add an opaque ReusableState plus AcceptWithValState/IsMatchWithValState that
thread a reusable *fstStateV1 through decoder.stateAt. fstStateV1 only references
the FST data (no owned slices), so a single ReusableState backs an entire walk
with zero per-transition allocation. Results are identical to the stock methods;
the existing API is untouched.

Tests: TestReusableStateMatchesStock (parity across match/prefix/absent keys) and
TestReusableStateNoAlloc (AllocsPerRun == 0).

Signed-off-by: Engineering Team <oc-engteam@codetrek.work>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
oc-engteam added a commit to codetrek/haystack that referenced this pull request Jun 18, 2026
…85)

Vendor vellum v1.0.10 (library only) under core/third_party/vellum with a relative replace, adding AcceptWithValState/IsMatchWithValState that thread a reusable *fstStateV1 through decoder.stateAt. The fstcjk segmenter holds one ReusableState per Cut (pooled) — eliminating the per-FST-transition state allocation that a memprofile pinned at ~69% of CJK-tokenization allocs.

Byte-identical to gse (fidelity tests + the FST-vs-gse CI gate pass).
BenchmarkCJKTokenizeForIndex: 1756->396 allocs/op (-77%), 232K->22K B/op (-90%), 138us->66us (2.1x faster).

Also excludes third_party from the repo-wide gofmt check (vendored upstream code).
The 2 reuse methods are proposed upstream (blevesearch/vellum#27); if accepted, the vendored copy can be dropped for a version bump.
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