Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
6cbc5ef
implement core error trait for the error types
crypt0miester Aug 22, 2026
552ea3e
return u8 from the fixed width encoders
crypt0miester Aug 22, 2026
3827c50
fold whole blocks in the any-length codec
crypt0miester Aug 22, 2026
445970c
update readme and benchmarks for the fold
crypt0miester Aug 22, 2026
c97e2dd
point repository url at spool-labs base58
crypt0miester Aug 22, 2026
4ab90c0
import encoded length constants in the backends
crypt0miester Aug 22, 2026
892c127
size decoded_len for runs of leading ones
crypt0miester Aug 22, 2026
88f4fc1
reject decodings past the variable length limit
crypt0miester Aug 22, 2026
32b1fe9
run fmt clippy and tests in ci
crypt0miester Aug 22, 2026
2aa6f01
add fuzz targets for decode and roundtrip
crypt0miester Aug 22, 2026
05a90e6
fold each block in place over a window
crypt0miester Aug 22, 2026
7f21924
record the in place fold numbers
crypt0miester Aug 22, 2026
8b2f265
lay decoded bytes out without a staging buffer
crypt0miester Aug 22, 2026
15d1950
add an alloc feature for longer input
crypt0miester Aug 22, 2026
b8f1ceb
record the zen 5 and long input numbers
crypt0miester Aug 22, 2026
0a8ff40
sum fold columns eight to a register
crypt0miester Aug 22, 2026
de2dc1e
pin a path when fuzzing on x86
crypt0miester Aug 23, 2026
fc1a76e
keep clippy and the msrv happy in ci
crypt0miester Aug 23, 2026
86517bc
settle the lints the x86 target reaches
crypt0miester Aug 23, 2026
05b592a
bump to 0.2.0 for the breaking changes
crypt0miester Aug 23, 2026
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
87 changes: 87 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
name: ci

on:
push:
branches: [main]
pull_request:

env:
CARGO_TERM_COLOR: always
RUSTFLAGS: -D warnings

jobs:
lint:
name: fmt and clippy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
- run: cargo fmt --check
- run: cargo clippy --all-targets

