Раскрываем мощь Zlib и немного Brotli в .NET

Brotli в .NET

В .NET Core 2.1 Microsoft добавила поддержку Brotli-компрессии, предоставив разработчикам привычный контракт на базе Stream.

Идея проста: вы оборачиваете поток с несжатыми или сжатыми данными в BrotliStream и читаете или пишете данные, как обычно.

Пример: компрессия в файл

using var input = File.OpenRead("input.bin");
using var output = File.Create("output.br");
using var brotli = new BrotliStream(output, CompressionLevel.Optimal);

input.CopyTo(brotli); // пошла руда

Это действительно удобный способ работы с файлами. Однако на практике он хорошо подходит только для файлов.

Если данные приходят из сети или уже находятся в памяти, вам придётся создавать MemoryStream, выполнять компрессию или декомпрессию через него, а затем извлекать результат.

Пример: компрессия в MemoryStream

byte[] Compress(byte[] data)
{
    using var output = new MemoryStream();
    using (var brotli = new BrotliStream(output, CompressionLevel.Optimal, true))
    {
        brotli.Write(data, 0, data.Length);
    }
    return output.ToArray();
}

Если использовать MemoryStream неаккуратно, количество аллокаций может оказаться неоправданно большим для такой простой операции, как компрессия или декомпрессия нескольких сотен байт. Особенно критично это на горячем пути сетевого приложения.

Где именно происходят аллокации

byte[] Compress(byte[] data)
{
    // аллокация массива для MemoryStream
    using var output = new MemoryStream();

    // аллокация BrotliStream + внутренние буферы
    using (var brotli = new BrotliStream(output, CompressionLevel.Optimal, true))
    {
        // возможные промежуточные аллокации при Write
        brotli.Write(data, 0, data.Length);
    }

    // аллокация нового массива под результат
    return output.ToArray();
}

BrotliEncoder / BrotliDecoder

Для таких сценариев разработчики Microsoft добавили низкоуровневые классы BrotliEncoder и BrotliDecoder. Они работают напрямую со Span<T> и ReadOnlySpan<T> и, по крайней мере теоретически, не создают мусор для GC.

Их интерфейс следует неформализованному, но уже широко используемому паттерну, встречающемуся в System.Text.Encoding, Base64, BrotliEncoder и других API:

public OperationStatus Operation(
    ReadOnlySpan<byte> source,
    Span<byte> destination,
    out int bytesConsumed,
    out int bytesWritten,
    bool isFinalBlock);

К слову, вы можете проголосовать за формализацию этого интерфейса в официальном API в соответствующем PR (ставь лайк/реакцию первому комменту):
https://github.com/dotnet/runtime/issues/122358

Скрытое Zlib API

Несмотря на то что для Brotli появилось высокопроизводительное API, для GZip (и Zlib) его официально добавлять не стали.

Или всё-таки стали?

На самом деле это API существует с .NET 1.0. Оно эффективное, производительное - и полностью приватное. Самое неприятное в этой истории то, что команды Microsoft сами копируют исходники этих классов в свои проекты, вместо того чтобы протестировать и опубликовать их.

История Zlib в .NET

.NET распространяется с Zlib с самых ранних версий. Однако код никогда не находился в zlib.dll. Вместо этого использовалась библиотека clrcompression.dll, в которой жила Zlib версии 1.2.3 - со всеми её плюсами и багами.

Контекст z_stream_s и сигнатуры методов полностью повторяли оригинальный API Zlib.

Так продолжалось до .NET 3.x, когда нативный код переехал в System.IO.Compression.Native.dll. При этом контекст и методы нативного API были слегка переработаны и стали version-agnostic относительно версии Zlib, с которой был собран рантайм.

Оригинальная clrcompression.dll была окончательно удалена из поставки в .NET 6.

Добавление Zlib API в проект

Несмотря на нежелание Microsoft публиковать высокопроизводительное API для GZip в виде ZlibEncoder/ZlibDecoder, ничто не мешает сделать это самостоятельно.

Варианты:

Благо System.IO.Compression.Native.dll уже загружена в процесс, и вы можете вызывать её напрямую через P/Invoke.

Я уже проделал эту работу и протестировал результат:

https://gist.github.com/deniszykov/89197480c20f537ce0c6c2a809b688ae

Использование

Использование полностью повторяет модель BrotliEncoder: вы создаёте энкодер, подаёте ему входные данные и извлекаете результат, проверяя статус на каждой итерации.

using var encoder = new ZlibEncoder(
    CompressionLevel.Optimal,
    ZlibEncoder.GZIP_WINDOW_BITS);

var input = dataBytes.AsSpan();
var buffer = ArrayPool<byte>.Shared.Rent(4096);
var output = Stream.Null;

var options = TransformOptions.Starting | TransformOptions.Final;
OperationStatus status = OperationStatus.NeedMoreData;

while (!(input.IsEmpty && status == OperationStatus.Done))
{
    status = encoder.Compress(
        input,
        buffer,
        out int consumed,
        out int written,
        options);

    if (status == OperationStatus.InvalidData)
        throw new InvalidOperationException();

    if (consumed > 0)
    {
        options &= ~TransformOptions.Starting;
        input = input.Slice(consumed);
    }

    if (written > 0)
    {
        output.Write(buffer, 0, written);
    }
}

