Skip to content
Merged
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
74ba40a
refactor: move vector kernels into VectorMath
unsafePtr Jul 27, 2026
9efcd13
perf: use widening 32x32 multiply in vector kernels
unsafePtr Jul 27, 2026
0b82f2a
test: cover VectorMath kernels directly
unsafePtr Jul 27, 2026
210d6d0
test: cover fast-path rejection of values too large for 32/64 bytes
unsafePtr Jul 27, 2026
f64e22d
refactor: fast decode paths return bool, drop misleading Try prefix
unsafePtr Jul 27, 2026
aaa6951
refactor: mark stateless Base58 helpers static
unsafePtr Jul 27, 2026
175c4fb
ci: add arm64 codegen probe for the vector kernels
unsafePtr Jul 27, 2026
b2b9aec
ci: fix arm64 codegen probe filter and fail on empty dump
unsafePtr Jul 27, 2026
36f4555
perf: use uzp1 and umull for the widening multiply on arm64
unsafePtr Jul 27, 2026
d1ef6ef
perf: narrow with xtn instead of uzp1 in the arm64 widening multiply
unsafePtr Jul 27, 2026
dcd9bdc
ci: probe arm64 on neoverse and apple silicon
unsafePtr Jul 27, 2026
cd141ac
ci: add cross-platform benchmark comparison against the branch baseline
unsafePtr Jul 27, 2026
548fb9c
chore: drop the net9 benchmark jobs
unsafePtr Jul 27, 2026
8f70bea
Revert "ci: add cross-platform benchmark comparison against the branc…
unsafePtr Jul 27, 2026
9106039
docs: link the upstream issue and benchmark behind the safe-rewrite note
unsafePtr Aug 6, 2026
059b6d3
perf: hoist the prepend-zero fill out of the fast decode loop
unsafePtr Aug 6, 2026
913b71e
refactor: slice to the fixed lengths in the bitcoin encode fast paths
unsafePtr Aug 7, 2026
32613b3
chore: hold SimpleBase at 5.6.2 pending upstream encoder fix
unsafePtr Aug 7, 2026
82394f5
docs: note the nuget cache mount for the big-endian docker run
unsafePtr Aug 7, 2026
614a0c1
test: run the vector fallback tests by default
unsafePtr Aug 7, 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
119 changes: 119 additions & 0 deletions .github/workflows/arm64-codegen-probe.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
name: arm64 codegen probe

# Diagnostic-only. VectorMath.MultiplyWidening32 falls through to the portable `x * y` on arm64
# (Avx2/Sse2 are both unsupported there), and NEON has no 64-bit vector multiply, so the JIT has to
# synthesise one. The open question is HOW: a vectorised umull-based decomposition, or a scalar
# software fallback. dotnet/runtime#103555 fixed exactly this for x64 and says nothing about arm64.
# This dumps the real arm64 disassembly so the answer comes from the machine, not from reasoning.

on:
push:
branches: [ 'perf/**' ]
workflow_dispatch:

jobs:
probe:
# Two arm64 microarchitectures from different vendors: Ampere/Neoverse-N2 on Linux and Apple
# Silicon on macOS. Both emit an identical 33-instruction sequence for each kernel, so the
# xtn/umull lowering is generic arm64 rather than a Neoverse-specific choice. Worth keeping as a
# pair: the JIT reports "generic ARM64 + SVE" on the Neoverse runner and plain "generic ARM64" on
# Apple, and Vector256 stays inactive on both (the only length gate emitted is cmp #2, the
# Vector128 one) — so even an SVE-capable arm64 host exercises only the Vector128 path.
strategy:
fail-fast: false
matrix:
include:
- runner: ubuntu-24.04-arm
label: neoverse
- runner: macos-26
label: apple-silicon
runs-on: ${{ matrix.runner }}
permissions:
contents: read

steps:
- uses: actions/checkout@v7

- name: Setup .NET
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.0.x

# Branch on the tool rather than on exit status: "lscpu | sed ... || sysctl" silently does nothing
# useful on macOS, because the redirect applies to sed and the pipeline reports sed's status (0),
# so the fallback never runs and the chip model is never printed.
- name: Host
run: |
uname -m
if command -v lscpu >/dev/null 2>&1; then
lscpu | sed -n '1,20p'
else
sysctl -n machdep.cpu.brand_string hw.ncpu
fi

- name: Build
run: |
dotnet restore src/Base58Encoding.slnx
dotnet build src/Base58Encoding.slnx --configuration Release --no-restore

