Skip to content

Compile mesh BVHs to a memory-mappable index file - #14

Open
lispandfound wants to merge 3 commits into
drop-numbafrom
index-format
Open

lispandfound wants to merge 3 commits into
drop-numbafrom
index-format

Conversation

@lispandfound

@lispandfound lispandfound commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Every process that queried a mesh model read the mesh off disk, ran the BVH
build itself, and kept the result on its heap. Two things about that stopped
scaling: the models are tens of millions of simplices and every worker held a
copy, and the tree had no Python representation so --distributed could only
run threads inside one interpreter with registry.py standing in for
pickling.

This PR writes the build's output to a file whose layout is the in-memory
layout, and maps it back. A worker opens the file, and the pages a query
touches fault in and are shared through the page cache by every process on the
node. Nothing rebuilds and nothing copies.

What it took

MeshModel was already most of the way there: the bvh crate ran once, the
result was packed into 56-byte CompactNodes, and queries walked plain
slices. So:

  • Records become castable. CompactNode, Simplex, VertexRefs and
    Quality are #[repr(C)] with zerocopy derives. Simplex and ChildRef
    swap nalgebra/Aabb fields for plain arrays (the derives can't see through
    foreign types; Matrix3::from on the array folds away). Real = f64 gets an
    explicit _pad so ChildRef has no tail padding.
  • Slab<T>: Owned(Vec<T>) or Mapped { mmap, offset, len }, derefs to
    &[T]. MeshModel's four arrays are slabs; nothing downstream changed.
    A mixed model map (constant and interpolating simplices in one mesh) marks a
    constant simplex with u32::MAX in its second ref slot rather than carrying
    a parallel kinds array.
  • src/index.rs: header page + 4 KiB-aligned sections, written via a
    .partial rename. The header carries a tagged section table (offset, count,
    stride, tag); the reader finds sections by tag and refuses one whose stride
    differs from its record type, so a layout skew is caught at open. It also
    records the Real width (mismatch is refused), model metadata, and a
    caller-supplied 32-byte fingerprint. index.rs only does bytes and offsets:
    SectionWriter/SectionReader are the whole API, and CompactBvh,
    ModelMap and MeshModel write and read their own sections, so no fields
    had to be exposed for serialisation. The one unsafe in the crate is
    Mmap::map, justified by the file's immutability.
  • Python: current_index(mesh) is the single decision point (index
    exists, header parses, fingerprint matches); MeshModel.from_path maps
    <mesh>.nzidx when it returns a path and builds otherwise, and
    compile_index and the CLI use the same check. The fingerprint is a
    scandir metadata walk (paths, sizes, mtimes, zarr.json bytes), one
    stat per file and no array reads. nzcvm index build models/*.zarr
    compiles; models and synthetic recipes run it.

Is the build worth skipping?

You asked. benchmarks/benchmark_index.py at 2.76 M tetrahedra:

read zarr + build BVH 1.0–1.5 s
open index (header, mmap, fingerprint walk) 7–13 ms
index on disk 252 MB (91 B/tet)
query 200k points, built 130 ms
query 200k points, mapped, first pass 163 ms
query 200k points, mapped, cache warm 146 ms

Build scales ~linearly, so a 20 M-tet mesh is ~10 s per loading process; eight
workers on a node spend 80 CPU-seconds and eight memory copies reaching the
same tree. Mapped queries run ~12% slower warm (file-backed pages can't use
THP, so the tree walk misses the TLB more), which is the price of sharing.

Not in this PR (documented in docs/design/index-format.md)

  • Pickle-by-path and deleting registry.py so --distributed runs processes.
    A mapped MeshModel reduces to its path; the rest is plumbing.
  • Surface/Coastline indexes (same format, 2-D sections).
  • NFS policy for Cascade: PopulateRead on NFS, WillNeed locally, never
    Random; content-addressed filenames to dodge ESTALESIGBUS; 1 MiB
    section alignment. The doc lists what to measure on Cascade first.

Testing

cargo test && cargo test --features high_precision   # 76 + 76, incl. proptest round trips
cargo clippy --all-targets -- -D warnings            # both features
uv run --dev pytest tests/                           # 323 passed
just synthetic_index && uv run python benchmarks/benchmark_index.py

Rust tests: mapped ≡ built at random points (proptest), mixed model maps,
empty meshes, header/name/AABB round trip, refusal of foreign, truncated,
wrong-Real-width and wrong-stride files, section alignment. Python: loader policy, stale
fingerprint falls back to build, CLI, mapped ≡ built through ModelTree.

🤖 Generated with Claude Code
Stack created with GitHub Stacks CLIGive Feedback 💬

@lispandfound lispandfound changed the title index format Compile mesh BVHs to a memory-mappable index file Sep 11, 2026
@github-actions

Copy link
Copy Markdown

Benchmark for ef12428

Click to view benchmark
Test Base PR %
Mesh_Point_Query/centre/4096000 314.1±2.41ns 353.2±1.73ns +12.45%
Mesh_Point_Query/far_corner/4096000 214.6±5.34ns 326.1±1.88ns +51.96%
Mesh_Point_Query/near_origin/4096000 348.5±1.69ns 382.1±2.14ns +9.64%

@github-actions

Copy link
Copy Markdown

Benchmark for 1ab119f

Click to view benchmark
Test Base PR %
Mesh_Point_Query/centre/4096000 235.1±1.28ns 239.4±2.46ns +1.83%
Mesh_Point_Query/far_corner/4096000 224.2±3.54ns 223.3±2.16ns -0.40%
Mesh_Point_Query/near_origin/4096000 224.6±1.75ns 230.8±1.14ns +2.76%

@github-actions

Copy link
Copy Markdown

Benchmark for 524267b

Click to view benchmark
Test Base PR %
Mesh_Point_Query/centre/4096000 315.1±1.23ns 320.4±1.19ns +1.68%
Mesh_Point_Query/far_corner/4096000 299.9±8.69ns 259.5±2.29ns -13.47%
Mesh_Point_Query/near_origin/4096000 368.2±2.60ns 371.3±2.91ns +0.84%

@github-actions

Copy link
Copy Markdown

Benchmark for 524267b

Click to view benchmark
Test Base PR %
Mesh_Point_Query/centre/4096000 309.7±2.07ns 354.8±2.66ns +14.56%
Mesh_Point_Query/far_corner/4096000 201.7±6.92ns 306.6±2.59ns +52.01%
Mesh_Point_Query/near_origin/4096000 348.9±1.77ns 374.6±2.79ns +7.37%

@github-actions

Copy link
Copy Markdown

Benchmark for ca36781

Click to view benchmark
Test Base PR %
Mesh_Point_Query/centre/4096000 118.6±4.32ns 113.9±1.58ns -3.96%
Mesh_Point_Query/far_corner/4096000 112.3±3.82ns 111.8±2.29ns -0.45%
Mesh_Point_Query/near_origin/4096000 130.8±5.66ns 125.3±5.47ns -4.20%

@github-actions

Copy link
Copy Markdown

Benchmark for ca36781

Click to view benchmark
Test Base PR %
Mesh_Point_Query/centre/4096000 315.5±1.30ns 354.1±2.46ns +12.23%
Mesh_Point_Query/far_corner/4096000 203.0±5.18ns 292.7±1.85ns +44.19%
Mesh_Point_Query/near_origin/4096000 350.7±1.91ns 374.0±1.83ns +6.64%

@github-actions

Copy link
Copy Markdown

Benchmark for f8b9a32

Click to view benchmark
Test Base PR %
Mesh_Point_Query/centre/4096000 305.0±2.47ns 350.4±1.96ns +14.89%
Mesh_Point_Query/far_corner/4096000 200.2±3.40ns 306.8±2.26ns +53.25%
Mesh_Point_Query/near_origin/4096000 347.1±2.45ns 373.7±2.04ns +7.66%

@github-actions

Copy link
Copy Markdown

Benchmark for f8b9a32

Click to view benchmark
Test Base PR %
Mesh_Point_Query/centre/4096000 317.2±2.77ns 355.7±4.34ns +12.14%
Mesh_Point_Query/far_corner/4096000 226.9±7.80ns 304.1±2.19ns +34.02%
Mesh_Point_Query/near_origin/4096000 349.5±2.38ns 373.0±2.26ns +6.72%

@github-actions

Copy link
Copy Markdown

Benchmark for f97e9f4

Click to view benchmark
Test Base PR %
Mesh_Point_Query/centre/4096000 314.9±3.00ns 345.7±4.17ns +9.78%
Mesh_Point_Query/far_corner/4096000 222.5±6.40ns 304.4±1.34ns +36.81%
Mesh_Point_Query/near_origin/4096000 348.7±1.68ns 375.6±1.63ns +7.71%

@github-actions

Copy link
Copy Markdown

Benchmark for f97e9f4

Click to view benchmark
Test Base PR %
Mesh_Point_Query/centre/4096000 307.5±6.02ns 347.6±1.75ns +13.04%
Mesh_Point_Query/far_corner/4096000 199.8±4.72ns 304.9±1.90ns +52.60%
Mesh_Point_Query/near_origin/4096000 348.0±1.69ns 371.2±3.17ns +6.67%

lispandfound and others added 3 commits September 21, 2026 10:27
Every process that queried a mesh model read the mesh off disk and ran the
BVH build itself, then held the result on its heap. A country-scale mesh runs
to tens of millions of simplices, and each worker process paid the build and
kept its own copy. The tree also could not leave the process, since it had no
Python representation, so `--distributed` ran thread workers inside one
interpreter with a registry of live pointers in place of pickling.

`MeshModel` was already most of the way there. The `bvh` crate ran once in
`MeshModel::new`, `CompactBvh::from_bvh` packed the result into 56-byte nodes,
and queries walked plain slices. The work here is letting those slices come
from a file.

Every hot record (`CompactNode`, `Simplex`, `VertexRefs`, `Quality`) is now
`#[repr(C)]` and derives zerocopy's `FromBytes`, `IntoBytes`, `KnownLayout`
and `Immutable`, which pins the layout and lets a byte range become a `&[T]`
with size and alignment checked. `Simplex` and `ChildRef` swap their nalgebra
and `Aabb` fields for plain arrays so the derives can see them; `Matrix3::from`
on the array is a load the optimiser folds away. `Slab<T>` holds each array as
either `Owned(Vec<T>)` or `Mapped { mmap, offset, len }` and dereferences to
`&[T]`, so nothing downstream changed.

`src/index.rs` writes the four arrays behind a header page, each section on a
4 KiB boundary, and opens the file by mapping it. The header carries the
section offsets and counts, the `Real` width (a reader built with the other
width refuses the file), the model's metadata, and a 32-byte fingerprint of
the source mesh supplied by the caller. The writer renames a `.partial` file
into place so a crash leaves nothing that parses. `Mmap::map` is the one
`unsafe` call in the crate, and the file's immutability is what justifies it.

On the Python side, `MeshModel.from_path` maps `<mesh>.nzidx` when its
fingerprint matches the store and builds otherwise. The fingerprint hashes
the path, size and mtime of every file plus each `zarr.json`, so checking it
is a directory walk with no array reads. `nzcvm index build models/*.zarr`
compiles meshes, and the `models` and `synthetic` recipes run it.

`benchmarks/benchmark_index.py` at 2.8 million tetrahedra: build 1.1 s, open
7.5 ms, index 252 MB (91 B per tetrahedron), and a mapped tree answers
queries about 12% slower than a built one with the page cache warm, which is
the cost of file-backed pages missing transparent huge pages.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The design behind the compiled index, written down where the next person can
find it: the records and why two of them changed shape, `Slab<T>`, the file
layout, why the index is a flat file beside the zarr rather than inside it,
the fingerprint and what it deliberately does not promise, and the benchmark
that answers whether skipping the build is worth a second copy on disk.

Cascade mounts NFS, which changes cold start and the failure modes. A page
fault that misses the client cache is a round trip, and a file replaced under
a live mapping ends the process with `SIGBUS`. The document sets out the
`madvise` policy to add (populate on NFS, `WillNeed` elsewhere, never
`Random`), the content-addressed naming that avoids `ESTALE`, and what to
measure on Cascade before choosing between them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Findings from the review pass over the index-format diff:

- Move the serialisation onto the types that own the records. `index.rs`
  keeps the header, a `SectionWriter` and a `SectionReader`; `CompactBvh`,
  `ModelMap` and `MeshModel` write and read their own sections, so the
  seven one-caller accessors and `from_parts` go.
- Replace the positional offset/count arrays in the header with a tagged
  section table that records each section's stride. The reader looks
  sections up by tag and refuses one whose stride differs from its record
  type.
- Fold the mixed model map's parallel `kinds` array into the refs: a
  constant simplex carries `u32::MAX` in its second slot.
- Remove the dead `Queryable` trait and `enum_dispatch` on `Model`, the
  unused `ModelMap::is_mapped`, `Slab: From<Vec>`, and make `Simplex::c3`
  private.
- `Slab::mapped` validates with one `ref_from_prefix_with_elems` and maps
  the cast error onto `SlabError`; the size assertion on `CompactNode`
  holds at both `Real` widths.
- `PyMeshModel::model()` replaces seven copies of the consumed-model check.
- `ChildRef::contains` goes through `Aabb::contains` again.
- Python: `current_index()` is the one place that decides whether an index
  is usable, shared by `from_path`, `compile_index` and the CLI, which
  used a weaker check. The fingerprint walk uses `scandir` so a store costs
  one `stat` per file. Tests share one synthetic tomography writer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Benchmark for 833baf2

Click to view benchmark
Test Base PR %
Mesh_Point_Query/centre/4096000 311.3±2.24ns 346.7±2.09ns +11.37%
Mesh_Point_Query/far_corner/4096000 200.3±4.54ns 304.2±1.85ns +51.87%
Mesh_Point_Query/near_origin/4096000 348.4±2.03ns 372.1±2.42ns +6.80%

@github-actions

Copy link
Copy Markdown

Benchmark for 833baf2

Click to view benchmark
Test Base PR %
Mesh_Point_Query/centre/4096000 309.0±2.11ns 350.8±2.27ns +13.53%
Mesh_Point_Query/far_corner/4096000 201.8±6.32ns 311.1±4.40ns +54.16%
Mesh_Point_Query/near_origin/4096000 348.7±2.28ns 372.9±1.38ns +6.94%

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant