Writing an algorithm to search a single byte from a given payload is as easy as it can get. One just has to perform a byte-by-byte scan to find the needle (the substring) from the given haystack (the payload).
For every iteration of a 64-byte window from the haystack, one would fetch a cache line (64 bytes on modern x86 cpu) from DRAM into L1D and execute the following workflow:
- issue a scalar load using
movzx - compare a single byte
- branch if the match is found
- continue otherwise for all 64 bytes
.loop:
movzx al, byte [rdi]
cmp al, sil
je .match
inc rdi
loop .loopOn a machine with a 2.5 GHz cpu, we can churn through ~2.14 GiB of payload per second[1], where the maximum memory bandwidth is about 18.70 GiB per second[2]. So the potential maximum is about 770% higher (~8.7x) than the baseline rate.
This is because the execution is bounded by the instruction execution time, i.e. compute-bound[4], and not the rate at which payload is fed from memory, i.e. memory-bound[3].
§ SWAR #
SIMD within a register, a.k.a. SWAR, is a technique for processing multiple data elements using a single GPR with bitwise arithmetic.
Instead of scanning one byte at a time, with SWAR, we broadcast the needle across a 64-bit register, load eight bytes per iteration instead of one into a GPR, and check all eight byte lanes simultaneously.
let needle_qword = (needle as u64) * 0x0101_0101_0101_0101;
let chunk = ptr::read_unaligned(ptr.add(i) as *const u64);
let x = chunk ^ needle_qword;
let match_mask = !((x & 0x7F7F_7F7F_7F7F_7F7F)
.wrapping_add(0x7F7F_7F7F_7F7F_7F7F) | x)
& 0x8080_8080_8080_8080;
if match_mask != 0 {
return Some(i + (match_mask.trailing_zeros() / 8) as usize);
}If the needle is matched, the mask marks the position, allowing trailing_zeros / 8 to find the match index with zero
branching. This bitwise technique avoids branch mispredictions by turning conditional control into arithmetic.
SWAR reaches about 9.25 GiB/sec throughput for cache-resident payloads on the same machine as the baseline, providing a >4.3x improvement in throughput. However, it still relies on multiple sequential ALU operations per eight bytes and remains constrained to 64-bit GPR widths.
§ SIMD #
Single Instruction Multiple Data, a.k.a. SIMD, enables hardware-level support for operating on multiple pieces of data at the exact same time, i.e. SWAR with dedicated hardware execution units.
So instead of working with standard 64-bit GPRs, the cpu is equipped with dedicated vector registers, whose sizes range from 128 bits (16 bytes) to 512 bits (64 bytes). With these wide registers, a single cpu instruction can process an entire block of data in parallel, drastically multiplying throughput.
§ AVX512BW #
AVX512 features 512-bit ZMM vector registers, which hold up to 64 bytes—matching the exact width of an x86 cpu's
cache line. A single vpcmpeqb instruction tests all 64 bytes simultaneously against the needle and writes the result
directly into a 64-bit opmask register __mmask64.
let v_needle = _mm512_set1_epi8(needle as i8);
let v = _mm512_loadu_si512(ptr.add(i) as *const _);
let mask = _mm512_cmpeq_epi8_mask(v, v_needle);
if mask != 0 {
return Some(i + mask.trailing_zeros() as usize);
}For completely L1D cache-resident payloads we get ~150 GiB/sec of throughput, and for DRAM-bound payloads (256 MiB to 1 GiB) we reach ~11.2–12.1 GiB/sec, fully saturating the single-core memory bandwidth ceiling.
§ Memory Wall #
One might wonder, if AVX512BW achieves ~150 GiB/sec for cache-resident workloads while AVX2 hits ~75–80 GiB/sec, why do both top out at ~11.2 to ~12.1 GiB/sec once the payload becomes DRAM-bound?
The simple answer is, for a memory-bound workload (which ours gracefully is 😎), SIMD width becomes irrelevant. The IPC for a single cpu core collapses from 3.10 down to 0.28 because the vector engine spends 90% of its time idling, stalled on memory requests.
In a single-threaded linear scan, memory throughput is governed by Little's Law,
On modern x86_64 processors, a single cpu core has a fixed, small pool of Line Fill Buffers (typically 12–32) to track in-flight cache line misses. While modern multi-channel memory subsystems (like 8-channel DDR5) can deliver 300+ GiB/sec across all cores, a single thread runs out of LFBs and hits a hard ~11–12 GiB/sec single-core memory wall, no matter how wide its vector registers are.