001 Notes

miniz Optimization: Checksums Win Before the Codec Does

0. Abstract

This note looks at a familiar compression-library pattern:

the checksum gets much faster first
the full codec improves only where that checksum or a safe copy path is visible

The measurements use miniz-style CRC-32, Adler-32, compression, decompression, match comparison, copy, and prefetch experiments on an AMD Ryzen 9 5900HX. They are also compared against an Apple M4 Pro optimization pass to separate the portable lessons from the machine-specific ones.

The headline result is simple:

AreaBest useful result
CRC-32 on Ryzen5.34x with portable slice-by-8
Adler-32 on Ryzen4.46x with AVX2 weighted sums
Compression on 64 MiB text1.17x
Decompression on 64 MiB text2.12x
AVX2 match compare microbench4.51x
Custom AVX2 copyslower than libc memcpy
Simple prefetch chain walkslower than no prefetch

The useful lesson is not “SIMD always wins.” The useful lesson is narrower:

SIMD wins when the operation is wide, regular, and semantically correct.
It does not rescue serial parsing, the wrong polynomial, bad prefetch distance,
or a worse memcpy than libc already ships.

1. Test Platform

The AMD measurements below were taken on:

ItemValue
CPUAMD Ryzen 9 5900HX with Radeon Graphics
Architecturex86_64
OSLinux 7.0.0-22-generic
CompilerGCC 15.2.0
Relevant CPU featuresSSE4.2, SSSE3, AVX2, PCLMULQDQ, VPCLMULQDQ
Raw rows192 benchmark rows

The comparison Apple system was an Apple M4 Pro on macOS 15.5. Treat absolute throughput as machine-specific. The durable part is the pattern of which work can be widened and which work remains serial.

2. What Was Optimized

The experiments fall into four groups:

GroupBaselineCandidate optimization
CRC-32byte/table CRC-32slice-by-8 CRC-32
Adler-32unrolled scalar Adler-32scalar 16x, SSSE3 weighted sums, AVX2 weighted sums
Compressionlevel-6 codec pathprefetch hints and AVX2 long-match comparison
Decompressioncodec path with checksum/copy workoptimized Adler updates and guarded 64-byte AVX2 copy

One tempting CRC path is deliberately rejected:

x86 SSE4.2 CRC instructions compute CRC32C.
zlib/miniz CRC-32 is a different polynomial.
Fast and wrong is still wrong.

Those rows are useful because they prevent a common optimization mistake: confusing “hardware CRC instruction exists” with “hardware CRC instruction matches this checksum.”

The snippets below show the hot-path shape. They omit table generation, feature dispatch, and benchmark scaffolding so the important part stays visible.

CRC-32 slice-by-8

The accepted CRC-32 path keeps the zlib/miniz polynomial and widens the table lookup. Eight bytes are consumed per loop, using precomputed tables for the same CRC-32 definition as the baseline.

static uint32_t crc32_slice_by_8(const uint8_t* p, size_t n, uint32_t crc) {
  crc = ~crc;

  while (n >= 8) {
    uint32_t lo;
    uint32_t hi;
    memcpy(&lo, p, sizeof(lo));
    memcpy(&hi, p + 4, sizeof(hi));

    crc ^= lo;
    crc =
        crc_table[7][crc & 0xff] ^
        crc_table[6][(crc >> 8) & 0xff] ^
        crc_table[5][(crc >> 16) & 0xff] ^
        crc_table[4][crc >> 24] ^
        crc_table[3][hi & 0xff] ^
        crc_table[2][(hi >> 8) & 0xff] ^
        crc_table[1][(hi >> 16) & 0xff] ^
        crc_table[0][hi >> 24];

    p += 8;
    n -= 8;
  }

  while (n-- != 0) {
    crc = crc_table[0][(crc ^ *p++) & 0xff] ^ (crc >> 8);
  }

  return ~crc;
}

The rejected SSE4.2 idea looks attractive because it is tiny and very fast:

static uint32_t crc32c_not_zlib_crc32(const uint8_t* p, size_t n) {
  uint64_t crc = 0xffffffffu;

  while (n >= 8) {
    uint64_t word;
    memcpy(&word, p, sizeof(word));
    crc = _mm_crc32_u64(crc, word);
    p += 8;
    n -= 8;
  }

  while (n-- != 0) {
    crc = _mm_crc32_u8(static_cast<uint32_t>(crc), *p++);
  }

  return static_cast<uint32_t>(~crc);
}

That computes CRC32C, not zlib CRC-32. It is a benchmark result, but not a replacement.

Adler-32 weighted sums

