diff --git a/.github/workflows/aot-tests.yml b/.github/workflows/aot-tests.yml new file mode 100644 index 0000000..e2751f3 --- /dev/null +++ b/.github/workflows/aot-tests.yml @@ -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 diff --git a/.github/workflows/publish-nuget.yml b/.github/workflows/publish-nuget.yml index da9c186..ca94306 100644 --- a/.github/workflows/publish-nuget.yml +++ b/.github/workflows/publish-nuget.yml @@ -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: @@ -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 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..5e90192 --- /dev/null +++ b/.github/workflows/tests.yml @@ -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 diff --git a/.gitignore b/.gitignore index 0c92fdb..26fdba4 100644 --- a/.gitignore +++ b/.gitignore @@ -427,4 +427,6 @@ FodyWeavers.xsd *.msm *.msp -nul \ No newline at end of file +nul +# Native AOT publish output from a local aot-tests.yml dry run +aot-out/ diff --git a/README.md b/README.md index 9e3bcce..bc8f505 100644 --- a/README.md +++ b/README.md @@ -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 `x86-64-v3` restores it, at the cost of making AVX2 a hard startup requirement. ## Usage diff --git a/src/Base58Encoding.Tests/Base58Encoding.Tests.csproj b/src/Base58Encoding.Tests/Base58Encoding.Tests.csproj index 9ffc9f7..b0d7e3f 100644 --- a/src/Base58Encoding.Tests/Base58Encoding.Tests.csproj +++ b/src/Base58Encoding.Tests/Base58Encoding.Tests.csproj @@ -1,25 +1,39 @@  - - Exe - false - Base58Encoding.Tests - - - - - - all - - - - - - - - - - - - - \ No newline at end of file + + Exe + false + Base58Encoding.Tests + + + + + false + true + + + + + + + + + + + + + + + all + + + + + + + + + + + + diff --git a/src/Base58Encoding.Tests/SimpleBaseFuzzTests.cs b/src/Base58Encoding.Tests/SimpleBaseFuzzTests.cs index 7fbc4e6..bd28503 100644 --- a/src/Base58Encoding.Tests/SimpleBaseFuzzTests.cs +++ b/src/Base58Encoding.Tests/SimpleBaseFuzzTests.cs @@ -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"; diff --git a/src/Base58Encoding.Tests/VectorInstructionSetTests.cs b/src/Base58Encoding.Tests/VectorInstructionSetTests.cs index d28c3ba..845f7ce 100644 --- a/src/Base58Encoding.Tests/VectorInstructionSetTests.cs +++ b/src/Base58Encoding.Tests/VectorInstructionSetTests.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Runtime.CompilerServices; namespace Base58Encoding.Tests; @@ -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"; @@ -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"; diff --git a/src/Base58Encoding/Base58Encoding.csproj b/src/Base58Encoding/Base58Encoding.csproj index a5997b0..d0e8b31 100644 --- a/src/Base58Encoding/Base58Encoding.csproj +++ b/src/Base58Encoding/Base58Encoding.csproj @@ -10,6 +10,7 @@ MIT PACKAGE.md false + true diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 1e9d92d..2204931 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -1,13 +1,13 @@ - - true - - - - - - - - - - \ No newline at end of file + + true + + + + + + + + + + diff --git a/src/PACKAGE.md b/src/PACKAGE.md index eac5e5c..afe5c6f 100644 --- a/src/PACKAGE.md +++ b/src/PACKAGE.md @@ -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