Skip to content

[RFC] allow configuring a compression algorithm for logs#15159

Open
CyberShadow wants to merge 3 commits intoNixOS:masterfrom
CyberShadow:compress-build-log-algo
Open

[RFC] allow configuring a compression algorithm for logs#15159
CyberShadow wants to merge 3 commits intoNixOS:masterfrom
CyberShadow:compress-build-log-algo

Conversation

@CyberShadow
Copy link
Member

Motivation

The compress-build-log setting currently only accepts true (bzip2) or false (none). This is an unfortunate choice of compression format, because bzip2 uses large blocks (up to 900KB) that cannot be decompressed until fully written. This means partially-written build logs are unreadable -- if you start nix build without -L and later want to check progress, there is no way to see the in-progress log.

The only workaround today is compress-build-log = false, which gives up compression entirely.

This is frustrating because most modern compression formats (zstd, gzip, lz4) use small frames that can be decompressed independently. A partially-written zstd file, for example, is decompressible up to the last complete frame -- you get everything except the last few KB. This would make nix log work on in-progress builds (returning whatever has been flushed so far), and would also let users zstdcat log files from another terminal while a build is running.

The problem in detail

  1. User starts a long build: nix build nixpkgs#something
  2. Realizes they forgot -L and want to see what's happening
  3. nix log returns nothing -- the log file exists on disk but is a partial bzip2 stream that cannot be decompressed
  4. The user must wait for the build to finish, or kill it and restart with -L

With a streaming-friendly format like zstd, step 3 would return all log output up to the last flushed frame.

Proposed solution

Make the compression format configurable by extending compress-build-log to accept an algorithm name:

compress-build-log = zstd

This gives the best of both worlds: compressed logs that are also partially readable during builds.

While this does not enable real-time streaming, it does allow you to see all but the last few KB of the log file. Changing the default to zstd could be considered separately in the future.

Attached patch

A machine-generated patch is attached that implements this feature. Summary of what it does:

  • Extends compress-build-log to accept a compression algorithm name (e.g. zstd, gzip, none) in addition to the existing true/false values
  • The default remains bzip2; true maps to bzip2 and false maps to none for backward compatibility
  • --no-compress-build-log on the command line still works; --compress-build-log now takes a <method> argument
  • Log reading (nix log, nix-store -l) tries all known compressed extensions (.bz2, .zst, .xz, .gz, .lz4, .br), so old logs remain readable after switching algorithms
  • addBuildLog() (used when receiving logs from remote builders) now also respects the setting -- it previously hardcoded bzip2 regardless of the compress-build-log value
  • Adds compressionAlgoExtension() to centralize the algo-to-file-extension mapping that was previously duplicated inline in binary-cache-store.cc

Builds on #15020 which introduced the CompressionAlgo enum and Setting<CompressionAlgo> infrastructure.


Add 👍 to pull requests you find important.

The Nix maintainer team uses a GitHub project board to schedule and track reviews.

Centralizes the mapping from CompressionAlgo to file extension
(e.g. bzip2 → ".bz2", zstd → ".zst") in a single function, replacing
the ad-hoc inline mapping in binary-cache-store.cc.

This will also be used in the next commit to support configurable
build log compression algorithms.
Change the `compress-build-log` setting from a boolean to a compression
algorithm name (e.g. `zstd`, `gzip`, `none`), using the existing
`CompressionAlgo` enum. The default remains `bzip2`.

For backward compatibility, `true` maps to `bzip2` and `false` maps to
`none`. On the command line, `--compress-build-log <method>` now takes
an algorithm argument, and `--no-compress-build-log` disables compression.

This also updates `addBuildLog()` to respect the setting (it previously
always hardcoded bzip2), and `getBuildLogExact()` to try all known
compressed extensions when reading logs, so old logs remain readable
after switching algorithms.

Motivation: bzip2 uses large blocks (up to 900KB) which makes partially
written build logs unreadable during builds. Streaming-friendly formats
like zstd or gzip flush data in smaller frames, allowing tools like
`nix log` to display in-progress build output.
…-log

Update the functional test to use the new `--compress-build-log <method>`
syntax. Add test cases for zstd and no-compression modes alongside the
existing bzip2 test.

Add a release note documenting the change.
@github-actions github-actions bot added documentation with-tests Issues related to testing. PRs with tests have some priority store Issues and pull requests concerning the Nix store labels Feb 6, 2026
@edolstra
Copy link
Member

edolstra commented Feb 6, 2026

Rather than making it configurable, maybe we should just pick a better algorithm (e.g. zstd) and switch to it.

@CyberShadow
Copy link
Member Author