Adler-32 can be widened because a block update is just a byte sum plus a weighted byte sum. For a 32-byte block:

static inline void adler32_avx2_chunk32(
    const uint8_t* p,
    uint32_t* s1,
    uint32_t* s2) {
  const __m256i bytes = _mm256_loadu_si256(
      reinterpret_cast<const __m256i*>(p));
  const __m256i zero = _mm256_setzero_si256();
  const __m256i weights = _mm256_setr_epi8(
      32, 31, 30, 29, 28, 27, 26, 25,
      24, 23, 22, 21, 20, 19, 18, 17,
      16, 15, 14, 13, 12, 11, 10, 9,
      8, 7, 6, 5, 4, 3, 2, 1);
  const __m256i ones = _mm256_set1_epi16(1);

  const uint32_t byte_sum =
      horizontal_sum_epu64(_mm256_sad_epu8(bytes, zero));
  const __m256i pair_weighted = _mm256_maddubs_epi16(bytes, weights);
  const uint32_t weighted_sum = horizontal_sum_epi32(
      _mm256_madd_epi16(pair_weighted, ones));

  *s2 += 32 * *s1 + weighted_sum;
  *s1 += byte_sum;
  *s1 %= 65521;
  *s2 %= 65521;
}

The exact implementation detail is less important than the recurrence: update s1 with the plain byte sum and update s2 with the old s1 plus weighted bytes.

3. Checksum Results

Random data, median throughput:

AlgorithmVariantSizeMB/sMedian msCorrect
crc32miniz table1 MiB441.12.267true
crc32slice-by-81 MiB2363.80.423true
crc32SSE4.2 CRC32C experiment1 MiB9511.30.105false
adler32miniz 8x1 MiB3158.80.317true
adler32scalar 16x1 MiB3415.90.293true
adler32SSSE3 weighted1 MiB12656.80.079true
adler32AVX2 weighted1 MiB15259.20.066true
crc32miniz table64 MiB438.4145.985true
crc32slice-by-864 MiB2341.127.337true
crc32SSE4.2 CRC32C experiment64 MiB9113.87.022false
adler32miniz 8x64 MiB3107.720.594true
adler32scalar 16x64 MiB3317.819.290true
adler32SSSE3 weighted64 MiB11655.65.491true
adler32AVX2 weighted64 MiB13851.04.621true

The valid 64 MiB checksum wins are:

AlgorithmBaselineOptimizedSpeedup
CRC-32 slice-by-8438.4 MB/s2341.1 MB/s5.34x
Adler-32 AVX23107.7 MB/s13851.0 MB/s4.46x

This is the cleanest part of the optimization story. Checksums are simple streams over bytes. Their state is small, their memory access is regular, and their correctness can be checked against many buffer shapes.

4. Codec Results

Checksums are not the whole compressor. A codec also has match finding, bitstream work, branchy decisions, copies, and data-dependent control flow. That makes the full result smaller and more dataset-dependent.

Level 6, 64 MiB rows:

OperationDatasetBaselineAMD experimentSpeedup
compresszeros388.9 MB/s438.4 MB/s1.13x
compresstext401.3 MB/s469.1 MB/s1.17x
compressblocks124.0 MB/s120.4 MB/s0.97x
compressrandom33.1 MB/s33.2 MB/s1.00x
decompresszeros455.1 MB/s531.3 MB/s1.17x
decompresstext2167.9 MB/s4588.8 MB/s2.12x
decompressblocks1877.1 MB/s3248.8 MB/s1.73x
decompressrandom2225.6 MB/s4650.9 MB/s2.09x

Compression barely moves on random data because there is little useful structure to compress. It also loses slightly on the blocks dataset, which is the kind of result that keeps optimization honest: a fast path can still make a different workload worse.

Decompression improves more because the optimized checksum work and guarded copy path are easier to expose on the read side. That does not mean every decompressor should grow hand-written SIMD everywhere. It means this measured path had visible checksum/copy work left to reduce.

5. Microbenchmarks

The isolated microbenchmarks explain why some ideas helped and some did not:

OperationVariantWork sizeMB/sMedian msCorrect
match comparescalar671088642741.623.344true
match compareAVX26710886412356.85.179true
copylibc memcpy6710886412436.95.146true
copyAVX2 64-byte671088646365.610.054true
prefetch chainno prefetch10485764406.73.631true
prefetch chainbuiltin prefetch10485764131.13.873true

The AVX2 match comparison win is real in isolation:

scalar compare: 2741.6 MB/s
AVX2 compare:  12356.8 MB/s

The match comparison hot path is exactly the kind of work SIMD likes: load two regular byte ranges, compare them, and stop at the first mismatch.

