Many high-performance x86 loops rely on the same pattern: compare 16 or 32 bytes in a vector register, collapse the comparison result into a scalar bitmask with PMOVMSKB, then use bit operations to find or iterate matching lanes. This shows up in string search, strlen, memchr, hash-table probing, compression match finding, regular-expression engines, UTF-8 scanners, and parsers.
Arm NEON has 128-bit SIMD registers and rich compare, load, shuffle, and arithmetic instructions, but it does not have a direct equivalent of x86 PMOVMSKB. A literal port through compatibility layers can work, yet the source article reports that direct or portable replacement sequences were sometimes up to 2x slower for Google workloads. The practical lesson is that porting SIMD code between ISAs is not a find-and-replace job; the algorithm has to be reshaped around the instructions that are cheap on the target machine.
The central NEON trick is to avoid building an exact 16-bit x86-style mask when the algorithm only needs a representation that supports zero checks, first-match discovery, and bit iteration. The shrn instruction, exposed through intrinsics such as vshrn_n_u16, can shift 16-bit lanes and narrow them to 8-bit lanes in one operation. When the input is a vector compare that produces 0x00 or 0xff bytes, that narrowing creates a compact grouped mask that preserves the useful match information.
A byte-wise SIMD comparison produces a vector, not a branch decision. On x86, _mm_movemask_epi8 extracts one selected bit from each byte lane and places those bits into a general-purpose register. Scalar code can then test whether any lane matched, find the first set bit, or repeatedly clear the lowest set bit to visit every candidate.
NEON ports should preserve the control-flow semantics, not necessarily the exact x86 mask layout. A grouped NEON mask may use four bits per input byte, two bits per input byte, or one bit per input byte depending on the load strategy. If the code consistently maps the grouped bit position back to the original lane, the surrounding algorithm can remain fast and correct.
vshrn_n_u16 takes eight 16-bit lanes, shifts them right by an immediate, and narrows the result into eight 8-bit lanes. Reinterpreting a 16-byte comparison vector as eight 16-bit lanes and narrowing by four compresses the match pattern into a 64-bit value. Matching bytes become repeated high-nibble bits; non-matches remain zero.
A NEON grouped mask deliberately stores more than one bit per original byte. The extra bits are not waste if they allow the mask to be built with fewer instructions and lower latency. The loop only needs a stable convention for testing, clearing, and converting grouped bit positions back into byte positions.
The familiar x86 loop compares a vector against a repeated byte, collapses the high bit of each comparison byte, and iterates the set bits. The resulting mask has one bit per input byte, so bit index i maps directly to byte offset i.
#include <immintrin.h>
#include <stdint.h>
uint32_t find_byte_sse(const uint8_t *src, uint8_t tag) {
__m128i chunk = _mm_loadu_si128((const __m128i *)src);
__m128i needle = _mm_set1_epi8((char)tag);
__m128i cmp = _mm_cmpeq_epi8(chunk, needle);
uint32_t mask = (uint32_t)_mm_movemask_epi8(cmp);
if (mask == 0) {
return 16;
}
return (uint32_t)__builtin_ctz(mask);
}
The NEON version compares the same 16-byte chunk but compresses it with vshrn_n_u16. The result is a 64-bit grouped mask, commonly filtered with a constant such as 0x8888888888888888 when the loop wants one canonical bit per group. Because a group represents one byte, the bit index must be divided by the group width when converting back to a byte offset.
#include <arm_neon.h>
#include <stdint.h>
static inline uint64_t neon_byte_match_mask(const uint8_t *src, uint8_t tag) {
uint8x16_t chunk = vld1q_u8(src);
uint8x16_t cmp8 = vceqq_u8(chunk, vdupq_n_u8(tag));
uint16x8_t cmp16 = vreinterpretq_u16_u8(cmp8);
uint8x8_t narrowed = vshrn_n_u16(cmp16, 4);
return vget_lane_u64(vreinterpret_u64_u8(narrowed), 0);
}
uint32_t find_byte_neon(const uint8_t *src, uint8_t tag) {
uint64_t grouped = neon_byte_match_mask(src, tag) & 0x8888888888888888ULL;
if (grouped == 0) {
return 16;
}
// AArch64 has CLZ and RBIT; reversing first gives trailing-zero behavior.
uint32_t bit = (uint32_t)__builtin_clzll(__builtin_bitreverse64(grouped));
return bit / 4;
}
NEON registers are 128 bits wide, but byte-search loops often want 32-byte or 64-byte logical chunks to reduce loop overhead. Interleaved loads such as vld2q_u8 and vld4q_u8 can distribute even and odd bytes into separate vectors. Combined with shift-right-insert operations such as vsriq_n_u8 and narrowing, those loads create larger masks without serially extracting one vector at a time.
strlen, memchr, and memcmp all need to scan byte sequences and stop at the first byte that satisfies a condition. The Arm article describes replacing a pairwise-add based mask builder in string routines with shrn, producing 10-15 percent improvements on a strlen distribution derived from SPEC CPU 2017.
ZSTD uses SIMD match masks for compression levels where candidate rows are probed for matching tags. The source describes an optimization that improved levels 5-9 by 3.5-4.5 percent and level 10 by 1 percent. Those are meaningful gains for a mature compression library because the hot path is already heavily optimized.
SwissTable-style hash maps store short hash fragments in metadata bytes and compare a probe hash against a group of metadata entries. On x86, that comparison naturally becomes a movemask. On Arm, a NEON-friendly mask representation can preserve fast candidate enumeration; the source reports 3-8 percent improvements across hash-table operations after architecture-aware optimization.
Regular-expression engines, JSON scanners, CSV parsers, and tokenizers often perform repeated byte classification: find quote characters, delimiters, backslashes, line breaks, or invalid bytes. A fast NEON mask path lets those systems use the same broad algorithmic structure as x86 code while avoiding expensive scalar-vector transfers.
Keep architecture-specific mask layout behind a narrow adapter. The caller should ask for operations such as Any, First, ClearFirst, and ByteIndex, rather than manipulating raw bits directly. This lets the x86 path use one-bit masks while the NEON path uses grouped masks.
typedef struct {
uint64_t bits;
uint32_t group_width;
} byte_mask_t;
static inline int mask_any(byte_mask_t m) {
return m.bits != 0;
}
static inline uint32_t mask_first_byte(byte_mask_t m) {
uint32_t bit = (uint32_t)__builtin_ctzll(m.bits);
return bit / m.group_width;
}
static inline void mask_clear_first(byte_mask_t *m) {
m->bits &= m->bits - 1;
}
The source shows several valid builders: plain shrn for 16-byte chunks, interleaved-load variants for 32-byte and 64-byte chunks, and pairwise approaches. There is no universal winner. The right choice depends on register pressure, whether the constant mask can stay live, how frequently matches occur, and which pipelines a target core can issue to in parallel.
A mask loop usually scans full vector chunks first, then handles the tail. Keep the tail path obviously correct and shared across architectures when possible. If the vector loop overreads within a padded allocation, document the invariant and test boundary lengths. If it cannot overread, use a scalar tail or a masked load strategy.
Measure end-to-end loops such as searching a buffer, probing a hash group, or finding compression candidates. Microbenchmarks for a single intrinsic sequence can mislead because the best sequence also depends on surrounding branch behavior, mask iteration cost, loaded constants, and cache effects.
PMOVMSKB result can waste instructions. Preserve the algorithm’s decisions, not the x86 bit layout.bits &= bits - 1 clears one set bit, not a whole byte group. If multiple bits represent one byte, normalize to one canonical bit or clear the group deliberately.