We can certainly do that.

A benchmark/comparison from Claude, measuring recoverability/streamability vs. compression qualities:


Details

Build log compression benchmark

Benchmark of compression algorithms for Nix build logs, measuring compression
ratio, speed, and streaming recoverability (how much of a partially-written
log can be decompressed).

Uses libarchive for both compression and decompression -- the same library Nix
uses internally -- so results reflect actual nix log behavior on truncated
(in-progress) log files.

Test data

Three real build logs from /nix/var/log/nix/drvs/:

Sample Uncompressed size Description
gcc-cross 11.3 MB Cross-compiler build (armv7l-musleabihf-gcc)
linux-kernel 4.6 MB Linux 6.15.11 kernel build
vm-test 612 KB NixOS VM integration test

Compression ratio & speed

Best of 3 runs per algorithm, using libarchive default settings.

Algorithm  Sample             Original   Compressed    Ratio    Comp MB/s  Decomp MB/s
──────────────────────────────────────────────────────────────────────────────────────
bzip2      gcc-cross           11.3 MB     163.4 KB     1.4%         6.0        81.2
gzip       gcc-cross           11.3 MB     276.9 KB     2.4%       204.1      3256.1
zstd       gcc-cross           11.3 MB     175.6 KB     1.5%      1940.9      3716.5
xz         gcc-cross           11.3 MB     140.9 KB     1.2%        20.0      1468.7
bzip2      linux-kernel         4.6 MB     323.5 KB     6.9%         7.3        71.1
gzip       linux-kernel         4.6 MB     397.5 KB     8.5%        99.1      1006.1
zstd       linux-kernel         4.6 MB     397.2 KB     8.5%       653.0      1803.4
xz         linux-kernel         4.6 MB     311.5 KB     6.7%         6.9       436.8
bzip2      vm-test            612.4 KB      87.4 KB    14.3%        14.6        51.7
gzip       vm-test            612.4 KB     104.0 KB    17.0%        64.9       548.3
zstd       vm-test            612.4 KB     105.3 KB    17.2%       385.2         -.--
xz         vm-test            612.4 KB      81.5 KB    13.3%         5.7       190.9

Summary:

Algorithm Ratio range Compression speed Decompression speed
bzip2 1.4 -- 14.3% 6 -- 15 MB/s 52 -- 81 MB/s
gzip 2.4 -- 17.0% 65 -- 204 MB/s 548 -- 3256 MB/s
zstd 1.5 -- 17.2% 385 -- 1941 MB/s 1803 -- 3717 MB/s
xz 1.2 -- 13.3% 6 -- 20 MB/s 191 -- 1469 MB/s

zstd achieves nearly the same compression ratio as bzip2 while being
100--300x faster to compress and 20--45x faster to decompress.

Streaming recoverability

The key metric: if the compressed file is truncated (build still in progress),
how much of the original log is recoverable by decompressing what has been
written so far?

Method: compress the full log, truncate the compressed output at X% of its
final size, decompress with libarchive, measure how much of the original is
recovered.

gcc-cross (11.3 MB uncompressed)

 Written               bzip2                gzip                zstd                  xz
────────────────────────────────────────────────────────────────────────────────────────
     5%      0.0% (     0 B)     1.1% (128.0 KB)     0.0% (     0 B)     0.0% (     0 B)
    10%      0.0% (     0 B)     3.3% (384.0 KB)     2.2% (256.0 KB)     2.2% (256.0 KB)
    20%      7.2% (832.0 KB)    10.0% (  1.1 MB)     4.4% (512.0 KB)     5.6% (640.0 KB)
    30%      7.2% (832.0 KB)    21.7% (  2.4 MB)    10.0% (  1.1 MB)    10.0% (  1.1 MB)
    40%     37.8% (  4.2 MB)    39.4% (  4.4 MB)    35.5% (  4.0 MB)    31.7% (  3.6 MB)
    50%     60.5% (  6.8 MB)    56.7% (  6.4 MB)    67.8% (  7.6 MB)    57.2% (  6.4 MB)
    60%     76.1% (  8.6 MB)    76.1% (  8.6 MB)    78.9% (  8.9 MB)    79.4% (  8.9 MB)
    70%     83.9% (  9.4 MB)    80.5% (  9.1 MB)    85.5% (  9.6 MB)    85.5% (  9.6 MB)
    80%     91.1% ( 10.2 MB)    88.3% (  9.9 MB)    93.3% ( 10.5 MB)    92.8% ( 10.4 MB)
    90%     91.1% ( 10.2 MB)    96.1% ( 10.8 MB)    96.6% ( 10.9 MB)    96.6% ( 10.9 MB)
    95%     91.1% ( 10.2 MB)    97.7% ( 11.0 MB)    97.7% ( 11.0 MB)    98.3% ( 11.1 MB)
   100%    100.0%  (    full)  100.0%  (    full)  100.0%  (    full)  100.0%  (    full)

