Skip to content

Commit 2f662f4

Browse files
authored
Merge pull request #11 from unsafePtr/feat/nativeaot
feat: Native AOT support
2 parents 2192df1 + 7c75941 commit 2f662f4

11 files changed

Lines changed: 237 additions & 55 deletions

File tree

.github/workflows/aot-tests.yml

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
name: Native AOT tests
2+
3+
# Publishes the test suite with PublishAot and runs the native binary on x64 and arm64.
4+
#
5+
# Not a duplicate of the JIT run: ILC resolves Vector256/Vector128.IsHardwareAccelerated at build
6+
# time against an instruction-set baseline instead of against the live CPU, so the same source can
7+
# compile to a different kernel. The x86-64-v3 legs guarantee AVX2; the default ones do not.
8+
9+
on:
10+
push:
11+
branches: [ master, main ]
12+
pull_request:
13+
branches: [ master, main ]
14+
workflow_dispatch:
15+
16+
jobs:
17+
aot:
18+
name: ${{ matrix.rid }}${{ matrix.instruction-set && format(' ({0})', matrix.instruction-set) || '' }}
19+
runs-on: ${{ matrix.runner }}
20+
permissions:
21+
contents: read
22+
23+
strategy:
24+
fail-fast: false
25+
matrix:
26+
include:
27+
- { runner: ubuntu-24.04, rid: linux-x64, instruction-set: '' }
28+
- { runner: ubuntu-24.04, rid: linux-x64, instruction-set: x86-64-v3 }
29+
- { runner: ubuntu-24.04-arm, rid: linux-arm64, instruction-set: '' }
30+
- { runner: macos-26, rid: osx-arm64, instruction-set: '' }
31+
- { runner: windows-2025, rid: win-x64, instruction-set: x86-64-v3 }
32+
33+
steps:
34+
- uses: actions/checkout@v7
35+
36+
- name: Setup .NET
37+
uses: actions/setup-dotnet@v5
38+
with:
39+
dotnet-version: 10.0.x
40+
41+
# ILC links through clang and needs zlib's headers.
42+
- name: Install native toolchain (Linux)
43+
if: runner.os == 'Linux'
44+
run: sudo apt-get update && sudo apt-get install -y clang zlib1g-dev
45+
46+
# An empty IlcInstructionSet is a no-op, so the default-baseline legs need no special case.
47+
- name: Publish (Native AOT)
48+
shell: bash
49+
run: |
50+
dotnet publish src/Base58Encoding.Tests/Base58Encoding.Tests.csproj \
51+
--configuration Release --runtime ${{ matrix.rid }} --output aot-out \
52+
-p:PublishAot=true -p:IlcInstructionSet=${{ matrix.instruction-set }} 2>&1 | tee publish.log
53+
54+
# Scoped to Base58Encoding so an unrelated xunit or SimpleBase warning cannot fail CI. Warning
55+
# lines end with the driving project path, which contains the name too, so strip it first.
56+
- name: Assert no AOT/trim warnings from the library
57+
shell: bash
58+
run: |
59+
ours=$(grep -E 'warning IL[0-9]+' publish.log | sed -E 's/ \[[^][]*\]$//' | grep 'Base58Encoding' || true)
60+
if [ -n "$ours" ]; then
61+
echo "::error::Base58Encoding produced trim/AOT warnings:"
62+
echo "$ours"
63+
exit 1
64+
fi
65+
66+
# No `dotnet` in front: a standalone binary. bash resolves the .exe suffix on Windows.
67+
- name: Run the suite natively
68+
shell: bash
69+
run: |
70+
chmod +x aot-out/Base58Encoding.Tests*
71+
./aot-out/Base58Encoding.Tests | tee native.log
72+
73+
# The xunit package is swapped on PublishAot, so the native legs discover tests with a source
74+
# generator while every other run uses reflection. Source-generated discovery is a strict subset:
75+
# a test it cannot see (generic test methods, interface-based attributes) is silently absent
76+
# rather than an error, so without this both legs would go green while the native one tested
77+
# less. Runtime-skipped tests still count toward Total -- VectorInstructionSetTests skips itself
78+
# here -- so a mismatch means genuinely undiscovered tests. One leg is enough: discovery does not
79+
# vary by RID or instruction set. The counts are read from the last summary line: a failing
80+
# VectorInstructionSetTests dumps its child's console output, which carries one too.
81+
- name: Discovery parity with the JIT package
82+
if: matrix.rid == 'linux-x64' && matrix.instruction-set == ''
83+
shell: bash
84+
run: |
85+
dotnet run --project src/Base58Encoding.Tests/Base58Encoding.Tests.csproj --configuration Release | tee jit.log
86+
native=$(grep -oP 'Total: \K[0-9]+' native.log | tail -1)
87+
jit=$(grep -oP 'Total: \K[0-9]+' jit.log | tail -1)
88+
echo "native discovered $native, JIT discovered $jit"
89+
if [ -z "$native" ] || [ -z "$jit" ]; then
90+
echo "::error::could not parse a test count from one of the runs"
91+
exit 1
92+
fi
93+
if [ "$native" != "$jit" ]; then
94+
echo "::error::source-generated discovery found $native tests, reflection found $jit"
95+
exit 1
96+
fi

