From e395724ba0c246b3100f63da7d93407529ecaf6a Mon Sep 17 00:00:00 2001 From: Hugh Perkins Date: Fri, 17 Jul 2026 06:09:49 -0700 Subject: [PATCH 1/7] Fix Metal native float atomics by compiling as MSL 3.0 OpAtomicFAddEXT lowers to atomic_float, which requires Metal Shading Language 3.0. Compiling with the default language version rejected the shader and then hard-aborted on a nil MTLFunction. When QD_METAL_NATIVE_FLOAT_ATOMICS=1, target SPIRV-Cross and MTLCompileOptions at MSL 3.0, and nil-check before pipeline creation. Add a headless FEM99 regression test for the historical FIXME path. --- quadrants/rhi/metal/metal_device.mm | 50 ++++++- tests/python/test_fem99_headless.py | 203 ++++++++++++++++++++++++++++ 2 files changed, 247 insertions(+), 6 deletions(-) create mode 100644 tests/python/test_fem99_headless.py diff --git a/quadrants/rhi/metal/metal_device.mm b/quadrants/rhi/metal/metal_device.mm index b871cd252c..cf68b27d8e 100644 --- a/quadrants/rhi/metal/metal_device.mm +++ b/quadrants/rhi/metal/metal_device.mm @@ -4,6 +4,9 @@ #include "quadrants/rhi/impl_support.h" #include "spirv_msl.hpp" +#include +#include + namespace quadrants::lang { namespace metal { @@ -115,6 +118,14 @@ if (feature_64_bit_integer_math) { options.set_msl_version(2, 3, 0); } + // OpAtomicFAddEXT -> SPIRV-Cross emits `atomic_float` / atomic_fetch_add_explicit, which require + // Metal Shading Language 3.0. Without this (and a matching MTLCompileOptions languageVersion in + // get_mtl_library), newLibraryWithSource fails with "unknown type name 'atomic_float'" and the + // subsequent nil computeFunction triggers an ObjC assert (Abort trap: 6). + if (caps.contains(DeviceCapability::spirv_has_atomic_float_add) || + caps.contains(DeviceCapability::spirv_has_atomic_float)) { + options.set_msl_version(3, 0, 0); + } compiler.set_msl_options(options); @@ -135,8 +146,16 @@ } MTLLibrary_id mtl_library = device.get_mtl_library(msl); + if (mtl_library == nil) { + return nullptr; + } MTLFunction_id mtl_function = device.get_mtl_function(mtl_library, std::string("main0")); + if (mtl_function == nil) { + // Avoid -[MTLComputePipelineDescriptorInternal setComputeFunction:]: `computeFunction must not + // be nil` which hard-aborts the process (Abort trap: 6) instead of returning RhiResult::error. + return nullptr; + } MTLComputePipelineState_id mtl_compute_pipeline_state = nil; { @@ -1086,11 +1105,18 @@ DeviceCapabilityConfig collect_metal_device_caps(MTLDevice_id mtl_device) { caps.set(DeviceCapability::spirv_has_atomic_int64, 1); } if (feature_floating_point_atomics) { - // FIXME: (penguinliong) For some reason floating point atomics doesn't - // work and breaks the FEM99/FEM128 examples. Should consider add them back - // figured out why. - // caps.set(DeviceCapability::spirv_has_atomic_float, 1); - // caps.set(DeviceCapability::spirv_has_atomic_float_add, 1); + // Historically left disabled (PENGUINLIONG, Taichi #7093, 2023-01): "floating point atomics + // doesn't work and breaks the FEM99/FEM128 examples." Root cause of today's hard abort when + // re-enabled: OpAtomicFAddEXT lowers via SPIRV-Cross to `atomic_float`, which needs MSL 3.0, + // but create_compute_pipeline targeted MSL 2.x and get_mtl_library used options:nil. Default + // remains CAS (uint-backed OpAtomicCompareExchange) for qd.atomic_add(f32). Set + // QD_METAL_NATIVE_FLOAT_ATOMICS=1 to opt into native Metal atomic_float / OpAtomicFAddEXT + // (also bumps SPIRV-Cross + MTLCompileOptions to MSL 3.0). + const char *env = std::getenv("QD_METAL_NATIVE_FLOAT_ATOMICS"); + if (env != nullptr && std::strcmp(env, "1") == 0) { + caps.set(DeviceCapability::spirv_has_atomic_float, 1); + caps.set(DeviceCapability::spirv_has_atomic_float_add, 1); + } } if (feature_simd_scoped_permute_operations || feature_quad_scoped_permute_operations) { caps.set(DeviceCapability::spirv_has_subgroup_vote, 1); @@ -1501,7 +1527,19 @@ void get_binding_mappings(spirv_cross::SmallVector *resou MTLLibrary_id mtl_library = nil; NSError *err = nil; NSString *msl_ns = [[NSString alloc] initWithUTF8String:source.c_str()]; - mtl_library = [mtl_device_ newLibraryWithSource:msl_ns options:nil error:&err]; + // Match SPIRV-Cross's MSL version. `atomic_float` (from OpAtomicFAddEXT) is only valid under + // MTLLanguageVersion3_0+; compiling with options:nil rejects it as "unknown type name". + MTLCompileOptions *compile_opts = nil; + DeviceCapabilityConfig caps = get_caps(); + if (caps.contains(DeviceCapability::spirv_has_atomic_float_add) || + caps.contains(DeviceCapability::spirv_has_atomic_float)) { + compile_opts = [[MTLCompileOptions alloc] init]; + if (@available(macOS 13.0, iOS 16.0, *)) { + compile_opts.languageVersion = MTLLanguageVersion3_0; + } + } + mtl_library = [mtl_device_ newLibraryWithSource:msl_ns options:compile_opts error:&err]; + [compile_opts release]; [msl_ns release]; if (mtl_library == nil) { diff --git a/tests/python/test_fem99_headless.py b/tests/python/test_fem99_headless.py new file mode 100644 index 0000000000..4ea4b3df57 --- /dev/null +++ b/tests/python/test_fem99_headless.py @@ -0,0 +1,203 @@ +"""Headless numerical repro for the alleged Metal native-float-atomic FEM99/FEM128 bug. + +Background +---------- +In Jan 2023 (Taichi #7093, PENGUINLIONG), when Metal switched to SPIR-V codegen, native float +atomics were detected for Apple7+/Mac2+ but immediately commented out with: + + FIXME: floating point atomics doesn't work and breaks the FEM99/FEM128 examples. + +Those examples were interactive autodiff neo-Hookean soft-body demos +(`python/taichi/examples/simulation/fem99.py`, later removed from Quadrants). They were NEVER +turned into a CI test, and the failure mode (wrong numbers? NaN? hang? visual explosion?) was +never written down. Upstream taichi still carries the identical FIXME. + +The critical atomic pattern in FEM99 is the scalar energy reduction under autodiff:: + + U[None] += V[i] * phi_i # parallel over faces; becomes qd.atomic_add(f32) + with qd.ad.Tape(loss=U): ... # reverse scatter also uses float atomics into pos.grad + +This file ports that pattern headlessly and checks for the symptoms we can assert without a GUI: +finite energy / positions, no blow-up, and gradients matching a CPU reference on a small case. + +A/B on Metal +------------ +Default Metal path: float atomics -> uint CAS (cap off). +Opt-in native path: ``QD_METAL_NATIVE_FLOAT_ATOMICS=1`` (see metal_device.mm). + + QD_WANTED_ARCHS=metal pytest tests/python/test_fem99_headless.py -v + QD_METAL_NATIVE_FLOAT_ATOMICS=1 QD_WANTED_ARCHS=metal pytest tests/python/test_fem99_headless.py -v +""" + +from __future__ import annotations + +import os + +import numpy as np +import pytest + +import quadrants as qd + +from tests import test_utils + + +def _native_float_atomics_env() -> bool: + return os.environ.get("QD_METAL_NATIVE_FLOAT_ATOMICS", "") == "1" + + +def _run_fem99(n_grid: int, n_frames: int, substeps: int, seed: int = 0): + """Port of the removed fem99.py, headless. Returns (U_hist, pos_final).""" + N = n_grid + dt = 1e-4 + dx = 1.0 / N + rho = 4e1 + NF = 2 * N**2 + NV = (N + 1) ** 2 + E, nu = 4e4, 0.2 + mu, lam = E / 2 / (1 + nu), E * nu / (1 + nu) / (1 - 2 * nu) + ball_pos = qd.Vector([0.5, 0.0]) + ball_radius = 0.32 + gravity = qd.Vector([0.0, -40.0]) + damping = 12.5 + + pos = qd.Vector.field(2, float, NV, needs_grad=True) + vel = qd.Vector.field(2, float, NV) + f2v = qd.Vector.field(3, int, NF) + B = qd.Matrix.field(2, 2, float, NF) + F = qd.Matrix.field(2, 2, float, NF, needs_grad=True) + V = qd.field(float, NF) + phi = qd.field(float, NF) + U = qd.field(float, (), needs_grad=True) + + @qd.kernel + def update_U(): + for i in range(NF): + ia, ib, ic = f2v[i] + a, b, c = pos[ia], pos[ib], pos[ic] + V[i] = abs((a - c).cross(b - c)) + D_i = qd.Matrix.cols([a - c, b - c]) + F[i] = D_i @ B[i] + for i in range(NF): + F_i = F[i] + log_J_i = qd.log(F_i.determinant()) + phi_i = mu / 2 * ((F_i.transpose() @ F_i).trace() - 2) + phi_i -= mu * log_J_i + phi_i += lam / 2 * log_J_i**2 + phi[i] = phi_i + # THE atomic float reduction that motivated the Metal native-float-atomic disable. + U[None] += V[i] * phi_i + + @qd.kernel + def advance(): + for i in range(NV): + acc = -pos.grad[i] / (rho * dx**2) + vel[i] += dt * (acc + gravity) + vel[i] *= qd.exp(-dt * damping) + for i in range(NV): + disp = pos[i] - ball_pos + disp2 = disp.norm_sqr() + if disp2 <= ball_radius**2: + NoV = vel[i].dot(disp) + if NoV < 0: + vel[i] -= NoV * disp / disp2 + cond = ((pos[i] < 0) & (vel[i] < 0)) | ((pos[i] > 1) & (vel[i] > 0)) + for j in qd.static(range(pos.n)): + if cond[j]: + vel[i][j] = 0 + pos[i] += dt * vel[i] + + @qd.kernel + def init_pos(): + for i, j in qd.ndrange(N + 1, N + 1): + k = i * (N + 1) + j + pos[k] = qd.Vector([i, j]) / N * 0.25 + qd.Vector([0.45, 0.45]) + vel[k] = qd.Vector([0.0, 0.0]) + for i in range(NF): + ia, ib, ic = f2v[i] + a, b, c = pos[ia], pos[ib], pos[ic] + B_i_inv = qd.Matrix.cols([a - c, b - c]) + B[i] = B_i_inv.inverse() + + @qd.kernel + def init_mesh(): + for i, j in qd.ndrange(N, N): + k = (i * N + j) * 2 + a = i * (N + 1) + j + b = a + 1 + c = a + N + 2 + d = a + N + 1 + f2v[k + 0] = [a, b, c] + f2v[k + 1] = [c, d, a] + + init_mesh() + init_pos() + + u_hist = [] + for _ in range(n_frames): + for _ in range(substeps): + with qd.ad.Tape(loss=U): + update_U() + advance() + u_hist.append(float(U[None])) + + return np.array(u_hist, dtype=np.float64), pos.to_numpy() + + +@test_utils.test(arch=[qd.cpu, qd.metal]) +def test_fem99_headless_stays_finite(): + """Does the FEM99 autodiff+atomic-reduce pattern stay numerically alive? + + On Metal this is the closest automated stand-in for the missing FEM99/FEM128 repro. + Run once with the default (CAS) path and once with QD_METAL_NATIVE_FLOAT_ATOMICS=1; if the + alleged 2023 bug still exists, the native arm should fail one of the asserts below (NaN, + explosion, or out-of-bounds positions) while CAS passes. + """ + arch = qd.lang.impl.current_cfg().arch + native = _native_float_atomics_env() + print(f"FEM99_HEADLESS arch={arch} native_float_atomics_env={int(native)}") + + # fem99 used N=32; keep it for fidelity on Metal. CPU can take the same size. + n_grid = 32 + n_frames = 5 + substeps = 30 # same as the original demo's per-frame substep count + + u_hist, pos = _run_fem99(n_grid=n_grid, n_frames=n_frames, substeps=substeps) + + assert np.isfinite(u_hist).all(), f"energy became non-finite: {u_hist} (native={native})" + assert np.isfinite(pos).all(), f"positions became non-finite (native={native})" + # Soft body starts in [0.45,0.70]^2-ish; after a few frames under gravity it should stay + # roughly in the unit square (the demo clamps at the walls). Explosion => |pos| >> 10. + assert np.max(np.abs(pos)) < 10.0, f"positions exploded: max|pos|={np.max(np.abs(pos))} (native={native})" + # Energy should not blow up by many orders of magnitude frame-to-frame. + assert np.max(np.abs(u_hist)) < 1e8, f"energy exploded: {u_hist} (native={native})" + print(f"FEM99_OK native={int(native)} u_hist={u_hist.tolist()} max|pos|={float(np.max(np.abs(pos)))}") + + +@test_utils.test(arch=[qd.cpu, qd.metal]) +def test_ad_scalar_atomic_reduce_matches_closed_form(): + """Smaller, sharper check: the exact `loss[None] += x[i]**2` pattern (test_ad_atomic) on Metal. + + If native float atomics corrupt either the forward reduction or the reverse scatter, this fails. + """ + N = 64 + x = qd.field(dtype=qd.f32, shape=N, needs_grad=True) + loss = qd.field(dtype=qd.f32, shape=(), needs_grad=True) + + @qd.kernel + def func(): + for i in x: + loss[None] += x[i] ** 2 + + for i in range(N): + x[i] = float(i) * 0.1 + + with qd.ad.Tape(loss): + func() + + expected = sum((i * 0.1) ** 2 for i in range(N)) + assert loss[None] == test_utils.approx(expected, rel=1e-4) + for i in range(N): + assert x.grad[i] == test_utils.approx(2 * i * 0.1, rel=1e-4) + + native = _native_float_atomics_env() + print(f"AD_REDUCE_OK native={int(native)} loss={float(loss[None])}") From 1ec72ad5793ef27203485c24d2b9912a1ce4eb24 Mon Sep 17 00:00:00 2001 From: Hugh Perkins Date: Fri, 17 Jul 2026 06:11:26 -0700 Subject: [PATCH 2/7] Drop long historical comment on Metal float-atomic opt-in --- quadrants/rhi/metal/metal_device.mm | 7 ------- 1 file changed, 7 deletions(-) diff --git a/quadrants/rhi/metal/metal_device.mm b/quadrants/rhi/metal/metal_device.mm index cf68b27d8e..0950004124 100644 --- a/quadrants/rhi/metal/metal_device.mm +++ b/quadrants/rhi/metal/metal_device.mm @@ -1105,13 +1105,6 @@ DeviceCapabilityConfig collect_metal_device_caps(MTLDevice_id mtl_device) { caps.set(DeviceCapability::spirv_has_atomic_int64, 1); } if (feature_floating_point_atomics) { - // Historically left disabled (PENGUINLIONG, Taichi #7093, 2023-01): "floating point atomics - // doesn't work and breaks the FEM99/FEM128 examples." Root cause of today's hard abort when - // re-enabled: OpAtomicFAddEXT lowers via SPIRV-Cross to `atomic_float`, which needs MSL 3.0, - // but create_compute_pipeline targeted MSL 2.x and get_mtl_library used options:nil. Default - // remains CAS (uint-backed OpAtomicCompareExchange) for qd.atomic_add(f32). Set - // QD_METAL_NATIVE_FLOAT_ATOMICS=1 to opt into native Metal atomic_float / OpAtomicFAddEXT - // (also bumps SPIRV-Cross + MTLCompileOptions to MSL 3.0). const char *env = std::getenv("QD_METAL_NATIVE_FLOAT_ATOMICS"); if (env != nullptr && std::strcmp(env, "1") == 0) { caps.set(DeviceCapability::spirv_has_atomic_float, 1); From d8532d795a57dc4bbc124b0391443b12d684fe1e Mon Sep 17 00:00:00 2001 From: Hugh Perkins Date: Fri, 17 Jul 2026 06:13:56 -0700 Subject: [PATCH 3/7] Enable Metal native f32 atomic_add by default on Apple7+/Mac2+ Drop the QD_METAL_NATIVE_FLOAT_ATOMICS opt-in; advertise float-atomic caps whenever the device supports them. --- quadrants/rhi/metal/metal_device.mm | 8 ++---- tests/python/test_fem99_headless.py | 38 ++++++----------------------- 2 files changed, 9 insertions(+), 37 deletions(-) diff --git a/quadrants/rhi/metal/metal_device.mm b/quadrants/rhi/metal/metal_device.mm index 0950004124..fa786d0dd5 100644 --- a/quadrants/rhi/metal/metal_device.mm +++ b/quadrants/rhi/metal/metal_device.mm @@ -5,7 +5,6 @@ #include "spirv_msl.hpp" #include -#include namespace quadrants::lang { namespace metal { @@ -1105,11 +1104,8 @@ DeviceCapabilityConfig collect_metal_device_caps(MTLDevice_id mtl_device) { caps.set(DeviceCapability::spirv_has_atomic_int64, 1); } if (feature_floating_point_atomics) { - const char *env = std::getenv("QD_METAL_NATIVE_FLOAT_ATOMICS"); - if (env != nullptr && std::strcmp(env, "1") == 0) { - caps.set(DeviceCapability::spirv_has_atomic_float, 1); - caps.set(DeviceCapability::spirv_has_atomic_float_add, 1); - } + caps.set(DeviceCapability::spirv_has_atomic_float, 1); + caps.set(DeviceCapability::spirv_has_atomic_float_add, 1); } if (feature_simd_scoped_permute_operations || feature_quad_scoped_permute_operations) { caps.set(DeviceCapability::spirv_has_subgroup_vote, 1); diff --git a/tests/python/test_fem99_headless.py b/tests/python/test_fem99_headless.py index 4ea4b3df57..074ac9fbaa 100644 --- a/tests/python/test_fem99_headless.py +++ b/tests/python/test_fem99_headless.py @@ -20,31 +20,18 @@ This file ports that pattern headlessly and checks for the symptoms we can assert without a GUI: finite energy / positions, no blow-up, and gradients matching a CPU reference on a small case. -A/B on Metal ------------- -Default Metal path: float atomics -> uint CAS (cap off). -Opt-in native path: ``QD_METAL_NATIVE_FLOAT_ATOMICS=1`` (see metal_device.mm). - QD_WANTED_ARCHS=metal pytest tests/python/test_fem99_headless.py -v - QD_METAL_NATIVE_FLOAT_ATOMICS=1 QD_WANTED_ARCHS=metal pytest tests/python/test_fem99_headless.py -v """ from __future__ import annotations -import os - import numpy as np -import pytest import quadrants as qd from tests import test_utils -def _native_float_atomics_env() -> bool: - return os.environ.get("QD_METAL_NATIVE_FLOAT_ATOMICS", "") == "1" - - def _run_fem99(n_grid: int, n_frames: int, substeps: int, seed: int = 0): """Port of the removed fem99.py, headless. Returns (U_hist, pos_final).""" N = n_grid @@ -145,17 +132,7 @@ def init_mesh(): @test_utils.test(arch=[qd.cpu, qd.metal]) def test_fem99_headless_stays_finite(): - """Does the FEM99 autodiff+atomic-reduce pattern stay numerically alive? - - On Metal this is the closest automated stand-in for the missing FEM99/FEM128 repro. - Run once with the default (CAS) path and once with QD_METAL_NATIVE_FLOAT_ATOMICS=1; if the - alleged 2023 bug still exists, the native arm should fail one of the asserts below (NaN, - explosion, or out-of-bounds positions) while CAS passes. - """ - arch = qd.lang.impl.current_cfg().arch - native = _native_float_atomics_env() - print(f"FEM99_HEADLESS arch={arch} native_float_atomics_env={int(native)}") - + """Does the FEM99 autodiff+atomic-reduce pattern stay numerically alive on Metal?""" # fem99 used N=32; keep it for fidelity on Metal. CPU can take the same size. n_grid = 32 n_frames = 5 @@ -163,14 +140,14 @@ def test_fem99_headless_stays_finite(): u_hist, pos = _run_fem99(n_grid=n_grid, n_frames=n_frames, substeps=substeps) - assert np.isfinite(u_hist).all(), f"energy became non-finite: {u_hist} (native={native})" - assert np.isfinite(pos).all(), f"positions became non-finite (native={native})" + assert np.isfinite(u_hist).all(), f"energy became non-finite: {u_hist}" + assert np.isfinite(pos).all(), "positions became non-finite" # Soft body starts in [0.45,0.70]^2-ish; after a few frames under gravity it should stay # roughly in the unit square (the demo clamps at the walls). Explosion => |pos| >> 10. - assert np.max(np.abs(pos)) < 10.0, f"positions exploded: max|pos|={np.max(np.abs(pos))} (native={native})" + assert np.max(np.abs(pos)) < 10.0, f"positions exploded: max|pos|={np.max(np.abs(pos))}" # Energy should not blow up by many orders of magnitude frame-to-frame. - assert np.max(np.abs(u_hist)) < 1e8, f"energy exploded: {u_hist} (native={native})" - print(f"FEM99_OK native={int(native)} u_hist={u_hist.tolist()} max|pos|={float(np.max(np.abs(pos)))}") + assert np.max(np.abs(u_hist)) < 1e8, f"energy exploded: {u_hist}" + print(f"FEM99_OK u_hist={u_hist.tolist()} max|pos|={float(np.max(np.abs(pos)))}") @test_utils.test(arch=[qd.cpu, qd.metal]) @@ -199,5 +176,4 @@ def func(): for i in range(N): assert x.grad[i] == test_utils.approx(2 * i * 0.1, rel=1e-4) - native = _native_float_atomics_env() - print(f"AD_REDUCE_OK native={int(native)} loss={float(loss[None])}") + print(f"AD_REDUCE_OK loss={float(loss[None])}") From ecd8c07c20a5681e52a12e585d9d33e38b39f4bc Mon Sep 17 00:00:00 2001 From: Hugh Perkins Date: Fri, 17 Jul 2026 06:16:11 -0700 Subject: [PATCH 4/7] docs(atomics): document Metal native global f32 atomic_add Apple7+/Mac2+ now advertise spirv_has_atomic_float_add; shared / f16 / f64 / float min-max remain on the CAS path. --- docs/source/user_guide/atomics.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/source/user_guide/atomics.md b/docs/source/user_guide/atomics.md index d30d4a1968..4cb026f921 100644 --- a/docs/source/user_guide/atomics.md +++ b/docs/source/user_guide/atomics.md @@ -12,7 +12,7 @@ All atomic ops follow the same shape: `qd.atomic_op(x, y)` performs `x = op(x, y | Op | CUDA | AMDGPU | SPIR-V (Vulkan / Metal) | CPU | |---------------------------------------------|--------------------------------------------|---------------------------------------|--------------------------------------------------------|----------------------------------| -| `atomic_add` | int / f32 native; f64 native (sm_60+) | int / f32 native; f64 hardware-dependent | int native; f16 / f32 / f64 capability-gated, else CAS | int / f32 / f64 native; f16 via CAS | +| `atomic_add` | int / f32 native; f64 native (sm_60+) | int / f32 native; f64 hardware-dependent | int native; Metal global f32 native (Apple7+/Mac2+); Vulkan f16 / f32 / f64 capability-gated, else CAS; Metal f16 / f64 / shared float via CAS | int / f32 / f64 native; f16 via CAS | | `atomic_sub` | rewritten to `atomic_add(x, -y)` at IR-construction time — see note below | (same) | (same) | (same) | | `atomic_mul` | CAS on every dtype | CAS | CAS | CAS | | `atomic_min`, `atomic_max` | int native; floats via CAS | int native; floats via CAS | int native; floats via CAS | int native; floats via CAS | @@ -26,7 +26,8 @@ A few cross-cutting notes that the cells above abbreviate: - **CAS-loop ops are noticeably slower than native atomics**, especially under contention — every contending thread retries the load + compare-exchange until it wins. Prefer pre-aggregating into a register or shared array and issuing a single atomic at the end of the block where possible. - **f16 floats always use a CAS loop** (no native f16 atomic on any backend except SPIR-V with the right capability bit). - **On CPU, "native" does not guarantee a single machine instruction.** On x86 and other architectures without hardware float atomics, the compiler backend lowers native float `atomic_add` (and integer `min` / `max`) to a CAS loop in machine code. Under high contention the performance is similar to the explicit "CAS" entries; the difference is that "native" ops benefit from hardware acceleration where available. -- **SPIR-V capability bits** (`spirv_has_atomic_float_add`, `spirv_has_atomic_float64_add`, `spirv_has_atomic_float16_add`) decide whether `atomic_add` lowers to native `OpAtomicFAddEXT` or a uint-backed CAS — the dispatch happens per-call inside `quadrants/codegen/spirv/spirv_codegen.cpp`. +- **SPIR-V capability bits** (`spirv_has_atomic_float_add`, `spirv_has_atomic_float64_add`, `spirv_has_atomic_float16_add`, plus the matching `spirv_has_shared_atomic_float*_add` bits for workgroup memory) decide whether `atomic_add` lowers to native `OpAtomicFAddEXT` or a uint-backed CAS — the dispatch happens per-call inside `quadrants/codegen/spirv/spirv_codegen.cpp`. +- **Metal float `atomic_add`.** On Apple7+ / Mac2+ the Metal RHI advertises `spirv_has_atomic_float` / `spirv_has_atomic_float_add`, so global / device-buffer `f32` `atomic_add` (and `atomic_sub`, rewritten to add) lowers to `OpAtomicFAddEXT` → MSL `atomic_fetch_add` on `device atomic_float`. That path requires MSL 3.0 (`MTLLanguageVersion3_0` + SPIRV-Cross `set_msl_version(3,0,0)`). Metal does **not** support threadgroup `atomic_float`, and has no native float min/max / f16 / f64 atomics, so shared float atomics and those dtypes stay on the uint CAS path. Vulkan still reads the same capability bits from `VK_EXT_shader_atomic_float*`. - **`i64` / `u64` atomic RMW is not portable to Metal.** Metal Shading Language only exposes 64-bit atomics as `atomic_fetch_min` / `atomic_fetch_max` on `uint64` (Apple GPU family 9+, M3 / A17); `atomic_add` / `sub` / `mul` and the bitwise family are unavailable on every Apple GPU. The Metal RHI today over-advertises `spirv_has_atomic_int64` (gated on Apple7 / Mac2 in `quadrants/rhi/metal/metal_device.mm`), so 64-bit integer atomics under Metal fail at pipeline create time with `RhiResult=-1`. Use `i32` / `u32` for Metal portability. CUDA, AMDGPU, and Vulkan with `VK_KHR_shader_atomic_int64` are unaffected. † `i64` / `u64` atomic RMW is **not portable to Metal**. Metal Shading Language only exposes 64-bit atomics as `atomic_fetch_min` / `atomic_fetch_max` on `uint64`, starting at Apple GPU family 9 (M3 / A17 and newer); `atomic_add` / `sub` / `mul` and the bitwise family are unavailable on every Apple GPU. The Metal RHI today over-advertises `spirv_has_atomic_int64` (gated on Apple7 / Mac2 in `quadrants/rhi/metal/metal_device.mm`), so trying to use 64-bit integer atomics under Metal currently fails at pipeline create time with `RhiResult=-1` ("SPIR-V shader was rejected by the backend"). Use `i32` / `u32` if you need cross-Metal portability. CUDA, AMDGPU, and Vulkan with `VK_KHR_shader_atomic_int64` are unaffected. @@ -244,7 +245,7 @@ Key: - `gfx942` (CDNA3 / MI300X, Quadrants' default AMDGPU target): `atomic_add` f32 / f64 are native (`flat_atomic_add_f32` / `_f64`), `atomic_min` / `max` f64 are native (`flat_atomic_min_f64` / `max_f64`); f32 min/max still expand to CAS. - `gfx906`, `gfx90a`, `gfx1030`, `gfx1100`: all f32 / f64 float atomics expand to CAS. -³ SPIR-V float `atomic_add` lowers to `OpAtomicFAddEXT` when the matching `spirv_has_atomic_float{32,64}_add` capability is present on the device, and to a CAS loop with a GLSL.std.450 payload otherwise. Quadrants does not currently emit `OpAtomicFMinEXT` / `OpAtomicFMaxEXT`, so float min/max is always CAS on SPIR-V backends. +³ SPIR-V float `atomic_add` lowers to `OpAtomicFAddEXT` when the matching `spirv_has_atomic_float{,64,16}_add` capability is present on the device, and to a CAS loop with a GLSL.std.450 payload otherwise. Metal always advertises global f32 add on Apple7+/Mac2+ (see bullet above); Vulkan is capability-gated per `VK_EXT_shader_atomic_float*`. Shared float add needs `spirv_has_shared_atomic_float*_add`, which Metal never sets. Quadrants does not currently emit `OpAtomicFMinEXT` / `OpAtomicFMaxEXT`, so float min/max is always CAS on SPIR-V backends. ## Related From b7113bb744e776c38282f4c72311cc9afdb6b7eb Mon Sep 17 00:00:00 2001 From: Hugh Perkins Date: Fri, 17 Jul 2026 06:18:07 -0700 Subject: [PATCH 5/7] docs(atomics): clarify atomic_sub '(same)' means same as atomic_add --- docs/source/user_guide/atomics.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/user_guide/atomics.md b/docs/source/user_guide/atomics.md index 4cb026f921..7f163bd1bc 100644 --- a/docs/source/user_guide/atomics.md +++ b/docs/source/user_guide/atomics.md @@ -13,7 +13,7 @@ All atomic ops follow the same shape: `qd.atomic_op(x, y)` performs `x = op(x, y | Op | CUDA | AMDGPU | SPIR-V (Vulkan / Metal) | CPU | |---------------------------------------------|--------------------------------------------|---------------------------------------|--------------------------------------------------------|----------------------------------| | `atomic_add` | int / f32 native; f64 native (sm_60+) | int / f32 native; f64 hardware-dependent | int native; Metal global f32 native (Apple7+/Mac2+); Vulkan f16 / f32 / f64 capability-gated, else CAS; Metal f16 / f64 / shared float via CAS | int / f32 / f64 native; f16 via CAS | -| `atomic_sub` | rewritten to `atomic_add(x, -y)` at IR-construction time — see note below | (same) | (same) | (same) | +| `atomic_sub` | rewritten to `atomic_add(x, -y)` at IR-construction time — see note below | (same as `atomic_add`) | (same as `atomic_add`) | (same as `atomic_add`) | | `atomic_mul` | CAS on every dtype | CAS | CAS | CAS | | `atomic_min`, `atomic_max` | int native; floats via CAS | int native; floats via CAS | int native; floats via CAS | int native; floats via CAS | | `atomic_and`, `atomic_or`, `atomic_xor` | int only (native) | int only (native) | int only (native) | int only (native) | From 5d0dab0cbbb30cc88ceedf070e0741422a58b505 Mon Sep 17 00:00:00 2001 From: Hugh Perkins Date: Fri, 17 Jul 2026 06:19:34 -0700 Subject: [PATCH 6/7] docs(atomics): replace non-ASCII punctuation on PR-added lines Em/en dashes to single hyphen, arrow to ->, superscript 3 to ASCII. --- docs/source/user_guide/atomics.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/source/user_guide/atomics.md b/docs/source/user_guide/atomics.md index 7f163bd1bc..e6959d2ba1 100644 --- a/docs/source/user_guide/atomics.md +++ b/docs/source/user_guide/atomics.md @@ -13,7 +13,7 @@ All atomic ops follow the same shape: `qd.atomic_op(x, y)` performs `x = op(x, y | Op | CUDA | AMDGPU | SPIR-V (Vulkan / Metal) | CPU | |---------------------------------------------|--------------------------------------------|---------------------------------------|--------------------------------------------------------|----------------------------------| | `atomic_add` | int / f32 native; f64 native (sm_60+) | int / f32 native; f64 hardware-dependent | int native; Metal global f32 native (Apple7+/Mac2+); Vulkan f16 / f32 / f64 capability-gated, else CAS; Metal f16 / f64 / shared float via CAS | int / f32 / f64 native; f16 via CAS | -| `atomic_sub` | rewritten to `atomic_add(x, -y)` at IR-construction time — see note below | (same as `atomic_add`) | (same as `atomic_add`) | (same as `atomic_add`) | +| `atomic_sub` | rewritten to `atomic_add(x, -y)` at IR-construction time - see note below | (same as `atomic_add`) | (same as `atomic_add`) | (same as `atomic_add`) | | `atomic_mul` | CAS on every dtype | CAS | CAS | CAS | | `atomic_min`, `atomic_max` | int native; floats via CAS | int native; floats via CAS | int native; floats via CAS | int native; floats via CAS | | `atomic_and`, `atomic_or`, `atomic_xor` | int only (native) | int only (native) | int only (native) | int only (native) | @@ -26,8 +26,8 @@ A few cross-cutting notes that the cells above abbreviate: - **CAS-loop ops are noticeably slower than native atomics**, especially under contention — every contending thread retries the load + compare-exchange until it wins. Prefer pre-aggregating into a register or shared array and issuing a single atomic at the end of the block where possible. - **f16 floats always use a CAS loop** (no native f16 atomic on any backend except SPIR-V with the right capability bit). - **On CPU, "native" does not guarantee a single machine instruction.** On x86 and other architectures without hardware float atomics, the compiler backend lowers native float `atomic_add` (and integer `min` / `max`) to a CAS loop in machine code. Under high contention the performance is similar to the explicit "CAS" entries; the difference is that "native" ops benefit from hardware acceleration where available. -- **SPIR-V capability bits** (`spirv_has_atomic_float_add`, `spirv_has_atomic_float64_add`, `spirv_has_atomic_float16_add`, plus the matching `spirv_has_shared_atomic_float*_add` bits for workgroup memory) decide whether `atomic_add` lowers to native `OpAtomicFAddEXT` or a uint-backed CAS — the dispatch happens per-call inside `quadrants/codegen/spirv/spirv_codegen.cpp`. -- **Metal float `atomic_add`.** On Apple7+ / Mac2+ the Metal RHI advertises `spirv_has_atomic_float` / `spirv_has_atomic_float_add`, so global / device-buffer `f32` `atomic_add` (and `atomic_sub`, rewritten to add) lowers to `OpAtomicFAddEXT` → MSL `atomic_fetch_add` on `device atomic_float`. That path requires MSL 3.0 (`MTLLanguageVersion3_0` + SPIRV-Cross `set_msl_version(3,0,0)`). Metal does **not** support threadgroup `atomic_float`, and has no native float min/max / f16 / f64 atomics, so shared float atomics and those dtypes stay on the uint CAS path. Vulkan still reads the same capability bits from `VK_EXT_shader_atomic_float*`. +- **SPIR-V capability bits** (`spirv_has_atomic_float_add`, `spirv_has_atomic_float64_add`, `spirv_has_atomic_float16_add`, plus the matching `spirv_has_shared_atomic_float*_add` bits for workgroup memory) decide whether `atomic_add` lowers to native `OpAtomicFAddEXT` or a uint-backed CAS - the dispatch happens per-call inside `quadrants/codegen/spirv/spirv_codegen.cpp`. +- **Metal float `atomic_add`.** On Apple7+ / Mac2+ the Metal RHI advertises `spirv_has_atomic_float` / `spirv_has_atomic_float_add`, so global / device-buffer `f32` `atomic_add` (and `atomic_sub`, rewritten to add) lowers to `OpAtomicFAddEXT` -> MSL `atomic_fetch_add` on `device atomic_float`. That path requires MSL 3.0 (`MTLLanguageVersion3_0` + SPIRV-Cross `set_msl_version(3,0,0)`). Metal does **not** support threadgroup `atomic_float`, and has no native float min/max / f16 / f64 atomics, so shared float atomics and those dtypes stay on the uint CAS path. Vulkan still reads the same capability bits from `VK_EXT_shader_atomic_float*`. - **`i64` / `u64` atomic RMW is not portable to Metal.** Metal Shading Language only exposes 64-bit atomics as `atomic_fetch_min` / `atomic_fetch_max` on `uint64` (Apple GPU family 9+, M3 / A17); `atomic_add` / `sub` / `mul` and the bitwise family are unavailable on every Apple GPU. The Metal RHI today over-advertises `spirv_has_atomic_int64` (gated on Apple7 / Mac2 in `quadrants/rhi/metal/metal_device.mm`), so 64-bit integer atomics under Metal fail at pipeline create time with `RhiResult=-1`. Use `i32` / `u32` for Metal portability. CUDA, AMDGPU, and Vulkan with `VK_KHR_shader_atomic_int64` are unaffected. † `i64` / `u64` atomic RMW is **not portable to Metal**. Metal Shading Language only exposes 64-bit atomics as `atomic_fetch_min` / `atomic_fetch_max` on `uint64`, starting at Apple GPU family 9 (M3 / A17 and newer); `atomic_add` / `sub` / `mul` and the bitwise family are unavailable on every Apple GPU. The Metal RHI today over-advertises `spirv_has_atomic_int64` (gated on Apple7 / Mac2 in `quadrants/rhi/metal/metal_device.mm`), so trying to use 64-bit integer atomics under Metal currently fails at pipeline create time with `RhiResult=-1` ("SPIR-V shader was rejected by the backend"). Use `i32` / `u32` if you need cross-Metal portability. CUDA, AMDGPU, and Vulkan with `VK_KHR_shader_atomic_int64` are unaffected. @@ -245,7 +245,7 @@ Key: - `gfx942` (CDNA3 / MI300X, Quadrants' default AMDGPU target): `atomic_add` f32 / f64 are native (`flat_atomic_add_f32` / `_f64`), `atomic_min` / `max` f64 are native (`flat_atomic_min_f64` / `max_f64`); f32 min/max still expand to CAS. - `gfx906`, `gfx90a`, `gfx1030`, `gfx1100`: all f32 / f64 float atomics expand to CAS. -³ SPIR-V float `atomic_add` lowers to `OpAtomicFAddEXT` when the matching `spirv_has_atomic_float{,64,16}_add` capability is present on the device, and to a CAS loop with a GLSL.std.450 payload otherwise. Metal always advertises global f32 add on Apple7+/Mac2+ (see bullet above); Vulkan is capability-gated per `VK_EXT_shader_atomic_float*`. Shared float add needs `spirv_has_shared_atomic_float*_add`, which Metal never sets. Quadrants does not currently emit `OpAtomicFMinEXT` / `OpAtomicFMaxEXT`, so float min/max is always CAS on SPIR-V backends. +3. SPIR-V float `atomic_add` lowers to `OpAtomicFAddEXT` when the matching `spirv_has_atomic_float{,64,16}_add` capability is present on the device, and to a CAS loop with a GLSL.std.450 payload otherwise. Metal always advertises global f32 add on Apple7+/Mac2+ (see bullet above); Vulkan is capability-gated per `VK_EXT_shader_atomic_float*`. Shared float add needs `spirv_has_shared_atomic_float*_add`, which Metal never sets. Quadrants does not currently emit `OpAtomicFMinEXT` / `OpAtomicFMaxEXT`, so float min/max is always CAS on SPIR-V backends. ## Related From 0d401e51e53183c75c1d9270c7081de0241ff7a4 Mon Sep 17 00:00:00 2001 From: Hugh Perkins Date: Tue, 21 Jul 2026 13:38:15 -0700 Subject: [PATCH 7/7] docs(atomics): keep backend internals out of the end-user path; wrap fem99 test docstring at 120 Addresses the "Check doc quality" and "Check line wrapping" CI checks on PR #788. atomics.md (doc quality): - Drop internal source-path references (frontend_ir.cpp, spirv_codegen.cpp, metal_device.mm) and internal build-config details (MTLLanguageVersion3_0 / SPIRV-Cross set_msl_version) from the user-facing notes. - Define "MSL" at first use, replace the undefined "RHI" abbreviation with "Quadrants", and replace "snodes" with "quantized fields". - Move the volatile-load lowering table + suppressed-optimization notes under an "Under the hood" heading, and mark the atomic-visibility-scope section "(advanced)", so the IR/backend-mechanics tables sit in clearly-internal sections. - Remove the redundant duplicate i64/u64 footnote. test_fem99_headless.py (line wrapping): - Reflow the module docstring prose and a comment from ~68-93c to 120c. --- docs/source/user_guide/atomics.md | 22 +++++++++++----------- tests/python/test_fem99_headless.py | 19 +++++++++---------- 2 files changed, 20 insertions(+), 21 deletions(-) diff --git a/docs/source/user_guide/atomics.md b/docs/source/user_guide/atomics.md index e6959d2ba1..d5a8baade8 100644 --- a/docs/source/user_guide/atomics.md +++ b/docs/source/user_guide/atomics.md @@ -22,15 +22,13 @@ All atomic ops follow the same shape: `qd.atomic_op(x, y)` performs `x = op(x, y A few cross-cutting notes that the cells above abbreviate: -- **`atomic_sub` is not a separate op in the IR.** `quadrants/ir/frontend_ir.cpp::AtomicOpExpression::flatten` rewrites every `atomic_sub(x, y)` into `atomic_add(x, -y)` before codegen sees it, so per-backend support and per-dtype behavior are exactly those of `atomic_add`. +- **`atomic_sub` is not a separate operation.** Every `atomic_sub(x, y)` is rewritten into `atomic_add(x, -y)` before code generation, so per-backend support and per-dtype behavior are exactly those of `atomic_add`. - **CAS-loop ops are noticeably slower than native atomics**, especially under contention — every contending thread retries the load + compare-exchange until it wins. Prefer pre-aggregating into a register or shared array and issuing a single atomic at the end of the block where possible. - **f16 floats always use a CAS loop** (no native f16 atomic on any backend except SPIR-V with the right capability bit). - **On CPU, "native" does not guarantee a single machine instruction.** On x86 and other architectures without hardware float atomics, the compiler backend lowers native float `atomic_add` (and integer `min` / `max`) to a CAS loop in machine code. Under high contention the performance is similar to the explicit "CAS" entries; the difference is that "native" ops benefit from hardware acceleration where available. -- **SPIR-V capability bits** (`spirv_has_atomic_float_add`, `spirv_has_atomic_float64_add`, `spirv_has_atomic_float16_add`, plus the matching `spirv_has_shared_atomic_float*_add` bits for workgroup memory) decide whether `atomic_add` lowers to native `OpAtomicFAddEXT` or a uint-backed CAS - the dispatch happens per-call inside `quadrants/codegen/spirv/spirv_codegen.cpp`. -- **Metal float `atomic_add`.** On Apple7+ / Mac2+ the Metal RHI advertises `spirv_has_atomic_float` / `spirv_has_atomic_float_add`, so global / device-buffer `f32` `atomic_add` (and `atomic_sub`, rewritten to add) lowers to `OpAtomicFAddEXT` -> MSL `atomic_fetch_add` on `device atomic_float`. That path requires MSL 3.0 (`MTLLanguageVersion3_0` + SPIRV-Cross `set_msl_version(3,0,0)`). Metal does **not** support threadgroup `atomic_float`, and has no native float min/max / f16 / f64 atomics, so shared float atomics and those dtypes stay on the uint CAS path. Vulkan still reads the same capability bits from `VK_EXT_shader_atomic_float*`. -- **`i64` / `u64` atomic RMW is not portable to Metal.** Metal Shading Language only exposes 64-bit atomics as `atomic_fetch_min` / `atomic_fetch_max` on `uint64` (Apple GPU family 9+, M3 / A17); `atomic_add` / `sub` / `mul` and the bitwise family are unavailable on every Apple GPU. The Metal RHI today over-advertises `spirv_has_atomic_int64` (gated on Apple7 / Mac2 in `quadrants/rhi/metal/metal_device.mm`), so 64-bit integer atomics under Metal fail at pipeline create time with `RhiResult=-1`. Use `i32` / `u32` for Metal portability. CUDA, AMDGPU, and Vulkan with `VK_KHR_shader_atomic_int64` are unaffected. - -† `i64` / `u64` atomic RMW is **not portable to Metal**. Metal Shading Language only exposes 64-bit atomics as `atomic_fetch_min` / `atomic_fetch_max` on `uint64`, starting at Apple GPU family 9 (M3 / A17 and newer); `atomic_add` / `sub` / `mul` and the bitwise family are unavailable on every Apple GPU. The Metal RHI today over-advertises `spirv_has_atomic_int64` (gated on Apple7 / Mac2 in `quadrants/rhi/metal/metal_device.mm`), so trying to use 64-bit integer atomics under Metal currently fails at pipeline create time with `RhiResult=-1` ("SPIR-V shader was rejected by the backend"). Use `i32` / `u32` if you need cross-Metal portability. CUDA, AMDGPU, and Vulkan with `VK_KHR_shader_atomic_int64` are unaffected. +- **SPIR-V capability bits** (`spirv_has_atomic_float_add`, `spirv_has_atomic_float64_add`, `spirv_has_atomic_float16_add`, plus the matching `spirv_has_shared_atomic_float*_add` bits for workgroup memory) decide whether `atomic_add` lowers to a native `OpAtomicFAddEXT` or a uint-backed CAS; the choice is made per call at code-generation time. +- **Metal float `atomic_add`.** On Apple7+ / Mac2+ Quadrants advertises `spirv_has_atomic_float` / `spirv_has_atomic_float_add`, so global / device-buffer `f32` `atomic_add` (and `atomic_sub`, rewritten to add) lowers to `OpAtomicFAddEXT`: a native `atomic_fetch_add` on a `device atomic_float` in the generated Metal Shading Language (MSL). That path requires the MSL 3.0 target, which Quadrants selects automatically. Metal does **not** support threadgroup `atomic_float`, and has no native float min/max / f16 / f64 atomics, so shared float atomics and those dtypes stay on the uint CAS path. Vulkan still reads the same capability bits from `VK_EXT_shader_atomic_float*`. +- **`i64` / `u64` atomic RMW is not portable to Metal.** MSL only exposes 64-bit atomics as `atomic_fetch_min` / `atomic_fetch_max` on `uint64` (Apple GPU family 9+, M3 / A17); `atomic_add` / `sub` / `mul` and the bitwise family are unavailable on every Apple GPU. On Metal, 64-bit integer atomics currently fail when the kernel is compiled. Use `i32` / `u32` for Metal portability. CUDA, AMDGPU, and Vulkan with `VK_KHR_shader_atomic_int64` are unaffected. ‡ `atomic_exchange` on `f16`, on shared (`qd.simt.block.SharedArray`) float arrays, and on f64 in workgroup memory is not yet wired up. Global-memory `atomic_exchange` on every other dtype/backend combination listed above is supported; the SPIR-V path bitcasts through the corresponding uint type so no `spirv_has_atomic_float_*` capability is required. @@ -72,7 +70,7 @@ Bitwise atomics. Integer dtypes only — passing `f32` / `f64` raises a type err ### `qd.atomic_sub(x, y)` / `qd.atomic_mul(x, y)` -Atomic subtract and atomic multiply. `atomic_sub` is rewritten to `atomic_add(x, -y)` at IR-construction time (`quadrants/ir/frontend_ir.cpp::AtomicOpExpression::flatten`), so its per-backend behavior is identical to `atomic_add`. `atomic_mul` always lowers to a CAS loop - no LLVM AtomicRMW or SPIR-V `OpAtomic*` op corresponds to multiply - and is intentionally not heavily optimized; prefer reducing to a different scheme on hot paths. +Atomic subtract and atomic multiply. `atomic_sub` is rewritten to `atomic_add(x, -y)` before code generation, so its per-backend behavior is identical to `atomic_add`. `atomic_mul` always lowers to a CAS loop - no single hardware atomic corresponds to multiply - and is intentionally not heavily optimized; prefer reducing to a different scheme on hot paths. ### `qd.atomic_exchange(x, y)` @@ -132,7 +130,7 @@ def cas_loop_max(): # Otherwise some other thread won the race; loop back and re-read. ``` -Currently restricted to integer dtypes (`i32` / `u32` / `i64` / `u64`); float CAS is rejected at compile time. The Metal `i64` / `u64` caveat in the support table footnote applies here too. There is no shared-memory CAS path yet. +Currently restricted to integer dtypes (`i32` / `u32` / `i64` / `u64`); float CAS is rejected at compile time. The Metal `i64` / `u64` caveat noted in the atomics table above applies here too. There is no shared-memory CAS path yet. ### `qd.volatile_load(target)` @@ -145,7 +143,9 @@ val = qd.volatile_load(target) # not be reused from a register or hoisted out of an enclosing loop. ``` -`target` must be a global lvalue (a field or ndarray subscript); function-scope local arrays are rejected because a local cannot be observed by another thread. Bit-packed quant snodes are also rejected (per-field volatile semantics on a shared physical word are not meaningful). +`target` must be a global lvalue (a field or ndarray subscript); function-scope local arrays are rejected because a local cannot be observed by another thread. Bit-packed quantized fields are also rejected (per-field volatile semantics on a shared physical word are not meaningful). + +#### Under the hood: lowering and suppressed optimizations | Backend | Lowering | |------------------|-------------------------------------------------------------------------------------------| @@ -154,7 +154,7 @@ val = qd.volatile_load(target) | Vulkan / Metal | SPIR-V `OpLoad` with the `Volatile` `MemoryAccess` mask, propagated through SPIRV-Cross to a re-read on every use in the generated MSL / GLSL. | | CPU (x86_64) | LLVM `load volatile` (the optimizer cannot hoist or merge it; the runtime cost is identical to an ordinary load on x86). | -Quadrants additionally suppresses the optimizations that would otherwise let an aliased rewrite slip past codegen: +Quadrants additionally suppresses the optimizations that would otherwise let an aliased rewrite slip past code generation: - `cache_loop_invariant_global_vars` does not hoist a volatile load out of an enclosing loop. - `simplify` does not replace a volatile load with the value of an earlier load of the same address. @@ -196,7 +196,7 @@ The decoupled-look-back scan in [grid](grid.md) shows the full pattern. - **`f64` atomics fall off the fast path** on most backends; if you only need monotonic accumulation, consider Kahan summation in registers and a single atomic-add at the end of the block. - **`atomic_mul` is generally a CAS loop** under the hood; don't put it on the hot path. -### Atomic visibility scope across backends +### Atomic visibility scope across backends (advanced) Every `qd.atomic_*` is emitted at **device-wide scope**: visible to all threads on the GPU executing the kernel, but not required to be coherent with the host CPU mid-kernel. The host only observes results once the kernel completes, at which point the launcher's stream-sync flushes everything regardless. Choosing device scope (rather than the strongest "system" scope) lets every backend lower the op to a single hardware atomic instruction instead of a software CAS retry loop, which matters for correctness as much as for speed: under heavy contention, a CAS loop on a non-converging op like `atomic_xor` can livelock. diff --git a/tests/python/test_fem99_headless.py b/tests/python/test_fem99_headless.py index 074ac9fbaa..ceab9a4309 100644 --- a/tests/python/test_fem99_headless.py +++ b/tests/python/test_fem99_headless.py @@ -2,23 +2,22 @@ Background ---------- -In Jan 2023 (Taichi #7093, PENGUINLIONG), when Metal switched to SPIR-V codegen, native float -atomics were detected for Apple7+/Mac2+ but immediately commented out with: +In Jan 2023 (Taichi #7093, PENGUINLIONG), when Metal switched to SPIR-V codegen, native float atomics were detected for +Apple7+/Mac2+ but immediately commented out with: FIXME: floating point atomics doesn't work and breaks the FEM99/FEM128 examples. -Those examples were interactive autodiff neo-Hookean soft-body demos -(`python/taichi/examples/simulation/fem99.py`, later removed from Quadrants). They were NEVER -turned into a CI test, and the failure mode (wrong numbers? NaN? hang? visual explosion?) was -never written down. Upstream taichi still carries the identical FIXME. +Those examples were interactive autodiff neo-Hookean soft-body demos (`python/taichi/examples/simulation/fem99.py`, +later removed from Quadrants). They were NEVER turned into a CI test, and the failure mode (wrong numbers? NaN? hang? +visual explosion?) was never written down. Upstream taichi still carries the identical FIXME. The critical atomic pattern in FEM99 is the scalar energy reduction under autodiff:: U[None] += V[i] * phi_i # parallel over faces; becomes qd.atomic_add(f32) with qd.ad.Tape(loss=U): ... # reverse scatter also uses float atomics into pos.grad -This file ports that pattern headlessly and checks for the symptoms we can assert without a GUI: -finite energy / positions, no blow-up, and gradients matching a CPU reference on a small case. +This file ports that pattern headlessly and checks for the symptoms we can assert without a GUI: finite energy / +positions, no blow-up, and gradients matching a CPU reference on a small case. QD_WANTED_ARCHS=metal pytest tests/python/test_fem99_headless.py -v """ @@ -142,8 +141,8 @@ def test_fem99_headless_stays_finite(): assert np.isfinite(u_hist).all(), f"energy became non-finite: {u_hist}" assert np.isfinite(pos).all(), "positions became non-finite" - # Soft body starts in [0.45,0.70]^2-ish; after a few frames under gravity it should stay - # roughly in the unit square (the demo clamps at the walls). Explosion => |pos| >> 10. + # Soft body starts in [0.45,0.70]^2-ish; after a few frames under gravity it should stay roughly in the unit square + # (the demo clamps at the walls). Explosion => |pos| >> 10. assert np.max(np.abs(pos)) < 10.0, f"positions exploded: max|pos|={np.max(np.abs(pos))}" # Energy should not blow up by many orders of magnitude frame-to-frame. assert np.max(np.abs(u_hist)) < 1e8, f"energy exploded: {u_hist}"