perf(parquet): use typed memo insertion for byte-array dictionaries - #1272
Conversation
DictByteArrayEncoder.PutByteArray inserted through the untyped MemoTable.GetOrInsert, boxing the parquet.ByteArray on every value written. Boxing a slice is a heap allocation that the memo table discards immediately. hashing.BinaryMemoTable already implements the allocation-free InsertOrGet, and GetOrInsert is a boxing wrapper over it. Expose InsertOrGet on the encoding.BinaryMemoTable interface so the encoder can call it directly. This applies the treatment from apache#1178 and apache#1251 to the byte-array path that apache#1178 explicitly left unchanged. BenchmarkEncodeDictByteArray, benchstat n=10: -23.61% sec/op, -42.26% B/op, -49.98% allocs/op. The allocation delta is exactly one per value. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
I think those are flaky tests, they're not related to this PR. I don't have the rights to restart the failed jobs |
zeroshade
left a comment
There was a problem hiding this comment.
Nice work, and thank you for the unusually complete write-up — the production profile context made this easy to evaluate. Approving.
I reproduced your benchmark independently (Linux, Intel Ultra 7 155H, benchstat n=12, interleaved against 7efe1c01):
| before | after | change | |
|---|---|---|---|
| sec/op | 5.346m ± 30% | 3.869m ± 17% | −27.62% (p=0.001) |
| B/op | 3.552Mi ± 0% | 2.051Mi ± 0% | −42.26% (p=0.000) |
| allocs/op | 131.12k ± 0% | 65.58k ± 0% | −49.98% (p=0.000) |
B/op and allocs/op match your numbers to the digit, and the delta is exactly one allocation per value as you said. My box is too noisy to confirm your −23.61% precisely, but the direction and magnitude agree.
Also verified: go build ./..., go vet, full go test ./..., and -race on parquet/internal/encoding and internal/hashing all pass. On the interface widening — the only two implementers of encoding.BinaryMemoTable are hashing.BinaryMemoTable (already satisfied it) and binaryMemoTableImpl (handled here). Every other reference in the repo is to the concrete *hashing.BinaryMemoTable, so nothing else is affected. The new test is good: covering both implementations, nil, and non-aliasing is more than I expected.
There is a second boxing allocation, and it's a one-liner
You removed one of two boxes per value. The other is still there, in hashing.BinaryMemoTable.InsertOrGet itself:
func (b *BinaryMemoTable) InsertOrGet(val []byte) (idx int, found bool, err error) {
h := b.getHash(val) // getHash(interface{}) — boxes the []byte againI isolated it against the obvious alternative suspect, the comparison closure in lookup (allocs/op on the already-present path):
| method | hash call | closure? | allocs/op |
|---|---|---|---|
InsertOrGet |
getHash(interface{}) |
yes | 1 |
GetOrInsertBytes |
Hash(val, 0) |
yes | 0 |
ExistsDirect |
Hash(val, 0) |
no | 0 |
Exists |
getHash(interface{}) |
yes | 1 |
GetOrInsertBytes shares the same closure and costs nothing, so it is the boxing, not the closure.
GetOrInsertBytes sits two functions above InsertOrGet and is otherwise identical. Changing that one line to h := Hash(val, 0) is semantically a no-op — getHash's []byte case is Hash(v, 0), and the method only accepts []byte. On top of your branch, benchstat n=12:
| this PR | + Hash(val, 0) |
change | |
|---|---|---|---|
| sec/op | 5.734m ± 30% | 2.570m ± 40% | −55.17% (p=0.000) |
| B/op | 2100.2Ki ± 0% | 563.2Ki ± 0% | −73.18% (p=0.000) |
| allocs/op | 65579 ± 0% | 44 ± 0% | −99.93% (p=0.000) |
65,579 → 44. Full go test ./... passes with it. Given your production profile, that looks worth having. Fold it in here or do it as a follow-up, whichever you prefer — this PR stands on its own either way. Exists has the same one-line fix available.
Minor
binaryMemoTableImpl.InsertOrGet allocates once per call. I measured 1 alloc/op against 0 for its GetOrInsert, because key := string(val) copies even on the found path; the compiler only elides that for m[string(b)] used directly as an index expression. It's the deprecated impl, but it exists for benchmark comparison, so an extra allocation there quietly skews exactly those comparisons:
func (m *binaryMemoTableImpl) InsertOrGet(val []byte) (idx int, found bool, err error) {
if idx, found = m.table[string(val)]; !found { // no alloc on the found path
idx = m.Size()
key := string(val)
m.builder.AppendString(key)
m.table[key] = idx
}
return
}Worth saying that your version is safer than the existing GetOrInsert regardless: valAsString casts with unsafe, so the map key aliases the caller's buffer and mutating it afterwards corrupts the table. Your test pins the copy behavior.
Type assertion placement (no action needed). enc.memo.(BinaryMemoTable) is inside PutByteArray, which Put and PutSpaced both call per value, whereas #1178 hoists it out of the loop. I built the hoisted variant expecting a win and measured none (p=0.713, n=12) — flagging it only so you know it was checked, and because matching #1178's shape has some readability value.
FLBA follow-up. Agreed on deferring; for whoever picks it up, it's fixed_len_byte_array_encoder.go:138 and :202, both inside loops.
Rationale for this change
DictByteArrayEncoder.PutByteArrayinserts through the untypedMemoTable.GetOrInsert(interface{}), which boxes theparquet.ByteArrayon every value written. Boxing a slice is a heap allocation (runtime.convTslice), and the memo table discards it immediately:hashing.BinaryMemoTablealready implements the allocation-free typed entry point, andGetOrInsertis a thin boxing wrapper over it:The encoder can't reach it, because
encoding.BinaryMemoTabledoesn't listInsertOrGetamong its methods.This is the same change already made for the numeric paths in #1178 and #1251. #1178 notes that it "keep[s] the existing byte-array and fixed-length byte-array paths unchanged", so this is the remaining half of that work rather than a new direction.
Benchmark —
BenchmarkEncodeDictByteArray, already in the tree (65,535 values, 100 unique, 8–32 byte strings).benchstat, n=10, Apple M3 Max, Go 1.27, againstmainat7efe1c0:The allocation delta is exactly 65,536 — one per value, plus one.
The benchmark understates the production effect, because its 100 distinct values keep the memo table small. In a low-cardinality column the boxing dominates: every value allocates, and every value is then found to be a duplicate. We hit this writing Iceberg tables through
iceberg-go, which writes viapqarrow. In a four-hour production CPU and heap profile of a single streaming writer,DictByteArrayEncoder.PutByteArraywas the fifth-largest allocation site in the whole process — 25.5M objects, 7.7% of everything allocated, all of it this one site.typedDictEncoder[int64].Putwas number one in the same profile at 15.1% before #1178 landed; together the two accounted for roughly a quarter of the process's allocations, which showed up as ~12% of CPU in GC mark.What changes are included in this PR?
InsertOrGet(val []byte)to theencoding.BinaryMemoTableinterface.DictByteArrayEncoder.PutByteArrayinstead ofGetOrInsert.InsertOrGettobinaryMemoTableImplso it still satisfies the interface.TestBinaryInsertOrGet, covering both implementations.Notes:
encoding.BinaryMemoTablelives underparquet/internal/, so widening it is not a public API change.hashing.BinaryMemoTable, viaNewBinaryDictionary) already satisfied the wider interface with no changes.binaryMemoTableImpl, which the source marks deprecated and benchmark-only ("will be removed in a future release"); the method added there mirrors its existingGetOrInsert.DictFixedLenByteArrayEncoderhas the same pattern. I left it out to keep this focused and because I have no production numbers for it — happy to follow up.Are these changes tested?
Yes.
TestBinaryInsertOrGetruns against bothBinaryMemoTableimplementations and checks index assignment, thefoundflag on re-insertion,niltreated as the empty value, agreement withGetOrInsert, and that stored values do not alias the caller's buffer. I confirmed the test fails when the implementation is deliberately broken.go build ./parquet/...go vet ./parquet/internal/encoding/go test ./parquet/internal/encoding/...— passgo test -race ./parquet/internal/encoding/...— passgo test ./parquet/pqarrow/... ./parquet/file/...— the only failures are pre-existing and byte-identical to unpatchedmain(theparquet-testingsubmodule data is not checked out locally); verified by stashing the patch and re-runninggofmt -lclean,git diff --checkcleanBenchmark command:
Are there any user-facing changes?
No. The modified interface is in an internal package, and encoded output is unchanged — only the insertion path differs.