Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

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)",
        _ => "",
    }
}