6 Commits

Author SHA1 Message Date
dailz
6a2f8ecb66 fix(bench): resolve pre-existing clippy warnings in report.rs and mmap_reader.rs 2026-06-07 09:15:34 +08:00
dailz
f6081b9fe9 fix(bench): propagate I/O errors in append_lines instead of silently defaulting to 0 (closes #45) 2026-06-07 09:13:37 +08:00
dailz
97a2c6a925 fix(bench): regenerate growable file each iteration in truncate safety benchmarks (closes #44) 2026-06-07 09:02:19 +08:00
dailz
e6e0e2cc90 fix(bench): correct lines_read to actual successful reads in bench_scroll_rss
lines_read was incorrectly set to max_lines.min(total) (the loop upper bound)
instead of the actual number of successfully read lines. Now tracks lines_read
via get_line(i).is_some() counter and uses max_lines.min(total) as the correct
loop upper bound to handle empty file edge case.

Fixes #43
2026-06-07 08:50:20 +08:00
dailz
ffaf462bae Merge fix/m23-single-frame-tail-overlap: [M23] small file single_frame_tail overlap fix 2026-06-07 08:32:58 +08:00
dailz
a8dc067cd4 fix(bench): [M23] prevent single_frame_tail/head overlap for small files
- Extract shared FRAME_LINES constant into suites/mod.rs
- Add select_frame_positions() helper with 3*FRAME_LINES threshold
  to guarantee non-overlapping head/middle/tail ranges
- Guard bench_reverse_scan against total <= FRAME_LINES
- Add 9 boundary tests for position selection (0..1M lines)

Closes #42
2026-06-07 08:31:00 +08:00
8 changed files with 186 additions and 37 deletions

View File

@@ -83,11 +83,11 @@ pub fn generate_growable_file(dir: &Path) -> std::io::Result<PathBuf> {
/// Append `count` lines to the file /// Append `count` lines to the file
pub fn append_lines(path: &Path, count: usize) -> std::io::Result<()> { pub fn append_lines(path: &Path, count: usize) -> std::io::Result<()> {
let existing_lines = count_existing_lines(path)?;
let mut file = BufWriter::with_capacity( let mut file = BufWriter::with_capacity(
64 * 1024, 64 * 1024,
fs::OpenOptions::new().append(true).open(path)?, fs::OpenOptions::new().append(true).open(path)?,
); );
let existing_lines = count_existing_lines(path).unwrap_or(0);
for i in 0..count { for i in 0..count {
writeln!( writeln!(
file, file,
@@ -117,7 +117,12 @@ pub fn rotate_file(path: &Path) -> std::io::Result<PathBuf> {
fn count_existing_lines(path: &Path) -> std::io::Result<u64> { fn count_existing_lines(path: &Path) -> std::io::Result<u64> {
let file = fs::File::open(path)?; let file = fs::File::open(path)?;
let reader = BufReader::new(file); let reader = BufReader::new(file);
Ok(reader.lines().count() as u64) let mut count = 0u64;
for line in reader.lines() {
line?;
count += 1;
}
Ok(count)
} }
#[cfg(test)] #[cfg(test)]

View File

@@ -35,7 +35,7 @@ const HANDLER_NONE: u8 = 0;
const HANDLER_DEFAULT: u8 = 1; const HANDLER_DEFAULT: u8 = 1;
const HANDLER_IGNORE: u8 = 2; const HANDLER_IGNORE: u8 = 2;
const HANDLER_PLAIN: u8 = 3; // extern "C" fn(c_int) const HANDLER_PLAIN: u8 = 3; // extern "C" fn(c_int)
#[expect(clippy::unseparated_literal_suffix, reason = "clarity: this is the SA_SIGACTION variant")] #[allow(clippy::unseparated_literal_suffix, reason = "clarity: this is the SA_SIGACTION variant")]
const HANDLER_SIGACTION: u8 = 4; // extern "C" fn(c_int, *mut siginfo_t, *mut c_void) const HANDLER_SIGACTION: u8 = 4; // extern "C" fn(c_int, *mut siginfo_t, *mut c_void)
/// Old SIGBUS handler type — raw atomic, async-signal-safe to read. /// Old SIGBUS handler type — raw atomic, async-signal-safe to read.

View File

@@ -143,8 +143,7 @@ pub fn format_report(results: &[BenchmarkResult]) -> String {
report.push_str("| Test | Variant | RSS | Peak RSS | Page Faults |\n"); report.push_str("| Test | Variant | RSS | Peak RSS | Page Faults |\n");
report.push_str("|------|---------|-----|----------|-------------|\n"); report.push_str("|------|---------|-----|----------|-------------|\n");
let mut mem_rows: Vec<&BenchmarkResult> = let mut mem_rows: Vec<&BenchmarkResult> = category_results.to_vec();
category_results.iter().copied().collect();
mem_rows.sort_by(|a, b| { mem_rows.sort_by(|a, b| {
(&a.test_name, &a.backend, &a.variant) (&a.test_name, &a.backend, &a.variant)
.cmp(&(&b.test_name, &b.backend, &b.variant)) .cmp(&(&b.test_name, &b.backend, &b.variant))
@@ -163,7 +162,10 @@ pub fn format_report(results: &[BenchmarkResult]) -> String {
report.push('\n'); report.push('\n');
} }
let mut extras: Vec<(String, String, Vec<(String, f64)>)> = category_results type ExtraEntry = (String, f64);
type ExtraGroup = (String, String, Vec<ExtraEntry>);
let mut extras: Vec<ExtraGroup> = category_results
.iter() .iter()
.filter(|r| !r.extra.is_empty()) .filter(|r| !r.extra.is_empty())
.map(|r| { .map(|r| {

View File

@@ -1,5 +1,6 @@
use std::collections::HashMap; use std::collections::HashMap;
use super::FRAME_LINES;
use crate::metrics::MetricsCollector; use crate::metrics::MetricsCollector;
use crate::mmap_reader::{ use crate::mmap_reader::{
MmapReaderPhaseAware, MmapReaderPlain, MmapReaderPopulate, MmapReaderRandom, MmapReaderPhaseAware, MmapReaderPlain, MmapReaderPopulate, MmapReaderRandom,
@@ -210,13 +211,17 @@ fn bench_reverse_scan<B: FileReaderBackend>(
let path = &config.test_file; let path = &config.test_file;
let reader = B::open(path).expect("Failed to open file"); let reader = B::open(path).expect("Failed to open file");
let total = reader.total_lines(); let total = reader.total_lines();
let iterations: usize = if config.quick_mode { 5 } else { 10 };
let mut latencies = Vec::with_capacity(iterations * 35); if total <= FRAME_LINES {
reader.close();
return vec![];
}
let iterations: usize = if config.quick_mode { 5 } else { 10 };
let mut latencies = Vec::with_capacity(iterations * FRAME_LINES);
for _ in 0..iterations { for _ in 0..iterations {
// Read lines backwards from end: last line, last-1, ..., last-34 let start = total - FRAME_LINES;
let start = total.saturating_sub(35);
for i in (start..total).rev() { for i in (start..total).rev() {
let t = std::time::Instant::now(); let t = std::time::Instant::now();
let _ = reader.get_line(i); let _ = reader.get_line(i);
@@ -229,7 +234,7 @@ fn bench_reverse_scan<B: FileReaderBackend>(
reader.close(); reader.close();
let mut extra = HashMap::new(); let mut extra = HashMap::new();
extra.insert("lines_per_scan".into(), 35.0); extra.insert("lines_per_scan".into(), FRAME_LINES as f64);
extra.insert("iterations".into(), iterations as f64); extra.insert("iterations".into(), iterations as f64);
vec![BenchmarkResult { vec![BenchmarkResult {

View File

@@ -101,11 +101,15 @@ fn bench_scroll_rss<B: FileReaderBackend>(
let sample_interval = 100_000; let sample_interval = 100_000;
let max_lines = if config.quick_mode { 100_000 } else { total }; let max_lines = if config.quick_mode { 100_000 } else { total };
let upper = max_lines.min(total);
let mut rss_samples = Vec::new(); let mut rss_samples = Vec::new();
let mut hwm_samples = Vec::new(); let mut hwm_samples = Vec::new();
let mut lines_read = 0usize;
for i in (0..max_lines).step_by(sample_interval) { for i in (0..upper).step_by(sample_interval) {
let _ = reader.get_line(i); if reader.get_line(i).is_some() {
lines_read += 1;
}
let rss = MetricsCollector::read_rss(); let rss = MetricsCollector::read_rss();
rss_samples.push(rss.vm_rss_kb); rss_samples.push(rss.vm_rss_kb);
hwm_samples.push(rss.vm_hwm_kb); hwm_samples.push(rss.vm_hwm_kb);
@@ -124,7 +128,7 @@ fn bench_scroll_rss<B: FileReaderBackend>(
"max_hwm_kb".into(), "max_hwm_kb".into(),
hwm_samples.iter().copied().fold(0u64, u64::max) as f64, hwm_samples.iter().copied().fold(0u64, u64::max) as f64,
); );
extra.insert("lines_read".into(), max_lines.min(total) as f64); extra.insert("lines_read".into(), lines_read as f64);
reader.close(); reader.close();

View File

@@ -5,3 +5,8 @@ pub mod memory;
pub mod render; pub mod render;
pub mod rotation; pub mod rotation;
pub mod startup; pub mod startup;
/// Number of lines read per single-frame render benchmark.
/// Also used as the scan window size for reverse-scan benchmarks.
/// Shared across suites to ensure consistent workload sizing.
pub(crate) const FRAME_LINES: usize = 35;

View File

@@ -1,5 +1,6 @@
use std::collections::HashMap; use std::collections::HashMap;
use super::FRAME_LINES;
use crate::metrics::MetricsCollector; use crate::metrics::MetricsCollector;
use crate::mmap_reader::{ use crate::mmap_reader::{
MmapReaderPhaseAware, MmapReaderPlain, MmapReaderPopulate, MmapReaderRandom, MmapReaderPhaseAware, MmapReaderPlain, MmapReaderPopulate, MmapReaderRandom,
@@ -88,15 +89,9 @@ fn bench_single_frame<B: FileReaderBackend>(
let total = reader.total_lines(); let total = reader.total_lines();
let mut results = Vec::new(); let mut results = Vec::new();
let positions = [ for (pos_name, start_line) in select_frame_positions(total) {
("head", 0), let mut latencies = Vec::with_capacity(FRAME_LINES);
("middle", total / 2), for i in 0..FRAME_LINES {
("tail", total.saturating_sub(35)),
];
for (pos_name, start_line) in positions {
let mut latencies = Vec::with_capacity(35);
for i in 0..35 {
let line_idx = start_line + i; let line_idx = start_line + i;
if line_idx >= total { if line_idx >= total {
break; break;
@@ -126,6 +121,33 @@ fn bench_single_frame<B: FileReaderBackend>(
results results
} }
/// Select non-overlapping benchmark positions for single-frame tests.
///
/// Returns `(label, start_line)` pairs. Positions are chosen so that no
/// two frames overlap, skipping any position that cannot fit without
/// colliding with a previously selected range.
pub(crate) fn select_frame_positions(total: usize) -> Vec<(&'static str, usize)> {
let frame = FRAME_LINES;
if total == 0 {
return vec![];
}
let mut positions: Vec<(&'static str, usize)> = vec![("head", 0)];
// Need at least 3 full frames to place head, middle, and tail without overlap.
if total >= 3 * frame {
let head_end = frame;
let tail_start = total - frame;
let gap = tail_start - head_end;
// Center the middle frame within the gap, ensuring it fits entirely
let middle_start = head_end + gap.saturating_sub(frame) / 2;
positions.push(("middle", middle_start));
positions.push(("tail", tail_start));
}
positions
}
fn bench_continuous_scroll<B: FileReaderBackend>( fn bench_continuous_scroll<B: FileReaderBackend>(
backend: &str, backend: &str,
variant: &str, variant: &str,
@@ -175,3 +197,119 @@ fn bench_continuous_scroll<B: FileReaderBackend>(
extra, extra,
}] }]
} }
#[cfg(test)]
mod tests {
use super::*;
fn starts(pos: &[(&str, usize)]) -> Vec<usize> {
pos.iter().map(|&(_, s)| s).collect()
}
fn labels<'a>(pos: &[(&'a str, usize)]) -> Vec<&'a str> {
pos.iter().map(|&(l, _)| l).collect()
}
fn ranges_overlap(a_start: usize, b_start: usize) -> bool {
let a_end = a_start + FRAME_LINES;
let b_end = b_start + FRAME_LINES;
a_start < b_end && b_start < a_end
}
#[test]
fn zero_lines_no_positions() {
assert!(select_frame_positions(0).is_empty());
}
#[test]
fn one_line_head_only() {
let pos = select_frame_positions(1);
assert_eq!(labels(&pos), vec!["head"]);
assert_eq!(starts(&pos), vec![0]);
}
#[test]
fn at_frame_lines_head_only() {
let pos = select_frame_positions(FRAME_LINES);
assert_eq!(labels(&pos), vec!["head"]);
assert_eq!(starts(&pos), vec![0]);
}
#[test]
fn just_above_threshold_head_middle_tail() {
let total = 3 * FRAME_LINES;
let pos = select_frame_positions(total);
assert_eq!(labels(&pos), vec!["head", "middle", "tail"]);
assert_eq!(starts(&pos)[0], 0);
assert_eq!(*starts(&pos).last().unwrap(), total - FRAME_LINES);
// Verify no overlap
for i in 0..pos.len() {
for j in (i + 1)..pos.len() {
assert!(
!ranges_overlap(pos[i].1, pos[j].1),
"overlap: {:?} @ {} vs {:?} @ {} (total={})",
pos[i].0, pos[i].1, pos[j].0, pos[j].1, total
);
}
}
}
#[test]
fn no_overlap_at_total_70() {
// total=70 < 3*35=105, so only head is returned
let pos = select_frame_positions(70);
assert_eq!(labels(&pos), vec!["head"]);
}
#[test]
fn no_overlap_at_total_104() {
let pos = select_frame_positions(104);
for i in 0..pos.len() {
for j in (i + 1)..pos.len() {
assert!(
!ranges_overlap(pos[i].1, pos[j].1),
"overlap at total=104: {:?} @ {} vs {:?} @ {}",
pos[i].0, pos[i].1, pos[j].0, pos[j].1
);
}
}
}
#[test]
fn no_overlap_at_total_105() {
let pos = select_frame_positions(105);
for i in 0..pos.len() {
for j in (i + 1)..pos.len() {
assert!(
!ranges_overlap(pos[i].1, pos[j].1),
"overlap at total=105: {:?} @ {} vs {:?} @ {}",
pos[i].0, pos[i].1, pos[j].0, pos[j].1
);
}
}
}
#[test]
fn middle_is_centered_between_head_and_tail() {
let total = 1000;
let pos = select_frame_positions(total);
assert_eq!(pos.len(), 3);
let (_, head_start) = pos[0];
let (_, middle_start) = pos[1];
let (_, tail_start) = pos[2];
assert_eq!(head_start, 0);
assert_eq!(tail_start, total - FRAME_LINES);
let head_end = head_start + FRAME_LINES;
let gap = tail_start - head_end;
assert_eq!(middle_start, head_end + gap.saturating_sub(FRAME_LINES) / 2);
}
#[test]
fn large_file_all_three_positions() {
let pos = select_frame_positions(1_000_000);
assert_eq!(labels(&pos), vec!["head", "middle", "tail"]);
}
}

View File

@@ -24,13 +24,14 @@ fn bench_truncate_safety_mmap(
dir: &std::path::Path, dir: &std::path::Path,
) -> Vec<BenchmarkResult> { ) -> Vec<BenchmarkResult> {
let sub_dir = dir.join("trunc_mmap"); let sub_dir = dir.join("trunc_mmap");
let path = data_gen::generate_growable_file(&sub_dir).expect("Failed to create file");
let iterations: usize = if _config.quick_mode { 3 } else { 10 }; let iterations: usize = if _config.quick_mode { 3 } else { 10 };
let mut latencies = Vec::with_capacity(iterations); let mut latencies = Vec::with_capacity(iterations);
let mut sigbus_detected = 0usize; let mut sigbus_detected = 0usize;
for _ in 0..iterations { for _ in 0..iterations {
let path = data_gen::generate_growable_file(&sub_dir).expect("Failed to create file");
mmap_reader::reset_sigbus_flag(); mmap_reader::reset_sigbus_flag();
let reader = MmapReaderPlain::open(&path).expect("Failed to open file"); let reader = MmapReaderPlain::open(&path).expect("Failed to open file");
@@ -48,12 +49,6 @@ fn bench_truncate_safety_mmap(
sigbus_detected += 1; sigbus_detected += 1;
} }
reader.close(); reader.close();
let mut f = std::fs::File::create(&path).expect("Failed to recreate file");
use std::io::Write;
for i in 0..1000u64 {
writeln!(f, "restored line {i}").unwrap();
}
} }
let rss = MetricsCollector::read_rss(); let rss = MetricsCollector::read_rss();
@@ -82,13 +77,14 @@ fn bench_truncate_safety_pread(
dir: &std::path::Path, dir: &std::path::Path,
) -> Vec<BenchmarkResult> { ) -> Vec<BenchmarkResult> {
let sub_dir = dir.join("trunc_pread"); let sub_dir = dir.join("trunc_pread");
let path = data_gen::generate_growable_file(&sub_dir).expect("Failed to create file");
let iterations: usize = if _config.quick_mode { 3 } else { 10 }; let iterations: usize = if _config.quick_mode { 3 } else { 10 };
let mut latencies = Vec::with_capacity(iterations); let mut latencies = Vec::with_capacity(iterations);
let mut error_count = 0usize; let mut error_count = 0usize;
for _ in 0..iterations { for _ in 0..iterations {
let path = data_gen::generate_growable_file(&sub_dir).expect("Failed to create file");
let reader = PreadReaderPlain::open(&path).expect("Failed to open file"); let reader = PreadReaderPlain::open(&path).expect("Failed to open file");
let original_size = reader.file_size(); let original_size = reader.file_size();
@@ -104,12 +100,6 @@ fn bench_truncate_safety_pread(
error_count += 1; error_count += 1;
} }
reader.close(); reader.close();
let mut f = std::fs::File::create(&path).expect("Failed to recreate file");
use std::io::Write;
for i in 0..1000u64 {
writeln!(f, "restored line {i}").unwrap();
}
} }
let rss = MetricsCollector::read_rss(); let rss = MetricsCollector::read_rss();