📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What is SIMD, how do CPU vector registers work, and when would you use Vector128<T>, Vector256<T> or Vector<T> in .NET?

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.

Senior Coding Expert cpu hardware-intrinsics performance registers simd vectorization

🎯 Key points

  • SIMD applies one instruction to several values (lanes) held in a wide vector register: 128-bit holds 4 ints or floats, 256-bit holds 8, 512-bit holds 16
  • Instruction sets differ: SSE/AVX2 on all current x64 servers, AVX-512 only on some (recent Intel Xeon, AMD Zen 4 and later), NEON/AdvSimd (128-bit) on every .NET-supported Arm64 chip
  • .NET exposes portable Vector128/256/512<T>, the machine-sized Vector<T>, and platform-specific intrinsics (Avx2, Avx512F, AdvSimd) guarded by IsSupported
  • Vector256<T> on Arm64 is not hardware accelerated (it is implemented as two 128-bit halves), so Vector128<T> is the portable choice and wider code paths should check IsHardwareAccelerated
  • Many BCL methods (span IndexOf/SequenceEqual, UTF-8 and Base64, LINQ Sum/Min/Max over primitive arrays) and TensorPrimitives are already vectorised: use them first
  • A vectorised loop processes full vectors, reduces the accumulator, then handles the remaining elements with a scalar tail; watch per-lane integer overflow and floating-point ordering differences
  • Keep a scalar fallback and test both paths (DOTNET_EnableHWIntrinsic=0), including lengths 0, 1, Count-1, Count and Count+1
  • SIMD helps CPU-bound loops over contiguous primitive data in cache; it does nothing for I/O-bound, memory-bandwidth-bound or pointer-chasing code, so prove the gain with BenchmarkDotNet on the target hardware

⏱ 30-second answer

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.

🗣 Full answer (senior)

How SIMD works

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)
  • Vector registers: XMM (128-bit), YMM (256-bit), ZMM (512-bit) on x64; V0–V31 (128-bit) on Arm64.
  • The CPU has dedicated vector units, so SIMD adds throughput on top of the scalar pipeline.

The .NET API layers

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

Writing a vectorised loop

  1. Check Vector128.IsHardwareAccelerated (or the 256 variant) and the length is worth it.
  2. Loop in steps of Vector128<T>.Count, loading with Vector128.Create(span) or Vector128.LoadUnsafe.
  3. Combine with operators (+, &, comparisons that produce masks).
  4. Reduce the vector to a scalar at the end (Vector128.Sum).
  5. Handle the remainder with a scalar loop.
  6. Test against the scalar version with random inputs, including lengths that aren't multiples of the vector size.

When SIMD is worth it

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

Production example

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.

🔍 What the interviewer is checking

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.

📚 Learn in depth

1. Lanes and widths

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.

2. Anatomy of a vectorised sum

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

3. Masks instead of branches

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.

4. Hardware realities

  • AVX-512 is available on recent Intel server CPUs and AMD Zen 4 and later; .NET 8 added Vector512<T>. On some CPUs, heavy 512-bit work lowers clock speed, so wider isn't automatically faster.
  • Arm64 has 128-bit NEON on all .NET-supported chips; Vector256 is emulated there.
  • Alignment: unaligned loads are fine on modern CPUs; crossing cache-line boundaries costs a little. Don't complicate code for alignment without measurements.
  • Memory bandwidth: if the loop is limited by reading data from RAM, SIMD can't make RAM faster. SIMD shines when data is in cache or the work per byte is significant.

5. Correctness pitfalls

  • Integer lanes can overflow (lanes wrap independently); widen to long lanes or reduce periodically.
  • Floating-point results differ slightly from scalar order.
  • The scalar tail is where bugs hide; test lengths 0, 1, Count - 1, Count, Count + 1 and large random lengths.
  • Test both paths: set DOTNET_EnableHWIntrinsic=0 to force the fallback in a test run.

6. Try it yourself

  1. Implement 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.
  2. Implement CountGreaterThan(ReadOnlySpan<int>, int threshold) with masks.
  3. Run the tests with DOTNET_EnableHWIntrinsic=0 and confirm the fallback gives identical results.
  4. Replace a hand-written cosine similarity with TensorPrimitives.CosineSimilarity and benchmark.

💻 Code

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);

⚠ Common mistakes

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.

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate says SIMD needs contiguous primitive data. Why: Checks the data-layout knowledge SIMD depends on.
    How to answer

    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.

  2. When: Candidate uses comparison masks instead of if statements. Why: Checks understanding of masks as a branchless technique.
    How to answer

    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.

🛠 Real-world problems: how you handle them

Semantic search similarity scoring 6x faster with TensorPrimitives
Situation
An internal knowledge-base search service scored each query embedding against about 200,000 stored 768-dimension embeddings in memory before re-ranking. p95 search latency was 400 ms.
Signals
Profiling showed about 40% of request time in a hand-written cosine similarity loop over `float[]`; CPU was the bottleneck, not I/O.
Investigation
Benchmarked the existing loop, a `Vector<T>` version and `TensorPrimitives.CosineSimilarity` on both the x64 production VMs and an Arm64 test VM, and compared scores to confirm differences were within floating-point tolerance.
Resolution
Replaced the loop with `TensorPrimitives.CosineSimilarity`, cutting scoring from about 160 ms to about 25 ms per query; p95 search latency fell to about 230 ms.
Prevention
Kept the benchmark in the repo, added a test comparing vectorised and scalar scores within a tolerance, and documented that further gains would need an approximate nearest-neighbour index rather than faster brute force.

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.

↩ Usually asked after

More in CPU

Last updated 23 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me