Skip to content

Run folds with batch dimensions densely, and small folds on one thread - #86

Merged
alxmrs merged 2 commits into
mainfrom
m2/batch-dims
Oct 29, 2026
Merged

alxmrs merged 2 commits into
mainfrom
m2/batch-dims

Conversation

@alxmrs

@alxmrs alxmrs commented Oct 28, 2026 •

Copy link
Copy Markdown
Member

🪭 Claude (einfold)

Part of #81: the GPT in xarray-sql's ML demos (xarray-sql's benchmarks/ml/02_nanogpt.py, a GPT written and trained in SQL) runs slower with einfold on.

What was slow. einfold detects 338 folds in the GPT's loss. 80 of them are causal attention's masked scores, one per batch and head:

SELECT q.b, q.h, q.t AS i, k.t AS j, SUM(q.x * k.x) FROM q JOIN k
  ON q.b = k.b AND q.h = k.h AND q.d = k.d WHERE q.t >= k.t GROUP BY q.b, q.h, q.t, k.t

Here b and h are batch dimensions: join keys that are also grouped by. The dense algorithm had no role for them and declined. These folds keep the host's plan, because the discrimination algorithm computes no masks. So each ran the host's plan, and that reads the fold's whole subtree again. In the loss, every one of the 80 fell back, on every run.

What changed.

  • Batch dimensions. The dense algorithm now takes them as a fourth role, W: out[w, x, y] = Σ_z a[w, x, z] · b[w, z, y]. A row of a and of the output is now a position of W and X together. b is held as one [z][y] matrix per batch position, and each output row multiplies its own batch's matrix. Existence, NULLs, masks and the domain work as before, with that row in place of X.
  • Small folds on one thread. Folds with fewer than 65,536 input rows now run on the calling thread. Starting threads cost more than these folds did: it was about half of the kernel time across the GPT's 338 small folds. Results don't depend on the number of threads, so this changes no bits.

Measured on the GPT's loss at the demo's size, with the einfold wheel through datafusion-python, on an Intel i7-8700. The machine had other jobs running, at a load average of 5–8:

execution only before after
einfold off 0.35–0.41 s (same)
einfold on 0.69–0.73 s 0.48 s
folds that fell back to the host's plan, per run 80 0
einfold's kernel time, per run 198 ms 100 ms

Planning is unchanged: about 0.8 s more than einfold off, half of it in planning the fallbacks of these 80 folds, which now never run. The next pull request in this stack plans a fallback only when it runs.

Tests.

  • fold_matches_sql, which checks the kernels against the SQL a fold stands for on random folds over random tables, now gives the dense family batch dimensions too. 30% of the dimensions held by both operands become outputs. It checks the same invariants as before: the same rows, values within float summation's bound of SQL's, the same bits for any batching and thread count, and unique domains.
  • It now also checks that the dense path ran in some case with a batch dimension. In 8,000 cases from two seeds, it ran in about 100 of them, and every case passed.
  • A hand test, masked_folds_over_heads_run_dense, runs causal attention's scores over two heads with einfold on. It checks that the answer is DataFusion's, that the dense algorithm ran, and that the host's plan didn't.

After CodeRabbit's review

  • The property test has a large batched case, BATCHED, with 66,560 rows in a. That's past the size the dense algorithm runs on one thread, so its batches accumulate in parallel.

🤖 Generated with Claude Code

https://claude.ai/code/session_01M96xSez6PZwTmfaGh2eJgS

@coderabbitai

coderabbitai Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Caution

Review failed

The pull request is closed.

Walkthrough

The dense fold now supports shared dimensions as output batches. Small folds can run on the calling thread. Tests cover batched dense execution and masked two-head results.

Changes: Batched Dense Folds

