Estimating PDAL Memory from Point Layout

TL;DR: Memory in standard mode ≈ point count × bytes per point × safety factor. Get the count from the header (pdal info --summary), get bytes per point by reading one point through the pipeline’s own stages and checking arrays[0].dtype.itemsize, add 8 bytes for every double a later filter creates, and multiply by 1.5–2 for neighbourhood stages that build a k-d tree.

# Context and Motivation

This guide is part of Memory Management in PDAL. Out-of-memory failures are the most common way PDAL batch jobs die, and they are expensive: a job killed by the kernel after forty minutes of SMRF has wasted forty minutes and gives no Python traceback. The fix is to know, before launching, how much memory each tile needs — then choose instance sizes, tile sizes and concurrency to fit.

PDAL makes this predictable. In standard mode it holds every point in a table whose row width is the sum of the dimension sizes, so memory scales linearly with point count and with the number of dimensions. Everything else — spatial indexes, raster buffers, Python copies — is a multiple on top of that base.

Where the bytes per point go A horizontal stacked bar of bytes per point for a typical PDRF 6 tile in PDAL's table. X, Y and Z as doubles take 24 bytes. GpsTime takes 8. Standard small dimensions — intensity, returns, flags, classification, scan angle, user data, point source ID — take about 16. HeightAboveGround added by a filter takes another 8. The total is about 56 bytes, roughly twice the 30-byte LAS record on disk. X, Y, Z doubles: 24 B GpsTime 8 small dims ≈ 16 HAG 8 PDRF 6 record on disk: 30 B in PDAL's point table, about 56 bytes per point after one added dimension LAZ on disk is smaller still — typically 5–10 bytes per point

# Prerequisites and Assumptions

  • PDAL 2.x and the Python bindings.
  • The pipeline you intend to run, and one representative input tile.
  • Standard (non-streaming) execution. Streaming mode bounds memory by chunk size instead, which is covered in running a PDAL pipeline in streaming mode.

# Step-by-Step Implementation

# Step 1 — Read the point count without loading points

pdal info --summary tile.laz reads only the header and returns summary.num_points. It is instant even for multi-gigabyte files.

# Step 2 — Measure bytes per point empirically

Run the pipeline’s own stages on the first point only, using filters.head with count: 1, and read arrays[0].dtype.itemsize. This counts every dimension the reader produces and every dimension the filters add — no guessing about extra bytes or PDRF layouts.

# Step 3 — Add stage overhead

Neighbourhood stages (filters.smrf, filters.outlier, filters.hag_nn, filters.covariancefeatures) build a spatial index: budget roughly 0.5 to 1× the point table again. writers.gdal holds its raster buffers: rows × columns × 8 bytes × number of output types.

# Step 4 — Add the Python copy

pipeline.arrays returns NumPy arrays that share or copy PDAL’s data depending on version and usage. If your code touches arrays, budget one extra copy of the table.

# Step 5 — Apply a safety factor and decide

Multiply by 1.2 for allocator slack and compare against the worker’s memory, leaving room for the OS and anything else on the machine.

# Complete Working Example

python
"""Estimate peak memory for a PDAL pipeline on a given tile."""
from __future__ import annotations

import copy
import json
import subprocess
from pathlib import Path

import pdal

NEIGHBOURHOOD = {"filters.smrf", "filters.pmf", "filters.outlier", "filters.hag_nn",
                 "filters.hag_delaunay", "filters.covariancefeatures", "filters.normal",
                 "filters.cluster", "filters.dbscan", "filters.csf"}


def point_count(path: Path) -> int:
    out = subprocess.run(["pdal", "info", "--summary", str(path)],
                         capture_output=True, text=True, check=True).stdout
    return int(json.loads(out)["summary"]["num_points"])


def bytes_per_point(spec: dict) -> int:
    probe = copy.deepcopy(spec)
    stages = [s for s in probe["pipeline"] if not str(s.get("type", "")).startswith("writers.")]
    stages.insert(1, {"type": "filters.head", "count": 1})
    p = pdal.Pipeline(json.dumps({"pipeline": stages}))
    p.execute()
    return p.arrays[0].dtype.itemsize


