Skip to content

perf(telemetry): stop dictionary-encoding near-unique JSON columns - #255

Merged
vishr merged 1 commit into
mainfrom
perf/drop-dict-on-near-unique-json
Sep 20, 2026
Merged

vishr merged 1 commit into
mainfrom
perf/drop-dict-on-near-unique-json

Conversation

@vishr

@vishr vishr commented Sep 20, 2026

Copy link
Copy Markdown
Member

First item of #254.

Problem

Parquet dictionary encoding holds every distinct value of a column chunk for the whole row group, and ingest flushes a row group only at parquetRowGroupRows (50,000, parquet.go:29). That is a saving on a column whose values repeat. It is a second full copy of the payload on a column whose values do not.

Five columns are near-unique per row by construction and were dictionary-encoded anyway:

  • attributes_json (spans, logs, metrics)
  • events_json, links_json (spans)
  • exemplars_json (metrics)
  • exception_message (spans)

Columns that genuinely repeat — resource_json, scope_*, http_*, hist_*, severity, service — keep their dictionaries.

Measurement

Peak HeapInuse during one CommitBatch, 50,000 synthetic spans, 23.7 MiB payload, sampled every 2ms with GOGC pinned low, repeated runs under -race:

Encoding Peak / payload
dictionary (before) 4.09x – 4.92x
plain (after) 2.79x – 3.49x

Roughly 40 MiB less live heap per in-flight commit. Four commit workers run concurrently with more batches queued behind them, so this multiplies across the process.

What this does not fix

It makes one commit cheaper, not cheap. What remains above 1x:

  • the conversion copy in makeSpanParquetRowstring(r.ResourceJSON) and friends, while the originals stay live until durable ack
  • ~24 MiB of row-struct headers per 50k spans
  • the writer's own page and compression buffers

Each is tracked separately in #254. Compaction's k-way merge, which is likely the larger allocator, is untouched here.

Test

TestCommitBatchPeakHeapStaysNearPayloadSize samples heap during a commit and fails above a ratio placed in the gap between the two measured distributions above, rather than at a guessed number. It was written first and verified to fail against the dictionary encoding — restoring the old tags reproduces ratio 4.92x and a red test.

The constant is documented as a ratchet: lower it as each remaining item in #254 lands.

BenchmarkCommitBatch reports peak-heap-MiB and peak/payload so a regression shows up as a number. There was no in-tree memory benchmark before this — the note in config.go:44-48 points at an external fanout-bench harness that is not in this checkout.

Compatibility

None needed. DuckDB reads either encoding, and parquet-go compares schema by node rather than by encoding, so existing files and in-flight merges are unaffected. Ingest files grow modestly; compaction re-encodes them anyway.

just check passes.

Parquet dictionary encoding keeps every distinct value of a column chunk
for the whole row group, and ingest flushes a row group only at
parquetRowGroupRows (50,000). On a column whose values repeat -- service
names, HTTP methods, scope identifiers, the resource block shared by every
span in a ResourceSpans -- that is a saving. On attributes_json,
events_json, links_json, exemplars_json and exception_message it is not:
those are near-unique per row by construction, so the dictionary is a
second full copy of the payload, held until the row group closes.

Measured against a 23.7 MiB synthetic batch of 50k spans, peak heap for
one CommitBatch: 4.09x-4.92x payload with dictionary encoding,
2.79x-3.49x without. Those columns are now written plain.

This does not make one commit cheap, it makes it cheaper. What remains
above 1x is the conversion copy in makeSpanParquetRow, roughly 24 MiB of
row-struct headers per 50k spans, and the writer's own buffers -- each
tracked separately in the ingest-memory issue.

Adds TestCommitBatchPeakHeapStaysNearPayloadSize, which samples HeapInuse
during a commit and fails above a ratio placed in the gap between the two
measured distributions, plus BenchmarkCommitBatch reporting peak heap so a
regression appears as a number rather than as a production OOM kill. There
was no in-tree memory benchmark before this.

No migration: DuckDB reads either encoding, and parquet-go compares schema
by node rather than by encoding, so existing files and merges are
unaffected. Ingest files grow modestly; compaction re-encodes them anyway.
@vishr
vishr merged commit 1cac1f4 into main Sep 20, 2026
8 checks passed
@vishr
vishr deleted the perf/drop-dict-on-near-unique-json branch September 20, 2026 18:13
vishr added a commit that referenced this pull request Sep 20, 2026
* test(telemetry): assert commit cost on allocation, not peak heap

The commit-memory gate added in #255 is flaky and its threshold does not
separate the two encodings. It failed CI at 3.83x against a 3.75 limit for
code that measured 2.79x-3.49x locally, turning main red.

Peak live heap was the wrong signal. It carries whatever slack the
collector happened to be holding when the sampler looked, which varies
with GOMAXPROCS and machine speed, and the two encodings' peak-heap
distributions overlap: dictionary encoding measured 3.21x-3.56x and plain
1.45x-2.52x once the sampling was averaged over several commits. A
threshold between those is a coin flip, and the 4.0 I first reached for
would have passed the very regression the test exists to catch.

Total bytes allocated does separate them, and barely varies: 10.68x-10.90x
with dictionary encoding against 4.40x-5.69x without. It also answers the
question the test is actually asking -- how many copies does one commit
make -- rather than how much the collector was holding at one instant. The
limit moves to 7.0, in the middle of that gap, and is verified in both
directions: restoring the dictionary tags fails it at 10.6x.

Peak heap is still measured and logged, because it is what the kernel
kills on, and the figure is now the minimum across three commits so the
log is comparable between runs.

* test(telemetry): exclude the commit-memory gate from race builds

The race detector instruments every allocation, which inflated total
allocation from roughly 5x payload to 33x on unchanged code and failed the
race target in CI. Peak heap is distorted the same way. These figures are
only meaningful in an uninstrumented build, so the file is excluded from
-race rather than given a second, meaningless threshold.
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