Skip to content

Optimizations

Every optimization in rypipe-core, why it matters, and what it replaces. These changes are not format-specific; they benefit every adapter equally.

1. Dense column storage

Before: HashMap<String, ColumnBuilder> required two hash probes per field: one in ensure_column (create if missing), one in get_mut (push value). For 10 fields per row, this was 20 HashMap operations per row.

After: Vec<ColumnBuilder> + field_index: HashMap<String, usize>. One hash probe (name → index via field_index.get), then columns[idx] is a bounds-checked array access. The push_field_resolved hot path does one hash, a frozen-schema check, a strict-types check, an observer notification, first-row ordinal tracking, one bit set, one conditional pop, and one push_value.

Example: the adapter-visible hot path, one hash probe per field:

use rypipe_core::{ColumnarSink, TableBuilder, Value};

fn feed_row(sink: &mut TableBuilder) {
    sink.begin_row();
    sink.put_field("id", Value::Int64(7));          // 1 hash: field_index
    sink.put_field("name", Value::Str("acme".into()));
    sink.end_row();                                 // finish_row: dirty-bitmask pass
}

Impact: Eliminates the second hash probe for every field. For 10 fields per row at ~10 ns per hash, this saves ~100 ns per row. On a 533 MB file with ~480K rows, that is ~48 ms saved.

2. Dirty bitmask null-fill

Before: For each row, loop over all columns:

for b in &mut columns {
    while b.len() < target { b.push(None); }
}
This pushed None for every missing column, even when most columns were present. For 10 columns where 8 are present, 2 null pushes per row.

After: row_dirty: Vec<u64> bitmask. In finish_row, a dense-row is_full check (every dirty bit set) skips the per-column loop entirely; otherwise only missing columns get null-filled. Example:

if is_full {
    self.row_dirty.fill(0);  // Dense row: no loop at all
} else {
    for (i, b) in columns.iter_mut().enumerate() {
        let word = i / 64;
        let bit = i % 64;
        if (row_dirty[word] >> bit) & 1 == 0 {
            b.push(None);  // Only missing columns get null-filled
        }
    }
    self.row_dirty.fill(0);  // Clear all bits for next row
}

Impact: For 10 columns where 8 are present, saves 80% of null-fill pushes. The bitmask word load plus bit test is cheaper than a Vec push per column. Measured: 34% reduction in finish_row cost.

3. Predicate-first deferred materialization

Before: Parse all fields into columns, then evaluate filter. If rejected, pop all columns (expensive for wide tables with many fields).

