Skip to content

perf: P0 bench-harness baseline (csvzen-bench module) - #18

Open
guizmaii wants to merge 13 commits into
mainfrom
perf/p0-bench-harness
Open

perf: P0 bench-harness baseline (csvzen-bench module)#18
guizmaii wants to merge 13 commits into
mainfrom
perf/p0-bench-harness

Conversation

@guizmaii

@guizmaii guizmaii commented May 5, 2026

Copy link
Copy Markdown
Contributor

Summary

Re-opens #17 — that PR closed unexpectedly when a git lfs migrate import rewrote the entire reachable history (not just the unique branch commits). This branch is now properly rebased on the latest main (4c2d1fe), and the LFS migration is scoped via --exclude-ref=main so only the 13 unique commits got rewritten — main's commits keep their canonical SHAs.

This PR lands Phase 0 of the csvzen performance overhaul (six-phase design at docs/superpowers/specs/2026-05-03-csvzen-perf-design.md): a new private csvzen-bench sbt module with JMH benchmarks (4 schemas × 3 sizes × 2 sinks = 24 cells) plus a buffer-size sweep, against the current Writer-backed implementation. Baseline captured on a MacBook Pro M3 Pro / Corretto 25.0.2.

The implementation plan is at docs/superpowers/plans/2026-05-03-csvzen-perf-p0-bench-harness.md.

What lands

New module — modules/bench/:

  • csvzen-bench: private (publish / skip := true), enables JmhPlugin, depends on core. Bench JVM is JDK 21 (rest of build stays on JDK 17 until P5).
  • Schemas.scala: four 5-field flat case classes (Mixed, IntHeavy, DoubleHeavy, StringHeavy) deriving CsvRowEncoder. ASCII-only String generation.
  • BenchData.scala: deterministic seeded generators per schema (TDD).
  • NullOutputStream.scala: pos-tracking, no-syscall sink (TDD).
  • CsvWriteBench.scala: two JMH classes — CsvWriteBenchSmallMedium (Throughput, 1k + 100k rows) and CsvWriteBenchLarge (SingleShotTime, 10M rows). 24 @Benchmark methods.
  • BufferSweepBench.scala: 9-point @Param sweep (4 KB → 1 MB) on Mixed @ 100k, both sinks.
  • README.md: run commands, profiler notes, results layout.
  • results/p0-baseline/{csv-write.json, buffer-sweep.json, SUMMARY.md}: real baseline numbers from this hardware. JSONs are git-lfs tracked.

Core module change:

  • FieldEmitter.scala: adds private[csvzen] def flushCount: Long = 0L stub. P1 swaps in the real counter.

Repo-level change:

  • .gitattributes: new rule modules/bench/results/**/*.json filter=lfs diff=lfs merge=lfs -text. All future phase result JSONs land in LFS automatically.

Headline numbers (full breakdown in modules/bench/results/p0-baseline/SUMMARY.md)

Hardware: MacBook Pro M3 Pro (36 GB), Corretto 25.0.2.

  • DoubleHeavy is the worst offender. 52 MB/op alloc on medium/null (= 5 × Double.toString per row × 100K rows). On large/null (10M rows) that's 5.19 GB allocated to write a single CSV. P2 Schubfach has a clear target.
  • Mixed @ medium/null: 34 MB/op (mostly Instant.toString + Double.toString).
  • IntHeavy / StringHeavy are already lean on null sink (~106 KB/op for medium); P1's byte[] rewrite drops that to ~0.
  • Buffer sweep is dead flat (4 KB → 1 MB moves null-sink throughput by < 5 %). The current Writer/CharsetEncoder path is the bottleneck, not the buffer. P1's post-rewrite sweep is what actually picks the new default.

P1 / P2 / P3 targets (concrete, gated by these numbers)

  • P1 (byte[] rewrite) DoD: ≥ 2× throughput on IntHeavy/small/null (≥ 11.5K ops/s) and IntHeavy/medium/null (≥ 114 ops/s). gc.alloc.rate.norm must drop to ≈ 0 on IntHeavy/null and StringHeavy/null cells.
  • P2 (in-buffer Schubfach) DoD: DoubleHeavy/medium/null allocation drops by ≥ 80 % (52.0 MB → ≤ 10.4 MB), throughput ≥ 1.5× (38 → ≥ 57 ops/s).
  • P3 (SWAR + Vector API quoting scan) DoD: the new StringHeavy-long schema must beat its post-P2 baseline by ≥ 1.5×.