linux-kernel (4.6 MB uncompressed)

 Written               bzip2                gzip                zstd                  xz
────────────────────────────────────────────────────────────────────────────────────────
     5%      0.0% (     0 B)     0.0% (     0 B)     0.0% (     0 B)     0.0% (     0 B)
    10%      0.0% (     0 B)     4.1% (192.0 KB)     2.7% (128.0 KB)     4.1% (192.0 KB)
    20%      0.0% (     0 B)     9.6% (448.0 KB)     8.2% (384.0 KB)     8.2% (384.0 KB)
    30%      0.0% (     0 B)    15.1% (704.0 KB)    11.0% (512.0 KB)    13.7% (640.0 KB)
    40%     17.8% (832.0 KB)    20.6% (960.0 KB)    16.5% (768.0 KB)    19.2% (896.0 KB)
    50%     17.8% (832.0 KB)    27.4% (  1.2 MB)    21.9% (  1.0 MB)    27.4% (  1.2 MB)
    60%     17.8% (832.0 KB)    34.3% (  1.6 MB)    30.2% (  1.4 MB)    37.0% (  1.7 MB)
    70%     37.0% (  1.7 MB)    43.9% (  2.0 MB)    38.4% (  1.8 MB)    50.7% (  2.3 MB)
    80%     56.2% (  2.6 MB)    64.4% (  2.9 MB)    57.6% (  2.6 MB)    67.2% (  3.1 MB)
    90%     74.0% (  3.4 MB)    83.6% (  3.8 MB)    79.5% (  3.6 MB)    83.6% (  3.8 MB)
    95%     74.0% (  3.4 MB)    91.9% (  4.2 MB)    90.5% (  4.1 MB)    91.9% (  4.2 MB)
   100%    100.0%  (    full)  100.0%  (    full)  100.0%  (    full)  100.0%  (    full)

vm-test (612 KB uncompressed)

 Written               bzip2                gzip                zstd                  xz
────────────────────────────────────────────────────────────────────────────────────────
     5%      0.0% (     0 B)     0.0% (     0 B)     0.0% (     0 B)     0.0% (     0 B)
    10%      0.0% (     0 B)     0.0% (     0 B)     0.0% (     0 B)     0.0% (     0 B)
    20%      0.0% (     0 B)    10.5% ( 64.0 KB)     0.0% (     0 B)    10.5% ( 64.0 KB)
    30%      0.0% (     0 B)    20.9% (128.0 KB)    20.9% (128.0 KB)    20.9% (128.0 KB)
    40%      0.0% (     0 B)    31.4% (192.0 KB)    20.9% (128.0 KB)    31.4% (192.0 KB)
    50%      0.0% (     0 B)    41.8% (256.0 KB)    41.8% (256.0 KB)    41.8% (256.0 KB)
    60%      0.0% (     0 B)    62.7% (384.0 KB)    41.8% (256.0 KB)    52.3% (320.0 KB)
    70%      0.0% (     0 B)    62.7% (384.0 KB)    62.7% (384.0 KB)    62.7% (384.0 KB)
    80%      0.0% (     0 B)    73.2% (448.0 KB)    62.7% (384.0 KB)    73.2% (448.0 KB)
    90%      0.0% (     0 B)    83.6% (512.0 KB)    83.6% (512.0 KB)    83.6% (512.0 KB)
    95%      0.0% (     0 B)    94.1% (576.0 KB)    83.6% (512.0 KB)    94.1% (576.0 KB)
   100%    100.0%  (    full)  100.0%  (    full)  100.0%  (    full)  100.0%  (    full)

bzip2 recovers zero bytes from the 612 KB vm-test log at every truncation
point -- the entire log fits within a single bzip2 block, so nothing is
decompressible until the build finishes and the block is flushed.

First-byte latency

Minimum compressed bytes that must be written before libarchive can recover
any decompressed output. Found by binary search.

