Skip to content

test(prm): run #1235's CPU bf16/fp16 steps on ATen's matmuls, fixing windows-latest 0xc000001d - #1314

Merged
MakazhanAlpamys merged 3 commits into
mainfrom
fix/1235-windows-ci-illegal-instruction
Sep 27, 2026
Merged

MakazhanAlpamys merged 3 commits into
mainfrom
fix/1235-windows-ci-illegal-instruction

Conversation

@MakazhanAlpamys

Copy link
Copy Markdown
Owner

Summary

main has been red since #1250 merged: test (windows-latest, 3.10) and test (windows-latest, 3.12)
in run 36251313755 (head 0334b975) die with Windows fatal exception: code 0xc000001d
(STATUS_ILLEGAL_INSTRUCTION) in the first test of tests/test_issue1235_prm_fp32_master_weights.py
that runs a real CPU bf16-autocast step. The crash kills the interpreter, so each cell reports nothing
about the 22,433 of its 25,318 selected tests it had not reached.

This keeps the tests running on every cell instead of skipping them. A new conftest fixture,
aten_half_matmuls, moves the step's half-precision matrix multiplies off the two libraries that pick
their instruction set at run time:

  • torch.backends.mkldnn.enabled = False, so bf16/fp16 matmuls run in ATen's own precompiled gemm
    instead of oneDNN's JIT kernels;
  • SDPA on its math backend, which upcasts q/k/v to fp32, so attention no longer calls MKL's
    cblas_gemm_bf16bf16f32 / cblas_gemm_f16f16f32.

The dtypes #1235 pins (fp32 master weights, the reward head taking the forward's dtype, the steps moving
the norms) do not depend on which library multiplies the matrices, and autocast stays on.

This cannot be proven before merge. Which runner a Windows job lands on is a lottery: #1250's own
run passed on all three Windows cells, so a green run on this PR proves nothing either way. What
supports the fix is the elimination below. If 0xc000001d comes back in these tests with the fixture
on, the fallback is the #382 skip (skip_on_windows_ci) on these tests, with
tests/test_issue382_windows_ci_guard_is_shared.py updated to match.

Test-only change: no changelog fragment, and no src/ change.

Root cause: evidence

The crash. Both cells die in
TestBf16MixedPrecisionTrains::test_one_step_runs_and_moves_the_norms_and_projections[docs-lr], right
after the eight TestTheLoadDtype tests (setup only, no forward) pass. The faulthandler frame is the
first bf16 matmul of the step:

torch/nn/modules/linear.py:134 in forward
transformers/models/llama/modeling_llama.py:254 in forward   # self.q_proj(hidden_states), layer 0
...
torch/amp/autocast_mode.py:44 in decorate_autocast
accelerate/utils/operations.py:943 in forward               # ConvertOutputsToFp32
soup_cli/trainer/prm.py:67 in compute_loss
transformers/trainer.py:2014 in training_step
soup_cli/trainer/prm.py:371 in train
tests/test_issue1235_prm_fp32_master_weights.py:276

No bitsandbytes and no Soup native code are on the stack (Soup has none). Stack: torch 2.14.0 (the CPU
wheel from PyPI), transformers 5.17.0, accelerate 1.15.0, image windows-2025-vs2026 20260922.246.2.

Which hosts. The fault depends on the host, not the code or the interpreter:

run tree windows 3.10 windows 3.11 windows 3.12
36051967682 #1250 at dc042a4e ran the steps, no fault* ran the steps, no fault* pass
36103050921 #1250 at ca14f096 pass pass pass
36251313755 main 0334b975 0xc000001d pass 0xc000001d

* Both failed an == 0.0 assertion that #1250 then relaxed to <= 0.01: 1 of 320 norm elements
unmoved. Both logged the same loss and grad-norm sequence, and it matches neither ATen path on the dev
box (AVX2: 0.5018 / 47.63 at step 2; forced DEFAULT: 0.9504 / 69.96 at step 4, against their
0.5025 / 47.65 and 0.9517 / 70.08). So those hosts ran run-time-dispatched half-precision kernels,
without a fault.

The ubuntu and macos cells have never crashed on these tests, or on the other CPU bf16 steps below.