After: Buffer (slot, Value<'static>) pairs in RowBuffer. Evaluate predicate as soon as the predicate column arrives. On Fail, discard buffer (no pops). On Pass, switch to direct mode and drain buffer to columns.

Example: selective filter via crxml (rejected rows are discarded before any column push):

import crxml

src = crxml.CrystalXMLSource("report.xml", row_tag="Row", engine="parallel")
df = src | crxml.FilterRows(field="status", op="==", value="active") | crxml.to_pandas()

Impact: For selective filters (e.g., 10% selectivity), eliminates 90% of column push/pop cycles. The adaptive strategy disables buffering when the predicate column's slot is at or beyond 4/5 of the column count (ordinal >= ncols * 4 / 5), falling back to direct push + pop-on-reject.

4. resolve + put_field_resolved (single hash)

Before: wants(name) + put_field(name, v) = two hash probes (one in wants via resolve_field, one in put_field via ensure_column_idx).

After: resolve(name) + put_field_resolved(resolved, v) = one hash probe. resolve does the rename/drop lookup; put_field_resolved does the column lookup with the already-resolved name.

Example: adapter call pattern (one hash probe instead of two):

// Instead of: if sink.wants(name) { sink.put_field(name, value); }
if let Some(resolved) = sink.resolve(name) {
    let owned = resolved.to_owned(); // end the borrow on sink
    sink.put_field_resolved(&owned, value);
}

Impact: Saves one HashMap lookup per field. For 10 fields per row, saves ~100 ns per row.

5. expect_slot layout prediction

Before: Every field: find_attr_value (memchr scan) + decode_attr (UTF-8 + entity unescape) + resolve (HashMap lookup) + put_field.

After: After first row, expect_slot(ordinal) returns (slot, raw_name_bytes). Adapter does memcmp (8-16 bytes, single SIMD compare), then put_field_at(slot, value).

Example: per-field fast path after row 1:

if let Some((slot, expected_name)) = sink.expect_slot(ordinal) {
    if raw_name_bytes == expected_name { // one memcmp, no scan/decode/hash
        sink.put_field_at(slot, value);
        continue;
    }
}
// memcmp miss: fall back to the generic attribute-scan path

Impact: Skips attribute scan, UTF-8 decode, hash lookup. ~25 ns → ~8 ns per field. ~17% on the hot path. Works for formats with stable field order (CSV, XML, JSONL).

6. row_satisfied projection short-circuit

Before: Scan all fields even when only 3 of 11 are wanted.

After: row_satisfied() returns true when all wanted columns have values. Scanner byte-jumps to row close via find_close_after with the precomputed close finder (see section 9).

Example: projection via crxml (scanner byte-jumps to the row close once all wanted fields arrived):

import crxml

src = crxml.CrystalXMLSource("report.xml", row_tag="Row", engine="parallel")
df = src | crxml.DropFields(["notes", "audit_trail", "meta_json"]) | crxml.to_pandas()

Impact: For projections, skips scanning 60-80% of fields. Measured: +123% on drop_half parallel (533 MB: 3,394 → 7,571 MB/s).

7. wanted_mask bitmask projection

Before: sink.wants(name) virtual call per field (vtable dispatch).

After: (wanted_mask >> slot) & 1: single bit test, no vtable dispatch.

Example: per-field membership test in the parse loop:

let mask = sink.wanted_mask(); // precomputed once, 0 when no projection
if mask != 0 && (mask >> slot) & 1 == 0 {
    continue; // not wanted: skip extraction entirely
}

Impact: Eliminates virtual dispatch overhead in the hot inner loop. Combined with row_satisfied, enables full projection optimization.

8. Ordinal threading

Before: parse_row doesn't track field ordinals.

After: Ordinal counter threads through parse_row → scan_child → field_element. Enables expect_slot (layout prediction) and row_satisfied (projection short-circuit).

Example: the counter threads through the parse recursion:

// the caller advances `ordinal` per field; it threads parse_row ->
// scan_child -> field_element so each field knows its position
fn field_element<S: ColumnarSink>(sink: &mut S, ordinal: u32,
                                  raw_name: &[u8], value: Value<'_>) {
    if let Some((slot, expected)) = sink.expect_slot(ordinal) {
        if raw_name == expected {
            sink.put_field_at(slot, value); // layout fast path
            return;
        }
    }
    // generic path: scan + decode + resolve + put_field
}

Impact: Enables optimizations 5 and 6. ~3% overhead for the counter increment, but the savings from 5 and 6 far outweigh it.

9. Precomputed close finder (F1)

Before: find_row_close allocates Vec<u8> + memmem::Finder per rejected or satisfied row. With row_satisfied, this ran on every row in projection workloads.

After: Precomputed once in scan_chunk, passed to parse_row as a reference. Zero allocation per row.

Example: precomputed once in scan_chunk, reused per row:

use rypipe_core::scan::find_literal;

let close_finder = memchr::memmem::Finder::new(b"</Row>"); // once per chunk
// per satisfied or rejected row: zero allocation
if let Some(close) = find_literal(bytes, pos, &close_finder) {
    pos = close + b"</Row>".len();
}

Impact: Eliminates per-row allocation. For 480K rows, saves 480K Vec allocations + Finder constructions. Measured: +10% single-thread, +9% parallel.

10. Scan primitives

Before: Raw memchr calls without fast path.

After: scan::find(hay, from, b) checks hay[from] == b first (O(1)), then delegates to memchr. scan::find2 for dual-byte searches with the same fast path.

Example: the next_lt hot path:

use rypipe_core::scan::{find, find2};

if let Some(lt) = find(bytes, pos, b'<') {
    // bytes[pos] == b'<': O(1) hit, AVX2 prologue skipped
}
let (i, hit) = find2(bytes, pos, b'<', b'"')?; // dual-byte, same fast path

Impact: 15% on the next_lt hot path (byte-at-position check avoids AVX2 prologue on 2/3 of calls).

11. Engine-provided Splitter default

Before: Adapters implement find_split_points from scratch, which is a recurring source of performance bugs: sampling too little data causes small-chunk collapse, sampling too much scans far more of the file than needed.

After: Default find_split_points uses next_record_start with serial boundary scanning plus skip-region rejection + dedup + chunk-size target (2 MiB heuristic).

Example: a minimal Splitter gets the tuned default for free:

use rypipe_core::Splitter;

struct RowTagSplitter;

impl Splitter for RowTagSplitter {
    fn next_record_start(&self, bytes: &[u8], from: usize) -> Option<usize> {
        memchr::memmem::find(&bytes[from..], b"<Row>").map(|p| from + p)
    }
    fn estimate_bytes_per_row(&self, _sample: &[u8]) -> usize {
        2048 // coarse hint, only used for chunk sizing
    }
}
// find_split_points comes free: serial boundary scan + skip-region rejection + chunk sizing.
let points = RowTagSplitter.find_split_points(bytes, 16);

Impact: Eliminates the bug class: +13-32% on projection workloads.

12. Incremental dictionary unification

Before: auto_dict=True forces serial merge path (no fast path). All chunks must be merged before dict upgrade.

After: Per-chunk upgrade in parallel, then unify_dictionaries + remap_codes (O(dict_size), not O(rows)).

Example: the user-facing trigger:

import crxml

src = crxml.CrystalXMLSource(
    "report.xml", row_tag="Row", engine="parallel", auto_dict=True
)
df = src.to_pandas()  # low-cardinality strings become dictionary columns

Impact: auto_dict parallel gap: 45% to 16%.

Summary table

# Optimization Measured gain Where
1 Dense column storage Eliminates 1 hash probe/field engine/table_builder.rs
2 Dirty bitmask null-fill 80% fewer null pushes engine/table_builder.rs
3 Predicate-first 90% fewer push/pop cycles engine/table_builder.rs
4 Single hash resolve 100 ns/row saved engine/table_builder.rs
5 expect_slot layout 17% on hot path decoder.rs, scanner.rs
6 row_satisfied +123% on drop_half decoder.rs, scanner.rs
7 wanted_mask Eliminates vtable dispatch decoder.rs
8 Ordinal threading Enables 5 and 6 decoder.rs, scanner.rs
9 Precomputed close finder Eliminates per-row alloc scanner.rs
10 Scan primitives 15% on next_lt scan/mod.rs
11 Splitter default Eliminates bug class decoder.rs
12 Incremental dicts 45% → 16% gap parallel.rs