Skip to content

perf(parquet): batch DELTA_BYTE_ARRAY encoding - #1179

Open
fallintoplace wants to merge 4 commits into
apache:mainfrom
fallintoplace:perf/delta-byte-array-batching
Open

perf(parquet): batch DELTA_BYTE_ARRAY encoding#1179
fallintoplace wants to merge 4 commits into
apache:mainfrom
fallintoplace:perf/delta-byte-array-batching

Conversation

@fallintoplace

@fallintoplace fallintoplace commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Batch DELTA_BYTE_ARRAY prefix lengths and suffixes in chunks of 256 values.
  • Reuse fixed scratch space for DELTA_LENGTH_BYTE_ARRAY lengths.
  • Preserve the previous value across batch boundaries and separate Put calls.
  • Add a boundary round-trip test and representative benchmarks.

Why

DELTA_BYTE_ARRAY was calling the prefix and suffix encoders once per value. Large pages therefore paid for many small calls and temporary length slices.

Apache Arrow C++ already uses fixed-size batching for this encoder. This change follows the same shape while keeping the existing Go encoding format and last-value behavior.

Correctness

  • The encoded layout is unchanged: prefix lengths first, followed by the suffix stream.
  • The first value still has a zero prefix.
  • Empty suffixes still work as before.
  • The new test compares one large Put call with Put calls split around a batch boundary, then decodes and compares every value.

Benchmark

64K values on an Apple M1 Pro, 1 second per sample, 3 samples:

Case Before After Change
prefix-heavy 2.09 ms/op 1.75 ms/op ~16% faster
low-prefix 1.68 ms/op 1.47 ms/op ~12% faster

Allocation counts stayed the same in both cases.

Checks

  • PARQUET_TEST_DATA=<parquet-testing-data> go test ./parquet/...
  • go test -race ./parquet/internal/encoding -count=1
  • git diff --check

No public API changes.

@fallintoplace
fallintoplace marked this pull request as ready for review August 17, 2026 23:49

@zeroshade zeroshade left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The encoded output remains compatible and I found no decoding or state bug introduced by the batching logic. The complete encoding tests, race tests, vet, and diff checks pass.

The fixed stack scratch arrays do, however, cause a substantial performance regression for small Put calls: approximately 3.5x slower for one value per call and 20% slower for eight values per call in direct merge-base versus PR measurements. The submitted benchmark exercises only one large call and cannot detect this regression. I left inline requests to reuse scratch storage, revise the benchmark, and add targeted standalone DELTA_LENGTH boundary coverage.

Separately, while tracing the encoder lifecycle I found a pre-existing multi-page bug: DeltaByteArrayEncoder.FlushValues does not reset lastVal, although the column writer reuses the encoder for subsequent pages. The next page can therefore start with a nonzero prefix and fail decoding with parquet: first delta byte array prefix length must be zero. This reproduces on the merge base, so it should not block this PR, but it warrants a separate issue/fix.


This review was drafted by an AI-assisted tool and
confirmed by an Apache Arrow Go maintainer. After you've
addressed the points above and pushed an update, an Apache Arrow Go
maintainer — a real person — will take the next look
at the PR. The findings cite the project's review criteria;
if you think one is mis-applied, please reply on the
PR and a maintainer will weigh in.

More on how Apache Arrow Go handles maintainer review:
CONTRIBUTING.md.

}
j++
lastVal := enc.lastVal
var prefixLengths [deltaByteArrayBatchSize]int32

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: these fixed arrays cause roughly 7 KiB of stack memory to be zeroed on every Put call, regardless of how many values the call contains. The nested DeltaLengthByteArrayEncoder.Put adds another 1 KiB scratch array.

Comparing the actual merge base and this PR on an Apple M4 while encoding 64K prefix-heavy values:

  • 1 value/Put: 2.72 ms → 9.57 ms, approximately 3.5× slower
  • 8 values/Put: 1.80 ms → 2.16 ms, approximately 20% slower
  • 256 values/Put: 1.57 ms → 1.26 ms, approximately 20% faster

