33zstd-java exposes two shapes of API:
44
55- ** ` byte[] ` ** — convenient, for callers whose data is already on the heap.
6- - ** ` MemorySegment ` ** — zero-copy, for callers whose data is already off-heap.
6+ - ** ` MemorySegment ` ** — zero-copy * at the call boundary* , for callers whose data
7+ is already off-heap.
78
8- This note explains why the segment shape exists and when it pays off.
9+ This note explains why the segment shape exists and when it pays off. For the
10+ recipes — sizing output, letting the codec allocate, ` ByteBuffer ` interop,
11+ streaming, and pledging the size — see the [ how-to guide] ( how-to.md ) .
12+
13+ ## What "zero-copy" means here
14+
15+ It means ** no copy at the Java↔native boundary** — the same sense as zero-copy
16+ I/O, where bytes still move but not redundantly between buffers. Compression
17+ itself always reads all input and writes all output; that is the work, not a
18+ copy. "Zero-copy" is about the * boundary* , and applies only to the
19+ ` MemorySegment ` path — the ` byte[] ` overloads copy twice (see the honest caveat).
920
1021## The core win: no copy at the call boundary
1122
@@ -14,20 +25,20 @@ the GC, so the FFM runtime copies it into native memory for the duration of the
1425call — and copies the result back. ** Two copies per call.**
1526
1627A native ` MemorySegment ` already * is* a native address. You hand
17- ` ZSTD_compress ` / ` ZSTD_decompress ` the pointer directly. ** Zero copies .**
28+ ` ZSTD_compress ` / ` ZSTD_decompress ` the pointer directly. ** No boundary copy .**
1829
1930``` text
2031byte[] path: heap byte[] ──copy──▶ native scratch ──ZSTD──▶ native scratch ──copy──▶ heap byte[]
21- segment path: native src ───────────────────────────ZSTD──▶ native dst (no copy)
32+ segment path: native src ───────────────────────────ZSTD──▶ native dst (no boundary copy)
2233```
2334
2435## When it actually pays off (not always)
2536
26- Zero-copy only helps if the data is ** already native** on both ends. The
27- canonical case is a memory-mapped reader (e.g. Vortex):
37+ This only helps if the data is ** already native** on both ends. The canonical
38+ case is a memory-mapped reader (e.g. Vortex):
2839
2940- ** Compressed input** — the reader ` mmap ` s the file into one ` MemorySegment ` ;
30- the zstd frame is already a zero-copy slice of it. A ` byte[] ` API forces
41+ the zstd frame is already a slice of it. A ` byte[] ` API forces
3142 ` frame.toArray() ` → ` new byte[] ` just to make the call. The segment API passes
3243 the mmap slice straight to ` ZSTD_decompress ` .
3344- ** Decompressed output** — allocate the output in your arena
@@ -36,7 +47,7 @@ canonical case is a memory-mapped reader (e.g. Vortex):
3647 ` MemorySegment.copy ` .
3748
3849The decode path collapses from ** mmap → byte[ ] → byte[ ] → arena** (three copies)
39- to ** mmap-slice → arena** (zero copies ).
50+ to ** mmap-slice → arena** (no boundary copy ).
4051
4152## Secondary wins
4253
@@ -52,155 +63,29 @@ to **mmap-slice → arena** (zero copies).
5263
5364If the caller hands you a heap ` byte[] ` (the aircompressor fallback path, or
5465external input), wrapping it with ` MemorySegment.ofArray(...) ` still triggers the
55- copy for the downcall — no free lunch. So the API is ** segment-first for the
56- zero-copy fast path, with a thin ` byte[] ` overload** for the rare heap caller.
57-
58- ## API map
59-
60- | Operation | byte[ ] (convenience) | MemorySegment (zero-copy) |
61- | ------------------------| -------------------------------------------------| ----------------------------------------------------|
62- | compress | ` ZstdCompressCtx.compress(byte[]) ` | ` ZstdCompressCtx.compress(dst, src) ` |
63- | compress + dict | ` ZstdCompressCtx.compress(byte[], ZstdCompressDict) ` | ` ZstdCompressCtx.compress(dst, src, ZstdCompressDict) ` |
64- | decompress | ` ZstdDecompressCtx.decompress(byte[], int) ` | ` ZstdDecompressCtx.decompress(dst, src) ` |
65- | decompress + dict | ` ZstdDecompressCtx.decompress(byte[], int, ZstdDecompressDict) ` | ` ZstdDecompressCtx.decompress(dst, src, ZstdDecompressDict) ` |
66- | size output (no copy) | frame header via ` Zstd.decompress(byte[]) ` | ` Zstd.decompressedSize(MemorySegment) ` |
67-
68- The explicit-` dst ` methods return the number of bytes written. Size ` dst ` with
69- ` Zstd.compressBound(srcSize) ` for compression, or ` Zstd.decompressedSize(frame) `
70- for decompression.
71-
72- ### Let the codec allocate
73-
74- If you don't want to size the destination yourself, pass an ` Arena ` and the codec
75- sizes, allocates, and writes the output for you — still zero-copy, since the
76- output is allocated in * your* arena and zstd writes into it directly. The
77- returned segment is owned by that arena.
78-
79- ``` java
80- MemorySegment frame = cctx. compress(arena, src); // bound-sized, trimmed to frame length
81- MemorySegment decoded = dctx. decompress(arena, frame); // header-sized, exact length
82- ```
83-
84- | Operation | explicit dst (you size) | arena (codec sizes) |
85- | -------------| -----------------------------------------------| --------------------------------------------|
86- | compress | ` compress(dst, src) ` → bytes written | ` compress(arena, src) ` → frame segment |
87- | decompress | ` decompress(dst, src) ` → bytes written | ` decompress(arena, frame) ` → output segment |
88-
89- The arena form of ` decompress ` requires the frame to store its decompressed size
90- (one-shot ` compress ` always stamps it; a * streamed* frame only does so when you
91- pledge the size up front — see [ Pledged size] ( #pledged-size-unlocks-zero-copy-decode ) ).
92- For size-less frames, size ` dst ` yourself.
93-
94- ## ByteBuffer interop
95-
96- Much of the Java ecosystem speaks ` ByteBuffer ` , not ` MemorySegment ` — NIO
97- channels, Netty, and ` FileChannel.map ` 's ` MappedByteBuffer ` . We deliberately do
98- ** not** add a third set of ` ByteBuffer ` overloads: the segment API already
99- bridges both directions of the FFM↔NIO boundary at zero copy, because FFM defines
100- the conversions.
101-
102- - ** ` ByteBuffer ` in** — wrap a * direct* buffer as a segment with
103- ` MemorySegment.ofBuffer(buf) ` (zero copy; a heap-backed buffer copies, the same
104- caveat as ` byte[] ` ). Hand the segment to ` compress ` / ` decompress ` .
105- - ** ` MemorySegment ` out to ` ByteBuffer ` ** — ` segment.asByteBuffer() ` returns a
106- buffer view over the native bytes, no copy. The decompressed arena segment is
107- consumable by an existing ` ByteBuffer ` pipeline as-is.
108-
109- ``` java
110- // an mmap'd frame is already a direct ByteBuffer (FileChannel.map)
111- MemorySegment frame = MemorySegment . ofBuffer(mappedByteBuffer);
112- MemorySegment out = dctx. decompress(arena, frame); // zero-copy decode
113- ByteBuffer result = out. asByteBuffer(); // zero-copy hand-off
114- ```
115-
116- ** Byte order.** ` asByteBuffer() ` on a * native* segment already returns a ** direct**
117- buffer aliasing the same off-heap bytes — there is no copy and nothing to convert.
118- The one wart is byte order: it comes back ` BIG_ENDIAN ` regardless of platform, so a
119- caller doing multi-byte reads must restore the native order:
120-
121- ``` java
122- import java.nio.ByteOrder ;
123-
124- ByteBuffer result = dctx. decompress(arena, frame)
125- .asByteBuffer()
126- .order(ByteOrder . nativeOrder()); // direct buffer, native order, zero copy
127- ```
128-
129- (For a pure byte payload the order does not matter and even that is unneeded.) The
130- remaining caveat is lifetime: the buffer borrows the arena's scope, so it must not
131- outlive the ` try ` -with-resources. A thin ` toByteBuffer() ` convenience on the
132- arena-returning results could fold the ` order(nativeOrder()) ` call in one place, but
133- it would be a one-line output adapter, not new capability — the conversion already
134- exists. We keep the API segment-first (no parallel ` ByteBuffer ` surface to maintain).
135-
136- ## Zero-copy streaming
137-
138- The one-shot segment methods above need the whole input in one segment. When data
139- is large or arrives incrementally but both ends are still off-heap, use the
140- segment ** stream driver** — ` ZstdCompressStream ` / ` ZstdDecompressStream ` — which
141- drives ` ZSTD_compressStream2 ` / ` ZSTD_decompressStream ` directly over native
142- buffers, in bounded memory, with no heap bounce (unlike ` ZstdOutputStream ` /
143- ` ZstdInputStream ` , which copy through ` byte[] ` to fit ` java.io ` ).
144-
145- Each step compresses/decompresses as much of ` src ` as fits in ` dst ` and reports a
146- ` ZstdStreamResult ` (` bytesConsumed ` , ` bytesProduced ` , ` remaining ` ). Advance the
147- source by ` bytesConsumed ` , drain ` bytesProduced ` from ` dst ` , and for compression
148- finish with ` ZstdEndDirective.END ` until ` isComplete() ` :
149-
150- ``` java
151- try (ZstdCompressStream cs = new ZstdCompressStream (level)) {
152- long off = 0 ;
153- ZstdStreamResult r;
154- do {
155- r = cs. compress(dst, src. asSlice(off), ZstdEndDirective . END );
156- off += r. bytesConsumed();
157- sink. write(dst. asSlice(0 , r. bytesProduced()));
158- } while (! r. isComplete());
159- }
160- ```
161-
162- Both drivers take an optional ` ZstdDictionary ` . Decompression mirrors the loop,
163- calling ` decompress(dst, src) ` until a result ` isComplete() ` (frame fully decoded).
164-
165- ## Pledged size unlocks zero-copy decode
166-
167- Streaming compression has a hidden cost the one-shot path does not: ** a streamed
168- frame does not record its decompressed size.** zstd writes the content-size field
169- in the frame header only when the encoder knows the total up front — trivially
170- true for ` ZSTD_compress ` , but a streaming encoder is fed incrementally and closes
171- the frame without ever being told the total.
172-
173- That field is exactly what the zero-copy decode path reads to size the output
174- arena. So a plain ` ZstdOutputStream ` frame ** cannot be decoded zero-copy** :
175-
176- ``` java
177- byte [] frame = streamCompress(data); // no pledged size
178- Zstd . decompressedSize(segmentOf(frame)); // throws: "decompressed size not stored in frame"
179- dctx. decompress(arena, segmentOf(frame)); // same — it can't size the arena
180- ```
181-
182- The consumer is forced back onto the bounded streaming decoder (allocate, decode a
183- chunk, grow, repeat) or a guessed ` maxSize ` — the very heap-bounce the segment API
184- exists to avoid.
185-
186- ` ZstdOutputStream.withPledgedSize(out, level, total) ` closes the loop. Tell the
187- encoder the total before the first byte and it stamps the content size into the
188- header, so a downstream reader can size the output arena exactly and decode in one
189- shot:
190-
191- ``` java
192- try (var zout = ZstdOutputStream . withPledgedSize(sink, 6 , data. length)) {
193- zout. write(data); // pledge must match the bytes written
194- }
195- byte [] frame = sink. toByteArray();
196-
197- // downstream, in a memory-mapped reader:
198- MemorySegment src = MemorySegment . ofBuffer(mmap);
199- MemorySegment out = dctx. decompress(arena, src); // one allocation, zero copies
200- ```
201-
202- This is the case where pledging is not a micro-optimization but a correctness
203- gate: it is the difference between a frame that participates in the zero-copy
204- decode path and one that does not. Pledge whenever the producer streams but the
205- total is known (file length, serialized record count, ` Content-Length ` ). The pledge
206- must equal the bytes actually written — a mismatch raises an error on close.
66+ copy for the downcall — no free lunch. A heap ` ByteBuffer ` is the same: its
67+ ` MemorySegment.ofBuffer(...) ` wrap is a heap segment and still copies. Only data
68+ that is * already native* avoids the boundary copy. So the API is ** segment-first
69+ for the zero-copy fast path, with a thin ` byte[] ` overload** for the rare heap
70+ caller.
71+
72+ We deliberately do ** not** add a parallel ` ByteBuffer ` API surface: FFM already
73+ defines the conversions (` MemorySegment.ofBuffer ` in, ` segment.asByteBuffer() `
74+ out), so a direct buffer reaches the same path with one wrapping call — see the
75+ [ how-to] ( how-to.md ) .
76+
77+ ## Why a streamed frame can't be decoded zero-copy
78+
79+ The zero-copy decode path reads the frame's ** decompressed-size** header field to
80+ size the output arena in one shot. zstd writes that field only when the encoder
81+ knows the total up front — trivially true for one-shot ` ZSTD_compress ` , but a
82+ * streaming* encoder is fed incrementally and closes the frame without ever being
83+ told the total. So a plain ` ZstdOutputStream ` frame omits the size, and a
84+ consumer is forced back onto the bounded streaming decoder (allocate, decode a
85+ chunk, grow, repeat) — the very heap-bounce the segment API exists to avoid.
86+
87+ The fix is to ** pledge the size** before the first byte, which stamps the content
88+ size into the header and lets a downstream reader size the arena exactly. This is
89+ not a micro-optimization but a correctness gate: it is the difference between a
90+ frame that participates in the zero-copy decode path and one that does not. The
91+ recipe is in the [ how-to] ( how-to.md#pledge-the-size-so-a-streamed-frame-decodes-in-one-shot ) .
0 commit comments