Skip to content

Dispatch UInt256.Multiply on limb shapes with inlined per-shape kernels and one 32-byte result store - #114

Merged
benaadams merged 18 commits into
mainfrom
multiply
Sep 2, 2026
Merged

Dispatch UInt256.Multiply on limb shapes with inlined per-shape kernels and one 32-byte result store#114
benaadams merged 18 commits into
mainfrom
multiply

Conversation

@benaadams

@benaadams benaadams commented Sep 2, 2026

Copy link
Copy Markdown
Member

Results

UInt256.Multiply gets 1.4x to 6.5x faster depending on operand shape and part, with no change to the public API or semantics. MultiplyScalarTargeted under BenchmarkDotNet, ns per product, main -> this branch:

Shape Zen 5 desktop Zen 4 CI (AVX2) ARM Neoverse N2
64x64 9.16 -> 1.85 (5.0x) 20.87 -> 3.20 (6.5x) 5.13 -> 1.62 (3.2x)
one limb x wide, either side 9.96-11.02 -> 3.25-3.42 (3.0x) 22.98-23.21 -> 5.69-5.80 (4.0x) 4.72-4.73 -> 2.81-2.86 (1.7x)
128x128 12.45 -> 4.46 (2.8x) 25.43 -> 7.35 (3.5x) 8.60 -> 4.30 (2.0x)
128x256, either side 11.56-12.78 -> 5.33-5.69 (2.4x) 25.35-25.43 -> 8.85-9.20 (2.8x) 8.59-8.60 -> 5.37-5.43 (1.6x)
256x256 11.38 -> 6.33 (1.8x) 25.44 -> 12.45 (2.0x) 8.60 -> 6.22 (1.4x)
production mix 11.21 -> 4.68 (2.4x) 24.09 -> 7.47 (3.2x) 6.78 -> 4.37 (1.6x)

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 MUL is 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) and wide x 1 (1.0 -> 1.6-1.9 ns): main answered those with a single vptest before 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

  • Dispatch Multiply on 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 vector IsZero tests, two vector IsOne tests and two IsUint64 calls (not inlined under Tier-1), then reloaded and re-gated the limbs in MultiplyScalar, which pushed eight registers on every path.
  • Write every result as one 32-byte store where Vector256 is accelerated. Four limb stores cannot be store-forwarded to the 32-byte load the EVM stack push, IsZero and the next vector op perform; the 64x64 fast path was worse still, a 32-byte default followed by two limb stores.
  • Fold each partial product into its column as it is produced, low-only top-limb products first: the full kernel goes from 12 stack references to 2 and the 128x128 kernel from 6 to 0.
  • Inline all kernels at the call site. Left to the JIT, Tier-1 PGO inlined whichever kernels fit the budget and left the rest as calls, so the same 4x4 product measured 0.58x or 0.88x of main depending on what else had been hot.
  • Add a two-limb by wide kernel (five full products and two low halves instead of six and four) and take the narrow kernels on every architecture; the earlier ARM64 regression came from gate order and constructor stores, both gone.
  • Remove the disabled 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 to MultiplyScalarTargeted.
  • Read in UInt256 operands as vectors with Unsafe.BitCast instead of Unsafe.As byrefs (21 sites; byte-identical codegen). The result stores keep the byref store: written as res = 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).

Exp calls Multiply instead 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 imul plus high-only mulx, with short carry chains. RyuJIT has no add-with-carry chaining and on 10.0.11 Math.BigMul(out), MultiplyNoFlags(&low) and UInt128 all still address-expose the low half, so the current form is the JIT's floor for the arithmetic. A 32x32 vpmuludq formulation 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 (Avx512Ifma is 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 imul chain (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 IsUint64 calls plus a 663-byte MultiplyScalar with 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/main plus benchmark file branch, built and run from its own checkout; candidate is 41ec30b.

