Skip to content

ling: Ling-3.1-flash on CUDA at TP=1 on #804's kernels (draft, stacked on #804) - #874

Draft
jschmied wants to merge 34 commits into
ashhart:mainfrom
jschmied:ling-family
Draft

jschmied wants to merge 34 commits into
ashhart:mainfrom
jschmied:ling-family

Conversation

@jschmied

@jschmied jschmied commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

What this changes

Ling-3.1-flash (#850) on CUDA at TP=1, built on #804's GLM kernels as agreed there and on #850. #804 is in main since 1.0.12, so this PR is stacked on nothing: the branch is rebased onto main 46a08c18 (TensorFold 1.0.12), and every commit on it is this PR's. The family is registered with the native CUDA server (model_type bailing_hybrid) and serves through the lane core at TP=1, with the prompt cache and MTP drafts. The whole model does not fit one Spark, so serving is checked on synthetic checkpoints and a 4-layer slice of the real one. docs/recipes/ling-3.1.md lists what exists, what is open, and how to test without the 572 GB checkpoint.

  • glm: GLM-5.3-Flash (NVIDIA's NVFP4 checkpoint) as a CUDA family at TP over hosts #804's kernels, extended, with GLM's bytes unchanged:
    • group-limited top-k (8 groups, keep 4) and a per-layer shared SwiGLU limit (f9fc1a78); a division-free branch for groups that are whole lane columns, which GLM's single group never enters (e78d23bb);
    • GLM's swiglu keeps its limit as given, 0 included, as at 6380c8da; only Ling's swigluWith maps 0 to unclamped (32055e9e, 991383f3);
    • MLA with Ling's 64 roped key columns, 128-wide heads and a head-wise gate (7d184cb7);
    • KDA checked as is on Ling's layer 0 (ef8f12f3). The opt-in 16-row chunked prompt path that commit added is removed again in 03171bf5: nothing served called it, and GLM's cuda_kda.zig is 6380c8da's file again;
    • Ling's SwiGLU clamp order, min(silu(g), L) * clip(u, -L, L), as new kernel instances (bfb44ddc);
    • tf_pack_fp8_experts in modelopt_pack.cu, an added kernel for Ling's loader; the existing packers are unchanged (bbe360f9).
  • zig/src/families/ling/, format-agnostic:
    • config and weight map (a6559571);
    • a loader that takes each tensor's format from the checkpoint, bf16, block FP8 or ModelOpt NVFP4, through qlinear and experts. EXL3 is refused and points to EXL3 checkpoints on the native engine (tracker) #832 (d46e3723);
    • ling-synth for small synthetic checkpoints in each format;
    • the TP=1 layer graph with prompt rows, decode windows and commit(keep) (6f583baf);
    • serving (a3f14d30): Engine with a sequence per slot, prompt chunks cut at any row, verify windows of up to 16 rows, shared windows of up to 8 streams, greedy and sampled draws; Lanes, GLM's cuda_lanes.zig without the ranks; native.zig with the server hooks and the prompt cache's snap_* hooks. A kept state is the sequence's arena copied whole, and a mark may fall on any row;
    • the MTP layer (model.layers.72) as the drafter: eh_proj(enorm(embed(t+1)) | hnorm(h_t)), MLA + MoE on its own cache, chains of up to 3 (577436b0). The server loads it unless --no-drafts;
    • routed experts staged in batches through pinned memory and packed on the GPU (bbe360f9);
    • a speed pass on the copies and small FP8 expert runs, same bytes (99652bab, b3faea69, 5b006370), and timing tests (7bd7a40a, 412f66fc, 0ffce4f7, bd7c23e3);
    • the recipe (faf7af1c).
  • Review fixes (one commit each, each with a regression test that fails with the fix reverted):
    • config refuses every geometry the kernels don't take, at load, with a named error: KdaGeometry, MlaGeometry, Width, TiedHead (66579cba);
    • Store.alloc owns a buffer only once it is in both lists; fake_driver.zig fails every driver call in turn (674163fa);
    • rows past a sequence's capacity are refused before any layer runs (82b3817b);
    • routing groups of one and a single pick are refused at load, as HF's group_limited_topk can't run them (92afed61);
    • Engine.init frees sequence 0 and the slot list when bind(0) or the last sync fails (0ba2adfb);
    • a new stream's slot is chosen before its map entry exists (0ba510ad);
    • every tensor of the full checkpoint checked against the loader's names, dtypes and shapes, from shard headers alone, through one shape table, names.dims (5649c63b);
    • the oracles check their HF source against inclusionAI/Ling-3.1-flash@a6943f4b by sha256 (e4c9bcc5);
    • a segment the scratch cannot hold is refused before any layer runs, also on the MTP layer (5e931c3a);
    • staging and loaded weights are freed only after the stream drains; a failed drain leaks them on purpose (1395e267).

Receipt

  • Environment: TensorFold 46a08c18 (1.0.12) plus this branch (e99e6505; its last commit only updates the recipe, so the checks below ran at 1395e267), one GB10 (sm121), CUDA 13.0, Zig 0.17.0. Checkpoint: inclusionAI/Ling-3.1-flash, block FP8, pinned at a6943f4b; its real layers read one at a time.

  • Serving (ling-lanes, under upstream's round loop, prompts in 37-row chunks), bf16, FP8 and NVFP4, each with and without --mtp: 100 and 109 checks pass in each format. Tokens in every check, state bytes in the engine-level ones:

    • chunked == whole, at chunks of 512, 37 and 16 rows;
    • drafted == plain, greedy and sampled, windows of 3 to 16 rows cut anywhere; a 9-row window kept to 4 then a 5-row window equals 9 single rows;
    • resumed == fresh, at marks 37, 101, 150, 263 and 349 (not multiples of 16);
    • concurrent == solo, nine cases of three streams, and shared windows of 2 streams equal lone twins;
    • MTP-drafted == plain, greedy, sampled, resumed and concurrent; chained drafts equal ones computed by hand;
    • the reply equals the serial forward's argmax at all 160 of 160 rows;
    • a refused request (past the sequence's capacity, or past the scratch's) leaves the arenas byte for byte as they were, and the slot then serves as a fresh one does.

    Served logits against ling_forward.py's float64 on the same picks: 2.09e-2 to 2.24e-2 rel L2, as far as the HF model's own bf16 rounding points are (2.10e-2 to 2.25e-2). On the real FP8 release, --layers 0,1,2,3 (real embedding, layers 0-3, final norm and head) passes the same exactness checks; that run is not in the re-run at this head.

  • Graph exactness, bit for bit, in bf16, FP8 and NVFP4 (ling-forward-synth): a prompt cut at 48 equals the uncut prompt; an 8-row verify window equals 8 decode rows; keeping 3 of 8 rows and then running 5 more leaves every KDA state, conv window and MLA cache row equal to the serial run; two sequences batched equal each run alone. On real layers, a prompt cut at 300 equals the uncut prompt and a 16-row window equals 16 decode rows.

  • Against the reference:

    • real FP8 layers 0, 4, 7, 65 and 71 against the HF bf16 model's captured chain: rel L2 3.1e-3 to 3.8e-3, excluding the 0 to 21 of 512 rows whose top-8 picks flip on a near tie. Layer 4 at this head: 3.126e-3, its output bytes equal to a78a7a49's;
    • MLA layer 7 alone: 4.4e-3 from HF's bf16 output (HF's bf16 output is 3.6e-3 from float64);
    • KDA layer 0: 3.591e-3 from fla, within the float64 bound;
    • the MTP layer (ling-mtp-real) against HF's BailingMoeV3MTPLayer: 7.28e-3 on the 488 of 511 rows whose top-8 agrees;
    • routing on layer 4: the same experts in the same order on all 512 rows;
    • synthetic forwards: within the float64 bound, as far from float64 as the bf16 model's own rounding is.
  • Full checkpoint (ling-ckpt-check, shard headers only): 54 shards, 214,025 tensors (3,100 of the MTP layer), 573,753,281,152 bytes, no name, dtype or shape mismatch, nothing missing or extra. The shard data is not hashed.

  • Fault paths (ling-init-faults, on fake_driver.zig): every driver call and host allocation of the open and a kept state fails in turn with transfers pending, then every call from N on. Over bf16, FP8 and NVFP4, with and without MTP: 15,180 failure points (1,804 failing with a transfer pending), none leaves anything held, leaked, freed twice or freed before a drain.

  • Loading one MoE layer's 512 FP8 experts (7.5 GiB): 6.1 s from local disk with the GPU packer, 14.3 s with the host packers before; host peak 1.3 GiB. Every packed byte equals the host packers' (ling-pack).

  • Decode and prompt speed: not measured. The whole model does not run on one Spark. The speed pass is measured per op only: prompt KDA and MLP column copies -50 % (99652bab); FP8 expert time -7 to -27 % for prompt chunks up to 768 rows (5b006370).

  • GLM unchanged:

    • glm-route-old (select, and swiglu at limits 10 and 0) and glm-mla-same (13 launches, 14.3 MB of outputs) run glm: GLM-5.3-Flash (NVIDIA's NVFP4 checkpoint) as a CUDA family at TP over hosts #804's 6380c8da fatbins next to the new ones, byte for byte; ling-route-old checks the division-free branch against the older select's bytes;
    • every kernel that existed before keeps its SASS and resource usage (cuobjdump, all fatbins);
    • ling-clamp-old checks the kept expert instances' bytes;
    • @Bizuayeu ran GLM's checks on the GLM-5.3 NVFP4 checkpoint on three GB10 hosts at c48be499, ac58df63 and e29e0c20 (now 03171bf5): one host, TP=2 and TP=3, and the server at TP=2 against 6380c8da's replies. All pass. GLM's and the shared code have not changed since 03171bf5.
  • Tests run at 1395e267: glm-ref, glm-route-old, ling-route-old, glm-mla-same, glm-kda-ling, ling-slots, ling-ckpt-check (three synthetic checkpoints and the full release), ling-init-faults and ling-lanes (three formats, with and without --mtp; ids and picks byte-equal to a78a7a49), ling-weights (three formats, worst 0.433 of the bound), ling-pack layer 4, ling-layer-real layer 4; zig build test (1390/1398, 8 skipped), test-ling 30/31 (1 skipped: it needs TF_LING_INDEX), test-cuda-host 237/237, zig fmt --check, lean_check 517, as on main. Deliberate mutations, all caught: routing 6, MLA 6, config 8, loader 5, graph 6, clamp 5, serving 5, MTP 5, and each review fix reverted alone.

  • Not run, and why:

    • a full 72-layer Ling forward, the real model served end to end, and MTP acceptance on real text (572 GB in FP8 needs five Sparks and TP);
    • TP=2 for Ling (one Spark);
    • Metal.

What is still open

Checklist

  • Every token still goes through the lane rounds. No serial path, and nothing that needs drafts off.
  • Drafted output equals "draft": false, a resumed prompt equals a fresh one, and concurrent equals solo. (On synthetic checkpoints in three formats and the real model's first 4 layers; not on the full model.)
  • No precision traded for speed. If the bits change, the description says which sums change and why.
  • Prompt processing is no slower than the last release. (Not measured for Ling; GLM's kernels are unchanged.)
  • The description says which platforms I ran, Metal M1 to M5 and CUDA, and which I could not.
  • New tests fail before the change, pass after it, and skip cleanly without their dependency.
  • Comments and docstrings are one line. No measurements or history in the source.
  • No personal data, machine names, internal hosts or local paths. No AI attribution lines.

Open question for @ashhart (from #850 and your reply of 2026-10-15): group-limited top-k is built into #804's tf_glm_topk, since Ling's MoE runs through #804's routing. It ranks sigmoid(logit)+bias as HF does for Ling; #798's moe_glue.cu ranks logit+bias, so the two can't simply replace each other. Where should the shared routing live?

@Bizuayeu

Copy link
Copy Markdown
Contributor

@jschmied, we ran GLM's checks on this branch at c48be49 on our three GB10 hosts, with the GLM-5.3 NVFP4 checkpoint, and they all pass:

  • One host: the build, zig fmt --check, test-cuda-host and zig build test, then glm-ref, the thin and synthetic lanes checks (cover and concurrent) and glm-shared-check.
  • TP=3 and TP=2: the concurrent check (every stream equals its solo run) and the shared check (108 rows of 5 streams equal their lone twins' logits, tokens and drafts byte for byte), and at TP=2 the cover check.
  • The server at TP=2 gives the same replies as glm: GLM-5.3-Flash (NVIDIA's NVFP4 checkpoint) as a CUDA family at TP over hosts #804's head (6380c8d) on the requests we compared, by hash, rounds and accepted drafts.

So the Ling commits leave GLM's checks and replies as they were. We have no Ling weights here, so this covers GLM only.

@jschmied

Copy link
Copy Markdown
Contributor Author

Thank you, @Bizuayeu, that's the check we couldn't run.

We'll push three more pieces to this branch shortly: serving at TP=1 (engine, lane backend, kept prompt states), the MTP drafter (model.layers.72) and a GPU packer for the experts. None of them touches GLM's code: git diff c48be499 -- zig/src/families/glm zig/kernels zig/src/cuda only adds a packer kernel and its host call to modelopt_pack.cu / cuda/modelopt.zig, which GLM doesn't use. A speed pass on the Ling kernels may follow; if it touches any shared GLM kernel, we'll say which and how we checked GLM is unchanged.

A rerun once it's up would be welcome, if you have the time.

@jschmied

Copy link
Copy Markdown
Contributor Author

Pushed ac58df63 (serving, MTP, expert packer, speed pass). The one shared GLM file touched is glm_glue_moe.cuh, a fast path inside the group-limited select that GLM (one group) never enters; glm-route-old, glm-mla-same and glm-ref are byte-equal / pass on this head.

@ashhart

ashhart commented Oct 15, 2026

Copy link
Copy Markdown
Owner

Thank you, @jschmied, for the GLM TP=2/3 checks and for identifying the upcoming serving, MTP and packer changes separately.

The routing comparison needs care: zig/kernels/cuda/moe_glue.cu:46 in #798 ranks logit+bias, while #874 ranks sigmoid(logit)+bias. Replacing one with the other changes picks and weights. We'll come back on where the shared routing should live.

At c48be49, the zero-limit GLM behavior and the 16-row helper's arbitrary-resume behavior remain open. Please send the pushed serving/MTP head and its delta from c48be49 so we can recheck those paths and the unchanged-shared-kernel claim.

Posted by Ash's AI Assistant Synthena

@jschmied

Copy link
Copy Markdown
Contributor Author

Thank you. Both open items are closed at e29e0c20:

  • Zero limit (6676c13c): at 6380c8da GLM's glue swiglu, like Metal's glm_rows_glu, clamps at the limit it's given, 0 included; 51c01573 had mapped 0 to unclamped for every caller. GLM's swiglu passes its limit as given again, and only Ling's swigluWith maps 0 to unclamped. glm-route-old now also compares GLM's swiglu at limit 0 with 6380c8da's kernels, byte for byte (it compared only 10 before; putting the old mapping back fails it).
  • The 16-row chunk helper (e29e0c20): nothing served called it, so it's out: glm_kda_chunk.cu, its tests and the chunked path in glm-kda-ling. GLM and Ling run the row path, which resumes at any row, and zig/src/families/glm/cuda_kda.zig is 6380c8da's file again.

Delta from c48be499 in GLM's and shared code (git diff --stat c48be499 e29e0c20 -- zig/src/families/glm zig/kernels zig/src/cuda zig/build zig/src/native):

  • glm_glue_moe.cuh: a division-free branch inside the group-limited select, taken only when groups are whole multiples of 32 experts (Ling's 64); GLM's single group never enters it.
  • glm/cuda_glue.zig: the swiglu split above.
  • modelopt_pack.cu / cuda/modelopt.zig: an added kernel, tf_pack_fp8_experts, for Ling's loader; the existing packers' SASS is unchanged.
  • ling_glue.cu, probe.cu (a timing spin kernel for tests), build/cuda.zig (Ling's module now also imports lanes), native/cuda.zig (Ling in the registry).
  • Removed: glm_kda_chunk.cu and its build and kernel-list entries.

Checked at e29e0c20: glm-ref, glm-route-old (select, and swiglu at 10 and 0), glm-mla-same (13 MLA launches byte-equal to 6380c8da), glm-kda-ling, zig build test, zig fmt --check, lean count unchanged. Everything else (zig/src/families/ling, its tests, oracle/, the recipe) is Ling-only: serving at TP=1, the MTP drafter, the expert packer and a speed pass.

@ashhart

ashhart commented Oct 15, 2026

Copy link
Copy Markdown
Owner

Thank you, @jschmied, for restoring GLM's zero limit behavior and removing the 16 row WY path.

We checked e29e0c2. glm/cuda_glue.zig:132 now preserves GLM's literal limit, and its KDA host file matches the earlier implementation. The KDA blocker remains at ling/config.zig:388: unsupported head widths and convolution tap counts still reach fixed kernels. Other open cases are Store.alloc ownership on allocation failure, capacity refusal after KDA state commits, singleton routing groups, engine initialization cleanup, and the undefined slot read at ling/lanes.zig:143. Full checkpoint checks and the exact HF source comparison also remain.

Please send a focused revision covering these cases, with geometry rejection and allocation failure regressions, so we can recheck it.

Posted by Ash's AI Assistant Synthena

@Bizuayeu

Copy link
Copy Markdown
Contributor

@jschmied, we ran GLM's checks again at e29e0c2 on our three GB10 hosts, with the GLM-5.3 NVFP4 checkpoint, and they all pass:

  • One host: the build, zig fmt --check, test-cuda-host and zig build test, then glm-ref, the thin and synthetic lanes checks (cover and concurrent) and glm-shared-check.
  • TP=3 and TP=2: the concurrent check (every stream equals its solo run) and the shared check (108 rows of 5 streams equal their lone twins' logits, tokens and drafts byte for byte), and at TP=2 the cover check.
  • The server at TP=2 gives the same replies as glm: GLM-5.3-Flash (NVIDIA's NVFP4 checkpoint) as a CUDA family at TP over hosts #804's head (6380c8d) on the requests we compared, by hash, rounds and accepted drafts.

We had also run ac58df6 the same way, with the same results. As before, this covers GLM only.

@jschmied

jschmied commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor Author

@ashhart, thank you. @Bizuayeu, thanks for rerunning the GLM checks at e29e0c2.

A focused revision on top of e29e0c20, one commit per case (head 7c446062). Each fix has a regression test, and each test fails with its fix reverted.

1. Geometry rejected at config load (19cf6f4f)
config.parse now refuses with a named error, before any allocation or launch, every shape the kernels don't take:

  • KdaGeometry: head_dim other than 128 and short_conv_kernel_size other than 4 (glm_kda.cu's DK and TAPS), and key/value heads other than the query heads.
  • MlaGeometry: kv_lora_rank other than 512, qk_nope_head_dim/v_head_dim other than 128, qk_rope_head_dim other than 64, NeoX rope, and a q_lora_rank without an rmsnorm instance.
  • Width: hidden_size other than 512/1536/4096, and MLP and expert widths outside whole 128-column blocks.
  • TiedHead: tied embeddings.

Regression (test-ling): 26 values refused one at a time on the tiny config, each checked for its error and reason. The tiny config with the kernels' own values and the real config are accepted. A test also holds the fixed widths to glm.kda.dk, glm.latent.rope_width and fp8.block.

2. Store.alloc ownership on failure (d11ad3f9)
Before, a failed name copy or named.append freed a buffer already in buffers, so deinit freed it again, and the copied name leaked. alloc now reserves both lists and copies the name first, then allocates and appends without any step that can fail: on error the store holds nothing new.

Regression: fake_driver.zig is a host stand-in for the driver (device memory in host memory, kernels as no-ops, call N failing on request). store_test fails every host allocation in turn (checkAllAllocationFailures) and every device call in turn. No leak, no buffer left held, no double free. The old alloc fails both tests.

3. Capacity refusal before KDA commits (c578f33f)
The capacity check sat in the MLA block, after the KDA layers before it had run. A prompt chunk past the capacity had already committed their states and conv windows, and a window had already written its saves. Forward.rowsOf now refuses any segment without room before any layer runs. The MTP layer is a single MLA block that already refused before its cache write.

Regression (ling-lanes, engine checks, at 1012 of 1024): a 30-row prompt chunk, a 16-row window and, with --mtp, MTP rows past the end each return CacheFull. The sequence's arenas stay byte for byte as they were. The next window then equals a twin's in tokens, logits and states, and a new request on that slot equals one on a fresh slot. With the old check the arenas differ and the next commit fails with KdaLayersOutOfStep.

4. Singleton routing groups (d6f5ad88)
HF's group_limited_topk scores a group by its top two (topk(2) raises on a group of one) and doesn't renormalize a single pick. Groups of one and num_experts_per_tok 1 are now refused at load. n_group 1, topk_group == n_group and groups of two stay accepted.

Regression: the refusals and the four accepted edge cases in test-ling. glm-ref adds five glue.select shapes against the float64 rule: one group, 8 of 8 kept, groups of two (64 in 32 and 16 in 8), and the tiny config's 16 in 4. Picks are equal and weights are within 2^-16.

5. Engine init cleanup (d523c01d)
The one gap was sequence 0 and the slot list, which leaked when bind(0) or the closing synchronize failed. Both are freed now.

Regression: ling-init-faults DIR [--mtp] runs native.open (engine, sequence 0, lane backend) on the fake driver. It opens once cleanly, then fails each driver call and each host allocation of the open in turn, and requires each failed open to leave nothing held, leaked or freed twice. Over bf16, FP8 and NVFP4, with and without MTP, that is 8,591 failure points, all clean. Dropping this fix, or any of five existing errdefers (engine, graph, state, weights, mtp), fails it.

6. Undefined slot read in lanes.zig (67fb3c22)
getOrPut was followed by freeSlot(), which walked the new entry before its slot was set. In a release build a reused bucket keeps its old value, so a free slot could count as taken. Lanes.claim now picks the slot first and then puts the entry.

Regression: slotCheck claims slots over map buckets that still hold slot 0 from cleared entries. It runs as a host test and as ling-slots in the release build, where the old order fails with SlotTaken. In a debug build the hash map writes 0xAA into a new value, which hides the bug there.

7. Full checkpoint and HF source

  • Full checkpoint (def8beba): the loader's shapes now come from one table, names.dims. ling-ckpt-check DIR [INDEX] reads only the shard headers and checks every tensor against the weight map at the dtype and shape the loader reads, including FP8 scale_inv blocks, NVFP4 codes and scales, bf16 exceptions, small tensors and convs. It also requires nothing missing, nothing extra, and the bytes to add up to the index's total_size. On the FP8 release (54 shards): 214,025 tensors, 3,100 of them in the MTP layer, 573,753,281,152 bytes, no mismatch.
  • HF source (7c446062): oracle/ling_hf.py pins inclusionAI/Ling-3.1-flash at a6943f4bf04f8041ad983d2b58dd43b5ba86ae4a, with the sha256 of modeling_bailing_moe_v3.py, configuration_bailing_moe_v3.py, config.json and the index. config.json is as of that revision; the other three are unchanged since the upload. ling_mtp.py, ling_layer.py (now given the modeling folder the chain was captured with) and ling_weights.py stop on any other copy. test-ling holds fixtures/config.json to the pin. Our local copies match HF at that revision byte for byte.
  • Still not possible on one 128 GB Spark: a 72-layer forward of the 572 GB FP8 checkpoint, so no end-to-end logits against HF, no serving of the real model and no real MTP acceptance. HF's own end-to-end run would need about 1.1 TB in bf16, so the HF reference stays the layer-by-layer captured chain. The real-weight checks remain single layers (0, 4, 7, 65, 71 and the MTP layer); the remaining layers could be checked one at a time from that chain at about 80 s per layer, which we haven't run. We also haven't hashed the shard data in this revision; the check reads headers only.

Re-run at 7c446062:

  • Host and lint: zig build test, test-ling 30/31 (1 skipped), zig fmt --check, lean_check 527 (unchanged).
  • GLM unchanged: glm-ref; glm-route-old, glm-mla-same (13 launches, 14.3 MB equal to 6380c8da) and ling-route-old.
  • Ling: glm-kda-ling; ling-lanes bf16/FP8/NVFP4 with and without --mtp (95 and 104 checks); ling-weights for the three formats; ling-pack layer 4.
  • Real layer: ling-layer-real L4 at rel L2 3.126e-3, as before. Its output bytes equal e29e0c20's.

@ashhart

ashhart commented Oct 16, 2026

Copy link
Copy Markdown
Owner

Thank you, @jschmied, for the focused fixes and regression cases at 7c44606.

The config refusals, Store ownership, normal sequence-capacity checks, slot initialization and engine cleanup now address the earlier cases in source. GLM's zero-limit behavior remains restored and WY remains removed.

Two follow-ups remain. ling/forward.zig:37 checks sequence capacity but leaves scratch capacity to the later MLA block; manually mismatched scratch and state can still commit KDA before refusing. Normal Engine allocations use matching capacities. In ling/weights_proj.zig:273, a failed packer launch can free pinned upload storage before the stream drains.

Please add the scratch preflight and drain-before-free error path, with a mismatched-capacity case and a test that keeps a transfer pending. We can fold these small fixes in at landing. We'll compare the newly pinned HF sources and run the host/fault checks, followed by full-model drafted/plain, resumed/fresh and concurrent/solo tests with arbitrary cuts and partial keeps.

Posted by Ash's AI Assistant Synthena

@jschmied

Copy link
Copy Markdown
Contributor Author

@ashhart, thank you. Both follow-ups are in, one commit each, on top of 7c446062 (head a78a7a49).

1. Scratch preflight (84050923)
block_mla.room checks a segment against the sequence's capacity and against the scratch's attention partials (Buffers.nch, the only scratch buffer sized from a capacity; row counts were already checked). Forward.rowsOf calls it for every segment before any layer runs, with the same CacheFull. block_mla also calls it for every segment before its first cache write. That covers the MTP layer, which runs the block without rowsOf.

Regression (ling-lanes, engine checks): Buffers built for 512 positions against sequences of 1024, with twins at 500. A 30-row prompt chunk, a 16-row window and, with --mtp, 16 MTP rows each fit the state but not the scratch, and each returns CacheFull. The arenas (KDA states, conv windows, MLA and MTP caches) and the position stay byte for byte as they were. The next window then equals the twin's in tokens, logits and states, and a new request on that slot equals one on a fresh slot.
With the preflight reverted, the arenas differ and the next commit fails with KdaLayersOutOfStep. With only block_mla's ordering reverted, the MTP case fails on its own.

2. Drain before free (a78a7a49)
Reader now records whether an upload or packer launch is queued, and settled() drains it before anything is freed. That covers the expert stage (pinned host and device, weights_proj.zig:273) and the bf16/FP8/NVFP4 lane staging. I found the same pattern in other places and fixed them too:

  • the packed-output store in weights.load
  • absorbOf's identity rows, results and partials in graph.zig
  • the engine's weights and graph when a later init step fails
  • keepState's copy targets

If the drain itself fails, the memory stays allocated on purpose. Store.drop, Weights.drop and Graph.drop free the host lists and leak the device buffers.

Regression: fake_driver.zig now keeps async copies, memsets and launches pending until a successful synchronize. It counts every free made while work is pending, and it can fail every call from N on, drains included. ling-init-faults fails each driver call and host allocation of the open plus a kept state in turn, with transfers pending, and then every call from N on. No free may happen while work is pending, and memory may stay held only after a failed drain. Over bf16, FP8 and NVFP4, with and without MTP, that is 15,180 failure points (1,804 of them failing with a transfer pending), all clean.
I reverted each fix on its own (Stage.deinit, lane staging, NVFP4 lane staging, the load's store, absorbOf, the engine's weights, keepState), and each revert makes the test fail. The Stage.deinit revert gives 24 to 32 failing points per FP8/NVFP4 run.

Re-run at a78a7a49:

  • Host and lint: zig build test, test-ling 30/31 (1 skipped), test-cuda-host, zig fmt --check, lean_check 527 (unchanged).
  • GLM unchanged: glm-ref; glm-route-old and glm-mla-same (13 launches, 14.3 MB equal to 6380c8da); ling-route-old.
  • Ling: glm-kda-ling; ling-slots; ling-ckpt-check (three synthetic checkpoints and the full FP8 release); ling-init-faults and ling-lanes for bf16/FP8/NVFP4 with and without --mtp (100 and 109 checks); ling-weights for the three formats; ling-pack layer 4.
  • Real layer: ling-layer-real L4 at rel L2 3.126e-3, as before. Its output bytes equal 7c446062's.

The branch conflicts with main since 1.0.12 (#804 is in it). I can rebase it onto main if that helps the recheck.

…layer shared SwiGLU limit, GLM's bytes unchanged

tf_glm_topk takes n_group and topk_group: with more than one group it keeps the
topk_group groups of the best top-2 biased sums (lower group on ties) before the
top-k. GLM's one group skips that path. swiglu's limit 0 now means unclamped (as
the experts' 0), and the MoE layer carries the shared expert's limit apart from
the routed one's. Tests: glm-ref adds group routing vs float64 and the swiglu
limit; glm-route-old compares GLM's select and swiglu with 6380c8d's fatbin;
glm-route-ling checks captured Ling layer-4 routing against HF and exllamav3.
…SwiGLU limits as GLM's kernels take them

config.zig parses config.json (BailingMoeV3ForCausalLM, bailing_hybrid) into a
typed Config: widths, the MLA/KDA schedule ((i+1) % layer_group_size, tail MLA),
dense layers, KDA safe gate, MLA head-wise gate and GPT-J rope, sigmoid routing
with 8 groups keeping 4, per-layer expert and shared SwiGLU limits, one MLA MTP
layer, FP8 128x128 blocks and modules_to_not_convert. It refuses what the engine
does not run, each with a one-sentence reason. names.zig enumerates every tensor
the config implies by role, with FP8 scale companions; against the checkpoint's
index (fixtures/names.txt, folded, count and sha256 checked) it finds all 214,025
names, none unaccounted: 210,925 backbone and 3,100 of the MTP layer 72.
cuda.zig gives select()'s Groups and each layer's limits for moe.Layer.
Host tests: zig build test-ling (also under test-cuda-host).
…Opt NVFP4) at TP=1, synthetic checkpoints in each

config.zig: Quant gains nvfp4. quantization_config fp8 (128x128) or modelopt NVFP4 (groups
of 16), or hf_quant_config.json's block (parseWith); ignore / exclude_modules /
modules_to_not_convert as names, dotted tails or * globs; format(module) gives each
linear's format; exl3 refused naming ashhart#832. names.zig emits each format's side tensors
and moduleName() gives the module a role reads.

weights.zig / weights_proj.zig: one Layer per layer with a qlinear.Weight per
projection (bf16, fp8g, nvfp4 through modelopt.Packer and fp8.packScales), routed
experts as experts.Layer (Fp8Experts / Nvfp4Experts packers; bf16 as lane weights,
no grouped bf16 kernel), the shared expert as projections, norms, fp32 q|k|v convs,
A_log, dt_bias, bf16 router (a quantized one refused) and bias, the MTP layer's
tensors. A file tensor whose dtype or shape disagrees with the config is refused.

Tests: test-ling (21, with TF_LING_INDEX on the real index); ling-synth writes 4-layer
+ MTP checkpoints with the real names (bf16 282, FP8 517, NVFP4 978 tensors, NVFP4
mixing a bf16 dense layer); ling-weights: every projection decode and prompt vs
float64 from the file, worst 0.456 of the bound, experts' gate-up and down, 37 small
tensors byte-equal in each format; ling-weights-real: layers 0, 4, 7 with 4 experts
from the FP8 release vs torch (oracle/ling_weights.py), 35 products, worst 0.414 of
the bound, rel L2 <= 1.7e-3. Mutations caught: gate/up swapped, conv order, NVFP4
global dropped, exceptions ignored, expert down scales misplaced.
…e heads, head-wise gate

chunks_wide<HB, RW>: q and cache rows LW + RW wide, scores over all of them, values
from the 512 latent; tf_glm_chunks_r16 is the 576-wide 16-head form (RW 0 builds
GLM's kernels to the same SASS). absorb_q/expand_v take the head width as a template
(GLM 256, Ling 128; the 128 absorb reads q_nope at a q stride, writes qa at a stride).
New tf_glm_rope (GPT-J pairs, fp32 angles, sincosf) and tf_glm_head_gate (fp32 sigmoid).
Host: absorbQStrided, expandV at 128, rope, ropeFreqs, headGate, attentionRoped.

Checks: ling-mla (float64 bounds; decode rows, a verify window and a cut prompt across
key 1536 equal the uncut call), ling-mla-real (layer 7 vs HF and float64; 400 + 32
decode rows, an 8-row window and a cut at 300 equal the prompt), glm-mla-same (GLM's
MLA launches against a 6380c8d glm_glue fatbin, byte for byte).
…opt-in 16-row WY chunked prompt path

- glm-kda-ling: glm_kda's fused chain, 16-row windows, wide prompt and chunked prompt on Ling-3.1 layer 0
  (q/k/v convs as one q|k|v conv): rel L2 3.6e-3 vs fla, within float64's bound; replays == chain bits.
- glm_kda_chunk.cu: new entry tf_glm_kda_chunk over prep's saves (kda.chunked, kda.chunkReplay). Opt-in only:
  GLM stays on the row path; glm_kda.cu and its fatbin are unchanged. Not the row path's bits; cuts at
  multiples of 16 == uncut, a prompt resumed elsewhere differs from a fresh one (measured in glm-kda-chunk).
- glm-kda-chunk: float64 bounds end to end and from the saves, saves equal the wide chain's, resumed vs fresh.
- glm-kda-speed: 64 heads x 128, 2048-row prompt row path vs chunked, decode row, 16-row window.
…dense and MoE MLPs, head, windows and commits

Every projection runs as the qlinear.Weight and every routed-expert layer as the experts.Layer the checkpoint
loaded (bf16, FP8 block, NVFP4); MLA's absorbed kv_b is derived through kv_b's own format on identity rows.
ling_glue.cu rounds the residual, head gate and expert sum where the HF bf16 model does. Decode windows run
KDA's three-kernel chain as GLM's forward does; commit(keep) replays the KDA states of a partial window.

ling-forward-synth: synthetic bf16/FP8/NVFP4 logits vs a float64 reference routed on the same picks (2.1-2.3e-2,
as far as HF's bf16 rounding points are), cut == uncut, windows == decode rows, a partial commit, batched == alone,
fused == three-kernel chain, bit for bit. ling-layer-real: real FP8 layers 0, 4, 7, 71 vs HF 3.0-3.7e-3 without
routing flips. GLM files untouched.
Ling's reference (inclusionAI's SwigluStepAndMul, SGLang's BailingMoeV3MLP)
clamps silu's output; GLM's glue and the grouped expert kernels clamp the
gate before silu. Ling now launches its own instances: tf_ling_swiglu_512/64
in glm_glue (glue.swigluWith) for the shared, dense and bf16 routed experts,
and epilogue 4 of the FP8 and NVFP4 grouped kernels, decode and prompt,
chosen by experts.Layer.after (set by the Ling loader, refused by affine4).

Every pre-existing kernel keeps its SASS and resource usage (cuobjdump over
all 40 fatbins vs a33dde0; 37 fatbins byte-identical, the other 3 only add
the new instances). ling-clamp-old runs the kept FP8 (12) and NVFP4 (13)
instance calls against a33dde0's fatbins byte for byte; glm-route-old and
glm-mla-same still equal 6380c8d.

At L = 10 both orders give the same bf16 bytes (silu is rounded to bf16
first and bf16(silu(g)) = 10 for g >= 10), so the real layers 65 and 71 and
the synthetic forward measure the same digits; ling-clamp checks the order
against float64 at L = 10 and L = 2 (where 16,255 bf16 gates differ) and
catches 5 of 5 mutants. The float64 oracle clamps after silu too.
…ver hooks and kept prompt states

Engine: a sequence per slot, prompt chunks cut at any row (marks included), verify windows of up to 16 rows,
shared windows of up to 8 streams, greedy (tf_glm_top1) and sampled (sample.cu) draws, kept states as arena copies.
Lanes: GLM's cuda_lanes.zig without the ranks. native.zig registers bailing_hybrid with the CUDA server.
ling-lanes checks chunked == whole, drafted == plain, resumed == fresh (marks off multiples of 16), concurrent == solo,
in tokens and state bytes, and the served logits against ling_forward.py's float64 on the same picks.
…h_proj, MLA + MoE on its own cache, chains of 3

mtp.Head runs the MTP layer over a view of the weights (final_layernorm as its norm, the shared head); each sequence
keeps a second State for its MLA cache. Prompt passes feed every row whose next token is known, Engine.draft absorbs
a verify's kept rows and chains drafts on the layer's own final-norm rows, kept states copy the MTP cache.
ling-lanes --mtp: MTP-drafted == plain (greedy, sampled, resumed, concurrent), chained drafts by hand, MTP logits vs
ling_forward.py --mtp float64; ling-mtp-real: the real layer vs HF's BailingMoeV3MTPLayer (oracle/ling_mtp.py).
… and checks its bytes against the host packers; ling-layer-real can dump its rows
…ed on the GPU (Packer.fp8Experts, fp4Experts), the file's pages dropped per batch
…hase; ling-layer-bytes writes every path's bytes for cmp
… no copies (4 KDA, 2 per MLP), the same bytes
…e expert kernels (same bits, -7 to -27 % expert time up to 768 rows)
…ne columns (Ling's 64), same picks and weights; ling-route-old
…dle GPU, the expert kernels and plan tiles side by side
…_spin), so other contexts' time slices miss the least
… to unclamped

At 6380c8d GLM's glue swiglu (like its Metal glm_rows_glu) clamps at the limit it is given, 0 included.
51c0157 made the glue map 0 to +inf for every caller; that mapping now applies to Ling's kernels only.
- glm-route-old: GLM's swiglu equals 6380c8d's kernels at limits 10 and 0 (0 was not compared before).
- glm-ref: the limit-0 check runs on tf_ling_swiglu_*, the path Ling's unclamped layers take.
…ig is 6380c8d's again

Nothing served called it: GLM and Ling run the row path, which resumes at any row. Removed with it: glm_kda_chunk.cu,
glm-kda-chunk, glm-kda-speed (wide vs chunked timing), and the chunked path in glm-kda-ling (chain, 16-row windows and
the wide prompt on Ling's layer 0 stay checked against fla and float64).
…with a named error

parse ran KDA head widths, conv tap counts and the MLA widths straight into kernels built for one shape. It now
refuses, before any allocation or launch: head_dim other than 128 and short_conv_kernel_size other than 4
(glm_kda.cu's DK and TAPS), key/value heads other than the query heads (KdaGeometry); kv_lora_rank other than 512,
qk_nope/v_head_dim other than 128, qk_rope_head_dim other than 64, NeoX rope, a q_lora_rank without an rmsnorm
instance (MlaGeometry); hidden_size other than 512/1536/4096, MLP and expert widths outside whole 128-column blocks
(Width); tie_word_embeddings (TiedHead).
- config_test: 26 refused values on the tiny config, each with its reason; the tiny config with the kernels' own
  values and the real config accepted. cuda.zig's test holds the fixed widths to glm.kda, glm.latent and fp8.block.
…oup and every group kept checked vs float64

HF's group_limited_topk scores a group by its top two (topk(2) raises on a group of one), and with one pick it skips
the renormalization glue.select applies. Both now stop at config load (Routing), naming HF's rule; n_group 1,
topk_group == n_group and groups of two stay accepted.
- config_test: the refusals on the tiny config, and the four accepted edge cases.
- glm-ref: glue.select against the float64 rule on five more shapes: one group, 8 of 8 groups kept, groups of two
  (64 experts in 32, 16 in 8) and the tiny config's 16 in 4; picks equal, weights within 2^-16.
… driver to fail every call in turn

A failed name copy or named.append used to free the device buffer already in buffers (Store.deinit freed it again)
and lose the copied name. alloc now reserves both lists and copies the name first, then allocates and appends without
failing: on any error the store holds nothing new.
- fake_driver.zig: the driver's entry points over host memory (kernels no-ops), counting allocations and handles,
  with call n failing on request; host tests and GPU-free checks build on it.
- store_test: every host allocation failing in turn (checkAllAllocationFailures) and every device call failing in turn
  leave no leak, no buffer held and no buffer freed twice. The old alloc fails both.
…s.zig read the entry's unset slot)

opening() did getOrPut and then freeSlot(), which walks every entry, the new one included, before its value was set.
In a release build a reused bucket keeps its old slot, so a free slot could count as taken and the stream got a
higher one. Lanes.claim picks the slot first, then puts the entry.
- slotCheck: claims over buckets that still hold slot 0 from cleared entries (the same keys hash back into them),
  as a host test and as `ling-slots` in the release build, where the old order fails (SlotTaken).
…ns, not at the first MLA layer

The capacity check sat in block_mla, after the KDA layers before it had run: a prompt chunk past the capacity had
already committed those layers' states and conv windows (and a window had written its saves) when CacheFull came
back. Forward.rowsOf now refuses every segment without room first, so a refused request changes nothing. The MTP
layer, one MLA block, already refused before its cache write.
- ling-lanes (engine checks): at 1012 of 1024, a 30-row prompt chunk, a 16-row window and (with --mtp) MTP rows past
  the end each come back CacheFull with the sequence's arenas byte for byte as they were; the next window equals a
  twin's (tokens, logits, states) and a new request on that slot equals one on a fresh slot. With the old check
  the arenas differ and the next commit fails (KdaLayersOutOfStep).
… sync fails; every init failure checked

The one gap in init's errdefer chain: sequence 0 (its arena, MTP cache and hold row) and the slot list leaked when
bind(0) or the closing synchronize failed. Both are freed now.
- ling-init-faults DIR [--mtp]: native.open (Engine.init, sequence 0, the lane backend) on the fake driver, once
  cleanly, then with each driver call and each host allocation of the open failing in turn; each failed open must
  leave no device or pinned allocation, no module/stream handle, no host leak and nothing freed twice. bf16, FP8 and
  NVFP4, with and without --mtp: 714 to 1,832 calls and 251 to 426 allocations a clean open, 8,591 failure points,
  none left anything. Dropping this fix, or any of five errdefers in engine, graph, state, weights and mtp, fails it.
…s names, dtypes and shapes, from headers alone

The loader's shapes now come from one table, names.dims (each role's [n, k], value count or conv shape); weights.zig
reads every tensor through it instead of computing shapes inline (the same shapes, ling-weights unchanged).
- ling-ckpt-check DIR [INDEX]: each shard's header only, every tensor against the weight map at the loader's dtype and
  shape (FP8's e4m3 weights and scale_inv blocks, ModelOpt NVFP4's codes and scales, bf16 exceptions, small tensors
  and convs), nothing missing, nothing extra, the bytes against the index's total_size. On the FP8 release (54 shards
  over sshfs, 3 s): 214,025 tensors, 3,100 of them the MTP layer's, 573,753,281,152 bytes, no mismatch. The three
  synthetic formats pass too; a wrong dims entry or scale shape fails it.
…ng-3.1-flash@a6943f4b by sha256

ling_hf.py pins the revision (a6943f4bf04f8041ad983d2b58dd43b5ba86ae4a: config.json as of ashhart#2; the modeling,
configuration and index files are unchanged since the upload) and the sha256 of modeling_bailing_moe_v3.py,
configuration_bailing_moe_v3.py, config.json and model.safetensors.index.json. ling_mtp.py checks the modeling package
it imports and the checkpoint's config and index; ling_layer.py takes the modeling folder the chain was captured with
and checks it; ling_weights.py checks the index. Any other copy stops the oracle naming both hashes.
- test-ling holds fixtures/config.json to the pinned sha256.
- The local copies match HF at that revision byte for byte; ling_layer.py rebuilds the layer-4 fixture identically.
…uns, not at the first MLA layer

Forward.rowsOf checked the sequence's capacity only; the scratch's attention partials (Buffers.nch, sized from the
capacity the Buffers were made for) were checked inside block_mla, after the KDA layers before it had committed and
after the segment's latent row was written. With scratch smaller than the state a prompt chunk or window that fit
the state still changed it before CacheFull came back. block_mla.room now checks both; rowsOf calls it for every
segment when the graph has MLA layers, and block_mla calls it for every segment before its first cache write (the
MTP layer, which runs the block without rowsOf).
- ling-lanes (engine checks): Buffers made for 512 positions against sequences of 1024, twins at 500: a 30-row
  prompt chunk, a 16-row window and (with --mtp) 16 MTP rows each come back CacheFull with the arenas byte for byte
  as they were; the next window equals the twin's (tokens, logits, states) and a new request on that slot equals one
  on a fresh slot. With the preflight reverted the arenas differ and the next commit fails (KdaLayersOutOfStep);
  with only block_mla's order reverted the MTP case alone fails.
…a failed drain leaks them on purpose

A failed packer launch, upload or copy returned through defers that freed what queued work could still read or
write: the expert stage (pinned host and device, weights_proj Stage.deinit), the lane staging of bf16, FP8 and NVFP4
projections, the store of packed outputs (weights.load), absorbOf's identity rows and results, the engine's weights
and graph when a later init step failed, and keepState's copy target. The Reader now tracks whether an upload or
launch is queued (settled() drains it first); the other paths drain before freeing. A drain that fails leaves the
memory allocated: Store.drop, Weights.drop and Graph.drop free the host lists and leak the device buffers.
- fake_driver.zig: async copies, memsets and launches stay pending until a successful synchronize; frees with work
  pending are counted, failures can persist from call N on (drains included), and leaked memory can be swept.
- ling-init-faults: every driver call and host allocation of the open and a kept state fails in turn with
  transfers pending, then every call from N on; no free may come while work is pending, and memory may stay held
  only after a failed drain. Over bf16, FP8 and NVFP4 with and without MTP: 15,180 points (1,804 failing with a
  transfer pending), all clean. Each fix reverted alone fails it (Stage.deinit, lane staging, NVFP4 lane staging,
  load's store, absorbOf, the engine's weights, keepState).
@jschmied

Copy link
Copy Markdown
Contributor Author

Rebased onto main 46a08c18 (1.0.12), now at 1395e267, since #804 is in main. Conflicts were only in wiring: build, the CUDA family registry in native/cuda.zig, the hub title and the test command table. Every Ling-only file is byte-identical to a78a7a49. Re-run: GLM unchanged (glm-route-old and glm-mla-same equal to 6380c8da); ling-lanes ids/picks byte-equal to before; ling-layer-real L4 output bytes equal (rel L2 3.126e-3). Hashes in the description are mapped to the new commits.

@jschmied

Copy link
Copy Markdown
Contributor Author

We are pausing here. The branch stays as it is at e99e6505, and anyone is welcome to continue it.

Why: the whole model does not fit one GB10. FP8 is 574 GB and NVFP4 about 324 GB, so an end-to-end run, real MTP acceptance and TP all need several Sparks; this PR was checked on synthetic checkpoints and single real layers only. A one-Spark build would have to be around 1.25-1.7 bpw EXL3, which TensorFold reads only once #832 is in. We tried our own 1.25 bpw conversion and stopped it: with 1-bit experts, each layer's error rose about 1.5x over 1.5-bit experts and the hidden states drifted quickly with depth, so we didn't expect a usable model.

Next steps: someone with two or more Sparks can take TP and the end-to-end checks (docs/recipes/ling-3.1.md lists the open items and how to test). We will pick it up again if a quantization that fits one Spark and holds up in quality is published.

@ashhart

ashhart commented Oct 17, 2026

Copy link
Copy Markdown
Owner

Thank you, @jschmied, the scratch and failure controls leave useful work available even though the whole model exceeds one GB10.

We checked e99e650 and will keep the branch parked, following your pause. It stays parked until a quality-qualified pack fits one GB10 or the TP fabric in #636 lands, with resumption left to you.

The failed-drain teardown follow-up at engine.zig:149-163/199-204 can go in at landing. We will retain your synthetic, allocation and real-layer receipts with their scope; whole-model, real MTP and TP qualification still need their own runs when work resumes.

Posted by Ash's AI Assistant Synthena

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.

3 participants