Pipeline Fusion¶
Pipeline fusion compresses multiple transformation stages into a single execution pass, reducing Python overhead and memory allocations.
Three levels of fusion¶
crxml has three fusion mechanisms:
| Level | Mechanism | When it applies |
|---|---|---|
| Dict-level fusion | apply + __call__ protocol |
Any pipeline with fusable stages |
| Columnar fusion | _plan_kwargs into Rust ExecutionPlan |
Source supports rypipe engine, stages export a plan |
| Vectorized batch chain | Volcano-style pull on Arrow RecordBatch |
After columnar fusion, remaining stages implement _plan_kwargs |
A stage is fusable if it has both apply(self, record) -> dict | None and
__call__(self, stream). When a contiguous run of fusable stages exists at
the front of the pipeline, they are fused into a single tight loop that
avoids Python generator overhead.
A stage supports columnar fusion if it implements
_plan_kwargs(self) -> dict | None. When all stages in a pipeline are
columnar-fusable, the entire pipeline compiles into the rypipe engine
and no Python dicts are created until the final Arrow table is converted.
Decision tree¶
When you iterate a pipeline, fusion.py follows this logic:
- Try columnar fusion: if the source has a
_read_arrowmethod and stages export_plan_kwargs, the pipeline runs entirely in Rust. - Vectorized batch chain: if columnar fusion found pushdown stages but
remaining stages exist, they run on Arrow
RecordBatchobjects via the batchpipe engine, keeping data in columnar format and avoiding per-row Python dict construction. - Dict-level fusion: if columnar fusion is not possible, the first contiguous run of fusable stages is fused into a single loop.
- Sequential: remaining stages run as Python generators on the dict stream.
When fusion applies¶
| Pipeline | Fusion level | Performance |
|---|---|---|
Source \| RenameFields \| CastTypes |
Columnar | Fastest (all Rust) |
Source \| DropFields \| RenameFields |
Columnar | Fastest (all Rust) |
Source \| FilterRows(field=..., op=..., value=...) |
Columnar | Fastest (all Rust) |
Source \| custom_fusable_stage \| CastTypes |
Dict-level | Fast (no generator overhead) |
Source \| generator_func \| CastTypes |
Sequential | Fusable stages after generator are NOT fused |
Source \| CastTypes \| generator_func |
Columnar + dict tail | Columnar up to the generator, then dicts |
For optimal performance, place fusable stages at the front of the pipeline:
# Good: CastTypes and DropFields fuse into columnar plan
pipe = source | CastTypes({"amt": float}) | DropFields(["tmp"]) | custom_filter
# Less good: custom_filter breaks the fusable chain
pipe = source | custom_filter | CastTypes({"amt": float}) | DropFields(["tmp"])
How columnar fusion works¶
- The pipeline calls
_try_columnar_fusion(source, stages). - For each stage,
_plan_kwargs()is called. If it returns a dict, the kwargs are merged into a singleplan_overridesdict and the stage is skipped in the Python stage list. source._read_arrow(plan_overrides=plan_overrides)is called. This runs the rypipe engine with the fused plan, producing apyarrow.Tabledirectly from the XML.- The Arrow table is wrapped in a row-by-row dict iterator.
- Any remaining stages (those that did not provide
_plan_kwargs) run on the dict stream.
This means columnar fusion can be partial. A pipeline like:
will execute RenameFields and CastTypes in Rust, produce an Arrow table,
convert to dicts, then apply custom_lambda to each dict. No unnecessary
Python object creation happens for the fused stages.
How dict-level fusion works¶
- The pipeline scans stages from the front until it finds a non-fusable
stage (no
applymethod). - All fusable stages are combined into a single
fused()generator:
def fused():
for record in source:
r = record
for fn in bound_stage_applies:
r = fn(r)
if r is None:
break
else:
yield r
- Non-fusable stages wrap the fused generator.
Verifying fusion¶
Set logging to DEBUG to see fusion decisions:
import logging
logging.basicConfig(level=logging.DEBUG)
# Logs: "columnar fusion with overrides: ..." or "fused N stages"
Or check programmatically by inspecting the pipeline:
pipe = source | CastTypes({"x": float})
print(type(pipe._stages[0])) # <class 'crxml.stages.cast.CastTypes'>
If the pipeline uses the rypipe engine, source._read_arrow is called
internally and the Rust-side profile counters show the fused plan.
Performance comparison¶
Using a 100 MB file with a 4-stage pipeline:
| Pipeline | Time | Speedup vs sequential |
|---|---|---|
| Sequential (no fusion) | 2.27s | 1x |
| Dict-level fusion only | 1.89s | 1.2x |
| Full columnar fusion | 0.69s | 3.3x |
Columnar fusion is particularly effective because it skips the two most expensive operations in the stream path: HTML unescaping and Python dict construction.