Skip to content

Batched attention's backward still joins the softmax normalizer on one key (sequences × heads too many rows inside a step; 12.8 s of 30 s with #220) #222

Closed

Description

@alxmrs

🌀 Claude:

What happens

PR #220 fixes #217's cross product in the contraction's transpose. But a second join in the same backward of batched, multi-head attention still loses the keys that its join makes equal only transitively. This one is inside ddx's steps, so the step tables' row counts don't show it.

The output joins the softmax normalizer norm(seq, h, i, z) as a.seq = n.seq AND a.h = n.h AND a.i = n.i. In the steps window_1, cotangent_0 and cotangent_1, ddx joins norm to the weights on i alone (ON (t6.c2 = t7.c2)). So every weight row pairs with every sequence's and head's normalizer: sequences × heads times the rows it needs. A later join on the sequence and head keys removes the extra rows, so the gradient is still right.

At the GPT's size

This is xarray-sql's GPT demo (xqlsystems/xarray-sql#269) at nanoGPT's CPU size (1 layer, 4 heads, 128 channels, context 64, batch 12). I ran it on DuckDB 1.5.6, with ddxdb built from #220 (fd82ce7), on an Intel i7-8700.

ddxdb 0.4.3 With #220
Training step 66 s 30.3 s
Peak memory 9.2 GB 5.8 GB
cotangent_2 13 s 12.8 s

#220 more than halves the step. What's left is mostly this join:

  • In cotangent_2, saved_6 (the normalizer, 3,072 rows) is joined to the weights (saved_5, 99,840 rows) on one key.
  • That's about 4.8M rows (99,840 × 48), against the 99,840 needed.
  • The step outputs only 98,304 rows, but takes 12.8 s of the 30.3 s, and raises the peak from 2.0 GB to 5.8 GB.

Repro

This counts every relation inside each step, by running the step's SQL up to each CTE. It uses 3 sequences, 2 heads, 8 positions and 4 channels:

"""Batched attention's backward on DuckDB: the largest relation inside each of
ddx's steps, and the joins that pair every sequence and head."""
import re
import duckdb, numpy as np, pyarrow as pa
import ddxdb.duckdb as dd

S, H, T, C = 3, 2, 8, 4  # sequences, heads, positions, channels per head
inner = []
orig = dd.DuckDBBackend.materialize
def materialize(self, name, plan):
    sql = dd.to_sql(plan, "duckdb")
    if sql.startswith("WITH "):
        head = sql[: sql.rindex(") SELECT ") + 1]
        for cte, body in re.findall(r"(__ddx_q\d+) AS \((.*?)\)(?=, __ddx_q\d+ AS \(|$)", head[5:]):
            n = self.con.execute(f"{head} SELECT count(*) FROM {cte}").fetchone()[0]
            on = re.search(r" ON (.*)$", body)
            inner.append((name.split("_", 3)[-1], cte, n, on.group(1) if on else ""))
    orig(self, name, plan)
dd.DuckDBBackend.materialize = materialize

con = duckdb.connect()
idx = np.indices((S, T, H, C)).reshape(4, -1)
for name, phase in (("q", 0.0), ("k", 1.0), ("v", 2.0)):
    con.register(name, pa.table({"seq": idx[0], "pos": idx[1], "h": idx[2], "c": idx[3],
                                 "val": np.sin(np.arange(idx.shape[1]) * 0.37 + phase)}))
LOSS = """WITH scores AS (
  SELECT q.seq, q.h, q.pos AS i, k.pos AS j, SUM(q.val * k.val) / 2.0 AS s
  FROM q JOIN k ON q.seq = k.seq AND q.h = k.h AND q.c = k.c WHERE q.pos >= k.pos
  GROUP BY q.seq, q.h, q.pos, k.pos),
weights AS (SELECT seq, h, i, j, exp(s - MAX(s) OVER (PARTITION BY seq, h, i)) AS p FROM scores),
norm AS (SELECT seq, h, i, SUM(p) AS z FROM weights GROUP BY seq, h, i),
heads AS (
  SELECT a.seq, a.i AS pos, a.h, v.c, SUM(a.p / n.z * v.val) AS val
  FROM weights a
  JOIN v ON a.seq = v.seq AND a.h = v.h AND a.j = v.pos
  JOIN norm n ON a.seq = n.seq AND a.h = n.h AND a.i = n.i
  GROUP BY a.seq, a.i, a.h, v.c),
loss AS (SELECT SUM(val * val) / 2 AS l FROM heads) """
dd.sql(con, LOSS + "SELECT seq, pos, h, c, grad(loss, val) AS g FROM q ORDER BY seq, pos, h, c").fetchall()
print(f"{S * H * T * (T + 1) // 2} weights rows, {S * H * T} norm rows")
for step, cte, n, on in sorted(inner, key=lambda r: -r[2])[:8]:
    print(f"  {step:>14} {cte:>9}: {n:5} rows" + (f"  ON {on}" if on else ""))

Output, with ddxdb built from fd82ce7:

216 weights rows, 48 norm rows
  bb668d12_1_saved_2  __ddx_q2:  1536 rows  ON ((t2.c0 = t3.c0) AND (t2.c1 = t3.c1))
  bb668d12_1_saved_2  __ddx_q7:  1536 rows
  bb668d12_1_contraction_0  __ddx_q3:  1536 rows  ON (((((t3.c3 IS NOT DISTINCT FROM t4.c3) AND ...
  bb668d12_1_contraction_0  __ddx_q4:  1536 rows
  bb668d12_1_window_1  __ddx_q6:  1296 rows  ON (t6.c2 = t7.c2)
  bb668d12_1_window_1  __ddx_q7:  1296 rows
  bb668d12_1_cotangent_1  __ddx_q6:  1296 rows  ON (t6.c2 = t7.c2)
  bb668d12_1_cotangent_1  __ddx_q7:  1296 rows
  • The bad joins: 1,296 = 216 weights × 6 (sequences × heads), joined ON (t6.c2 = t7.c2). cotangent_0 has the same join.
  • The fine ones: the 1,536-row relations are the scores' own join, q ⋈ k (3 × 2 × 4 × 8 × 8), before the causal mask. That's the size expected there.

Expected

The normalizer should join the weights on all three keys, seq, h and i, as #220 now does for the contraction's cotangent. Then each of these relations would have the weights' 216 rows here, and 99,840 at the GPT's size.

Activity

  1. added 2 commits that reference this issue on Oct 28, 2026
    1cfd481
    13c1cc8
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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions