| Specification | year | one channel |
|---|---|---|
| PCIe 1.x | 2003 | 500 MB/s |
| PCIe 2.x | 2007 | 1 GB/s |
| PCIe 3.x | 2010 | 2 GB/s |
| PCIe 4.x | 2017 | 4 GB/s |
| PCIe 5.x | 2019 | 8 GB/s |
| PCIe 6.x | 2022 | 16 GB/s |
| PCIe 7.x | 2025 | 32 GB/s |
| Generation | Year | Bandwidth (per stack) |
|---|---|---|
| HBM2E | 2020 | ~460 GB/s |
| HBM3 | 2022 | 819 GB/s |
| HBM3E | 2024 | ~1.2 TB/s |
| HBM4 | 2026 | >2.8 TB/s |
| HBM4E | 2027 | ~4 TB/s |
You are CPU bound.
strstr, 32-byte needle: 9.5 GB/sDo this last, not first.
| processor | year | arithmetic logic units | SIMD units |
|---|---|---|---|
| Pentium 4 | 2000 | 2 | |
| AMD Zen 2 | 2019 | 4 | |
| Apple M* | 2019 | 6+ | |
| Intel Lion Cove | 2024 | 6 | |
| AMD Zen 5 | 2024 | 6 |
Processors predict branches and execute code speculatively. A misprediction costs 10–20 cycles.

| cycle | action | action | pizza en route |
|---|---|---|---|
| 1 | order pizza A | ||
| 2 | order pizza B | A |
|
| 3 | order pizza C | A |
|
| 4 | order pizza D | eat pizza A |
B |
| 5 | order pizza E | eat pizza B |
C |

Restructure the queries so several memory accesses are in flight at once.
Same algorithm. Same hash functions. Different memory access pattern.
Portability is mostly solved: compile several kernels, dispatch at runtime on CPU features. C++26 adds data-parallel types (std::simd).
#ifdefA great place to start.
In ASCII/UTF-8, the digits 0, 1, ..., 9 have values
0x30, 0x31, ..., 0x39.
To recognize a digit:
// load 8 input bytes into val
bool is_made_of_eight_digits_fast(uint64_t val) noexcept {
return !((((val + 0x4646464646464646)
| (val - 0x3030303030303030))
& 0x8080808080808080));
}
compiles to
add rax, rdi
add rdi, rdx
or rax, rdi
test rax, rdx
Four instructions for eight characters, and no branch.
1.3321321e-12 to doubledouble result;
fast_float::from_chars(
input.data(), input.data() + input.size(), result);
We massively reduced the number of CPU instructions required.
| function | instructions |
|---|---|
| strtod | |
| our parser |
Reference:
Number Parsing at a Gigabyte per Second, Software: Practice and Experience 51 (8), 2021
For each character c
If c - 'A' <= 'Z' - 'A' then
c = c + 'a' - 'A'
EndIf
EndFor
One byte per iteration. One unpredictable branch per byte.
__m512i ca = _mm512_sub_epi8(c, _mm512_set1_epi8('A'));
__mmask64 is_upper = _mm512_cmple_epu8_mask(ca, _mm512_set1_epi8('Z' - 'A'));
__m512i result = _mm512_mask_add_epi8(c, is_upper, c, to_lower);
No branch at all.
llvm-mca is a static machine-code analyzer shipped with LLVMllvm-mca -mcpu=icelake-server -iterations=100 kernel.s
movzbl (%rdi,%rax), %ecx
leal -65(%rcx), %edx
cmpb $26, %dl
jae .LBB1_2
addb $32, %cl
movb %cl, (%rdi,%rax)
addq $1, %rax
cmpq %rax, %rsi
jne .LBB1_1
vmovdqu64 (%rdi,%rax), %zmm2
vpsubb %zmm0, %zmm2, %zmm3
vpcmpub $2, %zmm1, %zmm3, %k1
vpaddb %zmm4, %zmm2, %zmm2 {%k1}
vmovdqu64 %zmm2, (%rdi,%rax)
addq $64, %rax
cmpq %rax, %rsi
jne .LBB0_1
scalar AVX-512
Iterations: 100 100
Instructions: 900 800
Total Cycles: 206 216
uOps Per Cycle: 4.37 4.63
IPC: 4.37 3.70
About 60× fewer cycles per byte.
IPC is not a performance metric. It is a diagnostic.
Use it to explain a measurement, not to replace one.
$ go run parse_twitter.go
Parsed 0.63 GB in 6.961 seconds (90.72 MB/s)
This was the conventional wisdom. It was wrong.

