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}