facett-map/tests/zero_copy_gpu_lane.rs
//! **Zero copy on the GPU line lane** — the CPU painter must not materialise the
//! geometry the GPU already holds in VRAM.
//!
//! # Why a count of features is not enough
//!
//! `MapFacet::features_for_cpu` has always RETURNED the right list: it filtered the
//! GPU-owned [`DrawGeom::Line`]s out. What it did not do is skip building them —
//! [`WaysLayer::features`] cloned every visible way's `(lon, lat)` vector first and the
//! filter dropped it a moment later. A test that asserts "no Line features came back"
//! passes on both the old and the new code and can never see the defect. The only
//! observable that separates them is the **work**: heap bytes allocated while the frame's
//! feature list is being built.
//!
//! So this file counts allocations, through a test-local global allocator, and asserts
//! the applied output: for a layer the GPU lane has taken, building the CPU feature list
//! costs **no per-vertex allocation at all**.
//!
//! MEASURED (20 000 real Sweden ways, slippy zoom 6, before the fix): 2 654 vectors /
//! 130 236 vertices / 2.08 MB allocated *and freed* every frame for roads the CPU painter
//! was forbidden to draw — 42 % of the whole per-frame feature-build allocation.
//!
//! RED PROVEN, on the exact pre-fix shape: make `WaysLayer::features_into` build the
//! whole list and filter afterwards (so the RETURNED list is unchanged and every other
//! assertion in this file still passes) and
//! `the_cpu_lane_allocates_nothing_for_ways_the_gpu_took` fails with
//! **711 680 B allocated** where the fixed path allocates 0.
use std::alloc::{GlobalAlloc, Layout, System};
use facett_map::layer::{DrawGeom, LatLon, MapLayer, MapView, WaysLayer};
use facett_map::style::MapPalette;
use facett_map::Way;
// ── the measuring allocator ──────────────────────────────────────────────────
//
// **The counters are THREAD-LOCAL, and that is the whole correctness argument.**
//
// The allocator is process-wide but `#[test]` bodies run on parallel threads, so a
// global `BYTES`/`ARMED` pair measures "whatever the whole process allocated during
// my window" — which is not what any assertion here means. The [`SERIAL`] mutex
// below was the first attempt at closing that, and it leaks: a measuring test takes
// the lock inside [`heap_bytes`] but builds its FIXTURE outside it, so one test's
// 24 000-feature fixture build lands squarely in another's armed window.
//
// MEASURED 2026-08-03 at the mandated `--test-threads=2`:
// `counting_the_features_allocates_nothing` read **777 378 B** for a body that
// allocates nothing, and the number moved run to run. A cost measurement another
// thread can inflate is not a measurement — and this one had been reporting a
// number nobody could reproduce.
//
// Scoping the counters to the measuring thread fixes it BY CONSTRUCTION rather than
// by lock discipline nobody can see being violated: an allocation on any other
// thread simply is not this thread's count, whatever it is doing and whenever it
// does it. `Cell` with a `const` initialiser, so the TLS access inside `alloc`
// cannot itself allocate and recurse, and `try_with` so an allocation during TLS
// teardown is ignored rather than a panic inside the allocator.
thread_local! {
static BYTES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
static ARMED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// Add `n` to this thread's tally, but only while this thread is armed.
#[inline]
fn tally(n: usize) {
let armed = ARMED.try_with(std::cell::Cell::get).unwrap_or(false);
if armed {
let _ = BYTES.try_with(|b| b.set(b.get() + n));
}
}
struct Counting;
unsafe impl GlobalAlloc for Counting {
unsafe fn alloc(&self, l: Layout) -> *mut u8 {
tally(l.size());
unsafe { System.alloc(l) }
}
unsafe fn dealloc(&self, p: *mut u8, l: Layout) {
unsafe { System.dealloc(p, l) }
}
unsafe fn realloc(&self, p: *mut u8, l: Layout, n: usize) -> *mut u8 {
tally(n.saturating_sub(l.size()));
unsafe { System.realloc(p, l, n) }
}
}
#[global_allocator]
static A: Counting = Counting;
/// Retained so the non-measuring tests keep their old shape, but it is **no longer
/// what makes the measurement correct** — the thread-local counters above are. It
/// costs nothing and it keeps the fixture builds from thrashing each other's caches.
/// Poison is ignored on purpose: a panicking test has already failed and must not
/// turn the others red.
static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn exclusive() -> std::sync::MutexGuard<'static, ()> {
SERIAL.lock().unwrap_or_else(|e| e.into_inner())
}
/// Bytes **this thread's** heap grew by while `f` ran. Build the fixture BEFORE
/// calling this; only `f` is measured. Another test thread allocating a megabyte
/// mid-window cannot touch this number (see the allocator note above).
fn heap_bytes<T>(f: impl FnOnce() -> T) -> (T, usize) {
BYTES.with(|b| b.set(0));
ARMED.with(|a| a.set(true));
let r = f();
ARMED.with(|a| a.set(false));
(r, BYTES.with(std::cell::Cell::get))
}
// ── the fixture: a road network shaped like the real served payload ─────────
/// `n` unfilled roads of `v` vertices each, around (15 E, 59 N) — the density and the
/// 2.6-vertices-per-way shape the served korp basemap actually carries.
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-4, lat0 + k as f64 * 1e-4)).collect();
Way::from_lonlat_with(i as i64, &pts, &tags, &pal)
})
.collect()
}
/// The same shape but FILLED (buildings) — the tier the GPU line lane can never take,
/// so it must keep being built.
/// `n` filled building rings of `v` vertices.
///
/// **They are 4·10⁻³° across, not 4·10⁻⁴°**, and the size is load-bearing. The old
/// footprint was ~22 m, which at this file's `cam()` (zoom 12, world_px ≈ 1.05·10⁶) is
/// **1.2 screen px** — below `style::MIN_FILL_EXTENT_PX`, so `WaysLayer::draws` now
/// drops every one of them (stinky `kalm-3`, the gate that took 43–47 % of korp's z12
/// budget off the fill lane). These tests are about the zero-copy hand-over, the
/// count/paint agreement and the fill survival; a fixture of sub-pixel footprints would
/// silently turn all three into tests of the extent gate. At 4·10⁻³° they are ~220 m and
/// ~11.7 px at the same camera — a building a human would see.
fn buildings(n: usize, v: usize) -> Vec<Way> {
let pal = MapPalette::cartographic(true);
let tags = vec![("building".to_string(), "yes".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 mut pts: Vec<(f64, f64)> =
(0..v).map(|k| (lon0 + k as f64 * 1e-3, lat0 + k as f64 * 1e-3)).collect();
pts.push(pts[0]);
Way::from_lonlat_with(1_000_000 + i as i64, &pts, &tags, &pal)
})
.collect()
}
/// Centred ON the fixture (the roads span 15.000–15.099 E, 59.000–59.029 N) at a
/// city-tier zoom, so every way is inside the viewport and inside the LOD gate. A
/// camera that culled the payload would make every assertion below vacuous — the
/// `features().len()` assertions are there to keep that honest.
fn cam() -> MapView {
MapView::new(LatLon::new(59.015, 15.05), 12.0, (1600.0, 1000.0))
}
/// **RED-WHEN-BROKEN.** A `WaysLayer` the GPU line lane has taken must cost the CPU
/// painter *nothing per vertex*: `features_into(view, lines_on_gpu = true, …)` returns
/// an empty list AND allocates no geometry.
///
/// The bound is deliberately absolute rather than a ratio: the correct number of bytes
/// is **zero plus whatever the empty output `Vec` reserves**, and `out` is passed in
/// already allocated, so a correct run touches the heap not at all. MEASURED: the fixed
/// path allocates **0 B** for this fixture; the filter-after-clone path allocates
/// **711 680 B** (384 000 B of raw coordinates in 3 000 `Vec<(f64,f64)>`, the rest the
/// `DrawFeature` list that carries them). 64 kB is picked to survive allocator
/// bookkeeping without coming within an order of magnitude of the defect.
#[test]
fn the_cpu_lane_allocates_nothing_for_ways_the_gpu_took() {
let ways = roads(3_000, 8);
let view = cam();
let layer = WaysLayer::new("osm-roads", ways);
// The payload really is in view — otherwise the cull would make this test hollow.
let all = layer.features(&view);
assert_eq!(all.len(), 3_000, "every fixture road is inside the viewport at this camera");
let verts: usize = all
.iter()
.filter_map(|f| match &f.geom {
DrawGeom::Line(p) => Some(p.len()),
_ => None,
})
.sum();
assert_eq!(verts, 24_000, "and they carry the geometry whose copy is the defect");
let mut out = Vec::with_capacity(4_096);
let ((), bytes) = heap_bytes(|| layer.features_into(&view, facett_map::layer::GpuOwns { lines: true, fills: false }, &mut out));
assert!(out.is_empty(), "the GPU owns every line of this layer, so the CPU list is empty");
assert!(
bytes < 64 * 1024,
"building the CPU list for a GPU-owned layer must not materialise its geometry \
(allocated {bytes} B; the filter-after-clone path allocated 711 680 B on this \
same fixture, {} B of it the raw coordinate copy)",
verts * std::mem::size_of::<(f64, f64)>()
);
}
/// The saving must not be bought by dropping something. A mixed layer (roads + buildings)
/// on the GPU lane keeps **every** fill, with its rings intact.
#[test]
fn the_fills_of_a_gpu_taken_layer_still_reach_the_cpu_painter() {
let _serial = exclusive();
let mut ways = roads(500, 6);
ways.extend(buildings(300, 5));
let view = cam();
let layer = WaysLayer::new("osm-mixed", ways);
let mut out = Vec::new();
layer.features_into(&view, facett_map::layer::GpuOwns { lines: true, fills: false }, &mut out);
assert_eq!(out.len(), 300, "all 300 building fills survive; all 500 roads went to the GPU");
assert!(
out.iter().all(|f| matches!(f.geom, DrawGeom::Polygon { .. }) && f.fill.is_some()),
"and what survives is exactly the filled tier"
);
}
/// **Equivalence.** `features_into(view, false, …)` is `features(view)` — same ids, same
/// geoms, same order. This is what makes the new seam a refactor rather than a second
/// renderer input (LAW #5): one body, and the pure-CPU frame is the `false` case of it.
#[test]
fn asking_for_everything_is_byte_for_byte_the_old_features_call() {
let _serial = exclusive();
let mut ways = roads(400, 7);
ways.extend(buildings(250, 5));
let view = cam();
let layer = WaysLayer::new("osm-mixed", ways);
let old = layer.features(&view);
let mut new = Vec::new();
layer.features_into(&view, facett_map::layer::GpuOwns::NONE, &mut new);
assert_eq!(old.len(), new.len());
assert!(!old.is_empty(), "a fixture that draws nothing would make this vacuous");
for (a, b) in old.iter().zip(new.iter()) {
assert_eq!(a.id, b.id);
assert_eq!(a.order, b.order);
assert_eq!(a.color, b.color);
assert_eq!(a.fill, b.fill);
match (&a.geom, &b.geom) {
(DrawGeom::Line(p), DrawGeom::Line(q)) => assert_eq!(p, q),
(DrawGeom::Polygon { outer: o1, holes: h1 }, DrawGeom::Polygon { outer: o2, holes: h2 }) => {
assert_eq!(o1, o2);
assert_eq!(h1, h2);
}
_ => panic!("geometry kind changed between the two entry points"),
}
}
}
/// The **default** trait body must keep behaving like the old post-hoc filter, so a
/// layer that does not override `features_into` (points, labels, landmass) is untouched.
#[test]
fn the_default_body_still_filters_lines_for_a_layer_that_does_not_override() {
let _serial = exclusive();
/// A minimal `MapLayer` that emits one line + one polygon and does NOT override
/// `features_into` — it must inherit the filter.
struct Plain;
impl MapLayer for Plain {
fn id(&self) -> &str {
"plain"
}
fn kind(&self) -> facett_map::layer::LayerKind {
facett_map::layer::LayerKind::Line
}
fn features(&self, _v: &MapView) -> Vec<facett_map::layer::DrawFeature> {
vec![
facett_map::layer::DrawFeature {
id: 1,
geom: DrawGeom::Line(std::sync::Arc::new(vec![(15.0, 59.0), (15.1, 59.1)])),
color: egui::Color32::WHITE,
width: 1.0,
fill: None,
order: facett_map::PaintKey { rank: facett_map::paint_order::LINE_BAND, area_desc: u32::MAX, id: 1 },
tri: None,
lod: facett_map::style::Lod::Country,
dash: false,
},
facett_map::layer::DrawFeature {
id: 2,
geom: DrawGeom::Polygon {
outer: std::sync::Arc::new(vec![(15.0, 59.0), (15.1, 59.0), (15.1, 59.1)]),
holes: std::sync::Arc::new(Vec::new()),
},
color: egui::Color32::WHITE,
width: 1.0,
fill: Some(egui::Color32::BLUE),
order: facett_map::PaintKey { rank: facett_map::paint_order::AREA_BAND, area_desc: u32::MAX, id: 2 },
// This layer keeps no `FillTessCache`, so the painter ear-clips
// it per frame exactly as before — `None` is the unchanged path.
tri: None,
lod: facett_map::style::Lod::Country,
dash: false,
},
]
}
fn style(&self) -> &facett_map::layer::LayerStyle {
static S: std::sync::OnceLock<facett_map::layer::LayerStyle> = std::sync::OnceLock::new();
S.get_or_init(|| {
facett_map::layer::LayerStyle::new(facett_map::layer::LayerKind::Line, egui::Color32::WHITE, 1.0)
})
}
fn enabled(&self) -> bool {
true
}
fn set_enabled(&mut self, _on: bool) {}
}
let view = cam();
let mut all = Vec::new();
Plain.features_into(&view, facett_map::layer::GpuOwns::NONE, &mut all);
assert_eq!(all.len(), 2, "nothing is dropped when the GPU took nothing");
let mut cpu = Vec::new();
Plain.features_into(&view, facett_map::layer::GpuOwns { lines: true, fills: false }, &mut cpu);
assert_eq!(cpu.len(), 1, "the default body still drops the line");
assert!(matches!(cpu[0].geom, DrawGeom::Polygon { .. }));
}
// ── the OTHER half: the callers that never wanted the geometry ───────────────
//
// `features_into` fixed the frame's PAINT path. Two more callers were building the very
// same list, cloning the very same coordinates, and then reading a single integer off
// it — `MapFacet::state_json`, which publishes a feature COUNT, and
// `MapFacet::severity`, which asks only whether that count is zero.
//
// Neither is a rare path. korp calls `severity()` on EVERY frame
// (`Korp::react_to_facet_panes`, whose own doc says the severity gate exists "so a
// healthy frame never pays for a `state_json` build" — while the gate WAS that build)
// and `state_json()` every 300 ms (`Korp::dump_if_due`). So a korp frame paid the full
// per-frame geometry clone one to three times over, and only one of those copies was
// ever drawn.
//
// Same instrument as above, for the same reason: no assertion about the RETURNED number
// can see this. `feature_count()` and `enabled_features().len()` are equal by
// construction — that equality is asserted below — and only the heap can tell them apart.
/// **RED-WHEN-BROKEN: publishing the feature COUNT must not materialise the features.**
///
/// RED PROVEN on oden 2026-08-03, on the exact pre-fix shape (`MapFacet::feature_count`
/// implemented as `self.enabled_features().len()`, so the published NUMBER is unchanged
/// and every other assertion in this file still passes): **7 642 880 B allocated** for
/// this fixture, where the counting path allocates **0 B**.
#[test]
fn counting_the_features_allocates_nothing() {
use facett_map::layer::{MapFacet, MapTheme, PointsLayer};
let mut ways = roads(3_000, 8);
ways.extend(buildings(1_000, 5));
let pts: Vec<(f64, f64)> =
(0..20_000).map(|i| (15.0 + (i % 100) as f64 * 1e-3, 59.0 + (i / 100) as f64 * 1e-4)).collect();
let facet = MapFacet::new("count", cam(), MapTheme::default())
.with_layer(Box::new(WaysLayer::new("osm-roads", ways)))
.with_layer(Box::new(PointsLayer::new("osm-nodes", pts, egui::Color32::GRAY)));
// The count is the SAME number the built list would report — this is what makes the
// cheap path a refactor and not a second answer.
let built = facet.enabled_features().len();
let (counted, bytes) = heap_bytes(|| facet.feature_count());
assert_eq!(counted, built, "the counted number must equal the built one, exactly");
assert!(built > 20_000, "a fixture that draws almost nothing would make this vacuous");
assert!(
bytes < 64 * 1024,
"counting {built} features must not build them (allocated {bytes} B; the \
`enabled_features().len()` shape allocated 7 642 880 B on this same fixture)"
);
}
/// **RED-WHEN-BROKEN: `severity()` runs on every korp frame and must not build a frame's
/// worth of geometry to answer a yes/no.**
///
/// Both directions are asserted — a map that draws something is `Info`, an empty one is
/// `Error` — because a `severity` that always returned `Info` would allocate nothing and
/// pass a one-sided cost test. RED PROVEN on oden 2026-08-03, on the
/// `enabled_features().is_empty()` shape: **1 127 680 B** for 4 000 ways in view, every
/// frame; the counting path allocates **0 B**.
#[test]
fn severity_allocates_nothing_and_still_reports_both_ways() {
use facett_map::layer::{MapFacet, MapTheme};
use facett_core::Severity;
let mut ways = roads(3_000, 8);
ways.extend(buildings(1_000, 5));
let mut facet = MapFacet::new("sev", cam(), MapTheme::default())
.with_layer(Box::new(WaysLayer::new("osm-roads", ways)));
let (sev, bytes) = heap_bytes(|| facet.severity());
assert_eq!(sev, Severity::Info, "a map with 4 000 ways in view draws something");
assert!(
bytes < 64 * 1024,
"the per-FRAME severity check must not build the frame's feature list (allocated \
{bytes} B; the `enabled_features().is_empty()` shape allocated 1 127 680 B)"
);
// The other direction: switch the only layer off and the verdict must flip. Without
// this the test would pass on a `severity` hard-wired to `Info`.
facet.set_layer_enabled("osm-roads", false);
assert_eq!(
facet.severity(),
Severity::Error,
"a stack with nothing enabled draws nothing — the facet-map-2 nothing-to-plot state"
);
assert_eq!(facet.feature_count(), 0, "and the published count agrees with the verdict");
}
/// **The count and the paint must cull ALIKE.** `feature_count` walks its own predicate
/// now, so the one way it could go wrong is by disagreeing with what the painter draws —
/// and a count that quietly ignored the LOD gate or the viewport would be a plausible
/// number about a different map. Asserted across cameras that exercise BOTH gates.
#[test]
fn the_count_agrees_with_the_built_list_at_every_camera() {
let _serial = exclusive();
use facett_map::layer::{MapFacet, MapTheme, PointsLayer};
let mut ways = roads(800, 6);
ways.extend(buildings(400, 5));
let pts: Vec<(f64, f64)> =
(0..5_000).map(|i| (15.0 + (i % 100) as f64 * 1e-3, 59.0 + (i / 100) as f64 * 1e-4)).collect();
let mut seen_zero = false;
let mut seen_partial = false;
let mut seen_all = false;
for (zoom, centre) in [
(12.0, LatLon::new(59.015, 15.05)), // everything in view, city tier
(12.0, LatLon::new(59.015, 15.02)), // half the fixture off the left edge
(3.0, LatLon::new(59.015, 15.05)), // country tier ⇒ the LOD gate bites
(12.0, LatLon::new(0.0, 0.0)), // nowhere near it ⇒ the viewport cull bites
] {
let view = MapView::new(centre, zoom, (1600.0, 1000.0));
let facet = MapFacet::new("agree", view, MapTheme::default())
.with_layer(Box::new(WaysLayer::new("osm-roads", ways.clone())))
.with_layer(Box::new(PointsLayer::new("osm-nodes", pts.clone(), egui::Color32::GRAY)));
let built = facet.enabled_features().len();
assert_eq!(
facet.feature_count(),
built,
"count vs built disagreed at zoom {zoom} centred {:?}",
centre
);
if built == 0 {
seen_zero = true;
} else if built < ways.len() + pts.len() {
seen_partial = true;
} else {
seen_all = true;
}
}
// The cameras must really have exercised all three regimes, or "they agree" is a
// statement about one case repeated four times.
assert!(seen_all, "one camera must draw the whole fixture");
assert!(seen_partial, "one camera must draw only part of it (a real cull)");
assert!(seen_zero, "one camera must draw nothing at all");
}