Algorithm  Sample             Min compressed   First recovery
──────────────────────────────────────────────────────────────
bzip2      gcc-cross                 31.9 KB         832.0 KB
gzip       gcc-cross                 13.6 KB         128.0 KB
zstd       gcc-cross                 14.9 KB         128.0 KB
xz         gcc-cross                 12.1 KB         128.0 KB
bzip2      linux-kernel             113.5 KB         832.0 KB
gzip       linux-kernel              22.5 KB         128.0 KB
zstd       linux-kernel              26.0 KB         128.0 KB
xz         linux-kernel              19.8 KB         128.0 KB
bzip2      vm-test                   87.4 KB          64.0 KB
gzip       vm-test                   18.0 KB          64.0 KB
zstd       vm-test                   29.4 KB         128.0 KB
xz         vm-test                   15.7 KB          64.0 KB

Note: for the vm-test sample, bzip2's "min compressed" of 87.4 KB equals the
entire compressed file -- meaning the first byte of output only appears when
the stream is complete.

Effective block/frame sizes

Size of each independently-decompressible unit, as seen by libarchive. Measured
by sampling the compressed stream at 500 evenly-spaced truncation points and
detecting jumps in recoverable output.

gcc-cross (11.3 MB)

Algorithm    Blocks    Min block    Max block    Avg block       Median
──────────────────────────────────────────────────────────────────────
bzip2            14     131.4 KB     896.0 KB     823.1 KB     896.0 KB
gzip            180       3.4 KB     128.0 KB      64.0 KB      64.0 KB
zstd             89     128.0 KB     256.0 KB     129.5 KB     128.0 KB
xz              164      64.0 KB     128.0 KB      70.3 KB      64.0 KB

linux-kernel (4.6 MB)

Algorithm    Blocks    Min block    Max block    Avg block       Median
──────────────────────────────────────────────────────────────────────
bzip2             6     315.4 KB     896.0 KB     777.9 KB     896.0 KB
gzip             72      59.4 KB     128.0 KB      64.8 KB      64.0 KB
zstd             37      59.4 KB     128.0 KB     126.1 KB     128.0 KB
xz               72      59.4 KB     128.0 KB      64.8 KB      64.0 KB

vm-test (612 KB)

Algorithm    Blocks    Min block    Max block    Avg block       Median
──────────────────────────────────────────────────────────────────────
bzip2             1     612.4 KB     612.4 KB     612.4 KB     612.4 KB
gzip             10      36.4 KB      64.0 KB      61.2 KB      64.0 KB
zstd              5     100.4 KB     128.0 KB     122.5 KB     128.0 KB
xz               10      36.4 KB      64.0 KB      61.2 KB      64.0 KB

Conclusions

bzip2 is the worst choice for build log compression. Its 896 KB block
size means that for any log smaller than ~900 KB (which is most builds),
nix log returns nothing until the build completes. For larger logs, recovery
is coarse-grained and jumpy.

zstd is the recommended replacement:

  • Compression ratio nearly identical to bzip2 (1.5% vs 1.4% on the gcc log)
  • 100--300x faster compression, 20--45x faster decompression
  • 128 KB streaming blocks -- nix log can show all but the last ~128 KB
    of an in-progress build
  • Already a Nix dependency (used for NAR compression)

gzip and xz also stream well through libarchive (64 KB blocks), but gzip
has a noticeably worse compression ratio, and xz is very slow to compress.

Benchmark source code

Compile with: cc -O2 -o bench-libarchive bench-libarchive.c $(pkg-config --cflags --libs libarchive)

In a Nix checkout: nix develop --command sh -c 'cc -O2 -o bench-libarchive bench-libarchive.c $(pkg-config --cflags --libs libarchive)'

/*
 * Benchmark compression algorithms for Nix build logs using libarchive.
 *
 * This uses the exact same library that Nix uses for compression/decompression,
 * giving realistic results for streaming recoverability of truncated logs.
 *
 * Compile: cc -O2 -o bench-libarchive bench-libarchive.c -larchive
 */

#include <archive.h>
#include <archive_entry.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <errno.h>

/* ── Dynamic buffer ──────────────────────────────────────────────── */

typedef struct {
    char *data;
    size_t len;
    size_t cap;
} Buffer;

static void buf_init(Buffer *b) {
    b->data = NULL;
    b->len = 0;
    b->cap = 0;
}

static void buf_free(Buffer *b) {
    free(b->data);
    buf_init(b);
}

static void buf_append(Buffer *b, const void *data, size_t len) {
    if (b->len + len > b->cap) {
        size_t newcap = b->cap ? b->cap * 2 : 65536;
        while (newcap < b->len + len) newcap *= 2;
        b->data = realloc(b->data, newcap);
        b->cap = newcap;
    }
    memcpy(b->data + b->len, data, len);
    b->len += len;
}

/* ── Compression via libarchive ──────────────────────────────────── */

typedef int (*add_filter_fn)(struct archive *);

