Skip to content

Commit 1961e4a

Browse files
dfa1claude
andcommitted
feat: COVER / fast-COVER dictionary training
ZstdDictionary.trainCover / trainFastCover auto-tune dictionary parameters for higher quality than the default trainFromBuffer (fast-COVER is the recommended optimiser — near-COVER quality, much faster). Optional compressionLevel overload optimises for a target level. - bind ZDICT_optimizeTrainFromBuffer_cover / _fastCover (params passed by pointer; a zeroed struct auto-tunes k/d/steps, single-threaded, target level) - 58/186 symbols bound Tests: fast-COVER dict round-trips and beats dictionaryless on a tiny record; COVER round-trips (trained on a subset to stay quick). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 3f4a13b commit 1961e4a

3 files changed

Lines changed: 140 additions & 0 deletions

File tree

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,16 @@ final class Bindings {
261261
static final MethodHandle ZDICT_GET_DICT_ID =
262262
NativeLibrary.lookup("ZDICT_getDictID", FunctionDescriptor.of(JAVA_INT, ADDRESS, JAVA_LONG));
263263

264+
// size_t ZDICT_optimizeTrainFromBuffer_cover(dictBuffer, dictCap, samples, sizes, nbSamples,
265+
// ZDICT_cover_params_t* parameters) [params auto-tuned in place]
266+
static final MethodHandle ZDICT_OPTIMIZE_COVER =
267+
NativeLibrary.lookup("ZDICT_optimizeTrainFromBuffer_cover",
268+
FunctionDescriptor.of(JAVA_LONG, ADDRESS, JAVA_LONG, ADDRESS, ADDRESS, JAVA_INT, ADDRESS));
269+
// size_t ZDICT_optimizeTrainFromBuffer_fastCover(..., ZDICT_fastCover_params_t* parameters)
270+
static final MethodHandle ZDICT_OPTIMIZE_FASTCOVER =
271+
NativeLibrary.lookup("ZDICT_optimizeTrainFromBuffer_fastCover",
272+
FunctionDescriptor.of(JAVA_LONG, ADDRESS, JAVA_LONG, ADDRESS, ADDRESS, JAVA_INT, ADDRESS));
273+
264274
private Bindings() {
265275
// no instances
266276
}

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

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22

33
import java.lang.foreign.Arena;
44
import java.lang.foreign.MemorySegment;
5+
import java.lang.invoke.MethodHandle;
56
import java.nio.charset.StandardCharsets;
67
import java.util.List;
78

89
import static java.lang.foreign.ValueLayout.JAVA_BYTE;
10+
import static java.lang.foreign.ValueLayout.JAVA_INT;
911
import static java.lang.foreign.ValueLayout.JAVA_LONG;
1012

1113
/// An immutable zstd dictionary — the feature that makes compressing many small,
@@ -86,6 +88,95 @@ public static ZstdDictionary train(List<byte[]> samples, int maxDictBytes) {
8688
}
8789
}
8890

91+
/// Trains a dictionary with the COVER algorithm, auto-tuning its parameters
92+
/// for the best dictionary it can find. Higher quality than {@link #train},
93+
/// but slower; for a faster near-equal result use {@link #trainFastCover}.
94+
///
95+
/// @param samples representative payloads to learn from
96+
/// @param maxDictBytes upper bound on the produced dictionary size
97+
/// @return the trained dictionary
98+
/// @throws ZstdException if training fails
99+
public static ZstdDictionary trainCover(List<byte[]> samples, int maxDictBytes) {
100+
return trainCover(samples, maxDictBytes, 0);
101+
}
102+
103+
/// Trains a COVER dictionary optimised for a specific compression level.
104+
///
105+
/// @param samples representative payloads to learn from
106+
/// @param maxDictBytes upper bound on the produced dictionary size
107+
/// @param compressionLevel the level the dictionary will be used at (0 = default)
108+
/// @return the trained dictionary
109+
/// @throws ZstdException if training fails
110+
public static ZstdDictionary trainCover(List<byte[]> samples, int maxDictBytes, int compressionLevel) {
111+
return optimize(samples, maxDictBytes, compressionLevel, false);
112+
}
113+
114+
/// Trains a dictionary with the fast COVER algorithm, auto-tuning its
115+
/// parameters. The recommended optimiser: nearly the quality of
116+
/// {@link #trainCover} at a fraction of the time.
117+
///
118+
/// @param samples representative payloads to learn from
119+
/// @param maxDictBytes upper bound on the produced dictionary size
120+
/// @return the trained dictionary
121+
/// @throws ZstdException if training fails
122+
public static ZstdDictionary trainFastCover(List<byte[]> samples, int maxDictBytes) {
123+
return trainFastCover(samples, maxDictBytes, 0);
124+
}
125+
126+
/// Trains a fast COVER dictionary optimised for a specific compression level.
127+
///
128+
/// @param samples representative payloads to learn from
129+
/// @param maxDictBytes upper bound on the produced dictionary size
130+
/// @param compressionLevel the level the dictionary will be used at (0 = default)
131+
/// @return the trained dictionary
132+
/// @throws ZstdException if training fails
133+
public static ZstdDictionary trainFastCover(List<byte[]> samples, int maxDictBytes, int compressionLevel) {
134+
return optimize(samples, maxDictBytes, compressionLevel, true);
135+
}
136+
137+
private static ZstdDictionary optimize(List<byte[]> samples, int maxDictBytes,
138+
int compressionLevel, boolean fast) {
139+
if (samples.isEmpty()) {
140+
throw new ZstdException("cannot train a dictionary from zero samples");
141+
}
142+
try (Arena arena = Arena.ofConfined()) {
143+
long total = 0;
144+
for (byte[] s : samples) {
145+
total += s.length;
146+
}
147+
MemorySegment flat = arena.allocate(Math.max(total, 1));
148+
MemorySegment sizes = arena.allocate(JAVA_LONG, samples.size());
149+
long offset = 0;
150+
for (int i = 0; i < samples.size(); i++) {
151+
byte[] s = samples.get(i);
152+
MemorySegment.copy(s, 0, flat, JAVA_BYTE, offset, s.length);
153+
sizes.setAtIndex(JAVA_LONG, i, s.length);
154+
offset += s.length;
155+
}
156+
// zeroed params (auto-tune k/d/steps); set single-threaded + target level.
157+
// fastCover: nbThreads@16, compressionLevel@44, size 56.
158+
// cover: nbThreads@12, compressionLevel@32, size 48.
159+
MemorySegment params = arena.allocate(fast ? 56 : 48);
160+
params.set(JAVA_INT, fast ? 16 : 12, 1);
161+
params.set(JAVA_INT, fast ? 44 : 32, compressionLevel);
162+
MethodHandle handle = fast ? Bindings.ZDICT_OPTIMIZE_FASTCOVER : Bindings.ZDICT_OPTIMIZE_COVER;
163+
MemorySegment dictBuf = arena.allocate(maxDictBytes);
164+
long produced;
165+
try {
166+
produced = (long) handle.invokeExact(
167+
dictBuf, (long) maxDictBytes, flat, sizes, samples.size(), params);
168+
} catch (Throwable t) {
169+
throw rethrow(t);
170+
}
171+
if (zdictIsError(produced)) {
172+
throw new ZstdException("dictionary training failed: " + zdictErrorName(produced));
173+
}
174+
byte[] out = new byte[Math.toIntExact(produced)];
175+
MemorySegment.copy(dictBuf, JAVA_BYTE, 0, out, 0, out.length);
176+
return new ZstdDictionary(out);
177+
}
178+
}
179+
89180
/// The dictionary id zstd stamps into frames compressed with this dictionary,
90181
/// or `0` for a raw/content-only dictionary with no header.
91182
///

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

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,45 @@ void trainDictionary() {
3030
sut = ZstdDictionary.train(samples, 16 * 1024);
3131
}
3232

33+
@Nested
34+
class CoverTraining {
35+
36+
@Test
37+
void fastCoverRoundTrips() {
38+
// Given a fast-COVER-trained dictionary
39+
ZstdDictionary dict = ZstdDictionary.trainFastCover(samples, 16 * 1024);
40+
assertThat(dict.size()).isGreaterThan(0);
41+
42+
// Then records round-trip and compress smaller than dictionaryless
43+
byte[] record = samples.get(321);
44+
byte[] plain;
45+
byte[] withDict;
46+
byte[] restored;
47+
try (ZstdCompressCtx cctx = new ZstdCompressCtx();
48+
ZstdDecompressCtx dctx = new ZstdDecompressCtx()) {
49+
plain = cctx.compress(record);
50+
withDict = cctx.compress(record, dict);
51+
restored = dctx.decompress(cctx.compress(record, dict), record.length, dict);
52+
}
53+
assertThat(withDict.length).isLessThan(plain.length);
54+
assertThat(restored).isEqualTo(record);
55+
}
56+
57+
@Test
58+
void coverRoundTrips() {
59+
// COVER is slower, so train on a subset to keep the test quick
60+
ZstdDictionary dict = ZstdDictionary.trainCover(samples.subList(0, 1000), 8 * 1024);
61+
assertThat(dict.size()).isGreaterThan(0);
62+
63+
byte[] record = samples.get(5);
64+
try (ZstdCompressCtx cctx = new ZstdCompressCtx();
65+
ZstdDecompressCtx dctx = new ZstdDecompressCtx()) {
66+
byte[] frame = cctx.compress(record, dict);
67+
assertThat(dctx.decompress(frame, record.length, dict)).isEqualTo(record);
68+
}
69+
}
70+
}
71+
3372
@Nested
3473
class Training {
3574

0 commit comments

Comments
 (0)