Compare commits
11 Commits
9baec5ab69
...
fix/m20-ap
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a2f8ecb66 | ||
|
|
f6081b9fe9 | ||
|
|
97a2c6a925 | ||
|
|
e6e0e2cc90 | ||
|
|
ffaf462bae | ||
|
|
a8dc067cd4 | ||
|
|
502479677b | ||
|
|
5656b26d7b | ||
|
|
a8b64e78bd | ||
|
|
e945a357f7 | ||
|
|
fb57584546 |
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -91,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,
|
||||||
@@ -125,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)]
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)?;
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>(
|
||||||
|
|||||||
Reference in New Issue
Block a user