<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Optimization in .NET via C#]]></title><description><![CDATA[Learning about SIMD via Optizing .NET Runtime]]></description><link>https://mrnikbobjeff.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>Optimization in .NET via C#</title><link>https://mrnikbobjeff.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Tue, 22 Sep 2026 08:31:26 GMT</lastBuildDate><atom:link href="https://mrnikbobjeff.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Argmin in C#: from 3 to 36 billion elements per second]]></title><description><![CDATA[What porting Algorithmica's "Argmin" case study to .NET taught us about SIMD, RyuJIT and Zen 4, and why it ended in a patch for TensorPrimitives.IndexOfMin.

What this is about
Argmin is the simplest ]]></description><link>https://mrnikbobjeff.hashnode.dev/argmin-in-c-from-3-to-36-billion-elements-per-second</link><guid isPermaLink="true">https://mrnikbobjeff.hashnode.dev/argmin-in-c-from-3-to-36-billion-elements-per-second</guid><category><![CDATA[C#]]></category><category><![CDATA[.NET]]></category><category><![CDATA[BCL]]></category><category><![CDATA[performance]]></category><dc:creator><![CDATA[Niklas Schilli]]></dc:creator><pubDate>Wed, 16 Sep 2026 10:30:40 GMT</pubDate><content:encoded><![CDATA[<p><em>What porting Algorithmica's "Argmin" case study to .NET taught us about SIMD, RyuJIT and Zen 4, and why it ended in a patch for <code>TensorPrimitives.IndexOfMin</code>.</em></p>
<hr />
<h2>What this is about</h2>
<p>Argmin is the simplest task imaginable: return the index of the smallest element in an array. In Algorithmica's <a href="https://en.algorithmica.org/hpc/algorithms/argmin/">Algorithms for Modern Hardware</a> it serves as a case study in how far you can push a loop with a data-dependent branch once you take the branch predictor, SIMD and memory bandwidth seriously. The final resulting Algorithm will run at the speed of the L2 cache on my machine.</p>
<p>We ported the book's full progression to C# (.NET 11), three times over, once in each of the vector programming models .NET offers:</p>
<table>
<thead>
<tr>
<th>Model</th>
<th>Namespace</th>
<th>What it is</th>
</tr>
</thead>
<tbody><tr>
<td><code>Vector&lt;T&gt;</code></td>
<td><code>System.Numerics</code></td>
<td>Width-agnostic, 256 bits on our machine, no lane permutes</td>
</tr>
<tr>
<td><code>Vector256&lt;T&gt;</code></td>
<td><code>System.Runtime.Intrinsics</code></td>
<td>Fixed 256 bits with ISA-neutral helpers (<code>Min</code>, <code>ConditionalSelect</code>, <code>Shuffle</code>, ...)</td>
</tr>
<tr>
<td><code>Avx2</code> / <code>Avx</code> / <code>Sse2</code></td>
<td><code>System.Runtime.Intrinsics.X86</code></td>
<td>Raw instructions, mirroring <code>_mm256_*</code> calls</td>
</tr>
</tbody></table>
<p>Everything was measured with BenchmarkDotNet on a Ryzen 7 7800X3D (Zen 4, AVX-512 capable), N = 65536 <code>int</code>s, i.e. 256 KB, which sits comfortably in L2. The road from the first working version to the final table was much longer than expected, and almost every detour taught something that applies beyond argmin.</p>
<p>The journey in short:</p>
<table>
<thead>
<tr>
<th>Stage</th>
<th>GElem/s (Random)</th>
</tr>
</thead>
<tbody><tr>
<td>Scalar loop</td>
<td>3.1</td>
</tr>
<tr>
<td>Index-vector loop, first version (both vectors blended with the same mask)</td>
<td>6.6</td>
</tr>
<tr>
<td>Index-vector loop as in the book (<code>vpminsd</code> + one blend)</td>
<td>20</td>
</tr>
<tr>
<td><code>Vector&lt;T&gt;</code> with a <code>stackalloc</code> index helper</td>
<td>3.5</td>
</tr>
<tr>
<td><code>Vector&lt;T&gt;</code> with <code>Vector&lt;int&gt;.Indices</code></td>
<td>14.7</td>
</tr>
<tr>
<td><code>Vector&lt;T&gt;</code> with <code>Vector.LoadUnsafe</code></td>
<td>19.2</td>
</tr>
<tr>
<td>Branch-on-mask</td>
<td>24</td>
</tr>
<tr>
<td>Blocks of 32</td>
<td>35</td>
</tr>
<tr>
<td>Blocks of 256</td>
<td>36</td>
</tr>
<tr>
<td><code>TensorPrimitives.IndexOfMin&lt;int&gt;</code> from the BCL</td>
<td>5.6</td>
</tr>
<tr>
<td>Our block algorithm as <code>TensorPrimitives.IndexOfMin&lt;int&gt;</code></td>
<td>36</td>
</tr>
</tbody></table>
<hr />
<h2>Stage 1: the scalar baseline</h2>
<p>The naive loop is the baseline:</p>
<pre><code class="language-csharp">public static int Simple(ReadOnlySpan&lt;int&gt; a)
{
    int k = 0;
    for (int i = 1; i &lt; a.Length; i++)
        if (a[i] &lt; a[k])
            k = i;
    return k;
}
</code></pre>
<p>3.1 GElem/s on random data. The book's next step is a variant with <code>[[unlikely]]</code>, which tells the compiler the branch is almost never taken and thereby steers the code layout. The JIT has no such attribute, but the order of the condition does the same job: writing the compare the other way round (<code>&gt;</code> instead of <code>&lt;</code>) flips which path becomes the fall-through, so the direction of the condition works as a likely hint.</p>
<p>The scalar loop is as fast as it is because of the branch predictor. On random data the running minimum almost never drops after the first few hundred elements, so the branch is nearly perfectly predictable and costs nothing. That observation, that the hit rate of the running minimum decides what a branch costs, runs through every branch-based variant below.</p>
<hr />
<h2>Stage 2: the index-vector loop and the bug we wanted to pin on the JIT</h2>
<p>The book's first SIMD variant keeps two vectors: eight running minima and their eight indices. Each iteration loads a block, compares, and updates the indices with a blend.</p>
<p>Our first version looked the way you would write it "generically": compare, then select both the minimum and the index with the same mask. This incidentally also mirrored the implementation pattern found in the current version of IndexOfMin from the BCL TensorHelpers.</p>
<pre><code class="language-csharp">var mask = Avx2.CompareGreaterThan(min, v);   // v &lt; min
min = Avx2.BlendVariable(min, v, mask);
idx = Avx2.BlendVariable(idx, cur, mask);
</code></pre>
<p>6.6 GElem/s. The portable <code>Vector256.ConditionalSelect</code> in the same form reached 17.3. A look at the disassembly showed that on our AVX-512 machine the JIT does not turn <code>Avx2.BlendVariable</code> into <code>vpblendvb</code> but into <code>vpmovb2m</code> plus <code>vpblendmb</code>. After some further digging we found that:</p>
<ol>
<li><code>vpblendvb</code> has no EVEX encoding. On AVX-512 hardware the JIT's importer (<code>hwintrinsicxarch.cpp</code>, <code>case NI_AVX2_BlendVariable</code>) therefore rewrites every <code>BlendVariable</code> into the masked form <code>vpblendmb</code>, converting the vector mask into a k-register with <code>vpmovb2m</code>.</li>
<li>If the mask is used <strong>once</strong>, lowering folds the whole thing back into a plain <code>vpblendvb</code>. If it is used <strong>twice</strong>, the <code>vpmovb2m</code> is shared by CSE and stays.</li>
<li>On Zen 4, <code>vpmovb2m</code> is a vector-to-mask domain crossing with 4 cycles of latency. And because we selected the minimum through the mask as well, that crossing sat on the loop-carried chain of the minimum.</li>
</ol>
<p>So the BCL apparently has supoptimal code written for IndexOfMin. The book advances the minimum with <code>_mm256_min_epi32</code> and blends only the index:</p>
<pre><code class="language-csharp">var mask = Avx2.CompareGreaterThan(min, v);   // v &lt; min
idx = Avx2.BlendVariable(idx, cur, mask);     // the mask's only use: stays vpblendvb
min = Avx2.Min(min, v);                       // vpminsd, a 1-cycle chain
</code></pre>
<p>Now the mask is single-use, the blend stays <code>vpblendvb</code>, and the minimum chain is a single <code>vpminsd</code>. All three models now compile to the same <code>vpcmpgtd</code> / <code>vpblendvb</code> / <code>vpminsd</code> loop and run at about 20 GElem/s.</p>
<p>The lesson we learned today is that on my hardware, mask is only cheap if it has exactly one use.</p>
<hr />
<h2>Stage 3: <code>Vector&lt;T&gt;</code> and two more traps</h2>
<p>The <code>Vector&lt;T&gt;</code> variant of the same loop initially sat at 3.5 GElem/s, a sixth of the <code>Vector256&lt;T&gt;</code> version. Two causes, both at source level.</p>
<h3>Trap 1: a <code>stackalloc</code> helper is never inlined</h3>
<p><code>Vector&lt;T&gt;</code> has no constructor from eight literals, so we had built the starting vector <code>{0, 1, ..., W-1}</code> in a small helper:</p>
<pre><code class="language-csharp">static Vector&lt;int&gt; MakeIndices()
{
    Span&lt;int&gt; tmp = stackalloc int[Vector&lt;int&gt;.Count];
    for (int i = 0; i &lt; tmp.Length; i++) tmp[i] = i;
    return new Vector&lt;int&gt;(tmp);
}
</code></pre>
<p>RyuJIT inlines a method containing <code>localloc</code> only when the importer can turn the allocation into a fixed-size local. That requires the size to be a constant in the IL stack expression and at most 32 bytes (<code>DEFAULT_MAX_LOCALLOC_TO_LOCAL_SIZE</code> in <code>importer.cpp</code>). Otherwise the inline attempt is fatal (<code>CALLSITE_LOCALLOC_SIZE_UNKNOWN</code>), with or without <code>AggressiveInlining</code>.</p>
<p>Roslyn evaluates every non-literal <code>stackalloc</code> size once into an IL local, because the <code>Span</code> constructor needs it too. <code>Vector&lt;int&gt;.Count</code>, <code>Vector256&lt;int&gt;.Count</code>, <code>sizeof(...)</code>: all of them arrive as <code>ldloc</code>, which the importer does not fold. Only a true C# compile-time literal such as <code>stackalloc int[8]</code> qualifies, and even that only with <code>AggressiveInlining</code>.</p>
<p>The consequence of the failed inline: the helper returns the vector through a hidden buffer, the caller's copy becomes address-exposed, and the loop-carried blend round-trips through the stack every iteration:</p>
<pre><code>vmovups  ymm4, [rsp+60]
vpblendvb ymm3, ymm4, ymm2, ymm1
vmovups  [rsp+60], ymm3
</code></pre>
<p>The fix is <code>Vector&lt;int&gt;.Indices</code>, which the JIT materialises as a constant (<code>gtNewSimdGetIndicesNode</code>). That alone lifted the variant to 14.7 GElem/s.</p>
<h3>Trap 2: <code>new Vector&lt;int&gt;(span.Slice(i))</code> keeps two range checks</h3>
<p>The rest of the gap was the load. <code>new Vector&lt;int&gt;(a.Slice(i))</code> produces two bounds checks per iteration (one for <code>Slice</code>, one for the constructor's length check) that the JIT does not eliminate from the loop condition <code>i + w &lt;= n</code>. The fixed-width variants load with <code>Vector256.LoadUnsafe</code> instead.</p>
<p><code>Vector.LoadUnsafe(ref MemoryMarshal.GetReference(a), i)</code> is its width-agnostic twin. With it, every <code>Vector&lt;T&gt;</code> argmin variant compiles to the same loop as <code>Vector256&lt;T&gt;</code>, and the index-vector loop reaches 19.2 GElem/s.</p>
<hr />
<h2>Stage 4: small things you only see in the disassembly</h2>
<p>Two details that each touch a handful of instructions but are measurable.</p>
<p><strong>A <code>nuint</code> index with a precomputed last start.</strong> All loops were written as <code>for (; i + w &lt;= n; i += w)</code> with an <code>int</code> index and loaded through <code>(nuint)i</code>. That costs a <code>movsxd</code> and a <code>lea</code> per iteration. With <code>nuint i</code> and <code>last = n - w</code> the index-vector loop shrinks from nine to seven macro-ops but does not get faster (19.9 → 20.1): it was never dispatch-bound. The variants that carry more scalar work per vector operation do gain 8 to 21 percent, e.g. BranchOnMask from 19.3 to 23.5 and Blocks256 in <code>Vector256&lt;T&gt;</code> from 33.6 to 36.3.</p>
<p><strong>Rescanning over a slice instead of <code>[from, to)</code>.</strong> The scalar rescan helper that several variants call on a hit was written as <code>for (j = from; j &lt; to)</code> over the whole span. The JIT cannot prove <code>to &lt;= a.Length</code> at the call sites, so a <code>jae RNGCHKFAIL</code> per element stayed in. Iterating over <code>a.Slice(from, to - from)</code> instead lets the JIT recognise the <code>j &lt; s.Length</code> with <code>s[j]</code> pattern and drop the check. On decreasing data, where the rescan dominates, that took BranchOnMask from 24.8 to 19.3 µs.</p>
<hr />
<h2>Stage 5: why 20 and not 35?</h2>
<p>The index-vector loop stalled at 20 GElem/s while the book reaches the sane with its block variants on a Zen2. The progression there:</p>
<p><strong>BranchOnMask</strong> compares a block against the broadcast running minimum, tests the mask with <code>vptest</code> and only does a scalar rescan of the block on a hit. On random data an 8-block only hits when it holds a new global minimum, about 12 times per 65536 elements. The hot loop is thus six macro-ops without any loop-carried vector operation: 24 GElem/s.</p>
<p><strong>Blocks32</strong> loads four vectors, folds them with three <code>vpminsd</code> and does one compare per 32 elements: 35 GElem/s. That is the L2 fill throughput for a 256 KB working set. A probe loop that only loads and does <code>vpminsd</code> reads 36.</p>
<p><strong>Blocks256</strong> first computes the minimum of each 256-element block in vector code, remembers the winning block and then searches it for the first match by equality compare: 36 GElem/s, and unlike Blocks32 also on decreasing data, because the second phase never looks at more than 256 elements.</p>
<p>Why does the index-vector loop stay at 20? Not because of the select. Zen 4 has no per-instruction bottleneck in this loop: <code>vpminsd</code>, <code>vpcmpgtd</code>, <code>vpaddd</code>, <code>vpternlogd</code> and <code>vpblendmd</code> are all one-cycle operations on four pipes, <code>vpblendvb</code> one cycle on two. But the probe shows: the identical instruction sequence with the compare reading a constant instead of the minimum runs at 25.5, and unrolling by two with independent (minimum, index) pairs lifts the book loop to 24.7. Every further instruction that consumes the loaded vector costs throughput, because the core cannot keep enough iterations in flight ahead of L2. The lever for the index-vector loop is independent accumulators, not a different select instruction.</p>
<hr />
<h2>Stage 6: the generic template, and what Zen 4 has to say about it</h2>
<p>Anyone writing an argmin without the book almost inevitably lands on the template "compare, select the minimum and the index with one mask, bump the index vector". That is exactly the form the BCL uses too (more on that below). We wanted to know whether any formulation of this template can beat the book loop, and used the probe workflow to try every select variant the JIT can emit.</p>
<table>
<thead>
<tr>
<th>Formulation (N = 65536, Random)</th>
<th>Loop instructions</th>
<th>GElem/s</th>
</tr>
</thead>
<tbody><tr>
<td>Book: <code>vpcmpgtd</code>, <code>vpblendvb</code> (index), <code>vpminsd</code> (minimum)</td>
<td>7</td>
<td>20.5</td>
</tr>
<tr>
<td><code>Vector256.ConditionalSelect</code> ×2 with a shared mask → <code>vpternlogd</code> ×2</td>
<td>8</td>
<td>18.1</td>
</tr>
<tr>
<td><code>Avx512F.VL.TernaryLogic</code> for the index select → JIT emits <code>vpcmpgtd k1</code> + <code>vpblendmd</code></td>
<td>7</td>
<td>21.4 (needs AVX-512)</td>
</tr>
<tr>
<td><code>Avx512F.VL.CompareLessThan</code> + <code>Avx512F.VL.BlendVariable</code> ×2 → <code>vpcmpltd k1</code>, <code>vpblendmd</code> ×2</td>
<td>8</td>
<td>8.0</td>
</tr>
<tr>
<td><code>Avx2.BlendVariable</code> ×2 with a shared mask → <code>vpmovb2m</code> + <code>vpblendmb</code> ×2</td>
<td>8</td>
<td>6.6</td>
</tr>
<tr>
<td>Two separate compares, each mask single-use → <code>vpblendvb</code> ×2</td>
<td>9</td>
<td>13.1</td>
</tr>
</tbody></table>
<p>The uops.info numbers explain the order: on Zen 4, a compare that writes a k-register (<code>vpcmpgtd k, ymm, ymm</code>) has 4 cycles of latency, against 1 cycle for the vector-to-vector variant. <code>vpmovb2m</code> is likewise a 4-cycle crossing. As soon as the running minimum passes through a mask register, every step costs 4 cycles. And even the best branchless form of the template, two <code>vpternlogd</code>, puts the compare on the minimum chain (2 instructions per step instead of one <code>vpminsd</code>). The book says "min is faster", and that holds here.</p>
<p>Two JIT quirks worth knowing in passing: <code>Avx512F.VL.BlendVariable</code> with a single-use k-mask is lowered back to the VEX <code>vpblendvb</code>, and <code>Avx2.And/AndNot/Or</code> as a hand-built select becomes a four-instruction <code>vpternlogd</code>/<code>vpand</code> mess.</p>
<h3>The branchy reading beats the book, but only once tuned properly</h3>
<p>One formulation of the template did beat the book loop: the branchy one. Compare, <code>vptest</code>, <code>continue</code> if no lane was smaller, otherwise both selects and build the index vector from the loop counter.</p>
<pre><code class="language-csharp">var v = Avx.LoadVector256(p + i);
var mask = Avx2.CompareGreaterThan(g, v);           // v &lt; running minimum (g: in every lane)
if (Avx.TestZ(mask, mask)) continue;                // predicted: no new minimum in this block
var cur = Avx2.Add(Vector256.Create((int)i), lanes);
min = Vector256.ConditionalSelect(mask, v, min);
idx = Vector256.ConditionalSelect(mask, cur, idx);
g = BroadcastMin(v);                                // vpermq/vpshufd tree, no trip through a GPR
</code></pre>
<p>What makes the difference is the <strong>hit count</strong>, because every hit is a branch mispredict. Comparing against the per-lane running minimum, a block hits as soon as any of its eight lanes sees a lane record: 68 times per 65536 random elements. Comparing against the broadcast global minimum, it hits only on a new global minimum: 13 times. In the tiered probe the per-lane form read 24.1, the broadcast form 27.8 to 29.2, BranchOnMask 26.4. BenchmarkDotNet confirms it: 23.5 GElem/s on Random against 20.7 for the book loop.</p>
<p>The price: on decreasing data every block hits, the loop runs the full hit path per eight elements and drops to 9.3. The branchless template form is better there at 18.1, because it has no branch to pay for, and BranchOnMask with its scalar rescan drops to 3.4.</p>
<h3>The same hit path under Blocks32</h3>
<p>The nicest finding of this stage: put the same vectorized hit path under the Blocks32 loop (four loads, three <code>vpminsd</code>, one compare against the broadcast minimum, one <code>vptest</code> per 32 elements) and you keep Blocks32's throughput on random data (34.8 against 35.1) while replacing the 32-iteration rescan that collapses Blocks32 on decreasing data: 14.2 instead of 3.6 GElem/s.</p>
<p>One subtlety: the compares inside the hit block have to be against the <strong>per-lane</strong> minimum, not the old global one. Otherwise a later vector of the block can overwrite a smaller value already recorded in the same lane.</p>
<hr />
<h2>Stage 7: how you measure something like this at all</h2>
<p>A large share of the time went not into code but into the question of how to compare formulations quickly and reliably. What proved itself:</p>
<p><strong>A probe project with <code>DOTNET_JitDisasm</code>.</strong> A throwaway console app with one <code>[MethodImpl(NoInlining)]</code> method per candidate, each an exact mirror of the repo method. The release runtime is enough:</p>
<pre><code>DOTNET_TieredCompilation=0 DOTNET_JitDisasm='Cand*' DOTNET_JitDisasmDiffable=1 DOTNET_JitStdOutFile=disasm.txt dotnet JitProbe.dll
</code></pre>
<p>Then print only the loop block per method. The authoritative check remains BenchmarkDotNet's <code>--disasm</code>, but the probe takes seconds instead of minutes.</p>
<p><strong>Timing in the probe is only right with <code>TieredCompilation=0</code></strong>, otherwise a 500-call warmup ends inside the 100 ms call-counting delay and you measure half tier-0/OSR code (the index-vector loop read 7 instead of 20 GElem/s).</p>
<hr />
<h2>Stage 7: 512 bits, and why wider is not always better</h2>
<p><code>DOTNET_PreferredVectorBitWidth=512</code> leaves <code>Vector&lt;int&gt;.Count</code> at 8 on Zen 4, because <code>Vector512.IsHardwareAccelerated</code> is already <code>true</code> there and that variable only raises the preferred width. <code>Vector&lt;T&gt;</code> follows <code>DOTNET_MaxVectorTBitWidth</code>. With the right variable the index-vector loop becomes <code>vmovups zmm</code>, <code>vpaddd</code>, <code>vpcmpltd k1</code>, <code>vpblendmd zmm{k1}</code>, <code>vpminsd</code> and runs 29 percent faster from unchanged source (19.8 → 25.5): the blend now goes through a mask register and escapes the <code>vpblendvb</code> register constraint.</p>
<p>Blocks32, on the other hand, loses 7 percent and Blocks256 30 percent (30.7 → 21.6). Zen 4 executes 512-bit operations as two 256-bit halves, so the <code>Min</code> loops do not get faster, while the horizontal step through the <code>Vector&lt;T&gt;</code> indexer now walks 16 instead of 8 lanes per block, and Blocks256 does that 256 times per call.</p>
<hr />
<h2>Stage 8: <code>TensorPrimitives.IndexOfMin</code> and the road into the BCL</h2>
<p>As a reference we had included <code>TensorPrimitives.IndexOfMin&lt;int&gt;</code> from <code>System.Numerics.Tensors</code>: 5.6 GElem/s. A sixth of Blocks256, a quarter of the index-vector loop. This is the moment where all the earlier findings show up in a single code path.</p>
<p>The BCL (.NET 10) uses the index-vector algorithm, but its operator keeps <code>result</code> and <code>resultIndex</code> with a first-index tie-break: <code>LessThan</code>, an <code>EqualsAny</code> branch that ORs in <code>Equals &amp; IndexLessThan</code> on equality, then <code>BlendVariable(left, right, ~mask)</code> for both vectors. Two things follow:</p>
<ol>
<li>The minimum is produced by the blend instead of by <code>vpminsd</code>, so the chain is compare, invert, blend. The <code>EqualsAny</code> branch and the <code>~mask</code> are dead weight: a strict compare already keeps the earlier index within a lane, and the horizontal step picks the lowest index among equal lanes.</li>
<li>The mask is a vector-typed value that the JIT materialises as such. On AVX-512 hardware the chain therefore crosses between mask and vector registers twice per iteration. The 512-bit loop has 15 instructions, five of them on the <code>result</code> chain, and takes about 15 cycles per 16 elements.</li>
</ol>
<p>The proof that it is the mask crossings: with <code>DOTNET_EnableAVX512=0</code> the same code produces the plain AVX2 loop with <code>vpblendvb</code> ×2, a three-instruction chain, and runs at 11.1 GElem/s, <strong>twice as fast</strong>. Same binary, AVX-512 merely switched off.</p>
<p>In dotnet/runtime <code>main</code> (PR #127454, .NET 11) the core shared by <code>IndexOfMin</code>, <code>IndexOfMax</code>, <code>IndexOfMinMagnitude</code> and <code>IndexOfMaxMagnitude</code> had already been rewritten to the strict index-vector template, but it still blends the minimum with the same mask as the index: 5.1 GElem/s here, no faster than our implementation.</p>
<h3>The port</h3>
<p>We wrote our <code>Vector256_Blocks256</code> against the BCL's operator interface and dropped it in as the shared <code>IndexOfMinMaxCore&lt;T, TOperator&gt;</code>, for all four searches:</p>
<ul>
<li><strong>Pass 1</strong> reduces each block of 32 vectors with two accumulators of the operator's lane-wise <code>Reduce</code> (<code>Vector.Min</code>, <code>Vector.Max</code>, <code>Vector.MinMagnitude</code>, <code>Vector.MaxMagnitude</code>, the same aggregation operators the old core already relied on for its final match) and keeps the first block whose result wins under the operator's strict scalar <code>Compare</code>.</li>
<li><strong>Pass 2</strong> scans only that block for the first element the result does not beat under <code>Compare</code>. That is the operator's own tie rule (first equal element, -0 before +0 for min, +1 over -1 for max-magnitude) without any bitwise equality.</li>
<li><strong>NaN</strong> falls out of the reductions being IEEE <code>minimum</code>/<code>maximum</code>: a block with a NaN reduces to NaN, then the first NaN of that block is returned.</li>
</ul>
<p><code>IndexOfMin&lt;int&gt;</code> now runs at 35.6 to 36.7 GElem/s on all four input patterns at 512 bits, identical to <code>Vector256_Blocks256</code>. The block loop is two <code>vpminsd zmm</code> with memory operands, the only call is the final scan.</p>
<p>A first cut was 12 percent slower: two inlined block reductions plus the scan helpers had exhausted the JIT's inlining budget, and it emitted a <code>call HorizontalAggregate</code> per block. One block loop and <code>NoInlining</code> on the two helpers that run once per search fixed that.</p>
<p>The runtime's own tests pass for all four searches and all 17 element types on the 512-, 256- and 128-bit and scalar paths, extended with tests for multi-block lengths, NaN and signed zero. The PR is up as <a href="https://github.com/dotnet/runtime/pull/133969">dotnet/runtime#133969</a>.</p>
<hr />
<h2>What remains</h2>
<p>The findings that carry beyond argmin:</p>
<ol>
<li>The book is right about <code>min</code> instead of blend. The running minimum belongs on a 1-cycle <code>vpminsd</code> chain. Every formulation that puts the compare on that chain loses, and every one that routes it through a mask register loses four cycles per step on Zen 4.</li>
<li><code>Avx2.BlendVariable</code> is only cheap on AVX-512 hardware with a single-use mask. A shared mask becomes <code>vpmovb2m</code>. <code>Vector256.ConditionalSelect</code> with a shared mask becomes <code>vpternlogd</code> and is the better choice when you genuinely need two selects.</li>
<li><code>Vector&lt;T&gt;</code> needs <code>Vector&lt;T&gt;.Indices</code> and <code>Vector.LoadUnsafe</code>. A <code>stackalloc</code> helper with a non-literal size is never inlined, and <code>new Vector&lt;T&gt;(span.Slice(i))</code> keeps two range checks. With both fixes <code>Vector&lt;T&gt;</code> is on par for vertical arithmetic; for anything involving lane movement it remains unusable.</li>
<li>Bounds checks stay wherever the JIT cannot prove the length. <code>for (j = from; j &lt; to)</code> over a span keeps the check; <code>a.Slice(from, to - from)</code> with <code>j &lt; s.Length</code> does not.</li>
<li>The index-vector loop is L2-bound, not select-bound. The lever is independent accumulator pairs, not the select instruction. The block variants reach the bandwidth because they execute only one <code>vpminsd</code> per loaded vector.</li>
<li>Hit count equals mispredicts. For every branchy variant, what matters is how often the running minimum drops. Between "never" and "ln N" hits the difference is invisible; all separation between variants happens between Random and Decreasing. Comparing against the global instead of the per-lane minimum cuts the hit count from 68 to 13 per 65536 elements.</li>
</ol>
<hr />
<h2>Appendix: the final table</h2>
<p>BenchmarkDotNet <code>--job short</code>, N = 65536 <code>int</code>s, Ryzen 7 7800X3D, .NET 10. GElem/s, higher is better.</p>
<table>
<thead>
<tr>
<th>Variant</th>
<th>Increasing</th>
<th>HitOneOverN</th>
<th>Random</th>
<th>Decreasing</th>
</tr>
</thead>
<tbody><tr>
<td>Scalar_Simple</td>
<td>3.0</td>
<td>3.1</td>
<td>3.1</td>
<td>3.1</td>
</tr>
<tr>
<td>Vector256_IndexVector</td>
<td>19.8</td>
<td>20.6</td>
<td>20.3</td>
<td>19.9</td>
</tr>
<tr>
<td>Avx2_IndexVectorBlendBoth</td>
<td>17.1</td>
<td>18.1</td>
<td>17.6</td>
<td>17.5</td>
</tr>
<tr>
<td>Avx2_IndexVectorBlendBothOnHit</td>
<td>22.0</td>
<td>23.9</td>
<td>22.1</td>
<td>8.8</td>
</tr>
<tr>
<td>Vector256_BranchOnMask</td>
<td>22.3</td>
<td>24.2</td>
<td>24.2</td>
<td>3.3</td>
</tr>
<tr>
<td>Vector256_Blocks32</td>
<td>35.0</td>
<td>35.2</td>
<td>33.3</td>
<td>2.2</td>
</tr>
<tr>
<td>Avx2_Blocks32BlendBothOnHit</td>
<td>34.7</td>
<td>35.7</td>
<td>34.8</td>
<td>14.2</td>
</tr>
<tr>
<td>Vector256_MinThenFind</td>
<td>35.3</td>
<td>32.4</td>
<td>16.9</td>
<td>15.8</td>
</tr>
<tr>
<td>Vector256_Blocks256</td>
<td>34.8</td>
<td>36.6</td>
<td>35.9</td>
<td>35.3</td>
</tr>
<tr>
<td>TensorPrimitives_IndexOfMin (BCL, .NET 10)</td>
<td>4.9</td>
<td>5.4</td>
<td>5.6</td>
<td>5.6</td>
</tr>
<tr>
<td>TensorPrimitives_IndexOfMin (our port)</td>
<td>36</td>
<td>36</td>
<td>36</td>
<td>36</td>
</tr>
</tbody></table>
<p>The input patterns span the hit rate: <code>Increasing</code> (<code>a[i] = i</code>, the minimum is the first element and never moves), <code>HitOneOverN</code> (increasing baseline, block k holds a new global minimum with probability 1/k, 9 block hits per 65536 elements), <code>Random</code> (<code>Random(42).Next()</code> per element, 12 block hits and 51 lane records) and <code>Decreasing</code> (every element is a new minimum). All variants return the first index of the minimum.</p>
<p><code>MinThenFind</code> is the only variant whose cost depends not on the hit count but on the <strong>position</strong> of the minimum (0, 7077, 59735, 65535 in the four patterns): its second pass scans up to the first occurrence.</p>
<p>The code lives in <code>https://github.com/Mrnikbobjeff/HPC</code>, the full tables and disassembly findings are in the repo README.</p>
]]></content:encoded></item></channel></rss>