Skip to content

#56 Embedder — close threads long-n gap (attention/RoPE fuse) - #57

Merged
tps-flint merged 6 commits into
mainfrom
cursor/56-threads-long-n-gap-a732
Sep 4, 2026
Merged

tps-flint merged 6 commits into
mainfrom
cursor/56-threads-long-n-gap-a732

Conversation

@heskew

@heskew heskew commented Sep 4, 2026 •

Copy link
Copy Markdown
Member

Plan first (before kernel / coordinator code)

Lever 1 — parallelize attention / scores with the same column-split discipline (head-aligned). RoPE, LN, and SwiGLU stay on the coordinator (#28 WASM n=502: 0.10% / 0.17% / 0.23%). Joins already amortize at long-n — not the headline.

First PR comment + docs/wasm-threads-56-plan.md (8551bed, plan-only).

What landed

  • Phase A2: TY_ATTN, column_range(n_embd, w, W, head_dim), same serial Q@K / softmax / vmix_axpy.
  • ATTN_PARALLEL_MIN_TOKENS = 32: A2 join on this KVM host is not ~1 µs; unconditional A2 regressed the 8-case set ~60%. Below n=32 stay on attention_named (bit-exact). Long-n n=502 always splits.
  • Four-artifact loader unchanged. W=1 / SAB-absent / MILTON_THREADS=1 stay serial.
  • Threads wasm: CI blobs are the build of record (e4af2cf). Single-thread artifacts already matched CI.

Held vs main b89d446 (this VM)

Host: Linux x86_64, 4× Intel Xeon (KVM), Node v22.14.0, SAB, availableParallelism=4.
node harness/scripts/bench-held.mjs

lane main short 8 HEAD short 8 short Δ main long n≈502 HEAD long long vs main
product (threads+relaxed W=4) 297.23 ms 294.34 ms −1.0% 3242.01 ms 2287.39 ms 1.42×
single (MILTON_WASM_THREADS=0) 1092.86 1077.69 −1.4% 8727.49 8566.10 1.02×
MILTON_THREADS=1 1128.21 1080.50 −4.2% 8751.59 8526.53 1.03×
threads simd128 299.73 292.17 −2.5% 3232.50 2223.49 1.45×
  1. Long ≥1.35× met (1.42× product).
  2. Short / 8-case no >2% regression met.
  3. Single-thread within 2% met.
  4. Fallback lanes green and within 2% met.

Flint pairs M4.

Stage tables (npm run profile:stages:threads)

workers case n total ms matmul % attn % attn ms RoPE ms LN ms SwiGLU ms
1 short-hello-document 7 107.56 98.5% 0.5% 0.50 0.195 0.221 0.459
1 long-repeated 502 8823.17 83.0% 15.9% 1400.20 16.546 22.575 37.880
4 short-hello-document 7 41.45 95.9% 1.2% 0.51 0.228 0.229 0.457
4 long-repeated 502 2708.01 70.9% 26.6% 720.12 10.839 17.980 34.102

W=4 long attention 1400→720 ms (1.94×). RoPE/LN/SwiGLU stay coordinator and not material. Short n=7 attention stays serial (min-n gate).

How verified

node harness/scripts/bench-held.mjs          # product + fallbacks, paired vs b89d446
npm run profile:stages:threads               # 1 and 4 workers, short + long-n
npm run wasm:compare-verdict:threads         # 18/18 max_abs=0 wasm_artifact=threads workers=4 kernel=relaxed
npm run wasm:compare-verdict:threads:simd128 # 18/18 max_abs=0 workers=4 kernel=simd128
MILTON_WASM_THREADS=0 npm run wasm:compare-verdict  # 18/18 max_abs=0 single relaxed
npm run wasm:must-fail                       # RED (named layernorm/pooling/dropped-prefix)
npm run wasm:sab-absent                      # single-thread ordinary path
npm test                                     # 114 JS + 60 rust + 3 metadata

CI GREEN on e4af2cf (33828867105): wasm byte-compare + section-diff + wasm:gate:threads; wasm-compare / wasm-compare-node26 threaded product path max_abs=0 under Node 22 and 26; must-fail RED.

No epsilon / goldens / expected.json change.

Assignee

gh pr edit 57 --add-assignee heskew returned 403 (replaceActorsForAssignable). Token cannot assign.

Stay draft. Off merge. Cos sends Reviewer later. Do not request tps-flint / tps-kern / tps-sherlock.

HEAD: e4af2cfedbab7068f9f97a0014eea9bcd5a9ab3d

Refs #50 #44 #43 #53.

Open in Web Open in Cursor 

Plan-only. Names lever 1 (column/head-split attention) before any
kernel or coordinator code. Stage-time accounting from the #28 tables
at short vs long-n. Refs #50 #44 #43 #53.

Co-authored-by: Nathan Heskew <heskew@users.noreply.github.com>
@cursor

cursor Bot commented Sep 4, 2026

Copy link
Copy Markdown

First comment — lever, split, stage accounting (before any kernel / coord code)

Lever 1: parallelize attention / scores across workers with the same column-split discipline (head-aligned). That is the chip.

lever stage (WASM #28 n=502) share of single-thread wall on coordinator today? this PR
1 attn_qk + softmax + V-mix 9.1% (880.91 + 98.15 + 378.94 ms) yes move to workers
2 RoPE 0.10% (15.59 ms) yes leave — not material
3 LN 0.17% (24.94 ms) yes leave — not material
3 SwiGLU 0.23% (34.13 ms) yes leave — not material
4 48 phase joins amortized at long-n (#50) n/a do not chase

Why not 2/3/4 as the headline: RoPE / LN / SwiGLU together are 0.50% of single-thread long-n. After #50/#53 they are still tens of milliseconds against ~1.4 s of serial attention. Joins already amortize; adding one attention phase is +12 waits/token (~12 µs), not the limiter. Retuning #50/#53 kernel letters is out of scope.

Short-n (WASM #28 n=7): attention is 0.27% of wall. Parallelizing it cannot regress the 8-case set by more than timer noise.

Stage-time accounting, short vs long-n (a-priori, #28 tables)

Committed #28 WASM SIMD128 profile (harness/profile/stage-profile.tables.md). Shares are the roofline input. Absolute ms/token is re-measured on this PR against b89d446.

stage n=7 ms n=7 % n=502 ms n=502 %
attn_qk 0.225 0.118 880.91 5.89
attn_softmax 0.156 0.082 98.15 0.66
attn_vmix 0.126 0.066 378.94 2.53
attn total 0.507 0.27 1358.00 9.08
rope 0.188 0.099 15.59 0.10
layernorm 0.346 0.181 24.94 0.17
ffn_swiglu 0.463 0.24 34.13 0.23
matmul (5 sites) 189.16 99.1 13503.81 90.3

Attention is the only sequential stage that grows with n² (ms ratio ≈2679 vs n² 5143). RoPE/LN/SwiGLU stay linear in n and stay <0.3% each.

After #50/#53 the leftover is a larger share of threaded wall

T_matmul_par ≈ 0.903 / 3.3 / 1.2 = 0.228 of single-thread wall
T_attn       = 0.091
T_other_seq  = 0.006
T_main_thr   ≈ 0.325 of single-thread wall
attn share of main threaded wall ≈ 28%

speedup vs main product-path long-n ≈ 1 / ((1 − a) + a/S)

S on attention a = 0.28 vs 1.35× held
2.0 1.16× miss — explain
3.3 1.24× miss — explain
4.0 1.27× miss — explain
4.0 and a = 0.40 1.38× in range

The Cos 1.35× held is optimistic versus the #28 shares. This PR does not promise 1.35× from that table. It promises a measured stage table (1 and 4 workers; short and long-n) on this host, and a held-met or a miss explained by that table.

Split design

New atomic phase A2 between today's A (QKV → bias, split, RoPE) and B (out-proj). Same epoch Release / workers_done Acquire as #44. No JS postMessage per site.

attn_out is [n_tokens, n_embd] with heads concatenated (12 × 64 = 768). Reuse column_range(n_embd, worker, W, head_dim). W=4 → 3 heads / 192 cols each, leftover none. W=1 never enters the pool — existing attention_named.

Per (h, tq) the worker runs exactly today's body: serial dot += q*k, softmax_inplace, vmix_axpy (mul+add). Same Q/K/V bytes. Same op order. That is the max_abs=0 argument. Scores scratch is coordinator-owned, W * n_tokens, disjoint rows. No worker alloc. No new unbounded unsafe. Races: disjoint writes to attn_out columns + immutable Q/K/V reads.

RoPE is not fused into the Q/K write and is not worker-local. Fusing it into A2 is bit-exact and would cost zero extra joins, but 0.10% cannot close a 1.35× gap. Leave it; say so in the table.

Joins / token: 60 (5×12) vs today's 48. ~60 µs/token. Immaterial at n=502.

Four-artifact / SAB-absent / MILTON_THREADS=1 discipline unchanged. CI threaded product path (Flint ASK 2) stays the gate.

Full text: docs/wasm-threads-56-plan.md on 8551bed. No kernel / coordinator code in that commit.

Refs #50 #44 #43 #53.

cursoragent and others added 4 commits September 4, 2026 02:02
Parallelize coordinator attention across workers with the same
column-split discipline (align=head_dim). Serial Q@K / softmax /
vmix_axpy unchanged. RoPE, LN, and SwiGLU stay on the coordinator.
W=1 and the single-thread artifact keep attention_named. Refs #50 #44.

Co-authored-by: Nathan Heskew <heskew@users.noreply.github.com>
Unconditional A2 regressed the 8-case set ~60% on this KVM host.
Same attention_named body below the floor; long-n still head-splits.
Refs #50 #44.

Co-authored-by: Nathan Heskew <heskew@users.noreply.github.com>
Remapped scripts/build-wasm.sh. CI is the build of record if a
local rebuild diverges (build-std). Refs #56.

Co-authored-by: Nathan Heskew <heskew@users.noreply.github.com>
profile:stages:threads on the PR host. RoPE/LN/SwiGLU stay coordinator
and not material. Refs #56.

Co-authored-by: Nathan Heskew <heskew@users.noreply.github.com>
@cursor

cursor Bot commented Sep 4, 2026

Copy link
Copy Markdown

HEAD green — plan-first + stage tables + held met

HEAD 44bd23eb16593fb74a8a6ab351feda036453b518. Stay draft. Parent can ping Cos for Reviewer (Flint M4-pairs).

Lever (unchanged)

A2 head-split attention. RoPE / LN / SwiGLU left on the coordinator. n < 32 stays serial so the A2 join does not tax the 8-case set.

VM held vs main b89d446 (node harness/scripts/bench-held.mjs)

lane main short HEAD short main long HEAD long long ×
product threads+relaxed W=4 297.23 294.34 3242.01 2287.39 1.42×
single 1092.86 1077.69 8727.49 8566.10 1.02×
MILTON_THREADS=1 1128.21 1080.50 8751.59 8526.53 1.03×
threads simd128 299.73 292.17 3232.50 2223.49 1.45×

profile:stages:threads

W=1 long: 8823 ms, attn 1400 ms (15.9%). W=4 long: 2708 ms, attn 720 ms (26.6%). RoPE 11–17 ms, LN 18–23 ms, SwiGLU 34–38 ms — left on coordinator.

Compare / must-fail

wasm:compare-verdict:threads PASS 18/18 max_abs=0 wasm_artifact=threads workers=4. Same for threads simd128 and single relaxed. wasm:must-fail RED named. wasm:sab-absent single.

Refs #50 #44 #43 #53.

Single-thread artifacts already matched CI. Threads + threads-relaxed
differed at same section sizes (build-std name-section). CI is the
build of record. Refs #56.

Co-authored-by: Nathan Heskew <heskew@users.noreply.github.com>
@cursor

cursor Bot commented Sep 4, 2026

Copy link
Copy Markdown

CI on HEAD e4af2cfedbab7068f9f97a0014eea9bcd5a9ab3d is green: test, dequant, embed-native, wasm (byte-compare + section-diff + wasm:gate:threads), wasm-compare (threaded product path Node 22, max_abs=0), wasm-compare-node26 (threads 18/18 + must-fail). Stay draft.

The earlier wasm RED was f4362ba (source before the wasm commit) and 44bd23e (local threads blobs vs CI build-std). e4af2cf is CI's threads blobs.

Refs #50 #44 #43 #53.

@heskew heskew left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVE of e4af2cf vs #56.

Head-split attention on the threaded pool (lever 1). Prior REQUEST CHANGES of 44bd23e folded (CI threads blobs as build of record). Held: long-n product 1.42× (≥1.35×); short −1.0%; single −1.4%. CI 33828867105 SUCCESS. Same builder bc-62d6b452. Flint pairs M4.

Cos undrafts for Bugbot and requests tps-flint, tps-kern, tps-sherlock. Off merge.

@heskew
heskew marked this pull request as ready for review September 4, 2026 02:23

@tps-kern tps-kern left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVED. All five disprove points:

  1. HEAD ALIGNMENT — HOLDS, exhaustively verified. column_range (crate/src/qmatmul.rs:62-80) works in units = n_out/align with the last worker taking n_out. run_attn (crate/src/wasm_pool.rs:229-264) calls it with n_out=n_embd, align=head_dim, then h_start=col_start/head_dim, h_end=col_end/head_dim. I enumerated every (n_heads 1..24 × head_dim {32,40,64,80,96,128} × W {2..16}): ranges are always disjoint, covering (union = n_embd), and both endpoints multiples of head_dim — a head CANNOT straddle a worker because n_embd is by definition a multiple of head_dim and the unit math never produces a non-aligned endpoint except the last worker's n_out (itself aligned). Degenerate n_heads < W (e.g. 1 head, 4 workers): workers get empty [a,a) ranges, handled by the col_start >= col_end early return (wasm_pool.rs:232-234); coverage still complete; no head is dropped or double-counted. Non-divisible (5 heads, W=4): worker 3 takes heads [3,5). The unit test at qmatmul.rs:1619-1626 pins W=4/n_embd=768/align=64, and ops.rs:641-682 pins split-equals-serial bit-exactness with n=5 tokens, 12 heads, 4 workers.

  2. BIT-EXACTNESS FOR ALL n — HOLDS BY CONSTRUCTION, with one noted gap. The serial path now DELEGATES: attention_named's non-dump branch is literally attention_heads(q,k,v,...,0,n_heads,...) (crate/src/ops.rs:401-405). There is ONE loop body. The parallel path is the same function with a head subrange. Bit-exactness at any n is structural — same ops, same order — not just tested at the 18 compare points. The dump branch (native-only, MILTON_DUMP=1) keeps the old inline body for the kq dumps; wasm32 compiles dump=false so the product path is single-body everywhere. GAP (not blocking): the ATTN_PARALLEL_MIN_TOKENS=32 gate itself is not crossed by any test — the corpus jumps from n≤19 (serial) to n≈502 (parallel); no case lands at n=31/32. Because the two paths are the same function the risk is nil, but a boundary-crossing compare case (a text tokenizing to 32±1 through the THREADS artifact) would pin the gate mechanically. Worth a follow-up case in the corpus.

  3. MIN_TOKENS=32 — yes, a host-tuned literal (crate/src/model.rs:431), tuned on one KVM host whose join cost (~0.25 ms/layer-join, ~24 ms/embed over the 8-case set) is far above the #44 ~1 µs model. The failure mode is bounded: below 32 the serial path is bit-identical so correctness is unaffected; above 32 the n² growth of attention dominates the constant join cost, so the window where the literal is wrong on a pathological (even-slower-join) host is narrow (roughly n 32..100). On faster-join hosts 32 is merely conservative (leaves a small win unclaimed). It should become MILTON_ATTN_MIN_TOKENS (env override, default 32) or a one-time measured crossover like the Q4_K calibration — same discipline the repo already has for exactly this class of constant (applyQ4kPolicy). Follow-up, not blocking: it is a performance-portability constant, not a correctness one.

  4. MID-RUN WORKER FAILURE — inherited, not new. A worker dying mid-attention leaves workers_done short and the coordinator's wait_i32 (wasm_pool.rs:107-112, timeout -1) never wakes: a HANG, not a silent success. This is the same failure shape #44's matmul dispatch already has — panic_immediate_abort + a dead worker = a wedged embed, on both the old and new phases. The #55 report contract is about LOAD failures and is intact: a mid-run death is an embed-time failure, and the reports correctly continue to describe the last successful load (which is what is loaded). No new silent path. The pre-existing hang hazard (any phase) is worth a heartbeat/timeout follow-up someday, but it is out of #56's scope.

  5. FALLBACKS — exercised and leak-proof. attention_layer's split is behind #[cfg(all(target_arch = "wasm32", feature = "wasm-threads"))] (model.rs:446-447): the single-thread artifacts do not contain the dispatch at all, so W=1/SAB-absent/MILTON_THREADS=1/MILTON_WASM_THREADS=0 cannot reach it. On the threads artifact, the second gate is pool_live() = worker_count() > 1 (wasm_pool.rs:101-103) — a W=1 pool structurally cannot enter. CI: the threads-simd128 fallback lane (MILTON_RELAXED_SIMD=0 + threads W=4) DOES cross the split and asserts max_abs=0 — the split itself is under tight compare on both Node versions; the single lanes (MILTON_WASM_THREADS=0) run serial; wasm:sab-absent runs the SAB-absent child; MILTON_THREADS=1 tests assert artifact=single + wasm=milton_bg.wasm. All green on e4af2cf (run 33828867105).

Held numbers noted: 1.42× long-n vs main (bar 1.35×), short −1.0%, single 1.02×, simd128-threads 1.45× — met, on the KVM host; Flint pairs M4 separately. Stage table consistent with the plan's Amdahl argument (attention 1400→720 ms at W=4, 1.94× on the stage).

@tps-flint tps-flint left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVE on e4af2cf — M4 pair confirms the KVM numbers; long-n bar met on both lowerings.

Paired on this host against main b89d446, quiet box, 3 rounds, both blobs from CI:

Apple M4 (NEON), paired 3 rounds base b89d446 head e4af2cf ratio best / median
product (threads auto, W from availableParallelism), 8-case short, ms 197.4 197.3 1.001× / 1.000×
product, long-repeated n=502, ms 2103.0 1480.1 1.421× / 1.412×
threads OFF (MILTON_WASM_THREADS=0), 8-case short, ms 566.3 565.8 1.001× / 0.997×
threads OFF, long n=502, ms 4674.5 4685.0 0.998× / 0.998×

The two held numbers agree to the second decimal (KVM 1.42×, M4 1.42× long-n), the short set is flat on both, and the serial path is unchanged within noise. One transient: the threads-off control's first long-n pass read 0.859× and the script's automatic rerun gave 0.998×, so the 0.859 was a one-off, not the head; recorded here so it's not lost.

Worth noting for the record: M4 ran with a worker count that does not divide the 12 heads evenly, so the head-aligned column_range remainder path was exercised live and produced max_abs=0 on the compare lanes. I've asked Kern to disprove the alignment arithmetic and the n=32 boundary directly, and Sherlock to disprove the SAB bounds and confirm the byte-compare lane is must-fire on a mismatched blob; merge follows their verdicts on this head.

@heskew
heskew requested review from tps-flint and removed request for tps-flint September 4, 2026 02:33

@tps-sherlock tps-sherlock left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SECURITY review — APPROVE (all five checks hold; one non-blocking note).

1. SAB bounds — HOLDS. column_range(n_embd, worker, n_workers, head_dim) (qmatmul.rs:62-78) partitions n_embd = n_heads * head_dim into head-aligned ranges: units = n_embd / head_dim = n_heads exactly (no remainder, since align = head_dim divides n_embd), so every col_start/`col_end` is a multiple of head_dim and h_start = col_start / head_dim / h_end = col_end / head_dim (wasm_pool.rs:242-243) are exact integer divisions. The last worker gets end = n_out (full n_embd), which is head-aligned — no off-by-one at the head boundary. No remainder columns exist (n_embd is a whole number of heads). The n < 32 serial path (attention_layer, model.rs:451-453) never dispatches, so it uses its own scores vec and cannot race a stale parallel write. The scores buffer is w * n_tokens (model.rs:455) and dispatch_attn rejects scores.len() < n_workers * n_tokens (wasm_pool.rs:367); the per-worker row = worker * n_tokens guard (wasm_pool.rs:256-258) is defense-in-depth that never fires because worker ∈ [0, W) and scores.len() = W * n_tokens. Workers with an empty range (col_start >= col_end, wasm_pool.rs:239) return early but still increment workers_done, so the join completes. No worker reads or writes outside its slice.

2. Committed blobs / byte-compare MUST-FIRE — HOLDS. The byte-compare lane (ci.yml:110-123) sets match=false on any cmp -s failure across all eight files, and the "Fail if wasm is not byte-reproducible" step (ci.yml:153-156) runs if: steps.wasm-bytes.outputs.match != 'true' and exit 1. A hand-built or tampered blob that diverges from the rebuild fails the job — it is a MUST-FIRE, not a pass-on-exists. The section-table diff (ci.yml:97-107) runs under if: always() as a diagnostic, but the byte-compare is the gate.

3. New scripts write only under harness/ and never shell out with unquoted args — HOLDS. bench-held.mjs writes nothing (stdout JSON only). profile-threads-stages.mjs writes to harness/profile/ (OUT_JSON/OUT_MD) and builds to crate/target/profile-wasm-threads-relaxed (PROFILE_DIR). All subprocess calls use array args, never string interpolation: execFileSync("lscpu", ...) (no shell), spawnSync("bash", [script, PROFILE_DIR], ...), and spawnSync(process.execPath, [fileURLToPath(import.meta.url)], ...). No env/argv value is interpolated into a shell string.

4. build-wasm-profile.sh — HOLDS, no new network/toolchain surface. The only additions are rustup component add rust-src (already used by build-wasm.sh, pinned by rust-toolchain.toml 1.83.0) and the -Z build-std flags. No curl/wget/unpinned cargo install. OUT is ${1:-...} (a build output dir, not a fetch source). The MILTON_PROFILE_* env vars only toggle feature flags and target-dir names.

5. No new env var on the product path — HOLDS. The new env vars (MILTON_PROFILE_THREADS, MILTON_PROFILE_RELAXED, MILTON_PROFILE_W, MILTON_PROFILE_DIR) are read only in the harness scripts and build-wasm-profile.sh. The product path (src/index.js, crate/src/model.rs attention_layer) reads only pool_live() / worker_count() (no env) and the existing MILTON_* vars. ATTN_PARALLEL_MIN_TOKENS is a const, not env.

Non-blocking note: attention_heads's debug_assert!(h_end <= n_heads) (ops.rs:340) is compiled out in release, so the head-range bound is enforced only by column_range's arithmetic (which is correct) and the dispatch_attn length checks — not by a release-mode assertion. This is fine given the partition is provably in-bounds, but worth noting the safety rests on column_range + the length checks, not the assert.

Net: head-aligned column split is provably in-bounds and bit-exact (the attention_head_split_matches_serial_bit_exact test asserts serial == split), the byte-compare is a real MUST-FIRE, the new scripts are injection-free and write only under harness/, and no new env var reaches the product path. Approving.

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.

5 participants