001 Notes

SIMD Mapping: From Normal Code to Vector Code

The useful way to learn SIMD is not to memorize intrinsic names.

The useful way is to learn how normal code changes shape.

Scalar code asks:

What do I do to this one value?

SIMD code asks:

Can I do the same thing to 4, 8, 16, or 32 values at once?

That is the whole mental shift. A scalar value becomes a lane. A scalar loop becomes a chunk loop. A branch often becomes a mask. A sum becomes several partial sums that collapse at the end.

This article uses AVX2 as the concrete target. AVX2 is not the only SIMD instruction set, but it is common and explicit enough to make the mapping visible.

We will use one running example for most of the article:

score = price * quantity + fee
if (score > threshold) {
    count++;
    total += score;
}

That one loop contains most of the SIMD lesson:

  • elementwise arithmetic
  • broadcasting one scalar to all lanes
  • branch-to-mask conversion
  • counting selected lanes
  • summing selected values
  • reducing lanes back to one scalar

Then we will separately cover strings, because SIMD string scanning is a different but very important shape.

1. Scalar Instructions Already Hint At SIMD

A scalar float add operates on one value:

float add(float a, float b) {
    return a + b;
}

On x86, this kind of operation commonly maps to a scalar single-precision instruction:

addss

The suffix matters:

ss = scalar single-precision
ps = packed single-precision

Packed single-precision means multiple float values in one register.

scalar:
    addss    one float + one float

packed:
    addps    four floats + four floats
    vaddps   eight floats + eight floats with AVX2

So the SIMD question is not:

How do I make one add faster?

The SIMD question is:

Where can I line up many independent adds so one instruction can do all of them?

2. The Running Scalar Example

Suppose we have many orders. For each order, we compute a score:

score = price * quantity + fee

Then we count and sum only the scores above a threshold:

struct ScoreSummary {
    int count;
    float total;
};

ScoreSummary summarize_scalar(
    const float* price,
    const float* quantity,
    const float* fee,
    int n,
    float threshold
) {
    ScoreSummary result{0, 0.0f};

    for (int i = 0; i < n; ++i) {
        float score = price[i] * quantity[i] + fee[i];

        if (score > threshold) {
            ++result.count;
            result.total += score;
        }
    }

    return result;
}

This is a good SIMD candidate because each iteration is independent.

score0 depends on price0, quantity0, fee0
score1 depends on price1, quantity1, fee1
score2 depends on price2, quantity2, fee2

No iteration needs the previous iteration. No pointer chase decides where the next value lives. The branch depends on each lane’s score, but that can become a mask.

This is the scalar-to-SIMD mapping we want:

price[i]        -> load 8 prices
quantity[i]     -> load 8 quantities
fee[i]          -> load 8 fees
threshold       -> broadcast threshold to 8 lanes
score           -> compute 8 scores
score > x       -> compare 8 scores, producing 8 mask lanes
count++         -> count true mask bits
total += score  -> add only selected score lanes

3. First Fix The Data Shape

SIMD is often won or lost before the first intrinsic.

Bad shape:

struct Order {
    float price;
    float quantity;
    float fee;
    int customer_id;
    char symbol[16];
};

Order orders[N];

The hot loop only needs price, quantity, and fee, but memory is laid out like this:

price0 quantity0 fee0 cold0 cold0 ...
price1 quantity1 fee1 cold1 cold1 ...
price2 quantity2 fee2 cold2 cold2 ...

SIMD wants this:

price0 price1 price2 price3 price4 price5 price6 price7
qty0   qty1   qty2   qty3   qty4   qty5   qty6   qty7
fee0   fee1   fee2   fee3   fee4   fee5   fee6   fee7

That usually means Structure of Arrays:

struct OrdersSoA {
    float* price;
    float* quantity;
    float* fee;
    int count;
};

or separate arrays from the start:

const float* price;
const float* quantity;
const float* fee;

The important rule:

SIMD wants contiguous same-type values.

