Skip to content

Mesh.slice_points: avoid full-mesh index allocations for sparse cell blocks - #1966

Open
peterdsharpe wants to merge 7 commits into
NVIDIA:mainfrom
peterdsharpe:pr/mesh-reader-cell-compaction
Open

Mesh.slice_points: avoid full-mesh index allocations for sparse cell blocks#1966
peterdsharpe wants to merge 7 commits into
NVIDIA:mainfrom
peterdsharpe:pr/mesh-reader-cell-compaction

Conversation

@peterdsharpe

@peterdsharpe peterdsharpe commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Description

Mesh.slice_points no longer allocates point-count-sized index tensors when remapping a small cell block from a much larger mesh. It retains the lookup-table algorithm for ordinary meshes and switches to sorting the kept point IDs plus binary search when n_points > 64 * cells.numel() and fewer than n_points / 64 points are kept. Meshes without cells skip the remap, and remapped connectivity is always int64 whatever the input dtype. This targets the reader's compaction of large meshes after cell sampling.

Index normalization allocates only the selected range/IDs. Empty forward slices remain empty; int32/int64 indices, negative indices, duplicates, unsorted selections, boolean masks and legacy uint8 masks retain their selection semantics. Invalid tensor dtypes, malformed masks, multidimensional selections and out-of-range indices are rejected. Negative-step slices remain unsupported, matching PyTorch indexing.

Points and nested point data use ordinary indexed gathers, including when loaded from a memory-mapped mesh. The change does not claim sequential range reads. Duplicate point IDs retain the previous last-position cell remapping behavior.

Validation

  • 96 focused tests passed across test_slice_points_lookup_equivalence.py and test_slicing.py, with both remapping algorithms forced in the equivalence/regression suite.
  • Coverage includes memory-mapped meshes, nested fields, empty/clipped slices, mask/index dtype validation and positive/negative bounds errors.
  • All changed-file pre-commit hooks passed, including import-linter.
  • Local validation used Python 3.14, PyTorch 2.12 and TensorDict 0.14. Cluster (HiLiftAeroML) and synthetic benchmarks were rerun at f75eeea; results and figures are in the comment thread.

Mesh.slice_points builds two n_points-sized index tensors and
scatter-gathers the kept rows; on a 142M-vertex memmap-backed surface
that is ~2.3 GB of temporaries per 10k-cell sample. The reader already
knows every surviving cell is fully referenced, so compact directly:
torch.unique(..., return_inverse=True) remaps the cells and _gather_rows
reads the referenced rows of points and each point_data leaf (one
sequential range read when the rows span <= 256 MiB, else a plain
gather). Output is identical to the old path; tests assert equality.
…n ranges

slice_points remapped cells through a full-mesh lookup table (an arange and
an old-to-new map, each n_points long) and fancy-indexed the kept rows out
of points and point_data. On a memory-mapped surface with ~142M vertices
that is ~2.3 GB of temporaries and one page fault per row for a slice of a
few thousand points, in every dataloader worker.

Sort the kept ids once and locate each cell vertex by binary search (a
vertex is kept iff the search lands on an equal id; right-biased so a
duplicated id maps to its last position, as the lookup table did). Gather
kept rows with a helper that reads one contiguous range when the rows are
close together and falls back to a plain gather otherwise. Boolean masks
and slices normalize without an n_points-sized arange.

Results are unchanged; tests pin every accepted index form (unsorted,
duplicates, negatives, empty, slice, list, int, bool mask) and a
memmap-loaded mesh to the lookup-table algorithm.
@peterdsharpe peterdsharpe changed the title Mesh reader: compact subsampled cells without Mesh.slice_points Mesh.slice_points: avoid full-mesh index tensors and read memmap rows in ranges Sep 8, 2026
Synthetic sweeps showed the binary search alone is 2-10x slower than the
lookup table when slicing a full mesh (every cell vertex is searched with a
log k factor), while it wins 5-19x for the reader path (a small cell block
out of a 1e8-vertex mesh). Keep the lookup table when n_points is at most
64x the number of cell-vertex entries and search otherwise; the equivalence
tests now run on both algorithms.
@peterdsharpe

peterdsharpe commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator Author

Measurements: main vs this PR