Plan-vs-implementation deviations

  1. Files.deleteIfExists discards Boolean. Plan's verbatim teardown bodies failed Scala 3.3.7's -Wnonunit-statement. Fixed via the existing codebase convention (val _ = Files.deleteIfExists(...)).
  2. scalafmt cosmetic reformats. Plan's vertical-aligned = style and private inline ordering get rewritten on compile.
  3. plugins.sbt re-padding. Adding the longer pl.project13.scala group triggered scalafmt to re-pad the existing 5 plugin lines.
  4. Large bench heap. Plan's -Xms4g -Xmx4g for CsvWriteBenchLarge OOM'd in setupTrial (4 × 10M-row vectors hit ~6 GB live). Bumped to 10g.
  5. Relative -rff paths. Plan's -rff modules/bench/... doesn't survive sbt's JMH fork (sub-process resolves relative paths against a different cwd). Used absolute paths.
  6. -prof stack returns NaN under JDK 25 + Vector API incubator — JMH's stack sampler doesn't sample cleanly. Not pursued; we rely on -prof gc for the regression gate.

Test plan

  • All 161 unit tests pass (sbt --client test)
  • sbt --client check is green (scalafix + scalafmt)
  • sbt --client "bench/Jmh/compile" succeeds — JMH annotation processor wires up cleanly
  • Smoke test intHeavy_small_null runs end-to-end and reports a number
  • Smoke test BufferSweepBench.writeNullSink with two bufSize params runs end-to-end
  • flushCount is reachable and starts at 0 test passes against Writer-backed implementation
  • Full P0 baseline JMH run (24 cells, 18:09, captured to csv-write.json)
  • Buffer sweep (18 cells, 18:26, captured to buffer-sweep.json)
  • SUMMARY.md written with hardware info, full numbers, per-phase target deltas

Reproduce locally

export JAVA_HOME="$HOME/.sdkman/candidates/java/current"   # JDK 25
export PATH="$JAVA_HOME/bin:$PATH"
git lfs pull                       # fetches the result JSONs (~312 KB)

sbt --client test                                            # 161 tests
sbt --client check                                           # format/lint
sbt --client "bench/Jmh/compile"                             # harness wires up
sbt --client "bench/Jmh/run -wi 1 -i 1 -f 1 -t 1 .*intHeavy_small_null.*"  # ~10s smoke

Next steps after merge

  1. P1 plan generated against this baseline (concrete ≥ 2× IntHeavy/null-sink and ≥ 80 % DoubleHeavy alloc drop targets).
  2. Future phases will use the same bench harness; .gitattributes already routes their result JSONs to LFS automatically.

guizmaii added 13 commits May 5, 2026 12:58
- docs/performance-notes.md: snapshot of what governs CSV-writer
  throughput on the JVM, used as the seed for the optimization plan
- docs/superpowers/specs/2026-05-03-csvzen-perf-design.md: design for
  the full csvzen 1.0 performance overhaul (six phases, P0-P5)
- docs/superpowers/plans/2026-05-03-csvzen-perf-p0-bench-harness.md:
  detailed implementation plan for Phase 0 (bench harness baseline)
The four 10M-row Vector[A] data fixtures live concurrently for the trial.
StringHeavy alone is ~3 GB; together they hit ~6 GB before GC headroom and
JIT overhead. The plan's -Xms4g -Xmx4g OOM'd in setupTrial.
Run on MacBook Pro M3 Pro (36 GB), Corretto 25.0.2, off the current
Writer-backed encoder. 24 main-bench cells + 18 buffer-sweep cells.

Headline:
- Mixed @ 100k null-sink: 33 ops/s, 34 MB/op alloc
- DoubleHeavy @ 100k null-sink: 38 ops/s, 52 MB/op alloc (per-row Double.toString
  allocations dominate - direct P2 Schubfach target)
- IntHeavy/StringHeavy @ 100k null-sink: 57/68 ops/s, ~106 KB/op
- Buffer sweep is essentially flat (4 KB to 1 MB moves null-sink by < 5 %),
  consistent with the perf doc's observation that the BufferedWriter buffer
  isn't the bottleneck - the Writer/CharsetEncoder path is. The post-P1
  buffer sweep on the byte[] path is what actually picks the new default.

See SUMMARY.md for full numbers and per-phase target deltas.
Copilot AI review requested due to automatic review settings May 5, 2026 03:01

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Adds a new private bench SBT module so csvzen can capture JMH baselines for the current Writer-backed implementation before later performance phases change runtime internals. The PR also introduces supporting benchmark fixtures/tests, stores a P0 baseline result set, and exposes a temporary flushCount stub on FieldEmitter for future instrumentation.

