Skip to main content

stuffr_cli/bench/
measure.rs

1//! The timing harness for `stuffr bench`: one format at one level, run over
2//! the user's input, compressed and decompressed, with only codec work
3//! inside a timed region, except the temp file's writes when the output is
4//! too large to keep in memory (below).
5//!
6//! A compression region holds encoder creation, `write_all` of the input
7//! and `Sink::finish` (which consumes the sink, so its teardown is inside
8//! too). A decompression region holds decoder creation and reading the
9//! decoder to its end into a buffer whose bytes are only counted. Building
10//! the destination, opening files, reading streamed input, the temp file's
11//! creation and removal, and dropping the decoder are all outside.
12//!
13//! In memory, each region is one pair of clock reads. Streamed from disk,
14//! the input's reads happen between the encoder's writes, so compression is
15//! the sum of several regions (encoder creation, each chunk's `write_all`,
16//! `finish`); decompression is still one region, the decoder's own reads of
17//! the compressed temp file being part of decoding.
18//!
19//! The compressed output stays in memory whenever its worst case (input +
20//! 1/8 + 1 KiB, the same test for every codec) fits under the memory left
21//! for it: half of `--memory-limit` when the input is held in memory, all
22//! of it when the input is streamed. Only past that does it go to one temp
23//! file, and then its writes (to the page cache, through the 64 KiB
24//! buffer) fall inside the compression regions. Both destinations have the same shape: a 64 KiB
25//! `BufWriter` over a pre-reserved `Vec`, or over the temp file, so a codec
26//! that writes in small pieces pays the same either way. Decoding reads the
27//! same shape back: the `Vec` as a seekable, sized source, or the temp file
28//! through a 64 KiB `BufReader`, so a decoder that reads a byte at a time
29//! (LZW's) costs a memory copy per byte, not a system call.
30//!
31//! Each file's length is taken once, when the input is loaded; every later
32//! read stops there, so a file still being written is benched as it was.
33//!
34//! bench ignores STUFFR_THREADS; threads come only from --threads.
35//!
36//! With streamed input and `--threads` above 1, a multi-threaded encoder's
37//! workers keep running during the untimed input reads between regions, so
38//! its compression time can be under-reported against a single-threaded
39//! one. In memory there are no such gaps.
40
41use std::fs::{self, File};
42use std::io::{self, BufReader, BufWriter, Cursor, Read, Seek, SeekFrom, Write};
43use std::path::{Path, PathBuf};
44use std::sync::atomic::{AtomicU32, Ordering};
45use std::sync::{Arc, Mutex};
46use std::time::Instant;
47
48use stuffr::ops::{CompressOpts, resolved_budget};
49use stuffr::source::{GuardedSeek, SeekRead, Source, SourceCaps};
50use stuffr::{Codec, DecodeOpts, EncodeOpts, Error, FormatKind, Governor};
51
52use super::score::{Measured, Outcome};
53use crate::compat::{CleanupGuard, create_armed};
54
55/// The copy buffer, as `ops` uses.
56const CHUNK: usize = 64 * 1024;
57
58/// A monotonic clock in nanoseconds; injected so tests can count its reads.
59pub trait Clock {
60    fn now(&mut self) -> u64;
61}
62
63/// The real clock: nanoseconds since it was made.
64pub struct RealClock(Instant);
65
66impl RealClock {
67    pub fn new() -> Self {
68        Self(Instant::now())
69    }
70}
71
72impl Default for RealClock {
73    fn default() -> Self {
74        Self::new()
75    }
76}
77
78impl Clock for RealClock {
79    fn now(&mut self) -> u64 {
80        u64::try_from(self.0.elapsed().as_nanos()).unwrap_or(u64::MAX)
81    }
82}
83
84/// The input, in operand order: held in memory (one buffer per file) or
85/// streamed from the files.
86#[derive(Debug)]
87pub enum Input {
88    Memory(Vec<Vec<u8>>),
89    Files(Vec<Snapshot>),
90}
91
92/// A streamed file and its length when the input was loaded: every read of
93/// it stops at `len`, so bytes appended later are never benched.
94#[derive(Clone, Debug, PartialEq, Eq)]
95pub struct Snapshot {
96    pub path: PathBuf,
97    pub len: u64,
98}
99
100impl Snapshot {
101    /// The file, opened and limited to its loaded length.
102    fn open(&self) -> io::Result<io::Take<File>> {
103        Ok(File::open(&self.path)?.take(self.len))
104    }
105}
106
107/// One stream of the files concatenated, or one stream per file.
108#[derive(Clone, Copy, Debug, PartialEq, Eq)]
109pub enum Mode {
110    OneStream,
111    PerFile,
112}
113
114/// The conditions every row runs under.
115#[derive(Clone, Copy, Debug, PartialEq, Eq)]
116pub struct Plan {
117    pub mode: Mode,
118    /// Runs per row; the reported times are the medians. 0 counts as 1.
119    pub repeat: u32,
120    /// As `pack --threads`.
121    pub threads: Option<usize>,
122    pub memory_limit: u64,
123}
124
125/// Loads `paths` into memory when their total size is at most half of
126/// `memory_limit`, leaving room for the compressed copy; otherwise the input
127/// is streamed from the files.
128///
129/// Also returns the input's size, measured here once: the bytes read into
130/// memory, or the files' sizes when streamed. It is the ratio's denominator,
131/// and each file's length is snapshotted with it (reads stop there), so a
132/// file still growing while the rows run changes neither.
133pub fn load_input(paths: &[PathBuf], memory_limit: u64) -> io::Result<(Input, u64)> {
134    let mut total = 0u64;
135    let mut snaps = Vec::with_capacity(paths.len());
136    for p in paths {
137        let len = fs::metadata(p)?.len();
138        total = total.saturating_add(len);
139        snaps.push(Snapshot {
140            path: p.clone(),
141            len,
142        });
143    }
144    if total > memory_limit / 2 {
145        return Ok((Input::Files(snaps), total));
146    }
147    let mut parts = Vec::with_capacity(snaps.len());
148    for s in &snaps {
149        let mut buf = Vec::with_capacity(usize::try_from(s.len).unwrap_or(0));
150        s.open()?.read_to_end(&mut buf)?;
151        parts.push(buf);
152    }
153    let read = parts.iter().map(|b| b.len() as u64).sum();
154    Ok((Input::Memory(parts), read))
155}
156
157/// One untimed read of every file, so the first timed run does not pay for
158/// a cold cache. Nothing to do for input already in memory.
159pub fn warm(input: &Input) -> io::Result<()> {
160    if let Input::Files(files) = input {
161        let mut buf = vec![0u8; CHUNK];
162        for s in files {
163            let mut f = s.open()?;
164            while read_some(&mut f, &mut buf)? > 0 {}
165        }
166    }
167    Ok(())
168}
169
170/// Compresses and decompresses `input` with `codec` at `level` (`None`: the
171/// encoder's own default) under `plan`. Every error, a rejected level
172/// included, is a failed row; nothing panics.
173pub fn measure(
174    codec: &str,
175    level: Option<i32>,
176    input: &Input,
177    plan: &Plan,
178    clock: &mut dyn Clock,
179) -> Outcome {
180    match run(codec, level, input, plan, clock) {
181        Ok(m) => Outcome::Ok(m),
182        Err(e) => Outcome::Failed(e.to_string()),
183    }
184}
185
186/// One file's input as the harness reads it.
187#[derive(Clone, Copy)]
188enum Part<'a> {
189    Memory(&'a [u8]),
190    File(&'a Snapshot),
191}
192
193impl Part<'_> {
194    /// The length the input was loaded at, never re-read from the file.
195    fn len(&self) -> u64 {
196        match self {
197            Part::Memory(b) => b.len() as u64,
198            Part::File(s) => s.len,
199        }
200    }
201}
202
203/// Where one stream's compressed output goes. The same for every codec, so
204/// no format writes to disk while another writes to memory.
205#[derive(Clone, Copy, Debug, PartialEq, Eq)]
206enum Dest {
207    Memory,
208    TempFile,
209}
210
211/// Memory whenever the worst-case output of `input_len` bytes (+ 1/8 +
212/// 1 KiB) fits in the memory left for it: half of `memory_limit` beside
213/// input held in memory, all of it when the input is `streamed` (it holds
214/// no copy of the input). The temp file only past that.
215fn destination(input_len: u64, memory_limit: u64, streamed: bool) -> Dest {
216    // Small test inputs always fit; this is how the tests reach the temp
217    // file with a memory limit the decoders can still work under.
218    #[cfg(test)]
219    if tests::FORCE_DISK.with(std::cell::Cell::get) {
220        return Dest::TempFile;
221    }
222    // Checked, not saturating: a worst case past u64 never fits.
223    let worst = input_len
224        .checked_add(input_len / 8)
225        .and_then(|n| n.checked_add(1024));
226    let budget = if streamed {
227        memory_limit
228    } else {
229        memory_limit / 2
230    };
231    match worst {
232        Some(w) if w <= budget => Dest::Memory,
233        _ => Dest::TempFile,
234    }
235}
236
237/// The capacity reserved for `codec`'s output in memory, large enough that
238/// the `Vec` never reallocates inside a timed region. LZW (`compress`)
239/// writes no code wider than 16 bits and every code stands for at least one
240/// input byte, so it never writes more than two bytes per input byte (plus
241/// its 3-byte header and the padding at each code-width step); every other
242/// codec stays within 1/8 + 1 KiB, which
243/// `the_reserved_room_holds_incompressible_output` measures. Reserved pages
244/// a codec never writes are never touched.
245fn output_room(codec: &str, input_len: u64) -> u64 {
246    match codec {
247        "compress" => input_len.saturating_mul(2).saturating_add(1024),
248        _ => input_len.saturating_add(input_len / 8).saturating_add(1024),
249    }
250}
251
252fn run(
253    name: &str,
254    level: Option<i32>,
255    input: &Input,
256    plan: &Plan,
257    clock: &mut dyn Clock,
258) -> stuffr::Result<Measured> {
259    let reg = stuffr::registry();
260    let id = reg
261        .matrix()
262        .into_iter()
263        .find(|r| r.kind == FormatKind::Codec && r.id.as_str() == name)
264        .map(|r| r.id)
265        .ok_or_else(|| Error::Usage(format!("`{name}` is not a codec in this build")))?;
266    let codec = reg.require_encoder(id)?;
267    reg.require_decoder(id)?;
268
269    let encode = EncodeOpts {
270        level,
271        governor: governor_for(plan),
272        ..Default::default()
273    };
274    codec.check_encode_opts(&encode)?;
275    let decode = DecodeOpts {
276        memory_limit: Some(plan.memory_limit),
277        ..Default::default()
278    };
279
280    let parts: Vec<Part<'_>> = match input {
281        Input::Memory(bufs) => bufs.iter().map(|b| Part::Memory(b)).collect(),
282        Input::Files(files) => files.iter().map(Part::File).collect(),
283    };
284    let groups: Vec<&[Part<'_>]> = match plan.mode {
285        Mode::OneStream => vec![&parts[..]],
286        Mode::PerFile => parts.chunks(1).collect(),
287    };
288    let group_len = |g: &[Part<'_>]| g.iter().map(Part::len).sum::<u64>();
289    let streamed = matches!(input, Input::Files(_));
290    let dest = |g: &[Part<'_>]| destination(group_len(g), plan.memory_limit, streamed);
291    let temp = if groups.iter().any(|g| dest(g) == Dest::TempFile) {
292        Some(TempOut::create()?.0)
293    } else {
294        None
295    };
296
297    let mut buf = vec![0u8; CHUNK];
298    let mut bytes_out = None;
299    let mut compress = Vec::new();
300    let mut decompress = Vec::new();
301    for _ in 0..plan.repeat.max(1) {
302        let (mut out, mut c_ns, mut d_ns) = (0u64, 0u64, 0u64);
303        for group in &groups {
304            let expected = group_len(group);
305            let job = Job {
306                name: id.as_str(),
307                codec: codec.as_ref(),
308                dest: dest(group),
309                encode: &encode,
310                decode: &decode,
311                temp: temp.as_ref(),
312                buf: &mut buf,
313            };
314            let row = job.run(group, expected, clock)?;
315            out += row.bytes_out;
316            c_ns += row.compress_ns;
317            d_ns += row.decompress_ns;
318        }
319        bytes_out.get_or_insert(out);
320        compress.push(c_ns);
321        decompress.push(d_ns);
322    }
323    Ok(Measured {
324        bytes_out: bytes_out.unwrap_or(0),
325        compress_ns: median(compress),
326        decompress_ns: median(decompress),
327    })
328}
329
330/// The encode governor for `plan`: `pack --threads`'s mapping, except that
331/// no `--threads` means single-threaded whatever `STUFFR_THREADS` says
332/// (`resolved_budget` would read it). With a CLI value the variable is
333/// already ignored: `resolve_workers` takes the CLI value first.
334pub fn governor_for(plan: &Plan) -> Option<Arc<Governor>> {
335    let t = plan.threads?;
336    resolved_budget(&CompressOpts {
337        threads: Some(t),
338        memory_limit: Some(plan.memory_limit),
339        ..Default::default()
340    })
341}
342
343/// The middle value; the lower of the two middles for an even count.
344fn median(mut v: Vec<u64>) -> u64 {
345    v.sort_unstable();
346    v.get(v.len().saturating_sub(1) / 2).copied().unwrap_or(0)
347}
348
349/// Everything one stream's round trip needs.
350struct Job<'a> {
351    name: &'a str,
352    codec: &'a dyn Codec,
353    dest: Dest,
354    encode: &'a EncodeOpts,
355    decode: &'a DecodeOpts,
356    temp: Option<&'a TempOut>,
357    buf: &'a mut [u8],
358}
359
360impl Job<'_> {
361    /// Compresses `group` as one stream, then decompresses it, checking the
362    /// round trip gives back `expected` bytes.
363    fn run(
364        mut self,
365        group: &[Part<'_>],
366        expected: u64,
367        clock: &mut dyn Clock,
368    ) -> stuffr::Result<Measured> {
369        let (bytes_out, compress_ns, src) = match (self.dest, self.temp) {
370            (Dest::Memory, _) | (Dest::TempFile, None) => {
371                // Room for incompressible output, so the outer Vec does not
372                // reallocate inside the region. (`TempFile` always comes
373                // with a temp file; `run` creates one whenever any group
374                // needs it.)
375                let room = output_room(self.name, expected);
376                let shared = SharedVec::with_capacity(usize::try_from(room).unwrap_or(0));
377                let dst = BufWriter::with_capacity(CHUNK, shared.clone());
378                let ns = self.compress(group, Box::new(dst), clock)?;
379                let bytes = shared.take();
380                let len = bytes.len() as u64;
381                (len, ns, Box::new(MemSource::new(bytes)) as Box<dyn Source>)
382            }
383            (Dest::TempFile, Some(temp)) => {
384                let file = File::options()
385                    .write(true)
386                    .truncate(true)
387                    .open(temp.path())?;
388                let dst = BufWriter::with_capacity(CHUNK, file);
389                let ns = self.compress(group, Box::new(dst), clock)?;
390                let file = File::open(temp.path())?;
391                let len = file.metadata()?.len();
392                let src = temp_source(GuardedSeek::new(file), len);
393                (len, ns, Box::new(src) as Box<dyn Source>)
394            }
395        };
396        let (decoded, decompress_ns) = decode(self.codec, self.decode, src, self.buf, clock)?;
397        if decoded != expected {
398            return Err(Error::Corrupt(format!(
399                "the round trip gave back {decoded} bytes of {expected}"
400            )));
401        }
402        Ok(Measured {
403            bytes_out,
404            compress_ns,
405            decompress_ns,
406        })
407    }
408
409    /// The compression regions' total. In memory: one region. Streamed: the
410    /// file reads fall between regions.
411    fn compress(
412        &mut self,
413        group: &[Part<'_>],
414        dst: Box<dyn Write + Send>,
415        clock: &mut dyn Clock,
416    ) -> stuffr::Result<u64> {
417        if let Some(bufs) = group
418            .iter()
419            .map(|p| match p {
420                Part::Memory(b) => Some(*b),
421                Part::File(_) => None,
422            })
423            .collect::<Option<Vec<&[u8]>>>()
424        {
425            let t0 = clock.now();
426            let mut sink = self.codec.encoder(dst, self.encode)?;
427            for b in &bufs {
428                for chunk in b.chunks(CHUNK) {
429                    sink.write_all(chunk)?;
430                }
431            }
432            sink.finish()?;
433            let t1 = clock.now();
434            return Ok(t1.saturating_sub(t0));
435        }
436
437        let mut files = Vec::with_capacity(group.len());
438        // Not in memory, so every part is a file: an input is one or the other.
439        for p in group {
440            if let Part::File(snap) = p {
441                files.push((snap, snap.open()?));
442            }
443        }
444        let t0 = clock.now();
445        let mut sink = self.codec.encoder(dst, self.encode)?;
446        let t1 = clock.now();
447        let mut ns = t1.saturating_sub(t0);
448        for (snap, f) in &mut files {
449            let mut read = 0u64;
450            loop {
451                let n = read_some(f, self.buf)?;
452                if n == 0 {
453                    if read < snap.len {
454                        return Err(Error::Io(io::Error::new(
455                            io::ErrorKind::UnexpectedEof,
456                            format!(
457                                "{} shrank to {read} bytes from {} while bench ran",
458                                snap.path.display(),
459                                snap.len
460                            ),
461                        )));
462                    }
463                    break;
464                }
465                read += n as u64;
466                let t0 = clock.now();
467                sink.write_all(&self.buf[..n])?;
468                let t1 = clock.now();
469                ns += t1.saturating_sub(t0);
470            }
471        }
472        let t0 = clock.now();
473        sink.finish()?;
474        let t1 = clock.now();
475        Ok(ns + t1.saturating_sub(t0))
476    }
477}
478
479/// One region: decoder creation and reading it to its end, the bytes only
480/// counted. Returns the count and the region's length.
481fn decode(
482    codec: &dyn Codec,
483    opts: &DecodeOpts,
484    src: Box<dyn Source>,
485    buf: &mut [u8],
486    clock: &mut dyn Clock,
487) -> stuffr::Result<(u64, u64)> {
488    let t0 = clock.now();
489    let mut decoder = codec.decoder(src, opts)?;
490    let mut total = 0u64;
491    loop {
492        match decoder.read(buf) {
493            Ok(0) => break,
494            Ok(n) => total += n as u64,
495            Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
496            Err(e) => return Err(Error::from_decode_io(e)),
497        }
498    }
499    let t1 = clock.now();
500    drop(decoder);
501    Ok((total, t1.saturating_sub(t0)))
502}
503
504/// `read`, retried on `Interrupted`.
505fn read_some(r: &mut impl Read, buf: &mut [u8]) -> io::Result<usize> {
506    loop {
507        match r.read(buf) {
508            Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
509            other => return other,
510        }
511    }
512}
513
514/// The in-memory destination: the encoder owns one handle, the harness
515/// keeps the other to take the bytes back after `finish`.
516#[derive(Clone)]
517struct SharedVec(Arc<Mutex<Vec<u8>>>);
518
519impl SharedVec {
520    fn with_capacity(n: usize) -> Self {
521        Self(Arc::new(Mutex::new(Vec::with_capacity(n))))
522    }
523
524    #[cfg(test)]
525    fn capacity(&self) -> usize {
526        self.0.lock().unwrap_or_else(|e| e.into_inner()).capacity()
527    }
528
529    fn take(&self) -> Vec<u8> {
530        std::mem::take(&mut *self.0.lock().unwrap_or_else(|e| e.into_inner()))
531    }
532}
533
534impl Write for SharedVec {
535    fn write(&mut self, b: &[u8]) -> io::Result<usize> {
536        self.0
537            .lock()
538            .unwrap_or_else(|e| e.into_inner())
539            .extend_from_slice(b);
540        Ok(b.len())
541    }
542
543    fn flush(&mut self) -> io::Result<()> {
544        Ok(())
545    }
546}
547
548/// Compressed bytes in memory, as seekable and sized as the [`BufSource`]
549/// over the temp file, so a decoder sees the same capabilities either way.
550struct MemSource {
551    cursor: Cursor<Vec<u8>>,
552    len: u64,
553}
554
555impl MemSource {
556    fn new(bytes: Vec<u8>) -> Self {
557        let len = bytes.len() as u64;
558        Self {
559            cursor: Cursor::new(bytes),
560            len,
561        }
562    }
563}
564
565impl Read for MemSource {
566    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
567        self.cursor.read(buf)
568    }
569}
570
571impl Seek for MemSource {
572    fn seek(&mut self, pos: SeekFrom) -> io::Result<u64> {
573        self.cursor.seek(pos)
574    }
575}
576
577impl Source for MemSource {
578    fn caps(&self) -> SourceCaps {
579        SourceCaps {
580            seekable: true,
581            len: Some(self.len),
582        }
583    }
584
585    fn as_seek(&mut self) -> Option<&mut dyn SeekRead> {
586        Some(self)
587    }
588}
589
590/// The temp file's compressed bytes as the decoder reads them: through a
591/// 64 KiB buffer, so a decoder reading a byte at a time pays a memory copy
592/// per byte, as it does from [`MemSource`], not a system call. Seekable and
593/// sized like `MemSource`.
594fn temp_source<R: Read + Seek + Send>(inner: R, len: u64) -> BufSource<R> {
595    BufSource {
596        inner: BufReader::with_capacity(CHUNK, inner),
597        len,
598    }
599}
600
601struct BufSource<R> {
602    inner: BufReader<R>,
603    len: u64,
604}
605
606impl<R: Read> Read for BufSource<R> {
607    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
608        self.inner.read(buf)
609    }
610}
611
612impl<R: Seek> Seek for BufSource<R> {
613    fn seek(&mut self, pos: SeekFrom) -> io::Result<u64> {
614        self.inner.seek(pos)
615    }
616}
617
618impl<R: Read + Seek + Send> Source for BufSource<R> {
619    fn caps(&self) -> SourceCaps {
620        SourceCaps {
621            seekable: true,
622            len: Some(self.len),
623        }
624    }
625
626    fn as_seek(&mut self) -> Option<&mut dyn SeekRead> {
627        Some(self)
628    }
629}
630
631/// The next temp file's `<n>`; also how the tests see whether a run made one.
632static TEMP_SEQ: AtomicU32 = AtomicU32::new(0);
633
634/// The streamed run's compressed output, `stuffr-bench-<pid>-<n>` in the
635/// temp directory: armed for removal on SIGINT, SIGTERM and SIGHUP while it
636/// lives, removed when it drops (a panic included).
637struct TempOut {
638    path: PathBuf,
639    /// Dropped after `drop` has removed the file, so there is no moment the
640    /// file exists unarmed.
641    _armed: CleanupGuard,
642}
643
644impl TempOut {
645    fn create() -> io::Result<(Self, File)> {
646        let dir = std::env::temp_dir();
647        let mut tries = 0;
648        loop {
649            let n = TEMP_SEQ.fetch_add(1, Ordering::Relaxed);
650            let path = dir.join(format!("stuffr-bench-{}-{n}", std::process::id()));
651            match create_armed(&path) {
652                Ok((file, armed)) => {
653                    return Ok((
654                        Self {
655                            path,
656                            _armed: armed,
657                        },
658                        file,
659                    ));
660                }
661                Err(e) if e.kind() == io::ErrorKind::AlreadyExists && tries < 1000 => tries += 1,
662                Err(e) => return Err(e),
663            }
664        }
665    }
666
667    fn path(&self) -> &Path {
668        &self.path
669    }
670}
671
672impl Drop for TempOut {
673    fn drop(&mut self) {
674        let _ = fs::remove_file(&self.path);
675    }
676}
677
678#[cfg(test)]
679mod tests {
680    use super::*;
681    use std::fs;
682    use std::path::PathBuf;
683    use std::sync::atomic::{AtomicU32, Ordering};
684
685    /// 0, 10, 20, …: every region it times lasts exactly 10.
686    struct FakeClock(u64);
687
688    impl Clock for FakeClock {
689        fn now(&mut self) -> u64 {
690            let t = self.0;
691            self.0 += 10;
692            t
693        }
694    }
695
696    /// Times region `i` (reads `2i` and `2i + 1`) as `durations[i]`.
697    struct ScriptClock {
698        durations: Vec<u64>,
699        reads: usize,
700        t: u64,
701    }
702
703    impl Clock for ScriptClock {
704        fn now(&mut self) -> u64 {
705            if self.reads % 2 == 1 {
706                self.t += self.durations[self.reads / 2];
707            } else {
708                self.t += 1_000;
709            }
710            self.reads += 1;
711            self.t
712        }
713    }
714
715    /// A scratch directory `stuffr-benchtest-<pid>-<n>`, removed on drop.
716    struct Scratch(PathBuf);
717
718    impl Scratch {
719        fn new() -> Self {
720            static N: AtomicU32 = AtomicU32::new(0);
721            let n = N.fetch_add(1, Ordering::Relaxed);
722            let dir =
723                std::env::temp_dir().join(format!("stuffr-benchtest-{}-{n}", std::process::id()));
724            fs::create_dir_all(&dir).unwrap();
725            Scratch(dir)
726        }
727
728        fn file(&self, name: &str, bytes: &[u8]) -> PathBuf {
729            let p = self.0.join(name);
730            fs::write(&p, bytes).unwrap();
731            p
732        }
733    }
734
735    impl Drop for Scratch {
736        fn drop(&mut self) {
737            let _ = fs::remove_dir_all(&self.0);
738        }
739    }
740
741    /// Serialises the tests that arm the process-wide signal cleanup.
742    fn armed_lock() -> std::sync::MutexGuard<'static, ()> {
743        crate::compat::ARM_TEST_LOCK
744            .lock()
745            .unwrap_or_else(|e| e.into_inner())
746    }
747
748    fn text(len: usize, seed: u8) -> Vec<u8> {
749        b"the quick brown fox jumps over the lazy dog, "
750            .iter()
751            .cycle()
752            .enumerate()
753            .map(|(i, &b)| b.wrapping_add(seed).wrapping_add((i / 997) as u8))
754            .take(len)
755            .collect()
756    }
757
758    fn plan(mode: Mode, repeat: u32) -> Plan {
759        Plan {
760            mode,
761            repeat,
762            threads: None,
763            memory_limit: 1 << 30,
764        }
765    }
766
767    thread_local! {
768        /// Set by `on_disk`: every output goes to the temp file.
769        pub(super) static FORCE_DISK: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
770    }
771
772    /// Runs `f` with every compressed output sent to the temp file.
773    fn on_disk<T>(f: impl FnOnce() -> T) -> T {
774        FORCE_DISK.with(|d| d.set(true));
775        let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
776        FORCE_DISK.with(|d| d.set(false));
777        r.unwrap_or_else(|e| std::panic::resume_unwind(e))
778    }
779
780    /// The temp files created while `f` ran. Hold `armed_lock`: every test
781    /// that can create one does.
782    fn temps_made(f: impl FnOnce()) -> u32 {
783        let before = TEMP_SEQ.load(Ordering::Relaxed);
784        f();
785        TEMP_SEQ.load(Ordering::Relaxed) - before
786    }
787
788    /// Deterministic noise no codec can shrink.
789    fn noise(len: usize) -> Vec<u8> {
790        let mut x = 0x9e37_79b9_7f4a_7c15u64;
791        (0..len)
792            .map(|_| {
793                x ^= x << 13;
794                x ^= x >> 7;
795                x ^= x << 17;
796                (x >> 24) as u8
797            })
798            .collect()
799    }
800
801    fn ok(o: Outcome) -> Measured {
802        match o {
803            Outcome::Ok(m) => m,
804            Outcome::Failed(e) => panic!("failed: {e}"),
805        }
806    }
807
808    fn run(codec: &str, input: &Input, plan: &Plan) -> Measured {
809        ok(measure(codec, None, input, plan, &mut FakeClock(0)))
810    }
811
812    fn bench_temps() -> Vec<String> {
813        let prefix = format!("stuffr-bench-{}-", std::process::id());
814        fs::read_dir(std::env::temp_dir())
815            .unwrap()
816            .filter_map(|e| e.ok()?.file_name().into_string().ok())
817            .filter(|n| n.starts_with(&prefix))
818            .collect()
819    }
820
821    #[test]
822    fn one_stream_compresses_the_concatenation() {
823        let (a, b) = (text(20_000, 0), text(30_000, 7));
824        let both = [a.clone(), b.clone()].concat();
825        let p = plan(Mode::OneStream, 1);
826        for codec in ["gzip", "zstd", "bzip2"] {
827            let parts = run(codec, &Input::Memory(vec![a.clone(), b.clone()]), &p);
828            let whole = run(codec, &Input::Memory(vec![both.clone()]), &p);
829            assert_eq!(parts.bytes_out, whole.bytes_out, "{codec}");
830        }
831    }
832
833    #[test]
834    fn per_file_sums_each_file() {
835        let (a, b) = (text(20_000, 0), text(30_000, 7));
836        let p = plan(Mode::PerFile, 1);
837        let both = run("gzip", &Input::Memory(vec![a.clone(), b.clone()]), &p);
838        let one = run("gzip", &Input::Memory(vec![a]), &p);
839        let two = run("gzip", &Input::Memory(vec![b]), &p);
840        assert_eq!(both.bytes_out, one.bytes_out + two.bytes_out);
841        let stream = run(
842            "gzip",
843            &Input::Memory(vec![text(20_000, 0), text(30_000, 7)]),
844            &plan(Mode::OneStream, 1),
845        );
846        assert_ne!(
847            both.bytes_out, stream.bytes_out,
848            "per-file is not one stream"
849        );
850        // One compression and one decompression region per file, summed.
851        assert_eq!(both.compress_ns, 20);
852        assert_eq!(both.decompress_ns, 20);
853    }
854
855    /// Streamed: compression is encoder creation, one region per input
856    /// chunk's `write_all` (each 64 KiB read falls between them) and
857    /// `finish`; decompression is one region.
858    #[test]
859    fn a_streamed_run_times_each_write_and_not_the_reads() {
860        let _lock = armed_lock();
861        let s = Scratch::new();
862        // Two chunks: 64 KiB, then 36 KiB.
863        let paths = vec![s.file("a", &text(100 * 1024, 6))];
864        let files = load_input(&paths, 0).unwrap().0;
865        let mut clock = FakeClock(0);
866        let m = ok(measure(
867            "gzip",
868            None,
869            &files,
870            &plan(Mode::OneStream, 1),
871            &mut clock,
872        ));
873        let regions = 1 + 2 + 1;
874        assert_eq!(m.compress_ns, 10 * regions);
875        assert_eq!(m.decompress_ns, 10);
876        assert_eq!(clock.0, 10 * 2 * (regions + 1), "no other clock reads");
877    }
878
879    #[test]
880    fn no_threads_means_no_governor() {
881        // Pinned structurally rather than by exporting STUFFR_THREADS: the
882        // lib tests share one environment (edition 2024 `set_var` is
883        // unsafe for exactly that reason), and `governor_for` returns
884        // before `resolved_budget`, the only reader of the variable, is
885        // called.
886        assert!(governor_for(&plan(Mode::OneStream, 1)).is_none());
887        let two = Plan {
888            threads: Some(2),
889            ..plan(Mode::OneStream, 1)
890        };
891        assert!(governor_for(&two).is_some());
892    }
893
894    #[test]
895    fn the_fake_clock_times_exactly_one_region_each() {
896        let input = Input::Memory(vec![text(64 * 1024, 3)]);
897        let m = run("gzip", &input, &plan(Mode::OneStream, 1));
898        assert_eq!(m.compress_ns, 10);
899        assert_eq!(m.decompress_ns, 10);
900    }
901
902    #[test]
903    fn repeat_reports_the_median() {
904        let input = Input::Memory(vec![text(4096, 1)]);
905        // Regions alternate compress, decompress across the three runs.
906        let mut clock = ScriptClock {
907            durations: vec![30, 7, 10, 5, 20, 9],
908            reads: 0,
909            t: 0,
910        };
911        let m = ok(measure(
912            "gzip",
913            None,
914            &input,
915            &plan(Mode::OneStream, 3),
916            &mut clock,
917        ));
918        assert_eq!(m.compress_ns, 20);
919        assert_eq!(m.decompress_ns, 7);
920        assert_eq!(clock.reads, 12, "two reads per region, nothing else");
921    }
922
923    #[test]
924    fn an_even_repeat_reports_the_lower_median() {
925        let input = Input::Memory(vec![text(4096, 1)]);
926        let mut clock = ScriptClock {
927            durations: vec![40, 1, 10, 2, 30, 3, 20, 4],
928            reads: 0,
929            t: 0,
930        };
931        let m = ok(measure(
932            "gzip",
933            None,
934            &input,
935            &plan(Mode::OneStream, 4),
936            &mut clock,
937        ));
938        assert_eq!(m.compress_ns, 20);
939        assert_eq!(m.decompress_ns, 2);
940    }
941
942    #[test]
943    fn a_rejected_level_is_a_failed_row() {
944        let input = Input::Memory(vec![text(1024, 0)]);
945        let mut clock = FakeClock(0);
946        match measure(
947            "gzip",
948            Some(99),
949            &input,
950            &plan(Mode::OneStream, 1),
951            &mut clock,
952        ) {
953            Outcome::Failed(msg) => assert!(msg.contains("99"), "{msg}"),
954            Outcome::Ok(m) => panic!("level 99 accepted: {m:?}"),
955        }
956        assert_eq!(clock.0, 0, "nothing was timed");
957    }
958
959    #[test]
960    fn an_unknown_format_is_a_failed_row() {
961        let input = Input::Memory(vec![text(1024, 0)]);
962        let o = measure(
963            "nope",
964            None,
965            &input,
966            &plan(Mode::OneStream, 1),
967            &mut FakeClock(0),
968        );
969        assert!(matches!(o, Outcome::Failed(_)), "{o:?}");
970    }
971
972    #[test]
973    fn streamed_input_matches_memory_input() {
974        let _lock = armed_lock();
975        let s = Scratch::new();
976        let (a, b) = (text(32 * 1024, 2), text(32 * 1024, 9));
977        let paths = vec![s.file("a", &a), s.file("b", &b)];
978        let files = load_input(&paths, 0).unwrap().0;
979        assert!(matches!(files, Input::Files(_)));
980        warm(&files).unwrap();
981        let memory = load_input(&paths, 1 << 30).unwrap().0;
982        assert!(matches!(memory, Input::Memory(_)));
983        for mode in [Mode::OneStream, Mode::PerFile] {
984            let p = plan(mode, 1);
985            for codec in ["gzip", "xz", "lz4"] {
986                let want = run(codec, &memory, &p).bytes_out;
987                assert_eq!(run(codec, &files, &p).bytes_out, want, "{codec}");
988                let disk = on_disk(|| run(codec, &files, &p)).bytes_out;
989                assert_eq!(disk, want, "{codec} via the temp file");
990            }
991        }
992    }
993
994    #[test]
995    fn the_output_temp_file_is_removed() {
996        let _lock = armed_lock();
997        let s = Scratch::new();
998        let paths = vec![s.file("a", &text(16 * 1024, 4))];
999        let files = load_input(&paths, 0).unwrap().0;
1000        let made = temps_made(|| {
1001            on_disk(|| run("gzip", &files, &plan(Mode::OneStream, 2)));
1002        });
1003        assert_eq!(made, 1);
1004        assert_eq!(bench_temps(), Vec::<String>::new());
1005    }
1006
1007    #[test]
1008    fn the_temp_guard_removes_on_panic() {
1009        let _lock = armed_lock();
1010        let seen = std::sync::Mutex::new(None::<PathBuf>);
1011        let r = std::panic::catch_unwind(|| {
1012            let (guard, _file) = TempOut::create().unwrap();
1013            *seen.lock().unwrap() = Some(guard.path().to_path_buf());
1014            assert!(guard.path().exists());
1015            panic!("mid-run");
1016        });
1017        assert!(r.is_err());
1018        let path = seen.into_inner().unwrap().expect("the guard was created");
1019        assert!(!path.exists(), "{} survived the panic", path.display());
1020        #[cfg(unix)]
1021        assert!(
1022            crate::compat::armed_path().is_none(),
1023            "still armed after the panic"
1024        );
1025    }
1026
1027    #[cfg(unix)]
1028    #[test]
1029    fn the_temp_file_is_armed_for_signals() {
1030        let _lock = armed_lock();
1031        let (guard, _file) = TempOut::create().unwrap();
1032        let path = guard.path().to_path_buf();
1033        let name = path.file_name().unwrap().to_str().unwrap().to_owned();
1034        assert!(
1035            name.starts_with(&format!("stuffr-bench-{}-", std::process::id())),
1036            "{name}"
1037        );
1038        assert_eq!(crate::compat::armed_path().as_deref(), Some(path.as_path()));
1039        drop(guard);
1040        assert!(
1041            crate::compat::armed_path().is_none(),
1042            "armed after the drop"
1043        );
1044        assert!(!path.exists());
1045    }
1046
1047    #[test]
1048    fn empty_input_measures_without_error() {
1049        for mode in [Mode::OneStream, Mode::PerFile] {
1050            for codec in crate::bench::levels::benchable_codecs() {
1051                let m = run(codec, &Input::Memory(vec![Vec::new()]), &plan(mode, 1));
1052                assert!(m.bytes_out < 1024, "{codec}: {m:?}");
1053            }
1054        }
1055    }
1056
1057    #[test]
1058    fn every_benchable_codec_round_trips_in_both_modes() {
1059        let _lock = armed_lock();
1060        let s = Scratch::new();
1061        let paths = vec![s.file("a", &text(8 * 1024, 5)), s.file("b", &text(4096, 8))];
1062        let files = load_input(&paths, 0).unwrap().0;
1063        let memory = load_input(&paths, 1 << 30).unwrap().0;
1064        for mode in [Mode::OneStream, Mode::PerFile] {
1065            for codec in crate::bench::levels::benchable_codecs() {
1066                for input in [&files, &memory] {
1067                    run(codec, input, &plan(mode, 1));
1068                    on_disk(|| run(codec, input, &plan(mode, 1)));
1069                }
1070            }
1071        }
1072    }
1073
1074    /// Counts the reads that reach the wrapped reader.
1075    struct CountReads<R> {
1076        inner: R,
1077        reads: Arc<std::sync::atomic::AtomicUsize>,
1078    }
1079
1080    impl<R: Read> Read for CountReads<R> {
1081        fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
1082            self.reads.fetch_add(1, Ordering::Relaxed);
1083            self.inner.read(buf)
1084        }
1085    }
1086
1087    impl<R: Seek> Seek for CountReads<R> {
1088        fn seek(&mut self, pos: SeekFrom) -> io::Result<u64> {
1089            self.inner.seek(pos)
1090        }
1091    }
1092
1093    /// LZW's decoder reads one byte per call. Through the temp file's
1094    /// source those calls must not each reach the file (one system call
1095    /// per compressed byte made streamed decoding 17-34x slower than the
1096    /// same decode from memory).
1097    #[test]
1098    fn the_temp_file_is_read_in_large_pieces_by_a_byte_reader() {
1099        let input = text(64 * 1024, 4);
1100        let codec = stuffr::registry()
1101            .codec(stuffr::FormatId::new("compress"))
1102            .expect("compress is in every default build");
1103        let out = SharedVec::with_capacity(0);
1104        let mut sink = codec
1105            .encoder(Box::new(out.clone()), &EncodeOpts::default())
1106            .unwrap();
1107        sink.write_all(&input).unwrap();
1108        sink.finish().unwrap();
1109        let bytes = out.take();
1110        let len = bytes.len() as u64;
1111        let reads = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1112        let file = CountReads {
1113            inner: Cursor::new(bytes),
1114            reads: reads.clone(),
1115        };
1116        let src = temp_source(file, len);
1117        // The shape `MemSource` gives the in-memory path.
1118        assert_eq!(
1119            src.caps(),
1120            SourceCaps {
1121                seekable: true,
1122                len: Some(len)
1123            }
1124        );
1125        let mut buf = vec![0u8; CHUNK];
1126        let (decoded, _) = decode(
1127            codec.as_ref(),
1128            &DecodeOpts::default(),
1129            Box::new(src),
1130            &mut buf,
1131            &mut FakeClock(0),
1132        )
1133        .unwrap();
1134        assert_eq!(decoded, input.len() as u64);
1135        let reads = reads.load(Ordering::Relaxed);
1136        assert!(
1137            reads as u64 <= len / CHUNK as u64 + 4,
1138            "{reads} reads reached the file for {len} compressed bytes"
1139        );
1140    }
1141
1142    #[test]
1143    fn the_output_goes_to_memory_whenever_its_worst_case_fits() {
1144        // 0 bytes in: worst case 1 KiB, which fits under 2048 / 2.
1145        // Beside input in memory: half the limit.
1146        assert_eq!(destination(0, 2048, false), Dest::Memory);
1147        assert_eq!(destination(0, 2047, false), Dest::TempFile);
1148        // 8000 + 1000 + 1024.
1149        assert_eq!(destination(8000, 2 * 10_024, false), Dest::Memory);
1150        assert_eq!(destination(8000, 2 * 10_024 - 1, false), Dest::TempFile);
1151        assert_eq!(destination(u64::MAX, u64::MAX, false), Dest::TempFile);
1152        // Streamed input holds no copy: the whole limit.
1153        assert_eq!(destination(8000, 10_024, true), Dest::Memory);
1154        assert_eq!(destination(8000, 10_023, true), Dest::TempFile);
1155        assert_eq!(destination(u64::MAX, u64::MAX, true), Dest::TempFile);
1156    }
1157
1158    /// Streamed input whose output fits in the whole limit stays off the
1159    /// disk, one stream included; only output past the limit makes a temp
1160    /// file.
1161    #[test]
1162    fn streamed_input_compresses_to_memory_when_the_output_fits() {
1163        let _lock = armed_lock();
1164        let s = Scratch::new();
1165        let (a, b) = (text(16 * 1024, 1), text(16 * 1024, 2));
1166        let paths = vec![s.file("a", &a), s.file("b", &b)];
1167        // 32 KiB is over 40000 / 2, so the input is streamed. One stream's
1168        // worst case (32768 + 4096 + 1024 = 37888) fits under 40000, not
1169        // under 37887; each file's (19456) fits under either.
1170        let files = load_input(&paths, 40_000).unwrap().0;
1171        assert!(matches!(files, Input::Files(_)));
1172        let memory = Input::Memory(vec![a, b]);
1173        for (mode, limit, want_temps) in [
1174            (Mode::PerFile, 40_000, 0),
1175            (Mode::OneStream, 40_000, 0),
1176            (Mode::OneStream, 37_888, 0),
1177            (Mode::OneStream, 37_887, 1),
1178        ] {
1179            let p = Plan {
1180                memory_limit: limit,
1181                ..plan(mode, 1)
1182            };
1183            let mut got = None;
1184            let made = temps_made(|| got = Some(run("gzip", &files, &p)));
1185            assert_eq!(made, want_temps, "{mode:?} at {limit}");
1186            let want = run("gzip", &memory, &plan(mode, 1)).bytes_out;
1187            assert_eq!(got.unwrap().bytes_out, want, "{mode:?}");
1188        }
1189    }
1190
1191    /// In-memory input whose output does not fit goes to the temp file too,
1192    /// for every codec alike.
1193    #[test]
1194    fn memory_input_past_the_output_limit_uses_the_temp_file() {
1195        let _lock = armed_lock();
1196        let input = Input::Memory(vec![text(16 * 1024, 5)]);
1197        // The input fits under 32768 / 2; its worst case does not.
1198        let p = Plan {
1199            memory_limit: 32 * 1024,
1200            ..plan(Mode::OneStream, 1)
1201        };
1202        for codec in ["gzip", "compress"] {
1203            let made = temps_made(|| {
1204                run(codec, &input, &p);
1205            });
1206            assert_eq!(made, 1, "{codec}");
1207        }
1208    }
1209
1210    /// The reserved `Vec` must not reallocate inside a timed region, even
1211    /// on input no codec can shrink, at any level.
1212    #[test]
1213    fn the_reserved_room_holds_incompressible_output() {
1214        let input = noise(64 * 1024);
1215        let reg = stuffr::registry();
1216        for name in crate::bench::levels::benchable_codecs() {
1217            let codec = reg.codec(stuffr::FormatId::new(name)).unwrap();
1218            let sweep = crate::bench::levels::levels_of(name).unwrap().sweep;
1219            let levels: Vec<Option<i32>> = if sweep.is_empty() {
1220                vec![None]
1221            } else {
1222                sweep.iter().map(|&l| Some(l)).collect()
1223            };
1224            for level in levels {
1225                let room = output_room(name, input.len() as u64) as usize;
1226                let shared = SharedVec::with_capacity(room);
1227                let cap = shared.capacity();
1228                let dst = BufWriter::with_capacity(CHUNK, shared.clone());
1229                let opts = EncodeOpts {
1230                    level,
1231                    ..Default::default()
1232                };
1233                let mut sink = codec.encoder(Box::new(dst), &opts).unwrap();
1234                sink.write_all(&input).unwrap();
1235                sink.finish().unwrap();
1236                assert_eq!(shared.capacity(), cap, "{name} {level:?} reallocated");
1237                assert!(shared.take().len() <= room, "{name} {level:?}");
1238            }
1239        }
1240    }
1241
1242    /// A log still being written: the rows bench it as it was at load.
1243    #[test]
1244    fn a_file_that_grows_after_load_is_benched_at_its_loaded_length() {
1245        let _lock = armed_lock();
1246        let s = Scratch::new();
1247        let original = text(40 * 1024, 3);
1248        let path = s.file("grow.log", &original);
1249        let (files, bytes_in) = load_input(std::slice::from_ref(&path), 0).unwrap();
1250        assert!(matches!(files, Input::Files(_)));
1251        assert_eq!(bytes_in, original.len() as u64);
1252        let mut f = fs::OpenOptions::new().append(true).open(&path).unwrap();
1253        f.write_all(&text(10 * 1024, 9)).unwrap();
1254        drop(f);
1255        let snapshot = Input::Memory(vec![original]);
1256        for mode in [Mode::OneStream, Mode::PerFile] {
1257            let p = plan(mode, 1);
1258            for codec in ["gzip", "compress"] {
1259                // Ok: the round trip gave back exactly the loaded length.
1260                let grown = run(codec, &files, &p);
1261                assert_eq!(
1262                    grown.bytes_out,
1263                    run(codec, &snapshot, &p).bytes_out,
1264                    "{codec}: the appended bytes were benched"
1265                );
1266            }
1267        }
1268    }
1269
1270    #[test]
1271    fn load_input_splits_at_half_the_memory_limit() {
1272        let s = Scratch::new();
1273        let paths = vec![s.file("a", &[1; 100]), s.file("b", &[2; 100])];
1274        match load_input(&paths, 400).unwrap() {
1275            (Input::Memory(parts), 200) => {
1276                assert_eq!(parts, vec![vec![1; 100], vec![2; 100]])
1277            }
1278            other => panic!("200 bytes fit in 400 / 2: {other:?}"),
1279        }
1280        assert!(matches!(
1281            load_input(&paths, 399).unwrap(),
1282            (Input::Files(_), 200)
1283        ));
1284    }
1285}