Which code. On x86, torch 2.14 routes a CPU bf16 matmul as follows (source at v2.14.0):

  • cpublas::gemm (bf16 in, bf16 out) calls mkldnn_bf16_gemm first
    (aten/src/ATen/native/CPUBlas.cpp). mkldnn_gemm takes it when
    userEnabledMkldnn() && mkldnn_bf16_device_check() and m*n*k > 16**3
    (aten/src/ATen/native/mkldnn/Matmul.cpp). The device check is ideep::has_bf16_type_support(),
    which is true on AVX-512 and AVX2-VNNI-2 hosts. Everything else falls to ATen's gemm_stub
    (native/cpu/BlasKernel.cpp: templated C++, no BF16 intrinsics, compiled per ATen capability).
  • The fused CPU SDPA kernel multiplies bf16/fp16 blocks through the mixed-output cpublas::gemm, which
    goes to MKL's cblas_gemm_bf16bf16f32 / cblas_gemm_f16f16f32 when MKL has them. The oneDNN flag
    does not gate that route.

oneDNN and MKL both pick the instructions they execute at run time from their own CPU probe. The
precompiled ATen kernels are selected once, through cpuinfo, from the OS-enabled feature set. So the
question is which of these the crashing hosts could not run:

  • fp32 training runs there. On the two 2026-09-17 cells that died the same way (runs 35228388681 and
    35254266989), test_issue341_seed_and_fullft.py::TestFullFinetuneTrainer (a real trainer.train(),
    and Soup's CPU flags are bf16=False, fp16=False) passed at 12%. On today's cells the real fp32
    GRPO steps of test_issue1232_grpo_variant_kl.py::TestTheRealTrainer passed right before the crash.
    So MKL's SGEMM runs there.
  • bf16 element-wise ATen kernels run there. On the 2026-09-17 cells,
    test_issue722_chunked_distill.py's bfloat16 cases (log-softmax/KL, no matmul) passed at 20%.
  • The first CPU bf16 matmul dies. On 2026-09-17 that was
    test_rewind_hf.py::test_recorded_rows_match_sampler_order at 41%, the first trainer.train() under
    trl's SFTConfig. trl 0.29.1 defaults bf16=True even with use_cpu=True; this was checked, and
    GRPOConfig does the same, which covers the 2026-08-31 GRPO crash in test_v05311.py as well.
    Today it is PRM on CUDA trains bf16 weights with no fp32 master copy: the first bf16 step fails with a dtype mismatch, and with that patched the base updates round to zero #1235's first bf16 step.

What is left is the run-time-dispatched half-precision matmul code. Which instruction faults cannot be
read off these logs: the job logs do not name the CPU model, and a 0xc000001d dump carries no
registers. oneDNN 3.12 (bundled with torch 2.14) does gate AMX on Windows by XCR0[18:17]. A oneDNN
AMX matmul has produced exactly this signature before (pytorch/pytorch#99509). An unrelated project hit
the same thing on 2026-09-25: nn.Linear under CPU bf16 autocast, 0xC000001D, on some windows-latest
hosts, gone on a rerun (maged15/beamgrad#12). The fix below does not depend on which instruction it is.

Why this cannot be reproduced on the dev box, and what the fix removes. The dev box is an
i9-14900HX (AVX2 + AVX-VNNI, no AVX-512). There torch.ops.mkldnn._is_mkldnn_bf16_supported() is
False, so bf16 matmuls never reach oneDNN. ONEDNN_VERBOSE=1 logs 0 primitives across the six
bf16/fp16 tests. The positive control works: an fp32 conv2d logs primitives, and they stop inside
mkldnn.flags(enabled=False). MKL_VERBOSE=1 across the same six tests:

GEMM_BF16BF16F32 GEMM_F16F16F32 SGEMM SGEMM_BATCH oneDNN primitives
main 768 384 333 0 0 (no AVX-512 here)
this PR 0 0 109 264 0

With the fixture, the step's half-precision matmuls run in ATen's gemm_stub, and attention runs as
fp32 SGEMM, the MKL path the crashing hosts already run without fault.

Why this option

  • Not the CI: windows-latest cells die with 0xc000001d in the NF4 training-step test — three occurrences, now on main #382 skip. skip_on_windows_ci would stop the crash for certain, but it would drop these
    five cases on every Windows host, including the ones that run them fine (windows 3.11 on the same
    main run, and all three on fix(prm): train PRM on fp32 master weights on every device (#1235) #1250's run).
  • Not an ISA cap in the workflow. ONEDNN_MAX_CPU_ISA and MKL_ENABLE_INSTRUCTIONS are
    process-wide and read once when the library initialises, so they cannot be scoped to one test. They
    would change every test on the Windows cells, and choosing the cap needs the faulting ISA, which the
    logs do not show.
  • The fixture is scoped to the tests that request it, on every OS, so the kernel choice for these
    tests is the same on all nine cells. It uses monkeypatch plus the sdpa_kernel context manager, so
    both switches are restored after each test.

Changes

  • tests/conftest.py: the aten_half_matmuls fixture, with the evidence above in its docstring.
  • tests/test_issue1235_prm_fp32_master_weights.py:
    • precision requests the fixture, which covers the four bf16 cases and the fp16-flag case.
    • The fp16 autocast GradScaler test requests it too. That test was never reached on a crashing host,
      and fp16 goes through the same two routes.
    • Each of these tests asserts that both switches were in effect during the step.
    • New TestTheAtenMatmulSwitchStaysScoped, placed last in the file, asserts that both switches are
      back on outside the fixture.
  • tests/_windows_ci.py: the module docstring points at the fixture as the first thing to try when
    the crash frame is a CPU bf16/fp16 matmul.

Verification (dev box, PYTHONPATH=<worktree>/src, CUDA_VISIBLE_DEVICES=-1)

  • tests/test_issue1235_prm_fp32_master_weights.py: 25 passed (was 23; +2 scoping tests), and
    25 passed again with CI=true, so nothing is skipped on a Windows runner.
  • tests/test_issue382_windows_ci_guard_is_shared.py,
    tests/test_v07202.py::TestTheWindowsCiSkipStaysNarrow, tests/test_issue833_gpu_marker.py:
    60 passed.
  • ruff check src/soup_cli/ tests/ scripts/ benchmarks/: clean.
  • Mutation checks. Each was applied to the committed tree and then reverted, and each makes the named
    tests fail with the intended message:
    • precision without the fixture: 5 failed.
    • The fp16 test without the fixture: 1 failed.
    • The fixture with oneDNN off but fused SDPA left on: the scoping test and every step test failed.
    • A leaking fixture that sets both switches and never restores them: the scoping test failed.

What this cannot prove before merge. The fault is a host lottery. #1250's own run passed on all
three Windows cells, so a green run on this PR is not evidence either way. The evidence is the
elimination above. If this signature comes back in these tests with the fixture on, the faulting code
is not oneDNN's or MKL's half-precision GEMM. The fallback is then skip_on_windows_ci, with the
guard-sharing ratchet updated.

Open PRs that reach the same kernels

Follow-ups (not in this PR)

🤖 Generated with Claude Code

MakazhanAlpamys and others added 3 commits September 26, 2026 21:14
main (0334b97, CI run 36251313755) died on windows-latest 3.10 and 3.12 with
`Windows fatal exception: code 0xc000001d` in
test_one_step_runs_and_moves_the_norms_and_projections[docs-lr], the first
test to run a real step under CPU bf16 autocast. The faulting frame is
torch/nn/modules/linear.py:134 (q_proj of layer 0) under
torch/amp/autocast_mode.py: the first bf16 matmul of the process.

On x86, torch 2.14 routes a bf16 matmul with m*n*k > 16**3 to oneDNN whenever
oneDNN reports bf16 for the CPU (AVX-512 or AVX2-VNNI-2), and to ATen's
precompiled gemm otherwise. The same hosts ran fp32 training (MKL) and bf16
element-wise ops (ATen) without a fault, so the one run-time-dispatched code
on the crash path is oneDNN's JIT kernel. torch.backends.mkldnn.enabled gates
both oneDNN routes (mkldnn_gemm and the SDPA brgemm could_pack check).

The new onednn_off fixture (tests/conftest.py) switches that flag off for
the step through monkeypatch. The tests still run on every cell, under the
same autocast; only the matmul library changes, which is the path a box
without AVX-512 always took (ONEDNN_VERBOSE=1 logs no primitive for these
tests on the dev box). The fp16-autocast test gets the same treatment: it
was never reached on the crashing hosts, and fp16 is routed the same way on
AVX512-FP16 hosts. The tests assert the flag was off, and a last test
asserts it is back on outside the fixture.

Test-only change: no changelog fragment.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
oneDNN is not the only library in a CPU bf16 step that picks its instruction
set at run time. The fused CPU SDPA kernel multiplies its bf16/fp16 blocks
with MKL's cblas_gemm_bf16bf16f32 / cblas_gemm_f16f16f32, which the oneDNN
flag does not gate: MKL_VERBOSE=1 over the six bf16/fp16 tests on main logs
768 GEMM_BF16BF16F32 and 384 GEMM_F16F16F32 calls. On the crashing hosts the
oneDNN matmul in q_proj simply ran first, so switching oneDNN off alone could
have moved the crash into attention.

The fixture, renamed aten_half_matmuls, now also puts SDPA on its math
backend, which upcasts q/k/v to fp32 on CPU (_scaled_dot_product_attention_math
in aten/src/ATen/native/transformers/attention.cpp). With it the same six tests
log 0 half-precision MKL GEMMs and 0 oneDNN primitives: only SGEMM and
SGEMM_BATCH, the fp32 MKL path the crashing hosts already run without fault,
plus ATen's own precompiled bf16 gemm. The tests assert both switches were
in effect, and the scoping test checks both are back on afterwards.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The fixture moves half-precision matmuls to ATen, but attention goes to fp32
SGEMM in MKL, not to ATen. The docstrings now say both.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@MakazhanAlpamys
MakazhanAlpamys merged commit 6947824 into main Sep 27, 2026
18 checks passed
MakazhanAlpamys added a commit that referenced this pull request Sep 27, 2026
…-precision steps on ATen's matmuls

Review follow-ups for #1312, after rebasing onto main with #1314.

- setup() creates the fp32 gate on the device of the base's parameters instead of
  leaving it on the CPU for the Trainer to move. A base loaded to the CPU is still moved
  by the Trainer, gate and all. A pre-quantized bitsandbytes checkpoint is placed on its
  GPU at load, and neither Trainer._move_model_to_device nor Accelerate's prepare_model
  moves a 4/8-bit model, so a CPU gate would have met a CUDA base in compute_loss (main
  put the gate on "cuda" itself). New test: the base loaded on the meta device, standing
  in for that GPU, gets a meta fp32 gate.
- The seven test functions that run a real CPU bf16/fp16 step request aten_half_matmuls
  (#1314) and assert it was in effect after the step: cpu_run requests it, and the fp16
  GradScaler test, which does not use cpu_run, requests it directly. On the "cuda" branch
  the frozen base is bf16, so these steps multiply bf16 matrices above oneDNN's m*n*k >
  4096 cutoff (85-680 per test) and run the fused CPU SDPA (6-48) even without autocast,
  the kernels that kill part of the windows-latest fleet with 0xc000001d. The setup-only,
  fp32, routing-cast and old-bf16-file tests run neither and are left alone.
- docs/training.md: the gate's file size as 4 x hidden x N bytes (about 7 KB for the
  example, 2 MiB at 8192 x 64) instead of "a few kilobytes", and the stale "there is no
  serve-time MoLE path yet" sentence replaced with a pointer to soup serve --mole.
- The test docstrings no longer describe #868 as open or train() as passing no
  precision flags; cpu_run's flags override whatever train() asks for.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
MakazhanAlpamys added a commit that referenced this pull request Sep 28, 2026
…vice (#1266) (#1312)

`task: moe_lora_routing` trains one tensor, the router gate `Linear(hidden, N)`. On the device string `"cuda"`, which is what `soup train` passes, `setup()` cast that gate to bf16. `train()` ran with no autocast and kept no fp32 copy, so the gradient and both AdamW moments were bf16 as well. An AdamW step of about `lr` is below half a bf16 ulp for every `|w| >= 2^-7`, and the gate's default init puts most of its weights there. After 8 steps at the default `lr` (2e-5), 91.4% of the gate was bit-identical at hidden 64 and 82.6% at hidden 576, and training longer did not help. `"cuda:0"` kept the gate fp32, because the check was `== "cuda"`.

The gate is now created as an fp32 master weight on every device string, on the device of the base's parameters. The dtype is explicit, so torch's process-wide default dtype cannot change it. The placement means:
- a base loaded to the CPU is moved by the Trainer, gate and all;
- a pre-quantized bitsandbytes base, which is placed on its GPU at load and which neither the Trainer nor Accelerate moves, already has the gate beside it.

`mole_gate.pt` is saved with every floating tensor cast to fp32, so a DeepSpeed engine that holds the module in 16-bit cannot write a 16-bit gate. `compute_loss` already cast the router input to the gate's dtype and the routing weights back to the logits' dtype; tests now pin both casts. The frozen base and the task LoRAs load as before. `docs/training.md` says the gate trains and is saved in fp32 and gives the file size. It also replaces a stale "no serve-time MoLE path" sentence with a pointer to `soup serve --mole`.

The new tests' CPU bf16/fp16 steps request `aten_half_matmuls` (#1314) and assert it was in effect. On the CUDA branch the frozen base is bf16, so even a step without autocast runs the half-precision kernels that kill part of the `windows-latest` fleet with `0xc000001d`.

What users notice:
- On CUDA the whole gate trains. A gate trained on CUDA before this fix kept most of its initial weights, so retrain it.
- `mole_gate.pt` is now fp32: `4 x hidden x adapters` bytes, about 7 KB for the docs example.
- `soup serve --mole` loads the new file and an older bf16 file alike, and on CUDA serving still casts the gate to bf16.
- The gate's weight, gradient and two moments take 16 bytes per parameter instead of 8, which is 8 MiB at hidden 8192 with 64 adapters.

Verified on CPU:
- The unfixed trainer fails 15 of the 24 new tests, reproducing the issue's 117/128, 951/1152 and 333/1152 bit-identical counts. With the fix, 0 of 128 and 0 of 1152 gate elements are left unmoved.
- One-edit mutations were killed across the gate dtype, the gate placement, both routing casts and the save cast. The one survivor, dropping the `is_floating_point()` guard in the save, is equivalent, since the gate has no non-float state.
- Taking `aten_half_matmuls` out of the step tests fails 11, so the in-effect assertions are not vacuous.
- 1368 tests pass (8 skipped, 0 failed) across the new file and 33 neighbouring files, including the #1235 and #382 guard files.
- A freshly trained fp32 gate and an old bf16 gate both load and generate through `load_mole_for_serve`.

On a real RTX 5070 (sm_120), no gate element was left bit-identical after 8 steps, where `main` left 951 of 1152. Forcing `fp16=True` fails on `main` with the GradScaler's bf16 unscale error; with this change it trains the whole gate.

This lands before #868, whose MoLE `bf16`/`fp16` flags need an fp32 gate.

Closes #1266.
MakazhanAlpamys pushed a commit that referenced this pull request Sep 30, 2026
…f skipping (#1401)

Ten real-`train()` tests were skipped on every windows-latest cell under #382, although #1314 had traced that `0xc000001d` crash to CPU bf16/fp16 matmuls that its `aten_half_matmuls` fixture removes, and `_windows_ci()` still blamed bitsandbytes for all of them.

Nine of the tests (eight in `test_rewind_hf.py`, the GRPO step in `test_v05311.py`) now request `aten_half_matmuls` instead of skipping. The tenth, `test_hf_training_run_records_every_row_once`, trains fp32 and just loses its skip. `KNOWN_CALL_SITES` and `GUARDED_TESTS` shrink to the two NF4 tests in `test_v07202.py`. A new `FIXTURE_TESTS` check fails if any of the nine stops requesting the fixture or also carries the skip, and the `_windows_ci()` docstring now says which cause belongs to which tests.

Users notice nothing: this is tests only. The windows-latest cells now run ten tests they used to skip. Verified on a Windows box with `CI=true` set so the skip would have fired: 147 passed, 3 skipped across the guard file, the narrow-skip class and the three touched test files, all ten formerly skipped tests running; on clean `main` the new guard file fails 9. Six of eight mutations fail a named test, and the other two are the fixture made a no-op (caught by the existing `test_issue1235` fixture self-test) and a skip re-added to the fp32 test (deliberately unpinned). On the previous head the three windows-latest cells were green with all ten tests PASSED and no `0xc000001d`.

Closes #1359.
@MakazhanAlpamys
MakazhanAlpamys deleted the fix/1235-windows-ci-illegal-instruction branch October 6, 2026 16:54
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