Visitar URL original
gh-124951: Optimize base64 encode & decode for an easy 2-3x speedup [no SIMD] by gpshead · Pull Request #143262 · python/cpython · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
Add base64 benchmark tool and optimize encoding/decoding
Add Tools/binasciibench/binasciibench.py benchmark for measuring base64
encoding/decoding throughput.

Optimize base64 encoding/decoding by eliminating loop-carried dependencies.
Key changes:
- Add base64_encode_trio() and base64_decode_quad() helper functions
  that process complete groups independently
- Add base64_encode_fast() and base64_decode_fast() wrappers
- Update b2a_base64 and a2b_base64 to use fast path for complete groups

Performance gains (encode/decode speedup vs main, PGO builds):

             64 bytes    64K        1M
  Zen2:      1.1x/1.6x   1.6x/2.4x  1.4x/2.4x
  Zen4:      1.2x/1.7x   1.6x/3.0x  1.5x/3.0x
  M4:        1.3x/1.9x   2.3x/2.8x  2.4x/2.9x
  RPi5-32:   1.4x/1.4x   2.4x/2.0x  2.0x/1.9x

Additional SIMD implementations (NEON, AVX-512 VBMI) can achieve
+50% to +1500% further gains and are planned for follow-on work.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
  • Loading branch information
gpshead and claude committed Dec 29, 2025
commit 2d2be302a54131fe6ad9c02eb91be020e7ca1c42
155 changes: 135 additions & 20 deletions Modules/binascii.c
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,103 @@
/* Max binary chunk size; limited only by available memory */
#define BASE64_MAXBIN ((PY_SSIZE_T_MAX - 3) / 2)

/*
* Base64 encoding/decoding helpers optimized for throughput.
*
* Key optimization: Process complete groups (3 bytes -> 4 chars for encode,
* 4 chars -> 3 bytes for decode) without loop-carried dependencies.
* This allows the compiler to better optimize the hot loops.
*/

/* Forward declaration for table defined after the inline functions */
static const unsigned char table_b2a_base64[];

Check failure on line 113 in Modules/binascii.c

View workflow job for this annotation

GitHub Actions / Windows / Build and test (x64)

'table_b2a_base64': unknown size [D:\a\cpython\cpython\PCbuild\pythoncore.vcxproj]

Check failure on line 113 in Modules/binascii.c

View workflow job for this annotation

GitHub Actions / Windows (free-threading) / Build and test (x64)

'table_b2a_base64': unknown size [D:\a\cpython\cpython\PCbuild\pythoncore.vcxproj]

Check failure on line 113 in Modules/binascii.c

View workflow job for this annotation

GitHub Actions / Windows (free-threading) / Build and test (arm64)

'table_b2a_base64': unknown size [C:\a\cpython\cpython\PCbuild\pythoncore.vcxproj]

Check failure on line 113 in Modules/binascii.c

View workflow job for this annotation

GitHub Actions / Windows / Build and test (arm64)

'table_b2a_base64': unknown size [C:\a\cpython\cpython\PCbuild\pythoncore.vcxproj]

/* Encode 3 bytes into 4 base64 characters. */
static inline void
base64_encode_trio(const unsigned char *in, unsigned char *out,
const unsigned char *table)
{
unsigned int combined = ((unsigned int)in[0] << 16) |
((unsigned int)in[1] << 8) |
(unsigned int)in[2];
out[0] = table[(combined >> 18) & 0x3f];
out[1] = table[(combined >> 12) & 0x3f];
out[2] = table[(combined >> 6) & 0x3f];
out[3] = table[combined & 0x3f];
}

/* Encode multiple complete 3-byte groups.
* Returns the number of input bytes processed (always a multiple of 3).
*/
static inline Py_ssize_t
base64_encode_fast(const unsigned char *in, Py_ssize_t in_len,
unsigned char *out, const unsigned char *table)
{
Py_ssize_t n_trios = in_len / 3;
Py_ssize_t i;

/* Process complete 3-byte groups. Each iteration is independent. */
for (i = 0; i < n_trios; i++) {
base64_encode_trio(in + i * 3, out + i * 4, table);
Comment thread
gpshead marked this conversation as resolved.
Outdated
}

return n_trios * 3;
}