test:
name: test (${{ matrix.arch }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
arch: x86-64
- os: macos-latest
arch: aarch64
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
# Both profiles: debug catches the overflow checks, release catches what
# only shows up once the optimiser has been through the kernels.
- run: cargo test
- run: cargo test --release
# The benchmarks are a separate workspace and break silently when the
# library's signatures move, so they are typechecked here.
- run: cargo check --benches
working-directory: bench

portable:
name: portable backend
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: thumbv6m-none-eabi
- uses: Swatinem/rust-cache@v2
# No vector units and no std, which is the arm neither runner reaches.
- run: cargo build --lib --target thumbv6m-none-eabi

msrv:
name: msrv
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@1.89
- uses: Swatinem/rust-cache@v2
- run: cargo check --lib

fuzz:
name: fuzz
runs-on: ubuntu-latest
# Nightly carries lints the pinned toolchains do not, and a fuzz run is
# not the place to find out about them.
env:
RUSTFLAGS: ""
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@nightly
- uses: taiki-e/install-action@v2
with:
tool: cargo-fuzz
- uses: Swatinem/rust-cache@v2
# A smoke run, not a campaign. Long runs belong on a box, not on a pull
# request.
- run: cargo fuzz run --target x86_64-unknown-linux-gnu decode -- -max_total_time=60
- run: cargo fuzz run --target x86_64-unknown-linux-gnu roundtrip -- -max_total_time=60
88 changes: 50 additions & 38 deletions BENCHMARKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,13 @@ them nor the dependencies they need.
```sh
cd bench
cargo bench --bench paths # each x86 path, pinned in turn
cargo bench --bench against --features variable # against five8 and bs58
cargo bench --bench variable --features variable # any-length, by size
cargo bench --bench against # against five8 and bs58
cargo bench --bench variable # any-length, by size
cargo bench --bench codec # the dispatched entry points
cargo bench --bench scalar # the portable path alone
```

`TAPE_PATH=portable|avx2|avx512` pins the instruction set for `variable`, and
`TAPE_PATH=portable|avx2|avx512` pins the instruction set, and
`paths` sweeps all three itself. Pinning goes through `testing::force`, which
is `#[doc(hidden)]` and exists for this.

Expand Down Expand Up @@ -367,47 +367,63 @@ than anything about the code.

## Any length

Zen 5, stock flags, `--features variable`, AVX-512 pinned.
Anything that is neither a key nor a signature folds a 64-byte block at a
time. There is no feature flag and no table, only about 1.3 KiB of constants.
Below 128 bytes the value divides down instead.

| bytes | encode | bs58 | | decode | bs58 | |
|---|---|---|---|---|---|---|
| 32 | 20.7 ns | 679 ns | 33× | 67.7 ns | 247 ns | 3.6× |
| 128 | 323 ns | 12.4 µs | 38× | 229 ns | 3.93 µs | 17× |
| 256 | 747 ns | 52.7 µs | 71× | 516 ns | 16.6 µs | 32× |
| 512 | 1.92 µs | 213 µs | 111× | 1.39 µs | 69.3 µs | 50× |
| 1232 | 8.10 µs | 1.23 ms | **152×** | 5.74 µs | 402 µs | **70×** |
M4 Max, stock flags:

1232 bytes is one Solana packet. five8 has no any-length API, so bs58 is the
whole field here — and this is the path a transaction crosses on submission.
| bytes | encode | bs58 | decode | bs58 |
|---|---|---|---|---|
| 128 | 344 ns | 14.8 us | 147 ns | 5.12 us |
| 512 | 1.89 us | 240 us | 1.46 us | 82.3 us |
| 1232 | 7.31 us | 1.41 ms | 7.56 us | 489 us |

Zen 5, stock flags:

| bytes | encode | decode |
|---|---|---|
| 128 | 501 ns | 170 ns |
| 512 | 2.91 us | 1.50 us |
| 1232 | 11.5 us | 8.00 us |

The in place fold is worth 1.32x on encode at a packet on aarch64 and nothing
on x86, where it measures level against the two buffer form it replaced. It
is worth taking anyway for the frame it saves.

### What the feature is worth
Encode reads its columns eight to a register on x86, which is worth a further
1.27x at a packet. The window is widened to 64-bit lanes once a group rather
than on each load, and three earlier kernels lost to the compiler for want of
that. Two sheds in place of the settling were tried on top and measured worse,
so the settling stays as it is.

Zen 5, stock flags, at 1232 bytes.
Past a packet there is no length limit when the crate is built with `alloc`,
which is on by default. The cost is quadratic and measured so: four times the
input is about fifteen times the work.

| | encode | decode |
| bytes | encode | decode |
|---|---|---|
| default (no tables) | 45.9 µs | 21.8 µs |
| `variable`, portable | 16.4 µs | 21.8 µs |
| `variable`, AVX2 | 11.2 µs | 7.35 µs |
| `variable`, AVX-512 | **8.10 µs** | **5.74 µs** |
| 4096 | 127 us | 84.0 us |
| 16384 | 1.86 ms | 1.28 ms |
| 65536 | 29.1 ms | 20.3 ms |

The tables cost about 400 KiB. Two things they do not do:
Programs build with `default-features = false` and keep the stack path, which
tops out at `MAX_VARIABLE_LEN`.

- **Without the vector kernels they make decode slower** than not having them
— the scalar table walk loses to scaling the value directly at every size.
A machine with no AVX2 therefore decodes through the table-free path even
with the feature on.
- **They do nothing at 32 bytes.** Encode delegates to the fixed-width path
there (20.7 ns against 96 before), so the tables never run.
1232 bytes is one Solana packet. five8 has no any-length API, so bs58 is the
whole field here, and this is the path a transaction crosses on submission.

M4, stock flags, `--features variable`:
The fold replaced a walk that took 45.9 us to encode a packet on Zen 5 and a
table path that took 8.10. It beats the walk everywhere and beats the tables
on aarch64. Whether it now beats them on x86 is open. Hand-written x86 kernels
for the fold were tried and lost to what the compiler emits on its own.

| bytes | encode | bs58 | decode | bs58 |
|---|---|---|---|---|
| 32 | 16.9 ns | 1.10 µs | 91.2 ns | 402 ns |
| 128 | 361 ns | 14.3 µs | 310 ns | 5.13 µs |
| 512 | 2.71 µs | 246 µs | 2.24 µs | 85.3 µs |
| 1232 | 16.0 µs | 1.44 ms | 10.9 µs | 501 µs |
The value is folded in place. A column is written over a limb the next
seventeen still need, so a group of thirty-two carries those in a window
rather than the whole value carrying a second buffer: 1900 bytes of frame
against 4344, which is what brings it inside an SBF frame. Settling one column
at a time instead of a group measured 2.2x worse, because a group is what
keeps the multiply running ahead of the reduction.

## On chain

Expand Down Expand Up @@ -474,10 +490,6 @@ Do not reach for `opt-level = "z"` to shrink further. The speed of this
path is its unrolling, and `z` rolls the loops back up: the tape rows rise
about four-fold while the binary only loses a quarter of its bytes.

The `variable` feature does not belong in a program. Its decode overflows
the 4 KB SBF stack frame, and its tables are 420 KB of rent. The fixed
paths never needed it.

## Known gaps

- One machine per class. Both aarch64 parts agree on shape, so the NEON
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 7 additions & 5 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,23 +1,25 @@
[package]
name = "tape-base58"
version = "0.1.0"
version = "0.2.0"
edition = "2021"
rust-version = "1.89"
description = "Fast base58 encoding and decoding for Solana-shaped data"
license = "Apache-2.0"
repository = "https://github.com/tape-public/base58"
repository = "https://github.com/spool-labs/base58"
keywords = ["base58", "solana", "simd", "encoding"]
categories = ["encoding", "no-std"]

exclude = ["BENCHMARKS.md"]
exclude = ["BENCHMARKS.md", ".github"]

[lints.rust.unexpected_cfgs]
level = "warn"
check-cfg = ['cfg(target_os, values("solana"))']

[features]
default = []
variable = []
default = ["alloc"]

# Heap scratch, which lifts the length limit. Off for programs.
alloc = []

[dependencies]

Expand Down
17 changes: 13 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,20 @@ have that problem. The reduction the vector paths share divides every limb at
once and shifts the quotients a lane down, rather than walking them in a chain
of dependent divisions.

## Features
## Any length

`variable` converts input of any length against tables of place values rather
than walking the value down a limb at a time. It costs about 400 KiB of tables
and is worth several times the throughput at transaction sizes.
Input that is neither a key nor a signature is folded rather than walked. A
whole 64-byte block enters the value at once, so the value is reduced once per
limb per block instead of once per limb per word, and the multiply that
replaces the rest has no serial chain in it. It folds in place, against a
window rather than a second buffer. The constants are about 1.3 KiB, not a
table. Decoding runs the same trade the other way, ten characters a pass
into 64-bit words.

Below 128 bytes there is no whole block to fold and the value divides down
instead, which is cheaper while it is short. Above a packet the scratch comes
from the heap, so there is no length limit unless the crate is built without
`alloc`, which is what a program wants.

## Benchmarks

Expand Down
2 changes: 1 addition & 1 deletion bench/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 0 additions & 3 deletions bench/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,6 @@ criterion = { version = "0.5", default-features = false, features = ["cargo_benc
five8 = "1"
tape-base58 = { path = ".." }

[features]
variable = ["tape-base58/variable"]

[[bench]]
name = "against"
harness = false
Expand Down
4 changes: 2 additions & 2 deletions bench/benches/against.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,10 @@ fn noise(seed: u64, into: &mut [u8]) {
fn fixed(criterion: &mut Criterion) {
let signature = signature();
let mut key_out = [0u8; MAX_ENCODED_32];
let key_len = tape_base58::encode_32(&KEY, &mut key_out);
let key_len = tape_base58::encode_32(&KEY, &mut key_out) as usize;
let key_text = key_out[..key_len].to_vec();
let mut signature_out = [0u8; MAX_ENCODED_64];
let signature_len = tape_base58::encode_64(&signature, &mut signature_out);
let signature_len = tape_base58::encode_64(&signature, &mut signature_out) as usize;
let signature_text = signature_out[..signature_len].to_vec();

let mut key_bytes = [0u8; 32];
Expand Down
2 changes: 1 addition & 1 deletion bench/benches/codec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ fn codec(criterion: &mut Criterion) {
let mut group = criterion.benchmark_group("tape-base58");
let signature = signature();
let mut signature_text = [0u8; tape_base58::MAX_ENCODED_64];
let written = tape_base58::encode_64(&signature, &mut signature_text);
let written = tape_base58::encode_64(&signature, &mut signature_text) as usize;

let mut key_out = [0u8; tape_base58::MAX_ENCODED_32];
group.bench_function("encode_32", |bencher| {
Expand Down
4 changes: 2 additions & 2 deletions bench/benches/paths.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,11 +87,11 @@ fn compare(criterion: &mut Criterion) {
let signature = signature();

let mut key_text = [0u8; MAX_ENCODED_32];
let key_len = encode_32(&key, &mut key_text);
let key_len = encode_32(&key, &mut key_text) as usize;
let key_text = &key_text[..key_len];

let mut signature_text = [0u8; MAX_ENCODED_64];
let signature_len = encode_64(&signature, &mut signature_text);
let signature_len = encode_64(&signature, &mut signature_text) as usize;
let signature_text = &signature_text[..signature_len];

let keys = [key; BATCH];
Expand Down
9 changes: 3 additions & 6 deletions bench/benches/variable.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
//! Times the variable length codec, which is what account data and whole
//! transactions go through
//!
//! Build with `--features variable` to time the table path against the
//! same inputs; the entry points are the same either way.
//!
//! Set `TAPE_PATH` to `portable`, `avx2` or `avx512` to pin the instruction
//! set the codec dispatches to, which only `variable-simd` reads. Unset, or
//! wider than the machine can run, and it picks for itself as usual.
//! Set TAPE_PATH to portable, avx2 or avx512 to pin the instruction set the
//! fixed paths dispatch to. Unset, or wider than the machine can run, and it
//! picks for itself.

use criterion::{black_box, criterion_group, criterion_main, Criterion};

Expand Down
4 changes: 4 additions & 0 deletions fuzz/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
target
corpus
artifacts
coverage
Loading
Loading