Skip to content

Optimize Int256 negativity-only gates - #110

Merged
benaadams merged 15 commits into
mainfrom
perf/int256-negativity-gates
Sep 2, 2026
Merged

Optimize Int256 negativity-only gates#110
benaadams merged 15 commits into
mainfrom
perf/int256-negativity-gates

Conversation

@kamilchodola

@kamilchodola kamilchodola commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Results

The signed operations the EVM reaches for - SDIV, SMOD, SAR, SLT, SGT - against main, in ns per
operation. x64 is a Ryzen 9 9950X (Zen 5, AVX-512) under Tier-1 with dynamic PGO; ARM64 is the
ubuntu-24.04-arm runner (Neoverse N2) through the benchmark workflow. Operands are 32-byte aligned on
both, and SignedOpsBench in this PR is what produces the ARM column.

Operation x64: main -> PR ARM64: main -> PR
SAR, negative value 4.99-5.56 -> 0.95-0.99 0.18 5.58 -> 2.71 0.49
SAR, mixed signs 3.22-3.38 -> 0.99-1.03 0.29-0.32 4.66 -> 2.71 0.58
SAR, positive value 1.52-1.80 -> 0.96-0.99 0.55-0.63 3.63 -> 2.71 0.75
SAR by a constant 1 1.80 -> 0.99 0.55 2.80-4.22 -> 2.03 0.48-0.73
SLT, SGT (CompareTo) 1.20-1.41 -> 0.63-0.65 0.46-0.53 2.27-2.39 -> 1.55-1.58 0.65-0.70
operator < 1.06-1.26 -> 0.68 0.54-0.64 1.84-1.91 -> 1.61-1.62 0.85-0.88
Signed multiply, mixed signs 7.17-8.87 -> 4.90-5.76 0.65-0.68 20.1-20.2 -> 8.17 0.40-0.41
Signed multiply, positive 5.23 -> 4.91 0.94 12.36 -> 8.17 0.66
SMOD 8.91-11.78 -> 8.57-10.35 0.88-0.98 22.6-44.8 -> 22.1-33.2 0.74-0.98
SDIV 8.04-14.29 -> 7.86-14.58 0.98-1.02 18.9-29.0 -> 18.7-27.9 0.96-0.99
Neg (unchanged code, control) 0.63 -> 0.63 1.00 3.097 -> 3.097 1.000

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 IsNegative instead of Sign,
which drops an IsZero test - a 256-bit compare - from every gate that only wanted the sign bit. On top
of that:

  • Truncated signed multiplication needs no sign handling at all. Negation is exact in Z/2^256, so
    with the sign applied as modular negation |x|*|y| = s_a*s_b*x*y, and multiplying by s_a*s_b again
    returns x*y: the product of the raw words is already the signed product, Int256.Min included. The
    old code negated both operands, multiplied the magnitudes and negated the result. Exp inherits it,
    keeping only its negative-exponent check.
  • One funnel for the arithmetic shift. It ran two implementations: a non-negative value went to
    UInt256.Rsh, a negative one took an n % 64 == 0 switch, three Srsh64/128/192 helpers that each
    wrote a full Int256 the caller then re-read limb by limb, a goto ladder, and One.Neg for the
    saturating 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 shift stays in vector registers. Computing four limbs in general registers and handing them to
    the 32-byte store costs four domain crossings (vmovq 5 cycles, vpinsrq 6); 25 of the method's
    instructions 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
    vpcmpgtq plus vpermq. AVX-512VL does the window in one vpermt2q per source, taking a lane of the
    fill for any index past the top limb; AVX2 uses vpermd and selects the vacated lanes from the fill
    afterwards. 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.
  • The compares make one descending pass. CompareTo asked two questions to answer one:
    this < b ? -1 : Equals(b) ? 0 : 1 runs the ordering test and then a full 256-bit equality test
    whenever 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.cs ends up 418 lines changed and shorter than it started. Public API and semantics are
unchanged, 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:

  • Decided under Tier-1 with dynamic PGO, not FullOpts. Four further changes - a limb-wise ~x + 1
    negate, a Divide/Mod restructure, a MultiplyMod fold, a branchless Sign - measured 0.72-0.94 with
    tiering off and 1.04-2.36 with it on. PGO keeps UInt256.Subtract(0, x) in vector registers end to
    end 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.
  • Operands staged 32-byte aligned. A 4096-element operand array is large enough for the LOH, which
    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. SignedOpsBench stages into pinned aligned buffers for the same reason.
  • A control in every run. Neg is 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 Mod at 1.16 and the next at 0.79, with the
    base 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 out parameter also measured 1.07-1.25 and is
not 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

  • Full suite in five configurations: default 587,686, AVX2-only 587,686, no-AVX2 587,686,
    DOTNET_EnableHWIntrinsic=0 587,685 with the one expected hardware-hash skip, and
    -p:EnableZkEvm=true 587,683. The first three each exercise a different one of the shift's three paths.
  • The 258-count shift sweep over the signed boundary set covers both shift paths; the added test pins the
    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 --check clean.

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 == 0 fast path recovers it to 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.

