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

nordisk/facett

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