struct algo_info {
    const char *name;
    add_filter_fn add_filter;
    const char *filter_name;  /* for archive_write_set_filter_option */
    const char *extension;
};

static struct algo_info ALGOS[] = {
    {"bzip2", archive_write_add_filter_bzip2, "bzip2", ".bz2"},
    {"gzip",  archive_write_add_filter_gzip,  "gzip",  ".gz"},
    {"zstd",  archive_write_add_filter_zstd,  "zstd",  ".zst"},
    {"xz",    archive_write_add_filter_xz,    "xz",    ".xz"},
};
#define N_ALGOS (sizeof(ALGOS) / sizeof(ALGOS[0]))

/* Write callback for compression: appends to a Buffer */
struct write_ctx {
    Buffer *buf;
};

static ssize_t write_cb(struct archive *a, void *client, const void *buffer, size_t length) {
    struct write_ctx *ctx = client;
    buf_append(ctx->buf, buffer, length);
    return (ssize_t)length;
}

/* Compress `input` using `algo`, store result in `output` */
static int do_compress(struct algo_info *algo, const char *input, size_t input_len, Buffer *output) {
    struct archive *a = archive_write_new();
    if (!a) return -1;

    struct write_ctx ctx = {.buf = output};
    output->len = 0;

    algo->add_filter(a);
    archive_write_set_format_raw(a);
    archive_write_set_bytes_per_block(a, 0);
    archive_write_set_bytes_in_last_block(a, 1);

    archive_write_open(a, &ctx, NULL, write_cb, NULL);

    struct archive_entry *ae = archive_entry_new();
    archive_entry_set_filetype(ae, AE_IFREG);
    archive_write_header(a, ae);
    archive_entry_free(ae);

    archive_write_data(a, input, input_len);
    archive_write_close(a);
    archive_write_free(a);
    return 0;
}

/* Read callback for decompression: reads from a buffer */
struct read_ctx {
    const char *data;
    size_t len;
    size_t pos;
};

static ssize_t read_cb(struct archive *a, void *client, const void **buffer) {
    struct read_ctx *ctx = client;
    if (ctx->pos >= ctx->len) return 0;
    *buffer = ctx->data + ctx->pos;
    size_t avail = ctx->len - ctx->pos;
    size_t chunk = avail < 65536 ? avail : 65536;
    ctx->pos += chunk;
    return (ssize_t)chunk;
}

/*
 * Decompress `input[0..input_len)` using libarchive auto-detection.
 * Returns number of decompressed bytes, or -1 on total failure.
 * Tolerant of truncated streams.
 */
static ssize_t do_decompress(const char *input, size_t input_len, Buffer *output) {
    if (!input || input_len == 0) return 0;

    struct archive *a = archive_read_new();
    if (!a) return -1;

    output->len = 0;

    archive_read_support_filter_all(a);
    archive_read_support_format_raw(a);

    struct read_ctx ctx = {.data = input, .len = input_len, .pos = 0};
    int r = archive_read_open(a, &ctx, NULL, read_cb, NULL);
    if (r != ARCHIVE_OK) {
        archive_read_free(a);
        return -1;
    }

    struct archive_entry *ae;
    r = archive_read_next_header(a, &ae);
    if (r != ARCHIVE_OK) {
        archive_read_free(a);
        return 0;  /* can't even read header — 0 bytes recovered */
    }

    /* Read as much decompressed data as possible */
    char buf[65536];
    for (;;) {
        ssize_t n = archive_read_data(a, buf, sizeof(buf));
        if (n > 0) {
            buf_append(output, buf, (size_t)n);
        } else {
            break;  /* EOF, error, or truncated — stop */
        }
    }

    archive_read_free(a);
    return (ssize_t)output->len;
}

/* ── File I/O ────────────────────────────────────────────────────── */

static char *read_file(const char *path, size_t *out_len) {
    FILE *f = fopen(path, "rb");
    if (!f) { perror(path); return NULL; }
    fseek(f, 0, SEEK_END);
    long len = ftell(f);
    fseek(f, 0, SEEK_SET);
    char *data = malloc(len);
    if (fread(data, 1, len, f) != (size_t)len) { free(data); fclose(f); return NULL; }
    fclose(f);
    *out_len = (size_t)len;
    return data;
}

/* Decompress a .bz2 file to get the raw log */
static char *decompress_bz2_file(const char *path, size_t *out_len) {
    size_t comp_len;
    char *comp = read_file(path, &comp_len);
    if (!comp) return NULL;

    Buffer output;
    buf_init(&output);
    ssize_t r = do_decompress(comp, comp_len, &output);
    free(comp);
    if (r < 0) { buf_free(&output); return NULL; }
    *out_len = output.len;
    return output.data;
}

