Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
96 changes: 96 additions & 0 deletions .github/workflows/aot-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
name: Native AOT tests

# Publishes the test suite with PublishAot and runs the native binary on x64 and arm64.
#
# Not a duplicate of the JIT run: ILC resolves Vector256/Vector128.IsHardwareAccelerated at build
# time against an instruction-set baseline instead of against the live CPU, so the same source can
# compile to a different kernel. The x86-64-v3 legs guarantee AVX2; the default ones do not.

on:
push:
branches: [ master, main ]
pull_request:
branches: [ master, main ]
workflow_dispatch:

jobs:
aot:
name: ${{ matrix.rid }}${{ matrix.instruction-set && format(' ({0})', matrix.instruction-set) || '' }}
runs-on: ${{ matrix.runner }}
permissions:
contents: read

strategy:
fail-fast: false
matrix:
include:
- { runner: ubuntu-24.04, rid: linux-x64, instruction-set: '' }
- { runner: ubuntu-24.04, rid: linux-x64, instruction-set: x86-64-v3 }
- { runner: ubuntu-24.04-arm, rid: linux-arm64, instruction-set: '' }
- { runner: macos-26, rid: osx-arm64, instruction-set: '' }
- { runner: windows-2025, rid: win-x64, instruction-set: x86-64-v3 }

steps:
- uses: actions/checkout@v7

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

# ILC links through clang and needs zlib's headers.
- name: Install native toolchain (Linux)
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y clang zlib1g-dev

# An empty IlcInstructionSet is a no-op, so the default-baseline legs need no special case.
- name: Publish (Native AOT)
shell: bash
run: |
dotnet publish src/Base58Encoding.Tests/Base58Encoding.Tests.csproj \
--configuration Release --runtime ${{ matrix.rid }} --output aot-out \
-p:PublishAot=true -p:IlcInstructionSet=${{ matrix.instruction-set }} 2>&1 | tee publish.log

# Scoped to Base58Encoding so an unrelated xunit or SimpleBase warning cannot fail CI. Warning
# lines end with the driving project path, which contains the name too, so strip it first.
- name: Assert no AOT/trim warnings from the library
shell: bash
run: |
ours=$(grep -E 'warning IL[0-9]+' publish.log | sed -E 's/ \[[^][]*\]$//' | grep 'Base58Encoding' || true)
if [ -n "$ours" ]; then
echo "::error::Base58Encoding produced trim/AOT warnings:"
echo "$ours"
exit 1
fi

# No `dotnet` in front: a standalone binary. bash resolves the .exe suffix on Windows.
- name: Run the suite natively
shell: bash
run: |
chmod +x aot-out/Base58Encoding.Tests*
./aot-out/Base58Encoding.Tests | tee native.log

# The xunit package is swapped on PublishAot, so the native legs discover tests with a source
# generator while every other run uses reflection. Source-generated discovery is a strict subset:
# a test it cannot see (generic test methods, interface-based attributes) is silently absent
# rather than an error, so without this both legs would go green while the native one tested
# less. Runtime-skipped tests still count toward Total -- VectorInstructionSetTests skips itself
# here -- so a mismatch means genuinely undiscovered tests. One leg is enough: discovery does not
# vary by RID or instruction set. The counts are read from the last summary line: a failing
# VectorInstructionSetTests dumps its child's console output, which carries one too.
- name: Discovery parity with the JIT package
if: matrix.rid == 'linux-x64' && matrix.instruction-set == ''
shell: bash
run: |
dotnet run --project src/Base58Encoding.Tests/Base58Encoding.Tests.csproj --configuration Release | tee jit.log
native=$(grep -oP 'Total: \K[0-9]+' native.log | tail -1)
jit=$(grep -oP 'Total: \K[0-9]+' jit.log | tail -1)
echo "native discovered $native, JIT discovered $jit"
if [ -z "$native" ] || [ -z "$jit" ]; then
echo "::error::could not parse a test count from one of the runs"
exit 1
fi
if [ "$native" != "$jit" ]; then
echo "::error::source-generated discovery found $native tests, reflection found $jit"
exit 1
fi
25 changes: 8 additions & 17 deletions .github/workflows/publish-nuget.yml
Original file line number Diff line number Diff line change
@@ -1,26 +1,12 @@
name: Build and Publish to NuGet

# Release-only: tags and manual dispatch. Ordinary pushes and PRs are covered by tests.yml and
# aot-tests.yml, which build the same solution across more platforms -- this one used to duplicate
# that on every library change while never packing, since Pack is gated on a version being set.
on:
push:
tags:
- 'v*.*.*'
branches: [ master, main ]
paths:
- 'src/Base58Encoding/**'
- 'src/Directory.Build.props'
- 'src/Directory.Packages.props'
- 'src/NuGet.Config'
- 'src/PACKAGE.md'
- 'global.json'
pull_request:
branches: [ master, main ]
paths:
- 'src/Base58Encoding/**'
- 'src/Directory.Build.props'
- 'src/Directory.Packages.props'
- 'src/NuGet.Config'
- 'src/PACKAGE.md'
- 'global.json'
workflow_dispatch:
inputs:
version:
Expand Down Expand Up @@ -59,7 +45,12 @@ jobs:
- name: Build
run: dotnet build src/Base58Encoding.slnx --configuration Release --no-restore

# The release path's own gate. tests.yml never runs on a tag -- a branches: filter does not
# match refs/tags/* -- so for a tag cut anywhere other than an already-tested master commit,
# nothing else has run the suite against this ref. Skipped only for a dispatch with no version,
# which builds without packing.
- name: Test
if: env.VERSION != ''
run: dotnet test --project src/Base58Encoding.Tests/Base58Encoding.Tests.csproj --configuration Release --no-build --verbosity normal

- name: Pack
Expand Down
57 changes: 57 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
name: Tests

# The ordinary JIT test run, on every push and PR, with no path filter.
#
# aot-tests.yml covers the native legs and publish-nuget.yml runs the suite once more before it
# packs, but that one is filtered to src/Base58Encoding/** and runs on arm64 only -- so without this
# workflow a test-only change ran no JIT tests at all, and every x64 execution in CI came from an
# ILC-compiled binary.
#
# The x64 legs are not redundant with the native ones. VectorInstructionSetTests drives its coverage
# through DOTNET_EnableAVX2 and DOTNET_EnableHWIntrinsic, knobs ILC bakes past and an arm64 runner
# ignores, so these are the only jobs where the Vector128 and scalar fallbacks actually execute.

on:
push:
branches: [ master, main ]
pull_request:
branches: [ master, main ]
workflow_dispatch:

jobs:
test:
name: ${{ matrix.rid }}
runs-on: ${{ matrix.runner }}
permissions:
contents: read

strategy:
fail-fast: false
matrix:
include:
- { runner: ubuntu-24.04, rid: linux-x64 }
- { runner: ubuntu-24.04-arm, rid: linux-arm64 }
- { runner: windows-2025, rid: win-x64 }
- { runner: macos-26, rid: osx-arm64 }

steps:
- uses: actions/checkout@v7

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

# The whole solution, so a break in the benchmarks project surfaces here too: nothing else builds
# it, since publish-nuget.yml's path filter does not cover it either.
- name: Restore
run: dotnet restore src/Base58Encoding.slnx

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

# dotnet run rather than dotnet test: this is how the project drives xunit v3's own CLI, and it
# is what the child processes in VectorInstructionSetTests re-invoke. Those children add a few
# minutes -- they each re-run the whole suite with an instruction set disabled.
- name: Test
run: dotnet run --project src/Base58Encoding.Tests/Base58Encoding.Tests.csproj --configuration Release --no-build
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -427,4 +427,6 @@ FodyWeavers.xsd
*.msm
*.msp

nul
nul
# Native AOT publish output from a local aot-tests.yml dry run
aot-out/
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ A .NET 10.0 Base58 encoding and decoding library with support for multiple alpha
- **Type Safe**: Leverages ReadOnlySpan and ReadOnlyMemory for safe memory operations
- **Intrinsics**: Uses SIMD `Vector256` and unrolled loop for counting leading zeros
- **Optimized Hot Paths**: Fast fixed-length encode/decode for 32-byte and 64-byte inputs using Firedancer-like optimizations
- **Native AOT**: reflection-free and trim-clean; the suite itself runs as an AOT binary in CI

## Native AOT

The library is reflection-free and marked `IsAotCompatible`, so `PublishAot` and `PublishTrimmed` need no configuration and produce no warnings.

Note that `Vector256` is not used under Native AOT: ILC resolves `Vector256.IsHardwareAccelerated` at build time against a baseline that excludes AVX2, so AOT builds run the `Vector128` kernel. Setting `<IlcInstructionSet>x86-64-v3</IlcInstructionSet>` restores it, at the cost of making AVX2 a hard startup requirement.

## Usage

Expand Down
60 changes: 37 additions & 23 deletions src/Base58Encoding.Tests/Base58Encoding.Tests.csproj
Original file line number Diff line number Diff line change
@@ -1,25 +1,39 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
<RootNamespace>Base58Encoding.Tests</RootNamespace>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="SimpleBase" />
<PackageReference Include="Microsoft.Testing.Extensions.CodeCoverage">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="xunit.v3.mtp-v2" />
</ItemGroup>

<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Base58Encoding\Base58Encoding.csproj" />
</ItemGroup>

</Project>
<PropertyGroup>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
<RootNamespace>Base58Encoding.Tests</RootNamespace>
</PropertyGroup>

