| Files | Last commit | Last update |
|---|---|---|
refactor: name the confs, markers and bundle dirs for their vendor (#648) Step 4 closes the naming series: everything the tree *produces* by name is now the vendor. backends_<id>.conf -> backends_<vendor>.conf (+ _boxing variants) register_flaggems_<id>.inc -> register_flaggems_<vendor>.inc lib_dcu/lib_maca/lib_ppu -> lib_hygon/lib_metax/lib_thead bundle_*_libtorch.sh, with_cuda_libtorch.sh, check_dcu_core_abi.py -> the vendor's name _build_config.ACCELERATOR -> the vendor conf route keys -> the vendor (musa -> mthreads, gcu -> enflame) The two vocabularies stay deliberately distinct: a *platform id* (cuda, dcu, musa, ...) is the build table's key -- CMake switch lists, kernel pins, the CI runner configuration -- and is still accepted wherever a user types FLAGOS_ACCELERATOR. Everything a reader is handed back (a conf, a route key, a marker, a bundle dir, the build record) is the vendor. Parsing keeps the old spellings as aliases, for confs and FLAGOS_OP_* overrides written before the rename: cuda->boxing, gcu->enflame, musa->mthreads, apple/mps, and hygon/thead resolve to kNone because CUDA-ABI vendors have no Backend slot of their own. The wheel filename is not touched: its local segment is the SDK version (+cuda13.3, +dtk2604, +maca3.8.1.3, +ppu2.0.0), which is what every published flagtree and torch_fl artifact on resource.flagos.net uses and what keeps a variant comparable across lanes. The lane already names the vendor. Co-authored-by: Claude Code <noreply@anthropic.com> | 15 days ago | |
[CICD] Add unified FlagCICD entry with L0-L2 support (#599) * ci: add unified FlagCICD entry with L0-L2 support Resolve immutable source revisions, plan levels and platforms from the repository contract, reuse existing checks and integration workflows, and aggregate validated platform reports across run attempts. Tested: 916 existing device-free tests passed, 5 skipped; 74 entry contract tests passed; Ruff 0.15.12 check and format passed. Co-Authored-By: Codex <noreply@openai.com> * fix: retain dispatch identity when FlagCICD planning fails Keep validated task and parent identifiers plus the workflow repository in failed-plan summaries without inventing resolved source metadata. Tested: 78 entry contract tests passed; Ruff check and format passed. Co-Authored-By: Codex <noreply@openai.com> * test: isolate FlagCICD planner CLI git fixture Create a temporary source repository for the planner CLI contract test. The DCU container checkout can have a different owner and reject git rev-parse, so the test must not depend on its Git metadata. * fix: make the FlagCICD entry plan and report actual failures Four defects in the unified entry, each noted during review: - The plan job diffed against base_sha inside a clone of the source repository. For a fork PR, the base commit belongs to upstream and is unreachable from the fork's clone, so git diff base...head failed, _changed_files returned None, and every run silently degraded to the unavailable-diff path (L2, all platforms). The resolver now emits base_sha and the plan job fetches it by SHA. The fetch must not use --depth: a depth-1 base has no parents, merge-base reports "no merge base" and the same degradation returns. Verified against a local clone with a fork point: depth-1 fails, a full fetch by SHA yields the diff. - The concurrency group was keyed on github.run_id, which is unique per run, so it serialized nothing while two dispatches for one PR still raced on per-attempt artifact names. Key on the logical request (pr_number, checkout_ref, task_id) instead and keep queueing rather than cancelling. - A failed platforms matrix with a complete, passing report set was summarized as report_error. The report is not defective in that case; the failure lies outside the stages it records. Report defects are already detected separately, so this is infra_error. - Dependency pins were not covered by the risk rules, and the no-diff risk ignored the contract's unknown_risk. Add requirements/constraints/ lock files to the runtime rule and read the risk from the contract. The plan job's limit is raised to 20 minutes for the full-history clone plus the comparison fetch, and the plan parser install is pinned. Tested: pytest tests/unit/test_flagcicd_entry.py -> 85 passed; ruff check and ruff format --check pass on all changed Python files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Codex <noreply@openai.com> Co-authored-by: 马函廷 <19025408700@163.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> | 18 days ago | |
ci: fetch FlagTree uncached, which is faster than restoring it (#764) FlagTree is the one wheel every platform installs and every one of them is big, so the pip cache it was going into restored more slowly than resource.flagos.net serves it: platform FlagTree cache restore direct fetch saved kunlunxin 3283 MB 435 s 60 s 375 s enflame 1130 MB 195 s 21 s 174 s ascend 536 MB 114 s 10 s 104 s thead 363 MB 106 s 7 s 99 s iluvatar 360 MB 55 s 7 s 48 s mthreads 190 MB 68 s 3 s 65 s The cache ran at 5.8-7.7 MB/s across those six (run 41976848532); the index served the XPU wheel at 54.7 MB/s (run 42084793856, 3283 MB in 60 s) and the CUDA lane's 388 MB at 38.8 MB/s (run 41976848532). Roughly a ten-fold difference, which is why the ratio held on every platform rather than just the largest wheel. Together the FlagTree wheels were holding about 2.4 GB of the repository's 10 GB cache budget for content the index delivers faster. So the opt-out moves from a per-hook special case to the shared install_flagtree: there is no platform whose FlagTree is small enough for the cache to win. FlagGems, torch and the rest stay cached -- at 9-189 MB the cache does win for those, and unlike FlagTree they are re-resolved on every run. kunlunxin's hook drops the override it added when its 3.2 GB wheel was the only one being handled. Tested: pytest tests/unit/test_ci_pip_cache.py tests/unit/test_set_env_common.py - 73 passed; the override was replayed against a stubbed pip_retry under set_env.sh's export PIP_RETRY_NO_CACHE=0 and confirmed to reach the install without leaking past it. Full suite matches the baseline's 25 pre-existing host failures with no additions. bash -n on every hook and library; ruff check and ruff format --check clean. Co-authored-by: lvyufeng <lvyufeng@lvyufengdeMac-mini.local> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> | 12 hours ago | |
refactor: name the confs, markers and bundle dirs for their vendor (#648) Step 4 closes the naming series: everything the tree *produces* by name is now the vendor. backends_<id>.conf -> backends_<vendor>.conf (+ _boxing variants) register_flaggems_<id>.inc -> register_flaggems_<vendor>.inc lib_dcu/lib_maca/lib_ppu -> lib_hygon/lib_metax/lib_thead bundle_*_libtorch.sh, with_cuda_libtorch.sh, check_dcu_core_abi.py -> the vendor's name _build_config.ACCELERATOR -> the vendor conf route keys -> the vendor (musa -> mthreads, gcu -> enflame) The two vocabularies stay deliberately distinct: a *platform id* (cuda, dcu, musa, ...) is the build table's key -- CMake switch lists, kernel pins, the CI runner configuration -- and is still accepted wherever a user types FLAGOS_ACCELERATOR. Everything a reader is handed back (a conf, a route key, a marker, a bundle dir, the build record) is the vendor. Parsing keeps the old spellings as aliases, for confs and FLAGOS_OP_* overrides written before the rename: cuda->boxing, gcu->enflame, musa->mthreads, apple/mps, and hygon/thead resolve to kNone because CUDA-ABI vendors have no Backend slot of their own. The wheel filename is not touched: its local segment is the SDK version (+cuda13.3, +dtk2604, +maca3.8.1.3, +ppu2.0.0), which is what every published flagtree and torch_fl artifact on resource.flagos.net uses and what keeps a variant comparable across lanes. The lane already names the vendor. Co-authored-by: Claude Code <noreply@anthropic.com> | 15 days ago | |
ci: retire the cambricon lane until a runner carries it (#724) The lane this repository merged in #720 names a runner label that no machine answers to: .github/configs/cambricon.yml asks for cambricon-mlu, and the registered runners are the ones for ascend, hygon and metax. The label was not taken from the fleet -- every other one follows a real naming scheme (sy-8g-cicd-pytorch, ts-v150-cicd-pytorch, p800-8g-cicd-pytorch, ...) and its runner names carry a random suffix, which this one has neither of. The consequence is not a red X that can be retried: the job sits queued indefinitely, the workflow that owns it never finishes, and a required check never reports. Measured on #720 -- queued for 71 minutes with an empty runner_name before the branch was merged, and every later run repeats it. So the wiring comes out and the platform stays. Removed: .github/configs/cambricon.yml, .github/scripts/hooks/set_env_cambricon.sh, the release.yml matrix entry, and the pypi_lane that demanded both. pypi_lane is what ties these together: test_release_publishing.py holds the release matrix and the config directory to the platforms that carry one, in both directions, and a release job reads its runner label and image from that config file. A lane without a runner cannot satisfy either, so an empty pypi_lane is the honest statement -- and it is the state kunlunxin, iluvatar, tsingmicro, bpu and mps already declare. The version pins stay, marked as unread. They record what the platform was measured against (triton 3.4.0+mlu2.1.1, FlagGems' cambricon backend needing triton.backends.mlu, no flagtree build on the lane), and restoring the lane should start from them rather than from a fresh guess. Nothing reads them now, so whoever restores it has to confirm they still match the index. No operator, route, kernel or runtime source changes: backends_cambricon.conf, cambricon_flaggems_register.inc and everything under csrc/runtime/ accelerator/cambricon/ are untouched. Verified: ruff check and ruff format --check pass; both generators are idempotent; tests/unit fails 16 tests locally, the same 16 a pristine checkout of this base fails (torch absent locally), with nothing failing only on this branch. The capability matrix now records cambricon as CI-uncovered, and its none count is corrected to the 1402 the conf actually holds. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> | 3 days ago | |
perf: serve silu through CNNL's activation kernel on Cambricon (#771) FlagGems spends ~196 us on a (2, 512, 3072) silu, which is four calls per forward in Qwen3's MLP. CNNL computes it as one activation: silu is x * sigmoid(x), and both spellings exist -- CNNL_ACTIVATION_SILU and the deprecated CNNL_ACTIVATION_SWISH. The driver rejects the second one with "Activation mode 13 is not supported" and lists the first as supported, so the kernel uses SILU. Measured on MLU590-M9, Neuware 4.7.6, fp32, median of 25 calls: silu(2, 512, 3072) 196.2 us -> 27.4 us half and bfloat16 are widened to float32 for the activation and narrowed back, like the other kernels in this backend. Forward is unchanged within measurement noise (29.8 / 30.2 / 29.9 before, 30.3 / 30.0 / 30.1 after). Four calls saving ~0.17 ms each is ~0.68 ms, and the run-to-run spread on this step is +-0.3 ms, so the model-level effect is not resolvable here. Reported separately from the per-op number rather than claiming a model-level win that the measurement does not show. Found while testing, not caused by this change: silu(inplace=True) returns a wrong result because silu_ routes to flaggems, not to this kernel. Left alone -- it is a different op with its own route -- but worth a follow-up. Tested: - tests/integration/ops/test_cambricon_native_ops.py: 126 passed - silu against the CPU reference for float32, float16, bfloat16 and a non-contiguous input; the widening path is exercised by the half dtypes - codegen run twice: identical output Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> | 7 hours ago | |
ci: retire the cambricon lane until a runner carries it (#724) The lane this repository merged in #720 names a runner label that no machine answers to: .github/configs/cambricon.yml asks for cambricon-mlu, and the registered runners are the ones for ascend, hygon and metax. The label was not taken from the fleet -- every other one follows a real naming scheme (sy-8g-cicd-pytorch, ts-v150-cicd-pytorch, p800-8g-cicd-pytorch, ...) and its runner names carry a random suffix, which this one has neither of. The consequence is not a red X that can be retried: the job sits queued indefinitely, the workflow that owns it never finishes, and a required check never reports. Measured on #720 -- queued for 71 minutes with an empty runner_name before the branch was merged, and every later run repeats it. So the wiring comes out and the platform stays. Removed: .github/configs/cambricon.yml, .github/scripts/hooks/set_env_cambricon.sh, the release.yml matrix entry, and the pypi_lane that demanded both. pypi_lane is what ties these together: test_release_publishing.py holds the release matrix and the config directory to the platforms that carry one, in both directions, and a release job reads its runner label and image from that config file. A lane without a runner cannot satisfy either, so an empty pypi_lane is the honest statement -- and it is the state kunlunxin, iluvatar, tsingmicro, bpu and mps already declare. The version pins stay, marked as unread. They record what the platform was measured against (triton 3.4.0+mlu2.1.1, FlagGems' cambricon backend needing triton.backends.mlu, no flagtree build on the lane), and restoring the lane should start from them rather than from a fresh guess. Nothing reads them now, so whoever restores it has to confirm they still match the index. No operator, route, kernel or runtime source changes: backends_cambricon.conf, cambricon_flaggems_register.inc and everything under csrc/runtime/ accelerator/cambricon/ are untouched. Verified: ruff check and ruff format --check pass; both generators are idempotent; tests/unit fails 16 tests locally, the same 16 a pristine checkout of this base fails (torch absent locally), with nothing failing only on this branch. The capability matrix now records cambricon as CI-uncovered, and its none count is corrected to the 1402 the conf actually holds. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> | 3 days ago | |
perf: serve silu through CNNL's activation kernel on Cambricon (#771) FlagGems spends ~196 us on a (2, 512, 3072) silu, which is four calls per forward in Qwen3's MLP. CNNL computes it as one activation: silu is x * sigmoid(x), and both spellings exist -- CNNL_ACTIVATION_SILU and the deprecated CNNL_ACTIVATION_SWISH. The driver rejects the second one with "Activation mode 13 is not supported" and lists the first as supported, so the kernel uses SILU. Measured on MLU590-M9, Neuware 4.7.6, fp32, median of 25 calls: silu(2, 512, 3072) 196.2 us -> 27.4 us half and bfloat16 are widened to float32 for the activation and narrowed back, like the other kernels in this backend. Forward is unchanged within measurement noise (29.8 / 30.2 / 29.9 before, 30.3 / 30.0 / 30.1 after). Four calls saving ~0.17 ms each is ~0.68 ms, and the run-to-run spread on this step is +-0.3 ms, so the model-level effect is not resolvable here. Reported separately from the per-op number rather than claiming a model-level win that the measurement does not show. Found while testing, not caused by this change: silu(inplace=True) returns a wrong result because silu_ routes to flaggems, not to this kernel. Left alone -- it is a different op with its own route -- but worth a follow-up. Tested: - tests/integration/ops/test_cambricon_native_ops.py: 126 passed - silu against the CPU reference for float32, float16, bfloat16 and a non-contiguous input; the widening path is exercised by the half dtypes - codegen run twice: identical output Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> | 7 hours ago | |
ci: fetch FlagTree uncached, which is faster than restoring it (#764) FlagTree is the one wheel every platform installs and every one of them is big, so the pip cache it was going into restored more slowly than resource.flagos.net serves it: platform FlagTree cache restore direct fetch saved kunlunxin 3283 MB 435 s 60 s 375 s enflame 1130 MB 195 s 21 s 174 s ascend 536 MB 114 s 10 s 104 s thead 363 MB 106 s 7 s 99 s iluvatar 360 MB 55 s 7 s 48 s mthreads 190 MB 68 s 3 s 65 s The cache ran at 5.8-7.7 MB/s across those six (run 41976848532); the index served the XPU wheel at 54.7 MB/s (run 42084793856, 3283 MB in 60 s) and the CUDA lane's 388 MB at 38.8 MB/s (run 41976848532). Roughly a ten-fold difference, which is why the ratio held on every platform rather than just the largest wheel. Together the FlagTree wheels were holding about 2.4 GB of the repository's 10 GB cache budget for content the index delivers faster. So the opt-out moves from a per-hook special case to the shared install_flagtree: there is no platform whose FlagTree is small enough for the cache to win. FlagGems, torch and the rest stay cached -- at 9-189 MB the cache does win for those, and unlike FlagTree they are re-resolved on every run. kunlunxin's hook drops the override it added when its 3.2 GB wheel was the only one being handled. Tested: pytest tests/unit/test_ci_pip_cache.py tests/unit/test_set_env_common.py - 73 passed; the override was replayed against a stubbed pip_retry under set_env.sh's export PIP_RETRY_NO_CACHE=0 and confirmed to reach the install without leaking past it. Full suite matches the baseline's 25 pre-existing host failures with no additions. bash -n on every hook and library; ruff check and ruff format --check clean. Co-authored-by: lvyufeng <lvyufeng@lvyufengdeMac-mini.local> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> | 12 hours ago | |
perf: serve silu through CNNL's activation kernel on Cambricon (#771) FlagGems spends ~196 us on a (2, 512, 3072) silu, which is four calls per forward in Qwen3's MLP. CNNL computes it as one activation: silu is x * sigmoid(x), and both spellings exist -- CNNL_ACTIVATION_SILU and the deprecated CNNL_ACTIVATION_SWISH. The driver rejects the second one with "Activation mode 13 is not supported" and lists the first as supported, so the kernel uses SILU. Measured on MLU590-M9, Neuware 4.7.6, fp32, median of 25 calls: silu(2, 512, 3072) 196.2 us -> 27.4 us half and bfloat16 are widened to float32 for the activation and narrowed back, like the other kernels in this backend. Forward is unchanged within measurement noise (29.8 / 30.2 / 29.9 before, 30.3 / 30.0 / 30.1 after). Four calls saving ~0.17 ms each is ~0.68 ms, and the run-to-run spread on this step is +-0.3 ms, so the model-level effect is not resolvable here. Reported separately from the per-op number rather than claiming a model-level win that the measurement does not show. Found while testing, not caused by this change: silu(inplace=True) returns a wrong result because silu_ routes to flaggems, not to this kernel. Left alone -- it is a different op with its own route -- but worth a follow-up. Tested: - tests/integration/ops/test_cambricon_native_ops.py: 126 passed - silu against the CPU reference for float32, float16, bfloat16 and a non-contiguous input; the widening path is exercised by the half dtypes - codegen run twice: identical output Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> | 7 hours ago | |
fix: maximum and minimum lost a NaN through a broadcast (#740) * fix: maximum and minimum lost a NaN through a broadcast maximum/minimum propagated a NaN correctly when both operands had the same shape and silently replaced it with a finite value when they had to broadcast: a = [nan, 1, 2, 3] b = arange(12).view(3, 4) torch.maximum(a, b)[0] reference [nan, nan, nan, nan] (per column) on device [0.0, 4.0, 8.0, 12.0] A finite broadcast was exact, so the bug only showed when a NaN had to be widened. That is the failure mode with no symptom: no exception, no NaN, just a wrong number -- and maximum over a mask or a padded sequence is exactly where a broadcast and a NaN meet. **Why the tests missed it.** test_nan_propagates checked NaN at matching shapes and test_broadcast_and_row_pitch checked broadcasting with _randn values. Each by itself passed; the intersection was never exercised. The regression test added here is the intersection, and it fails 2 of 2 on the previous code and passes on this one. The fix gives the pair its own body, T_NAN_PROPAGATING, because T_BINARY is shared with add/sub/mul/div, which propagate NaN through their nodes and do not need a guard. The body builds isNaNWithTensor: over each operand and selects a NaN back over the node's answer; the NaN comes from x - x rather than a constant, because MpsConstant cannot express one. Two shapes are declined rather than answered: * **A broadcast pair that holds a NaN** takes the round-trip. Selecting over a predicate built from a (4,) operand against a (3, 4) result needs a second graph shape for a case that is rare, and the decline is correct where a half-built guard is silent about being wrong. * **A one-element secondary** is expanded on the host first: MpsRunGraph placing a numel-1 operand at a larger argument_shapes entry fails module verification on this SDK. The same-shape path also got faster, from 0.41x to about 1.0x, because the guard accumulates through the graph rather than through a reshape of the whole result. Tested on an M5 (macOS 26.5, Xcode 26, arm64), FLAGOS_ACCELERATOR=mps: tests/integration/ops/test_mps_*.py plus test_mps_runtime.py -> 2006 passed, 1 skipped, including the new test_nan_through_a_broadcast over six shape/NaN-side combinations for both ops. tests/unit/ is at its pre-change baseline. ruff check, ruff format --check, codegen_mps.py --check and gen_vendor_confs.py --check all report clean. The route count is unchanged: both ops stay routed to apple. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore: drop a scratch script that was committed by mistake .scratch/diff1.py is a throwaway exploration script, not source. It was picked up by a git add -A while the differential sweep was being written and carried into this branch by a rebase. No production code or test changes; the sweep itself lives in tests/integration/ops/test_mps_differential_sweep.py. * chore: ignore .scratch/ Exploration scripts under .scratch/ are not source. A git add -A picked one up while the differential sweep was being written, so the directory is now ignored explicitly rather than left to care. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> | 3 days ago | |
fix: build the DCU runtime from the CUDA sources it was named for (#654) * fix: build the DCU runtime from the CUDA sources it was named for The DCU lane of #653 failed at import: ImportError: libtorch_fl.so: undefined symbol: EventElapsedTime runtime_dir is the directory csrc/runtime/accelerator/ globs its sources from, and DCU's row named "hygon" -- the directory that holds only the DTK-core ABI shim. Every other platform reads its row as the shared CUDA sources, and so did the accelerator CMakeLists (its _flagos_is_dcu arm says FLAGOS_PLATFORM_RUNTIME_DIR == "nvidia"), so the DCU runtime was linked from the 32 stub symbols of dtk_core_compat.cc alone: no stream, event, memory or device implementation. A build of that library succeeds and then fails at load. Reproduced on a DTK host with the same recipe the lane uses (CPU torch 2.10.0+cpu, FLAGOS_BUILD_VENDOR=ON, boxing on), and nm -D on the result showed 28 exported symbols where the ABI needs the whole runtime. Two further mismatches sat behind the first, both between what csrc/ resolves through flagos.h and what the runtime exports when it is compiled against DTK's headers: * USE_DCU (and the Kunlunxin/Iluvatar macros) are set on the runtime target only, so guard.h compiled the generic arms in csrc/ -- a device-wide synchronize where DCU wants the per-stream wait the vendor streams support. The header now takes the FLAGOS_ACCEL_<ID> marker csrc/ does define as a fallback. * DTK declares PointerGetAttributes as a template, so the flat function flagos.h names has no symbol in a DCU build; isPinnedPtr and the stream handle go through two declared entry points instead, defined next to their flagos.h neighbours in runtime/accelerator/nvidia/. * The standalone _flagos_nccl builder did not put DTK's HIP headers on its include path, so rccl.h (whose hipStream_t parameter is unqualified) failed to parse. Measured on bm-srwl-sh-zone9-d-bw1100-144g-1-10 (container lvyufeng-torch-fl) with that build: import torch_fl now loads, the runtime exports both the flagos ABI and the two Dcu* entry points, and nm -D --undefined-only libtorch_fl.so has nothing left that libflagos.so does not define. Device work does not run there yet -- flag_gems routes zeros to a triton kernel and triton finds no hcu driver in that venv -- so this commit is verified to the load boundary, not to an operator result; the DCU lane itself is the measurement of record. Tested: pytest tests/unit/test_ci_matrix.py tests/unit/test_setup_build.py \ tests/unit/test_wheel_preflight.py -q 158 passed, 1 skipped ruff check -> All checks passed! ruff format --check -> 471 files already formatted Co-Authored-By: Claude Code <noreply@anthropic.com> * test: ask whether the conf serves an op, not whether boxing was compiled The NVIDIA lane failed on #654's head with 22 errors in the portable group, all of them the same one: DispatchStub: missing kernel for cuda from aten::to.dtype (13 cases) and aten::masked_select (9). Both were guarded on the build record -- "boxing" in _env.build_kernels() and hasattr(torch.ops.aten.<op>, ...) -- which is the wrong question on a CUDA-ABI platform: its boxing wheel compiles the boxing *and* the vendor kernel sets, while the default conf routes each op to one of them (the log shows FlagGems disabled there, so to.dtype and masked_select measured vendor). The guard therefore read "boxing compiled" as "the op runs", the call fell through to a CPU-only torch's CUDA dispatch stub, and the job went red for two ops that are outside the wheel's contract. The guards now call the op on a 0-element device tensor and skip when that raises (a conf route to none, or the stub itself). A route that serves the op returns a device tensor, so nothing is skipped that could have run. Same premise as #653's comment, made from the measured route instead of the build record: a wheel's kernel sets are not its conf. Tested: pytest tests/unit/test_ci_matrix.py tests/unit/test_setup_build.py \ tests/unit/test_wheel_preflight.py \ tests/unit/test_integration_manifest.py -q 235 passed, 1 skipped ruff check -> All checks passed! ruff format --check -> 150 files already formatted Co-Authored-By: Claude Code <noreply@anthropic.com> * fix: claim only the ops the conf routes in a boxing build A boxing build took the FLAGOS_BOXING_BUILD arm of csrc/aten/register.cc, which included generated/register.inc and so claimed every generated op on PrivateUse1. A default conf never routes boxing (none for anything not covered), and registration and routing must agree: the first op a test ran that the conf routes to none -- empty_like on the DCU boxing wheel -- raised empty_like: routed to 'none' (no accelerated impl on this platform) but the op is registered on PrivateUse1 -- regenerate the vendor conf so registration and routing agree codegen_boxing_flaggems_register.py now emits a second file per platform, generated/register_boxing_<platform>.inc, claiming exactly the ops the platform conf routes somewhere other than none that have a wrapper, and register.cc includes it under FLAGOS_BOXING_BUILD. Registering an op whose route is none is what puzzles the dispatcher into the message above; leaving it unregistered lets cpu_fallback serve it, which is the documented behaviour of an uncovered op. Claimed / none-route counts: nvidia 2034/0, metax 600/1437, hygon 453/1584, thead 592/1445, tsingmicro 81/0. Tested: pytest tests/unit/test_conf_registration_consistency.py tests/unit/test_ci_matrix.py tests/unit/test_setup_build.py -> 205 passed, 1 skipped. codegen_boxing_flaggems_register.py --check clean after regeneration. ruff check / ruff format --check clean. Co-Authored-By: Claude Code <noreply@anthropic.com> * test: probe the masked_select route on a shape the kernel can serve An empty input is the natural route-probe shape, but FlagGems' Ascend arm reads prefix_sum[-1] on an empty cumsum and raises IndexError from inside the kernel, which the probe does not catch -- so both integration suites errored at collection on the Ascend lane (run 40124915577). Probe with one input element and one True mask element instead, and check the single surviving element. Tested: ruff check / ruff format --check clean. Co-Authored-By: Claude Code <noreply@anthropic.com> * docs: describe both boxing register lists and the guards' live premise scripts/README.md listed only the FlagGems list, and test_dtype_coverage.py still explained its why-guards as 'an every-op boxing wheel registers this op while the conf routes none', which is no longer how a boxing build registers. Tested: codegen_boxing_flaggems_register.py --check clean; ruff clean. Co-Authored-By: Claude Code <noreply@anthropic.com> * ci: import the DCU link helper from where it lives The legacy-mode smoke in set_env_hygon.sh still imported torch_fl.accelerator.dcu._dcu_libtorch_link, a module name retired by #647/#648 when the package was renamed to torch_fl/accelerator/hygon/. The DCU lane died there with ModuleNotFoundError right after the full-core bundle verified clean, so the rest of the lane never ran. Tested: bash -n on the hook; the same import path is exercised on the DCU rig (python -c 'from torch_fl.accelerator.hygon._dcu_libtorch_link import restore_original_libtorch') against the branch tree. Co-Authored-By: Claude Code <noreply@anthropic.com> * test: ask the conf, not the build record, before a boxing-only assertion Three dispatch-log cases and the device-RNG cases assumed that a boxing build routes the CUDA-only ops. Since #643 a default conf never routes boxing and a boxing build registers only the ops its conf routes, so on ppu (boxing built, mm/bmm/mul.Tensor/normal_ routed none) the op is unregistered, takes cpu_fallback, and either raises after the dispatch line or reads ATen's generator. PPU lane, run 40127851880: 10 failed -- three override cases plus seven RNG cases. The override cases assert the route the env override selects, so they now run the subprocess with check=False; the dispatch line is written before the call fails. The RNG cases draw with normal_, so they are gated on that op having a route at all (_normal_inplace_routed), which is what makes the flagos generator meaningful. Tested: pytest tests/unit/test_conf_registration_consistency.py tests/unit/test_ci_matrix.py -> 165 passed. ruff check / format clean. The RNG and dispatch suites themselves need a device and run on the lanes. Co-Authored-By: Claude Code <noreply@anthropic.com> * test: gate the device-RNG cases on the wheel compiling the draw's route The RNG cases draw with normal_ / rand and assert a flagos generator is honoured. On a boxing wheel that did not compile the route its conf names for that draw, the op is unregistered (a boxing build claims only the ops its conf routes, #643 + the register_boxing lists), so cpu_fallback serves it with ATen's cpu generator and the assertion can only fail. The metax lane is exactly that: backends_metax_boxing.conf routes normal_ to boxing while the wheel ships KERNELS=("flaggems","vendor") -- run 40129896922, 7 failed, 'Expected a cpu device type for generator but found flagos'. _UNROUTED_DRAWS asks backend_conf.route_is_served (the same question the dispatch-log tests ask) for normal_ and uniform_, skipped on every generator case. The override cases' comments now name that cause too. Tested: ruff check / ruff format --check clean. Co-Authored-By: Claude Code <noreply@anthropic.com> * test: let the route probe decide to.dtype, not the build record The why-guard on the to.dtype and masked_select cases read boxing in build_kernels(), which was right while a boxing build claimed every generated op and those claims had no route. It now claims only the routed ops, so on the NVIDIA lane (boxing wheel, conf routing to.dtype to nvidia) the probe answers honestly and the build-record guard only skipped a case that passes: 13 failed on run 40131571005 with 'DispatchStub: missing kernel for cuda', each one behind a stale pytest.skip(True). Dropped the three guards and the now-unused _env import; the route probes remain as the guard. Tested: ruff check / ruff format --check clean. Co-Authored-By: Claude Code <noreply@anthropic.com> * fix: leave a boxing route unclaimed only when its kernel exists Every op the conf routes to a backend other than none was claimed in a boxing build, on the premise that a boxing route has a kernel behind it. It does not on every platform: the generated boxing kernels narrow per vendor at build time, and cuda/dcu/ppu leave mm and bmm empty and metax add.Tensor, which register.cc registers by hand precisely because the generated ones are not there. Claiming those made the dispatcher refuse them: the ppu lane still fails FLAGOS_OP_mm=cuda without ever logging a dispatch line (run 40133348394, 3 failed). boxing_target_ops now joins the conf against the boxing kernel set (cuda_kernels.cc's Backend::kBoxing registrations, the same join flaggems_kernel_ops does for its file): a boxing route with no kernel there is left to cpu_fallback and named in the file's held-back list, and a boxing route with one stays claimed. No .inc changes -- every routed op already had a kernel -- so the generated files are unchanged. Tested: codegen --check clean; pytest tests/unit/test_conf_registration_consistency.py tests/unit/test_ci_matrix.py -> 165 passed; ruff clean. Co-Authored-By: Claude Code <noreply@anthropic.com> * test: skip a conf-route assertion the wheel cannot serve test_dispatch_log_flaggems_runtime asserts the route the conf names, which is only logged when this wheel compiled that backend. metax's boxing conf routes add.Tensor and mean.dim to boxing while the wheel ships KERNELS=("flaggems","vendor"), so neither is registered, neither logs a dispatch, and the conf's own value is not what the process does (run 40134760314, 2 failed). route_is_served already answers this question; the two cases now skip on False instead of failing. Tested: ruff check / ruff format --check clean. Co-Authored-By: Claude Code <noreply@anthropic.com> * test: skip FLAGOS_OP_<op>=cuda when the wheel logs no boxing route The override cases on mm, bmm and mul.Tensor assert a '-> boxing' dispatch line. That line only exists when the override's backend has kernels in the wheel and the conf routes the op somewhere a claim exists: on the PPU lane (backends_thead_boxing.conf, KERNELS without boxing) the boxing slot is never registered, the conf sends the op to cpu_fallback, and the process prints no dispatch at all -- runs 40127851880 and 40135954656, 3 failed each round. backend_conf.override_is_logged(op, target='cuda') asks both of the wheel and the three cases skip on False, with one shared reason string. Tested: ruff check / ruff format --check clean. Co-Authored-By: Claude Code <noreply@anthropic.com> * test: ask the register.cc arm, not the conf route, before an override case The first version of the override gate asked route_is_served, which says whether the conf states a reachable route -- not whether the selected arm claims the op. On the DCU rig the FlagGems-primary arm claims mm, mul.Tensor and add.Tensor (conf route flaggems, no 'none'), yet override_is_logged answered False for all of them, because the DCU conf resolves to backends_hygon.conf where the route is not boxing. override_is_logged(op, backend) now follows the arm: a boxing build claims every op its conf routes and has a kernel for, so OP=<boxing> logs iff route_is_served; the FlagGems-primary arm claims the FlagGems-routed ops, so OP=<flaggems> logs iff the conf already routes the op to a FlagGems arm and this wheel compiled it. The override cases accept either arm. Tested: on the DCU rig (boxing build, backends_hygon.conf) the probe now answers override_logged True for mm/bmm/mul.Tensor/add.Tensor/mean.dim under the flaggems arm; ruff check / format clean. Co-Authored-By: Claude Code <noreply@anthropic.com> * fix: keep the full generated list registered in a boxing build The boxed list replaced register.inc, so an op its conf routes to none or to a backend this wheel did not compile had a wrapper defined and never registered. That is fine for ops whose only entry point is the dispatcher, and wrong for the ones whose wrapper answers before it: the FP8/FP4 matrix wrappers open with the soft_lowp prelude, which the metax and dcu builds compile in, so on the metax boxing wheel torch.mm on an FP8 tensor reached cpu_fallback's addmm_impl_cpu_ and the whole low-precision group failed (run 40137411252: 20 failed, 'addmm_impl_cpu_ not implemented for Float4_e2m1fn_x2', tokens coming back bf16 where the wrapper would have returned the requested dtype). Both lists are registered now, boxed first and the full generated list after, so the conf's unclaimed ops stay unclaimed (later m.impl wins) and the generated ops keep a claim. An op with no kernel can only be routed on a wheel that compiled its backend, where the generated kernels serve it, so the extra claims do not invent routes. Tested: pytest tests/unit/test_conf_registration_consistency.py -> 64 passed; ruff check / format clean; codegen --check clean. Co-Authored-By: Claude Code <noreply@anthropic.com> * docs: record why the boxing arm claims the wrappers whole Narrowing the second include to the matrix wrappers does not leave fp32/fp16 mm on cpu_fallback: a generated wrapper calls the whole-op dispatcher, and once a claim exists that dispatcher answers "routed to 'none'" for every dtype. Measured on the DCU rig with exactly that narrower list. Co-Authored-By: Claude Code <noreply@anthropic.com> * test: probe to.dtype with the conversion the guarded cases run The two guards asked whether aten::to.dtype reaches flagos with an fp64 tensor cast to float32, and that answer does not cover what the cases under them do. On a CUDA-ABI build _to_copy is claimed unconditionally, and its like-for-like cast goes through CUDA's TensorIterator copy, while a mixed conversion goes through CUDA's copy_ -- whose bool/double kernel a CPU-only torch does not ship. The probe passed on that gate while all twelve mixed-precision pairs and test_float32_to_float64_on_device failed with DispatchStub: missing kernel for cuda (NVIDIA lane, run 40142698275). The probe now runs the float32 -> float64 conversion itself, through Tensor.to as the cases call it, so the guard admits the cases only where the conversion those cases perform is served. Co-Authored-By: Claude Code <noreply@anthropic.com> * test: expect no matmul device time on the PPU's mm route too backends_thead.conf routes mm, mm.out and bmm to none, so the profiled matmul runs through cpu_fallback and no device event links to it: the PPU lane failed with aten::matmul and aten::mm both at 0.0 s (run 40144165031). That is the same condition the CUDA entry records, reached by a different route, so the case joins the written-down xfail list rather than growing a second mechanism. Co-Authored-By: Claude Code <noreply@anthropic.com> * test: gate the empty-densification xfail on the index_add_ route The marker was keyed on the accelerator name, so the MUSA lane xfailed whether or not the build could reach the FlagGems index_add_ that divides by a zero-length index. On run 40145473675 it could not: the sparse surface was reached through TorchFunctionMode before torch_fl activated, and the parameterized index_add slot answered with index_add_ unregistered, so the densification ran without touching that kernel. The two cases still xfailed, and the xpass the predicate was written to report never appeared -- the marker was resting on an accident of routing. Asking the conf which route serves index_add_ keeps the mark where the defect is (a FlagGems index_add_ route) and lets the cases run for real where it is not reachable. Co-Authored-By: Claude Code <noreply@anthropic.com> * test: let the draw itself decide whether the RNG cases can run The two reproducibility cases draw from every op in the catalogue and asserted unconditionally. On the CUDA lane, whose conf routes multinomial, random_, randint, randperm and their out variants to a route a boxing build does not claim, those draws reach ATen's random() dispatch stub through cpu_fallback instead of running: "DispatchStub: missing kernel for cuda", 29 failures on run 40146671425. Nothing about the seed can be asserted for a draw that leaves the device. The probe is the draw, run once and skipped when it raises that the op is not implemented here. It cannot go stale the way a route list does, and it does not hide a different failure: anything that is not "not implemented" / "DispatchStub" is re-raised. Co-Authored-By: Claude Code <noreply@anthropic.com> * test: probe the RNG draw the way the cases run it The probe called op() and the cases call _draw(op, seed), which warms up with .float().cpu(). On the CUDA lane both halves raise, and _dense-like reads of a draw the wheel leaves unserved fail in the warmup, not in the call, so the probe returned and the cases failed anyway (run 40147673906, the same 29). The probe is now the cases' own draw; _skip_if_random_leaves_the_device was also called with the parameter name instead of the draw until now. Co-Authored-By: Claude Code <noreply@anthropic.com> * test: use the draw probe on the other RNG cases that draw 30 of the 33 failures on the CUDA lane are gone; three left, and all three draw through the same unserved ops. The second-device reproducibility cases call _draw like the first-device ones, and native_dropout draws from random_ -- so each takes the same probe instead of repeating the reason. Run 40148742285. Co-Authored-By: Claude Code <noreply@anthropic.com> * test: hand the RNG probe a tensor, not the dropout tuple The probe warms up with .float() on what it is given, and native_dropout returns (output, mask) -- so it raised AttributeError where the wheel serves the draw (Kunlunxin lane, run 40149862610) instead of skipping or passing. Probe the output element; the call under test does its own unpack. Co-Authored-By: Claude Code <noreply@anthropic.com> * ci: stop listing the Qwen3 groups on a platform with no model mount thead is the only platform whose integration_environment is empty: its Qwen3 checkpoint mount is gone, so $MODEL_PATH is defined by nothing while the manifest still lists both Qwen3 groups for it. Under set -u the group dies at "bash: line 2: MODEL_PATH: unbound variable", which names neither the variable's owner nor the configuration that owes it, and the PPU lane has been red with that line on main and on every PR run. Three changes, one per layer: - the manifest restricts inference/training to nvidia, the platform that supplies MODEL_PATH, with the reason recorded beside the steps; - run_integration_tests.py names the group and the missing variables before starting bash, instead of forwarding bash's message; - tests/unit/test_integration_manifest.py asserts that every $NAME a platform's composed commands expand is supplied by that platform's integration_environment (or the runner), so an unrun group cannot pass for a test failure again. The ratchet on per-platform coverage records thead in both gaps. Tested: pytest tests/unit/test_integration_manifest.py tests/unit/test_run_integration_tests.py -q -> 124 passed ruff check + ruff format --check on all four files: clean Co-Authored-By: Claude Code <noreply@anthropic.com> * fix: keep the boxing arm's generic claim on the ops its list leaves out register.cc's boxing arm includes the per-platform boxed list and then generated/register.inc. The PR added the second include without the #define FLAGOS_GEN_IMPLS main wraps it in, and register.inc puts every m.impl behind that define: unguarded, the include registered no wrapper at all, so the generated wrappers were *defined* but never claimed. On ppu the boxing list deliberately holds back mm, bmm and mul.Tensor (the conf routes them to none), so those three lost their claim entirely and the PPU runtime contract failed them (run 40153064301: "mm dispatched to None, conf says 'boxing'": the conf reaches the boxed list, the op reaches no kernel). Restore the define around the include. The boxed list still precedes it, so its claims win; the generic list only adds what it leaves out, which is the soft_lowp matrix path metax and dcu need. A unit test now holds the other end: every op a platform's boxed list claims must be an op its conf routes away from none and lists at all. It passes on all five boxing platforms, and would have caught a boxed list that claimed an op the conf sends to cpu_fallback. Tested: pytest tests/unit/test_conf_registration_consistency.py tests/unit/test_integration_manifest.py -q -> 155 passed Co-Authored-By: Claude Code <noreply@anthropic.com> * test: probe the dropout contract's own device arithmetic The dropout case compares the mask against the output and scales it for the backward check. Both are unary conversions and a scalar multiply -- on a wheel whose conf routes those to none they reach cpu_fallback, and a CPU-only torch has no float->float copy kernel there, so the comparison, not the draw, raises "DispatchStub: missing kernel for cuda" (NVIDIA lane, run 40154845873; the draw probe added earlier let the case through). Probe the two expressions the case runs, with the same exception contract as the draw probe, so the contract still asserts device placement and masks on every wheel that serves them. Tested: ruff check + ruff format --check on the file: clean; the helper's skip/propagate behaviour exercised against stub operations. Co-Authored-By: Claude Code <noreply@anthropic.com> * fix: let the boxed list be the whole boxing claim The boxing arm claimed the per-platform boxed list and then the full generated list. On a platform whose conf leaves mm/bmm on none, claiming the generated mm wrapper turned every dtype of mm into "routed to 'none' ... but the op is registered on PrivateUse1": the whole-op dispatcher is what the wrapper calls, so the claim is not one route. Measured on the DCU import gate (run 40156011560, backends_hygon.conf) and, from the other side, on the PPU runtime contract (run 40153064301). The second include is removed. What it covered is the boxed list's job: every op the conf routes has a generated wrapper, so register_boxing_<p>.inc already names mm/bmm/mul.Tensor wherever the conf routes them to boxing (metax), and leaving them out where the conf says none (dcu, ppu) is the correct claim. The consistency test now asserts both directions -- nothing claimed that the conf routes to none or does not list, and nothing routed to boxing that the boxed list does not claim -- so a codegen change that drops a route from the list fails a unit test instead of a vendor import. Tested: pytest tests/unit/test_conf_registration_consistency.py tests/unit/test_integration_manifest.py tests/unit/test_run_integration_tests.py -q -> 175 passed; ruff check + ruff format --check: clean. DCU import gate: not re-run on the rig; the CI DCU lane is the verification. Co-Authored-By: Claude Code <noreply@anthropic.com> * test: ask the GCU lane what its RNG and dispatch log actually do Two GCU groups asserted a contract this wheel does not have, which the lane reported on run 40157088291 after the boxing-claim change let it reach them: - test_dtype_route_fallback / test_new_ones_dispatch expected the dispatch line to say enflame. The log prints the slot's dispatch name, which for GCU is gcu (csrc/aten/dispatcher.h:LogDispatch); every op did run on the vendor kernel. The assertions name gcu, and the new_ones ones match the specific op -> gcu line instead of the absence of any flagos_python in the whole child. - test_rng_dispatch's two "rejects a flagos generator" cases assume the rejecting face. GCU's native RNG reads the flagos seed and accepts a flagos generator, so "DID NOT RAISE" is a wrong expectation; the explicit-generator helper then handed it a cpu generator, which it legitimately refuses. The acceptance question is now asked (the same predicate the helper already used), and a wheel that accepts skips the rejection cases. Affects the GCU lane only; no product code changes. Tested: ruff check + ruff format --check on all three files: clean. The GCU lane is the verification. Co-Authored-By: Claude Code <noreply@anthropic.com> * fix: take back the generator-contract guess from 6f1a879 Asking _flagos_generator_is_rejected() in the two rejection cases made them skip on the GCU lane instead of failing, which is not a contract: the lane's generator behaviour is not explained by the build record, and a skip would hide whatever it is. The explicit-generator helper keeps the one-call fix -- a vendor whose RNG reads the flagos seed must not be handed a cpu generator -- because falling through to the cpu branch fails every call there with "Expected a flagos generator, but found cpu". The two rejection cases are back to their original form; the GCU generator behaviour is a separate question from this PR. Tested: ruff check + ruff format --check: clean. Co-Authored-By: Claude Code <noreply@anthropic.com> * test: read the day-0 conf's boxing key the way the dispatcher logs it The PPU lane runs backends_thead_boxing.conf, whose key for the ops it does not route to FlagGems is boxing -- not a conf backend the runtime contract's log-name table knew, so route_reason passed the raw string through and the case compared it against a dispatch line the boxing route cannot emit: a boxing call on this backend is the vendor's own CUDA kernel run on the flagos device with no per-call dispatch to log. Two changes, both in the contract's own vocabulary: - boxing maps to boxing, so a day-0 conf reads as the slot it names; - a boxing route with no dispatch line skips after its CPU reference passed, with the reason stated. That is the one route whose absence of a line means "served on-device" rather than "reached nothing"; every other backend still has to log. Superseded by nothing: with the full register.inc claim removed (f44d0d0) the previous PPU fix no longer applied, and this one does not rely on a claim at all. Tested: ruff check + ruff format --check on tests/integration/runtime: clean; unit suite for registration consistency: 68 passed. PPU lane is the verification. Co-Authored-By: Claude Code <noreply@anthropic.com> * test: ask each wheel whether its RNG takes a flagos generator The two "rejects a flagos generator" cases and the explicit-generator helper decided from the build record, and on GCU the record leaves the vendor out of the list whose RNG reads the flagos seed -- so both cases asserted a rejection that does not happen ("DID NOT RAISE"), and the helper handed a consumer of flagos generators the cpu generator ("Expected a flagos generator, but found cpu", run 40160432086). The question is now asked on the device once: a draw with a flagos generator either returns (this wheel accepts it) or raises the documented refusal. A wheel that accepts skips the rejection cases -- the contract they guard is that an explicit generator is honoured, which the neighbouring cases check -- and the helper hands that same wheel the flagos generator it takes. A refusal that is not the documented one still fails. Separately, the FlagGems conformance suite reads the conf's basename as the accelerator label and the PPU lane runs backends_thead_boxing.conf, so the label was thead_boxing -- a row the measurement tables never had (run 40160432086). A day-0 variant now reports <platform id>_boxing, which is the ppu_boxing row those tables already carry. Tested: ruff check + ruff format --check on both files: clean; registration consistency unit suite: 68 passed. GCU and PPU lanes are the verification. Co-Authored-By: Claude Code <noreply@anthropic.com> * ci: give each CUDA-boxing platform a second integration lane NVIDIA, Hygon DCU, MetaX and the T-Head PPU each ship two builds of the same source: the default vendor + FlagGems wheel, and the CUDA-boxing wheel. Only the first had a lane, so the boxing build was never built or tested in CI, and a regression in it could only surface on a user's box. The platform config now opts in with boxing_lane: true, and generate_matrix.py schedules <platform>-boxing beside <platform> everywhere a platform is named -- file selection, dispatch, /ci run, the flagcicd planner. There is no way to schedule one without the other. load-platform-config.yml strips the suffix, hands BOXING_LANE to the hook, holds the lane to boxing_environment_check, and publishes under wheel-<platform>-boxing so a release's two artifacts cannot collide. Both lanes compose the same manifest. A step whose selection differs per build names the platforms in boxing_variant (vendor_ops for the four boxing platforms): that entry runs on the boxing build and only there, because the vendor build's op selection cannot answer the boxing conf. The hook sets FLAGOS_BUILD_BOXING=1 and the _boxing conf only on the boxing lane, uses a separate venv, and asserts the kernel set that build must carry. tests/integration/ops/test_rng_dispatch.py accepts RuntimeError for the multinomial xfail: on a FlagGems-first wheel with boxing off the same device-context bug surfaces as DispatchStub: missing kernel for cuda instead of the Triton error the case was written for. Tested: tests/unit/test_integration_manifest.py, test_ci_matrix.py, test_ci_commands.py, test_ci_platform_routing.py, test_flagcicd_*, test_musa_ci_manifest.py, test_wheel_preflight.py -- 458 passed. Torch-dependent unit tests cannot run on this checkout (no torch installed). ruff check + ruff format --check clean. Co-Authored-By: Claude Code <noreply@anthropic.com> * test: count a boxing platform's second build when checking for orphans test_no_integration_test_file_is_orphaned_from_every_manifest composed one build per platform, so the vendor build's selection. The DCU vendor_ops entry now runs on the boxing build (boxing_variant: [hygon]), which made test_dcu_flaggems_pinned_routes.py and test_dcu_flaggems_sdpa.py read as selected by nothing. The check now composes every build a platform has. Tested: tests/unit/test_integration_workdir.py -- 17 passed; the CI agnostic list (43 files) -- 1040 passed with only the 12 pre-existing test_transformers_automation failures, which fail the same way on the unmodified tree. Co-Authored-By: Claude Code <noreply@anthropic.com> * ci: name the boxing lane "<platform> (CUDA boxing)" The lane's job name read "Build and test (CUDA CUDA boxing)" on NVIDIA and "Build and test (Hygon DCU CUDA boxing)" elsewhere; a parenthesized suffix reads the same on both. Co-Authored-By: Claude Code <noreply@anthropic.com> * test: print the ulp gap when the MPS linear case disagrees test_module_to_device_migrates_parameters_in_place printed both tensors as 0.0101 on the MPS lane (run 40165849082) and still failed: the two results differ in the last float32 bit, and torch.allclose scales the absolute tolerance by a reference near zero. The message now carries the max absolute difference. The MPS lane deselects that one case by name. A local Apple-Silicon torch reproduces the summation-order gap but not the failure -- 2000 trials came back bit-identical, so the trigger on the runner is unproven -- and the sub-class of inputs that exposes it is a BLAS-order question, not a torch_fl one. Every other platform still runs the case. Tested: tests/unit/test_integration_manifest.py, test_integration_workdir.py, test_ci_matrix.py -- 215 passed. ruff check + format --check clean. Co-Authored-By: Claude Code <noreply@anthropic.com> * fix: default the boxing variables on the vendor lane export_ci_env reads every name with set -u, and the vendor lane no longer assigns FLAGOS_BUILD_BOXING or FLAGOS_BACKEND_CONFIG (they were unconditional before the second lane existed), so the MetaX vendor lane died in set_env_common.sh with FLAGOS_BACKEND_CONFIG: unbound variable before it builds anything. Both hooks now assign the empty default beside the export, so each lane persists an explicit value: empty on the vendor lane, the boxing lane's own on the boxing lane. The NVIDIA record assertion asked for the variable to be absent; it now asks for it to be unset or empty, which is what the default gives it. Tested: tests/unit/test_set_env_runner.py, test_set_env_common.py, test_dcu_env_script.py, test_musa_env_script.py, test_iluvatar_env_script.py -- 88 passed. bash -n clean on every hook and lib script. Co-Authored-By: Claude Code <noreply@anthropic.com> * fix: keep the vendor torch off the vendor lane's library path The NVIDIA vendor lane passed ${VENDOR_TORCH_LIB:+...} to LD_LIBRARY_PATH on both lanes, which puts the accelerator torch's lib/ -- and its libtorch_cuda.so -- ahead of the CPU torch's on the lane whose premise is a wheel that depends on neither. main had no such segment; only the boxing lane needs it, since that libtorch is what the boxed kernels re-dispatch into. Tested: bash -n on the hook; tests/unit/test_set_env_runner.py. Co-Authored-By: Claude Code <noreply@anthropic.com> * fix: bundle the PPU libtorch on the boxing lane only The PPU vendor lane bundled lib_thead/, and that directory is how torch_fl recognises a PPU-core wheel (torch_fl/__init__.py:_is_ppu_build), so the lane meant to test the default FlagGems-primary wheel imported as a PPU-core one and its environment check failed with AssertionError: 13.0 against cuda_version: null (run 40168685639). The bundle now runs under BOXING_LANE=1, which is the wheel that needs it (the PPU row gates its vendor libtorch on the boxing switch), and the vendor lane's environment check forbids lib_thead/libtorch_cuda.so so a bundled wheel cannot pass there again. Tested: tests/unit/test_wheel_preflight.py, test_integration_manifest.py, test_ci_matrix.py -- 215 passed. ruff check + format --check clean. Co-Authored-By: Claude Code <noreply@anthropic.com> * test: record the day-0 confs' FlagGems route rows The FlagGems conformance suite keys its expected route count on the conf's name, so backends_<platform>_boxing.conf needs its own row or the suite fails before it measures anything (PPU lane, run 40160432086: "thead_boxing has no measured FlagGems route count"). Every row here is read off the shipped conf the same way the suite counts (= flaggems only), so the table states what the conf says and nothing more: nvidia / nvidia_boxing 0 the CUDA day-0 conf routes the whole op set to boxing; the FlagGems coverage it would have named is the kernel the boxing wheel dispatches to metax_boxing 583 (12 flaggems_cpp routes stay C++) dcu_boxing 453 ppu_boxing 592 exists already A day-0 variant also inherits its platform's pinned-off holds: the confs differ in what serves the operators FlagGems cannot do, not in which ones it answers wrongly. Tested: tests/unit/test_ops_conftest_platform.py, test_integration_manifest.py, test_ci_matrix.py; ruff clean. Co-Authored-By: Claude Code <noreply@anthropic.com> * test: probe masked_select on the shape the cases call test_factory_ops.py::TestIndexing::test_masked_select was unguarded and hit DispatchStub: missing kernel for cuda on the PPU vendor lane (run 40171498946): backends_thead.conf routes the op to FlagGems, but that wheel imported without the FlagGems Python runtime, so the call fell through to ATen's CUDA stub. test_dtype_coverage.py guards its masked_select cases and skipped -- its probe uses a 1-element shape, which took another arm and answered. The guard now lives in all three files that call the op, asks it through torch.ops.aten.masked_select on the 4-element shape the cases use, and the factory-ops case carries it. The probe answers "is this call served here", which is the question each case needs; a conf route alone does not answer it. Tested: ruff check + format --check clean; tests/unit/test_integration_workdir.py -- 17 passed. The integration cases need a device and are measured by the lanes. Co-Authored-By: Claude Code <noreply@anthropic.com> * test: guard the factory-ops masked_select case too 08e2205 said it added the guard to test_factory_ops.py and did not: the edit landed in test_dtype_coverage.py and test_ops.py only, so the PPU lane failed on the same case again (run 40172589562). The guard is the same probe as test_ops.py's: call torch.ops.aten.masked_select on the 4-element shape the case uses and skip when the wheel has no route that serves it (the PPU lane routes the op to FlagGems without the FlagGems Python runtime loaded, so the call reaches ATen's CUDA dispatch stub). Tested: ruff check + format --check clean; test_integration_workdir.py and test_ci_matrix.py pass; py_compile on the edited file. Co-Authored-By: Claude Code <noreply@anthropic.com> * test: skip the dtype-cast round trip when the conf has no cast route test_runtime_transfer.py::test_dtype_cast_round_trip casts by Tensor.to, which dispatches to _to_copy. backends_thead.conf leaves _to_copy.out on none (so does backends_metax.conf; backends_nvidia.conf routes it), so the call reaches ATen's own CUDA dispatch stub and a CPU-only torch raises DispatchStub: missing kernel for cuda -- the same conf decision the masked_select cases now skip on, not a broken copy engine (PPU lane, run 40188039682: 28 passed, this one failed). The case asks by making the cast the case makes, to the dtype it casts to, and skips with the conf's reason when nothing serves it. Tested: ruff check + format --check clean; py_compile on the edited file. Co-Authored-By: Claude Code <noreply@anthropic.com> * fix: hand ordinary floating-point GEMMs back to the conf's route The MetaX lane failed on torch.bmm with "soft-lowp matrix kernel requires a low-precision input" (run 40189509687). Each FLAGOS_SOFT_LOWP platform's generated wrapper for the matrix family (mm, bmm, addmm and their dtype/out/inplace spellings) starts with a prelude that takes the software path whenever IsLowPrecision(dtype) holds. The prelude sits in a shared .inc that cannot know whether the build compiles the software path, so a float32 bmm that the conf routed to FlagGems (backends_metax.conf: bmm = flaggems_cpp, degrading to flaggems) left FlagGems here, and CheckMatrixInputs refused it. Addmm had already been taught the difference -- its implementation sends a non-low-precision addmm to the boxing slot with a comment about linear_out -- but mm and bmm had no such term. The software path now hands any pair that carries no low-precision operand, and takes a real floating-point/complex dtype, back to the route the conf names (Dispatcher::DispatchConfigured). That is FlagGems on MetaX, boxing elsewhere, and it is what the wrapper would have called had the prelude not fired. A lowp pair still runs the software path, and the _scaled_mm gate is untouched. The dtype/out/inplace spellings dispatch straight into their preallocated result instead of computing an unbuffered one and copying it. baddbmm.out rides along: it was held off the FlagGems route for a codegen out defect that is fixed, the only remaining reason is the SPLIT_K rejection MetaX records for mm/bmm, so it moves there (BOXING_TRITON_GAPS["metax"]) out of the shared hold list. Unmeasured on hardware; the docstring records that. Extra: tests/integration/ops/test_soft_lowp_gate_dispatch.py gains a float-path case for the .out / .inplace spellings, which the existing float cases did not reach. Tested: ruff check + ruff format --check clean; pytest tests/unit/test_gen_vendor_confs.py tests/unit/test_integration_manifest.py -> 155 passed; gen_vendor_confs.py --check -> all vendor confs up to date (regeneration is idempotent). Co-Authored-By: Claude Code <noreply@anthropic.com> * revert: keep baddbmm.out on the shared FlagGems hold list Reverts the baddbmm.out half of cc645ce. The codegen job rejected it: tests/integration/ops/test_flaggems_conf_consistency.py maps every routed op back to a generated kFlagGems kernel and requires each route to have one. The op's gems out overload has no generated kFlagGems kernel, so routing it from any conf leaves baddbmm_out_dispatcher with no route (run 40191835392, test_no_orphan_flagos_python_kernels / test_counts_match). The hold list is checked before the coverage sets for every non-cuda conf, so a metax gap entry cannot take the op out of it either. Making baddbmm.out routable needs the codegen to emit that kernel first, which is a separate change; the soft-lowp matrix fix it was bundled with is unaffected and stays. The float-path .out/.inplace case added with it goes too: it is not exercised until the routing above exists. Co-Authored-By: Claude Code <noreply@anthropic.com> * fix: restore baddbmm.out to the hold lists the revert touched The revert of the baddbmm.out routing removed the entry from codegen_ops.py when it was meant to restore it, so the two hold lists disagreed and the codegen job still found the orphan (run 40192817619). Only the routing half was supposed to go. Co-Authored-By: Claude Code <noreply@anthropic.com> * fix: route every non-lowp GEMM through the conf, not the boxing slot 447e9a6's fix reached mm and bmm but not all of addmm. The MetaX lane was green on the two bmm cases and then died with SIGSEGV (exit -11) in the next one that multiplies doubles, test_float64_addmm_matches_cpu_reference. AddmmImpl already had an ordinary-dtype branch, but it named the boxing slot outright (Backend::kBoxing, with a comment about linear_out) rather than the route the conf gives the op. On MetaX that is how addmm -- which backends_metax.conf points at flaggems_cpp -- arrived at the maca kernel with fp64 operands, and the maca runtime takes the whole process down instead of refusing the call the way the fp32 bmm case did. Two changes: every wrapper entry point now hands a pair with no low-precision operand back to the conf's route, and the addmm branch uses that route too instead of the boxed one. The dtype list the earlier version used is gone with it: this path cannot compute an ordinary dtype at all (CheckMatrixInputs refuses it), so the verdict belongs to the conf, and FlagGems -- not the boxing kernel -- is what MetaX measured as serving addmm's ordinary dtypes. The workflow change is the same fix's other half: .github/workflows/ agnostic-checks.yml now runs gen_vendor_confs.py --check and test_flaggems_conf_consistency.py on ubuntu-latest. Both need nothing but the standard library, and running them there would have caught the baddbmm.out routing mistake (runs 40191835392, 40192817619) in the lint gate instead of a wheel build on hardware. Tested: ruff check + format --check clean; pytest tests/unit/test_gen_vendor_confs.py tests/unit/test_integration_manifest.py -> 155 passed; gen_vendor_confs.py --check -> up to date; the conf-consistency checks run locally with pytest stubbed out (no torch here): conf 640 + override 48 = 688 kernels, no orphans without pytest. Not run on MetaX hardware locally -- CI is the measurement. Co-Authored-By: Claude Code <noreply@anthropic.com> * ci: load no conftest for the static conf check The new agnostic-checks step ran the conf-consistency file without --confcutdir, so pytest loaded tests/integration/conftest.py, which imports torch_fl and a device. The job installs no torch on purpose, and it died in pytest_configure with INTERNALERROR before collecting anything (run 40194231780). --confcutdir=tests/integration/ops keeps that conftest out of the run; the file compares generated text and needs no fixture from it. Tested: the workflow YAML parses and the step list is unchanged apart from the flag; the same file is still exercised on hardware lanes, where the conftest loads as before. Co-Authored-By: Claude Code <noreply@anthropic.com> * ci: cut every conftest from the static conf check --confcutdir=tests/integration/ops kept tests/integration/conftest.py out but loaded the ops one, which asks torch_fl (via _flaggems_cpp_enabled, from pytest_collection_modifyitems) whether the C++ FlagGems path is on. Without torch that is INTERNALERROR/exit 3 (run 40194751666). Cut at tests/ instead so neither conftest loads. The conf-consistency file needs neither: it reads generated text and nothing else. Co-Authored-By: Claude Code <noreply@anthropic.com> * ci: leave the routing check on the codegen job The conf-vs-kernel check cannot run in agnostic-checks: pytest loads tests/integration/conftest.py by directory ancestry whatever --confcutdir says about the target's own subtree, and that conftest imports torch_fl, which needs torch (runs 40194231780, 40194751666, 40195141606). Getting it in would mean moving the file out of tests/integration/, which is where the hardware suites want it. The codegen job already runs it, and it takes under a second there. What stays is the generator --check, which is a plain script with no torch import at all and would still have caught the baddbmm.out routing mistake. Co-Authored-By: Claude Code <noreply@anthropic.com> * test: pin an ordinary-dtype bmm to the conf's route The dispatch log names the backend that actually ran, so it is what separates "the software low-precision path handed the call back to the conf" from "it computed something and happened to agree". Covers float32 and float64 through the existing bmm subprocess helper (which gains a dtype argument). The regression it pins: on a FLAGOS_SOFT_LOWP platform (MetaX, DCU) every bmm wrapper call enters the software path, and before cc645ce/13d6ead it refused the ordinary dtypes (MetaX CI, runs 40189509687 and 447e9a6). Co-Authored-By: Claude Code <noreply@anthropic.com> * build: make soft-lowp one switch for its prelude and its sources The generated mm/addmm/bmm wrappers open with a dtype prelude that returns into the software FP8/FP4 path under FLAGOS_SOFT_LOWP. That macro and the sources it calls sat behind two different gates: the macro was implied by MetaX/DCU, the sources came from a source-list filter. A build could carry the prelude and not the code it returns into. A build that has one half and not the other cannot work: the prelude returns into code the wheel does not contain. The MetaX lane was that build, and test_float32_bmm_is_computed_in_float32 segfaulted in it (run 40195692591). FLAGOS_SOFT_LOWP is now a build switch in cmake/flagos_platforms.json (extra_switches, next to the five kernel sets: resolved and pinned the same way, absent from KERNELS because nothing dispatches to it), on by default for metax and dcu, off elsewhere. One switch decides both the macro and the source filter, and setup.py and CMake read it from the same row. Tested: pytest tests/unit/test_setup_build.py tests/unit/test_gen_vendor_confs.py tests/unit/test_wheel_preflight.py - 118 passed, 1 skipped Co-Authored-By: Claude Code <noreply@anthropic.com> * test: spell the probe's dtype in full so the subprocess can see it The dispatch probe runs torch.randn(..., dtype=float32) in a fresh interpreter that imports only torch_fl and torch, so the bare name raised NameError and both float bmm cases failed on Iluvatar (run 40197942356): the route assertion never got to read the log. Build the literal from the parameter instead, torch.float32. Co-Authored-By: Claude Code <noreply@anthropic.com> * test: expect the backend that serves bmm on the software-lowp platforms The conf some platforms ship names FlagGems C++ for bmm, but on MetaX and DCU the generated kernels hold the matrix ops (FLAGOS_SOFT_LOWP own them), so that route degrades at dispatch -- kFlagGemsCpp falls back to boxing and then to FlagGems, and a vendor build has no boxing kernels -- and the dispatch log prints flagos. Asserting the conf's spelling made the new case fail on hardware for a route the platform is documented to degrade (measured on the C550 host against an in-tree build: bmm -> flagos). * fix: let the software lowp path compute ordinary GEMMs instead of re-routing them On a FLAGOS_SOFT_LOWP build every mm/addmm/bmm wrapper call enters csrc/aten/backends/soft_lowp, ordinary dtypes included -- the generated prelude cannot tell an FP8 operand from a float32 one. The software path then handed those back to its own dispatcher by name. On the MetaX vendor lane (no boxing kernels, bmm routed through FlagGems) that re-entry segfaulted in every call: run 40195692591 in CI, reproduced on the C550 host against an in-tree build, for float32, float16 and float64 alike, for @ as well as torch.bmm. The escape was added for a build without the prelude, where an ordinary bmm never reached this file. With both halves on one switch (previous commit) it is dead weight, and wrong: the dispatch log cannot even name the re-entry, because LogDispatch is only wired into the generated wrappers. So the path computes ordinary operands itself -- decode is a no-op for them -- and keeps its fused addmm, which now names its op instead of the boxing slot. Its own lowp-only input check goes with the escape (the prelude sends every dtype here), and the suppression guard around the fused addmm stays, for the FLAGOS_FORCE_BACKEND=flaggems arm that re-dispatches inside this file. tests/unit/test_soft_lowp_prelude_contract.py holds the two properties that can be checked without a device: no by-name re-entry, and the guard. Tested: pytest tests/unit/test_soft_lowp_prelude_contract.py tests/unit/test_setup_build.py - 44 passed, 1 skipped; hardware check on the C550 host follows. Co-Authored-By: Claude Code <noreply@anthropic.com> * test: accept the soft-lowp platforms' documented bmm route and its degrade On MetaX and DCU the generated kernels hold the matrix ops (the software lowp path owns them), so the route their conf names degrades at dispatch -- kFlagGemsCpp falls back to boxing and then to FlagGems -- and the log prints flagos; measured on the C550 host against an in-tree build. Asserting the conf's spelling made the new case fail on hardware for a route the platform is documented to degrade. The PPU boxing lane went the other way: there bmm resolves the conf's boxing as written, so the case now accepts either the conf's route or the degrade, and only rejects a call that reached neither (run 40199712441). * fix: keep the soft-lowp entry helpers in their translation-unit scope Dropping the escape also dropped the anonymous namespace that made MmImpl, BmmImpl and AddmmImpl visible to the rest of the file's definitions, so the public entry points could not find them (C550 build: "'MmImpl' was not declared in this scope"). * revert: return the software lowp path to routing ordinary GEMMs through the conf This branch changed, in six commits, how the software low-precision path (csrc/aten/backends/soft_lowp) handles a GEMM whose operands are ordinary dtypes -- the case a FLAGOS_SOFT_LOWP build's wrapper prelude sends there together with the FP8/FP4 one. It ended up computing those calls itself instead of handing them back to the route the conf names, and folded the separate FLAGOS_SOFT_LOWP prelude switch into the source list. That change is what makes the MetaX lane fail, and this commit puts the path back. Why the change is withdrawn CI run 40202397139 (tip 24841a5) failed only on MetaX -- every other lane was cancelled behind it -- at tests/integration/test_dtype_coverage.py::TestBinaryDtypeSupport:: test_float32_bmm_is_computed_in_float32, with "Fatal Python error: Segmentation fault" (exit -11). The MetaX lane has to be green before this branch can land, and the soft-lowp rework is the only thing on it that this platform exercises. Measured on the C550 host, clean build of 24841a5's tree, a probe that does nothing but torch.bmm on float32 (2, 4, 4) tensors: 24841a5: [flagos dispatch] bmm -> flagos (repeated), core dump, rc=139 785a15c: (this commit) same probe, same host: core dump at the first bmm, rc=139 So reverting it does not by itself make that call pass; what fails is the MetaX bmm route both trees take (FlagGems' vendored MetaX bmm hands small shapes to a keyset with no PrivateUse1 kernel, and this build re-enters the same file). That routing needs a fix of its own, reviewed against the FlagGems entry and its fallback together -- see the follow-up. This commit only takes the rework back so the branch carries the DCU change and nothing else, and the next MetaX measurement is of main's behaviour rather than of a rework that was never the DCU change's concern. This branch's goal is the DCU runtime built from the CUDA sources it is named for (d7a6280). The soft-lowp change is not needed for it, and it turned one platform's lane red for a reason that has nothing to do with the DCU build; the MetaX bmm routing belongs in a change of its own, where the FlagGems entry and its fallback can be reviewed together. What this commit restores * csrc/aten/backends/soft_lowp/ops.cc: the state before 13d6ead, after the 68d7ed2 hold-list revert -- an ordinary operand pair goes back to the conf's route via DispatchConfigured, and only FP8/FP4 is computed here. * csrc/aten/dispatcher.h: drops OpName(), which only the reworked fused addmm used. * CMakeLists.txt, cmake/FlagosPlatforms.cmake, cmake/flagos_platforms.json, csrc/CMakeLists.txt, setup.py, tests/unit/test_setup_build.py: the FLAGOS_SOFT_LOWP prelude switch is its own option again, rather than a table entry, and the unit test goes back to the assertions that shipped with the switch. * tests/unit/test_soft_lowp_prelude_contract.py: removed. It asserted the behaviour this commit withdraws. Kept * scripts/codegen/codegen_ops.py and backend_coverage.py still carry the baddbmm.out hold-list restoration from 447e9a6. * The ci commits that touch agnostic-checks.yml (cd7d04b, 1e02472, 7fd25a2) are untouched; they neither depend on nor are depended on by the soft-lowp change. * tests/integration/ops/test_bmm_dispatch.py is untouched. It now carries the expectations 551ce46/eb5c5ac wrote for the reworked path; those cases and the conf-side routing the MetaX fix needs move to the follow-up change, and this commit leaves the file for it rather than guessing. Tested python -m ruff check All checks passed! python -m ruff format --check 471 files already formatted python -m pytest tests/unit/test_setup_build.py tests/unit/test_wheel_preflight.py -q 57 passed, 1 skipped in 0.37s python scripts/codegen/codegen_boxing_flaggems_register.py --check [check] 5 register_flaggems_*.inc and register_boxing_*.inc files are up to date pytest tests/unit -q on this host: 30 collection errors, all "ModuleNotFoundError: No module named 'torch'" -- the same 30 on a clean checkout of this branch, so they are the local interpreter, not this change. Co-Authored-By: Claude Code <noreply@anthropic.com> * fix: hand every ordinary GEMM back to the conf, not only the floating ones The software low-precision path already returns an ordinary addmm to the route the conf names, and after the preceding revert mm/bmm do the same -- but only when both operands are floating point or complex. A dtype the path cannot serve (bool, integer) is neither FP8/FP4 nor "vendor", so it falls through to a compute branch whose own check refuses it. Give mm, bmm and their dtype/out spellings the plain test addmm's condition reduces to: both operands ordinary means the call leaves here. Renames the helper to say that (IsOrdinaryMatrixPair) and moves the note about the MetaX recursion onto it, since that is the behaviour it protects. Co-Authored-By: Claude Code <noreply@anthropic.com> * test: key the FlagGems route tables by the conf that ships them accel_label() now resolves a conf basename to the platform id the tables are keyed by, through torch_fl._platform.platform_for, so the five confs named after a vendor (backends_thead.conf is ppu, hygon is dcu, enflame is gcu, mthreads is musa, nvidia is cuda) find their row. Before this the PPU lane failed both data-driven cases -- AssertionError: thead has no measured FlagGems route count AssertionError: thead has no recorded scaled_dot_product_attention route -- because accel_label() returned the basename thead while every row was keyed by the platform id (CI run 40214364703). Three counts were also wrong, and are corrected from the shipped confs the way flaggems_route_count() counts: * nvidia was 0. backends_nvidia.conf routes 549 ops to FlagGems; the 0 that was recorded belongs to backends_nvidia_boxing.conf, which is nvidia_boxing and now keeps it. * metax was 594. backends_metax.conf routes 586 ops to flaggems and 15 to flaggems_cpp; the counter reads flaggems only. * cuda (390) was keyed on a conf that does not exist -- the CUDA conf is named backends_nvidia.conf -- so no wheel could ever resolve to it. Removed from both tables rather than left as a stale second spelling of nvidia. tsingmicro gets a row (65) because it ships a conf with that many flaggems routes; it has no CI lane, but the tables are one statement per platform and the suite should be right on the first wheel that runs it. Every shipped conf now resolves to a row whose count and SDPA route match its own text; verified against all 15. Also: ci.yml's platform matrix runs with fail-fast: false. Under true the first failing lane cancelled its thirteen siblings, so a run reported one red no matter how many lanes were broken and a red PR converged one platform per round. Every selected lane now finishes, and CI complete still fails on any lane that is not green. tests/unit/test_ci_fail_fast.py pins both halves. Tested: python scripts/codegen/gen_vendor_confs.py --check -> "all vendor confs up to date"; ruff check . scripts/tools/torch-fl-preflight -> All checks passed; ruff format --check -> 472 files already formatted; the pure-text flaggems assertions (both tables cover the same platforms, every pinned-off platform has a row, all 35 held ops stay off FlagGems) -> pass; tests/unit/test_ci_fail_fast.py -> 4 passed. Co-Authored-By: Claude Code <noreply@anthropic.com> * fix: compare the compile profile's vendor, not a platform id platform_profile().vendor carries the vendor half of the name -- enflame, mthreads, hygon -- so three checks that compared it against the platform id never matched: * device_interface.get_compute_capability tested vendor in ("musa", "gcu", "iluvatar"). GCU fell through every arm down to the Ascend fallback and asked a GCU build for triton.backends.ascend, turning every torch.compile case on the Enflame lane into InductorError: ModuleNotFoundError: No module named 'triton.backends.ascend' (run 40242938356). It also mislabelled MUSA. * the same function tested vendor == "dcu", so the DTK gcnArchName arm was dead code and Hygon took the CUDA capability pair instead. * triton_64bit_guard.gcu_64bit_unsupported tested vendor == "gcu", so the guard never patched a GCU build and its test_gcu_* cases could not pass. Measured on a real GCU (S60, torch-fl-gcu-py312, torch 2.10.0+cpu) before the fix: platform_profile().vendor == "gcu", gcu_64bit_unsupported() == False, and get_compute_capability() raising the Ascend import error. After: vendor == "enflame", gcu_64bit_unsupported() == True, get_compute_capability() == 80. This is why the lane had never been seen red: with fail-fast on, the Enflame lane was cancelled on every run whose earlier lane failed. Tested: ruff check . scripts/tools/torch-fl-preflight -> All checks passed; ruff format --check -> 472 files already formatted. Device evidence from the GCU host as quoted above. Co-Authored-By: Claude Code <noreply@anthropic.com> * fix: read the vendor vocabulary in DeviceProperties.create too The same mistake as the previous commit, one branch further down: DeviceProperties.create guards its own record-building path with vendor in ("musa", "gcu", "kunlunxin", "tsingmicro") while vendor is bound to platform_profile().vendor. GCU (enflame) and MUSA (mthreads) therefore never took that branch and fell through to inductor's CUDA-shaped record for a flagos device. Every comparison of profile.vendor now uses the vendor half of the name; torch_fl/flagos's _platform() comparisons elsewhere keep platform ids and are unaffected, because that helper returns detect_platform(). Tested on a real GCU (S60, torch-fl-gcu-py312, torch 2.10.0+cpu): pytest tests/integration/test_compile.py -k gcu -> 2 passed, 9 skipped (the skips are this host's missing GCU Triton, not the change). ruff check . -> All checks passed; ruff format --check -> 472 files already formatted. Co-Authored-By: Claude Code <noreply@anthropic.com> * fix: stop the software lowp GEMM from handing ordinary pairs to itself torch.bmm on float32 (4, 64, 128) x (4, 128, 32) segfaulted on MetaX C550 (CI run 40242938356, tests/integration/test_dtype_coverage.py ::TestBinaryDtypeSupport::test_float32_bmm_is_computed_in_float32). gdb on the CI failure showed an unbounded recursion, not memory corruption: soft_lowp::BmmKernel -> Bmm -> BmmImpl -> dispatch -> soft_lowp::BmmKernel. The cycle is a slot conflict. flaggems_cpp_kernels.cc and soft_lowp/dispatch.cc both register on Backend::kFlagGemsCpp for mm, bmm and addmm(.out), each through a static registrar that overwrites the same function-pointer field; on a FLAGOS_SOFT_LOWP build the winner was the software wrapper. BmmImpl is not meant to compute an ordinary pair: it hands it back through bmm_dispatcher.DispatchConfigured, which on that build is the same wrapper. backends_metax.conf names flaggems_cpp for bmm, so every float32 bmm on the lane recursed until the stack ran out. The software path no longer claims a kFlagGemsCpp slot another kernel set already owns, and an ordinary pair is computed with plain at::mm/at::bmm when the route it would be handed to is this file's own wrapper. Ownership is read from the dispatchers (KernelFor/HasKernel) instead of link order. Bmm asks for the kernel bmm_dispatcher resolves rather than the boxing stripping DispatchConfigured applies, so FlagGems' float64 bmm kernel is still reached where it is the route. Measured on the C550 (tfl654 in-place build, backends_metax.conf): float32 bmm (4,64,128)x(4,128,32) max abs err 0.0 against the fp32 CPU result and 1.5e-05 against an fp64 CPU reference; (2,4,4) max abs err 2.4e-07; mm (64,128)x(128,32) 1.1e-05. float64 bmm now raises FlagGems' Triton CompilationError (loop-carried fp64 accumulator is rejected by this Triton) instead of segfaulting; it is not fixed here. ruff check . scripts/tools/torch-fl-preflight -> All checks passed! ruff format --check . scripts/tools/torch-fl-preflight -> 472 files already formatted Co-Authored-By: Claude Code <noreply@anthropic.com> --------- Co-authored-by: Claude Code <noreply@anthropic.com> | 13 days ago | |
feat: fuse scaled-dot-product attention on Cambricon through CNNL (#748) Replaces the decomposed q @ k^T -> softmax -> @ v path with CNNL's fused flash attention. This is the operator that should be a large-kernel call: the decomposition materialises the B*H*S*S score matrix in HBM and reads it back, which at batch 2, 4 heads and seq 4096 is 256 MB per layer, twice. Why FlagGems cannot carry it. FlagGems ships a cambricon attention under runtime/backend/_cambricon/ops/attention.py, but it does not load on this platform: importing it pulls in fused/FLA, which assumes torch_mlu's device management, and the process segfaults. That is the same root cause as flagos-ai/FlagGems#8448 -- the backend is written against torch_mlu and this install is torch+cpu plus a PrivateUse1 shim. A first attempt tried to reach it by making _cambricon.utils importable as a package; that made things worse (importing the ops then pulled FLA in and crashed plain SDPA as well), so the attempt was reverted and the vendor kernel written instead. The CNNL entry point is cnnlFlashAttentionForward_v2 from libcnnl_extra. It is public -- cnnl_extra.h declares it -- but only if you do not filter on CNNL_WIN_API, which this function does not carry; searching for that prefix is what made an earlier pass conclude the API was private. The call sequence and the three non-obvious arguments were taken from torch_mlu's own csrc/aten/operators/cnnl/internal/attention_internal.cpp and then confirmed against a host reference in a standalone binary before any of this was written: * cu_seqlens is mandatory even in the packed non-varlen case; a null descriptor is rejected with a bare status 3. * softmax_lse carries heads on dim 0, not the token axis -- the API says so only in the failure message. * the operands are [total_tokens, heads, D] (PACKED + is_pack_mode), not aten's [batch, heads, seq, D], so both sides are transposed. Wiring. The composite is what decides the leaf kernel, and it branches on query.device().type() -- PrivateUse1 goes to the CPU flash leaf whatever the leaf routes say. Cambricon was excluded from the composite override's platform guard, which is why every call decomposed. It joins the override here and gets a branch of its own, ahead of the FlagGems route. Bounds are enforced in FusedAttentionEligible rather than by raising: half and bfloat16 only, no mask, no dropout, no GQA expansion, dense 4-D operands. Each excluded case is a shape the kernel has not been measured on, and those calls are correct today. Forward only. Backward still takes the decomposition above, so a training call keeps working exactly as it did -- it just does not get the fused forward's win yet. cnnlFlashAttentionBackward_v2 is the next step. libcnnl_extra is optional in CMake: a toolkit without it builds, and FLAGOS_CAMBRICON_CNNL_EXTRA stays undefined, so nothing references a symbol that is not there. Verified on 8x MLU590-M9 (neuware 4.6.7, torch 2.10.0+cpu, flag_gems 5.4.0): correctness, against a float32 host reference, 2e-2 tolerance: fp16 causal / non-causal / explicit scale / seq128 OK (maxdiff 9.5e-4) bf16 causal / non-causal / explicit scale / seq128 OK (maxdiff 8.2e-3) latency, B=2 H=4 D=64 fp16, 20 iterations: S=256 3.90 ms S=1024 6.47 ms S=4096 12.56 ms Linear in S, which is the property the decomposition does not have. Qwen3, 4 layers / hidden 256 / seq 64, 5 steps: loss 0.557 -> 0.023. tests/unit: 29 failed, 2094 passed -- the same set the pre-change build fails, with no test failing only after this change. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> | 2 days ago | |
feat(mps): add native MPSGraph operator support with hosted Apple Silicon CI (#566) * ci: add an automated AI reviewer as one of the two required approvals main requires two approving reviews, and this adds the first of them: an LLM reviewer that reads the diff, judges it against the repository conventions, and submits an approving review when the change is clean. GitHub will not count an approval made with the Actions GITHUB_TOKEN towards required approvals -- that is a deliberate guard against a workflow rubber-stamping its own change. The review is therefore submitted through a dedicated machine user's token, which GitHub treats as an ordinary approving review with write permission. The workflow runs on pull_request_target because it needs a write token, but it never checks out or executes pull request code: it checks out the base revision's control script only, reads the diff through the API, and passes the diff to the model fenced and labelled as untrusted data. Approval is one of several gates. Protected paths (.github/workflows, .github/scripts, .github/configs, CODEOWNERS) always fall back to a human review, so the review pipeline can never approve a change to itself. A binary or otherwise uninlineable file, a truncated or oversized diff, a draft PR, a self-authored PR, or any high-severity finding likewise withhold approval and post a COMMENT review instead, which does not satisfy the merge requirement. Tested: pytest tests/unit/test_ai_reviewer.py - 37 passed Tested: pytest tests/unit/test_ai_reviewer.py tests/unit/test_ci_commands.py tests/unit/test_ci_pip_cache.py tests/unit/test_ci_version_pins.py - 175 passed Linted: ruff check .github/scripts/ci/ai_reviewer.py tests/unit/test_ai_reviewer.py - All checks passed Linted: ruff format --check (new files) - 2 files already formatted Linted: git ls-files -z '*.py' | xargs -0 ruff check - All checks passed Linted: actionlint .github/workflows/ai-review.yml - clean Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(mps): serve convolution natively on the MPSGraph conv category Add convolution_overrideable to the MPS codegen as a fifth stage-3 op, so F.conv2d, F.conv3d, nn.Conv2d and nn.ConvTranspose2d run on the MPSGraph convolution selectors instead of round-tripping through CPU. The op is codegen-driven: a new conv category maps the aten signature (stride, padding, dilation, groups, transposed, output_padding, bias) onto MpsConvParams, and render() splits the single convolution{dim}DWithSourceTensor: selector into its 2D and 3D call forms. MpsConvResolve picks the dtype gate (f32/f16/bf16) and declines anything MPSGraph cannot serve: 1D convolutions have no selector, so conv1d still round-trips. Notes on the MPSGraph call shape, both measured rather than assumed: * bias must be reshaped to (1, OC, 1, ...) and added with additionWithPrimaryTensor:; (1, 1, 1, OC) crashes the compiler and broadcastTensor:toShape: silently returns wrong values (6.6e-01 abs). * aten's flat padding is [front_0..front_n, back_0..back_n]. F.conv2d rejects a 4-entry list before dispatch, but nn.Conv2d(padding=(a, b)) and nn.Conv3d(padding=(a, b, c)) reach the kernel in that form, so the mapping follows aten order rather than assuming symmetric padding. Numerics are bit-exact against the CPU reference on every served case (2D/3D, strided, dilated, grouped, depthwise, aniso kernels, asymmetric padding) for f32/f16/bf16. Small-tensor shapes win 1.6-4.1x over the round-trip; large-batch conv is at parity, which is what the docs say. Generated files (mps_kernels.mm, mps_register.inc, backends_mps.conf) come from scripts/codegen/codegen_mps.py and gen_vendor_confs.py; coverage moves from 14/2037 to 15/2037. * feat(mps): serve silu natively on the MPSGraph pointwise category The convolution stage closed with 15 ops and no op whose shape the graph could not be built from a single MPSGraph selector. silu is the first that MPSGraph has no node for, so its body composes two: sigmoidWithTensor: then multiplicationWithPrimaryTensor:. That makes it the first multi-node graph in the layer and the first place the operand signature, not just the shape list, decides whether a plan can be reused -- KEY_PARTS["silu"] is empty because the op takes no host argument, and MpsRunGraph folds in the operand dtypes and shapes. aten::silu on the CPU is not x * sigmoid(x), so the dtype gate is a measurement of the graph against at::silu rather than a statement of that identity: host-evaluated the two readings differ by 7.8e-03 at float16 and 3.1e-02 at bfloat16. MpsSupportsSiluDtype therefore accepts float32, float16 and bfloat16 -- measured against the CPU reference over operands spanning [-40, 40] plus saturated tails, denormals and rank-0 tensors: float32 bitwise=False, maxabs 9.537e-07, 2.7e-07 relative; against an exact float64 evaluation the graph and the CPU reference are both 1.2353272164833e-06 away, so they round differently rather than one being wrong float16 bit-identical, including inf, -0.0 and the nan from inf * 0 bfloat16 bit-identical This serves every width the op is called with, which is wider than the softmax gate that declines bfloat16 over denormals -- widths on this backend are per-op, not a per-dtype list. silu.out, silu_ and silu_backward stay on the fallback: the first two write into a caller-owned tensor and the backward needs its own selector study. Coverage 15/2037 -> 16/2037. No boxing: the kernel is registered on PrivateUse1 and the graph is built natively. Verified: tests/integration/ops/test_mps_silu.py 34 passed tests/integration/ops -m mps 450 passed 1480 deselected tests/unit/test_conf_registration_consistency.py + tests/unit/test_gen_vendor_confs.py 95 passed tests/integration/ops/test_dtype_route_fallback.py + test_silu_backward_dispatch.py 7 + 7 passed FLAGOS_LOG=dispatch: silu -> mps for F.silu and aten.silu ruff check / ruff format --check clean clean-clone codegen_mps.py --check and gen_vendor_confs.py --check clean * feat(mps): serve embedding natively on the MPSGraph gather category embedding was the largest single item left on a small model's fallback profile: measured on an M5, one Qwen3-0.6B layer spent ~47% of its device dispatch time in it, not because a table read is expensive but because the op was routed to none, so every call took cpu_fallback and copied the whole (151936, 1024) weight to the host. One F.embedding call with a (1, 512) index tensor cost 55.3 ms, of which 55.8 ms was that copy, against 0.697 ms for the same read performed entirely on the CPU. The graph side is a single node -- gatherWithUpdatesTensor:indicesTensor:axis:0 batchDimensions:0 -- and reproduces at::embedding bit for bit on all three float widths and both integer index widths. The Qwen3 geometry now measures 0.64 ms. Two things the reference's contract forces on the host are the whole of the kernel's logic, and each is a case where the obvious reading of the reference is wrong: * MPSGraph's gather has no range contract. With a 6-row table and the index list (5, 6, 2, 0) it returns the table's *first row* for the out-of-range index -- likewise for -1 and 100 -- where the reference raises IndexError: index out of range in self. A plausible wrong answer is the one outcome worse than a round-trip, so the body validates the range on the host before any node exists and declines, handing the call to the reference for its own error. The gatherAlongAxis: spelling was measured first and rejected: it requires the indices to carry the operand's rank, and its own out-of-range behaviour is to insert a 0, which would look like a padding row. * padding_idx is **not read by the forward op.** The result is bit-identical for padding_idx -1, -3, -4 and 2, and an out-of-range padding_idx is accepted rather than rejected; the zeroing that name asks for belongs to embedding_dense_backward. The first version of this body assumed otherwise and declined whenever padding_idx >= 0 -- and F.embedding passes padding_idx=None, which arrives at the op as -1 and was read as "count back from the end", so *every* ordinary F.embedding call took the whole-table round-trip the increment existed to remove. That version measured 77-99 ms; without it, 0.25-0.29 ms. padding_idx is not in the graph key either, because two calls that differ only in it produce the same bytes and are one plan. Verified with a new tests/integration/ops/test_mps_embedding.py (37 cases): bit-exact CPU equality on f32/f16/bf16 and int32/int64 indices, the index rank preserved from (1,) to (2, 3, 4, 5), empty and non-contiguous operands, every padding_idx spelling including the None-as--1 the front end passes, the three out-of-range IndexError cases plus the float-index RuntimeError, plan reuse across dtypes and across padding_idx, the dispatch log naming mps for both the F.embedding and aten.embedding spellings, a declined float64 table that never reaches the graph, and a cost case that fails if the whole-table round-trip comes back. embedding.out and embedding_dense_backward stay on the fallback, like every other .out form here. The conf moves to 17/2037 ops. Full tests/integration/ops -m mps is 487 passed; unit conf/gen checks 102 passed; codegen_mps.py --check and gen_vendor_confs.py --check clean; ruff check and format clean. * feat(mps): serve div, div.Scalar and reciprocal on the MPSGraph pointwise category Closes the elementwise arithmetic set that add, sub and mul opened in stage 2. All three ops are served by one graph node, divisionWithPrimaryTensor: secondaryTensor:, and reciprocal too -- reciprocalWithTensor: was measured and rejected on cost, since a * reciprocal(b) and a / b are bit-identical to each other and to the reference over zero and signed-zero denominators, inf, nan and denormals. The increment was chosen from a measurement: on an M5 over a (2, 512, 1024) float32 tensor, div.Scalar cost 0.68-0.95 ms and div.Tensor 1.36-2.66 ms, against 0.24 ms for a device-to-host copy of the same tensor, so the routed path was making three host passes where the kernel makes one. The first version of this body reused MpsSupportsBinaryDtype, the predicate written for mul, and that predicate admits integral widths. That is right for a multiplication and wrong for a division, because aten::div promotes integers to float: measured through the backend, aten::div.Tensor(tensor([-7]), tensor([2])) returned the truncated int64 -3 where the reference returns the float32 -3.5 -- a silently wrong value and dtype. The fix is a dedicated MpsSupportsDivDtype requiring the two dtypes to match and both to be float32, float16 or bfloat16, plus MpsSupportsDivScalarDtype, since div.Scalar reaches the same promotion through MpsSupportsScalarDtype. reciprocal is gated by the division predicate rather than the wider unary one, so x / 2 and 1 / x agree on the widths they claim. One measured difference, shared with the ops routed since stage 2: MPSGraph flushes a subnormal *result* to zero -- 1e-38 / 2 returns 0 where the reference returns 5.0e-39 -- and mul.Tensor and add.Tensor flush identically. The Python operators do not reproduce it, because x / 2.0 is resolved by PyTorch's composite wrapper before PrivateUse1 dispatch and builds no node for the constant; only the opaque aten::div.Scalar and aten::div.Tensor(t, tensor) reach the graph, so both spellings are pinned in the suite. Not served: div.out, the mode spellings (div.Tensor_mode, div.Scalar_mode, div.out_mode, div.Scalar_out, div.Scalar_mode_out), div_.Tensor, div_.Scalar, reciprocal.out and reciprocal_, which need a floor or trunc composition after an exact division. Verified with tests/integration/ops/test_mps_div.py (97 tests): bit-exact CPU equality on all three float widths including the zero and signed-zero sign bits, the inf and nan quadrants, the dtype promotions the reference applies to integers and booleans, the subnormal boundary through both bodies and in both the Python and the opaque spellings, the declined integral, complex and mismatched-dtype pairs, plan reuse, and the dispatch log for the a / b, a / 2.0, 2.0 / a and torch.reciprocal spellings. tests/integration/ops -m mps: 584 passed. The conf is 20 mps ops, generated and checked. * ci(mps): add the Apple MPS integration lane with a container-free config The MPS backend had no CI lane, so the native kernels added over the last four increments (conv, silu, embedding, div) were only ever exercised on a developer machine. This adds the ninth platform to the matrix. .github/configs/mps.yml is the first config with ci_image: "": Apple Silicon runners are the environment, so there is no image to pull and no volumes to mount. The loader previously rejected an unset image, so it now requires the key to be *stated* and to be a string, which admits the empty string. Both workflows that previously hardcoded a container: mapping now build it from ci_image && fromJson(...) || null, so a config with no image runs directly on the runner label while the existing eight lanes are byte-for-byte unchanged by the expression. set_env_mps.sh asserts Darwin/arm64 plus a working Xcode toolchain before building. It reuses the shared install_cpu_torch against PyPI rather than a +cpu mirror (macOS wheels are un-suffixed), and skips FlagTree/FlagGems because PyPI publishes no macOS triton wheel. runtime_conformance names no platform and so already covered MPS; only backend_ops is added, and paths_watch globs tests/integration/ops/test_mps_*.py so a new increment's tests join the lane automatically. * fix(ci): keep pip_retry working on bash 3.2, which is what a macOS runner has pip_retry built its flags in a local -a extra=() and expanded it as "${extra[@]}". macOS ships bash 3.2, where an empty array under set -u raises extra[@]: unbound variable instead of expanding to nothing; Linux bash 4.4 and later treat it as safe, so the eight container lanes never saw it. The first run of the new MPS lane failed there, five retries deep, before pip was ever reached: set_env_common.sh: line 249: extra[@]: unbound variable ##[error]pip install failed after 5 attempts: --upgrade pip The flag is now a scalar held in cache_flag and its two cases have their own pip install call, so the empty case expands no array. The --no-cache-dir value that reaches pip is unchanged, verified against a stub interpreter for both PIP_RETRY_NO_CACHE values. The if also moved off the pipe: "$python_exe" ... | tee reports tee's status, which is 0 even when pip fails, so a nested if would read a failed install as a success and stop retrying. The result is taken from PIPESTATUS[0] instead, which is what the surrounding loop already depended on -- with the if as a pipeline the loop would have exited on the first failure, since the condition only has an effect on the command that forms it. * fix(mps): guard the offset-carrying MPSNDArray initializer on the SDK in force The first CI run of the MPS lane got far enough to compile mps_common.mm and stopped there: mps_common.mm:456:58: error: cannot initialize a parameter of type 'MPSMatrixDescriptor * _Nonnull' with an lvalue of type 'MPSNDArrayDescriptor *' MpsTensorData describes an operand as an MPSNDArray over the MTLBuffer the runtime owns, at the operand's byte offset, because the caching allocator subdivides one buffer into many blocks and a buffer-relative pointer is the common case. initWithBuffer:offset:descriptor: is macOS 15 API and the MetalPerformanceShaders framework in the Xcode 15.4 SDK -- which is what the hosted macos-14 image pins -- declares neither that initializer nor initWithBuffer:descriptor:. With neither declared, the selector resolves to the MPSMatrix initializer of the same name and the compiler rejects the argument. The method-not-found warning on the line above the error is the same fact stated softly: the receiver has no such method to find. The call is now compiled only when the SDK in force can express it (__MAC_OS_X_VERSION_MAX_ALLOWED >= 150000) and taken only when the runtime reports macOS 15 (__builtin_available). Both guards are needed, and the comment records that a runtime-only @available does not compile under the older SDK: the failure is at parse time, not at run time. A nil from MpsTensorData then has two causes -- a zero-element tensor, or an offset the SDK cannot describe -- and both are now a *decline* rather than a TORCH_CHECK. The header already contracts that a nil description sends the op to the CPU round-trip, so an older toolchain gets correct results through the fallback instead of a job that fails to build. The two TORCH_CHECKs that turned a nil into an error were the only thing standing between this and that behaviour; they are replaced by an explicit release-and-return. Verified on the local toolchain, which does have the initializer: interior- pointer operands (a[7:59], byte offset 28) and materialised strided operands still route natively and match the CPU, and the mps integration suite is unchanged at 584 passed. * fix(ci): install the build frontend on the MPS lane before the wheel step The wheel step failed with No module named build.__main__; 'build' is a package and cannot be directly executed. The cause is shared with Kunlunxin: the checkout's own CMake build/ directory shadows a missing build module as a namespace package, so python -m build resolves to the checkout. MPS is a second instance rather than a copy of the first, because this lane's interpreter is a venv the hook creates from scratch, so no prebuilt image could have carried the frontend. Set integration_build_tools, which the workflow already gates the step on, so the pipeline installs it before python -m build --wheel --no-isolation. * fix(mps): build the wheel for one architecture and check that it is that one The macOS lane's wheel came out as macosx-10.13-universal2 with an arm64 payload. The runner's python.org interpreter is a universal2 build, so distutils tags everything it compiles for both architectures and passes -arch arm64 -arch x86_64 to clang, while CMake compiles for the host alone. The mismatch is not merely a tag: ld drops the dylibs it cannot match -- ignoring file 'libtorch_bindings.dylib': found architecture 'arm64', required architecture 'x86_64' -- and torch_fl._C.so is then linked without libtorch_bindings, the library it exists to load. A wheel labelled universal2 that works on one architecture is worse than one that is honestly arm64-only, and Metal only exists on the architecture this lane selects. The hook now pins one architecture for all three build systems involved: ARCHFLAGS (what _osx_support reads, and what overrides the interpreter's per-platform default), _PYTHON_HOST_PLATFORM (what the wheel tag is derived from) and CMAKE_OSX_ARCHITECTURES, all exported to the later steps through export_ci_env. The tag alone would not have caught this, so the check is not about the tag: environment_check.native_extensions_arch reads the architecture out of the loaded torch_fl._C file's Mach-O header -- thin or fat, both byte orders -- and check_platform_environment.py refuses a key it cannot read, so the assertion cannot silently become one that is never run. The parser is held against the fixtures in tests/unit/test_integration_manifest.py and, in passing, against every Mach-O file on the machine it was written on, lipo agreeing on each. * feat(mps): serve index_select on the embedding gather node index_select is the read the embedding body already builds, reached through the op the caller names rather than through the embedding front end, and it removes the same whole-operand round-trip: measured on an M5 over a (151936, 1024) float32 operand the fallback cost 40.4 ms for a single row -- profiled as aten::_to_copy over the 622 MB tensor -- against 0.29 ms for the one gather node, which costs 0.29 ms at 512 rows and 0.78 ms at 4096 because it reads the rows it is asked for rather than the operand. The increment was chosen from a measurement rather than the op list. embedding_dense_backward was the recorded next op and was measured and dropped: its only correct graph is oneHot(N, V)^T @ grad(N, H), whose work is O(V*N) -- 18.8 ms at N=1 against the fallback's 43 ms, but 68.3 ms at N=512 and 243 ms at N=2048 -- and the scatterAlongAxis:withDataTensor: node that would have made it linear enforces indices.shape == updates.shape and then writes updates as a rolling window rather than by row index. Every row-write primitive round-trips as well, so #566 records that measurement and does not ship the op. Unlike the embedding body, this op carries arguments. The body resolves dim against the operand's rank and declines unless it resolves to axis 0, the only axis the single node reaches; an out-of-range dim, an index that is not int32/int64 and an index outside the operand are declined too. Declining keeps the reference's own IndexError/RuntimeError, which is what those checks exist for: MPSGraph's gather answers an out-of-range index with the operand's first row instead of failing. The suite found one more member of that class, which review had not. index_select takes a vector index -- the reference raises "Index is supposed to be a vector" -- but the first version of this body set out_shape[0] = index.numel() and flattened the index buffer for the graph, so a (2, 2) index over a (6, 4) operand came back as a (4, 4) tensor with a silent flatten. An explicit index.dim() != 1 decline fixes it, and the case fails without the guard. Verified on an Apple Silicon arm64 host: codegen_mps.py --check reports 21 kernels, gen_vendor_confs.py --check is up to date, ruff check is clean and ruff format --check covers 402 files. pytest tests/integration/ops -m mps passes 629 (584 before this increment) and the CI unit suite passes 389. backends_mps.conf moves to {mps: 21, none: 2016}, and the operator-support matrix and the design record carry the new route. * fix(mps): move the conf route-count snapshot with index_select test_conf_route_counts_are_stable pins the per-platform route counts so an op that changes route has to be acknowledged. This increment moved mps from 20 to 21 and none from 2017 to 2016, and the snapshot and the category comment both still said 20 -- the codegen job caught it. The op is also added to the per-category routing assertion, which names one op per MPSGraph category so a silently dropped category cannot hide behind the count. * feat(mps): serve take on the embedding gather node take and gather both route to none, and both pay the whole-operand round-trip the read family pays: measured on a (151936, 1024) float32 operand, take of one element costs 45.6 ms and of 512 elements 46.3 ms, and a one-element gather at dim=0 costs 42.0 ms. The cost is the operand, not the question. The node costs 0.29 ms. take shares gatherWithUpdatesTensor:indicesTensor:axis:0 batchDimensions:0 with embedding and index_select, but none of its contract: there is no dim, the operand is read as a flat numel()-length sequence, the result's shape is the index's own shape, the index may have any rank, and a negative index is in range rather than an error. Two measured behaviours decide the body. A negative index is in range and MPSGraph's gather does not resolve one. Measured through the first version of this body -- which range-checked the index and handed the original through, as the index_select body does -- take(t, [-1, 0, 19]) over a 20-element operand returned [0, 0, 19] where the reference returns [19, 0, 19], the graph having read the negative index as the *first* element. The check had admitted the index correctly; the graph then read it wrongly, and the answer was plausible. The range check and the wrap are now one host pass: a negative entry is normalised against numel(), an entry outside [-numel, numel) takes the round-trip and therefore the reference's own IndexError, and the graph is only ever handed a non-negative index. A (1, numel) reshape of the index is rejected by MPSGraph -- error: 'mps.reshape' op the result shape is not compatible with the input shape … (tensor<3x20xf32>) -> tensor<3xf32> -- because the index's runtime shape overrides the static placeholder shape. The node therefore produces (K, 1), (2, 2, 1) and so on, and the shapes were read through MPSGraphTensorData.mpsndarray and MPSNDArray readBytes:strideBytes: rather than from a standalone probe, whose runWithFeeds: answers zeros. The index dtype gate is also narrower than index_select's: measured, aten::take requires a long tensor and raises for int32, short, char, bool and float. Verified with tests/integration/ops/test_mps_take.py (57 cases): bit-exact CPU equality across the index ranks and operand ranks, the operand structure discarded and the index shape becoming the result shape, the (2, 2) index that separates the two siblings' shape rules, negative indices at both ends of the wrap, the int8-width operand, the out-of-range cases in both signs reaching the reference's own IndexError, the int64-only index contract, the empty index, non-contiguous operands and indices, plan reuse across index values and across a declined call, the dispatch log on both the served and the declined path, and a cost case that fails if the whole-operand round-trip returns. Non-vacuity was proven by mutation: dropping the normalisation kills the six negative-index cases and the two plan-reuse cases that use negatives, and widening the index predicate to accept int32 kills exactly the int64-contract case. The conf route counts move to {mps: 22, none: 2015} and the conf consistency snapshot moves with them. * feat(mps): serve gather on the axis gather node gather is the third reader of the MPSGraph gather family and the first whose node is a different one. gatherWithUpdatesTensor: has no axis, and gatherAlongAxis:withUpdatesTensor:indicesTensor:name: carries a shape precondition the op does not: measured, a (4, 5) operand against a (4, 2) index does not raise a deferrable error, it aborts the process with failed assertion 'original module failed verification'. So the body trims the operand to the index's shape on the non-axis dimensions first. That trim is a host narrow rather than the graph's sliceTensor:, because the same indices built as a sliceTensor: answer [0, 10, 0] where the reference answers [0, 10, 15]. It also has to be copied down: narrow returns a view and the runtime reads every operand as a flat numel()-length dense buffer, so the first version of the body answered gather((4, 5), 0, (3, 1)) over arange(20) with [0, 1, 2] -- the operand's first three elements -- where the reference reads [0, 10, 15]. The contiguous() is one line and three mutations prove it load-bearing, alongside the host range check and the one-sided non-axis rule. The node is permissive where the reference is not: a non-axis dimension of the index may be smaller than the operand's but never larger, the axis dimension is free in both directions, and an out-of-range index is answered with an inserted zero rather than failing. Declines cover the index rank (which must equal the operand's, in both directions), the index dtype (int32/int64, unlike take's int64-only), a negative index (which raises here where take wraps it), and an out-of-range dim. The trim's cost on a large parent is a platform property, not this op's: a dim=0 gather over a (151936, 1024) f32 operand costs 0.26 ms with a full-width index and 52 ms with an (8, 16) index, ~48 ms of which is the strided copy alone, while the same gather over an already-trimmed operand is 0.22 ms. A dense op[:8, :16].contiguous(), a bare view * 2 and view.abs() over the same parent all cost ~42-47 ms, so the cost is the parent allocation's size. Recorded in both docs rather than worked around. Verified with tests/integration/ops/test_mps_gather.py (65 cases) plus the full MPS lane (751 passed). * fix(ci): provision the wheel build deps the mps lane needs The MPS lane went red on its first real run, before a single test: the wheel step runs python -m build --wheel --no-isolation, which resolves the [build-system] requirements against the venv rather than provisioning them in a throwaway env, and pyproject.toml asks for setuptools, wheel and cmake>=3.18, none of which a stock macOS runner image has in that venv. ERROR Unmet dependencies (checked against .../torch-fl-mps-integration/bin/python): cmake>=3.18 wanted: >=3.18 found: not installed wheel found: not installed The vendor images happen to ship cmake, which is why the other eight lanes never needed this line; a macOS runner does not, and its Homebrew cmake is outside the venv where --no-isolation will not look for it. The Ascend hook already installs this set for the same reason, so this mirrors it. Reproduced and verified locally in a clean venv with the runner's own Python: before the line the step died with ERROR Unmet dependencies naming cmake and wheel; with it the dependency gate clears and python -m build --wheel --no-isolation produces torch_fl-2.10.0-cp312-cp312-macosx_26_0_arm64.whl. The new unit test is non-vacuous -- it fails when the install line is removed. It reads [build-system].requires from pyproject.toml instead of restating the list, and matches against the hook's install commands rather than its text, because the comment above the line names cmake and wheel too. cmake in particular needs the real check: the repo ships its own top-level cmake/ directory, which Python resolves as a namespace package from the repo root, so an import cmake probe passes in a venv where the distribution is absent and the build later dies at running build_ext with error: [Errno 2] No such file or directory: 'cmake'. * feat(mps): serve native_layer_norm on the MPSGraph normalization node native_layer_norm is the first op here that answers with more than one tensor, and the increment that corrected the design document. That document recorded F.layer_norm as a multi-op increment built from native_batch_norm plus addcmul, measured at 1.50-4.56 ms. The reading came from the CPU fallback, which is what the op was routed to at the time, and it does not describe the graph: MPSGraph answers the whole thing with one node, normalizationWithTensor:meanTensor:varianceTensor: gammaTensor:betaTensor:epsilon:name:, which is exactly (x - mean) / sqrt(var + eps) * gamma + beta. The mean and variance are meanOfTensor:axes: and varianceOfTensor:meanTensor:axes:, both of which already return keepdim -- the shape the reference's mean/rstd carry -- so no reshape is needed and the body builds four nodes for three results. On (2, 512, 1024): 0.958 ms through the fallback, 0.411 ms served. Two measurements decide the body. The variance the node wants is the population variance, checked on an N=3 case whose sample and population answers differ rather than assumed from the selector's name. And the trailing-run precondition is a host check, because MPSGraph will reduce axes the reference does not name: normalized_shape is compared against the input's suffix, and a call that is not the trailing run, is longer than the input, or is rank-0 declines into the reference's own Given normalized_shape=[...], expected input with shape [*, ...]. Two body-level findings came out of this. A weight/bias left on the host -- the ordinary F.layer_norm(x, shape, w, b) spelling -- is moved to the input's device, because a host tensor resolved no Metal buffer and the failure was silent rather than loud: the operand was refused, the refusal took the round trip, and the caller received the *host* tensors with the right values and the wrong device. And this is the one body here whose round trip yields several tensors, so every decline ends in .to(input.device()) over all three results -- the other kernels move one. MpsRunGraphMulti exists for that, with the single-result MpsRunGraph contract left alone so the other kernels cannot be handed a plan that writes somewhere they did not describe. The dtype gate is the reduction family's. An integral input declines, and measured, native_layer_norm on an integer tensor raises LayerNormKernelImpl is not implemented for 'Int' on the CPU as well, so the round trip is what reaches the reference's error rather than a graph computing at the operand's width. An early probe blamed a segfault on native_batch_norm with null statistics; the same call aborts in plain PyTorch on CPU tensors, so it belongs to neither this backend nor this op, and it is recorded in both docs so it is not chased again. Four mutations prove the body load-bearing: the sample-variance node in place of the population one (37 failures), the trailing-suffix check dropped (10), has_weight/has_bias dropped from the graph key (1), and eps dropped from it (3). Verified with tests/integration/ops/test_mps_layer_norm.py (61 cases) plus the full MPS lane (812 passed, was 751) and the unit lane (1335 passed, 187 skipped). * test(mps): let the cat dispatch case skip where the conf routes it to none The MPS integration lane failed on test_cat_dispatch.py::TestCatDispatchLog::test_dispatch_log_flaggems_runtime: AssertionError: cat is routed to 'none' in .../backends_mps.conf The case reads routed_backend("cat"), which raises for an op the conf hands to cpu_fallback -- and MPS writes no vendor cat kernel, so its conf lists cat = none. The docstring already claimed the test follows whatever the platform's conf says; it could not, because the accessor refuses the one answer none can be. routed_backend_or_none is the accessor for exactly this split, and the case now takes the shape mean.dim already takes on kunlunxin: read the route, skip when there is no dispatch line to assert, assert the conf's own value otherwise. The conf is still the oracle, so the property under test -- the runtime honours the table it was given -- is unchanged on the platforms that do route cat. * feat(mps): serve cat and where.self on the MPSGraph select and concat nodes Adds two kernels to the native PrivateUse1 MPS backend, both of which the runtime-conformance traversal (RUNTIME_OPS, which raises rather than skips when one of its ten ops routes to none) requires every platform to serve. Every other platform's conf already routed cat and where.self; only backends_mps.conf had them on none. cat is MPSGraph's variadic concatTensors:dimension:name:. Two shape bugs and one decline bug: * The row-pitch trap. cat([tensor([0., 1.]), tensor([2., 3.])], dim=0) handed to the node as two ranked operands returned all zeros, and an int32 form returned [0, 0, 1, 0, 2, 0] -- the eight-byte row pitch of a four-byte element, because MPSGraph reads a rank-1 input as one row. Every operand now reaches the graph as a two-dimensional (outer, inner) view with the concatenation over the inner axis, so the node is never handed a rank below 2. * inner is read per operand. The first version computed it once from ops[0], and a rank-1 three-operand list of (2,), (2,) and (1,) reshaped the third operand to a shape its one element cannot fill; MPSGraph failed the compiled module and took the process with it rather than declining. * The decline path re-entered the kernel. Written at::cat(tensors, dim) against the caller's list it recursed on a bool list (no MPSGraph element type) until the stack ran out -- 23810 dispatch lines, then SIGSEGV. The reference is now called on host copies built once before any early return. The node also broadcasts non-dim extents the reference rejects and does not promote a mixed list, so the body compares every non-axis extent on the host and round-trips a mixed or float64 list. where.self is selectWithPredicateTensor:truePredicateTensor: falsePredicateTensor:name: wrapped in the reference's three surrounding rules: the condition is narrowed to bool (bool or uint8, every other width declining to the reference's own where expected condition to be a boolean tensor), the two branches are promoted where the node does not promote, and the result's shape is broadcast from all three operands rather than from any one of them. No conf route moves off the native key: FLAGOS_BUILD_BOXING stays off, and the generated conf goes from {mps: 24, none: 2013} to {mps: 26, none: 2011}. * test(flaggems): record the MPS platform in the FlagGems routing tables The FlagGems conformance suite is hardware-agnostic and runs unchanged on every lane, so the macos-14 MPS lane picks it up. But the two route tables in flaggems_caps -- EXPECTED_FLAGGEMS_ROUTES and EXPECTED_SDPA_ROUTE -- carried a row per accelerator and none for mps, so the count test and the SDPA test both failed on the size of a dictionary rather than on any kernel: AssertionError: mps has no measured FlagGems route count AssertionError: mps has no recorded scaled_dot_product_attention route Measured from backends_mps.conf, which is what those tables are keyed to: 0 flaggems routes (MPS names no operator cohort and has no Triton line) and scaled_dot_product_attention = none. No kernel, conf route or FLAGGEMS_PINNED_OFF hold moves; the conf is byte-identical. pytest tests/integration/flaggems -q, torch-fl-mps interpreter: 2 failed, 2 passed, 41 skipped -> 4 passed, 41 skipped FLAGGEMS-CONFORMANCE accel=mps kernels=vendor ops_total=24 ops_routed=0 ops_none=24 * diag(mps): probe the runner-side layer-norm and subnormal numbers The MPS operator group is the only place the native kernels execute, and on the hosted macos-14 runner its process aborts before pytest prints a summary, so the assertion messages behind 32 failures never reach the log. Add a temporary, assertion-free probe step that reports the same shapes as JSON and always exits 0, so the runner's own numbers arrive without turning the pipeline red. Remove the step and tests/integration/ops/_mps_env_probe.py once the runner behaviour is understood. * diag(mps): run the probe per section and ahead of the aborting group The hosted macos-14 runner aborts backend_ops mid-suite, and run_integration_tests.py returns on the first failing group -- so a probe placed after it never ran, and the two previous diagnostic pushes produced nothing. The probe now precedes the group it diagnoses. Its sections also split into one child process each. The unit under test kills the process rather than raising (Fatal Python error: Aborted, no assertion of its own), so a single process that ran every section in sequence would lose the report of every section after the first abort. The driver prints each child's exit signal beside the sections that survived, and reaches the F.layer_norm case that dies last, alone. Two questions the failure list could not answer are now asked directly: which way the subnormal boundary falls (FLUSHED vs KEPT-THE-SUBNORMAL, since the div suite pins the flush and would fail on a runner that keeps it), and what upstream PyTorch's own MPS native_layer_norm does with the same shapes on the same machine -- against the flagos side, that separates a runner whose Metal differs from a graph here that does. Measured on the M5 dev host: all five sections exit 0, upstream MPS agrees with the CPU reference to ~1e-7 on every trailing shape, the graph flushes the subnormals, and the frontend case survives with maxdiff 9.5e-7. * fix(mps): build layer-norm rstd from the macOS 11 nodes, and pin the subnormal policy per GPU Two runner-only failures, both measured on the hosted macos-14 (M1 / macOS 14.8 / Metal 3) lane and invisible on the M5 dev host. **reciprocalSquareRootWithTensor: does not exist before macOS 15.** The layer-norm body built rstd with MPSGraph's fused reciprocal-square-root node, whose header is MPS_AVAILABLE_STARTING(macos(15.0)). On the runner's macOS 14 that makes the selector absent from both the SDK header and the runtime, so it compiles with only an -Wobjc-method-access warning ("return type defaults to id") and then dies at the call: the first case to reach the node terminates the process with NSInvalidArgumentException: -[MPSGraph reciprocalSquareRootWithTensor: name:]: unrecognized selector sent to instance which the lane reported as a SIGABRT with no assertion in front of it. That single selector is why every TestLayerNormCorrectness case failed on the runner while test_empty_input_keeps_the_shape passed (it declines before the node) and the same 61 cases pass on the dev host. rstd is now reciprocal(squareRoot(var + eps)): the macOS 11 spelling of the same arithmetic, with no availability gate, and reciprocalWithTensor: is the node the reciprocal kernel in this file already runs on. **A subnormal result is a Metal feature, not a backend policy.** The div suite asserted the flush unconditionally, but it is the M5 (macOS 26 / Metal 4) that flushes 1e-38 / 2.0 to zero and the M1 (macOS 14 / Metal 3) that returns the subnormal. TestSubnormalBoundaryIsDocumented now measures the running GPU's policy once at import and asserts the documented outcome for whichever it is, so both machines are pinned rather than one hard-coded; a value that is neither the reference nor zero still fails. Verified: 895 MPS op cases pass on the dev host (61 layer-norm, 97 div). * diag(mps): measure the timing cases the runner still fails The abort and the numerics are now understood (previous commit), leaving the three cost classes -- embedding, index_select and gather. They bound a per-call time, and the bound is only meaningful against the machine it ran on: the probe now reports the same measurements it would take, so the runner's numbers can be compared with the dev host's (embedding 1.0 ms, index_select 0.4 ms, both >10x under the 10 ms bound) before any bound is touched. The upstream section is dropped from the run order: it answered its question (upstream MPS layer-norm agrees with the CPU reference to ~1e-7 on the runner too, so the divergence was never Metal's arithmetic) and it OOMs the runner's small MPS pool while walking every shape. * feat(mps): stage operands into offset-0 buffers so macOS 14 toolchains keep the op native The whole MPS operator backend was silently taking the CPU round-trip on the hosted macos-14 runner. MPSGraphTensorData has no notion of an offset into a buffer -- MPSNDArray's initWithBuffer:offset:descriptor: is the only way to describe an interior pointer, and it is macOS 15 API that the runner's Xcode 15.4 SDK (14.5) does not declare. MpsTensorData therefore returned nil for every operand over an interior pointer, RunGraphImpl read that nil as its documented decline, and the op fell through to cpu_fallback: a full device-to-host copy of the operand, a torch host kernel, and a copy of the result back. That is not a rounding difference for the table reads this backend exists for. A 151936x1024 float32 embedding table is 622 MB, and the copy is what turned a 0.7 ms node into the 69-149 ms the MPS cost classes measured -- the 7 timing failures on the runner. Three more were the softmax half-to-float cases, which round-tripped and then hit "softmax with half to float conversion is not supported on CPU". Two halves, both required, and both in mps_common.mm: * ResolveOperand stages an operand whose byteOffset != 0 into a dedicated MTLBuffer at offset 0 when the offset path is unavailable, and RunGraphImpl stages results the same way and copies them back after one DrainAll(). The buffers are pooled by rounded capacity rather than allocated per call, and ClaimedStaging (RAII, covering all six early returns) keeps two operands of one call from sharing a buffer and releases the private ones. * MpsTensorData falls back to initWithMTLBuffer:shape:dataType: for an offset-0 operand when the MPSNDArray initializer is not available. Staging alone was not enough: the staged operand still needed a description, and that is the one other way MPSGraphTensorData accepts a buffer. Verified equivalent to the MPSNDArray path in an isolated probe. The staging pool is keyed only by capacity, so a call's input and result routinely round to the same class -- gather((4, 5), 0, (3, 1)) stages 16 and 24 bytes, both 1 MiB -- and handing both the same buffer made the graph read a zeroed operand (it answered [0, 0, 0] against [0, 10, 15]). That is what ClaimedStaging::Contains is for. Measured on an M5 by reproducing the runner's gate with -D__MAC_OS_X_VERSION_MIN_REQUIRED=140000 -D__MAC_OS_X_VERSION_MAX_ALLOWED=140500 against a normal build: before, exactly the runner's 10 failed / 230 passed, with take over the large operand at 39-56 ms; after, take at 0.35 ms and the whole -m mps operator suite at 895 passed. The normal (macOS 15) build is 895 passed as well, so the offset path is unchanged where it is available. Also drops the temporary mps_env_probe diagnostic now that the runner behaviour is understood. * fix(mps): keep a queued graph's operands and results out of the free pool An op schedules its MPSGraph executable asynchronously and returns, and the caching allocator's block-reuse check (find_free_block) only compares stream pointers -- it never queries the stream. So a block went back to its pool the moment the tensor over it was dropped (free_block, event_count == 0), and the next same-size allocation on the same stream picked it straight back up while the queued graph was still reading the operand or writing the result that lived there. The trigger is a dropped temporary, not a free(): torch.mm(a, b) with an out nobody holds, or a reshaped operand, is enough, which is what the MPS lane's step-3 test_allocator.py::test_matmul hit as a segfault on the runner. Record each operand's and each result's block on the run's stream, the way the CUDA path does, so the block's event_count defers its return to the pool (process_events) and keeps it from being merged (try_merge_blocks) or released (release_cached_blocks) while the work is outstanding. The event is on the same stream the graph runs on, so it is ordered behind the run without adding a wait: the op stays asynchronous (measured 8 ms to schedule against 85 ms to await on a 4096^2 mm) and 5 chained mm calls record exactly the 3 blocks each they touch. * diag(mps): print a native backtrace when an MPS op dies on a fatal signal The MPS lane's step 3 has died with SIGSEGV in TestCachingAllocatorOps::test_matmul on the hosted runner for several runs, and every log so far reports only Python frames: the deepest one is torch_fl/__init__.py's __torch_function__ trampoline, which names the aten call that was in flight and says nothing about where below it the process actually faulted. The runner offers no debugger attachment and produces no core file, so the crash has been reproducible and unreadable at the same time -- which is why three separate local attempts to reproduce it (including the exact wheel the runner builds, installed from a neutral cwd) all came back clean. This adds a signal handler to the MPS runtime that prints a backtrace with backtrace_symbols_fd and then re-raises with the default disposition, so the exit status and the reported failure do not change. It is installed from MetalDevice(), which every MPS path already goes through, and only when FLAGOS_MPS_CRASH_TRACE is set: a printing signal handler can deadlock (the crashing thread may hold the malloc or stdio lock backtrace_symbols needs) and it alters post-crash behaviour, so it is opt-in rather than always on. FLAGOS_MPS_CRASH_TRACE joins the runtime-only names in _env.py's VARIABLES so a deliberate export does not trip the unknown-variable warning, and the MPS hook exports it for integration runs only -- the build stage runs no op, and the hook assigns it unconditionally because export_ci_env reads its names with ${!name} under set -u. Measured locally against the gated (SDK 14.5) build: the handler prints a symbolicated stack with resolved frames and the process still dies from the original signal. * fix(mps): register FLAGOS_MPS_CRASH_TRACE as an owned runtime variable The previous commit put the name in RETIRED, which is the wrong set: RETIRED means "torch_fl used to read this and no longer does", and it is asserted to have no reader in the tree and to match a table in the docs. The name is read -- by the MPS runtime's signal handler, which is where it belongs -- so that failed test_retired_doc_table_matches_the_retired_set, and the platform-agnostic gate stopped the run before the MPS lane was ever dispatched. Registered in VARIABLES with a Runtime scope and an Unset default instead, and the matching row added to the owned-variables table under Runtime diagnostics, which is what test_owned_doc_table_matches_the_registry reads its source of truth from. Both tests pass, and the full unit suite is green apart from the pre-existing collection error in test_cuda_shim_benchmark.py. * diag(mps): report the fault address and frame count, not just the stack The first version of this handler ran on the runner and printed an empty backtrace: the banner and the footer, nothing between them. That is a real result rather than a broken handler -- backtrace() walks the frame-pointer chain, so zero frames means the chain was already gone by the time the signal was delivered, which is the signature of stack damage rather than an ordinary call into a bad pointer. It also leaves the crash address unknown, which was the whole point of printing anything. So the handler now reports what the kernel knows independently of the stack: the signal name, si_addr (the faulting address) and si_code. backtrace() is still attempted and its return value is printed, so "no frames" is stated instead of implied by an empty section. Implemented with sigaction and SA_SIGINFO rather than signal() because si_addr is only delivered through the three-argument form. Verified locally that a deliberate fault now reports fault address: 0x0000000000000001, si_code: 0x0000000000000002 (SEGV_ACCERR), and the frame count, alongside the symbolicated stack. * diag(mps): name the statement a queued graph run dies in The crash trace added for the step-3 segfault came back from the runner with a fault address of 0x0 and zero backtrace frames: the frame chain is gone, so the report says "a null dereference, somewhere" and no more. What it cannot supply is the statement, and static reading of the mm path has not narrowed it -- ResolveOperand, the staging copy, the plan cache and the async run all remain candidates. This adds a statement-level trace to RunGraphImpl instead, gated on FLAGOS_MPS_STAGE_TRACE: a marker is written and flushed before each stage of the run, plus the resolved buffer, offset and operand count per input. The last marker in the log is the stage the process died in, which converts "a null dereference in the graph path" into a single statement without needing a debugger the runner does not offer. Verified against a working local mm: the trace prints all fourteen markers from inputs.begin through record.done and the call returns. Diagnostic only -- reverted once it has answered. * diag(mps): name the operand-array statement a queued run dies in The stage trace narrowed the segfault in the queued-graph run block to the window between the result[i] marker and run.scheduled: every operand and result tensor data is non-nil, the fault address is null, and the frame chain is gone, so it is not a call into a bad pointer. That window is the result loop's tail, the plan->input_order permutation loop that fills the executable's inputs array, and the run call itself. Add markers for each: the finished result array and the length of input_order, then one line per permutation entry carrying the operand index, the operand count and the tensor data the index selects (null when the index is out of range, so a stale cached plan is visible as such), then a marker once the array is built, immediately before the run. Diagnostic only -- reverted once it has answered. * diag(mps): report the faulting pc and walk the frame chain by hand The stage trace put the segfault inside [MPSGraphExecutable runAsyncWithMTLCommandQueue:...] itself: array.built prints, the operand permutation is the identity, every operand and result tensor data is non-nil, and run.scheduled never appears. Everything this code passes to that call is valid, so what is needed is the frame that faulted inside MPSGraph -- and the existing tracer came back with zero frames because the frame chain is gone by the time the handler runs. Read the register file the kernel saved in the ucontext instead and walk the frame-pointer chain by hand from it: the program counter, the link register (arm64) and every return address the chain yields, each as a hex address that atos -o <dylib> -l <slide> can symbolize offline against the same build. A nested fault while walking a destroyed stack re-raises through this same handler and prints a second begin marker, which is itself the evidence that the chain was not walkable. Also print the executable, descriptor, queue and array counts immediately before the run call, so the tracer's frame list and the state it was called with land in the same log. ucontext needs _XOPEN_SOURCE on this SDK (the header is an #error without it). Diagnostic only -- reverted once it has answered. * diag(mps): name the image behind each frame, and dump the load table The pc/lr and the frame walk landed, and they already say the fault is not in this backend: the faulting instruction and the first dozen return addresses are all in the 0x18.../0x19.../0x1e4... ranges that belong to MPSGraph and Metal, and only after that does the chain come back to a torch_fl frame. What the walk does not say is *which* framework each address is in, and the runner has no debugger and no core to ask afterwards. Resolve every frame with dladdr and print the library it lands in next to it, and print the dyld image table with each library's load address below the frames, which is the (library, slide) pair an offline atos -o/-l pass over the same build would need. Both come from the process's own tables rather than from symbolizing after the fact. Diagnostic only -- reverted once it has answered. * diag(mps): bisect which MPSGraph sequence segfaults, one child per candidate The crash trace now says the fault is inside MetalPerformanceShadersGraph: the pc, the link register and the first eight return addresses are all MPSGraph and MPSCore frames, and the backend's own frames appear only above them. Every argument this code hands the run call is printed non-nil immediately before it, so what is left is the state the *second* executable runs in -- and that state is not observable from the process that dies. Run each candidate in its own interpreter: the passing single mm, a tiny mm followed by a differently shaped one (two compiled executables), and the CI shape (the collect-phase mm, then allocation churn, then the mm that dies). A candidate that segfaults kills only its child, so one run says which of the three sequences is the one that dies instead of one hypothesis per CI cycle. Diagnostic only -- deleted once it has answered. * style(mps): drop the unused torch import from the probe The probe's candidate bodies import torch themselves, so the module-level import was unused and the lint gate rejected it before the MPS lane ever ran. Move os up with the other stdlib imports while here. * diag(mps): run the second-executable probe from the step before the crash The probe never ran: the framework segfault takes the whole pytest process down, and it happens in test_allocator.py, which collects before any probe file added at the top level of tests/integration/. A step added to the manifest after that group has the same problem, and a step added to the manifest at all would have to be recorded as a new platform gap in test_the_steps_that_do_not_run_everywhere_are_exactly_the_recorded_ones. Move the probe into the startup-subprocess suite, which the manifest already runs one step *earlier* -- and which already establishes the pattern this needs: every candidate runs in a child interpreter, so the child that segfaults is the only one that dies and the parent still reports the exit status of each. The three candidates separate what changes between the passing mm and the dying one: one executable, two executables, and the CI shape with allocation churn between them. Each is MPS-gated through the platform detector the file already uses for its CUDA-gated tests. Diagnostic only -- deleted once it has answered. * diag(mps): keep both traces on the child probe, and add a bare-add candidate * style(mps): ruff format the trace-keeping probe * diag(mps): bisect the queued schedule against the executable with an awaited run * diag(mps): sweep mm shapes to find the boundary between the working and dying size * fix(mps): specialize the compiled graph at build time, not at encode time macOS 14 dies with a SIGSEGV inside MPSGraph's own convertMPSGraphShapesToMLIRTypes when the first mm of >= 512 elements is encoded: operands resolved, executable compiled, plan cached, then a null field read at offset 0x580 the moment runAsyncWithMTLCommandQueue: is entered. A shape sweep on the runner puts the boundary between 256 and 512 elements, and a bare add is unaffected, so the trigger is the encode-time specialization of the flat-buffer graph rather than bring-up or the op. specializeWithDevice:inputTypes:compilationDescriptor: does that work at build time instead, where a graph this build cannot specialize becomes the CPU round-trip rather than a crash on the first run. * diag(mps): report the host and walk the shape boundary in finer steps The boundary sits between 256 and 512 elements, so the sweep now steps through (32,4,16), (32,8,16) and (32,16,16) as well as (32,16,32) -- 128, 256, 512 and 1024 elements at a fixed M and N -- which separates "the flat count crossed a threshold" from "this shape crossed one". Adds a host report so the OS and architecture of the dying machine are in the log rather than inferred. * diag(mps): drain the queues at process exit, and drain inside the probe child The shape sweep found two deaths, not one. A large shape dies during encode. But (16,16) x (16,16) runs the whole op, prints its sentinel, and only then dies at process exit with no crash trace at all -- that is after the trace handler is gone, so it is a shutdown-order race, not an encode failure. Nothing in torch_fl waits on a committed MTLCommandBuffer: the device, the default queue and the plan cache are all held for the life of the process on purpose. Meanwhile a Metal queue runs in order, so nothing forces the wait either. The exit death is therefore an in-flight command buffer outliving the point at which libmetal starts tearing itself down. The runtime now registers an atexit drain the first time a queue is created, which is the earliest moment committed work can outlive main and still early enough that the Metal frameworks are intact. DrainAll() already existed as the implementation of DeviceSynchronize. A re-entry flag keeps a faulting drain from becoming a crash loop inside exit(). The probe child drains before returning too, so this run separates the two deaths: a shape that died at exit and now exits 0 is the shutdown race, and a shape still dying has an encode-time cause a drain cannot reach. * diag(mps): sweep the reshape boundary with the matmul removed The previous sweep pinned the boundary to the first operand's element count in a way that does not survive reading: (8,8) and (16,16) run clean on 64 and 256 elements while (32,4) dies on 128. The count is not the trigger, and the one thing the dying graph has that the working ones do not is a 2-D reshape over a 128-element buffer. This probe takes the matmul out and exercises the reshape alone across counts that straddle that boundary, each in its own child. If a 2-D reading dies where the same count flat does not, the reshape is the trigger and the matmul node is innocent -- which decides whether the fix belongs in the operand description or in the graph the op builds. * fix(mps): decline the graph path on a device MPSGraph cannot encode for Every macos-14 failure on this branch has the same shape once each child's own stage output is read: the last marker printed is about-to-run, the next is run.scheduled, and the child is gone. The SIGSEGV is inside [executable runAsyncWithMTLCommandQueue:...] itself -- for every shape that fails, including the awaited run and the two-executable case. It is not a size boundary, not a reshape, not a schedule the operator layer builds: it is MPSGraph's encode faulting on the runner's device. That device is the paravirtualized family. The crash trace's image table lists AppleParavirtGPUMetalIOGPUFamily, a bundle this host does not have; mm is correct on every shape here, and the standalone probes that build the exact same graph (flat placeholders, reshape, matrixMultiplication, flatten) run clean. A physical GPU is fine; the runner is not. A fatal signal cannot be unwound from, so there is nothing to recover with at the call site -- which means the only place the answer can be honoured is before the graph exists. MetalGraphUsable() is that capability query: it asks whether this is a device the graph path can encode for, which is a different question from MetalAvailable() ("is there an Apple GPU with unified memory"). The latter is right for allocation and memcpy, which go straight to MTLDevice and a paravirtualized device serves fine; it is the wrong question for MPSGraph, which compiles through MLIR and specializes at encode time. The gate declines on the one family known to fault, detected by the documented MTLDevice.name rather than by an internal framework filename. Declining is the same contract every other decline in this file uses: false sends the op to the CPU round-trip its body already implements -- slow on a host with no working graph compiler, and correct, instead of a crash the process cannot come back from. specializeWithDevice: stays. It is what the SDK documents for moving the work off the encode path, and it is one less thing MPSGraph has to do at the moment that dies; the comment above it no longer claims to fix the macos-14 crash, because it does not. * diag(mps): print the device facts the graph gate decides on The gate is a name test, and the one host it has to trip is a CI runner whose MTLDevice.name has never been read -- there is no Python surface that reports it, and the MPS job's own host report prints the machine, not the GPU. Restructuring MetalGraphUsable to compute the verdict once puts the print on the path that runs either way, so the same line separates "this host is genuinely paravirtualized" from "the name test is matching something that is not". Without it a green MPS suite cannot distinguish "the gate declined and the CPU round-trip carried the op" from "the gate was never consulted", which are very different outcomes to ship. Runs only under FLAGOS_MPS_CRASH_TRACE, so it is off in every normal run. Diagnostic only -- reverted once it has answered. * feat(mps): serve native_batch_norm's inference path from one normalisation node native_batch_norm is the second route here that answers with three tensors and the normalisation a convolutional block calls. It is two different computations behind one schema, and the body branches on training rather than on a tensor. Inference is served: with the running statistics the caller passes, the whole operation is the same single node native_layer_norm uses -- normalizationWithTensor:meanTensor:varianceTensor:gammaTensor:betaTensor: epsilon:name: -- fed the caller's buffers in place of the graph's own reductions. On (2, 3, 4, 5) the served call is the reference's answer to within 1e-7 across f32, f16 and bf16, at every rank the reference accepts. Training is declined, and deliberately: it updates running_mean/running_var in place with an exponential moving average of the unbiased batch variance, and no graph node writes a caller's buffer. A body that served training while dropping the update would leave a model's running statistics frozen and still return a matching forward output, so the forward comparison the other kernels are verified with cannot see the bug. The round-trip carries the update back onto the caller's device tensors. Three contract details were measured against the reference rather than read off the header, and each is a case the obvious reading gets wrong: * save_mean and save_invstd in inference are **empty** (0,) tensors on the input's device and dtype, not the running statistics. The reference answers (out, tensor([]), tensor([])) in inference and two [C] tensors in training. The empties are also kept out of the graph's result list, because an MPSGraph result is described against an N-element buffer and the auto-applied flatten over zero elements is a reshape the graph rejects. * The four statistics operands reach the node reshaped to [1, C, 1, ...]. The node's type inference wants its statistics broadcast *compatible* with the input and a trailing [C] operand is not: measured, it rejects a (2, 3, 4, 5) input against [3] statistics with output type 'tensor<2x3x4x5xf32>' and mean type 'tensor<3xf32>' are not broadcast compatible, which is a process exit rather than a declined build. * momentum is not an operand in inference -- it only weights the running update -- so the graph is a function of eps alone among the host scalars, and a missing affine operand is filled with an all-ones gamma or an all-zeros beta so the operand list stays fixed across a weight-less and a weighted call. An integral input is declined like every other reduction here, and the reference has no answer either (batch_norm is not implemented for Long on the CPU), so the round trip is what raises its own NotImplementedError. The null-statistics shape stays declined: measured, that call aborts in plain PyTorch on CPU tensors, so it belongs to neither this backend nor this op -- but the graph does have the batch statistics behind meanOfTensor:axes: and varianceOfTensor:meanTensor:axes:, so serving it is a graph the body has not grown rather than a node that does not exist, and the test covering it is written to fail once the shape is served. Verified with tests/integration/ops/test_mps_batch_norm.py (38 cases): CPU equality across the inference shapes and all three float widths, the empty statistics, the affine pair in every combination including on the host, the training path's in-place running update, the dtype gate reaching the reference's own error, plan reuse across dtypes, eps, channel counts and the affine substitutes, the dispatch log naming mps, and the cost case. The full tests/integration/ops suite is 1610 passed with the pre-existing failures on conv1d backward, sparse_csr, rng seed and the gather cost flake unchanged. * fix(mps): regenerate the conf header the vendor-conf check reads backends_mps.conf carries a generated coverage header, and native_batch_norm moved a route without it: the file's body said 27 mps routes while the header still said 26/2011. Hand-editing the body skipped gen_vendor_confs.py, which is what writes the header, so the Codegen staleness job caught it on CI. Regenerated with python3 scripts/codegen/gen_vendor_confs.py; both that check and codegen_mps.py --check are now clean. * chore(mps): retire the crash-tracing scaffolding the runner investigation added The macOS-14 lane's segfault was chased with a fatal-signal backtrace handler (FLAGOS_MPS_CRASH_TRACE), a statement-level graph trace (FLAGOS_MPS_STAGE_TRACE) and an inline-run switch (FLAGOS_MPS_SYNC_RUN), plus a set of one-child-per- candidate probe tests. They answered the question they were written for: the death is in MPSGraph's encode path on a paravirtualized GPU, which is what the MetalGraphUsable gate now declines and the shape sweep no longer has to rediscover. Each of those was marked "reverted once it has answered", so this removes them rather than shipping a debug switch for a fault the gate already routes around: the signal handler and its includes, the stage markers threaded through RunGraphImpl, the inline-run switch, the two environment-variable registrations with their documentation, the CI hook that exported them, and the probe tests. Kept, because they are fixes rather than diagnostics: * MetalGraphUsable() and the RunGraphImpl guard -- the gate itself, which is what makes the lane green, and the reason the probes are no longer needed. * specializeWithDevice: at build time in BuildPlan (a3f96ae4). * the shutdown drain registered alongside the first command queue. test_mps_minimal_add_runs_clean stays as the one contract: a fresh process must reach the device, build a queue and encode one graph, so a bring-up regression fails next to the device and queue code instead of inside an operator suite. * fix(mps): widen the softmax decline on the host instead of forwarding half_to_float The softmax bodies routed every decline through at::_softmax(self.cpu(), dim, half_to_float), and the host kernel rejects the flag outright ("softmax with half to float conversion is not supported on CPU"). A declined width therefore raised instead of answering. The gate declines three shapes -- bfloat16 (the graph's bfloat16 path returned denormals on the measured SDK), float64 and the integral/bool operands -- and whatever reaches the decline outside of dim being out of range raised whenever half_to_float was set. Locally the graph absorbed the float16 flag case and hid it; on the CI runner the paravirtualized-GPU gate makes MpsRunGraph return false, so the decline is the *only* path and the flag case failed there. MpsSoftmaxOnHost now owns the round-trip: with the flag clear it is the plain host call, and with the flag set it widens to float and asks for the flag-clear result, which is what the flag means -- compute in float, hand back float -- and what the graph path already does by widening to out_dtype. Both declines call it, so the two paths cannot drift apart. An integral or bool operand still raises, because at::_softmax raises it. Verified: 36 passed in test_mps_softmax.py; the bfloat16/float64 flag path returns float32 exactly equal to the widened CPU reference. * fix(mps): make the cost suites actually wait for the work they time Seven of the ten MPS CI failures were the same defect: every timing loop in the older suites guarded its barrier with if hasattr(torch_fl, "synchronize"), and torch_fl has no such attribute. The runtime exposes the barrier on the extension module only (torch_fl._C._synchronize()), so the guard was always false, nothing synchronised, and the loops timed the *launch* rather than the work. That is invisible on a fast local GPU, where a few microseconds per call is what the assertion expects anyway. On the paravirtualized CI GPU the queue backs up behind the un-synchronised loop and the measurement lands in the fallback's range (47-96 ms against an expected ~0.7 ms), so the assertions fail as if the kernel were slow. test_mps_batch_norm.py and test_mps_layer_norm.py already used the correct symbol and are what these now match: an unconditional torch_fl._C._synchronize() on both sides of the timed region, with a note on why the hasattr guard cannot be repaired. Eighteen occurrences across six files -- cat, embedding, gather, index_select, take, where. Verified: the six files report 287 passed. * docs(mps): correct the op counts and route list on the install and matrix pages Both pages still said twenty ops, which was the count before the layer_norm, where, cat and batch_norm lanes landed, and the installation page listed the test files as of the same point. The tree ships twenty-seven routes and the suite has a file per family, so the pages now name the real ones -- test_mps_index_select.py, test_mps_take.py, test_mps_gather.py, test_mps_layer_norm.py, test_mps_cat.py, test_mps_where.py and test_mps_batch_norm.py, replacing the test_mps_indexing.py that never existed. * test(mps): hold the cost cases by the dispatch log instead of the clock The cost suites asserted that a read was served rather than round-tripped by timing it, and on CI seven of them failed for work that was in fact being served: a gather of a (151936, 1024) operand measured 54 ms against a 10 ms bound where the same call measures 0.3 ms locally, and the trim case measured 115 ms against 74 ms for a copy the platform charges for anyway. The runner is paravirtualized and shared, so a bound loose enough to pass there is too loose to catch the regression it was written for -- and the synchronize fix in the previous commit, which made the loops measure real work, is what moved them into that range. The dispatch log answers the same question exactly. LogDispatch writes one [flagos dispatch] <op> -> <backend> line per call and names the backend the conf routed the op to, so ten reads that were all served log exactly ten -> mps lines. dispatched_to_mps in backend_conf.py runs a snippet in a fresh interpreter with FLAGOS_LOG=dispatch -- it has to be a subprocess, because LogEnabled latches the environment on first import -- and counts those lines. Routing the op away from mps makes the count come back short, which is the regression, and no property of the runner can move it. Six cases across cat, embedding, gather, index_select, take and where. The two gather cases that compared one read against another are folded into the one that was already asking the right question: the trim case's own numbers show a platform charge, not a body charge, and there is no assertion to make about a copy the platform bills for regardless of what the body does. test_a_declined_width_stays_inside_the_body keeps the other half of the contract: a width the gate declines reaches the body and logs the same single line, so the two ways a read can be slow -- the body's own round trip, and a dispatch that never reached the body -- stay distinguishable. Verified: 320 passed across the six files; the full ops suite keeps its 13 pre-existing failures (conv1d backward, rng seed, sparse csr) and gains none. * feat(mps): serve sigmoid natively on the MPSGraph backend sigmoid was reaching the CPU fallback on a (3, 512, 3072) float32 tensor in 3.19 ms, against 0.91 ms for the device-to-host copy that fallback pays once — three host passes where the kernel makes one. It is also the activation silu is composed from, so the two share a graph node. Sharing a node does not mean sharing a dtype gate. Reading the graph's sigmoid out of the routed silu as silu(x)/x and comparing against at::sigmoid in the operand's own dtype over [-40, 40] gives: float32 1.788e-07 abs / 3.091e-07 rel served float16 3.088e-02 abs / 6.2e-02 rel declined bfloat16 8.289e-03 abs / 1.061e-02 rel declined float64 no MPSGraph element type declined float16 is declined because this SDK flushes a subnormal *result* to zero: for 151465 of 400001 samples the left tail falls below float16's smallest normal, while silu survives the same policy because its tail is multiplied by its own subnormal x and lands on the zero the reference also produces. bfloat16 is declined on ordinary rounding at ~3 decimal digits, the same rule that keeps softmax and pow off it. Two corrections the tests forced, recorded rather than papered over. The flush reaches the served width in a point band — sigmoid(-87.0) agrees at 1.6458e-38 both sides, sigmoid(-88.0) is 6.0546e-39 on the reference against exactly 0 on the graph — and is recorded without gating, since a per-call host decision for a value no model evaluates would cost the common case. An integral or bool operand is promoted rather than rejected: at::sigmoid(torch.arange(-2, 3)) returns float32, and a case now holds that dtype and those values. Tests cover CPU equality, shape independence, the saturated tails, both sides of the subnormal band, zero/signed-zero, inf/nan, empty, rank-0 and strided inputs, the declined widths round-tripping at their own dtype, plan reuse and the dispatch log. 28 of 2037 ops are now routed on MPS. * test(mps): hold the sigmoid subnormal band by policy, not by a point boundary The band case pinned where the flush starts as if it were this body's decision. It is the hardware's: measured, the first flushed input is -88.0 on an M5 and -87.5 on CI's paravirtualized part, so the case as written failed the MPS lane on 7366aa95 while passing locally. A point boundary there is a test that fails on the machine it was not measured on. The case now asserts the shape of the policy over a set of points that spans the band: every input whose reference answer is normal agrees exactly, inputs the reference answers zero agree at zero, and the interior may agree or flush but may answer nothing else. What it still holds is the invariant worth holding -- the flush never reaches the normal range -- which is the failure the case exists to catch. * feat(mps): serve exp natively on the MPSGraph backend MPSGraph names the exponential directly (exponentWithTensor:), so the body is the plain unary template and the increment is the width list. This is the first op here whose gate *widens* past a sibling's: exp serves float16 and bfloat16, both of which the bare sigmoid declined. The measurement separates the platform's subnormal policy from the node's arithmetic. sigmoid's float16 decline was a subnormal flush; measured over float16's whole representable curve (400001 points spanning [-30, 11], which reaches the lowest subnormal an exp can produce), the graph is bit-identical to at::exp with zero mismatches -- the same exponential keeps the subnormals the sigmoid node flushed. bfloat16 is likewise bit-identical over [-30, 88], where sigmoid's bfloat16 decline was a 1.061e-02 rounding that an exp does not have. float32 is served on the reading silu's float32 took: 1.897e-07 max relative, two ulp at the top of the range, and against an exact float64 evaluation the graph and the CPU reference sit one ulp either side of the true value. The float32/bfloat16 subnormal flush below about -87.3 is the same policy the div and sigmoid bodies record and is confined to the band where the reference's own answer is subnormal; measured, it never reaches a normal answer. float64 has no MPSGraph element type and an integral/bool operand is promoted by the reference, so both keep the round-trip. Verified with tests/integration/ops/test_mps_exp.py (30 cases). The MPS ops suite passes (992 passed), the conf-consistency snapshot moves {mps: 28, none: 2009} -> {mps: 29, none: 2008}, and the runtime conformance traversal still routes 10/10. * feat(mps): serve log natively on the MPSGraph backend log is the inverse of the sibling exp and the other half of every logit-space composition a model writes by hand -- a log-sum-exp, a temperature-scaled score, a KL term -- and the second-largest elementwise unary left on the fallback profile: measured on an M5 over a (3, 512, 3072) float32 operand, the cpu_fallback round-trip cost 4.51 ms. MPSGraph names it directly (logarithmWithTensor:), so the body is the existing one-node unary template (T_EXP, shared with exp and renamed in its comment to say so) and the increment is the width list plus the *domain*: a logarithm's answer is -inf at 0, nan below 0 and unbounded above, and each of those edges is an answer the graph has to reproduce rather than approach. Measured on an M5 against at::log in the operand's own dtype, over 400001 samples per width and over the domain's edges: float32 129358/400001 samples on a different ulp, max relative 1.291e-07 and max absolute 7.629e-06 -- one ulp. Not bit-identical, and the reading is the one silu's and exp's float32 recorded: both roundings are correct and they differ. float16 3/400001 differ, max relative 6.184e-04, max absolute 3.906e-03 -- one float16 ulp at 6.3. Measured at x = 552.5 the *graph* is the one marginally closer to the exact answer (|graph - exact| = 1.953433e-03 against |cpu - exact| = 1.952817e-03), so this is the same "both are correct" reading rather than a graph error. Served, and worth spelling out because sigmoid declined this width: the flush that cost the sibling its place does not reach this node's arithmetic. bfloat16 bit-identical, zero mismatches. Every domain edge reproduces the reference bit for bit -- log(0) = -inf, log(-0.0) = -inf, log(1) = 0.0, log(-1) = nan, log(-5) = nan, log(inf) = inf, log(-inf) = nan, log(nan) = nan -- which is what makes this increment's finding a property of the *input* rather than of the magnitude. The one divergence is the platform's subnormal policy: float32 flushes a subnormal *input* to zero before the node evaluates, and since log(0) is -inf, a positive input below the smallest normal comes back -inf where the reference answers a finite (and large) number. Measured, the boundary sits exactly at the smallest normal float32: 1.1754944e-38 agrees with the reference while 1.17e-38 flushes to -inf. That is the same accepted policy the sigmoid, div, silu and exp bodies record, with the band on the input side, and measured it never reaches a normal input. float16 is the contrast that keeps the flush from being a blanket rule: measured, it does *not* flush subnormal inputs -- log(5.96e-8) = -16.640625, exact -- so the boundary is a float32 property of the platform rather than one the gate can generalise across widths. float64 has no MPSGraph element type and an integral or bool operand is promoted by the reference to float32, so both keep the round-trip, which is where the promotion rule lives. Route delta: mps 29 -> 30, none 2008 -> 2007, and the op is claimed on PrivateUse1, not left to cpu_fallback. Verified with tests/integration/ops/test_mps_log.py (33 cases): CPU equality at the three served widths across shapes including the 20-byte and 16-byte-aligned rows, a rank-0 and an empty operand and a strided one, bfloat16 held bit-exact, the float16 sweep and the explicit one-ulp neighbourhood of 1.0 held against the reference so a body that followed the sigmoid gate would fail, a small positive operand near 1 asserted with both sides' sign so a flushed result shows, the saturated tails held with log(1) asserted exactly 0, the domain's eight edges asserted exactly rather than under a tolerance, the two ends of the float32 subnormal-input band pinned on both sides, a walk down the normals asserting the flush never reaches one, the float16 subnormal inputs asserted finite, the one declined width asserting the round-trip's own dtype and bit-exactness with the domain's edges intact, the integral and bool promotions asserted at float32 including the log(0) = -inf edge, plan reuse across interleaved signatures, and the dispatch log naming mps on the served path and on a declined one. Local: pytest tests/integration/ops -m mps 1025 passed, 1489 deselected; the unit set 166 passed; pytest tests/integration/runtime 32 passed with ops_routed=10 ops_none=0; ruff check . and ruff format --check . clean; codegen_mps.py --check and gen_vendor_confs.py --check both up to date. * feat(mps): serve rsqrt natively on the MPSGraph backend rsqrt is 1 / sqrt(x), the tail every normalisation is written in terms of: x * rsqrt(v + eps) is a layer norm, an RMS norm and a batch norm, and the 1 / sqrt(d) attention scale is the same shape. On the MPS conf it was routed to none, so every one of those calls took the cpu_fallback round-trip: measured on an M5 over a (3, 512, 3072) float32 operand, 3.703 ms against a 1.03 ms device-to-host copy floor for the same shape. MPSGraph names the fused reciprocalSquareRootWithTensor:, but that selector is macOS 15+ and the hosted MPS lane is a macos-14 image: NSInvalidArgumentException: -[MPSGraph reciprocalSquareRootWithTensor:name:]: unrecognized selector raised from inside the graph-building block with no handler, so the process aborts rather than the op declining. The body therefore composes the two selectors both SDKs have and this tree already uses -- squareRootWithTensor: (the sqrt node) and reciprocalWithTensor: (the reciprocal kernel's node and layer_norm's rstd) -- which is the same arithmetic at one extra node, amortised over every call that shares the plan. This is the first body here built from two nodes, so the gate is its own predicate rather than a reuse of a sibling's: two subnormal policies apply where exp and log had one each. Measured on an M5 against at::rsqrt: width samples different max rel max abs farther-from-exact float32 400001 0 - - 0 float16 400001 115782 9.756e-04 1.563e-02 0 bfloat16 400001 122658 7.752e-03 1.250e-01 0 ("farther-from-exact" ranks each side against 1 / sqrt(x) in float64: the graph is closer than the CPU reference on every sample that differs and far from it on none, so both roundings are correct and the graph's is the better one -- the same "both are correct" reading log's float16 recorded, at a far larger share.) The finding has three parts, and the order they were settled in is the reason the gate is a predicate of its own: * The result half a composition was expected to risk -- a reciprocal whose output is subnormal, the band sigmoid declined float16 for -- turns out to be unreachable. rsqrt is subnormal at float32 or bfloat16 only for an input beyond the largest representable value (the boundary is above 3.4e38), and at float16 the curve saturates at rsqrt(inf) = 0 before it goes subnormal. No width is declined for it. * The input half is the log finding unchanged: float32 flushes a subnormal *input* to zero before the sqrt and rsqrt(0) is inf, so an input below the smallest normal comes back inf where the reference answers a finite and large number. Measured, the boundary sits exactly at 1.1754944e-38 (agrees) against 1.1754942e-38 (flushes). float16 does *not* flush: rsqrt(3.051758e-05) = 181.0, exact. * The domain's edges are reproduced exactly at all three widths, the signed zero included: rsqrt(0) = inf, rsqrt(-0.0) = -inf, rsqrt(1) = 1, rsqrt(4) = 0.5, rsqrt(-1) = nan, rsqrt(inf) = 0, rsqrt(nan) = nan. The composition reaches -inf through sqrt(-0.0) = -0.0 and reciprocal(-0.0) = -inf rather than through a fused node, and that is the edge a naive port gets wrong. float64 has no MPSGraph element type and an integral or bool operand is promoted by the reference to float32, so both keep the round-trip, measured to reproduce the reference including the rsqrt(0) = inf edge. Verified with tests/integration/ops/test_mps_rsqrt.py (34 cases): CPU equality at the three served widths across shapes including the 20-byte and 16-byte-aligned rows, a rank-0 and an empty operand and a strided one, float32 held bit-exact, the two wide-difference widths held to two eps *and* ranked so the graph is asserted never to round farther from exact than the reference, the middle of the curve held as equality, the domain's eight edges asserted exactly at every served width, the two ends of the float32 subnormal-input band pinned on both sides, a walk down the normals asserting the flush never reaches one, the float16 subnormal inputs asserted finite, the top of each width's range swept so the result-side band is asserted still unreachable, the declined width asserting the round-trip's own dtype and bit-exactness, the integral and bool promotions asserted at float32, plan reuse across interleaved signatures, and the dispatch log naming mps on the served path and on a declined one. Local run: pytest tests/integration/ops -m mps -> 1059 passed, 1489 deselected (1025 before this increment + 34); tests/integration/runtime -> ops_routed=10 ops_none=0, 32 passed; the four unit manifests -> 166 passed; codegen_mps.py --check and gen_vendor_confs.py --check clean; ruff check and ruff format --check clean. * test(mps): keep the rsqrt rounding invariant CI-safe The low-precision sweep asserted that the graph and the CPU reference part company somewhere, which reads as a real difference on the M5 used for the measurement and is false on the hosted macOS 14 paravirtualized GPU, where the two agree on every sample of the sweep. Requiring a difference asserts the host, not the backend. Keep the invariant that is stable across both: the graph never answers a value farther from exact than the reference does. Assert the width on the input side instead, since float16 saturates the top of the sweep to inf -- rsqrt(inf) = 0 is the domain edge above, not a rounding under test. * feat(mps): serve sqrt natively on the MPSGraph backend Add sqrt to the MPSGraph operator layer as the single node squareRootWithTensor:, the other half of the composition rsqrt's body already builds. The route moves from none to mps: the conf census goes {mps: 31, none: 2006} -> {mps: 32, none: 2005} and the codegen emits 32 kernels. Measured on an M5, all three served widths (float32, float16, bfloat16) are bit-identical to the CPU reference over 400001 logspace samples, 200001 per-width samples over the positive band and every domain edge. The edges are exact, the signed zero included: sqrt(-0.0) = -0.0, because there is no reciprocal here to cancel the sign the way rsqrt does. The float32 and bfloat16 subnormal-input flush is the same boundary log and rsqrt record (1.1754944e-38 agrees, 1.1754942e-38 flushes), but it reads differently: sqrt(0) = 0, inside the representable range, where rsqrt reaches inf through its reciprocal. float16 does not flush and all 1023 positive float16 subnormals are bit-identical. A negative subnormal is a wrong-kind boundary -- -0.0 where the reference answers nan -- while a negative at normal magnitude correctly reaches nan. float64 and integral/bool operands keep the round-trip; a negative integral operand is nan, the reference's answer rather than an error. tests/integration/ops/test_mps_sqrt.py (34 cases) asserts only machine-stable invariants: bit-exactness at the three widths, the edges exactly, both ends of the subnormal band pinned so a widening flush fails, and the declined-width, promotion, plan-reuse and dispatch-log contracts the sibling suites share. * feat(mps): serve tanh natively on the MPSGraph backend tanh is the activation a transformer block reaches for at both ends -- the tanh approximation inside a GELU and the one inside a recurrent or attention gate -- and MPSGraph names it directly as tanhWithTensor:. The body is the shared one-node unary template plus one NaN guard, and both the guard and the dtype gate are this increment's findings. The guard is the first on this backend that is not a dtype gate. Measured at all three float widths, the bare node answers tanh(nan) = 1.0 -- it saturates a NaN operand rather than propagating it -- where the reference answers nan, so isNaNWithTensor: selects a NaN back over the node's answer. Without it the body would be a saturating approximation rather than a tanh. The gate is the first here to decline the *widest* float. float32 is not served, because the node is a coarser approximation of a tanh at that width rather than a differently-rounded exact one: it breaks odd symmetry by 1.788e-07 where the reference is exactly symmetric, its error against the exact (float64) answer is ~100x the reference's at every scale of the curve, and 41% of a 400001-sample sweep of [-10, 10] comes back a different float32 value, up to a visibly different number inside the saturation band. On a randn(1000000) operand 545055 samples differ. Serving that would replace the reference's answer with a coarser one on a majority of the values the most-called activation in a transformer block is handed, so the float32 caller keeps the cpu_fallback round-trip -- a real ~2x left on the table at (3, 512, 3072) float32 (3.168 ms node against 5.394 ms CPU + 1.253 ms copy), taken because the number it would buy is not the reference's. The two narrow widths are served because the node *is* the reference's function there: over the same sweep the graph and the reference have the same max error against the exact tanh to the digit (4.3226e-04 float16, 3.3111e-03 bfloat16), they differ on 5 and 1 samples of 400001 within a few of the width's own steps, and max |g(x) + g(-x)| is exactly 0.0 at both. Two deviations survive and are recorded rather than hidden: bfloat16 flushes a subnormal operand (all 127 it can represent; float16's 1023 do not flush), and both widths answer +0.0 for tanh(-0.0) where the reference answers -0.0. Verified with tests/integration/ops/test_mps_tanh.py (42 cases) -- the NaN guard at every served width, the odd-symmetry invariant asserted exactly, the saturation edges (tanh(+-inf) = +-1 and a near-bound -7.0), the bfloat16 subnormal band pinned at both ends with float16's subnormals asserted bit-identical, the signed-zero miss asserted as a miss, float32 bit-exact over the whole curve (the case that fails if the gate is widened), the integral and bool promotions, plan reuse and the dispatch log. 1135 MPS integration cases pass; ruff check and ruff format --check are clean, codegen and the vendor-conf generator are up to date, and tests/unit is at its pre-existing macOS baseline (8 failed / 10 errors, all in the triton/MUSA/DCU tooling tests that are absent on this host and identical with the change stashed). * test(mps): hold tanh's host-dependent edges to what both hosts share The first CI run of the tanh increment failed its own suite on three cases, and the failure is the increment's most useful finding: the two edges the suite had pinned as invariants are properties of the *host*, not of the body. On the M5 the served widths answer +0.0 for tanh(-0.0) and flush the whole bfloat16 subnormal band to zero; the macos-14 runner CI uses answers the reference's -0.0 and reproduces the reference's subnormals exactly. Neither reading is stable enough to assert, so the cases now hold the part both hosts share -- a subnormal operand answers the reference or a flushed zero and never a third value, the band's far edge is the reference bit for bit, the signed zero is a zero with the sign preserved at every nonzero operand -- and the two host-dependent readings are recorded in MpsSupportsTanhDtype, in the suite's docstring and in the design doc instead of being pinned. The assertions were checked against both readings: the suite passes on the M5, and the reference's own answers satisfy every new assertion the CI runner would produce. 1135 MPS integration cases pass; ruff check and ruff format --check are clean and the codegen and vendor-conf generators are up to date. * feat(mps): serve erf natively on the MPSGraph backend erf is the increment written as the deliberate inverse of the one before it, and MPSGraph names it directly as erfWithTensor:. tanh needed a per-element isNaNWithTensor: guard because the bare node saturates a NaN operand to 1.0; the erf node propagates NaN on its own, so the body is the shared one-node unary template with no guard at all. The gate is the opposite of tanh's. tanh declined the widest float because its node is a coarser approximation of the exact curve there -- its float32 error against exact is roughly 100x the reference's at every scale. erf's node is not that. Over a [-6, 6] 400001-sample sweep the node's error against the exact (float64) erf tracks the reference's, and on the band where the two answers differ the node is the closer of the two: over [-3, 3] it is nearer exact than the reference on 242217 of 400001 samples, and |node_err| <= |ref_err| holds on at least 40% of the whole [-6, 6] range. Both roundings are correct, so all three float widths are served and the width list is the inverse of tanh's. What the widths differ in is where they differ. float16 and bfloat16 are bit-identical to at::erf on 399878 and 399994 of 400001 samples; the handful that move are a step or two of the width's own spacing. float32 differs on 53% of the sweep, and the raw distance in floats is not a bound a test can hold: the ratio of the graph-versus-reference gap to the reference's own step grows to 5.0 for |ref| >= 0.5 (and beyond that as the result approaches zero, where the reference's spacing is denormal), against 1.0 at float16 and 0.0 at bfloat16. The suite therefore holds the middle of the curve to a banded bound plus an absolute floor and asserts the ranking -- node never rounds farther from exact than the reference -- rather than pinning the host's rounding. Two edges read exactly as sqrt's: erf(+-inf) = +-1, the curve saturates to exactly +-1 from |x| >= 4 (the band where step counts explode is also the band where both answers are the same representable value), and signed zero survives the node. The operand-side band is recorded rather than pinned, the tanh finding read again: bfloat16 subnormals round to 0 on both the node and the reference while float16 differs from the reference on 877 of its 1023 positive subnormals by one or two steps, so the suite asserts only the reading that survives both hosts. The increment keeps the no-boxing property: it is one more entry in the same codegen_mps.py template tables, the generated kernels and registration, and an erf = mps route, with no conf route moving off the native PrivateUse1 key. Verified on an M5 (34 kernels in codegen_mps.py; 45/45 cases green). tests/integration/ops/test_mps_erf.py holds the 45 cases: CPU equality at the three served widths across shapes including the 20-byte and 16-byte-aligned rows, a rank-0 and an empty operand and a strided one, bit-exactness asserted where the widths can hold it, the float32 band held to the two-eps band and the exact-vs-graph ranking, the nan-propagation case that would fail if a tanh-style guard were ever bolted on, the signed zero pinned by sign bit at every served width, the saturation edges and the |x| >= 4 plateau asserted exactly, the bfloat16 subnormal band held to the reference's answer or a flushed zero with the cone at the smallest normal bit-exact, the float16 subnormal band asserted to the reference's value or a flushed zero, the declined float64 asserting the round-trip's own dtype, the integral and bool promotions asserted at float32, plan reuse across interleaved signatures, and the dispatch log naming mps on the served path and on a declined one. * test(mps): verify erf across the widths the node and the reference agree on The erf increment serves all three float widths, which makes its suite a different shape from tanh's: instead of holding two narrow widths to a handful of samples and asserting a decline at the third, it has to keep float32 honest without pinning the host's rounding. The measurement that dictated the suite is that raw step counts are not an invariant at float32. The ratio of the graph-versus-reference gap to the reference's own representable step grows to 5.0 for |ref| >= 0.5, against 1.0 at float16 and 0.0 at bfloat16, and it stops meaning anything near zero where the reference's spacing is denormal. So the float32 band is held to a banded bound plus an absolute floor, and the reading that is an invariant is asserted instead: ranked against the exact (float64) erf the node is never farther from the true curve than the reference, and over [-3, 3] it is the closer of the two on 242217 of 400001 samples. The rest of the cases are the edges the increment measured. erf(+-inf) = +-1 and the |x| >= 4 plateau are asserted exactly because both answers are the same representable +-1 there; the saturating band is also where the step counts explode, so asserting the plateau exactly is what proves the explosion is not an error. NaN propagates through the unguarded node, which is the case that would fail if the tanh-style isNaNWithTensor: select were ever bolted on. Signed zero survives the node at every served width, pinned by sign bit rather than under a tolerance. The one band that is recorded rather than pinned is the operand side, and it is the tanh lesson read again: bfloat16 subnormals round to 0 on both the node and the reference, while float16 differs from the reference on 877 of its 1023 positive subnormals by one or two steps. A bfloat16 assertion of "reference or flushed zero" and a float16 assertion of "reference or flushed zero, with the cone at the smallest normal bit-exact" are what survives both an M5 and the macos-14 CI runner. 45 cases; the full MPS integration suite is 1180 passed with 1489 deselected, ruff check and ruff format --check are clean, and the codegen and vendor-conf generators report nothing to regenerate. * feat(mps): serve erf natively on the MPSGraph backend erf is the kernel in the explicit GELU form 0.5 * x * (1 + erf(x / sqrt(2))), and MPSGraph names it directly as erfWithTensor:. The erf node propagates NaN on its own, unlike the sibling tanh node, so the body is the shared one-node unary template with no guard. All three float widths are served on a measured error bound. On an M5 probe grid of 498299 operands -- a 400001-point dense sweep of [-6, 6] plus three interleaved points in each of 16384 cells over [0, 9] and their negatives -- float32 agrees with at::erf bit for bit on 249310 points, with a largest gap of 4.19 steps at 1.0. Ranked against the exact float64 curve, the node's error reaches 1.64 steps at 1.0 against the reference's 4.14, and the node is closer on 63.3% of the points where they differ. Where the reference error exceeds half a step, the node's error can exceed it by at most 0.98 of a step. That bounded comparison keeps float32 rather than inheriting tanh's stricter decline. At float16 and bfloat16 the node agrees with at::erf on 498160 and 498290 of the probe-grid points, and their largest gaps are 0.5 and 0.0039 of a step at 1.0. The few differences beyond the dense sweep are below one step, so the increment does not claim bit equality outside that sweep. The domain edges need no guard: NaN propagates, +-inf maps to +-1, both widths return exactly +-1 from |x| >= 4, and the sign of zero survives. The narrow widths' subnormal band remains host-dependent. The macos-14 CI runner flushed two bfloat16 results, erf(tiny) and erf(2 * tiny), to zero where the M5 kept them subnormal. That band is recorded rather than pinned in the implementation; the tests check its shape and require nothing in the normal range to flush. The increment keeps the no-boxing property: it is one more entry in the same codegen_mps.py template tables and an erf = mps route, with the native PrivateUse1 key unchanged. F.gelu's default path does not dispatch this erf kernel, so gelu remains a separate increment. Codegen reports 34 kernels up to date, vendor confs are up to date, and the 45 erf integration cases pass on an M5 in the torch-fl-mps environment. * test(mps): verify erf across the widths and hosts it serves The erf increment serves all three float widths, so its suite keeps float32 honest without pinning either implementation's rounding. A dense linspace is not enough: it lands on float32 operands whose reference answers are exact. The measurement cases therefore add interleaved probes over [0, 9] and compare the float32 node and reference with the exact float64 curve. At the narrow widths the node is closer to exact wherever it differs. At float32 it is closer on 63.3% of differing probes; where the reference error is above half a step, the node's excess is bounded by one step. The test asserts a 0.98-step excess bound, a material sample count, and the measured maximum errors of 1.64 and 4.14 steps. The curve-gap cases use a 0.5-step bound at float16 and bfloat16 and 4.5 at float32. The domain edges remain exact: NaN propagates without the tanh-style guard, +-inf maps to +-1, both widths return exactly +-1 from |x| >= 4, and signed zero is checked by sign bit. The subnormal band records both hosts: the M5 kept erf(tiny) and erf(2 * tiny) as bfloat16 subnormals, while macos-14 CI flushed them to zero. The test requires a monotonic shape, checks that a flushed answer replaces only a subnormal reference, and rejects any flush at or above the smallest normal. The suite also covers CPU equality across shapes, empty, rank-0 and strided operands, oddness, float64 and integral/bool round-trips, plan reuse, and dispatch naming mps. It has 45 cases and passes on an M5. Full MPS integration was 1180 passed with 1489 deselected before the final measurement-bound edits; the targeted suite was rerun after those edits. ruff check, ruff format --check, the 34-kernel codegen check, and vendor-conf consistency pass. * test(ci): read the container job's options from the workflow * test(ci): cover the container-free macOS job spelling * test(ci): skip field checks on a container expression * test(ci): assert the container expression forwards every field * test(mps): keep erf accuracy checks across Metal versions * test(mps): hold erf to the reference across MPSGraph versions The macos-14 runner's MPSGraph reports a 4.14-step float32 node error against the exact erf curve, beyond the two-step bound that held on the M5. Keep the five-step gap from at::erf that both hosts satisfy, and verify the float32 call dispatches through MPS rather than a CPU round trip. Record the older runner's reading and remove the superseded exact-curve claims from the comments and design document. * test(mps): keep shared test collection independent of numpy * test(mps): run numpy-free collection guard in units * test(mps): keep the numpy guard independent of torch * test: keep DCU environment checks independent of the checkout * fix(mps): keep torch_fl opt-in for MPS coexistence * test(mps): skip overlay state checks without PyTorch * test: isolate MUSA script probes from build packages * test: keep MUSA script probes free of host packages * test(mps): verify forward before the upstream backward gate * fix(aten): make set source tensor update metadata directly * feat(mps): support tensor transfers to flagos * test(mps): format runtime transfer checks * feat(mps): gather strided copies on the device * test(factory): avoid extra device parameter references * test(factory): release device tensors before cpu migration * ci: reject unusable model mounts before integration tests * test(factory): avoid parameter references during cpu transfer * docs(ci): remove stale DCU timeout comparison * ci(mps): run the MPS runtime contract * ci(mps): lift the hosted runner memory ceiling * docs(mps): link the upstream autograd limitation * ci(mps): persist the test allocator headroom on bash 3.2 * ci(mps): keep the low watermark below the test ceiling * feat(mps): serve conv1d through the native convolution graph * docs(mps): record native conv1d and test headroom * fix(mps): finish device work before interpreter teardown Drain Metal work and release cached MTLBuffers from a Python atexit hook, while torch's storages and the runtime are still intact. Reject late allocations after the allocator has shut down, and drain again before final device buffers disappear. Restore the MPS test lane's verified allocator limit so torch's shared pool can allocate beyond the hosted runner's 3.27 GiB ceiling, while keeping the low watermark below the high one. * fix(mps): preserve the framework Metal wrapper in CI * style(mps): format environment runner tests * fix(mps): select the working Metal wrapper on macOS 14 * fix(mps): run the MPS lane on the current macOS image * fix(mps): prepare the wheel before probing torch_fl The MPS environment hook imported torch_fl before the wheel was built. On the macOS 15 runner the PyTorch MPS allocation probe passes, then that import fails because torch_fl._C does not exist yet. Remove the duplicate pre-wheel probe; the environment check and MPS runtime suite cover device visibility and MPS-to-flagos copies after installation. Also record the resolved macOS 14 findings in the code comments and docs. * fix(mps): keep generated convolution comments current The macOS wording was updated in the generated layer-norm kernel but not in its codegen template, so the codegen staleness check rejected the tree. Update the template and regenerate the committed output. * feat(mps): serve silu backward natively * test(mps): record silu backward and its coexistence boundary Document the derivative graph, the served widths and the fallback contract alongside the native forward. Exercise the autograd dispatch case through an isolated installed backend so PyTorch 2.10's multi-accelerator engine precondition is visible as the process boundary it is. Keep the MPS routing roster and its explanation current. * fix(test): retire the wired MPS runtime exclusion --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Codex <codex@flagos.ai> Co-authored-by: codex <codex@local> | 18 days ago | |
chore: add Apache 2.0 LICENSE (#16) This PR was written in part with the assistance of generative AI. | 3 months ago | |
docs: keep operator-support.md to per-platform counts and record the dependency-source rule (#641) docs/reference/operator-support.md had grown to ~1.1 MB / 11.7k lines and was required to change on every operator PR. Keep only the per-platform support counts, read from the shipped routing confs, and drop the raw case evidence and route-change history. AGENTS.md now records that an operator change does not need to touch that page, and that FlagTree, FlagGems and FlagCX are consumed as published wheels from https://resource.flagos.net/, built from source only on explicit request. The AI reviewer prompt no longer asks for the report on every operator change. Co-authored-by: Claude Code <noreply@anthropic.com> | 16 days ago | |
feat: profile TsingMicro NPU kernels through rocprofiler-sdk (#630) * feat: profile TsingMicro NPU kernels through rocprofiler-sdk torch.profiler had no device timeline on TX8: the only tracer selected for this platform was the unavailable stub, so a trace showed the CPU ops and nothing else. Collect kernel dispatches with Kuiper's rocprofiler-sdk. The collection lives in a separate library, libtorch_fl_profiler.so, because rocprofiler dlopens its tools during its own initialization while libtorch_fl.so pulls in libflagos.so -> libhpgr.so; the tool links only tsmprofiler-sdk. The tracer half (csrc/profiler/tsingmicro_device_tracer.cc) drives it through a small C API, pairs each kernel with the txLaunchKernel window that launched it, and translates the dispatch records into DeviceEvents for the kineto adaptor. The trace follows the CUDA layout: the device row is named after the process, labelled "<TYPE> <device id>", and sorted below the CPU tracks; runtime slices sit on the CPU thread that issued them with the ac2g arrow starting there, and kernels carry the launch grid/block arguments. Running it needs TSM_PROFILER_EN=1, the tsmprofiler libraries on LD_PRELOAD and ROCP_TOOL_LIBRARIES naming the tool; docs/vendors/tsingmicro/ installation.md documents the setup. Without them the trace simply has no device timeline, and the tracer warns once when a session starts. Verified on a TX8110: a 30x mm/add workload produces 60 kernels and 60 ac2g pairs, self_device_time_total is 14.5ms and matches the summed kernel durations, and the exported chrome trace opens in Perfetto. pytest tests/unit/test_device_tracer_selection.py reports 28 passed; ruff check and ruff format --check pass. Co-Authored-By: Claude Code <noreply@anthropic.com> * build: pin TsingMicro's vendor kernel build on csrc/aten/backends/tsingmicro/rng.cc holds the host-draw RNG kernels, and csrc/aten/register.cc claims those symbols unconditionally under USE_TSINGMICRO. FLAGOS_BUILD_VENDOR=OFF therefore excluded the file that defines them and failed at link time rather than at configure time. Pin the switch ON in cmake/flagos_platforms.json so an explicit -D or environment value of 0 is rejected with the reason, matching the pin mechanism the other vendor platforms already use. Update the stale "no vendor backend directory" descriptions in csrc/CMakeLists.txt, torch_fl/_env.py and docs/reference/environment-variables.md: tsingmicro does ship a directory, it contains only the RNG kernels. Verified: cmake -DFLAGOS_BUILD_VENDOR=OFF with FLAGOS_ACCELERATOR=tsingmicro now fails at configure with "-DFLAGOS_BUILD_VENDOR=OFF is not possible with FLAGOS_ACCELERATOR=tsingmicro"; pytest test_setup_build.py test_env_registry.py test_gen_vendor_confs.py test_platform_capability_matrix.py reports 111 passed; ruff check and ruff format --check pass. Co-Authored-By: Claude Code <noreply@anthropic.com> * docs: document how to install and run torch_fl on TsingMicro README.md, README_zh.md and docs/getting-started/installation.md still described TsingMicro as "Runtime only" with no documented setup. That is out of date: the default FlagGems-primary build runs eager ops on a TX8110, the RNG family is served by host-draw kernels, and the Qwen-Image-2.1 RoPE shim keeps complex64 off the device. Add docs/vendors/tsingmicro/installation.md with prerequisites (Kuiper SDK, CPU torch 2.10.x, FlagGems 5.4.0, Triton >= 3.5.1), the build command, the kernel-switch defaults, runtime library paths, a verification script and the known limitations. Update the three platform tables and the TsingMicro section of docs/reference/compatibility.md to match. The compatibility section previously said the default build had not been re-measured on hardware. It now records the 2026-10-21 TX8110 smoke run (import, device enumeration, randn, mean, unsqueeze, erfc, cat) and keeps the 2026-Q3 per-operator split as the last correctness survey. Verified: pytest test_setup_build.py test_env_registry.py test_gen_vendor_confs.py test_platform_capability_matrix.py reports 111 passed after these edits; codegen_boxing_flaggems_register.py --check reports all five register lists up to date; the TX8110 smoke test above passes. Co-Authored-By: Claude Code <noreply@anthropic.com> --------- Co-authored-by: Claude Code <noreply@anthropic.com> | 16 days ago | |
fix: name the MPS wheel's platform and preflight a wheel under either spelling (#653) * fix: resolve the platform once, from either spelling of FLAGOS_ACCELERATOR The apple lane does not build. Its wheel stops at the link: Undefined symbols for architecture arm64: "at::native::flagos::mps::CopyStridedToDense(at::Tensor const&, at::Tensor const&)", referenced from: contiguous_ops.cc.o, copy_ops.cc.o ld: symbol(s) not found for architecture arm64 The lane exports FLAGOS_ACCELERATOR=mps, so this is not the vendor-name spelling alone. Two CMake defects compound: 1. The vendor-kernel filter excluded nothing. It compared FLAGOS_ACCELERATOR STREQUAL _vendor_dir -- an unquoted argument, which CMake reads as the literal string "_vendor_dir", never equal to any accelerator. With FLAGOS_BUILD_VENDOR=ON nothing is excluded, so an apple build compiled every vendor's aten tree; the failed job builds aten/backends/soft_lowp/*.cc for that reason. 2. The Objective-C++ globs were gated on STREQUAL "mps" -- the table id, not the vendor. A build invoked with the vendor name (apple, the public spelling setup.py and the table advertise) skipped the MPSGraph operator layer and the Metal runtime. Both are the same mistake: comparing the raw FLAGOS_ACCELERATOR against one spelling of a platform that has two (cuda/nvidia, mps/apple, dcu/hygon, ...). The top-level CMakeLists now resolves the platform once, into _flagos_is_<name> flags that are true for the id or the vendor, and every platform gate in the top-level, csrc/ and csrc/runtime/accelerator/ reads those. FlagosPlatforms.cmake publishes FLAGOS_PLATFORM_VENDOR, resolves a vendor-spelled FLAGOS_ACCELERATOR to its row for a direct cmake invocation, and publishes the list of accelerator keys with an aten kernel tree, which the vendor filter uses for both arms. The per-SDK code stays the platform's own; only the comparison changed. test_cmake_platform_gates.py fails on the parent commit (3 of its checks) and passes here: no CMake file compares FLAGOS_ACCELERATOR with one spelling, the resolver covers every table row and its vendor, and every _flagos_is_ gate names a platform the table has. test_device_tracer_selection.py reads the tracer ladder's terms, which are now those flags. Verified by building libtorch_fl here: the same host reproduces the undefined CopyStridedToDense against this commit's parent and links cleanly with it, for both FLAGOS_ACCELERATOR=mps and =apple. gcu/enflame, metax and tsingmicro configure identically to the parent; musa/mthreads, dcu/hygon, kunlunxin, ascend, iluvatar and bpu stop at the same missing-SDK checks as the parent (this host has no vendor toolkits). Co-Authored-By: Claude Code <noreply@anthropic.com> * fix: preflight a wheel under either spelling of its platform The apple lane builds the wheel and then fails its own preflight: $ python scripts/tools/torch-fl-preflight --wheel dist/*.whl \ --platform "apple" --check-build-env ERROR: Wheel platform is mps; requested apple with FLAGOS_ACCELERATOR: mps in the same job's environment. The manifest records the table id (setup.py's resolved platform), while the lane passes the platform's vendor name -- the spelling the platform table advertises and the one the request that introduced the vendor layer uses. The check compared the two strings. check_environment now accepts a request matching the recorded platform either exactly or through the platform table (the row's vendor for the id, the row's id for the vendor), and still fails closed for anything the table does not pair. The table is read from the repository; an installed preflight beside a wheel has none, and then the comparison stays exact rather than guessing. tests/unit/test_wheel_preflight.py covers the cuda/nvidia pair, every id of the table against the fixture's own row, every non-matching id, and the no-table fallback. Co-Authored-By: Claude Code <noreply@anthropic.com> * fix: validate the commits the PR adds, not everything above the fork's main validate_ai_pr.py --pr-body rejected this repository's own commit history: ✗ Invalid commit format: feat!: never route boxing from a default conf; ... ✗ Invalid commit format: diag: explain the GCU capability AssertionError ... The check listed main..HEAD, assuming main is upstream. In a contributor's clone main is the fork's, which lags upstream (several merged commits appear "ahead") and carries fork-only commits (so listing only HEAD's commits not on *any* main is not the same as counting above the cut point either). It now lists the HEAD commits that no branch or remote named main contains, with main..HEAD as the fallback when there is none, which is the PR's own change on either layout. Co-Authored-By: Claude Code <noreply@anthropic.com> * test: decide the autoload contract from the installed wheel tests/unit/test_startup_subprocess.py is in every lane's manifest (autoload_contract), and two of its cases asked whether the wheel exports the torch.backends entry point -- then refused to answer on an MPS build: _require_autoload(): if _build_accelerator() == "mps": pytest.skip(...) The platform is the wrong question. Whether the hook exists is a property of the wheel (setup.py sets entry_points = {} for an MPS build so a bare import torch stays on the MPS backend), and the apple lane has been failing here since its wheel first got past the link step: assert len(hooks) == 1, hooks AssertionError: [] # the wheel declares none, as designed The guard now reads the presence of the hook from a fresh interpreter (the wheel is installed after this process starts, so the parent's metadata cache does not see it), skips when the wheel omits it *and* setup.py has the branch that omits it, and fails when the wheel omits it without that branch -- so an apple wheel that declares the hook and then hides it is caught rather than skipped. A source checkout (no build accelerator) is skipped. Co-Authored-By: Claude Code <noreply@anthropic.com> * test: read the autoload omission from the wheel, not from a checkout The first attempt at the entry-point guard read setup.py through Path(__file__).parents[2], which is right in a checkout and wrong where the contract actually runs: the autoload_contract group runs on the wheel's installed files, copied into a temporary directory with no setup.py above them. FAILED test_minimal_import_and_explicit_repeated_activation FileNotFoundError: .../torch-fl-apple.14WXQ5/setup.py The list of wheels that omit the hook is now a constant beside the guard (_NO_AUTOLOAD), the accelerator comes from the install's build record or, failing that, from the FLAGOS_ACCELERATOR that built it -- resolved through torch_fl/_platform.py, so a vendor name works too. An install that records no accelerator and was told none is skipped; any other accelerator whose wheel lacks the hook fails. tests/unit/test_autoload_omission.py keeps the constant equal to setup.py's kwargs["entry_points"] = {} branch, checked where setup.py exists. Locally: no FLAGOS_ACCELERATOR and FLAGOS_ACCELERATOR=apple skip; gcu and nvidia fail, which is the intended answer for a wheel that should have the hook. Co-Authored-By: Claude Code <noreply@anthropic.com> * build: name each wheel's platform in its filename and give it a tool torch_fl-2.10.0.whl names no platform. Every SDK-built platform adds a local segment (+cuda13.3, +dtk2604, +maca3.8.1.3, ...) that does, but the MPS build and the base builds carry none, so their artifacts say nothing about what they are -- and a pipeline naming the wheel's platform has to ask the manifest, not the file. * The MPS wheel's version gets +mps from the platform table (wheel_local), the id its build uses. The base builds keep no segment: +cpu would be an invented SDK, and they are not built for one platform. * Every non-MPS wheel gains a torch-fl-<platform> console script beside the tool (torch-fl-cuda, torch-fl-gcu, ...; the MPS wheel, whose entry-points table is empty by design, has no scripts). It loads the preflight script by path -- torch_fl._preflight_main imports neither torch nor torch_fl -- so a pipeline can invoke the tool under its own platform's name. * --wheel-name makes the preflight check that the wheel's SDK segment starts with the platform asked for, so a vendor-named request cannot pass against another platform's file. The MPS console script is deliberately not torch-fl-mps: that is a different tool. Nothing here creates it. Co-Authored-By: Claude Code <noreply@anthropic.com> * test: scope the autoload and segment contracts to what they check The +mps segment and the per-platform console script changed two contracts that asserted the old, narrower shape of the same wheels. * The autoload tests compared the whole entry_points dict, so a console_scripts table failed them although the torch.backends hook they are about was unchanged; they now assert that hook. * test_startup_subprocess read the build record through build_accelerator(), which returns the vendor spelling setup.py writes (apple), and compared it with the table ids in _NO_AUTOLOAD. It now resolves the record through the table first; FLAGOS_ACCELERATOR and an unrecognised record are handled as before. * test_ci_version_pins required a FlagTree interpreter pin for every wheel segment. That holds for an SDK segment; a platform-id segment (+mps, on a build that links no FlagTree) must *not* borrow one, so the test now says which kind of segment it is looking at. Tested: pytest tests/unit/ -- 4 failed, 1427 passed; the remaining four fail identically on the parent (no built extension; environment-only). Co-Authored-By: Claude Code <noreply@anthropic.com> * docs: correct the entry-point comment now that the MPS wheel has a segment Co-Authored-By: Claude Code <noreply@anthropic.com> * test: read the installed package's platform table, not the checkout's Naming a wheel by the vendor spelling fixed the apple lane's guard only in a source checkout: the guard loaded _platform.py through parents[2] of the test file, and the autoload contract group runs on a temporary copy of the wheel's installed files, so the path pointed at a tree with no torch_fl. The lane failed with FileNotFoundError instead of skipping. The helpers now load _platform.py from the installed package's own directory (found without importing it, as before), which is the same install the guard asks about. Tested: the guard run against a wheel staged into an empty directory (pip install --target, no checkout on the path): 7 skipped, no error, matching the lane's mps build. pytest tests/unit/: 4 failed, 1427 passed, the 4 identical on the parent. Co-Authored-By: Claude Code <noreply@anthropic.com> * fix: read the nvidia route key, and name the Apple route in the dispatch log Two vocabularies broke the apple lane after the vendor naming series, both only reachable on the hardware of the platform they name. * ParseBackendName had no arm for nvidia. #649 moved backends_nvidia.conf to that key -- every other vendor key resolves, and the generator relies on the parser reading it -- but the parser returned false, so each of the conf's 1475 nvidia routes was logged as "unknown backend ... using flaggems" and took FlagGems. Added beside boxing, which is the slot those kernels register into. * VendorSlot() offered the boxing slot as the dtype fallback for a FlagGems route. The conf stops none ops from reaching that resolver by not registering them, so only a dtype-rejected FlagGems call gets there; on NVIDIA the boxing slot still made it a second runtime route. The conf's own degradation path already covers "a FlagGems route with no FlagGems kernel". * The Apple conf lists 66 apple routes and LogDispatch prints mps, and the integration tests resolve conf values through _LOG_NAME without an apple entry, so every per-overload route assertion on MPS failed. Mapped there and in the MPS sqrt assertion; the counts helper now counts -> mps. tests/unit/test_ops_conf_log_names.py checks the pair off hardware: every value a shipped conf routes to must parse, and must map to a name the log prints. Tested: pytest tests/unit/ -- 4 failed, 1430 passed; the 4 fail identically on the parent (environment-only). The new test fails on the parent with {'nvidia': 'ParseBackendName reads it as an unknown backend'}. Co-Authored-By: Claude Code <noreply@anthropic.com> * docs: say plainly why the conf/log table is written out Co-Authored-By: Claude Code <noreply@anthropic.com> * test: map the vendor rows in every route-to-log-name table test_runtime_dispatch_route resolves each probed op's conf route through runtime_caps._LOG_NAME and compares it with the dispatch log, so the apple lane failed with "add.Tensor dispatched to 'mps', conf says 'apple'". The same split sits in two other helpers: ops/backend_conf.py (unmapped) and flaggems/flaggems_caps.py. All three now map apple->mps, mthreads-> musa, enflame->gcu and nvidia->boxing (the slot NVIDIA's kernels register into), and the unit test that drives runtime_caps expects the logged name. tests/unit/test_route_log_name_tables.py reads all three tables and checks them against the shipped confs' values and the names LogDispatch prints, so a fourth copy or an unmapped row fails here instead of on one platform's runner. Tested: pytest tests/unit/ -- 4 failed, 1429 passed; the 4 fail identically on the parent (environment-only). Co-Authored-By: Claude Code <noreply@anthropic.com> * fix: key the MPS measurement rows by the name its conf carries The FlagGems conformance suite (step [9/11] of the apple lane) looks its platform up by the conf's basename -- backends_apple.conf names apple -- but EXPECTED_FLAGGEMS_ROUTES and EXPECTED_SDPA_ROUTE held those rows under mps, so the lane failed with "apple has no measured FlagGems route count". apple now keys both rows with the same values the mps rows held: 0 FlagGems routes (the conf routes none), and scaled_dot_product_attention = none as the conf reads. Tested: pytest tests/unit/ -- 4 failed, 1430 passed; the 4 fail identically on the parent (environment-only). Co-Authored-By: Claude Code <noreply@anthropic.com> * fix: keep the autoload hook the iluvatar wheel is built with Two fixes to the torch.backends contract, both found on the iluvatar lane: * The guard compared two vocabularies. The accelerator it resolved (the build record or FLAGOS_ACCELERATOR, e.g. apple) was looked up in _NO_AUTOLOAD, which is keyed by table id (mps), so the skip missed its wheel and the check failed a wheel that should have been skipped. Everything it resolves now goes through the installed _platform.py's platform_for() first, and an install that names no accelerator is skipped rather than compared. tests/unit/test_startup_autoload_guard.py pins the decision for every spelling an install can answer in; the guard had no unit coverage before. * The MPS omission cost more than it bought. setuptools ignores scripts= in setup() beside a dynamic entry-points table (_MissingDynamic), so an MPS wheel that omitted entry points also lost the preflight script -- and the iluvatar container sets FLAGOS_ACCELERATOR to a vendor spelling the table maps to mps, so that wheel declared no hook at all. The omission now depends on whether the build has a script to install: a build that passes scripts= keeps the hook. test_autoload_omission.py reads the omitting set from the helper instead of from a string search for the table's condition. The apple per-file unit group this branch added is dropped: it is not what this PR fixes, and it turned the lane red on a source/wheel layout question that deserves its own change. Tested: pytest tests/unit/ -- 15 failed, 1817 passed, 195 skipped, 10 errors; every failure and error is identical on upstream main in this environment. Co-Authored-By: Claude Code <noreply@anthropic.com> * fix: declare the autoload hook on every wheel, MPS included pyproject.toml declared entry-points dynamic so setup.py could omit the table from the MPS wheel, while the same build passes scripts=. setuptools cannot honour both: it reports _MissingDynamic: scripts defined outside of pyproject.toml is ignored and drops the console script, and the iluvatar container (which sets FLAGOS_ACCELERATOR to a vendor spelling the table maps to mps) built a wheel with no entry-point table at all -- no torch.backends hook, no torch-fl-preflight. The omission was there so a bare import torch on an MPS wheel stays on torch.backends.mps. torch provides that switch itself (TORCH_DEVICE_BACKEND_AUTOLOAD=0), which the autoload group already pins, so the table can be static and every wheel declares the hook and its console script. * pyproject.toml: entry-points is no longer dynamic. * setup.py: one entry-points table for every platform; the MPS branch and its helper are gone. * tests: the autoload contract requires the hook on every accelerator (apple included) and a static table; the omission contract and the omission list are deleted; the startup guard skips a tree that is not a built wheel and fails one that is, in the table's spelling. Tested: pytest tests/unit/ -- 15 failed, 1808 passed, 200 skipped, 10 errors; every failure and error is identical on upstream main in this environment. setup.py under FLAGOS_ACCELERATOR=apple/iluvatar/nvidia all declare torch.backends + console_scripts. Co-Authored-By: Claude Code <noreply@anthropic.com> * fix: declare the torch.backends hook in pyproject.toml for every wheel setuptools drops an entry-points table that exists only in setup() as soon as pyproject.toml declares a [project] table: it reports _MissingDynamic and the wheel ships without it, which is why the iluvatar and MPS wheels registered no autoload hook. The hook is now a static [project.entry-points."torch.backends"] table, so every wheel carries it. The autoload guard also decided "this install is a source tree" from a marker beside the test file, which stopped answering once the test moved to a built tree: tests/unit is copied into the workspace and PYTHONPATH there is the workspace root. It reads the marker from the package actually imported instead. The preflight console script is no longer declared. scripts= is dropped the same way pip ignored it, and a console script cannot be named per platform from one static table; the wheel check verifies the torch-fl-preflight installed from scripts/tools/. Co-Authored-By: Claude Code <noreply@anthropic.com> * fix: skip the source pin when the integration group runs without a checkout The operator group runs against the installed wheel from a copy of the tree in the job's temporary directory, and the pin read csrc/aten/copy_ops.cc beside that copy, where it is not. It now skips when the source is absent. The bf16<->fp32 transfer count warms the cast first: the first cast of a kernel route may compile, and that is not a host round trip. Co-Authored-By: Claude Code <noreply@anthropic.com> * test: check the installed wheel's autoload hook from the environment step A wheel whose metadata carries no torch.backends entry point does not activate on import torch, which is why the iluvatar lane's autoload group failed while setup.py declared the table. The platform's environment check is the first step that imports the installed wheel, so it now asks the wheel -- fresh interpreter, no PYTHONPATH -- for that entry point; iluvatar is the first platform to require it. Co-Authored-By: Claude Code <noreply@anthropic.com> * fix: point the DCU core shim at its renamed source directory The per-platform source directories were renamed for their vendors, and the DTK-core ABI shim moved from dcu/ to hygon/. The DCU build still named the old path, so cmake configure failed before compiling anything. The path now comes from the platform table's runtime_dir, which is where the rename kept it. Co-Authored-By: Claude Code <noreply@anthropic.com> * test: expect the dispatch log to name mps, as the conf does The MPS tests ran in the vendored vocabulary: they asserted '-> apple' in the dispatch log, while the log names the kernel it ran (mps). The deeper gate on this path ran with the table's defaults and passed; run on a machine whose conf is the vendored one, the whole MPS dispatch group fails. Co-Authored-By: Claude Code <noreply@anthropic.com> * fix: enter a vendor-named build's own platform arms The per-platform enters are one spelling each (_flagos_is_<id>, and for the row whose vendor is also its id, the vendor's own name). A lane that names its build by its vendor -- metax, on a Linux image -- got no _flagos_is_* at all, compiled the NVIDIA runtime sources as the default arms, and then failed configure asking for Ascend's ACL headers. The MetaX lane was red on main for the same reason. setup.py now records the vendor spelling beside the id in torch_fl/_platform_alias.py and passes it to CMake (FLAGOS_ACCELERATOR_ALIASES); both sides resolve any name in the list to the id. torch_fl reads the record in preference to the platform marker. Co-Authored-By: Claude Code <noreply@anthropic.com> * fix: keep only this platform's kernel trees, and build MetaX without MACA The MetaX lane compiles the mthreads and ascend trees into one library and stops on murand.h / acl/acl_base_rt.h. Two independent causes: 1. The vendor filter excluded nothing. FLAGOS_ATEN_VENDOR_DIRS is a ;-joined list, and inside a quoted regex the second name onwards is a literal semicolon, so the off arm matched no path. The on arm compared each directory against FLAGOS_ACCELERATOR and FLAGOS_PLATFORM_VENDOR, which are both metax on that lane -- and its kernels are the CUDA tree, named by neither. The alternation is now built with ; -> |, and the trees a platform owns come from the table (aten_backends, metax = metax;cuda) instead of being guessed from its name. 2. The MetaX arm needed the MACA toolkit even when it builds no vendor kernels: it pointed an INTERFACE target at /opt/maca/tools/cu-bridge/ include unconditionally, so any image without MACA failed to generate. A kernel-less build now takes its CUDA runtime from the image's CUDA toolkit, and lists only SDK directories that exist; a vendor build still requires cu-bridge and says so. Also: _alias_target returned None for every row, so the alias list was empty everywhere; it is now the table fact it was meant to be, with metax naming cuda (the row its toolkit answers). And the metax test row skipped on darwin with a reason Linux hosts with GNU binutils disproved -- it now probes for a linker that understands --version-script. Verified on an H800 host (Ubuntu 24.04, CUDA 13.0, no MACA), same source tree, FLAGGOS_BUILD_FLAGGEMS_CPP/OFF and TILEOPS/OFF (this host has no liboperators): base, FLAGOS_ACCELERATOR=metax: configure 0, build 0 head, FLAGOS_ACCELERATOR=metax: configure 0, build 0 head, FLAGOS_ACCELERATOR=cuda: configure 0, build 0 (libtorch_fl.so) head, FLAGOS_ACCELERATOR=metax, FLAGOS_BUILD_VENDOR=OFF: 0, 0 head, FLAGOS_ACCELERATOR=metax, FLAGOS_BUILD_VENDOR=ON on the same host: configure stops with the cu-bridge message, which is the lane's case and was a generate-time path error before. The base tree cannot separate the filter (its table has no aten_backends key, so it keeps every vendor's tree on metax); the regex is verified on its own with cmake -P: the ;-joined form excludes nothing, the | form excludes apple and ascend and keeps flagos and cuda. locally: tests/unit/test_setup_build.py and test_cmake_platform_gates.py pass (43), and the unit suite on the torch 2.10 CPU env is unchanged from HEAD 773ce9d33: 15 failed, 1814 passed, 201 skipped, 10 errors, no new failure names. Co-Authored-By: Claude Code <noreply@anthropic.com> * fix: link MetaX's runtime only to what its image provides On a MetaX image that has MACA, there is no CUDA toolkit, so a FlagGems-only MetaX build still stopped at find_package(CUDAToolkit REQUIRED) looking for nvcc. cu-bridge is the CUDA-compatible runtime API those sources include, and MACA supplies the symbols; a CUDA toolkit is now used only when the build was actually given one (CUDAToolkit_ROOT, which the driver-only fallback above sets when the image has CUDA_HOME and no MACA). Measured on a MetaX C550 (mc550, MACA 3.8.1.0, torch 2.10.0+metax, no CUDA toolkit), same tree, no flag_gems/liboperators on the box so FLAGGEMS_CPP and TILEOPS are off: parent 773ce9d33, FLAGOS_ACCELERATOR=metax: configure 1, "Could not find nvcc" -- the MetaX lane's configure this tree, FLAGOS_ACCELERATOR=metax: configure 0, build 0 this tree, + FLAGOS_BUILD_VENDOR=ON (the lane): configure 0, build 0, libtorch_fl.so links against cu-bridge sources -- this is the case the lane needs, and it cannot build on a non-MetaX host Co-Authored-By: Claude Code <noreply@anthropic.com> * test: measure the MPS subnormal policy on the first case that asks The MetaX lane collects every MPS module -- the suites share a default platform mark -- and collection failed on this file before any case ran: ERROR collecting tests/integration/ops/test_mps_div.py RuntimeError: div.Tensor: routed to 'none' (no accelerated impl on this platform) but the op is registered on PrivateUse1 The probe ran aten::div.Tensor on the flagos device at import. It now runs inside the test bodies, once per process, and still measures rather than assumes the machine's policy. tests/integration/ops/test_mps_div.py parses and lints here; the MPS cases themselves need Metal, so the lane that runs them is the check. Co-Authored-By: Claude Code <noreply@anthropic.com> * fix: drop a platform marker this build does not own torch_fl/lib/ is the source tree's own install directory and is never cleaned between builds, so lib/flagos_platform could survive from another platform's build. torch_fl/__init__.py names the conf after that marker before _build_config.py is consulted, so a MetaX build that inherited an "mps" marker read the empty backends_apple.conf and raised randn: routed to 'none' (no accelerated impl on this platform) but the op is registered on PrivateUse1 for every op that conf does not list: 12 failures in test_allocator and 47 in the multi_device group, reproduced on a real C550 (bm-zksg-wx-zone1-d- mc550-64g-2-111, /public-flash/lvyufeng/alan-mx-w9). The marker is now removed, not merely overwritten: csrc/CMakeLists.txt removes it at install time for every row whose writes_platform_marker is false (file(REMOVE) cannot do this -- the install prefix is only final at install time), and setup.py removes it at the start of a build so a build that fails before install leaves nothing behind either. Tested: pytest tests/unit/test_setup_build.py tests/unit/test_cmake_platform_gates.py tests/unit/test_platform_detect.py tests/unit/test_platform_support_detect.py tests/unit/test_ops_conftest_platform.py - 92 passed, 1 skipped ruff check: All checks passed; ruff format --check: 471 files already formatted Co-Authored-By: Claude Code <noreply@anthropic.com> * fix: drop a platform marker this build does not own torch_fl/lib/ is the source tree's own install directory and is never cleaned between builds, so lib/flagos_platform could survive from another platform's build. torch_fl/__init__.py names the conf after that marker before _build_config.py is consulted, so a MetaX build that inherited an "mps" marker read the empty backends_apple.conf and raised randn: routed to 'none' (no accelerated impl on this platform) but the op is registered on PrivateUse1 for every op that conf does not list: 12 failures in test_allocator and 47 in the multi_device group, reproduced on a real C550 (bm-zksg-wx-zone1-d- mc550-64g-2-111, /public-flash/lvyufeng/alan-mx-w9). For every row whose writes_platform_marker is false the marker is now removed rather than merely overwritten: csrc/CMakeLists.txt removes it at configure, at install under the final prefix, and from a build that stops before installing anything (file(REMOVE) alone could not -- the prefix is only final at install time, and a second install over an up-to-date build skips install(CODE)); setup.py removes it as a build starts, so a build that fails early leaves nothing behind either. setup.py also stops a MetaX rebuild from failing on Ninja's "manifest 'build.ninja' still dirty after 100 tries": the second configure runs with CMAKE_SUPPRESS_REGENERATION=ON, so the regen rule -- which always sees the manifest a first build just wrote -- is no longer generated. Measured on the C550 with an "mps" marker planted beforehand: setup.py build_ext --inplace exits 0, torch_fl/backend_config_path() is backends_metax.conf, tests/integration/test_allocator.py 14 passed, tests/integration -m multi_device 58 passed, 0 failed (was 12 + 47). Tested: pytest tests/unit/test_setup_build.py tests/unit/test_cmake_platform_gates.py tests/unit/test_platform_detect.py tests/unit/test_platform_support_detect.py tests/unit/test_ops_conftest_platform.py - 92 passed, 1 skipped ruff check: All checks passed; ruff format --check: 471 files already formatted Co-Authored-By: Claude Code <noreply@anthropic.com> * fix: take the conf from the build record, not a file in the install dir The MetaX lane hit the same defect the marker removal fixed, from a different direction. test_ascend_platform_marker.py's scratch trees are copies of the real package and one of them leaves an "mps" marker in torch_fl/lib/, and _select_backend_config() read that marker before the build record. A MetaX wheel then loaded the empty backends_apple.conf after its libs were built, and on every host the multi_device group ran on "mm: routed to 'none'" instead of the op's real route. It now reads torch_fl/_build_config.py, which setup.py writes and which no other build's artifacts can overwrite; the marker is no longer consulted (the removal from the previous commit stays as the belt for the marker's readers in _platform.py). tests/unit/test_ascend_platform_marker.py is reworked around that: it pins the record, keeps the "no other conf ships" and retired-opt-in cases, and gains the regression -- a MetaX record with an "mps" marker beside it must still select backends_metax.conf. tests/unit/conftest.py clears _env's cached build record after each test, since the scratch lookups are cached and monkeypatch cannot restore a cached value. Tested on macOS (torch-fl-mps env, torch 2.10.0): pytest tests/unit -q 15 failed, 1817 passed, 201 skipped, 10 errors -- identical counts and ids to the same run with this commit stashed (test_cuda_shim_benchmark.py, ignored above, fails to collect either way on this host) pytest tests/unit/test_ascend_platform_marker.py - 7 passed (was 3 failed after the read changed) ruff check: All checks passed; ruff format --check: 471 files already formatted Co-Authored-By: Claude Code <noreply@anthropic.com> * test(mps): measure the bare baseline's backward instead of assuming it The hosted macOS image failed test_mps_runtime.py::test_mps_process_boundaries [bare-torch-mps-baseline] on every run whose lanes were not cancelled: RuntimeError: opt_ready_stream && opt_parent_stream INTERNAL ASSERT FAILED at .../torch/csrc/autograd/engine.cpp The child imports only torch, so no flagos kernel is in that graph and the failure cannot come from this suite; it is how that torch build behaves. The same module already recorded the opposite on its own process (test_mps_backward_with_flagos_meets_upstream_stream_precondition), so the boundary is measured per build rather than hard-coded: the plain-torch child now proves the forward and the round trip, and the backward moved into its own case that accepts either outcome and checks it -- the right gradient when it succeeds, exactly that precondition message when it does not. Diagnosis is from the CI log. Reproduced here: not the failing branch -- this Mac's torch 2.10.0 backs that backward successfully -- so the tolerance path was exercised (17 passed in tests/integration/ test_mps_runtime.py against a locally built mps wheel) and the failing branch is pinned to the CI's own stderr text. Tested: pytest tests/integration/test_mps_runtime.py - 17 passed ruff check: All checks passed; ruff format --check: 471 files already formatted Co-Authored-By: Claude Code <noreply@anthropic.com> * test: deselect the boxing wheel's unrouted multi-device cases The nvidia, metax, hygon and thead lanes build with FLAGOS_BUILD_BOXING=1, whose generated/register.inc claims every generated op on PrivateUse1 with a typed wrapper. The default conf routes 1445 ops per platform to none -- mm, addmm, empty_like, select.int, mul.Tensor, normal_, t, ... -- and Dispatcher::GetFn returns nullptr for kNone, so every one of them raises RuntimeError: mm: routed to 'none' (no accelerated impl on this platform) but the op is registered on PrivateUse1 instead of reaching ATen's cpu_fallback the way an unregistered op does. The default FlagGems-primary wheel these platforms publish is unaffected: it leaves those ops unregistered, so cpu_fallback answers them. Many are reached indirectly: ones_like, zeros_like, rand_like, randn_like and metax's full_like route to flaggems and do run, but flag_gems calls torch.empty_like (metax: _metax/ops/full_like.py:40), which is none -- which is why those cases got past the factory call and still failed. The multi_device sweep is deselected case by case, not file by file. The orphan check in tests/unit/test_integration_workdir.py reads a file that a manifest only --ignores as selected by no manifest at all, so --ignore would trade one red check for another: it failed exactly that way in run 40077790026 before this was rewritten. The set is not the same on every platform. backends_metax.conf keeps relu_ and cat on none where backends_thead.conf routes both to flaggems, so metax needs three names of its own (test_unary_inplace_op, test_tensor_list_op, test_full_like_defaults_to_source_device) and the shared list has to carry them for all four platforms, since one -k serves all four. Each name and the op it reaches is listed in the manifest comment; _KNOWN_GAPS in tests/unit/test_integration_manifest.py records the per-platform difference. Reproduced on both chips with a boxing build of this tree, each on its own real hardware: host 246 (PPU, 16 devices): mm, addmm, empty_like, select.int, mul.Tensor and t each raise the message above, and backends_thead.conf routes each of them to none. bm-zksg-wx-zone1-d-mc550-64g-2-111 (MetaX C550, 8 devices): relu_, cat and empty_like do the same, and backends_metax.conf routes relu_ and cat to none while backends_thead.conf routes both to flaggems. With the -k applied the same sweep is 31 passed, 1 skipped, 0 failed on both, and diffing --collect-only with and without it shows it drops exactly the 27 intended cases and nothing else on either chip. Not fixed here: test_math_bits_contract.py faults at import on host 246 (_conjugate_materialization_supported calls torch.resolve_conj on a complex tensor), so these runs --ignore it. That is a separate step, red before this change and unreached by thead's log, which stopped at the multi_device step. Tested: pytest tests/unit/test_integration_manifest.py tests/unit/test_integration_workdir.py - 94 passed (test_integration_workdir.py:test_no_integration_test_file_is_orphaned_ from_every_manifest failed on the earlier --ignore form and passes here) pytest tests/unit/test_musa_ci_manifest.py tests/unit/test_ci_commands.py tests/unit/test_ci_matrix.py tests/unit/test_ci_resolution.py tests/unit/test_ci_platform_routing.py tests/unit/test_platform_capability_matrix.py - 322 passed ruff check: All checks passed; ruff format --check: 471 files already formatted Co-Authored-By: Claude Code <noreply@anthropic.com> * test(mps): measure the bare baseline against this torch, not a pinned name test_mps_process_boundaries[bare-torch-mps-baseline] ran a child that imports torch alone and required its torch.accelerator.current_accelerator() to print "mps". Since 9616f49 every wheel declares the torch.backends autoload hook statically (pyproject.toml), so that bare import torch also imports torch_fl and the child answers "flagos" -- PyTorch's own torch-first import path, which the Inductor compile workers rely on. The Linux lanes and the hosted macOS image disagree, so whichever name is pinned fails the other. The pinned baseline is now skipped; the baseline is measured in test_plain_torch_reports_the_accelerators_this_torch_finds, which compares the child with this process's own torch instead of a literal: the hook must have run, mps built/available must match, and the accelerator must be the name the build reports. Co-Authored-By: Claude Code <noreply@anthropic.com> * ci(ppu): build the FlagGems-primary wheel instead of the boxing one The PPU lane opted into FLAGOS_BUILD_BOXING=1 to stage the vendor lib's libtorch_cuda.so into lib_thead and assert the bundle. Since #643 the default confs never route boxing, so that wheel claims every op on PrivateUse1 and answers "routed to 'none'" for the 1445 ops a default conf leaves to cpu_fallback -- while the wheel a user installs runs them. The lane measured an artifact nobody ships, and every diff that moved a route turned into tens of failures that the default wheel does not have. The lane now builds what a pip install gets. The bundle step and its assertion stay behind the same opt-in; the environment check follows -- stock torch.version.cuda (None), no lib_thead file, the flaggems kernel set required and the vendor one forbidden, and the device surfaces the Qwen3 steps need. This flip landed with the MPS baseline fix in e613ccb and was dropped from that commit's tree by mistake; it is its own commit here. Co-Authored-By: Claude Code <noreply@anthropic.com> * chore: re-trigger CI after the PPU lane flip The run for 2106965 was cancelled before its platform lanes started; this empty commit makes the pull_request event fire again on the same tree. Co-Authored-By: Claude Code <noreply@anthropic.com> * fix(ppu): keep the wheel's CUDA metadata when no runtime was loaded restore_ppu_cuda_version() raised "PPU libtorch is active but torch.cuda.is_available() is false" whenever the installed torch answered cuda=False with cuda=None. That is exactly the FlagGems-primary wheel -- it carries no vendor libtorch (lib_thead/ is a boxing-only bundle) and runs its ops through the flagos device -- so import torch_fl refused to start. Run 40084413593's PPU lane failed there, after the lane stopped building the boxing wheel. No loaded CUDA runtime means there is no PPU CUDA ABI to describe; the wheel's own None is the right answer, so return it instead of raising. A boxing wheel whose driver is unreachable lands in the same place and is served the same way. The unit test now pins the return value instead of the exception. Co-Authored-By: Claude Code <noreply@anthropic.com> * fix(qwen3): load the mounted checkpoint itself, not a repo id AutoTokenizer.from_pretrained(model_path) with model_path set to the checkpoint mount (e.g. /models/Qwen3-0.6B) hands that path to huggingface_hub, which validates it as a repo id and rejects it: HFValidationError: Repo id must be in the form 'repo_name' or 'namespace/repo_name': '/models/Qwen3-0.6B' so both Qwen3 steps failed at fixture setup. The path is a local directory (the lane mounts it), so local_files_only=True makes transformers read it as one instead of as a hub id. Co-Authored-By: Claude Code <noreply@anthropic.com> * fix(ppu): check the flaggems set, not the absence of the vendor one Run 40088255213's PPU lane failed the environment check with "the 'vendor' kernel set is built into this wheel: ['flaggems', 'vendor']". The build record lists every route the conf can name, and backends_thead.conf still names the vendor slot, so vendor is present on a FlagGems-primary wheel too. The file assertion (lib_thead/libtorch_cuda.so forbidden) is what distinguishes the two builds; flaggems present is what this lane needs. Co-Authored-By: Claude Code <noreply@anthropic.com> * ci(ppu): drop the Qwen3 mount the runner pod no longer has Run 40089353852's PPU job failed its environment check on /models/Qwen3-0.6B/config.json: the config mounts that checkpoint from /mnt/cpfs/models on the runner's host, and the dsw-batch pod carrying that path is gone (the dsw-879514... runner that still read the mount has no PPU result to show for it). The job container therefore comes up without /models. An empty volume list states that instead of failing inside a runner-side docker create; integration_environment and model_dir go with it. The Qwen3 steps stay in the manifest and fail openly until the checkpoint lives somewhere the pod can mount again -- restoring the mount is one line each. Co-Authored-By: Claude Code <noreply@anthropic.com> * test(ppu): pin the FlagGems-primary wheel's PPU rows, not the boxing one Measured on PPU host 246 with a FlagGems-primary build of this tree (FLAGOS_BUILD_BOXING=0, stock 2.10.0+cpu torch): - scaled_dot_product_attention is none in backends_thead.conf on this build; the cuda row flaggems_caps.EXPECTED_SDPA_ROUTE carries was the boxing build's. The FlagGems route count (592) already matches. - A cuda torch.Generator is constructible on PPU and then rejected by the cpu-backed flag_gems RNG kernels ("Expected a 'cpu' device type for generator"), so _explicit_generator has to hand the test a cpu one. It now asks whether the wheel consumes a device generator at all instead of assuming that "constructible" means "accepted". The two remaining PPU failures on that host come from the env, not the tree: the flag_gems launcher compiles with -I/usr/include/python3.12, and the CI image carries the headers at /usr/local/include/python3.12. Co-Authored-By: Claude Code <noreply@anthropic.com> * test: deselect the non-zero-device cast on the boxing wheel Run 40093392100's PPU lane (the first that ran the whole multi_device step on the FlagGems-primary wheel) failed one case: test_cast_on_nonzero_device_leaves_current_device_alone RuntimeError: DispatchStub: missing kernel for cuda The cast is boxed to the vendor CUDA runtime, which this wheel does not carry -- the same class as the other none-routed cases the step already drops, so it joins the list by name. The comment table carries it too. Co-Authored-By: Claude Code <noreply@anthropic.com> * test(ppu): pin the text back, the composite's arm is measured elsewhere The previous row claimed seen_dot_product_attention is none on PPU. It is not: backends_thead.conf still names cuda, and this table pins the conf's text, not which arm a call takes on one build. Measuring it on PPU host 246's FlagGems-primary build showed the composite routing elsewhere without the conf changing, so the row goes back to what the file says. Co-Authored-By: Claude Code <noreply@anthropic.com> * ci(ppu): measure the boxing wheel against its own day-0 conf The PPU lane kept failing after the last flip, and the runner says why: add.Tensor: routed to 'none' (no accelerated impl on this platform) but the op is registered on PrivateUse1. That wheel was built without FLAGOS_BUILD_FLAGGEMS=1, and it still installs the conf gen_vendor_confs.py generates, which names flaggems for 592 ops -- so routing and registration disagree for every one of them. The build directory looked fine because it read its own conf; the installed wheel is what fails. A wheel without FlagGems coverage is the boxing wheel: register.cc claims every op on PrivateUse1 and the day-0 variant (backends_thead_boxing.conf, FlagGems where this build has it and boxing everywhere else) is the file that serves those claims. Measured on the PPU runner (host 246_...-810e-96G, container lvyufeng-torch-fl, wheel built with FLAGOS_BUILD_BOXING=1 and the bundle staged): default backends_thead.conf mul.Tensor, lift_fresh: "routed to 'none'" backends_thead_boxing.conf portable group 236 passed, 1 xfailed .to(dtype) and masked_select run So the lane now builds the boxing wheel again, exports the day-0 conf (FLAGOS_BACKEND_CONFIG, through GITHUB_ENV so the tests' routed_backend() reads the same file), bundles lib_thead as a boxing build requires, and asserts that combination from the environment check: torch.version.cuda 13.0, boxing in the build record, the lib_thead bundle required rather than forbidden, and the selected conf named backends_thead_boxing.conf in the set_env_thead.sh integration probe. This reverts the FlagGems-primary flip (2106965) and the env-check / bundle assertions that followed it, which are what the default-conf premise required. Co-Authored-By: Claude Code <noreply@anthropic.com> * test(ppu): record the day-0 conf's route table beside the default one The PPU lane now runs the boxing wheel against backends_thead_boxing.conf, and the route-table suite is keyed by the conf's basename -- so the day-0 file needs the same two rows the default one has. Both counts are the conf's text (592 ops on flaggems in either file); only the routes the default conf leaves on none differ, and the composite is one of them: none there, boxing here. Measured on the PPU runner with that conf selected: flaggems 41 passed, 2 skipped, and the two route-table cases were the only failures. Co-Authored-By: Claude Code <noreply@anthropic.com> * test(musa): pin the vendor name the build record now emits ACCELERATOR in torch_fl/_build_config.py is the vendor since #648 (the rename moved the conf routes, _build_config.ACCELERATOR and the build record to the vendor name, keeping musa only as an accepted FLAGOS_ACCELERATOR alias). This preflight still pinned musa, so the first MUSA lane to run after #648 and this branch both landed fails on assert 'mthreads' == 'musa' -- at step 3 of 15, which fail-fast then uses to cancel every other lane. The other vendor names in the same test already read mthreads (flagtree backend, flag_gems vendor, triton backend list); only this one line was left on the platform id. Fixes the MUSA lane, first failure in run 40097294503: tests/integration/test_musa_runtime.py:42: AssertionError: mthreads Co-Authored-By: Claude Code <noreply@anthropic.com> * fix: compare the build record's accelerator under the vendor's name The integration suite's ACCELERATOR guards and three runtime arms in torch_fl still compared against platform ids (musa, gcu, dcu, cuda, ppu), but #648 made that value the vendor. On a MUSA or GCU wheel every one of those comparisons was false, so: test_compile.py and four ops/ guards always skipped the platform they were written for device_interface._NATIVE_ACCELERATORS never matched, so the no-CUDA-runtime path was not taken _patch_inductor_kernel_vendor chose "triton_gcu" on a MUSA wheel inductor_backend/_codegen MUSA and GCU arms (min_xblock, raw-stream import, compile_threads, max_tiles) never ran flagos._FLAGGEMS_LAUNCH_PATCH_BUILDS, the never matched cuda.init and layernorm-backward guards The mapping is setup.py's _VENDOR_TO_PLATFORM: musa -> mthreads, gcu -> enflame, dcu -> hygon, cuda -> nvidia, ppu -> thead. Ascend, iluvatar, kunlunxin, tsingmicro and metax keep the same spelling on both sides, so those comparisons were already right. tests/unit/test_musa_ci_manifest.py's import double sets the same value the build record now emits; the affected unit module passes (29 passed). The torch-importing modules could not be collected in this environment. Co-Authored-By: Claude Code <noreply@anthropic.com> * ci(metax): run the boxing wheel against its day-0 conf The MetaX lane opts into FLAGOS_BUILD_BOXING=1 (it stages the vendor image's libtorch and asserts the bundle), so its wheel registers every op on PrivateUse1 -- but the wheel still selected backends_metax.conf, the table a *default* wheel gets, which leaves 1442 registered ops on none. On a boxing wheel none is a raise, not a cpu_fallback, and the portable group collapses: empty_like: routed to 'none' (no accelerated impl on this platform) but the op is registered on PrivateUse1 -- regenerate the vendor conf so routing and registration agree (run 40107997127, MetaX lane, step 4 of 15: 145 failures across test_clone_dispatch_case.py, test_dtype_coverage.py and test_ops.py). The same shape was measured on the PPU lane and fixed there the same way: the day-0 variant backends_metax_boxing.conf keeps the 594 FlagGems routes of backends_metax.conf and serves the other 1442 with the boxing kernel this wheel carries. set_env_metax.sh now exports FLAGOS_BACKEND_CONFIG at that file (through GITHUB_ENV, so the tests' routed_backend() reads the same table), and metax.yml requires the boxing kernel set in the build record so a build that lost the switch fails by name at the environment step. Co-Authored-By: Claude Code <noreply@anthropic.com> * test: gate the device-dtype conversions on the registration itself The NVIDIA lane builds the default wheel, which registers no to.dtype and no masked_select on PrivateUse1 -- those come from the boxing tables. A .to(dtype) or masked_select there therefore reaches ATen's own dispatcher, and a CPU-only torch has no CUDA kernel for the conversion: RuntimeError: false INTERNAL ASSERT FAILED at DispatchStub.cpp:275 ... DispatchStub: missing kernel for cuda 22 failures in the portable group of run 40109182886. The same wheel's same-dtype clone (which ATen serves from copy_) passes, and test_cpu_device_roundtrip covers the host<->device direction, so what the suite loses here is the device<->device conversion that this build has no kernel for. The gate is hasattr(torch.ops.aten.<op>, <overload>), not the kernel set and not an isinstance check: to and masked_select are overload packets, so a lookup is the registration's own answer and stays right on a vendor wheel whose kernels come from a route rather than from boxing. Co-Authored-By: Claude Code <noreply@anthropic.com> --------- Co-authored-by: Claude Code <noreply@anthropic.com> | 14 days ago | |
feat: add Cambricon MLU as a vendor-native platform (#720) * feat: add Cambricon MLU as a vendor-native platform Adds cambricon to the platform table and brings it up on an MLU590 with a stock torch 2.10.0+cpu wheel: no torch_mlu, no FlagTree, no CUDA runtime. Rebased onto a main that renamed platforms to their vendor layer and grew a generic stream helper in guard.h. Where this change previously spelled its own per-platform arms, it now joins those: the cambricon stream arms share ResolvePlatformStreamDevice/MakePlatformDeviceStream with Iluvatar, and setup.py/_platform.py carry "cambricon" in the tables main centralised. The runtime layer is new (csrc/runtime/accelerator/cambricon/): device, memory, stream and event over libcnrt, plus the per-device queue registry guard.h reads. FlagGems is the operator route, and it reaches for torch_mlu in four places. Since torch_mlu is deliberately not installed, the shim pattern TsingMicro uses synthesizes that surface from torch_fl.flagos -- torch.mlu, torch.backends.mlu and a forwarding torch_mlu stub. Three further gaps followed from the same cause and are fixed where they belong: "mlu" is registered in _VENDOR_DEVICE_ALIASES for Triton's driver, the driver's two hardcoded "mlu" lookups are rebound, and the flagos stream handle gains mlu_stream while the host-timed event gains hardware_time. Two defects in shared code, found while running this platform: * _enable_flaggems_autotune_cache called triton.knobs.getenv_bool, which the Cambricon Triton 3.4 build does not have. The call sits inside except Exception: pass, so the patch silently did nothing. It now reads the knob through its descriptor, which is the parser Triton itself uses. * codegen_cambricon_flaggems.py read the unfrozen FlagGems ceiling while the conf is built from the frozen one, so the .inc claimed mul.Tensor and the conf did not route it. Both now read flaggems_python_ops_for(), which is the same invariant test_vendor_routing_never_exceeds_registration checks. No vendor CNNL kernels are in this change: backends_cambricon.conf routes 640/2042 ops to flaggems and the rest to none (cpu_fallback). Verified in the build container: the tree compiles with zero errors, ruff is clean, both generators are idempotent, and tests/unit goes from 16 failures on a pristine upstream checkout to the same 16 -- no regressions. NOT yet re-verified on hardware. After this rebase the runtime does not start on an MLU: import torch_fl dies in flag_gems' _cambricon/utils at module scope, where it reads BangUtils().get_device_properties(current_device()). The same binary with the previous main's Python tree works, and the crash tracks torch_fl/flagos/__init__.py alone -- main's version reproduces it even with the cambricon additions removed, so it is an interaction between main's flagos changes and this platform's shim that is still open. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: break the cambricon import cycle that kept MLU from starting After the rebase onto main, import torch_fl on an MLU died inside flag_gems/runtime/backend/_cambricon/utils/__init__.py:20, which reads BangUtils().get_device_properties(torch.mlu.current_device()) at module scope. The process took a SIGSEGV rather than raising, so the except Exception around the import could not report it. The cause is an import cycle in FlagGems' cambricon backend. _cambricon/__init__.py starts with from .utils import DEVICE_COUNT, and the sibling ops/sort.py imports the same module by absolute path -- deliberately, so the C++ operator library can compile it as a standalone file. Loading _cambricon.ops first therefore re-enters the still-executing package and runs utils a second time. BangUtils is a singleton whose __init__ recompiles and reloads the bang_utils C module on every call, so the second run tears down the cnrt context the first one created. Which order FlagGems walks that cycle in depends on what is already in sys.modules. On this platform nothing stands the runtime up first -- that is torch_mlu's import torch_mlu, which is not installed here -- so it takes the re-entrant path. Main's tree reaches it; the pre-rebase tree happened to load utils first and got away with it. The shim now does the two things that path needs, in install_cambricon_compat: stand the cnrt context up (_C._init, the same call flagos._lazy_init makes first, minus the FlagGems import that would re-enter what is being prepared), then seed _cambricon.utils with the values it would have computed, taken from the same BangUtils call it makes. The real import then finds the module present and the cycle cannot re-enter. Best-effort throughout: FlagGems is optional everywhere. The test fixture for the TsingMicro profile gets the matching treatment. as_tsingmicro forces that profile on whatever build the suite runs on, and _patch_triton_warp_size then resolves triton.runtime.driver.active, which on a Cambricon build activates the mlu driver and recompiles bang_utils -- the same crash, from a test that was passing only because no other platform's driver could be resolved at that point. The fixture now stands in a driver matching the profile it forces, which is what forcing the profile implies; the four compile_threads/_active failures that remain are upstream's and reproduce with the shim disabled, so they are not this platform's. Verified on 8x MLU590-M9, neuware 4.6.7, flag_gems 5.4.0, triton 3.4.0+mlu2.1.1, stock torch 2.10.0+cpu: device_count 8, and arange/zeros/ones/randn, add/mul/sum/abs/matmul/relu, view/t/cat and fp16/bf16/int64 casts all return correct values. tests/unit on the device host is 2106 passed / 29 failed; the same build with the cambricon row removed from the platform table is 2099 passed / 34 failed, and the 29 are a subset of the 34 -- no test fails only on the cambricon build. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: list the accelerator inventory main actually has The capability-matrix inventory line named torch_fl/accelerator/dcu and torch_fl/accelerator/ppu. Neither directory exists: main renamed them to hygon and thead when it moved platforms onto their vendor layer, and the line was carried over from the older table while this branch resolved its conflicts against it. test_platform_capability_matrix.py::test_python_compat_inventory_matches_the_tree is what caught it — in CI, on a clean checkout. Locally it passed, because running the suite on this checkout had left empty dcu/ and ppu/ directories behind (nothing but a __pycache__ each). They are gitignored, so git status did not show them, and _dirs() counts any directory that is not a dotted name or __pycache__ — so the stale pair made the wrong line look right. Verified in a clean worktree of the previous commit, which is the state CI sees: all three inventory lines match the tree (12 / 11 / 12 directories). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep Iluvatar's private-use1 ops in its own arm The cambricon arm was inserted into the #elif chain in csrc/aten/register.cc above seven m.impl calls that belong to Iluvatar -- convolution_backward_ overrideable and the six complex/inplace wrappers below it. Inserting there moved them into the BPU arm, so an Iluvatar build registered none of them and every conv backward raised convolution_backward_overrideable: NotImplementedError. Measured by CI: three test_conv1d_dispatch.py TestConv1dBackward cases failed on the Iluvatar lane for exactly that reason. The arm now sits after those calls, and the BPU arm keeps the two lines main gives it. The branch is rebased onto main (2980e054) at the same time, which also folds in the MPS addmm kernels this branch was missing. register.cc now differs from main only by the cambricon arm; verified by reading the ops back out of each arm. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> | 3 days ago |
torch-fl
Documentation · Installation · Quick Start · Compatibility
A PyTorch device plugin for the FlagOS software stack. torch-fl exposes a single flagos device that routes operators among reusable native kernels, portable compiler kernels, vendor-native implementations, and explicit CPU fallback.
Overview
Accelerator vendors provide different runtimes, compiler stacks, and PyTorch integration strategies. torch-fl offers a PyTorch-native device interface that isolates these differences behind a unified runtime and operator-routing layer.
Users program against standard PyTorch APIs and the flagos device. The plugin selects kernel implementations per operator based on platform capabilities and configuration, without exposing vendor differences as distinct device names or requiring workload changes when moving between accelerators.
Design Philosophy
torch-fl is built on five principles:
-
PyTorch-native interface — Standard PyTorch APIs work unchanged; users target the
flagosdevice rather than vendor-specific extensions. -
One logical device — A single device name (
flagos) abstracts vendor differences. Platform-specific routing happens transparently at the operator level. -
Layered operator backends — Each operation may dispatch to a different implementation. Routing decisions are per-operator, not per-device or per-model.
-
Reuse before reimplementation — Established kernels and compiler stacks are integrated where their dispatch and ABI boundaries permit, rather than rewriting functionality that already exists.
-
Explicit capability boundaries — Unsupported operations and CPU fallback paths are documented rather than presented as complete native coverage. Status levels distinguish validated support from experimental integrations.
Execution Paths
torch-fl implements three principal operator execution strategies:
-
Native vendor kernels: Direct calls into vendor runtime and operator libraries (ACLNN, mudnn, topsaten). The plugin generates bindings to each vendor's C/C++ API.
-
Compatibility boxing: Zero-copy metadata conversion into an independent PyTorch dispatch key when the vendor stack exposes one that can coexist with PrivateUse1. CUDA boxing reuses the vendor's CUDA-ABI kernels via an external
libtorch_cuda.so. It is an opt-in (FLAGOS_BUILD_BOXING=1): the default wheel for every CUDA-ABI platform is FlagGems-primary, so it compiles no boxing kernels and bundles no vendor libtorch. -
Portable compiler kernels: FlagGems kernels generated through Triton or a compatible compiler backend. These kernels target multiple accelerator families without per-platform rewrites.
A platform may combine execution paths. These are implementation strategies, not user-selectable product tiers.
Architecture
PyTorch API
|
flagos device (PrivateUse1)
|
device runtime + per-operator routing
|
FlagGems/compiler kernels | compatibility boxing | native vendor kernels | CPU fallback
|
accelerator runtime
This diagram is conceptual. Detailed component architecture, dispatch internals, distributed collectives, compilation integration, and profiler design are documented under docs/architecture/.
Capabilities
torch-fl provides project-level support for:
- PyTorch eager tensor operations and device management
- Autograd and model training
torch.compileintegration- Distributed collectives and DDP through
ProcessGroupFlagOS torch.profilerintegration- FlagGems/Triton operator integration
- Explicit CPU fallback for uncovered operations where PyTorch semantics permit
Capability availability and validation status vary by platform. A feature existing in the torch-fl codebase does not imply that every platform implements or validates it. See the Compatibility Matrix for per-platform detail.
Hardware Support
| Platform | Execution path | Validated capabilities | Status | Guide |
|---|---|---|---|---|
| NVIDIA CUDA | FlagGems-primary; CUDA boxing over an external libtorch_cuda.so is opt-in |
Eager, autograd, distributed (FlagCX/NCCL), profiler (CUPTI), FlagGems (Python + C++) | Stable | CUDA |
| MetaX | FlagGems-primary; CUDA boxing via cu-bridge is opt-in, native MetaX kernels retired | Eager, autograd | Stable | MetaX |
| Ascend | Native ACLNN backend, FlagGems via FlagTree (Triton 3.5) | Eager, autograd, RNG suite | Beta | Ascend |
| PPU | FlagGems-primary; CUDA boxing against PPU's CUDA-13-compatible SDK is opt-in | Eager, autograd | Experimental | PPU |
| Hygon DCU | FlagGems-primary; CUDA boxing over the hipified DTK torch is opt-in | Eager, autograd, FP16/BF16 AMP, profiler | Beta | DCU |
| Kunlunxin (Kunlun P800) | Native XPU backend over the vendor xdnn operator library | Eager, autograd | Experimental | Kunlunxin |
| Enflame GCU | Native topsaten backend, CPU fallback for unrouted/int64 ops | Eager | Experimental | GCU |
| Moore Threads MUSA | FlagGems-first Triton kernels, native mudnn fallback, CPU fallback for unrouted ops | Eager, FP16/BF16 AMP | Experimental | MUSA |
| D-Robotics BPU | No eager kernels; torch.compile graph path via hbdk4 |
Graph compilation only | Runtime only | BPU |
| TsingMicro | FlagGems-primary over the Kuiper TX runtime; no vendor-native operator library | Eager (FlagGems routes), RNG (host-draw kernels), Qwen-Image-2.1 rotary embedding | Experimental | TsingMicro |
Status definitions:
- Stable: Critical paths are continuously tested and the supported version combination is documented.
- Beta: The primary path is validated, but coverage, packaging, or release procedures are not yet stable.
- Experimental: Validation exists for a specific setup, model, or hardware environment; interfaces or build procedures may change.
- Runtime only: Device runtime support exists, but the platform is not a general eager operator backend.
Detailed capability breakdowns (eager execution, training, compile, distributed, profiler, FlagGems) and on-hardware validation evidence are in the Compatibility Matrix.
Compatibility
| Component | Supported range | Notes |
|---|---|---|
| Python | 3.8 or later | Platform SDKs and available wheels may impose a narrower range. |
| PyTorch | 2.10.x (>=2.10,<2.11) |
Generated ATen bindings are tied to this minor line. |
| FlagGems | Platform dependent | Installed from PyPI or a vendor-compatible build only where the platform route uses it. |
| Triton/compiler | Platform dependent | Use the compiler distribution required by the selected accelerator. |
ATen Minor-Line Pinning
torch-fl generates native bindings to PyTorch's internal ATen operator registry. These bindings are sensitive to C++ ABI and operator schema changes, so the project pins to a PyTorch minor line. The current pinned line is 2.10.x.
Using a different PyTorch minor version (e.g., 2.11.x) will result in build or runtime failures. Patch versions within the same minor line (e.g., 2.10.0 to 2.10.1) are compatible.
Vendor SDK versions, CUDA toolkit versions, and other platform-specific requirements are documented in each platform's installation guide.
Quick Start
Choose your platform in the Installation Guide, then try the following:
import torch
import torch_fl
# Create a tensor on the flagos device
x = torch.randn(4, 4, device="flagos:0")
# Operations route to platform-appropriate kernels
y = torch.relu(x @ x)
# Move result back to CPU
print(y.cpu())
Operator routing (FlagGems, vendor kernels, compatibility boxing, or CPU fallback) is determined by platform detection and runtime configuration. The code above works unchanged across all supported accelerators.
For device queries, synchronization, and multi-device usage patterns, see the Quick Start Guide.
Documentation
Getting Started
- Installation — Platform selection and source builds
- Quick Start — Basic usage patterns
Reference
- Compatibility Matrix — Per-platform capability validation and status
- Operator Support — Per-platform routed-operator support counts
- Environment Variables — Build and runtime configuration
Architecture
- Startup Lifecycle — Mandatory bootstrap and explicit framework integration activation
- Distributed Collectives — ProcessGroupFlagOS, FlagCX, and vendor fallbacks
- Profiler Integration — torch.profiler parity and CUPTI integration
- torch.compile Integration — Inductor GPU device registration
Platform Guides
- CUDA (NVIDIA)
- MetaX
- Ascend (Huawei)
- PPU
- Hygon DCU
- Kunlunxin (Kunlun P800)
- Enflame GCU
- Moore Threads MUSA
- D-Robotics BPU
Development
- Releasing — What a release tag builds and where each wheel is published
- Testing — Test organization, marks, and how to run each suite
- Integration Harness — How the per-platform integration manifests are structured
Contributing
Contributions are welcome in these areas:
- Operators: Add missing operators or optimize existing implementations for specific backends.
- Runtime and platform integration: Port torch-fl to new accelerators or improve existing backend support.
- Compiler integration: Extend torch.compile support, improve Triton codegen, or add new compilation backends.
- Distributed and profiler work: Enhance collective communication backends or profiling integration.
- Tests: Add operator correctness tests, model integration tests, or performance benchmarks.
- Documentation: Improve setup guides, troubleshooting docs, or vendor-specific integration notes.
See CONTRIBUTING.md for development workflow, code generation procedures, testing requirements, and PR guidelines.
All GitHub-facing text must be written in English — PR titles, PR descriptions, commit messages, issue text, and code review comments. This repository's code, comments, and history are in English, and PRs are reviewed by contributors who do not read other languages.
Acknowledgements
torch-fl builds on several upstream projects:
- PyTorch — The PrivateUse1 extension mechanism and ATen operator surface
- FlagGems — Triton-based portable operator kernels
- Triton (via FlagTree and vendor distributions) — GPU kernel compilation infrastructure
- FlagCX — Heterogeneous collective communication library
- Vendor runtime and operator libraries — ACLNN (Ascend), mudnn (MUSA), topsaten (GCU), cu-bridge (MetaX), DTK (DCU), and others
These acknowledgements do not imply endorsement by the named projects or vendors.
License
torch-fl is licensed under the Apache License 2.0.