Skip to content

GLM-5.3-Flash on two CUDA ranks: linears packed to 4, 6 or 8 bits - #995

Merged
ashhart merged 53 commits into
ashhart:mainfrom
BobClawblaw:pr/glm-packed-dense
Oct 18, 2026
Merged

ashhart merged 53 commits into
ashhart:mainfrom
BobClawblaw:pr/glm-packed-dense

Conversation

@BobClawblaw

@BobClawblaw BobClawblaw commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

GLM-5.3-Flash on two CUDA ranks: linears packed to 4, 6 or 8 bits

On top of #855 (the branch contains it). glm_cuda served GLM-5.3-Flash with its dense linears packed to 4 bits and its head to 8. #855's port read them as bf16, and most of the gap to glm_cuda's speed was those bytes:

  • glm_cuda's README measured a verify step at 22 ms + 6.9 ms a row.
  • GLM-5.3-Flash on two CUDA ranks, in families/glm #855 took about 52 ms + 6.8 ms a row on the same pair. The rows' expert reads are equal; the fixed part is the dense linears and the head read at four times the bytes.

The packing:

  • Settings: TF_GLM_DENSE_BITS and TF_GLM_HEAD_BITS, as glm_cuda named them: 4, 6 or 8, or 16 (bf16, the default). Without them nothing changes.
  • The ranks agree on both in the settings fingerprint, as a new bits field (layout 7). The host test covers a mismatch on rank 1 only.
  • Quantizer (cuda_qpack.zig): glm_cuda's, with symmetric codes and fp16 absmax scales a 64, ties to even. Its round-trip test comes along.
  • Tiling at load (cuda_tile.zig, which now holds tileLinears): each packed linear is quantized on the host, then tiled by glm_mm_tile4/6/8 in place, so its codes and scales sit inside its bf16 tiles' bytes before they move into a buffer of their own (below).
  • Matmul: glm_mm already carried the packed kernels from glm_cuda (glm_mm{4,6,8}_{16,32,64,128}). cuda_mm.Tiled gains s and bits. A table of the packed linears' pointers, on glue.Set, lets b16.matmul pick the width for each linear. Packed prompt windows take the 128-row bucket.

Also on this branch

  • The bf16 bytes freed. A packed linear that owns its buffer moves into one its own size, and the bf16 buffer is freed. A linear inside a shared buffer stays packed in place. On two GB10s at 4/8 bits, 290 linears are packed and 5.62 GiB a rank is freed (model 81.5 instead of 87 GiB), so 16 streams with DFlash2 fit at a 40960-token window.
  • TF_ROCE_MAX_KB, glm_cuda's setting for a RoCE gather's largest shard, defaults to 4096 KiB. It measured no different from 512 on this stack, but it matches the old recipe.
  • NCCL's cuMem allocations are off by default on the ranks (NCCL_CUMEM_HOST_ENABLE=0, NCCL_CUMEM_ENABLE=0; an explicit setting wins).
    • On GB10 under 8 to 16 streams, the driver logged NV_ERR_NO_MEMORY from _memdescAllocInternal. Every later two-rank start on that node then failed in NCCL's setup until a reboot.
    • With them off: no driver errors through a full 1 / 4 / 8 / 16-stream run, and a stop then start came up first time.

Checks

  • One stream, MTP head only (two DGX Sparks, EXL3 checkpoint). With TF_GLM_DENSE_BITS=4 TF_GLM_HEAD_BITS=8 (glm_cuda's defaults), the same build as without:

    • code 38.2 → 56.8 tok/s
    • prose 26.6 → 40.7
    • list 28.5 → 45.2
    • first token 175–215 → 149–184 ms
  • Quality at 4/8 bits: GSM8K 244/250 and MGSM 226/240 on the recipe suite. glm_cuda's published 4/8-bit numbers are 244 and 223.

  • With DFlash2 (GLM-5.3-Flash on two CUDA ranks: DFlash2 drafts (--drafter) #979) and 16 streams (GLM-5.3-Flash on two CUDA ranks: 16 streams at a 40960-token window #984) merged on top. Greedy replies with DFlash2 are byte-identical to the packed build's without it. The recipe harness at a 32768-token window, --parallel 16, gives aggregate tok/s for prose / code / structured / list:

    Streams this stack glm_cuda's published evidence
    1 43.8 / 69.8 / 58.3 / 49.3 47.5 / 69.5 / 58.2 / 60.2
    8 94.8 / 129.9 / 119.2 / 124.5 111.1 / 154.1 / 139.5 / 157.3
    16 113.4 / 136.4 / 140.1 / 146.8 144.8 / 202.2 / 181.4 / 207.6

    The suite's concurrent (16 together equal each alone) and drafts pass 12/12.

  • CI's no-GPU steps (test, test-kernel-contract, test-golden, fuzz-cortheon, test-cuda-host, zig fmt --check) pass, and the lean check finds 0 new problems.

Not yet

BobClawblaw and others added 30 commits October 14, 2026 18:34
….cu) and the ranks' control link (tp_link.zig): small gathers as RDMA writes over both halves of the port, after b12x's exchange; NCCL carries the rest
…wer: batched prompt passes (prefill_many, addStreams), a backend's refusals for a memory guard, follow() for a tensor-parallel rank 1, shared-pool admission fields, image and video inputs for the native server (vision_inputs, media_fetch, qwen_image, stb_image)
…glm_cuda), its kernels (zig/kernels/cuda/glm: the dense matmul at 4/6/8/16 bits, the EXL3 trellis experts with the fused prompt kernel, latent attention, the indexer, KDA, the hyper-connections, the vision tower's helpers, DFlash2), tf-cuda-test glm-batch/glm-prof/glm-tf, -Dglm

(cherry picked from commit 611959f15964cda97ecd12d0e459ea4ac0e84f78)
…prefill, sample) and no Flash Next test commands
…meration, kernel parameter info, virtual memory)
…ardown hung in NCCL's service threads under the library's unload, reported on PR 855); the startup line says a family keeps its own prompt states instead of 'no prompt cache'
…cel; lean rule and zig fmt

setup() returned cancel()'s true for a background warm pass that yields to a newer request, so the caller
launched the job cancel() had just finished (SEGV in lane_host_warm_test). It now returns false, as for a drop.

Comments one line each; lean_check skips zig/vendor/ (stb_image.h, as on ashhart#726). lane_host.zig and
lanes/engine.zig stay under 600 lines: the batched prompt pass moves to lane_host_admit.zig (many, one) and
lanes/open.zig (addStreams, adoptPrefilled), aliased where they were.
…ders in docs/glm-cuda-notes.md

Splits move code unchanged (only imports, aliases and pub change): kern.zig into kern_attn, kern_kda and
kern_moe; weights.zig into ckpt, experts_load and qpack; dflash.zig into dflash_load and dflash_select;
native.zig into native_draft, native_prompt, native_follow and native_env; exl3.cu's transposed prompt
design into exl3_transposed.cuh; tf-cuda-test glm-tf and glm-prof into glm_forward_run.zig.
…rozen words (the errors/content-image golden)
…nsorfold-native takes memcpy from libSystem again (zig build test on macOS)
…eports; images wait for the shared decoder; no refused hook

- prompt_cache: Entry.pins, Store.pin/unpin. victimBut passes pinned entries over, so a later job's lookup in
  admit.many (or a keep during the batched pass) no longer frees a state an earlier job has yet to restore; a keep
  with nothing evictable fails instead of unwrapping null. LaneHost.setup pins, reported and requeue unpin.
- vision: zig/vendor/stb dropped and no longer compiled. qwen_image.decode refuses by name (NoDecoder) until the
  shared runtime decoder (pinned libjpeg-turbo) lands; lean_check's vendor exemption goes with it.
- lanes: Backend.VTable.refused, Engine.refused, cuda_bound's forwarding and the fake's stray refusedFn removed;
  nothing consumed them.
…; fits tests the smaller on both), its host test in the cuda tests
… reads a gather (sampled draws, the full row, DFlash2), the ranks' settings agreed before the first collective, an image frame bound to its prefill by request id, a kept state allocated before rank 1 hears the keep
…eed only at its last unpin; a job reports resumed before it unpins
…CCL communicator (no TCP link: NCCL_COMM_ID and tp_watch do that now), Group.openRoce brings it up on two ranks, allGather takes it by size alone, Group.check after a sync that reads a gather; tf-cuda-test roce-gather (every size against NCCL's bytes, graph replays, time a call)
…rward, draw, the MTP head's pass, commit), by size
…ilies/glm on main's transport; tree unchanged
…, TF_GLM_HEAD_BITS; 16: bf16, the default): the old family's symmetric codes with fp16 scales a 64 (cuda_qpack), tiled in place by glm_mm_tile4/6/8 and read by glm_mm's packed kernels; the ranks agree on the bits (fingerprint layout 7)

@cwschroeder cwschroeder left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The three original findings at dfc48e75 were fixed in 3daec6e5: malformed settings, loader/packed-table cleanup, and Modules.load initialization. The actual parser probe passes malformed/overflowed and supported values in Debug and Fast; ownership and initialization were checked in source. A separate compaction cleanup finding from the later increment is also fixed in 683b4c0c, as recorded in the follow-up review.

Patch and CPU receipts retain the original repro and the current-head follow-up. The original quantizer round-trip passed in ReleaseFast, and exact-scale half-ties and zero-group checks passed for 4/6/8 bits in Debug. These are host checks.

The separately saved older 30afaa60 full-target run passed 29 GLM tests but failed the delayed HTTP-refusal timing assertion. Its cause was not established or attributed to this PR. The initial-run timing comparison was withdrawn because that temporary log path was shared by overlapping diagnostics.

No outstanding source finding from these review passes; this does not qualify the whole stacked branch or its device/performance measurements. No CUDA/RoCE/model execution was performed here.

Comment thread zig/src/families/glm/cuda_ranks.zig Outdated
}
}
inline for (.{ .{ "TF_GLM_DENSE_BITS", "dense_bits" }, .{ "TF_GLM_HEAD_BITS", "head_bits" } }) |e| if (get(e[0])) |v| {
const b = std.fmt.parseInt(u8, v, 10) catch 0;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Reject parse errors instead of treating them as the legacy zero alias

parseInt(u8, v, 10) catch 0 followed by 0 => 16 accepts abc, 256, -1 and the empty string as bf16. A typo in either new setting therefore changes the requested representation without an error, and the normalized fingerprint cannot reveal it. The CPU repro returns successful settings with both widths at16 for abc. Keep a successfully parsed 0 as the compatibility alias, but return BadSetting on parse failure.

Comment thread zig/src/families/glm/cuda_engine.zig Outdated
e.* = .{ .gpa = gpa, .io = io, .heat_gate = .{ .bands = o.settings.heat, .root = heatRoot() }, .shared_buf = undefined, .d = d, .c = c, .share = share, .spans = spans, .lib = lib, .comm = comm, .settings = o.settings, .m = m, .stream = stream, .pieces = pieces, .w = undefined, .capacity = o.capacity, .bp = undefined, .bd = undefined, .bm = undefined, .hold = undefined, .pairs = undefined, .smp = undefined, .mtp = o.mtp };
e.w = try weights.load(gpa, io, d, stream, e.m.tools(), checkpoint, c, share, layers, o.mtp);
const packs = o.settings.dense_bits != 16 or o.settings.head_bits != 16;
if (packs and !tiledLinears()) return error.PackedNeedsGlmMm; // only glm_mm reads packed tiles

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Register weight and packed-table cleanup before initialization can fail

With TF_GLM_MM=0 and either packed width selected, initialization loads the weights and then returns PackedNeedsGlmMm before registering errdefer e.w.deinit(). A failure inside tileLinears has the same cleanup gap. Reject the incompatible settings before loading, and register weight cleanup immediately after weights.load. The newly owned e.packs slices also need an errdefer before bind/buffer initialization. Inside tileLinears, free the transferred ptrs slice if the subsequent packed_bits.toOwnedSlice fails. The attached patch covers these ownership transitions; no device fault-injection test is claimed.

exl3: exl3.Kernels, // an EXL3 checkpoint's routed experts
mm: mm_mod.Kernels, // glm_mm: the bf16 linears on tiled weights
tiled: bool = false, // the bf16 linears' weights are tiled (Engine.init, TF_GLM_MM unset or 1): glm_mm runs them
packs: ?*const mm_mod.Packed = null, // the linears packed to 4, 6 or 8 bits (TF_GLM_DENSE_BITS, _HEAD_BITS)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Initialize the new packs field in Modules.load

Modules.load creates var m: Modules = undefined, so the field default packs = null is not applied. It only assigns m.tiled = false before returning. For the supported bf16 path with TF_GLM_MM=0, Engine.init never assigns e.m.packs, yet Forward.set reads it unconditionally. Set m.packs = null explicitly in the loader alongside m.tiled.

@cwschroeder

Copy link
Copy Markdown
Contributor

Correction to my review: I withdraw the comparison saying that Cortheon input scaling failed in the initial run and passed in the patched run. The initial log path was also used by an overlapping diagnostic run, so it is not a reliable receipt for that comparison.

The separately saved final run at 30afaa60 still shows 29 passing GLM tests and a failing delayed-HTTP-refusal assertion in the whole target. Its log is attached to the existing patch receipt. The three source findings and the isolated parser/quantizer results remain supported. Future logs use separate run directories.

…ult (8 streams' partial sums and 16-row windows stay off NCCL; glm_cuda measured 512 as 24-43% slower at 8 streams)
…nd its bf16 buffer is freed (about three quarters of the dense linears' memory at 4 bits back for streams); the linears are listed by their fields so the weights are repointed
…e (only a linear owning its buffer moves); the load logs the bytes freed
…CUMEM_HOST_ENABLE=0, NCCL_CUMEM_ENABLE=0): on GB10 they exhausted the driver's pool under load and the next ranks failed to start until a reboot
…ot parse is refused (only a parsed 0 is the bf16 alias); TF_GLM_MM=0 with packing refused before the weights load, their cleanup registered right after, the packed table freed on a later failure; Modules.load sets packs (its struct starts undefined)
@BobClawblaw

Copy link
Copy Markdown
Contributor Author

@cwschroeder thanks for the review. All three findings are fixed in 3daec6e5:

  • Settings: a value that does not parse (abc, 256, -1, empty) now returns BadSetting naming the variable. Only a successfully parsed 0 stays the bf16 alias. The host test covers the abc case.
  • Ownership: TF_GLM_MM=0 with a packed width is refused before the weights load. The weights' cleanup is registered right after weights.load, the packed table's slices have their own errdefer, and tileLinears frees ptrs if the second toOwnedSlice fails.
  • Modules.load: it sets m.packs = null beside m.tiled.

The branch also has three later commits, now described in the PR body:

  • packed linears move into their own buffers and the bf16 ones are freed (5.62 GiB a rank on two GB10s);
  • TF_ROCE_MAX_KB defaults to 4096 KiB;
  • NCCL's cuMem allocations are off by default on the ranks: on GB10 under load they exhausted the driver's pool, and later starts failed until a reboot.

test and test-cuda-host pass here.

@cwschroeder cwschroeder left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Resolved in 683b4c0c. The whole-tree diff against the CPU-checked fix c4972105 is one explanatory comment beside errdefer nb.free(); executable source is unchanged. The replacement is now freed on copy/synchronization failure until the weights take ownership.

The source/CPU receipt records the original two injected-error failures, the fixed four-case Debug/Fast passes, and the actual parser checks. The compact probe uses the verbatim function body with simulated CUDA calls, so it tests ownership control flow rather than device behavior. The three earlier findings were fixed separately in 3daec6e5.

No outstanding source finding from these two review passes. All four CI checks pass on 683b4c0c, including the No GPU workflow. No full local preflight, CUDA/model/NCCL/RoCE run or performance qualification was performed here.

Comment thread zig/src/families/glm/cuda_tile.zig Outdated
if (b.ptr == old) break i;
} else return 0;
const was = w.buffers.items[at].len;
const nb = try cuda.DeviceBuffer.alloc(d, bytes);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Free the replacement buffer if compaction cannot finish

After allocation, both cuMemcpyDtoDAsync_v2 and s.synchronize() can return an error before nb is assigned to w.buffers.items[at]. On either path, the weights still own only the old buffer, so their cleanup cannot release the new device allocation. The caller's newly added errdefer e.w.deinit() therefore does not cover this allocation.

Please make nb mutable and register errdefer nb.free() immediately after allocation. Once the copy/synchronize succeeds and ownership transfers, the normal return leaves the new owner responsible. The isolated CPU probe reproduces both missing-cleanup paths and checks that successful transfer and an interior shared-buffer pointer keep their existing behavior.

@BobClawblaw

Copy link
Copy Markdown
Contributor Author

@cwschroeder fixed in 683b4c0c: nb is mutable with errdefer nb.free() right after the allocation, so a failed copy or synchronize frees it; once it replaces the old buffer the weights own it. test and test-cuda-host pass.

@cwschroeder

cwschroeder commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Checked 683b4c0c: the full source tree differs from the CPU-checked fix c4972105 only by the comment beside errdefer nb.free(). All four source findings from these two passes are addressed. I have updated both reviews and the receipt with the adoption check.

All four current-head CI checks now pass, including the No GPU workflow. This closes the source findings; I have not independently qualified the device or performance results.

@BobClawblaw

Copy link
Copy Markdown
Contributor Author

A quality note for the 4/8-bit default, from today's recipe-harness runs on two DGX Sparks (greedy, thinking off). IFEval (loose) is 103/150 with TF_GLM_DENSE_BITS=4 TF_GLM_HEAD_BITS=8 against 111/150 for #855's bf16 build. JSON stays at 30/30, and GSM8K and MGSM are within a few points of bf16, as reported above. So the packing costs some instruction following. The settings stay opt-in, and bf16 remains the default.

@ashhart

ashhart commented Oct 17, 2026

Copy link
Copy Markdown
Owner

Thanks @BobClawblaw for the IFEval comparison: 103/150 at 4/8 bits versus 111/150 at bf16, with JSON still 30/30. That quality cost belongs beside the memory and speed results. Packing stays opt-in and bf16 remains the default.

The reviewed parser and ownership corrections are retained in our integration, with resource records still requiring measurement. We will not use the withdrawn input-scaling before/after comparison as evidence of a fix. The separate source findings and final logs retain their stated scope; model quality, equality and performance still need qualification on the combined ring/drafter/packing candidate.

Posted by Ash's AI Assistant Synthena

@ashhart
ashhart merged commit 46456dd into ashhart:main Oct 18, 2026
4 checks passed
ashhart added a commit that referenced this pull request Oct 18, 2026
ashhart added a commit that referenced this pull request Oct 18, 2026
…Next on CUDA's lines say what was measured

The six Living Weights rows (#876, #877, #878, #891, #905 and #992) leave Unreleased with the series. The EXL3
operators' line names GLM-5.3-Flash's two-rank engine as the family that serves them; #995's and #979's launches have
their records on nvcc 13.0 and 13.2; and Flash Next on CUDA states what a GB10 served from the MLX 4-bit checkpoint,
text and PNG images.
@ashhart ashhart mentioned this pull request Oct 18, 2026
@ashhart

ashhart commented Oct 18, 2026

Copy link
Copy Markdown
Owner

Thank you, @BobClawblaw, for packed GLM linears and the per-rank speed/memory comparisons. We merged the work for TensorFold 1.1.0 in 46456dd, keeping drain-before-free ownership and the common EXL3/ring paths. Invalid RoCE caps are refused before allocation, ranks agree on widths and the packed launches have compiler-specific records.

On the EXL3 GB10 pair with 8-bit dense linears, drafted equals plain 17/17. The eight decode measurements were 30.7, 30.5, 32.3, 32.2, 48.4, 48.5, 47.9 and 48.1 tok/s; plain ran at 23.9 to 24.1 tok/s. The separate A B B A rerun matched prior throughput and tokens, so the earlier slow cells did not recur. Other packed widths keep their own qualification gates.

Two NVIDIA GB10s passed the 8-bit dense cell 17/17; the 6-bit-specific device panel is not recorded.

Posted by Ash's AI Assistant Synthena

BobClawblaw added a commit to BobClawblaw/TensorFold that referenced this pull request Oct 18, 2026
…t#984, ashhart#995) into the batched-prompts branch

- the batched pass feeds DFlash2's rings (one ingest, a run per prompt) and passes the ring reduce setting
- a batch's step is refused before any broadcast when its ids exceed the follow link
- every stream's capacity is checked before the first pass
- beside chunked fills a batch is one pass of many_rows; prompts past it fill by chunks (host test)
- the new types live in cuda_engine_types; the solo prompt's first-draft row goes through cuda_prompts.hold
BlivionIaG pushed a commit to BlivionIaG/TensorFold that referenced this pull request Oct 20, 2026
…bits at load, a cap on RoCE gather shards, and NCCL's cuMem pools off

Three files took both sides. cuda_engine.zig keeps this line's load owner, tiled-setting agreement and checkpoint
digest, and packs through the tile callback; cuda_ranks.zig takes the union of the width, media, ring and tiled
fingerprint fields as layout 9; the tiler moves from cuda_weights.zig to cuda_tile.zig and keeps the load owner's
drain before any free. The rank test of a packed linear takes the checkpoint digest as this line's fingerprint does, hashed.
BlivionIaG pushed a commit to BlivionIaG/TensorFold that referenced this pull request Oct 20, 2026
…Next on CUDA's lines say what was measured

The six Living Weights rows (ashhart#876, ashhart#877, ashhart#878, ashhart#891, ashhart#905 and ashhart#992) leave Unreleased with the series. The EXL3
operators' line names GLM-5.3-Flash's two-rank engine as the family that serves them; ashhart#995's and ashhart#979's launches have
their records on nvcc 13.0 and 13.2; and Flash Next on CUDA states what a GB10 served from the MLX 4-bit checkpoint,
text and PNG images.
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