You can use gathers and shuffles to fight a bad layout, but those are costs. A clean layout lets the SIMD loop become load, compute, store or accumulate.

4. Registers, Lanes, And AVX2 Names

An AVX2 register is 256 bits wide.

The same 256-bit register can be interpreted as:

32 x 8-bit integers
16 x 16-bit integers
8  x 32-bit integers
4  x 64-bit integers

8  x 32-bit floats
4  x 64-bit doubles

Common register types:

TypeWidthCommon use
__m128128-bit4 floats
__m128d128-bit2 doubles
__m128i128-bitinteger bytes/words/dwords/qwords
__m256256-bit8 floats
__m256d256-bit4 doubles
__m256i256-bitinteger bytes/words/dwords/qwords

Intrinsic names are ugly but patterned:

_mm256_add_ps
  |     |   |
  |     |   packed single-precision floats
  |     add
  256-bit AVX operation

Common suffixes:

SuffixMeaning
pspacked single-precision floats
pdpacked double-precision floats
epi8packed 8-bit integer lanes
epi16packed 16-bit integer lanes
epi32packed 32-bit integer lanes
epi64packed 64-bit integer lanes
si128raw 128-bit integer vector
si256raw 256-bit integer vector

You do not need to memorize everything. You need enough vocabulary to read a loop:

__m256 vprice = _mm256_loadu_ps(price + i);
__m256 vqty = _mm256_loadu_ps(quantity + i);
__m256 vfee = _mm256_loadu_ps(fee + i);
__m256 vscore = _mm256_add_ps(_mm256_mul_ps(vprice, vqty), vfee);

That says:

load 8 prices
load 8 quantities
load 8 fees
multiply price and quantity lane-wise
add fee lane-wise

5. The Four-Step SIMD Loop

Most SIMD loops follow this structure:

load chunk
compute chunk
store or accumulate chunk
handle tail

For the running example, the core chunk loop is:

for (; i + 8 <= n; i += 8) {
    __m256 vprice = _mm256_loadu_ps(price + i);
    __m256 vqty = _mm256_loadu_ps(quantity + i);
    __m256 vfee = _mm256_loadu_ps(fee + i);

    __m256 vscore = _mm256_add_ps(_mm256_mul_ps(vprice, vqty), vfee);

    // mask/count/sum comes next
}

Why i += 8?

Because AVX2 holds eight 32-bit floats in one __m256.

If n is not a multiple of 8, the SIMD loop handles the largest clean prefix and the scalar tail handles the rest:

for (; i < n; ++i) {
    float score = price[i] * quantity[i] + fee[i];

    if (score > threshold) {
        ++result.count;
        result.total += score;
    }
}

The tail loop is not optional unless you arrange padding or masked tail handling. The simple default is scalar tail.

6. Branches Become Masks

The scalar branch is:

if (score > threshold) {
    ++count;
    total += score;
}

The SIMD version cannot branch separately for lane 0, lane 1, lane 2, and so on. It compares all lanes and produces a mask:

__m256 vthreshold = _mm256_set1_ps(threshold);
__m256 mask = _mm256_cmp_ps(vscore, vthreshold, _CMP_GT_OQ);

Conceptually:

score:      [12.0  4.0  9.0 30.0  1.0 50.0  8.0 10.0]
threshold:  [10.0 10.0 10.0 10.0 10.0 10.0 10.0 10.0]
mask:       [true false false true false true false false]

In the register, true is represented as all bits set in that lane and false is represented as zero bits.

To count selected lanes, turn the mask into normal integer bits:

int bits = _mm256_movemask_ps(mask);
count += __builtin_popcount((unsigned)bits);

To sum only selected scores, zero out unselected lanes:

__m256 selected = _mm256_and_ps(vscore, mask);
total_vec = _mm256_add_ps(total_vec, selected);

That maps:

if (score > threshold) {
    total += score;
}

to:

make unselected lanes zero
add all lanes to vector accumulator

Masking is SIMD’s replacement for per-lane control flow.

Important trap:

uint32_t a = arr[i];
uint32_t b = arr[j];
uint32_t result = select(cond, a, b);

Both loads happen before the selection. If j is invalid, masking does not make it safe. Masks select values that already exist; they do not cancel unsafe memory access.

7. Full AVX2 Version Of The Running Example

Here is the complete mapped version.

#include <immintrin.h>

struct ScoreSummary {
    int count;
    float total;
};

static float hsum_avx2(__m256 v) {
    __m128 lo = _mm256_castps256_ps128(v);
    __m128 hi = _mm256_extractf128_ps(v, 1);
    __m128 sum = _mm_add_ps(lo, hi);

    sum = _mm_hadd_ps(sum, sum);
    sum = _mm_hadd_ps(sum, sum);

    return _mm_cvtss_f32(sum);
}

ScoreSummary summarize_avx2(
    const float* price,
    const float* quantity,
    const float* fee,
    int n,
    float threshold
) {
    int i = 0;
    int count = 0;

    __m256 vthreshold = _mm256_set1_ps(threshold);
    __m256 total_vec = _mm256_setzero_ps();

    for (; i + 8 <= n; i += 8) {
        __m256 vprice = _mm256_loadu_ps(price + i);
        __m256 vqty = _mm256_loadu_ps(quantity + i);
        __m256 vfee = _mm256_loadu_ps(fee + i);

        __m256 vscore = _mm256_add_ps(_mm256_mul_ps(vprice, vqty), vfee);
        __m256 mask = _mm256_cmp_ps(vscore, vthreshold, _CMP_GT_OQ);

        int bits = _mm256_movemask_ps(mask);
        count += __builtin_popcount((unsigned)bits);

        __m256 selected = _mm256_and_ps(vscore, mask);
        total_vec = _mm256_add_ps(total_vec, selected);
    }

    float total = hsum_avx2(total_vec);

    for (; i < n; ++i) {
        float score = price[i] * quantity[i] + fee[i];

        if (score > threshold) {
            ++count;
            total += score;
        }
    }

    return ScoreSummary{count, total};
}

The hot loop does not look like the scalar loop anymore, but the mapping is direct:

Scalar codeSIMD mapping
price[i]load 8 prices
quantity[i]load 8 quantities
fee[i]load 8 fees
thresholdbroadcast to 8 lanes
price * quantity + feelane-wise multiply and add
score > thresholdvector compare mask
count++movemask plus popcount
total += scoremask unselected lanes to zero, vector add
final totalhorizontal sum once
leftover elementsscalar tail

This is the core SIMD translation pattern.

8. Normal Code To AVX2 Cheatsheet

Use this table as a first-pass mapping.

Normal code ideaAVX2 operation
x = 0_mm256_setzero_ps(), _mm256_setzero_si256()
x = value repeated_mm256_set1_ps(x), _mm256_set1_epi32(x)
x = a[i]_mm256_loadu_ps(a + i), _mm256_loadu_si256(...)
out[i] = x_mm256_storeu_ps(out + i, v), _mm256_storeu_si256(...)
a + b_mm256_add_ps(a, b), _mm256_add_epi32(a, b)
a - b_mm256_sub_ps(a, b), _mm256_sub_epi32(a, b)
a * b_mm256_mul_ps(a, b), _mm256_mullo_epi32(a, b)
a / b for floats_mm256_div_ps(a, b), _mm256_div_pd(a, b)
a * b + c for floats_mm256_fmadd_ps(a, b, c)
min(a, b)_mm256_min_ps(a, b), _mm256_min_epi32(a, b)
max(a, b)_mm256_max_ps(a, b), _mm256_max_epi32(a, b)
bitwise AND_mm256_and_si256(a, b)
bitwise OR_mm256_or_si256(a, b)
bitwise XOR_mm256_xor_si256(a, b)
a == b_mm256_cmpeq_epi32(a, b), _mm256_cmp_ps(...)
a > b_mm256_cmpgt_epi32(a, b), _mm256_cmp_ps(...)
condition ? a : bcompare to mask, then blend or bitwise select
sum arrayvector accumulators, then horizontal reduction
find matching bytesbyte compare plus _mm256_movemask_epi8
reorder lanes_mm256_shuffle_*, _mm256_permute_*
indexed load_mm256_i32gather_*

Important defaults:

  • Use unaligned loads and stores unless you control alignment.
  • Prefer contiguous loads over gathers.
  • Avoid packed integer division in AVX2; there is no simple equivalent of a / b for integer lanes.
  • Do not assume intrinsics beat compiler auto-vectorization. Measure.

9. Reductions: Stay Vector As Long As Possible

This scalar loop reduces every element into one value:

float total = 0.0f;

for (int i = 0; i < n; ++i) {
    total += a[i];
}

Bad SIMD shape:

for (int i = 0; i + 8 <= n; i += 8) {
    __m256 v = _mm256_loadu_ps(a + i);
    total += hsum_avx2(v);
}

This collapses SIMD into scalar every iteration.

Better SIMD shape:

load vector
add into vector accumulator
repeat
reduce accumulator once at the end

This is what the running example does:

total_vec = _mm256_add_ps(total_vec, selected);

It keeps eight partial sums alive:

total_vec:
[sum0 sum1 sum2 sum3 sum4 sum5 sum6 sum7]

Only after the loop does it call:

float total = hsum_avx2(total_vec);

Rule:

Vertical work in the hot loop.
Horizontal work at the end.

10. Integer Masks Use The Same Idea

For integer comparisons, AVX2 masks are also all-zero or all-one lanes.

Scalar:

int count_gt_scalar(const int* a, int n, int x) {
    int count = 0;

    for (int i = 0; i < n; ++i) {
        count += a[i] > x;
    }

    return count;
}

AVX2:

#include <immintrin.h>

int count_gt_avx2(const int* a, int n, int x) {
    int i = 0;

    __m256i vx = _mm256_set1_epi32(x);
    __m256i ones = _mm256_set1_epi32(1);
    __m256i counts = _mm256_setzero_si256();

    for (; i + 8 <= n; i += 8) {
        __m256i v = _mm256_loadu_si256((const __m256i*)(a + i));
        __m256i mask = _mm256_cmpgt_epi32(v, vx);
        __m256i inc = _mm256_and_si256(mask, ones);

        counts = _mm256_add_epi32(counts, inc);
    }

    alignas(32) int tmp[8];
    _mm256_store_si256((__m256i*)tmp, counts);

    int count = tmp[0] + tmp[1] + tmp[2] + tmp[3] +
                tmp[4] + tmp[5] + tmp[6] + tmp[7];

    for (; i < n; ++i) {
        count += a[i] > x;
    }

    return count;
}

This line is the important mapping:

__m256i inc = _mm256_and_si256(mask, ones);

It turns:

false: 0x00000000
true:  0xFFFFFFFF

into:

false: 0
true:  1

11. Strings Map To Byte Scans

Strings are different.

To SIMD, a string is not words or characters. It is bytes.

"hello,world\n"

is:

h e l l o , w o r l d \n

The common mapping is:

load 32 bytes
compare all bytes against target
turn byte comparison into bitmask
use scalar bit operations on the mask
handle tail

Scalar search:

const char* find_char_scalar(const char* s, int n, char target) {
    for (int i = 0; i < n; ++i) {
        if (s[i] == target) {
            return s + i;
        }
    }

    return nullptr;
}

AVX2 byte scan:

#include <immintrin.h>

const char* find_char_avx2(const char* s, int n, char target) {
    int i = 0;
    __m256i vt = _mm256_set1_epi8(target);

    for (; i + 32 <= n; i += 32) {
        __m256i chunk = _mm256_loadu_si256((const __m256i*)(s + i));
        __m256i cmp = _mm256_cmpeq_epi8(chunk, vt);

        int mask = _mm256_movemask_epi8(cmp);

        if (mask != 0) {
            int offset = __builtin_ctz(mask);
            return s + i + offset;
        }
    }

    for (; i < n; ++i) {
        if (s[i] == target) {
            return s + i;
        }
    }

    return nullptr;
}

