crates/nornir-docbook/examples/mem.rs
//! **Where the 3.2 GB goes.** The memory half of `examples/bench.rs`.
//!
//! ```text
//! cargo run --release --example mem # the 3 MB document
//! cargo run --release --example mem -- 300000 # any other size
//! ```
//!
//! `bench.rs` reports ONE peak-RSS number per input size and the 2026-09-09
//! table's own conclusion was that memory, not time, is the ceiling: **3359 MB
//! of peak RSS for 3 MB of markdown, about a thousand times the input**. One
//! number cannot be acted on, because it does not say whether the memory is
//! typst laying the document out or this crate copying the result afterwards.
//! This splits it.
//!
//! # The instrument
//!
//! [`gunnar_stage`] (`crates/gunnar-stage`, zero dependencies, already in this
//! workspace) does the per-stage bookkeeping — it is not written twice. The two
//! facts it deliberately refuses to gather for itself arrive here as the
//! measuring binary's own:
//!
//! * a `#[global_allocator]` wrapping `System` that calls
//! [`gunnar_stage::note_alloc`] / [`note_dealloc`](gunnar_stage::note_dealloc).
//! A `#[global_allocator]` is a property of the whole executable and is the
//! binary's to choose, which is exactly why the attributor does not ship one;
//! this is the ~20 lines that are genuinely this example's and not a second
//! copy of the attributor.
//! * `VmHWM` / `VmRSS` out of `/proc/self/status`, read at stage boundaries
//! **outside** the guards, so the read's own allocation is not charged to the
//! stage it is measuring.
//!
//! The columns are read this way:
//!
//! | column | what it is |
//! |---|---|
//! | `churn` | bytes this stage asked the allocator for, children subtracted. Transients included. |
//! | `peak+` | how much this stage raised the high-water mark of LIVE bytes. **This is the column that owns the 3.2 GB** — it is monotone, so the stages sum to the peak instead of each claiming it. |
//! | `kept` | live bytes the stage still holds when it returns |
//! | `VmHWM` | the kernel's process peak as of the end of the stage |
//!
//! # Why the typst half is reconstructed, and how that is kept honest
//!
//! `export_svg_column` is one public call: engine, compile, `typst_svg::svg`,
//! `optimize_svg`, the frame walk and the text layer are all inside it, and a
//! stage boundary cannot be put in the library — `nornir-docbook` is published
//! and will not grow a dependency for a measurement.
//!
//! So the ladder below rebuilds the same pipeline out of the same crates, and
//! then **proves it is the same pipeline by bytes**: [`verify_reconstruction`]
//! runs both on a small document and asserts that everything after the root
//! element is identical, character for character. A reconstruction that drifted
//! — a different template, a different font set, a different column width —
//! moves those bytes and fails. That check is the whole reason these numbers may
//! be quoted; "a plausible number measured off the real path" is the failure
//! this example is most exposed to.
mod common;
use std::alloc::{GlobalAlloc, Layout, System};
use std::sync::atomic::{AtomicI64, Ordering};
use common::{document_with, mb, meta, ms, peak_rss_kb, rss_kb};
use nornir_docbook::export::md_to_typst;
use nornir_docbook::{export_svg_column, optimize_svg, ImageSet, TextLayer};
use typst::foundations::{Dict, IntoValue};
use typst_as_lib::TypstEngine;
// ── the allocator's side ────────────────────────────────────────────────────
/// A `#[global_allocator]` that tells [`gunnar_stage`] the size of every block.
///
/// It forwards to `System` unchanged and the accounting call allocates nothing
/// (four thread-local `Cell`s), so it cannot re-enter the allocator that is
/// calling it. Disabled — which is every run until [`gunnar_stage::enable`] —
/// each hook is one relaxed atomic load.
struct CountingAlloc;
/// Live allocated bytes, and their high-water mark, for the WHOLE PROCESS.
///
/// Beside [`gunnar_stage`]'s per-thread attribution rather than instead of it,
/// and the two answer different questions. The attributor is per thread by
/// design — it exists because a per-process counter could not tell 32
/// concurrent clients apart — but typst lays out on rayon workers, so its
/// per-thread columns below are the MAIN THREAD'S SHARE of typst's cost and
/// therefore a floor on it, never a ceiling. These two atomics see every
/// thread, so `live` and `peak live` are the process's real numbers.
static LIVE: AtomicI64 = AtomicI64::new(0);
static PEAK_LIVE: AtomicI64 = AtomicI64::new(0);
#[inline]
fn note(delta: i64) {
if !gunnar_stage::enabled() {
return;
}
let live = LIVE.fetch_add(delta, Ordering::Relaxed) + delta;
PEAK_LIVE.fetch_max(live, Ordering::Relaxed);
}
/// Live allocated bytes across every thread, right now.
fn live_bytes() -> i64 {
LIVE.load(Ordering::Relaxed)
}
/// The high-water mark of [`live_bytes`] since the counters were last reset.
fn peak_live_bytes() -> i64 {
PEAK_LIVE.load(Ordering::Relaxed)
}
fn reset_live() {
LIVE.store(0, Ordering::Relaxed);
PEAK_LIVE.store(0, Ordering::Relaxed);
}
// SAFETY: every method forwards to `System` unchanged; the accounting touches
// only `Cell`s and two atomics and cannot allocate.
unsafe impl GlobalAlloc for CountingAlloc {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
let p = System.alloc(layout);
if !p.is_null() {
gunnar_stage::note_alloc(layout.size());
note(layout.size() as i64);
}
p
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
gunnar_stage::note_dealloc(layout.size());
note(-(layout.size() as i64));
System.dealloc(ptr, layout)
}
unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
let p = System.realloc(ptr, layout, new_size);
if !p.is_null() {
gunnar_stage::note_dealloc(layout.size());
gunnar_stage::note_alloc(new_size);
note(new_size as i64 - layout.size() as i64);
}
p
}
unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
let p = System.alloc_zeroed(layout);
if !p.is_null() {
gunnar_stage::note_alloc(layout.size());
note(layout.size() as i64);
}
p
}
}
#[global_allocator]
static ALLOC: CountingAlloc = CountingAlloc;
// ── the pipeline, rebuilt ───────────────────────────────────────────────────
/// The column template, read from the same file `rust-embed` embeds.
fn column_template() -> String {
let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/typst/column.typ");
std::fs::read_to_string(&p).unwrap_or_else(|e| panic!("{} must exist: {e}", p.display()))
}
fn fonts() -> Vec<typst::text::Font> {
// typst-kit 0.15: the bundled faces only, as the shipped `embedded_fonts()` does.
typst_kit::fonts::embedded().map(|(font, _info)| font).collect()
}
fn inputs(title: &str, body: String, width_pt: f64) -> Dict {
let mut inputs = Dict::new();
inputs.insert("title".into(), title.to_owned().into_value());
inputs.insert("version".into(), String::new().into_value());
inputs.insert("generated".into(), String::new().into_value());
inputs.insert("cover_image".into(), String::new().into_value());
inputs.insert("body".into(), body.into_value());
inputs.insert("width".into(), width_pt.into_value());
inputs
}
/// **The guard that lets the ladder be quoted.** Run the reconstruction and the
/// shipped function on the same document and compare every byte after the root
/// element.
///
/// Returns the number of bytes compared, so a caller can refuse a comparison
/// that was vacuously short.
fn verify_reconstruction(md: &str, width_pt: f64) -> usize {
let body = md_to_typst(md);
let engine = TypstEngine::builder()
.main_file(column_template())
.fonts(fonts())
.build();
let doc: typst_layout::PagedDocument = engine
.compile_with_input(inputs("corpus", body, width_pt))
.output
.expect("reconstruction compiles");
let page = doc.pages().first().expect("one page");
let mine = optimize_svg(&typst_svg::svg(page, &typst_svg::SvgOptions::default()));
let shipped =
export_svg_column(md, &meta(), &ImageSet::new(), 720.0, TextLayer::Off).expect("shipped");
// `dress_svg` rewrites only the root element's own attributes and inserts a
// `<title>`; everything after the root's first `>` is the page itself.
let tail = &mine[mine.find('>').expect("a root element") + 1..];
assert!(
shipped.ends_with(tail),
"the reconstruction is NOT the shipped pipeline: {} B rebuilt, {} B shipped",
mine.len(),
shipped.len(),
);
tail.len()
}
// ── the ladder ──────────────────────────────────────────────────────────────
fn main() {
let mode = std::env::args().nth(1).unwrap_or_default();
let bytes: usize = std::env::args()
.nth(2)
.and_then(|s| s.parse().ok())
.unwrap_or(3_000_000);
// Non-vacuity, first, because every column below is shaped exactly like
// "this stage is free" when the instrument is not actually installed.
prove_the_instrument_works();
match mode.as_str() {
// What ONE caller pays, in a process that has rendered nothing else.
// The ladder cannot answer this: it holds three renders' worth of
// high-water by the time it finishes, which is a server's number and
// not a request's.
"one" => one(bytes),
// Does typst's `comemo` memo cache hold the document after the render?
"memo" => memo(bytes),
// What `SvgzBook::write` costs per page, which is the book path rather
// than the column path.
"book" => book(bytes),
// Write the rendered page to `argv[3]`, so two builds of this crate can
// be compared byte for byte from outside either of them.
"dump" => dump(bytes),
_ => {
// The reconstruction is proved on a SMALL document: the assertion
// holds two full copies of the page, which on the 3 MB corpus
// would itself be the largest thing in the process.
let compared = verify_reconstruction(&document_with(30_000, false), 720.0);
assert!(
compared > 1_000_000,
"the byte comparison must actually span a page: only {compared} B",
);
println!(
"reconstruction verified against the shipped pipeline: {compared} B identical\n"
);
ladder(bytes);
}
}
}
/// **One render, one process** — what a forge answering a README request pays.
fn one(bytes: usize) {
let md = document_with(bytes, false);
gunnar_stage::reset();
reset_live();
gunnar_stage::enable();
let base = live_bytes();
let t = std::time::Instant::now();
let out = export_svg_column(&md, &meta(), &ImageSet::new(), 720.0, TextLayer::On).expect("svg");
let wall = t.elapsed();
let held = live_bytes();
let n = out.len();
drop(out);
let after = live_bytes();
gunnar_stage::disable();
println!(
"one render, fresh process: {} KB markdown -> {} KB SVG\n \
VmHWM {} MB VmRSS now {} MB peak live {} MB live at return {} MB \
live after drop {} MB {:.0} ms",
md.len() / 1024,
n / 1024,
peak_rss_kb() / 1024,
rss_kb() / 1024,
mb(peak_live_bytes().max(0) as u64),
mb((held - base).max(0) as u64),
mb((after - base).max(0) as u64),
ms(wall),
);
}
/// **Does the memo cache hold the document?** typst memoizes through `comemo`
/// into a process-wide cache that nothing in this crate ever evicts, and a
/// cache that is never evicted is indistinguishable from a leak on a server.
///
/// Two renders of DIFFERENT documents, with the live-byte floor read between
/// them and after an explicit eviction. If the floor after the second render
/// is the floor after the first, the cache holds nothing; if it climbs and an
/// `evict` brings it back down, the cache is the ceiling.
fn memo(bytes: usize) {
gunnar_stage::reset();
reset_live();
gunnar_stage::enable();
let base = live_bytes();
let show = |label: &str, v: i64| println!(" {label:<34} {:>8} MB", mb((v - base).max(0) as u64));
for round in 0..3 {
let md = format!("{}\n\nround {round}\n", document_with(bytes, false));
let out =
export_svg_column(&md, &meta(), &ImageSet::new(), 720.0, TextLayer::On).expect("svg");
drop(out);
drop(md);
show(&format!("live after render {}", round + 1), live_bytes());
if round == 1 {
// Twice: `comemo::evict(max_age)` ages every entry by one and keeps
// what is younger than `max_age`, so one call with 0 can still keep
// whatever the render just touched.
comemo::evict(0);
show("live after comemo::evict(0)", live_bytes());
comemo::evict(0);
show("live after a second evict(0)", live_bytes());
}
}
println!(" VmHWM {} MB, VmRSS {} MB", peak_rss_kb() / 1024, rss_kb() / 1024);
gunnar_stage::disable();
}
/// The allocator hook and the attributor are both live, proved by a stage that
/// allocates a known amount and must report it.
fn prove_the_instrument_works() {
gunnar_stage::reset();
gunnar_stage::enable();
{
let _s = gunnar_stage::enter("probe");
let v: Vec<u8> = vec![7u8; 64 << 20];
std::hint::black_box(&v);
}
let t = gunnar_stage::thread_report();
let probe = t.get("probe").copied().unwrap_or_default();
gunnar_stage::disable();
assert!(
probe.excl_alloc_bytes >= 64 << 20,
"the counting allocator is not linked: a 64 MiB allocation reported {} B",
probe.excl_alloc_bytes,
);
assert!(
probe.peak_growth_bytes >= 64 << 20,
"peak growth is not being tracked: {} B",
probe.peak_growth_bytes,
);
assert_eq!(probe.retained_bytes, 0, "the probe's vector was dropped");
}
fn ladder(bytes: usize) {
let title = "corpus";
let width_pt = 720.0;
let mut marks: Vec<(&'static str, u64, i64)> = Vec::new();
let mark = |name: &'static str, marks: &mut Vec<(&'static str, u64, i64)>| {
marks.push((name, peak_rss_kb() * 1024, live_bytes()));
};
gunnar_stage::reset();
reset_live();
gunnar_stage::enable();
let t0 = std::time::Instant::now();
let md = {
let _s = gunnar_stage::enter("corpus");
document_with(bytes, false)
};
mark("corpus", &mut marks);
let body = {
let _s = gunnar_stage::enter("md_to_typst");
md_to_typst(&md)
};
let body_len = body.len();
mark("md_to_typst", &mut marks);
let engine = {
let _s = gunnar_stage::enter("engine");
TypstEngine::builder()
.main_file(column_template())
.fonts(fonts())
.build()
};
mark("engine", &mut marks);
let doc: typst_layout::PagedDocument = {
let _s = gunnar_stage::enter("compile");
engine
.compile_with_input(inputs(title, body, width_pt))
.output
.expect("compile")
};
mark("compile", &mut marks);
let raw = {
let _s = gunnar_stage::enter("typst_svg");
typst_svg::svg(doc.pages().first().expect("one page"), &typst_svg::SvgOptions::default())
};
let raw_len = raw.len();
mark("typst_svg", &mut marks);
let opt = {
let _s = gunnar_stage::enter("optimize_svg");
optimize_svg(&raw)
};
let opt_len = opt.len();
mark("optimize_svg", &mut marks);
{
let _s = gunnar_stage::enter("drop raw");
drop(raw);
}
mark("drop raw", &mut marks);
{
let _s = gunnar_stage::enter("drop doc");
drop(doc);
drop(engine);
}
mark("drop doc", &mut marks);
{
let _s = gunnar_stage::enter("drop opt");
drop(opt);
}
mark("drop opt", &mut marks);
// The two grades of the SHIPPED function, in the same process and after the
// reconstruction has been dropped: the difference between them is the frame
// walk and the text layer, which are private and cannot be entered directly.
let off = {
let _s = gunnar_stage::enter("export_svg_column Off");
export_svg_column(&md, &meta(), &ImageSet::new(), width_pt, TextLayer::Off).expect("off")
};
let off_len = off.len();
drop(off);
mark("export_svg_column Off", &mut marks);
let on = {
let _s = gunnar_stage::enter("export_svg_column On");
export_svg_column(&md, &meta(), &ImageSet::new(), width_pt, TextLayer::On).expect("on")
};
let on_len = on.len();
drop(on);
mark("export_svg_column On", &mut marks);
let wall = t0.elapsed();
gunnar_stage::disable();
let report = gunnar_stage::thread_report();
println!(
"input {} KB -> typst markup {} KB, typst SVG {} KB, optimized {} KB, \
column Off {} KB, column On {} KB",
md.len() / 1024,
body_len / 1024,
raw_len / 1024,
opt_len / 1024,
off_len / 1024,
on_len / 1024,
);
println!("whole ladder {:.0} ms, process VmHWM {} MB\n", ms(wall), peak_rss_kb() / 1024);
println!(
"{:<24} {:>10} {:>10} {:>10} {:>10} {:>10} {:>9} whose",
"stage", "churn MB", "peak+ MB", "kept MB", "live MB", "VmHWM MB", "wall ms"
);
println!("{}", "-".repeat(108));
let mut peak_sum = 0u64;
for (name, hwm, live) in &marks {
let t = report.get(name).copied().unwrap_or_default();
peak_sum += t.peak_growth_bytes;
println!(
"{:<24} {:>10} {:>10} {:>10} {:>10} {:>10} {:>9.0} {}",
name,
mb(t.excl_alloc_bytes),
mb(t.peak_growth_bytes),
signed_mb(t.retained_bytes),
mb((*live).max(0) as u64),
mb(*hwm),
t.excl_wall_ns as f64 / 1e6,
whose(name),
);
}
println!("{}", "-".repeat(108));
println!(
"sum of per-stage peak growth (main thread) {} MB; process peak live {} MB; VmHWM {} MB",
mb(peak_sum),
mb(peak_live_bytes().max(0) as u64),
mb(peak_rss_kb() * 1024),
);
}
/// Render and write the page out, for a cross-build byte comparison.
fn dump(bytes: usize) {
let path = std::env::args().nth(3).expect("dump <bytes> <path>");
let md = document_with(bytes, false);
let out =
export_svg_column(&md, &meta(), &ImageSet::new(), 720.0, TextLayer::On).expect("svg");
std::fs::write(&path, out.as_bytes()).expect("write");
println!("wrote {} B to {path}", out.len());
}
/// **The book path**: `SvgzBook::write` over `n` copies of a real typst page.
///
/// It ran the optimizer TWICE per page until 2026-09-12 — once inside
/// `to_svgz` and once to fill in `optimized_bytes` — so it also held a second
/// full copy of every page it wrote. `n` is the first argument (default 20).
fn book(n: usize) {
let n = if n > 1000 { 20 } else { n.max(1) };
let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/typst-page-raw.svg");
let page = std::fs::read_to_string(&fixture).expect("the captured typst page");
// Distinct pages, so nothing can be answered from a cache.
let pages: Vec<String> = (0..n)
.map(|i| page.replace("841.8897637795276", &format!("841.88976377952{:02}", i % 100)))
.collect();
let dir = std::env::temp_dir().join(format!("nornir-docbook-mem-{}", std::process::id()));
gunnar_stage::reset();
reset_live();
gunnar_stage::enable();
let t = std::time::Instant::now();
let stats = nornir_docbook::SvgzBook { dir: &dir, digits: 3, keep: &[] }
.write(&pages)
.expect("write");
let wall = t.elapsed();
gunnar_stage::disable();
let _ = std::fs::remove_dir_all(&dir);
println!(
"SvgzBook::write, {} pages of {} KB\n \
raw {} MB -> optimized {} MB -> svgz {} MB peak live {} MB VmHWM {} MB {:.0} ms",
stats.pages,
page.len() / 1024,
mb(stats.raw_bytes as u64),
mb(stats.optimized_bytes as u64),
mb(stats.svgz_bytes as u64),
mb(peak_live_bytes().max(0) as u64),
peak_rss_kb() / 1024,
ms(wall),
);
}
/// A signed MB, so a stage that FREES reads as one.
fn signed_mb(bytes: i64) -> String {
if bytes < 0 {
format!("-{}", mb((-bytes) as u64))
} else {
mb(bytes as u64)
}
}
/// Which side of the boundary a stage is on. The whole point of the table.
fn whose(stage: &str) -> &'static str {
match stage {
"corpus" => "the bench's own",
"md_to_typst" => "OURS",
"engine" | "compile" | "typst_svg" => "typst",
"optimize_svg" => "OURS",
"export_svg_column Off" => "typst + ours",
"export_svg_column On" => "typst + ours (+ text layer)",
_ => "",
}
}