The crossover is around 32 values per call. Default writer batches benefit, but callers using small WriteBatch or encoder Put calls encounter a large performance cliff.

Please keep the scratch storage on the encoder and allocate it once rather than zeroing full stack arrays per call. The suffix scratch must be cleared after each used batch so it does not retain caller-owned buffers.

"github.com/apache/arrow-go/v18/parquet"
)

func encodeDeltaByteArrayUnbatched(values []parquet.ByteArray) (Buffer, error) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This hand-written “before” implementation is not the actual merge-base implementation: it links against this PR’s modified DeltaLengthByteArrayEncoder.Put. On the current head it reports approximately 3.45 ms → 1.28 ms, a roughly 63% improvement, which does not correspond to the PR description’s actual revision-to-revision result.

It also benchmarks only one large Put, so it cannot detect the small-call regression introduced by the fixed scratch arrays.

Please benchmark the production encoder with several Put chunk sizes—at least 1, 8, 32, 256, and all values—and use revision-to-revision benchstat results rather than retaining a duplicate historical implementation in the benchmark.

for idx, val := range in {
lengths[idx] = int32(val.Len())
totalLen += val.Len()
var lengths [deltaByteArrayBatchSize]int32

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add a targeted standalone DELTA_LENGTH_BYTE_ARRAY test around 255/256/257 and 511/512/513 values, comparing one large Put with split Put calls and round-tripping the result.

The new DELTA_BYTE_ARRAY test does not exercise this loop’s multi-batch path because the outer encoder invokes the suffix encoder with at most 256 values at a time.

@zeroshade zeroshade left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The updated implementation addresses most of the prior review:

  • the 7 KiB per-Put stack clearing is gone;
  • suffix scratch references are cleared;
  • the synthetic baseline benchmark was replaced with a useful Put-size matrix;
  • standalone DELTA_LENGTH boundary/split coverage was added;
  • output remains byte-compatible;
  • the additional lastVal page reset fixes the previously identified multi-page corruption.

The encoding and race suites, vet, and diff checks pass. I left two remaining performance comments inline: the one-value Put case is still approximately 12–15% slower than the merge base, and the newly enlarged encoder structs retain value-receiver Type methods that copy the complete scratch storage.


This review was drafted by an AI-assisted tool and
confirmed by an Apache Arrow Go maintainer. After you've
addressed the points above and pushed an update, an Apache Arrow Go
maintainer — a real person — will take the next look
at the PR. The findings cite the project's review criteria;
if you think one is mis-applied, please reply on the
PR and a maintainer will weigh in.

More on how Apache Arrow Go handles maintainer review:
CONTRIBUTING.md.

// to the suffix, and aren't forcing the *entire* value to stay
// in memory while we have this reference to just the suffix.
enc.lastVal = append([]byte{}, enc.lastVal...)
enc.lastVal = append([]byte{}, lastVal...)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The original multi-fold performance cliff is fixed, but the one-value Put case remains approximately 12–15% slower than the merge base:

  • merge base: approximately 2.72 ms
  • current head: approximately 3.12 ms

The remaining dominant cost is this allocation and copy on every Put. Reusing the existing backing buffer is safe within a page:

enc.lastVal = append(enc.lastVal[:0], lastVal...)

FlushValues can continue setting it to nil to reset page state and release the retained value. An independent probe found this change makes the one-value case faster than the merge base while preserving byte-identical output.

Since this is a performance PR and small Put calls were the original blocking concern, please eliminate the remaining regression and update the benchmark results.

prefixEncoder *DeltaBitPackInt32Encoder
suffixEncoder *DeltaLengthByteArrayEncoder

prefixLengths [deltaByteArrayBatchSize]int32

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moving these arrays onto the encoder makes DeltaByteArrayEncoder roughly 7 KiB, but its Type method still has a value receiver:

func (DeltaByteArrayEncoder) Type() parquet.Type

Calling it through the encoder interface now copies the entire struct. DeltaLengthByteArrayEncoder has the same issue with its new roughly 1 KiB scratch field.

Both constructors return pointers, so please switch both Type methods to pointer receivers.

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.

2 participants