/* ── Formatting helpers ──────────────────────────────────────────── */

static void human_size(char *buf, size_t buflen, size_t n) {
    if (n < 1024)
        snprintf(buf, buflen, "%zu B", n);
    else if (n < 1024 * 1024)
        snprintf(buf, buflen, "%.1f KB", n / 1024.0);
    else
        snprintf(buf, buflen, "%.1f MB", n / (1024.0 * 1024.0));
}

static double now_sec(void) {
    struct timespec ts;
    clock_gettime(CLOCK_MONOTONIC, &ts);
    return ts.tv_sec + ts.tv_nsec * 1e-9;
}

/* ── Benchmark ───────────────────────────────────────────────────── */

struct sample {
    const char *name;
    char *data;
    size_t len;
};

int main(int argc, char **argv) {
    /*
     * Usage: bench-libarchive [logfile.bz2 ...]
     *
     * If no arguments are given, uses hardcoded paths to sample logs.
     * If arguments are given, each should be a .bz2-compressed log file.
     */

    struct sample samples[16];
    int loaded = 0;

    printf("=== Loading sample build logs ===\n\n");

    if (argc > 1) {
        for (int i = 1; i < argc && loaded < 16; i++) {
            size_t len;
            char *data = decompress_bz2_file(argv[i], &len);
            if (data) {
                /* Use basename as sample name */
                const char *name = strrchr(argv[i], '/');
                samples[loaded].name = name ? name + 1 : argv[i];
                samples[loaded].data = data;
                samples[loaded].len = len;
                char hs[32];
                human_size(hs, sizeof(hs), len);
                printf("  %s: %s\n", samples[loaded].name, hs);
                loaded++;
            }
        }
    } else {
        struct { const char *name; const char *path; } defaults[] = {
            {"gcc-cross",    "/nix/var/log/nix/drvs/cq/6vldgqkcbka93kbmxy1ji9arnsxni2-armv7l-unknown-linux-musleabihf-gcc-14.3.0.drv.bz2"},
            {"linux-kernel", "/nix/var/log/nix/drvs/pi/fxyb7a6z4pblflrbrqnlh9rhznyffh-linux-6.15.11.drv.bz2"},
            {"vm-test",      "/nix/var/log/nix/drvs/16/7iyxzvppyagla72yz0rj94l5744ar8-vm-test-run-k4-21a-health-checks.drv.bz2"},
        };
        for (int i = 0; i < 3; i++) {
            size_t len;
            char *data = decompress_bz2_file(defaults[i].path, &len);
            if (data) {
                samples[loaded].name = defaults[i].name;
                samples[loaded].data = data;
                samples[loaded].len = len;
                char hs[32];
                human_size(hs, sizeof(hs), len);
                printf("  %s: %s\n", samples[loaded].name, hs);
                loaded++;
            }
        }
    }
    printf("\n");

    if (loaded == 0) {
        fprintf(stderr, "No sample logs found. Pass .bz2 log files as arguments.\n");
        return 1;
    }

    /* Storage for compressed data per (sample, algo) */
    Buffer compressed[16][N_ALGOS];
    for (int s = 0; s < loaded; s++)
        for (size_t a = 0; a < N_ALGOS; a++)
            buf_init(&compressed[s][a]);

    /* ── 1. Compression ratio & speed ─────────────────────────── */
    printf("=== Compression ratio & speed ===\n\n");
    printf("%-10s %-16s %10s %12s %8s %12s %12s\n",
           "Algorithm", "Sample", "Original", "Compressed", "Ratio", "Comp MB/s", "Decomp MB/s");
    for (int i = 0; i < 82; i++) putchar('-');
    putchar('\n');

    for (int s = 0; s < loaded; s++) {
        for (size_t a = 0; a < N_ALGOS; a++) {
            Buffer *comp = &compressed[s][a];
            Buffer decomp_buf;
            buf_init(&decomp_buf);

            double best_comp = 1e9;
            for (int run = 0; run < 3; run++) {
                comp->len = 0;
                double t0 = now_sec();
                do_compress(&ALGOS[a], samples[s].data, samples[s].len, comp);
                double dt = now_sec() - t0;
                if (dt < best_comp) best_comp = dt;
            }

            double best_decomp = 1e9;
            for (int run = 0; run < 3; run++) {
                decomp_buf.len = 0;
                double t0 = now_sec();
                do_decompress(comp->data, comp->len, &decomp_buf);
                double dt = now_sec() - t0;
                if (dt < best_decomp) best_decomp = dt;
            }

            double ratio = (double)comp->len / samples[s].len * 100.0;
            double comp_speed = best_comp > 0.001 ? samples[s].len / 1048576.0 / best_comp : 0;
            double decomp_speed = best_decomp > 0.001 ? samples[s].len / 1048576.0 / best_decomp : 0;

            char hs_orig[32], hs_comp[32];
            human_size(hs_orig, sizeof(hs_orig), samples[s].len);
            human_size(hs_comp, sizeof(hs_comp), comp->len);

            printf("%-10s %-16s %10s %12s %7.1f%% %11.1f %11.1f\n",
                   ALGOS[a].name, samples[s].name, hs_orig, hs_comp,
                   ratio, comp_speed, decomp_speed);

            buf_free(&decomp_buf);
        }
    }
    printf("\n");

    /* ── 2. Streaming recoverability ──────────────────────────── */
    printf("=== Streaming recoverability ===\n\n");

    int trunc_pcts[] = {5, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95};
    int n_pcts = sizeof(trunc_pcts) / sizeof(trunc_pcts[0]);

    Buffer decomp_buf;
    buf_init(&decomp_buf);

    for (int s = 0; s < loaded; s++) {
        char hs[32];
        human_size(hs, sizeof(hs), samples[s].len);
        printf("-- %s (%s uncompressed) --\n", samples[s].name, hs);

        printf("%8s", "Written");
        for (size_t a = 0; a < N_ALGOS; a++)
            printf("  %18s", ALGOS[a].name);
        printf("\n");
        for (int i = 0; i < 8 + (int)N_ALGOS * 20; i++) putchar('-');
        putchar('\n');

        for (int p = 0; p < n_pcts; p++) {
            printf("%6d%% ", trunc_pcts[p]);
            for (size_t a = 0; a < N_ALGOS; a++) {
                Buffer *comp = &compressed[s][a];
                size_t trunc_bytes = comp->len * trunc_pcts[p] / 100;

                decomp_buf.len = 0;
                ssize_t recovered = do_decompress(comp->data, trunc_bytes, &decomp_buf);
                if (recovered < 0) recovered = 0;

                double recov_pct = (double)recovered / samples[s].len * 100.0;
                char hs2[32];
                human_size(hs2, sizeof(hs2), (size_t)recovered);
                printf("  %6.1f%% (%8s)", recov_pct, hs2);
            }
            printf("\n");
        }
        printf("%6d%%   (all algorithms: 100%%)\n\n", 100);
    }

    /* ── 3. First-byte latency ────────────────────────────────── */
    printf("=== First-byte latency ===\n\n");
    printf("%-10s %-16s %16s %16s\n",
           "Algorithm", "Sample", "Min compressed", "First recovery");
    for (int i = 0; i < 62; i++) putchar('-');
    putchar('\n');

    for (int s = 0; s < loaded; s++) {
        for (size_t a = 0; a < N_ALGOS; a++) {
            Buffer *comp = &compressed[s][a];
            size_t lo = 1, hi = comp->len;
            while (lo < hi) {
                size_t mid = (lo + hi) / 2;
                decomp_buf.len = 0;
                ssize_t r = do_decompress(comp->data, mid, &decomp_buf);
                if (r > 0) hi = mid; else lo = mid + 1;
            }

            decomp_buf.len = 0;
            ssize_t first_recovered = do_decompress(comp->data, lo, &decomp_buf);
            if (first_recovered < 0) first_recovered = 0;

            char hs_min[32], hs_rec[32];
            human_size(hs_min, sizeof(hs_min), lo);
            human_size(hs_rec, sizeof(hs_rec), (size_t)first_recovered);

            printf("%-10s %-16s %16s %16s\n",
                   ALGOS[a].name, samples[s].name, hs_min, hs_rec);
        }
    }
    printf("\n");

    /* ── 4. Effective block/frame sizes ───────────────────────── */
    printf("=== Effective block/frame sizes ===\n\n");

    for (int s = 0; s < loaded; s++) {
        printf("-- %s --\n", samples[s].name);
        printf("%-10s %8s %12s %12s %12s %12s\n",
               "Algorithm", "Blocks", "Min block", "Max block", "Avg block", "Median");
        for (int i = 0; i < 70; i++) putchar('-');
        putchar('\n');

        for (size_t a = 0; a < N_ALGOS; a++) {
            Buffer *comp = &compressed[s][a];
            size_t step = comp->len / 500;
            if (step < 1) step = 1;

            size_t prev_recovered = 0;
            size_t *block_sizes = NULL;
            int n_blocks = 0, block_cap = 0;

            for (size_t offset = step; offset <= comp->len; offset += step) {
                decomp_buf.len = 0;
                ssize_t r = do_decompress(comp->data, offset, &decomp_buf);
                size_t recovered = r > 0 ? (size_t)r : 0;
                if (recovered > prev_recovered) {
                    size_t block = recovered - prev_recovered;
                    if (n_blocks >= block_cap) {
                        block_cap = block_cap ? block_cap * 2 : 64;
                        block_sizes = realloc(block_sizes, sizeof(size_t) * block_cap);
                    }
                    block_sizes[n_blocks++] = block;
                    prev_recovered = recovered;
                }
            }
            if (prev_recovered < samples[s].len) {
                if (n_blocks >= block_cap) {
                    block_cap = block_cap ? block_cap * 2 : 64;
                    block_sizes = realloc(block_sizes, sizeof(size_t) * block_cap);
                }
                block_sizes[n_blocks++] = samples[s].len - prev_recovered;
            }

            if (n_blocks > 0) {
                for (int i = 0; i < n_blocks - 1; i++)
                    for (int j = i + 1; j < n_blocks; j++)
                        if (block_sizes[i] > block_sizes[j]) {
                            size_t tmp = block_sizes[i];
                            block_sizes[i] = block_sizes[j];
                            block_sizes[j] = tmp;
                        }

                size_t total = 0, min_b = block_sizes[0], max_b = block_sizes[0];
                for (int i = 0; i < n_blocks; i++) {
                    total += block_sizes[i];
                    if (block_sizes[i] < min_b) min_b = block_sizes[i];
                    if (block_sizes[i] > max_b) max_b = block_sizes[i];
                }

                char hs_min[32], hs_max[32], hs_avg[32], hs_med[32];
                human_size(hs_min, sizeof(hs_min), min_b);
                human_size(hs_max, sizeof(hs_max), max_b);
                human_size(hs_avg, sizeof(hs_avg), total / n_blocks);
                human_size(hs_med, sizeof(hs_med), block_sizes[n_blocks / 2]);

                printf("%-10s %8d %12s %12s %12s %12s\n",
                       ALGOS[a].name, n_blocks, hs_min, hs_max, hs_avg, hs_med);
            }
            free(block_sizes);
        }
        printf("\n");
    }

    /* Cleanup */
    buf_free(&decomp_buf);
    for (int s = 0; s < loaded; s++) {
        free(samples[s].data);
        for (size_t a = 0; a < N_ALGOS; a++)
            buf_free(&compressed[s][a]);
    }

    return 0;
}