.github/workflows/publish-nuget.yml

Lines changed: 8 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,12 @@
11
name: Build and Publish to NuGet
22

3+
# Release-only: tags and manual dispatch. Ordinary pushes and PRs are covered by tests.yml and
4+
# aot-tests.yml, which build the same solution across more platforms -- this one used to duplicate
5+
# that on every library change while never packing, since Pack is gated on a version being set.
36
on:
47
push:
58
tags:
69
- 'v*.*.*'
7-
branches: [ master, main ]
8-
paths:
9-
- 'src/Base58Encoding/**'
10-
- 'src/Directory.Build.props'
11-
- 'src/Directory.Packages.props'
12-
- 'src/NuGet.Config'
13-
- 'src/PACKAGE.md'
14-
- 'global.json'
15-
pull_request:
16-
branches: [ master, main ]
17-
paths:
18-
- 'src/Base58Encoding/**'
19-
- 'src/Directory.Build.props'
20-
- 'src/Directory.Packages.props'
21-
- 'src/NuGet.Config'
22-
- 'src/PACKAGE.md'
23-
- 'global.json'
2410
workflow_dispatch:
2511
inputs:
2612
version:
@@ -59,7 +45,12 @@ jobs:
5945
- name: Build
6046
run: dotnet build src/Base58Encoding.slnx --configuration Release --no-restore
6147

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

6556
- name: Pack

.github/workflows/tests.yml

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
name: Tests
2+
3+
# The ordinary JIT test run, on every push and PR, with no path filter.
4+
#
5+
# aot-tests.yml covers the native legs and publish-nuget.yml runs the suite once more before it
6+
# packs, but that one is filtered to src/Base58Encoding/** and runs on arm64 only -- so without this
7+
# workflow a test-only change ran no JIT tests at all, and every x64 execution in CI came from an
8+
# ILC-compiled binary.
9+
#
10+
# The x64 legs are not redundant with the native ones. VectorInstructionSetTests drives its coverage
11+
# through DOTNET_EnableAVX2 and DOTNET_EnableHWIntrinsic, knobs ILC bakes past and an arm64 runner
12+
# ignores, so these are the only jobs where the Vector128 and scalar fallbacks actually execute.
13+
14+
on:
15+
push:
16+
branches: [ master, main ]
17+
pull_request:
18+
branches: [ master, main ]
19+
workflow_dispatch:
20+
21+
jobs:
22+
test:
23+
name: ${{ matrix.rid }}
24+
runs-on: ${{ matrix.runner }}
25+
permissions:
26+
contents: read
27+
28+
strategy:
29+
fail-fast: false
30+
matrix:
31+
include:
32+
- { runner: ubuntu-24.04, rid: linux-x64 }
33+
- { runner: ubuntu-24.04-arm, rid: linux-arm64 }
34+
- { runner: windows-2025, rid: win-x64 }
35+
- { runner: macos-26, rid: osx-arm64 }
36+
37+
steps:
38+
- uses: actions/checkout@v7
39+
40+
- name: Setup .NET
41+
uses: actions/setup-dotnet@v5
42+
with:
43+
dotnet-version: 10.0.x
44+
45+
# The whole solution, so a break in the benchmarks project surfaces here too: nothing else builds
46+
# it, since publish-nuget.yml's path filter does not cover it either.
47+
- name: Restore
48+
run: dotnet restore src/Base58Encoding.slnx
49+
50+
- name: Build
51+
run: dotnet build src/Base58Encoding.slnx --configuration Release --no-restore
52+
53+
# dotnet run rather than dotnet test: this is how the project drives xunit v3's own CLI, and it
54+
# is what the child processes in VectorInstructionSetTests re-invoke. Those children add a few
55+
# minutes -- they each re-run the whole suite with an instruction set disabled.
56+
- name: Test
57+
run: dotnet run --project src/Base58Encoding.Tests/Base58Encoding.Tests.csproj --configuration Release --no-build

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -427,4 +427,6 @@ FodyWeavers.xsd
427427
*.msm
428428
*.msp
429429

