1use 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
55const CHUNK: usize = 64 * 1024;
57
58pub trait Clock {
60 fn now(&mut self) -> u64;
61}
62
63pub 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#[derive(Debug)]
87pub enum Input {
88 Memory(Vec<Vec<u8>>),
89 Files(Vec<Snapshot>),
90}
91
92#[derive(Clone, Debug, PartialEq, Eq)]
95pub struct Snapshot {
96 pub path: PathBuf,
97 pub len: u64,
98}
99
100impl Snapshot {
101 fn open(&self) -> io::Result<io::Take<File>> {
103 Ok(File::open(&self.path)?.take(self.len))
104 }
105}
106
107#[derive(Clone, Copy, Debug, PartialEq, Eq)]
109pub enum Mode {
110 OneStream,
111 PerFile,
112}
113
114#[derive(Clone, Copy, Debug, PartialEq, Eq)]
116pub struct Plan {
117 pub mode: Mode,
118 pub repeat: u32,
120 pub threads: Option<usize>,
122 pub memory_limit: u64,
123}
124
125pub 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
157pub 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
170pub 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#[derive(Clone, Copy)]
188enum Part<'a> {
189 Memory(&'a [u8]),
190 File(&'a Snapshot),
191}
192
193impl Part<'_> {
194 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
206enum Dest {
207 Memory,
208 TempFile,
209}
210
211fn destination(input_len: u64, memory_limit: u64, streamed: bool) -> Dest {
216 #[cfg(test)]
219 if tests::FORCE_DISK.with(std::cell::Cell::get) {
220 return Dest::TempFile;
221 }
222 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
237fn 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
330pub 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
343fn 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
349struct 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 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 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 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 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
479fn 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
504fn 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#[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
548struct 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
590fn 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
631static TEMP_SEQ: AtomicU32 = AtomicU32::new(0);
633
634struct TempOut {
638 path: PathBuf,
639 _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 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 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 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 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 pub(super) static FORCE_DISK: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
770 }
771
772 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 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 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 assert_eq!(both.compress_ns, 20);
852 assert_eq!(both.decompress_ns, 20);
853 }
854
855 #[test]
859 fn a_streamed_run_times_each_write_and_not_the_reads() {
860 let _lock = armed_lock();
861 let s = Scratch::new();
862 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 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 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 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 #[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 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 assert_eq!(destination(0, 2048, false), Dest::Memory);
1147 assert_eq!(destination(0, 2047, false), Dest::TempFile);
1148 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 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 #[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 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 #[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 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 #[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 #[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 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}