SIMD base64, hiding behind libb64's old API
libb64 is a tiny, public-domain base64 library that has been around for years. Its claim to fame is a cute implementation trick: the encoder and decoder are written as coroutines using Simon Tatham's "switch into a loop" device, so a single function can suspend mid-triple and resume on the next chunk. That makes streaming trivial and the code small — but it also means the hot path processes one byte at a time, which on a modern CPU leaves most of the machine idle.
This fork brings it up to date — SIMD on x86 and ARM, a much faster portable fallback, an optional customer allocator, and a real bug fix — while keeping the public API, ABI, streaming semantics, line wrapping, and the decoder's lenient behavior exactly as they were. Drop it in and existing code keeps compiling and linking; it just runs ~20× faster.
The numbers first
Throughput in MB/s of plaintext on an Intel Core i9-8950HK (Apple
clang 17, -O3), cache-resident buffers, measured by one
equal-conditions harness across three builds: stock (the
original byte-at-a-time coroutine), this fork's portable
scalar core with SIMD disabled, and its
SIMD path (AVX2 on this CPU).
| Encode | stock | scalar | SIMD |
|---|---|---|---|
| 4 KiB | 668 | 2,311 (3.5×) | 12,940 (19×) |
| 64 KiB | 664 | 2,149 (3.2×) | 12,707 (19×) |
| 1 MiB | 665 | 2,232 (3.4×) | 12,387 (19×) |
| Decode | stock | scalar | SIMD |
|---|---|---|---|
| 4 KiB | 464 | 2,444 (5.3×) | 9,337 (20×) |
| 64 KiB | 471 | 2,482 (5.3×) | 9,734 (21×) |
| 1 MiB | 465 | 2,415 (5.2×) | 8,982 (19×) |
The headline is ~19–20× with SIMD. The quieter result is the middle column: even with no SIMD at all, the rewritten scalar core is ~3× faster on encode and ~5× on decode than stock — which matters on any target without a vector unit.
SIMD behind the unchanged C API
The vector kernels implement the well-known Muła/Lemire base64 algorithm — the same approach the fastest open-source codecs use. At load time the library picks the best path the CPU supports and caches the choice: AVX2 → SSE4.1 on x86, NEON on ARM (aarch64 and ARMv7-A), and a portable scalar fallback everywhere else. One binary runs correctly on all of them; there are no new entry points and no build-time ISA flags to juggle.
The SIMD path only engages at a clean stream boundary and hands the leftover bytes back to the scalar coroutine, so streaming, chunked I/O, and MIME line-wrapped decode all still work — the decoder even re-engages SIMD after each newline in wrapped input.
Windows gets SIMD too
The kernels were originally gated to GNU/Clang because they used
__attribute__((target)), __builtin_cpu_supports,
and GCC atomics — so MSVC builds quietly fell back to scalar on hardware
that supports AVX2. Those three compiler-isms are now abstracted behind a
small portability layer, and MSVC (cl.exe) selects SSE4.1/AVX2 via a
__cpuid/XGETBV check and NEON on ARM64. Windows x86-64 and
arm64 now run the vector paths, not the fallback.
A faster fallback, still portable
The scalar core isn't just a safety net — it's the residue path for the SIMD kernels and the only path on exotic targets, so it was worth optimizing on its own terms (no intrinsics, any compiler):
- Encode uses a 12-bit dual-character lookup table: two table reads per 3-byte triple instead of four.
- Decode uses a four-table SWAR design — four 32-bit table lookups OR'd together with a single branchless validity test per 4-character quad.
Both tables are generated and committed (with their generators kept for reproducibility), so there's no runtime initialization and no data race.
Bring your own allocator
The core block API allocates nothing — it works entirely on
caller-supplied buffers — but the convenience helpers and C++ wrappers
previously used the C library directly. They now route every allocation
through an optional customer callback shaped like realloc,
with a context pointer and a lifetime hint:
void *realloc_fn(void *ctx, void *ptr, size_t size, b64_memlife life);
B64_MEM_SHORT marks transient scratch freed within the
call; B64_MEM_LONG marks buffers handed back to you. New
one-shot helpers (base64_encode_alloc /
base64_decode_alloc) size and allocate the output for you;
a null allocator falls back to the standard realloc/
free. In C++, the wrappers are now configured with a builder
that carries the allocator:
auto enc = base64::encoder_builder()
.buffer_size(4096)
.chars_per_line(76)
.realloc(my_realloc, my_ctx)
.build();
A correctness fix while we were in there
A full audit of the library turned up a long-standing one-byte heap
overflow: base64_decode_maxlength() under-sized its result
by one for inputs whose length is ≡ 3 (mod 4), so the coroutine
decoder's speculative write could land one byte past an exactly-sized
buffer. It was usually masked because callers add +1 for a
NUL terminator, but it was reachable through the public API. The fix is a
one-line sizing change, and there's now an AddressSanitizer regression
test that reproduces the overflow before the fix and is clean after.
Honest caveats
MIME line-wrapped decode is the one case SIMD can't fully accelerate. The kernel only runs on unbroken runs, so a newline every 76 characters forces a scalar hand-off per line (~1.9 GB/s). It still beats the original wrapped decode by ~4–5×, but it doesn't reach the unwrapped figures above.
- Memory-bound past the last-level cache. The ~12–13 GB/s encode figures are the L1/L2 compute-bound regime; for buffers larger than LLC, throughput settles into a DRAM-bound ~4–6 GB/s. That's bandwidth, not the transform.
- Output is unchanged. This is standard base64 — same alphabet, padding, and decoder tolerance as before. Nothing about the on-the-wire format changes; only the speed does.
Getting it
Released as 2.1.0, public domain, drop-in. The public API, ABI, and behavior are unchanged from upstream libb64; the SIMD, MSVC, allocator, and fix work all sit behind it.
Actively maintained — issues and PRs welcome.