dotnet / dotnet/runtime

[API Proposal]: Expose HashLog and ChainLog on ZstandardCompressionOptions

Open
#129,214 6 comments 1 reaction 0 assignees View on GitHub
api-ready-for-review area-System.IO.Compression
Dominant language
C#
Stars
18.3k
Forks
5.6k
PR merge metrics
PR metrics pending

Description

## Background and motivation

`ZstandardEncoder.SetPrefix` / `ZstandardDecoder.SetPrefix` are the building blocks for delta compression ("patch-from", like `zstd --patch-from`): compressing a new version of a file against the previous one. Under the bt strategies (quality 16+), zstd only indexes the suffix `1 << MAX(hashLog+3, chainLog+1)` bytes of the prefix and silently drops the rest, so the prefix stops being referenced as it grows — with no error or diagnostic.

Self-delta probe (compress a buffer using **itself** as the prefix — an effective prefix must produce a near-zero output), .NET 11 preview 4, LDM on, windowLog covering prefix+input:

| prefix size | quality 15 | quality 19 | quality 19 + `hashLog` raised (native) |
|---|---|---|---|
| 32 MB | 0.011% | 0.009% | — |
| 48 MB | 0.011% | 33.4% | — |
| 64 MB | 0.011% | 51.0% | — |
| 96 MB | 0.011% | **100% (prefix ignored)** | — |
| 160 MB | 0.011% | **100% (prefix ignored)** | 0.008% (`hashLog=27`, 13 s vs 56 s default) |
| 700 MB | — | — | 0.008% (`hashLog=28`, 75 s) |
| 1280 MB | 0.011% | — | — |

(Full per-quality matrix in the comments below: qualities 0–15 are unaffected, 16–19 degrade from ~48 MB, 20–21 from ~96–160 MB; 22 is unaffected up to 160 MB — its larger default tables cover the prefix.)

Severity depends on the content (measurements in the comments): on whole-file version pairs the bt levels degrade gracefully despite the truncation — LDM-off controls show LDM is what rescues them — and the knob recovers the last ~11–16%. The failure is decisive where LDM does not rescue the matches in practice: the incompressible probe above (where LDM demonstrably fails to find even the whole-buffer match — mechanism unclear), and delta dictionaries dominated by sub-64-byte matches, below LDM's minimum match length, where raising `hashLog` took a production patch from ~11 MiB to ~1.7 MiB at the same level.

Decompression with `ZstandardDecoder.SetPrefix` round-trips correctly in all cases — the data is valid, just not delta-compressed. Setting `WindowLog = 31`, toggling `EnableLongDistanceMatching`, and calling/omitting `SetSourceLength` make no difference. The same sequence against vanilla libzstd 1.5.7 (`ZSTD_CCtx_refPrefix` + `ZSTD_compressStream2`) reproduces identical results, so the wrapper faithfully exposes upstream behavior; the native workaround is raising `ZSTD_c_hashLog`/`ZSTD_c_chainLog` (the zstd CLI ecosystem passes `--zstd=chainLog=30` together with `--patch-from` for the same reason).

`HashLog` is the knob that matters here, and it is dramatically cheaper than `ChainLog` (160 MB self-delta probe, quality 19, native libzstd 1.5.7):

| workaround | output / input | wall time | table memory |
|---|---|--:|--:|
| default cParams | 100% (prefix ignored) | 56 s | — |
| `ZSTD_c_chainLog = 30` | 0.008% | 115 s | ~4 GiB |
| `ZSTD_c_hashLog = 27` | 0.008% | **13 s** | **512 MiB** |

However, `ZstandardCompressionOptions` exposes only `Quality`, `WindowLog`, `Dictionary`, `TargetBlockSize`, `AppendChecksum` and `EnableLongDistanceMatching` — there is no way to set `hashLog`/`chainLog`, so the managed API has no equivalent. Real-data measurements (linux-6.12.92→93 tar pair and a production delta workload, in the comments below) show the bt levels + `hashLog` producing ~2× smaller deltas than the best achievable with quality ≤ 15.

As managed precedent: ZstdSharp exposes `ZSTD_c_hashLog` via `Compressor.SetParameter`, and our delta-update tooling ships on exactly this recipe today (`hashLog = clamp(log2ceil(prefixLength) - 3, 23, 30)` whenever a prefix > 32 MB is referenced); first-class prefix support was also just merged there (oleg-st/ZstdSharp#71).

Validation harness with all raw measurements: https://github.com/afernandes/zstd-prefix-validation.

## API Proposal

```diff
namespace System.IO.Compression;

public sealed partial class ZstandardCompressionOptions
{
+ /// Gets the minimum hash table size, expressed as base 2 logarithm.
+ public static int MinHashLog { get; } // ZSTD_HASHLOG_MIN (6)
+
+ /// Gets the maximum hash table size, expressed as base 2 logarithm.
+ public static int MaxHashLog { get; } // ZSTD_HASHLOG_MAX (30)
+
+ /// Gets the minimum match-search table size, expressed as base 2 logarithm.
+ public static int MinChainLog { get; } // ZSTD_CHAINLOG_MIN (6)
+
+ /// Gets the maximum match-search table size, expressed as base 2 logarithm.
+ public static int MaxChainLog { get; } // ZSTD_CHAINLOG_MAX (30 on 64-bit, 29 on 32-bit)
+
+ /// Gets or sets the size of the initial probe (hash) table, expressed as base 2 logarithm.
+ /// The hash table size as a power of 2. The valid range is from to
+ /// . Value 0 indicates the default derived from .
+ ///
+ /// Expert parameter; the default derived from is appropriate for most scenarios.
+ /// Resulting memory usage is 1 << (HashLog + 2) bytes. When compressing with
+ /// and a prefix larger than
+ /// ~32 MB at high quality levels, raise this value so the entire prefix is indexed
+ /// (the encoder indexes at most 1 << max(HashLog + 3, ChainLog + 1) bytes of the prefix).
+ ///
+ public int HashLog { get; set; }
+
+ /// Gets or sets the size of the multi-probe search (chain) table, expressed as base 2 logarithm.
+ /// The chain table size as a power of 2. The valid range is from to
+ /// . Value 0 indicates the default derived from .
+ ///
+ /// Expert parameter; the default derived from is appropriate for most scenarios.
+ /// Resulting memory usage is 1 << (ChainLog + 2) bytes. Larger tables result in better but
+ /// slower compression.
+ ///
+ public int ChainLog { get; set; }
}
```

Both properties follow the existing `WindowLog` pattern exactly: `0` means "derive from `Quality`" (matching the native *"Special: value 0 means use default"*), non-zero values are validated against the static bounds with `ArgumentOutOfRangeException`, and the encoder constructor forwards them via `ZSTD_CCtx_setParameter` — `ZSTD_c_hashLog = 102` / `ZSTD_c_chainLog = 103` are already present in the internal `ZstdCParameter` enum, so the implementation is a thin pass-through.

A `` note on `ZstandardEncoder.SetPrefix` documenting the indexing limit (and pointing to `HashLog`) would complete the fix.

## API Usage

```csharp
byte[] previousVersion = File.ReadAllBytes("app-v1.bin"); // e.g. 160 MB

var options = new ZstandardCompressionOptions
{
Quality = 19,
WindowLog = ComputeWindowLog(previousVersion.Length * 2L),
EnableLongDistanceMatching = true,
// index the entire prefix: 1 << (HashLog + 3) >= prefix length
HashLog = Math.Clamp(BitOperations.Log2((uint)previousVersion.Length) + 1 - 3,
ZstandardCompressionOptions.MinHashLog,
ZstandardCompressionOptions.MaxHashLog),
};

using var encoder = new ZstandardEncoder(options);
encoder.SetPrefix(previousVersion);
// Compress(newVersion, ...) now emits a true delta at any prefix size.
```

## Alternative Designs

- **Raise `ZSTD_c_hashLog` automatically when `SetPrefix` is called** — rejected in the discussion below: `hashLog`/`chainLog` persist across `Reset()` while the prefix does not, leaving the encoder in a surprising state after the first reset.
- **Prefix-scoped overload** (e.g. `SetPrefix(ReadOnlyMemory prefix, bool indexFully)`) that applies/undoes the parameter around the next frame — avoids the stickiness issue but adds hidden parameter mutation and doesn't compose with user-set values.
- **Use `Quality <= 15`** (interim workaround, works at any prefix size) — gives up the bt strategies' ratio: ~2× larger deltas measured on real version pairs (see comments).
- **Documentation only** — leaves the advertised scenario (`Quality` up to 22 + `SetPrefix`) without a managed path to full prefix indexing.

## Risks

Low. Both parameters are plain pass-throughs already clamped by libzstd; `0` preserves today's behavior exactly, so there is no change for existing callers. Compressed output bytes may differ when the knobs are set, but zstd output is already not guaranteed stable across versions — only the format is.

---

Original report (kept for context — the per-quality matrix and real-data measurements are in the comments)

### Reproduction

.NET 11.0.0-preview.4.26230.115, `net11.0`:

```csharp
using System.Buffers;
using System.IO.Compression;

foreach (var sizeMi in new[] { 32, 48, 64, 96, 160 })
{
var prefix = new byte[sizeMi << 20];
new Random(42).NextBytes(prefix);
var input = (byte[])prefix.Clone(); // identical content: an effective prefix => near-zero output

int windowLog = 10;
while (windowLog < 31 && (1L << windowLog) < 2L * prefix.Length) windowLog++;

using var encoder = new ZstandardEncoder(new ZstandardCompressionOptions
{
Quality = 19,
WindowLog = windowLog,
EnableLongDistanceMatching = true,
});
encoder.SetPrefix(prefix);

var dest = new byte[ZstandardEncoder.GetMaxCompressedLength(input.Length)];
var status = encoder.Compress(input, dest, out _, out var written, isFinalBlock: true);
if (status != OperationStatus.Done) throw new Exception(status.ToString());

Console.WriteLine($"{sizeMi,3} MB prefix -> {written * 100.0 / input.Length:F3}% of input");
}
```

Output observed:

```
32 MB prefix -> 0.009% of input
48 MB prefix -> 33.354% of input
64 MB prefix -> 50.973% of input
96 MB prefix -> 100.002% of input
160 MB prefix -> 100.002% of input
```

Expected: all sizes near 0% (the input is byte-identical to the prefix and the window covers prefix + input).

### Configuration

- .NET SDK 11.0.100-preview.4.26230.115, runtime 11.0.0-preview.4.26230.115
- Windows 11 Pro 10.0.26200, x64
- Bundled zstd: 1.5.7

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.