Skip to content

Parallel short-lived DeflateStream compressors: page-fault storms on .NET 10 (Windows x64), none on .NET 8 #134700

Open

Description

@pakkoapp-oss

Description

A ZIP writer that compresses many small files in parallel (one DeflateStream per entry) got
about 1.4x slower moving from .NET 8 to .NET 10 on Windows x64. The extra time is kernel time from
page faults around deflate state creation/destruction. Single-threaded compression is faster on
.NET 10.

Real workload (5,000 files of 1-10 KB, first operation in the process, Release):

.NET 8.0.31 .NET 10.0.12
wall time ~480 ms ~680 ms
page faults ~21-34k ~195-230k
kernel CPU ~1.0-1.4 s ~1.4-2.2 s

The gap narrows over later operations in the same process (~40k faults by the third). CPU sampling
(EventPipe) puts Deflater..ctor + ZLibStreamHandle.ReleaseHandle at 1,002 samples on .NET 10
vs 133 on .NET 8 for the same work. Managed allocations are identical on both runtimes; turning
off tiered compilation or PGO does not change the gap; opting into SegmentHeap makes it worse on
both.

.NET 10 vendors zlib-ng 2.2.5, whose alloc_deflate requests the whole deflate state in one
zalloc (~330 KB at windowBits 15 / memLevel 8). .NET 8 on Windows x64 used zlib-intel, which
made five separate, smaller ZALLOC calls. Both go through the same private heap
(HeapCreate(0, 0, 0) in zlib_allocator_win.c). Our guess, not verified: the heap decommits the
freed ~330 KB block and the next compressor faults it back in.

Reproduction

using System.Diagnostics;
using System.IO.Compression;
using System.Runtime.InteropServices;

var data = new byte[5000];
new Random(1).NextBytes(data.AsSpan(0, 2500)); // semi-compressible
const int N = 20000;
void One()
{
    using var ms = new MemoryStream();
    using (var ds = new DeflateStream(ms, CompressionLevel.Optimal, leaveOpen: true)) ds.Write(data);
}
for (int i = 0; i < 2000; i++) One();
foreach (int dop in new[] { 1, 2, 4, 8, Environment.ProcessorCount })
{
    uint f0 = Faults();
    var sw = Stopwatch.StartNew();
    Parallel.For(0, N, new ParallelOptions { MaxDegreeOfParallelism = dop }, _ => One());
    Console.WriteLine($"dop={dop}: {sw.ElapsedMilliseconds} ms, faults/op={(Faults() - f0) / (double)N:F2}");
}

static uint Faults()
{
    var c = new PMC { cb = (uint)Marshal.SizeOf<PMC>() };
    GetProcessMemoryInfo(Process.GetCurrentProcess().Handle, ref c, c.cb);
    return c.PageFaultCount;
}
[DllImport("psapi.dll")] static extern bool GetProcessMemoryInfo(IntPtr h, ref PMC c, uint cb);
[StructLayout(LayoutKind.Sequential)] struct PMC { public uint cb; public uint PageFaultCount; public nuint a, b, c, d, e, f, g, h; }

Multi-target net8.0;net10.0, Release.

Microbenchmark results (3 runs, AMD Ryzen 5 PRO 4650U 6C/12T, Windows 11 26200)

dop .NET 8.0.31 ms (faults/op) .NET 10.0.12 ms (faults/op)
1 1670-1981 (0.55) 1404-1589 (0.05)
2 865-992 (0.43-0.56) 761-859 (0.01-0.02)
4 523-638 (0.20-0.29) 458-472 (0.01-0.03)
8 347-390 (0.23-0.30) 641-664 (17.7-18.2)
12 286-313 (0.22-0.33) 247-265 (0.42-0.49)

In this sequence the fault storm is reproducible at dop 8 but not at 12; it depends on
concurrency (and possibly on prior heap state), which matches the erratic behaviour seen in the
real workload.

Ask

There is no way to reuse a compressor: System.IO.Compression.Native exports no DeflateReset,
and DeflateStream.Flush only issues Z_SYNC_FLUSH, so one stream cannot produce independent
deflate streams. A resettable/reusable compressor, or a heap policy that keeps zlib-ng state
blocks committed, would remove the per-entry cost for archive writers.

Configuration

.NET 10.0.12 vs 8.0.31, Windows 11 Pro 10.0.26200, x64, Workstation GC.

Activity

  1. dotnet-policy-service commented on Sep 26, 2026

    @dotnet-policy-service
    Contributor

    Tagging subscribers to this area: @karelz, @dotnet/area-system-io-compression
    See info in area-owners.md if you want to be subscribed.

  2. rzikm commented on Oct 1, 2026

    @rzikm
    Member

    There is no way to reuse a compressor: System.IO.Compression.Native exports no DeflateReset,
    and DeflateStream.Flush only issues Z_SYNC_FLUSH, so one stream cannot produce independent
    deflate streams. A resettable/reusable compressor, or a heap policy that keeps zlib-ng state
    blocks committed, would remove the per-entry cost for archive writers.

    In .NET 11 we added DeflateEncoder and DeflateDecoder which can be reused (via Reset()), maybe that would work better in your case than streams

  3. removed
    untriagedNew issue has not been triaged by the area owner
    on Oct 1, 2026
  4. added this to the Future milestone on Oct 1, 2026
  5. pakkoapp-oss commented on Oct 3, 2026

    @pakkoapp-oss
    Author

    Thanks @rzikm — DeflateEncoder with Reset() is exactly what a parallel writer needs: one encoder per worker instead of a new zlib-ng state per entry. We're on .NET 10 LTS for now, so we'll switch our small-entry path to it when we move to a runtime that has it, and report the numbers back here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Type

No type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions