build: pure-Rust zlib-rs as the default deflate backend

The core crates (clawhdf5, -agent, -format, -io, -filters, -ann, -accel,
-netcdf4, -cli) now build no C by default: deflate defaults to zlib-rs,
a pure-Rust port of zlib-ng, and zlib-ng becomes the opt-in
`fast-deflate`, which overrides zlib-rs wherever it is enabled. A default
build no longer needs cmake or a C compiler.

Measured on tank, both builds run alternately, three rounds, medians:
zlib-rs is within 6% of zlib-ng on every HDF5 read and write (512x512
deflate-6 chunked write 1.458 vs 1.484 ms; 64 MB compressed read 64.4
vs 65.2 ms), and compressed output is byte-identical. Details in
BENCHMARKS.md, "Deflate backend".

Getting there took two fixes the first measurement exposed:

- zlib-rs needs `std` to detect SIMD at runtime. flate2 enables it via
  its default `runtime_detection`, which `default-features = false` had
  switched off, leaving zlib-rs 3.5x slower on inflate. The `zlib-rs`
  features now enable it.
- Both deflate paths streamed through flate2's 32 KiB read/write
  wrappers. They now hand the codec the whole chunk in one call, into a
  buffer sized up front (~5% on chunked writes). This also fixes a
  silent short read: the streaming reader returned a truncated stream's
  bytes without an error; a truncated chunk is now DecompressionError.
  In clawhdf5-filters, output longer than the stated size is now an
  error rather than silently cut off.

