Debugging and Profiling filters.python

TL;DR: Print to sys.stderr from inside the function and run pdal pipeline --verbose 4 to see it; wrap the body in cProfile when it is slow; and when the stage produces nothing, check in this order — is the function being called at all, is it returning True, and is the key you wrote into outs spelled exactly as declared.

# Context and Motivation

This guide is part of Programmable Python Filters in PDAL. It exists because the debugging experience inside a filters.python stage is unusually poor: there is no interactive prompt, print appears to go nowhere, an exception surfaces as a C++ error with a truncated traceback, and the most common failure — a dimension that exists and is entirely zero — produces no diagnostic at all.

None of that is unfixable. The stage runs in an ordinary Python interpreter with an ordinary standard library, so every normal tool is available once you know where its output goes. The three techniques below cover essentially every failure people actually hit.

Working backwards from a stage that did nothing A diagnostic path. First check whether the function is called at all by writing a marker to stderr. If it is not, the script path or function name is wrong. If it is called, check the return value, then check the key spelling against add_dimension, then check the writer's extra_dims. Each branch ends at a specific, checkable cause. is it called? stderr marker does it return True? None fails silently is the key spelled right? must match add_dimension is it in extra_dims? writer decides the file script path or function name wrong missing return — stage marked failed typo — the value is discarded present in memory, absent from the file work left to right and stop at the first check that fails — every one of these produces a run that exits zero, so the exit code tells you nothing and the checks have to be explicit.

# Prerequisites and Assumptions

Requirement Detail
PDAL 2.4+ with Python support
A stage that already runs this is about behaviour, not setup
pdal pipeline --verbose 4 the flag that lets stage output reach your terminal
cProfile, time standard library, available inside the stage

# Step-by-Step Implementation

# Step 1 — Get output out of the stage

print goes to stdout, which PDAL may buffer or swallow depending on how it was invoked. sys.stderr is reliable:

python
import sys

def score(ins, outs):
    print(f"[score] buffer of {len(ins['Z'])} points", file=sys.stderr, flush=True)
    ...

Run with pdal pipeline p.json --verbose 4 and the line appears. If it does not appear at all, the function is not being called — check script and function.

# Step 2 — Count the calls

In streaming mode the function runs once per chunk, and a rule that assumed one call per cloud will behave differently. A module-level counter makes it visible:

python
_CALLS = 0

def score(ins, outs):
    global _CALLS
    _CALLS += 1
    print(f"[score] call {_CALLS}", file=sys.stderr, flush=True)

# Step 3 — Profile the body when it is slow

python
import cProfile, pstats, io

def score(ins, outs):
    profiler = cProfile.Profile()
    profiler.enable()
    result = _score_impl(ins, outs)
    profiler.disable()
    buf = io.StringIO()
    pstats.Stats(profiler, stream=buf).sort_stats("cumulative").print_stats(8)
    print(buf.getvalue(), file=sys.stderr)
    return result

The top line is nearly always either a NumPy call doing real work — fine — or a Python-level loop, which is the problem.

# Step 4 — Assert inside the function

An assertion that fires produces a real traceback with your module label, which is far better than a wrong answer:

python
assert outs["Confidence"].dtype == np.uint8, "dtype drifted from the declaration"
assert len(outs["Confidence"]) == len(ins["Z"]), "output length must match the buffer"

# Complete Working Example

python
"""A filters.python stage instrumented for diagnosis."""
import io
import os
import sys
import time

import numpy as np

DEBUG = os.environ.get("PDAL_PY_DEBUG") == "1"
_CALLS = 0
_SECONDS = 0.0


def _log(message: str) -> None:
    if DEBUG:
        print(f"[roughness] {message}", file=sys.stderr, flush=True)


def _impl(ins, outs):
    z = ins["Z"]
    # A cheap local roughness proxy: deviation from the buffer median.
    med = np.median(z)
    mad = np.median(np.abs(z - med)) * 1.4826
    scaled = np.zeros_like(z, dtype=np.float32) if mad == 0 else \
        (np.abs(z - med) / mad).astype(np.float32)
    outs["Roughness"] = np.clip(scaled, 0, 1000).astype(np.float32)
    return True


def roughness(ins, outs):
    global _CALLS, _SECONDS
    _CALLS += 1
    started = time.perf_counter()

    n = len(ins["Z"])
    _log(f"call {_CALLS}: {n:,} points")

    result = _impl(ins, outs)

    elapsed = time.perf_counter() - started
    _SECONDS += elapsed
    _log(f"call {_CALLS}: {elapsed * 1000:.1f} ms "
         f"({n / max(elapsed, 1e-9) / 1e6:.1f} M pts/s), total {_SECONDS:.2f} s")

    assert result is True, "impl must return True"
    assert outs["Roughness"].dtype == np.float32, "dtype drifted from the declaration"
    assert len(outs["Roughness"]) == n, "output length must match the input buffer"
    return result

Enable it without editing the pipeline:

bash
PDAL_PY_DEBUG=1 pdal pipeline roughness.json --verbose 4
Four verbosity levels, three of them useful PDAL verbosity from zero to eight. Level zero prints nothing. Level two names stages and errors. Level four is where output written to stderr from inside a Python stage becomes visible, which is the level to debug at. Level eight adds capability negotiation and option resolution, which is what to use when a pipeline refuses to stream. 0 nothing the default, and useless while debugging 2 stage names and errors enough to see which stage failed 4 your stderr output appears the level to debug a Python stage at 8 capability and option resolution why a chain refused to stream pdal pipeline p.json --verbose N in Python the same dial is pipeline.loglevel, and it must be set before execute rather than after

# Key Parameter Table

Technique Cost Catches
sys.stderr marker negligible function never called, wrong call count
call counter negligible streaming surprises, chunk-dependent logic
cProfile around the body 10–30% a Python loop hiding inside vectorised-looking code
in-function assertions negligible dtype drift, wrong array length
PDAL_PY_DEBUG env flag none when off leaving instrumentation in place safely

# Verification

The instrumentation is off by default. PDAL_PY_DEBUG unset means no output and no profiler. Instrumentation that cannot be left in the repository gets deleted and rewritten every time.

The assertions fire when they should. Deliberately break the dtype — assign float64 — and confirm the assertion raises rather than the pipeline quietly succeeding. An assertion that has never rejected anything proves nothing.

Throughput is where you expect. The logged points-per-second should be in the millions. Anything in the tens of thousands is a Python loop.

The number that tells you which mistake you made Throughput in millions of points per second for four implementations of the same per-point rule. Vectorised NumPy operations reach tens of millions. A list comprehension reaches ninety thousand and an explicit loop with append reaches forty thousand. When the logged throughput is in the tens of thousands, the cause is always the same. np.where on a mask 41 M pts/s np.percentile 12 M pts/s a list comprehension 0.09 M pts/s a for loop with append 0.04 M pts/s same rule, same tile, log scale log the rate in the stage itself and the diagnosis takes one run instead of an afternoon with a profiler

# Gotchas and Edge Cases

print to stdout can vanish. Use stderr with flush=True. Buffered output that never flushes is indistinguishable from a function that never ran.

A profiler around a NumPy call tells you very little. cProfile sees one entry taking all the time. Its value is showing you the Python calls, and the giveaway is a call count in the millions.

Module-level state persists across buffers and across tiles in the same process. A counter is fine; a cache keyed by nothing is a memory leak that only appears in a long parallel run.

Exceptions lose their traceback. The module label is what identifies the stage in the error PDAL surfaces, so give every programmable filter a distinct one.

# Frequently Asked Questions

Why does print produce no output from my filter?

Because stdout may be buffered or swallowed depending on how PDAL was invoked. Print to sys.stderr with flush set to True and run the pipeline with --verbose 4. If the marker still does not appear, the function is not being called and the script path or function name is wrong.

My dimension exists but is all zeros — where do I start?

Work through four checks in order: is the function called at all, does it return True, is the key you wrote into outs spelled exactly as declared in add_dimension, and is that name in the writer extra_dims. Each of these produces a run that exits zero, so none of them shows up without an explicit check.

How do I tell whether my function is the bottleneck?

Log the points-per-second the function achieves on each buffer. Vectorised NumPy runs in the millions of points per second; anything in the tens of thousands is a Python loop hiding somewhere in code that looks vectorised.

Is module-level state safe?

For counters and immutable lookups, yes — it persists across buffers and across tiles in the same process, which is usually what you want. A growing cache is a memory leak that only shows up hours into a long parallel run, so keep anything mutable bounded.