perf(avro): reuse write plumbing in writeDatum — attribution + fix for #119 - #120
Merged
Merged
Conversation
…#119 Issue #119 measured the kindlings-derived AvroCodec encode route at 1.4-1.7x a hand-written direct-BinaryEncoder writer and 9-18% more allocated. An attribution decomposition (docs/research note) shows the time gap is tree build + GenericDatumWriter dispatch (kindlings' side), but most of the ALLOCATION gap was ours: writeDatum allocated a fresh ByteArrayOutputStream (32-byte start), GenericDatumWriter, and BufferedBinaryEncoder on every call — 2,928 B/op of churn on a 245 B record. writeDatum now writes through a per-thread DatumWriters (reused BAOS + re-bound BufferedBinaryEncoder + per-schema cached writers), mirroring the read side's DatumReaders/binaryDecoderCache. Byte output is unchanged; AvroWriteCorrectnessSpec pins retention under same-thread churn, recovery after an aborted write, and 8-thread isolation. AvroEncodeRouteBench (mapped into the bench CI) keeps the four routes permanently comparable; measured write-side cost drops to just the returned array (full route 3,456 -> 904 B/op). Also checked and refuted, in the research note, the bundled claim that generics lens[S] reorders multi-selector focus types — selector order is pinned by type ascriptions against published 0.14.0 and 0.17.0.
Contributor
|
🚀 Cloudflare Pages preview for https://1c8a1bd9.cats-eo-docs.pages.dev Branch alias: https://perf-avro-write-plumbing-119.cats-eo-docs.pages.dev Built from commit |
Contributor
Benchmark A/BAllocation (B/op) — authoritative
438 more benchmarks
Timing (ns/op) — directional only, same-VM but shared runner
New (head only, not diffed): base_sha: |
kryptt
added a commit
that referenced
this pull request
Sep 29, 2026
Minor release on the 0.x line. One change since 0.17.0: the #119 attribution and its fix — AvroBinaryCursor.writeDatum now writes through a per-thread reused buffer/encoder/writer set instead of rebuilding the plumbing per call (full derived-encode route 3,456 → 904 B/op on the attribution fixture, wire bytes unchanged). Non-breaking: every touched symbol is private[avro], so no mima.sbt entry and no recompile warning — 0.18.0 is a drop-in for 0.17.x. - build.sbt: tlBaseVersion 0.17 -> 0.18. The tag still derives the published version; this is what gates which tags the build accepts and what the snapshot version reads as. - CHANGELOG.md: `## [0.18.0] - 2026-09-29` cut out of Unreleased (the section was written at release time from the #120 attribution, not staged under Unreleased during the PR), link refs updated. Verified before tagging: `sbt githubWorkflowGenerate` leaves ci.yml byte-identical, scalafmtCheckAll + scalafmtSbtCheck + benchmarks/scalafmtCheck + githubWorkflowCheck all pass, and root-aggregate `sbt test` is green on JDK 25 (kyo in the aggregate; the merged #120 tree was swept in full earlier the same day).
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.
What
Addresses the allocation half of #119: the derived-Avro encode route allocated ~9–18% more than a hand-written direct-
BinaryEncoderwriter. Attribution (below) shows the time gap is tree build +GenericDatumWriterdispatch — kindlings'/avro's side — but most of the allocation gap was ours:AvroBinaryCursor.writeDatumallocated a freshByteArrayOutputStream(32-byte start, doubling), a freshGenericDatumWriter, and a freshBufferedBinaryEncoder(2 KB buffer) on every call.This PR makes
writeDatumwrite through a per-threadDatumWriters— reused buffer, re-boundBufferedBinaryEncoder, per-schema cached writers — the exact mirror of the read side's existingDatumReadersreader cache +binaryDecoderCache. Wire bytes are unchanged (the module's exact-bytes specs pin them).Attribution (issue-shaped reduced fixture: 15 fields, 12-field all-
Optionnested, 18-branch union, 245 B output; hand-written route byte-identical to eo's)encode(a)→Any(tree only)writeDatum)AvroCodec.encodeValue) pre-fixAfter: full eo route 3,456 → 904 B/op (−74%); write-side cost is now exactly the returned array.
Commits / files
avro/.../AvroBinaryCursor.scala—DatumWriters+writeDatumdelegation (all five write call sites funnel here).avro/.../AvroWriteCorrectnessSpec.scala— new pins: retention under 200 same-thread writes, clean recovery after an aborted write, 8-thread isolation vs golden bytes.benchmarks/.../AvroEncodeRouteBench.scala(+MODULE_BENCHESmapping) — permanent four-route attribution bench,-prof gc.docs/research/2026-09-29-issue-119-avro-encode-attribution.md— full write-up, incl. the answer to the streaming-encoder question and what remains on the kindlings side.Verification
sbt test(root aggregate): all suites green, incl. avro 237/237.-prof gc):eo_encodeValue568 B/op vsnaive_freshPlumbing2,624 B/op;handwritten_stream240 B/op.sbt avroIntegration/scalafmtCheck+python3 -m unittest discover .github/benchclean.Remaining gap (tree +
GenericDatumWriterdispatch) is the encode-side twin of #95: a derivedA => Encoder => Unitstreaming encoder lives in kindlings-avro-derivation (AvroEncoderisA => Any, so no eo-side shim can skip the datum). Tracked in the research note.Open in Delta