The numbers point at zstd as the current champion, and it does seem to be universally well-liked today.

Granted, 128K is still a lot of text, so it's not a qualitative improvement, but it does seem to beat bzip2 in every aspect quantitatively.

Shall I amend this to switch compression of new logs to zstd?

@roberth
Copy link
Member

roberth commented Feb 6, 2026

This is a low risk strategy but also an incomplete solution. A complete solution would return everything at byte or line granularity, but this requires either an uncompressed buffer file on disk (and a good plan for sync) or an architectural change like daemon threads that share memory.

For sync I imagine you could use fresh buffer files for each e.g. 1 MB block and give them a sequence number. Only delete the uncompressed block when the compressed stream includes it in whole.
That way a reader can read everything by opening the oldest block (keeping it alive), then decompressing the compressed stream up to the uncompressed size that matches the block's starting point, then switch to uncompressed block.

Doesn't seem too complicated after all, but then I haven't implemented it.

This is not a review - just an idea.

@edolstra
Copy link
Member

zstd is probably the best choice, though it must be said that it often compresses surprisingly worse than bzip2, e.g.

$ wc -c /nix/var/log/nix/drvs/jg/98lh511qlnnbzzf42wyhnl3w57m24i-llvm-10.0.1.drv.bz2
308866 /nix/var/log/nix/drvs/jg/98lh511qlnnbzzf42wyhnl3w57m24i-llvm-10.0.1.drv.bz2

$ bunzip2 < /nix/var/log/nix/drvs/jg/98lh511qlnnbzzf42wyhnl3w57m24i-llvm-10.0.1.drv.bz2 | zstd -15 | wc -c
382369

So even at level 15 (which is still fast), zstd generates output that is 23% bigger than bzip2 in this example.

(Fun fact: at level 16-18, the output becomes bigger... Level 19 is a lot smaller (335623 bytes) but also much slower.)

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

Labels

documentation store Issues and pull requests concerning the Nix store with-tests Issues related to testing. PRs with tests have some priority

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants