Skip to content

base64 scalar - Improve performance of scalar base64 decoder and encoder - #1037

Open
gaspardpetit wants to merge 8 commits into
simdutf:masterfrom
gaspardpetit:feat/add-fast-branch-base64-scalar
Open

gaspardpetit wants to merge 8 commits into
simdutf:masterfrom
gaspardpetit:feat/add-fast-branch-base64-scalar

Conversation

@gaspardpetit

@gaspardpetit gaspardpetit commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Short title (summary):

Accelerate scalar Base64 encoding and decoding

Description

Optimize the scalar Base64 clean-input paths while preserving existing API behavior. Encoding processes four 3-byte groups using 12-bit pair lookup tables; decoding processes three 4-character groups together with combined validation and a packed output store. Existing paths continue to handle tails, whitespace, garbage, URL-safe input, UTF-16, strict/partial modes, padding, and bounded output.

Type of change

  • Bug fix
  • Optimization
  • New feature
  • Refactor / cleanup
  • Documentation / tests
  • Other (please describe):

How to verify / test

The existing base64_tests and constexpr_base64_tests pass. A dedicated C++23 compile-time test covering the new encoder and decoder paths also passes.

Scalar-only benchmarks were compiled with MSVC 19.51 using /O2, with SIMD backends disabled. Each number is the average of the medians from two alternating original/patched runs, using seven samples of approximately 350 ms each. Throughput is measured against binary input for encoding and encoded input for decoding.

Operation Size Original Proposed Change
Encode 32 B 2.413 GB/s 2.891 GB/s +19.8%
Encode 32 KiB 3.381 GB/s 4.936 GB/s +46.0%
Encode 32 MiB 3.376 GB/s 4.778 GB/s +41.5%
Decode 32 B 2.143 GB/s 2.274 GB/s +6.1%
Decode 32 KiB 5.847 GB/s 7.093 GB/s +21.3%
Decode 32 MiB 5.754 GB/s 6.695 GB/s +16.3%

The benchmark validates output correctness before measuring each case.

Checklist before submitting

  • I added/updated tests covering my change (if applicable)
  • Code builds locally and passes my check
  • Documentation / README updated if needed
  • Commits are atomic and messages are clear
  • I linked the related issue (if applicable)

Final notes

Further performance can be achieved if we reduce the responsibilities of the code, ex. externalize whitespace handling - see for example https://github.com/gaspardpetit/base64/

At this point, I maintained backward compatibility, but if interested, I can push this further.

[EDIT]

The original naive approach had an unexpected side effect - adding the new 12 bit handling branch added pressure on the registers and increased stack allocation, causing additional fixed cost on encoding of small payloads - in particular the 1-11 byte range.

The solution was to split the new branch in its own function - which still left a bit of degradation on small payloads (1-2%) for the extra branch.

I chose to integrate another PR I wanted to submit later with this one to make sure we improve all cases, and so I added size-based custom handling of tails. The net effect provides significant improvement on all small payloads sizes.

This improvement - although it primarily targets the scalar implementation - also benefits SIMD implementations that delegate the small tails (ex. < 28 bytes) to scalar handling. So these improvements are particularly beneficial to SIMD implementations when handling 1-256 bytes.

For encoding, I tested AVX2, but I expect similar results across the board for SIMD:

Encoded payload size Tail AVX2 master → improved AVX2 gain Scalar master → improved Scalar gain
0 B 0 B 3.34 → 3.45 ns −3.1% 1.83 → 2.08 ns −12.1%
1–11 B 1–11 B 3.92–7.69 → 3.55–5.57 ns +27.4% 2.36–6.11 → 2.11–3.99 ns +35.3%
12–27 B 12–27 B 7.77–13.70 → 6.54–9.52 ns +32.4% 6.33–11.82 → 5.22–7.83 ns +34.2%
28–35 B 4–11 B 6.37–8.87 → 5.47–6.83 ns +22.6% 12.47–14.80 → 7.77–9.24 ns +57.1%
36–51 B 12–27 B 9.14–14.72 → 8.26–10.86 ns +24.0% 14.90–20.19 → 9.25–11.84 ns +66.6%
52 B 4 B 7.21 → 6.38 ns +13.0% 20.55 → 12.15 ns +69.2%
1016 B 8 B 40.16 → 38.90 ns +3.2% 399.34 → 171.73 ns +132.5%
1024 B 16 B 43.38 → 42.44 ns +2.2% 399.03 → 174.27 ns +129.0%
1032 B 24 B 45.42 → 43.96 ns +3.3% 396.98 → 169.91 ns +133.6%
2048 B 8 B 75.61 → 75.87 ns −0.3% 801.54 → 343.06 ns +133.6%
2056 B 16 B 77.32 → 77.48 ns −0.2% 773.35 → 337.97 ns +128.8%
2064 B 24 B 78.96 → 76.39 ns +3.4% 784.29 → 340.40 ns +130.4%
1 MiB + 8 24 B 37.59 → 38.09 µs −1.3% 402.03 → 178.70 µs +125.0%
1 MiB + 16 8 B 37.71 → 38.32 µs −1.6% 404.95 → 179.39 µs +125.7%
1 MiB + 24 16 B 38.03 → 38.42 µs −1.0% 405.26 → 183.52 µs +120.8%
2 MiB + 8 16 B 95.09 → 94.70 µs +0.4% 811.85 → 358.85 µs +126.2%
2 MiB + 16 24 B 95.34 → 94.03 µs +1.4% 808.60 → 347.97 µs +132.4%
2 MiB + 24 8 B 94.94 → 93.80 µs +1.2% 800.92 → 364.03 µs +120.0%