/* Decode 4 base64 characters into 3 bytes.
* Returns 1 on success, 0 if any character is invalid.
*/
static inline int
base64_decode_quad(const unsigned char *in, unsigned char *out,
const unsigned char *table)
{
unsigned char v0 = table[in[0]];
unsigned char v1 = table[in[1]];
unsigned char v2 = table[in[2]];
unsigned char v3 = table[in[3]];

if ((v0 | v1 | v2 | v3) & 0xc0) {
return 0;
}

out[0] = (v0 << 2) | (v1 >> 4);
out[1] = (v1 << 4) | (v2 >> 2);
out[2] = (v2 << 6) | v3;
return 1;
}

/* Decode multiple complete 4-character groups (no padding allowed).
* Returns the number of input characters processed.
* Stops at the first invalid character, padding, or incomplete group.
*/
static inline Py_ssize_t
base64_decode_fast(const unsigned char *in, Py_ssize_t in_len,
unsigned char *out, const unsigned char *table)
{
Py_ssize_t n_quads = in_len / 4;
Py_ssize_t i;

/* Process complete 4-character groups. Each iteration is mostly independent. */
for (i = 0; i < n_quads; i++) {
const unsigned char *inp = in + i * 4;

/* Check for padding - exit fast path to handle it properly.
* Four independent comparisons lets the compiler choose the optimal
* approach; on modern pipelined CPUs this is faster than bitmask tricks
* like XOR+SUB+AND for zero-detection which have data dependencies.
*/
if (inp[0] == '=' || inp[1] == '=' || inp[2] == '=' || inp[3] == '=') {
break;
}

if (!base64_decode_quad(inp, out + i * 3, table)) {
break;
}
}

return i * 4;
}

static const unsigned char table_b2a_base64[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";

Expand Down Expand Up @@ -403,10 +500,26 @@
goto error_end;
}

size_t i = 0; /* Current position in input */

/* Fast path: use optimized decoder for complete quads.
* This works for both strict and non-strict mode for valid input.
* The fast path stops at padding, invalid chars, or incomplete groups.
*/
if (ascii_len >= 4) {
Py_ssize_t fast_chars = base64_decode_fast(ascii_data, (Py_ssize_t)ascii_len,
bin_data, table_a2b_base64);
if (fast_chars > 0) {
Comment thread
gpshead marked this conversation as resolved.
i = (size_t)fast_chars;
bin_data += (fast_chars / 4) * 3;
}
}