AddMod and SubtractMod keep their structure: their mixed-sign arm adds exactly, since opposite signs
cannot 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(...) < 0 and
> 0, which materialises a three-way answer to ask a two-way question, and SAR's count >= 256 guard uses
.Sign >= 0 where !IsNegative is the same test without the zero check.

@LukaszRozmej

Copy link
Copy Markdown
Member

Review

Verdict: approve. Every gate rewrite is provably equivalent, the public API is untouched, and CI is green on all 13 matrices (incl. NoHWIntrinsics and ARM). I also ran the two new tests locally in a worktree: 26/26 pass.

Correctness — checked each rewrite

The load-bearing identity: Sign is IsZero ? 0 : u3 < 2^63 ? 1 : -1, so Sign < 0 <=> u3 >= 2^63 <=> (long)u3 < 0. The IsZero short-circuit is dead weight for a negativity test because u3 >= 2^63 already implies non-zero. The new IsNegative is exact, not approximate — that's what makes the rest mechanical.

  • Multiply: (aSign<0 && bSign<0) || (aSign>=0 && bSign>=0) == aIsNegative == bIsNegative
  • MultiplyMod: (x<0 && y>=0) || (x>=0 && y<0) == xIsNegative != yIsNegative
  • ExpMod: exp < Zero == exp.IsNegative (operator < is two's-complement signed) ✔; mAbs.Sign < 0 -> m.IsNegative is fine since mAbs is an unmodified copy of m at that point ✔
  • Divide: hoisting both IsNegative reads above the branches is aliasing-safe — every path writes res only after its last read of n/d, so Divide(in a, in b, out b) behaves as before ✔
  • Mod, Rsh, AddMod, SubtractMod: direct substitutions ✔

Boundary math in the new test also checks out by hand (min/-1 -> min, min*min -> 0, ExpMod with m = -2^255 works because Neg(min)._value == 2^255 is the correct unsigned magnitude).

Findings

1. 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: UnaryOps.SignedTestCases has 22 values, of which exactly 2 are negativeTestNumbers.Int256Min plus one of the five RandomSigned(0) draws (seed 0 yields +, +, +, -, +; verified by running it). And TestNumbers.Int256Min = -Int256Max = -(2^255 - 1) is not the true minimum: -2^255 was never exercised by any signed test before this PR. Likewise TernaryOps.SignedModTestCases filters Item3 >= 0, which is why negative moduli were uncovered.

So the gap you found is real, but fixing it at the source — add -(BigInteger.One << 255) to SignedTestCases, relax the modulus filter — extends it to all ~12 signed operations through the existing template, rather than to the hand-picked subset re-implemented in the new test. Cost is ~14% more ternary cases on a suite that runs in under 2 min. (Relaxing the modulus filter needs the template oracle checked first, so that part may be a separate PR.)

2. NegativityGatedOperations_MatchBigInteger is a 90-line grab-bag. It asserts ~10 operations plus four exception contracts in one test. A failure gives you one red test and a because string to decode, and it can't be filtered or parallelized per-op. At minimum, split the throw assertions (divide/mod by zero, negative exponent) out of a test named *MatchBigInteger*, and prefer [TestCaseSource] over nested foreach per the repo's test guidance.

3. Coverage gap inside the new test. if (a < -17 || a > 17) continue; sits before the AddMod/SubtractMod/MultiplyMod block, and the follow-up values × values loop only calls AssertMultiplyDivideAndMod. So the three mod operations — whose gates this PR changes — are never exercised with x/y at ±2^255. That's the interesting case precisely because Neg(MinValue) == MinValue. Worth extending the second loop to cover them.

4. operator < now duplicates IsNegative. It open-codes unchecked((long)z._value.u3) < 0 twice, with the comment that explains the trick. Now that IsNegative is that expression, use z.IsNegative / x.IsNegative there.

5. Two negativity-only gates were missed. Abs() (if (Sign >= 0)) and Convert(out BigInteger) (if (Sign < 0)). Neither is hot — Abs is only reached from Convert — but converting them makes "no negativity-only Sign gates remain" actually true.

6. Follow-up (not this PR): AddMod/SubtractMod don't actually need three-way Sign. The description says zero-vs-positive changes control flow there, but working through all the zero cases, it doesn't: routing a zero operand into the IsNegative-based branch gives the same answer every time (0 AddMod y == (0+y) mod m; for SubtractMod with y == 0, -(|x| mod m) == x mod m under truncated division). Both could drop Sign entirely — but add explicit zero-operand cases to the corpus first.

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: SignedBenchmarkBase.Values is { Int256Max, RandomSigned(1) }, and RandomSigned(1) at seed 0 is positive — so DivideSigned/RightShiftSigned/MultiplySigned never take a negative branch. There's also no ModSigned benchmark at all, despite Mod being the largest reported win (-9.9% ARM). Adding one negative value to SignedBenchmarkBase.Values plus a ModSigned class would make the numbers re-derivable later. Not a merge blocker.

On the measurement write-up

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

LukaszRozmej and others added 4 commits September 2, 2026 12:32
`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.
@benaadams
benaadams merged commit 55ca462 into main Sep 2, 2026
17 checks passed
@benaadams
benaadams deleted the perf/int256-negativity-gates branch September 2, 2026 23:02
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