A Ruby pull request, live in your browser
SWAR: 8 Bytes at a Time
A pull request to Ruby proposes an eight-bytes-per-step fast path for
String#capitalize!:
one ordinary 64-bit register, no vector instructions. The technique is SWAR
(SIMD Within A Register), from
Lamport, 1975.
Type a string and step through it.
Every step on this page is computed by Ruby (loading WebAssembly…)
UTF-8 bytes ≥ 0x80 are what the guard bit rejects, so emoji pass through untouched. A tail shorter than 8 bytes is padded with “·” here; the real code sends those to a plain scalar loop instead (the SWAR word only runs once 8 bytes are available — try “RUBY💎 (short)” to see the pad).
the 64-bit register, one byte per lane
Select a step above to walk through the pipeline.
What the steps show
capitalize! upcases byte 0 with a plain scalar step (the dashed card), then runs
the SWAR loop over the next eight bytes at once. Steps 2–6 above are that loop's
helper, swar_in_range(v, 'A', 'Z') from string.c: park the guard bit,
two adds to test every lane against 'A' and 'Z', a non-ASCII check, then
AND the three verdicts into one mask. The caller does the rest — step 1 loads the register,
and steps 7–8 shift that mask onto the case bit and XOR it in, flipping every uppercase
lane. One question — is this byte an ASCII uppercase letter? — answered for
eight bytes at once, in bit 7 of each lane.
Hex, quickly
A byte is two hex digits, 4 bits each. Upper and lower case differ in exactly bit 5
(0x20): 'R' 0x52 ^ 0x20 = 0x72 'r'. One bit, whole alphabet;
the C code asserts it at compile time (ASCII_CASE_BIT == ('Z'^'z')).
Why it's safe
Eight independent bytes share one adder: a carry from lane 3 could corrupt lane 4. Three properties prevent it:
- The guard bit stops carries. Lanes are masked to ≤ 0x7F and the constant is ≤ 0x3F, so the worst sum (0xBE) stays inside the byte. The carry lands in bit 7, the answer flag (Lamport 1975; Knuth TAOCP 4A §7.1.3).
- The shift can't smear. Each lane holds 0x80 or 0x00; bit 7 shifted by 2 is bit 5, same byte.
- XOR touches only masked lanes. 0x20 in letter lanes, 0x00 everywhere else. Non-letters pass bit-for-bit; rewriting an unchanged byte with its own value is a no-op.
Non-ASCII gets caught twice: a byte like 0xC3 masks to a fake letter, so the final mask
also requires the original bit 7 clear (~v & 0x80…).
Endianness
Every step stays inside its own lane, so register byte order is irrelevant: little-endian, big-endian (s390x), same result. Only SWAR that moves data between lanes has to care.
Why bother, isn't this the compiler's job?
For the copying case (downcase), yes: unconditional stores auto-vectorize to 16-byte NEON.
The in-place case must store only what changed, and there compilers give up: clang emits slow masked
stores, GCC goes byte-at-a-time. SWAR does one 8-byte load, test, store. Measured: parity below 64
bytes, roughly 2× (clang) and 3× (GCC) on multi-kilobyte strings.
Further reading
- The
String#capitalize!SWAR pull request: the change this page animates. - Lamport, CACM 18(8), 1975: the original; the guard bit is his “separator bit”.
- Knuth, TAOCP Vol. 4A, §7.1.3: broadword computation.
- ruby/ruby #17403: the merged
capitalizeASCII fast path this builds on. - ruby/ruby #17415: the related
upcase/downcasework.
The Ruby that ran this page
The lane values came from the function below: real Ruby, in your browser, via
ruby.wasm, mirroring the C helper line for line.
If the WebAssembly download fails, a JavaScript BigInt engine takes over; the badge
up top says which one you got.
Show the in-browser Ruby source