Two implementations of Mesh.slice_points are compared throughout:

  • main: full-mesh lookup table. arange(n_points) plus an n_points-long old-to-new map, then old_to_new[cells]; fancy-index gathers for points and point data.
  • this PR (f75eeea): lookup table or binary search, chosen by mesh shape. The same lookup table when n_points ≤ 64 × cells.numel() or when at least 1/64 of the points are kept; otherwise binary search of each cell vertex in the sorted kept ids. Points and point data are gathered with ordinary indexing.

Every measurement runs in a fresh process. Measured at the current head (f75eeea), after the review changes. Figures, raw results and scripts are on an assets branch of my fork.

1. The real workload: HiLiftAeroML boundaries on a cluster (Lustre memmaps)

Each HiLiftAeroML boundary mesh has ~285M cells and ~142M vertices, stored as memory-mapped files on Lustre. The recipe's reader takes a contiguous block of cells per training sample and then calls slice_points to drop the vertices the block does not reference (a 10k-cell block keeps ~30k of the 142M vertices, so these meshes take the binary-search branch). Twelve cold cases per implementation per block size, 24 distinct cases in total, run in two rounds with the package order swapped and the two implementations alternating so file-system drift cancels. Median with min–max in parentheses.

block cells metric main: lookup table this PR: table or search, by shape speed-up
10,000 cold sample (load + block + compaction) 2.22 s (1.94 s–8.36 s) 2.60 s (1.94 s–62.97 s) 0.9x
10,000 second block, same case (partly cached) 935 ms (680 ms–5.67 s) 976 ms (621 ms–25.14 s) 1.0x
10,000 slice_points alone, same block (point pages just read) 48 ms (38 ms–6.03 s) 2 ms (2 ms–2.01 s) 27.9x
10,000 recipe reader __getitem__ 469 ms (314 ms–1.34 s) 341 ms (266 ms–996 ms) 1.4x
10,000 peak RSS increase during the sample 3,081 (2,826–3,186) MiB 963 (702–1,044) MiB 3.2x
100,000 cold sample (load + block + compaction) 3.25 s (2.20 s–8.78 s) 2.92 s (2.24 s–6.43 s) 1.1x
100,000 second block, same case (partly cached) 95 ms (75 ms–6.82 s) 46 ms (36 ms–10.60 s) 2.1x
100,000 slice_points alone, same block (point pages just read) 52 ms (44 ms–2.31 s) 10 ms (9 ms–9.62 s) 5.1x
100,000 recipe reader __getitem__ 147 ms (133 ms–2.41 s) 119 ms (94 ms–5.49 s) 1.2x
100,000 peak RSS increase during the sample 3,622 (3,510–3,942) MiB 1,476 (1,314–1,584) MiB 2.5x

HiLiftAeroML on the cluster

What this says:

  • Memory is the headline. The per-sample peak RSS increase drops from ~3.1 GB to ~0.96 GB at 10k cells and from ~3.6 GB to ~1.5 GB at 100k cells. The ~2.1 GB saved is the two n_points-long int64 tensors (2 × 142M × 8 B) that every dataloader worker used to allocate per sample.
  • slice_points itself goes from 48 ms to 2 ms on a 10k-cell block (28x at the median) and from 52 ms to 10 ms at 100k cells (5x).
  • Cold wall time is unchanged within noise (medians 2.2–2.6 s / 2.9–3.3 s, fastest cases identical at 1.94 s). On Lustre the cold sample is dominated by reading 30k–300k scattered vertex rows out of a 1.7 GB point file, which no in-process algorithm changes. The outliers in both implementations (63 s, 25 s, 9 s, 8 s) are storage stalls on a loaded file system that hit whichever one was running; they also account for the min–max spread in the slice_points rows, whose point gather touches the same memory-mapped pages.
  • The recipe reader's end-to-end __getitem__ on a partly cached case improves modestly (469 → 341 ms, 147 → 119 ms).

2. Synthetic: the reader's path as a function of mesh size

slice_cells(block) then slice_points(unique(cells)) on an N-vertex triangle mesh with 2N random cells, one scalar and one vector point field, saved once to a node-local memmap ("memory" rows clone everything into RAM first). The block is small; what matters is how compaction scales with the size of the mesh it came from. Median of 3 runs.

