Splitting a Blocking Pipeline into Two Passes
TL;DR: Cut the chain at the blocking stage: run everything above it as a streaming pass that writes an intermediate LAZ, run the blocking stage alone over that much smaller file, then stream the tail. Two passes over reduced data almost always beat one pass that has to materialise the original cloud.
# Context and Motivation
This guide is part of Streaming Mode Execution in PDAL. The parent explains why a single blocking stage makes an entire pipeline non-streamable; this page is what to do about it when the blocking stage is one you actually need.
The situation is common enough to be the normal case. A production chain reads a tile, applies elevation limits, reprojects, classifies ground with filters.smrf, keeps the ground returns and writes a DTM. Four of those six stages stream. The classifier does not, and neither does the raster writer, so the whole pipeline runs conventionally and the reader materialises 18 million points before anything else happens. Splitting the chain does not make SMRF stream — nothing does — but it changes what SMRF has to hold. If the streaming pass has already discarded noise and cropped to the area of interest, the blocking stage starts from a third of the points, and a job that needed 12 GB now needs 4.
# Prerequisites and Assumptions
| Requirement | Detail |
|---|---|
| PDAL | 2.3+ with the Python bindings |
| Scratch space | room for one intermediate LAZ, typically 20–40% of the input |
| A known blocker | identified as in which filters break streaming |
| Ordering freedom | the reducing stages must be safe to run before the blocking one |
That last row is the real constraint. Moving filters.crop ahead of filters.smrf is safe if the crop boundary is generous enough that the classifier still sees the terrain context it needs. Moving it ahead with a tight boundary starves SMRF of the surroundings at the tile edge and produces a different, worse answer. Buffer the crop — the same reasoning as tile buffering in DTM raster generation.
# Step-by-Step Implementation
# Step 1 — Identify the cut point
Bisect the chain until pipeline.streamable flips. Everything before the blocker is pass one; the blocker is pass two; everything after is pass three.
# Step 2 — Make pass one reduce as much as it safely can
This is where the benefit comes from. Elevation limits, noise-class rejection and a buffered crop all stream, and each one shrinks what pass two must hold.
{"pipeline": [
{"type": "readers.las", "filename": "tile_0431.laz"},
{"type": "filters.range", "limits": "Z[-30:5000]"},
{"type": "filters.range", "limits": "Classification![7:7]"},
{"type": "filters.crop", "polygon": "POLYGON((...))"},
{"type": "filters.reprojection", "out_srs": "EPSG:6318"},
{"type": "writers.las", "filename": "/scratch/pass1.laz", "compression": "laszip", "forward": "all"}
]}# Step 3 — Run the blocking stage alone
{"pipeline": [
{"type": "readers.las", "filename": "/scratch/pass1.laz"},
{"type": "filters.smrf", "window": 18, "slope": 0.15, "threshold": 0.5, "cell": 1.0},
{"type": "writers.las", "filename": "/scratch/pass2.laz", "compression": "laszip", "forward": "all"}
]}# Step 4 — Stream the tail
Selecting ground and writing the output are both per-point operations, so pass three streams like pass one.
# Step 5 — Delete the intermediates deliberately
Scratch files that survive a crash will be picked up by the next run and silently reused. Write them under a run-specific prefix and remove them on success.
# Complete Working Example
"""Split a pipeline at its blocking stage and run the three passes in order."""
from __future__ import annotations
import json
import logging
import shutil
import tempfile
from pathlib import Path
import pdal
LOG = logging.getLogger("split_passes")
def run_streaming(stages: list[dict], chunk: int = 100_000) -> int:
pipeline = pdal.Pipeline(json.dumps({"pipeline": stages}))
if not pipeline.streamable:
raise RuntimeError("pass was expected to stream but does not")
return pipeline.execute_streaming(chunk_size=chunk)
def run_standard(stages: list[dict]) -> int:
return pdal.Pipeline(json.dumps({"pipeline": stages})).execute()
def process(src: Path, dst: Path, polygon: str, out_srs: str = "EPSG:6318") -> dict:
scratch = Path(tempfile.mkdtemp(prefix="pdal_split_"))
p1 = scratch / "pass1.laz"
p2 = scratch / "pass2.laz"
try:
n1 = run_streaming([
{"type": "readers.las", "filename": str(src)},
{"type": "filters.range", "limits": "Z[-30:5000]"},
{"type": "filters.range", "limits": "Classification![7:7]"},
{"type": "filters.crop", "polygon": polygon},
{"type": "filters.reprojection", "out_srs": out_srs},
{"type": "writers.las", "filename": str(p1),
"compression": "laszip", "forward": "all"},
])
LOG.info("pass 1 (streaming) kept %d points", n1)
n2 = run_standard([
{"type": "readers.las", "filename": str(p1)},
{"type": "filters.smrf", "window": 18, "slope": 0.15,
"threshold": 0.5, "cell": 1.0},
{"type": "writers.las", "filename": str(p2),
"compression": "laszip", "forward": "all"},
])
LOG.info("pass 2 (standard, blocking) classified %d points", n2)
n3 = run_streaming([
{"type": "readers.las", "filename": str(p2)},
{"type": "filters.range", "limits": "Classification[2:2]"},
{"type": "writers.las", "filename": str(dst),
"compression": "laszip", "forward": "all"},
])
LOG.info("pass 3 (streaming) wrote %d ground points", n3)
return {"pass1": n1, "pass2": n2, "ground": n3}
finally:
shutil.rmtree(scratch, ignore_errors=True)
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
poly = "POLYGON((512000 4783000, 513000 4783000, 513000 4784000, 512000 4784000, 512000 4783000))"
print(json.dumps(process(Path("tile_0431.laz"), Path("ground.laz"), poly), indent=2))# Key Parameter Table
| Choice | Options | Guidance |
|---|---|---|
| Intermediate format | LAZ, LAS | LAZ unless the intermediate is read many times; see LAZ vs uncompressed LAS |
| Scratch location | tmpfs, local SSD, network | Local disk; tmpfs re-introduces the memory problem you are solving |
| Crop buffer | 0–50 m | At least the classifier’s window, so edge points keep their context |
forward on intermediates |
all |
Header records must survive both hand-offs or the final file loses them |
| Cleanup | on success only | Keep the scratch files when a pass fails — they are the debugging material |
# Verification
The three passes agree with one. On a tile small enough to run both ways, the single-pipeline output and the three-pass output should contain the same number of ground points. A difference means a reducing stage moved ahead of a stage that depended on what it removed.
Peak memory dropped where you expected. Measure each pass separately. Passes one and three should be flat and small; pass two should be noticeably below the original peak. If pass two is unchanged, the streaming pass is not actually reducing anything.
The intermediate is not silently stale. Assert the intermediate’s modification time is newer than the source before pass two reads it, or generate it under a unique prefix per run.
# Gotchas and Edge Cases
The intermediate write can dominate. If pass one removes almost nothing, you have added a full write and read for no benefit. Measure the reduction before committing to the split; below about 30% it is rarely worth it.
Two processes, two memory peaks. Splitting only helps if the passes run sequentially. Launching them concurrently on the same worker re-creates the ceiling you were avoiding.
Metadata has to be carried across each hand-off. Every intermediate write is an opportunity to lose VLRs and extra dimensions. forward: "all" on every writer in the chain, and a check on the final header as described in metadata and header sync.
# Frequently Asked Questions
Does splitting a pipeline change the result?
It should not, and verifying that is part of the job. Run both forms on a tile small enough to afford it and compare the surviving point counts. A difference means a reducing stage moved ahead of a stage whose decision depended on the points it removed — most often a crop without enough buffer.
How much does the intermediate file cost?
One extra write and one extra read, typically a few seconds per tile against gigabytes of memory headroom. The trade is only bad when the streaming pass removes very little; below roughly a thirty percent reduction the split stops paying for itself.
Can I keep the intermediate in memory instead of on disk?
You can pass arrays between pipelines in Python, but doing so re-creates exactly the whole-cloud residency that the split was meant to avoid. Local disk is the right place for the intermediate; tmpfs is not, because it is memory wearing a filesystem interface.
Which stages are safe to move ahead of the blocking one?
Ask whether the blocking stage would have decided differently had it seen the removed points. Dropping noise and applying generous elevation limits are safe. Cropping is safe with a buffer at least as wide as the classifier window. Thinning the cloud is not safe, because it changes which points are local minima.
# Related
- Streaming Mode Execution in PDAL — the parent guide to the chunked execution model
- Which PDAL Filters Break Streaming Mode — how to identify the stage you need to cut at
- Running a PDAL Pipeline in Streaming Mode — executing and verifying the streaming passes
- Memory Management in PDAL Pipelines — the buffer model that sets each pass’s peak
- PDAL Stage Chaining — ordering rules that decide what can move earlier