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>
183 lines
7.5 KiB
Rust
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 })
|
|
}
|