Sitelet https://github.com/mlx-node/mlx-node/pull/71
Skip to content

feat: aarch64-unknown-linux-gnu (NVIDIA GB10 / DGX Spark) CUDA backend support - #71

Merged
Brooooooklyn merged 10 commits into
mainfrom
feat/cuda-backend-poc
Jun 16, 2026
Merged

Brooooooklyn merged 10 commits into
mainfrom
feat/cuda-backend-poc

Conversation

@Brooooooklyn

@Brooooooklyn Brooooooklyn commented Jun 16, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Brings the macOS/Metal-only @mlx-node/core addon up on NVIDIA aarch64-linux (GB10 / DGX Spark, sm_121) via MLX's CUDA backend, using device-agnostic eager fallbacks — no custom CUDA kernels this milestone. All four Qwen3.6 quant models run inference on the GB10:

Qwen3.6-27B    dense  Q4-affine / NVFP4   ✓ coherent output, exit 0
Qwen3.6-35B-A3B  MoE  Q4-affine / NVFP4   ✓ coherent output, exit 0

Every change is #[cfg]- or Metal-gated, so macOS is byte-for-byte unaffected (the Metal probe is always true on Apple; the C++ guards compile out on __APPLE__).

Build-port (Linux / CUDA)

File Change
mlx-sys/build.rs Linux/CUDA cmake branch, 3 Metal-only TU exclusions, c++20 on Linux, CUDA link set
mlx_paged_stubs_linux.cpp (new) throwing stubs for the paged Metal primitives, #if !defined(__APPLE__)
mlx_paged_dispatch.h Metal-only decls guarded by #if defined(__APPLE__)
mlx-paged-attn/{lib.rs, metal_dtype.rs *(new)*} expose MetalDtype on non-macOS so the cross-platform LayerKVPool/profile APIs resolve without pulling in Metal
mlx_advanced_ops.cpp explicit Shape{} for slice_update (stricter Linux toolchain)
packages/core/{build.ts, package.json, npm/linux-arm64-gnu} add the aarch64-unknown-linux-gnu napi target; yarn.lock updated

Runtime bring-up (Metal-gated; each found via gdb on the GB10)

  1. stream.rs — collapse new GPU streams onto the default stream on CUDA (a secondary stream makes the eval graph span streams → cross-stream cu::AtomicEvent::wait segfaults).
  2. array/data.rs — sync eval instead of async eval on CUDA (async attaches per-stream events → later cross-stream wait segfaults).
  3. mlx_nn_ops.cpp — eval before host data() in read_scalar on non-Apple only (an un-eval'd array's data() is a null device buffer on CUDA → Buffer::raw_ptr() segfault). Compiled out on macOS to keep the prior eval-free read.
  4. Qwen3.5/3.6 + LFM2 — force flat eager (skip the paged adapter, compiled-C++ registration, paged-prefill, and the GDN kernel) when the Metal backend is unavailable.

Validation

  • ✅ All 4 Qwen3.6 models run on the GB10 (sm_121, CUDA 13.0), coherent output.
  • ✅ macOS native build green with all changes present (zero-regression gate).
  • ✅ yarn install --immutable passes (lockfile updated for the Linux optional package).
  • 📊 Benchmark vs Apple M3 Max (docs/cuda-poc-benchmark.md): decode is bandwidth-bound — GB10 lands ~0.59–0.76× of M3 Max (tracks the 273/400 GB/s ratio); prefill is bottlenecked by the sequential GDN fallback (no CUDA GDN kernel). NVFP4 is not faster than Q4-affine in this PoC (dequant fallback, no native FP4 GEMM).

Adversarial review

Ran /codex:adversarial-review; verified each finding against the control flow and fixed all three:

  • [high] default bf16 LFM2 still entered the Metal-only paged path on Linux (unwrap_or(true), no gate) → first prefill hit a throwing stub. Fixed: gate the paged-adapter default + compiled registration on the Metal backend probe (mirrors Qwen).
  • [medium] read_scalar eval() was ungated → ran on macOS too. Fixed: #if !defined(__APPLE__) (no-op on every macOS hot path, but now provably zero-change).
  • [medium] yarn.lock stale after adding the Linux optional package. Fixed: regenerated; immutable install passes.

