perf: bulk-copy bitpacked streams instead of element-wise decode - #487
radmirnovii wants to merge 1 commit into
Conversation
cheb0
left a comment
There was a problem hiding this comment.
Overall looks good to me, but I still have weird feelings about build tags solely for this micro optimization. I'd honestly redo this (see comments).
Please also note reviewing a PR fully generated with AI is a frustrating experience. Please clean up suggested places (I guess it's our job now). I'd also clean up description - looks completely unreadable to me.
| // | ||
| // Big-endian hosts use the element-wise fallback in copy_uints_be.go. | ||
|
|
||
| //go:build 386 || amd64 || arm || arm64 || loong64 || mipsle || mips64le || ppc64le || riscv64 || wasm |
There was a problem hiding this comment.
I have odd feelings about introducing build tags for this small optimization. Initially I did this, it just didn't get into main branch: 22a3e36#diff-aab3afc5841441065dc926e0dccdfca3010a8eb081ccc5c04bb394f902bde1d3R17
I though it's easier to just check if host is LE and then use if branch.
The main problem I see with the proposed solution is I don't know much about these platforms and build tags look like overkill for this kind of micro-optimization. I also looked through arrow-go library, haven't found any build tags, but endianess field is there in the schema.
What are you thoughts?
| @@ -0,0 +1,78 @@ | |||
| // Byte->word conversion of the little-endian streams. | |||
| // | |||
| // On little-endian hosts the in-memory representation of []uint32/[]uint64 is | |||
There was a problem hiding this comment.
Please clean up the comment. That's a typical huge claude comment explaining trivial things. And there a lot of questionable numbers and statements as well.
| // overhead. Measured crossover (BenchmarkSmallCopyCrossover, Cascade Lake): | ||
| // the loop wins at 1-2 words, ~ties at 3, memmove wins from 4 on (-15%) | ||
| // and widens quickly (-48% at 8 words). | ||
| const smallCopyWords = 3 |
There was a problem hiding this comment.
We can remove this smallCopyWords, I'd generally expect we pass large blocks (128 bytes or more) through this function.
| @@ -0,0 +1,78 @@ | |||
| // Byte->word conversion of the little-endian streams. | |||
There was a problem hiding this comment.
The file named copy_uints.go. Do you think it's a good name for this particular file? I'd personally put it somewhere to utils package and named copy_utils.go
| const smallCopyWords = 3 | ||
|
|
||
| // copyAsUints32 reinterprets dst as bytes and bulk-copies src into it. | ||
| // Panics if len(src) is not a multiple of the word size — same fail-fast |
There was a problem hiding this comment.
Same for comments, please simplify.
| if len(src)%8 != 0 { | ||
| panic(fmt.Sprintf("packer: ragged uint64 stream: %d bytes", len(src))) | ||
| } | ||
| n := len(src) / 8 |
| } | ||
| return dst | ||
| } | ||
| // copyAsUints32 and copyAsUints64 convert the little-endian byte stream into |
There was a problem hiding this comment.
Why do we need a comment here? Look weird :)
| } | ||
| } | ||
|
|
||
| func BenchmarkCopyAsUints32(b *testing.B) { |
There was a problem hiding this comment.
I'd remove these benchmarks. Probably most benchmarks in this file. The problem is we actually run all microbenchmarks on each commit so that we can track numbers in time. However, these benches do not carry any useful info/insights.
Our project already has such non-informative benches, so yes, there is work to do.
Besides, everybody know memcpy should be faster than hand-rolled loop unless the theoretical compiler targets this particular optimization.
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.