facett-map/tests/vram_ratio.rs
//! **GG audit row 21, first half: VRAM is at most 1.2× the geometry it holds.**
//!
//! # The defect
//!
//! Every chunk the renderer uploads carried a `compact_buf` allocated at the FULL size
//! of its geometry — the cull copied surviving VERTICES into it, so it had to be able
//! to hold every vertex in the chunk. That is one whole second copy of the geometry
//! sitting beside the first for the lifetime of the chunk, whether one way or all of
//! them survive the cull: **VRAM ≈ 2× geometry**, on the monolith path and on every
//! resident tile alike. The tile cache's own census (`TileGpu::bytes`) even said so —
//! `(line_bytes + outline_bytes) * 2`.
//!
//! # The oracle
//!
//! A counter of the bytes this renderer asks the device for, bumped at EVERY
//! `create_buffer` it performs ([`facett_map::gpu::gpu_buffer_bytes_allocated`]). The
//! delta across one upload is what the upload cost the card; the payload the upload was
//! handed (every vertex + every per-way meta, computed HERE from the `GpuGeometry` the
//! test built, not read back from the renderer) is the denominator. The bound is the
//! row's: **≤ 1.2×**.
//!
//! The fix that makes this green compacts INDICES (4 B per vertex) instead of vertices
//! (24 B / 48 B) and draws `draw_indexed_indirect` straight off the geometry buffer, so
//! the bound is provable rather than fitted:
//!
//! ```text
//! allocated = geom + meta + index + ctl
//! = S·V + 32·n + 4·V + 4·(5 + ⌈n/64⌉ + n) S = 24 or 48, V vertices, n ways
//! overhead / payload = (4V + 4n + 4⌈n/64⌉ + 20) / (S·V + 32n)
//! ≤ 4V/(24V) + (4n + 4⌈n/64⌉ + 20)/(32n) (each term over its own part)
//! ≤ 1/6 + 1/8 + (4⌈n/64⌉ + 20)/(32n) → 1.29 worst case for a chunk of ONE way,
//! ```
//!
//! and the row's fixture is not one way: at S = 24 the vertex term is 1/6 and the way
//! terms together fall below 1/30 once a chunk has ≥ 16 ways, so a chunk of ≥ 16 ways is
//! ≤ 1.2× by arithmetic, not by measurement. Both fixtures below are thousands of ways.
//!
//! Run (needs a real device):
//! ```text
//! cargo test -p facett-map --features wgpu --test vram_ratio -- --test-threads=1 --nocapture
//! ```
#![cfg(feature = "wgpu")]
use facett_map::Way;
use facett_map::gpu::{
GpuGeometry, GpuMapRenderer, GpuWayMeta, LineVertex, Vertex, build_gpu_fill_geometry, build_gpu_geometry,
gpu_buffer_bytes_allocated, ways_origin,
};
mod common;
/// The row's ceiling.
const RATIO_CEILING: f64 = 1.2;
/// `n` three-point roads in a row — the `cull_order.rs` shape, sized to the audit's
/// 20 000-way fixture.
fn roads(n: i64, lon0: f64) -> Vec<Way> {
let c = egui::Color32::from_rgb(0xE8, 0xB0, 0x30);
(0..n)
.map(|i| {
let lon = lon0 + (i as f64) * 0.004;
Way::line(i + 1, "road", vec![(lon, 47.0), (lon + 0.002, 47.01), (lon + 0.004, 47.0)], c, 3.0)
})
.collect()
}
/// `n` square lakes in a row — the fill lane's 24 B triangle stream and its 48 B outline
/// stream, so the ratio is measured over EVERY stream the renderer uploads.
fn lakes(n: i64, lon0: f64) -> Vec<Way> {
(0..n)
.map(|i| {
let lon = lon0 + (i as f64) * 0.02;
let (lat, w) = (47.0, 0.008);
let r = vec![(lon, lat), (lon + w, lat), (lon + w, lat + w), (lon, lat + w), (lon, lat)];
Way::filled(
100_000 + i,
"water",
r,
egui::Color32::from_rgb(0x6E, 0x9C, 0xC4),
egui::Color32::from_rgb(0x30, 0x40, 0x55),
1.0,
)
})
.collect()
}
/// The bake the live `MapFacet` performs — lines, then fills off the ear-clip cache
/// (`fill_gpu_lane.rs::baked`, same shape).
fn baked(ways: &[Way]) -> GpuGeometry {
let origin = ways_origin(ways.iter()).expect("non-empty fixture has an origin");
let mut geo = build_gpu_geometry(ways.iter().map(|w| (w, w.style.lod as u32)), origin);
let cache = facett_map::tessellate::FillTessCache::new(ways.len());
geo.fills = build_gpu_fill_geometry(
ways.iter().enumerate().filter_map(|(i, w)| cache.tris(i, &w.lonlat, &w.holes).map(|t| (w, w.style.lod as u32, t))),
origin,
);
geo
}
/// **The denominator** — every byte of geometry and per-way meta the payload carries,
/// summed from the `GpuGeometry` itself. Not asked of the renderer: the renderer's own
/// upload accounting is what this test is checking.
fn payload_bytes(g: &GpuGeometry) -> u64 {
let v24 = std::mem::size_of::<Vertex>() as u64;
let v48 = std::mem::size_of::<LineVertex>() as u64;
let m = std::mem::size_of::<GpuWayMeta>() as u64;
(g.vertices.len() as u64) * v24
+ (g.way_meta.len() as u64) * m
+ (g.line_vertices.len() as u64) * v48
+ (g.line_meta.len() as u64) * m
+ (g.fills.tri_vertices.len() as u64) * v24
+ (g.fills.tri_meta.len() as u64) * m
+ (g.fills.outline_vertices.len() as u64) * v48
+ (g.fills.outline_meta.len() as u64) * m
}
fn ratio(allocated: u64, payload: u64) -> f64 {
allocated as f64 / payload as f64
}
/// **The whole-payload path.** 20 000 roads plus 2 000 lakes through `upload`, the
/// bytes the device was asked for against the bytes the upload was handed.
#[test]
fn a_whole_payload_upload_allocates_at_most_1_2x_the_geometry_it_was_handed() {
let Some((device, queue)) = common::device_or_skip(
"a_whole_payload_upload_allocates_at_most_1_2x_the_geometry_it_was_handed",
"VRAM allocated by the monolith upload is within 1.2x of the geometry bytes",
) else {
return;
};
let mut ways = roads(20_000, 9.0);
ways.extend(lakes(2_000, 9.0));
let geo = baked(&ways);
let payload = payload_bytes(&geo);
assert!(!geo.line_meta.is_empty() && !geo.fills.tri_meta.is_empty() && !geo.fills.outline_meta.is_empty(),
"the fixture must bake all three live streams (roads, fill triangles, fill outlines)");
assert!(payload > 50 * 1024 * 1024, "a fixture under 50 MB would make the ratio a rounding exercise: {payload} B");
let mut r = GpuMapRenderer::new(&device, wgpu::TextureFormat::Rgba8Unorm);
let before = gpu_buffer_bytes_allocated();
let t0 = std::time::Instant::now();
r.upload_for_test(&device, &queue, &geo);
let upload_ms = t0.elapsed().as_secs_f64() * 1e3;
let allocated = gpu_buffer_bytes_allocated() - before;
let x = ratio(allocated, payload);
eprintln!(
"LOOK: [gg21 vram monolith] payload {payload} B, allocated {allocated} B, ratio {x:.3}x \
({} ways, {} quad verts, {} fill tri verts, {} outline verts, upload {upload_ms:.1} ms)",
ways.len(),
geo.line_vertices.len(),
geo.fills.tri_vertices.len(),
geo.fills.outline_vertices.len(),
);
// NON-VACUOUS: the counter must see at least the geometry itself. A counter that
// missed a stream's `create_buffer` would report a flattering ratio for free.
assert!(allocated >= payload, "the allocator saw {allocated} B for a {payload} B payload — a stream is not being counted");
assert!(
x <= RATIO_CEILING,
"the upload allocated {x:.3}x its geometry ({allocated} B for {payload} B); the row's ceiling is \
{RATIO_CEILING}x. A compact buffer sized at the full geometry doubles the card's bill for every chunk."
);
nornir_testmatrix::functional_status(
"facett-map",
"gg21_vram_ratio_monolith",
x <= RATIO_CEILING,
&format!("{allocated} B allocated for {payload} B geometry = {x:.3}x"),
);
}
/// **The tile lane**, which is what korp ships: eight resident tiles, each uploaded
/// through `upload_tile`. Two claims — the ratio holds per tile set, and the cache's own
/// census (`tile_cache_bytes`, the number the eviction budget is enforced against) is
/// EXACTLY what the device was asked for. A census that undercounts lets the LRU keep
/// more than the budget; one that overcounts evicts tiles that fit.
#[test]
fn the_tile_lane_allocates_at_most_1_2x_per_tile_set_and_its_census_is_exact() {
let Some((device, queue)) = common::device_or_skip(
"the_tile_lane_allocates_at_most_1_2x_per_tile_set_and_its_census_is_exact",
"VRAM allocated per resident tile is within 1.2x of its geometry and equals tile_cache_bytes",
) else {
return;
};
const TILES: i64 = 8;
let mut payload = 0u64;
let mut r = GpuMapRenderer::new(&device, wgpu::TextureFormat::Rgba8Unorm);
let before = gpu_buffer_bytes_allocated();
for t in 0..TILES {
let mut ways = roads(2_500, 9.0 + t as f64 * 12.0);
ways.extend(lakes(250, 9.0 + t as f64 * 12.0));
let geo = baked(&ways);
payload += payload_bytes(&geo);
r.upload_tile_for_test(&device, &queue, 1000 + t as u128, &geo);
}
let allocated = gpu_buffer_bytes_allocated() - before;
let census = r.tile_cache_bytes();
let x = ratio(allocated, payload);
eprintln!(
"LOOK: [gg21 vram tiles] {TILES} tiles: payload {payload} B, allocated {allocated} B, ratio {x:.3}x, \
census {census} B"
);
assert!(allocated >= payload, "the allocator saw {allocated} B for a {payload} B payload — a stream is not being counted");
assert_eq!(
census, allocated,
"tile_cache_bytes says {census} B and the device was asked for {allocated} B — the eviction budget is \
enforced against a number that is not the card's bill"
);
assert!(
x <= RATIO_CEILING,
"the tile set allocated {x:.3}x its geometry ({allocated} B for {payload} B); the row's ceiling is {RATIO_CEILING}x"
);
nornir_testmatrix::functional_status(
"facett-map",
"gg21_vram_ratio_tiles",
x <= RATIO_CEILING && census == allocated,
&format!("{TILES} tiles: {allocated} B for {payload} B = {x:.3}x, census {census} B"),
);
}