Layer / File(s) Summary
Batch-aware dense fold
crates/einfold-kernels/src/dense.rs
The fold assigns shared output dimensions to W, lays out the second operand by W, Z, and Y, and accumulates each output row from its matching batch. Folds below FEW_ROWS use one thread, and par_tasks runs single-threaded tasks on the calling thread.
Batched fold validation
crates/einfold-kernels/tests/kernels_vs_sql.rs, crates/einfold-datafusion/tests/detect_vs_host.rs
Randomized cases can use a shared output dimension, and tests check batched dense runs. A masked two-head test compares results with DataFusion and checks that the plan uses one dense fold without a host-plan fallback.

Priority: Normal

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Feature · Unblocks: 12 PRs

Possibly related issues

Merge Risk: Low · previous review 3e005

A shortened property-test run can fail despite correct kernel behavior. The risk is limited to test configurations; make a large case reliably dense-eligible to remove it.

Security architecture risk: Moderate · up to 3e005

Queries newly optimized by this change can allocate gigabytes outside their configured memory budget before limits are checked, risking process-wide memory exhaustion. No privilege or data-access expansion was identified.

Retained concerns

  • Medium · security · inferred: Newly eligible batched queries enter an existing allocation path that does not reserve dense intermediate memory. Without a domain, output admission does not bound output size relative to input size, and accumulation retains all output blocks before allocating the final output array. A modest input can therefore trigger gigabytes of allocation before output memory accounting occurs. The base rejected these shared-output-dimension folds and used the host fallback; this PR expands exposure to the unreserved path.
Security review details

Security Blast Radius

  • inferred — A caller able to execute the newly supported join-and-aggregate shape over suitable integer-keyed data can drive allocations beyond a configured query memory pool. Exhaustion can affect the hosting process and concurrent work. Cross-tenant deployment, credentials, and additional service exposure are not established by the available evidence.

Security Findings and Attack Paths

  • inferred — A source-derived example uses two W batches, 16,384 X positions, 16,384 Y positions, and one Z position, with complete, unique, finite factors and no domain. Each operand needs 32,768 rows, but the admitted output contains 536,870,912 cells. Accumulation retains approximately 4 GiB of blocks and then allocates another approximately 4 GiB output vector before memory-pool accounting. This is an inferred failure path, not an executed reproduction; the batching change newly admits this query class to the pre-existing unreserved allocator.

Trust Boundaries and Controls

  • observed — Dense eligibility rejects invalid integer keys, overflowing dimension products, excessive cell counts, duplicates, and unsupported NULL or missing-factor combinations. W uses consistent dimension identity and ordering across operands. These controls address indexing and result semantics, but the output cell ceiling is not a byte reservation and does not enforce the session memory budget.

Resilience and Maintainability Implications

  • observed — Cancellation does not immediately stop an already-running kernel. The existing shared reservation keeps its inputs counted until that kernel ends. The PR preserves this lifetime control, but it neither accounts for temporary dense buffers nor makes cancellation a containment mechanism for their allocation.

Hardening Proposals

  • proposed — Make dense admission reserve a checked estimate of peak intermediate and output memory, including simultaneously retained blocks and the combined output. Decline to the existing fallback before allocation when that reservation cannot be obtained.
Pre-merge checks: Passed 5
Check name Status Explanation
Title check Passed The title clearly summarizes both primary changes: dense execution for folds with batch dimensions and single-thread execution for small folds.
Description check Passed The description is detailed and covers the user impact, design changes, measurements, tests, and follow-up work. It does not use the template headings consistently, and it provides “Part of #81” inste…
Docstring Coverage Passed Docstring coverage is 83.33% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentati…
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Description check

Explanation

The description is detailed and covers the user impact, design changes, measurements, tests, and follow-up work. It does not use the template headings consistently, and it provides “Part of #81” instead of a completed “Closes #” entry, but these omissions do not prevent review.

Full details: Docstring Coverage

Explanation

Docstring coverage is 83.33% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentation-only edits and deletions within surviving functions are included; unchanged diff context and fully deleted functions are excluded. Analyzed 6 functions across 3 files.

