Conversation
MultiplyAvx512F has been unreachable behind `false &&` since the scalar kernel overtook it, and the branch measurements on this Zen 5 host put it at 1.68-2.00x slower than the scalar kernel for full, one-small and production-mix operands. The shape of the loss is structural rather than a tuning problem: a 256x256 -> 256 product with 32x32 vpmuludq partial products needs 36 of them plus cross-lane carry propagation, which the vector unit has to serialise through shifts and permutes, while the scalar kernel issues 16 multiplier uops with short carry chains. .NET 10 does not expose AVX-512 IFMA, so the 52-bit form that makes vector multiplication competitive for batched work is not available either. No behaviour change: Multiply already called MultiplyScalar unconditionally.
Multiply ran two vector IsZero tests, two vector IsOne tests and two IsUint64 calls before reaching MultiplyScalar, which then reloaded the eight limbs and gated a second time. Under Tier-1 the IsUint64 checks stayed out of line, so the most common shape paid two calls and a frame, and the 64x64 fast path wrote `res = default` as one 32-byte store followed by two 8-byte limb stores, which a Vector256 reader (the EVM stack push, IsZero, the next vector op) cannot store-forward from. Every kernel shared MultiplyScalar's prolog, which pushed eight registers on the one-limb path as well as the full one. Load the eight limbs once, OR them into a high word and a top word per operand, and tail-jump to a kernel per shape: the 64x64 product inline as one 32-byte store, the one-limb ladder for either side, the 128x128 kernel on x64 and the full kernel otherwise. Each kernel is its own method and pays only its own prolog. The zero and one shortcuts move into the ladder as one compare, so a wide operand times zero or one stays within noise of main instead of costing every call two vector tests. Exp calls Multiply rather than the full kernel so a one-limb base takes the ladder. The ladder passes the shared limb as the first mulx operand so it stays in rdx: 36 instructions with two spill pairs became 29 with none. Zen 5, .NET 10.0.11, one-process paired harness, new/main, FullOpts and Tier-1 with dynamic PGO, result read as one vector: 1x1 0.21 / 0.21 4x1, 1x4 0.76-0.78 / 0.66-0.73 2x2 0.74 / 0.73 2x4, 4x2, 3x3 0.86-0.87 / 0.69-0.73 4x4 0.85 / 0.76 production mix 0.78 / 0.66 dependent chain 4x1 / 4x4 0.50 / 0.83 and 0.55 / 0.61 wide x zero, wide x one 1.10 / 0.90 and 0.92 / 0.84 Codex's branch measured 1.17-1.32x slower than main on the 1x2, 2x1 and 2x2 shapes under Tier-1 because its 128x128 path ran behind the old entry; those shapes are now 0.70-0.80x.
The kernels wrote their four limbs with scalar stores. A consumer that
reads the result as one Vector256 (the EVM stack push, IsZero, the next
vector op) then hits a store-to-load forwarding failure, because a wide
load cannot forward from several narrower in-flight stores. Route all
four kernels through StoreProduct, which builds the vector from the
limb registers and stores it once where Vector256 is accelerated and
keeps the limb stores elsewhere (ARM64, x64 without AVX2, the zkVM).
Measured forwarding costs on Zen 5 (dependency-chain probe calibrated
against a 3-cycle imul chain, cycles on the critical path):
8B store -> 8B load, same address 1 (memory renaming)
four 8B stores -> 8B load, any limb 2
32B store -> 8B load, any offset ~8.5 hop, forwards
32B store -> 16B or 32B load forwards
four 8B stores -> 32B load ~17 hop, waits for commit
two 16B stores -> 32B load ~17 hop, waits for commit
Building the vector from four registers adds ~4 cycles over a scalar
store, so a scalar consumer that depends on the result pays about 12
cycles more latency than it did, while a vector consumer stops paying
the failure, which costs 9-21 cycles per op even with independent
iterations because the load cannot complete until the stores commit.
Zen 5, .NET 10.0.11, one-process paired harness, new/main, FullOpts and
Tier-1 with dynamic PGO, versus the previous commit in brackets:
vector reader 4x1 0.52 / 0.59 [0.78 / 0.73] 1x4 0.54 / 0.57 [0.76 / 0.66]
2x2 0.57 / 0.66 [0.74 / 0.73] 4x4 0.78 / 0.57 [0.85 / 0.76]
2x4 0.74 / 0.59 production mix 0.66 / 0.63 [0.78 / 0.66]
limb reader 4x1 0.64 / 0.75 2x2 0.63 / 0.74 4x4 0.87 / 0.67 mix 0.72 / 0.70
chain, result aliased into the next product (scalar consumer):
4x1 0.85 / 0.88 [0.50 / 0.55] 4x4 1.02 / 0.96 [0.83 / 0.61]
The chain rows are the scalar-consumer cost above; they stay at or
below main, and no production caller of Multiply chains through limb
reads except Exp, whose wide base already measured neutral.
The 128x128 and 256x256 kernels computed every partial product up front
and then summed the columns, which kept twelve product halves plus the
eight input limbs live at once. RyuJIT schedules in source order, so the
allocator spilled: the full kernel carried twelve stack references and
an eight-register prolog, the two-limb kernel six stack references.
Take the four low-only products of the top limb first, which retires x3
and y3 before the carry columns start, then fold each remaining product
into its column as soon as it exists so at most one high/low pair is in
flight. Same arithmetic, same carry handling. Full kernel: 113 -> 108
instructions, 12 -> 2 stack references, 8 -> 7 pushes. Two-limb kernel:
71 -> 63 instructions, 6 -> 0 stack references.
Zen 5, .NET 10.0.11, one-process paired harness, new/main, FullOpts and
Tier-1 with dynamic PGO, previous commit in brackets:
vector reader 2x2 0.49 / 0.65 [0.57 / 0.66] 4x4 0.78 / 0.58 [0.78 / 0.57]
3x3 0.74 / 0.60 [0.81 / 0.61] 2x4 0.72 / 0.56 [0.74 / 0.59]
production mix 0.67 / 0.64 [0.66 / 0.63]
limb reader 2x2 0.60 / 0.66 [0.63 / 0.74] 4x4 0.84 / 0.70 [0.87 / 0.67]
chain 4x4 0.92 / 0.76 [1.02 / 0.96] fixed 4x4 0.79 / 0.57 [0.94 / 0.65]
The kernels were plain methods behind the inlined dispatcher, so what
the caller got depended on the tier. FullOpts left every kernel as a
call, which costs the call, a seven-register prolog and the spills the
caller needs around it. Tier-1 with dynamic PGO inlined whichever
kernels fit the remaining budget and left the rest as calls, so the
speed of a shape depended on how hot the other shapes had been while the
method was being profiled: the same 4x4 product measured 0.58x or 0.88x
of main in different runs.
Mark the four kernels AggressiveInlining. Every Multiply call site then
carries the dispatcher and all kernels, about 1.6 KB on x64, and each
path still allocates only its own registers.
The zero shortcut in the one-limb ladder now stores an explicit vector
zero instead of `res = default`. When the out parameter resolves to a
promoted Vector256 local the .NET 10.0.11 JIT turns the block zero into
`vmovq ymm, r64`, which has no encoding and faults; it also falls back to
MinOpts on limb stores into such a local. Neither shape occurs with a
UInt256 local, but the explicit store costs nothing and removes the
dependency on it.
Zen 5, .NET 10.0.11, one-process paired harness on four-limb struct
storage, new/main, FullOpts and Tier-1 with dynamic PGO, previous commit
in brackets:
vector reader 1x1 0.27 / 0.28 [0.25 / 0.24] 4x1 0.42 / 0.49 [0.55 / 0.48]
2x2 0.47 / 0.51 [0.54 / 0.49] 4x4 0.63 / 0.76 [0.89 / 0.70]
3x3 0.63 / 0.70 [0.90 / 0.70] production mix 0.52 / 0.57 [0.64 / 0.59]
limb reader 4x1 0.68 / 1.02 [0.77 / 0.90] 4x4 0.80 / 0.98 [0.95 / 0.96]
chain 4x1 / 4x4 0.79 / 0.85 and 0.93 / 0.99 [0.82 / 0.80 and 0.91 / 0.94]
wide x zero, wide x one 2.6 / 1.7 and 1.5 / 1.3 (0.5 -> 1.4 ns and 1.0 -> 1.6 ns)
The last row is the price of the scalar dispatch: main answers a zero
operand with one vector test, this code with eight limb loads. It stays
under 1.5 ns absolute and is the only shape slower than main.
An operand that fits in two limbs against a wide one needs five full products and two low halves (x0*y3 and x1*y2) rather than the six and four of the full kernel: four fewer multiplier uops and one fewer carrying add. The dispatcher already has both top words, so the gate is one test per side after the two-limb-by-two-limb check, and the 2x3, 2x4, 3x2 and 4x2 shapes all take it. Same fold-as-you-go ordering as the other kernels, no stack references. Zen 5, .NET 10.0.11, one-process paired harness on four-limb struct storage, new/main, FullOpts and Tier-1 with dynamic PGO, previous commit in brackets: 2x4, 4x2 0.56 / 0.59 [0.63 / 0.70] 3x3, 4x4 0.65 / 0.71 and 0.65 / 0.78 [0.63 / 0.70 and 0.63 / 0.76] production mix 0.53 / 0.56 [0.52 / 0.57] The two extra gates cost the 3x3 and 4x4 shapes 2-3%. In the wide-modulus MulMod corpus 2x4 is 27% of products against 36% for 4x4, and in checked EVM arithmetic a 4x4 product overflows while 2x3 and 3x2 usually do not.
The 128x128 and 128x256 kernels were gated on X86Base.X64.IsSupported after an earlier ARM64 run regressed. That version tested the two-limb shapes ahead of the one-limb ones, so every 4x1 product paid an extra gate, and built its results through the constructor. Neither applies now: the one-limb ladder is tested first, the kernels store limbs directly where Vector256 is not accelerated, and they are inlined at the call site, so the only per-call cost of the gates is two ORs of limbs that are already loaded and up to three predictable branches. The arithmetic saving is the same on every target: 4 widening products instead of 10 for 2x2 and 7 instead of 10 for 2x3, 2x4, 3x2 and 4x2. Where Multiply64 is the four-multiply software fallback (riscv64, or x64 with intrinsics disabled) that is 16 multiplies instead of 40 for 2x2. x64 codegen is unchanged. With intrinsics disabled, which is the only local configuration where X86Base.X64.IsSupported is false, against the previous commit (Zen 5, .NET 10.0.11, FullOpts, one-process paired harness, ratio to main): limb reader 2x2 0.70 -> 0.47 production mix 0.72 -> 0.66 vector reader 2x2 0.89 -> 0.76 2x4 0.89 -> 0.83 4x4 0.85 -> 0.87 chains 4x1 0.59 -> 0.63 4x4 0.72 -> 0.76 (within noise) ARM64 needs the benchmark workflow run before this is called settled; the earlier regression is explained above but not yet re-measured there.
…d limbs The zero and one shortcuts are gone from the Multiply entry, so those operands now travel through the one-limb ladder and the kernels, and the 2x2 and 2xN kernels are reached from both operand orders. Multiply every width pair 1..4 x 1..4 with limbs drawn from 0, 1, 2, 2^63, 2^64 - 1 and random values, plus the all-ones operand of each width against all-ones, zero and one. Each product is checked against BigInteger through the static call, the operator, and with the result aliased over either input.
MultiplyScalarTargeted had the wide-by-one shape but not one-by-wide, and nothing that reaches the two-limb-by-wide kernel. Add SmallOne, TwoByFour and FourByTwo so the ARM benchmark workflow exercises the second one-limb gate and the 2xN kernel in both operand orders.
Every place that viewed an `in UInt256` operand as a Vector256 did so with `Unsafe.As<UInt256, Vector256<T>>(ref Unsafe.AsRef(in x))`, a byref reinterpretation of a value that is only read. `Unsafe.BitCast<UInt256, Vector256<T>>(x)` says the same thing without the byref, which is what the JIT team recommends for same-size conversions and what UInt256.std.cs already uses. 21 sites in AddOverflow, SubtractImpl, the LessThan family, ReduceSumAssumingLT2m and the small shifts. The 9 result stores keep `Unsafe.As<UInt256, Vector256<T>>(ref res) = v`. Written as `res = Unsafe.BitCast<Vector256<T>, UInt256>(v)` the JIT routes the value through stack temps whenever the out parameter is a promotable local in the inlining caller: `operator +` grew from 108 to 156 bytes with a 120-byte frame and three extra 32-byte copies, and `operator -` from 428 to 548, while the standalone methods were unchanged. The read-side change is byte-identical in every touched method (JitAsm, Tier-1, AVX-512 and AVX2), including the operator wrappers, Divide, Mod, MultiplyMod and the multiply kernels; LessThanBothAvx2 differs only in register assignment. The six Vector128 half-views in the add and subtract fallbacks are genuine byref views of half a struct and stay.
Contributor
There was a problem hiding this comment.
🔵 Needs a closer look
There is a confirmed incorrect Vector256.ConditionalSelect usage in ReduceSumAssumingLT2m that could produce wrong results if that branch is taken.
Pull request overview
This PR refactors UInt256.Multiply to dispatch on operand “limb width” and use inlined per-shape scalar kernels, while also optimizing the result write on accelerated Vector256 targets to a single 32-byte store. It also replaces several Unsafe.As byref vector reads with Unsafe.BitCast, and expands tests/benchmarks to cover the new dispatch shapes and aliasing scenarios.
Changes:
- Reworked
UInt256.Multiplyinto a limb-shape dispatcher with specialized 1×1 / 1×N / 2×2 / 2×4 / 4×4 kernels and a unifiedStoreProductfast store path. - Updated vector reads from
Unsafe.As<UInt256, Vector256<ulong>>(ref Unsafe.AsRef(in ...))toUnsafe.BitCast<UInt256, Vector256<ulong>>(...)in multiply-related and shift/compare paths. - Added/extended tests and benchmark shapes to validate all width pairs (including edge limbs and aliasing) and measure targeted multiply shapes.
File summaries
| File | Description |
|---|---|
| src/Nethermind.Int256/UInt256.DivideMod.cs | Switched vector loads to Unsafe.BitCast inside reduction and shift helpers; found a ConditionalSelect argument-order bug in the vector reduction branch. |
| src/Nethermind.Int256/UInt256.cs | Major Multiply refactor into limb-shape dispatch + new kernels + single 32-byte store on Vector256 acceleration; updated multiple vector reads to BitCast; Exp now uses Multiply. |
| src/Nethermind.Int256.Tests/UInt256Tests.cs | Added comprehensive multiply tests across all limb-width pairs, edge limb sets, operand order, and aliasing directions vs BigInteger modulo 2^256. |
| src/Nethermind.Int256.Benchmark/Benchmarks.cs | Expanded MultiplyScalarTargeted benchmark shapes to include more width-pair scenarios (e.g., 1×2, 2×1, 2×4, 4×2). |
Review details
Suppressed comments (1)
src/Nethermind.Int256/UInt256.DivideMod.cs:558
Vector256.ConditionalSelectarguments appear reversed. The scalar fallback computes(d & mask) | (sum & ~mask), so the vector form should select betweendVandsumVbased onmaskV. Current code usesdVas the condition andmaskVas a data vector, which does not match the scalar semantics (and would produce incorrect results if this branch were ever taken).
Vector256<ulong> dV = Unsafe.BitCast<UInt256, Vector256<ulong>>(d);
Vector256<ulong> sumV = Unsafe.BitCast<UInt256, Vector256<ulong>>(sum);
Vector256<ulong> maskV = Vector256.Create(mask);
Vector256<ulong> resultV = Vector256.ConditionalSelect(dV, sumV, maskV);
- Files reviewed: 4/4 changed files
- Comments generated: 0
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
The Copilot review on #114 pointed out that the Vector256 branch called ConditionalSelect(dV, sumV, maskV), which selects between sumV and maskV on dV, not between d and sum on the mask as the scalar formula does. The branch could never run: needSub is carry | (borrow ^ 1) with both inputs 0 or 1, so mask is always 0 or all ones and the two branches above it cover every case. Remove the dead vector and scalar-mask branches and test needSub directly. 185 -> 156 bytes; AddMod codegen unchanged; the suite passes with and without intrinsics.
LukaszRozmej
approved these changes
Sep 2, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Results
UInt256.Multiplygets 1.4x to 6.5x faster depending on operand shape and part, with no change to the public API or semantics.MultiplyScalarTargetedunder BenchmarkDotNet, ns per product,main-> this branch:Every shape improves on every part, the full 256x256 product included, and the shapes EVM traffic is made of improve most: checked Solidity arithmetic reverts on overflow, so successful
MULis dominated by products that fit in one or two limbs. The production mix row is 31% wide-by-one-limb, 28% 128x128, 22% 256x256, 10% 192x192, 9% 64x64.The only regressions are
wide x 0(0.5 -> 1.4-2.1 ns) andwide x 1(1.0 -> 1.6-1.9 ns): main answered those with a singlevptestbefore doing anything else, this code with eight limb loads and a compare inside the one-limb ladder. Both stay under 2.1 ns absolute.Per-part detail, hardware and workflow run links are under Evidence below.
What changed
Multiplyon scalar limb tests: load the eight limbs once, OR them into a high word and a top word per operand, and take one of five inlined kernels: 64x64 (one 32-byte store), the one-limb ladder for either side, 128x128, 128x256 for either side, and the full 256x256. The old entry ran two vectorIsZerotests, two vectorIsOnetests and twoIsUint64calls (not inlined under Tier-1), then reloaded and re-gated the limbs inMultiplyScalar, which pushed eight registers on every path.Vector256is accelerated. Four limb stores cannot be store-forwarded to the 32-byte load the EVM stack push,IsZeroand the next vector op perform; the 64x64 fast path was worse still, a 32-bytedefaultfollowed by two limb stores.MultiplyAvx512F, extend the tests to every width pair with zero, one, saturated and random limbs in both operand orders and both aliasing directions, and add the one-by-wide and two-by-wide shapes toMultiplyScalarTargeted.in UInt256operands as vectors withUnsafe.BitCastinstead ofUnsafe.Asbyrefs (21 sites; byte-identical codegen). The result stores keep the byref store: written asres = Unsafe.BitCast<Vector256<ulong>, UInt256>(v)the JIT routes the value through stack temps when the out target is a promotable local in the inlining caller (operator +108 to 156 bytes,operator -428 to 548).ExpcallsMultiplyinstead of the full kernel so a one-limb base takes the ladder.Why not AVX2, AVX-512 or NEON for the product itself
The truncated 256x256 product is ten 64x64 widening multiplies (six full, four low-only), sixteen multiplier uops as
imulplus high-onlymulx, with short carry chains. RyuJIT has no add-with-carry chaining and on 10.0.11Math.BigMul(out),MultiplyNoFlags(&low)andUInt128all still address-expose the low half, so the current form is the JIT's floor for the arithmetic. A 32x32vpmuludqformulation needs 36 partial products plus cross-lane carry propagation through shifts and permutes, which is why the removed AVX-512 kernel measured 1.7-2.0x slower. AVX-512 IFMA, the 52-bit form that makes vector multiplication competitive for batched work, is not exposed by .NET 10 (Avx512Ifmais absent from the ref pack). NEON has no 64x64->128 vector multiply. Every gain here is in dispatch, stores and register pressure, and the vector unit's job is the result store.Evidence
Where the time goes
Store-to-load forwarding, measured with a dependency-chain probe calibrated against a 3-cycle
imulchain (cycles on the critical path): 8B store to 8B load 1 (memory renaming); four 8B stores to an 8B load 2; 32B store to an 8B load at any offset about 8.5, it forwards; 32B store to 16B or 32B load forwards; four 8B stores or two 16B stores to a 32B load about 17, a failure that waits for commit; building the vector from four registers costs about 4 more cycles than a broadcast. That is the trade in the 32-byte store: a scalar consumer that depends on the result pays about 12 cycles more latency, a vector consumer stops paying a failure that also costs 9-21 cycles per op with independent iterations.Codegen (Tier-1, x64 with AVX-512 or AVX2): the dispatcher with all kernels inlined is 1,579 bytes; the ladder 163 bytes with no stack references, the 128x128 kernel 202 with none, the 128x256 kernel 274 with none, the full kernel 360 with two. Main: 208-byte entry with two
IsUint64calls plus a 663-byteMultiplyScalarwith 16 stack references.operator *(in UInt256, uint)now folds the dispatch to the 1x1 path and the ladder in 239 bytes with no calls.BenchmarkDotNet, per part
x64 desktop, Ryzen 9 9950X (Zen 5, AVX-512), 10-iteration job
Baseline is the
origin/mainplus benchmark file branch, built and run from its own checkout; candidate is 41ec30b.These ratios are stronger than the harness ratios below because the BenchmarkDotNet loop copies both operands into locals and folds the result into a vector accumulator, so it pays main's store-forwarding failures twice per product; the harness reads operands from arrays and models one consumer at a time.
x64 CI, EPYC 9V74 at 2.6 GHz (ubuntu-latest, AVX2 without AVX-512), ShortRun job
Standard deviations below 0.2 ns in both runs. Baseline is the same
origin/mainplus benchmark file branch (run 33649803371); candidate is 41ec30b (run 33649799441).The gap is wider than on the Zen 5 desktop because main's limb stores cost more on this Zen 4 part: at 2.6 GHz its 1x1 product is about 54 cycles per iteration against 8 for the branch, which is the store-forwarding failure between the limb stores and the benchmark's vector read of the result (the same read the EVM stack push performs), not the multiply.
ARM64, Neoverse N2 (ubuntu-24.04-arm, 4 vCPUs), ShortRun job
256 products per invocation. Baseline is
origin/main(8457e91) plus the benchmark file (run 33642919142); candidate is this branch at 0db2d01, before the BitCast refactor, which is codegen-neutral (run 33642915610).On main every shape with a two-limb or wider operand cost the full kernel (8.6 ns) and every one-limb shape 4.7 ns; the branch separates them. This is the first ARM64 measurement of the uniform dispatch, and it settles the earlier regression: none of the narrow shapes is slower and the wide ones improve with the kernel ordering and inlining alone.
Paired A/B harness, Zen 5: consumer shapes, tiers, per commit, ISA off
.NET 10.0.11, one-process paired harness (all variants in one process, per-round paired ratios, pinned core, operands in a plain four-limb struct), ratios are branch / main.
vecreads the result as oneVector256,limbreads two limbs,chainisx = x * b[i]with the output aliasing the input,fixedrepeats one operand pair like the BenchmarkDotNet benchmark. Two runs of the final code are shown as a range where they differed.AVX2 without AVX-512 matches the vector-reader table (1x1 0.31, one-limb 0.44, 2x2 0.48, 4x4 0.55, mix 0.55). With AVX2 disabled, which on .NET 10 also disables BMI2 and is the closest x64 proxy for the riscv64
Multiply64fallback, the limb reader measures 2x2 0.44, 1x4 0.66, 4x1 0.69, 4x4 0.76, mix 0.66 and the chains 0.66 / 0.75; with all intrinsics off 2x2 0.48, 4x1 0.67, 4x4 0.77, mix 0.66, chains 0.61 / 0.67.Per commit, vector reader, FullOpts / Tier-1: scalar dispatch alone took 1x1 to 0.25 / 0.28 and the wide shapes to 0.80-0.95; the 32-byte store took 4x1 to 0.55 / 0.52 and 2x2 to 0.61 / 0.65; the kernel ordering took 4x4 to 0.89 / 0.70; inlining the kernels took 4x4 to 0.63 / 0.76 and the mix to 0.52 / 0.57; the 2x4 kernel took 2x4 from 0.63 / 0.70 to 0.56 / 0.59 at a 2-3% cost on 3x3 and 4x4. The FullOpts numbers quoted in the first three commit messages were taken with a harness that punned a
Vector256local as the out parameter; that made the JIT fall back to MinOpts for the loops around main and overstated those commits by roughly 10% on the wide shapes. The tables here are from the corrected harness.The branch as Codex left it (15f7b4e) measured 2x2 at 0.88 but the one-limb shapes at 1.03-1.10x slower than main, because its 128x128 gate ran ahead of the one-limb gates; under Tier-1 its 1x2, 2x1 and 2x2 shapes were 1.17-1.38x slower.
Validation
DOTNET_EnableAVX2=0,DOTNET_EnableHWIntrinsic=0(one hardware-dependent skip) and-p:EnableZkEvm=trueconfigurations.git diff --checkpasses.ubuntu-24.04-armbenchmark workflow on the final head is in the table above; the uniform dispatch commit is separate so it can be reverted alone if a later ARM64 workload disagrees.Side findings
Vector256local emitsvmovq ymm, r64, which has no encoding and faults at run time; the same punning with a block zero plus field stores makes FullOpts compilation fail and fall back to MinOpts. Neither shape occurs withUInt256locals. The ladder stores an explicit vector zero so the library does not depend on it. Filed as JIT:initobjof a struct through a reinterpreted byref to a Vector256 local emitsvmovq ymm, r64(illegal instruction) with optimizations, and throws InvalidProgramException at Tier0 dotnet/runtime#133085 (reproduces on .NET 9.0.19 as well).