Choosing a Library for Streaming Versus In-Memory Work
The pipeline ran fine for a year and then a 900 million point survey arrived and the process was killed. Nothing changed in the code. What changed is that one stage in the chain cannot stream, and until that survey nothing had been large enough for it to matter.
Streaming is the property that decides whether a point-cloud job runs on a workstation or needs a machine nobody has. It is also poorly understood, because it is not a property of a library — it is a property of each individual operation, and one non-streaming stage anywhere in a chain forces the whole cloud into memory.
This page covers which operations can stream and which fundamentally cannot, how to check, and how to write the chunked alternative correctly when streaming is unavailable. It extends the library comparison in PDAL vs laspy for point cloud automation.
What makes an operation streamable
An operation can stream if its output for a given point depends only on that point and on a bounded amount of preceding context. Assigning a classification from a range test streams: each point’s answer depends on nothing else. Reprojecting streams. Writing streams.
An operation cannot stream if it needs to see points that may arrive later, or all of them. A statistical outlier filter needs the distribution of the whole cloud before it can judge any point. Ground classification needs a neighbourhood that extends in every direction. Sorting, obviously, needs everything.
The distinction has a practical consequence that is easy to miss: a chain streams only if every stage in it does. A pipeline of four streaming stages and one blocking stage has the memory profile of the blocking stage, and the streaming ones contribute nothing beyond a false sense of safety.
Figure 1 — Why “the pipeline streams” is not a property a pipeline has unless every stage does.
Minimal reproducible solution
PDAL will tell you whether a pipeline streams, which turns a guess into a check.
import json
import subprocess
def pipeline_streams(pipeline: dict) -> dict:
"""Ask PDAL whether this pipeline can run in streaming mode.
`--stream` fails loudly on a pipeline that cannot stream rather than
silently falling back, which is exactly the behaviour you want in CI: a
change that breaks streaming should break the build, not production.
"""
proc = subprocess.run(
["pdal", "pipeline", "--stdin", "--stream", "--dryrun"],
input=json.dumps(pipeline), text=True, capture_output=True)
ok = proc.returncode == 0
return {"streams": ok,
"message": (proc.stderr or "").strip()[:300] if not ok else ""}
NON_STREAMING = {
"filters.outlier", # needs the whole distribution
"filters.smrf", # neighbourhood in every direction
"filters.pmf",
"filters.hag_nn", # nearest ground neighbour, unbounded search
"filters.covariancefeatures",
"filters.sort",
"filters.voxelcenternearestneighbor",
}
def explain_blocking(pipeline: dict) -> list[str]:
"""Name the stages that force the whole cloud into memory."""
return [s["type"] for s in pipeline["pipeline"]
if isinstance(s, dict) and s.get("type") in NON_STREAMING]
Keeping an explicit list alongside the runtime check is worth it because the check requires PDAL to be installed and the list can be consulted at design time — when the pipeline is being written and the decision is cheap.
When streaming is unavailable: tiling, done correctly
For a blocking operation on a cloud that does not fit, the answer is tiling with a halo. The halo is the part that goes wrong, and it goes wrong in a way that produces a plausible result with a regular grid of artefacts.
import json
import subprocess
from pathlib import Path
def tiled_blocking_op(src: str, out_dir: str, stage: dict,
*, tile: float = 500.0, halo: float = 0.0) -> None:
"""Run a non-streaming stage tile by tile, with a halo, then trim it.
The halo must exceed the stage's own spatial reach — the SMRF window, the
outlier neighbourhood radius, the covariance knn distance. Set it below
that and every tile edge is computed against a truncated neighbourhood,
producing a grid of artefacts at exactly the tile spacing.
"""
reach = {"filters.smrf": stage.get("window", 18.0),
"filters.outlier": stage.get("radius", 1.0) * 3,
"filters.covariancefeatures": 5.0}.get(stage["type"], 20.0)
if halo <= reach:
raise ValueError(
f"halo {halo} m does not exceed the stage's reach of {reach} m — "
"expect seams at the tile spacing")
Path(out_dir).mkdir(parents=True, exist_ok=True)
pipeline = {"pipeline": [
src,
{"type": "filters.splitter", "length": tile, "buffer": halo},
stage,
{"type": "writers.las", "filename": f"{out_dir}/tile_#.laz",
"compression": "laszip", "forward": "all"},
]}
subprocess.run(["pdal", "pipeline", "--stdin"],
input=json.dumps(pipeline), text=True, check=True)
Deriving the required halo from the stage’s own parameters, rather than accepting one from the caller, is what makes this safe. A halo chosen by hand is a halo that will be wrong the first time somebody changes the window size.
The laspy equivalent, and what it costs
Where the operation is not a PDAL stage, the same structure has to be written by hand. It is not difficult, but it is more code than it appears, and the halo bookkeeping is the whole of it.
import numpy as np
import laspy
def chunked_statistic(path: str, chunk_points: int = 5_000_000):
"""Stream a per-point statistic that needs no neighbourhood.
This is the easy case: each chunk is independent, memory is bounded by
chunk size, and there is no halo to manage. Anything needing neighbours
requires spatial tiling instead of sequential chunking, because a
sequential chunk is not spatially compact.
"""
total = 0
running_sum = 0.0
with laspy.open(path) as fh:
for points in fh.chunk_iterator(chunk_points):
z = np.asarray(points.z)
running_sum += float(z.sum())
total += z.size
return {"mean_z": running_sum / total, "points": total}
The comment marks the distinction that matters most. chunk_iterator yields points in file order, which for most LAS files is roughly acquisition order — not spatial order. A neighbourhood operation over a sequential chunk therefore sees a scattered subset of the site, not a compact region, and produces nonsense. Spatial tiling is a different and more expensive operation, and it is the reason PDAL’s splitter earns its keep.
Figure 3 — The operation decides the model, and the model decides the library.
Edge-case matrix
| Operation | Streams? | If not, what to do |
|---|---|---|
| Range / assign filters | Yes | — |
| Reprojection | Yes | — |
| Writing LAS/LAZ | Yes | — |
| Writing COPC | No | Needs the whole cloud to build the octree; size the machine |
| Statistical outlier | No | Tile with a halo, or use radius method per tile |
| SMRF / PMF ground | No | Tile with a halo exceeding the window |
| Height above ground | No | Tile with a generous halo |
| Covariance features | No | Tile with a halo above the knn reach |
| Sorting | No | Rarely necessary; avoid |
| Sequential chunking for neighbourhoods | N/A | Wrong tool — chunks are not spatially compact |
Verification snippet
The failure that tiling introduces is visible as a periodic pattern, and it can be detected without looking at anything.
import numpy as np
def detect_tile_seams(values: np.ndarray, transform, tile_size: float,
*, z_threshold: float = 4.0) -> dict:
"""Look for structure at exactly the tile spacing, which is never real.
Compares the mean absolute gradient along rows that fall on tile
boundaries against the overall mean. Terrain has no reason to change at
the tile spacing, so an excess there is a processing artefact.
"""
gy, gx = np.gradient(np.nan_to_num(values))
grad = np.hypot(gx, gy)
step = max(int(round(tile_size / abs(transform.a))), 1)
boundary = grad[:, ::step]
overall_mean = float(np.nanmean(grad))
overall_sd = float(np.nanstd(grad))
boundary_mean = float(np.nanmean(boundary))
z = (boundary_mean - overall_mean) / max(overall_sd, 1e-9)
return {"boundary_mean": boundary_mean, "overall_mean": overall_mean,
"z_score": z,
"seams_detected": bool(z > z_threshold),
"note": ("gradient is elevated at the tile spacing — increase the halo"
if z > z_threshold else "no periodic structure detected")}
Figure 2 — The halo rule, and the artefact it prevents.
When to escalate
- A required stage cannot stream and the cloud will not tile cleanly. Some operations — COPC writing, global sorting — genuinely need everything. Size the machine for the largest survey rather than engineering around it.
- Tiling produces seams even with a generous halo. The stage’s reach is larger than documented, or the operation is not local at all. Test on a single tile against the whole-cloud result before scaling.
- Memory is fine and the job is still slow. Streaming is about feasibility, not speed. Profile the stages, per benchmarking PDAL pipelines against laspy loops.