perf: bulk-copy bitpacked streams instead of element-wise decode - #487
Open
radmirnovii wants to merge 1 commit into
Open
perf: bulk-copy bitpacked streams instead of element-wise decode#487radmirnovii wants to merge 1 commit into
radmirnovii wants to merge 1 commit into
Conversation
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.
perf: bulk-copy bitpacked streams instead of element-wise decode
Description
DecompressDeltaBitpack{Uint16,Uint32,Uint64}convert the compressed stream from bytes to words with a per-elementbinary.LittleEndianloop (copyAsUints32/64). The loop is instruction-bound at ~4.5 GiB/s regardless of input size. On little-endian hosts the in-memory representation of a[]uint32/[]uint64is exactly the on-disk little-endian stream, so the conversion can be a single bulkcopy(runtime.memmove), which runs at memory/cache bandwidth (30–70 GiB/s).The implementations are split by build tag: bulk copy on every little-endian GOARCH (
copy_uints.go), the element-wise reference on big-endian (copy_uints_be.go) — behavior for BE builds is unchanged. The smallest inputs (≤3 words) keep the plain loop on the LE path too: the crossover was measured (BenchmarkSmallCopyCrossover— the loop wins at 1–2 words, memmove wins from 4 on, −48% at 8). The fail-fast contract on a corrupted stream is preserved: a ragged length (not a multiple of the word size) panics, exactly as the element-wise decode did.No format change, no assembly,
CGO_ENABLED/purego paths untouched.Where this path runs
DecompressDeltaBitpack*is the only road from disk bytes to index values:seqids/blocks.go(BlockMIDs.Unpack)findLIDsper fetched ID)lids/block.goBlock{LIDs, Offsets}) — each unpack converts two streams, and the offsets stream is tiny whenever a block is dominated by one heavy tokentoken/block_loader.go(u16/u32)GetToken/Narrow/SelectEntries/FindContainson token-block cache missThe same loaders serve local sealed fractions, remote (S3) fractions and compaction reads.
Since the nanosecond-MID migration the compressed MID stream is ~36% of the raw block (bitlen≈23), so the conversion was a third of the MID decompression cost (12–18% of
IndexSearchin profiles).Measurements
Conversion microbenchmarks (
benchstat, n=10, Cascade Lake; arm64/M1 shows the same shape, copy −84%):Why not just a better loop? A safe-Go rewrite (indexed writes instead of
append,variant=indexedin the benchmark) gives only −18%: any scalar loop moves 8 bytes per iteration, whilememmovemoves cache lines with vector registers (×12 over the indexed loop) — that is what theunsafebuys.Full
Decompressfor every production shape,mainvs this PR (n=10, pre-sized buffers modeling the production pools):DecompressMIDBlock— 4096 nanosecond MIDs, bitlen≈23DecompressLIDBlock— 65536-entry postings blockDecompressSmallBlock/n=127— raw-residual streamDecompressSmallBlock/n=16DecompressSmallBlock/n=1— 2-word streamSmall streams occur in production as the offsets arrays of blocks dominated by one heavy token, the tail block of every fraction, and freshly sealed small fractions (LID blocks are shared multi-token containers, so per-token postings length does not reach this function directly). The
n=1case regresses by 3.6 ns absolute: the copy helper grew past the inlining budget (the ragged-length check and the two-path body), so the tiny-input call pays a function-call overhead — a few nanoseconds once per block unpack; no search scenario regresses end-to-end (see below); called out here for completeness rather than hidden by the geomean (−44%).Search-level, this branch vs
main(in-process harness: sealed fraction, 2M synthetic structured logs, production sealing params; n=10):trace_id:<uuid>)level:error AND service:X, limit 100findLIDsprobes a different MID block per ID)trace_id:ab*cd*)GetMIDs)The scattered-fetch scenario is the strongest production case:
findLIDsdecompresses ~1.5 MID blocks per fetched ID (54.7% ofFetchCPU), and the conversion is a third of that.Tests
packer/copy_uints_test.go:dstreuse modes;FuzzCopyAsUints32/64— differential fuzzing against the reference;TestCopyAsUintsRaggedPanics— pins the fail-fast contract on corrupted-stream lengths;TestDecompressBoundarySizes— round-trip at the raw-residual/bitpacked switchover (0, 1, 63, 127, 128, 129, 255, 256, 257, 4096 values, u32 and u64);variant=loopvsvariant=memcpyfor the conversion,BenchmarkSmallCopyCrossoverjustifying the small-input threshold, plus the end-to-endDecompress*regression anchors above.Verified additionally: full
go test ./...,-raceon the affected packages, cross-compilation for the release matrix (linux/darwin × amd64/arm64), wasm and big-endian (GOARCH=s390x); byte-identical query results vsmainon a 57-query equivalence dump (IDs/aggregations/histogram/doc hashes) over the 2M-doc dataset.