Similarly, for decoding, AVX2 degrades slightly (2ns) in the 1-8B because of the new branch, but then improves well in the 9-47 B range and the 57-95 B where we have 1 or 2 AVX2 blocks and enough tail bytes to follow the new branch. Meanwhile, pure scalar decoding is improved across the board.

Decoded payload Tail AVX2 master → improved AVX2 gain Scalar master → improved Scalar gain
1–8 B Short scalar fallback 12.66–14.48 → 12.84–15.12 ns −2.0% 8.60–11.17 → 8.86–11.67 ns −2.5%
9–47 B Improved scalar loop 13.54–24.44 → 12.98–20.35 ns +10.8% 10.52–21.97 → 9.95–18.41 ns +13.9%
48–56 B AVX2 block + 0–8 B tail 12.12–21.19 → 11.63–20.48 ns +2.1% 20.96–24.28 → 17.65–20.89 ns +16.5%
57–95 B AVX2 block + 9–47 B tail 19.03–27.27 → 16.97–25.43 ns +8.1% 23.35–34.77 → 20.06–27.42 ns +23.3%
96 B Two AVX2 blocks 12.40 → 12.92 ns −4.0% +37.8%
1016 B 8 B tail 61.78 → 63.33 ns −2.5% 279.91 → 198.40 ns +41.1%
1024 B 16 B tail 63.39 → 65.35 ns −3.0% 272.07 → 207.48 ns +31.1%
1032 B 24 B tail 65.54 → 64.11 ns +2.2% 274.58 → 203.62 ns +34.9%
2024 B 8 B tail 103.77 → 106.33 ns −2.4% 541.12 → 382.73 ns +41.4%
2032 B 16 B tail 103.79 → 106.33 ns −2.4% 532.03 → 393.10 ns +35.3%
2040 B 24 B tail 108.21 → 106.58 ns +1.5% 540.57 → 392.62 ns +37.7%
1 MiB − 8 8 B tail 44.75 → 45.05 µs −0.6% 263.65 → 201.17 µs +31.1%
1 MiB 16 B tail 45.34 → 45.36 µs 0.0% 263.59 → 201.11 µs +31.1%
1 MiB + 8 24 B tail 44.75 → 45.43 µs −1.5% 266.19 → 202.69 µs +31.3%
2 MiB − 24 8 B tail 100.84 → 99.70 µs +1.1% 526.91 → 407.73 µs +29.2%
2 MiB − 16 16 B tail 100.74 → 100.08 µs +0.7% 541.39 → 402.48 µs +34.5%
2 MiB − 8 24 B tail 99.93 → 100.23 µs −0.3% 528.84 → 403.47 µs +31.1%

(The +2% / -2% in the >1KiB is likely to be noise here - it is not supported by the changes being submitted).


I will be providing a third PR that will stack over this one and address the cliffs you have also been observing with SIMD implementations, there tail degrades until we reach the magic multiples.

@gaspardpetit gaspardpetit changed the title Improve performance of scalar base64 decoder and encoder base64 scalar - Improve performance of scalar base64 decoder and encoder Sep 18, 2026
@gaspardpetit

gaspardpetit commented Sep 19, 2026

Copy link
Copy Markdown
Contributor Author

I would hold this PR for now; a side effect here is that we improve the tail handling in SIMD implementations, but while performance improved for tails of >= 12 bytes, it degraded slightly for smaller ones. It turns out the extra setup increases stack allocation and prevents some register optimizations that were previously possible. I'll provide a better solution soon.

@gaspardpetit
gaspardpetit marked this pull request as draft September 19, 2026 01:56
lemire and others added 6 commits September 18, 2026 23:17
…B in the alphabet lookup (simdutf#1038)

Port of the lookup-index simplification from simdutf#1036 (AVX2) to the SSE
encoder. Saturating-subtract 51, then subtract the (input > 25) mask
(which is -1), so that the LUT index becomes 0 for 'A'..'Z', 1 for
'a'..'z', 2..11 for digits, 12 and 13 for the two symbols. Saves one
instruction per 16 output bytes.
… cheaper alphabet lookup (simdutf#1039)

- binary_to_base64_with_lines was falling back to the scalar code on both
  lsx and lasx; it now uses the vectorized encoders with the same
  line-feed insertion scheme as the westmere/haswell kernels.
- lasx: groups 1..3 of each 96-byte block use a single overlapping
  32-byte load instead of two 16-byte loads plus xvpermi.q (as in simdutf#1036).
- lsx/lasx: vshuf.b/xvshuf.b only use the low five bits of the index, so
  the second table shuffle no longer subtracts 32 from the indices.
…rlap operations (simdutf#1036)

* replace "compare + AND + OR" with "compare + SUB" / overlap operations

* Applied clang-format 18.1.8
@gaspardpetit

gaspardpetit commented Sep 19, 2026

Copy link
Copy Markdown
Contributor Author

PR is ready for review. The benefits are clear for the scalar-only implementation, and for SIMD, the extra branch cost is offset when combined with #1041

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants