Skip to content

Use block reductions in TensorPrimitives.MinMaxCore and AggregateAnyAll - #134147

Draft
Mrnikbobjeff wants to merge 5 commits into
dotnet:mainfrom
Mrnikbobjeff:tensorprimitives-block-reductions
Draft

Mrnikbobjeff wants to merge 5 commits into
dotnet:mainfrom
Mrnikbobjeff:tensorprimitives-block-reductions

Conversation

@Mrnikbobjeff

@Mrnikbobjeff Mrnikbobjeff commented Sep 17, 2026

Copy link
Copy Markdown

Follow-up to #133969 (block reduction for IndexOfMin/IndexOfMax/IndexOfMinMagnitude/IndexOfMaxMagnitude), applying the same loop shape to the two remaining reduction cores whose vector loops did one vector per iteration with a test and a branch on every vector. Both changes keep the observable results identical (verified bit for bit against the unmodified library, see below) and only change how many vectors are folded before a decision is made.

MinMaxCore (Min, Max, MinMagnitude, MaxMagnitude, MinNumber, MaxNumber, MinMagnitudeNumber, MaxMagnitudeNumber, Half, the float overloads)

The current loop keeps one accumulator and, for float and double, checks every vector for NaN before folding it. On AVX-512 hardware Vector.Min<float> lowers to vrangeps plus two dependent vfixupimmps, so one accumulator serializes three ops per vector, and the NaN test adds a compare, a k-mask test and a branch on top; TensorPrimitives.Min<float> measures 11.4 GElem/s on a Ryzen 7 7800X3D for 65536 elements (its 512-bit path), a third of the load bandwidth.

The core now reduces blocks of 32 vectors with two independent accumulators and decides once per block:

  • Generic path (every operator): two accumulators of the operator's vector Invoke, NaN masks OR-accumulated from the elements (not from the operator's result, because the Number operators drop NaN and the current core returns the first NaN for them too), one test per block, then a cold NoInlining scan for the first NaN of that block, payload preserved. The overlapping final vector and the scalar path are unchanged.
  • MinOperator/MaxOperator on float and double: MinNative/MaxNative in the loop (a single vminps/vmaxps), the NaN mask as above, and the raw bits OR-ed (Min) or AND-ed (Max) for the signed-zero rule. vminps never returns a value below every input, so when a block's minimum compares equal to zero and the block has no NaN, no element is negative and any set sign bit is a -0; the mirror argument gives +0 for Max. This keeps IEEE minimum/maximum semantics (-0 < +0, NaN propagates) with two instructions per vector on the loop-carried chain instead of three dependent ones.

Integer types keep the operator's Invoke (two vpminsd with memory operands per iteration at the load ceiling).

AggregateAnyAll (the 36 IsXxAny / IsXxAll methods)

The current loop tests every vector's mask and branches. The loop is front-end bound (8 instructions and two branches per 32-byte load; its 512-bit path is no faster than 256-bit on Zen 4). The core now OR-accumulates the masks over blocks of 32 vectors with two accumulators (All accumulates the complement, which lets the JIT fold the ~ and avoids a k-mask round trip that ~IsZero inside a vector AND would otherwise cost on AVX-512), tests once per block, and keeps the overlapping final vector and scalar path. IAnyAllAggregator<T> gained one Accumulate member per width. An early exit happens at most one block later than before, still within the span.

Measurements

7800X3D (Zen 4, AVX-512), BenchmarkDotNet, N = 65536, GElem/s, package 10.0.12 versus this branch:

Method before after
Min<float> 11.4 27.0
Max<float> 11.5 26.2 to 27.2
Min<double> 5.7 13.5 to 14.1
Min<int> 31 to 35 34 to 35 (load ceiling)
IsNaNAny<float> 23.4 32.5
IsFiniteAll<float> 22.3 28.9
IsNegativeAny<float> 23.0 30.5
IsNaNAny<double> 14.8 17.8

With the hit at N/2, IsNaNAny<float> goes from 45.3 to 64.7 (elements per second over the full N). One small regression is known: IsSubnormalAny<float> on the 512-bit path, 20.8 to 19.1, because that operator's compare previously branched on its k-register directly and accumulation has to materialize it with vpmovm2d.

Verification

  • Differential harnesses against the unmodified main build of the library, bit-exact, on the 512-, 256- and 128-bit paths: over 270,000 Min/Max cases (8 types, 8 reductions, 29 lengths from 1 to 65539, NaN payloads at block boundaries, two NaNs, -0/+0, magnitude ties, MinValue) and 253,555 Any/All cases (36 methods, 7 types, 33 lengths, the deciding element at every vector, block and ragged boundary). The only differences are which of two distinct NaN payloads Half Min/Max/MinMagnitude/MaxMagnitude returns (the Half path reaches the core as short, so it has no element-level NaN check, and the old code returned the second NaN in two thirds of those pairs; the result is always one of the input NaNs in both).
  • Full System.Numerics.Tensors.Tests suite: 6006 passed.
  • New tests: long lengths past Helpers.TensorLengths with the best value, the first NaN, -0/+0 and all-zero spans at every block boundary for Min/Max/MinMagnitude/MaxMagnitude/MinNumber/MaxNumber; long-length boundary tests for IsNaNAny/All, IsFiniteAll/Any, IsNegativeAny, IsPositiveAny, IsZeroAll/Any; and a two-distinct-NaN-payload test that pins "first NaN" for float and double and "one of the input NaNs" for Half.

Possible follow-ups, not in this PR: operator-specific block folds for Any/All (an unsigned max of the absolute bit pattern makes IsNaNAny/IsFiniteAll reach the load ceiling), and k-mask accumulation on the 512-bit Any/All path.

Niklas Schilli and others added 3 commits September 17, 2026 11:34
MinMaxCore backs Min, Max, MinMagnitude, MaxMagnitude, MinNumber, MaxNumber,
MinMagnitudeNumber, MaxMagnitudeNumber (and the Half path through
HalfAsInt16AggregationOperator). Its vector loops used one accumulator, one
vector per iteration and, for float/double, a NaN test with a branch on every
vector. On AVX-512 hardware Vector.Min/Max<float> also lowers to vrangeps plus
two dependent vfixupimmps, so the float loop was bound by a 3-instruction
latency chain plus 5-6 instructions of NaN checking per vector.

The vector paths (512/256/128, kept symmetric) now reduce the whole vectors in
blocks of 32 vectors with two independent accumulators, then handle any
remaining elements with one final vector that overlaps the last whole one, as
before. NaN is decided once per block: the IsNaN masks of the block's vectors
are OR-accumulated (branch-free) and, if any is set, a cold NoInlining helper
rescans the block for the first NaN, so the first NaN of the input is still the
one returned (blocks are visited in order and the running result is only
combined after the check). The mask is gathered from the elements rather than
from the result because the Number operators discard NaN; the previous
behavior of returning the first NaN for those operators too is preserved.

Min and Max over float and double take a fast path that reduces each block
with Vector.MinNative/MaxNative (vminps/vmaxps, one instruction, no NaN or
signed-zero guarantees) and restores the IEEE 754:2019 minimum/maximum rules
once per block: the NaN mask above, and an OR (Min) or AND (Max) of the raw
element bits. The native minimum never returns a value below every input, so
when a block's minimum compares equal to zero and the block holds no NaN, no
element is negative and any set sign bit is a -0; the maximum case is mirrored.
The Magnitude and Number operators, Half and the integer types use the generic
block reduction with the operator's own vector Invoke (for integers that is
already two vpminsd with memory operands per iteration).

Block results are combined with the operator's scalar Invoke, first block
first, so the operators' tie rules are unchanged. The per-width methods are
NoInlining so that each is its own JIT root with enough inlining budget for
the block reduction and the horizontal aggregates; the old shape was inlined
into the public entry point, whose small budget left HorizontalAggregate and
the typeof helpers as real calls.

Hot loop for float on AVX-512 (32 floats per iteration): 2 loads, 2 vminps,
1 vpternlogd, 2 vcmpneqps, korw, vpmovm2d, vorps, plus the loop overhead.

