Repository navigation
Add einfold-kernels, with EinFold's hash algorithm on Arrow arrays - #44
Conversation
|
Important Review skippedReview was skipped as selected files did not have any reviewable changes.
|
| Layer / File(s) | Summary |
|---|---|
Fold model and validation crates/einfold-ir/src/fold.rs, crates/einfold-ir/src/lib.rs |
Adds fold metadata for dimensions, operands, outputs, aggregation, and domains. Fold::try_new validates fold descriptions, and the crate re-exports the fold types. |
Kernel API and schema Cargo.toml, crates/einfold-kernels/Cargo.toml, crates/einfold-kernels/src/lib.rs, crates/einfold-kernels/src/keys.rs |
Adds the kernel crate, host float-key policy, schema validation, and Arrow row-key encoding. SignedZerosEqual normalizes signed zero in plain floating-point columns. |
Hash join, grouping, and domain output crates/einfold-kernels/src/hash.rs, crates/einfold-kernels/src/lib.rs |
Adds deterministic ordering and hash matching for two operands. The kernel sums non-NULL products and returns grouped results or rows matched against supplied domain batches. |
Generated cases and SQL oracle crates/einfold-kernels/tests/kernels_vs_sql.rs |
Generates seeded fold inputs and builds equivalent DataFusion SQL, including optional domain joins. |
Kernel execution and result checks crates/einfold-kernels/tests/kernels_vs_sql.rs |
Runs the kernel against the generated cases and checks repeatability, row keys, NULLs, special floating-point values, and bounded numeric differences against SQL. |
Priority:
Estimated code review effort: 4 (Complex) | ~45 minutes
Change: Feature
Sequence Diagram(s)
sequenceDiagram
participant Caller
participant FoldAPI as einfold-kernels fold API
participant HashFold as hash::fold
participant Side
participant Groups as Output groups
Caller->>FoldAPI: Submit fold, operand batches, and optional domain batches
FoldAPI->>HashFold: Delegate fold execution
HashFold->>Side: Validate and order operand rows
Side-->>HashFold: Return shared and output keys
HashFold->>Groups: Index second operand and accumulate matching products
HashFold->>Groups: Match groups to domain rows when configured
HashFold-->>FoldAPI: Return output batches
FoldAPI-->>Caller: Return Algorithm::Hash and batches
Possibly related PRs
- xqlsystems/einfold#43: Defines the
FoldIR types and invariants used by this kernel. - xqlsystems/einfold#37: Adds an earlier hash implementation whose behavior this PR ports to the Arrow kernel API.
- xqlsystems/einfold#42: Defines the engine-independent kernel architecture implemented by this PR.
Merge Risk: 
Minimal · previous review 2c731
-
No concrete issue requiring a fix before merge has been established; proceed with normal checks.
Security architecture risk: 
Low · up to 2c731
-
The new library has limited demonstrated exposure: the identified caller is a test, and execution uses call-local state. Callers remain responsible for validating inputs before execution. No introduced security attack path was established, but production integration and exposure are not fully covered.
Retained concerns
No architecture-level concerns identified.Security review details
-
Security Blast Radius
- inferred — The demonstrated exposure is an in-process computation over caller-supplied Arrow batches, with a test-only caller identified. Production cross-service or tenant reachability is not established; the reported five-dependent count does not by itself demonstrate such exposure.
Security Findings and Attack Paths
- inferred — A caller supplying incompatible metadata or batches could reach a panic instead of a returned validation error. An attacker-controlled route to those arguments is not established, and the identified caller validates schemas first, so this remains an integration constraint rather than a verified denial-of-service finding.
Trust Boundaries and Controls
- observed — The inspected boundary separates host-supplied fold metadata and Arrow data from kernel execution. Structural validation belongs to Fold construction; supported arity, column existence and matching dimension types belong to schema(). These are data-contract controls, not authentication or authorization checks.
Resilience and Maintainability Implications
- observed — Hash indexes, interners, accumulators and output vectors are invocation-local. Normal errors propagate without returning earlier domain batches, and carried Arrow arrays are immutable shared references. Repetition and concurrent invocations therefore do not share accumulator state in the inspected path; malformed-input panics remain outside that normal error guarantee.
Hardening Proposals
- proposed — Before adding an untrusted production query path, bind execution to validated input schemas through a prepared contract or equivalent entrypoint checks. This would make failure containment independent of callers remembering the separate validation sequence.
-
Pre-merge checks: 
4 · 
1
-
Failed checks: 1 warning Passed checks: 4 passed-
Full details: Title check
-
Explanation
The title "Add einfold-kernels, with EinFold's hash algorithm on Arrow arrays" names the main change: a new einfold-kernels crate whose first algorithm is the hash fold path over Arrow arrays. The raw summary confirms the new crate, the schema and fold entry points, and the hash implementation. The title is specific and accurate, and it is a single sentence of 9 words.
Full details: Description check
-
Explanation
The description follows the template. It has the SPDX/template comments, a 'What and why' section, and a 'How it is tested' section. It explains the purpose (a crate of EinFold algorithms over Arrow arrays with no query-engine dependency), the schema and fold API, the domain semantics, the Host float-key policy, and the invariants tested. The 'Closes #' issue link is left blank, and the template's AGENTS.md reference (identity line, issue link, design review) is only partly covered. The 'Closes #' line is an unfilled placeholder. The description also refers to other PRs and issues (
#37,#38,#39,#41,#42,#43,#21) without giving the context needed to understand them. Because the core sections are complete, the check passes, but the author should fill in the issue link.
-
Full details: Docstring Coverage
-
Explanation
Docstring coverage is 56.45% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Documentation-only edits and deletions within surviving functions are included; unchanged diff context and fully deleted functions are excluded. Analyzed 62 functions across 6 files. (2 skipped: 2 unsupported.)
Resolution
Write docstrings for the functions missing them to satisfy the coverage threshold.
-
Finishing Touches: 💡 1
-
📝 Generate docstrings: 💡
-
- Commit to this branch
- Create a new PR
🧪 Generate unit tests (beta)
-
- Commit to this branch
- Create a new PR
-
- Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts
Comment @coderabbitai help to get the list of available commands.
2ae0efe to
3e56b09
Compare
|
🪭 Claude (einfold) A later idea for this hash path, from Haskell: discrimination instead of sorting and hashing. I'm not proposing it for this PR. It's worth a spike once the hash path matters for speed. Today the hash path sorts each operand by its rows' contents (O(n log n)), only to make its order of additions deterministic, then builds and probes hash tables. Fritz Henglein's generic discrimination (ICFP 2008) sorts and partitions by distribution, a generalized radix sort, in worst-case linear time, with no comparisons and no hashing. Henglein and Larsen then built SQL-style equijoins on it, discrimination-based joins, with Cartesian products kept symbolic rather than formed. That is our fold over a join, and they found it competitive with SQL engines. Edward Kmett's Haskell package Why it fits:
Why it waits:
So: a spike, timed against DataFusion's own hash join and aggregate on M1's shapes, before any change here.
|
3e56b09 to
2c7310e
Compare
|
🪤 Claude (fuzz adversary) No bugs found in the hash algorithm. Two searches ran it on inputs its own test doesn't draw:
I also checked by hand that NaN payloads and dictionary-encoded |
I would actually like to explore this path early! Before merging down the stack, let's try this alternative implementation and evaluate it. At the very least, maybe we could guard this by a flag (though, it sounds worthy of being our primary impl). |
|
After learning more by reading PRs, I think it could be factored as another algorithm. |
|
🪭 Claude (einfold) Agreed, and as its own algorithm. Planning it showed a factoring I like: discrimination turns any keys into dense ranks. Henglein's discriminator partitions keys by their bytes, a radix pass per byte with no comparisons and no hashing, so it can give each distinct key a rank,
Keys are already in Arrow's row format, byte strings that compare as the host compares keys, which is what a byte-wise discriminator needs. Plan: spike S30, on |
|
🪭 Claude (einfold) Spike S30 is done (report, on It ranks each dimension's keys by radix discrimination: integers as order-preserving
What I'd propose for the stack, before you merge it:
One choice is yours: once discrimination beats the host on a fold, should |
|
🪭 Claude (einfold) Decision: discrimination is the default where the dense algorithm declines (now on #68, with the design updated on #42). Per your note, I surveyed the queries einfold is meant to serve, then chose. Where a fold appears that the dense algorithm can't take, it's almost always a sparse product on integer ids, the shape where S30 measured discrimination beating DataFusion 1.3–1.9×, with integer and text keys alike:
The shape where discrimination lost, a matrix product whose keys are sparse integers or text, doesn't appear in these queries. The closest is the warp's two-column coordinate keys. Those take discrimination's byte-by-byte path, so ranking each column separately is the next improvement there. What stays on the host's plan: a fold joined back to its domain, where only the host's plan is right if the domain's keys turn out not to be unique; and pipelines, which have no general algorithm yet. |
A new crate holds EinFold's algorithms over Arrow arrays, depending on no query engine, so any engine that speaks Arrow can run them. Its first is the hash algorithm: Gustavson's sparse product generalized to folds, following SQL's bag semantics, group existence and per-key NULL equality, with a domain computed as a plain left join. The host's float-key rule is a parameter, and the output schema is derived here rather than in an engine's node. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M96xSez6PZwTmfaGh2eJgS
The hash path and schema() take DimensionId and ColumnIndex, read each output's domain key from the output, and schema() refuses a fold that isn't an einsum, since the kernels compute SUM of products only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M96xSez6PZwTmfaGh2eJgS
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M96xSez6PZwTmfaGh2eJgS
2c7310e to
c694fc5
Compare
|
LGTM |
🪭 Claude (einfold) Third pull request of the restacked M1 (design v17, #42), stacked on #44. It ports #38's dense algorithm, its review fix included, into `einfold-kernels`, onto the new IR. **When it runs.** A two-operand fold whose every dimension is an output held only by the first operand (x), summed and held by both (z), or an output held only by the second (y). That is a matrix product `out[x, y] = Σ_z a[x, z]·b[z, y]`, with each role's dimensions flattened into one position. Keys must be non-negative integers that fit in `BIGINT`, used directly as positions. Otherwise the hash path from #44 runs. `fold` reports which algorithm ran. **Where SQL and dense arrays differ** (design §8.3): - **Duplicate key tuples, and NULL keys:** the hash path runs. - **Missing or NULL rows of `b`** are held as 0, but only where no factor of `a` is NaN or infinite, since `0·∞` is NaN where SQL forms no product. - **Group existence:** new with the domain. A domain whose default is `+0.0`, `COALESCE(SUM(…), 0.0)` as ddx's gradients write it, makes an unreached group and an all-NULL one look the same, so existence can be ignored. With any other default, or none, `b` must be complete. A group `(x, y)` then exists exactly when `a` has a row at `x`, and its sum is NULL exactly when every factor of `a` there is NULL. - **Domain keys** that are NULL, or outside the operands' range, match no group under either key equality, since the operands have no NULL keys. **Determinism.** As in #38 (spike S24): fixed blocks chosen from the shape alone, 256 output rows per block or 4,096 positions along z, added in block order, with rows visited in position order. `Options::threads` caps the threads, and the bits don't depend on it. About 320 non-test lines. **Invariants tested** (`tests/kernels_vs_sql.rs`, extended). Half the cases come from the dense family, chosen by the seed. Each case now runs twice, each run on 1–8 threads and in its own arrival order, and must give the same bits. The dense path must run in at least a quarter of the dense family; it runs in about 35%. The dense family leans to the `0.0` default ddx uses. <!-- - [x] 400 default cases (dense path in 70 of 200), and a 3,000-case sweep from seed 7100000 (520 of 1,500): all pass - [x] mutations, each caught: ignoring existence for any default; giving unreached cells their 0 sum; wrapping out-of-range domain keys; ignoring the default for unmatched domain rows; letting duplicate rows of b overwrite; using keys too large for BIGINT as positions - [x] fmt, clippy -D warnings, doc -D warnings, reuse lint --> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01M96xSez6PZwTmfaGh2eJgS <!-- This is an auto-generated comment: release notes by coderabbit.ai --> <!-- coderabbit-ai-generated:v1 --> ## Summary by CodeRabbit * **Performance** * Added a faster processing path for eligible folds, with the existing processing path retained for cases that don’t meet its requirements. * **Reliability** * Fold results remain consistent across different input orderings and thread counts. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…els (#46) 🪭 Claude (einfold) Fourth pull request of the restacked M1 (design v17, #42), stacked on #45. It replaces #37's and #38's DataFusion code with a thin layer over `einfold-kernels`, and fixes the operator problems the review of #37 and #38 found. **What's here** - `FoldNode`: a fold in a logical plan. Its inputs are the two operands, then the domain, and its schema is `einfold_kernels::schema`'s. - `EinFoldExec`: runs the kernels, with `HOST` (DataFusion 55's key equality, pinned by `tests/datafusion_behaviors.rs`). `EinFoldPlanner` and `EinFoldQueryPlanner` plan the node. **Behaving like a DataFusion operator** (the review's findings): - **Memory:** it reserves its inputs' memory from the session's memory pool, then the concatenated operands' and the output's, releasing the parts as they're concatenated. It fails with DataFusion's "Resources exhausted" when the pool can't give it the memory, rather than overrunning. - **Off the async runtime:** the kernels run in a blocking task (`SpawnedTask::spawn_blocking`). Before, the dense kernel's scoped threads blocked a Tokio worker. - **Metrics:** `dense_folds`, `hash_folds`, `elapsed_compute` and `output_rows`. - **Still to come:** streaming operands into the dense arrays as they arrive. The operator still holds its inputs whole, now counted against the pool. **Speed.** `examples/matmul_gradient.rs` runs ddx's two `matmul` gradients as a fold with a domain, plus the `CASE` that gives NULL where the domain's value is NULL, against the SQL ddx writes. Best of 5, 12 threads, i7-8700, DataFusion 55.2: | n | gradient | SQL | `Fold` | speedup | |---:|---|---:|---:|---:| | 50,000 | Ā = Ȳ·Wᵀ | 86–88 ms | 10.7–11.0 ms | 8× | | 50,000 | W̄ = Aᵀ·Ȳ | 27 ms | 14.3 ms | 2× | Run back to back, #38's operator took 9.6–10.0 ms and 13.2–14.8 ms on the same shapes. The extra millisecond buys the reservation, the blocking task, and the `CASE`, which is now a projection rather than part of the fold. **Tests.** The kernels' property test (#44, #45) carries the semantics. `tests/operator.rs` adds hand tests of what the operator does: - ddx's gradient through `FoldNode` plus the projection equals ddx's SQL exactly (integer values, so every sum is exact), with 1, 3 or 5 input partitions, 1, 2 or 6 threads, and batch sizes of 5, 64 and 8,192; - `EXPLAIN ANALYZE` shows the fold and `dense_folds=1`; - a 4 KiB memory pool fails cleanly. Writing that SQL turned up a parser quirk: `a IS NOT DISTINCT FROM b AND c` parses as `a IS NOT DISTINCT FROM (b AND c)`, where PostgreSQL binds `IS DISTINCT FROM` tighter than `AND`. The test parenthesizes it. I'll draft it for upstream separately. About 330 non-test lines. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01M96xSez6PZwTmfaGh2eJgS <!-- This is an auto-generated comment: release notes by coderabbit.ai --> <!-- coderabbit-ai-generated:v1 --> ## Summary by CodeRabbit * **New Features** * Added support for planning and running fold operations in DataFusion, including folds with an optional domain. * Fold execution combines partitioned inputs and produces results in batches, with memory tracking and execution metrics. * Added an example comparing fold-based matrix gradient calculations with equivalent SQL queries across configurable input sizes. * Fold operations are available through DataFusion’s query planner and can be inspected in query plans using einsum-style notation. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
🪭 Claude (einfold) Follows #44's discussion and spike S30 (`docs/spikes/s30-discrimination`). EinFold's algorithm for keys the dense algorithm can't take was Gustavson's sparse product over hash tables. It sorted each operand by its rows' contents first, so that the order of additions didn't depend on arrival order. That algorithm ran at 0.03–0.3× of DataFusion on S30's shapes. It is now Gustavson's product over keys **ranked by discrimination**, Henglein's linear-time partitioning by a key's bytes ("Generic Discrimination", ICFP 2008), with no hashing and no comparisons of keys: 1. **Ranks.** Each distinct key of each dimension gets a rank, `0..k`, in key order. The join's keys are ranked over both operands at once, and the outputs' keys with the domain's, so equal keys get equal ranks. - A single integer column with no NULLs is ranked by its values. - Other keys use Arrow's row format, the same host-normalized bytes the hash path used. When every key has one width and at most 8 bytes vary, those bytes are packed into a word. Either way, words go through a least-significant-byte-first radix sort over `(word, row)` pairs on the session's threads, which skips bytes no key varies in. - Any other key takes a most-significant-byte-first radix sort. 2. **Order.** `a`'s rows are put in `(x, z)` order and `b`'s in `(z, y)` order, by counting sorts over the ranks. Rows with equal keys go in order of their factors' bits, so duplicate key tuples add in an order fixed by their contents. 3. **Gustavson's product** over the buckets, in fixed blocks of 256 output ranks on the threads in turn. Each block's groups come out in key order. 4. **Domains:** each domain row's ranks locate its group by binary search among its `x`'s groups, with no hashing. SQL's semantics are the hash path's, unchanged: - bag semantics; - a group exists when any pair reaches it, and its sum is NULL when every product was; - a NULL key under `=` joins nothing, and under `IS NOT DISTINCT FROM` joins NULL. NULL is logical here, so a dictionary's NULL value counts; - the host's comparison of `-0.0` and `0.0`; - a domain is a left join. `Algorithm::Hash` becomes `Algorithm::Discriminate`, `EinFoldExec`'s metric `hash_folds` becomes `discrimination_folds`, and `hash.rs` goes. `DetectFolds::without_fallback` now runs discrimination where the dense algorithm declines. **Invariants checked**, by the existing property tests, which now run this algorithm wherever they ran the hash one: - `kernels_vs_sql.rs` (4,000 cases searched): the same rows as SQL on DataFusion, with: - keys of six types, NULL keys, dictionary NULLs and NaN and `-0.0` keys; - duplicate tuples; - domains with either equality and a default or none; - values within the summation bound; - the same bits on other batchings, orders and thread counts. - `detect_vs_host.rs` (2,000 cases): detected folds through the kernels match DataFusion. Four mutants were each caught within 800 cases: - dropping the duplicate tiebreak (bits); - `=` treated as `IS NOT DISTINCT FROM`; - a NULL sum reported as a value; - existence tied to finiteness. **Speed**, from Arrow rows to Arrow rows on 12 threads, with S30's harness pointed at this branch (ms): | workload, n = 50,000 | keys | this PR | hash path | DataFusion | |---|---|---:|---:|---:| | matrix product, `n × 16 · 16 × 8` | sparse integers | 56 | 1,635–1,719 | 37–49 | | matrix product | text | 85 | 1,058–1,069 | 66–75 | | sparse product, 3.2M output rows | dense integers | 85 | 890–898 | 135–143 | | sparse product | sparse integers | 101 | 1,019–1,030 | 136–148 | | sparse product | text | 152 | 870–909 | 176–185 | At n = 10,000 it's 11–27 ms, against 113–244 for the hash path and 8–35 for DataFusion. So, over these 10 cases at both sizes: - it is 4–30× faster than the hash path in every case; - it beats DataFusion in 6, by 1.3–1.9×: the sparse product, with every kind of key, at both sizes; - it loses in 4: the matrix product with sparse integer or text keys, at both sizes. At 50,000 that's 56 ms against 37–49 and 85 against 66–75; at 10,000, 10.8 against 8.4 and 14.3 against 13.0. **It is now the default where the dense algorithm declines**, rather than the host's plan, as decided on #44 after a survey of the demos' and xarray-sql's queries. Where dense declines in those, the folds are sparse products on integer ids (regridding weights, graph edges, embedding lookups), where it wins. A fold joined back to its domain keeps the host's plan, for when the domain's keys aren't unique. `DetectFolds::with_host_fallback()` restores the old behavior, and `without_fallback()` goes, since it is now the default. <!-- - [x] cargo fmt / clippy -D warnings / test / doc -D warnings / reuse lint --> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01M96xSez6PZwTmfaGh2eJgS <!-- This is an auto-generated comment: release notes by coderabbit.ai --> <!-- coderabbit-ai-generated:v1 --> ## Summary by CodeRabbit * **New Features** * Non-dense folds now use a discrimination-based algorithm when dense execution does not apply; dense execution remains preferred. * Host execution is available as an explicitly selected fallback. * **Changes** * The default fold behavior now uses the kernel-based fallback instead of the host plan. * The fallback algorithm is identified as discrimination rather than hashing. Applications using the previous algorithm name may need updates. * Joined-back folds are now detected regardless of the selected fallback. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
🪭 Claude (einfold)
Second pull request of the restacked M1 (design v17, #42), stacked on #43. It adds
einfold-kernels, a crate of EinFold's algorithms over Arrow arrays with no query-engine dependency. An engine that keeps its data as Arrow arrays can run them: DataFusion now, and a DuckDB extension such as duckdb-zarr later (#39). The first algorithm is the hash path, ported from #37 to the new IR.What's here
schema(fold, operands, domain): the result's schema, with the type checks Add the Fold node and EinFoldExec's hash path #37'sFoldNodemade. The checks now live with the kernels, so every engine gets them. Its columns are named by position ("0","1", …), since a fold names none; the kernels read only the schema's types, so a caller names the columns as it likes.fold(fold, a, b, domain, schema, host): Gustavson's sparse product ("Two Fast Algorithms for Sparse Matrices", 1978), generalized to folds. It follows SQL's bag semantics, group existence (a group reached only by NULL products exists, with a NULL sum), NULL keys per dimension, and a fixed visiting order, so results don't depend on how rows arrive.=orIS NOT DISTINCT FROM, thenCOALESCE(value, missing)if a default is given. Add the Fold node and EinFoldExec's hash path #37's ddx-specific "NULL where the fill's value is NULL" is gone. A projection above the node can express it.Hostcarries what SQL leaves to the engine. For now that'sFloatKeys: whether-0.0and0.0are one key. DataFusion 55 says yes, except for dictionary-encoded floats, which einfold#41 drafts for upstream.About 380 non-test lines.
Invariants tested (
tests/kernels_vs_sql.rs, ported from #37 and #38). On random folds of one to four dimensions, withBIGINT,VARCHAR,DOUBLEand dictionary keys, NULL keys, duplicate tuples, missing rows, and factors that are NULL, NaN, ±inf,-0.0or ill-conditioned, half of them with a domain:LEFT JOINwithCOALESCE;The oracle writes
IS NOT DISTINCT FROMas=on(key IS NULL, COALESCE(key, sentinel)), which avoids mixing both kinds of equality in one join chain (#21).🤖 Generated with Claude Code
https://claude.ai/code/session_01M96xSez6PZwTmfaGh2eJgS
Summary by CodeRabbit