Files
DarkRoom/tools/bench/src/memory.rs
T
dtourolleandClaude Opus 5 a8008feaf3 Inline the verdict header, and take the formatter's pass
clippy::print_literal on the results table header, and rustfmt's first
look at code whose author could not run cargo. The four constructs the
author flagged as risky -- scoped-thread lanes, a seventeen-argument
params!, is_some_and over a closure, a refutable let-else -- all compiled
untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 13:13:57 +02:00

183 lines
7.5 KiB
Rust

//! Idle memory with a 50k catalog open — and the question NFR-P8 leaves open.
//!
//! # The question §4.1 asks, answered
//!
//! §4.1 says of NFR-P8: *"must state whether it measures RSS inclusive or
//! exclusive of GPU allocations, and whether it holds after SQLite's page cache
//! warms on a 50k catalog."* Both halves have an answer, and neither is
//! flattering.
//!
//! **On GPU memory: what this reports is RSS, and RSS is exclusive of
//! device-local GPU allocations.** A Vulkan allocation in device-local heap
//! never enters the process's address space, so no counter under
//! `/proc/self/status` can see it; what *does* land in RSS is the host-visible
//! side — staging buffers, mapped upload rings, the read-back `AdjustPass`
//! performs on export — and the driver's own resident pages. So "RSS < 500 MB"
//! is not one budget, it is two questions wearing one number, and a build that
//! kept RSS at 400 MB while holding 3 GB of textures would pass it.
//!
//! The recommendation this measurement exists to support: **NFR-P8 should be
//! restated as two figures** — host RSS exclusive of device-local memory, and
//! a separate VRAM ceiling read from the adapter — because the second is the
//! one that decides whether the application survives beside a browser on an
//! 8 GB card, and nothing in this repository currently measures it.
//!
//! **On the page cache: warm.** The probe runs the queries before it reads the
//! counter, so SQLite's page cache holds the b-tree pages a grid scroll
//! touches. That is the right side to err on — a figure taken before the cache
//! warms would understate a steady-state library — and it is why the probe
//! scrolls rather than opening and stopping.
//!
//! # Why this is a subprocess
//!
//! RSS is a high-water-influenced property of a *process*, not of a function.
//! Building a 50k fixture allocates hundreds of megabytes; decoding thumbnails
//! allocates more; the allocator returns some of it to the OS and keeps the
//! rest. Measuring after any of that would report the harness's history rather
//! than the catalog's cost. So the probe is a fresh process that opens the
//! catalog, does the grid's work, reads its own counters and exits.
//!
//! # What this cannot certify, said plainly
//!
//! Not NFR-P8. The requirement is about the *application* at idle — Slint, the
//! wgpu device, the font stack, the decode cache and the catalog together — and
//! this process contains only the last of those. No requirement tag in this
//! crate names NFR-P8, for that reason — and see `exporting.rs` for why that
//! sentence avoids spelling the tag out.
//!
//! What it is, is the catalog layer's share, measured rather than guessed. The
//! decision NFR-P8 actually needs — how much of the 500 MB belongs to the
//! catalog and how much to everything above it — is a decision somebody has to
//! take, and taking it against a recorded number is better than taking it
//! against an estimate. That is what this records. Until it is taken, the
//! metric carries no budget and gates only against its own baseline.
use std::path::Path;
use anyhow::Result;
use dr_catalog::{Catalog, Granularity, Query};
/// Rows fetched per window while the probe scrolls. The same 400
/// [`crate::catalog_open`] uses, for the same reason.
const WINDOW: usize = 400;
/// Windows the probe pages through before reading the counter.
///
/// Twenty-five is ten thousand rows: enough that SQLite's page cache holds a
/// realistic working set and that any per-window leak would be visible, and
/// far short of the whole library, which FR-CAT-4 forbids holding anyway.
const WINDOWS: usize = 25;
/// A fixed clock, for the reason `catalog_open`'s `NOW` gives: nothing here
/// should depend on the day it runs.
const NOW: i64 = 2_000_000_000;
/// Resident memory, in kilobytes, as Linux reports it.
#[derive(Debug, Clone, Copy)]
pub struct Rss {
/// `VmRSS`: resident now.
pub now_kb: u64,
/// `VmHWM`: the peak this process reached. Reported alongside because a
/// process that touched 900 MB and gave it back is not idling at 200 MB in
/// any sense a user would recognise — the pages came from somewhere.
pub peak_kb: u64,
}
impl Rss {
pub fn now_mb(&self) -> f64 {
self.now_kb as f64 / 1024.0
}
pub fn peak_mb(&self) -> f64 {
self.peak_kb as f64 / 1024.0
}
}
/// Read this process's own counters.
///
/// `None` anywhere without a Linux-shaped `/proc` — including Android, where
/// the file exists but a benchmark does not run, and macOS, where it does not.
/// Returning `None` rather than zero is deliberate: a memory figure of zero
/// would be reported as an excellent result.
pub fn of_this_process() -> Option<Rss> {
let status = std::fs::read_to_string("/proc/self/status").ok()?;
let mut now = None;
let mut peak = None;
for line in status.lines() {
if let Some(rest) = line.strip_prefix("VmRSS:") {
now = rest.split_whitespace().next()?.parse::<u64>().ok();
} else if let Some(rest) = line.strip_prefix("VmHWM:") {
peak = rest.split_whitespace().next()?.parse::<u64>().ok();
}
}
Some(Rss {
now_kb: now?,
peak_kb: peak?,
})
}
/// The probe: open the catalog, do what the grid does, print the counters.
///
/// Stdout is one line of `key=value` pairs rather than JSON, because the only
/// reader is [`in_a_fresh_process`] and a format a human can read in a log is
/// worth more here than one a parser prefers.
pub fn probe(catalog_path: &Path) -> Result<()> {
let catalog = Catalog::open(catalog_path)
.map_err(|e| anyhow::anyhow!("opening {} : {e}", catalog_path.display()))?;
let q = Query::default();
let images = catalog.count(&q, NOW)?;
let buckets = catalog.timeline(&q, Granularity::Month, NOW)?.len();
// Scroll, keeping only the window in hand — which is what the grid does,
// and what FR-CAT-4 requires it to do. If this ever starts costing memory
// proportional to how far the user scrolled, that is the bug this figure
// exists to catch.
let mut rows = 0usize;
let span = images.saturating_sub(WINDOW).max(1);
for i in 0..WINDOWS {
let start = (i * span) / WINDOWS.max(1);
rows = catalog.window(&q, start..start + WINDOW, NOW)?.len();
}
let Some(rss) = of_this_process() else {
anyhow::bail!("no /proc/self/status on this platform; RSS cannot be read");
};
println!(
"rss_kb={} peak_kb={} images={images} buckets={buckets} last_window={rows}",
rss.now_kb, rss.peak_kb
);
Ok(())
}
/// Run [`probe`] in a fresh copy of this executable and read back its counters.
pub fn in_a_fresh_process(catalog_path: &Path) -> Result<Rss> {
let exe = std::env::current_exe()?;
let output = std::process::Command::new(&exe)
.arg("memory-probe")
.arg(catalog_path)
.output()?;
if !output.status.success() {
anyhow::bail!(
"the memory probe exited with {}: {}",
output.status,
String::from_utf8_lossy(&output.stderr).trim()
);
}
let text = String::from_utf8_lossy(&output.stdout);
let field = |key: &str| -> Option<u64> {
text.split_whitespace()
.find_map(|pair| pair.strip_prefix(key))
.and_then(|v| v.parse::<u64>().ok())
};
let (Some(now_kb), Some(peak_kb)) = (field("rss_kb="), field("peak_kb=")) else {
anyhow::bail!(
"the memory probe printed something unreadable: {}",
text.trim()
);
};
Ok(Rss { now_kb, peak_kb })
}