Finishing Touches
📝 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.

alxmrs added a commit that referenced this pull request Oct 29, 2026
🪭 Claude (einfold): This is #91's commit, moved onto `main` so that it
can merge ahead of the stack it was part of. #91 is closed in its favor.

Answers #79's second ask, and opens its third, as design v18.

**The goal.** For models trained in SQL, such as the GPT in the ML demos
of xarray-sql, an XQL Systems project for querying xarray datasets with
SQL (its `benchmarks/ml/02_nanogpt.py`), memory limits model size as
much as time does. Principle 9, "never more memory than the host", says
how einfold's operators keep to it. They reserve what they read, and
what their kernels allocate, from the host's memory pool, before
allocating it. Where the pool can't give it, they run the host's own
plan, whose operators can spill to disk. Every benchmark measures peak
memory next to time.

**What changes in the design.**

- **Section 4** adds principle 9.
- **Section 10.2** gains a bullet on memory: what `EinFoldExec` does
(#89, #90), and what comes next. The positional fold should stream its
larger operand in row blocks, so that its peak stays below the host's
even without a pool to enforce it. That needs each dimension's extent
before reading, and #80's dictionaries answer it: the dictionary's
length, when it is the whole axis. Exact statistics or a first pass over
the keys also give it.
- **Section 15** reports peak memory next to time, which
`python/benchmarks/memory.py` (#85) does for the Python extension.
- **Section 14** opens #79's third ask, dense chains of folds: handing
the dense form from one einfold operator to the next, so the joined
intermediates never exist as rows. It lists what is open: the form
handed on, how a host operator between two folds reads it, and when a
chain is worth keeping dense.
- **`lessons.md`** records what running einfold under the GPT taught
(#77, #79, #81):
  - profile through the real bridge;
  - report statistics, and cache them;
  - plan a fallback only if it runs;
  - batch dimensions are common.

**Where the stack stands on #79**, at its size (128 channels, context
64, batch 12, one layer), through datafusion-python on an Intel i7-8700:

| | DataFusion alone | einfold, before #86 | einfold, with #86–#90 |
|---|---:|---:|---:|
| the loss | 108 s, 8.9 GB peak | 42 s, 11.8 GB | 13.6 s, 6.9 GB |
| the gradients | 36.5 s, 9.8 GB | | 4.2 s, 1.8 GB |

The peak is above the loaded tables. So the GPT already keeps to the
principle at this size. Streaming in row blocks would make that hold by
construction, not only under a pool.

🤖 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

* **Documentation**
* Updated the design guidance to cover host-managed memory use, fallback
behavior, peak-memory benchmarks, and current limitations around
whole-input processing.
* Added lessons on measuring end-to-end performance, reporting row-count
statistics, delaying fallback planning until execution, and supporting
shared batch dimensions.
* Clarified that success benchmarks compare speed while keeping memory
use within the host’s plan.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Claude Opus 5.5 <[email protected]>
@alxmrs
alxmrs force-pushed the m2/batch-dims branch 2 times, most recently from d9e7eee to 9231bc9 Compare October 29, 2026 07:54
@alxmrs
alxmrs force-pushed the m2/memory-bench branch 2 times, most recently from 8dd9912 to 9740fdd Compare October 29, 2026 21:07
@alxmrs
alxmrs force-pushed the m2/batch-dims branch 2 times, most recently from 102a7a0 to cfe05fd Compare October 29, 2026 22:53
@alxmrs
alxmrs force-pushed the m2/memory-bench branch 2 times, most recently from 5e7b53b to 834c94e Compare October 29, 2026 22:53
@alxmrs
alxmrs force-pushed the m2/batch-dims branch 2 times, most recently from 1dc4ce5 to d9b426d Compare October 29, 2026 22:53
@alxmrs
alxmrs changed the base branch from m2/memory-bench to main October 29, 2026 22:54
alxmrs and others added 2 commits October 29, 2026 15:54
A fold's dimension held by both operands and grouped by, such as a
multi-head model's batch and head, gave the dense algorithm no role, so
it declined. The GPT in xarray-sql's ML demos has 80 such folds in its
loss, causal attention's masked scores per batch and head, and each ran
the host's plan, which reads the fold's whole subtree again.

The dense algorithm now takes them as a batch of matrix products: b is
held as one [z][y] matrix per batch position, and each output row
multiplies the matrix of its batch.

Folds with fewer than 65,536 input rows now run on the calling thread:
starting the threads cost more than the fold, about half of einfold's
kernel time in the GPT's 338 small folds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01M96xSez6PZwTmfaGh2eJgS
@alxmrs
alxmrs merged commit 34d20e1 into main Oct 29, 2026
@alxmrs
alxmrs deleted the m2/batch-dims branch October 29, 2026 22:54
alxmrs added a commit that referenced this pull request Oct 29, 2026
🪭 Claude (einfold)

Part of #81: planning the GPT of xarray-sql's ML demos (xarray-sql's
`benchmarks/ml/02_nanogpt.py`, a GPT written and trained in SQL) took
longer with einfold on.

**Why.** Some fold nodes keep the host's own plan, to run where the
dense algorithm declines: masked folds, folds joined back to their
domain, fold pipelines, and every fold under
`DetectFolds::with_host_fallback`. `EinFoldPlanner` planned that
fallback along with the operator. A fallback is the fold's whole
subtree, so this planned the operator's inputs a second time, and within
them every inner fold's fallback again. In a deep plan that grows with
the square of the depth. The GPT's loss has 80 such folds, and with #86
none of them falls back, yet planning their fallbacks took 0.5 s of the
loss's 1.45 s of physical planning.

**What changed.** `EinFoldExec` keeps its fallback as a logical plan,
and plans it only when it runs. It plans it for the task that runs it:
with that task's session configuration and runtime, and DataFusion's
default physical optimizer rules. The rules are the one difference from
before: a session's own physical optimizer rules don't reach a fallback
now. einfold's Python extension already plans with the default rules,
since the session's rules see only proxies through the FFI.

`EinFoldExec::with_fallback` now takes a `LogicalPlan`, and `fallback()`
returns one. A fallback planned when it runs starts with fresh state, so
the operator no longer resets it, and its `reset_state` is DataFusion's
default again.

**Measured** on the GPT's loss at the demo's size, through
datafusion-python, on an Intel i7-8700. Each wheel ran twice,
alternating, while another job compiled, at a load average of 3–5:

| | #86 | this pull request |
|---|---:|---:|
| einfold's physical planning, inside the extension | 1.45 s | 0.9 s |
| the loss's planning, seen from Python (einfold off: 2.5 s) | 3.4–3.5 s
| 2.9 s |
| the loss's execution, after the first run (einfold off: 0.35–0.40 s) |
0.47–0.53 s | 0.57–0.58 s |

The executions are within the noise of the machine. Most of the planning
gap that's left is DataFusion's FFI: the session sends the logical plan,
about 10,000 lines of it, to the extension as protobuf (#58).

**Tests.** The existing tests of fallbacks pass unchanged:
- recursive queries whose fallback runs on every iteration;
- plans that are reset and run again;
- unique-domain checks that decline;
- the property test of detection against the host, with fallbacks on.

The two tests that checked a fallback holds no `EinFoldExec` now check
that its logical plan holds no fold node. The Python extension's tests
pass too.

## After CodeRabbit's review

- **A deferred fallback is planned with the planning session's
`ExecutionProps`,** so variables (`@name`) resolve there as they did in
the query. `fallbacks_plan_with_the_sessions_variables` failed before.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01M96xSez6PZwTmfaGh2eJgS

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant