Skip to content

Commit 634719d

Browse files
dfa1claude
andcommitted
docs: split zero-copy into explanation (why) and how-to (recipes)
zero-copy.md was a hybrid: explanation mixed with recipes, which is what made the ByteBuffer content duplicate across docs. Per Diataxis, move every recipe (segment API map, codec-allocated output, ByteBuffer interop + byte-order mechanics, zero-copy streaming, pledged size) into how-to.md, and keep zero-copy.md as pure explanation (core win, when it pays, secondary wins, honest caveat, why a streamed frame can't decode zero-copy). Also qualify the claim: "zero-copy" means no copy at the Java<->native boundary (the MemorySegment path), not zero data movement. The byte[] path copies twice and a heap ByteBuffer copies too — stated explicitly via a new "What zero-copy means here" section, removing the bare-term overclaim. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 1d94dcf commit 634719d

2 files changed

Lines changed: 139 additions & 168 deletions

File tree

docs/how-to.md

Lines changed: 94 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -127,27 +127,113 @@ There are matching `compress(dst, src)` / `decompress(dst, src)` overloads (plus
127127
dictionary variants) returning the number of bytes written. For *why and when*
128128
this pays off, see the [explanation](zero-copy.md).
129129

130+
The segment-API map:
131+
132+
| Operation | byte[] (convenience) | MemorySegment (boundary zero-copy) |
133+
|------------------------|-------------------------------------------------|----------------------------------------------------|
134+
| compress | `ZstdCompressCtx.compress(byte[])` | `ZstdCompressCtx.compress(dst, src)` |
135+
| compress + dict | `ZstdCompressCtx.compress(byte[], ZstdCompressDict)` | `ZstdCompressCtx.compress(dst, src, ZstdCompressDict)` |
136+
| decompress | `ZstdDecompressCtx.decompress(byte[], int)` | `ZstdDecompressCtx.decompress(dst, src)` |
137+
| decompress + dict | `ZstdDecompressCtx.decompress(byte[], int, ZstdDecompressDict)` | `ZstdDecompressCtx.decompress(dst, src, ZstdDecompressDict)` |
138+
| size output (no copy) | frame header via `Zstd.decompress(byte[])` | `Zstd.decompressedSize(MemorySegment)` |
139+
140+
Size `dst` with `Zstd.compressBound(srcSize)` for compression, or
141+
`Zstd.decompressedSize(frame)` for decompression.
142+
143+
## Let the codec size and allocate the output
144+
145+
If you don't want to size `dst` yourself, pass an `Arena`: the codec sizes,
146+
allocates in *your* arena, and writes the output directly into it (still no
147+
boundary copy). The returned segment is owned by that arena.
148+
149+
```java
150+
MemorySegment frame = cctx.compress(arena, src); // bound-sized, trimmed to frame length
151+
MemorySegment decoded = dctx.decompress(arena, frame); // header-sized, exact length
152+
```
153+
154+
| Operation | explicit dst (you size) | arena (codec sizes) |
155+
|-------------|--------------------------------------|---------------------------------------------|
156+
| compress | `compress(dst, src)` → bytes written | `compress(arena, src)` → frame segment |
157+
| decompress | `decompress(dst, src)` → bytes written | `decompress(arena, frame)` → output segment |
158+
159+
The arena form of `decompress` needs the frame to store its decompressed size —
160+
one-shot `compress` always stamps it; a *streamed* frame only does if you pledge
161+
the size up front (see below). For size-less frames, size `dst` yourself.
162+
130163
## Compress a `ByteBuffer` (NIO / Netty) without copying
131164

132165
Much of the ecosystem speaks `ByteBuffer`. There is no separate `ByteBuffer` API —
133166
wrap the buffer as a `MemorySegment` with `MemorySegment.ofBuffer(...)` and use the
134-
segment overloads above. A **direct** buffer wraps with zero copy; a heap buffer is
135-
rejected by the native guard (wrap is a heap segment), so copy it to a direct buffer
136-
or a `byte[]` first.
167+
segment overloads above. A **direct** buffer wraps with no boundary copy; a heap
168+
buffer is rejected by the native guard (its wrap is a heap segment), so copy it to a
169+
direct buffer or a `byte[]` first.
137170

138171
```java
139172
try (Arena arena = Arena.ofConfined();
140173
ZstdCompressCtx cctx = new ZstdCompressCtx()) {
141174
ByteBuffer src = channel.map(READ_ONLY, 0, size, arena); // direct, off-heap
142-
MemorySegment in = MemorySegment.ofBuffer(src); // zero-copy view
175+
MemorySegment in = MemorySegment.ofBuffer(src); // covers [position, limit)
143176
MemorySegment out = cctx.compress(arena, in); // arena-owned frame
144-
ByteBuffer frame = out.asByteBuffer(); // zero-copy hand-off
177+
ByteBuffer frame = out.asByteBuffer(); // direct view, no copy
178+
}
179+
```
180+
181+
- `ofBuffer` covers the buffer's `[position, limit)`; a read-only buffer yields a
182+
read-only segment.
183+
- The wrapped segment borrows the buffer's lifetime — keep the buffer reachable
184+
while compressing.
185+
- `asByteBuffer()` on a native segment returns a **direct** buffer aliasing the same
186+
bytes, but always `BIG_ENDIAN`. For multi-byte reads, restore native order:
187+
`out.asByteBuffer().order(ByteOrder.nativeOrder())`. (Irrelevant for a pure byte
188+
payload.) That buffer also borrows the arena's scope — don't let it outlive the
189+
`try`.
190+
191+
## Stream zero-copy over native buffers
192+
193+
When data is large or arrives incrementally but both ends stay off-heap, use the
194+
segment stream drivers — `ZstdCompressStream` / `ZstdDecompressStream` — which drive
195+
`ZSTD_compressStream2` / `ZSTD_decompressStream` directly over native buffers in
196+
bounded memory, no heap bounce (unlike `ZstdOutputStream` / `ZstdInputStream`, which
197+
copy through `byte[]` to fit `java.io`).
198+
199+
Each step processes as much of `src` as fits in `dst` and reports a
200+
`ZstdStreamResult` (`bytesConsumed`, `bytesProduced`, `remaining`). Advance the
201+
source, drain `dst`, and for compression finish with `ZstdEndDirective.END` until
202+
`isComplete()`:
203+
204+
```java
205+
try (ZstdCompressStream cs = new ZstdCompressStream(level)) {
206+
long off = 0;
207+
ZstdStreamResult r;
208+
do {
209+
r = cs.compress(dst, src.asSlice(off), ZstdEndDirective.END);
210+
off += r.bytesConsumed();
211+
sink.write(dst.asSlice(0, r.bytesProduced()));
212+
} while (!r.isComplete());
213+
}
214+
```
215+
216+
Both drivers take an optional `ZstdDictionary`. Decompression mirrors the loop,
217+
calling `decompress(dst, src)` until a result `isComplete()`.
218+
219+
## Pledge the size so a streamed frame decodes in one shot
220+
221+
A streamed frame does **not** record its decompressed size, so it cannot be decoded
222+
zero-copy — `Zstd.decompressedSize(frame)` throws and `decompress(arena, frame)`
223+
can't size the arena (see [the explanation](zero-copy.md)). Tell the encoder the
224+
total up front and it stamps the content size into the header:
225+
226+
```java
227+
try (var zout = ZstdOutputStream.withPledgedSize(sink, 6, data.length)) {
228+
zout.write(data); // pledge must equal bytes written
145229
}
230+
MemorySegment src = MemorySegment.ofBuffer(mmap); // downstream, in a mapped reader
231+
MemorySegment out = dctx.decompress(arena, src); // one allocation, no boundary copy
146232
```
147233

148-
For the mechanics — `[position, limit)` coverage, read-only and lifetime rules,
149-
and the `asByteBuffer()` byte-order wart on the way back — see
150-
[zero-copy.md § ByteBuffer interop](zero-copy.md).
234+
Pledge whenever the producer streams but the total is known (file length, record
235+
count, `Content-Length`). A pledge that doesn't match the bytes written errors on
236+
close.
151237

152238
## Run against a self-built libzstd
153239

docs/zero-copy.md

Lines changed: 45 additions & 160 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,20 @@
33
zstd-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
1425
call — and copies the result back. **Two copies per call.**
1526

1627
A 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
2031
byte[] 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

3849
The 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

5364
If the caller hands you a heap `byte[]` (the aircompressor fallback path, or
5465
external 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

Comments
 (0)