Repository navigation
Parallel short-lived DeflateStream compressors: page-fault storms on .NET 10 (Windows x64), none on .NET 8 #134700
Description
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
dotnet-policy-service commentedon Sep 26, 2026
Tagging subscribers to this area: @karelz, @dotnet/area-system-io-compression
See info in area-owners.md if you want to be subscribed.
docs(T-F270,T-F271): T-F270 done after on-device smoke; link upstream…
Scope Deflater pool to Windows and rename regression test class
rzikm commentedon Oct 1, 2026
In .NET 11 we added DeflateEncoder and DeflateDecoder which can be reused (via
Reset()), maybe that would work better in your case than streamspakkoapp-oss commentedon Oct 3, 2026
Thanks @rzikm —
DeflateEncoderwithReset()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.