Pushdown fusion¶
rypipe splits ingestion into two layers:
- Adapter layer: reads bytes, splits them into records, and emits
Valuerows. - Engine layer: builds typed Arrow arrays, applies filters, and exports record batches.
Pushdown fusion is the process by which the Python Pipeline rewrites a chain of lightweight stages into a single ExecutionPlan. The engine then applies rename, drop, filter, and cast while it parses each row, instead of materializing a full table and running Python transforms afterward.
A fused pipeline¶
from rypipe import RenameFields, DropFields, FilterRows, CastTypes
source = MyAdapter("data.log")
result = (
source
| RenameFields({"old_name": "new_name"})
| DropFields(["internal_id"])
| FilterRows(field="status", op="==", value="active")
| CastTypes({"amount": "float64"})
).to_arrow()
When the pipeline reaches to_arrow(), the stage list is collapsed into one plan. The Rust parser:
- renames
old_nametonew_nameas fields arrive; - skips
internal_identirely (it is not allocated); - drops rows whose
statusis notactivebefore they leave the builder; - casts
amounttofloat64once, during parse.
Rows that fail the filter are never materialized; dropped columns are never allocated; and casts happen once inside Rust instead of twice in Python and Rust.
The ExecutionPlan fields¶
pub struct ExecutionPlan {
pub field_map: HashMap<String, String>, // rename
pub drop_fields: HashSet<String>, // drop
pub field_types: HashMap<String, FieldType>, // cast
pub dictionary_columns: HashSet<String>, // explicit dict encoding
pub filter: Option<FilterPredicate>, // per-row or post-reduce
pub schema_order: Vec<String>, // output column order
pub auto_dict: bool, // auto-dict upgrade
}
The plan is built by _build_plan_kwargs() on the Python side and consumed by TableBuilder on the Rust side.
Field resolution order¶
Inside TableBuilder, every emitted field goes through this pipeline:
field_maprenames the raw field name.drop_fieldschecks the resolved name; if dropped, the field is ignored.field_types/dictionary_columnschooses the storage type.filterrejects rows duringend_row(forEqual/NotEqual) or after assembly (forCompare).
This order matters. A filter runs on the resolved name, so it must be written in post-rename terms. A cast type is attached to the resolved name as well.
What is fusable¶
Fusable stages implement _plan_kwargs() and merge cleanly into an ExecutionPlan:
| Stage | Plan field | Notes |
|---|---|---|
RenameFields |
field_map |
Multiple renames merge into one map. |
DropFields |
drop_fields |
Merges as a set union. |
CastTypes |
field_types |
Later casts overwrite earlier ones for the same field. |
FilterRows constant |
filter |
Equal/NotEqual on a constant string; evaluated during parse. |
FilterRows is fusable only when it uses a constant predicate: field, op, and value are all literals. op can be == or != for in-parser filtering, or comparison operators (<, <=, >, >=) for post-assembly filtering with Arrow compute kernels.
What is not fusable¶
Non-fusable stages still work, but they run over the Arrow table after the engine finishes:
- Python callables (
lambdaor any callable stage). - Stateful transforms such as window or aggregate stages.
FilterRowswith computed or non-constant values.- Custom stages that do not implement
_plan_kwargs().
When a non-fusable stage is present, the pipeline automatically falls back to a row stream or table transform path. The fusable prefix still runs in Rust; only the suffix runs in Python.
Inspecting plan_overrides in an adapter¶
Adapters that subclass rypipe.Source receive fused plan kwargs through _read_arrow:
class MySource(Source):
def _read_arrow(self, *, plan_overrides=None, **kwargs):
plan_overrides = plan_overrides or {}
print(plan_overrides)
# {
# "field_mapping": {"old_name": "new_name"},
# "drop_fields": ["internal_id"],
# "filter": {"field": "status", "op": "==", "value": "active"},
# "field_types": {"amount": "float64"},
# }
return my_rust_read(path=self.path, **plan_overrides, **kwargs)
The Rust side merges these kwargs into an ExecutionPlan with execution_plan_from_kwargs from rypipe-python, or constructs the plan manually:
use rypipe_core::{ExecutionPlan, FieldType};
let plan = ExecutionPlan::new()
.rename("old_name", "new_name")
.drop("internal_id")
.type_as("amount", FieldType::Float64)
.filter_eq("status", "active");
If an adapter ignores plan_overrides, fused stages silently fall back to Python execution over a full table. That is one of the most expensive anti-patterns.
Order of operations across the pipeline¶
Fusable stages commute in the plan, but the engine applies them in a fixed order:
raw field name
|
v
rename (field_map)
|
v
drop check (drop_fields)
|
v
type selection (field_types / dictionary_columns)
|
v
per-row filter (Equal / NotEqual)
|
v
builder append
|
v
post-assembly filter (Compare)
|
v
finish: sort by schema_order, auto-dict, Arrow export
Because drop happens before type selection, you cannot cast a dropped field. Because per-row filter happens before the row is committed, rejected rows consume no Arrow storage.
When fusion does not help¶
Fusion is not free if the adapter cannot act on the plan. An adapter that always parses every field into Python objects and then builds Arrow will not benefit; the engine must receive fields through put_field and honor wants(). If the adapter is a thin wrapper around a library that returns full Python dicts, fusion only removes a small amount of Python overhead.
Fusion also cannot help when the workload is dominated by I/O. If the file is on a slow network share, reducing CPU work may not change wall-clock time. Profile first.
Summary¶
- Fuse
RenameFields,DropFields,CastTypes, and constantFilterRowsby implementing_read_arrow(plan_overrides=...). - Inspect
plan_overridesto confirm that stages reach the Rust parser. - Non-fusable stages run after the engine; keep them out of the hot path when throughput matters.