430-
nul
430+
nul
431+
# Native AOT publish output from a local aot-tests.yml dry run
432+
aot-out/

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@ A .NET 10.0 Base58 encoding and decoding library with support for multiple alpha
99
- **Type Safe**: Leverages ReadOnlySpan and ReadOnlyMemory for safe memory operations
1010
- **Intrinsics**: Uses SIMD `Vector256` and unrolled loop for counting leading zeros
1111
- **Optimized Hot Paths**: Fast fixed-length encode/decode for 32-byte and 64-byte inputs using Firedancer-like optimizations
12+
- **Native AOT**: reflection-free and trim-clean; the suite itself runs as an AOT binary in CI
13+
14+
## Native AOT
15+
16+
The library is reflection-free and marked `IsAotCompatible`, so `PublishAot` and `PublishTrimmed` need no configuration and produce no warnings.
17+
18+
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.
1219

1320
## Usage
1421

Lines changed: 37 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,39 @@
11
<Project Sdk="Microsoft.NET.Sdk">
22

3-
<PropertyGroup>
4-
<OutputType>Exe</OutputType>
5-
<IsPackable>false</IsPackable>
6-
<RootNamespace>Base58Encoding.Tests</RootNamespace>
7-
</PropertyGroup>
8-
9-
<ItemGroup>
10-
<PackageReference Include="SimpleBase" />
11-
<PackageReference Include="Microsoft.Testing.Extensions.CodeCoverage">
12-
<PrivateAssets>all</PrivateAssets>
13-
</PackageReference>
14-
<PackageReference Include="xunit.v3.mtp-v2" />
15-
</ItemGroup>
16-
17-
<ItemGroup>
18-
<Using Include="Xunit" />
19-
</ItemGroup>
20-
21-
<ItemGroup>
22-
<ProjectReference Include="..\Base58Encoding\Base58Encoding.csproj" />
23-
</ItemGroup>
24-
25-
</Project>
3+
<PropertyGroup>
4+
<OutputType>Exe</OutputType>
5+
<IsPackable>false</IsPackable>
6+
<RootNamespace>Base58Encoding.Tests</RootNamespace>
7+
</PropertyGroup>
8+
9+
<!-- Native AOT is opt-in per publish (-p:PublishAot=true); ordinary builds stay on the JIT. -->
10+
<PropertyGroup Condition="'$(PublishAot)' == 'true'">
11+
<TrimmerSingleWarn>false</TrimmerSingleWarn>
12+
<InvariantGlobalization>true</InvariantGlobalization>
13+
</PropertyGroup>
14+
15+
<ItemGroup>
16+
<PackageReference Include="SimpleBase" />
17+
</ItemGroup>
18+
19+
<ItemGroup Condition="'$(PublishAot)' == 'true'">
20+
<PackageReference Include="xunit.v3.aot.mtp-v2" />
21+
</ItemGroup>
22+
23+
<ItemGroup Condition="'$(PublishAot)' != 'true'">
24+
<PackageReference Include="xunit.v3.mtp-v2" />
25+
<!-- Loaded by assembly name, which ILC cannot follow: the native run dies at startup. -->
26+
<PackageReference Include="Microsoft.Testing.Extensions.CodeCoverage">
27+
<PrivateAssets>all</PrivateAssets>
28+
</PackageReference>
29+
</ItemGroup>
30+
31+
<ItemGroup>
32+
<Using Include="Xunit" />
33+
</ItemGroup>
34+
35+
<ItemGroup>
36+
<ProjectReference Include="..\Base58Encoding\Base58Encoding.csproj" />
37+
</ItemGroup>
38+
39+
</Project>

src/Base58Encoding.Tests/SimpleBaseFuzzTests.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ namespace Base58Encoding.Tests;
1212
//
1313
// Ground truth is a BigInteger oracle (the literal definition of Base58), so the fuzz validates our
1414
// code without trusting any third party. We also cross-check our encoder against SimpleBase's, but
15-
// not SimpleBase.Decode(ours): its 5.6.2 decoder drops the most-significant byte on some lengths
16-
// (ssg/SimpleBase#83, fixed in 5.6.3 — which we cannot take yet, see Directory.Packages.props).
15+
// not SimpleBase.Decode(ours): their decoder dropped the most-significant byte on some lengths
16+
// (ssg/SimpleBase#83), which ruled that direction out while we were pinned below the fix. We are
17+
// on 5.6.4 now and that bug is fixed, so the reverse cross-check could be enabled.
1718
public class SimpleBaseFuzzTests
1819
{
1920
private const string Alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";

src/Base58Encoding.Tests/VectorInstructionSetTests.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System.Diagnostics;
2+
using System.Runtime.CompilerServices;
23

34
namespace Base58Encoding.Tests;
45

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

4047
#if DEBUG
4148
const string configuration = "Debug";

src/Base58Encoding/Base58Encoding.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
<PackageLicenseExpression>MIT</PackageLicenseExpression>
1111
<PackageReadmeFile>PACKAGE.md</PackageReadmeFile>
1212
<GeneratePackageOnBuild>false</GeneratePackageOnBuild>
13+
<IsAotCompatible>true</IsAotCompatible>
1314
</PropertyGroup>
1415

1516
<ItemGroup>

src/Directory.Packages.props

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
<Project>
2-
<PropertyGroup>
3-
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
4-
</PropertyGroup>
5-
<ItemGroup>
6-
<PackageVersion Include="BenchmarkDotNet" Version="0.15.8" />
7-
<PackageVersion Include="Microsoft.Testing.Extensions.CodeCoverage" Version="18.1.0" />
8-
<!-- Held at 5.6.2: 5.6.3's encoder truncates at 128-char output (ssg/SimpleBase#87). -->
9-
<PackageVersion Include="SimpleBase" Version="5.6.2" />
10-
<PackageVersion Include="System.Numerics.Tensors" Version="10.0.10" />
11-
<PackageVersion Include="xunit.v3.mtp-v2" Version="3.2.2" />
12-
</ItemGroup>
13-
</Project>
2+
<PropertyGroup>
3+
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
4+
</PropertyGroup>
5+
<ItemGroup>
6+
<PackageVersion Include="BenchmarkDotNet" Version="0.15.8" />
7+
<PackageVersion Include="Microsoft.Testing.Extensions.CodeCoverage" Version="18.1.0" />
8+
<PackageVersion Include="SimpleBase" Version="5.6.4" />
9+
<PackageVersion Include="System.Numerics.Tensors" Version="10.0.10" />
10+
<PackageVersion Include="xunit.v3.mtp-v2" Version="4.0.0" />
11+
<PackageVersion Include="xunit.v3.aot.mtp-v2" Version="4.0.0" />
12+
</ItemGroup>
13+
</Project>

0 commit comments

Comments
 (0)