CI: ci-test.sh lints and tests the zlib-ng path, and fails if a
C-building crate (*-sys, cc, cmake) enters a core crate's default
dependency tree. The arm64 job no longer installs cmake.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
osobh
2026-09-23 11:05:22 -05:00
co-authored by Claude Opus 5.5
parent e9b37a9602
commit 735db117a7
13 changed files with 471 additions and 113 deletions
+8 -2
View File
@@ -32,7 +32,10 @@ name = "bench"
harness = false
[features]
default = ["std", "checksum", "deflate", "provenance", "fast-deflate", "system-zlib-decompress"]
# Deflate backend: `zlib-rs` (pure Rust) by default. `fast-deflate` selects
# zlib-ng instead (C, built with cmake); flate2 prefers a C zlib whenever one
# is enabled, so turning it on anywhere in the build overrides the default.
default = ["std", "checksum", "deflate", "provenance", "zlib-rs", "system-zlib-decompress"]
std = []
checksum = []
deflate = ["flate2"]
@@ -42,7 +45,10 @@ fast-checksum = ["crc32fast"]
fast-deflate = ["flate2/zlib-ng"]
system-zlib = ["flate2/zlib-default"]
system-zlib-decompress = []
zlib-rs = ["flate2/zlib-rs"]
# `runtime_detection` gives zlib-rs `std`, which it needs to detect and use
# SIMD at runtime. flate2 enables it by default, but we build flate2 with
# default-features = false, and without it zlib-rs inflates 3.5x slower.
zlib-rs = ["flate2/zlib-rs", "flate2/runtime_detection"]
lz4 = ["lz4_flex"]
zstd = ["dep:zstd"]
blake3_hash = ["blake3"]
+165 -21
View File
@@ -629,21 +629,70 @@ fn deflate_decompress(data: &[u8], expected_bytes: usize) -> Result<Vec<u8>, For
// Fall through to flate2 on error
}
use std::io::Read;
let decoder = flate2::read::ZlibDecoder::new(data);
let mut result = Vec::with_capacity(limit.min(1 << 20));
// Read one byte past the limit so an over-size stream is distinguishable
// A chunk's decompressed size is known, so allocate it once; without one,
// start from a multiple of the input and grow.
let size_hint = if expected_bytes != 0 {
expected_bytes
} else {
data.len().saturating_mul(4).min(1 << 20)
};
inflate_bounded(data, size_hint, limit).map_err(FormatError::DecompressionError)
}
/// Inflate a zlib stream into a buffer sized up front, handing the decoder the
/// whole input at once.
///
/// `flate2::read::ZlibDecoder` feeds its input through a 32 KiB buffer and
/// grows the output as it goes; on single chunks that cost zlib-rs up to 3.7x
/// against zlib-ng (`BENCHMARKS.md`, "Deflate backend"). Output beyond `limit`
/// is an error, as is a stream that ends before its end-of-stream marker (the
/// streaming reader returned the bytes it had and no error).
#[cfg(feature = "deflate")]
pub(crate) fn inflate_bounded(
data: &[u8],
size_hint: usize,
limit: usize,
) -> Result<Vec<u8>, String> {
use flate2::{Decompress, FlushDecompress, Status};
// One byte of headroom past the limit distinguishes an over-size stream
// from one that legitimately ends exactly at the limit.
decoder
.take(limit as u64 + 1)
.read_to_end(&mut result)
.map_err(|e| FormatError::DecompressionError(e.to_string()))?;
if result.len() > limit {
return Err(FormatError::DecompressionError(
"deflate: output exceeds size limit".into(),
));
let max_capacity = limit.saturating_add(1);
let mut out = Vec::new();
out.try_reserve_exact(size_hint.clamp(1, max_capacity))
.map_err(|e| format!("deflate: cannot allocate output: {e}"))?;
let mut inflater = Decompress::new(true);
loop {
let (in_before, out_before) = (inflater.total_in(), inflater.total_out());
let status = inflater
.decompress_vec(
&data[in_before as usize..],
&mut out,
FlushDecompress::Finish,
)
.map_err(|e| format!("deflate: {e}"))?;
if out.len() > limit {
return Err("deflate: output exceeds size limit".into());
}
match status {
Status::StreamEnd => return Ok(out),
Status::Ok | Status::BufError if out.len() == out.capacity() => {
// Out of room: double, up to the limit.
let grow = out.capacity().min(max_capacity - out.capacity()).max(1);
out.try_reserve_exact(grow)
.map_err(|e| format!("deflate: cannot allocate output: {e}"))?;
}
Status::Ok | Status::BufError => {
// Room left, so the decoder stopped for want of input.
if inflater.total_in() as usize >= data.len()
|| (inflater.total_in(), inflater.total_out()) == (in_before, out_before)
{
return Err("deflate: truncated stream".into());
}
}
}
}
Ok(result)
}
/// Direct FFI to Apple's system libz for fast decompression.
@@ -722,14 +771,41 @@ fn deflate_decompress(_data: &[u8], _expected_bytes: usize) -> Result<Vec<u8>, F
/// Compress data with zlib.
#[cfg(feature = "deflate")]
fn deflate_compress(data: &[u8], level: u32) -> Result<Vec<u8>, FormatError> {
use std::io::Write;
let mut encoder = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::new(level));
encoder
.write_all(data)
.map_err(|e| FormatError::CompressionError(e.to_string()))?;
encoder
.finish()
.map_err(|e| FormatError::CompressionError(e.to_string()))
deflate_bounded(data, level).map_err(FormatError::CompressionError)
}
/// Deflate `data` into a zlib stream in one pass, into a buffer sized for the
/// worst case up front (the same reasoning as [`inflate_bounded`]).
#[cfg(feature = "deflate")]
pub(crate) fn deflate_bounded(data: &[u8], level: u32) -> Result<Vec<u8>, String> {
use flate2::{Compress, Compression, FlushCompress, Status};
// zlib's compressBound, plus the zlib header and trailer.
let bound = data.len() + (data.len() >> 12) + (data.len() >> 14) + (data.len() >> 25) + 13 + 6;
let mut out = Vec::new();
out.try_reserve_exact(bound)
.map_err(|e| format!("deflate: cannot allocate output: {e}"))?;
let mut deflater = Compress::new(Compression::new(level), true);
loop {
let (in_before, out_before) = (deflater.total_in(), deflater.total_out());
let status = deflater
.compress_vec(&data[in_before as usize..], &mut out, FlushCompress::Finish)
.map_err(|e| format!("deflate: {e}"))?;
match status {
Status::StreamEnd => return Ok(out),
// The bound should make running out of room unreachable; grow
// rather than fail if it happens.
Status::Ok | Status::BufError if out.len() == out.capacity() => out
.try_reserve(out.capacity().max(4096))
.map_err(|e| format!("deflate: cannot allocate output: {e}"))?,
Status::Ok | Status::BufError => {
if (deflater.total_in(), deflater.total_out()) == (in_before, out_before) {
return Err("deflate: encoder made no progress".into());
}
}
}
}
}
#[cfg(not(feature = "deflate"))]
@@ -1833,6 +1909,74 @@ mod tests {
assert!(deflate_decompress(&compressed, 64).is_err());
}
#[cfg(feature = "deflate")]
fn noisy_bytes(n: usize) -> Vec<u8> {
// Compressible but not trivially so.
(0..n)
.map(|i| ((i as f64 * 0.01).sin() * 127.0 + 128.0) as u8 ^ (i as u8 & 3))
.collect()
}
#[test]
#[cfg(feature = "deflate")]
fn deflate_decompress_accepts_output_exactly_at_chunk_size() {
let data = noisy_bytes(100_000);
let compressed = deflate_compress(&data, 6).unwrap();
assert_eq!(deflate_decompress(&compressed, data.len()).unwrap(), data);
// One byte short of the real size is over the limit.
assert!(deflate_decompress(&compressed, data.len() - 1).is_err());
}
#[test]
#[cfg(feature = "deflate")]
fn deflate_decompress_without_size_grows_the_buffer() {
// No chunk size: the output starts at 4x the input and has to grow.
let data = vec![7u8; 3 * 1024 * 1024];
let compressed = deflate_compress(&data, 6).unwrap();
assert!(compressed.len() * 4 < data.len());
assert_eq!(deflate_decompress(&compressed, 0).unwrap(), data);
}
#[test]
#[cfg(feature = "deflate")]
fn deflate_decompress_rejects_truncated_stream() {
// The streaming reader this replaced returned the bytes it had and no
// error, so a truncated chunk read back short.
let data = noisy_bytes(100_000);
let compressed = deflate_compress(&data, 6).unwrap();
for cut in [compressed.len() - 1, compressed.len() / 2, 3] {
assert!(
deflate_decompress(&compressed[..cut], data.len()).is_err(),
"truncated to {cut} of {} bytes",
compressed.len()
);
}
}
#[test]
#[cfg(feature = "deflate")]
fn deflate_compress_roundtrips_incompressible_data() {
// Random-looking input compresses to slightly more than it started
// as; the output must still fit the pre-sized buffer (or grow).
let mut x = 0x9E37_79B9_7F4A_7C15u64;
let data: Vec<u8> = (0..200_000)
.map(|_| {
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
x as u8
})
.collect();
for level in [0, 1, 6, 9] {
let compressed = deflate_compress(&data, level).unwrap();
assert_eq!(deflate_decompress(&compressed, data.len()).unwrap(), data);
}
assert_eq!(
deflate_decompress(&deflate_compress(&[], 6).unwrap(), 0).unwrap(),
Vec::<u8>::new()
);
}
#[test]
#[cfg(feature = "zstd")]
fn zstd_decompress_rejects_output_exceeding_chunk_size() {