def raster_bytes(spec: dict, extent_m: float = 1000.0) -> int:
    total = 0
    for s in spec["pipeline"]:
        if s.get("type") == "writers.gdal":
            cells = (extent_m / float(s.get("resolution", 1.0))) ** 2
            kinds = len(str(s.get("output_type", "all")).split(","))
            total += int(cells * 8 * (6 if s.get("output_type", "all") == "all" else kinds))
    return total


def estimate(spec: dict, tile: Path, python_copy: bool = True) -> dict:
    n = point_count(tile)
    bpp = bytes_per_point(spec)
    table = n * bpp
    types = {s.get("type") for s in spec["pipeline"] if isinstance(s, dict)}
    index = table * (1.0 if types & NEIGHBOURHOOD else 0.0)
    extra = table if python_copy else 0
    peak = 1.2 * (table + index + extra + raster_bytes(spec))
    return {"points": n, "bytes_per_point": bpp,
            "table_gb": round(table / 1e9, 2), "peak_gb": round(peak / 1e9, 2)}


if __name__ == "__main__":
    spec = json.loads(Path("dtm.json").read_text())
    print(estimate(spec, Path("tiles/t_0431.laz")))

For a 1 km² tile with 42 million points, a PDRF 6 input and a pipeline that adds HeightAboveGround, the output looks like:

text
{'points': 42000000, 'bytes_per_point': 56, 'table_gb': 2.35, 'peak_gb': 8.54}
From point table to peak A waterfall of memory components for a 42 million point tile. The point table is 2.35 gigabytes. The spatial index for SMRF adds 2.35. The Python array copy adds 2.35. Raster buffers add 0.05. A 1.2 safety factor brings the peak estimate to about 8.5 gigabytes. table 2.35+ index 2.35+ copy 2.35+ raster 0.05peak ≈ 8.5 GB × 1.2

# Key Parameter Table

Component Estimate Notes
Point table count × itemsize Measured, not assumed
Added doubles 8 B per new dimension per point HAG, covariance features, normals
Spatial index 0.5–1 × table Any neighbourhood filter
Python copy 1 × table Only if pipeline.arrays is accessed
writers.gdal buffers cells × 8 B × output types output_type: "all" is six buffers
Safety factor 1.2 Allocator slack and fragmentation

# Verification

The estimate is only useful if it matches reality. Measure peak memory for a few tiles with the method in measuring peak memory of a PDAL pipeline and compare: a good estimate is within 20 percent and errs high. If measured peaks are consistently lower, reduce the index factor; if higher, look for a stage the estimate does not know about.

# Gotchas and Edge Cases

Point density varies across tiles. Tiles over forest or with flightline overlap can hold twice the points of tiles over fields. Estimate from the largest tile in the batch, not the median, or size workers per tile.

Extra bytes dimensions. A vendor file with ten extra-byte dimensions carries them into PDAL’s table. The empirical itemsize catches this; an estimate from the PDRF alone would not.

Merged inputs. Pipelines that merge neighbours hold all of them at once; sum the counts of every reader.

Size for the biggest tile, not the average A row of twelve bars showing point counts for tiles in a batch. Most are between 30 and 45 million. Two forest tiles with overlap reach 78 and 84 million. A dashed line at the median sits far below the maximum, illustrating that a worker sized for the median would fail on the largest tiles. median ≈ 38 M 84 M tiles in one batch, points per tile

Streaming changes everything. If every stage streams, memory is bounded by chunk_size × bytes per point, independent of tile size. Check pipeline.streamable before sizing for standard mode.

# Frequently Asked Questions

How many bytes per point does PDAL use in memory?

It depends on the dimensions present. A typical point format 6 tile uses around 48 bytes per point in PDAL’s table before any filters add dimensions, because coordinates are stored as 8-byte doubles. Measure it for your data with a one-point probe.

Why is PDAL’s memory use much larger than the LAZ file size?

LAZ compresses points to a few bytes each, and even uncompressed LAS stores coordinates as scaled 4-byte integers. In memory, PDAL expands coordinates to doubles and adds any computed dimensions, so the table is often five to ten times the LAZ size.

How much extra memory does SMRF or outlier filtering need?

Neighbourhood filters build a spatial index over all points, which typically costs half to one times the point table again for the duration of the stage. Budget for it whenever such a stage is present.

Can I avoid the Python copy of the points?

Yes, by not accessing pipeline.arrays when you only need the pipeline to write files. Execute and let the writer produce the output; read the result later only if you need it.