ManagedCompressions.Zstd
0.2.0
dotnet add package ManagedCompressions.Zstd --version 0.2.0
NuGet\Install-Package ManagedCompressions.Zstd -Version 0.2.0
<PackageReference Include="ManagedCompressions.Zstd" Version="0.2.0" />
<PackageVersion Include="ManagedCompressions.Zstd" Version="0.2.0" />
<PackageReference Include="ManagedCompressions.Zstd" />
paket add ManagedCompressions.Zstd --version 0.2.0
#r "nuget: ManagedCompressions.Zstd, 0.2.0"
#:package ManagedCompressions.Zstd@0.2.0
#addin nuget:?package=ManagedCompressions.Zstd&version=0.2.0
#tool nuget:?package=ManagedCompressions.Zstd&version=0.2.0
ManagedCompressions.Zstd
Zstandard (RFC 8878) compression and decompression for .NET, in fully managed C#.
- It contains no native code: no bundled library and no P/Invoke, so it runs wherever .NET runs, and it is compatible with trimming and Native AOT.
- It writes the same frames as libzstd. At every compression level, ManagedCompressions.Zstd writes exactly the bytes that libzstd 1.5.7 writes, with dictionaries, with long-distance matching and in multithreaded compression.
- It is fast. On arm64, decompression is 1.3–1.5× as fast as the libzstd that NativeCompressions and
.NET ship, and 1.11–1.21× as fast as libzstd built from source at
-O3on the Silesia corpus. On x64, where libzstd has a hand-written assembly Huffman decoder, it is 1.0–1.2× as fast as NativeCompressions' libzstd. Compression of large inputs is within a few percent of libzstd, except on x64 at levels 1 to 5, which are up to 13 % slower (see Performance). - A warm compressor or decompressor allocates nothing.
ManagedCompressions.Zstd is an independent implementation of the format. It is not affiliated with the zstd project or with Meta.
Requirements
ManagedCompressions.Zstd targets .NET 11 (net11.0). Its only dependency is System.IO.Hashing, for the
XXH64 frame checksums.
dotnet add package ManagedCompressions.Zstd
From source, reference src/ManagedCompressions.Zstd/ManagedCompressions.Zstd.csproj.
Usage
Decompression
using System.Buffers;
using ManagedCompressions.Zstd;
var decompressor = new ZstdDecompressor(); // reuse it: it keeps its tables and buffers between frames
if (ZstdDecompressor.TryGetFrameContentSize(frame, out ulong size))
{
byte[] content = new byte[size];
OperationStatus status = decompressor.Decompress(frame, content, out int consumed, out int written);
}
Decompress decodes one whole frame per call:
| status | meaning |
|---|---|
Done |
The frame was decoded. consumed is its size, so the next frame starts there. |
NeedMoreData |
The source ends inside the frame. |
DestinationTooSmall |
The content does not fit in the destination. |
InvalidData |
The source is not a valid frame. |
After NeedMoreData or DestinationTooSmall, nothing has been consumed, so call again with the whole
frame or a larger destination. Checksums are verified when a frame has one. A skippable frame decodes
to nothing.
Compression
var compressor = new ZstdCompressor(level: 3); // from ZstdCompressor.MinLevel (-131072) to MaxLevel (22)
byte[] frame = new byte[ZstdCompressor.GetMaxCompressedLength(content.Length)];
compressor.Compress(content, frame, out _, out int length); // the frame is frame[..length]
Compress writes one whole frame per call, and the frame always declares its content size. Set
AppendChecksum = true to end each frame with a checksum of its content. Level 0 selects
ZstdCompressor.DefaultLevel, which is 3.
Dictionaries
byte[] dictionary = File.ReadAllBytes("records.dict"); // trained by `zstd --train`, or any raw content
var compressor = new ZstdCompressor(level: 3, dictionary);
var decompressor = new ZstdDecompressor(dictionary);
The dictionary is prepared once, in the constructor, and then used for every frame.
ManagedCompressions.Zstd does not train dictionaries, so use zstd --train, or
ZstandardDictionary.Train from System.IO.Compression.
A decompressor can also take the dictionary with each frame, which lets one decompressor serve frames of any dictionary:
var decompressor = new ZstdDecompressor();
OperationStatus status = decompressor.Decompress(frame, content, dictionary, out int consumed, out int written);
The dictionary is copied for the call and not kept, but its tables are kept for the next calls that pass the same dictionary. From then on, a frame costs a copy of its dictionary and nothing more, as if the decompressor had been created with it. Another dictionary rebuilds the tables without allocating.
Parallel compression
int length = await compressor.CompressAsync(content, frame, maxDegreeOfParallelism: 8);
CompressAsync cuts the source into jobs and compresses them on the thread pool. It writes the same
frame as libzstd with worker threads (ZSTD_c_nbWorkers), so the frame does not depend on the degree
of parallelism. Up to 512 KiB, it writes the same frame as Compress.
Threading
Instances are not thread-safe. Use one per thread, or one per concurrent operation.
Compatibility with libzstd
Decompression follows libzstd's decoding and validation, so the two accept and reject the same frames. ManagedCompressions.Zstd also enforces RFC 8878's limit on the size of a block in every case, where libzstd only partly enforces it in one-shot decoding.
Compression reproduces libzstd 1.5.7's ZSTD_compress byte for byte, at every level from -131072 to
22. This covers:
- the nine strategies, from
fasttobtultra2 - the pre- and post-block splitters
- the long-distance matching that libzstd turns on at level 22 for sources over 64 MiB
With a dictionary, ManagedCompressions.Zstd writes the frames libzstd writes when the dictionary is
prepared at the same level (ZSTD_createCDict, then ZSTD_CCtx_refCDict), which is also how the
ZstandardDictionary of System.IO.Compression uses it. CompressAsync reproduces libzstd's
multithreaded compression (zstdmt), dictionaries and long-distance matching included.
The limitations are these:
- There is no streaming API. A frame is compressed or decompressed in one call, from and into memory.
- Sources and frames must fit in a span, so they are limited to 2 GiB.
- Compression takes a level, a dictionary and the checksum option. libzstd's advanced parameters, such as the window size or the strategy, are not exposed.
Performance
| speedup over libzstd 1.5.7 | ManagedCompressions.Zstd | .NET 11 System.IO.Compression |
|---|---|---|
| decompression, silesia.tar | 1.11–1.21× | 0.83–0.86× |
| decompression, small records | 1.17–1.37× | 0.91–0.94× |
| decompression, small records with a dictionary | 1.36–1.52× | 0.74–0.79× |
| compression, silesia.tar | 0.96–1.07× | 0.97–1.01× |
| compression, small records | 0.87–1.00× | 0.89–0.98× |
| compression, small records with a dictionary | 0.87–1.26× | 0.95–1.00× |
The ranges span compression levels 1 to 19. The reference here is libzstd built from source with Apple
clang 21 at -O3, which decompresses some 20 % faster than the libzstd NativeCompressions and .NET ship:
against those, decompression is 1.3–1.5× as fast on arm64. On x64 it is 1.0–1.2× as fast as
NativeCompressions' libzstd, as the repository's README shows.
The measurements use the data of zstd's own benchmarks, which bench/zstd-corpus.sh downloads:
- silesia.tar is the Silesia corpus, which the benchmarks in zstd's README use, taken as one tar of 211,948,032 bytes compressed into one frame.
- github is the 500 JSON records of GitHub users from zstd's regression tests, 407,963 bytes in all. Each record is its own frame, and all frames go through one compressor or decompressor.
- github + dict is the same records with
github.dict, the 110 KiB dictionary zstd's tests use with them.
Three implementations are compared:
- libzstd 1.5.7 compiled at
-O3with Apple clang 21, the reference ZstandardEncoderandZstandardDecoderfrom .NET 11, which wrap the libzstd the runtime ships- ManagedCompressions.Zstd, compiled with Native AOT
The machine is an Apple M4 Pro (arm64) running macOS 26.7, on one thread. The three implementations run in one process and take turns, and each figure is the median of its rounds. MB/s means millions of bytes of uncompressed data per second. At every level the three write identical frames, which the benchmark checks, so one compression ratio holds for all of them. Decompression decodes libzstd's frames at each level.
Compression ratio
| level | 1 | 3 | 5 | 7 | 9 | 13 | 16 | 19 |
|---|---|---|---|---|---|---|---|---|
| silesia.tar | 2.896 | 3.205 | 3.378 | 3.518 | 3.588 | 3.674 | 3.834 | 4.011 |
| github | 2.866 | 2.992 | 3.019 | 3.019 | 3.019 | 3.070 | 3.063 | 3.070 |
| github + dict | 9.886 | 9.922 | 10.527 | 10.524 | 10.344 | 10.225 | 10.764 | 10.760 |
Compression, MB/s
| corpus | implementation | 1 | 3 | 5 | 7 | 9 | 13 | 16 | 19 |
|---|---|---|---|---|---|---|---|---|---|
| silesia.tar | libzstd 1.5.7 (native) | 786 | 460 | 239 | 153 | 118 | 23.6 | 10.6 | 5.12 |
| .NET 11 (System.IO.Compression) | 774 | 460 | 241 | 154 | 117 | 23.3 | 10.5 | 4.97 | |
| ManagedCompressions.Zstd (Native AOT) | 773 | 467 | 237 | 149 | 113 | 25.2 | 10.9 | 5.09 | |
| github | libzstd 1.5.7 (native) | 323 | 306 | 212 | 203 | 155 | 39.9 | 15.4 | 12.6 |
| .NET 11 (System.IO.Compression) | 308 | 291 | 204 | 196 | 147 | 39.2 | 14.1 | 11.2 | |
| ManagedCompressions.Zstd (Native AOT) | 300 | 268 | 185 | 178 | 144 | 39.9 | 14.3 | 11.4 | |
| github + dict | libzstd 1.5.7 (native) | 1188 | 1134 | 444 | 232 | 145 | 70.3 | 6.19 | 6.27 |
| .NET 11 (System.IO.Compression) | 1149 | 1096 | 439 | 230 | 143 | 70.6 | 5.92 | 5.98 | |
| ManagedCompressions.Zstd (Native AOT) | 1133 | 1035 | 395 | 202 | 133 | 88.7 | 7.82 | 7.80 |
Compression, speedup over libzstd 1.5.7 (native)
| corpus | implementation | 1 | 3 | 5 | 7 | 9 | 13 | 16 | 19 |
|---|---|---|---|---|---|---|---|---|---|
| silesia.tar | .NET 11 (System.IO.Compression) | 0.99× | 1.00× | 1.01× | 1.01× | 1.00× | 0.99× | 0.99× | 0.97× |
| ManagedCompressions.Zstd (Native AOT) | 0.98× | 1.01× | 0.99× | 0.98× | 0.96× | 1.07× | 1.03× | 0.99× | |
| github | .NET 11 (System.IO.Compression) | 0.95× | 0.95× | 0.96× | 0.96× | 0.95× | 0.98× | 0.92× | 0.89× |
| ManagedCompressions.Zstd (Native AOT) | 0.93× | 0.88× | 0.87× | 0.88× | 0.93× | 1.00× | 0.93× | 0.90× | |
| github + dict | .NET 11 (System.IO.Compression) | 0.97× | 0.97× | 0.99× | 0.99× | 0.99× | 1.00× | 0.96× | 0.95× |
| ManagedCompressions.Zstd (Native AOT) | 0.95× | 0.91× | 0.89× | 0.87× | 0.92× | 1.26× | 1.26× | 1.25× |
Decompression, MB/s
| corpus | implementation | 1 | 3 | 5 | 7 | 9 | 13 | 16 | 19 |
|---|---|---|---|---|---|---|---|---|---|
| silesia.tar | libzstd 1.5.7 (native) | 2166 | 2022 | 2027 | 2190 | 2250 | 2332 | 2243 | 2100 |
| .NET 11 (System.IO.Compression) | 1873 | 1680 | 1675 | 1824 | 1874 | 1956 | 1871 | 1751 | |
| ManagedCompressions.Zstd (Native AOT) | 2415 | 2423 | 2446 | 2637 | 2668 | 2745 | 2584 | 2538 | |
| github | libzstd 1.5.7 (native) | 774 | 537 | 542 | 542 | 541 | 539 | 547 | 549 |
| .NET 11 (System.IO.Compression) | 706 | 503 | 510 | 510 | 507 | 506 | 511 | 514 | |
| ManagedCompressions.Zstd (Native AOT) | 1060 | 628 | 637 | 636 | 634 | 635 | 641 | 645 | |
| github + dict | libzstd 1.5.7 (native) | 3613 | 3743 | 3984 | 3207 | 3067 | 3258 | 3919 | 3908 |
| .NET 11 (System.IO.Compression) | 2781 | 2849 | 2961 | 2520 | 2431 | 2556 | 2946 | 2982 | |
| ManagedCompressions.Zstd (Native AOT) | 4993 | 5361 | 5820 | 4439 | 4210 | 4425 | 5913 | 5930 |
Decompression, speedup over libzstd 1.5.7 (native)
| corpus | implementation | 1 | 3 | 5 | 7 | 9 | 13 | 16 | 19 |
|---|---|---|---|---|---|---|---|---|---|
| silesia.tar | .NET 11 (System.IO.Compression) | 0.86× | 0.83× | 0.83× | 0.83× | 0.83× | 0.84× | 0.83× | 0.83× |
| ManagedCompressions.Zstd (Native AOT) | 1.11× | 1.20× | 1.21× | 1.20× | 1.19× | 1.18× | 1.15× | 1.21× | |
| github | .NET 11 (System.IO.Compression) | 0.91× | 0.94× | 0.94× | 0.94× | 0.94× | 0.94× | 0.93× | 0.94× |
| ManagedCompressions.Zstd (Native AOT) | 1.37× | 1.17× | 1.17× | 1.17× | 1.17× | 1.18× | 1.17× | 1.17× | |
| github + dict | .NET 11 (System.IO.Compression) | 0.77× | 0.76× | 0.74× | 0.79× | 0.79× | 0.78× | 0.75× | 0.76× |
| ManagedCompressions.Zstd (Native AOT) | 1.38× | 1.43× | 1.46× | 1.38× | 1.37× | 1.36× | 1.51× | 1.52× |
To reproduce the measurements (macOS, .NET 11 SDK):
tools/native-ref/build.sh && bench/zstd-corpus.sh
dotnet publish bench/ManagedCompressions.Zstd.Perf -c Release -o artifacts/perf
artifacts/perf/ManagedCompressions.Zstd.Perf --corpus silesia,github,github-dict --results corpus.tsv --markdown corpus.md
On Windows x64, from Git Bash, the reference is built with a GNU-compatible clang such as
llvm-mingw, because MSVC builds neither zstd's assembly
Huffman decoder nor its BMI2 dispatch, which libzstd has wherever a GNU-compatible compiler builds it.
--core N keeps the timed thread on one logical processor:
CC=<llvm-mingw>/bin/clang tools/native-ref/build.sh && bench/zstd-corpus.sh
dotnet publish bench/ManagedCompressions.Zstd.Perf -c Release -o artifacts/perf
artifacts/perf/ManagedCompressions.Zstd.Perf --core 2 --corpus silesia,github,github-dict --results corpus.tsv --markdown corpus.md
Testing
Correctness is checked against libzstd itself:
- Differential tests against the libzstd of .NET's
System.IO.Compression: ManagedCompressions.Zstd must give back the content libzstd decodes, and write libzstd's compressed frames byte for byte, with and without dictionaries. - zstd's golden files, for decompression, decompression errors and dictionaries.
- A decodecorpus corpus: random valid frames from zstd's own generator. They exercise the modes an encoder rarely emits, such as RLE and repeated tables, treeless literals and direct Huffman weights.
- Fuzzing, with buffers placed against guard pages.
- Multithreaded compression, checked against libzstd built with threads. These tests are skipped until
tools/native-ref/build.sh mthas built that library.
The golden files and the decodecorpus corpus are binary files that are generated rather than kept in
the repository. tools/native-ref/testdata.sh writes them from zstd 1.5.7's sources into
tests/ManagedCompressions.Zstd.Tests/testdata/. The tests that read them are skipped without them.
Building
tools/native-ref/testdata.sh
dotnet build -c Release
dotnet test
The SDK version is pinned in global.json. testdata.sh downloads the zstd 1.5.7 sources once,
checks their SHA-256, builds zstd's decodecorpus with the system C compiler and writes the test
data. It runs on macOS and Linux, and on Windows from Git Bash with a GNU-compatible clang as CC. The
native reference that tools/native-ref/build.sh builds is only needed for the multithreaded
compression tests and for the benchmarks: dylibs on macOS, DLLs on Windows (with the same CC), and
shared objects elsewhere.
Repository layout
| path | contents |
|---|---|
src/ManagedCompressions.Zstd |
the library |
tests/ManagedCompressions.Zstd.Tests |
differential tests, golden files, unit tests, fuzzing |
bench/ManagedCompressions.Zstd.Perf |
Native AOT benchmarks against libzstd and the platform, in turns (--corpus, --compress) |
bench/ManagedCompressions.Benchmarks |
BenchmarkDotNet benchmarks against NativeCompressions and .NET |
bench/zstd-corpus.sh |
downloads the benchmark data into tests/ManagedCompressions.Zstd.Tests/testdata/corpus |
tools/native-ref |
the native reference, libzstd 1.5.7 built from source with its timing harness (build.sh), and the test data's generator (testdata.sh) |
tests/ManagedCompressions.Zstd.Tests/testdata |
generated, not kept: zstd's golden files and the decodecorpus corpus (testdata.sh), and the benchmark data (zstd-corpus.sh) |
License
ManagedCompressions.Zstd is licensed under the MIT License. Its compressor is derived from libzstd 1.5.7, and that part remains under zstd's BSD license, as does the test data copied from the zstd repository. See NOTICE, which the package carries.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net11.0 is compatible. |
-
net11.0
- System.IO.Hashing (>= 10.0.12)
NuGet packages (4)
Showing the top 4 NuGet packages that depend on ManagedCompressions.Zstd:
| Package | Downloads |
|---|---|
|
Vorticity
Pure .NET implementation of the Vortex columnar file format, and Apache Parquet read and written by the same engine, with no third-party dependency. |
|
|
Vorticity.RowEncoding
Byte-sortable row encoding for Vortex arrays: columns to row keys whose memcmp order is tuple order. Upstream marks the format experimental and may change its byte layout between releases. |
|
|
Vorticity.Dataset
A versioned dataset over an object store: immutable data and commit objects, a prolly tree of leaves, and the store seam an S3 library implements. The format is experimental and this repository is the first to write it. |
|
|
Vorticity.ObjectStorage
Object stores for Vorticity's files and datasets on Amazon S3, Cloudflare R2, OVHcloud, Scaleway, Azure Blob Storage and Google Cloud Storage, over the platform's HTTP client and nothing else. |
GitHub repositories
This package is not used by any popular GitHub repositories.