N vertices block cells kept vertices backing main: lookup table this PR: table or search, by shape speed-up
1,000,000 10,000 29,539 memmap 3 ms 3 ms 1.1x
1,000,000 10,000 29,539 memory 2 ms 2 ms 1.0x
1,000,000 100,000 259,232 memmap 3 ms 3 ms 1.0x
1,000,000 100,000 259,232 memory 2 ms 2 ms 0.9x
10,000,000 10,000 29,955 memmap 8 ms 5 ms 1.6x
10,000,000 10,000 29,955 memory 4 ms 2 ms 1.8x
10,000,000 100,000 295,678 memmap 8 ms 6 ms 1.3x
10,000,000 100,000 295,678 memory 7 ms 5 ms 1.3x
100,000,000 10,000 29,997 memmap 62 ms 13 ms 4.8x
100,000,000 10,000 29,997 memory 49 ms 2 ms 20.8x
100,000,000 100,000 299,571 memmap 65 ms 17 ms 3.8x
100,000,000 100,000 299,571 memory 53 ms 8 ms 6.5x

Synthetic reader path

  • With the lookup table, the cost of compacting a fixed-size block grows linearly with the size of the source mesh (2 ms → 49 ms from 1M to 100M vertices for a 10k-cell block in memory). With this PR it is flat at ~2 ms: the work depends on the block, not on the mesh.
  • The 1M-vertex rows are on the lookup-table side of the threshold (33 points per cell-vertex entry) and match main. The memmap rows at 100M vertices keep ~13 ms because the kept rows are scattered through the file and have to be read individually.

3. Synthetic: slice_points on the full mesh (keeping k random vertices)

Same meshes, now slicing the whole mesh to k random vertices, which is the usual use of slice_points (mesh transforms, cropping). Cell connectivity is 6N entries, so every row here takes the lookup-table branch of this PR. Median of 3 runs.

N vertices k kept backing main: lookup table this PR: table or search, by shape speed-up
1,000,000 1,000 memmap 6 ms 6 ms 1.1x
1,000,000 1,000 memory 15 ms 5 ms 3.0x
1,000,000 30,000 memmap 6 ms 6 ms 1.0x
1,000,000 30,000 memory 15 ms 6 ms 2.6x
10,000,000 1,000 memmap 78 ms 69 ms 1.1x
10,000,000 1,000 memory 76 ms 66 ms 1.1x
10,000,000 30,000 memmap 80 ms 73 ms 1.1x
10,000,000 30,000 memory 73 ms 77 ms 0.9x
10,000,000 1,000,000 memmap 83 ms 82 ms 1.0x
10,000,000 1,000,000 memory 78 ms 81 ms 1.0x
100,000,000 1,000 memmap 1.01 s 929 ms 1.1x
100,000,000 1,000 memory 961 ms 923 ms 1.0x
100,000,000 30,000 memmap 995 ms 947 ms 1.1x
100,000,000 30,000 memory 992 ms 944 ms 1.1x
100,000,000 1,000,000 memmap 1.02 s 992 ms 1.0x
100,000,000 1,000,000 memory 988 ms 995 ms 1.0x

Synthetic full mesh

This PR matches main on full-mesh slices (0.9x–1.1x at 10M and 100M vertices; the 1M-vertex rows are all under 20 ms and within run-to-run noise): the common use of slice_points is unaffected, and the gains above come only where the mesh is far larger than the cells being kept.

Method and raw data
  • HiLift: bench_hilift_slice.py on one GB300 node of an internal Slurm cluster (CPU work only), with PYTHONPATH switched between a snapshot of main and this branch; both use the unchanged upstream reader (_subsample_mesh_cellsslice_cellsslice_points). Two dozen cases taken at a fixed stride through the dataset (12 per round, the second round on a disjoint dozen with the package order swapped), block seed 0 for the cold sample, seed 1 for the second block. Peak RSS from ru_maxrss. Raw: hilift_aga.jsonl.
  • Synthetic: bench_slice_points.py and bench_slice_points_block.py, one subprocess per (implementation, case), on the same node. The lookup-table algorithm from main is reproduced verbatim inside the script; the other column runs the branch's Mesh.slice_points. Raw: synthetic_full_mesh.json, synthetic_reader_path.json.
  • Tables and figures: analyze_bench1966.py.

@NVIDIA NVIDIA deleted a comment from github-actions Bot Sep 8, 2026
@NVIDIA NVIDIA deleted a comment from copy-pr-bot Bot Sep 8, 2026
@peterdsharpe
peterdsharpe marked this pull request as ready for review September 8, 2026 18:28
@greptile-apps

greptile-apps Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