Known limitations (follow-ups, out of scope this PoC)

  • No custom CUDA kernels — GDN prefill is the sequential eager fallback (the main perf lever); paged attention is forced off on CUDA (Metal-only kernels).
  • CCCL headers for runtime nvrtc JIT are currently placed next to the addon via a manual symlink on the DGX; folding this into build.rs/build.ts is a follow-up.
  • LFM2 on CUDA falls back to flat eager (gated, won't crash) but was not run on the GB10 this milestone — Qwen3.6 is the validated path.

🤖 Generated with Claude Code


Note

Medium Risk
Large native build and inference-path changes (eval, streams, model dispatch) gated on Metal availability; macOS should be unaffected but regressions would hit core GPU execution and packaging.

Overview
This PR adds an experimental Linux aarch64 (glibc) + MLX CUDA path that produces mlx-core.linux-arm64-gnu.node, documents GB10 preview usage, and keeps macOS/Metal as the fully supported stack.

Build & packaging: mlx-sys/build.rs switches on target_os: macOS still builds Metal/metallibs; Linux enables MLX_BUILD_CUDA, skips Metal-only C++ TUs, links CUDA/cuDNN/BLAS, and uses C++20 for the FFI bridge. Non-macOS gets throwing paged-attention stubs plus a standalone MetalDtype so cross-platform code compiles. packages/core/build.ts skips metallib copy on non-darwin and copies the addon into a platform-derived npm/<triple>/ dir; NAPI adds aarch64-unknown-linux-gnu.

Runtime (CUDA correctness): When mlx_metal_is_available() is false, the stack avoids Metal-only work: sync eval instead of async eval, default GPU stream only (no secondary streams), host read_scalar eval on non-Apple, no block-paged adapter / compiled C++ registration / GDN metal kernels / paged-prefill paged-attention — models fall through to flat eager + SDPA. Qwen, Gemma4, LFM2, and MoE loaders share compiled_forward_backend_available(); paged-related tests skip without Metal.

Docs: README/CLAUDE/architecture/privacy-filter updates, docs/cuda-poc-benchmark.md, and CUDA PoC planning specs. Recommended CUDA run env: MLX_QWEN35_FORCE_EAGER=1 and MLX_QWEN35_PAGED_OVERRIDE=0.

Reviewed by Cursor Bugbot for commit a7417c1. Bugbot is set up for automated code reviews on this repo. Configure here.

Brooooooklyn and others added 4 commits June 16, 2026 11:50
Approved design for bringing mlx-node up on the NVIDIA GB10 (sm_121) via
MLX's CUDA backend through device-agnostic fallbacks (no custom-kernel
ports), with a prefill/decode/throughput benchmark vs M5 Max.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…gate PASS

- sm_121 gate PASSED end-to-end: MLX backend/cuda builds (arch 121a) AND runs
  real kernels on GB10 GPU (matmul/softmax/rms_norm/rope/quantized_matmul-4bit/sdpa).
- 8-phase plan with 11 adversarial-review fixes + 14 completeness items folded in.
- Model acquisition resolved: pre-quantized HF checkpoints (no bf16 -> Q4/Q8 first-light,
  NVFP4 perf); no mlx-convert on the critical path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…d support

Bring the macOS/Metal-only addon up on NVIDIA aarch64-linux via MLX's
CUDA backend, using device-agnostic eager fallbacks (no custom CUDA
kernels this milestone). All four Qwen3.6 quant models (27B dense
Q4/NVFP4 + 35B-A3B MoE Q4/NVFP4) run inference on the GB10 (sm_121).
Every change is cfg-gated or Metal-gated so macOS is byte-for-byte
unaffected (the Metal probe is always true on Apple; the C++ guards
compile out on __APPLE__).

Build-port (Linux/CUDA):
- mlx-sys/build.rs: Linux/CUDA cmake branch, 3 Metal-only TU exclusions,
  c++20 on Linux, CUDA link set.
- mlx_paged_stubs_linux.cpp (new): throwing stubs for the paged Metal
  primitives under #if !defined(__APPLE__).
