[improve][broker] Optimize TripleLongPriorityQueue heap operations - #26010
Conversation
lhotari
left a comment
There was a problem hiding this comment.
Didn't go through deeply, but one question about swapping.
One possible optimization for swapping would be to read 3 longs in one shot and just writing it to the other location in one shot, using a ByteBuf directly. For comparisons, reading and comparing the keys alone should be sufficient.
could you add the benchmarks to the |
|
I performed a local review with Claude Code and these were the findings:
|
I will make a new PR to implement this. |
|
@nodece please add the algorithm description to the PR description as well. |
Motivation
TripleLongPriorityQueueis the core data structure for Pulsar's delayed message delivery, storing(deliverAt, ledgerId, entryId)tuples in a min-heap backed bySegmentedLongArray(direct memory, 16MB segments). CPU profiling showssiftUp/siftDownconsuming ~37% of hot thread time during delayed message processing.The original implementation has two performance issues:
compare(tupleIdx1, tupleIdx2)reads 6 longs per comparison, but the current node's values are already known from the prior iteration — they're re-read fromSegmentedLongArrayunnecessarily.Each
readLongonSegmentedLongArrayincurs 3 layers of indirection (division +ArrayList.get()+ByteBuf.getLong()), so reducing readLong calls directly translates to throughput gains.Modifications
Rewrote
siftUpandsiftDownusing the hole-based algorithm with register-cached values:siftUp(tupleIdx, n1, n2, n3): Passes the inserted element's values as parameters. Each layer only reads the parent's 3 longs (was: 6 longs for self + parent), then writes the parent down. The displaced value lives in local variables and is written once at the final position.siftDown(tupleIdx, val0, val1, val2): Pop reads the last tuple and passes it directly. Each layer reads left + right child's 3 longs each (was: 6 longs for self + left + right), promotes the smaller child, and writes the displaced value at the leaf.add(): Removed redundant pre-write —siftUpwrites the tuple at its final position.pop(): Reads last tuple, passes tosiftDown(0, n1, n2, n3). Early return for single-element case.compare(),swap(),put()methods.Per-layer readLong reduction:
This is a 4x reduction in
SegmentedLongArrayreadLong calls per sift layer. Since eachreadLonginvolves 3 levels of indirection (segment lookup + ArrayList.get + ByteBuf.getLong), this reduces both instruction count and cache pressure.Benchmark Results
TestNG micro-benchmark (add-then-pop, single-threaded):
JMH benchmark (add-then-pop,
@Fork(2),@Warmup(3),@Measurement(5)):The add-then-pop pattern (bulk recovery scenario) shows significant improvement because the heap stays in CPU cache during the pop phase, and the reduced readLong calls directly translate to fewer cache line fetches.
JMH realistic workload benchmark (interleaved batch add/pop, simulating steady-state delayed delivery):
The interleaved pattern — where messages are added in small batches (~500) and popped in small batches, matching Pulsar's actual
getScheduledMessagesbehavior — shows roughly equivalent performance between old and new. This is expected: in the interleaved pattern, the heap is smaller and cache miss patterns are similar between both algorithms. The benchmark's data generation andBlackhole.consumeoverhead also dilute the algorithmic difference.The code simplification (removing
compare(),swap(),put()) is the primary benefit of this change, with performance improvement as a secondary benefit that manifests most clearly in bulk-operation scenarios.Further optimization
The remaining performance gap is dominated by
SegmentedLongArray's per-readLong indirection cost. ReplacingSegmentedLongArraywith a flatlong[]eliminates the segment lookup entirely — eachreadLongbecomes a single array load with hardware prefetcher support. Benchmark comparison showslong[]-backed implementation is ~3x faster thanSegmentedLongArrayat the same heap operations. This could be considered as a follow-up if the direct memory trade-off (GC pressure vs. off-heap) is acceptable for the delayed delivery use case.Testing
testCompareWithSamePrefix(validates correct ordering on tuples with shared prefixes).testDifferentialRandomPriorityQueue— randomized differential test againstjava.util.PriorityQueue<long[]>with 10 trials, ~15K interleaved add/pop per trial, 10% same-prefix tuples.microbenchmodule with three workload scenarios.