RetriggerView in Greptile

The PR appears safe to merge from a correctness perspective, with a non-blocking performance concern because the new range-read helper does not deliver its claimed sequential memory-mapped read.

Findings

  1. P2 Range Read Stays Lazy

Summary

  • Normalizes supported point-index forms without constructing a full-mesh arange.
  • Adds search-based remapping for meshes whose point count greatly exceeds connectivity size.
  • Adds equivalence coverage for index forms, nested point data, and memory-mapped meshes.
  • The new row-gather helper remains correct, but its contiguous-slice branch does not materialize the range and therefore does not provide the claimed sequential-read behavior.

Comment thread physicsnemo/mesh/utilities/_row_gather.py Outdated
peterdsharpe added a commit to peterdsharpe/physicsnemo that referenced this pull request Sep 8, 2026
…her; upstream PR NVIDIA#1966 (Mesh.slice_points) supersedes it

Reverts the effect of f955fbb, 11ab332, 4ce9a7d, a2e9970, 1b2ac66.
The compaction fix now lives in Mesh.slice_points (PR NVIDIA#1966, merged from
origin/pr/mesh-reader-cell-compaction at 1e605e0), so _subsample_mesh_cells
is back to slice_cells + slice_points as on main. The page-wise positional
read path (PHYSICSNEMO_MESH_PREAD_GATHER) is dropped, not re-hooked: on AGA
Lustre it was 4-5x slower inside the training loop (320-361 s vs 70-130 s per
epoch on the same node; book/18-notebook.qmd#sec-nb-hilift-io-verdict).
peterdsharpe added a commit to peterdsharpe/physicsnemo that referenced this pull request Sep 8, 2026
@copy-pr-bot

copy-pr-bot Bot commented Sep 8, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@peterdsharpe

Copy link
Copy Markdown
Collaborator Author

/ok to test 1aaf5fd

@peterdsharpe peterdsharpe changed the title Mesh.slice_points: avoid full-mesh index tensors and read memmap rows in ranges Mesh.slice_points: avoid full-mesh index allocations for sparse cell blocks Sep 9, 2026
@peterdsharpe

Copy link
Copy Markdown
Collaborator Author

/ok to test 1d5ad82

Signed-off-by: Peter Sharpe <peterdsharpe@gmail.com>
@peterdsharpe
peterdsharpe force-pushed the pr/mesh-reader-cell-compaction branch from 1d5ad82 to 8c0941f Compare September 9, 2026 13:40
@peterdsharpe

Copy link
Copy Markdown
Collaborator Author

/ok to test 8c0941f

@negin513 negin513 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the two remap paths and the index normalization carefully — the sort+searchsorted branch is correct (stable sort + right=True reproduces last-occurrence semantics; the clamp + equality check filters dropped points safely), the dense path is unchanged in complexity, and the tests genuinely force both algorithms. Nice.

One real regression in the empty-selection branch, one gap in the algorithm gate, and one comment that overstates the cost model — inline below.

Two broader notes. (1) The 28x / RSS numbers in the description were measured at 1e605e0, before the row-gather helper was removed; worth a rerun on head before they're cited. (2) Since #1894, the zarr reader path (_zarr_mesh_subsampled in datapipes/readers/mesh.py) compacts via torch.unique(return_inverse=True) and never calls slice_points, so this optimization only serves the .pmsh/.pdmsh memmap reader and external callers. Still worthwhile, but it argues for keeping the gate simple rather than tuning it further.

Comment thread physicsnemo/mesh/mesh.py Outdated
Comment thread physicsnemo/mesh/mesh.py Outdated
Comment thread physicsnemo/mesh/mesh.py Outdated
… for point clouds, table when most points are kept

Review follow-ups on NVIDIA#1966: the empty-selection branch returned the input
connectivity dtype (int32 stayed int32) while the other branches always gave
int64; a mesh without cells now skips the remap entirely; and the algorithm
gate also considers how many points are kept, since the search's sort scales
with the kept set, not with the connectivity. Comment on index normalization
no longer claims boolean masks are free.

Signed-off-by: Peter Sharpe <peterdsharpe@gmail.com>
@peterdsharpe

Copy link
Copy Markdown
Collaborator Author

^ fixes pushed up, benchmarks re-running

@peterdsharpe

Copy link
Copy Markdown
Collaborator Author

/ok to test f75eeea

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.

2 participants