facett-map/tests/bake_alloc_budget.rs
#![cfg(feature = "wgpu")]
//! **The GPU bake's heap budget** (stinky `mapdraw-5`) — one allocation per way, not
//! a throwaway `Vec` per way.
//!
//! `build_gpu_geometry` expanded every way's quad geometry with
//!
//! ```ignore
//! line_vertices.extend_from_slice(&build_line_geometry(std::slice::from_ref(poly)));
//! ```
//!
//! — a fresh `Vec<LineVertex>` per way, grown by repeated `extend_from_slice` and then
//! memcpy'd into the shared buffer and dropped, plus a fresh `Vec<Join>` per way inside
//! the expander. MEASURED on the real Sweden payload (20 000 ways / 452 697 vertices):
//! **88 807 allocations and 255.2 MB of transient heap to produce 178 MB of buffers**,
//! 70.55 ms wall.
//!
//! # Why a byte budget alone could not see it
//!
//! The throwaway `Vec` allocates roughly what the shared buffer's own doubling
//! allocates — so BYTES barely separate the two shapes. What separates them is the
//! allocation **COUNT**: the temporary costs `O(log(way vertices))` events *per way*,
//! the shared buffer costs `O(log(total))` events *in total*. This file therefore
//! asserts the count, and asserts it as a RATIO to the way count so the number cannot
//! be re-tuned into a tautology when the fixture changes size.
//!
//! # Why it is not a wall-clock test
//!
//! A ms ceiling in the suite fails on machine load, not on code (stinky `mapgpu-2`
//! says so about `facett-core`'s force-layout test, and oden runs eight agents). An
//! allocation count is contended-machine-proof: it is the same number on an idle box
//! and on a box at load 120.
//!
//! # RED PROVEN
//!
//! With the per-way temporary put back (`extend_from_slice(&build_line_geometry(..))`
//! plus the per-way `joins` `Vec`), `the_bake_does_not_allocate_per_way_temporaries`
//! fails — the figures are in the assertion message, which prints what it measured.
//!
//! The refactor's OUTPUT equality is not re-asserted here: `cull_indirect.rs`'s
//! `per_way_quad_expansion_concatenates_to_the_whole_tier_expansion` already pins the
//! per-way expansion byte-for-byte against the whole-slice call, and a second copy of
//! that assertion is exactly the twin LAW 5 rejects.
use facett_map::Way;
use facett_map::gpu::{build_gpu_geometry, ways_origin};
use facett_map::style::MapPalette;
#[path = "common/heap.rs"]
mod heap;
#[global_allocator]
static A: heap::Counting = heap::Counting;
/// `n` unfilled roads of `v` vertices each — the shape and density of the served korp
/// basemap (the real Sweden payload is 452 697 vertices over 12 625 GPU-lane ways,
/// i.e. ~36 vertices a way, which is what `v` defaults to at the call sites below).
fn roads(n: usize, v: usize) -> Vec<Way> {
let pal = MapPalette::cartographic(true);
let tags = vec![("highway".to_string(), "primary".to_string())];
(0..n)
.filter_map(|i| {
let (lon0, lat0) = (15.0 + (i % 100) as f64 * 1e-3, 59.0 + (i / 100) as f64 * 1e-3);
let pts: Vec<(f64, f64)> = (0..v).map(|k| (lon0 + k as f64 * 2e-5, lat0 + k as f64 * 1e-5)).collect();
Way::from_lonlat_with(i as i64, &pts, &tags, &pal)
})
.collect()
}
/// Bake `ways` and report `(allocations, bytes, produced LineVertex count)`.
fn bake_cost(ways: &[Way]) -> (usize, usize, usize) {
let origin = ways_origin(ways.iter()).unwrap_or([0.0, 0.0]);
let (geo, allocs, bytes) = heap::measure(|| build_gpu_geometry(ways.iter().map(|w| (w, 0u32)), origin));
(allocs, bytes, geo.line_vertices.len())
}
/// **THE GUARD.** The bake's allocation count must not scale with the way count
/// beyond the one per-way vector the geometry genuinely needs (the origin-local
/// Mercator centreline, which is MOVED into the polyline and is not a temporary).
///
/// Stated as a ratio, and pinned at BOTH ends:
///
/// * an upper bound of **2.0 allocations a way**, which the per-way-temporary shape
/// cannot meet (it pays one for the throwaway result `Vec`, one for `joins`, and
/// one per doubling of the throwaway as it grows to the way's quad count);
/// * a lower bound asserting the fixture is not trivially small, because "0.4
/// allocations a way" over 10 ways proves nothing.
#[test]
fn the_bake_does_not_allocate_per_way_temporaries() {
const WAYS: usize = 12_000;
const VERTS: usize = 36;
let ways = roads(WAYS, VERTS);
assert_eq!(ways.len(), WAYS, "fixture must build every way, else the ratio is measured over the wrong n");
let (allocs, bytes, quad_verts) = bake_cost(&ways);
// The premise: this really is a big bake, so a per-way cost has room to show.
assert!(
quad_verts >= WAYS * (VERTS - 1) * 6,
"fixture premise: {WAYS} ways x {VERTS} verts must expand to >= {} quad vertices, got {quad_verts}",
WAYS * (VERTS - 1) * 6
);
let per_way = allocs as f64 / WAYS as f64;
eprintln!(
"[bake] {WAYS} ways x {VERTS} verts -> {quad_verts} quad vertices: {allocs} allocations \
({per_way:.2}/way), {bytes} B"
);
assert!(
per_way <= 2.0,
"the bake allocated {allocs} times for {WAYS} ways = {per_way:.2}/way ({bytes} B). \
Budget is 2.0/way: ONE origin-local Mercator vector per way (moved into the \
polyline, not a temporary) plus the shared buffers' O(log n) growth. A count \
above that means a per-way temporary is back — see the module note."
);
}
/// **The shape assertion, and the reason the constant above cannot simply be
/// re-tuned.** Per-way allocation count must not depend on how LONG the way is.
///
/// A ceiling on its own is blind twice over: it can be raised, and "2x the ways cost
/// 2x the allocations" is equally true of both shapes, so a growth ratio over the way
/// COUNT proves nothing. What separates them is the way's VERTEX count. A throwaway
/// result `Vec` grown by `extend_from_slice` costs one allocation per doubling, and
/// the number of doublings is `log2(quad vertices)` — so an 8x longer way costs the
/// old shape ~3 more allocations EACH, while the fixed shape still pays exactly one
/// (the origin-local Mercator vector, which is moved into the polyline).
///
/// Same total vertex budget on both sides, so the shared buffers' own growth is held
/// constant and only the per-way term can move the number.
#[test]
fn per_way_allocation_does_not_grow_with_the_way_length() {
// 8 000 x 36 and 1 000 x 288 — the same 288 000 source vertices either way.
let short = roads(8_000, 36);
let long = roads(1_000, 288);
let (a_short, _, v_short) = bake_cost(&short);
let (a_long, _, v_long) = bake_cost(&long);
// Within 5 % — `n` vertices give `n-1` segments, so the two fixtures differ by the
// per-way endpoint and nothing else. The point of holding the total is that the
// SHARED buffers' own doubling count is then the same on both sides, leaving the
// per-way term as the only thing that can move the number.
let skew = (v_short as f64 - v_long as f64).abs() / v_short as f64;
assert!(skew < 0.05, "premise: both fixtures must expand to ~the same quad-vertex total ({v_short} vs {v_long})");
let per_short = a_short as f64 / 8_000.0;
let per_long = a_long as f64 / 1_000.0;
eprintln!("[bake] per-way allocations: 36-vertex ways {per_short:.2}, 288-vertex ways {per_long:.2}");
assert!(
per_long <= per_short + 0.5,
"8x longer ways cost {per_long:.2} allocations each against {per_short:.2} for the short \
ones ({a_long} over 1 000 vs {a_short} over 8 000). A per-way cost that GROWS with the \
way's length is a temporary being doubled into existence per way — the fixed shape pays \
exactly one allocation a way whatever the length."
);
}