Changes:

  • Adds csvzen-bench with benchmark schemas, deterministic data generation, a null sink, the main 24-cell JMH suite, and a buffer-size sweep.
  • Extends the build with sbt-jmh, aggregates the new module, and adds a temporary FieldEmitter.flushCount surface plus a unit test.
  • Commits benchmark documentation and captured P0 baseline outputs/design docs for the planned multi-phase performance work.

Reviewed changes

Copilot reviewed 18 out of 19 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
project/plugins.sbt Adds sbt-jmh plugin.
build.sbt Adds and aggregates the new bench subproject.
.gitattributes Routes benchmark JSON outputs through Git LFS.
modules/core/src/main/scala/com/guizmaii/csvzen/core/FieldEmitter.scala Adds flushCount stub for future instrumentation.
modules/core/src/test/scala/com/guizmaii/csvzen/core/FieldEmitterSpec.scala Adds a surface test for flushCount.
modules/bench/src/main/scala/com/guizmaii/csvzen/bench/Schemas.scala Defines benchmark row schemas.
modules/bench/src/main/scala/com/guizmaii/csvzen/bench/BenchData.scala Adds deterministic benchmark data generators.
modules/bench/src/main/scala/com/guizmaii/csvzen/bench/NullOutputStream.scala Adds null sink for encoder-only measurements.
modules/bench/src/main/scala/com/guizmaii/csvzen/bench/CsvWriteBench.scala Adds the main 24-cell JMH benchmark suite.
modules/bench/src/main/scala/com/guizmaii/csvzen/bench/BufferSweepBench.scala Adds buffer-size sweep benchmarks.
modules/bench/src/test/scala/com/guizmaii/csvzen/bench/BenchDataSpec.scala Tests benchmark data determinism/basic properties.
modules/bench/src/test/scala/com/guizmaii/csvzen/bench/NullOutputStreamSpec.scala Tests null sink byte counting/reset behavior.
modules/bench/README.md Documents benchmark layout and run commands.
modules/bench/results/p0-baseline/SUMMARY.md Adds narrative summary of captured baseline results.
modules/bench/results/p0-baseline/csv-write.json Adds raw main-benchmark result artifact (LFS).
modules/bench/results/p0-baseline/buffer-sweep.json Adds raw sweep result artifact (LFS).
docs/superpowers/specs/2026-05-03-csvzen-perf-design.md Adds multi-phase performance design spec.
docs/superpowers/plans/2026-05-03-csvzen-perf-p0-bench-harness.md Adds detailed P0 implementation plan.
docs/performance-notes.md Adds background notes guiding optimization priorities.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

*/
@State(Scope.Benchmark)
@BenchmarkMode(Array(Mode.Throughput))
@OutputTimeUnit(TimeUnit.MILLISECONDS)
*/
@State(Scope.Benchmark)
@BenchmarkMode(Array(Mode.Throughput))
@OutputTimeUnit(TimeUnit.MILLISECONDS)
Comment thread modules/bench/README.md
Comment on lines +28 to +35
```bash
sbt --client "bench/Jmh/run -prof gc -prof stack -rf json -rff modules/bench/results/p0-baseline/csv-write.json com.guizmaii.csvzen.bench.CsvWriteBench.*"
```

### Buffer sweep (~10 minutes)

```bash
sbt --client "bench/Jmh/run -prof gc -rf json -rff modules/bench/results/p0-baseline/buffer-sweep.json com.guizmaii.csvzen.bench.BufferSweepBench.*"
Comment on lines +1117 to +1130
```bash
sbt --client "bench/Jmh/run -prof gc -prof stack -rf json -rff modules/bench/results/p0-baseline/csv-write.json com.guizmaii.csvzen.bench.CsvWriteBench.*"
```

Expected: completes in ~30 minutes (this is the full run — 24 benchmarks × 2 forks × (5 warmup + 10 measurement) iterations × 2s + the four large-size singleshot runs). On completion, sbt reports `[success]` and `csv-write.json` exists at the path passed to `-rff`.

If the run is interrupted, the JSON is *not* written. Restart from scratch — JMH does not resume.

- [ ] **Step 3: Run the buffer sweep and capture JSON**

Run:

```bash
sbt --client "bench/Jmh/run -prof gc -rf json -rff modules/bench/results/p0-baseline/buffer-sweep.json com.guizmaii.csvzen.bench.BufferSweepBench.*"
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