# DOTNET_JitDisasm works on the release runtime since .NET 8. TieredCompilation=0 skips tier-0 so
# the first call is already fully optimised. The filter must be NAMESPACE-QUALIFIED: a bare
# "VectorMath:*" matches nothing, because the JIT names the method
# "Base58Encoding.VectorMath:TensorDot". JitDisasmSummary is captured alongside so that, if the
# filter ever stops matching again, the log shows every method actually compiled.
# The codegen step below filters to *VectorMathTests* to keep the disassembly readable, so on its
# own it proves nothing about the rest of the library. The publish workflow runs the full suite on
# arm64, but only for master and PRs into it — never for perf/** branches. Run it here so an arm64
# lane-ordering bug cannot reach master unnoticed.
- name: Test (full suite, arm64)
run: |
dotnet run --project src/Base58Encoding.Tests/Base58Encoding.Tests.csproj \
--configuration Release --no-build

- name: Dump arm64 codegen for VectorMath
env:
DOTNET_JitDisasm: 'Base58Encoding.VectorMath:*'
DOTNET_JitDisasmSummary: '1'
DOTNET_TieredCompilation: '0'
run: |
dotnet run --project src/Base58Encoding.Tests/Base58Encoding.Tests.csproj \
--configuration Release --no-build -- -method "*VectorMathTests*" \
> arm64-codegen.txt 2>&1 || true
echo "----- captured $(wc -l < arm64-codegen.txt) lines -----"
echo "----- methods the JIT compiled from VectorMath -----"
grep -E 'JIT compiled Base58Encoding\.VectorMath' arm64-codegen.txt || echo "(none - were they inlined?)"

- name: Verdict
run: |
set -u
listings=$(grep -cE '^; Assembly listing for method' arm64-codegen.txt || true)
echo "assembly listings captured: $listings"
if [ "$listings" -eq 0 ]; then
echo "::error::No disassembly captured - the JitDisasm filter matched nothing, so the census below would be meaningless."
exit 1
fi

echo
echo "=== method headers ==="
grep -E '^; Assembly listing for method|^; Emitting|Total bytes of code' arm64-codegen.txt

echo
echo "=== instruction census (what the 64-bit multiply lowered to) ==="
for m in umull umull2 uzp1 uzp2 mul shl ushr usra add bl blr; do
printf '%-8s %s\n' "$m" "$(grep -cE "^[[:space:]]+$m( |$)" arm64-codegen.txt || true)"
done

echo
echo "=== calls out of the kernels (scalar software fallback would show here) ==="
grep -nE '^[[:space:]]+(bl|blr)( |$)' arm64-codegen.txt || echo "no calls - not scalarising"

echo
echo "=== every instruction in the two kernels ==="
sed -n '/^; Assembly listing for method Base58Encoding.VectorMath/,/^; Total bytes of code/p' arm64-codegen.txt

- name: Upload full dump
if: always()
uses: actions/upload-artifact@v7
with:
name: arm64-codegen-${{ matrix.label }}
path: arm64-codegen.txt
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@

namespace Base58Encoding.Benchmarks;