Measured on a Ryzen 7 7800X3D (Zen 4, AVX-512), 65536 elements, package
10.0.12 vs this change, GElem/s:
  Min<float>   11.1-11.5 -> 27.0
  Max<float>   11.4-11.5 -> 26.2-27.2
  Min<double>   5.6-5.7  -> 13.5-14.1
  Min<int>     31-35     -> 34-35 (load ceiling either way)

Verified bit-exact against the unmodified library over a differential matrix
(8 element types x 8 reductions x 29 lengths up to 65539 x random/all-equal/
all-zero/signed-zero/NaN-with-payload/two-NaN/infinity/magnitude-tie/MinValue
cases, on the 512, 256 and 128-bit paths). The only difference is which of
two distinct NaN payloads Half returns: the Half path never had a "first NaN"
rule (it depends on the lane pairing of the vector operator, and the previous
code returned the second NaN's payload in two thirds of those cases), and the
block pairing changes it in about 5% of them. Tests cover lengths past 256,
the first-NaN rule, signed zeros and all-zero inputs at block boundaries for
all six span reductions.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
AggregateAnyAll backs the 36 IsXxAny / IsXxAll methods (IsNaNAny, IsFiniteAll,
IsNegativeAny, IsZeroAll, ...). Its vector loops tested and branched on every
vector: load, the operator's compare, a horizontal test (vptest, or for All a
compare against zero and vptest) and a conditional branch, plus the loop
overhead, so the loop was front-end bound at 8 instructions and two branches
per vector, and its 512-bit path was no faster than the 256-bit one.

The vector paths (512/256/128, kept symmetric) now fold the whole vectors in
blocks of 32 vectors into two independent accumulators with no branch on the
data and decide once per block, then fold the remaining whole vectors (fewer
than a block) the same way, and finally handle any remaining elements with one
vector that overlaps the last whole one, as before. The index is a nuint so that
the loads need no zero extension.

IAnyAllAggregator<T> gains Accumulate(accumulator, result) per width, the fold
of an operator result into a block accumulator, in which a lane with all bits
set means that the block settles the result. AnyAggregator ORs the result in;
AllAggregator ORs its complement in (a lane where the operator was false), so
both use a zero identity, an OR to merge the two accumulators and the same
block test (AnyWhereAllBitsSet). Accumulating the complement rather than AND-ing
the results also lets the JIT fold the negation into an operator that ends in
one, such as IsFinite. The per-vector ShouldEarlyExit overloads remain in use
for the final overlapping vector; the scalar path is unchanged. The results are
identical: a hit is still found, only its detection is deferred to the end of
its block, and every read stays within the span. The per-width methods are
NoInlining roots, as in MinMaxCore, so that the operator and the aggregator
stay inlined.

Hot loop for float on AVX2 (16 floats per iteration): 2 loads, 2 vcmpneqps,
2 vorps, add, cmp, jb; one vorps, vpcmpeqd x2, vptest, jne per 32 vectors. On
AVX-512 the JIT goes through a mask register per vector (vcmpneqps k1 +
vpmovm2d + vorps).

Measured on a Ryzen 7 7800X3D (Zen 4, AVX-512), 65536 elements, GElem/s:
  BenchmarkDotNet, HPC AnyAllBenchmarks, package 10.0.12 vs this change, no hit
  (the whole span is scanned):
    IsNaNAny<float>      23.4 -> 32.5 (AVX-512 path), 23.9 -> 31.5 (AVX2 path)
    IsFiniteAll<float>   22.3 -> 28.9,                12.6 -> 28.9
    IsNegativeAny<float> 23.0 -> 30.5,                24.5 -> 35.6
    IsNaNAny<double>     14.8 -> 17.8,                12.3 -> 18.0
  Hit at N/2: IsNaNAny<float> 45 -> 65 (the block shape reads at most one extra
  block past the hit); hit at N-1: the same as no hit.
  Stopwatch A/B of 21 vectorized instantiations against the unmodified library,
  no hit: 1.02x to 1.32x on the AVX-512 path, 1.04x to 2.05x on the AVX2 path
  (IsFiniteAll<float> 14.3 -> 29.1, IsZeroAll<int> 21.9 -> 33.7, IsOddIntegerAny
  <ushort> 29.3 -> 53.4). The one exception is IsSubnormalAny<float> on the
  AVX-512 path, 20.8 -> 19.1: the old shape tested the compare's mask register
  directly (kortestw), the block shape has to materialize it (vpmovm2d) to OR it
  in, and with that operator's four instructions per vector the double-pumped
  512-bit units are the bound on Zen 4; on the AVX2 path it is 17.5 -> 23.3.

