From a3a722aa6c71236b5f2a00b0accafc3d1c6d7ee8 Mon Sep 17 00:00:00 2001 From: Davide Angelocola Date: Fri, 24 Jul 2026 09:26:21 +0200 Subject: [PATCH] docs: zero-copy mmap-vs-jni story, JMH-backed Adds benchmark/.../LargeFileBenchmark, a JMH benchmark comparing this library's mmap + MemorySegment path (with/without posix_madvise(WILLNEED)) against zstd-jni's classic streaming API, at sizes from 4 MiB to 10 GiB - replacing the ad hoc System.nanoTime scratch harnesses that produced the previously-committed numbers in docs/zero-copy.md. Each @Benchmark invocation is one full-file compression, so this uses Mode.SingleShotTime (JMH times individual invocations) rather than the Throughput mode the other benchmarks use for tight loops. The source file for a given size is generated once and cached under the system temp directory, shared across all three variants' JMH forks rather than rewritten per variant. Fixes a real bug the switch to JMH surfaced: the mmap path was bouncing compressed output through a heap byte[] before handing it to the sink, defeating the zero-copy point. Now uses a direct ByteBuffer view over the native destination segment straight into a WritableByteChannel sink. Verified byte-for-byte round-trip against the independent zstd CLI. Also adds integration-tests/.../LargeMemoryMappedFileTest, a disabled-by- default test proving the library's >2 GiB memory-mapping capability claim: the classic ByteBuffer-based FileChannel.map overload rejects a file past Integer.MAX_VALUE, the long-indexed MemorySegment overload maps it in one call and addresses bytes past the boundary correctly, and the whole file round-trips through this library's zero-copy streaming compressor - plus a companion test showing zstd-jni's stream API can still compress a file this large (just not zero-copy map it) and that the frame it produces decodes correctly through this library. Rewrites docs/zero-copy.md's throughput section with the corrected, JMH-backed numbers (time + -prof gc allocation, full reference table with min-max ranges since JMH's 99.9% CI is unstable at n=3, machine specs, reproduce command). The corrected data confirms the shape of the earlier ad hoc finding - mmap ahead at medium sizes, the madvise hint mattering more at 10 GiB - but at roughly a tenth the magnitude the ad hoc timing loops reported, plus a previously-unseen small reversal at 2.25-4 GiB where the WILLNEED hint costs more than it saves. Adds an "Is the extra code worth it?" section arguing the zero-copy MemorySegment path is real but ~4x more code than the easy ZstdOutputStream API, and its throughput edge is only decisive at small files - at large scale it's a few percent either way, which is where the "no 2 GiB cap" capability claim (not throughput) becomes the real argument for the extra code. Independently reviewed: verified the mmap zero-copy fix round-trips byte-for-byte via the zstd CLI, verified (empirically, by reproducing the data generator and compressing both variants) that the benchmark's repeated-8-MiB-chunk source data doesn't inflate the measured compression ratio versus continuously-varying data, and fixed a real 3x-redundant-write inefficiency in the benchmark's own setup by caching the generated source file per size instead of regenerating it per variant. Closes #95. --- .../dfa1/zstd/bench/LargeFileBenchmark.java | 215 ++++++++++++++ docs/zero-copy.md | 202 ++++++++++++++ .../zstd/it/LargeMemoryMappedFileTest.java | 264 ++++++++++++++++++ 3 files changed, 681 insertions(+) create mode 100644 benchmark/src/main/java/io/github/dfa1/zstd/bench/LargeFileBenchmark.java create mode 100644 integration-tests/src/test/java/io/github/dfa1/zstd/it/LargeMemoryMappedFileTest.java diff --git a/benchmark/src/main/java/io/github/dfa1/zstd/bench/LargeFileBenchmark.java b/benchmark/src/main/java/io/github/dfa1/zstd/bench/LargeFileBenchmark.java new file mode 100644 index 0000000..f1f176e --- /dev/null +++ b/benchmark/src/main/java/io/github/dfa1/zstd/bench/LargeFileBenchmark.java @@ -0,0 +1,215 @@ +package io.github.dfa1.zstd.bench; + +import io.github.dfa1.zstd.ZstdCompressStream; +import io.github.dfa1.zstd.ZstdCompressionLevel; +import io.github.dfa1.zstd.ZstdEndDirective; +import io.github.dfa1.zstd.ZstdStreamResult; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Warmup; + +import java.io.BufferedInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.foreign.Arena; +import java.lang.foreign.FunctionDescriptor; +import java.lang.foreign.Linker; +import java.lang.foreign.MemorySegment; +import java.lang.foreign.SymbolLookup; +import java.lang.invoke.MethodHandle; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.concurrent.TimeUnit; + +import static java.lang.foreign.ValueLayout.ADDRESS; +import static java.lang.foreign.ValueLayout.JAVA_INT; +import static java.lang.foreign.ValueLayout.JAVA_LONG; + +/// Whole-file compression: this library's `mmap` + `MemorySegment` path +/// (with and without a `posix_madvise(WILLNEED)` readahead hint) against +/// zstd-jni's classic `ZstdOutputStream` over a buffered `FileInputStream` — +/// the comparison behind the numbers in `docs/zero-copy.md`. +/// +/// Sizes run from 4 MiB up to 10 GiB, so each `@Benchmark` invocation is one +/// full-file compression (seconds to tens of seconds), not a tight throughput +/// loop — [Mode#SingleShotTime] times individual invocations rather than +/// counting iterations in a fixed window. +/// +/// The source file is generated once per size and cached under the system +/// temp directory, reused across all three variants' trials rather than +/// regenerated per (variant, size) — each variant's JMH fork otherwise +/// rewrites an identical multi-gigabyte file for no reason. Only the timed +/// compress-and-write work is measured. Both the mmap path and zstd-jni's +/// stream path write their compressed output to a real file, matching real +/// usage rather than discarding output to a null sink. +@BenchmarkMode(Mode.SingleShotTime) +@OutputTimeUnit(TimeUnit.MILLISECONDS) +@State(Scope.Thread) +@Fork(value = 1, jvmArgsAppend = "--enable-native-access=ALL-UNNAMED") +@Warmup(iterations = 1) +@Measurement(iterations = 3) +public class LargeFileBenchmark { + + /// Chunk used both to buffer zstd-jni's read loop and to size the native + /// destination buffer the mmap path streams compressed output through. + private static final int CHUNK = 8 * 1024 * 1024; + + /// `POSIX_MADV_WILLNEED`, shared across the POSIX platforms this binds on + /// (macOS and Linux use the same value); unavailable on Windows. + private static final int POSIX_MADV_WILLNEED = 3; + + private static final MethodHandle POSIX_MADVISE = lookupPosixMadvise(); + + // 4 MiB, 64 MiB, 2.25 GiB, 4 GiB, 10 GiB — matches the sizes historically + // reported in docs/zero-copy.md so the JMH numbers replace them directly. + @Param({"4194304", "67108864", "2415919104", "4294967296", "10737418240"}) + private long size; + + @Param({"3"}) + private int level; + + private Path sourceFile; + private Path destFile; + + @Setup(Level.Trial) + public void setup() throws IOException { + sourceFile = cachedSourceFile(size); + destFile = Files.createTempFile("large-bench-dst", ".zst"); + } + + @TearDown(Level.Trial) + public void tearDown() throws IOException { + Files.deleteIfExists(destFile); + } + + @Benchmark + public long mmapNoAdvise() throws IOException { + return mmapCompress(false); + } + + @Benchmark + public long mmapWithAdvise() throws IOException { + return mmapCompress(true); + } + + @Benchmark + public long zstdJniStream() throws IOException { + try (InputStream in = new BufferedInputStream(Files.newInputStream(sourceFile), CHUNK); + com.github.luben.zstd.ZstdOutputStream out = + new com.github.luben.zstd.ZstdOutputStream(Files.newOutputStream(destFile), level)) { + byte[] buffer = new byte[CHUNK]; + long total = 0; + int n; + while ((n = in.read(buffer)) != -1) { + out.write(buffer, 0, n); + total += n; + } + return total; + } + } + + /// Maps `sourceFile`, optionally hints the OS with `posix_madvise`, and + /// streams the compressed frame out through a direct `ByteBuffer` view of + /// the native destination buffer — no heap `byte[]` bounce on the way out. + private long mmapCompress(boolean advise) throws IOException { + try (FileChannel in = FileChannel.open(sourceFile, StandardOpenOption.READ); + FileChannel out = FileChannel.open(destFile, StandardOpenOption.WRITE, + StandardOpenOption.TRUNCATE_EXISTING); + Arena arena = Arena.ofConfined(); + ZstdCompressStream cs = new ZstdCompressStream(new ZstdCompressionLevel(level))) { + MemorySegment source = in.map(FileChannel.MapMode.READ_ONLY, 0, size, arena); + if (advise) { + adviseWillNeed(source); + } + + MemorySegment dst = arena.allocate(CHUNK); + ByteBuffer dstView = dst.asByteBuffer(); + long srcOff = 0; + long total = source.byteSize(); + ZstdStreamResult r; + do { + MemorySegment srcSlice = source.asSlice(srcOff, total - srcOff); + r = cs.compress(dst, srcSlice, ZstdEndDirective.END); + srcOff += r.bytesConsumed(); + dstView.clear().limit((int) r.bytesProduced()); + while (dstView.hasRemaining()) { + out.write(dstView); + } + } while (!r.isComplete()); + return srcOff; + } + } + + /// Hints the OS that `mapped` will be read sequentially and in full, right + /// after mapping — a no-op if `posix_madvise` is unavailable (e.g. Windows, + /// where there is no direct equivalent). + private static void adviseWillNeed(MemorySegment mapped) { + if (POSIX_MADVISE == null) { + return; + } + try { + var _ = (int) POSIX_MADVISE.invokeExact(mapped, mapped.byteSize(), POSIX_MADV_WILLNEED); + } catch (Throwable t) { + throw new RuntimeException("posix_madvise failed", t); + } + } + + @SuppressWarnings("restricted") // downcallHandle needs it; posix_madvise's signature is fixed and safe + private static MethodHandle lookupPosixMadvise() { + Linker linker = Linker.nativeLinker(); + SymbolLookup stdlib = linker.defaultLookup(); + return stdlib.find("posix_madvise") + .map(addr -> linker.downcallHandle(addr, + FunctionDescriptor.of(JAVA_INT, ADDRESS, JAVA_LONG, JAVA_INT))) + .orElse(null); + } + + /// A source file of exactly `size` bytes, generated once and cached under + /// the system temp directory keyed by size, rather than regenerated for + /// every trial — `mmapNoAdvise`, `mmapWithAdvise`, and `zstdJniStream` each + /// run in their own JMH fork per size, so without this a fresh 10 GiB file + /// would be written three times over for that one size point alone. Left + /// on disk afterward (like the rest of this benchmark's temp files) for + /// later trials/runs to reuse rather than deleted per trial; a mismatched + /// size (e.g. a stale file from an interrupted prior run) forces a rewrite. + private static Path cachedSourceFile(long size) throws IOException { + Path file = Path.of(System.getProperty("java.io.tmpdir"), "large-bench-src-" + size + ".bin"); + if (!Files.exists(file) || Files.size(file) != size) { + writeCompressibleFile(file, size); + } + return file; + } + + /// Writes `size` bytes of realistic, compressible content to `file`: one + /// [BenchData] chunk generated once, then repeated — cheap to write at + /// multi-gigabyte scale, and no less representative than regenerating it, + /// since [BenchData#generate] is itself a repeating pseudo-random pattern. + private static void writeCompressibleFile(Path file, long size) throws IOException { + byte[] chunk = BenchData.generate(CHUNK); + ByteBuffer view = ByteBuffer.wrap(chunk); + try (FileChannel channel = FileChannel.open(file, StandardOpenOption.WRITE, + StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)) { + long written = 0; + while (written < size) { + int len = (int) Math.min(chunk.length, size - written); + view.clear().limit(len); + while (view.hasRemaining()) { + written += channel.write(view); + } + } + } + } +} diff --git a/docs/zero-copy.md b/docs/zero-copy.md index c641433..3a1d84f 100644 --- a/docs/zero-copy.md +++ b/docs/zero-copy.md @@ -59,6 +59,208 @@ to **mmap-slice → arena** (no boundary copy). - **Typed reads** — read `JAVA_LONG` / `JAVA_DOUBLE` straight off the decompressed segment with no re-wrap. +## The 2 GiB cap: capability vs. throughput + +The "no 2 GiB cap" line above is a **capability** claim, and it is unconditional: +`FileChannel.map(mode, pos, size, Arena)` is `long`-indexed and maps a file of +any size in one call, on every OS this library supports. The reference +`zstd-jni` binding cannot do this at all — its entire zero-copy surface is +`java.nio.ByteBuffer`-based, and the classic 3-arg `FileChannel.map(mode, pos, +size)` it depends on throws `IllegalArgumentException` past +`Integer.MAX_VALUE`. There is no workaround inside zstd-jni; the caller would +have to hand-roll mapping the file in sub-2 GiB windows. This is proven by a +runnable test — see [below](#proof-largememorymappedfiletest). + +Whether that mapping is also **faster** than zstd-jni's fallback (a classic +`ZstdOutputStream` over a buffered `FileInputStream`, copying through a heap +`byte[]`) is a separate question. Unlike the capability claim, this one needs +a real measurement — see [`LargeFileBenchmark`](#proof-largefilebenchmark), +a JMH benchmark (not a timing loop) comparing three paths at zstd's default +level (3), single-threaded, on the same synthetic, ~3x-compressible file: +this library's `mmap` + `MemorySegment` path, that same path with a +`posix_madvise(WILLNEED)` readahead hint right after mapping, and zstd-jni's +classic stream. + +| size | mmap (no hint) | mmap + `WILLNEED` | zstd-jni | +|---|---|---|---| +| 4 MiB | 5.2 ms `[4.8–5.7]` | 5.2 ms `[4.7–5.8]` | 10.0 ms `[8.3–12.0]` | +| 64 MiB | 81.8 ms `[81.4–82.5]` | 76.8 ms `[76.6–77.1]` | 81.7 ms `[80.6–83.5]` | +| 2.25 GiB | 2843 ms `[2840–2845]` | 2875 ms `[2872–2880]` | 2895 ms `[2887–2906]` | +| 4 GiB | 5042 ms `[5022–5056]` | 5124 ms `[5120–5130]` | 5160 ms `[5140–5173]` | +| 10 GiB | 13631 ms `[13605–13673]` | 12897 ms `[12880–12914]` | 13094 ms `[13027–13153]` | + +(mean `[min–max]` across `n=3` measurement iterations after 1 warmup, single +fork — see [reproduce](#reproduce-it) for what that buys and doesn't. JMH also +reports a 99.9% confidence interval per cell; at `n=3` — 2 degrees of freedom — +that interval is wide enough to make every row above look like a tie on paper, +even where the raw min–max ranges below don't overlap at all. The ranges are +the more honest read of this data, so that's what's tabulated.) + +Reading the ranges size by size, a real (if modest) pattern emerges — the +`WILLNEED` hint's payoff **flips sign with size**, not something the earlier +ad hoc numbers showed: + +- **4 MiB**: both mmap variants clearly beat zstd-jni (ranges don't overlap — + every mmap iteration faster than every zstd-jni one), by close to 2x. The + hint makes no difference between the two mmap variants — expected, since a + 4 MiB file just written is already fully page-cached, so there's no + readahead to hint about. This is **fixed per-call overhead**, not + throughput: opening a JNI stream and its buffers costs more than an FFM + downcall plus an `mmap()` syscall, and at 4 MiB that fixed cost is most of + the total. +- **64 MiB**: `WILLNEED` is the clear winner here (ranges separated from both + others, ~6% faster than zstd-jni); unadvised mmap and zstd-jni are + genuinely tied (ranges overlap: 81.4–82.5 vs. 80.6–83.5). +- **2.25 GiB and 4 GiB**: unadvised mmap is fastest at both (ranges separated + from both others, ~2% ahead of zstd-jni); `WILLNEED` is *slower than + unadvised mmap* at these two sizes, though still ahead of zstd-jni by a + smaller margin (~0.7%). The hint has a real cost — the `posix_madvise` call + itself, plus whatever readahead it triggers — that isn't worth paying yet + at these sizes, where compression compute still dominates over I/O. +- **10 GiB**: the pattern flips. `WILLNEED` becomes the fastest of the three + (~1.5% ahead of zstd-jni, ranges separated). Unadvised mmap becomes the + *slowest* (~4% behind zstd-jni, ranges separated from both). This is the + same direction as the original ad hoc finding — mmap loses ground at very + large scale unless hinted — just far smaller in magnitude: **4–5% swings + here, not the 24–45% the ad hoc timing loops reported**. + +That correction in magnitude is the headline finding, not the direction: the +previous version of this page, measured with `System.nanoTime` timing loops +(no fork isolation, one sample per cell), reported mmap beating zstd-jni by +~23% at 2.25 GiB on macOS and losing to it by ~45% at 10 GiB without the +`madvise` hint. This JMH data confirms the *shape* of that story (mmap ahead +at medium sizes, behind at 10 GiB unless hinted) but not its *size* — the real +effect is an order of magnitude smaller than the ad hoc measurement suggested. +A single untreated timing sample is exactly what +[`Mode#SingleShotTime`](https://javadoc.io/doc/org.openjdk.jmh/jmh-core/latest/org/openjdk/jmh/annotations/Mode.html) +with warmup, repeated measurement, and a dedicated fork exists to correct for, +and this page no longer carries a claim it doesn't back with that. + +Where the two approaches do differ clearly is **allocation**, confirming the +"[zero GC](#secondary-wins)" claim quantitatively for the first time: at every +size, `-prof gc` shows the mmap paths allocating on the order of 10–60 KB per +run (JVM/JMH/Arena bookkeeping, not data), while zstd-jni's stream path +allocates a constant ~8.1 MB per run regardless of file size — the reused +read buffer and `BufferedInputStream`'s internal buffer, once per invocation. +Neither figure scales with file size, since both reuse their buffers across +the read loop, but the mmap path's is roughly two orders of magnitude smaller. + +### The `madvise` hint + +`FileChannel.map()` — both the `ByteBuffer` and `MemorySegment` overloads — +just calls `mmap()`/`MapViewOfFile` under the hood and nothing else. It never +tells the OS how the mapping will be accessed, so the kernel falls back to its +default readahead heuristic. For a large, purely-sequential scan (exactly what +a one-shot compress of an mmap'd file is), telling it explicitly — +`posix_madvise(addr, len, POSIX_MADV_WILLNEED)`, a raw FFM downcall to libc +the benchmark makes directly, the same mechanism this library already uses +for `libzstd` itself — turns out to help at some sizes and hurt at others, +per the ranges in the table above: a clear win at 64 MiB and at 10 GiB (~6% +and ~1.5–5% respectively, depending whether you compare it to unadvised mmap +or to zstd-jni), but a small, equally real *loss* relative to unadvised mmap +at 2.25 GiB and 4 GiB. A plausible read: the `madvise` syscall and whatever +readahead it triggers cost something up front, which pays for itself once I/O +is enough of the total to matter (small files, where every syscall counts +proportionally more; or 10 GiB, where the OS's default readahead heuristic +falls behind) but not in between, where compute still dominates and the hint +is close to pure overhead. This is a plausible mechanism, not a confirmed +one — it wasn't tested directly (e.g. isolating I/O with compression removed, +the way the very first version of this investigation did informally), so +treat it as the shape the data suggests, not a settled explanation. + +A prior ad hoc (non-JMH) pass on this same machine had suggested a much +larger effect at 10 GiB specifically (mmap losing to zstd-jni by ~45% +unadvised, beating it by ~24% advised); this benchmark reproduces the +*direction* but at roughly a tenth the magnitude, which is exactly why it +replaced the ad hoc numbers. + +This remains untested on Linux (same POSIX API, plausible either way) and has +no direct equivalent on Windows (the closest primitive, +`PrefetchVirtualMemory`, is structurally different and untried). This library +does not currently call `madvise` anywhere; doing so — and deciding whether it +belongs as an internal default, an opt-in flag, or just a documented recipe +for callers — is open follow-up work, not a shipped feature. Given the hint +helps at some sizes and costs at others on this data, "always call it" isn't +obviously the right default even if it were implemented. + +### Is the extra code worth it? + +The zero-copy path is real code, not a one-liner: [`LargeFileBenchmark`'s +mmap path](../benchmark/src/main/java/io/github/dfa1/zstd/bench/LargeFileBenchmark.java) +— open two `FileChannel`s, map the source, optionally advise it, allocate a +native destination buffer, wrap it in a `ByteBuffer` view, and drive +`ZstdCompressStream` in a loop draining that view into the output channel — +is roughly 4x the line count of the equivalent one-shot call through this +library's own [`ZstdOutputStream`](how-to.md) over `Files.newInputStream`/ +`newOutputStream`. That is the real trade-off, independent of zstd-jni: more +code, more to get wrong (buffer sizing, loop termination, resource cleanup +across four `try`-with-resources, getting the `madvise` hint's sign right for +your size), for a throughput edge that — per the data above — is decisive +only at small files (4 MiB), and elsewhere tops out at single-digit +percentages that depend on file size and whether you bothered with the hint. + +What doesn't shrink is the **capability** claim: past 2 GiB, `ZstdOutputStream` +over a classic `InputStream` still works today (zstd-jni's own fallback proves +that path scales fine), but zstd-jni's zero-copy `ByteBuffer` surface simply +cannot address the file at all, with no in-library workaround. So the honest +recommendation is size-dependent: for small files, or when you're already +holding native memory (an existing arena, an mmap'd reader), the segment path +pays for its complexity outright. For a one-shot large-file compress where you +don't otherwise need off-heap data, the throughput case for the extra code is +weak **on this single-machine, `n=3` data** — a few percent either way, not the +multiples the earlier ad hoc numbers implied — so `ZstdOutputStream`'s +simplicity is a reasonable default *for this data set*, not a settled +conclusion, unless you've measured your own workload and size and found +otherwise. The segment path's unconditional win is addressing past the 2 GiB +`ByteBuffer`/`byte[]` boundary at all, which `ZstdOutputStream` doesn't need to +do since it never holds more than a chunk in memory at once. + +### Proof: `LargeMemoryMappedFileTest` + +`integration-tests/.../LargeMemoryMappedFileTest` is a runnable, disabled-by- +default test that proves the *capability* claim: it writes a file just over +the `Integer.MAX_VALUE`-byte boundary, shows the classic `ByteBuffer` mapping +call rejects it, shows the FFM `MemorySegment` mapping call succeeds and +addresses bytes past the boundary correctly, and round-trips the whole file +through this library's zero-copy streaming compressor — plus a companion test +showing zstd-jni's stream API can still compress a file this large (just not +zero-copy map it) and that the frame it produces decodes correctly through +this library. It is gated behind a system property, since it writes several +gigabytes of real files to disk and is too slow/disk-hungry for a plain `mvn +test`/`mvn verify` or CI: + +``` +./mvnw -pl integration-tests test -Dzstd.test.large=true -Dtest=LargeMemoryMappedFileTest +``` + +### Proof: `LargeFileBenchmark` + +[`benchmark/.../LargeFileBenchmark`](../benchmark/src/main/java/io/github/dfa1/zstd/bench/LargeFileBenchmark.java) +is the JMH benchmark behind the throughput table above — `Mode.SingleShotTime` +(each `@Benchmark` invocation is one full-file compress, not a tight +throughput loop), one fork, one warmup iteration, three measurement +iterations, at `size ∈ {4 MiB, 64 MiB, 2.25 GiB, 4 GiB, 10 GiB}`. Like +`LargeMemoryMappedFileTest`, it writes real multi-gigabyte files to a temp +directory and is not part of `mvn test`/`mvn verify` or CI. + +#### Reproduce it + +``` +./mvnw -pl benchmark -am package -DskipTests +java -jar benchmark/target/benchmarks.jar LargeFileBenchmark -prof gc +``` + +The numbers above are from one run on one machine (Apple M5, 10 cores, 32 GiB +RAM, macOS 26.5.2, internal SSD, Zulu JDK 25.0.2) — `n=3` per size, not the +larger sample a load-bearing perf claim would usually want, because a proper +sweep at the 10 GiB end of this benchmark is itself a multi-hundred-gigabyte, +many-minute exercise per run. Treat the "statistical tie" calls above as +"no effect was distinguishable in this data," not "there is provably no +effect" — if throughput at your specific size and OS matters for a decision, +run it yourself, ideally with more iterations at the sizes you care about; +the reproduce command above is the whole ask. Only macOS has been measured; +Linux and Windows are open follow-up (same caveat as the `madvise` hint). + ## Honest caveat If the caller hands you a heap `byte[]` (the aircompressor fallback path, or diff --git a/integration-tests/src/test/java/io/github/dfa1/zstd/it/LargeMemoryMappedFileTest.java b/integration-tests/src/test/java/io/github/dfa1/zstd/it/LargeMemoryMappedFileTest.java new file mode 100644 index 0000000..e7668a9 --- /dev/null +++ b/integration-tests/src/test/java/io/github/dfa1/zstd/it/LargeMemoryMappedFileTest.java @@ -0,0 +1,264 @@ +package io.github.dfa1.zstd.it; + +import com.github.luben.zstd.ZstdOutputStream; +import io.github.dfa1.zstd.ZstdCompressStream; +import io.github.dfa1.zstd.ZstdCompressionLevel; +import io.github.dfa1.zstd.ZstdDecompressStream; +import io.github.dfa1.zstd.ZstdEndDirective; +import io.github.dfa1.zstd.ZstdStreamResult; +import org.assertj.core.api.ThrowableAssert.ThrowingCallable; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.junit.jupiter.api.io.TempDir; + +import java.io.BufferedInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.foreign.Arena; +import java.lang.foreign.MemorySegment; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; + +import static java.lang.foreign.ValueLayout.JAVA_BYTE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/// Proves the zero-copy claim from `docs/zero-copy.md` that the `MemorySegment` +/// API removes the 2 GiB cap that `byte[]` / `ByteBuffer` impose, and that this +/// project's segment-based streaming compressor round-trips a file larger than +/// [Integer#MAX_VALUE] bytes read straight off a single memory mapping — and +/// contrasts that with the reference `zstd-jni` binding, whose zero-copy surface +/// is entirely `ByteBuffer`-based and hits the same `int` cap with no way around +/// it (it falls back to the classic heap-copying stream API instead). +/// +/// This test creates a file just over 2 GiB on disk, memory-maps it, and streams +/// the whole thing through compress/decompress, so it is slow and disk-hungry — +/// far too heavy for a plain `mvn test`/`mvn verify` or CI. It is therefore gated +/// behind the `zstd.test.large` system property and skipped unless it is `true`. +/// +/// Run it explicitly with: +/// +/// ``` +/// ./mvnw -pl integration-tests test -Dzstd.test.large=true -Dtest=LargeMemoryMappedFileTest +/// ``` +@EnabledIfSystemProperty(named = "zstd.test.large", matches = "true") +class LargeMemoryMappedFileTest { + + /// A size strictly greater than [Integer#MAX_VALUE], so the `int`-indexed + /// [ByteBuffer] path cannot address it. About 2.25 GiB. + private static final long FILE_SIZE = (long) Integer.MAX_VALUE + 256L * 1024 * 1024; + + /// Chunk used both to write the file and to feed the streaming buffers. + private static final int CHUNK = 8 * 1024 * 1024; + + @TempDir + private static Path tempDir; + + private static Path file; + + /// Written once and shared by every test in this class — regenerating a + /// 2.25 GiB file per test would double the already-heavy I/O for no benefit, + /// since every test here only reads it. + @BeforeAll + static void writeSharedFile() throws IOException { + file = tempDir.resolve("large-2gib.bin"); + writeDeterministicFile(file); + } + + /// Deterministic content: every byte is a pure function of its absolute file + /// offset, so any offset (including past 2 GiB) can be regenerated for + /// verification without keeping a multi-gigabyte reference buffer around. + private static byte expectedByteAt(long offset) { + return (byte) ('a' + (offset % 8)); + } + + @Test + void segmentMappingBeatsByteBufferAndRoundTripsPast2Gib() throws IOException { + // Given the shared file, just over the Integer.MAX_VALUE byte boundary + try (FileChannel channel = FileChannel.open(file, StandardOpenOption.READ); + Arena arena = Arena.ofConfined()) { + + // When the classic int-indexed ByteBuffer overload is asked to map + // more than Integer.MAX_VALUE bytes + ThrowingCallable byteBufferMap = + () -> channel.map(FileChannel.MapMode.READ_ONLY, 0, FILE_SIZE); + + // Then it rejects the request outright + assertThatThrownBy(byteBufferMap).isInstanceOf(IllegalArgumentException.class); + + // When the long-indexed FFM overload maps the same range in one call + MemorySegment mapped = channel.map(FileChannel.MapMode.READ_ONLY, 0, FILE_SIZE, arena); + + // Then the whole file is addressable from a single segment + assertThat(mapped.byteSize()).isEqualTo(FILE_SIZE); + + // And reads at offsets past Integer.MAX_VALUE resolve correctly, + // proving addressing beyond the int boundary + long[] probes = { + (long) Integer.MAX_VALUE, + (long) Integer.MAX_VALUE + 1, + (long) Integer.MAX_VALUE + 123_456_789L, + FILE_SIZE - 1 + }; + for (long offset : probes) { + assertThat(mapped.get(JAVA_BYTE, offset)).isEqualTo(expectedByteAt(offset)); + } + + // When the mapped segment is streamed through the zero-copy compressor + byte[] frame = compressFromSegment(arena, mapped); + + // Then decompressing verifies every byte in place against the same + // offset -> byte function, without materializing 2.25 GiB anywhere + long produced = verifyDecompress(arena, frame); + assertThat(produced).isEqualTo(FILE_SIZE); + } + } + + @Test + void zstdJniCannotZeroCopyMapTheWholeFileButItsStreamApiStillCompressesIt() throws IOException { + // Given the shared file, and zstd-jni's zero-copy surface — which is + // entirely java.nio.ByteBuffer, backed by the same 3-arg FileChannel.map + try (FileChannel channel = FileChannel.open(file, StandardOpenOption.READ)) { + // When it is asked to map the whole file for a zero-copy compress call + ThrowingCallable byteBufferMap = + () -> channel.map(FileChannel.MapMode.READ_ONLY, 0, FILE_SIZE); + + // Then it hits the exact same int cap zstd-jni's ByteBuffer API cannot + // work around: there is no long-indexed mapping in zstd-jni to fall + // back to, unlike the FFM MemorySegment path above + assertThatThrownBy(byteBufferMap).isInstanceOf(IllegalArgumentException.class); + } + + // When zstd-jni instead falls back to its classic stream API, copying + // through a heap byte[] buffer rather than mapping the file at all + byte[] frame = compressWithZstdJni(file); + + // Then it does successfully compress the whole file, and the frame it + // produced decodes correctly through this library's own zero-copy + // streaming decompressor — proving real format interop at this size + try (Arena arena = Arena.ofConfined()) { + long produced = verifyDecompress(arena, frame); + assertThat(produced).isEqualTo(FILE_SIZE); + } + } + + /// Compresses `file` with the reference `zstd-jni` binding via its classic + /// [ZstdOutputStream], reading through a reused heap `byte[]` buffer — the + /// heap-copying path zstd-jni falls back to since it has no `long`-indexed + /// zero-copy alternative. + private static byte[] compressWithZstdJni(Path file) throws IOException { + ByteArrayOutputStream frame = new ByteArrayOutputStream(); + try (InputStream in = new BufferedInputStream(Files.newInputStream(file), CHUNK); + ZstdOutputStream out = new ZstdOutputStream(frame, 1)) { + byte[] buffer = new byte[CHUNK]; + int n; + while ((n = in.read(buffer)) != -1) { + out.write(buffer, 0, n); + } + } + return frame.toByteArray(); + } + + /// Writes [#FILE_SIZE] bytes to `file` in [#CHUNK]-sized batches. Each byte is + /// [#expectedByteAt(long)] of its absolute offset, so no random source and no + /// full-file buffer are needed. + private static void writeDeterministicFile(Path file) throws IOException { + byte[] chunk = new byte[CHUNK]; + try (FileChannel channel = FileChannel.open( + file, StandardOpenOption.CREATE, StandardOpenOption.WRITE)) { + long written = 0; + while (written < FILE_SIZE) { + int len = (int) Math.min(CHUNK, FILE_SIZE - written); + for (int i = 0; i < len; i++) { + chunk[i] = expectedByteAt(written + i); + } + ByteBuffer buffer = ByteBuffer.wrap(chunk, 0, len); + while (buffer.hasRemaining()) { + channel.write(buffer); + } + written += len; + } + } + } + + /// Streams the whole `source` segment through [ZstdCompressStream], always + /// under [ZstdEndDirective#END], slicing from a running offset to the end of + /// the segment each call and looping until the frame is complete — the same + /// shape as `ZstdSegmentStreamTest.Chunked`. `ZstdStreamBuffer#set` only + /// points a native struct at `src`; it never copies, so handing the entire + /// remaining (multi-gigabyte) slice in each call is free — consumption per + /// call is bounded by `dst`'s capacity, not by the slice length. The payload + /// is highly compressible, so the resulting frame is small enough to + /// accumulate in memory. + private static byte[] compressFromSegment(Arena arena, MemorySegment source) { + ByteArrayOutputStream frame = new ByteArrayOutputStream(); + MemorySegment dst = arena.allocate(CHUNK); + byte[] scratch = new byte[CHUNK]; + try (ZstdCompressStream cs = new ZstdCompressStream(new ZstdCompressionLevel(1))) { + long srcOff = 0; + long total = source.byteSize(); + ZstdStreamResult r; + do { + MemorySegment srcSlice = source.asSlice(srcOff, total - srcOff); + r = cs.compress(dst, srcSlice, ZstdEndDirective.END); + srcOff += r.bytesConsumed(); + drain(dst, r.bytesProduced(), scratch, frame); + } while (!r.isComplete()); + } + return frame.toByteArray(); + } + + /// Copies the first `produced` bytes of `dst` into `frame` via a reused + /// `scratch` array, avoiding a fresh allocation per streaming step. + private static void drain(MemorySegment dst, long produced, byte[] scratch, ByteArrayOutputStream frame) { + int n = (int) produced; + MemorySegment.copy(dst, JAVA_BYTE, 0, scratch, 0, n); + frame.write(scratch, 0, n); + } + + /// Decompresses `frame` through [ZstdDecompressStream], verifying each output + /// chunk in place against [#expectedByteAt(long)] using a running absolute + /// output offset, so the ~2.25 GiB decompressed stream is never held in full. + /// + /// @return the total number of decompressed bytes produced + private static long verifyDecompress(Arena arena, byte[] frame) { + MemorySegment src = arena.allocate(frame.length); + MemorySegment.copy(frame, 0, src, JAVA_BYTE, 0, frame.length); + MemorySegment dst = arena.allocate(CHUNK); + + long outOff = 0; + try (ZstdDecompressStream ds = new ZstdDecompressStream()) { + long srcOff = 0; + ZstdStreamResult r; + do { + MemorySegment srcSlice = src.asSlice(srcOff, frame.length - srcOff); + r = ds.decompress(dst, srcSlice); + srcOff += r.bytesConsumed(); + outOff += verifyChunk(dst, r.bytesProduced(), outOff); + } while (srcOff < frame.length || !r.isComplete()); + } + return outOff; + } + + /// Checks that `produced` bytes at the front of `dst` match the deterministic + /// content starting at absolute output offset `outOff`. + /// + /// @return `produced`, so callers can advance the running offset + private static long verifyChunk(MemorySegment dst, long produced, long outOff) { + for (long i = 0; i < produced; i++) { + byte actual = dst.get(JAVA_BYTE, i); + byte expected = expectedByteAt(outOff + i); + if (actual != expected) { + assertThat(actual) + .as("byte at output offset %d", outOff + i) + .isEqualTo(expected); + } + } + return produced; + } +}