SIMD base64, hiding behind libb64's old API

June 2026 · QVXLabs · github.com/QVXLabs/libb64

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).

Encodestock scalarSIMD
4 KiB6682,311 (3.5×)12,940 (19×)
64 KiB6642,149 (3.2×)12,707 (19×)
1 MiB6652,232 (3.4×)12,387 (19×)
Decodestock scalarSIMD
4 KiB4642,444 (5.3×)9,337 (20×)
64 KiB4712,482 (5.3×)9,734 (21×)
1 MiB4652,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):

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.

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.

libb64 on GitHub

Actively maintained — issues and PRs welcome.