Skip to content

agx: exact division, log and sin/cos for compute; graphics keeps the base math - #14

Draft
joshuaswarren wants to merge 22 commits into
omacom:integration/aurorafrom
joshuaswarren:aurora-correctness-fixes
Draft

joshuaswarren wants to merge 22 commits into
omacom:integration/aurorafrom
joshuaswarren:aurora-correctness-fixes

Conversation

@joshuaswarren

@joshuaswarren joshuaswarren commented Oct 25, 2026 •

Copy link
Copy Markdown

Current status: NOT READY TO MERGE / CPU regressions fixed (29 October)

Current head: c9cdabbd1a7dbe36290eae553d3c6aea6d3d7f29, on Aurora 672657e05e6b382a0203975f7a26c46f9a674973.

The clean failing-before run at 7f9dd7cddfc46a27e90709e0b5b54aceae16529a failed exactly the two new graphics tests on both architectures (run 40967791964). The minimal shared correction now lowers fp16 graphics division like fp32 and restores exact integer-division reciprocal refinement in graphics. The compute branch and test assertions were unchanged.

Full CI is now green on x86_64 and aarch64. This is CPU/compiler evidence, not on-device graphics or numerical qualification. Exact-head source review, Chris's graphics/math/precision hold, GPU precision/graphics comparison and compute qualification remain. No READY claim.

Historical evidence and original PR description (head bindings remain as recorded)

NOT READY TO MERGE

Head: b717bba9992d55a95de848389b707a54e72c8d31.

GitHub reports MERGEABLE against integration/aurora: no text conflicts. This is not a test pass. Its merge status is UNSTABLE.

This public head is still the candidate. The graphics 16-bit fdiv has a blocking source defect: it survives lowering, but the backend has no fdiv instruction. The recorded 352/352 CPU tests do not cover that case. The new fixture has not run, and production code is not yet fixed. Prove the failure, fix the shared lowering, run the final tests and obtain the focused source review. Chris's graphics/math/precision hold remains open. Local da4deb0 measurements belong to another commit and do not qualify this head. Keep this PR in draft.

Earlier review, source and test notes are kept below with their original bindings. They do not change this status.


Status: DRAFT until a GPU run

This PR is now the compiler math only. The memory fixes moved to #15. The head changed on 25 Oct. It drops them and adds one commit that keeps the cost out of graphics.

What it changes (17 commits)

  • Compute shaders: division and reciprocal round correctly. Log and log2 are faithful. Sin and cos use an exact range reduction. The native G17P sin and cos sequence is kept for G17P.
  • Late division: 64-bit integer conversion and offset interpolation emit an fdiv after preprocessing. The backend has no fdiv, so a late pass lowers it.
  • Tests: unit tests for the division bounds and the late lowering, and a repaired fp32 error oracle with 7 CPU controls.
  • The last commit (agx: keep the exact division, log and sin/cos lowering for compute) limits the exact sequences to compute. Graphics keeps the base reciprocal, quotient, log, sin and cos. A late fdiv in graphics becomes a multiply by the hardware reciprocal.

Why the last commit: a same-boot A/B on the M1 of the earlier head showed about 18 percent lower vkmark and 7 percent lower vkQuake. The M3 Pro was unchanged. The exact division explains the vkmark loss, and the log pass explains vkQuake. The Vulkan precision table asks for 2.5 ULP for division. It gives limits for log, log2 and pow. It does not ask for exact results.

MEASURED, native arm64 build of exactly this head on a 16-inch M1 Max, CPU only:

  • The five no-submit compiler probes pass (G13G, G15S, G15C, G17P, G15G).
  • The AGX compiler unit tests: 352 of 352 pass. The 7 CPU controls of the error oracle pass.
  • meson test: 120 pass and 19 fail. The same 19 fail on the base built the same way.

MEASURED earlier, on a whole-suite CTS run of the #6 stack on the 16-inch M1 Max: none of the 34 Fail to Pass tests was a precision test. 30 were memory model tests, fixed by the image visibility change now in #15. The other 4 were timing tests. So the precision groups of the CTS pass on the base on that chip.

NOT measured:

  • Any GPU run of this head, the CTS on this head, and the vkmark and vkQuake A/B of this head. That graphics now takes the base path is by construction, not measured.
  • Where the base is outside the precision table for division, log and pow. A bound test on the GPU for the base and the old head is queued.
  • The speed of compute with the exact sequences, and MLX.

@chriskearney

Copy link
Copy Markdown

Ran same-boot graphics A/Bs of d8af31047b29dc974cf371b59639069ec12cc3fc against 147a62f619001f5f2588c7417c2b1fac3b0d93ad, then separated the compiler math from the memory fixes. Two useful isolations on M1:

  • Exact division/reciprocal alone reproduces almost the full ~18% vkmark reduction.
  • Removing only the agx_nir_lower_log pass invocation restores Quake close to the control, while retaining the new division, trig, image-visibility and memory-reserve changes.

These are performance-localization experiments, not proposed reverts to merge or evidence that the old numerical behavior is correct in every case.

M1 MacBook Pro / J293, G13G

Each row is a separate matched A/B block. Arrows show installed control → candidate. Quake values are medians of three measured timedemos per arm; each mark score is one fixed suite per arm.

Candidate vkQuake FPS vkmark points glmark2 points
Full #14 497.4 → 461.9 7,649 → 6,300 1,269 → 1,198
#14 with compiler math restored to the base; memory fixes retained 496.8 → 500.3 7,661 → 7,522 1,281 → 1,277
#14 division/reciprocal + memory fixes; base log/trig 506.2 → 497.2 7,640 → 6,294 1,277 → 1,254
#14 with only the log pass omitted 504.7 → 498.5 7,619 → 6,321 1,270 → 1,255
Original base, freshly rebuilt with the same recipe 494.4 → 503.8 7,664 → 7,647 1,270 → 1,271

The fresh-base check and the math-removed variant make a broad build-environment difference a poor explanation for the larger losses. Small differences in these short runs should not be treated as gains.

On the M1 Air / J313, full #14 gave 511.5 → 446.6 FPS and 7,423 → 6,160 vkmark points. With compiler math restored to the base and the memory fixes retained, the separate A/B gave 486.9 → 507.2 FPS and 7,344 → 7,378 points. The control FPS varied, but removing the math returned the candidate to roughly its original throughput. On M3 Pro / J516S, full #14 was essentially unchanged: 185.9 → 185.7 FPS, glmark2 718 → 718, vkmark 2,209 → 2,220. The diagnostic splits above were run on the M1s, not M3.

A separate AGX_MESA_DEBUG=shaderdb capture on J293 compiled the same-sized application shader corpus under the base and #14: 276 VS, 270 FS, 21 CS. The aggregate fragment instruction count rose 18,450 → 44,025. Neither capture reported spills/fills, and their compiler-reported occupancy-limit distributions matched. These are static compilation aggregates, not executed instruction counts or per-pass GPU timings. Debug flags were unset for the timed comparisons.

Quake's full-screen gamma shader uses pow(color, gamma), so that is a plausible log-sensitive hot path. The single-pass experiment localizes the cost to log lowering; I have not measured that individual draw's GPU time.

This points toward checking cheaper API-compliant math paths while retaining the necessary correctness fixes. In particular, a bitwise difference from a correctly rounded CPU reference alone does not establish an ordinary-division conformance failure: the Vulkan precision table permits 2.5 ULP over the specified divisor range. These graphics measurements do not establish the numerical conformance of a replacement or measure MLX/model performance.

Reproduction, exact diagnostic changes, and measurement scope

All variants start from the exact #14 head above. The original base is the exact 147a62f619001f5f2588c7417c2b1fac3b0d93ad tree. The native diagnostic builds used identical dependency inventories, makepkg build options and build environments. Each passed the recipe's 104 configured tests; that is build/unit-test evidence, not CTS.

Memory fixes with base compiler math: restore src/asahi/compiler/ and src/compiler/nir/nir_builder_opcodes_h.py to 147a62f619001f5f2588c7417c2b1fac3b0d93ad. Relative to the base, the only remaining production changes are src/asahi/vulkan/hk_device_memory.c and src/compiler/spirv/spirv_to_nir.c. The added numerical fixtures remain but do not change the runtime driver.

Omit only log lowering: remove this one line in agx_preprocess_nir_internal:

-   NIR_PASS(_, nir, agx_nir_lower_log);

Division/reciprocal-only math split: apply the following diagnostic diff to #14. It restores the base sin/cos filter and omits the new log/trig passes; both new division passes remain.

diff --git a/src/asahi/compiler/agx_compile.c b/src/asahi/compiler/agx_compile.c
index 003fd738b6e..6ede7262e61 100644
--- a/src/asahi/compiler/agx_compile.c
+++ b/src/asahi/compiler/agx_compile.c
@@ -6808,11 +6808,9 @@ agx_calc_stats(agx_context *ctx, unsigned size, struct agx2_stats *stats)
 }
 
 static bool
-agx_lower_sincos_filter(const nir_instr *instr, const void *g17p)
+agx_lower_sincos_filter(const nir_instr *instr, UNUSED const void *_)
 {
-   /* Only the native G17P sequence is lowered here; every other target goes
-    * through agx_nir_lower_sincos, the exact reduction. */
-   if (!(uintptr_t)g17p || instr->type != nir_instr_type_alu)
+   if (instr->type != nir_instr_type_alu)
       return false;
 
    nir_alu_instr *alu = nir_instr_as_alu(instr);
@@ -9859,7 +9857,6 @@ agx_preprocess_nir_internal(nir_shader *nir, bool g17p)
    NIR_PASS(_, nir, nir_lower_load_const_to_scalar);
    NIR_PASS(_, nir, nir_lower_flrp, 16 | 32 | 64, false);
    NIR_PASS(_, nir, agx_lower_sincos, g17p);
-   NIR_PASS(_, nir, agx_nir_lower_sincos);
    NIR_PASS(_, nir, nir_shader_intrinsics_pass, agx_lower_front_face,
             nir_metadata_control_flow, NULL);
    if (g17p)
@@ -9889,7 +9886,6 @@ agx_preprocess_nir_internal(nir_shader *nir, bool g17p)
     * reciprocals it builds are not refined twice.
     */
    NIR_PASS(_, nir, agx_nir_lower_fdiv);
-   NIR_PASS(_, nir, agx_nir_lower_log);
 
    NIR_PASS(_, nir, nir_opt_deref);
    NIR_PASS(_, nir, nir_lower_vars_to_ssa);

J293/J313 retained kernel 7.2.11-6-1-ARCH, source 7876c8e8349c4f3d0a2f4ce982e3e13ce5bb5d70. J516S used that source with its working G15 configuration (7.2.11-lab7876c8e8-cc71). Kernel and boot ID stayed fixed within every A/B. Candidate drivers were extracted privately and selected per process; the installed Mesa was unchanged. Library mappings and SHA256 checks verified the selected native driver. Short GL/Vulkan clear/triangle readbacks passed with zero wrong pixels, and no new GPU/DART/host faults were observed during the runs.

Quake: vkQuake 1.36.0, demo1, 969 frames, actual 1920×1080 verified from each run's game PNG. One discarded warm-up per arm, then control/candidate/candidate/control/control/candidate. Vsync/MSAA off, r_scale=1, frame latency 2, r_oit=1, r_waterwarpcompute=1, r_rtshadows=0, gamma 0.9, contrast 1.4, host_maxfps=200, 1,000 warm-up waits. Same executable and assets throughout. Shader caches were separate per driver/variant; no experimental behavior/debug flags were inherited.

Executable SHA256: 3661f258b23a4ae66749af5062eab70ea1066ed936b9b7a114cc2c5cba379483.

  • PAK0: 35a9c55e5e5a284a159ad2a62e0e8def23d829561fe2f54eb402dbc0a9a946af
  • PAK1: 94e355836ec42bc464e4cbe794cfb7b5163c6efa1bcc575622bb36475bf1cf30
  • demo1: 893e5279e3a84fed0416a1101dfc2b6bca1ebd28787323daa8e22cefe54883c1
  • e1m3 BSP: 5941c37c67267ae11aff572fdba7f813148cc99da3cfc34d01eea81dc23edfd0

J293 raw measured Quake samples, control / candidate:

Other suites: Omarchy M Test graphics benchmark suite 1 settings, 1920×1080, 3 seconds per scene. glmark2 2023.01, Wayland off-screen: build/VBO, linear texture, Phong shading, high-poly bump, effect2d kernel, desktop blur, jellyfish, terrain. vkmark 2025.01, headless: device-local vertex, anisotropic texture, Phong, cel, blur, desktop, cube. All scenes completed on the Apple GPU. Marks were not repeated, and this does not measure sustained clocks, thermals, stutter or full conformance.

Generated-by: LLM

@joshuaswarren

Copy link
Copy Markdown
Author

The split and the plan for #14.

The memory fixes cost nothing in your A/B, and the math costs a lot. So they should not travel together.

  1. Memory fixes. I will move the device memory reserve, the BO cache change and the image texel visibility change into their own PR against integration/aurora. Their tests go with them. The code stays as it is in agx: exact division, log and sin/cos for compute; graphics keeps the base math #14 now, so it can land as is.
  2. Math. agx: exact division, log and sin/cos for compute; graphics keeps the base math #14 becomes the math only, and stays a draft. The aim is the Vulkan precision table with the cheapest sequence that meets it, not exact results:
    • Division and reciprocal: 2.5 ULP over the specified range. I will first measure on the GPU where the base sequence is outside that range, and keep the base sequence everywhere it is inside. Only the failing cases get extra work. Exact division stays for precise and integer paths.
    • Log, log2 and pow: the table limits for log and log2, and the exp2 limit for pow. I will check the base lowering against them in the same way. The log pass is dropped from the hot path unless a measurement shows the base is outside the table.
    • Sin and cos: the same check.
  3. Proof, in this order:
    • Per operation maximum ULP on the GPU for the base, the old agx: exact division, log and sin/cos for compute; graphics keeps the base math #14 and each new candidate, with the existing bound test.
    • Static instruction counts for the same shader corpus, to see the cost before any run.
    • The CTS precision groups and the arithmetic groups of the SPIR-V assembly tests, for each candidate.
    • A same-boot A/B of vkQuake and vkmark on the 13-inch and 16-inch M1, after the full CTS run finishes on the 13-inch. That run is still going.

NOT measured yet: any candidate. The numbers above are yours. I have not reproduced the Quake or vkmark costs, and I have not measured the base against the table.

@joshuaswarren
joshuaswarren force-pushed the aurora-correctness-fixes branch from d8af310 to b717bba Compare October 25, 2026 19:45
@joshuaswarren joshuaswarren changed the title agx, hk: exact division and log, late fdiv lowering, image texel visibility, memory reserve, repaired fp32 error oracle agx: exact division, log and sin/cos for compute; graphics keeps the base math Oct 25, 2026
@joshuaswarren

Copy link
Copy Markdown
Author

The head of this PR is now b717bba9992d. It is the compiler math only: the memory fixes are in #15. The new last commit limits the exact division, log and sin/cos sequences to compute shaders. Graphics takes the base math again.

MEASURED on a native build of this head (CPU only): the five probes pass, 352 of 352 compiler tests pass, and meson test gives the same 19 failures as the base.

NOT measured: any GPU run of this head, and the vkQuake and vkmark A/B. Could you run the same A/B on this head? The bound test of the base against the precision table is queued.

One finding from the CTS runs of the #6 stack: none of the tests that went from Fail to Pass was a precision test. The precision groups pass on the base on the M1 Max.

@joshuaswarren

Copy link
Copy Markdown
Author

Measurement of the base against the Vulkan division and reciprocal table, on the GPU.

Setup: 16-inch M1 Max, a compute shader, 328,192 pairs (262,144 random plus 66,048 around the 2^-126 normal limit). The oracle is the correctly rounded quotient with denormals flushed. I count only pairs with a divisor in the specified range, 2^-126 to 2^126 (324,976 pairs). Tolerance 2.5 ULP.

division over 2.5 ULP division special mismatches reciprocal max ULP
Base 147a62f6190 169 25 1.0
#14 head before the last change 0 0 0.0

All 169 base cases have an exact quotient of exactly 2^-126, the smallest normal number. The base returns a flushed zero there. All 25 special mismatches are the mirror case at the same boundary. So the base is off only where the correct result is within one rounding step of the denormal limit. A result that close to the denormal range may be flushed under the spec, so this is not an ordinary conformance failure. Everywhere else the base division is inside 2.5 ULP, and its reciprocal is within 1 ULP.

What this means for the plan:

  • The base sequence meets the division and reciprocal table. The exact sequence is not needed for the table.
  • The exact sequence stays useful for code that needs a correctly rounded quotient, such as the MLX shaders that this work started from.

I will offer two cheap versions and measure both:

  1. Exact only for precise (exact) operations. Everything else keeps the base. This is the cheapest.
  2. Exact for compute only, which is the head now. Graphics keeps the base.

Which one should land depends on whether compute users need the rounding without precise. I have not measured the cost of the exact sequence in compute, or MLX.

NOT measured: log, log2, pow, sin and cos against the table. The bound test only covers division and reciprocal so far.

@joshuaswarren

Copy link
Copy Markdown
Author

Now log, log2, exp, exp2, pow, sin and cos, on the GPU.

Setup: 16-inch M1 Max, a compute shader, 262,144 random inputs per operation, a float64 reference. The bounds are the Vulkan precision table.

  • log and log2: 2^-21 absolute for x in [0.5, 2]. 3 ULP elsewhere.
  • exp and exp2: 3 + 2|y| ULP.
  • pow: exp2(w log2 x), with the log2 error carried.
  • sin and cos: 2^-11 absolute for angles in [-pi, pi].
Base 147a62f6190 #14 head before the last change
log, over the bound 0 (worst 2.51 ULP outside the window) 0 (worst 0.50 ULP)
log2, over the bound 0 (worst 1.69 ULP outside the window) 0 (worst 0.50 ULP)
exp, over the bound 0 (worst 28.9 ULP, bound 3 + 2 y
exp2, over the bound 0 (worst 2.32 ULP) 0 (same)
pow, over the bound 0 0
sin, worst absolute error 3.9e-7 4.5e-8
cos, worst absolute error 3.9e-7 4.5e-8

So the base meets the table for all of these on this chip. The exact log, sin and cos sequences are not needed for the table. With the division result above, the base sequences meet the whole table. The old head is more accurate than the table asks. That accuracy is what costs the graphics speed.

Consequence for this PR: the exact sequences are an option for code that wants correctly rounded results. They are not a conformance fix. The head already limits them to compute. You may prefer them only for precise operations. That is one more condition in the same passes, and I will make and measure it.

NOT measured:

  • The compute cost of the exact sequences, and MLX.
  • A graphics shader version of these tests. This ran as compute.
  • The vkQuake and vkmark A/B of the new head.

@joshuaswarren

Copy link
Copy Markdown
Author

The decision is to keep head b717bba9992d: exact division, log and sin/cos for compute only, base math for graphics. The compute shaders keep correct rounding for MLX. Graphics returns to the base sequences.

Could you rerun your vkQuake, vkmark and glmark2 A/B on b717bba9992d against 147a62f6190? You have the rig. I expect graphics to match the base, because the exact passes no longer run for graphics shaders. That is by construction and not measured.

I am measuring the compute cost with an MLX decode A/B on the 16-inch M1 Max. I will post it here.

maralcbr added a commit that referenced this pull request Oct 25, 2026
hk, spirv: image texel visibility and the free-memory reserve (split out of #14)
@joshuaswarren

Copy link
Copy Markdown
Author

The compute cost after the last change, measured on an M2 Max by another lane, same method as before (five interleaved rounds per model, one boot, release builds, digests equal in every round).

Arms: base 147a62f6190 against head da4deb0d3fea (division and reciprocal exact only where the shader asks for it; log and sin/cos still exact for compute).

Model Base decode Head decode Change
Qwen3-4B 64.6 t/s 63.0 t/s -2.5 percent, head lower in 5 of 5
Qwen3.8-2B 106.0 t/s 104.8 t/s -1.1 percent, head lower in 5 of 5

For comparison, the head before (b717bba9992d, exact division for all compute) measured -2.9 and -1.4 percent on an M1 Max. So gating the exact division on precise did not remove the decode cost. What remains is the exact log and sin/cos for compute, which rope and softmax-adjacent code use, and the non-exact division changes are no longer the difference. A per-operation table of this head is being prepared.

These absolute numbers are the base tree of #14, not the cooperative matrix stack of #6.

NOT measured here: graphics on this head (expected to equal the base by construction; the vkmark A/B of b717bba9992d was -0.5 percent).

@joshuaswarren

Copy link
Copy Markdown
Author

The per-operation table of the head da4deb0d3fea against the base, measured on an M2 Max by another lane (median of 3, 1M samples, release builds, same boot):

  • divide 16M: 0.99x. This is the DEFAULT path, not the precise path: the test shader does not ask for precise or NoContraction, so this row says nothing about the correctly rounded contract.
  • reciprocal: 1.03x.
  • log, log2: 1.80x, from thousands of ULP to 0.50.
  • sin, cos: 1.92 and 1.93x, to under 0.8 ULP.
  • sin*cos: 1.79x.
  • sigmoid: 1.00x.
  • softmax and rms_norm, f32 and bf16: 0.86 to 0.98x, unchanged.

So the remaining cost sits in the exact log and sin/cos for compute. The decode cost stands at -2.5 and -1.1 percent with equal outputs. The ULP figures near zero are the near-zero metric, not typical error.

@joshuaswarren

Copy link
Copy Markdown
Author

Status, no change to head b717bba9 (still draft, MERGEABLE against integration/aurora, no checks run). Waiting on: (1) the blocking source defect in the graphics 16-bit fdiv path (prove the failure with the new fixture, fix the shared lowering, run the final tests, then the focused source review); (2) the MLX compute A/B (compute speed with the exact sequences and MLX is not measured yet, and no GPU/CTS/vkmark/vkQuake run exists for this head); (3) landing order with the other open mesa PRs (#6, #8, #9 and #10 are all still open). Chris's graphics/math/precision hold stays open. Not superseded; staying open as a draft. Not ready to merge.

…duction

Three fp32 rounding defects on Honeykrisp (Apple M1 G13G, Mesa 26.1.7)
that MLX patches around in its shaders:

  a) GLSL "/" was lowered by nir_opt_algebraic (lower_fdiv) to
     a * frcp(b), and frcp was only Newton-refined when the instruction
     was `precise`. Even with an exact reciprocal a * RN(1/b) is not
     RN(a/b): 80/25 and 10/25 come out one ulp low, 6.55859375/0.030016447
     one ulp high. Measured on 1008 pairs: 299 mismatches (298 at 1 ulp,
     1 at 2 ulp), 1/b: 204 mismatches; 1M random pairs: 305792 mismatches.
     fma() itself is IEEE (0/1000 mismatches), the MLX note
     blaming fma contraction was a misdiagnosis.
  b) log(x) was flog2(x) * ln2 on the hardware log2, which is only
     absolutely accurate: log(3) = 1.0986121892929077 instead of
     1.0986123085021973, 2792/4107 mismatches on [0.5, 64] with errors up
     to 357 ulp near 1 (log(1.0000001) = 1.65e-7 vs 1.0e-7); log2 had
     2905/4107 mismatches.
  c) sin/cos reduced the argument as fract(x / 2pi) in fp32, so the
     reduced angle lost ulp(x) of precision: sin(3.0) 14 ulp off,
     sin(1e5) 1.06e-3 absolute error, sin(1e8) = 0.0, sin(-1e-3) 571 ulp
     off, sin(pi_f32) = 0, and the hardware sin_pt_2 core itself is 2 to
     3 ulp off (cos(1e-3) 3 ulp).

The lowering moves to a new the AGX compiler pass file agx_nir_lower_math.c,
built only from the exact fp32 fma and marked no-fast-math so
nir_opt_algebraic cannot reassociate it:

  - lower_fdiv is dropped from agx_nir_options; fdiv32 becomes Markstein
    division (y = refined rcp, q = a y, r = fma(-b, q, a), q + r y) with
    the NaN/zero guard keeping q for b = 0, overflow and signed zero.
    frcp32 is always refined. fdiv16 keeps a * frcp(b).
  - fmul(flog2(x), ln2) as emitted by nir_flog, and any remaining flog2,
    become a double-float evaluation of log(m) = 2s + 2s^3 P(s^2) with
    s = (m-1)/(m+1) kept as a pair, m folded into [sqrt(1/2), sqrt(2)),
    e ln2 split hi/lo and Fast2Sum accumulation (about 40 bits before the
    final rounding). Zero/subnormal/negative/inf/NaN are handled by bit
    tests, not the hardware log2.
  - fsin/fcos: Cody-Waite in radians with pi/2 in three pieces, the k C2
    product and its subtraction as TwoProduct/TwoSum so the reduced
    argument is a pair accurate to an ulp, for |x| < 2^22; an integer
    Payne-Hanek (24-bit mantissa times a 128-bit window of 2/pi, 64
    fraction bits kept) for |x| >= 2^22 up to FLT_MAX. The quadrant then
    selects a Taylor polynomial in quadrant units (sin degree 13, cos
    degree 14, leading terms as pairs, low part of the argument fed to
    the derivative). The hardware sin_pt_1/sin_pt_2 core (nir_op_fsin_agx)
    is no longer used by the lowering. fp16 sin/cos go through the fp32
    path.

Verification (standalone Vulkan compute reproducer):

  - division: 1008-pair table incl. 80/25, 10/25, 1000 random: 0
    mismatches (stock 299); 1/b 0 (stock 204); 1M random pairs 0
    mismatches for both a/b and 1/b; b = 0, inf, NaN, overflow, flushed
    subnormal and -0/5 = -0 match host IEEE.
  - log: 4107 points on [0.5, 64] plus pinned exact cases: 0 mismatches
    (stock 2792), log(3) = 1.0986123085021973; log2 0 (stock 2905);
    1M random on [1e-37, 1e38]: log 3 at 1 ulp, log2 14 at 1 ulp,
    rest exact.
  - sin/cos at 1e5, 5e5, 1e6, 5e6, 1e8: all 0 ulp from the double
    reference except cos(5e6) at 1 ulp (stock: 2.8e5 to 1.06e9 ulp);
    1310 mixed points max 1 ulp; 1M random over [1e-3, 1e9] and [-10, 10]:
    sin 42346 and cos 41184 at 1 ulp, rest exact, max 1 ulp (stock
    >= 8 ulp for 627689 / 512816 of them).
  - MLX suites on the same Release tree, stock vs fixed:
    primitive 98/98, eq_math 7/7, fast_ops 30/30, runtime 40/40,
    reduce_ops 34/34 both; complex_ops 30/33 both with the same three
    pre-existing cases (failed assertions 31 -> 29). The upstream
    ops_tests log(3) pin through libmlx: FAIL on stock, PASS on fixed,
    as do 80/25, 10/25 and sin/cos up to the 1e5 limit mlx enforces.
  - timing: 16M-element elementwise dispatch is memory bound at ~2.45 ms
    on both drivers (sin 2.474 -> 2.463 ms median, within spread). A
    32-deep in-register chain shows the ALU cost: sin 6.64 -> 39.6 ms,
    log 2.57 -> 34.8 ms, div 2.84 -> 7.24 ms, mul unchanged.
The sin zero-return selects the argument when it compares equal to zero.
The comparison flushes, so a subnormal argument matches and leaks out
unflushed through the select. From flushed sources (in-shader arithmetic)
the argument is already zero and nothing changes; from unflushed sources
(buffer loads) a shader declaring DenormFlushToZero must see a flushed,
sign-preserving zero, which the hardware fmul path it sits behind does
not give it.

Gate the fix on the DenormFlushToZero fp32 execution mode alone. The
CTS float_controls sin cases are all DenormFlushToZero (optionally with
SignedZeroInfNanPreserve on other operands); SignedZeroInfNanPreserve
alone never exercises sin and preserving the subnormal argument is
correct under DenormPreserve and under no mode at all, so those modes
keep the existing emission byte-identical.

Division is deliberately left unkeyed: the CTS fp32 group exercises
OpFDiv only under DenormFlushToZero with in-shader-flushed denorm
inputs (expectations V_ZERO/V_UNUSED) and has no DenormPreserve or
rounding-mode division cases, and the previously-landed exact sequence
flushes the subnormal-result band itself (it is FTZ-by-construction),
so no existing sequence satisfies DenormPreserve there and none is
required by any current CTS case.

Pinned by test-lower-math.cpp: the default-mode fdiv/frcp op histograms
are exact pins of the sequences the MLX decode digests were measured
against, and the sin case distinguishes the keyed FTZ emission.
…ction step

Stress testing on an M2 Pro found a third division failure class the
original review missed (2026-09-13): when the exact
quotient sits just below the normal boundary, the GPU zeroes the
correction step's intermediate value and the Markstein step is silently
skipped, giving 1 ulp errors -- 18,382 wrong results over 1M random
pairs. The first commit in this series refined a * rcp(b) in place, so
the flush landed on an intermediate, not on the result.

Divide the normalized mantissas instead and restore the exponent
afterwards, so the refinement always runs on operands near one and the
flush happens on the exact quotient, before rounding. The rounding
decision is integer: the half-ulp point is exact by construction, and the
residual of the second Markstein step is exact by the fma contract, so
the comparison against it decides the tie without a third refinement.
The reciprocal takes the same path, and the log2 series divisor (near
two) keeps the unguarded Newton form.

This re-lands the division part of the fix first made on 2026-09-13 and
pulled with it during the 2026-09-20 A/B isolation windows; it was never
re-applied to the omacom/mesa stack. Verified against an exact model over
262,144 pairs covering edge bit patterns and the binade boundaries, and
by an independent exact-rational checker; see the receipt in the PR.
nir_builder_opcodes_h.py renders the intrinsic builders from a mako
template that references FP_MATH_CTRL, but the index was never imported
or passed to render(), so mako resolved the name to UNDEFINED and
silently skipped the builder-defaulting block for every FP_MATH_CTRL
intrinsic. NoContraction on cooperative-matrix operations was therefore
dropped in every driver, not just this one, and the generated header is
missing the twelve fp_math_ctrl defaulting blocks.

Import the index and hand it to the template. The generated header grows
exactly 24 lines, twelve two-line defaulting blocks.
Stress runs in review (2026-09-13) found that a precise
log2(x) * ln2 gave different bits depending on whether ln2 was a literal
or read from a buffer, on 26.8% of inputs. The stack carries an amended
form of the log fix in which a multiply of a hardware flog2 by the fp32
ln2 constant is rewritten to the correctly rounded soft log. That reopens
the finding from the other side: the literal form now gets the
single-rounding soft log, while the buffer form still computes
soft log2 and multiplies, so the two forms of the same source expression
differ by 1 ulp on 25.3% of inputs (66,332 of 262,144 in the
literal_vs_buffer reproducer), and the precise multiply check fails the
same way.

Drop the literal-only rewrite. log2 is lowered to the double-float soft
log2 everywhere, and log() keeps the one rounded multiply the source
asks for, from a literal or from a buffer: the two forms now agree bit
for bit on every input, powers of two still come out exact because the
series returns the exponent unchanged, and log stays within 1 ulp on the
262,144-input reproducer.
The Vulkan SPIR-V environment requires only 2.5 ulp for OpFDiv
(docs.vulkan.org/spec/latest/appendices/spirvenv.html), but the correctly
rounded division kept as the AGX default costs ~2.2% 4B prefill on G13G.
A cheaper guarded Markstein default (common 2^-3 operand scale for
d >= 2^126, float selects for the specials) was tried and measured at
8,376 over-bound results over the 328,192-pair set: the compute pipeline
preserves denormals, so any float-path special handling has to re-add the
integer flush machinery, and nothing is left to save. The normalized
correctly rounded division therefore stays the default for every fdiv
and frcp.

The test pins the contract the default actually must meet: within 2.5 ulp
of the correctly rounded (with FTZ) quotient over the 262,144-pair
precise_math regression input plus a 66,048-pair 2^-126-straddle boundary
set (the M2 Pro failure class), with bitwise
specials. On 1d72152 the measured maximum is 0 ulp on both sets,
division and reciprocal.

The exact-rounding contract remains in precise_math.py for the
nir_fp_exact path; rusticl does not advertise
CL_FP_CORRECTLY_ROUNDED_DIVIDE_SQRT, so no OpenCL correctly-rounded
selection exists in this driver today.
Since 43b2756, every fp32 frcp and every fdiv(1, x) takes the
normalized correctly rounded division. After constant folding that is
about 28 ops; "hk: enable cooperative matrices by default on G14X" used about 5. Decode kernels take many
reciprocals (sigmoid, softmax normalizers), so this change costs decode
speed. A per-commit bisect of "hk: enable cooperative matrices by default on G14X" to "tests: pin the 2.5 ulp default-division bound for AGX" on an M1 Max
(G13C, MLX held fixed, 5 alternating rounds) put -6.63 tok/s of 2B
decode on 43b2756, negative in 5 of 5 rounds. 443153a recovered 2.1
tok/s; no other commit moved decode beyond noise.

For x in [2^-126, 2^126), the normalized reciprocal is the frcp
refinement on x, scaled by a power of two. So 1/x is frcp plus two fma
there. The other exponents have fixed results, chosen by selects: zero
and denormals give +-inf, exactly 2^126 gives +-2^-126, the rest up to
inf gives +-0, and NaN gives the canonical NaN. fdiv(1, x) takes the
same path. General division does not change.

The results are bit-identical to "tests: pin the 2.5 ulp default-division bound for AGX". tests/div_window.py
compares every fp32 reciprocal (2^32) and 2^32 hashed quotients with
"tests: pin the 2.5 ulp default-division bound for AGX" and with the correctly rounded FTZ quotient: 0 mismatches
on G13C. The same sweep on G13G found 0 mismatches for the frcp scaling
that this path uses. precise_math.py and ulp_bound.py pass unchanged
(max 0 ulp on both sets).

d64 decode tok/s on the M1 Max, MLX bf62cfb, medians of two 5-round
runs ("hk: enable cooperative matrices by default on G14X" / "tests: pin the 2.5 ulp default-division bound for AGX" / this commit):
  Qwen3.8-2B 4-bit: 104.95, 103.48 / 101.20, 100.11 / 104.03, 103.53
  Qwen3-4B 4-bit:   66.58, 66.34 / 64.12, 63.82 / 64.97, 64.77
  9B qwen3_5 4-bit: 14.23, 14.21 / 14.08, 14.13 / 14.17, 14.13
Greedy token digests do not change.
155e743 dropped the rewrite of fmul(flog2(x), ln2) into the single
rounding soft log, so that a precise log2(x) * ln2 gives the same bits
from a literal ln2 and from a buffer (found in review). The
cost is accuracy for every default log(). Over all 2,130,706,432
positive normal fp32 inputs against fp64 on an M1 Max (G13C):

  log() before 155e743: max 0.5004 ulp, mean 0.250, 3,448 not
                         correctly rounded
  log() after 155e743:  max 1.2388 ulp, mean 0.343, 536,324,864 not
                         correctly rounded
  log2() is the same on both (max 0.5025 ulp).

Restore the rewrite, but skip an exact multiply. A default log() may be
evaluated more accurately than written, so it gets the soft log again.
A precise multiply keeps the rounding the source asks for, so the
literal and buffer forms still agree bit for bit.
The exact reciprocal took 16 ops: one Newton step inside [2^-126, 2^126)
and selects for the other exponents. The hardware rcp already gives
those results. It flushes denormal inputs to +-inf and results below
2^-126 to +-0. Where the Newton step is NaN (x zero, denormal, infinite
or NaN), keep the hardware value, then make NaN the canonical NaN. This
is the "hk: enable cooperative matrices by default on G14X" reciprocal plus the canonical NaN: 7 ops.

The results are bit-identical to "agx: round log() once unless the multiply is exact". tests/div_window.py, all
2^32 fp32 reciprocals and 2^32 hashed quotients against "agx: round log() once unless the multiply is exact" and
against the correctly rounded FTZ quotient on an M1 Max (G13C): 0
mismatches. precise_math.py and ulp_bound.py pass (max 0 ulp).

d64 decode tok/s on the M1 Max, MLX bf62cfb, 5 alternating rounds,
medians ("agx: round log() once unless the multiply is exact" / this commit / "hk: enable cooperative matrices by default on G14X"):
  Qwen3.8-2B 4-bit: 103.19 / 104.35 / 104.75, ahead in 5 of 5 rounds,
                    median paired gain +0.76
  Qwen3-4B 4-bit:   64.80 / 64.87 / 66.68
The 4B gap is general division, which stays on the exact path.
DivDefaultFastSequencePinned and FrcpDefaultSequencePinned counted the
ALU ops of the "hk: enable cooperative matrices by default on G14X" division and reciprocal. Every exact lowering
since "agx: flush the exact quotient in precise fp32 division, not the correction step" emits other ops, so agx_tests has failed these three
pins on the PR omacom#6 head (found by w7Q, the driver's meson suite: 61/64).
The bits of division and reciprocal are pinned on hardware by
tests/precise_math.py, ulp_bound.py and div_window.py. What this file
can pin is the float-control contract: DenormFlushToZero emits exactly
the default lowering. Compare the two op histograms for fdiv and frcp
instead of fixed counts.
…it late

Symptom: any shader converting fp32 to a 64-bit integer aborts in the
backend with "Unhandled ALU op fdiv" (agx_compile.c agx_emit_alu); the
MLX suite the copy-offset tests crashes on "dtype converting
copies cover the integer family and bool". Fragment shaders with
perspective-correct varyings hit the same path.

Cause: "agx: correctly rounded fdiv/frcp, faithful log/log2, exact sin/cos reduction" dropped lower_fdiv from the compiler options so
nir_opt_algebraic no longer splits fdiv into fmul/frcp, and only ran
agx_nir_lower_fdiv in agx_preprocess_nir. nir_lower_int64 (f2u64/f2i64:
fdiv by 2^32, plus the frem that lower_fmod turns into a second fdiv) and
agx_nir_lower_interpolation run after preprocessing, so their fdiv reached
the backend, which has no fdiv.

Fix: agx_nir_lower_fdiv_late runs in agx_optimize_nir after the int64
cleanup loop and lowers only fdiv, leaving frcp alone so the reciprocals
built by the first pass and by the log lowering are not refined twice.

Verification: hk/precise-math driver alone reproduced the abort on the 13-inch M1;
with this commit the copy-offset tests passes 25/25 .
nir_lower_int64 (float to 64-bit integer conversions) and smooth offset
interpolation emit a dynamic fdiv after preprocessing. The backend has no fdiv,
so agx_nir_lower_fdiv_late lowers it. Three unit tests pin that the late pass
lowers fdiv like the first pass, leaves a plain reciprocal alone, and does not
refine an existing reciprocal a second time.

MEASURED: the AGX compiler unit tests pass on a native arm64 build, 63 of 63,
including the three new ones.
NOT measured: a shader that goes through nir_lower_int64 or the interpolation
pass end to end; the tests build the division directly.

Signed-off-by: Joshua Warren <816217+joshuaswarren@users.noreply.github.com>
…it needs

ulp_bound.py divided the error by np.spacing of the float64 reference.
np.spacing is negative for a negative reference, so the distance came out
negative and any error on a negative reference passed the > 2.5 check; for
a positive reference the float64 spacing made every inexact result fail
instead. Use the absolute float32 spacing of the reference.

Add a --self-test mode that needs no GPU: it checks the distance for 0, 1
and 3 ULP errors in both directions on positive and negative references,
that a 3 ULP error on a negative reference exceeds 2.5, and that the old
expression gives a negative distance for it.

The script also compiles precise_math.comp, which this tree did not have;
add it.

MEASURED: the self test passes; the old expression gives -1610612736 for a
3 ULP error at -1.5, which passes the old check.
NOT measured: the GPU run of ulp_bound.py with the corrected distance; the
numbers from the old script are not to be relied on.

Signed-off-by: Joshua Warren <816217+joshuaswarren@users.noreply.github.com>
chriskearney and others added 3 commits October 29, 2026 16:54
Measure positive binary32 ULPs, reject non-finite results for finite references, and add seven CPU controls. Restore unchanged public shader/runner fixtures from Joshua Warren source f144c79; provenance is recorded beside the tests. No driver code changes.

Generated-by: LLM
Graphics shaders pay for the exact sequences and gain nothing the Vulkan
precision table asks for. A same-boot A/B of the full change on an M1
showed about 18 percent lower vkmark and 7 percent lower vkQuake.

Lower the exact sequences only for compute shaders, where the rounding
contract matters. Graphics keeps the base reciprocal and quotient, the
base log and the base sin/cos sequence. A fdiv that is emitted after
preprocessing (int64 lowering, perspective divide) is still lowered,
as a multiply by the hardware reciprocal, because the backend has no
fdiv.

Not measured: speed or ULP of this change.

Signed-off-by: Joshua Warren <816217+joshuaswarren@users.noreply.github.com>
The AGX math pass lowers graphics-stage fdiv only at 32 bits and
leaves the 16-bit division untouched for a backend that has no fdiv,
so any fp16 division in a graphics shader cannot compile. Pin the
graphics contract in the CPU suite: a fragment-stage fdiv at 16 and 32
bits must lower to fmul + frcp in both the early and the late pass,
with no ffma and no surviving fdiv. The 16-bit halves of this pin fail
deliberately until the source fix lands; the 32-bit halves pin that
the coming fix keeps the existing hardware-reciprocal quotient.
@joshuaswarren
joshuaswarren force-pushed the aurora-correctness-fixes branch from b717bba to 6902830 Compare October 29, 2026 22:04
Two integration slips from rebasing the math range onto the current
integration/aurora, both breaking the build:

- The conflict resolution at the preamble-retry wrapper left the
  superseded libagx_frcp refinement and the old three-argument
  agx_nir_lower_fdiv in agx_compile.c alongside their replacement in
  agx_nir_lower_math.c, giving agx_nir_lower_fdiv conflicting types.
  Drop the two dead helpers; the dispatch in agx_nir_lower_math.c is
  the sole lowering now.

- The compute guard in agx_lower_sincos still referenced the bool
  g17p after upstream widened the parameter to the three-valued
  native target code (1=G17P, 2=M5 native fragment depth, 0=older).
  Key the guard on native != 1: compute keeps the exact
  agx_nir_lower_sincos reduction on every target except G17P, and the
  explicit-RTE repair path, which calls the base pass with native=1,
  still lowers compute.
nir_lower_idiv marks everything it emits exact and opens emit_udiv
with frcp(u2f32(denominator)). The refinement that correctly rounds
that reciprocal used to apply in every shader stage; the math range
now refines exact reciprocals in compute only, so a fragment shader
with variable integer division keeps the raw hardware reciprocal.
Pin the production pattern with the real lowering: run
nir_lower_idiv on a fragment udiv, run the early pass, and require
the refinement steps to appear, while an unmarked fast-math frcp
stays untouched. The idiv half fails deliberately until the
precision source fix lands.
The early pass lowers through nir_def_replace, which removes the
lowered fdiv instruction, while the fixture builder cursor stayed
parked after that instruction; the late-half fdivs were then inserted
behind a removed instruction and crashed the suite. Reset the cursor
to the end of the entrypoint implementation before building them.
No assertion changes.
The math range left graphics-stage division half-wired: fdiv at 16
bits fell through to a backend that has no fdiv, and the correctly
rounded reciprocal that nir_lower_idiv builds (an exact
frcp over an integer conversion) was refined in compute only, so
integer division in vertex and fragment shaders kept the raw hardware
reciprocal.

Lower graphics fdiv at 16 bits the same way as at 32, multiplying by
the hardware reciprocal. Refine the exact integer-conversion
reciprocal in graphics stages with the same correctly rounded helper
the compute path uses, restoring the refinement scope of the pass
this range replaced: keyed on the exact marking and, for application
shaders, on an integer-conversion input, while ordinary graphics
reciprocals keep the hardware instruction and all compute sequences
are untouched. The refinement's float controls are scoped to its own
emissions and restored afterwards, and the late pass stays fdiv-only,
so nothing is refined twice.

Failing before this change at 7f9dd7c,
witnessed by CI run 40967791964 on both architectures:
GraphicsFdivLowersBothWidthsInEarlyAndLatePasses and
GraphicsIdivReciprocalRefinedPlainFrcpUntouched fail while the other
155 tests in the target pass.
@joshuaswarren

Copy link
Copy Markdown
Author

Source review at c9cdabb (delta from 7f9dd7c: one commit, src/asahi/compiler/agx_nir_lower_math.c, +31 -6). Not a merge GREEN: CI for this head is still running and the precision hold is open. This is a source review only, with no hardware run.

Result: no defect found in the delta.

Traced against the two failing tests (I did not run them; I read the code and test-lower-math.cpp at this head):

  • GraphicsFdivLowersBothWidthsInEarlyAndLatePasses: in a fragment shader an fdiv skips the new frcp branch and reaches the cheap quotient, fmul(a, frcp(d)), at both 16 and 32 bits (the old early return on width is gone). That gives 2 fmul and 2 frcp after the early pass and 4 and 4 after the late pass, with no ffma. This is what the test counts. The late pass ignores frcp, so the early results are not refined again.
  • GraphicsIdivReciprocalRefinedPlainFrcpUntouched: nir_lower_idiv marks what it emits exact (fp_math_ctrl |= nir_fp_exact) and opens with frcp(u2f32(denominator)). In the new branch that is a 32-bit exact frcp whose resolved source is u2f32, so it is replaced by rcp_rn and ffma appears. A plain fast-math frcp is not exact and the pass returns false, so no ffma and no progress.
  • In the pass order, agx_nir_lower_fdiv runs right after nir_lower_idiv in agx_preprocess_nir_internal, before anything can put a fabs or fneg between the conversion and the reciprocal. So the source match on u2f32 and i2f32 sees the pattern the idiv lowering emits.

Checked in the diff:

  • 16-bit frcp and 64-bit ops still return false in graphics, as before. Compute is unchanged.
  • The exact float controls are saved and restored around the refinement in graphics, so they no longer leak into the rest of the shader. In compute the existing code still sets them without restoring; that is unchanged and intended there.
  • Driver-internal shaders refine every exact 32-bit frcp, and application shaders only an exact frcp of u2f32 or i2f32. That matches the comment.

Observation, not a defect in this diff: nir_fp_no_fast_math contains the exact bits, so the frcp that rcp_rn emits is itself exact by nir_alu_instr_is_exact. A second run of the early pass over the same graphics shader would refine it again (for an application shader its source is still the same conversion; for an internal shader unconditionally). The early pass runs once in agx_preprocess_nir_internal, and the late pass skips frcp for this reason, so nothing repeats today. I did not check whether any internal shader path calls agx_preprocess_nir twice on one shader.

Not checked: the CI results for this head, tests/div_window.py, and any hardware.

@joshuaswarren

Copy link
Copy Markdown
Author

Follow-up at c9cdabb: the CI run for this head has finished. build (ubuntu-26.04, x86_64), build (ubuntu-26.04-arm, aarch64), source, plan and ci / result all succeeded (the attested ARM candidate job was skipped). I did not read the test log, so I have not confirmed the two named tests in it. This is still a source review, not a merge GREEN: the precision hold is open.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants