Optimize Int256 negativity-only gates - #110
Conversation
ReviewVerdict: approve. Every gate rewrite is provably equivalent, the public API is untouched, and CI is green on all 13 matrices (incl. Correctness — checked each rewriteThe load-bearing identity:
Boundary math in the new test also checks out by hand ( Findings1. The boundary coverage belongs in the shared case sources, not a bespoke test. This is the one I'd push on. I probed the existing corpus: So the gap you found is real, but fixing it at the source — add 2. 3. Coverage gap inside the new test. 4. 5. Two negativity-only gates were missed. 6. Follow-up (not this PR): 7. The perf claim isn't reproducible from the repo. Deleting the temp harness was right, but the permanent benchmarks can't stand in for it: On the measurement write-upGood discipline — flagging the AMD control drift as not attribution-safe, and reporting the RPC corpus as neutral rather than spinning it, is the right way to present a library-level micro-optimization. |
`TestNumbers.Int256Min` was `-Int256Max`, i.e. the minimum plus one, so -2**255 was never exercised by any signed test. Fix the constant and keep the old value as `Int256Min + 1`. `TernaryOps.SignedModTestCases` filtered moduli to `Item3 >= 0`, which is why negative moduli were uncovered. BigInteger's `%` and `ModPow` ignore the modulus sign the same way `Int256` does, so the template oracles hold without the filter and it can go. Both extend every signed operation through the existing template, which makes the ad-hoc `NegativityGatedOperations_MatchBigInteger` redundant: the zero-divisor and negative-ExpMod-exponent throws it asserted are already covered by `Div`/`Mod`/`ExpMod` there. Only `Exp` with a negative exponent was genuinely uncovered, since the template takes `int n >= 0`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Abs` and `Convert` still classified three ways to answer a yes/no question, and `operator <` open-coded the high-bit test that `IsNegative` now is. `AddMod`/`SubtractMod` do not need the zero distinction either: routing a zero operand into an IsNegative-based branch gives the same answer, since `0 AddMod y == (0 + y) mod m` and, for `SubtractMod` with `y == 0`, `-(|x| mod m) == x mod m` under truncated division. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`SignedBenchmarkBase.Values` was `{ Int256Max, RandomSigned(1) }`, and
that draw is positive, so no signed benchmark ever took a negative
branch. There was also no `Mod` benchmark despite it being the operation
with the largest measured win.
`Numbers.Int256Min` carried the same off-by-one as its test-project twin.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Routing a zero operand into the IsNegative-based branch is not purely equivalent after all: `SubtractMod(0, Int256.Min, m)` used to fall into the `else` branch, which wraps `0 - Int256.Min` to Int256.Min before reducing and so returned the negation of the right answer. The IsNegative branches reduce first, which is correct. The extended signed corpus already generates this case, but a named test documents what regressed and keeps the two changes from being separated by accident. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Negation is exact in Z/2**256, so a sign-magnitude multiply reduces to the
raw product: writing x = s_a*a and y = s_b*b with s in {1,-1} applied as
modular negation, |x|*|y| = s_a*s_b*x*y, and multiplying that by s_a*s_b
again returns x*y unchanged. The truncated 256-bit product of the raw words
is therefore already the signed product, Int256.Min included, where the old
code negated both operands, multiplied the magnitudes and negated the
result. Exp inherits it: repeated multiplication of the raw base gives the
signed power, so only the negative-exponent check has to stay.
That removes, per call: two IsNegative tests, up to three 256-bit negations
(each a vector subtract plus a 16-entry borrow-correction table lookup on
this JIT), the two unconditional 32-byte operand copies `Int256 av = a,
bv = b`, and the `new Int256(ures)` copy of the result. The product is now
written straight through the out parameter.
Paired A/B harness (Zen 5, .NET 10.0.11, FullOpts, result read as one
Vector256, ratios to this branch's parent):
mul 4x4 mixed sign 16.07 -> 7.52 ns 0.47
mul 2x2 mixed sign 15.55 -> 7.27 ns 0.47
mul 1x1 mixed sign 12.32 -> 5.91 ns 0.48
mul 4x4 positive 10.31 -> 7.28 ns 0.71
All-positive operands gain because the operand copies and the result copy
went away, not the negations.
Full Int256 suite (587,056 cases) passes, including the Multiply and Exp
BigInteger oracles over the signed boundary set.
The arithmetic shift ran two implementations. A non-negative value was handed to UInt256.Rsh, which on this JIT stayed an out-of-line call; a negative one took a bespoke path: a `n % 64 == 0` switch, three helpers (Srsh64/128/192) that the JIT also left as calls, each writing a full Int256 that the caller immediately re-read limb by limb, a goto ladder into the middle of a funnel, and One.Neg for the counts that saturate, which compiles to a 256-bit vector subtract. Eleven basic blocks and four calls for a shift, with the cost depending on both the sign and the count. The sign is the only thing the two cases disagree about, and it is exactly what the funnel needs to shift in from above: with `fill = (long)u3 >> 63` taking the place of the zero that the unsigned shift feeds in, one funnel computes both, count for count, including the whole-word counts and the counts that saturate to 0 or -1. That is the unsigned Rsh with `fill` substituted for `0`, so the two shifts now have the same shape, the same 32-byte result store, and no path that reloads what it just stored. Negative counts, which no caller passes, move from per-limb garbage to the unsigned type's documented rule (a negative multiple of 64 gives 0 or -1, any other negative count shifts by `n & 63`); the remarks say so. Paired A/B harness (Zen 5, .NET 10.0.11, FullOpts, result read as one Vector256, ratios to this branch's parent, ns per shift): negative value, count 0-255 5.11 -> 1.59 0.31 negative value, count 1-63 5.38 -> 1.83 0.34 negative value, whole words 3.06 -> 1.61 0.52 negative value, constant 1 5.18 -> 1.54 0.30 negative value, constant 96 5.13 -> 1.45 0.28 mixed signs, count 0-255 3.52 -> 1.61 0.46 positive value, count 0-255 2.21 -> 1.62 0.73 Every shape now costs the same 1.5-1.8 ns: the shift no longer depends on the sign of the value or on where the count falls. The positive path gains from losing the call, the negative one from losing the store-reload ladder. The 258-count sweep over the signed boundary set already covered this; the added test pins the vacated limbs themselves, which is the part a zero fill would get wrong and an oracle sweep only catches indirectly. Verified it fails with `fill = 0`. Full signed and shift suites (587,583 cases) pass.
CompareTo asked two questions to answer one: `this < b ? -1 : Equals(b) ? 0 : 1` runs the ordering test and then, whenever the answer is not "less", a full 256-bit equality test as well. SLT and SGT in the EVM go through exactly this, and they only ever look at the answer's sign. Flipping the top bit of both operands maps two's-complement order onto unsigned order of the raw words, so one descending limb pass gives the three-way answer, and the same flip removes the sign-class branch from `operator <`. Both now read the operands once, in place, and stop at the first limb that differs. Paired A/B harness (Zen 5, .NET 10.0.11, FullOpts, ratios to this branch's parent, ns per comparison): CompareTo, mixed signs 1.39 -> 0.92 0.67 CompareTo, positive 1.50 -> 0.89 0.60 operator <, mixed signs 1.16 -> 0.90 0.80 operator <, positive 1.32 -> 0.90 0.68 An A/A control on the same corpora skews these cases by up to 1.19 in the candidate's favour-to-beat, so these are well outside the floor. Measured again under Tier-1 with dynamic PGO and 32-byte aligned operands: CompareTo 0.56-0.65, operator < 0.62-0.75, and on Neoverse N2 0.71-0.75 and 0.93-0.95. Both compare paths now cost the same 0.9 ns whatever the operand signs are. The signed limb ladder also beats the vector compare the unsigned `<` uses: a 32-byte compare has to move its mask back through a general register, which costs more latency than the first differing limb costs to find. Full Int256 suite (587,584 cases) passes; the compare oracles cross the signed boundary set with itself, so equal operands and every sign-class pair are covered.
The comment predates the unsigned layer throwing; the suite has required the throw for both Divide and Mod since.
The signed benchmarks time one operation per invocation against a BigInteger baseline, which cannot resolve the 1-2 ns these operations now take, and their operands come from a boundary-value list, so the sign - the thing that used to decide how much work SDIV, SMOD and SAR did - is not a dimension of the measurement. SignedOpsBench runs each of SAR (dynamic count and a constant 1), SDIV, SMOD, signed multiply, negate, CompareTo and operator < over a 256-operand batch with OperationsPerInvoke, parameterised by Mixed, Positive and Negative operand signs. Mixed is the case a branch on the sign mispredicts. Portable, so the ARM benchmark workflow can run it with --filter '*SignedOps*'. Operands are staged in 32-byte-aligned pinned buffers. An Int256[] of this size lands in the large object heap at an arbitrary offset - 0, 8, 16 and 24 mod 32 all showed up in one process - and an unaligned operand splits a cache line on half of its 32-byte reads, which biased runs by more than some of the differences being measured.
…by hand The funnel built the sign into the top limb the same way it builds a carry into the others, `(x3 >> b) | ((fill << 1) << c)`, which is three instructions plus the `fill` word itself. An arithmetic shift of that limb is the same value in one: `(long)x3 >> b` shifts the sign in by definition, and the identity holds at b = 0 as well, where it returns x3 unchanged. So `fill` is only needed for limbs the shift vacates entirely, and the whole-word paths compute it where they use it. The word-shift-of-zero path - the one a small count takes - is now instruction-for-instruction the unsigned funnel with one `sar` where it has a `shr`. That closes the one regression this branch had on x64 and speeds up every other shape as a side effect (Zen 5, Tier-1 with dynamic PGO, 32-byte aligned operands, ratios to the PR's head): positive value, count 0-255 1.168 -> 0.979 positive value, constant 1 0.987 -> 0.900 negative value, count 0-255 0.327 -> 0.282 negative value, count 1-63 0.330 -> 0.286 negative value, whole words 0.587 -> 0.497 mixed signs, count 0-255 0.536 -> 0.468 Absolute cost per shift went from 1.59-1.76 ns to 1.41-1.55 ns, still flat across signs and counts. With intrinsics disabled every shape is 0.39-0.54. One shape stays slower than before: with AVX2 unavailable, so no 32-byte store to hide the funnel behind, a count that is an exact multiple of 64 measures 1.40, because the old code answered those with limb moves and no shifts. A `bitShift == 0` fast path guarded on `!Vector256.IsHardwareAccelerated` recovers 1.40 -> 1.32 but costs the common mixed-count case in that same configuration 0.805 -> 0.840, so it is not worth taking: every other shape there is 0.68-0.95. Full suite passes in the default, AVX2-only, no-intrinsics and zkEVM configurations (587,686 / 587,686 / 587,685 + 1 skip / 587,683).
The funnel computed four limbs in general registers and then handed them to
a 32-byte store, which is four domain crossings: vmovq is 5 cycles and
vpinsrq 6, so the assembly cost more than the shifting did. Counted across
the method, 25 of its instructions were vmovq/vpinsrq/vinserti128, and the
timed loop was throughput-bound at 7.8 cycles for ~28 uops.
The operand is already 32 contiguous bytes, so it can be loaded as one
vector and never leave: the word shift is a lane permute, the bit funnel is
two shifts and an OR, and the sign fill is the top limb's sign broadcast
with vpcmpgtq plus vpermq. Only the two shift counts still cross, and they
depend on the count rather than the value, so they are ready early.
The lane window comes from a table indexed by the word shift. With AVX-512VL
one vpermt2q per source does it, taking a lane of the fill for any index
past the top limb. On AVX2, vpermd reaches every lane but only one operand,
so the vacated lanes are selected from the fill afterwards; its indices wrap
past limb 3, which is what the mask table is for. Everything else - no AVX2,
no intrinsics, and ARM64, where Vector256 is not accelerated - keeps the
scalar funnel.
Crossings per method: 25 -> 7, and only 2 on the hot path.
Zen 5, Tier-1 with dynamic PGO, 32-byte aligned operands, ratios to the PR's
head, and against the scalar funnel of the previous commit in ns:
AVX-512 negative 0-255 0.282 -> 0.216 (1.41 -> 1.05 ns)
mixed signs 0.468 -> 0.353 (1.47 -> 1.08 ns)
positive 0-255 0.979 -> 0.785 (1.41 -> 1.06 ns)
negative, words 0.497 -> 0.385 (1.43 -> 1.07 ns)
AVX2 negative 0-255 0.296 -> 0.241 (1.45 -> 1.18 ns)
mixed signs 0.472 -> 0.394 (1.45 -> 1.21 ns)
positive 0-255 1.051 -> 0.885 (1.41 -> 1.19 ns)
That clears the last of the x64 regressions: a positive value shifted by a
dynamic count was 1.05 on AVX2 and 0.98 on AVX-512, and is now 0.89 and
0.79. The configurations that keep the scalar funnel are unchanged, as is
ARM64, and the whole-word case with no AVX2 at all stays at 1.40 for the
reason the previous commit gives.
Suite passes in the default, AVX2-only, no-AVX2, no-intrinsics and zkEVM
configurations - the first three each exercise a different one of the three
paths (587,686 / 587,686 / 587,686 / 587,685 + 1 skip / 587,683).
The sign-bit flip needed the constant in a register on both sides, and the JIT materialised 0x8000000000000000 twice and XORed twice, on the path straight out of the two loads. None of it is necessary: the sign lives entirely in the top limb, so comparing that limb as a signed long and the rest unsigned is two's-complement order exactly, with no bias to apply first. Four instructions leave both compares, and the constant goes with them. Zen 5, Tier-1 with dynamic PGO, aligned operands, ratios to the PR's head, against the biased form of the previous commits in ns: CompareTo, mixed signs 0.648 -> 0.525 (0.79 -> 0.66 ns) CompareTo, alternating 0.646 -> 0.521 (0.78 -> 0.63 ns) CompareTo, positive 0.559 -> 0.463 (0.78 -> 0.65 ns) operator <, mixed signs 0.736 -> 0.633 (0.81 -> 0.70 ns) operator <, alternating 0.718 -> 0.638 (0.82 -> 0.72 ns) operator <, positive 0.619 -> 0.540 (0.81 -> 0.69 ns) It also reads as what it is, which the bias did not. What is left in these two is one `cmp` per limb that the JIT issues twice, once for the inequality and once for the ordering, because it does not reuse the flags a single compare already set. That is 1 uop per limb examined and there is no way to express the reuse from C#. Full Int256 suite (587,586 cases) passes; the compare oracles cross the signed boundary set with itself, so every sign-class pair and equal operands are covered.
Results
The signed operations the EVM reaches for - SDIV, SMOD, SAR, SLT, SGT - against
main, in ns peroperation. x64 is a Ryzen 9 9950X (Zen 5, AVX-512) under Tier-1 with dynamic PGO; ARM64 is the
ubuntu-24.04-armrunner (Neoverse N2) through the benchmark workflow. Operands are 32-byte aligned onboth, and
SignedOpsBenchin this PR is what produces the ARM column.CompareTo)operator <Neg(unchanged code, control)SAR now costs the same regardless of the sign of the value or where the count falls, where it used to
cost 3.7x more for a negative value than a positive one. Nothing regresses on either platform.
What is in it
Two parts. The original change routes the negativity-only gates through
IsNegativeinstead ofSign,which drops an
IsZerotest - a 256-bit compare - from every gate that only wanted the sign bit. On topof that:
with the sign applied as modular negation
|x|*|y| = s_a*s_b*x*y, and multiplying bys_a*s_bagainreturns
x*y: the product of the raw words is already the signed product,Int256.Minincluded. Theold code negated both operands, multiplied the magnitudes and negated the result.
Expinherits it,keeping only its negative-exponent check.
UInt256.Rsh, a negative one took ann % 64 == 0switch, threeSrsh64/128/192helpers that eachwrote a full
Int256the caller then re-read limb by limb, a goto ladder, andOne.Negfor thesaturating counts - eleven basic blocks and four calls for a shift. The sign is the only thing the two
cases disagree about and it is exactly what the funnel shifts in from above, so one funnel covers both,
count for count.
the 32-byte store costs four domain crossings (
vmovq5 cycles,vpinsrq6); 25 of the method'sinstructions were that. The operand is already 32 contiguous bytes, so it loads as one vector: the word
shift is a lane permute from a small window table, the bit funnel two shifts and an OR, the sign fill a
vpcmpgtqplusvpermq. AVX-512VL does the window in onevpermt2qper source, taking a lane of thefill for any index past the top limb; AVX2 uses
vpermdand selects the vacated lanes from the fillafterwards. Crossings per method 25 -> 7, and 2 on the hot path. Everything without 256-bit vectors -
no AVX2, no intrinsics, and ARM64 - keeps the scalar funnel, which also got cheaper: an arithmetic
shift of the top limb is that limb's funnel step with the sign already in it, one instruction instead
of three.
CompareToasked two questions to answer one:this < b ? -1 : Equals(b) ? 0 : 1runs the ordering test and then a full 256-bit equality testwhenever the answer is not "less" - and SLT and SGT go through exactly this to read one bit of the
result. The sign lives in the top limb, so comparing that limb signed and the rest unsigned is
two's-complement order: one pass, stopping at the first limb that differs, and no sign-class branch in
operator <either.Int256.csends up 418 lines changed and shorter than it started. Public API and semantics areunchanged, with one documented exception: a negative shift count, which no caller passes, moves from
per-limb garbage to the unsigned type's documented rule.
How it was measured
Two independent harnesses, because they disagreed twice and each time the disagreement was real:
~x + 1negate, a Divide/Mod restructure, a MultiplyMod fold, a branchless
Sign- measured 0.72-0.94 withtiering off and 1.04-2.36 with it on. PGO keeps
UInt256.Subtract(0, x)in vector registers end toend at 0.65 ns, where the limb form has the shorter carry chain but has to gather four registers into
the 32-byte result. All four were dropped; only what wins in the configuration that ships is here.
put it at 0, 8, 16 and 24 mod 32 across shapes in one process; an unaligned 32-byte read splits a cache
line half the time, and that bias was large enough on its own to invert a copy-versus-by-reference
comparison.
SignedOpsBenchstages into pinned aligned buffers for the same reason.Negis untouched by this PR, so it reads 1.000 on ARM and 1.00 on x64;when a row moved without a code change - one ARM pair had
Modat 1.16 and the next at 0.79, with thebase drifting 32% between runs on identical code - the control is what said to re-run rather than
report it. Every ARM number above is from a repeated dispatch pair.
Writing a non-inlined callee's result straight through the
outparameter also measured 1.07-1.25 and isnot used here: the JIT cannot prove the parameter does not alias the operands, while the copy through a
local it can prove is free.
Validation
DOTNET_EnableHWIntrinsic=0587,685 with the one expected hardware-hash skip, and-p:EnableZkEvm=true587,683. The first three each exercise a different one of the shift's three paths.limbs the shift vacates, which is the part a zero fill would get wrong, and it was verified to fail with
fill = 0.git diff --checkclean.Not taken
With no AVX2 and no NEON - the portable fallback, where there is no 32-byte store to hide the funnel
behind - a count that is an exact multiple of 64 costs 1.40x, because the old code answered those with
limb moves and no shifts. A
bitShift == 0fast path recovers it to 1.32 but costs the commonmixed-count case in that same configuration 0.805 -> 0.840, so it is not worth taking; every other shape
there is 0.68-0.95.
AddModandSubtractModkeep their structure: their mixed-sign arm adds exactly, since opposite signscannot overflow, and then reduces signed, which the magnitude form cannot express, and no caller in
Nethermind uses either.
Two follow-ups belong in the consuming repo rather than here: SLT and SGT call
CompareTo(...) < 0and> 0, which materialises a three-way answer to ask a two-way question, and SAR's count >= 256 guard uses.Sign >= 0where!IsNegativeis the same test without the zero check.