<!-- Native AOT is opt-in per publish (-p:PublishAot=true); ordinary builds stay on the JIT. -->
<PropertyGroup Condition="'$(PublishAot)' == 'true'">
<TrimmerSingleWarn>false</TrimmerSingleWarn>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="SimpleBase" />
</ItemGroup>

<ItemGroup Condition="'$(PublishAot)' == 'true'">
<PackageReference Include="xunit.v3.aot.mtp-v2" />
</ItemGroup>

<ItemGroup Condition="'$(PublishAot)' != 'true'">
<PackageReference Include="xunit.v3.mtp-v2" />
<!-- Loaded by assembly name, which ILC cannot follow: the native run dies at startup. -->
<PackageReference Include="Microsoft.Testing.Extensions.CodeCoverage">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>

<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Base58Encoding\Base58Encoding.csproj" />
</ItemGroup>

</Project>
5 changes: 3 additions & 2 deletions src/Base58Encoding.Tests/SimpleBaseFuzzTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ namespace Base58Encoding.Tests;
//
// 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
// 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).
// not SimpleBase.Decode(ours): their decoder dropped the most-significant byte on some lengths
// (ssg/SimpleBase#83), which ruled that direction out while we were pinned below the fix. We are
// on 5.6.4 now and that bug is fixed, so the reverse cross-check could be enabled.
public class SimpleBaseFuzzTests
{
private const string Alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
Expand Down
7 changes: 7 additions & 0 deletions src/Base58Encoding.Tests/VectorInstructionSetTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Diagnostics;
using System.Runtime.CompilerServices;

namespace Base58Encoding.Tests;

Expand All @@ -19,6 +20,9 @@ namespace Base58Encoding.Tests;
//
// Runs by default: these are the only tests covering the Vector128 and scalar paths. ChildMarker is
// what stops the child spawning its own child.
//
// Both knobs are JIT-only: ILC bakes the instruction sets in when it compiles, so a native run
// skips these and the aot-tests workflow covers the kernels with per-baseline publishes instead.
public class VectorInstructionSetTests
{
private const string ChildMarker = "BASE58_VECTOR_CHILD";
Expand All @@ -36,6 +40,9 @@ public VectorInstructionSetTests(ITestOutputHelper output)
public void AllTests_Pass_WithVectorInstructionSetDisabled(string environmentVariable, string value)
{
Assert.SkipWhen(Environment.GetEnvironmentVariable(ChildMarker) == "1", "already the child run");
Assert.SkipUnless(
RuntimeFeature.IsDynamicCodeSupported,
"native AOT: instruction sets are baked in by ILC, so the env knob would do nothing");

#if DEBUG
const string configuration = "Debug";
Expand Down
1 change: 1 addition & 0 deletions src/Base58Encoding/Base58Encoding.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageReadmeFile>PACKAGE.md</PackageReadmeFile>
<GeneratePackageOnBuild>false</GeneratePackageOnBuild>
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>

<ItemGroup>
Expand Down
24 changes: 12 additions & 12 deletions src/Directory.Packages.props
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="BenchmarkDotNet" Version="0.15.8" />
<PackageVersion Include="Microsoft.Testing.Extensions.CodeCoverage" Version="18.1.0" />
<!-- Held at 5.6.2: 5.6.3's encoder truncates at 128-char output (ssg/SimpleBase#87). -->
<PackageVersion Include="SimpleBase" Version="5.6.2" />
<PackageVersion Include="System.Numerics.Tensors" Version="10.0.10" />
<PackageVersion Include="xunit.v3.mtp-v2" Version="3.2.2" />
</ItemGroup>
</Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="BenchmarkDotNet" Version="0.15.8" />
<PackageVersion Include="Microsoft.Testing.Extensions.CodeCoverage" Version="18.1.0" />
<PackageVersion Include="SimpleBase" Version="5.6.4" />
<PackageVersion Include="System.Numerics.Tensors" Version="10.0.10" />
<PackageVersion Include="xunit.v3.mtp-v2" Version="4.0.0" />
<PackageVersion Include="xunit.v3.aot.mtp-v2" Version="4.0.0" />
</ItemGroup>
</Project>
6 changes: 6 additions & 0 deletions src/PACKAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ Returns a typical upper bound for the decoded byte count from an encoded input o
Formula: `encodedLength * 733 / 1000 + 1`. Suitable for inputs without leading `1` characters.
For inputs that may contain leading `1`s, size the destination at `encodedLength` (safe upper bound).

## Native AOT

Reflection-free and marked `IsAotCompatible`, so `PublishAot` and `PublishTrimmed` need no configuration.

Note that `Vector256` is not used under Native AOT: ILC fixes `Vector256.IsHardwareAccelerated` at build time against a baseline without AVX2, so AOT builds run the `Vector128` kernel.

## Usage

### Allocating API
Expand Down