[SimpleJob(RuntimeMoniker.Net90)]
[SimpleJob(RuntimeMoniker.Net10_0)]
[DisassemblyDiagnoser(exportCombinedDisassemblyReport: true)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

namespace Base58Encoding.Benchmarks;

[SimpleJob(RuntimeMoniker.Net90)]
[SimpleJob(RuntimeMoniker.Net10_0)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
[MemoryDiagnoser]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@

namespace Base58Encoding.Benchmarks;

[SimpleJob(RuntimeMoniker.Net90)]
[SimpleJob(RuntimeMoniker.Net10_0)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
[DisassemblyDiagnoser]
Expand Down
74 changes: 74 additions & 0 deletions src/Base58Encoding.Tests/Base58DecodeFast.cs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,80 @@ public void Decode32ast_WithInvalidInput_ReturnsNull(string input)
Assert.Throws<ArgumentException>(() => Base58.DecodeBitcoin32Fast(input));
}

// The fast paths are chosen purely by encoded LENGTH, but the base58 length ranges are wider than
// the byte counts they map to: 88 characters hold up to 58^88-1 (~2^515.5) while 64 bytes hold
// 2^512-1, and 44 characters hold ~2^257.8 while 32 bytes hold 2^256-1. So a perfectly well-formed
// string of fast-path length can still encode a value that does not fit, and the fast decoders must
// reject it (via the `binary[0] > 0xFFFFFFFF` carry check) so the caller falls back to the generic
// decoder. Without that check Decode would silently return the wrong byte count for roughly 93% of
// 88-character and 72% of 44-character inputs.

// An over-large input can be rejected by either of two guards, depending on the value:
// * the carry guard, `binary[0] > 0xFFFFFFFF`
// * the leading-zero cross-check, `outputLeadingZeros != inputLeadingOnes`
// 2^512 exactly trips the SECOND one — its top limb is 2^32, whose low 32 bits are zero, so every
// limb looks like a leading zero byte. To exercise the carry guard the top limb's low 32 bits must
// be large, hence the 0xFF in the second byte below.
[Fact]
public void Decode64Fast_RejectsValueTooLargeFor64Bytes()
{
var largestValid = new byte[64];
Array.Fill(largestValid, (byte)0xFF); // 2^512 - 1, the largest 64-byte value

var tooLarge = new byte[65];
tooLarge[0] = 0x01;
tooLarge[1] = 0xFF; // 2^512 + 0xFF * 2^504 — needs 65 bytes, still encodes to 88 chars

string validEncoded = Base58.Bitcoin.Encode(largestValid);
string tooLargeEncoded = Base58.Bitcoin.Encode(tooLarge);
Assert.Equal(88, validEncoded.Length);
Assert.Equal(88, tooLargeEncoded.Length); // same length, different byte count

Assert.Equal(largestValid, Base58.DecodeBitcoin64Fast(validEncoded));
Assert.Null(Base58.DecodeBitcoin64Fast(tooLargeEncoded));

// The public API must fall back to the generic decoder and return all 65 bytes.
Assert.Equal(tooLarge, Base58.Bitcoin.Decode(tooLargeEncoded));
}

[Fact]
public void Decode32Fast_RejectsValueTooLargeFor32Bytes()
{
var largestValid = new byte[32];
Array.Fill(largestValid, (byte)0xFF); // 2^256 - 1

var tooLarge = new byte[33];
tooLarge[0] = 0x01;
tooLarge[1] = 0xFF; // 2^256 + 0xFF * 2^248

string validEncoded = Base58.Bitcoin.Encode(largestValid);
string tooLargeEncoded = Base58.Bitcoin.Encode(tooLarge);
Assert.Equal(44, validEncoded.Length);
Assert.Equal(44, tooLargeEncoded.Length);

Assert.Equal(largestValid, Base58.DecodeBitcoin32Fast(validEncoded));
Assert.Null(Base58.DecodeBitcoin32Fast(tooLargeEncoded));

Assert.Equal(tooLarge, Base58.Bitcoin.Decode(tooLargeEncoded));
}

// The extreme case: every digit at its maximum. Asserts the decoded VALUE, not just the length, so
// a fallback that returned the right size but wrong bytes would still fail.
[Theory]
[InlineData(88, 65)] // 58^88 - 1 needs 65 bytes
[InlineData(44, 33)] // 58^44 - 1 needs 33 bytes
public void Decode_AllMaxDigitsAtFastPathLength_FallsBackToGeneric(int chars, int expectedBytes)
{
string maxAtLength = new('z', chars); // 'z' is digit 57, so this is 58^chars - 1

byte[] decoded = Base58.Bitcoin.Decode(maxAtLength);

Assert.Equal(expectedBytes, decoded.Length);
Assert.Equal(
System.Numerics.BigInteger.Pow(58, chars) - 1,
new System.Numerics.BigInteger(decoded, isUnsigned: true, isBigEndian: true));
}

[Fact]
public void Decode32Fast_WithLeadingOnes_HandlesCorrectly()
{
Expand Down
5 changes: 3 additions & 2 deletions src/Base58Encoding.Tests/BigEndianTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ public BigEndianTests(ITestOutputHelper output)
}

/// <summary>
/// Alternatively can be run manually to verify compatibility on big-endian systems using below command
/// docker run -it --rm --platform linux/s390x -v ProjectPath/src:/src registry.access.redhat.com/dotnet/sdk:10.0 sh -c "cd /src/Base58Encoding.Tests && dotnet run"
/// Alternatively can be run manually to verify compatibility on big-endian systems using below command.
/// Mount the NuGet cache too: restore over the network sometimes hangs under s390x emulation.
/// docker run -it --rm --platform linux/s390x -v ProjectPath/src:/src -v HomeDir/.nuget/packages:/nuget -e NUGET_PACKAGES=/nuget registry.access.redhat.com/dotnet/sdk:10.0 sh -c "cd /src/Base58Encoding.Tests &amp;&amp; dotnet run"
/// </summary>
[Fact(Skip = "For local usage only")]
public void Base58Encoding_Works_On_BigEndian_ViaProcess()
Expand Down
12 changes: 5 additions & 7 deletions src/Base58Encoding.Tests/SimpleBaseFuzzTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,9 @@ namespace Base58Encoding.Tests;
// to run just one (e.g. -method "*Bitcoin_32And64*").
//
// Ground truth is a BigInteger oracle (the literal definition of Base58), so the fuzz validates our
// code without trusting any third party. We also cross-check our ENCODER against SimpleBase's, but
// intentionally do NOT assert SimpleBase.Decode(ours): SimpleBase's decoder (5.6.0 and 5.6.2) drops
// the most-significant byte on some larger inputs (verified: our encoding matches the oracle and the
// Python base58 library, both of which decode it correctly). Reported: ssg/SimpleBase#83
// (https://github.com/ssg/SimpleBase/issues/83).
// code without trusting any third party. We also cross-check our encoder against SimpleBase's, but
// not SimpleBase.Decode(ours): its 5.6.2 decoder drops the most-significant byte on some lengths
// (ssg/SimpleBase#83, fixed in 5.6.3 — which we cannot take yet, see Directory.Packages.props).
public class SimpleBaseFuzzTests
{
private const string Alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
Expand Down Expand Up @@ -74,7 +72,7 @@ public void EncodeDecode_MatchesOracleAndSimpleBase_UnderRandomInput()
_output.WriteLine($"Fuzz OK: {iterations:N0} iterations in {sw.Elapsed.TotalSeconds:F0}s, max input {maxLen} bytes, zero mismatches.");
}

// Focused fuzz on the Bitcoin 32- and 64-byte fast paths (TryDecodeBitcoin{32,64}Fast and the
// Focused fuzz on the Bitcoin 32- and 64-byte fast paths (DecodeBitcoin{32,64}Fast and the
// SIMD encode). Only 32/64-byte inputs; the MSB is kept non-zero most of the time so the encoding
// lands in the fast-path length window (43-44 / 87-88 chars) and Decode takes the fast path.
// Exercises the string and byte-span overloads of both Encode and Decode against the oracle.
Expand Down Expand Up @@ -104,7 +102,7 @@ public void Bitcoin_32And64_FastPaths_MatchOracle_UnderRandomInput()
}
else if (data[0] == 0)
{
// Keep it in the fast-path length window so Decode hits TryDecodeBitcoin{32,64}Fast.
// Keep it in the fast-path length window so Decode hits DecodeBitcoin{32,64}Fast.
data[0] = 1;
}

Expand Down
11 changes: 8 additions & 3 deletions src/Base58Encoding.Tests/VectorInstructionSetTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,22 +17,26 @@ namespace Base58Encoding.Tests;
// DOTNET_EnableAVX2=0 disables AVX2 (and AVX-512), leaving SSE -> forces the Vector128 path
// DOTNET_EnableHWIntrinsic=0 disables all hardware intrinsics -> forces the scalar path
//
// Explicit because it spawns processes -- opt-in, not part of every run. The child runs with the
// default (explicit off), so this test does not re-spawn itself.
// Runs by default: these are the only tests covering the Vector128 and scalar paths. ChildMarker is
// what stops the child spawning its own child.
public class VectorInstructionSetTests
{
private const string ChildMarker = "BASE58_VECTOR_CHILD";

private readonly ITestOutputHelper _output;

public VectorInstructionSetTests(ITestOutputHelper output)
{
_output = output;
}

[Theory(Explicit = true)]
[Theory]
[InlineData("DOTNET_EnableAVX2", "0")] // disable AVX2 -> forces the Vector128 path
[InlineData("DOTNET_EnableHWIntrinsic", "0")] // disable all hardware intrinsics -> forces the scalar path
public void AllTests_Pass_WithVectorInstructionSetDisabled(string environmentVariable, string value)
{
Assert.SkipWhen(Environment.GetEnvironmentVariable(ChildMarker) == "1", "already the child run");

#if DEBUG
const string configuration = "Debug";
#else
Expand All @@ -55,6 +59,7 @@ public void AllTests_Pass_WithVectorInstructionSetDisabled(string environmentVar
startInfo.ArgumentList.Add("--no-build");
startInfo.ArgumentList.Add("--no-restore");
startInfo.Environment[environmentVariable] = value;
startInfo.Environment[ChildMarker] = "1";

using var process = Process.Start(startInfo)!;
string stdout = process.StandardOutput.ReadToEnd();
Expand Down
Loading