ArrayPool<byte>.Shared.Return(buffer);

Альтернатива от ICSharpCode.SharpZipLib

Опытный разработчик заметит, что не на всех платформах доступна нативная библиотека System.IO.Compression.Native - например, в Mono или Blazor WebAssembly.

Разработчики Microsoft подстраховались ещё в .NET Framework, реализовав Inflater и DeflaterManaged (нейминг мое почтение), но, разумеется, они тоже приватные.

В итоге остаётся два варианта:

Пример адаптации под тот же контракт:

https://gist.github.com/deniszykov/5cfee5f1f770a9792a74e4b3a1e0db55

Производительность

Главный вопрос — имеет ли смысл использовать встроенный Zlib, если существует полностью managed-альтернатива, работающая на всех платформах.

Ответ — смотрите на бенчмарки и решайте сами:

| Method           | Mean        | Error     | StdDev    | Median      | Gen0    | Allocated |
|----------------- |------------:|----------:|----------:|------------:|--------:|----------:|
| ZStdEncoder      |    996.6 us |  19.77 us |  45.43 us |    972.7 us |       - |      65 B |
| BrotliEncoder    |  9,370.7 us | 180.74 us | 200.89 us |  9,369.3 us |       - |      47 B |
| GZipEncoder†     | 46,820.3 us | 903.98 us | 967.25 us | 46,531.9 us | 90.9091 | 1678379 B |
| ZlibEncoder      | 40,846.6 us | 189.32 us | 147.81 us | 40,795.5 us |       - |     105 B |
| Method           | Mean       | Error    | StdDev   | Median     | Allocated |
|----------------- |-----------:|---------:|---------:|-----------:|----------:|
| ZStdDecoder      |   434.5 us | 10.20 us | 30.08 us |   423.0 us |      56 B |
| BrotliDecoder    |   434.8 us | 10.15 us | 29.92 us |   422.0 us |      32 B |
| ZlibDecoder      |   644.0 us | 10.12 us |  9.47 us |   643.6 us |      49 B |
| GZipDecoder†     | 3,604.4 us |  3.81 us |  3.18 us | 3,604.0 us |   33011 B |

† полностью managed реализация через ICSharpCode.SharpZipLib

В бенчмарке сжимались и разжимались 2 МБ случайных данных с уровнем сжатия 6 и максимальным размером битового окна.

Пропускная способность на моём железе

 ZstdEncoder: 1888.00 MiB/s
 BrotliEncoder 224.75 MiB/s
 ZlibEncoder: 52.69 MiB/s
 GZipEncoder: 45.32 MiB/s

 ZstdDecoder: 4822.32 MiB/s
 BrotliDecoder 4491.45 MiB/s
 ZlibDecoder 5481.22 MiB/s
 GZipDecoder 586.43 MiB/s

Пишите в комментариях насколько вам не понравилось как я проводил бенчмарк и как бы вы его провели.

@shai_hulud
22.12.2025 19:39 UTC
Первоисточник

Комментарии

@iamkisly
23.12.2025 07:50 UTC
-1

на моём железе

На каком?

@shai_hulud
23.12.2025 10:03 UTC
+4

AMD Ryzen 9 5900X 12-Core Processor

Но мне кажется смысл имеет только отношение одного к другому, абсолютные цифры не так важны.

@viruseg
23.12.2025 12:35 UTC
+2

Рекомендую обратить внимание на Zstandard. Порт для .NET ZstdSharp. С 24 года поддерживается всеми браузерами.

@shai_hulud
04.01.2026 14:26 UTC
+1

обновил результаты бенчмарка с Zstd

@stiz
24.12.2025 08:00 UTC
0

Скорость - это хорошо, а что в плане нагрузки на процессор?

@shai_hulud
24.12.2025 10:42 UTC
0

Мои бенчи были однопоточны, без IO, максимум что их может приостановить от 100% нагрузки это очереди на порты процессора из за SIMD или на доступ в память, и то сомнительно.

Если вы про условная "тяжесть" операции сжатия и ее степень, то я не смотрите на мой бенчмарк, он говно, как и чужие бенчмарки тоже. Можно подкрутить данные или параметры так, что zlib уделает brotli всухую.
Лучше сделать свой для своих данных и там решать.

@Fedorkov
24.12.2025 18:55 UTC
+1

Сейчас обнаружил интересный эффект: BrotliStream на уровне CompressionLevel.Fastest сильно тормозит и относительно плохо сжимает на большом количестве небольших записей:

using System.Diagnostics;
using System.IO.Compression;

File.Delete("test.br");
using var file = File.OpenWrite("test.br");
Stream stream = new BrotliStream(file, CompressionLevel.Fastest);
var data = new byte[16];

var sw = Stopwatch.StartNew();

//stream = new BufferedStream(stream);
for (int i = 100 * 1024 * 1024 / data.Length; i != 0; i--)
{
    Random.Shared.NextBytes(data);
    await stream.WriteAsync(data);
}
await stream.DisposeAsync();

Console.WriteLine(sw);

Если раскомментировать stream = new BufferedStream(stream), сжатие будет в 30 раз быстрее и на четверть компактнее.