Skip to content

Commit e4eefde

Browse files
Compile without reflection, and gate on a trimmed and NativeAOT run (#363, #746) (#1016)
* Make the two compilation benchmarks runnable BenchLinqCompilation and CacheCompiledFunc had no way to be run: the first was a commented-out BenchmarkRunner.Run at the bottom of Main, the second had no arm in the switch at all. A benchmark nobody can invoke measures nothing, and these two are the only coverage of the Linq compilation path apart from CommonFunctionsInterVersion's CompileEasy/CompileHard. Both now take an argument like the others, and both report Allocated as well as Mean -- #746's item 80 asks for time and allocation, and allocation is the axis on which the compilation path actually moves. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011emtnRT6EWTrxXNtqDVK3e * Compile without reflection, and gate on a trimmed and NativeAOT run (#363, #746) The Linq compilation path named four kinds of member at run time, and each is invisible to the trimmer and to the NativeAOT compiler: - typeof(MathAllMethods).GetMethod(name, types) for every mathematical function. Both tools answer a run-time name by keeping every public method of the class, and the run paid an overload resolution per node. - expr.Type.GetMethod("IsNaN") for the NaN test, which the trimmer had no reason to keep. IL2075, and under NativeAOT a null that reached AngouriBugException and took the process down on the first double -> long? conversion. - typeof(Nullable<>).MakeGenericType(t), which NativeAOT cannot promise. IL3050. - the operators and conversions Expression.Add and Expression.Convert find by reflecting over the operand or target type. Complex and BigInteger define theirs as methods, and NativeAOT kept no metadata for them: "x + 1" over a complex argument threw "the binary operator Add is not defined for the types 'System.Numerics.Complex' and 'System.Numerics.Complex'", and "double -> Complex" threw "no coercion operator is defined between types". All four are now tables of members named at compile time. MathAllMethods.Definitions is generated from the same lists as the methods it dispatches to, so the two cannot drift apart, and every entry is an expression tree because the compiler emits an ldtoken for the call inside one -- which is how C# names a method without a string, and what roots it for the trimmer. Sources/Tests/AotSmokeTest is the gate. It parses, simplifies, solves, differentiates, integrates, takes a limit and compiles twenty-two expressions, asserts values rather than printed forms, and returns non-zero on any mismatch. It publishes trimmed and NativeAOT with TrimmerSingleWarn=false and the trimmer's and ILC's own warnings-as-errors switches, and the workflow runs the published binary: everything that goes wrong here goes wrong silently at publish time, so publishing proves nothing and only running does. Measured on linux-x64, .NET 10.0.10: both publishes report zero IL2xxx/IL3xxx, and the 44 assertions give byte-identical output under the JIT, the trimmed binary and the NativeAOT binary. Before this, the NativeAOT binary failed six of them and aborted on the seventh. The kernel is marked IsAotCompatible for net8.0 and later as a result, which is also a promise to consumers and is recorded in BREAKING-CHANGES.md. CompileEasy allocates 16,377 B -> 11,122 B and CompileHard 38,090 B -> 20,433 B, with every other row of CommonFunctionsInterVersion unchanged to the byte; BenchLinqCompilation and CacheCompiledFunc, which run compiled delegates rather than compiling them, are unchanged within noise. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011emtnRT6EWTrxXNtqDVK3e --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent d52b792 commit e4eefde

16 files changed

Lines changed: 861 additions & 27 deletions

File tree

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
name: 'Trimming & NativeAOT'
2+
3+
# Deliberately without a paths: filter. What breaks a trimmed or NativeAOT build is a run-time
4+
# name lookup, and one can be introduced by a change anywhere in the kernel, by a package bump,
5+
# or by an SDK bump -- so there is no subset of the tree this gate could safely skip. Two
6+
# workflows here have already been silenced by a filter that matched nothing
7+
# (https://github.com/asc-community/AngouriMath/issues/775); an unconditional trigger cannot be.
8+
on:
9+
push:
10+
branches:
11+
- master
12+
pull_request:
13+
branches:
14+
- '*'
15+
workflow_dispatch:
16+
17+
jobs:
18+
Publish:
19+
strategy:
20+
fail-fast: false
21+
matrix:
22+
include:
23+
- os: ubuntu-latest
24+
rid: linux-x64
25+
- os: windows-latest
26+
rid: win-x64
27+
- os: macos-latest
28+
rid: osx-arm64
29+
30+
runs-on: ${{ matrix.os }}
31+
32+
steps:
33+
- uses: actions/checkout@v6
34+
35+
- name: Setup .NET 10
36+
uses: actions/setup-dotnet@v5
37+
with:
38+
dotnet-version: '10.x'
39+
dotnet-quality: 'preview'
40+
41+
# ILC links the runtime into the binary and needs a native linker and zlib to do it.
42+
- name: 'Install the native toolchain for Linux'
43+
if: ${{ matrix.os == 'ubuntu-latest' }}
44+
run: |
45+
sudo apt-get update
46+
sudo apt-get install -y clang zlib1g-dev
47+
48+
# The project sets PublishTrimmed, TrimmerSingleWarn=false and ILLinkTreatWarningsAsErrors,
49+
# so any IL2xxx from the kernel fails this step. There is no flag to forget on the command
50+
# line and no way to read a warning as noise.
51+
- name: 'Publish trimmed'
52+
run: dotnet publish Sources/Tests/AotSmokeTest/AotSmokeTest.csproj -c Release -r ${{ matrix.rid }} -o artifacts/trimmed
53+
54+
# Publishing is not the test. Everything the trimmer removed that the kernel reaches for by
55+
# name is invisible until the binary runs, so the binary runs, and its exit code is the gate.
56+
- name: 'Run the trimmed binary'
57+
shell: bash
58+
run: ./artifacts/trimmed/AotSmokeTest
59+
60+
- name: 'Publish NativeAOT'
61+
run: dotnet publish Sources/Tests/AotSmokeTest/AotSmokeTest.csproj -c Release -r ${{ matrix.rid }} -p:AngouriMathPublishAot=true -o artifacts/aot
62+
63+
- name: 'Run the NativeAOT binary'
64+
shell: bash
65+
run: ./artifacts/aot/AotSmokeTest

‎AGENTS.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,7 @@ are short, and a stale one is worse than none — if you change what a file desc
419419
| [`Contributing/Transformations.md`](Sources/AngouriMath/Docs/Contributing/Transformations.md) | the transformation layer the 1.x entry points sit on, and how to add the next rule set |
420420
| [`Contributing/SimplificationContract.md`](Sources/AngouriMath/Docs/Contributing/SimplificationContract.md) | what a rewrite may assume, and the ten obligations one has to meet. Read it *before* adding or changing a rule |
421421
| [`Contributing/CanonicalForm.md`](Sources/AngouriMath/Docs/Contributing/CanonicalForm.md) | canonical versus simplest, why no canonical form exists for the whole language, and what one means per node class. Read it before comparing two expressions for equality |
422+
| [`Contributing/Trimming.md`](Sources/AngouriMath/Docs/Contributing/Trimming.md) | what `IsAotCompatible` on the kernel promises, and what breaks it. Read it before adding reflection anywhere |
422423
| [`Contributing/coding_rules.md`](Sources/AngouriMath/Docs/Contributing/coding_rules.md) | sealed-or-abstract, and immutability of `Entity` |
423424
| [`WhatsNew/version_performance_control.md`](Sources/AngouriMath/Docs/WhatsNew/version_performance_control.md) | the inter-version performance table, how to add a column, and when the CI performance baseline may be updated |
424425
| `Sources/Analyzers/` | the custom analyzers, including the static-field one behind `[ConstantField]` |

‎BREAKING-CHANGES.md‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ read first.
3030
| **Silent** | `"a in (b in c)".ToEntity().Stringize()`, and `Latexize` | `a in b in c` / `a \in b \in c` | `a in (b in c)` / `a \in \left(b \in c\right)` |
3131
| **Silent** | `"x * (y mod z)".ToEntity().Stringize()`, and `Latexize` | `x * y mod z` / `x y \bmod z` | `x * (y mod z)` / `x \left(y \bmod z\right)` |
3232
| **Silent** | `"-1 * (y mod z)".ToEntity().Stringize()` | `-y mod z` | `-(y mod z)` |
33+
| | any expression mixing a number with a `Complex` argument, `Compile`d in a NativeAOT app — `"x + 1".Compile<Complex, Complex>("x")` | `UncompilableNodeException: ... The binary operator Add is not defined for the types 'System.Numerics.Complex' and 'System.Numerics.Complex'` | the compiled function, answering as it does under the JIT |
34+
| | `Compile` to a nullable integral return type in a NativeAOT app | `AngouriBugException: IsNaN method expected for type System.Double`, which took the process down | the compiled function |
35+
| **Silent** | an app publishing with `PublishTrimmed` or NativeAOT | `AngouriMath.dll` was copied in whole, being unmarked | it is trimmed with the rest, since the assembly now declares `IsTrimmable` |
3336

3437
### A printed operator that is not associative keeps its brackets
3538

@@ -94,6 +97,32 @@ bracketing for. The change only ever adds `\left(`/`\right)` groups, which CShar
9497
parses, so nothing downstream needs a matching change
9598
([#822](https://github.com/asc-community/AngouriMath/issues/822)).
9699

100+
### `Compile` works in a trimmed or NativeAOT application
101+
102+
The Linq compilation path found the method for each node by name —
103+
`typeof(MathAllMethods).GetMethod(name, ...)` for the mathematical functions,
104+
`expr.Type.GetMethod("IsNaN")` for the NaN test — and left the operators and conversions between
105+
`Complex`, `BigInteger` and the primitives to `Expression.Add` and `Expression.Convert`, which find
106+
them by reflecting over the operand type. A name resolved at run time is invisible to the trimmer,
107+
so the members were removed and the lookups came back empty. All four are now tables of members
108+
named at compile time: `MathAllMethods.Definitions` generated beside the methods it dispatches to,
109+
and `nanChecks`, `operators` and `conversionOperators` in `CompilationProtocol`.
110+
111+
Nothing changes under the JIT — the same `MethodInfo` reaches the same `Expression` node — and the
112+
44 assertions of `Sources/Tests/AotSmokeTest` give byte-identical output under the JIT, a trimmed
113+
publish and a NativeAOT publish. What changed is that the second and third of those now run at all:
114+
before this, six of them threw and the seventh aborted the process.
115+
116+
`AngouriMath` is marked `IsAotCompatible` for `net8.0` and later as a result, which is also the
117+
silent half of the entry above: a trimmer reads that as permission to remove unused code from
118+
inside the assembly, so an app that trims will now get a smaller `AngouriMath` rather than all of
119+
it. That is the point of the mark, and
120+
[`Docs/Contributing/Trimming.md`](Sources/AngouriMath/Docs/Contributing/Trimming.md) says what
121+
keeps it true.
122+
123+
[#363](https://github.com/asc-community/AngouriMath/issues/363),
124+
[#746](https://github.com/asc-community/AngouriMath/issues/746) item 79.
125+
97126
---
98127

99128
## 2.3.0 — since 2.2.0

‎Sources/.editorconfig‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,6 @@ file_header_template=\nCopyright (c) 2019-2026 Angouri.\nAngouriMath is licensed
8787

8888
[AngouriMath/Convenience/MathS.Polynomials.cs]
8989
file_header_template=\nCopyright (c) 2019-2026 Angouri.\nAngouriMath is licensed under MIT.\nDetails: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md.\nWebsite: https://am.angouri.org.\n
90+
91+
[Tests/AotSmokeTest/*.cs]
92+
file_header_template=\nCopyright (c) 2019-2026 Angouri.\nAngouriMath is licensed under MIT.\nDetails: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md.\nWebsite: https://am.angouri.org.\n

‎Sources/AngouriMath.sln‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "InteractiveWrapperUnitTests
3333
EndProject
3434
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DotnetBenchmark", "Tests\DotnetBenchmark\DotnetBenchmark.csproj", "{C61DD310-AF04-4B4F-8A34-59FFC8A78F41}"
3535
EndProject
36+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AotSmokeTest", "Tests\AotSmokeTest\AotSmokeTest.csproj", "{4A21F0B8-9C36-4E9D-B77E-5B84C7D8F2A1}"
37+
EndProject
3638
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "TerminalUnitTests", "Tests\TerminalUnitTests\TerminalUnitTests.fsproj", "{90552AEB-7563-4C06-A0B7-9F810A572E8A}"
3739
EndProject
3840
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Analyzers.CodeFixes", "Analyzers\Analyzers.CodeFixes\Analyzers.CodeFixes.csproj", "{9E5FEA3D-F9DE-2F38-D434-8C97DF80633F}"
@@ -119,6 +121,10 @@ Global
119121
{C61DD310-AF04-4B4F-8A34-59FFC8A78F41}.Debug|Any CPU.Build.0 = Debug|Any CPU
120122
{C61DD310-AF04-4B4F-8A34-59FFC8A78F41}.Release|Any CPU.ActiveCfg = Release|Any CPU
121123
{C61DD310-AF04-4B4F-8A34-59FFC8A78F41}.Release|Any CPU.Build.0 = Release|Any CPU
124+
{4A21F0B8-9C36-4E9D-B77E-5B84C7D8F2A1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
125+
{4A21F0B8-9C36-4E9D-B77E-5B84C7D8F2A1}.Debug|Any CPU.Build.0 = Debug|Any CPU
126+
{4A21F0B8-9C36-4E9D-B77E-5B84C7D8F2A1}.Release|Any CPU.ActiveCfg = Release|Any CPU
127+
{4A21F0B8-9C36-4E9D-B77E-5B84C7D8F2A1}.Release|Any CPU.Build.0 = Release|Any CPU
122128
{90552AEB-7563-4C06-A0B7-9F810A572E8A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
123129
{90552AEB-7563-4C06-A0B7-9F810A572E8A}.Debug|Any CPU.Build.0 = Debug|Any CPU
124130
{90552AEB-7563-4C06-A0B7-9F810A572E8A}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -183,6 +189,7 @@ Global
183189
{3C03B5FC-D0D8-4E52-B581-743D4211F819} = {9869C55D-A753-4DE2-9C22-CCF768C6EC53}
184190
{FDEE9428-74CB-44ED-BBD7-DAAC9CCBADCA} = {9869C55D-A753-4DE2-9C22-CCF768C6EC53}
185191
{C61DD310-AF04-4B4F-8A34-59FFC8A78F41} = {9869C55D-A753-4DE2-9C22-CCF768C6EC53}
192+
{4A21F0B8-9C36-4E9D-B77E-5B84C7D8F2A1} = {9869C55D-A753-4DE2-9C22-CCF768C6EC53}
186193
{90552AEB-7563-4C06-A0B7-9F810A572E8A} = {9869C55D-A753-4DE2-9C22-CCF768C6EC53}
187194
{9E5FEA3D-F9DE-2F38-D434-8C97DF80633F} = {358BFC2B-1A5B-4740-8BA1-0E53EFC7EDAC}
188195
{7C7DE223-D688-E7C3-1021-C86827910720} = {358BFC2B-1A5B-4740-8BA1-0E53EFC7EDAC}

‎Sources/AngouriMath/AngouriMath.csproj‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,23 @@
2121
<DocumentationFile>bin/$(Configuration)/$(TargetFramework)/$(MSBuildProjectName).xml</DocumentationFile>
2222
</PropertyGroup>
2323

24+
<!--
25+
The kernel is trimmable and NativeAOT-compatible, and Sources/Tests/AotSmokeTest is what says
26+
so: it publishes an app that parses, simplifies, solves, differentiates and compiles, both
27+
trimmed and NativeAOT, fails the build on any IL2xxx/IL3xxx warning, and then runs the binary.
28+
This claim is an assertion to consumers (a trimmer takes it as licence to remove code inside
29+
this assembly), so it may only be made while that gate passes.
30+
31+
IsAotCompatible is net8.0 and later (it implies IsTrimmable and turns on the three analyzers);
32+
netstandard2.0 has no ahead-of-time story, and IsTrimmable there would be a claim about a
33+
target framework where nothing checks it. Written as a compatibility test so that adding a
34+
framework to TargetFrameworks does not silently drop it.
35+
https://github.com/asc-community/AngouriMath/issues/363
36+
-->
37+
<PropertyGroup Condition="$([MSBuild]::IsTargetFrameworkCompatible($(TargetFramework), 'net8.0'))">
38+
<IsAotCompatible>true</IsAotCompatible>
39+
</PropertyGroup>
40+
2441
<PropertyGroup>
2542
<Nullable>enable</Nullable>
2643
<NoWarn>NU5104;CA2252</NoWarn>

‎Sources/AngouriMath/Docs/Contributing/README.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ If you aren't sure about what to add, you may want to check the current projects
1818
6. <a href="./CanonicalForm.md">What canonical form means here</a> — and how it differs from
1919
"simplest". Why no canonical form exists for the whole language, what one means per node class,
2020
and where the library measurably stands against it
21-
7. <a href="./coding_rules.md">Coding rules</a> — sealed-or-abstract, immutability, and what may be
21+
7. <a href="./Trimming.md">Trimming and NativeAOT</a> — what the kernel promises by being marked
22+
`IsAotCompatible`, what checks it, and the four shapes of run-time name lookup that break it
23+
8. <a href="./coding_rules.md">Coding rules</a> — sealed-or-abstract, immutability, and what may be
2224
made `public`
2325

2426
See also <a href="../../../../BREAKING-CHANGES.md">BREAKING-CHANGES.md</a>, where a change that makes
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Trimming and NativeAOT
2+
3+
The kernel is published with `IsAotCompatible`, which is a claim to consumers: a trimmer takes it
4+
as licence to remove code *inside* `AngouriMath.dll`, and the NativeAOT compiler keeps no
5+
reflection metadata for a member nothing referenced. The claim is only as good as what checks it,
6+
and what checks it is `Sources/Tests/AotSmokeTest`.
7+
8+
Run it the way CI does:
9+
10+
```
11+
dotnet publish Sources/Tests/AotSmokeTest/AotSmokeTest.csproj -c Release -r linux-x64 -o artifacts/trimmed
12+
./artifacts/trimmed/AotSmokeTest
13+
14+
dotnet publish Sources/Tests/AotSmokeTest/AotSmokeTest.csproj -c Release -r linux-x64 -p:AngouriMathPublishAot=true -o artifacts/aot
15+
./artifacts/aot/AotSmokeTest
16+
```
17+
18+
`-r` is required for both — trimming and ILC are per-runtime-identifier. On Linux the NativeAOT
19+
half also needs `clang` and `zlib1g-dev`; on Windows the MSVC build tools; on macOS the Xcode
20+
command line tools.
21+
22+
## Two things it checks, and they are not the same
23+
24+
**Publishing.** The project sets `TrimmerSingleWarn=false` so a warning names a line rather than an
25+
assembly, and `ILLinkTreatWarningsAsErrors` / `IlcTreatWarningsAsErrors` so any IL2xxx or IL3xxx
26+
from the kernel fails the publish. `TreatWarningsAsErrors` alone does not cover either of those:
27+
Roslyn, the trimmer and the NativeAOT compiler each have their own switch.
28+
29+
**Running.** The binary is then executed, and its exit code is the gate. This half is not
30+
redundant. Everything that goes wrong here goes wrong *silently at publish time*: a member removed
31+
because only a string named it produces no warning at all, and the failure is a `null` from
32+
`Type.GetMethod` or a "no coercion operator is defined between types" from `Expression.Convert`
33+
some number of calls later. Publishing proves nothing about that; running does.
34+
35+
The smoke test covers parse, `Simplify`, `Solve`, `Differentiate`, `Integrate`, `Limit` and
36+
`Compile`, asserts values rather than printed forms, and returns non-zero on any mismatch.
37+
38+
## What breaks a trimmed build
39+
40+
Anything that names a member at run time.
41+
42+
- `Type.GetMethod("Name")`, `Activator.CreateInstance(type)`, assembly scanning. Use a table
43+
generated beside what it dispatches to; `MathAllMethods.Definitions` is the worked example, and
44+
its entries are expression trees precisely because the compiler emits an `ldtoken` for the call
45+
inside one, which is how C# names a method without a string.
46+
- `Type.MakeGenericType` over a value type, which NativeAOT cannot promise (IL3050). Where the set
47+
of types is closed, write the closed set down: `CompilationProtocol.nullableForms`.
48+
- Letting `System.Linq.Expressions` find an operator or a conversion for you.
49+
`Expression.Add(l, r)` reflects over the operand type looking for `op_Addition`, and
50+
`Expression.Convert(e, t)` reflects over the target type looking for `op_Implicit`. For `double`
51+
and `long` those are IL instructions and there is nothing to find, but for
52+
`System.Numerics.Complex` and `BigInteger` they are methods, and a NativeAOT build had no
53+
metadata for them: `x + 1` compiled over a complex argument threw "the binary operator Add is not
54+
defined for the types 'System.Numerics.Complex' and 'System.Numerics.Complex'". Pass the method
55+
in — `CompilationProtocol.operators` and `CompilationProtocol.conversionOperators`.
56+
57+
`Expression.Compile()` itself is fine: under NativeAOT it falls back to the interpreter rather than
58+
emitting IL, so a compiled expression is slower there than under the JIT but it is not broken.
59+
See [#363](https://github.com/asc-community/AngouriMath/issues/363).
60+
61+
## When you add a node, a function or a protocol
62+
63+
Add the case to the smoke test as well. It is a few lines, it is the only place the trimmed and
64+
NativeAOT behaviour of the new path is measured, and the publish will not tell you it is missing.

0 commit comments

Comments
 (0)