- mlx_paged_dispatch.h: Metal-only decls guarded by #if defined(__APPLE__).
- mlx-paged-attn: metal_dtype.rs (new) exposes MetalDtype on non-macOS so
  the cross-platform LayerKVPool/profile APIs resolve without Metal.
- mlx_advanced_ops.cpp: explicit Shape{} for slice_update (Linux toolchain).
- packages/core: build.ts + package.json + npm/linux-arm64-gnu add the
  aarch64-unknown-linux-gnu napi target; yarn.lock updated.

Runtime bring-up (Metal-gated; each found via gdb on the GB10):
- stream.rs: collapse new GPU streams onto the default stream on CUDA
  (cross-stream cu::AtomicEvent::wait segfaults).
- array/data.rs: sync eval instead of async eval on CUDA (async event
  attach -> later cross-stream wait segfaults).
- mlx_nn_ops.cpp: eval before host data() in read_scalar on non-Apple
  (un-eval'd data() is a null device buffer on CUDA); compiled out on
  macOS to keep the prior eval-free read.
- qwen3_5 / qwen3_5_moe / lfm2: force flat eager (skip paged adapter,
  compiled-C++ registration, paged-prefill, GDN kernel) when the Metal
  backend is unavailable, so default loads fall back instead of hitting
  the Metal-only paged stubs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 16, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c00cca8e-e961-41f2-b90a-556103549da6

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/cuda-backend-poc

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3b214feab4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/mlx-sys/build.rs
Brooooooklyn and others added 3 commits June 16, 2026 15:56
…pport

Add a Platform Support section + nav link, and correct the prior
"Apple Silicon and CUDA devices" / "coming soon" claims. Framed as an
inference-only experimental preview: Qwen3.6 dense/MoE validated on the
GB10 (sm_121, CUDA 13.0), eager fallbacks with no custom CUDA kernels,
perf below Apple Silicon, training/other families untested. Updates the
prerequisites, build steps (Linux/CUDA note), and architecture diagram.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Update the stale "macOS only / no CUDA" claims for consistency with the
new Platform Support section:
- CLAUDE.md: intro + Known limitations now describe the inference-only
  CUDA preview (Qwen3.6 dense/MoE on GB10, eager fallbacks, perf below
  Apple, training/other families untested).
- docs/architecture.md: backend row -> Metal / CUDA / Accelerate;
  build:native output table notes the Linux/CUDA .node (no metallibs).
- docs/privacy-filter.md: clarify it stays macOS-only because the
  banded-attention kernel is Metal-specific (no CUDA port) — not covered
  by the experimental CUDA backend.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Both families default `use_block_paged_cache` to true and route generation
through `paged_adapter.is_some()`, so on the CUDA/Linux build the first
prefill hit the non-macOS `update_keys_values` throwing stub and crashed
instead of falling back to flat eager. Apply the same backend-availability
gate already used for LFM2/Qwen3.5 at each adapter-construction site
(qwen3/model.rs, gemma4/model.rs): leave the adapter None when the Metal
backend is unavailable so the existing is_some() routing falls through to
the untouched flat path. Zero macOS impact (probe always true there).

Addresses the Codex PR #71 P1 review comment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@cursor cursor Bot 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.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 2dd7168. Configure here.

Comment thread crates/mlx-core/src/models/qwen3/model.rs
Comment thread crates/mlx-core/src/array/data.rs

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2dd71683fe

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/mlx-core/src/models/qwen3_5/persistence.rs
Address two Cursor Bugbot review comments on PR #71:

- [high] Qwen3.5 dense + MoE default `use_block_paged_cache` to false (paged
  off by default, safe) but still built the paged adapter when forced on via
  config `Some(true)` or `MLX_QWEN35_PAGED_OVERRIDE=1` — on CUDA the eager
  paged forward then hit the throwing non-macOS KV write/gather stubs and
  aborted. Add the same `compiled_forward_backend_available()` construction
  gate used for qwen3/gemma4/lfm2 (qwen3_5/model.rs, qwen3_5_moe/model.rs).
  The env override is resolved at config-parse time, so the construction-time
  gate sits after it; adapter stays None on non-Metal -> flat eager fallback.
  Qwen3.5 was the last family with an ungated paged adapter.

- [medium] `async_eval_arrays`' non-Metal sync-eval fallback discarded
  `mlx_eval`'s bool, unlike `eval_arrays`. Record failures via
  `write_inference_trace` (keeping the fire-and-forget `-> ()` signature)
  so a failed materialization is logged, not silently dropped.

Both zero macOS impact (Metal probe always true there).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2cda4ecc84

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/core/package.json Outdated
The aarch64-unknown-linux-gnu support is build-from-source only; CI
builds and publishes the macOS artifact alone. Wiring
`@mlx-node/core-linux-arm64-gnu` as an optionalDependency made a
linux/arm64/glibc registry install resolve that package and then fail
to require its `main` `.node`, which is never produced.

Drop the optionalDependency and the npm stub dir so the broken package
is no longer published, regenerate yarn.lock so `--immutable` stays
green, and keep the napi target (loader is a static template; build.ts
ignores it). `copyNativeAddon` now creates the platform dir on demand
so a from-source linux build still lands its `.node` next to index.cjs
after the committed stub is gone. Zero macOS impact: darwin resolves
its own loader branch and its committed npm dir is untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7f66a94450

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/mlx-core/src/models/qwen3/model.rs
The Metal gate on paged-adapter construction makes `*Inner::new` return
Ok with `paged_adapter == None` on a non-Metal host instead of erroring
with "No Metal device found". The paged unit tests only skipped on that
error string, so on Linux/CUDA they would now run and panic on the
first adapter touch (`assert!(paged_adapter.is_some())` or
`paged_adapter.as_mut().expect(...)`).

Add an early skip keyed on the same backend probe used by the
construction gate, at the top of every paged test that exercises the
adapter (qwen3 ×9, gemma4 ×3, lfm2 ×4). No-op on macOS where the probe
is always true, so those tests run exactly as before; on a non-Metal
host they skip cleanly. qwen3.5/qwen3.5-moe need no change — their
paged tests are #[ignore]'d and env-gated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Brooooooklyn
Brooooooklyn merged commit 87a59fc into main Jun 16, 2026
7 checks passed
@Brooooooklyn
Brooooooklyn deleted the feat/cuda-backend-poc branch June 16, 2026 13:35
Brooooooklyn added a commit that referenced this pull request Jun 17, 2026
…n3.6 classes) (#72)

## Summary

Collapses the **O(T) per-step gated-delta (GDN) recurrence** on the CUDA
(non-Metal) prefill path into **O(T/BT) chunk-serial batched matmuls**
(cuBLAS / tensor cores) — a device-agnostic, pure-`MxArray` port of the
in-tree Metal chunked kernel
(`crates/mlx-sys/src/metal/gated_delta_chunked.metal.inc`).

- **Zero build changes** (no nvcc/NVRTC) — matmuls route through cuBLAS.
- **Default-on** for the CUDA ops path; `MLX_GDN_KERNEL=perstep` reverts
for same-binary A/B.
- **No Metal impact** — `use_kernel=true` never reaches this path; the
Mac/Metal production path is byte-identical.

This attacks the *"GDN per-step recurrence is the prefill floor"*
bottleneck that [PR #71](#71
benchmark flagged as the **#1 dense lever**.

## Measured (GB10 / DGX Spark, Qwen3.6, warm, prefill TTFT vs per-step)

| model        | 1577-tok speedup | parity |
|--------------|------------------|--------|
| dense-Q4     | **1.62×**        | byte-identical / late-drift |
| dense-NVFP4  | 1.33×            | identical |
| MoE-Q4       | **1.75×**        | coherent |
| MoE-NVFP4    | 1.40×            | coherent |

Win **grows with prompt length** (dense-Q4: 1.06×@200 → ~1.58×@1577+;
the chunked inverse is fixed-cost while per-step is O(T)). chunked
prefill tok/s climbs to ~242 vs per-step's flat ~150. MoE wins more (GDN
is a larger prefill fraction); NVFP4 less (dequant dominates its
prefill).

## Numerical-stability fixes (each with a Mac regression test)

1. **Triangular inverse overflow.** `M = (I+A)⁻¹` by repeated squaring
overflows f32 at `BT=64` (`N³² ≈ 4e57` before nilpotency zeroes it at
`N⁶⁴`), producing garbage. Replaced with **row-iterative forward
substitution** (FLA / vLLM `solve_tril`): `M[i,:] = eᵢ − A[i,:]·M` — no
powers of A, stable for any `‖A‖`, serial depth independent of T. Test:
`chunked_ops_stable_with_correlated_unit_norm_keys`.
2. **Gate underflow (MoE-only garbage).** `g_log = g.log()` round-trips
through the exp-space gate; strong decay (which MoE has, dense doesn't)
underflows `g` to 0 → `log(0) = -inf` → chunked `gcum_i − gcum_j = inf −
inf = NaN`. Now compute `g_log = -exp(a_log)·softplus(a + dt_bias)`
**directly in log-space** (matches the native `g_log` the fused Metal
gating returns). Test: `compute_g_log_finite_under_strong_decay`.

## Validation

- 6/6 `gated_delta` Rust unit tests, `cargo clippy -p mlx-core
--all-targets`, `cargo fmt` — green.
- Correctness validated on the DGX across **all four Qwen3.6 classes**
(dense/MoE × Q4/NVFP4) — per-step vs chunked greedy A/B, coherent output
everywhere (garbage only before the two fixes above).
- Algorithm derivation in `docs/gdn-chunked-ops-spec.md`.

## Follow-ups (not blocking)

- FLA 16-block row-iterative + block-merge inverse to cut short-prompt
inverse depth 63→~16 (long-prompt asymptote is carry-bound, won't move).
- Runtime non-finite guard → fall back to per-step (overflow is
currently silent; the `Err` fallback doesn't catch `Inf`/`NaN`).
- Possibly raise `CHUNK_THRESHOLD`→256 (the 200-tok win is only 1.06×).

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

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Touches core Qwen3.5 inference recurrence and output numerics on CUDA,
though Metal is gated off, per-step fallback exists, and behavior is
covered by parity/stability tests.
> 
> **Overview**
> Adds a **default-on CUDA prefill fast path** for Qwen gated-delta
(GDN): long, unmasked sequences on the non-Metal ops branch now run
**`gated_delta_chunked_ops`**, a pure `MxArray` chunk-parallel port
(BT=64) that replaces the O(T) per-token loop with O(T/BT) chunk carries
and batched matmuls. Metal/`use_kernel=true` routing is unchanged;
decode and masked calls still use per-step ops.
**`MLX_GDN_KERNEL=perstep`** (and **`ForceChunkedOps`** / `chunked_ops`
aliases) support same-binary A/B.
> 
> Two **numerical fixes** ship with the chunked path:
**`compute_g_log`** computes the decay gate in log-space (avoids
`log(0)` → NaN on strong MoE decay), and
**`invert_i_plus_strict_lower`** builds `(I+A)⁻¹` via forward
substitution instead of f32 power squaring that overflows at BT=64.
Chunked ops errors fall back to per-step with a stderr warning.
> 
> Adds **`docs/gdn-chunked-ops-spec.md`** plus unit tests for env
parsing, chunked vs per-step parity across chunk boundaries,
correlated-key inverse stability, and strong-decay gating.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
efe9c8f. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Brooooooklyn added a commit that referenced this pull request Jun 18, 2026
Sync the chat-engine refactor branch with origin/main's two CUDA commits.
All CUDA additions are Metal-gated, so the macOS path is unchanged.

Conflict resolution (refactor base kept; CUDA additions ported):
- The refactor relocated qwen3_5::persistence_common -> engine::persistence,
  so every CUDA-added compiled_forward_backend_available() guard (20 call
  sites across gemma4/lfm2/qwen3/qwen3_5/moe, auto-merged with the old path)
  is repointed to crate::engine::persistence.
- lfm2 keeps its compiled path: its Metal gate is folded onto the P5
  per-model-id deadlock-fix registration; persistence imports updated.
- qwen3_5 dense + qwen3_5_moe register_weights_with_cpp C++ compiled
  forwards were deleted by P6/P7; CUDA's edits to those blocks are dropped
  (0 callers on HEAD).
- gated_delta.rs: our GDN tape-replay test and #72's three chunked-ops
  tests coexist in one module (all 7 module tests green).

Gates (M5 Max): cargo build/clippy -D warnings/fmt --check clean;
mlx-core lib suite 1775 passed, 2 pre-existing f32-precision failures
(banded_attention / attention_vjp finite-diff) on code paths byte-identical
to origin/main — not introduced by this merge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Brooooooklyn added a commit that referenced this pull request Jun 22, 2026
…_config.json (#38) (#70)

## Chat-engine refactor — model-neutral engine → paging inversion →
compiled coexistence → pure-Rust eager/MTP → declarative convert

> **Scope.** This branch carries the **entire** chat-engine refactor
stacked on `main` — Phase 1 (engine extraction, S1–S12) plus follow-ups
**P1–P8**, the **MTP and vision genericizations**, and the `#38`
generation-config feature: **73 commits, 143 files, +39.5k / −54.0k (net
≈ −14.5k LOC)**. The phases were developed on one long-lived branch and
build on each other, so they cannot be split into separate PRs against
`main`. Opened for review.
>
> **Synced with `main`.** Merged `origin/main` (NVIDIA DGX/CUDA backend
[#71] + qwen3.5 GDN chunked-ops prefill [#72]) into the branch — **PR is
now conflict-free / mergeable**. The CUDA additions are Metal-gated, so
the macOS path is byte-for-byte unchanged; the only merge work was
repointing CUDA's `compiled_forward_backend_available()` Metal guards
from the pre-refactor `qwen3_5::persistence_common` to the relocated
`engine::persistence`, and keeping both our GDN tape-replay test and
#72's new chunked-ops tests.

### Why
Adding a new model family cost ~10–17k LOC because the production engine
(decode loop, session mgmt, paged-KV, compiled-graph path, MTP) was
built inside the first family (`qwen3_5/`) and copy-forked into each new
one. This refactor extracts a model-neutral engine and pushes every
production feature behind an engine-owned seam, so a new family becomes
a thin backend impl — hence the large net deletion.

### Phases on this branch

**Phase 1 — extract the model-neutral chat engine (S1–S12).** New
`crates/mlx-core/src/engine/`: `ChatConfig/ChatResult/ChatStreamChunk`
types, `ChatBackend`/`DecodeStep` traits, generic `run_decode_loop`,
generic session cores + `ChatCmd` + napi glue, compiled locks,
persistence helpers. All five families (qwen3, qwen3_5 dense,
qwen3_5_moe, gemma4, lfm2) migrated onto it; legacy per-family chat
machinery deleted (grep-gated).

**P1–P3 — engine seams.** `ThinkingPolicy` + ChatML delta
single-source-of-truth; paged-turn lifecycle dedup via a `PagedTurnPlan`
adapter.

**P4 — paging inversion (`engine::run_paged_turn<B: PagedBackend>`).**
The default paged auto-regressive whole turn now flows through one
generic engine path + `run_decode_loop` for all five families, behind a
paged-forced byte-equivalence gate.

**P5 — per-model-id compiled slot map.** Replaces the single
process-global `g_active_model_id` gate over the C++ compiled-decode
state with per-model-id slot maps, so **two compiled models coexist in
one process** instead of the 2nd load silently demoting the 1st to
eager. Includes the MTP draft/commit-graph residency owner-guard and a
deadlock fix on the per-`Inner` Drop.

**P6/P7 — delete the C++ compiled forwards, go pure-Rust.** Removed the
qwen3_5 dense + MoE C++ compiled forward paths (~9.4k LOC + 104 FFI
externs) in favor of a pure-Rust eager forward and a **pure-Rust eager
MTP** (drafter + batched verify + accept/rewind + a Rust GDN tape for
bit-exact acceptance). AR eager == former compiled (parity); eager MTP
keeps committed-history acceptance. lfm2/gemma4 compiled paths
preserved. A hard perf gate (eager-MTP must not regress vs compiled-MTP)
was enforced.

**P7 (collapse) — TS / NAPI / Cmd / training.** 6 streaming wrapper
classes → one `makeStreamingModel` factory + `MODEL_REGISTRY`; 35
hand-written `#[napi]` chat methods → one `chat_napi_surface!`
decl-macro; per-family training enums → `engine::cmd::TrainCmd` +
`handle_train_cmd<B>` (+ 12 fallible AdamW-apply `unwrap` → `?`).

**P8 — declarative convert (`ConversionRecipe` registry).** Collapsed
the 7 scattered per-family `convert.rs` dispatch sites into one
`recipe_for(model_type)` registry; **e2e `mlx convert` output is
byte-identical (sha256)** for qwen3_5 / lfm2 / privacy-filter.

**#38 — honor `generation_config.json` defaults engine-wide (vLLM
out-of-box).** When a request leaves a sampling field unspecified it
falls back to the checkpoint's `generation_config.json`, and that file's
`eos_token_id` list merges into the per-turn stop set. Precedence
`request > generation_config > sampler builtin`; eos is a **union**
(never an override). Default-ON. `index.d.cts` unchanged (defaults live
on each `Inner`).

**MTP genericization — `engine::run_mtp_turn<B: MtpBackend>` +
`run_mtp_cycle<S: MtpStepper>`.** Continuing P4's inversion for
speculative decoding: the MTP whole turn and its propose / verify /
accept-or-rewind cycle moved off the per-family `decode_loop_mtp!` macro
onto one engine-owned generic path. qwen3_5 dense (flat sync, flat
streaming, paged) and qwen3_5_moe (flat) all run it; the now-dead
`decode_loop_mtp!` macro + `MtpOps` closure-bundle were deleted.
Per-family T=0 byte-identity gates; the macro's unit tests were migrated
to the live engine path — Codex caught a real dense
`accept_with_residual` (T=1.0 dense-accept) coverage gap the migration
had dropped, fixed with exact-one-hot deterministic tests.

**Vision merge-contract genericization — `engine::vision::{VisionMerge,
run_vlm_prefill_layers}`.** The qwen3.5 dense + MoE image-prefill
already shared the vision encode + image-feature merge + M-RoPE step
(one free fn); its last forked piece — the prefill layer loop — is now
unified behind an engine-owned `VisionMerge` DTO + a `VlmPrefillLayer`
trait (associated cache type, so each family keeps its distinct layer
cache) + one generic `run_vlm_prefill_layers`. The sole divergence
(dense relies on the attention layer's internal causal SDPA; MoE passes
an explicit causal mask) is now a parameter, not a forked loop. gemma4
stays on its own core — it is structurally disjoint (masked_scatter + 4D
mask, no M-RoPE) and even rejects a text delta on an image session.
Env-gated T=0 image-chat goldens are byte-identical before / after for
both dense (Qwen3.5-4B) and MoE (Qwen3.6-35b-a3b).

### Docs & examples
- **Docs:** `ModelGenerationDefaults` rustdoc now documents the full
`generation_config.json` vs `config.json` override order (sampling
ladder + eos union + raw-`generate()`/Gemma4 divergences).
- **Examples cleanup:** removed ~30 internal
debug/probe/microbench/perf-A-B/smoke scripts (incl. the
`grpo/validation/**` real-API subtree); `examples/` now keeps only
user-facing examples — inference (`lm.ts`), VLM (`vlm-inference.ts`),
OCR (`paddle-ocr-pipeline.ts`), tool-use (`tool-use-example.ts`), and
the GRPO/SFT GitHub-tool training example. Reference-safe (`yarn
typecheck` + `lsp.test.ts` 75/75 green; dead `package.json` bench
scripts + doc references pruned).

### Verification
- `cargo test -p mlx-core --lib` green (only the 2–3 pre-existing
Metal-f32 failures, documented; on code paths byte-identical to
`origin/main`) · `cargo test -p mlx-paged-attn` green.
- Per-family real-checkpoint parity at each P4/P5 step (paged-vs-flat
byte-identical); P6/P7 AR eager==compiled + eager-MTP acceptance parity;
P8 byte-identical convert; #38 real-checkpoint smoke + 11 unit tests.
- `clippy --all -D warnings` + `cargo fmt --check` clean · `yarn
build:native` ok · `packages/core/index.d.cts` byte-stable · `yarn
typecheck` + TS lint clean.
- Post-sync re-gate on the merge commit: `build` + `clippy -D warnings`
+ `fmt --check` clean; `mlx-core` lib 1775 pass; the merged
`gated_delta` module runs both the GDN tape-replay test and #72's
chunked-ops tests (7/7).
- MTP + vision genericization: per-family T=0 byte-identity goldens
green before / after; `mlx-core` lib **1796 pass** / 3 pre-existing
Metal-f32; Codex adversarial-review **approve** (no material findings)
on both.
- Adversarial (Codex) review on each landed phase (all approve /
findings fixed).

### Post-review follow-ups & CI

- **Open review threads resolved (3/3), each verified against real
control flow first:** restored the native config constructor on the
streaming wrappers (`ConstructorParameters<C>` — `new
Gemma4Model(config)` / `new QianfanOCRModel(config)` type-check again);
reinserted the `.biases` sidecar on the vision-dequant fallback so a
failed dequant can't emit a corrupt affine quant group; the gemma4
paged-cap flag was confirmed **not** a regression (the clamp was
intentionally converged onto the shared engine contract — the KV pool is
sized to `max_position_embeddings` and `record_tokens` rolls back on
exhaustion, so over-budget turns abort cleanly rather than overrun).
- **Rust Lint fix:** moved the `VlmPrefillLayer` impl above the test
module (`clippy::items-after-test-module`, a `--all-targets`-only lint
that the local `--lib` check missed).
- **Removed T=0 parity scaffolding** (`scripts/t0-smoke.sh` + its
env-gated Rust capture harnesses, −1.6k LOC): dev-only, never ran in CI,
and the refactor it verified has landed. Kept the real CI coexistence
test.
- **Gated heavy model E2E, folded into `ci.yml` to reuse the single
Build:** real-checkpoint tests run as a label-gated `model-test` job
inside `.github/workflows/ci.yml` that **reuses the `build` job's
`mlx-node` + `mlx-metallib` artifacts — no second native build** (the
original standalone `model-e2e.yml` duplicated the entire build job
verbatim). A per-family matrix downloads the smallest checkpoint (qwen3
0.6B · qwen3.5 dense 0.8B · lfm2 1.2B · gemma4 E2B) and runs that
family's self-referential `--ignored` parity/session tests (paged≡flat,
reset/replay determinism, session warm-reuse, greedy MTP≡AR — no
host-specific golden). Runs only on `main` pushes, `workflow_dispatch`,
or a PR labeled `model-e2e` — keyed on the label that fired the event,
so adding an unrelated label to an already-labeled PR is a no-op. On a
`model-e2e` label event only `build` + `model-test` run (the regular
`test`/`lint` already ran on the PR's push). qwen3.5 MoE is excluded (no
checkpoint below 35B-A3B).

[#71]: #71
[#72]: #72

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

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **High Risk**
> The declaration file removes and reshapes large parts of the public
`@mlx-node/core` API (chat, Qwen3 paged paths, training hooks), and
publish now gates on optional E2E—both affect consumers and release
flow.
> 
> **Overview**
> Adds a **`model-test` CI job** inside `ci.yml` that reuses the build
artifacts, downloads small real checkpoints per family (qwen3, qwen3.5
dense, lfm2), and runs `#[ignore]` parity/session tests when pushing to
`main`, on `workflow_dispatch`, or when a PR carries the **`model-e2e`**
label—with guards so unrelated label events do not re-run the full
test/lint suite. **Publish** now depends on `model-test` succeeding (or
being skipped) so a failed E2E leg blocks release.
> 
> Regenerates **`packages/core/index.d.cts`** to match the refactored
native surface: unified **`ChatConfig` / `ChatResult` /
`ChatStreamChunk`**, session + streaming entry points on model classes,
**`ChatStreamHandle`**, block-paged cache helpers, expanded GRPO/SFT
engine factories, and new exports (Gemma4, LFM2, Harrier, PrivacyFilter,
QianfanOCR, ResponseStore, profiling/memory helpers). Several legacy
Qwen3 paged-attention and in-process `chat()`/`forward` APIs are
**removed** from the typings in favor of model-thread session APIs.
> 
> Small follow-ups: Gemma4 loader test asserts **transitive subclass**
via `makeStreamingModel`; README TUI example points at
`train-github-tool.ts`.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
99cb197. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: p6 <p6@local>
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