static size_t equal_prefix_avx2(
    const uint8_t* a,
    const uint8_t* b,
    size_t limit) {
  size_t i = 0;

  while (i + 32 <= limit) {
    const __m256i va = _mm256_loadu_si256(
        reinterpret_cast<const __m256i*>(a + i));
    const __m256i vb = _mm256_loadu_si256(
        reinterpret_cast<const __m256i*>(b + i));
    const __m256i eq = _mm256_cmpeq_epi8(va, vb);
    const uint32_t mask =
        static_cast<uint32_t>(_mm256_movemask_epi8(eq));

    if (mask != 0xffffffffu) {
      return i + static_cast<size_t>(__builtin_ctz(~mask));
    }

    i += 32;
  }

  while (i < limit && a[i] == b[i]) {
    ++i;
  }

  return i;
}

But the copy and prefetch results are the caution sign:

  • libc memcpy beat the simple custom AVX2 copy by about 1.95x;
  • prefetching the chain walk was slower than not prefetching;
  • isolated wins only matter when the full codec exposes enough of that work.

The losing copy experiment was not mysterious. It was a simple fixed-width copy, and libc already had a better implementation on this machine.

static inline void copy64_avx2(uint8_t* dst, const uint8_t* src) {
  const __m256i a = _mm256_loadu_si256(
      reinterpret_cast<const __m256i*>(src));
  const __m256i b = _mm256_loadu_si256(
      reinterpret_cast<const __m256i*>(src + 32));

  _mm256_storeu_si256(reinterpret_cast<__m256i*>(dst), a);
  _mm256_storeu_si256(reinterpret_cast<__m256i*>(dst + 32), b);
}

The prefetch experiment had the same lesson. Adding a prefetch instruction is not the same thing as hiding latency:

for (size_t i = 0; i < chain_length; ++i) {
  const Link* next = cursor->next;

  if (next != nullptr) {
    __builtin_prefetch(next, 0, 1);
  }

  checksum += cursor->value;
  cursor = next;
}

That pattern only helps when the next address is known early enough and the prefetch distance matches the machine.

6. Apple Silicon Comparison

On Apple M4 Pro, the largest wins were also checksum-focused:

ComponentBaselineOptimizedSpeedup
CRC-32 ARM hardware445 MB/s47,300 MB/s106x
CRC-32 slice-by-8445 MB/s2,135 MB/s4.8x
Adler-32 NEON3,408 MB/s24,900 MB/s7.3x
String compareabout 500 MB/sabout 2,000 MB/s4x
Compression800 MB/sabout 880 MB/s1.1x
Decompression2,000 MB/sabout 2,200 MB/s1.1x

The architecture-specific details differ, but the shape is the same:

checksums and regular byte scans widen well
full compression gains are limited by serial, branchy codec work
custom copy code can lose to platform libc

7. Correctness Rules

These optimizations only count when they preserve the byte stream.

The checksum tests cover empty, short, unaligned, random, repeated, and large buffers. Incremental checksum calls are compared with one-shot calls. Codec rows are accepted only when decompression reproduces the original bytes.

The checksum validation has a simple shape:

static bool same_crc32_behavior(const uint8_t* p, size_t n) {
  const uint32_t one_shot_ref = crc32_reference(0, p, n);
  const uint32_t one_shot_new = crc32_candidate(0, p, n);

  const size_t split = n / 2;
  uint32_t incremental = crc32_candidate(0, p, split);
  incremental = crc32_candidate(incremental, p + split, n - split);

  return one_shot_new == one_shot_ref &&
         incremental == one_shot_ref;
}

That is why the SSE4.2 CRC row is marked incorrect even though it is very fast. It answers a different checksum question.

The rule is:

measure speed only after proving the replacement is semantically identical

8. Practical Takeaways

For miniz-style codec optimization, the useful order is:

  1. Measure checksums separately.
  2. Replace CRC-32 with a correct wider CRC path, not a CRC32C path.
  3. Replace Adler-32 with a vectorized weighted-sum path where available.
  4. Measure codec-level impact per dataset.
  5. Keep libc memcpy unless a custom copy wins on the target machine.
  6. Treat prefetch as a measured distance problem, not a magic annotation.
  7. Reject fast rows that fail correctness.

The big wins were real, but they were not evenly distributed. Checksums were wide and regular, so they moved a lot. Compression remained branchy and data-dependent, so it moved a little. Decompression landed in the middle because checksum and safe-copy work were easier to expose.

That is the pattern worth carrying forward:

optimize where the work is regular enough to widen
verify that the widened work is the same work
then measure whether the full system actually notices

Comments