_mm256_movemask_epi8 is the key instruction:

byte comparison:
[00 00 FF 00 FF 00 00 FF ...]

movemask:
00101001...

Now normal integer bit operations can find the matching byte positions.

To find comma or newline:

__m256i comma = _mm256_set1_epi8(',');
__m256i newline = _mm256_set1_epi8('\n');
__m256i chunk = _mm256_loadu_si256((const __m256i*)ptr);

__m256i is_comma = _mm256_cmpeq_epi8(chunk, comma);
__m256i is_newline = _mm256_cmpeq_epi8(chunk, newline);
__m256i either = _mm256_or_si256(is_comma, is_newline);

int mask = _mm256_movemask_epi8(either);

This pattern is the base of fast CSV, JSON, HTTP, and log scanning.

It also works inside UTF-8 text when looking for ASCII delimiters. But byte scanning is not the same as Unicode character processing. Counting characters, slicing by character index, or changing case is a different problem.

12. Awkward Code Does Not Map Cleanly

Some code has a clean SIMD shape.

Some code fights the hardware.

Scalar shapeSIMD fitReason
out[i] = a[i] + b[i]GoodLane-independent and contiguous
score = price[i] * qty[i] + fee[i]GoodElementwise arithmetic
count += a[i] > xGoodCompare maps to mask
find '\n' in a bufferGoodByte compare maps to movemask
sum += a[i]MediumNeeds final horizontal reduction
out[i] = a[i - 1] + a[i] + a[i + 1]MediumNeeds neighboring lanes or overlapping loads
node = node->nextBadPointer chasing
obj[i].virtual_call()BadIndirect calls and per-object control flow
map[key] traversalBadScattered memory and branches

Lane-crossing is one reason “medium” cases need care.

AVX2 256-bit registers often behave like two 128-bit halves:

[a b c d | e f g h]

Operations that stay lane-wise are cheap:

[a0 a1 a2 a3 | a4 a5 a6 a7]
+
[b0 b1 b2 b3 | b4 b5 b6 b7]

Operations that constantly move data across lanes need shuffles, permutes, extracts, or inserts.

Rule of thumb:

0 cross-lane operations per vector iteration:
    excellent

1 occasional cross-lane operation:
    usually fine

1 cross-lane operation every iteration:
    benchmark carefully

multiple dependent cross-lane operations every iteration:
    likely expensive

Wider SIMD is not automatically faster. If widening the vector adds many shuffles, the gain may shrink or disappear.

13. Workflow And Checklist

Do not start with intrinsics.

Use this workflow:

write clear scalar code
benchmark it
check compiler auto-vectorization
fix memory layout
remove unpredictable branches if needed
write intrinsics only for hot loops
benchmark again

Useful commands:

g++ -O3 -march=native main.cpp -o main
g++ -O3 -fopt-info-vec main.cpp -o main
clang++ -O3 -Rpass=loop-vectorize main.cpp -o main
perf stat ./bench

Useful counters:

cycles
instructions
branches
branch-misses
cache-misses
L1-dcache-load-misses

Before writing SIMD code, ask:

Is the data contiguous?
Is the same operation repeated?
Are lanes independent?
Can branches become masks?
Can reductions be delayed?
Can the tail be handled safely?
Is this loop actually hot?

The final mapping model:

Scalar value      -> SIMD lane
Scalar loop       -> chunk loop
Scalar constant   -> broadcast vector
Scalar branch     -> mask or blend
Scalar byte scan  -> compare plus movemask
Scalar reduction  -> vector accumulators plus final horizontal reduction
Object layout     -> usually fix layout before writing intrinsics

SIMD works when code has this shape:

same operation
many contiguous elements
minimal branching
minimal lane communication

If the code has that shape, SIMD maps naturally.

If it does not, do not force it. Change the data shape first, or leave the code scalar.

Comments