SWAR: SIMD Within a Register
Jialin Lu, 2026-08-14
Code: LuxxxLucy/easySWAR
TL;DR : In this blog we introduce SWAR (SIMD Within A Register), a trick that I recently learned which enables us to do SIMD with a 64-bit register. This is narrower than the SSE 128 bit wide and AVX 256 or more bit wide capabilities, but it is a more portable version, consider this does not involve the complicated intrinsics or neon syntax hell and that some small embedded chips do not possess SIMD capability.
Here we introduce SWAR with examples and benchmarking results. Later in the post we will provide a Rust crate, in what I think would be an easier way to program with SWAR, so that writing such functions (here we target string processing) would be less of a panic.
References:• Daniel Lemire, SWAR explained: parsing eight digits• Daniel Lemire, Detect control characters, quotes and backslashes efficiently using SWAR• Yagiz Nizipli, Eliminating branches in C++ loops• Daniel Lemire, Why do we even need SIMD instructions?• MLabs, The ‘A’ is for ‘Accelerated’: checking ASCII with SWAR• Greg Baker, Data parallelism
Introduction
If we are writing small parsers for application layer protocols, a very common task we see is that we want to find whether a particular char, normally a delimiter, exists in a string.
Say we want to find any newlines (\n) in a string. The simplest C impl would probably look like this:
bool has_newline_naive(const uint8_t *s, size_t n)
{
for (size_t i = 0; i < n; i++) {
if (s[i] == '\n') {
return true;
}
}
return false;
}A correct implementation. It loops over all the bytes one by one and returns at first hit. It is just that it is quite slow. The compiled loop is six instructions per byte:
ldrb w8, [x0], #1 load one byte
cmp w8, #10 compare it with '\n'
cset w8, eq
ccmp x9, #0, #4, ne and test the remaining count (end of loop)
sub x9, x9, #1
b.ne loopNow there are many problems with this implementation, for example, the if condition introduces branch misprediction. We can instead opt for a branchless version.Note that here we do not have early exit anymore, instead, we will have to loop over all the bytes exactly once. This would be an issue if the particular character we want to find exists early in the string or the frequency of it is high. But in many cases we are looking for a character that is usually serving as some sort of “delimiter”, so that is a reasonable trade-off. In the later version we will re-introduce it with some kind of loop unrolling and applies early exit. We still want to do it, but just less frequently for the sake of performance.
bool has_newline_reduce_misprediction(const uint8_t *s, size_t n)
{
bool found = false;
for (size_t i = 0; i < n; i++) {
found |= s[i] == '\n';
}
return found;
}Modern compilers are in fact smart enoughI would say, at 99.99% of times the compiler would be smarter than you., so -O3 and CPU pipelining would give you almost equally good results, likely 0.9 1.1 cycles per byte. While it seems that we gained little, but this branchless version does give us a hint on how to write this function, that we can parallelize it.
It is here we discuss stronger performance offered by SIMD. SIMD stands for Single Instruction, Multiple Data, a parallel computing model that allows a single instruction to be applied on multiple data elements in one instruction. As a way of parallelism or vectorization, we can batch several units of data and process it in one shot, it would have slightly higher latency but enable us to improve throughput a lot so in the end it would be worthy of it. For example, you can compare N bytes using a single instruction in parallel; as long as N > 1, it would definitely be quicker than the naive 1 byte version.Exactly how many bytes can be processed together though is still determined by what the hardware can offer. N can be 16, 32 or even larger.
Programming SIMD is usually done by using intrinsic on x86. With AVX2, which offers 256-bit width, we can land with the following version.
bool has_newline_avx2(const uint8_t *s, size_t n)
{
if (n < 32) {
return has_newline_naive(s, n);
}
__m256i newline = _mm256_set1_epi8('\n');
size_t i = 0;
for (; i + 128 <= n; i += 128) {
__m256i m = _mm256_or_si256(
_mm256_or_si256(_mm256_cmpeq_epi8(load256(s + i), newline),
_mm256_cmpeq_epi8(load256(s + i + 32), newline)),
_mm256_or_si256(_mm256_cmpeq_epi8(load256(s + i + 64), newline),
_mm256_cmpeq_epi8(load256(s + i + 96), newline)));
if (_mm256_movemask_epi8(m)) {
return true;
}
}
for (; i + 32 <= n; i += 32) {
if (_mm256_movemask_epi8(_mm256_cmpeq_epi8(load256(s + i), newline))) {
return true;
}
}
return _mm256_movemask_epi8(
_mm256_cmpeq_epi8(load256(s + n - 32), newline)) != 0;
}Note that the code has become expectedly uglier, but it can get even worse. We have a list of SIMD standards where we can code:
- SSE, SSE2, SSE3 ..
- AVX, AVX2
- Neon
- SVE
- …
Portability has always been an issue with SIMD. Essentially this means you need to write different code tailored at the targeted architecture, and different SIMD supports would all have different syntax, SIMD widthFor example, the machine I use, my MacBook which supports Neon, offers 128-bit (16 bytes) SIMD width. While AVX supports 256 bits, and SVE supports a more flexible variable width given the hardware., and programming styles.
Here we show the different versions of the same function in different settings for AVX2, NEON, AVX-512, and SVE.
We do incorporate additional tricks that we do loop unrolling and bring the if-condition back at the frequency of the unrolled loop. I think this is a reasonable trade-off to make
NEON, 128-bit width, N=16 bytes, c/has_newline/simd.c
bool has_newline_simd(const uint8_t *s, size_t n)
{
if (n < 16) {
return has_newline_naive(s, n);
}
uint8x16_t newline = vdupq_n_u8('\n');
size_t i = 0;
for (; i + 64 <= n; i += 64) {
uint8x16_t m =
vorrq_u8(vorrq_u8(vceqq_u8(vld1q_u8(s + i), newline),
vceqq_u8(vld1q_u8(s + i + 16), newline)),
vorrq_u8(vceqq_u8(vld1q_u8(s + i + 32), newline),
vceqq_u8(vld1q_u8(s + i + 48), newline)));
if (vmaxvq_u8(m)) {
return true;
}
}
for (; i + 16 <= n; i += 16) {
if (vmaxvq_u8(vceqq_u8(vld1q_u8(s + i), newline))) {
return true;
}
}
return vmaxvq_u8(vceqq_u8(vld1q_u8(s + n - 16), newline)) != 0;
}AVX2, 256-bit width, N=32 bytes, c/has_newline/simd_avx2.c
static inline __m256i load256(const uint8_t *p)
{
return _mm256_loadu_si256((const __m256i *)p);
}bool has_newline_avx2(const uint8_t *s, size_t n)
{
if (n < 32) {
return has_newline_naive(s, n);
}
__m256i newline = _mm256_set1_epi8('\n');
size_t i = 0;
for (; i + 128 <= n; i += 128) {
__m256i m = _mm256_or_si256(
_mm256_or_si256(_mm256_cmpeq_epi8(load256(s + i), newline),
_mm256_cmpeq_epi8(load256(s + i + 32), newline)),
_mm256_or_si256(_mm256_cmpeq_epi8(load256(s + i + 64), newline),
_mm256_cmpeq_epi8(load256(s + i + 96), newline)));
if (_mm256_movemask_epi8(m)) {
return true;
}
}
for (; i + 32 <= n; i += 32) {
if (_mm256_movemask_epi8(_mm256_cmpeq_epi8(load256(s + i), newline))) {
return true;
}
}
return _mm256_movemask_epi8(
_mm256_cmpeq_epi8(load256(s + n - 32), newline)) != 0;
}AVX-512, 512-bit width, N=64 bytes, c/has_newline/simd_avx512.c
bool has_newline_avx512(const uint8_t *s, size_t n)
{
if (n < 64) {
return has_newline_naive(s, n);
}
__m512i newline = _mm512_set1_epi8('\n');
size_t i = 0;
for (; i + 256 <= n; i += 256) {
__mmask64 m =
_mm512_cmpeq_epi8_mask(_mm512_loadu_si512(s + i), newline) |
_mm512_cmpeq_epi8_mask(_mm512_loadu_si512(s + i + 64), newline) |
_mm512_cmpeq_epi8_mask(_mm512_loadu_si512(s + i + 128), newline) |
_mm512_cmpeq_epi8_mask(_mm512_loadu_si512(s + i + 192), newline);
if (m) {
return true;
}
}
for (; i + 64 <= n; i += 64) {
if (_mm512_cmpeq_epi8_mask(_mm512_loadu_si512(s + i), newline)) {
return true;
}
}
return _mm512_cmpeq_epi8_mask(_mm512_loadu_si512(s + n - 64), newline) != 0;
}SVE, 128 to 2048-bit width, N set at run time, c/has_newline/simd_sve.c
bool has_newline_sve(const uint8_t *s, size_t n)
{
for (size_t i = 0; i < n; i += svcntb()) {
svbool_t active = svwhilelt_b8((uint64_t)i, (uint64_t)n);
svuint8_t x = svld1_u8(active, s + i);
if (svptest_any(active, svcmpeq_n_u8(active, x, '\n'))) {
return true;
}
}
return false;
}has_newline under four different SIMD support.SWAR: SIMD within a register
SWAR, “SIMD within a register”,It is a very old idea, dated from Leslie Lamport’s 1975 paper, Multiple byte processing with full-word instructions, Communications of the ACM 18(8), 1975 attempted to do SIMD without SIMD supported. The idea is that we can use the 64-register to group the data so to have 64-width SIMD-like capability. Now is 2026 and mainstream CPUs are overwhelmingly 64-bit architectures so that is a rather quite ubiquitous setting. A 64-bit register can hold 8 bytes, so that enables doing N=8 parallelism without any portability issue.
We can start with one byte version first, to check whether a byte c is \n (0x0A), instead of doing c == 0x0A, we can do with a trick with XOR.
- we first apply
c = c ^ 0x0A,cwould be zero if it is indeed\n, and nonzero otherwise. - we subtract it by 1 (
c = c - 0x01) and so if it would becomes negative (the highest bit becomes 1). - return
c & 0x80
We then vectorize this to 8 byte. Suppose x is a 64-bit integer representing 8 bytes. We have the following vectorized version.
- we first apply
x = x ^ 0x0A0A0A0A0A0A0A0A, each lane ofxwould be zero if it is indeed\n, and nonzero otherwise. - we subtract it by 1 (
x = x - x0101010101010101) and so if it would becomes negative (the highest bit becomes 1). - return
x & 0x8080808080808080
Following this idea, we denote 64-bit as 8 lanes for each byte, and we can have:
#define ONES 0x0101010101010101ULL // 0x01 in every lane
#define HIGH 0x8080808080808080ULL // the highest bit of every lanestatic inline uint64_t has_newline_swar_helper(uint64_t x)
{
uint64_t v = x ^ (ONES * '\n');
return (v - ONES) & ~v & HIGH;
}bool has_newline_swar_simple(const uint8_t *s, size_t n)
{
size_t i = 0;
for (; i + 8 <= n; i += 8) {
if (has_newline_swar_helper(load64(s + i))) {
return true;
}
}
return has_newline_naive(s + i, n - i);
}Lemire’s post on parsing eight digits is the goto explanation of SWAR. I recommend reading it. Note there is additional complexity of non-ascii characters, whose value are no less than 128 (0x80) to start with, so this becomes a & ~v operation to filter them out,
To understand what happens, we can go through the code with the following string abc\ndefg.
x 61 62 63 0A 64 65 66 67 "abc\ndefg"
v = x ^ ONES*10 6B 68 69 00 6E 6F 6C 6D the newline lane is zero
v - ONES 6A 67 68 FF 6C 6E 6B 6C the zero lane wrapped to FF
& ~v & HIGH 00 00 00 80 00 00 00 00 top bit marks the laneand we only ask whether any lane fired. It does matter once masks are combined with AND, and we come back to it in the section on byte sequences.
We can apply the same loop unrolling idea and arrive at:
bool has_newline_swar(const uint8_t *s, size_t n)
{
if (n < 8) {
return has_newline_naive(s, n);
}
size_t i = 0;
for (; i + 32 <= n; i += 32) {
uint64_t m = has_newline_swar_helper(load64(s + i)) |
has_newline_swar_helper(load64(s + i + 8)) |
has_newline_swar_helper(load64(s + i + 16)) |
has_newline_swar_helper(load64(s + i + 24));
if (m) {
return true;
}
}
for (; i + 8 <= n; i += 8) {
if (has_newline_swar_helper(load64(s + i))) {
return true;
}
}
return has_newline_swar_helper(load64(s + n - 8)) != 0;
}Here we present the results On an laptop Apple M4, with clang 21 at -O3 and with auto-vectorization off (to avoid the compiler convert the SWAR into NEON automatically). at 2 MB input:
new_line.What if the function becomes complicated?
has_newline, which is a relative simple task, becomes hard to recognize and complicated. Advanced use cases would become even more complicated.
In Daniel Lemire’s blogDaniel Lemire, Detect control characters, quotes and backslashes efficiently using SWAR, 2025. about JSON parsing, the task is defined as determining the validness of a string, where:
- the string should not contain a control character (any char below
32,0x20) - the string should not contain a quote
'"'(34,0x22) - the string should not contain a backslash
'\\(92,0x5C)
The code would be like this:
static inline uint64_t has_json_invalid_char_swar_helper(uint64_t x)
{
uint64_t is_ascii = ~x & HIGH;
uint64_t lt32_or_eq34 = (x ^ (ONES * 2)) - (ONES * 33);
uint64_t eq92 = (x ^ (ONES * '\\')) - ONES;
return (lt32_or_eq34 | eq92) & is_ascii;
}Here I decide to write a Rust lib easySWAR that I think can provide good interface so that can writing these kinds of functions easier.
easySWAR uses a proc-macro swar! to generate the code as a sort of preprocessing. Several predicates such as eq, lt, range, non_ascii, and seq, combined with |, &, and !, can be composed easily
The aforementioned JSON check function would look like this:
fn json_macro(s: &[u8]) -> bool {
swar!(lt(0x20) | eq(b'"') | eq(b'\\')).contains(s)
}has_json_invalid_char on a 2 MiB input.You can see from the results that, though the 8-byte SWAR is not as good as the proper SIMD, it still produces impressive results.
We can even make more complicated functions. A task that comes to my mind is searching for a substring delimiter, such as \r\n or \r\n.\r\n, which are frequently used as delimiters in application layer protocols (say HTTP, SMTP etc). In C, the \r\n.\r\n search would be like this.
#define STEP_BYTES 12static inline uint64_t load64(const uint8_t *p)
{
uint64_t x;
memcpy(&x, p, 8);
return x;
}static inline uint64_t eq_lanes(uint64_t x, uint8_t c)
{
uint64_t v = x ^ (ONES * c);
return ~(((v & LOW7) + LOW7) | v) & HIGH;
}static inline uint64_t has_end_of_message_swar_helper(const uint8_t *p)
{
return eq_lanes(load64(p), '\r') & eq_lanes(load64(p + 1), '\n') &
eq_lanes(load64(p + 2), '.') & eq_lanes(load64(p + 3), '\r') &
eq_lanes(load64(p + 4), '\n');
}bool has_end_of_message_swar(const uint8_t *s, size_t n)
{
if (n < STEP_BYTES) {
return has_end_of_message_naive(s, n);
}
size_t i = 0;
for (; i + 24 + STEP_BYTES <= n; i += 32) {
uint64_t m = has_end_of_message_swar_helper(s + i) |
has_end_of_message_swar_helper(s + i + 8) |
has_end_of_message_swar_helper(s + i + 16) |
has_end_of_message_swar_helper(s + i + 24);
if (m) {
return true;
}
}
for (; i + STEP_BYTES <= n; i += 8) {
if (has_end_of_message_swar_helper(s + i)) {
return true;
}
}
return has_end_of_message_swar_helper(s + n - STEP_BYTES) != 0;
}Using easySWAR it can be written simply as:
fn end_of_message_search(s: &[u8]) -> bool {
swar!(seq(b"\r\n.\r\n")).contains(s)
}has_end_of_message on a 2 MiB input.Wrap up
SWAR is a small trick, that provides SIMD-like performance using the 64-bit register as the vehicle. In this post we introduce progressively on writing functions in naive ways, and to writing it in SIMD and in SWAR. Later we move to more advanced usages where we want to write more complicated functions and introduce easySWAR as a friendly Rust lib interface to write the code easily.