Skip to content

perf: hot-path quick wins — hoist getSimpleName, batch scratch buffer + BitSet, drop KeyOrdered wrapper#203

Merged
eschizoid merged 1 commit into
mainfrom
perf/hot-path-quick-wins
Jun 20, 2026
Merged

perf: hot-path quick wins — hoist getSimpleName, batch scratch buffer + BitSet, drop KeyOrdered wrapper#203
eschizoid merged 1 commit into
mainfrom
perf/hot-path-quick-wins

Conversation

@eschizoid

Copy link
Copy Markdown
Owner

Summary

Three small, surgical allocation cuts on the per-record hot path. No behavior change; existing tests pass unmodified.

1. TypedPipelineBuilder.deserialize — hoist getSimpleName()

format.getClass().getSimpleName() was recomputed on every deserialize() call, but the value is only used inside the catch arm (diagnostic message wrap). Moved it to a final capture in build() so it's computed exactly once per built pipeline.

Allocation eliminated: one String plus a class-hierarchy walk per record on the success path.

2. BatchPipelineWrapper.flushLocked — scratch buffer reuse + BitSet

  • Replaced List.copyOf(buffer) + separate ArrayList<T> for values with one pre-sized ArrayList<>(buffer) snapshot and one pre-sized values list. The buffer field is now reused (ArrayList.clear() keeps the backing array), so steady-state flushes don't reallocate the buffer.
  • Replaced HashSet<Integer>(size * 2) for succeeded with BitSet(size) — constant-time set / get on primitive int indexes, zero Integer boxing on the walks.
  • Replaced ArrayList<Integer> missing with a count-first pass; only allocate the list on the (rare) coverage-violation path. Common case is allocation-free for the missing-index tracking.

Allocations eliminated per flush: the immutable List.copyOf array, the HashSet plus its boxed Integer entries, the always-allocated missing list, and the buffer's backing array on every steady-state flush.

3. KeyOrderedDispatcher.dispatch — drop the wrapper Runnable

The dispatcher used to wrap (processTask, onComplete) in a per-dispatch lambda that also captured pending and key. Replaced with a small QueuedTask record holding only (processTask, onComplete). drain() now handles pending.decrement + onComplete directly in its own try/finally, preserving the same throw-safety + drain-loop-survives-any-Throwable semantics as the old wrapper.

The dispatcher's public dispatch(record, processTask, onComplete) signature is unchanged — all existing dispatcher tests pass without modification.

Allocation eliminated: the wrapper Runnable (captures 4 things) becomes a 2-field record. Lower object header + capture overhead per dispatch.

Deferred for JMH-baselined PRs

Bigger wins surfaced by the same audit are deliberately not in this PR — they need before/after benchmark numbers, not just "looks faster":

  • OffsetManager: cache TopicPartition instances per (topic, partition) instead of allocating one per record's markOffsetProcessed / trackOffset.
  • MessageFormat SPI: add deserialize(byte[], int, int) so the skipBytes(n) path can skip the System.arraycopy on every record.

Test plan

  • ./gradlew :lib:kpipe-core:test — green (change 1 covered)
  • ./gradlew :lib:kpipe-consumer:test — green twice, 263 unit tests pass on both runs. Two @Testcontainers integration tests (ExternalOffsetIntegrationTest, KPipeProducerIntegrationTest) fail with DockerClientProviderStrategy errors — pre-existing failure on main in any environment without a running Docker daemon, unrelated to these changes.
  • All KeyOrderedDispatcherTest cases pass without modification (signature preserved).
  • All BatchPipelineWrapper*Test cases pass — coverage contract, concurrency, and out-of-range index handling all green.

… + BitSet, drop KeyOrdered wrapper

Three small, surgical allocation cuts on the per-record hot path. No
behavior change; existing tests pass unmodified.

1. TypedPipelineBuilder.deserialize: hoist `format.getClass().getSimpleName()`
   from per-record to build-time. The value is only used inside the catch
   arm (diagnostic message), but it was being recomputed on every successful
   deserialize.

2. BatchPipelineWrapper.flushLocked:
   - Replace `List.copyOf(buffer)` + separate values `ArrayList` with one
     pre-sized `ArrayList<>(buffer)` snapshot and one pre-sized values list.
     The buffer is now reused (`ArrayList.clear()` keeps the backing array)
     so steady-state flushes don't reallocate it.
   - Replace `HashSet<Integer>(size*2)` with `BitSet(size)` for tracking
     succeeded indexes — no boxing on the index walks.
   - Replace `ArrayList<Integer> missing` with a count-first pass; only
     allocate the list on the (rare) coverage-violation path. Common case
     is allocation-free.

3. KeyOrderedDispatcher.dispatch: replace the per-dispatch wrapper
   `Runnable` (capturing `processTask`, `onComplete`, `pending`, and `key`)
   with a small `QueuedTask` record holding only `(processTask, onComplete)`.
   `drain()` now handles `pending.decrement` + `onComplete` directly in its
   own try/finally, preserving the same throw-safety + drain-loop-survives-
   any-Throwable semantics as the old wrapper.

Bigger wins surfaced by the audit (OffsetManager TopicPartition caching,
MessageFormat `deserialize(byte[], int, int)` SPI extension to skip the
`System.arraycopy` on the skipBytes path) are deferred for JMH-baselined PRs.

Tests: kpipe-core and kpipe-consumer unit suites green on both runs. The
two failing tests (`ExternalOffsetIntegrationTest`, `KPipeProducerIntegrationTest`)
are Testcontainers-based and fail on main too due to Docker daemon being
unavailable in this environment — unrelated to these changes.
@eschizoid

Copy link
Copy Markdown
Owner Author

@copilot please review

@codecov

codecov Bot commented Jun 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.46154% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 77.80%. Comparing base (4ccac98) to head (6f618c5).
⚠️ Report is 5 commits behind head on main.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
...eschizoid/kpipe/consumer/BatchPipelineWrapper.java 80.00% 0 Missing and 3 partials ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##               main     #203      +/-   ##
============================================
- Coverage     77.82%   77.80%   -0.02%     
+ Complexity      732      724       -8     
============================================
  Files            68       66       -2     
  Lines          2800     2798       -2     
  Branches        346      351       +5     
============================================
- Hits           2179     2177       -2     
+ Misses          462      461       -1     
- Partials        159      160       +1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

@eschizoid eschizoid merged commit 79d9b13 into main Jun 20, 2026
4 checks passed
@eschizoid eschizoid deleted the perf/hot-path-quick-wins branch June 22, 2026 04:02
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