JSON.parse in Node.js, Bun and Deno is data-parallel.Stage 1 (data-parallel): scan the whole document with SIMD
Stage 2 (mostly serial): walk the index and build values
We need to sort every byte into a class:
,:[, ], {, }A switch statement per byte? No.
H(c)H1 and H2H1 and H2 such that the bitwise AND of the lookups classifies the character:H1(c & 0xf) & H2(c >> 4)low_nibble_mask = {16, 0, 0, 0, 0, 0, 0, 0, 0, 8, 12, 1, 2, 9, 0, 0};
high_nibble_mask = {8, 0, 18, 4, 0, 1, 0, 1, 0, 0, 0, 3, 2, 1, 0, 0};
Five instructions, 16 to 64 bytes at a time:
nib_lo = input & 0xf;
nib_hi = input >> 4;
shuf_lo = lookup(low_nibble_mask, nib_lo);
shuf_hi = lookup(high_nibble_mask, nib_hi);
return shuf_lo & shuf_hi;
This trick generalizes: any 256-way classification into 8 classes.
", \, and control characters.Traditional (1 byte at a time):
for (char c : str) {
if (c == '"' || c == '\\' || c < 0x20)
return true;
}
SIMD (64 bytes at once):
auto chunk = load_64_bytes(str);
auto needs_escape = check_all_conditions_parallel(chunk);
if (!needs_escape)
return false; // Fast path!
struct Player {
std::string username;
int level;
};
Player load_player(std::string& json_str) {
return simdjson::from(json_str);
}
std::string save_player(const Player& p) {
return simdjson::to_json(p);
}
No macros. No code generation step. No runtime reflection cost.
U+D800–U+DBFF followed by U+DC00–U+DFFF.Every JavaScript string, every Java string, every Windows filename.
PROCEDURE validate_utf16(code_units)
i ← 0
WHILE i < |code_units|
unit ← code_units[i]
IF unit ≤ 0xD7FF OR unit ≥ 0xE000 THEN
INCREMENT i
CONTINUE
IF unit ≥ 0xD800 AND unit ≤ 0xDBFF THEN
IF i + 1 ≥ |code_units| THEN
RETURN false
next_unit ← code_units[i + 1]
IF next_unit < 0xDC00 OR next_unit > 0xDFFF THEN
RETURN false
i ← i + 2 // Valid surrogate pair
CONTINUE
RETURN false
RETURN true
1 character per cycle might be just 4 GB/s — slower than your disk.
We are now barely at 1 GB/s. The branch predictor was doing the work.
static uint8_t transition_table[3][256] = { {...}, {...}, {...} };
bool is_valid_utf16_ff(std::span<uint16_t> code_units) {
uint8_t state = 0; // Start in Initial state
for (auto code_unit : code_units) {
uint8_t high_byte = code_unit >> 8;
state = transition_table[state][high_byte];
}
return state == 0; // Valid only if we end in Initial state
}
Three states: default, just saw a high surrogate, error. No branches.
const str = "ab\uD800";
console.log(str.toWellFormed());
// "ab�"
String.prototype.toWellFormed() must copy and repair, not merely check.
The SIMD correction function (which copies the data) beats the non-SIMD validation function.
| scalar | ARM NEON | |
|---|---|---|
| GB/s | 2.2 | 18.9 |
| ins/byte | 12.0 | 0.9 |
13× fewer instructions per byte.
Test it yourself: https://lemire.github.io/browserwellformed/
Encodes binary data as text using 64 characters (A-Z, a-z, 0-9, +, /)
3 bytes input → 4 characters output (33% overhead)
Data URLs, email, JWTs, web APIs, embedded images
"Hello, World!" → SGVsbG8sIFdvcmxkIQ==
Bit manipulation on a fixed schedule: the ideal SIMD problem.
const b64 = Uint8Array.prototype.toBase64(bytes);
const recovered = Uint8Array.fromBase64(b64);
| function (Safari, Apple M4) | speed |
|---|---|
Uint8Array.fromBase64() |
11 GiB/s |
Uint8Array.toBase64() |
20 GiB/s |
Test in your browser: https://simdutf.github.io/browserbase64/
vpermb (twice) and vpmultishiftqb.Successive differences: out[i] = in[i] - in[i-1]
Scalar: 1 cycle per element, 6 instructions per element.
Vectorized: 0.25 cycles per element, 0.9 instructions per element. 4× faster, for free.
Note the instructions per cycle went down, from 6 to 3.8.
out[i] = out[i-1] + in[i]This is the whole talk in one slide.
Fewer cycles, yes. But you are still touching one element at a time.
Frontier models in 2026 are genuinely good at:
They optimize what you measured. They do not choose what to measure.
Give the agent the three things it cannot produce on its own:
llvm-mca (or perf) so it can see cycles, not vibesThen let it iterate.
Here is a scalar reference implementation and a fuzzer that
compares any candidate against it.
Here is a benchmark: `make bench` prints cycles per byte.
Write an AVX2 version. After each attempt, run the fuzzer and
the benchmark, and run llvm-mca on the inner loop. Do not stop
until the fuzzer passes and cycles/byte is below 0.2.
The constraint is the contribution. The agent supplies the patience.
| you | the agent |
|---|---|
| choose the algorithm | write the intrinsics |
| define correctness | run the fuzzer |
| define the benchmark | iterate on the schedule |
| decide when it is fast enough | port to the other instruction sets |


Timings tell you that something is slow. Counters tell you why.
perf stat, Instruments, or a library such as performancecounters.
| variant | ns/byte | instr/byte | cycles/byte | IPC | branch miss/byte |
|---|---|---|---|---|---|
| scalar | |||||
| SWAR | |||||
| SIMD |
If instructions/byte did not drop, you did not do data parallelism.
If cycles/byte did not drop, find out which counter did move.
Good signs
Bad signs
memchr, CSV, regex prefiltering"Inherently serial" is usually a statement about the algorithm you happen to know.
Daniel Lemire — lemire.me
X: @lemire · GitHub: github.com/lemire
============ PART 1 ============
============ PART 2 ============
============ PART 3 ============
============ PART 4 ============
============ PART 5 ============
============ JSON ============
============ UNICODE ============
============ PART 8 ============
============ PART 5 ============
============ PART 6 ============
============ PART 7 ============
https://lemire.me/blog/2023/04/27/hotspot-performance-engineering-fails/
https://lemire.me/blog/2023/04/27/hotspot-performance-engineering-fails/
============ CONCLUSION ============