/* Slow path: handle remaining input (padding, invalid chars, partial groups) */
int quad_pos = 0;
unsigned char leftchar = 0;
int pads = 0;
for (size_t i = 0; i < ascii_len; i++) {
for (; i < ascii_len; i++) {
unsigned char this_ch = ascii_data[i];

/* Check for pad sequences and ignore
Expand Down Expand Up @@ -533,9 +646,6 @@
/*[clinic end generated code: output=4ad62c8e8485d3b3 input=0e20ff59c5f2e3e1]*/
{
const unsigned char *bin_data;
int leftbits = 0;
unsigned char this_ch;
unsigned int leftchar = 0;
Py_ssize_t bin_len;
binascii_state *state;

Expand Down Expand Up @@ -566,26 +676,31 @@
}
unsigned char *ascii_data = PyBytesWriter_GetData(writer);

for( ; bin_len > 0 ; bin_len--, bin_data++ ) {
/* Shift the data into our buffer */
leftchar = (leftchar << 8) | *bin_data;
leftbits += 8;

/* See if there are 6-bit groups ready */
while ( leftbits >= 6 ) {
this_ch = (leftchar >> (leftbits-6)) & 0x3f;
leftbits -= 6;
*ascii_data++ = table_b2a_base64[this_ch];
}
}
if ( leftbits == 2 ) {
*ascii_data++ = table_b2a_base64[(leftchar&3) << 4];
/* Use the optimized fast path for complete 3-byte groups */
Py_ssize_t fast_bytes = base64_encode_fast(bin_data, bin_len, ascii_data,
table_b2a_base64);
bin_data += fast_bytes;
ascii_data += (fast_bytes / 3) * 4;
Comment thread
gpshead marked this conversation as resolved.
bin_len -= fast_bytes;

/* Handle remaining 0-2 bytes */
if (bin_len == 1) {
/* 1 byte remaining: produces 2 base64 chars + 2 padding */
unsigned int val = bin_data[0];
*ascii_data++ = table_b2a_base64[(val >> 2) & 0x3f];
*ascii_data++ = table_b2a_base64[(val << 4) & 0x3f];
*ascii_data++ = BASE64_PAD;
*ascii_data++ = BASE64_PAD;
} else if ( leftbits == 4 ) {
*ascii_data++ = table_b2a_base64[(leftchar&0xf) << 2];
}
else if (bin_len == 2) {
/* 2 bytes remaining: produces 3 base64 chars + 1 padding */
unsigned int val = ((unsigned int)bin_data[0] << 8) | bin_data[1];
*ascii_data++ = table_b2a_base64[(val >> 10) & 0x3f];
*ascii_data++ = table_b2a_base64[(val >> 4) & 0x3f];
*ascii_data++ = table_b2a_base64[(val << 2) & 0x3f];
*ascii_data++ = BASE64_PAD;
}

if (newline)
*ascii_data++ = '\n'; /* Append a courtesy newline */

Expand Down
193 changes: 193 additions & 0 deletions Tools/binasciibench/binasciibench.py
Comment thread
gpshead marked this conversation as resolved.
Outdated
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
#!/usr/bin/env python3
"""Benchmark for binascii base64 encoding and decoding performance.

This benchmark measures the throughput of base64 encoding and decoding
operations using the binascii module's C implementation.

Usage:
python Tools/binasciibench/binasciibench.py [--sizes S1,S2,...]

Each benchmark runs for ~1.5 seconds to ensure accurate measurements.
"""

import argparse
import binascii
import os
import statistics
import sys
import time

# Default test parameters
DEFAULT_SIZES = [64, 1024, 65536, 1048576]

# Timing targets
TARGET_TOTAL_TIME_S = 1.5 # Target ~1.5 seconds total per benchmark
MIN_ITERATIONS = 5 # Minimum iterations for statistical significance
MIN_OPS_PER_ITER = 10 # Minimum operations per iteration


def generate_test_data(size):
"""Generate random binary data of the specified size."""
return os.urandom(size)


def generate_base64_data(size):
"""Generate valid base64-encoded data of approximately the specified decoded size."""
binary = os.urandom(size)
return binascii.b2a_base64(binary, newline=False)


def benchmark_encode(data, num_ops):
"""Benchmark base64 encoding."""
b2a = binascii.b2a_base64
start = time.perf_counter_ns()
for _ in range(num_ops):
b2a(data, newline=False)
end = time.perf_counter_ns()
return end - start


def benchmark_decode(data, num_ops):
"""Benchmark base64 decoding."""
a2b = binascii.a2b_base64
start = time.perf_counter_ns()
for _ in range(num_ops):
a2b(data)
end = time.perf_counter_ns()
return end - start


def calibrate_and_run(bench_func, data, target_total_s):
"""Calibrate and run benchmark to achieve target total time.

Returns (times_ns, num_ops) where times_ns is a list of per-iteration
timings and num_ops is the number of operations per iteration.
"""
# Quick calibration: measure time for a small batch
num_ops = MIN_OPS_PER_ITER
elapsed_ns = bench_func(data, num_ops)
time_per_op_ns = elapsed_ns / num_ops

# Calculate ops and iterations to hit target total time
# We want: iterations * num_ops * time_per_op = target_total
# With constraint: iterations >= MIN_ITERATIONS
target_ns = target_total_s * 1_000_000_000

# Start with minimum iterations, calculate required ops
iterations = MIN_ITERATIONS
total_ops_needed = int(target_ns / time_per_op_ns)
num_ops = max(MIN_OPS_PER_ITER, total_ops_needed // iterations)

# If num_ops would be huge, increase iterations instead
max_ops_per_iter = 1_000_000
if num_ops > max_ops_per_iter:
num_ops = max_ops_per_iter
iterations = max(MIN_ITERATIONS, total_ops_needed // num_ops)

# Warmup
bench_func(data, num_ops)

# Timed runs
times_ns = []
for _ in range(iterations):
elapsed_ns = bench_func(data, num_ops)
times_ns.append(elapsed_ns)

return times_ns, num_ops


def format_throughput(bytes_per_second):
"""Format throughput in human-readable units."""
if bytes_per_second >= 1_000_000_000:
return f"{bytes_per_second / 1_000_000_000:.2f} GB/s"
elif bytes_per_second >= 1_000_000:
return f"{bytes_per_second / 1_000_000:.2f} MB/s"
elif bytes_per_second >= 1_000:
return f"{bytes_per_second / 1_000:.2f} KB/s"
else:
return f"{bytes_per_second:.2f} B/s"


def format_size(size):
"""Format size in human-readable units."""
if size >= 1_048_576:
return f"{size // 1_048_576}M"
elif size >= 1024:
return f"{size // 1024}K"
else:
return str(size)


def print_results(name, size, times_ns, num_ops, data_size):
"""Print benchmark results."""
# Calculate statistics
times_per_op_ns = [t / num_ops for t in times_ns]
mean_ns = statistics.mean(times_per_op_ns)
stdev_ns = statistics.stdev(times_per_op_ns) if len(times_per_op_ns) > 1 else 0

# Calculate throughput
bytes_per_ns = data_size / mean_ns
bytes_per_second = bytes_per_ns * 1_000_000_000
throughput = format_throughput(bytes_per_second)

# Calculate coefficient of variation
cv = (stdev_ns / mean_ns * 100) if mean_ns > 0 else 0

size_str = format_size(size)
print(f"{name:<20} {size_str:>8} {mean_ns:>12.1f} ns "
f"(+/- {cv:>5.1f}%) {throughput:>12}")


def run_all_benchmarks(sizes):
"""Run all benchmark variants for all sizes."""
print(f"binascii base64 benchmark")
print(f"Python: {sys.version}")
print(f"Target time per benchmark: {TARGET_TOTAL_TIME_S}s")
print()
print(f"{'Benchmark':<20} {'Size':>8} {'Time/op':>15} "
f"{'Variance':>10} {'Throughput':>12}")
print("-" * 75)

for size in sizes:
# Generate test data
binary_data = generate_test_data(size)
base64_data = generate_base64_data(size)

# Benchmark encode
times, num_ops = calibrate_and_run(benchmark_encode, binary_data,
TARGET_TOTAL_TIME_S)
print_results("b2a_base64", size, times, num_ops, size)

# Benchmark decode
times, num_ops = calibrate_and_run(benchmark_decode, base64_data,
TARGET_TOTAL_TIME_S)
print_results("a2b_base64", size, times, num_ops, size)

print()


def main():
parser = argparse.ArgumentParser(
description="Benchmark binascii base64 encoding and decoding",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__
)
parser.add_argument(
"-s", "--sizes",
type=str,
default=None,
Comment thread
gpshead marked this conversation as resolved.
Outdated
help="Comma-separated list of sizes to test (e.g., '64,256,1024')"
)

args = parser.parse_args()

if args.sizes:
sizes = [int(s.strip()) for s in args.sizes.split(",")]
else:
sizes = DEFAULT_SIZES

run_all_benchmarks(sizes)


if __name__ == "__main__":
main()
Loading