Skip to content

Commit db3d419

Browse files
committed
perf: map base58 digits to the Bitcoin alphabet without a table
The second output-side gap issue #9 lists. It frames the vector lookup as awkward because a 58-entry table does not fit vpshufb 16-byte window, so it needs a multi-table blend or a range-based mapping. Both were built and measured, and they are far apart. The multi-table blend -- four 16-byte slices of the alphabet, four shuffles blended by digit >> 4 -- is alphabet-agnostic but not worth shipping: at the 44 digits a 32-byte encode emits it lands at 0.98 end to end, because building the four tables costs about what the lookup saves over so few digits. VectorMath.MapBitcoinAlphabet needs no table. The Bitcoin alphabet is six runs of consecutive ASCII, so the character is the digit plus an offset that steps up at five known digits, and each comparison mask ANDed with its weight contributes that weight or nothing. Five compares, five ANDs and five adds per 32 digits, no memory traffic. EmitForward selects it on a typeof check against the alphabet type parameter, which folds at JIT time, so other alphabets keep the table loop. The remainder is finished by one more full-width block ending at the last digit, overlapping what the loop already wrote; mapping a digit is a pure function so rewriting a character with the same character is harmless. That beat both alternatives, measured in one process: 44 digits 256+128+scalar 13.46 ns 256+scalar 11.99 ns overlap 10.41 ns 88 digits 256+128+scalar 16.61 ns 256+scalar 21.27 ns overlap 13.59 ns Worth recording: at 44 digits the remainder is 12, fewer than the 128-bit loop 16, so that loop never runs -- and merely having it in the method still cost 12% of the stage, with error bars of 0.07 ns. Never-taken forward branches are not always free at this method size. Against the scalar table load, UTF-16 destination, launchCount 3: 44 digits 26.75 ns -> 10.41 ns 0.39x 88 digits 53.02 ns -> 13.59 ns 0.26x AlphabetMapTests walks every digit through every lane position at every length from 0 to 100, crossing both vector widths and the overlap, for byte and UTF-16 destinations, plus a guard that the overlapping store never writes past the digits it was given. A 240-second fast-path fuzz against the BigInteger oracle passed with zero mismatches.
1 parent cf8835b commit db3d419

5 files changed

Lines changed: 760 additions & 4 deletions

