Skip to content

Commit 61472d6

Browse files
dfa1claude
andcommitted
feat: expose NB_WORKERS parameter setter on streaming compressors (#82)
ZstdCompressStream and ZstdOutputStream now expose the same parameter(ZstdCompressParameter, int) surface as ZstdCompressContext, so NB_WORKERS (plus JOB_SIZE/OVERLAP_LOG) is reachable from streaming compression - previously MT was one-shot-only, even though unbounded streaming input is where it pays off most. Set right after construction, before the first compress()/write() call. Also renames Mt* identifiers to MultiThread* for consistency with ZstdMultithreadTest (MtCompressBenchmark -> MultiThreadCompressBenchmark, Smoke.mtRoundTrip -> multiThreadRoundTrip, and the new/existing zstd-jni interop test names). Tests: unit round-trips for both streaming classes under NB_WORKERS, plus a new zstd-jni interop test proving a Java MT ZstdOutputStream frame decodes in zstd-jni (the reverse direction was already covered). ADR 0015 updated to record the follow-up as resolved. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 25aba11 commit 61472d6

8 files changed

Lines changed: 111 additions & 15 deletions

File tree

.github/smoke/Smoke.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ public static void main(String[] args) throws IOException {
6868
compressContextAdvancedParameters();
6969
decompressContextAdvanced();
7070
prefixCompression();
71-
mtRoundTrip();
71+
multiThreadRoundTrip();
7272

7373
List<byte[]> samples = jsonSamples();
7474
ZstdDictionary dict = ZstdDictionary.train(samples, 8 * 1024);
@@ -258,7 +258,7 @@ private static void prefixCompression() {
258258
}
259259
}
260260

261-
private static void mtRoundTrip() {
261+
private static void multiThreadRoundTrip() {
262262
// Native worker-thread spawning is exactly the platform-specific
263263
// behavior this smoke suite exists for: pthreads on glibc/musl,
264264
// Win32 _beginthreadex on Windows. 2 MiB clears zstd's 512 KiB

adr/0015-enable-zstd-multithread.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,14 @@ compression is only for dedicated, caller-owned contexts —
8282
### Risks to manage
8383
- PR CI compiles all six classifiers but executes tests only on linux-x86_64;
8484
thread spawning on Windows/macOS/musl is proven by the release-smoke matrix
85-
(`Smoke.mtRoundTrip`), not by PR CI.
86-
- Streaming classes (`ZstdCompressStream`, `ZstdOutputStream`) have no
87-
parameter setter, so MT is one-shot-only today; tracked as a follow-up.
85+
(`Smoke.multiThreadRoundTrip`), not by PR CI.
86+
- ~~Streaming classes (`ZstdCompressStream`, `ZstdOutputStream`) have no
87+
parameter setter, so MT is one-shot-only today; tracked as a follow-up.~~
88+
Resolved by [issue #82](https://github.com/dfa1/zstd-java/issues/82): both
89+
classes now expose `parameter(ZstdCompressParameter, int)`, set once right
90+
after construction and before the first `compress`/`write` call — the same
91+
lifecycle constraint above applies, since the streams already own their
92+
context exclusively for their lifetime and free it on `close()`.
8893

8994
## Alternatives considered
9095

benchmark/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ Plus a multithreading suite (kept separate so the single-threaded numbers stay
2121
longitudinally comparable, and because MT is a no-op below zstd's 512 KiB job
2222
minimum):
2323

24-
- `MtCompressBenchmark` — 1 MiB / 64 MiB × `nbWorkers` 0 / 2 / 4 at level 3,
24+
- `MultiThreadCompressBenchmark` — 1 MiB / 64 MiB × `nbWorkers` 0 / 2 / 4 at level 3,
2525
zero-copy `MemorySegment` path only. The 1 MiB legs measure MT overhead on a
2626
single-job input (no speedup expected by design — at level 3 the default job
2727
size is ~8 MiB); the 64 MiB legs span multiple jobs and show the scaling.

benchmark/src/main/java/io/github/dfa1/zstd/bench/MtCompressBenchmark.java renamed to benchmark/src/main/java/io/github/dfa1/zstd/bench/MultiThreadCompressBenchmark.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
@Fork(value = 1, jvmArgsAppend = "--enable-native-access=ALL-UNNAMED")
3838
@Warmup(iterations = 3)
3939
@Measurement(iterations = 5)
40-
public class MtCompressBenchmark {
40+
public class MultiThreadCompressBenchmark {
4141

4242
@Param({"1048576", "67108864"})
4343
private int size;

integration-tests/src/test/java/io/github/dfa1/zstd/it/ZstdJniInteropTest.java

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -102,12 +102,12 @@ void jniStreamToJavaStream() throws Exception {
102102
class Multithreaded {
103103

104104
/// Above zstd's 512 KiB minimum job size, so workers actually engage.
105-
private static final int MT_PAYLOAD_SIZE = 2 * 1024 * 1024;
105+
private static final int MULTI_THREAD_PAYLOAD_SIZE = 2 * 1024 * 1024;
106106

107107
@ParameterizedTest
108108
@ValueSource(ints = {1, 2})
109-
void javaMtCompressJniDecompress(int nbWorkers) {
110-
byte[] data = compressible(new Random(0x5EED), MT_PAYLOAD_SIZE);
109+
void javaMultiThreadCompressJniDecompress(int nbWorkers) {
110+
byte[] data = compressible(new Random(0x5EED), MULTI_THREAD_PAYLOAD_SIZE);
111111

112112
byte[] frame;
113113
try (ZstdCompressContext ctx = new ZstdCompressContext()) {
@@ -118,8 +118,8 @@ void javaMtCompressJniDecompress(int nbWorkers) {
118118
}
119119

120120
@Test
121-
void jniMtCompressJavaDecompress() throws Exception {
122-
byte[] data = compressible(new Random(0xFEED), MT_PAYLOAD_SIZE);
121+
void jniMultiThreadCompressJavaDecompress() throws Exception {
122+
byte[] data = compressible(new Random(0xFEED), MULTI_THREAD_PAYLOAD_SIZE);
123123

124124
ByteArrayOutputStream sink = new ByteArrayOutputStream();
125125
try (var zout = new com.github.luben.zstd.ZstdOutputStream(sink)) {
@@ -128,6 +128,18 @@ void jniMtCompressJavaDecompress() throws Exception {
128128
}
129129
assertThat(Zstd.decompress(sink.toByteArray(), data.length)).isEqualTo(data);
130130
}
131+
132+
@Test
133+
void javaMultiThreadStreamCompressJniDecompress() throws Exception {
134+
byte[] data = compressible(new Random(0xC0FFEE), MULTI_THREAD_PAYLOAD_SIZE);
135+
136+
ByteArrayOutputStream sink = new ByteArrayOutputStream();
137+
try (ZstdOutputStream zout = new ZstdOutputStream(sink)) {
138+
zout.parameter(ZstdCompressParameter.NB_WORKERS, 2);
139+
zout.write(data);
140+
}
141+
assertThat(com.github.luben.zstd.Zstd.decompress(sink.toByteArray(), data.length)).isEqualTo(data);
142+
}
131143
}
132144

133145
@Nested

zstd/src/main/java/io/github/dfa1/zstd/ZstdCompressStream.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,21 @@ private void loadDictionary(ZstdDictionary dictionary) {
8282
}
8383
}
8484

85+
/// Sets an advanced compression parameter — e.g.
86+
/// [ZstdCompressParameter#NB_WORKERS] for multithreaded streaming
87+
/// compression. Set it right after construction, before the first
88+
/// [#compress] call; changing it mid-frame is not supported.
89+
///
90+
/// @param parameter the parameter to set
91+
/// @param value the value, validated natively against the parameter's bounds
92+
/// @return `this`, for chaining
93+
/// @throws ZstdException if the value is out of range for the parameter
94+
public ZstdCompressStream parameter(ZstdCompressParameter parameter, int value) {
95+
NativeCall.checkReturnValue(() -> (long) Bindings.CCTX_SET_PARAMETER.invokeExact(
96+
ptr(), parameter.value(), value));
97+
return this;
98+
}
99+
85100
/// Compresses as much of `src` as fits into `dst` in one step.
86101
///
87102
/// Advance the source by [ZstdStreamResult#bytesConsumed()] and write out

zstd/src/main/java/io/github/dfa1/zstd/ZstdOutputStream.java

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,8 @@
2727
/// Not thread-safe: confine an instance to a single thread.
2828
public final class ZstdOutputStream extends OutputStream {
2929

30-
// ZSTD_cParameter / ZSTD_EndDirective values from zstd.h — see
30+
// ZSTD_EndDirective values from zstd.h — see
3131
// https://facebook.github.io/zstd/doc/api_manual_latest.html
32-
private static final int ZSTD_C_COMPRESSION_LEVEL = 100;
3332
private static final int ZSTD_E_CONTINUE = 0;
3433
private static final int ZSTD_E_FLUSH = 1;
3534
private static final int ZSTD_E_END = 2;
@@ -105,7 +104,7 @@ public ZstdOutputStream(OutputStream out, int level, ZstdDictionary dictionary)
105104
c = NativeCall.createOrThrow("ZSTD_createCCtx", () -> (MemorySegment) Bindings.CREATE_CCTX.invokeExact());
106105
this.cctx = c;
107106
NativeCall.checkReturnValue(() -> (long) Bindings.CCTX_SET_PARAMETER.invokeExact(
108-
cctx, ZSTD_C_COMPRESSION_LEVEL, level));
107+
cctx, ZstdCompressParameter.COMPRESSION_LEVEL.value(), level));
109108
if (dictionary != null) {
110109
loadDictionary(dictionary);
111110
}
@@ -142,6 +141,21 @@ private void loadDictionary(ZstdDictionary dictionary) {
142141
}
143142
}
144143

144+
/// Sets an advanced compression parameter — e.g.
145+
/// [ZstdCompressParameter#NB_WORKERS] for multithreaded compression of a
146+
/// large or unbounded [#write] stream. Set it right after construction,
147+
/// before the first `write`; changing it mid-frame is not supported.
148+
///
149+
/// @param parameter the parameter to set
150+
/// @param value the value, validated natively against the parameter's bounds
151+
/// @return `this`, for chaining
152+
/// @throws ZstdException if the value is out of range for the parameter
153+
public ZstdOutputStream parameter(ZstdCompressParameter parameter, int value) {
154+
NativeCall.checkReturnValue(() -> (long) Bindings.CCTX_SET_PARAMETER.invokeExact(
155+
cctx, parameter.value(), value));
156+
return this;
157+
}
158+
145159
@Override
146160
public void write(int b) throws IOException {
147161
single[0] = (byte) b;

zstd/src/test/java/io/github/dfa1/zstd/ZstdMultithreadTest.java

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,14 @@
55
import org.junit.jupiter.params.ParameterizedTest;
66
import org.junit.jupiter.params.provider.ValueSource;
77

8+
import java.io.ByteArrayOutputStream;
9+
import java.io.IOException;
10+
import java.lang.foreign.Arena;
11+
import java.lang.foreign.MemorySegment;
812
import java.nio.charset.StandardCharsets;
913

14+
import static io.github.dfa1.zstd.ZstdTestSupport.bytesOf;
15+
import static io.github.dfa1.zstd.ZstdTestSupport.segmentOf;
1016
import static org.assertj.core.api.Assertions.assertThat;
1117

1218
/// Multithreaded compression via `NB_WORKERS`. zstd engages workers only when
@@ -77,6 +83,50 @@ void honorsJobSizeAndOverlapLog() {
7783
}
7884
}
7985

86+
@Nested
87+
class SegmentStreamRoundTrip {
88+
89+
@ParameterizedTest
90+
@ValueSource(ints = {1, 2})
91+
void roundTripsWithWorkers(int nbWorkers) {
92+
// Given a segment stream configured with worker threads
93+
byte[] frame;
94+
try (Arena arena = Arena.ofConfined();
95+
ZstdCompressStream sut = new ZstdCompressStream()) {
96+
sut.parameter(ZstdCompressParameter.NB_WORKERS, nbWorkers);
97+
98+
// When compressing a payload large enough to engage the workers
99+
MemorySegment src = segmentOf(arena, LARGE_PAYLOAD);
100+
MemorySegment dst = arena.allocate(Zstd.compressBound(LARGE_PAYLOAD.length));
101+
ZstdStreamResult r = sut.compress(dst, src, ZstdEndDirective.END);
102+
frame = bytesOf(dst, r.bytesProduced());
103+
}
104+
105+
// Then the frame decompresses back to the original
106+
assertThat(Zstd.decompress(frame)).isEqualTo(LARGE_PAYLOAD);
107+
}
108+
}
109+
110+
@Nested
111+
class OutputStreamRoundTrip {
112+
113+
@ParameterizedTest
114+
@ValueSource(ints = {1, 2})
115+
void roundTripsWithWorkers(int nbWorkers) throws IOException {
116+
// Given an output stream configured with worker threads
117+
ByteArrayOutputStream sink = new ByteArrayOutputStream();
118+
try (ZstdOutputStream sut = new ZstdOutputStream(sink)) {
119+
sut.parameter(ZstdCompressParameter.NB_WORKERS, nbWorkers);
120+
121+
// When writing a payload large enough to engage the workers
122+
sut.write(LARGE_PAYLOAD);
123+
}
124+
125+
// Then the frame decompresses back to the original
126+
assertThat(Zstd.decompress(sink.toByteArray(), LARGE_PAYLOAD.length)).isEqualTo(LARGE_PAYLOAD);
127+
}
128+
}
129+
80130
@Nested
81131
class Lifecycle {
82132

0 commit comments

Comments
 (0)