Shape main (ns) this branch (ns) ratio
Small (1x1) 9.16 1.85 0.20
OneSmall (4x1), SmallOne (1x4) 10.42, 11.02 3.42, 3.31 0.33, 0.30
OneByTwo, TwoByOne 9.96, 10.85 3.37, 3.25 0.34, 0.30
TwoByTwo 12.45 4.46 0.36
TwoByFour, FourByTwo 12.78, 11.56 5.33, 5.69 0.42, 0.49
Full (4x4) 11.38 6.33 0.56
ProductionMix 11.21 4.68 0.42

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/main plus benchmark file branch (run 33649803371); candidate is 41ec30b (run 33649799441).

Shape main (ns) this branch (ns) ratio
Small (1x1) 20.87 3.20 0.15
OneSmall (4x1), SmallOne (1x4) 23.21, 22.98 5.80, 5.77 0.25
OneByTwo, TwoByOne 23.04, 23.03 5.78, 5.69 0.25
TwoByTwo 25.43 7.35 0.29
TwoByFour, FourByTwo 25.43, 25.35 9.20, 8.85 0.35-0.36
Full (4x4) 25.44 12.45 0.49
ProductionMix 24.09 7.47 0.31

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).

Shape main (ns) this branch (ns) ratio
Small (1x1) 5.13 1.62 0.32
OneSmall (4x1), SmallOne (1x4) 4.73, 4.72 2.81, 2.86 0.60
OneByTwo, TwoByOne 4.73, 4.73 2.86, 2.81 0.60
TwoByTwo 8.60 4.30 0.50
TwoByFour, FourByTwo 8.59, 8.60 5.37, 5.43 0.63
Full (4x4) 8.60 6.22 0.72
ProductionMix 6.78 4.37 0.64

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. vec reads the result as one Vector256, limb reads two limbs, chain is x = x * b[i] with the output aliasing the input, fixed repeats one operand pair like the BenchmarkDotNet benchmark. Two runs of the final code are shown as a range where they differed.

Shape, vector reader FullOpts (ratio to main) Tier-1 + dynamic PGO (ratio to main)
1x1 0.29 0.25-0.26
1x2, 2x1, 1x4, 4x1 0.42-0.44 0.45-0.54
2x2 0.47 0.49-0.50
2x4, 4x2 0.56 0.59-0.62
3x3 0.64 0.71-0.78
4x4 0.64-0.65 0.70-0.78
production mix 0.53-0.57 0.56-0.60
wide x zero (0.5 ns on main, 1.4-2.1 ns here) 2.6-3.3 1.6-1.7
wide x one (1.0 ns on main, 1.6-1.9 ns here) 1.5-1.7 1.2
Other consumers FullOpts (ratio to main) Tier-1 + dynamic PGO (ratio to main)
limb reader 2x2 / 4x1 / 4x4 / mix 0.55 / 0.60 / 0.75 / 0.71 0.66 / 0.91 / 0.90 / 0.83
chain 4x1 / 4x4 0.75-0.79 / 0.83-0.93 0.85 / 1.00
fixed 1x1 / 4x1 / 4x4 0.44 / 0.44 / 0.62 0.19 / 0.47 / 0.68

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 Multiply64 fallback, 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 Vector256 local 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

  • Full suite (576,451 tests) passes in the default (AVX-512), DOTNET_EnableAVX2=0, DOTNET_EnableHWIntrinsic=0 (one hardware-dependent skip) and -p:EnableZkEvm=true configurations.
  • git diff --check passes.
  • The ubuntu-24.04-arm benchmark 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.
  • Pending: the isolated eth_call A/B.

Side findings

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.
Copilot AI lite review requested due to automatic review settings September 2, 2026 15:33

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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.Multiply into a limb-shape dispatcher with specialized 1×1 / 1×N / 2×2 / 2×4 / 4×4 kernels and a unified StoreProduct fast store path.
  • Updated vector reads from Unsafe.As<UInt256, Vector256<ulong>>(ref Unsafe.AsRef(in ...)) to Unsafe.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.ConditionalSelect arguments appear reversed. The scalar fallback computes (d & mask) | (sum & ~mask), so the vector form should select between dV and sumV based on maskV. Current code uses dV as the condition and maskV as 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.
@benaadams
benaadams merged commit 283efef into main Sep 2, 2026
14 checks passed
@benaadams
benaadams deleted the multiply branch September 2, 2026 21:34
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.

3 participants