File tree

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
using System.Runtime.CompilerServices;
2+
using System.Runtime.InteropServices;
3+
using System.Runtime.Intrinsics;
4+
using System.Runtime.Intrinsics.X86;
5+
6+
using BenchmarkDotNet.Attributes;
7+
8+
namespace Base58Encoding.Benchmarks;
9+
10+
// The alphabet map -- firedancer's raw_to_base58 -- in isolation: raw base-58 digits to output
11+
// characters. This is the emit loop of EncodeState, so the destination is UTF-16 char, which is
12+
// what string.Create hands the encoder on the common Encode(data) API.
13+
//
14+
// Ceiling = widen the digits straight into the destination with no alphabet lookup at all. Not a
15+
// correct encoder; it exists to bound how much the whole stage can possibly be worth.
16+
// Scalar = the production per-digit table load.
17+
// Shuffle = alphabet split into four 16-byte tables, four vpshufb lookups blended by digit >> 4.
18+
// Alphabet-agnostic, so it would work for Flickr and Ripple too.
19+
// Range = Bitcoin-specific. Its alphabet is six runs of consecutive ASCII, so the character is
20+
// the digit plus an offset chosen by five comparisons, with no table at all.
21+
[MemoryDiagnoser]
22+
[HideColumns("RatioSD")]
23+
public class AlphabetMapBenchmark
24+
{
25+
// 44 = digits emitted by a 32-byte encode, 88 = by a 64-byte encode.
26+
[Params(44, 88)]
27+
public int DigitCount { get; set; }
28+
29+
private byte[] _digits = default!;
30+
private char[] _destination = default!;
31+
private char[] _expected = default!;
32+
33+
[GlobalSetup]
34+
public void Setup()
35+
{
36+
var rng = new Random(42);
37+
_digits = new byte[DigitCount];
38+
for (int i = 0; i < DigitCount; i++)
39+
{
40+
_digits[i] = (byte)rng.Next(58);
41+
}
42+
43+
_destination = new char[DigitCount];
44+
_expected = new char[DigitCount];
45+
MapScalar(_digits, _expected);
46+
47+
Verify(MapShuffle, nameof(MapShuffle));
48+
Verify(MapRange, nameof(MapRange));
49+
Verify(MapRange256Only, nameof(MapRange256Only));
50+
}
51+
52+
private delegate void Mapper(ReadOnlySpan<byte> digits, Span<char> destination);
53+
54+
private void Verify(Mapper mapper, string name)
55+
{
56+
Array.Clear(_destination);
57+
mapper(_digits, _destination);
58+
if (!_destination.AsSpan().SequenceEqual(_expected))
59+
{
60+
throw new InvalidOperationException($"{name} disagrees with the scalar alphabet map at {DigitCount} digits");
61+
}
62+
}
63+
64+
[Benchmark]
65+
public void Ceiling() => MapNone(_digits, _destination);
66+
67+
[Benchmark(Baseline = true)]
68+
public void Scalar() => MapScalar(_digits, _destination);
69+
70+
[Benchmark]
71+
public void Shuffle() => MapShuffle(_digits, _destination);
72+
73+
[Benchmark]
74+
public void Range() => MapRange(_digits, _destination);
75+
76+
[Benchmark]
77+
public void Range256Only() => MapRange256Only(_digits, _destination);
78+
79+
// ---- arms ----------------------------------------------------------------------------------
80+
81+
// No alphabet at all: the widening store on its own, as a floor for the stage.
82+
private static void MapNone(ReadOnlySpan<byte> digits, Span<char> destination)
83+
{
84+
for (int i = 0; i < digits.Length; i++)
85+
{
86+
destination[i] = (char)digits[i];
87+
}
88+
}
89+
90+
private static void MapScalar(ReadOnlySpan<byte> digits, Span<char> destination)
91+
{
92+
ReadOnlySpan<byte> alphabet = BitcoinAlphabet.Characters;
93+
for (int i = 0; i < digits.Length; i++)
94+
{
95+
destination[i] = (char)alphabet[digits[i]];
96+
}
97+
}
98+
99+
// vpshufb indexes with the low four bits of each lane and zeroes the lane when bit 7 is set, so
100+
// four lookups against four 16-byte slices of the alphabet cover all 58 entries; digit >> 4
101+
// selects which one survives the blend. The table is duplicated into both 128-bit halves
102+
// because vpshufb never crosses the lane boundary.
103+
private static void MapShuffle(ReadOnlySpan<byte> digits, Span<char> destination)
104+
{
105+
ref byte src = ref MemoryMarshal.GetReference(digits);
106+
ref char dst = ref MemoryMarshal.GetReference(destination);
107+
int len = digits.Length;
108+
int i = 0;
109+
110+
if (Avx2.IsSupported && len >= Vector256<byte>.Count)
111+
{
112+
ReadOnlySpan<byte> alphabet = BitcoinAlphabet.Characters;
113+
Vector256<byte> t0 = Vector256.Create(Vector128.Create(alphabet[..16]), Vector128.Create(alphabet[..16]));
114+
Vector256<byte> t1 = Vector256.Create(Vector128.Create(alphabet[16..32]), Vector128.Create(alphabet[16..32]));
115+
Vector256<byte> t2 = Vector256.Create(Vector128.Create(alphabet[32..48]), Vector128.Create(alphabet[32..48]));
116+
117+
Span<byte> tail = stackalloc byte[16];
118+
tail.Clear();
119+
alphabet[48..].CopyTo(tail);
120+
Vector256<byte> t3 = Vector256.Create(Vector128.Create((ReadOnlySpan<byte>)tail), Vector128.Create((ReadOnlySpan<byte>)tail));
121+
122+
Vector256<byte> one = Vector256.Create((byte)1);
123+
Vector256<byte> two = Vector256.Create((byte)2);
124+
Vector256<byte> three = Vector256.Create((byte)3);
125+
126+
int upper = len - Vector256<byte>.Count;
127+
for (; i <= upper; i += Vector256<byte>.Count)
128+
{
129+
Vector256<byte> d = Vector256.LoadUnsafe(ref src, (nuint)i);
130+
Vector256<byte> selector = Vector256.ShiftRightLogical(d.AsUInt16(), 4).AsByte() & Vector256.Create((byte)0x0F);
131+
132+
Vector256<byte> mapped = Avx2.Shuffle(t0, d);
133+
mapped = Avx2.BlendVariable(mapped, Avx2.Shuffle(t1, d), Vector256.Equals(selector, one));
134+
mapped = Avx2.BlendVariable(mapped, Avx2.Shuffle(t2, d), Vector256.Equals(selector, two));
135+
mapped = Avx2.BlendVariable(mapped, Avx2.Shuffle(t3, d), Vector256.Equals(selector, three));
136+
137+
(Vector256<ushort> lo, Vector256<ushort> hi) = Vector256.Widen(mapped);
138+
lo.StoreUnsafe(ref Unsafe.As<char, ushort>(ref dst), (nuint)i);
139+
hi.StoreUnsafe(ref Unsafe.As<char, ushort>(ref dst), (nuint)(i + Vector256<ushort>.Count));
140+
}
141+
}
142+
143+
ReadOnlySpan<byte> table = BitcoinAlphabet.Characters;
144+
for (; i < len; i++)
145+
{
146+
Unsafe.Add(ref dst, i) = (char)table[Unsafe.Add(ref src, i)];
147+
}
148+
}
149+
150+
// The Bitcoin alphabet is '1'-'9', 'A'-'H', 'J'-'N', 'P'-'Z', 'a'-'k', 'm'-'z': six runs of
151+
// consecutive ASCII. So character == digit + 49 + 7*(d>8) + (d>16) + (d>21) + 6*(d>32) + (d>43),
152+
// and each comparison mask ANDed with its weight contributes that weight or nothing. Every digit
153+
// is under 58, so the signed byte compares are safe.
154+
// Calls production directly so the measured arm and the shipped code cannot drift apart.
155+
// Production runs a 256-bit loop, then a 128-bit loop over the remainder, then a scalar tail.
156+
private static void MapRange(ReadOnlySpan<byte> digits, Span<char> destination)
157+
=> VectorMath.MapBitcoinAlphabet(digits, destination);
158+
159+
// Same arithmetic, but the remainder after the 256-bit loop goes straight to the scalar tail
160+
// with no 128-bit pass. A 32-byte encode emits 44 digits, so that remainder is 12 -- the
161+
// 128-bit pass maps eight of them and leaves four. Whether that pays is the question: it has
162+
// to earn back a second loop's worth of setup over eight digits, and the two arms are here
163+
// rather than compared across runs because a four-nanosecond difference is exactly the size
164+
// of the run-to-run drift this machine shows.
165+
private static void MapRange256Only(ReadOnlySpan<byte> digits, Span<char> destination)
166+
{
167+
ref byte src = ref MemoryMarshal.GetReference(digits);
168+
ref char dst = ref MemoryMarshal.GetReference(destination);
169+
int len = digits.Length;
170+
int i = 0;
171+
172+
if (Vector256.IsHardwareAccelerated && len >= Vector256<byte>.Count)
173+
{
174+
for (; i <= len - Vector256<byte>.Count; i += Vector256<byte>.Count)
175+
{
176+
Vector256<sbyte> d = Vector256.LoadUnsafe(ref src, (nuint)i).AsSByte();
177+
178+
Vector256<sbyte> mapped = d + Vector256.Create((sbyte)49)
179+
+ (Vector256.GreaterThan(d, Vector256.Create((sbyte)8)) & Vector256.Create((sbyte)7))
180+
+ (Vector256.GreaterThan(d, Vector256.Create((sbyte)16)) & Vector256.Create((sbyte)1))
181+
+ (Vector256.GreaterThan(d, Vector256.Create((sbyte)21)) & Vector256.Create((sbyte)1))
182+
+ (Vector256.GreaterThan(d, Vector256.Create((sbyte)32)) & Vector256.Create((sbyte)6))
183+
+ (Vector256.GreaterThan(d, Vector256.Create((sbyte)43)) & Vector256.Create((sbyte)1));
184+
185+
(Vector256<ushort> lower, Vector256<ushort> upper) = Vector256.Widen(mapped.AsByte());
186+
lower.StoreUnsafe(ref Unsafe.As<char, ushort>(ref dst), (nuint)i);
187+
upper.StoreUnsafe(ref Unsafe.As<char, ushort>(ref dst), (nuint)(i + Vector256<ushort>.Count));
188+
}
189+
}
190+
191+
ReadOnlySpan<byte> table = BitcoinAlphabet.Characters;
192+
for (; i < len; i++)
193+
{
194+
Unsafe.Add(ref dst, i) = (char)table[Unsafe.Add(ref src, i)];
195+
}
196+
}
197+
}

0 commit comments

Comments
 (0)