Skip to main content

qsm_core/inversion/
tiled.rs

1//! Overlap-tiled inference for fully-convolutional deep-learning inversions (`onnx`).
2//!
3//! Whole-volume nets (e.g. xQSM, QSMnet) allocate activations proportional to the entire
4//! volume, which overflows a 32-bit WASM heap (4 GB ceiling) on clinical-size data. Because
5//! these nets are fully convolutional, they can instead be run **patch-by-patch**: each
6//! output "core" is produced from a patch that includes a `halo` of surrounding context,
7//! then written back — the classic U-Net overlap-tile strategy. Peak memory is bounded by a
8//! single patch regardless of volume size.
9//!
10//! NOTE: tiling a net trained on whole volumes is an **approximation**. Dipole inversion is a
11//! global operation, so a patch is blind to distant susceptibility sources; a larger `halo`
12//! reduces the resulting low-frequency / boundary error but does not eliminate it.
13//!
14//! Tiles run in parallel with the `parallel` feature. One patch bounds memory only if the number
15//! of *concurrent* patches is bounded too, which on a 32-bit WASM heap it has to be — see
16//! [`tile_concurrency`].
17
18use crate::grid::Grid;
19use crate::models::onnx::{OnnxError, OnnxModel, Tensor};
20use crate::models::TileCost;
21pub use super::tile_budget::*;
22
23/// The cost a tiled network's wrapper budgets with, from its registry entry
24/// ([`ModelSpec::tile_cost`](crate::models::ModelSpec::tile_cost)). Every tiled network has one (a
25/// test below checks), so a missing figure is a bug in the registry, not a runtime condition.
26pub(crate) fn tile_cost_of(model_id: &str) -> TileCost {
27    crate::models::find_model(model_id)
28        .and_then(|m| m.tile_cost())
29        .unwrap_or_else(|| panic!("{model_id} is tiled but has no tile_bytes_per_voxel in the registry"))
30}
31
32/// Refuse a tile config whose patch is too big for a WASM heap even on its own.
33///
34/// Running such a patch does not fail cleanly. Near the ceiling the module stops making progress
35/// without an error (#89), so it is refused up front with the largest core that would fit.
36/// `cost` is the network's ([`ModelSpec::tile_cost`](crate::models::ModelSpec::tile_cost)): its
37/// size divisor, which [`tile_patch_size`] rounds the patch up to, and its bytes per patch voxel.
38/// The tiled drivers call this on WASM before building a plan; it is callable anywhere, so a
39/// native host can check a config before handing it to a browser.
40pub fn check_wasm_patch_fits(cfg: &TileConfig, cost: &TileCost) -> Result<(), OnnxError> {
41    let bytes = tile_bytes(cfg, cost);
42    if bytes <= WASM_MAX_PATCH_ACTIVATION_BYTES {
43        return Ok(());
44    }
45    let p = tile_patch_size(cfg, cost.divisor);
46    let d = cost.divisor.max(1);
47    let max_edge = max_wasm_patch_edge(cost.bytes_per_voxel) / d * d;
48    let advice = match max_edge.checked_sub(2 * cfg.halo) {
49        Some(core) if core > 0 => format!("at halo {} the largest core that fits is {core}", cfg.halo),
50        _ => format!("halo {} alone leaves no room for a core; reduce the halo", cfg.halo),
51    };
52    Err(OnnxError::Memory(format!(
53        "a {p}³ tile patch needs about {:.1} GB, more than a browser's 4 GB WASM heap can hold even \
54         one tile at a time (patches up to {max_edge}³ fit for this network); {advice}",
55        bytes as f64 / 1e9,
56    )))
57}
58
59/// How many tiles [`tiled_scatter`] keeps in flight at once.
60///
61/// Natively this is just the rayon pool: memory is cheap and tile-level parallelism scales far
62/// better than `tract`'s intra-op threading. On **WASM** the limit is memory, not cores. Every
63/// concurrent tile holds its own activations in the single shared heap, so how much of the 4 GB
64/// ceiling a tiled run occupies is set by the pool size, the patch size and the network together —
65/// and by nothing else, the volume included. Tiling a bigger volume adds tiles, not concurrent
66/// ones.
67///
68/// Measured in Chromium (cross-origin-isolated) before this cap existed, the wasm heap high-water
69/// tracked the per-patch arithmetic: xQSM at 64³ patches, 1.1–1.4 GB with a 4-thread pool and
70/// 2.6–3.4 GB with 14. Raising the core one step is what makes it dangerous: `core: 96` (104³
71/// patches) on a 4-thread pool peaked at **4.24 GB, 98.7% of the ceiling**. It did not abort
72/// there. It finished one inference and then stopped making progress, never completing a second
73/// within 30 minutes — a stall with no error, which is harder to diagnose than a crash. Nothing in
74/// the library noticed (astewartau/QSM.rs#89).
75///
76/// So on WASM the count is [`wasm_tiles_in_flight`]: whatever fits
77/// [`WASM_TILE_ACTIVATION_BUDGET`] at the network's own cost per tile, and never more than the
78/// pool. The cost differs a lot between networks — a 64³ tile of BFRnet holds 3.4 times what one
79/// of iQSM does, and 1.8 times xQSM's — so one network's figure cannot stand in for the others.
80pub fn tile_concurrency(cfg: &TileConfig, cost: &TileCost) -> usize {
81    #[cfg(not(feature = "parallel"))]
82    {
83        let _ = (cfg, cost);
84        1
85    }
86    #[cfg(feature = "parallel")]
87    {
88        let pool = rayon::current_num_threads().max(1);
89        #[cfg(not(target_family = "wasm"))]
90        {
91            let _ = (cfg, cost);
92            pool
93        }
94        #[cfg(target_family = "wasm")]
95        {
96            wasm_tiles_in_flight(cfg, cost, pool)
97        }
98    }
99}
100
101/// A core-aligned tile: `(x0, y0, z0, cx, cy, cz)` — origin + core extent (clamped at edges).
102pub type Tile = (usize, usize, usize, usize, usize, usize);
103
104/// Shared overlap-tiling driver. Enumerates the core-aligned tiles that actually touch `mask`
105/// (all-background tiles are skipped → work is restricted to the mask bounding box for free),
106/// runs each through `run_tile`, and scatters the results back into a full-volume buffer
107/// (masked). Handles the empty-tile skip, parallel batching, and progress reporting so each
108/// model only supplies its own per-patch logic.
109///
110/// `run_tile(&tile)` must return the tile's **post-processed core block** — row-major
111/// `oi,oj,ok`, length `cx·cy·cz` — and be pure + `Sync`: it runs on up to
112/// [`tile_concurrency`] threads at once, each holding one patch's activations, which on WASM
113/// share a single 4 GB heap. `cost` is what one tile of the network costs
114/// ([`ModelSpec::tile_cost`](crate::models::ModelSpec::tile_cost)), which sets how many fit at
115/// once. `progress(done, total)` is called from the driver thread only
116/// (the JS callback isn't `Sync`).
117pub fn tiled_scatter(
118    grid: &Grid,
119    mask: &[u8],
120    cfg: &TileConfig,
121    cost: &TileCost,
122    run_tile: impl Fn(&Tile) -> Result<Vec<f64>, OnnxError> + Sync,
123    mut progress: impl FnMut(usize, usize),
124) -> Result<Vec<f64>, OnnxError> {
125    let (nx, ny, nz) = grid.dims;
126    let n = nx * ny * nz;
127    assert_eq!(mask.len(), n, "mask length must match grid");
128    #[cfg(target_family = "wasm")]
129    check_wasm_patch_fits(cfg, cost)?;
130    let core = cfg.core.max(1);
131
132    // Does the core block at (x0,y0,z0) cover any mask voxel?
133    let core_has_mask = |x0: usize, y0: usize, z0: usize, cx: usize, cy: usize, cz: usize| {
134        for oj in 0..cy {
135            for ok in 0..cz {
136                let row = x0 + nx * ((y0 + oj) + ny * (z0 + ok));
137                if mask[row..row + cx].iter().any(|&m| m != 0) {
138                    return true;
139                }
140            }
141        }
142        false
143    };
144
145    // Enumerate the core-aligned tiles that actually touch the mask.
146    let mut tiles: Vec<Tile> = Vec::new();
147    let mut x0 = 0usize;
148    while x0 < nx {
149        let cx = core.min(nx - x0);
150        let mut y0 = 0usize;
151        while y0 < ny {
152            let cy = core.min(ny - y0);
153            let mut z0 = 0usize;
154            while z0 < nz {
155                let cz = core.min(nz - z0);
156                if core_has_mask(x0, y0, z0, cx, cy, cz) {
157                    tiles.push((x0, y0, z0, cx, cy, cz));
158                }
159                z0 += core;
160            }
161            y0 += core;
162        }
163        x0 += core;
164    }
165    let total_tiles = tiles.len();
166    progress(0, total_tiles);
167
168    // Scatter a computed core block (row-major oi,oj,ok) into the full volume (masked).
169    let write_tile = |chi: &mut [f64], &(x0, y0, z0, cx, cy, cz): &Tile, block: &[f64]| {
170        for oi in 0..cx {
171            for oj in 0..cy {
172                for ok in 0..cz {
173                    let dst = (x0 + oi) + nx * ((y0 + oj) + ny * (z0 + ok));
174                    if mask[dst] != 0 {
175                        chi[dst] = block[(oi * cy + oj) * cz + ok];
176                    }
177                }
178            }
179        }
180    };
181
182    let mut chi = vec![0.0f64; n];
183
184    // Parallel path: run tiles in batches, then write + report progress from this (driver)
185    // thread. Falls back to sequential without the `parallel` feature.
186    //
187    // The batch size IS the memory bound — see `tile_concurrency`. Batching is how concurrency
188    // gets capped: rayon has no per-call concurrency limit, and the obvious alternative (a
189    // dedicated pool of that size) cannot be built on wasm32 at all, since
190    // `ThreadPoolBuilder::build` needs `std::thread::spawn`. The cost is a barrier per batch —
191    // the next wave waits for the slowest tile of this one — which is the price of not holding
192    // more patches in the heap than fit.
193    #[cfg(feature = "parallel")]
194    {
195        use rayon::prelude::*;
196        let batch = tile_concurrency(cfg, cost);
197        let mut done = 0usize;
198        for chunk in tiles.chunks(batch) {
199            let blocks: Vec<Vec<f64>> = chunk.par_iter().map(&run_tile).collect::<Result<_, _>>()?;
200            for (tile, block) in chunk.iter().zip(&blocks) {
201                write_tile(&mut chi, tile, block);
202                done += 1;
203                progress(done, total_tiles);
204            }
205        }
206    }
207    #[cfg(not(feature = "parallel"))]
208    {
209        let _ = cost;
210        for (t, tile) in tiles.iter().enumerate() {
211            let block = run_tile(tile)?;
212            write_tile(&mut chi, tile, &block);
213            progress(t + 1, total_tiles);
214        }
215    }
216    Ok(chi)
217}
218
219/// Overlap-tile an **entire volume→volume algorithm** (not just one forward pass). For each
220/// tile, a padded `p³` sub-volume of `field`/`mask` is cut out (with `halo` context, zero
221/// outside the volume), `run_patch(field_patch, mask_patch, patch_grid)` is run on it, and the
222/// central core is written back. Use this for the FFT-unrolled nets (lpcnn/modl-qsm/nextqsm)
223/// whose Rust-side physics loop wraps a whole-volume CNN — running the whole algorithm per patch
224/// bounds memory. Strongly off-design (the dipole/k-space step then sees only a patch), so results
225/// are approximate; callers should warn and steer users to a full-volume run for real work.
226/// `cost` is the network's tile cost, as for [`tiled_scatter`]; its divisor pads each patch.
227#[allow(clippy::too_many_arguments)]
228pub fn tiled_volume_algorithm(
229    field: &[f64],
230    mask: &[u8],
231    grid: &Grid,
232    cost: &TileCost,
233    cfg: &TileConfig,
234    run_patch: impl Fn(&[f64], &[u8], &Grid) -> Result<Vec<f64>, OnnxError> + Sync,
235    progress: impl FnMut(usize, usize),
236) -> Result<Vec<f64>, OnnxError> {
237    let (nx, ny, nz) = grid.dims;
238    assert_eq!(field.len(), nx * ny * nz, "field length must match grid");
239    let halo = cfg.halo;
240    let (nxi, nyi, nzi) = (nx as i64, ny as i64, nz as i64);
241    #[cfg(target_family = "wasm")]
242    check_wasm_patch_fits(cfg, cost)?;
243    let p = tile_patch_size(cfg, cost.divisor);
244    let (vsx, vsy, vsz) = grid.voxel_size;
245    let inside = move |x: i64, y: i64, z: i64| x >= 0 && x < nxi && y >= 0 && y < nyi && z >= 0 && z < nzi;
246
247    let run_tile = move |&(x0, y0, z0, cx, cy, cz): &Tile| -> Result<Vec<f64>, OnnxError> {
248        // Cut a column-major p³ sub-volume of field + mask (zero/empty outside the volume).
249        let mut fpatch = vec![0.0f64; p * p * p];
250        let mut mpatch = vec![0u8; p * p * p];
251        for k in 0..p {
252            let vz = z0 as i64 - halo as i64 + k as i64;
253            for j in 0..p {
254                let vy = y0 as i64 - halo as i64 + j as i64;
255                for i in 0..p {
256                    let vx = x0 as i64 - halo as i64 + i as i64;
257                    if inside(vx, vy, vz) {
258                        let s = vx as usize + nx * (vy as usize + ny * vz as usize);
259                        let d = i + p * (j + p * k);
260                        fpatch[d] = field[s];
261                        mpatch[d] = mask[s];
262                    }
263                }
264            }
265        }
266        let pgrid = Grid::new(p, p, p, vsx, vsy, vsz);
267        let chi = run_patch(&fpatch, &mpatch, &pgrid)?;
268        if chi.len() != p * p * p {
269            return Err(OnnxError::Run(format!(
270                "tiled algorithm returned {} voxels, expected {}", chi.len(), p * p * p
271            )));
272        }
273        // Extract the central core (column-major) at offset `halo`.
274        let mut core = vec![0.0f64; cx * cy * cz];
275        for oi in 0..cx {
276            for oj in 0..cy {
277                for ok in 0..cz {
278                    let src = (halo + oi) + p * ((halo + oj) + p * (halo + ok));
279                    core[(oi * cy + oj) * cz + ok] = chi[src];
280                }
281            }
282        }
283        Ok(core)
284    };
285
286    tiled_scatter(grid, mask, cfg, cost, run_tile, progress)
287}
288
289/// Run a fully-convolutional field→χ ONNX net patch-by-patch with a context halo, bounding
290/// peak memory to a single patch.
291///
292/// Values are fed/read as f32 in the x-outer `NCDHW` layout the nets use; `pre` maps each
293/// input field value, `post` maps each raw net output value. Each patch is zero-padded to a
294/// multiple of `cost.divisor` (the net's `size_divisor`) and to include the halo. The result is
295/// masked, matching the whole-volume wrappers. `cost` is the network's tile cost, as for
296/// [`tiled_scatter`].
297#[allow(clippy::too_many_arguments)]
298pub fn tiled_field_inversion(
299    field: &[f64],
300    mask: &[u8],
301    grid: &Grid,
302    model: &OnnxModel,
303    cost: &TileCost,
304    cfg: &TileConfig,
305    pre: impl Fn(f64) -> f32 + Sync,
306    post: impl Fn(f32) -> f64 + Sync,
307    progress: impl FnMut(usize, usize),
308) -> Result<Vec<f64>, OnnxError> {
309    let (nx, ny, nz) = grid.dims;
310    assert_eq!(field.len(), nx * ny * nz, "field length must match grid");
311    let halo = cfg.halo;
312    let (nxi, nyi, nzi) = (nx as i64, ny as i64, nz as i64);
313
314    // Field sampler: zero outside the volume (edge context / padding).
315    let at = |x: i64, y: i64, z: i64| -> f64 {
316        if x >= 0 && x < nxi && y >= 0 && y < nyi && z >= 0 && z < nzi {
317            field[x as usize + nx * (y as usize + ny * z as usize)]
318        } else {
319            0.0
320        }
321    };
322
323    // One fixed patch shape for the whole run → the graph is optimized once and reused.
324    #[cfg(target_family = "wasm")]
325    check_wasm_patch_fits(cfg, cost)?;
326    let p = tile_patch_size(cfg, cost.divisor);
327    let plan = model.plan_for(&[&[1, 1, p, p, p]])?;
328
329    // Per-patch: build the p³ input (NCDHW, x outer, z inner) sampling at (x0-halo+i, …), run the
330    // net, and return the central core (offset `halo`) post-processed.
331    let run_tile = move |&(x0, y0, z0, cx, cy, cz): &Tile| -> Result<Vec<f64>, OnnxError> {
332        let mut inp = vec![0.0f32; p * p * p];
333        for i in 0..p {
334            let vx = x0 as i64 - halo as i64 + i as i64;
335            for j in 0..p {
336                let vy = y0 as i64 - halo as i64 + j as i64;
337                let base = (i * p + j) * p;
338                for k in 0..p {
339                    let vz = z0 as i64 - halo as i64 + k as i64;
340                    inp[base + k] = pre(at(vx, vy, vz));
341                }
342            }
343        }
344        let out = plan.run_single(&Tensor::new(vec![1, 1, p, p, p], inp))?;
345        if out.shape != [1, 1, p, p, p] {
346            return Err(OnnxError::Run(format!(
347                "unexpected patch output shape {:?}, expected [1,1,{p},{p},{p}]",
348                out.shape
349            )));
350        }
351        let mut core_block = vec![0.0f64; cx * cy * cz];
352        for oi in 0..cx {
353            for oj in 0..cy {
354                for ok in 0..cz {
355                    let src = ((halo + oi) * p + (halo + oj)) * p + (halo + ok);
356                    core_block[(oi * cy + oj) * cz + ok] = post(out.data[src]);
357                }
358            }
359        }
360        Ok(core_block)
361    };
362
363    tiled_scatter(grid, mask, cfg, cost, run_tile, progress)
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369    use crate::models::all_models;
370
371    /// The networks QSM-Core runs tiled, with the divisor each one's wrapper pads its patches to.
372    const TILED: [(&str, usize); 11] = [
373        ("bfrnet", 8),
374        ("xqsm", 8),
375        ("qsmnet", 16),
376        ("qsmnet-plus", 16),
377        ("ir2qsm", 8),
378        ("lpcnn", 8),
379        ("modl-qsm", 1),
380        ("nextqsm", 64),
381        ("iqsm", 16),
382        ("iqsm-plus", 16),
383        ("iqfm", 16),
384    ];
385
386    /// QSMbly's tile config: 64³ patches.
387    const BROWSER: TileConfig = TileConfig { core: 56, halo: 4 };
388
389    /// A cost for testing the arithmetic on its own: xQSM-sized, no divisor.
390    const ARITH: TileCost = TileCost { divisor: 1, bytes_per_voxel: 1_100 };
391
392    /// Exactly the tiled networks carry a tile cost, each with the divisor its wrapper pads to,
393    /// so every wrapper's `tile_cost_of` finds one and none budgets with another's figure.
394    #[test]
395    fn every_tiled_network_has_its_own_tile_cost() {
396        for m in all_models() {
397            match TILED.iter().find(|(id, _)| *id == m.id) {
398                Some(&(id, divisor)) => {
399                    let cost = m.tile_cost().unwrap_or_else(|| panic!("{id} is tiled but has no figure"));
400                    assert_eq!(cost.divisor, divisor, "{id}");
401                    assert_eq!(tile_cost_of(id), cost);
402                }
403                None => assert!(m.tile_cost().is_none(), "{} is not tiled but has a tile cost", m.id),
404            }
405        }
406        // QSMnet and QSMnet+ share an architecture and one tiled wrapper, which budgets with
407        // QSMnet's entry.
408        assert_eq!(tile_cost_of("qsmnet"), tile_cost_of("qsmnet-plus"));
409    }
410
411    /// For every tiled network, at the browser config and around it, the tiles a WASM pool keeps
412    /// in flight times what one of them costs fits the budget: more than one tile is only let
413    /// through when they all fit, and a lone tile is either inside the budget or refused by
414    /// `check_wasm_patch_fits` if it cannot run even alone.
415    #[test]
416    fn every_tiled_network_fits_the_budget_at_its_cap() {
417        let configs = [
418            BROWSER,
419            TileConfig { core: 24, halo: 4 },
420            TileConfig { core: 40, halo: 8 },
421            TileConfig { core: 88, halo: 4 },
422            TileConfig { core: 120, halo: 4 },
423            TileConfig::default(),
424        ];
425        for (id, _) in TILED {
426            let cost = tile_cost_of(id);
427            for cfg in configs {
428                let one = tile_bytes(&cfg, &cost);
429                for threads in [1, 2, 4, 8, 16] {
430                    let n = wasm_tiles_in_flight(&cfg, &cost, threads);
431                    assert!((1..=threads).contains(&n), "{id} {cfg:?} on {threads} threads: {n}");
432                    if n > 1 {
433                        assert!(
434                            n as u64 * one <= WASM_TILE_ACTIVATION_BUDGET,
435                            "{id} {cfg:?}: {n} tiles of {one} B exceed the budget"
436                        );
437                    } else if one > WASM_TILE_ACTIVATION_BUDGET {
438                        assert_eq!(
439                            check_wasm_patch_fits(&cfg, &cost).is_ok(),
440                            one <= WASM_MAX_PATCH_ACTIVATION_BYTES,
441                            "{id} {cfg:?}"
442                        );
443                    }
444                }
445            }
446        }
447    }
448
449    /// BFRnet was budgeted with xQSM's figure (1 100 B per patch voxel), which let a 4-thread
450    /// browser run four of its 64³ tiles at once: 2.2 GB of tiles by its own measure, over the
451    /// budget, on top of the model and the volumes. On its own figure it runs two.
452    #[test]
453    fn bfrnet_runs_fewer_tiles_at_once_than_on_the_shared_figure() {
454        let shared = TileCost { divisor: 8, bytes_per_voxel: 1_100 };
455        assert_eq!(wasm_tiles_in_flight(&BROWSER, &shared, 4), 4);
456        let bfrnet = tile_cost_of("bfrnet");
457        let n = wasm_tiles_in_flight(&BROWSER, &bfrnet, 4);
458        assert!(n < 4, "BFRnet still runs {n} tiles at once on 4 threads");
459        assert_eq!(n, BFRNET_CAP_AT_FOUR);
460        // What the four tiles the shared figure allowed would hold by BFRnet's own measure.
461        assert!(4 * tile_bytes(&BROWSER, &bfrnet) > WASM_TILE_ACTIVATION_BUDGET);
462    }
463    const BFRNET_CAP_AT_FOUR: usize = 2;
464
465    /// A tile's bytes follow the patch the network really runs: rounded up to its divisor.
466    #[test]
467    fn tile_bytes_follow_the_rounded_patch() {
468        // 56 + 2·4 = 64 is a multiple of 8 and of 64; 40 + 2·4 = 48 is not a multiple of 64, so
469        // NeXtQSM pads it to 64³.
470        let c8 = TileCost { divisor: 8, bytes_per_voxel: 1_000 };
471        let c64 = TileCost { divisor: 64, bytes_per_voxel: 1_000 };
472        assert_eq!(tile_bytes(&BROWSER, &c8), 64 * 64 * 64 * 1_000);
473        assert_eq!(tile_bytes(&BROWSER, &c64), 64 * 64 * 64 * 1_000);
474        let small = TileConfig { core: 40, halo: 4 };
475        assert_eq!(tile_bytes(&small, &c8), 48 * 48 * 48 * 1_000);
476        assert_eq!(tile_bytes(&small, &c64), 64 * 64 * 64 * 1_000);
477        // Saturates rather than overflowing.
478        assert_eq!(tile_bytes(&TileConfig { core: usize::MAX, halo: 8 }, &c8), u64::MAX);
479    }
480
481    /// The WASM budget has to translate patch size into a tile count that actually fits a 4 GB
482    /// heap. Figures below are `(core + 2·halo)³ · 1 100 B` against `WASM_TILE_ACTIVATION_BUDGET`,
483    /// i.e. what a browser can hold at once.
484    #[test]
485    fn wasm_tile_budget_tracks_patch_footprint() {
486        // 64³ patches, ~288 MB each → five fit in 1.5 GB.
487        assert_eq!(tiles_within_wasm_budget(&BROWSER, &ARITH), 5);
488        // Doubling the patch edge is 8× the memory, so the count collapses fast: 32³ patches are
489        // 36 MB each (41 fit), 128³ are 2.3 GB (one, over budget on its own).
490        assert_eq!(tiles_within_wasm_budget(&TileConfig { core: 24, halo: 4 }, &ARITH), 41);
491        assert_eq!(tiles_within_wasm_budget(&TileConfig { core: 120, halo: 4 }, &ARITH), 1);
492        // A patch that alone exceeds the budget still has to be attempted, not divided by zero:
493        // 144³ is ~3.3 GB, and the crate's own native default is 192³ on top of that.
494        assert_eq!(tiles_within_wasm_budget(&TileConfig { core: 128, halo: 8 }, &ARITH), 1);
495        assert_eq!(tiles_within_wasm_budget(&TileConfig { core: 192, halo: 32 }, &ARITH), 1);
496        // Degenerate configs must not panic, divide by zero, or overflow into a wrong answer. A
497        // patch too small to matter leaves the pool as the only limit; `usize::MAX` saturates to
498        // one tile rather than wrapping to a large count.
499        assert!(tiles_within_wasm_budget(&TileConfig { core: 0, halo: 0 }, &ARITH) > 1);
500        assert_eq!(tiles_within_wasm_budget(&TileConfig { core: usize::MAX, halo: 0 }, &ARITH), 1);
501
502        // The count is monotonically non-increasing in patch size — the property the cap relies
503        // on, and the thing a wrong unit or an inverted ratio would break.
504        let mut prev = usize::MAX;
505        for core in (8..=200).step_by(8) {
506            let n = tiles_within_wasm_budget(&TileConfig { core, halo: 4 }, &ARITH);
507            assert!(n <= prev, "count rose from {prev} to {n} at core {core}");
508            assert!(n >= 1);
509            prev = n;
510        }
511        // And in the network's cost: a heavier network never fits more tiles.
512        let heavier = TileCost { bytes_per_voxel: 3 * ARITH.bytes_per_voxel, ..ARITH };
513        assert!(tiles_within_wasm_budget(&BROWSER, &heavier) < tiles_within_wasm_budget(&BROWSER, &ARITH));
514    }
515
516    /// A patch that cannot fit a WASM heap even alone is refused with the largest core that would,
517    /// and everything a browser actually runs is let through.
518    #[test]
519    fn wasm_refuses_a_patch_too_big_to_run_alone() {
520        let by8 = TileCost { divisor: 8, ..ARITH };
521        assert_eq!(max_wasm_patch_edge(ARITH.bytes_per_voxel), 131);
522        // The browser config, and the largest whole-divisor patch under the limit at /8.
523        assert!(check_wasm_patch_fits(&BROWSER, &by8).is_ok());
524        assert!(check_wasm_patch_fits(&TileConfig { core: 120, halo: 4 }, &by8).is_ok());
525        // One voxel more rounds up to a 136³ patch, which does not fit.
526        assert!(check_wasm_patch_fits(&TileConfig { core: 121, halo: 4 }, &by8).is_err());
527        // Without a divisor, 131³ fits and 132³ does not.
528        assert!(check_wasm_patch_fits(&TileConfig { core: 123, halo: 4 }, &ARITH).is_ok());
529        assert!(check_wasm_patch_fits(&TileConfig { core: 124, halo: 4 }, &ARITH).is_err());
530
531        // The crate's native default (144³) is refused, naming the core that would fit at its halo:
532        // 128 (131 rounded down to /8) less 2 × 8.
533        match check_wasm_patch_fits(&TileConfig::default(), &by8) {
534            Err(OnnxError::Memory(m)) => {
535                assert!(m.contains("144³"), "{m}");
536                assert!(m.contains("largest core that fits is 112"), "{m}");
537            }
538            other => panic!("expected a memory error, got {other:?}"),
539        }
540        // A halo that leaves no room for any core says so, rather than suggesting core 0.
541        match check_wasm_patch_fits(&TileConfig { core: 8, halo: 64 }, &by8) {
542            Err(OnnxError::Memory(m)) => assert!(m.contains("reduce the halo"), "{m}"),
543            other => panic!("expected a memory error, got {other:?}"),
544        }
545        // A heavier network is refused at a smaller patch.
546        assert!(max_wasm_patch_edge(3 * ARITH.bytes_per_voxel) < 131);
547    }
548
549    /// `tile_concurrency` never exceeds the pool, and never reports zero tiles in flight.
550    #[test]
551    fn tile_concurrency_is_bounded_by_the_pool() {
552        for cfg in [BROWSER, TileConfig { core: 128, halo: 8 }, TileConfig::default()] {
553            for (id, _) in TILED {
554                let n = tile_concurrency(&cfg, &tile_cost_of(id));
555                assert!(n >= 1);
556                #[cfg(feature = "parallel")]
557                assert!(n <= rayon::current_num_threads().max(1));
558                #[cfg(not(feature = "parallel"))]
559                assert_eq!(n, 1);
560            }
561        }
562    }
563}