Large clouds: memory budget, cost advisories, benchmarks¶
Phytograph holds an imported cloud in RAM as the source of truth (see Backend sidecar), so "how big a cloud can it handle" is a question about memory first and time second. This page documents the machinery that makes both visible and bounded, and the measurements it is built on. The phased plan toward arbitrarily large clouds (memory-mapped sessions, tiled processing, streaming import) builds on these pieces.
What a cloud costs¶
A CloudSession stores positions as float64 (24 B/pt), colors as three
uint16 (6 B/pt), intensity as uint16 (2 B/pt), timestamps as float64
(8 B/pt), and a one-byte delete mask. A scalar column is float32 (4 B/pt)
unless every value is a whole number. Then it is stored in the narrowest
integer type that holds its values: 1 B/pt for a class label, flag or small
index, 2 for row/column indices, 4 (uint32) only for a large id range.
Compact integer columns. Most columns on a processed cloud are whole
numbers: ground_class, wood_class, noise and manual classes,
tree_instance, is_miss, row_index/column_index (with a −1 sentinel,
so signed), target_index/target_count, and the LAS standard dims and
flags. A segmented TLS scan carrying eight of them used to spend 32 B/pt on
them. It now spends about 12 B/pt, and HAG is the only one that stays
float32.
The type is chosen from the column's content (_compact_column),
except that columns whose meaning is continuous (normals, the timestamp
display copy, height above ground) stay float32 even when one cloud happens
to hold only whole numbers in them. It is chosen at the places columns are born: import, every tool's
result column (_session_add_extra_column), computed fields, merge,
duplicate, synthetic scans, and project open (which also compacts the float32
columns of older projects). A column with any fraction or NaN stays float32.
It is storage only. Every reader already converts (np.rint, float64,
==), and the octree and every export write float32 extra dims exactly as
before, so no value changes anywhere downstream.
The one hazard is an in-place write that does not fit: numpy's setitem cast
would silently wrap 256 into 0 in a uint8 column. So the two in-place writers,
painting (label_region) and label undo, go through
_column_widen_for_locked, which widens the column first (an instance id of
70 000 moves a uint8 column to uint32). Widening only grows the range, so an
undo, which restores values the column held before, always fits.
memory_budget.bytes_per_point still charges 4 B per scalar column, a
conservative overestimate. Pinned by backend-api/tests/test_compact_columns.py.
A typical terrestrial LAS import is ~57 B/pt; a RIEGL position with its
thirteen attributes is ~87 B/pt. So:
| Points | Resident session | Peak during import |
|---|---|---|
| 10 M | 0.6–0.9 GB | ~0.7–1.0 GB |
| 30 M | 1.7–2.6 GB | ~2–3 GB |
| 100 M | 5.7–8.7 GB | ~6.5–10 GB |
memory_budget.bytes_per_point() and estimate_session_bytes() are the
single source of these numbers in code.
Import reads in chunks¶
_read_las_into_arrays used to reader.read() the whole LAS and then
np.stack(...).astype(float64) it, so the entire laspy record and the
float64 position copy were resident at once — a 2x transient. It now sizes
every column from the header and fills them from chunk_iterator
(_LAS_READ_CHUNK = 2 M rows), so the peak is the session plus one chunk.
Columns whose fate depends on their contents (constant standard dims and
all-zero intensity are pruned; gps_time is kept only when it varies) are
tracked per chunk in their native dtype and cast to float32 only if kept.
The same chunked read backs _file_miss_mask, which used to load a whole
LAS to get one byte per point. The PLY converter writes its LAS in blocks
straight from plyfile's memory map (binary PLY; an ASCII PLY is parsed into
RAM by plyfile itself), and the E57 converter converts and writes one scan
at a time instead of accumulating every scan and concatenating — the LAS
schema is decided from the scan headers' field lists and the offset placed
at the first scanner position, both known before a point is read. PCD has
its own streaming reader (_pcd_to_las): ASCII through pandas' C parser in
blocks, binary through a memory map, two passes (count/offset/intensity
range, then write), every single-valued field kept. Only binary_compressed
(LZF, one compressed block per column) still goes through open3d's
whole-file read.
Positions go straight to their destination. A converted format's LAS is
1 mm-quantized, so the session's positions come from the source. They used
to be gathered as a list of chunks, concatenated (two copies at the peak),
and then read back out of the LAS as well and discarded: three copies of the
largest column. Now each converter reports its exact kept count first and
writes into a _PositionSink. The sink is a RAM array, or, when the cloud is
large enough to be store-backed, a column of the session's store created
there and then, so a large cloud's positions are never a RAM copy. The LAS
read-back then skips positions (_read_las_into_arrays(positions=...)).
This also fixed a silent precision loss: PLY and PCD positions used to be
read back from the 1 mm LAS. PTX decodes each block straight into its
session's store the same way (_ptx_to_arrays(make_store=...)). E57 still
reads its positions back from its LAS.
Candidate dimensions are lazy. _read_las_into_arrays keeps a standard
LAS dimension (classification, scan angle, user data, …) only if it varies,
so it used to allocate a full-length array for each of ~10 candidates before
knowing. It now allocates one only when a chunk first differs from the
constant, back-filling what came before. A converter-written LAS has them
all constant, and so allocates none.
Measured on a 10 M-point x y z intensity ASCII file, conversion plus
read-back (tracemalloc peak of numpy/Python allocations):
| Peak | Resident after | Time | |
|---|---|---|---|
| before | 855 MiB (3.7× positions) | 539 MiB | 5.9 s |
| after | 516 MiB (2.3× positions) | 310 MiB | 4.7 s |
What remains of the peak is the finished columns plus one pandas chunk.
backend-api/tests/test_streaming_readers.py pins the precision, the
sink/store adoption for XYZ, PLY, PCD and PTX, and a bound on the XYZ
conversion's peak.
Undo history is deltas, not snapshots¶
Each committed delete used to push sess.deleted.copy() — a full (N,) bool
mask — onto deleted_history, so fifty erase clicks on a 100 M-point cloud
were 5 GB of undo history, more than the positions. It now records the
indices each step newly deleted and reset_edits replays them; when the
bounded stack (_MAX_DELETED_HISTORY) drops its oldest step, that step's
deletions fold into deleted_base, the floor the replay starts from.
Session caps that scale with it¶
Three limits used to be constants sitting next to budget-derived ones, which made them disagree by an order of magnitude across machines. All three keep their 16 GB values (the machine they were tuned on) and now scale from there:
| Limit | Derivation | 8 GB RAM | 16 GB | 64 GB |
|---|---|---|---|---|
_MAX_CLOUD_SESSIONS (count) |
budget / 1 GB nominal, clamped [4, 32] | 4 | 8 | 32 |
| spill directory cap (disk) | 8 x budget, clamped [16, 256] GiB | 32 GiB | 64 GiB | 256 GiB |
| DEM stream cutoff | 15 % of budget / 112 B per point | ~5.8 M pts | ~11 M | ~46 M |
_MAX_CLOUD_SESSIONS stays a plain int module attribute (the eviction path and
~30 tests set it directly) and is applied before the byte cap, so a count too
low for the machine evicted sessions the budget would have kept. The spill cap
matters most: overflowing it drops a session, and for a cloud edited since
import that is unrecoverable work, so a laptop wants a smaller cap than a
workstation for the same reason it wants a smaller budget. Pinned by
backend-api/tests/test_budget_derived_thresholds.py, which asserts the
scaling relationship rather than the numbers.
Store-backed sessions (memory-mapped columns)¶
Above _session_store_min_points() — 10 % of the memory budget divided by
the per-point cost, so ~15 M points on a 16 GB laptop and ~60 M on 64 GB;
PHYTOGRAPH_SESSION_STORE_MIN_POINTS pins it — a session's arrays live in a
session_store.SessionStore: one memory-mapped .npy per column under
<octree cache root>/.sessions/<pid>-<nonce>/<session_id>.store/. What
changes and what does not:
- Import writes the columns there directly.
_read_las_into_arrays(store=…)allocates each kept column in the store and fills it chunk by chunk, so a large cloud is disk-backed from the first chunk and never exists as a RAM copy. The renderer, the octree build and every tool see the sameCloudSessionfields as before; a memmap is an ndarray. - In-place edits persist by themselves.
deleted |= mask,positions -= shift, a label brush writing into an extras column — all go through the map. - New and replaced columns go straight into the store. Every column a
tool creates or replaces wholesale (a class, five normals, a height, a
scalar-field result, a label column, a widened label column, the normals
rotated by an alignment) goes through
_session_put_column_locked. On a store-backed session, that writes the column into the store under the slug's existing column, or a freshx<k>, and the session keeps the map. These used to stay RAM arrays until the next eviction; on the 45.7 M-point scan the five normals columns alone held ~0.9 GB. A replacement writes a new file and renames it over the old one, so a streaming reader holding the old map keeps a valid one. Anything else that is not already the store's own map (a merge's columns, a bake on a RAM session) is still written by_session_write_back_to_storeat the next eviction (checked by identity, not equality), which also updates the extras mapping. - Spill is a write-back plus a small pickle.
_spill_cloud_sessionwrites back, then pickles_session_detach_for_pickle(sess)— the session with every store-backed array stripped — beside the store. Restore unpickles that and_session_reattach_storehands the maps back. The live object is never mutated by a spill, so the mid-spill claim-back the spill design relies on stays safe. A session born with RAM arrays (a split child, a merge, a synthetic scan) gets its store at its first eviction. - Eviction now also fires on memory pressure.
_eviction_victims_lockedadds, after the TTL and count victims, the least recently used RAM-resident sessions while together they exceed_SESSION_RAM_FRACTION(50 %) of the budget. Store-backed columns count as nothing there: they are page cache the OS reclaims on its own, and evicting such a session costs one small pickle. - Delete removes the directory, whether the session was live or spilled; a run that dies leaves directories that the next launch's dead-pid reaping removes (they live under the per-process spill root).
Export streams from the session. A session exported to a LAS/LAZ file
goes through _export_session_to_las: the survivor indices are taken once
under the session lock, then each 2 M-row block is gathered under the lock
and written outside it, so the transient is one block and the lock is never
held across LAZ compression. The generic path (_read_points_and_extras
plus one laspy.LasData for everything, ~10 GB of transient at 100 M
points) remains for the base64 response and for text formats, which were
already chunked at the write. Same columns, same classification byte, same
point-format choice; intensity and RGB are written verbatim rather than
through the generic path's float32 round trip.
What is not yet chunked: compute tools still call positions[keep].copy()
through _read_points_and_extras, so a tool on a 100 M-point store still
materializes a full copy of the hits — the tiled runner (next section) is the
answer for neighborhood tools, and per-block reads for the point-local ones
(C2M, DEM pre-binning).
Split and extract children are born in a store¶
_session_subset_by_indices_locked builds every split, extract and crop
child. It used to gather the child's rows into RAM whatever its size.
Splitting the 45.7 M-point store-backed scan by ground class left the
39.3 M-point child holding 1.95 GB of RAM for good, next to a parent that
held almost none. A child over _session_should_use_store is now gathered
DEFAULT_CHUNK_ROWS at a time into its own <id>.store, the same way an
import is: every column is a map and extras / extras_order are recorded.
A failed gather deletes the directory. A child below the threshold stays in
RAM, as before. tests/test_session_store_backed.py checks that the values
match the parent's rows, and that the child deletes, bakes, exports and
removes its store like any store-backed cloud.
A bake re-homes the store¶
bake removes deleted rows for good. Only an explicit Permanently apply
deletions does that now: the renderer's background refresh queue calls
bake?compact=false, which rebuilds the octree from the survivors but keeps
every row under the mask, because LAD restores the deleted hits outside its
voxel grid from them (_deleted_hit_grid_masks). A compacting refresh had
undone a crop-to-grid LAD a few seconds after every crop. Keeping the mask
across rebuilds needs two things compaction had hidden: reset_edits needs
an undo floor (_commit_delete_history_locked sets deleted_base wherever
history is cleared), and the renderer's pendingDeletedCount must be
measured against the last installed build (pending_deleted_count, from
octree_point_count), not the cumulative deleted_count. Pinned by
tests/test_background_refresh_keeps_deletions.py and
test_lad.py::test_crop_survives_the_background_octree_refresh. On a
store-backed session the retained rows cost disk, not RAM.
Rows that leave a session for good are counted in unrestorable_hit_count, so
LAD can warn: the hits a compacting bake drops, and, for a child made by
split, extract, duplicate or merge, every parent hit it did not take (plus
what the parent had already lost). Backfill Misses resets the count, because
gap-filling against the remaining hits re-creates those pulses as misses.
Measured on the multi-return fixture: 2.4676 uncropped, 2.8613 after a crop
plus bake, 2.4723 once re-backfilled. Separately, backfilled_misses_moved
marks a buffer a transform moved. Its beam directions are not rotated, so LAD
warns from that flag even when every deletion was restored.
On a store-backed session a compacting bake used to
boolean-index every column, which pulled a full in-RAM copy of the survivors
back into the process and left the store recording the pre-bake point
count. The next eviction's write-back then refused the mismatch, the spill
failed, and the sweep dropped the session: a cropped 100 M-point cloud was
lost after 30 idle minutes. _compact_session_store_locked now gathers the
survivors block by block into a new <id>.g<N>.store directory, points the
session at its maps and unlinks the old directory. The old files are never
truncated in place, because a streaming reader may still hold a map between
its blocks. Session delete removes every generation. Pinned by
tests/test_bake_store_backed.py through the real crop, bake, evict and
restore API.
Tiled processing (the lidR engine)¶
backend-api/tiled.py runs a whole-cloud algorithm per XY tile plus a collar
of neighboring points, keeps only the tile's own results, and merges — what
lidR's LAScatalog, LAStools' lastile -buffer and PDAL's filters.splitter
do. Points are binned once by cell id (one argsort), so a tile's core and
collar are contiguous ranges of the sorted order rather than a pass over N.
The collar is the algorithm's own scale; too small a collar shows up as
seams, which tests/test_tiled.py checks separately within 0.5 m of tile
boundaries rather than letting the whole-cloud average hide it.
Tree segmentation tiles differently (tiled_trees.py), because a tree
crosses tile lines: each buffered tile is segmented whole, every tree is
anchored (its seed, or the centroid of its lowest 1 m), the one tile whose core
holds the anchor keeps it, and overlapping claims go to the tile where the
point sat deepest inside the buffer. Voxel sizes are resolved once from the
point spacing (no whole-plot coarsening), tiles run on the spawn pool inside
the seg worker, and trees_truncated reports kept trees whose points were only
seen near an interior buffer edge. Method and defaults:
Stem detection & tiling;
tests/test_tiled_trees.py checks the stitching against an oracle segmenter
and the real path through the worker.
Ground segmentation is the first tool on it. From
PHYTOGRAPH_GROUND_TILE_MIN_POINTS (default 4 M) up, segment_ground runs
per tile of about PHYTOGRAPH_GROUND_TILE_TARGET_POINTS (3 M) points with a
collar of 30 cloth resolutions (2–30 m). The CSF working set (two copies of
the points plus the cloth, ~60 B/pt) is then bounded by one tile instead of
the cloud, which is what keeps a 100 M-point run inside a 16 GB laptop's
budget. With Measure from the scan on, the ground tolerance is measured
once on an even stride sample and applied to every tile, so tiles cannot
disagree about where the ground is; the session endpoint reports the plan
(tiled: {tiles, tile_m, buffer_m, workers, …}).
Tiles run on every core. tiled.run_tiled_parallel fans the tiles out
to a multiprocessing spawn pool (never fork: the worker has open3d
loaded and the backend libhelios, and a forked copy of either crashes in
the post-fork window). Children memory-map the worker's staged input.npy
(PHYTOGRAPH_TILE_POINTS_NPY, set by seg_worker) and gather their own
tile by index, so a task pickle is a few strings plus one index array, and
only the core rows' results come back. tiled.worker_count picks
min(cores, tiles, what the memory budget allows at one tile plus ~400 MB
per child); PHYTOGRAPH_TILE_WORKERS pins it, 1 disables the pool. The
children inherit the worker's process group, so a cancel that killpgs the
worker reaps them. A pool is only ever opened inside the seg worker
(worker_count returns 1 without PHYTOGRAPH_SEG_WORKER): multiprocessing's
POSIX launcher forks before it execs, and the backend process has libhelios
(GLFW) and open3d loaded — a forked copy of that dies with SIGSEGV in the
post-fork window (the reason _SegProc uses posix_spawn), which leaves the
pool blocked on its start-up pipe. Seen as a hang in pytest, whose process
has main imported; the pool tests therefore drive tests/tile_pool_probe.py
in a fresh interpreter launched with posix_spawn, never subprocess.run. In the frozen bundle the children are the backend
binary itself: backend_wrapper.py calls multiprocessing.freeze_support()
before anything else so a pool child runs its task loop and exits, and the
seg-worker dispatch is guarded by __name__ == "__main__" so a child that
imports the wrapper as __mp_main__ cannot start a second segmentation.
Both are pinned at source level, and the pool's answer is pinned equal to
the sequential one for ground segmentation and both denoise criteria.
Outlier removal (denoise.py) tiles its two local criteria the same
way from PHYTOGRAPH_DENOISE_TILE_MIN_POINTS (4 M): radius outlier removal
with a collar of the radius, voxel-count with a collar of the voxel and the
grid anchored at one origin for every tile (a per-tile minimum would shift
the grid at every seam). Parameters are resolved once, on every k-th small
cell of a fine probe grid — spatially contiguous inside each cell, so the
nearest-neighbor spacing is the true one, and spread across the cloud so a
dense tile does not understate the sparse far field. SOR's threshold is a
mean over the whole cloud by definition and stays untiled.
A terrestrial scan defeats a uniform grid. The tile edge is sized from
the cloud's mean density, but a TLS scan's density falls as 1/r² from the
scanner. On a real 45.7 M-point scan (ExampleScan_01_misses.laz) one 11 m
normals cell held 35.3 M points (77 %) and one 61 m ground cell held 45.5 M
(99.5 %). That cell ran on a single pool child, whose peak was 14 GB. It also
ran on a single core, because each child gets cores / workers OpenMP
threads, so nine children sat idle for most of the run. So TilePlan takes
an opt-in max_tile_points:
- A cell over that count is binned once on a fine k × k sub-grid. k is as
fine as the collar allows: the sub-cell edge is at least 2× the collar,
capped at 250 so sub-cell ids stay in
uint16. - A quadtree then merges sub-cells back into blocks of at most that many points. A block is only allowed over the cap when it is a single sub-cell.
- Core membership comes from each point's own binning, so the blocks still partition the cloud exactly, far-edge clipping included.
Normals and denoise turn it on at 2× their tile target. Ground can't use it:
its 15 m cloth collar allows at most a 2 × 2 split of a 61 m cell. Tree
segmentation keeps the uniform grid, which its ownership rule depends on.
tests/test_tiled.py checks the partition and collar completeness, and
tests/test_normals_tiled.py checks equality with the untiled result on a
true 1/r² cloud. (That file's older _tls_density fixture draws radius as
the square root of a uniform, which gives a uniform areal density despite
its name.)
Three changes make the plan itself cheap:
- XY is stored in cell order, so a cell's points are one contiguous slice. Each of the dense cell's neighbors builds a neighborhood that contains the dense cell, and random-row gathers over it cost ~1.4 s per neighbor.
- Cell ids are sorted as
uint16where they fit, because numpy radix-sorts 16-bit keys: 0.45 s against 21.8 s for 36 M int64 keys. plan.gathered()yields tiles lazily and drops each split cell's index after its last block.run_tiled_parallelfeeds the pool from that generator, so only tiles in flight hold index arrays. The old task list held every tile's at once, about 3× the cloud in int64 once collars are counted.
The normals worker also memory-maps its input instead of loading it, since it only reads XY for the plan and per-tile gathers.
Every backend process runs with MallocLargeCache=0. macOS libmalloc
keeps recently freed large blocks cached in the process. footprint(1)
reports them as MALLOC_LARGE, dirty, with 0 bytes reclaimable, so they count
against the machine until the process reuses them. malloc_zone_pressure_relief
releases none of it. Measured:
- A freed 1.1 GB array stayed in the footprint.
- Each of a normals pool's ten children sat on ~1 GB it no longer used.
- The backend held 1.1–2.8 GB after a 45.7 M-point import, and 5.6 GB after the split below, with ~360 MB of live arrays.
spawnChild (src/main/backend.ts) and scripts/dev.mjs set the variable
for the backend, unless the shell already set it. _run_killable_admitted
sets it again for a seg worker started any other way, and the tile pool
inherits it. The cost, from alternating runs: the octree LAS write goes from
4.2 to 4.8 s at 45.7 M points, and import is ~4 % slower. The variable is
ignored off macOS. src/main/memoryBudgetEnv.test.ts pins both spawn paths.
On the 45.7 M-point scan, Compute Normals went from 182 s and a 20.6 GB total peak (worker plus pool) to 87 s and 6.6 GB. Ground segmentation went from 12.1 to 6.6 GB with the allocator setting below, and then to 4.8 GB, at ~15 % more time, when CSF stopped seeing every point (next).
CSF sees one point per cloth particle (backend-api/csf_reduced.py).
Tiling can't shrink ground's dense cell, so this shrinks CSF's input instead.
In the Apache-2.0 cloth-simulation-filter source, the cloth depends on the
points through only two things:
- the bounding box, which fixes the cloth grid (
CSF::do_cloth); - for each particle, the height of the first point at the smallest squared
XY distance to it (
RasterTerrian). Slope smoothing reads only those heights.
Every other point matters only to the final labeling (c2cdist), a
bilinear interpolation of the settled cloth. So segment_ground hands CSF
the six bounding-box extremes plus every point within a relative 1e-9 of its
particle's minimum distance (the slack absorbs a fused multiply-add in the
compiled library; the kept points keep their order, so CSF's first-wins
tie-break is unchanged). It takes the cloth at full precision from
do_cloth_export, and labels every point in 2 M-row chunks with
c2cdist's arithmetic.
On the 45.7 M-point scan, CSF saw 11,358 points at a 0.5 m cloth (41,300 at
0.2 m), and CSF's own peak fell from 1.3 GB to 0.29 GB. The labels matched
plain CSF on the whole cloud at 0.5 m. At 0.2 m, 4 of 45.7 M differed, and on
five crops at 0.1/0.2 m, 2 of ~78 M. Every one sat exactly on the threshold
(|h| − 0.1 = 0 to 12 decimals). There, CSF's strict < is decided by
rounding, and it happens because settled particles take a point's own height
and elevations sit on a 1 mm grid.
One CSF quirk is reproduced on purpose. A point on the far x edge of a
grid-aligned cloud reads col0 + 1 == width, which in CSF's
row * width + col indexing is the next row's first particle. On the last
row it is past the array (undefined in CSF), so the index is clamped there.
tests/test_csf_reduced.py pins label equality with plain CSF on 1/r²
plots, including slope smoothing, duplicate points and that edge.
This does not make ground faster. Its time is the cloth simulation, which scales with particles, not points (683 s at a 0.2 m cloth over the whole 45.7 M scan, either way). The ground worker also memory-maps its input now, as normals does.
Point-local tools stream instead. _iter_session_hit_positions yields a
session's surviving hits in 2 M-row blocks (deletions, misses, world shift
and translation applied) under a per-block lock; cloud-to-mesh distance
consumes it and keeps only the float32 distance per point.
The DEM pre-bin is bounded and no longer sort-bound. _compute_dem
reduces the cloud to one z per grid cell (a low percentile, the robust
near-minimum) before anything is interpolated, so the grid work is bounded
by _DEM_MAX_CELLS however many points arrive. The reduction itself used
to cost ~48 B/pt of int64 temporaries plus an np.lexsort over two keys —
measured 32 s at 20 M points, i.e. the DEM of a 100 M-point cloud would
have spent nearly three minutes sorting. Cell ids are now int32 computed in
2 M-row blocks (_dem_cell_ids, also used by the density layers), and the
cell-then-z order comes from ONE composite float64 key (_dem_cell_z_order:
cell * span + (z - zmin), with span wider than the z range by a margin
far above the key's float64 spacing so cells never interleave), which
np.argsort orders in 2.6 s at 20 M points with 412 MB of transient
against 777 MB before. The result is identical to lexsort's — pinned by a
test with exact ties and cells up to the cap — to within the key's spacing,
about 1e-9 of the z range, which decides only which of two z values closer
than that is picked. A session DEM whose whole-cloud working set would exceed
_DEM_STREAM_BUDGET_FRACTION (15 %) of the memory budget — i.e. that fraction
divided by the measured _DEM_BYTES_PER_POINT, so ~11 M points on a 16 GB
laptop and ~46 M on 64 GB, with PHYTOGRAPH_DEM_STREAM_MIN_POINTS still pinning
an exact count — no longer materializes the hits, the ground subset and the
first-return subset (~24 B/pt each). This was a flat 5 M points, which on a
64 GB workstation took the slower streamed path for a DEM that fit in RAM twenty
times over, and on an 8 GB laptop took it too late.
_do_session_dem_streamed reads the session in 2 M-row blocks: one pass for
subset counts and extents, one for per-row counts and the density,
intensity and footprint grids, and one that appends each gridded point's
cell id and z to band files cut at _DEM_BAND_POINTS (4 M). Each band is
sorted alone, and its per-cell percentile is the one the whole-cloud sort
would pick, because a band is a contiguous run of grid rows and cells never
span rows. Height above ground is sampled per block from one interpolator.
Everything after the per-cell representatives is shared with the in-memory
path (_dem_surface_from_reps, _dem_layers_result, _chm_from_surfaces),
and tests/test_dem_streamed.py requires the two to agree field by field
for DTM, DSM and CHM, with and without void filling and height above ground.
A cloud with no usable ground column and CSF requested still takes the
in-memory path, since CSF needs every point at once.
Measured on the synthetic terrestrial clouds (DTM, 0.5 m cells, TIN, ground column present), one process per run. Peak is the resident set above the loaded session. For the store-backed 100 M cloud it includes the store pages the reads touch, which the OS can reclaim.
| Points | In-memory | Streamed |
|---|---|---|
| 10 M | 4.1 s, +0.37 GB | 3.8 s, +0.35 GB |
| 100 M | 18.3 s, +7.76 GB | 21.0 s, +1.87 GB |
At 10 M the copies are small enough that the two paths cost the same; at 100 M streaming trades about 15 % of wall time for a quarter of the memory, which is the difference between fitting and paging on the 16 GB baseline.
Remaining candidates, each with its natural collar: normals (the search
radius). Wood/leaf is left global on purpose. Its per-point PCA
features are the only tileable stage (collar = the largest neighborhood
scale); the GMM threshold, the skeleton segments and the cylinder gate that
follow are whole-tree operations on the voxel-decimated cloud
(PHYTOGRAPH_WOOD_MAX_POINTS, 1.5 M), so tiling the features alone would
not raise the cap — it stays a global-decimate tool with its cost stated.
The octree LAS write no longer holds the session lock¶
_session_rebuild used to hold the global session lock across the whole
_session_to_las encode — "the longest lock hold in the process", ~1 s per
10 M points with extras, during which every other session request stalled.
_session_to_las(block_lock=…) now snapshots the survivor set and the array
references once under the lock and takes it again only per 2 M-row block
for the gather; the laspy encode runs unlocked. Capturing references means a
bake that replaces positions mid-write cannot desynchronize the block
indices from the arrays they index; an in-place edit landing between blocks
can differ between blocks, which the next rebuild reconciles and the renderer
masks in the meantime. Pinned by racing a slowed write against a request on
another session.
A bake and the background display refresh (bake?compact=false) build their
octree through _build_octree_from_las directly and now write the same way.
Because the write is no longer one critical section, a non-compacting refresh
decides whether an edit raced it from the session's edit generation
(octree_stale_gen) as well as the mask: a delete and its undo landing between
blocks leave the mask equal while the octree is missing points.
What else left the session lock¶
_cloud_session_lock is one lock for every cloud, so a long hold on one cloud
stalls strokes, deletes, previews and exports on all of them.
- Display stats. The class lists and robust colorbar domains attached to
every rebuild and bake (
_session_display_stats: a gather of every column, anp.uniqueper integer column, percentiles per scalar) are measured on references captured under the lock, not under it. delete_region's selection. The projection and region test run outside the lock on a capturedpositionsreference, aslabel_region's do, and are redone under it only if an edit landed in between.- Erase stamps.
_squares_union_maskculls to the stamps' union bounding box before the per-stamp test, so an erase costs one pass over the cloud plus stamps × candidates instead of stamps × N.
Pinned by backend-api/tests/test_session_lock_holds.py.
Filter commits rebuild in the background¶
Remove points on a session cloud used to await the reconversion inline:
session_filter with rebuild: true, i.e. the point deletion (milliseconds)
plus a full PotreeConverter run (a minute on a large plot) before the panel
closed. It now commits with rebuild: false and hands the rebuild to the
same octreeRefreshQueue that crop and erase use. Until the swap, the
committed predicate stays on the cloud's edit state as committedFilters
and is drawn through the very per-tile mask that showed the live preview,
so nothing the user can see changes at the moment of commit; the scan row's
count drops immediately from the backend's cumulative deleted_count. Every
compute and export path reads the session arrays, so the cloud is
filtered the moment the request returns. The refresh runner clears the
mask in the same state update that installs the rebuilt octree. A second
filter on a cloud whose rebuild is still queued waits for it to settle
first, because the rebuilt octree is what the next mask must be drawn over.
The ground and wood/leaf splits follow the same rule for their parent. The
segmentation defers the parent's octree (defer_octree) and now marks it
stale, and the split builds only the children (rebuild_parent: false). The
renderer then queues the parent's rebuild on the refresh queue; the parent is
hidden once the split lands, so nothing waits on it. Measured at 10 M points,
the children alone take 7.7 s against 11.3 s with the parent in the same
build pool, where the three converter runs compete for the same cores. The
stale mark is load-bearing: without it the queued bake would see no deletions,
take its fast path and hand back the pre-column octree as current.
PyHelios streaming and bulk APIs (helios-core v1.3.86)¶
PyHelios v0.1.32 exposes the lidar plugin's large-cloud APIs. The backend uses four of them; each streaming path has a default-on environment switch so a test can compare it with the retained path on the same input.
Synthetic scans stream their chunks (PHYTOGRAPH_SYNTH_SCAN_STREAM).
setSyntheticScanHitSink fires after each traced chunk lands in the native
cloud; the handler reads the chunk through the bulk readers and releases it
with deleteHitPoints(first, count). Chunks are always the tail of the cloud
and earlier ones are already gone, so the bulk readers return exactly the
chunk, and the native cloud never holds more than one chunk rather than every
return of the scan. Chunk size follows synthetic_scan_memory_budget_mb.
TestStreamedScan in tests/test_lidar_scan.py forces a multi-chunk,
two-scanner trace with misses and requires the streamed result to equal the
retained one exactly.
Leaf-area triangulation keeps no mesh (PHYTOGRAPH_LAD_TRI_STREAM). The
inversion needs only the per-voxel leaf-angle sums, which the cloud keeps
either way, and nothing in _do_lad_computation reads the triangles. So
setTriangulationSink counts each scan's triangles and drops them. The
triangle count guard reads the sink's tally, because a streamed cloud reports
zero. TestTriangulationSink pins exact per-cell equality with the retained
mesh and that the streamed cloud holds no triangles. Helios triangulation
itself still retains its mesh, since the mesh is what that endpoint returns.
Hits are ingested in bulk at float64. Leaf area, scan export, miss
backfill and Helios triangulation call addHitPointsBulk through
_add_hits_bulk, after one reserveHitPoints for the whole cloud. addHitPointsWithData cast
coordinates to float32 and grew the hit array by reallocation, holding old and
new buffers at once. A NaN value now leaves that label absent on that hit,
where the old path stored a NaN the C++ read as present.
Triangulation was the last to move. It wrote each scan to a text file with
np.savetxt and had Helios parse it back, and with a grid box it first re-read
that file line by line in Python to crop it. At 10 M points the write and parse
measured 15 s against 11 s for the triangulation itself, and the crop another
12 s and 3.4 GB. _load_triangulation_scan now hands a session, a decoded
binary file or inline points to Helios in RAM (0.07 s) and the crop is an array
mask. Only an ASCII scan file still goes through Helios's own loader, because
its format string may name columns only that loader interprets.
TestInMemoryIngest pins that the two paths build the same mesh.
Large grids invert a block at a time. calculateLeafAreaBlock sizes the
per-voxel accumulators, and their per-thread copies, to a block of the
lattice. Tiling is not free: every block call re-walks every beam of every
scan. _lad_block_cells_limit therefore tiles only when the estimated scratch
(_LAD_SCRATCH_BYTES_PER_CELL_THREAD per voxel per thread) exceeds a quarter
of the memory budget, and _lad_lattice_blocks uses the fewest blocks that
fit, in whole voxel columns. Each block reports progress and honors cancel.
Terrain-following grids are not a regular lattice and keep the single call;
a large grid Helios does not recognize as a lattice is inverted whole, with a
warning. Hidden-return inference is per scan and ignores the block, so the
cropped-return statistics after the last block are the whole-grid figures.
PHYTOGRAPH_LAD_BLOCK_CELLS pins the block size; tests/test_lad_blocks.py
forces single-voxel blocks on both the triangulated and supplied-G(theta)
paths and requires exactly the whole-grid result.
Per-scan column readers are not used yet: every place that reads a native cloud back either holds a single scan or needs all of it.
The memory budget¶
backend-api/memory_budget.py measures the machine (via psutil, with an
os.sysconf fallback) and derives a budget: PHYTOGRAPH_MEMORY_BUDGET_BYTES
if pinned, otherwise PHYTOGRAPH_MEMORY_BUDGET_FRACTION (default 0.5) of
physical RAM. A fraction rather than a constant so the same build scales from
a 16 GB laptop (~8 GB budget) to a 64 GB workstation (~32 GB) without a
setting — and the user can still pin it: Settings → Performance → Memory
budget (MB) is passed to the sidecar as PHYTOGRAPH_MEMORY_BUDGET_BYTES
at spawn (memoryBudgetEnv() in src/main/backend.ts, pinned by
memoryBudgetEnv.test.ts), so it takes effect on the next backend start.
Every large-cloud threshold in the backend derives from this number.
The renderer's counterpart is Settings → Performance → Display point
budget (million points): potree's scene-wide point budget, the number of
octree points kept resident and drawn per frame whatever the cloud's size
(DEFAULT_POINT_BUDGET = 2 M, ~24 MB of positions on the GPU). It is
stored in millions (displayPointBudgetM), resolved and clamped by
resolveDisplayPointBudget (250 k – 30 M), re-read on every settings-dialog
close like the marker scale, and only ever lowered further by the crop
preview's overdraw guard. Rendering cost is O(budget), never O(N), so this
is the one knob that trades detail for frame rate on a 100 M-point plot.
GET /health reports the budget, the backend's resident set, and what is
currently admitted against the budget; the slow-request log line
([slow] POST /api/... took 12.3s, rss 1.2 GB -> 4.8 GB of a 16.0 GB budget)
says what a slow request cost. Settings → Performance shows what the budget
resolved to (getMemoryBudget() reads /health when the dialog opens):
Auto: using 8 GB of 16 GB detected, or Using 6 GB (set here) when pinned.
Without it a blank field gave the user no way to tell half of 64 GB from the
4 GB unmeasurable fallback, which is also why the readout says when psutil
could not measure RAM at all.
The synthetic-scan budget is reconciled against it¶
Settings → Performance → Synthetic scan memory budget (MB) is Helios's own
knob (setSyntheticScanMemoryBudget, 4 GiB CPU / 8 GiB GPU by default) and was
wholly independent of the process budget, so the two adjacent, near-identically
labeled fields could contradict each other in silence: a 2 GB process budget
left the ray trace on its 4 GiB default, and a 32 GB scan budget on an 8 GB
machine was accepted without comment. _synthetic_scan_budget_bytes now clamps
an explicit request to the process budget and, when nothing is requested,
overrides Helios's default only if the budget is the tighter of the two — so a
machine with plenty of RAM behaves exactly as before. Pinned by
backend-api/tests/test_synthetic_scan_budget.py.
The budget vs. what is free right now¶
budget_bytes() is a property of the MACHINE and deliberately does not move
with free memory: it decides a session's on-disk-vs-in-RAM layout at import
(_session_store_min_points()), and a value that drifted would make the same
cloud spill or not depending on when it happened to be imported.
Admission is the opposite — a decision about now — so it uses
admission_budget_bytes(): the budget capped at _AVAILABLE_HEADROOM (70 %) of
what the OS reports available, floored at _MIN_ADMISSION_BYTES (1 GiB) so a
machine under pressure still makes progress one job at a time. A pinned
budget is honored as-is (the user named a number; quietly admitting less would
make the setting a lie), and unmeasurable availability falls back to the plain
budget. This is the laptop case: 16 GB physical is an 8 GB budget, but with
2.4 GB actually free only ~1.7 GB of concurrent work is admitted instead of
8 GB of page-thrashing. available_bytes() was measured and reported for a long
time before anything acted on it.
Admission control¶
Heavy paths declare their working set to _ADMISSION.admit(bytes, label)
before allocating it: the import read (the session's columns plus one
chunk), the killable segmentation workers (parent copy + worker copy +
labels), export (a copy of every surviving column), every PotreeConverter
run (72 B/pt, measured), and the session DEM (_DEM_BYTES_PER_POINT =
112 B/pt: the hits plus the ground and first-return subsets, measured at
10 M points).
A job waits until it fits beside what is already running; a job larger than
the whole budget is admitted alone and logged rather than refused. The
budget is advisory for a lone job and a hard cap only on concurrency, because
paging is recoverable and a refused export is not. Refusing or prompting
happens before the work is committed to, via the cost advisory.
Two more families were outside the gate until recently, which is worse than it
sounds: Admission._acquire admits freely whenever nothing is in flight, so
unadmitted work is arithmetically invisible — it neither waits for a running
job nor makes one wait for it.
- Session mutations —
merge(the single largest allocation the backend can make: N sessions' survivor slices and the concatenated output are live at once),transform(a float64 copy of positions and beam origins, charged against the FULL count since deleted rows keep their coordinates for undo),splitandextract(survivor slice plus the child's columns). Sized by_session_mutation_bytes, which reads the columns a session actually carries rather than a flat per-point figure, so a bare xyz cloud is not charged for color, intensity, timestamps and beam origins it does not have. - Registration —
/api/c2c/icp-register,/api/c2m/icp-registerand/api/c2c/global-register, via_registration_bytes(both clouds, Open3D's own copies and its KD-trees,_REGISTRATION_COPIES= 4).c2m/distanceis deliberately not admitted: it streams the cloud in blocks and holds only 4 B/pt of distances plus the mesh scene, so it is already bounded.
Admission is always acquired OUTSIDE _cloud_session_lock, never under it.
_acquire sleeps on its Condition while it waits, and holding the global
session lock across that sleep would stall every unrelated session request
behind one queued operation — and deadlock against anything that admits while
holding it. test_admission_coverage.py pins the ordering (by lock depth at
admit time, since a merge takes and releases the lock several times) as well as
the presence.
Cost advisories (the 409 prompt)¶
Ground segmentation estimates its wall time and transient memory before
spawning anything, from measured throughputs (_RATE_* constants next to
_cost_advisory in main.py, deliberately about half the measured rate so
the estimate errs long):
- cloth filter: worker start (~4.5 s) + staging + CSF at ~10 M pts/s + the cloth simulation at ~50 M node-iterations/s (nodes = (extent / cloth)², 500 iterations);
- octree rebuild: LAS write at ~10 M pts/s + PotreeConverter at the rate of the sampling method it will be given (below); a run that will split into ground/plant children counts the parent's points twice.
Past PHYTOGRAPH_COST_WARNING_SECONDS (default 90) — or when the transient
working set exceeds the room actually left — the endpoint answers 409
with a structured cost_warning (message, estimated_seconds,
estimated_bytes, budget_bytes, headroom_bytes, admitted_bytes,
over_time, over_memory). The renderer's
existing CostWarningError path (shared with TreeIso) turns that into an
amber advisory and a Segment Anyway button, which re-sends with
acknowledge_cost: true. A cloth resolution that would build more than
25 M cloth nodes is refused outright (400) with the coarsest resolution that
fits, because that is a hang, not a slowdown, and independent of point count.
What the memory arm is judged against¶
bytes_needed > budget_bytes() was the wrong comparison twice over, and both
misses were in the direction that hurts:
- work already in flight. A 3 GB run under an 8 GB budget did not warn even
with 6 GB of exports admitted, because the test never consulted
_ADMISSION. It would then simply block inadmit()— correct, but the user had committed to a wait nobody mentioned. - memory the rest of the machine has taken. Admission derates the budget by
what the OS reports free (
admission_budget_bytes), so an advisory using the nominal budget contradicts the gate it is warning about.
The arm now compares against admission_budget_bytes() - admitted_bytes(), and
the message names the other work ("more than the 2.0 GB free right now, 6.0 GB
is already in use by another operation") rather than quoting a budget the user
cannot reconcile with the numbers in front of them. budget_bytes stays in the
body for the renderer's existing field.
TreeIso's prompt gained a memory arm at the same time. Its node count is a good
TIME signal and a poor memory one — decimation bounds the nodes, but the worker
still stages and holds the FULL cloud, so a 40 M-point cloud that decimates to
500 k voxels is ~2.1 GB of resident work while reporting nothing unusual. Both
the prompt and the admission now size that from one shared
_killable_worker_bytes, so the number the user is shown is the number the gate
will use.
Octree LOD sampling policy¶
PotreeConverter builds each inner node's level-of-detail sample either with
poisson (blue-noise; its default) or random. Measured on a 10 M-point
cloud on an M-series laptop:
| Stage | Time |
|---|---|
| LAS write (laspy, chunked) | 0.4 s |
| sha1 of the LAS | 0.2 s |
PotreeConverter -m poisson |
32.2 s (0.31 M pts/s) |
PotreeConverter -m random |
5.1 s (1.97 M pts/s; 6.1 M pts/s on the 100 M cloud, where start-up no longer dominates) |
The converter is the largest cost of every import, bake, filter, split and
segmentation on a large cloud, and a ground segmentation with split
reconverts about 2N points. So the backend passes -m random from
2 M points up (_POTREE_RANDOM_SAMPLING_MIN_POINTS) and -m poisson below,
where the user cannot feel the difference. Random sampling is what
Entwine/untwine (COPC) and QGIS use; the full-resolution leaves are identical
either way and only the coarse LOD nodes differ. Pin a method with
PHYTOGRAPH_POTREE_SAMPLING=poisson|random, or move the knee with
PHYTOGRAPH_POTREE_RANDOM_SAMPLING_MIN_POINTS.
The cloth filter itself is not the wall. Measured at 10 M points: CSF 0.27 s, copying its result out of the SWIG vector 0.30 s, converting labels to JSON 0.04 s, staging the worker's input 0.08 s. What made "ground segmentation above ~20 M points" slow was the three Poisson reconverts that followed it.
Measured so far (10 M points, M-series laptop, 10 cores)¶
Synthetic terrestrial cloud from tools/make_big_cloud.py (10 % misses),
through the real API via tests/bench/. Peak = resident set of the backend
plus its children (worker, PotreeConverter) above the idle baseline.
| Stage | Before (Poisson LOD, whole-file read) | After (random LOD from 2 M, chunked read) |
|---|---|---|
| import | 14.8 s, +2.9 GB | 9.7 s, +2.9 GB |
| ground segmentation (no rebuild) | 2.4 s, +2.5 GB | 2.6 s, +2.5 GB |
| split into ground + plant (3 rebuilds) | 16.5 s, +5.7 GB | 12.5 s, +4.1 GB |
| delete region + rebuild | 11.0 s, +4.1 GB | 6.0 s, +3.1 GB |
| export LAZ | 2.2 s | 2.3 s |
LAS read alone (_read_las_into_arrays) |
0.22 s, 1138 MB peak | 0.43 s, 905 MB peak |
| DTM from the ground column (0.5 m cells, TIN, no rebuild) | — | 3.5 s, +1.7 GB |
At 30 M points, store-backed from import, tiled ground segmentation and the streamed export (one run, same machine):
| Stage | Time | Peak over baseline |
|---|---|---|
| import (store-backed) | 25 s | +7.7 GB |
| ground segmentation (tiled, no rebuild) | 8 s | +8.4 GB |
| split into ground + plant (3 rebuilds) | 30 s | +8.6 GB |
| delete region + rebuild | 16 s | +7.2 GB |
| export LAZ (streamed) | 5 s | +4.0 GB |
At 100 M points (same machine, 32 GB; store-backed, tiled ground on a spawn pool, streamed export; the whole workflow in 4.4 min):
| Stage | Time | Peak over baseline |
|---|---|---|
| import (store-backed) | 65 s | +13.3 GB |
| ground segmentation (tiled, pooled, no rebuild) | 26 s | +12.7 GB |
| split into ground + plant (3 rebuilds) | 84 s | +14.1 GB |
| delete region + rebuild | 48 s | +9.0 GB |
| export LAZ (streamed) | 15 s | +9.8 GB |
PotreeConverter alone, measured with /usr/bin/time -l on the 100 M-point
LAS (random sampling): 16 s and a 7.06 GB peak resident set, about 70 B
per point, in its own process. That is the largest single allocation any
edit makes on a large cloud, and a ground segmentation with split launches
three of them (parent plus two children). Every build is therefore admitted
against the memory budget at _POTREE_BYTES_PER_POINT (72) per point, which
serializes concurrent builds on a machine that cannot hold them side by side
— on a 16 GB laptop (8 GB budget) the three converts of a 100 M split run
one after another instead of stacking to 14 GB.
Read those peaks with care. They are resident-set sizes, so they include the session's memory-mapped pages. Those pages are file-backed and the OS can drop them under pressure. They also leave out memory the OS has compressed, which is real. The bench now also records the macOS physical footprint of the backend and of its children, separately. Footprint counts anonymous and compressed memory and never memmapped pages, which makes it the number to judge pressure by.
On a real 45.7 M-point TLS scan, footprint above baseline, with all the fixes above and the app's own settings:
| Stage | Time | Backend peak | Workers peak | Backend after |
|---|---|---|---|---|
| import (store-backed) | 30 s | 1.6 GB | 2.0 GB (converter) | 0.15 GB |
| ground segmentation | 15 s | 1.9 GB | 6.6 GB | 0.2 GB |
| compute normals | 95 s | 3.9 GB | 6.7 GB | 1.0 GB |
| split into ground + plant | 50 s | 4.0 GB | 3.7 GB | 1.5 GB |
| delete region + rebuild | 21 s | 3.3 GB | 2.0 GB | 1.6 GB |
Before these fixes the workers peaked at 13.0 GB (ground) and 18.5 GB (normals), normals took 189 s, and the backend kept 3.6 GB after import and 4.2 GB after the split. What the backend still keeps after the split is mostly the five normals columns added to the stored parent, ~0.9 GB, which at the time stayed in RAM until a spill. They now go straight into the store (above). The rest is the 6.3 M-point ground child, which is under the store threshold.
Benchmark harness¶
backend-api/tools/make_big_cloud.py writes a synthetic terrestrial-style
LAS at any size in chunks: 1/r² areal density from a central scanner, a
ground plane with mm noise, ellipsoidal tree crowns, optional sky/miss
returns projected 1 km out, plus intensity, RGB, gps_time, a ground_truth
column and a target_index/target_count pair.
backend-api/tests/bench/test_large_cloud_bench.py (gated on
PHYTO_BENCH=1) drives import → ground segmentation → DTM → split →
delete + rebuild → LAZ export through the real HTTP API and records wall time and
peak resident memory of the backend and its children per stage, plus the
agreement of the CSF result with the generator's ground truth. Each stage
also records:
- the macOS physical footprint, split into the backend's peak, the workers' peak, and the backend's footprint left once the stage ends;
- its octree share: the session → LAS write, the whole cache build, and PotreeConverter alone, with the points converted. These are summed across threads, so a parallel split can exceed its wall time.
Results land in perf/bench-large-<N>M-<timestamp>.json.
PHYTO_BENCH_LAS=/path/to.laz runs the same workflow on a real file instead
of a generated one.
cd backend-api
PHYTO_BENCH=1 PHYTO_BENCH_POINTS=10e6,30e6 venv/bin/python -m pytest tests/bench -s --no-cov
Compare runs on the same machine and the same point counts; the generated
LAS is cached under tmp/bench/ so only the pipeline is re-measured.