Skip to content

Explore FFX-style packed factorized execution for multi-hop CSR #105

Open

Description

@prrao87

Parent: #78

Problem

CsrExpandExec emits one flat Arrow row per match. Its bounded batches control memory per chunk, but each hop still repeats earlier bindings and property columns before the next hop can filter or aggregate them. #97 avoids this for a narrow two-hop count, and #101 delays node-property lookup after grouping in memory. General compact intermediates between hops remain open.

Explore whether FFX's execution methodology can address that remaining gap while preserving regular query performance and DataFusion's planning and execution framework. This is an investigation, not a commitment to adopt the full FFX engine.

Paper

Sunny Yasser, Anas Dorbani and Amine Mhedhbi. Factorized and Vectorized Execution: Optimizing Analytical and Semantic Queries over Relations. Proceedings of the ACM on Management of Data 4(3), Article 178, June 2026 (SIGMOD 2026).

The relevant parts are packed factorized vectors (§4), join evaluation (§5.1), cascade updates (§5.2), and analytical evaluation (§§7.2–7.3). FFX keeps values in contiguous buffers, with offsets describing parent–child groups and masks tracking active values. This permits branching factorization layouts while batching work across multiple parents. Cascade updates propagate invalidations through dependent groups.

Treat the evaluation as motivation, not a lance-csr speedup estimate. The end-to-end comparisons use in-memory execution with spilling disabled, exclude compilation time, and select factorization-friendly orderings. Outer and anti-joins are unsupported. The largest reported gains use MIN aggregation to avoid output enumeration, and some packed plans are slower than their unpacked counterparts. DataFusion is not an evaluated baseline.

Proposed investigation

Start with a small, compatible multi-hop CSR region implemented as a custom DataFusion ExecutionPlan. Keep intermediates compact inside that region and emit ordinary Arrow batches with the original schema and row semantics at its boundary. Use the existing optimizer and CsrExtensionPlanner hooks; retain DataFusion's default planning and ordinary operators elsewhere.

Questions to resolve:

  • Representation: carry node IDs, edge occurrences, parent-binding references, offsets and selection masks without copying every earlier column per match. Query bindings can revisit the same node: grouping must preserve multiplicity and relationship-reuse rules.
  • Traversal and grouping order: compare a left-to-right chain with a layout rooted at a middle node, where independent left and right branches remain separate until needed. High fan-out alone does not establish a benefit; account for selective starting nodes, predicates, skew and the eventual output size.
  • Filtering: evaluate supported branch-local predicates with DataFusion expressions before combining branches. Propagate empty groups correctly. Predicates comparing independent branches, such as a.age < c.age, require combination-aware evaluation or expansion at that boundary.
  • Consumers: test ordinary row-returning queries as well as compatible aggregates. A generic COUNT(*) cannot infer how many paths a compact group represents. Preserve perf(planner): count two-hop paths from per-node degrees #97's simpler rewrite where it applies; grouped or longer-path counts need their own correctness argument.
  • Materialization: fetch properties only when a predicate or output needs them. Coordinate with Late-materialize a grouped node's properties from Lance node tables #99 for lazy node lookup and Read relationship columns once on large named hops from Lance #102 for named relationship columns; reducing intermediate computation does not remove scattered Lance I/O costs.

Arrow list arrays could store groups, but ordinary DataFusion operators do not interpret sibling lists as an implicit Cartesian product. Keep that interpretation within the custom region. Choose a physical rewrite or a logical extension based on the information required to recognize and plan the supported pattern, and document that choice.

First experiment and boundaries

Use a three- or four-hop directed pattern with independent branches and a selective downstream predicate. Include an ordinary row-returning version so the experiment addresses work beyond the two-hop count already optimized by #97. Use low-fan-out and selective-start variants as controls.

Initially support fixed-length inner matches with clearly defined predicates and binding semantics. Leave OPTIONAL MATCH, undirected and variable-length traversal, cross-branch predicates, and unsupported aggregates on their existing plans. For initial experiments, distinct relationship types can isolate representation costs; do not extend eligibility to same-type hops without preserving the current relationship-reuse semantics.

