Skip to content

moss_image: the transformer gated block by block against float32, on Metal and CUDA - #2294

Merged
ashhart merged 3 commits into
ashhart:mainfrom
BobClawblaw:moss-block-gate
Nov 7, 2026
Merged

ashhart merged 3 commits into
ashhart:mainfrom
BobClawblaw:moss-block-gate

Conversation

@BobClawblaw

@BobClawblaw BobClawblaw commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

Part of #1529 (its third item: the teacher check gated against float32, as #1512 does for the encoder).

What changes

  • tf-moss-image-blocks (Metal and CUDA) runs one step's branch through Model.steps. That is the embedding, each kept block alone on a float32 run's stream rounded to bf16, then the final layer. Each must be within twice torch bf16's distance from float32, in cosine error and in its worst row, and a second run must repeat the first byte for byte. Otherwise it fails.
  • moss_image_case.py blocks records that step's float32 stream and torch bf16's steps alone on it. It runs them twice: on the case's device and on the CPU. The limit is the farther of the two (reference.wider; tested in reference.zig).
  • tf-moss-image keeps Tests: the MOSS-Image teacher gate compares each step with the teacher, not only with a repeat (#1529) #2291's teacher gate on each whole forward as it landed, and still reports cosine and worst row beside torch bf16's. The docs describe both gates. On both doc cases the teacher gate fails every step, on Metal and on CUDA, and torch's own bf16 run on the CPU fails it too (see Measured). The bound's form is being settled separately.
  • Model.forward on both backends moves its modulation and embedding into helpers that Model.steps shares. Both doc cases give byte-identical latents before and after, on Metal and on an RTX 5090.

No shared code changes. No kernels change.

Why two torch runs set the limit

I first gated each whole forward, as one 42-block chain. Metal failed one branch-step of 100 on the sticky note: a worst row 2.55 times torch MPS's, at row 313. Block by block, every block matched torch. torch's own bf16 run on the CPU of that step has a worst row 1.37 times its MPS run's. So the worst row of a 42-block bf16 chain is not a stable gate, and this PR gates block by block.

Per block, one MPS run as the limit failed CUDA once in 305 gates: block 0 of the fox's step 1, negative branch. There the negative prompt's seventh row becomes a massive activation, with one channel near 16,500. I dumped CUDA's block 0 and replayed each operation from CUDA's own bf16 inputs with the publisher's torch code. situ and both residual adds match torch bit for bit. The norms, q/k norm and rotary differ on about 0.001% of elements. The projections differ by one ulp on about 0.2%, which is accumulation order. Attention differs from torch no more than torch CPU differs from torch CUDA. The whole block in torch bf16 on the CPU lands where CUDA does:

block 0, fox step 1, negative 1 - cosine worst row
torch bf16, MPS 2.28e-6 0.00687
torch bf16, CPU 6.58e-6 0.00960
ours, Metal 2.28e-6 0.00687
ours, CUDA 5.70e-6 0.00960

At every other gate, torch's two runs are within 0.84 to 1.20 times each other in worst row and 0.95 to 1.05 in cosine error.

Measured (this commit)

Seven steps' branches, made on the Mac with torch 2.14.1 and diffusers 0.42.0 (MPS and CPU): the fox's steps 1 and 25 and the sticky note's step 23, both branches each, and the sticky note's step 50, prompt branch.

Metal (M5 Ultra) CUDA (RTX 5090, sm_120)
block gates 305/305 pass, mean 0.50 of the limit, at most 0.50 305/305 pass, mean 0.50, at most 0.60
second run byte-identical (7/7) byte-identical (7/7)
a row planted 5% off in block 20 (stream and both torch runs) fails at blocks 20 and 21 not run
whole forwards against float32, ours / torch bf16 cosine 1.00x and 1.01x, worst row 1.01x and 1.03x (fox, sticky note) cosine 1.00x and 1.01x, worst row 1.01x and 1.02x
#2291's teacher gate, worst element / bound, min / median / max fail 100/100 each: fox 146 / 509 / 7206, sticky note 168 / 435 / 6018 fail 100/100 each: fox 161 / 432 / 2946, sticky note 115 / 459 / 7921

The CUDA whole forwards use the CUDA doc's cases, recorded on the GB10. A block case runs the whole transformer in float32, which does not fit an RTX 5090, so the CUDA block cases are the Mac's. The block gate was not run on a GB10. On Metal, #2291's head without this PR gives the same latents and the same worst element at every step.

The teacher gate at the sticky note's step 23, negative branch (in the Metal doc):

step 23, negative branch, against float32 worst error / bound elements over the bound median error / bound
torch bf16, CPU 353 16.6% 0.50
ours, Metal 284 22.3% 0.50

Checks

Each exits 0 on this head (6eb8ad969, on main c70d67bb2; 39ea89817 rebased, CHANGELOG.md the only conflict, the code commit unchanged by git range-diff), on an M5 Ultra:

  • zig fmt --check build.zig zig
  • zig build test --test-timeout 10m
  • test-golden (322/335 and 55/55 equal to server goldens, 0 unexpected)
  • test-kernel-contract
  • test-server-security test-pull-security
  • check-release-paths
  • fuzz-cortheon
  • dev-tools -Doptimize=Debug
  • the Metal block gates above, every case's per-step lines identical to the pre-rebase head's (the planted row still fails at blocks 20 and 21), and Tests: the MOSS-Image teacher gate compares each step with the teacher, not only with a repeat (#1529) #2291's teacher gate on both doc cases, with latents byte-identical to the pre-rebase head's

On the RTX 5090, zig build install -Dsm=120, test-cuda-host and the block gates exited 0 on 39ea89817. Main's CUDA changes since then are GLM's L2 prefetch and its contract records, which this PR's CUDA code doesn't use; they were not rerun on this head.

Lean: base 155, head 155, 0 new.

Overlap

#2281 (MOSS-Image serving on CUDA) touches two of the same files, and both conflicts are small:

Whichever lands second rebases.

@ashhart

ashhart commented Nov 6, 2026

Copy link
Copy Markdown
Owner

Thanks @BobClawblaw. The block gate reads well: the float32 stream comes from the pinned publisher pipeline, each
piece is checked alone, and setting the limit from torch's own device and CPU runs is backed by the block-0 replay
(our CUDA lands where torch's CPU does, and Metal matches MPS exactly). The forward refactor keeps the same order on
both backends. A few things:

  1. Our Tests: the MOSS-Image teacher gate compares each step with the teacher, not only with a repeat (#1529) #2291 is queued and gates each whole teacher step per element against float32, with exit 2 when float32
    coverage is missing. After it lands, this PR's moss_image_run.zig usage hunk won't apply, and its "reported, not
    gated" wording in the usage and the recipe would be wrong. Please rebase onto it then and describe both gates.
  2. Your sticky-note step (worst row 2.55× torch MPS on Metal) matters to Tests: the MOSS-Image teacher gate compares each step with the teacher, not only with a repeat (#1529) #2291's gate too. If you still have that
    case, the step's result under Tests: the MOSS-Image teacher gate compares each step with the teacher, not only with a repeat (#1529) #2291's per-element gate on Metal, main against the branch, would help us.
  3. A CHANGELOG line under Unreleased, for example: "Tests: tf-moss-image-blocks gates MOSS-Image's transformer
    block by block against the publisher's float32 run, within twice the spread of torch bf16's device and CPU runs,
    on Metal and CUDA (moss_image: the transformer gated block by block against float32, on Metal and CUDA #2294, part of moss_image: the flow-matching schedule computed natively, and the teacher check gated per step #1529)."
  4. "Closes moss_image: the flow-matching schedule computed natively, and the teacher check gated per step #1529" will close the whole issue on merge; if its other items are open, "Part of moss_image: the flow-matching schedule computed natively, and the teacher check gated per step #1529" is safer.
    zig/build/cuda.zig is at exactly 600 lines with this; moss image: serve pictures on CUDA (encoder, decoder and host for #1573) #2281's move of the MOSS tools brings it back down, so
    whichever lands second can settle that too.

@BobClawblaw

Copy link
Copy Markdown
Contributor Author

Thanks @ashhart. Items 3 and 4 are done locally: the CHANGELOG line as you worded it, and the body will say "Part of #1529". I'll push them with the rebase onto #2291 (item 1) once it lands.

On item 2: I ran #2291's head (0dc4613) on Metal on both doc cases. Each case has float32 values for all 50 steps on both branches, so coverage is complete. I ran it twice: once on main + #2291, and once on this branch rebased onto #2291. The two builds give the same results: byte-identical final latents, and the same worst element at every step. So this PR's forward refactor doesn't change anything #2291 measures.

Both builds fail every one of the 100 branch-steps in each case:

case steps passing error / bound (min / median / max) repeat
fox 1024, seed 42 0 / 100 146 / 511 / 7206 identical
sticky note 1200x880, seed 7 0 / 100 168 / 444 / 6018 identical

At the sticky-note step you asked about (step 23, negative branch), our worst element is 284 times its bound: |diff| 0.0173 against a bound of 6.1e-5.

I think this comes from the bound, not from our forward. The same check on torch's own bf16 run of that step on the CPU, scored against the MPS teacher's per-element bound, fails too:

step 23, negative branch, vs float32 worst error / bound elements over bound median ratio
torch bf16, CPU 353 16.6% 0.50
ours, Metal 284 22.3% 0.50

The bound at each element is twice the MPS bf16 run's own error at that element, so it inherits wherever MPS happened to round close to float32. Across a 42-block chain, any other bf16 run lands elsewhere at a sizeable share of the elements, torch's CPU run included. As written, #2291 fails every real MOSS-Image case, on any device. The planted case in its host tests passes because its synthetic bf16 values sit inside the envelope by construction.

Ways to gate whole steps against float32 that torch's own bf16 runs would pass:

  • the per-step statistics MOSS-Image: the prompt encoder on Metal #1512 uses for the encoder (cosine error and worst row), limited by torch's spread across devices, as this PR does per block;
  • or a per-element bound taken over the whole tensor rather than each element alone, e.g. a high quantile of |ours - fp32| against the same quantile of torch bf16's (MPS and CPU), times two.

I'd rather you choose the form. Once #2291 settles, I'll rebase onto it and describe both gates as they stand, with this table in the doc.

@ashhart

ashhart commented Nov 6, 2026

Copy link
Copy Markdown
Owner

Thanks @BobClawblaw, and for the table. #2291 landed this morning, so please push the rebase onto main now, with the CHANGELOG line and "Part of #1529". Describe #2291's gate as it stands, with your table in the doc. Your table makes a fair point: at step 23 the branch is 284× the per-element bound on Metal, and torch's own CPU bf16 run is 353×. We'll settle the bound's form separately, and this PR doesn't need to wait for it.

BobClawblaw added a commit to BobClawblaw/TensorFold that referenced this pull request Nov 6, 2026
@BobClawblaw

Copy link
Copy Markdown
Contributor Author

Thanks @ashhart. The rebase onto main is pushed as 39ea89817. It has the CHANGELOG line, and the body says "Part of #1529".

The docs now describe #2291's gate as it landed. moss-image.md has the per-element bound, exit 2 when float32 coverage is incomplete, and the table from my last comment under Measured. moss-image-cuda.md has the same gate on the RTX 5090. There it also fails all 100 branch-steps of both CUDA cases (worst element / bound min / median / max: fox 161 / 432 / 2946, sticky note 115 / 459 / 7921), and two runs give identical latents. The text no longer says whole forwards are ungated.

One correction to my last comment: the medians are 509 (fox) and 435 (sticky note), not 511 and 444. I had taken the upper middle value of the 100 instead of the median. The min and max are unchanged.

On this head the block gates still pass 305/305 on Metal and on the 5090. The required checks and the lean check pass; the body lists where each one ran.

@ashhart

ashhart commented Nov 6, 2026

Copy link
Copy Markdown
Owner

/atc exchange 892c67d43fd79e570efeb4c30f1a1dd99fd66fde18ea5060338a6b4ee332f1ef

@BobClawblaw

Copy link
Copy Markdown
Contributor Author

/atc exchange e335b80a12fb288f57914b77362b5bf04e7f85f2e919d07d945e9e4f81bd4aff

BobClawblaw added a commit to BobClawblaw/TensorFold that referenced this pull request Nov 6, 2026
@BobClawblaw

Copy link
Copy Markdown
Contributor Author

Rebased onto main c70d67bb2 as 6eb8ad969. CHANGELOG.md was the only conflict, and git range-diff shows the code commit unchanged. On the M5 Ultra the CI set passes (fmt, lean 0 new, test, golden, kernel contract, security, release paths, fuzz, dev-tools). The Metal block gates give the same per-step lines as before the rebase, and #2291's teacher gate gives byte-identical latents on both doc cases. The body's checks section is updated.

@ashhart

ashhart commented Nov 6, 2026

Copy link
Copy Markdown
Owner

Thanks @BobClawblaw, the rebase is what we asked for. Both gates are described as they stand, the step-23 table is in the doc, and the body says "Part of #1529". The block gate is a clear improvement: 305 of 305 at half the limit on both backends, and the planted row caught where it should be. It lands once CI has run, and we'll add the thanks to the CHANGELOG line. The teacher bound's form is still to be settled on #1529, separately from this PR.

@ashhart

ashhart commented Nov 7, 2026

Copy link
Copy Markdown
Owner

Thanks @BobClawblaw. Main reached 600 lines in zig/build/cuda.zig tonight, and tf-moss-image-blocks would have taken it to 602. So I rebased the branch onto main (cad4ab2270) and moved both MOSS-Image tools, unchanged, to zig/build/moss_image_tools.zig, which cuda.zig calls once. That commit is ours. The landing gate's host checks pass, including the Linux CUDA cross-compile, and it's back in our landing queue.

BobClawblaw and others added 3 commits November 7, 2026 05:11
…Metal and CUDA

tf-moss-image-blocks runs one step's branch through Model.steps (on both backends): the embedding, each
kept block alone on a float32 run's stream rounded to bf16, then the final layer. Each must be within twice
torch bf16's distance from float32 in cosine error and worst row, and a second run must repeat the first
byte for byte. moss_image_case.py blocks records the float32 stream and torch bf16's steps alone, on the
case's device and on the CPU; the limit is the farther of the two (reference.wider), since torch's own bf16
runs disagree at a massive-activation row (block 0 of the fox's step 1, negative branch: worst row 0.00687
on MPS, 0.00960 on the CPU).

tf-moss-image's whole forwards are reported beside torch bf16's and not gated, as the encoder's whole prompt
is not; a repeat that differs still fails it.

Seven steps' branches: 305 of 305 gates pass on Metal (at most 0.50 of the limit) and on an RTX 5090 (at
most 0.60), each run repeated byte for byte; a row planted 5% off in block 20 fails at blocks 20 and 21.
Model.forward's modulation and embedding moved into helpers that Model.steps shares; both cases' latents are
byte-identical before and after on both backends.
…o zig/build/cuda.zig stays under 600 lines

With main at 600 lines, this branch's tf-moss-image-blocks took zig/build/cuda.zig to 602. Both
MOSS-Image tools move, unchanged, to one file that cuda.zig calls once.
@ashhart
ashhart merged commit 9fb5d20 into ashhart:main Nov 7, 2026
0 of 7 checks passed
ashhart pushed a commit that referenced this pull request Nov 7, 2026
@ashhart

ashhart commented Nov 7, 2026

Copy link
Copy Markdown
Owner

Thank you @BobClawblaw. This is merged into main with your commits under your own name, so you're listed as a TensorFold contributor. It ships in the next release, credited to you in the changelog and the release notes.

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.

2 participants