Verified identical to the unmodified library over a differential matrix (all
36 methods x float/double/Half/int/long/byte/ushort x 33 lengths up to 65539 x
uniform fills, one deciding element at every vector, block and ragged boundary,
two deciding elements, random mixes: 253,555 cases, on the 512, 256 and 128-bit
paths). Tests place the deciding element at every block, vector and ragged
boundary of long inputs for IsNaNAny/All, IsFiniteAll/Any, IsNegativeAny,
IsPositiveAny and IsZeroAll/Any.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Min/Max/MinMagnitude/MaxMagnitude with two NaNs of different payloads at every
pair of block-boundary positions over the long lengths: float and double must
return the first NaN bit for bit; Half (which reaches the core as short and has
no element-level NaN check) must return one of the two input NaNs rather than
a canonical NaN or a value. The payloads sit above bit 13 so they survive the
narrowing to Half, and the test asserts the two inputs really differ. Passes on
the unmodified library as well; nothing in the suite covered this before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@dotnet-policy-service dotnet-policy-service Bot added the community-contribution Indicates that the PR has been added by a community member label Sep 17, 2026
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 3 pipeline(s).
13 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @dotnet/area-system-numerics
See info in area-owners.md if you want to be subscribed.

Niklas Schilli and others added 2 commits September 17, 2026 21:21
….AggregateAnyAll loops

On AVX-512 hardware the JIT keeps a 512-bit comparison result in a mask
register, and OR-ing it into a vector accumulator first materializes it as a
vector (vpmovm2d), so the block loops of AggregateAnyAll cost three vector
instructions per vector. A select whose condition is the comparison lowers to a
masked blend that consumes the mask register as it is, and a select whose other
operand is the zero vector costs no extra instruction (the JIT even folds it into
a zero-masking move under the inverted comparison), whereas an all-ones operand
is rematerialized inside the loop (vpternlogd).

The 512-bit loops therefore accumulate by clearing lanes: the accumulators start
from all bits set, IAnyAllAggregator<T>.ClearSettled (which replaces the 512-bit
Accumulate) clears the lanes whose result settles the aggregation, the two
accumulators are combined with an AND and a block is decided by any lane being
zero. Any clears where the result is true (ConditionalSelect(result, Zero, acc))
and All where it is false (ConditionalSelect(result, acc, Zero)).

The 128- and 256-bit loops keep vector accumulators, but each is now a result
itself: the OR of Any results and the AND of All results, starting from the
result that equals DefaultResult, so that ShouldEarlyExit decides the block. This
removes the complement from All's accumulation ("acc | ~result"), which on
AVX-512VL hardware running 256-bit vectors folded into the predicate of an
integer comparison that the JIT can then not rewrite from its EVEX mask form
back to a VEX vector comparison, costing a vpmovm2d per vector.

The scalar loop moves to AggregateAnyAllScalar; results are unchanged.

Ryzen 7 7800X3D, best of three runs, N = 4096 (L1-resident), GElem/s, before ->
after, 512-bit path: IsNaNAny<float> 38.6 -> 42.7, IsNaNAll<float> 39.1 -> 45.6,
IsFiniteAll<float> 33.0 -> 39.9, IsFiniteAny<float> 32.0 -> 39.8,
IsInfinityAny<float> 30.6 -> 36.4, IsIntegerAll<float> 16.4 -> 20.8,
IsOddIntegerAny<ushort> 60.8 -> 72.5, IsNegativeAny<int> 34.5 -> 41.4,
IsNaNAny<double> 16.2 -> 17.7. The 256-bit path (with and without AVX-512VL) and
the 128-bit path are unchanged within noise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…n TensorPrimitives.AggregateAnyAll

Most of the IEEE 754 classifications are a single unsigned comparison of the
element's bits against a constant: a NaN has an absolute bit pattern above
infinity's, a finite value one below it, a negative value has bits above the
largest pattern with the sign bit clear, and a normal or subnormal value falls
in a range that a subtraction turns into a comparison with wrap-around. For such
an operator an Any/All aggregation does not need the comparison per vector: it
folds the keys of a block with one unsigned minimum or maximum per vector and
compares the fold against the threshold once per block. Any looks for a key on
the operator's side of the threshold and All for one on the other side, so the
fold is the maximum when the operator is true above the threshold and the
aggregation is Any, or true below it and the aggregation is All, and the
minimum otherwise.

IBooleanUnaryOperator<T> gains the threshold form as optional members with
defaults: HasThresholdForm, TrueBelowThreshold, ThresholdBits (the threshold as
the bits of an unsigned integer of the element size) and Key per width. The
operators that take it are IsNaN, IsFinite and IsRealNumber (the absolute bits
against infinity), IsNormal and IsSubnormal (the absolute bits minus the
smallest normal value, or minus one, against the width of the range), IsZero
(the absolute bits, or the bits for the integers, below one) and IsNegative and
IsPositive (the bits against the sign bit), the last three for the primitive
integers as well as for float and double.

AggregateAnyAll dispatches such an operator to AggregateAnyAllThreshold, generic
over the key type (the unsigned integer of the element size) and over the fold,
MaxOperator<TKey> or MinOperator<TKey>: choosing the fold by type rather than
by a branch on its direction leaves a single use of each key in the loop, which
the JIT then folds into the fold instruction's memory operand. The 64-bit
unsigned minimum and maximum are single instructions only with AVX-512, so
double and the 64-bit integers use the threshold form only when Avx512F.VL is
supported and keep the operator's comparison otherwise. The per-width loops
have the block shape of the generic paths; the final overlapping vector is
compared against the threshold as well.

Hot loops on AVX-512 for IsNaNAny<float>: vandps with the load as its memory
operand and vpmaxud per vector, vpcmpgtud and kortestw per block; for
IsNegativeAny<int> a single vpmaxud with a memory operand per vector, and on
SSE/AVX2 the same with 128- and 256-bit registers.

Ryzen 7 7800X3D, best of three runs, N = 4096 (L1-resident), GElem/s, before the
two commits -> after, 512-bit path: IsNaNAny<float> 39.8 -> 43.0, IsNaNAll<float>
40.1 -> 57.5, IsFiniteAll<float> 33.0 -> 43.2, IsFiniteAny<float> 32.4 -> 42.6,
IsNormalAll<float> 28.3 -> 41.3, IsSubnormalAny<float> 19.5 -> 28.5 (the
regression noted in the block-accumulation change is gone), IsNegativeAny<float>
38.9 -> 59.3, IsNegativeAny<int> 36.8 -> 65.9, IsNegativeAny<long> 17.9 -> 31.2,
IsFiniteAll<double> 15.6 -> 21.4, IsNormalAll<double> 14.0 -> 20.3,
IsZeroAll<int> 40.2 -> 44.1, IsZeroAny<byte> 140.9 -> 172.4. N = 65536
(L2-resident): IsNaNAny<float> 34.3 -> 36.4, IsFiniteAll<float> 31.9 -> 38.5,
IsNormalAll<float> 27.7 -> 33.9, IsSubnormalAny<float> 19.0 -> 27.8. AVX2 path
(AVX-512 disabled), N = 4096: IsFiniteAny<float> 24.2 -> 41.7, IsPositiveAll
<float> 23.4 -> 42.8, IsNormalAll<float> 23.9 -> 40.1, IsZeroAll<int> 29.9 ->
43.1, IsNegativeAny<int> 41.0 -> 58.9; IsNaNAny<float> and IsZeroAny<float>,
already two instructions per vector there, vary within +-10% between runs and
sizes. 128-bit path: IsFiniteAny<float> 13.1 -> 26.8, IsPositiveAll<float> 13.2
-> 27.1, IsNegativeAny<int> 22.8 -> 32.7.

Tests: long-length boundary tests for the operators that gained the threshold
form, placing the values whose bits lie next to the thresholds (-0, the smallest
and largest subnormal, the smallest normal, the largest finite value, infinity
and the NaN whose bits follow it, each with both signs) at every block, vector
and ragged boundary; and a theory over all 17 Any/All pairs that checks long
spans filled with each special value, and spans in which one special value is
placed at every boundary of a span filled with another, against the scalar
predicate. The Is* tests pass at 512, 256 and 128 bits, with AVX-512, AVX2 and
AVX disabled, and with hardware intrinsics disabled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Mrnikbobjeff

Copy link
Copy Markdown
Author

Pushed the two follow-ups from the description as two commits on top of the PR:

  • 5683256 — the 512-bit AggregateAnyAll loops consume the comparison's mask register directly. A ConditionalSelect whose condition is the comparison lowers to a masked blend, and with the zero vector as its other operand the JIT folds it into a zero-masking move under the inverted comparison (vpcmp k1 + vpblendmd {k1}{z} per vector: no vpmovm2d, no constant materialized), so the accumulators start from all bits set and ClearSettled clears the lanes that settle the aggregation. At 128/256 bits each accumulator is now simply a result (the OR of Any results, the AND of All results, starting from the result equal to DefaultResult), which also drops the ~result from All that left integer comparisons in EVEX form on AVX-512VL hardware running 256-bit vectors.
  • ce7fc23 — operator-specific block folds. IBooleanUnaryOperator<T> gains an optional threshold form (Invoke(x) == Key(x) < ThresholdBits, or > ThresholdBits, read as unsigned integers of the element size), and AggregateAnyAll then folds a block with one unsigned minimum or maximum per vector and compares once per block. IsNaN/IsFinite/IsRealNumber use the absolute bits against infinity, IsNormal/IsSubnormal the absolute bits minus the smallest normal (or minus one) against the width of the range, IsZero the (absolute) bits below one, and IsNegative/IsPositive the bits against the sign bit, the last three for the primitive integers too. The fold is chosen by type (MaxOperator<TKey>/MinOperator<TKey>), so the loop body is a single call whose key the JIT folds into the instruction's memory operand: vandps zmm, zmm, [mem] + vpmaxud per vector for IsNaNAny<float>, a single vpmaxud zmm, zmm, [mem] for IsNegativeAny<int>. 64-bit keys use it only where Avx512F.VL is supported (elsewhere Max<ulong> is emulated).

Benchmarks

Ryzen 7 7800X3D (Zen 4, AVX-512, Vector512 accelerated), Windows 11, BenchmarkDotNet 0.15.8 in-process against the locally built library, PR head (5724ace) vs. this branch (ce7fc23). Inputs are random finite values (positive for the sign tests), so nothing settles the result unless the name says so.

1. AnyAllBenchmarks from the HPC project (the benchmark behind the numbers in the description), .NET 10.0.12, net10.0 build of the library, N = 65536 (L2-resident):

Method N Input PR head (mean) now (mean) PR head GElem/s now GElem/s speed-up
IsNaNAny 65536 NoHit 2.028 μs 1,882.0 ns 32.31 34.82 1.08x
IsFiniteAll 65536 NoHit 2.194 μs 1,882.8 ns 29.87 34.81 1.17x
IsNegativeAny 65536 NoHit 2.059 μs 1,830.4 ns 31.83 35.81 1.12x
IsNaNAnyDouble 65536 NoHit 3.771 μs 3,969.6 ns 17.38 16.51 0.95x
IsNaNAny 65536 HitMid 1.038 μs 987.3 ns 63.14 66.38 1.05x
IsFiniteAll 65536 HitMid 1.147 μs 978.3 ns 57.11 66.99 1.17x
IsNegativeAny 65536 HitMid 1.064 μs 935.9 ns 61.57 70.02 1.14x
IsNaNAnyDouble 65536 HitMid 1.916 μs 1,822.8 ns 34.21 35.95 1.05x
IsNaNAny 65536 HitLast 1.991 μs 1,857.5 ns 32.92 35.28 1.07x
IsFiniteAll 65536 HitLast 2.193 μs 1,850.8 ns 29.88 35.41 1.18x
IsNegativeAny 65536 HitLast 2.007 μs 1,794.8 ns 32.66 36.51 1.12x
IsNaNAnyDouble 65536 HitLast 4.006 μs 3,540.1 ns 16.36 18.51 1.13x

2. All affected methods, net11.0 build on this branch's own runtime (testhost: .NET 12.0.0-dev, X64 RyuJIT x86-64-v4), N = 4096 (L1-resident) and 65536 (L2-resident):

46 rows
Method N PR head (mean) now (mean) PR head GElem/s now GElem/s speed-up
IsNaNAny_float 4096 105.68 ns 94.40 ns 38.76 43.39 1.12x
IsNaNAny_float_HitMid 4096 63.79 ns 41.00 ns 64.21 99.90 1.56x
IsNaNAll_float 4096 105.45 ns 93.63 ns 38.84 43.75 1.13x
IsFiniteAll_float 4096 129.22 ns 94.95 ns 31.70 43.14 1.36x
IsFiniteAll_float_HitMid 4096 80.61 ns 56.50 ns 50.81 72.50 1.43x
IsFiniteAny_float 4096 126.94 ns 71.47 ns 32.27 57.31 1.78x
IsRealNumberAll_float 4096 99.63 ns 94.97 ns 41.11 43.13 1.05x
IsNormalAll_float 4096 147.17 ns 88.69 ns 27.83 46.18 1.66x
IsSubnormalAny_float 4096 210.17 ns 143.29 ns 19.49 28.59 1.47x
IsZeroAny_float 4096 102.76 ns 96.42 ns 39.86 42.48 1.07x
IsNegativeAny_float 4096 108.39 ns 92.71 ns 37.79 44.18 1.17x
IsPositiveAll_float 4096 102.26 ns 64.00 ns 40.05 64.00 1.60x
IsInfinityAny_float 4096 126.85 ns 114.24 ns 32.29 35.86 1.11x
IsIntegerAll_float 4096 245.98 ns 198.96 ns 16.65 20.59 1.24x
IsEvenIntegerAll_float 4096 838.71 ns 850.10 ns 4.88 4.82 0.99x
IsNaNAny_double 4096 224.85 ns 204.47 ns 18.22 20.03 1.10x
IsFiniteAll_double 4096 266.48 ns 203.09 ns 15.37 20.17 1.31x
IsNormalAll_double 4096 294.42 ns 213.87 ns 13.91 19.15 1.38x
IsZeroAll_int 4096 107.89 ns 94.03 ns 37.96 43.56 1.15x
IsNegativeAny_int 4096 109.87 ns 94.18 ns 37.28 43.49 1.17x
IsNegativeAny_long 4096 229.85 ns 144.19 ns 17.82 28.41 1.59x
IsZeroAny_byte 4096 24.50 ns 25.42 ns 167 161 0.96x
IsOddIntegerAny_ushort 4096 62.37 ns 49.20 ns 65.67 83.24 1.27x
IsNaNAny_float 65536 1,919.12 ns 2,070.85 ns 34.15 31.65 0.93x
IsNaNAny_float_HitMid 65536 913.71 ns 942.73 ns 71.73 69.52 0.97x
IsNaNAll_float 65536 1,948.08 ns 1,945.18 ns 33.64 33.69 1.00x
IsFiniteAll_float 65536 2,111.03 ns 1,875.91 ns 31.04 34.94 1.13x
IsFiniteAll_float_HitMid 65536 1,076.40 ns 957.80 ns 60.88 68.42 1.12x
IsFiniteAny_float 65536 2,088.97 ns 1,949.32 ns 31.37 33.62 1.07x
IsRealNumberAll_float 65536 1,912.13 ns 1,901.21 ns 34.27 34.47 1.01x
IsNormalAll_float 65536 2,348.91 ns 1,994.06 ns 27.90 32.87 1.18x
IsSubnormalAny_float 65536 3,378.40 ns 2,466.67 ns 19.40 26.57 1.37x
IsZeroAny_float 65536 1,937.88 ns 1,940.04 ns 33.82 33.78 1.00x
IsNegativeAny_float 65536 1,930.32 ns 1,820.01 ns 33.95 36.01 1.06x
IsPositiveAll_float 65536 2,177.65 ns 1,824.08 ns 30.09 35.93 1.19x
IsInfinityAny_float 65536 2,084.46 ns 2,033.17 ns 31.44 32.23 1.03x
IsIntegerAll_float 65536 3,934.07 ns 3,175.72 ns 16.66 20.64 1.24x
IsEvenIntegerAll_float 65536 13,315.57 ns 13,459.75 ns 4.92 4.87 0.99x
IsNaNAny_double 65536 3,827.66 ns 3,770.82 ns 17.12 17.38 1.02x
IsFiniteAll_double 65536 4,163.46 ns 3,795.09 ns 15.74 17.27 1.10x
IsNormalAll_double 65536 4,716.22 ns 4,021.73 ns 13.90 16.30 1.17x
IsZeroAll_int 65536 1,908.78 ns 1,824.26 ns 34.33 35.92 1.05x
IsNegativeAny_int 65536 1,793.25 ns 1,777.95 ns 36.55 36.86 1.01x
IsNegativeAny_long 65536 3,467.05 ns 3,522.99 ns 18.90 18.60 0.98x
IsZeroAny_byte 65536 491.16 ns 475.36 ns 133 138 1.03x
IsOddIntegerAny_ushort 65536 1,052.04 ns 1,030.62 ns 62.29 63.59 1.02x

Reading the tables:

  • At N = 65536 the 4-byte methods are bound by L2 bandwidth at ~35 GElem/s on this box, and the float classifications now sit on that ceiling (IsFiniteAll 29.9 → 34.8, IsNegativeAny 31.8 → 35.8, IsNaNAny 32.3 → 34.8). The fewer instructions per vector show at L1-resident sizes: up to 1.78× (IsFiniteAny), 1.66× (IsNormalAll), 1.60× (IsPositiveAll), 1.59× (IsNegativeAny<long>), 1.47× (IsSubnormalAny, whose 512-bit regression noted in the description is gone), 1.27× (IsOddIntegerAny<ushort>, generic path).
  • L2-bound scans of the same code vary by ±10% between runs here: the IsNaNAny_float / 65536 row of table 2 (0.93×) re-ran at 32.3 → 34.7 GElem/s (1.08×), in line with table 1, and the IsNaNAny<double> NoHit row of table 1 (0.95×) is the same full scan as its HitLast row (1.13×). IsEvenIntegerAll and IsZeroAny<byte> are unchanged within noise.
  • The 256-bit path (with and without AVX-512VL) and the 128-bit path were measured with a stopwatch harness (DOTNET_PreferredVectorBitWidth, DOTNET_EnableAVX512=0, DOTNET_EnableAVX2=0): the same operators gain there too (AVX2, N = 4096: IsFiniteAny 24 → 42, IsPositiveAll 23 → 43, IsNormalAll 24 → 40, IsZeroAll<int> 30 → 43 GElem/s; 128-bit: IsFiniteAny 13 → 27, IsNegativeAny<int> 23 → 33), the rest within noise.

Correctness: the results are unchanged. Long-length tests place the values whose bits sit next to each threshold (−0, the smallest and largest subnormal, the smallest normal, the largest finite value, infinity and the first NaN after it, with both signs) at every block, vector and ragged boundary, and a theory over all 17 Any/All pairs checks long spans filled with each special value, and spans with one special value placed at every boundary of another, against the scalar predicate. The Is* tests (982) pass at 512, 256 and 128 bits, with AVX-512, AVX2 and AVX disabled and with hardware intrinsics disabled; the full System.Numerics.Tensors.Tests suite passes (6343).

This branch has not been deployed

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

Labels

area-System.Numerics community-contribution Indicates that the PR has been added by a community member

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant