13 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
dailz
502479677b fix(bench): warn when clear_file_cache fails instead of silently skipping cold benchmarks (closes #41) 2026-06-05 17:28:57 +08:00
dailz
5656b26d7b refactor(bench): unify line counting in get_file_info to use count_existing_lines
Reuse the existing count_existing_lines() (reader.lines().count())
instead of a manual read_until loop, eliminating duplicate line-counting
logic in data_gen.rs.

Closes #40
2026-06-05 17:04:46 +08:00
dailz
a8b64e78bd fix(bench): stabilize report column/row ordering across input permutations (#39)
- variants: use direct tuple comparison instead of format! string, add sort() after dedup
- Memory section: sort rows by (test_name, backend, variant) before output
- Extra Metrics section: sort rows by (test_name, variant_label) before output
- add [lib] target to Cargo.toml to enable unit tests
- add regression test: same data in different input order produces identical report
2026-06-05 16:24:41 +08:00
dailz
e945a357f7 fix(bench): warn on all reset_vm_hwm errors, not just PermissionDenied
Issue #38: warn_reset_hwm() silently swallowed non-permission I/O errors
from /proc/self/clear_refs (e.g. missing /proc, read-only procfs, kernel
incompatibility). This left users unaware that VmHWM reset failed and
memory peak data could be contaminated across suites.

Changes:
- runner.rs: all errors now produce a warning with specific failure reason;
  PermissionDenied retains 'try running as root' hint; AtomicBool warn-once
  prevents duplicate output across 7 suite runs
- main.rs: preflight check now uses warn_reset_hwm() instead of the vague
  can_reset_vm_hwm(), sharing the same warn-once mechanism
- metrics.rs: remove dead can_reset_vm_hwm() (no callers remaining)
- tests: add hwm_warned_flag_prevents_reentry and warn_reset_hwm_does_not_panic
2026-06-05 15:52:01 +08:00
dailz
fb57584546 fix(bench): validate --suites names, reject unknown suites at CLI boundary
Introduce Suite enum (runner.rs) replacing stringly-typed suite matching.
BenchConfig.suites is now Option<Vec<Suite>>, making invalid states
unrepresentable. Unknown suite names produce a clear error listing all
valid values.

Fixes: #37
2026-06-05 15:20:24 +08:00
dailz
9baec5ab69 fix(bench): refresh PreadReader index periodically in scroll_during_append (closes #36)
The reader's line_index and file_size were frozen at open time.
After current_line exceeded the initial 150K lines, get_line_impl
returned None for all subsequent reads. With the background thread
appending ~10K lines/sec, ~40% of measured frame latencies were
actually the cost of a None return, not real I/O.

- Add PreadReaderCore::refresh_index(&mut self): seek to start,
  rebuild LineIndex, update file_size, invalidate read cache
- Add PreadReaderPlain::refresh_index forwarding method
- Add ReadCache::invalidate to force cache miss after reindex
- Rewrite bench_scroll_during_append: time-based refresh (250ms),
  only record latencies for successful reads, assert max_line > initial
- Add regression tests for refresh_index with appended lines
2026-06-05 14:40:32 +08:00
dailz
6dd87d2872 fix(bench): wrap file writes with BufWriter to reduce syscall overhead
Add BufWriter::with_capacity(64KB) to generate_test_file,
generate_growable_file, and append_lines in data_gen.rs.

Previously each writeln! triggered an individual write syscall,
making 5GB/74M-line benchmark data generation extremely slow.
BufWriter batches writes into 64KB chunks, reducing syscalls
by ~1000x.

Explicit flush()? + drop before subsequent reads ensures data
visibility and propagates flush errors (BufWriter::drop swallows
them).

Closes #35
2026-06-05 14:01:35 +08:00
15 changed files with 597 additions and 112 deletions

View File

@@ -3,6 +3,10 @@ name = "log-viewer-bench"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
[lib]
name = "log_viewer_bench"
path = "src/lib.rs"
[[bin]] [[bin]]
name = "log-viewer-bench" name = "log-viewer-bench"
path = "src/main.rs" path = "src/main.rs"

View File

@@ -1,5 +1,5 @@
use std::fs; use std::fs;
use std::io::{BufRead, BufReader, Write}; use std::io::{BufRead, BufReader, BufWriter, Write};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
pub struct TestFileInfo { pub struct TestFileInfo {
@@ -21,15 +21,7 @@ pub fn ensure_test_file(path: &Path) -> std::io::Result<TestFileInfo> {
fn get_file_info(path: &Path) -> std::io::Result<TestFileInfo> { fn get_file_info(path: &Path) -> std::io::Result<TestFileInfo> {
let metadata = fs::metadata(path)?; let metadata = fs::metadata(path)?;
let size_bytes = metadata.len(); let size_bytes = metadata.len();
let line_count = count_existing_lines(path)?;
let file = fs::File::open(path)?;
let mut reader = BufReader::new(file);
let mut line_count: u64 = 0;
let mut buf = Vec::new();
while reader.read_until(b'\n', &mut buf)? > 0 {
line_count += 1;
buf.clear();
}
let avg_line_length = if line_count > 0 { let avg_line_length = if line_count > 0 {
size_bytes as f64 / line_count as f64 size_bytes as f64 / line_count as f64
@@ -51,7 +43,7 @@ fn generate_test_file(path: &Path) -> std::io::Result<TestFileInfo> {
fs::create_dir_all(parent)?; fs::create_dir_all(parent)?;
} }
let mut file = fs::File::create(path)?; let mut file = BufWriter::with_capacity(64 * 1024, fs::File::create(path)?);
let target_lines: u64 = 74_000_000; let target_lines: u64 = 74_000_000;
for i in 0..target_lines { for i in 0..target_lines {
writeln!( writeln!(
@@ -63,6 +55,8 @@ fn generate_test_file(path: &Path) -> std::io::Result<TestFileInfo> {
i * 7 i * 7
)?; )?;
} }
file.flush()?;
drop(file);
get_file_info(path) get_file_info(path)
} }
@@ -72,7 +66,7 @@ pub fn generate_growable_file(dir: &Path) -> std::io::Result<PathBuf> {
fs::create_dir_all(dir)?; fs::create_dir_all(dir)?;
let path = dir.join("growable.log"); let path = dir.join("growable.log");
let mut file = fs::File::create(&path)?; let mut file = BufWriter::with_capacity(64 * 1024, fs::File::create(&path)?);
for i in 0..150_000u64 { for i in 0..150_000u64 {
writeln!( writeln!(
file, file,
@@ -82,14 +76,18 @@ pub fn generate_growable_file(dir: &Path) -> std::io::Result<PathBuf> {
i i
)?; )?;
} }
file.flush()?;
Ok(path) Ok(path)
} }
/// 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 mut file = fs::OpenOptions::new().append(true).open(path)?; let existing_lines = count_existing_lines(path)?;
let existing_lines = count_existing_lines(path).unwrap_or(0); let mut file = BufWriter::with_capacity(
64 * 1024,
fs::OpenOptions::new().append(true).open(path)?,
);
for i in 0..count { for i in 0..count {
writeln!( writeln!(
file, file,
@@ -97,6 +95,7 @@ pub fn append_lines(path: &Path, count: usize) -> std::io::Result<()> {
existing_lines + i as u64 existing_lines + i as u64
)?; )?;
} }
file.flush()?;
Ok(()) Ok(())
} }
@@ -118,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

@@ -25,6 +25,21 @@ struct Args {
fn main() { fn main() {
let args = Args::parse(); let args = Args::parse();
let suites = match args.suites {
Some(names) => {
let parsed: Result<Vec<_>, _> =
names.iter().map(|s| s.parse::<log_viewer_bench::runner::Suite>()).collect();
match parsed {
Ok(s) => Some(s),
Err(e) => {
eprintln!("error: {e}");
std::process::exit(1);
}
}
}
None => None,
};
println!("=== Benchmark: mmap vs pread ==="); println!("=== Benchmark: mmap vs pread ===");
println!("Test file: {}", args.test_file.display()); println!("Test file: {}", args.test_file.display());
println!("Quick mode: {}", args.quick); println!("Quick mode: {}", args.quick);
@@ -33,7 +48,7 @@ fn main() {
let config = log_viewer_bench::runner::BenchConfig { let config = log_viewer_bench::runner::BenchConfig {
test_file: args.test_file.clone(), test_file: args.test_file.clone(),
quick_mode: args.quick, quick_mode: args.quick,
suites: args.suites, suites,
}; };
if !config.test_file.exists() { if !config.test_file.exists() {
@@ -44,9 +59,7 @@ fn main() {
std::process::exit(1); std::process::exit(1);
} }
if !log_viewer_bench::metrics::MetricsCollector::can_reset_vm_hwm() { log_viewer_bench::runner::warn_reset_hwm();
eprintln!("WARNING: VmHWM reset unavailable (no root). Memory peak values may be contaminated across tests.");
}
println!("Running benchmarks..."); println!("Running benchmarks...");
let results = log_viewer_bench::runner::run_all(&config); let results = log_viewer_bench::runner::run_all(&config);

View File

@@ -80,14 +80,6 @@ impl MetricsCollector {
}) })
} }
/// Check if we can reset VmHWM (i.e., can open `/proc/self/clear_refs` for writing)
pub fn can_reset_vm_hwm() -> bool {
std::fs::OpenOptions::new()
.write(true)
.open("/proc/self/clear_refs")
.is_ok()
}
/// Get file inode number /// Get file inode number
pub fn get_inode(path: &Path) -> std::io::Result<u64> { pub fn get_inode(path: &Path) -> std::io::Result<u64> {
let meta = fs::metadata(path)?; let meta = fs::metadata(path)?;

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

@@ -10,7 +10,7 @@
use std::cell::RefCell; use std::cell::RefCell;
use std::fs::File; use std::fs::File;
use std::io::BufReader; use std::io::{BufReader, Seek as _, SeekFrom};
use std::os::unix::fs::FileExt; use std::os::unix::fs::FileExt;
use std::os::unix::io::AsRawFd; use std::os::unix::io::AsRawFd;
use std::path::Path; use std::path::Path;
@@ -43,6 +43,10 @@ impl ReadCache {
/// Read `len` bytes starting at `offset`. Returns a slice into the cache. /// Read `len` bytes starting at `offset`. Returns a slice into the cache.
/// On cache hit (range fully within cached block), no syscall needed. /// On cache hit (range fully within cached block), no syscall needed.
/// On miss, performs a `read_exact_at` syscall. /// On miss, performs a `read_exact_at` syscall.
fn invalidate(&mut self) {
self.buf_len = 0;
}
fn get(&mut self, file: &File, offset: u64, len: usize) -> std::io::Result<&[u8]> { fn get(&mut self, file: &File, offset: u64, len: usize) -> std::io::Result<&[u8]> {
let end = offset + len as u64; let end = offset + len as u64;
if offset >= self.buf_offset && end <= self.buf_offset + self.buf_len as u64 { if offset >= self.buf_offset && end <= self.buf_offset + self.buf_len as u64 {
@@ -189,6 +193,19 @@ impl PreadReaderCore {
pub fn total_lines(&self) -> usize { pub fn total_lines(&self) -> usize {
self.line_index.line_count() self.line_index.line_count()
} }
pub fn refresh_index(&mut self) -> std::io::Result<()> {
let new_size = self.file.metadata()?.len();
self.file.seek(SeekFrom::Start(0))?;
let new_index = {
let mut reader = BufReader::new(&self.file);
LineIndex::from_reader(&mut reader)?
};
self.file_size = new_size;
self.line_index = new_index;
self.cache.get_mut().invalidate();
Ok(())
}
} }
// ─── 3 Variants ─────────────────────────────────────────────────────────────── // ─── 3 Variants ───────────────────────────────────────────────────────────────
@@ -198,6 +215,12 @@ pub struct PreadReaderPlain {
inner: PreadReaderCore, inner: PreadReaderCore,
} }
impl PreadReaderPlain {
pub fn refresh_index(&mut self) -> std::io::Result<()> {
self.inner.refresh_index()
}
}
impl FileReaderBackend for PreadReaderPlain { impl FileReaderBackend for PreadReaderPlain {
fn name(&self) -> &str { fn name(&self) -> &str {
"pread_plain" "pread_plain"
@@ -441,6 +464,55 @@ mod tests {
reader.close(); reader.close();
} }
#[test]
fn test_refresh_index_sees_appended_lines() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("refresh_test.log");
// Phase 1: write 3 initial lines
{
let mut f = std::fs::File::create(&path).unwrap();
std::io::Write::write_all(&mut f, b"alpha\nbeta\ngamma\n").unwrap();
}
let mut reader = PreadReaderPlain::open(&path).unwrap();
assert_eq!(reader.total_lines(), 3);
assert_eq!(reader.get_line(0), Some("alpha".to_owned()));
assert_eq!(reader.get_line(3), None, "should be out of bounds before append");
// Phase 2: append 2 more lines
{
use std::io::Write as _;
let mut f = std::fs::OpenOptions::new().append(true).open(&path).unwrap();
f.write_all(b"delta\nepsilon\n").unwrap();
}
// Still stale — refresh needed
assert_eq!(reader.get_line(3), None, "stale index before refresh");
reader.refresh_index().unwrap();
assert_eq!(reader.total_lines(), 5);
assert_eq!(reader.get_line(3), Some("delta".to_owned()));
assert_eq!(reader.get_line(4), Some("epsilon".to_owned()));
assert_eq!(reader.get_line(5), None);
reader.close();
}
#[test]
fn test_refresh_index_no_change_is_noop() {
let f = create_temp_file(b"one\ntwo\n");
let mut reader = PreadReaderPlain::open(f.path()).unwrap();
assert_eq!(reader.total_lines(), 2);
reader.refresh_index().unwrap();
assert_eq!(reader.total_lines(), 2);
assert_eq!(reader.get_line(0), Some("one".to_owned()));
assert_eq!(reader.get_line(1), Some("two".to_owned()));
reader.close();
}
#[test] #[test]
fn test_truncation_graceful_error() { fn test_truncation_graceful_error() {
// Verify that truncated file access returns None/Err instead of panicking // Verify that truncated file access returns None/Err instead of panicking

View File

@@ -43,14 +43,14 @@ pub fn format_report(results: &[BenchmarkResult]) -> String {
let mut variants: Vec<(String, String)> = Vec::new(); let mut variants: Vec<(String, String)> = Vec::new();
for r in category_results { for r in category_results {
let key = format!("{} ({})", r.backend, r.variant);
if !variants if !variants
.iter() .iter()
.any(|(b, v)| format!("{} ({})", b, v) == key) .any(|(b, v)| b == &r.backend && v == &r.variant)
{ {
variants.push((r.backend.clone(), r.variant.clone())); variants.push((r.backend.clone(), r.variant.clone()));
} }
} }
variants.sort();
report.push_str("### Latency\n\n"); report.push_str("### Latency\n\n");
report.push_str("| Test |"); report.push_str("| Test |");
@@ -143,7 +143,12 @@ 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");
for r in category_results { let mut mem_rows: Vec<&BenchmarkResult> = category_results.to_vec();
mem_rows.sort_by(|a, b| {
(&a.test_name, &a.backend, &a.variant)
.cmp(&(&b.test_name, &b.backend, &b.variant))
});
for r in mem_rows {
let variant_label = format!("{} ({})", r.backend, r.variant); let variant_label = format!("{} ({})", r.backend, r.variant);
report.push_str(&format!( report.push_str(&format!(
"| {} | {} | {} | {} | {} |\n", "| {} | {} | {} | {} | {} |\n",
@@ -157,7 +162,10 @@ pub fn format_report(results: &[BenchmarkResult]) -> String {
report.push('\n'); report.push('\n');
} }
let 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| {
@@ -171,6 +179,7 @@ pub fn format_report(results: &[BenchmarkResult]) -> String {
) )
}) })
.collect(); .collect();
extras.sort_by(|a, b| (&a.0, &a.1).cmp(&(&b.0, &b.1)));
if !extras.is_empty() { if !extras.is_empty() {
report.push_str("### Extra Metrics\n\n"); report.push_str("### Extra Metrics\n\n");
@@ -205,3 +214,50 @@ fn capitalize(s: &str) -> String {
Some(f) => f.to_uppercase().chain(c).collect(), Some(f) => f.to_uppercase().chain(c).collect(),
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn make_result(
category: &str,
test_name: &str,
backend: &str,
variant: &str,
latency_us: Vec<u64>,
) -> BenchmarkResult {
BenchmarkResult {
category: category.to_string(),
test_name: test_name.to_string(),
backend: backend.to_string(),
variant: variant.to_string(),
latency_us,
rss_kb: 0,
rss_peak_kb: 0,
page_faults: 0,
extra: HashMap::new(),
}
}
#[test]
fn report_ordering_independent_of_input_order() {
let set_a = vec![
make_result("sequential", "read_1mb", "pread", "default", vec![100, 110, 105]),
make_result("sequential", "read_1mb", "mmap", "default", vec![80, 85, 90]),
make_result("sequential", "read_4kb", "pread", "default", vec![10, 12, 11]),
make_result("sequential", "read_4kb", "mmap", "default", vec![8, 9, 7]),
];
let set_b = vec![
make_result("sequential", "read_4kb", "mmap", "default", vec![8, 9, 7]),
make_result("sequential", "read_1mb", "mmap", "default", vec![80, 85, 90]),
make_result("sequential", "read_4kb", "pread", "default", vec![10, 12, 11]),
make_result("sequential", "read_1mb", "pread", "default", vec![100, 110, 105]),
];
let report_a = format_report(&set_a);
let report_b = format_report(&set_b);
assert_eq!(report_a, report_b, "Reports must be identical regardless of input order");
}
}

View File

@@ -1,59 +1,221 @@
use crate::metrics::MetricsCollector; use crate::metrics::MetricsCollector;
use crate::types::BenchmarkResult; use crate::types::BenchmarkResult;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
/// All recognized benchmark suite identifiers.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Suite {
Startup,
Render,
Jump,
Memory,
Growth,
Rotation,
Concurrent,
}
impl Suite {
/// All valid suite identifiers, in execution order.
pub const ALL: &[Suite] = &[
Suite::Startup,
Suite::Render,
Suite::Jump,
Suite::Memory,
Suite::Growth,
Suite::Rotation,
Suite::Concurrent,
];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Suite::Startup => "startup",
Suite::Render => "render",
Suite::Jump => "jump",
Suite::Memory => "memory",
Suite::Growth => "growth",
Suite::Rotation => "rotation",
Suite::Concurrent => "concurrent",
}
}
}
impl std::str::FromStr for Suite {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Suite::ALL
.iter()
.find(|suite| suite.as_str() == s)
.copied()
.ok_or_else(|| {
let valid = Suite::ALL
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", ");
format!("invalid value '{s}' for '--suites': valid values are {valid}")
})
}
}
pub struct BenchConfig { pub struct BenchConfig {
pub test_file: PathBuf, pub test_file: PathBuf,
pub quick_mode: bool, pub quick_mode: bool,
pub suites: Option<Vec<String>>, pub suites: Option<Vec<Suite>>,
} }
fn warn_reset_hwm() { /// Track whether we have already warned about VmHWM reset failure.
/// Prevents duplicate warnings across multiple suite runs.
static HWM_WARNED: AtomicBool = AtomicBool::new(false);
pub fn warn_reset_hwm() {
if let Err(e) = MetricsCollector::reset_vm_hwm() { if let Err(e) = MetricsCollector::reset_vm_hwm() {
if HWM_WARNED.swap(true, Ordering::Relaxed) {
return;
}
if e.kind() == std::io::ErrorKind::PermissionDenied { if e.kind() == std::io::ErrorKind::PermissionDenied {
eprintln!("WARNING: VmHWM reset requires root: {e}"); eprintln!(
"WARNING: Failed to reset VmHWM via /proc/self/clear_refs: {e}. \
Memory peak values may be contaminated across benchmark suites. \
Try running as root."
);
} else {
eprintln!(
"WARNING: Failed to reset VmHWM via /proc/self/clear_refs: {e}. \
Memory peak values may be contaminated across benchmark suites."
);
} }
} }
} }
#[cfg(test)]
fn reset_hwm_warned() {
HWM_WARNED.store(false, Ordering::Relaxed);
}
pub fn run_all(config: &BenchConfig) -> Vec<BenchmarkResult> { pub fn run_all(config: &BenchConfig) -> Vec<BenchmarkResult> {
let mut results = Vec::new(); let mut results = Vec::new();
let should_run = |name: &str| -> bool { let should_run = |suite: Suite| -> bool {
match &config.suites { match &config.suites {
Some(suites) => suites.iter().any(|s| s == name), Some(suites) => suites.contains(&suite),
None => true, None => true,
} }
}; };
if should_run("startup") { if should_run(Suite::Startup) {
warn_reset_hwm(); warn_reset_hwm();
results.extend(crate::suites::startup::run(config)); results.extend(crate::suites::startup::run(config));
} }
if should_run("render") { if should_run(Suite::Render) {
warn_reset_hwm(); warn_reset_hwm();
results.extend(crate::suites::render::run(config)); results.extend(crate::suites::render::run(config));
} }
if should_run("jump") { if should_run(Suite::Jump) {
warn_reset_hwm(); warn_reset_hwm();
results.extend(crate::suites::jump::run(config)); results.extend(crate::suites::jump::run(config));
} }
if should_run("memory") { if should_run(Suite::Memory) {
warn_reset_hwm(); warn_reset_hwm();
results.extend(crate::suites::memory::run(config)); results.extend(crate::suites::memory::run(config));
} }
if should_run("growth") { if should_run(Suite::Growth) {
warn_reset_hwm(); warn_reset_hwm();
results.extend(crate::suites::growth::run(config)); results.extend(crate::suites::growth::run(config));
} }
if should_run("rotation") { if should_run(Suite::Rotation) {
warn_reset_hwm(); warn_reset_hwm();
results.extend(crate::suites::rotation::run(config)); results.extend(crate::suites::rotation::run(config));
} }
if should_run("concurrent") { if should_run(Suite::Concurrent) {
warn_reset_hwm(); warn_reset_hwm();
results.extend(crate::suites::concurrent::run(config)); results.extend(crate::suites::concurrent::run(config));
} }
results results
} }
#[cfg(test)]
mod tests {
use super::Suite;
use std::str::FromStr;
#[test]
fn parse_all_valid_suites() {
let expected = [
("startup", Suite::Startup),
("render", Suite::Render),
("jump", Suite::Jump),
("memory", Suite::Memory),
("growth", Suite::Growth),
("rotation", Suite::Rotation),
("concurrent", Suite::Concurrent),
];
for (s, expected_suite) in expected {
assert_eq!(Suite::from_str(s).unwrap(), expected_suite, "failed to parse '{s}'");
}
}
#[test]
fn misspelled_suite_returns_error() {
let err = Suite::from_str("startp").unwrap_err();
assert!(
err.contains("invalid value 'startp'"),
"error should mention the bad value: {err}"
);
}
#[test]
fn error_message_lists_all_valid_values() {
let err = Suite::from_str("bogus").unwrap_err();
for name in Suite::ALL.iter().map(|s| s.as_str()) {
assert!(
err.contains(name),
"error should list valid suite '{name}': {err}"
);
}
}
#[test]
fn mixed_valid_invalid_stops_at_first_error() {
// "startup" is valid, "zzz" is not — collect hits the first Err
let names = ["startup".to_string(), "zzz".to_string()];
let result: Result<Vec<Suite>, _> = names.iter().map(|s| s.parse()).collect();
assert!(result.is_err());
}
#[test]
fn hwm_warned_flag_prevents_reentry() {
use super::reset_hwm_warned;
use std::sync::atomic::Ordering;
reset_hwm_warned();
assert!(
!super::HWM_WARNED.load(Ordering::Relaxed),
"flag should be false after reset"
);
// Simulate the flag being set (as warn_reset_hwm would do on error)
super::HWM_WARNED.store(true, Ordering::Relaxed);
// swap should now return true (old value), indicating already warned
assert!(
super::HWM_WARNED.swap(true, Ordering::Relaxed),
"swap should return the previous value (true)"
);
}
#[test]
fn warn_reset_hwm_does_not_panic() {
use super::reset_hwm_warned;
reset_hwm_warned();
// Whether reset_vm_hwm succeeds or fails, warn_reset_hwm must not panic.
// Multiple calls must also be safe.
super::warn_reset_hwm();
super::warn_reset_hwm();
super::warn_reset_hwm();
}
}

View File

@@ -180,18 +180,40 @@ fn bench_scroll_during_append(config: &BenchConfig, dir: &std::path::Path) -> Ve
} }
}); });
let reader = PreadReaderPlain::open(&path).expect("Failed to open file"); let mut reader = PreadReaderPlain::open(&path).expect("Failed to open file");
let initial_lines = reader.total_lines();
let mut frame_latencies = Vec::new(); let mut frame_latencies = Vec::new();
let mut current_line = 0usize; let mut current_line = 0usize;
let mut refresh_count: u64 = 0;
let mut none_count: u64 = 0;
let scroll_start = std::time::Instant::now(); let scroll_start = std::time::Instant::now();
let mut last_refresh = std::time::Instant::now();
let refresh_interval = std::time::Duration::from_millis(250);
while scroll_start.elapsed().as_secs() < duration_secs { while scroll_start.elapsed().as_secs() < duration_secs {
let t = std::time::Instant::now(); if last_refresh.elapsed() >= refresh_interval {
let _ = reader.get_line(current_line); reader.refresh_index().ok();
frame_latencies.push(t.elapsed().as_micros() as u64); refresh_count += 1;
current_line += 1; last_refresh = std::time::Instant::now();
continue;
}
if let Some(_line) = reader.get_line(current_line) {
let t = std::time::Instant::now();
frame_latencies.push(t.elapsed().as_micros() as u64);
current_line += 1;
} else {
none_count += 1;
std::thread::sleep(std::time::Duration::from_millis(1));
}
} }
let max_line = current_line;
assert!(
max_line > initial_lines,
"benchmark never read past initial {initial_lines} lines (max={max_line})"
);
reader.close(); reader.close();
bg_handle.join().ok(); bg_handle.join().ok();
@@ -202,6 +224,10 @@ fn bench_scroll_during_append(config: &BenchConfig, dir: &std::path::Path) -> Ve
extra.insert("duration_secs".into(), duration_secs as f64); extra.insert("duration_secs".into(), duration_secs as f64);
extra.insert("append_rate_per_sec".into(), append_rate as f64); extra.insert("append_rate_per_sec".into(), append_rate as f64);
extra.insert("frames_rendered".into(), frame_latencies.len() as f64); extra.insert("frames_rendered".into(), frame_latencies.len() as f64);
extra.insert("refresh_count".into(), refresh_count as f64);
extra.insert("none_count".into(), none_count as f64);
extra.insert("initial_lines".into(), initial_lines as f64);
extra.insert("max_line_seen".into(), max_line as f64);
vec![BenchmarkResult { vec![BenchmarkResult {
category: "growth".into(), category: "growth".into(),

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();

View File

@@ -40,35 +40,42 @@ pub fn run(config: &BenchConfig) -> Vec<BenchmarkResult> {
)); ));
if !config.quick_mode { if !config.quick_mode {
if MetricsCollector::clear_file_cache(&config.test_file).is_ok() { match MetricsCollector::clear_file_cache(&config.test_file) {
results.extend(bench_cold_open::<MmapReaderPlain>("mmap", "plain", config)); Ok(()) => {
results.extend(bench_cold_open::<MmapReaderSequential>( results.extend(bench_cold_open::<MmapReaderPlain>("mmap", "plain", config));
"mmap", results.extend(bench_cold_open::<MmapReaderSequential>(
"sequential", "mmap",
config, "sequential",
)); config,
results.extend(bench_cold_open::<MmapReaderRandom>( ));
"mmap", "random", config, results.extend(bench_cold_open::<MmapReaderRandom>(
)); "mmap", "random", config,
results.extend(bench_cold_open::<MmapReaderPopulate>( ));
"mmap", "populate", config, results.extend(bench_cold_open::<MmapReaderPopulate>(
)); "mmap", "populate", config,
results.extend(bench_cold_open::<MmapReaderPhaseAware>( ));
"mmap", results.extend(bench_cold_open::<MmapReaderPhaseAware>(
"phase_aware", "mmap",
config, "phase_aware",
)); config,
results.extend(bench_cold_open::<PreadReaderPlain>( ));
"pread", "plain", config, results.extend(bench_cold_open::<PreadReaderPlain>(
)); "pread", "plain", config,
results.extend(bench_cold_open::<PreadReaderRandom>( ));
"pread", "random", config, results.extend(bench_cold_open::<PreadReaderRandom>(
)); "pread", "random", config,
results.extend(bench_cold_open::<PreadReaderSequential>( ));
"pread", results.extend(bench_cold_open::<PreadReaderSequential>(
"sequential", "pread",
config, "sequential",
)); config,
));
}
Err(e) => {
eprintln!(
"WARNING: Failed to clear file cache; skipping cold startup benchmarks because results would not be cold: {e}"
);
}
} }
} }
@@ -88,8 +95,15 @@ fn bench_cold_open<B: FileReaderBackend>(
variant: &str, variant: &str,
config: &BenchConfig, config: &BenchConfig,
) -> Vec<BenchmarkResult> { ) -> Vec<BenchmarkResult> {
let _ = MetricsCollector::clear_file_cache(&config.test_file); match MetricsCollector::clear_file_cache(&config.test_file) {
open_and_measure::<B>(backend, variant, &config.test_file, "cold_open") Ok(()) => open_and_measure::<B>(backend, variant, &config.test_file, "cold_open"),
Err(e) => {
eprintln!(
"WARNING: Failed to clear file cache for {backend}/{variant}; skipping cold benchmark: {e}"
);
Vec::new()
}
}
} }
fn open_and_measure<B: FileReaderBackend>( fn open_and_measure<B: FileReaderBackend>(