Single instruction, multiple data: vector registers and instruction sets (SSE, AVX2, AVX-512, NEON), the .NET hardware-intrinsic APIs, vectorised BCL methods and TensorPrimitives, writing a vectorised loop with a scalar tail, and when SIMD is or isn't worth it.
SIMD (single instruction, multiple data) lets one CPU instruction operate on several values at once, held in a vector register: a 128-bit register holds four ints, a 256-bit one holds eight. x64 offers SSE, AVX2 and AVX-512; Arm64 offers NEON. .NET exposes this through portable types (Vector128<T>, Vector256<T>, Vector512<T> and the size-agnostic Vector<T>), plus platform-specific intrinsics. First, use what's already vectorised: Span methods like IndexOf and SequenceEqual, and TensorPrimitives. Hand-write SIMD only for a measured hot loop over contiguous primitive data, with a scalar tail and a fallback when IsHardwareAccelerated is false.
scalar add: a0 + b0 -> c0 (one instruction per element)
SIMD add: [a0 a1 a2 a3]
+ [b0 b1 b2 b3]
= [c0 c1 c2 c3] (one instruction, four elements)
| Layer | Examples | Use when |
|---|---|---|
| Already vectorised BCL | span.IndexOf, IndexOfAny, SequenceEqual, Contains, Ascii, Utf8 validation, Encoding, LINQ Sum/Min/Max on int/long arrays and spans |
Always first: zero effort |
System.Numerics.Tensors.TensorPrimitives |
Sum, Dot, Max, CosineSimilarity, Add, Multiply over spans |
Numeric work on arrays, ML-style maths |
| Portable vectors | Vector128<T>, Vector256<T>, Vector512<T> (.NET 8+), Vector<T> |
Custom hot loops, one code path for x64 and Arm64 |
| Platform intrinsics | Avx2, Avx512F, AdvSimd |
Instructions with no portable equivalent; always guarded by IsSupported |
Vector128.IsHardwareAccelerated (or the 256 variant) and the length is worth it.Vector128<T>.Count, loading with Vector128.Create(span) or Vector128.LoadUnsafe.+, &, comparisons that produce masks).Vector128.Sum).| Good fit | Poor fit |
|---|---|
| Large arrays or spans of primitives | Small collections (setup cost dominates) |
| Simple, uniform operations (sum, compare, search, transform) | Branch-heavy, per-element logic |
| Contiguous memory | Objects, linked structures, dictionaries |
| CPU-bound hot loops | I/O-bound request handling |
A document-similarity feature computed cosine similarity between a query embedding and 200,000 stored 768-dimensional float vectors per search. A naive loop took about 160 ms. Switching to TensorPrimitives.CosineSimilarity over spans brought it to about 25 ms on AVX2 hardware and similar on Arm64 NEON, with no platform-specific code.
Checks advanced performance knowledge and engineering judgement.
Green flags: explains lanes and vector registers, knows the .NET API layers, prefers BCL and TensorPrimitives, writes a loop with a tail and fallback, mentions overflow, portability and benchmarking, and says when SIMD doesn't help.
Red flags: wants to vectorise everything; writes x86-only intrinsics without a guard; no idea the BCL is already vectorised.
| Register width | byte lanes |
int/float lanes |
long/double lanes |
|---|---|---|---|
| 128-bit | 16 | 4 | 2 |
| 256-bit | 32 | 8 | 4 |
| 512-bit | 64 | 16 | 8 |
Vector<T>.Count depends on the machine (for example 8 ints with AVX2, 4 with NEON), which is convenient but makes results and performance hardware-dependent. Fixed-width Vector128<T> and Vector256<T> give predictable behaviour.
data: [ 1 2 3 4 | 5 6 7 8 | 9 10 11 12 | 13 14 ]
vector 0 vector 1 vector 2 scalar tail
acc = [0 0 0 0]
acc += [1 2 3 4] -> [ 1 2 3 4]
acc += [5 6 7 8] -> [ 6 8 10 12]
acc += [9 10 11 12] -> [15 18 21 24]
horizontal sum -> 78
scalar tail 13 + 14 -> 105
Comparisons such as Vector128.GreaterThan(a, b) return a vector of all-ones or all-zeros lanes. Combine with & to select values, or use Vector128.ConditionalSelect(mask, a, b). To find the first match, ExtractMostSignificantBits() turns a mask into an integer bitmap that BitOperations.TrailingZeroCount can scan. This is how IndexOf is implemented.
Vector512<T>. On some CPUs, heavy 512-bit work lowers clock speed, so wider isn't automatically faster.Vector256 is emulated there.long lanes or reduce periodically.Count - 1, Count, Count + 1 and large random lengths.DOTNET_EnableHWIntrinsic=0 to force the fallback in a test run.SumVector128(ReadOnlySpan<int>) and compare it with a scalar loop and values.Sum() (LINQ) in BenchmarkDotNet at 16, 1,000 and 1,000,000 elements.CountGreaterThan(ReadOnlySpan<int>, int threshold) with masks.DOTNET_EnableHWIntrinsic=0 and confirm the fallback gives identical results.TensorPrimitives.CosineSimilarity and benchmark.namespace SimdBasics;
using System.Runtime.InteropServices;
using System.Runtime.Intrinsics;
public static class VectorMath
{
public static long Sum(ReadOnlySpan<int> values)
{
long total = 0;
int i = 0;
if (Vector128.IsHardwareAccelerated && values.Length >= Vector128<int>.Count)
{
// Accumulate in long lanes to avoid per-lane int overflow.
var acc = Vector128<long>.Zero;
ref int start = ref MemoryMarshal.GetReference(values);
int lastBlock = values.Length - Vector128<int>.Count;
for (; i <= lastBlock; i += Vector128<int>.Count)
{
var v = Vector128.LoadUnsafe(ref start, (nuint)i);
var (lower, upper) = Vector128.Widen(v); // 4 ints -> 2 + 2 longs
acc += lower + upper;
}
total = Vector128.Sum(acc);
}
for (; i < values.Length; i++) // scalar tail (and fallback)
{
total += values[i];
}
return total;
}
public static int CountGreaterThan(ReadOnlySpan<int> values, int threshold)
{
int count = 0, i = 0;
if (Vector128.IsHardwareAccelerated)
{
var limit = Vector128.Create(threshold);
for (; i <= values.Length - Vector128<int>.Count; i += Vector128<int>.Count)
{
var mask = Vector128.GreaterThan(Vector128.Create(values.Slice(i, Vector128<int>.Count)), limit);
count += System.Numerics.BitOperations.PopCount(mask.ExtractMostSignificantBits());
}
}
for (; i < values.Length; i++)
{
if (values[i] > threshold) count++;
}
return count;
}
}
// Prefer the library when it already does the job (NuGet: System.Numerics.Tensors)
using System.Numerics.Tensors;
float similarity = TensorPrimitives.CosineSimilarity(queryEmbedding, documentEmbedding);
Mistake: Hand-writing SIMD before checking the BCL.
Why it is wrong: IndexOf, SequenceEqual, UTF-8 validation, LINQ integer aggregates and TensorPrimitives are already vectorised, tuned per platform and well tested.
Instead: use the built-in APIs first and only hand-vectorise a measured hot loop they do not cover.
Mistake: Forgetting the scalar tail. Why it is wrong: lengths that are not a multiple of the vector width silently skip the last elements or read past the end. Instead: always finish with a scalar loop and test awkward lengths.
Mistake: No fallback, or an untested fallback.
Why it is wrong: on hardware without acceleration the code either throws (PlatformNotSupportedException from an unguarded intrinsic) or runs a path nobody has tested.
Instead: check IsHardwareAccelerated/IsSupported, keep a scalar path, and run tests with DOTNET_EnableHWIntrinsic=0.
Mistake: Ignoring lane overflow.
Why it is wrong: summing many ints in int lanes wraps per lane, giving wrong totals that are hard to spot.
Instead: widen to long lanes or reduce periodically.
Mistake: Assuming wider vectors are always faster.
Why it is wrong: Vector256<T> is not accelerated on Arm64, so 256-bit code there runs as two 128-bit operations plus overhead and can be slower than a Vector128 loop. AVX-512 is missing on many VMs and can lower clock speed on some older Intel CPUs.
Instead: write Vector128 first for portability, add wider paths only behind IsHardwareAccelerated checks, and benchmark on the real target hardware.
Mistake: Vectorising memory-bound or object-based code. Why it is wrong: if the loop waits on RAM or chases references, SIMD adds complexity without speed. Instead: fix data layout first (contiguous primitives, struct-of-arrays), then consider SIMD.
SIMD is a specialist tool, and the senior skill is knowing when not to use it.
| Situation | Choice | Why |
|---|---|---|
| Search, compare, encode text or bytes | BCL span / UTF-8 / Base64 APIs | Already vectorised, improves every release |
| Sum, dot product, cosine similarity on float arrays | TensorPrimitives |
Vectorised for x64 and Arm64, tested |
| Custom hot loop over primitives, both architectures | Vector128<T> with scalar tail |
Portable, predictable width |
| Proven x64-only hot path with AVX-512 servers | Vector512<T> behind IsHardwareAccelerated |
Extra width only where it exists |
| An instruction with no portable equivalent | Avx2 / AdvSimd intrinsics with IsSupported |
Last resort, needs both paths tested |
| I/O-bound request handling, object graphs | Do not use SIMD | No benefit, more complexity |
Most .NET services benefit from SIMD indirectly, through a vectorised BCL. Hand-written vector code is justified for proven hot loops (analytics, image processing, compression, parsing, embedding similarity for AI features), and then it needs portable APIs, a tested fallback, an equivalence test against the scalar version and a benchmark kept in the repo. Trade-off: each hand-vectorised method is code the team must review and maintain across two architectures, so the measured gain must justify it.
CPUs load memory in cache lines of 64 bytes, so after one access the neighbouring bytes are already in cache (spatial locality), and recently used data is likely still there (temporal locality). Hardware prefetchers also spot sequential access and fetch ahead. A C# int[,] is stored row-major: one row's elements are next to each other. Walking row by row uses every byte of each line and the prefetcher keeps up; walking column by column jumps a whole row each step, wasting most of each line and missing cache, often 5–10× slower for large arrays. The same logic favours arrays of structs over lists of objects scattered on the heap, and struct-of-arrays layouts for hot numeric fields.
A modern CPU doesn't wait to find out which way an if goes: its branch predictor guesses from recent history and the pipeline runs ahead speculatively. When data is sorted, a test like value >= 128 is false for a long run, then true for a long run, so the guess is almost always right. On random data it's right about half the time, and each misprediction throws away 15–20 cycles of work. Fixes: make data predictable (sort or partition), go branchless (conditional moves, masks, Math.Max, SIMD compares), or move rare cases out of the hot path. In .NET, check the disassembly because the JIT sometimes emits a conditional move already, and Dynamic PGO helps predict virtual calls.
How to tell it (STAR)
**Situation:** Our [knowledge-base search] at [company] had p95 latency of [400 ms], and profiling showed a hand-written similarity loop taking about [40%] of it. **Task:** I needed to cut latency without changing search quality. **Action:** I benchmarked the loop against a `Vector<T>` version and `TensorPrimitives.CosineSimilarity` on both x64 and Arm64, checked the scores matched within tolerance, and switched to `TensorPrimitives`. **Result:** Scoring dropped from [160 ms] to [25 ms], p95 fell to about [230 ms], and I documented that the next step would be an ANN index rather than more micro-optimisation.
Last updated 23 Oct 2026.