@@ -99,6 +99,39 @@ scale linearly with payload size.
9999 regardless of binding; the segment path additionally avoids the off-heap↔heap copy.
100100 aircompressor * compress* still allocates internal hash tables per call.
101101
102+ ## Where the work goes (async-profiler, 64 MiB)
103+
104+ Profiling the cache-busting 64 MiB case with async-profiler corroborates the
105+ allocation counters from two angles — allocation flamegraph (what hits the heap)
106+ and itimer/CPU flamegraph (what burns cycles).
107+
108+ ** Allocation flamegraph** — dominant heap-allocation site per benchmark:
109+
110+ | benchmark | dominant alloc site |
111+ | -----------| ---------------------|
112+ | Compress ` zstdJavaSegment ` | ** none — no heap allocation sampled** |
113+ | Compress ` zstdJavaBytes ` | ` byte[] ` in ` Zstd.copyOut ` (the returned frame array) |
114+ | Compress ` zstdJni ` | ` byte[] ` inside ` luben…Zstd.compress ` |
115+ | Compress ` aircompressor ` | ` byte[] ` + ` BlockCompressionState.<init> ` (internal tables) |
116+ | Decompress ` zstdJavaSegment ` | ** none — no heap allocation sampled** |
117+ | Decompress ` zstdJavaBytes ` / ` zstdJni ` | ` byte[] ` (the returned output array) |
118+
119+ ** CPU (itimer) flamegraph** — the heap paths additionally pay a memcpy bounce and
120+ GC work that the segment path does not:
121+
122+ - ` zstdJavaBytes ` : ` MemorySegment.copy ` → ` ScopedMemoryAccess.copyMemory ` →
123+ ` Unsafe.copyMemory ` (heap↔native in/out), ** plus** ` G1ParCopyClosure::do_oop_work `
124+ (GC triggered by the output allocation).
125+ - ` zstdJni ` : ` byte_disjoint_arraycopy ` + ` Arrays.copyOfRange ` (the JNI copy and the
126+ trim copy).
127+ - ` zstdJavaSegment ` : ** neither** — no copy frames, no GC frames. Only codec work
128+ (` ZSTD_* ` , ` FSE_buildCTable_wksp ` , ` encodeSequences ` , ` FSE_readNCount ` ).
129+
130+ So the segment API does strictly less work: no per-op heap allocation (hence no GC)
131+ and no memcpy bounce. At 64 MiB this overhead is a small fraction of total codec
132+ time — which is why throughput ties — but it is pure waste the zero-copy path
133+ removes entirely, and it dominates under sustained, allocation-sensitive load.
134+
102135## Reproduce
103136
104137``` bash
@@ -109,4 +142,11 @@ java -jar benchmark/target/benchmarks.jar -prof gc
109142
110143# single size
111144java -jar benchmark/target/benchmarks.jar -prof gc -p size=67108864
145+
146+ # async-profiler flamegraphs for the 64 MiB case (macOS: itimer; Linux: cpu)
147+ LIB=/opt/homebrew/lib/libasyncProfiler.dylib
148+ java -jar benchmark/target/benchmarks.jar -p size=67108864 \
149+ -prof " async:libPath=$LIB ;output=flamegraph;event=alloc;dir=benchmark/target/async-alloc"
150+ java -jar benchmark/target/benchmarks.jar -p size=67108864 \
151+ -prof " async:libPath=$LIB ;output=flamegraph;event=itimer;dir=benchmark/target/async-cpu"
112152```
0 commit comments