Retain bounded chunks, cancellation, backpressure and correct partitioning. Bound and account for retained memory through DataFusion's facilities. A query returning every path still has to produce every result row, even if earlier work is reduced.

Acceptance

  • Record the supported shapes, representation, DataFusion extension points, expansion boundaries, and a conservative eligibility/cost rule. Unsupported or unprofitable shapes keep their existing plans.
  • Build a focused prototype and compare its results with existing CSR and join execution. Cover repeated bindings, parallel edges, self-loops, empty groups, null predicates, deleted edges, chunk boundaries and supported relationship-reuse rules. Verify fallback for unsupported shapes.
  • Report a table of planning time, execution time, peak memory and intermediate work for the target patterns and controls. Distinguish synthetic experiments from suite results.
  • Before proposing a performance change, follow CONTRIBUTING.md: release wheels from main and the branch, both benchmark suites, both memory and lazy modes, alternating repeated runs, every query and result check reported. Do not change benchmark queries, expected answers or runner settings. Investigate regressions beyond observed run-to-run noise.
  • Conclude whether the measured benefit justifies the implementation and identify the smallest production follow-up, or document why it does not.

Completing this investigation does not close #78. That issue still requires general factorized intermediates between hops. Porting FFX's full engine, general join-order search and its LLM operators are outside this investigation.

Activity

  1. prrao87 commented on Nov 2, 2026

    @prrao87
    MemberAuthor

    Conclusion of the investigation so far: a factorized representation isn't justified by current evidence. The measured wins came from relational fixes in #108, and the queries where lance-csr trails weren't limited by intermediate size.

    Prototypes (standalone DataFusion ExecutionPlans in the unpublished benchmark crate, not wired into the planner; timings exclude loading, index builds and planning):

    Experiment Packed (FFX-style offsets) Control Result
    graph-benchmark q8 count, 2.4M edges 4.0 ms 2.5 ms (direct degree sums) packing slower than plain counting
    graph-benchmark q9 count 5.3 ms 3.7 ms (direct degree sums) same
    Four-hop, two branches, rows returned 4.0 ms 4.6 ms (fused flat walk) about 12% faster, inside run-to-run noise
    Four-hop, skewed, counted 0.045 ms 0.012 ms (direct branch counts) direct counting about 4x faster
    Four-hop, one empty branch, rows returned 0.009 ms 0.17 ms (fused flat walk) 19x faster, from pruning the empty branch, which a cheaper emptiness check also gives

    Every variant matched the existing CSR operators and an edge-table oracle, including duplicate edges and roots, self-loops and empty inputs. Packing cuts leaf visits sharply (4,608 vs 334,080 on the two-branch case), but row output still has to produce every path, so the saved traversal rarely shows end to end.

    How the paper's results map onto lance-csr:

    • Its speedup over Kuzu has two parts. About 1.5–1.7x comes from packed vectors versus list-based processing even when nothing factorizes. lance-csr never had that overhead: DataFusion's batches are already packed. Only the factorization part (5–9x on branched layouts in the paper) would be new.
    • Those factorization wins come from self-joins over one edge relation with intermediates of up to billions of tuples, often with MIN aggregates so the output is never enumerated. The FFX prototype doesn't support outer or anti joins.

    Where lance-csr actually lost. On SF1, IC10 was about 7x slower than Ladybug. Its graph hops took microseconds. The time went to pattern predicates re-planning their input (the plan doubled per predicate) and to list comprehensions copying each row's list onto every element. #108 fixes both relationally: IC10 is −75%, and IC7, whose isNew is a pattern predicate, is −32–36%. The anti and outer joins involved are outside what FFX supports, so the fixes there would be needed even with factorization.

    What would justify revisiting: a query that is slow because it builds a branched many-to-many intermediate with no aggregate to absorb it, such as two independent high-fan-out branches from one node returned or filtered before aggregation. The next step is to measure intermediate row counts across all 44 suite queries. That's not been done yet; only the slow queries (IC10, IC7, IC14, IC1) have been profiled.

    Leaving this open; whether that measurement is worth doing now is a later call. #78 tracks the general mechanism.

    🤖 Posted by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions