From 284a4bc0fbd510670e5ccb0735bbb7e8f0dcc804 Mon Sep 17 00:00:00 2001 From: Rafael Vuijk Date: Sun, 23 Aug 2026 03:33:23 +0000 Subject: [PATCH 1/2] 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) Claude-Session: https://claude.ai/code/session_011emtnRT6EWTrxXNtqDVK3e --- Sources/Tests/DotnetBenchmark/BenchLinqCompilation.cs | 1 + Sources/Tests/DotnetBenchmark/CacheCompiledFunc.cs | 1 + Sources/Tests/DotnetBenchmark/Program.cs | 6 +++++- 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/Sources/Tests/DotnetBenchmark/BenchLinqCompilation.cs b/Sources/Tests/DotnetBenchmark/BenchLinqCompilation.cs index 5428e7ff4..b8b6dd2db 100644 --- a/Sources/Tests/DotnetBenchmark/BenchLinqCompilation.cs +++ b/Sources/Tests/DotnetBenchmark/BenchLinqCompilation.cs @@ -13,6 +13,7 @@ namespace DotnetBenchmark { + [MemoryDiagnoser] public class BenchLinqCompilation { public readonly Func NormalSimpleLambda = diff --git a/Sources/Tests/DotnetBenchmark/CacheCompiledFunc.cs b/Sources/Tests/DotnetBenchmark/CacheCompiledFunc.cs index bd2ba2018..316f07d50 100644 --- a/Sources/Tests/DotnetBenchmark/CacheCompiledFunc.cs +++ b/Sources/Tests/DotnetBenchmark/CacheCompiledFunc.cs @@ -14,6 +14,7 @@ namespace DotnetBenchmark { + [MemoryDiagnoser] public class CacheCompiledFunc { private readonly FastExpression complexFunc; diff --git a/Sources/Tests/DotnetBenchmark/Program.cs b/Sources/Tests/DotnetBenchmark/Program.cs index c7cdfcf4e..839838f08 100644 --- a/Sources/Tests/DotnetBenchmark/Program.cs +++ b/Sources/Tests/DotnetBenchmark/Program.cs @@ -87,6 +87,11 @@ public static void Main(string[] args) // them. See MatchingEngine for what the factor decides. "MatchingEngine" => GetReportByBenchmark(typeof(MatchingEngine), "Mean", "Error", "StdDev", "Ratio", "Allocated"), "CompiledFuncTest" => GetReportByBenchmark(typeof(CompiledFuncTest), "Mean", "Error", "StdDev"), + // The two compilation benchmarks existed as classes that nothing could + // run: one was a commented-out BenchmarkRunner.Run below, the other had + // no arm at all. A benchmark nobody can invoke measures nothing. + "BenchLinqCompilation" => GetReportByBenchmark(typeof(BenchLinqCompilation), "Mean", "Error", "StdDev", "Allocated"), + "CacheCompiledFunc" => GetReportByBenchmark(typeof(CacheCompiledFunc), "Mean", "Error", "StdDev", "Allocated"), "NumbersBenchmark" => GetReportByBenchmark(typeof(NumbersBenchmark), "Mean", "Error", "StdDev"), _ => throw new($"Unexpected benchmark {arg}") }).ToArray(); // active action @@ -103,7 +108,6 @@ public static void Main(string[] args) Console.WriteLine(); Console.WriteLine(); } - // BenchmarkRunner.Run(); Console.ReadLine(); Console.ReadLine(); Console.ReadLine(); } From 1a82bcbd6fa06fa903544a6b297d27d6352198f3 Mon Sep 17 00:00:00 2001 From: Rafael Vuijk Date: Sun, 23 Aug 2026 03:33:41 +0000 Subject: [PATCH 2/2] 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) Claude-Session: https://claude.ai/code/session_011emtnRT6EWTrxXNtqDVK3e --- .github/workflows/TrimmingAndAot.yml | 65 +++++ AGENTS.md | 1 + BREAKING-CHANGES.md | 38 +++ Sources/.editorconfig | 3 + Sources/AngouriMath.sln | 7 + Sources/AngouriMath/AngouriMath.csproj | 17 ++ .../AngouriMath/Docs/Contributing/README.md | 4 +- .../AngouriMath/Docs/Contributing/Trimming.md | 64 +++++ .../IntoLinq/CompilationProtocol.cs | 177 +++++++++++-- .../Compilation/IntoLinq/MathAllMethods.cs | 137 +++++++++- .../Compilation/IntoLinq/MathAllMethods.tt | 64 +++++ .../Tests/AotSmokeTest/AotSmokeTest.csproj | 69 +++++ Sources/Tests/AotSmokeTest/Program.cs | 243 ++++++++++++++++++ 13 files changed, 863 insertions(+), 26 deletions(-) create mode 100644 .github/workflows/TrimmingAndAot.yml create mode 100644 Sources/AngouriMath/Docs/Contributing/Trimming.md create mode 100644 Sources/Tests/AotSmokeTest/AotSmokeTest.csproj create mode 100644 Sources/Tests/AotSmokeTest/Program.cs diff --git a/.github/workflows/TrimmingAndAot.yml b/.github/workflows/TrimmingAndAot.yml new file mode 100644 index 000000000..618e7af4a --- /dev/null +++ b/.github/workflows/TrimmingAndAot.yml @@ -0,0 +1,65 @@ +name: 'Trimming & NativeAOT' + +# Deliberately without a paths: filter. What breaks a trimmed or NativeAOT build is a run-time +# name lookup, and one can be introduced by a change anywhere in the kernel, by a package bump, +# or by an SDK bump -- so there is no subset of the tree this gate could safely skip. Two +# workflows here have already been silenced by a filter that matched nothing +# (https://github.com/asc-community/AngouriMath/issues/775); an unconditional trigger cannot be. +on: + push: + branches: + - master + pull_request: + branches: + - '*' + workflow_dispatch: + +jobs: + Publish: + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + rid: linux-x64 + - os: windows-latest + rid: win-x64 + - os: macos-latest + rid: osx-arm64 + + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v6 + + - name: Setup .NET 10 + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '10.x' + dotnet-quality: 'preview' + + # ILC links the runtime into the binary and needs a native linker and zlib to do it. + - name: 'Install the native toolchain for Linux' + if: ${{ matrix.os == 'ubuntu-latest' }} + run: | + sudo apt-get update + sudo apt-get install -y clang zlib1g-dev + + # The project sets PublishTrimmed, TrimmerSingleWarn=false and ILLinkTreatWarningsAsErrors, + # so any IL2xxx from the kernel fails this step. There is no flag to forget on the command + # line and no way to read a warning as noise. + - name: 'Publish trimmed' + run: dotnet publish Sources/Tests/AotSmokeTest/AotSmokeTest.csproj -c Release -r ${{ matrix.rid }} -o artifacts/trimmed + + # Publishing is not the test. Everything the trimmer removed that the kernel reaches for by + # name is invisible until the binary runs, so the binary runs, and its exit code is the gate. + - name: 'Run the trimmed binary' + shell: bash + run: ./artifacts/trimmed/AotSmokeTest + + - name: 'Publish NativeAOT' + run: dotnet publish Sources/Tests/AotSmokeTest/AotSmokeTest.csproj -c Release -r ${{ matrix.rid }} -p:AngouriMathPublishAot=true -o artifacts/aot + + - name: 'Run the NativeAOT binary' + shell: bash + run: ./artifacts/aot/AotSmokeTest diff --git a/AGENTS.md b/AGENTS.md index 2201a1f9b..c5bd8cde0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -399,6 +399,7 @@ are short, and a stale one is worse than none — if you change what a file desc | [`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 | | [`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 | | [`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 | +| [`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 | | [`Contributing/coding_rules.md`](Sources/AngouriMath/Docs/Contributing/coding_rules.md) | sealed-or-abstract, and immutability of `Entity` | | [`WhatsNew/version_performance_control.md`](Sources/AngouriMath/Docs/WhatsNew/version_performance_control.md) | the inter-version performance table, and how to add a column | | `Sources/Analyzers/` | the custom analyzers, including the static-field one behind `[ConstantField]` | diff --git a/BREAKING-CHANGES.md b/BREAKING-CHANGES.md index c194d9725..a0a446e81 100644 --- a/BREAKING-CHANGES.md +++ b/BREAKING-CHANGES.md @@ -15,6 +15,44 @@ read first. --- +## Unreleased — since 2.3.0 + +### At a glance + +| Silent? | What | Was | Is | +|---|---|---|---| +| | any expression mixing a number with a `Complex` argument, `Compile`d in a NativeAOT app — `"x + 1".Compile("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 | +| | `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 | +| **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` | + +### `Compile` works in a trimmed or NativeAOT application + +The Linq compilation path found the method for each node by name — +`typeof(MathAllMethods).GetMethod(name, ...)` for the mathematical functions, +`expr.Type.GetMethod("IsNaN")` for the NaN test — and left the operators and conversions between +`Complex`, `BigInteger` and the primitives to `Expression.Add` and `Expression.Convert`, which find +them by reflecting over the operand type. A name resolved at run time is invisible to the trimmer, +so the members were removed and the lookups came back empty. All four are now tables of members +named at compile time: `MathAllMethods.Definitions` generated beside the methods it dispatches to, +and `nanChecks`, `operators` and `conversionOperators` in `CompilationProtocol`. + +Nothing changes under the JIT — the same `MethodInfo` reaches the same `Expression` node — and the +44 assertions of `Sources/Tests/AotSmokeTest` give byte-identical output under the JIT, a trimmed +publish and a NativeAOT publish. What changed is that the second and third of those now run at all: +before this, six of them threw and the seventh aborted the process. + +`AngouriMath` is marked `IsAotCompatible` for `net8.0` and later as a result, which is also the +silent half of the entry above: a trimmer reads that as permission to remove unused code from +inside the assembly, so an app that trims will now get a smaller `AngouriMath` rather than all of +it. That is the point of the mark, and +[`Docs/Contributing/Trimming.md`](Sources/AngouriMath/Docs/Contributing/Trimming.md) says what +keeps it true. + +[#363](https://github.com/asc-community/AngouriMath/issues/363), +[#746](https://github.com/asc-community/AngouriMath/issues/746) item 79. + +--- + ## 2.3.0 — since 2.2.0 ### At a glance diff --git a/Sources/.editorconfig b/Sources/.editorconfig index 7df11d04b..08686a858 100644 --- a/Sources/.editorconfig +++ b/Sources/.editorconfig @@ -81,3 +81,6 @@ file_header_template=\nCopyright (c) 2019-2026 Angouri.\nAngouriMath is licensed [Analyzers/RuleRegistryGenerator/*.cs] 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 + +[Tests/AotSmokeTest/*.cs] +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 diff --git a/Sources/AngouriMath.sln b/Sources/AngouriMath.sln index 2827231b4..8e196265e 100644 --- a/Sources/AngouriMath.sln +++ b/Sources/AngouriMath.sln @@ -33,6 +33,8 @@ Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "InteractiveWrapperUnitTests EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DotnetBenchmark", "Tests\DotnetBenchmark\DotnetBenchmark.csproj", "{C61DD310-AF04-4B4F-8A34-59FFC8A78F41}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AotSmokeTest", "Tests\AotSmokeTest\AotSmokeTest.csproj", "{4A21F0B8-9C36-4E9D-B77E-5B84C7D8F2A1}" +EndProject Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "TerminalUnitTests", "Tests\TerminalUnitTests\TerminalUnitTests.fsproj", "{90552AEB-7563-4C06-A0B7-9F810A572E8A}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Analyzers.CodeFixes", "Analyzers\Analyzers.CodeFixes\Analyzers.CodeFixes.csproj", "{9E5FEA3D-F9DE-2F38-D434-8C97DF80633F}" @@ -119,6 +121,10 @@ Global {C61DD310-AF04-4B4F-8A34-59FFC8A78F41}.Debug|Any CPU.Build.0 = Debug|Any CPU {C61DD310-AF04-4B4F-8A34-59FFC8A78F41}.Release|Any CPU.ActiveCfg = Release|Any CPU {C61DD310-AF04-4B4F-8A34-59FFC8A78F41}.Release|Any CPU.Build.0 = Release|Any CPU + {4A21F0B8-9C36-4E9D-B77E-5B84C7D8F2A1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4A21F0B8-9C36-4E9D-B77E-5B84C7D8F2A1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4A21F0B8-9C36-4E9D-B77E-5B84C7D8F2A1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4A21F0B8-9C36-4E9D-B77E-5B84C7D8F2A1}.Release|Any CPU.Build.0 = Release|Any CPU {90552AEB-7563-4C06-A0B7-9F810A572E8A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {90552AEB-7563-4C06-A0B7-9F810A572E8A}.Debug|Any CPU.Build.0 = Debug|Any CPU {90552AEB-7563-4C06-A0B7-9F810A572E8A}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -183,6 +189,7 @@ Global {3C03B5FC-D0D8-4E52-B581-743D4211F819} = {9869C55D-A753-4DE2-9C22-CCF768C6EC53} {FDEE9428-74CB-44ED-BBD7-DAAC9CCBADCA} = {9869C55D-A753-4DE2-9C22-CCF768C6EC53} {C61DD310-AF04-4B4F-8A34-59FFC8A78F41} = {9869C55D-A753-4DE2-9C22-CCF768C6EC53} + {4A21F0B8-9C36-4E9D-B77E-5B84C7D8F2A1} = {9869C55D-A753-4DE2-9C22-CCF768C6EC53} {90552AEB-7563-4C06-A0B7-9F810A572E8A} = {9869C55D-A753-4DE2-9C22-CCF768C6EC53} {9E5FEA3D-F9DE-2F38-D434-8C97DF80633F} = {358BFC2B-1A5B-4740-8BA1-0E53EFC7EDAC} {7C7DE223-D688-E7C3-1021-C86827910720} = {358BFC2B-1A5B-4740-8BA1-0E53EFC7EDAC} diff --git a/Sources/AngouriMath/AngouriMath.csproj b/Sources/AngouriMath/AngouriMath.csproj index dcc02ce36..693d146b5 100644 --- a/Sources/AngouriMath/AngouriMath.csproj +++ b/Sources/AngouriMath/AngouriMath.csproj @@ -21,6 +21,23 @@ bin/$(Configuration)/$(TargetFramework)/$(MSBuildProjectName).xml + + + true + + enable NU5104;CA2252 diff --git a/Sources/AngouriMath/Docs/Contributing/README.md b/Sources/AngouriMath/Docs/Contributing/README.md index 74587e3c9..8fc262f57 100644 --- a/Sources/AngouriMath/Docs/Contributing/README.md +++ b/Sources/AngouriMath/Docs/Contributing/README.md @@ -18,7 +18,9 @@ If you aren't sure about what to add, you may want to check the current projects 6. What canonical form means here — and how it differs from "simplest". Why no canonical form exists for the whole language, what one means per node class, and where the library measurably stands against it -7. Coding rules — sealed-or-abstract, immutability, and what may be +7. Trimming and NativeAOT — what the kernel promises by being marked + `IsAotCompatible`, what checks it, and the four shapes of run-time name lookup that break it +8. Coding rules — sealed-or-abstract, immutability, and what may be made `public` See also BREAKING-CHANGES.md, where a change that makes diff --git a/Sources/AngouriMath/Docs/Contributing/Trimming.md b/Sources/AngouriMath/Docs/Contributing/Trimming.md new file mode 100644 index 000000000..bd196a439 --- /dev/null +++ b/Sources/AngouriMath/Docs/Contributing/Trimming.md @@ -0,0 +1,64 @@ +# Trimming and NativeAOT + +The kernel is published with `IsAotCompatible`, which is a claim to consumers: a trimmer takes it +as licence to remove code *inside* `AngouriMath.dll`, and the NativeAOT compiler keeps no +reflection metadata for a member nothing referenced. The claim is only as good as what checks it, +and what checks it is `Sources/Tests/AotSmokeTest`. + +Run it the way CI does: + +``` +dotnet publish Sources/Tests/AotSmokeTest/AotSmokeTest.csproj -c Release -r linux-x64 -o artifacts/trimmed +./artifacts/trimmed/AotSmokeTest + +dotnet publish Sources/Tests/AotSmokeTest/AotSmokeTest.csproj -c Release -r linux-x64 -p:AngouriMathPublishAot=true -o artifacts/aot +./artifacts/aot/AotSmokeTest +``` + +`-r` is required for both — trimming and ILC are per-runtime-identifier. On Linux the NativeAOT +half also needs `clang` and `zlib1g-dev`; on Windows the MSVC build tools; on macOS the Xcode +command line tools. + +## Two things it checks, and they are not the same + +**Publishing.** The project sets `TrimmerSingleWarn=false` so a warning names a line rather than an +assembly, and `ILLinkTreatWarningsAsErrors` / `IlcTreatWarningsAsErrors` so any IL2xxx or IL3xxx +from the kernel fails the publish. `TreatWarningsAsErrors` alone does not cover either of those: +Roslyn, the trimmer and the NativeAOT compiler each have their own switch. + +**Running.** The binary is then executed, and its exit code is the gate. This half is not +redundant. Everything that goes wrong here goes wrong *silently at publish time*: a member removed +because only a string named it produces no warning at all, and the failure is a `null` from +`Type.GetMethod` or a "no coercion operator is defined between types" from `Expression.Convert` +some number of calls later. Publishing proves nothing about that; running does. + +The smoke test covers parse, `Simplify`, `Solve`, `Differentiate`, `Integrate`, `Limit` and +`Compile`, asserts values rather than printed forms, and returns non-zero on any mismatch. + +## What breaks a trimmed build + +Anything that names a member at run time. + +- `Type.GetMethod("Name")`, `Activator.CreateInstance(type)`, assembly scanning. Use a table + generated beside what it dispatches to; `MathAllMethods.Definitions` is the worked example, and + its entries are expression trees precisely because the compiler emits an `ldtoken` for the call + inside one, which is how C# names a method without a string. +- `Type.MakeGenericType` over a value type, which NativeAOT cannot promise (IL3050). Where the set + of types is closed, write the closed set down: `CompilationProtocol.nullableForms`. +- Letting `System.Linq.Expressions` find an operator or a conversion for you. + `Expression.Add(l, r)` reflects over the operand type looking for `op_Addition`, and + `Expression.Convert(e, t)` reflects over the target type looking for `op_Implicit`. For `double` + and `long` those are IL instructions and there is nothing to find, but for + `System.Numerics.Complex` and `BigInteger` they are methods, and a NativeAOT build had no + metadata for them: `x + 1` compiled over a complex argument threw "the binary operator Add is not + defined for the types 'System.Numerics.Complex' and 'System.Numerics.Complex'". Pass the method + in — `CompilationProtocol.operators` and `CompilationProtocol.conversionOperators`. + +`Expression.Compile()` itself is fine: under NativeAOT it falls back to the interpreter rather than +emitting IL, so a compiled expression is slower there than under the JIT but it is not broken. +See [#363](https://github.com/asc-community/AngouriMath/issues/363). + +## When you add a node, a function or a protocol + +Add the case to the smoke test as well. It is a few lines, it is the only place the trimmed and +NativeAOT behaviour of the new path is measured, and the publish will not tell you it is missing. diff --git a/Sources/AngouriMath/Functions/Compilation/IntoLinq/CompilationProtocol.cs b/Sources/AngouriMath/Functions/Compilation/IntoLinq/CompilationProtocol.cs index cdc54dc25..0a693948d 100644 --- a/Sources/AngouriMath/Functions/Compilation/IntoLinq/CompilationProtocol.cs +++ b/Sources/AngouriMath/Functions/Compilation/IntoLinq/CompilationProtocol.cs @@ -85,20 +85,21 @@ public virtual Expression ConvertType(Expression expr, Type type) { return Expression.Condition(Expression.Equal(expr, Expression.Constant(null, expr.Type)), ConvertNaN(type), - Expression.Convert(expr, type)); + Coerce(expr, type)); } // ex. double -> long? if (!exprNullable && exprNaNisNaN && typeNullable && !typeNaNisNaN) { - MethodInfo isNaN = expr.Type.GetMethod("IsNaN") ?? throw new AngouriBugException($"IsNaN method expected for type {expr.Type}"); - + if (!nanChecks.TryGetValue(expr.Type, out var isNaN)) + throw new AngouriBugException($"IsNaN method expected for type {expr.Type}"); + return Expression.Condition(Expression.Call(isNaN, expr), Expression.Constant(null, type), - Expression.Convert(expr, type)); + Coerce(expr, type)); } - return Expression.Convert(expr, type); + return Coerce(expr, type); } /// @@ -158,16 +159,18 @@ public virtual Expression ConvertBinaryNode(Expression left, Expression right, E (left, right) = EqualizeTypesIfAble(left, right); return typeHolder switch { - Sumf => Expression.Add(left, right), - Minusf => Expression.Subtract(left, right), - Mulf => Expression.Multiply(left, right), - Divf => Expression.Divide(ShouldBeAtLeastDouble(left), ShouldBeAtLeastDouble(right)), + Sumf => Operator(ExpressionType.Add, left, right), + Minusf => Operator(ExpressionType.Subtract, left, right), + Mulf => Operator(ExpressionType.Multiply, left, right), + Divf => Operator(ExpressionType.Divide, ShouldBeAtLeastDouble(left), ShouldBeAtLeastDouble(right)), // ((a % b) + b) % b, and not the bare a % b: the runtime truncates, where the // node is the floored remainder that takes the sign of the divisor, and this is // the shortest expression that turns the one into the other for every pair of // signs. Not widened to double the way division is -- the remainder of two // integers is an integer, and widening would only lose that. - Modf => Expression.Modulo(Expression.Add(Expression.Modulo(left, right), right), right), + Modf => Operator(ExpressionType.Modulo, + Operator(ExpressionType.Add, Operator(ExpressionType.Modulo, left, right), right), + right), Powf when ShouldBeAtLeastDouble(left) is var newLeft && ShouldBeAtLeastDouble(right) is var newRight @@ -182,11 +185,11 @@ Logf when Xorf => Expression.ExclusiveOr(left, right), Impliesf => Expression.Or(Expression.Not(left), right), - Lessf => Expression.LessThan(left, right), - LessOrEqualf => Expression.LessThanOrEqual(left, right), - Greaterf => Expression.GreaterThan(left, right), - GreaterOrEqualf => Expression.GreaterThanOrEqual(left, right), - Equalsf => Expression.Equal(left, right), + Lessf => Operator(ExpressionType.LessThan, left, right), + LessOrEqualf => Operator(ExpressionType.LessThanOrEqual, left, right), + Greaterf => Operator(ExpressionType.GreaterThan, left, right), + GreaterOrEqualf => Operator(ExpressionType.GreaterThanOrEqual, left, right), + Equalsf => Operator(ExpressionType.Equal, left, right), Providedf => HandleProvidedf(left, right), @@ -267,17 +270,139 @@ private static object DownCast(Number num) } - private static Type[] GenerateArrayOfType(int argCount, Type type) + // MathAllMethods is a generated table of typed overloads, and this used to reach it with + // typeof(MathAllMethods).GetMethod(name, ...) -- a name and a signature resolved at run + // time. Both the trimmer and the NativeAOT compiler answer that by keeping every public + // method of the class, used or not, since neither can tell which name will be asked for; + // and the run paid an overload resolution per node of every expression compiled. The + // dictionary is generated beside the methods, so the two cannot drift apart. + // https://github.com/asc-community/AngouriMath/issues/363 + private static MethodInfo GetDef(string name, int argCount, Type type) + => MathAllMethods.Definitions.TryGetValue((name, argCount, type), out var method) + ? method + : throw new AngouriBugException($"No compiled definition of {name} taking {argCount} argument(s) of type {type}"); + + /// + /// The types whose NaN is a value rather than -- the only ones for + /// which ever needs to ask whether a value is NaN. Named rather + /// than found by Type.GetMethod("IsNaN"), which a trimmed build has no way to keep. + /// + [ConstantField] private static readonly Dictionary nanChecks = new() + { + { typeof(System.Numerics.Complex), Named(v => MathAllMethods.IsNaN(v)) }, + { typeof(double), Named(v => double.IsNaN(v)) }, + { typeof(float), Named(v => float.IsNaN(v)) }, + }; + + /// + /// The user-defined conversion operators between the types in + /// . + /// finds one by reflecting over the target type, and a trimmed or NativeAOT build has no + /// metadata for an operator nothing referenced: double -> Complex came back as "no + /// coercion operator is defined between types", so a compiled expression mixing an integer + /// literal with a complex argument threw where the JIT build answered. Naming them keeps + /// them, and spares the lookup. + /// https://github.com/asc-community/AngouriMath/issues/363 + /// + [ConstantField] private static readonly Dictionary<(Type From, Type To), MethodInfo> conversionOperators = new() + { + { (typeof(int), typeof(System.Numerics.Complex)), Conv(v => (System.Numerics.Complex)v) }, + { (typeof(long), typeof(System.Numerics.Complex)), Conv(v => (System.Numerics.Complex)v) }, + { (typeof(float), typeof(System.Numerics.Complex)), Conv(v => (System.Numerics.Complex)v) }, + { (typeof(double), typeof(System.Numerics.Complex)), Conv(v => (System.Numerics.Complex)v) }, + { (typeof(BigInteger), typeof(System.Numerics.Complex)), Conv(v => (System.Numerics.Complex)v) }, + + { (typeof(int), typeof(BigInteger)), Conv(v => (BigInteger)v) }, + { (typeof(long), typeof(BigInteger)), Conv(v => (BigInteger)v) }, + { (typeof(float), typeof(BigInteger)), Conv(v => (BigInteger)v) }, + { (typeof(double), typeof(BigInteger)), Conv(v => (BigInteger)v) }, + + { (typeof(BigInteger), typeof(int)), Conv(v => (int)v) }, + { (typeof(BigInteger), typeof(long)), Conv(v => (long)v) }, + { (typeof(BigInteger), typeof(float)), Conv(v => (float)v) }, + { (typeof(BigInteger), typeof(double)), Conv(v => (double)v) }, + }; + + /// + /// The nullable form of every type in that has one, in + /// place of typeof(Nullable<>).MakeGenericType(t): constructing a generic over + /// a value type at run time is the one thing NativeAOT cannot promise (IL3050), and the + /// set of types this can be asked for is closed and six long. + /// + [ConstantField] private static readonly Dictionary nullableForms = new() + { + { typeof(System.Numerics.Complex), typeof(System.Numerics.Complex?) }, + { typeof(double), typeof(double?) }, + { typeof(float), typeof(float?) }, + { typeof(long), typeof(long?) }, + { typeof(BigInteger), typeof(BigInteger?) }, + { typeof(int), typeof(int?) }, + }; + + // An expression tree is how C# names a method without a string: the compiler emits an + // ldtoken for the call or the conversion inside it, which resolves the overload at compile + // time and roots the member for the trimmer. + private static MethodInfo Named(Expression> e) + => ((MethodCallExpression)e.Body).Method; + + private static MethodInfo Conv(Expression> e) + => ((UnaryExpression)e.Body).Method + ?? throw new AngouriBugException($"{typeof(TFrom)} -> {typeof(TTo)} is not a user-defined conversion"); + + /// + /// The operators and define + /// as methods. Expression.Add and its neighbours look one up by reflecting over the + /// operand type, which is the same hole as the conversions above: NativeAOT kept no + /// metadata for Complex.op_Addition, so x + 1 compiled over a complex + /// argument threw "the binary operator Add is not defined". The built-in numeric types are + /// absent on purpose -- their operators are IL instructions and there is nothing to find. + /// https://github.com/asc-community/AngouriMath/issues/363 + /// + [ConstantField] private static readonly Dictionary<(ExpressionType Kind, Type Type), MethodInfo> operators = new() + { + { (ExpressionType.Add, typeof(System.Numerics.Complex)), Op((a, b) => a + b) }, + { (ExpressionType.Subtract, typeof(System.Numerics.Complex)), Op((a, b) => a - b) }, + { (ExpressionType.Multiply, typeof(System.Numerics.Complex)), Op((a, b) => a * b) }, + { (ExpressionType.Divide, typeof(System.Numerics.Complex)), Op((a, b) => a / b) }, + { (ExpressionType.Equal, typeof(System.Numerics.Complex)), Cmp((a, b) => a == b) }, + + { (ExpressionType.Add, typeof(BigInteger)), Op((a, b) => a + b) }, + { (ExpressionType.Subtract, typeof(BigInteger)), Op((a, b) => a - b) }, + { (ExpressionType.Multiply, typeof(BigInteger)), Op((a, b) => a * b) }, + { (ExpressionType.Divide, typeof(BigInteger)), Op((a, b) => a / b) }, + { (ExpressionType.Modulo, typeof(BigInteger)), Op((a, b) => a % b) }, + { (ExpressionType.Equal, typeof(BigInteger)), Cmp((a, b) => a == b) }, + { (ExpressionType.LessThan, typeof(BigInteger)), Cmp((a, b) => a < b) }, + { (ExpressionType.LessThanOrEqual, typeof(BigInteger)), Cmp((a, b) => a <= b) }, + { (ExpressionType.GreaterThan, typeof(BigInteger)), Cmp((a, b) => a > b) }, + { (ExpressionType.GreaterThanOrEqual, typeof(BigInteger)), Cmp((a, b) => a >= b) }, + }; + + private static MethodInfo Op(Expression> e) + => ((BinaryExpression)e.Body).Method + ?? throw new AngouriBugException($"{typeof(T)} defines this operator as an instruction, not a method"); + + private static MethodInfo Cmp(Expression> e) + => ((BinaryExpression)e.Body).Method + ?? throw new AngouriBugException($"{typeof(T)} defines this operator as an instruction, not a method"); + + // MakeBinary with a null method is exactly what Expression.Add and its neighbours do, so a + // type that is not in the table is built the way it always was. liftToNull: false is their + // default too, and MakeBinary ignores it for everything but the comparisons. + private static Expression Operator(ExpressionType kind, Expression left, Expression right) + => Expression.MakeBinary( + kind, left, right, liftToNull: false, + method: operators.TryGetValue((kind, Nullable.GetUnderlyingType(left.Type) ?? left.Type), out var op) ? op : null); + + private static Expression Coerce(Expression expr, Type type) { - var l = new List(); - for (int i = 0; i < argCount; i++) - l.Add(type); - return l.ToArray(); + var from = Nullable.GetUnderlyingType(expr.Type) ?? expr.Type; + var to = Nullable.GetUnderlyingType(type) ?? type; + return conversionOperators.TryGetValue((from, to), out var op) + ? Expression.Convert(expr, type, op) + : Expression.Convert(expr, type); } - private static MethodInfo GetDef(string name, int argCount, Type type) - => typeof(MathAllMethods).GetMethod(name, GenerateArrayOfType(argCount, type)) ?? throw new AngouriBugException("No null expected"); - [ConstantField] private static readonly Dictionary typeLevelInHierarchy = new() { @@ -323,7 +448,11 @@ private static Type MaxType(Type a, Type b) var typeToCastTo = MaxType(leftType, rightType); if (nullable && ((ConstantExpression)ConvertNaN(typeToCastTo)).Value == null) { - typeToCastTo = typeof(Nullable<>).MakeGenericType(typeToCastTo); + // object is the one key of typeLevelInHierarchy with no nullable form, and it + // cannot arrive here: nullable is only true when one side unwrapped to a value + // type, whose level is above object's, so MaxType never returns object. + if (nullableForms.TryGetValue(typeToCastTo, out var nullableForm)) + typeToCastTo = nullableForm; } left = ConvertType(left, typeToCastTo); diff --git a/Sources/AngouriMath/Functions/Compilation/IntoLinq/MathAllMethods.cs b/Sources/AngouriMath/Functions/Compilation/IntoLinq/MathAllMethods.cs index 846f1d203..d95112125 100644 --- a/Sources/AngouriMath/Functions/Compilation/IntoLinq/MathAllMethods.cs +++ b/Sources/AngouriMath/Functions/Compilation/IntoLinq/MathAllMethods.cs @@ -1,4 +1,5 @@ -// + +// // Copyright (c) 2019-2022 Angouri. // AngouriMath is licensed under MIT. // Details: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md. @@ -8,7 +9,10 @@ /* THIS FILE IS AUTO-GENERATED */ using System; +using System.Collections.Generic; +using System.Linq.Expressions; using System.Numerics; +using System.Reflection; namespace AngouriMath.Core.Compilation.IntoLinq { @@ -243,5 +247,136 @@ public static BigInteger Sgn(BigInteger a) return -1; return 0; } + + /// + /// True where System.Numerics.Complex.IsNaN is, spelled out because that overload is + /// .NET 7 and later and this assembly also targets netstandard2.0. A complex number is NaN + /// when neither part is infinite and at least one of them is not finite -- (NaN, +oo) is an + /// infinity, not a NaN. + /// + public static bool IsNaN(System.Numerics.Complex a) + => !(double.IsInfinity(a.Real) || double.IsInfinity(a.Imaginary)) + && !(IsFinite(a.Real) && IsFinite(a.Imaginary)); + + private static bool IsFinite(double d) => !double.IsNaN(d) && !double.IsInfinity(d); + + /// + /// What dispatches through, in place of looking a method + /// up on this class by name at run time. A name resolved at run time is invisible to the + /// trimmer and to the NativeAOT compiler, which is what + /// https://github.com/asc-community/AngouriMath/issues/363 is about; every entry here is a + /// direct reference the compiler emits an ldtoken for, so the method is reachable by + /// construction. It is also a dictionary lookup rather than an overload resolution, which + /// is what it costs per node of every compiled expression. + /// + [ConstantField] + internal static readonly Dictionary<(string Name, int ArgCount, Type Type), MethodInfo> Definitions = new() + { + { ("Log", 2, typeof(System.Numerics.Complex)), Def((a, b) => Log(a, b)) }, + { ("Log", 2, typeof(double)), Def((a, b) => Log(a, b)) }, + { ("Log", 2, typeof(float)), Def((a, b) => Log(a, b)) }, + { ("Log", 2, typeof(long)), Def((a, b) => Log(a, b)) }, + { ("Log", 2, typeof(int)), Def((a, b) => Log(a, b)) }, + { ("Log", 2, typeof(BigInteger)), Def((a, b) => Log(a, b)) }, + { ("Pow", 2, typeof(System.Numerics.Complex)), Def((a, b) => Pow(a, b)) }, + { ("Pow", 2, typeof(double)), Def((a, b) => Pow(a, b)) }, + { ("Pow", 2, typeof(float)), Def((a, b) => Pow(a, b)) }, + { ("Pow", 2, typeof(long)), Def((a, b) => Pow(a, b)) }, + { ("Pow", 2, typeof(int)), Def((a, b) => Pow(a, b)) }, + { ("Pow", 2, typeof(BigInteger)), Def((a, b) => Pow(a, b)) }, + { ("Sin", 1, typeof(System.Numerics.Complex)), Def(a => Sin(a)) }, + { ("Sin", 1, typeof(double)), Def(a => Sin(a)) }, + { ("Sin", 1, typeof(float)), Def(a => Sin(a)) }, + { ("Sin", 1, typeof(long)), Def(a => Sin(a)) }, + { ("Sin", 1, typeof(int)), Def(a => Sin(a)) }, + { ("Sin", 1, typeof(BigInteger)), Def(a => Sin(a)) }, + { ("Cos", 1, typeof(System.Numerics.Complex)), Def(a => Cos(a)) }, + { ("Cos", 1, typeof(double)), Def(a => Cos(a)) }, + { ("Cos", 1, typeof(float)), Def(a => Cos(a)) }, + { ("Cos", 1, typeof(long)), Def(a => Cos(a)) }, + { ("Cos", 1, typeof(int)), Def(a => Cos(a)) }, + { ("Cos", 1, typeof(BigInteger)), Def(a => Cos(a)) }, + { ("Tan", 1, typeof(System.Numerics.Complex)), Def(a => Tan(a)) }, + { ("Tan", 1, typeof(double)), Def(a => Tan(a)) }, + { ("Tan", 1, typeof(float)), Def(a => Tan(a)) }, + { ("Tan", 1, typeof(long)), Def(a => Tan(a)) }, + { ("Tan", 1, typeof(int)), Def(a => Tan(a)) }, + { ("Tan", 1, typeof(BigInteger)), Def(a => Tan(a)) }, + { ("Asin", 1, typeof(System.Numerics.Complex)), Def(a => Asin(a)) }, + { ("Asin", 1, typeof(double)), Def(a => Asin(a)) }, + { ("Asin", 1, typeof(float)), Def(a => Asin(a)) }, + { ("Asin", 1, typeof(long)), Def(a => Asin(a)) }, + { ("Asin", 1, typeof(int)), Def(a => Asin(a)) }, + { ("Asin", 1, typeof(BigInteger)), Def(a => Asin(a)) }, + { ("Acos", 1, typeof(System.Numerics.Complex)), Def(a => Acos(a)) }, + { ("Acos", 1, typeof(double)), Def(a => Acos(a)) }, + { ("Acos", 1, typeof(float)), Def(a => Acos(a)) }, + { ("Acos", 1, typeof(long)), Def(a => Acos(a)) }, + { ("Acos", 1, typeof(int)), Def(a => Acos(a)) }, + { ("Acos", 1, typeof(BigInteger)), Def(a => Acos(a)) }, + { ("Atan", 1, typeof(System.Numerics.Complex)), Def(a => Atan(a)) }, + { ("Atan", 1, typeof(double)), Def(a => Atan(a)) }, + { ("Atan", 1, typeof(float)), Def(a => Atan(a)) }, + { ("Atan", 1, typeof(long)), Def(a => Atan(a)) }, + { ("Atan", 1, typeof(int)), Def(a => Atan(a)) }, + { ("Atan", 1, typeof(BigInteger)), Def(a => Atan(a)) }, + { ("Cot", 1, typeof(System.Numerics.Complex)), Def(a => Cot(a)) }, + { ("Cot", 1, typeof(double)), Def(a => Cot(a)) }, + { ("Cot", 1, typeof(float)), Def(a => Cot(a)) }, + { ("Cot", 1, typeof(long)), Def(a => Cot(a)) }, + { ("Cot", 1, typeof(int)), Def(a => Cot(a)) }, + { ("Cot", 1, typeof(BigInteger)), Def(a => Cot(a)) }, + { ("Sec", 1, typeof(System.Numerics.Complex)), Def(a => Sec(a)) }, + { ("Sec", 1, typeof(double)), Def(a => Sec(a)) }, + { ("Sec", 1, typeof(float)), Def(a => Sec(a)) }, + { ("Sec", 1, typeof(long)), Def(a => Sec(a)) }, + { ("Sec", 1, typeof(int)), Def(a => Sec(a)) }, + { ("Sec", 1, typeof(BigInteger)), Def(a => Sec(a)) }, + { ("Csc", 1, typeof(System.Numerics.Complex)), Def(a => Csc(a)) }, + { ("Csc", 1, typeof(double)), Def(a => Csc(a)) }, + { ("Csc", 1, typeof(float)), Def(a => Csc(a)) }, + { ("Csc", 1, typeof(long)), Def(a => Csc(a)) }, + { ("Csc", 1, typeof(int)), Def(a => Csc(a)) }, + { ("Csc", 1, typeof(BigInteger)), Def(a => Csc(a)) }, + { ("Acot", 1, typeof(System.Numerics.Complex)), Def(a => Acot(a)) }, + { ("Acot", 1, typeof(double)), Def(a => Acot(a)) }, + { ("Acot", 1, typeof(float)), Def(a => Acot(a)) }, + { ("Acot", 1, typeof(long)), Def(a => Acot(a)) }, + { ("Acot", 1, typeof(int)), Def(a => Acot(a)) }, + { ("Acot", 1, typeof(BigInteger)), Def(a => Acot(a)) }, + { ("Asec", 1, typeof(System.Numerics.Complex)), Def(a => Asec(a)) }, + { ("Asec", 1, typeof(double)), Def(a => Asec(a)) }, + { ("Asec", 1, typeof(float)), Def(a => Asec(a)) }, + { ("Asec", 1, typeof(long)), Def(a => Asec(a)) }, + { ("Asec", 1, typeof(int)), Def(a => Asec(a)) }, + { ("Asec", 1, typeof(BigInteger)), Def(a => Asec(a)) }, + { ("Acsc", 1, typeof(System.Numerics.Complex)), Def(a => Acsc(a)) }, + { ("Acsc", 1, typeof(double)), Def(a => Acsc(a)) }, + { ("Acsc", 1, typeof(float)), Def(a => Acsc(a)) }, + { ("Acsc", 1, typeof(long)), Def(a => Acsc(a)) }, + { ("Acsc", 1, typeof(int)), Def(a => Acsc(a)) }, + { ("Acsc", 1, typeof(BigInteger)), Def(a => Acsc(a)) }, + { ("Abs", 1, typeof(System.Numerics.Complex)), Def(a => Abs(a)) }, + { ("Abs", 1, typeof(double)), Def(a => Abs(a)) }, + { ("Abs", 1, typeof(float)), Def(a => Abs(a)) }, + { ("Abs", 1, typeof(long)), Def(a => Abs(a)) }, + { ("Abs", 1, typeof(int)), Def(a => Abs(a)) }, + { ("Abs", 1, typeof(BigInteger)), Def(a => Abs(a)) }, + { ("Sgn", 1, typeof(System.Numerics.Complex)), Def(a => Sgn(a)) }, + { ("Sgn", 1, typeof(double)), Def(a => Sgn(a)) }, + { ("Sgn", 1, typeof(float)), Def(a => Sgn(a)) }, + { ("Sgn", 1, typeof(long)), Def(a => Sgn(a)) }, + { ("Sgn", 1, typeof(int)), Def(a => Sgn(a)) }, + { ("Sgn", 1, typeof(BigInteger)), Def(a => Sgn(a)) }, + }; + + // An expression tree is how C# spells "the MethodInfo of this method" without a string: + // the compiler emits ldtoken for the call it contains, which both picks the overload at + // compile time and roots the method for the trimmer. + private static MethodInfo Def(Expression> e) + => ((MethodCallExpression)e.Body).Method; + + private static MethodInfo Def(Expression> e) + => ((MethodCallExpression)e.Body).Method; } } diff --git a/Sources/AngouriMath/Functions/Compilation/IntoLinq/MathAllMethods.tt b/Sources/AngouriMath/Functions/Compilation/IntoLinq/MathAllMethods.tt index 7dde7c3a7..581b632f2 100644 --- a/Sources/AngouriMath/Functions/Compilation/IntoLinq/MathAllMethods.tt +++ b/Sources/AngouriMath/Functions/Compilation/IntoLinq/MathAllMethods.tt @@ -1,11 +1,30 @@  <#@ template debug="false" hostspecific="false" language="C#" #> <#@ output extension=".cs" #> +<# + // The one-argument functions, in the order they are defined below. Both the definitions and + // the dispatch table at the bottom are generated from these, so a function cannot be added + // to one and forgotten in the other. + var unary = new[]{ "Sin", "Cos", "Tan", "Asin", "Acos", "Atan", + "Cot", "Sec", "Csc", "Acot", "Asec", "Acsc", + "Abs", "Sgn" }; + var binary = new[]{ "Log", "Pow" }; + var types = new[]{ "System.Numerics.Complex", "double", "float", "long", "int", "BigInteger" }; +#> +// +// Copyright (c) 2019-2022 Angouri. +// AngouriMath is licensed under MIT. +// Details: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md. +// Website: https://am.angouri.org. +// /* THIS FILE IS AUTO-GENERATED */ using System; +using System.Collections.Generic; +using System.Linq.Expressions; using System.Numerics; +using System.Reflection; namespace AngouriMath.Core.Compilation.IntoLinq { @@ -128,5 +147,50 @@ namespace AngouriMath.Core.Compilation.IntoLinq return -1; return 0; } + + /// + /// True where System.Numerics.Complex.IsNaN is, spelled out because that overload is + /// .NET 7 and later and this assembly also targets netstandard2.0. A complex number is NaN + /// when neither part is infinite and at least one of them is not finite -- (NaN, +oo) is an + /// infinity, not a NaN. + /// + public static bool IsNaN(System.Numerics.Complex a) + => !(double.IsInfinity(a.Real) || double.IsInfinity(a.Imaginary)) + && !(IsFinite(a.Real) && IsFinite(a.Imaginary)); + + private static bool IsFinite(double d) => !double.IsNaN(d) && !double.IsInfinity(d); + + /// + /// What dispatches through, in place of looking a method + /// up on this class by name at run time. A name resolved at run time is invisible to the + /// trimmer and to the NativeAOT compiler, which is what + /// https://github.com/asc-community/AngouriMath/issues/363 is about; every entry here is a + /// direct reference the compiler emits an ldtoken for, so the method is reachable by + /// construction. It is also a dictionary lookup rather than an overload resolution, which + /// is what it costs per node of every compiled expression. + /// + [ConstantField] + internal static readonly Dictionary<(string Name, int ArgCount, Type Type), MethodInfo> Definitions = new() + { +<# foreach (var name in binary) { #> +<# foreach (var type in types) { #> + { ("<#= name #>", 2, typeof(<#= type #>)), Def<<#= type #>>((a, b) => <#= name #>(a, b)) }, +<# } #> +<# } #> +<# foreach (var name in unary) { #> +<# foreach (var type in types) { #> + { ("<#= name #>", 1, typeof(<#= type #>)), Def<<#= type #>>(a => <#= name #>(a)) }, +<# } #> +<# } #> + }; + + // An expression tree is how C# spells "the MethodInfo of this method" without a string: + // the compiler emits ldtoken for the call it contains, which both picks the overload at + // compile time and roots the method for the trimmer. + private static MethodInfo Def(Expression> e) + => ((MethodCallExpression)e.Body).Method; + + private static MethodInfo Def(Expression> e) + => ((MethodCallExpression)e.Body).Method; } } diff --git a/Sources/Tests/AotSmokeTest/AotSmokeTest.csproj b/Sources/Tests/AotSmokeTest/AotSmokeTest.csproj new file mode 100644 index 000000000..cf8b3d448 --- /dev/null +++ b/Sources/Tests/AotSmokeTest/AotSmokeTest.csproj @@ -0,0 +1,69 @@ + + + + + + Exe + net10.0 + 14.0 + enable + false + AotSmokeTest + AngouriMath.AotSmokeTest + true + + + + + true + full + + + true + + + false + false + + true + true + true + + + true + true + true + + + + + + + diff --git a/Sources/Tests/AotSmokeTest/Program.cs b/Sources/Tests/AotSmokeTest/Program.cs new file mode 100644 index 000000000..d4f6a41ec --- /dev/null +++ b/Sources/Tests/AotSmokeTest/Program.cs @@ -0,0 +1,243 @@ +// +// Copyright (c) 2019-2026 Angouri. +// AngouriMath is licensed under MIT. +// Details: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md. +// Website: https://am.angouri.org. +// + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Numerics; +using AngouriMath; +using AngouriMath.Extensions; + +namespace AngouriMath.AotSmokeTest +{ + /// + /// Publishes trimmed and NativeAOT, then runs. Anything the kernel reaches for at run time + /// that the trimmer removed shows up here as an exception rather than as a warning, which is + /// why this program is run and not merely built. + /// + internal static class Program + { + private static int failed; + private static int passed; + + private static int Main() + { + Console.WriteLine($"AngouriMath AOT/trimming smoke test, runtime {System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription}"); + Console.WriteLine(); + + Parsing(); + Simplification(); + Solving(); + Differentiation(); + Integration(); + Limits(); + Compilation(); + + Console.WriteLine(); + Console.WriteLine($"passed: {passed}, failed: {failed}"); + if (failed > 0) + Console.WriteLine("FAILED"); + else + Console.WriteLine("OK"); + return failed > 0 ? 1 : 0; + } + + private static void Section(string name) + { + Console.WriteLine($"--- {name}"); + } + + private static void Check(string what, bool ok, string detail) + { + if (ok) + { + passed++; + Console.WriteLine($" pass {what}: {detail}"); + } + else + { + failed++; + Console.WriteLine($" FAIL {what}: {detail}"); + } + } + + private static void CheckClose(string what, Complex actual, Complex expected, double tolerance = 1e-9) + { + var ok = Complex.Abs(actual - expected) <= tolerance * Math.Max(1, Complex.Abs(expected)); + Check(what, ok, $"{Show(actual)} (expected {Show(expected)})"); + } + + private static string Show(Complex c) + => c.Imaginary == 0 + ? c.Real.ToString("G17", CultureInfo.InvariantCulture) + : c.Real.ToString("G17", CultureInfo.InvariantCulture) + " + " + c.Imaginary.ToString("G17", CultureInfo.InvariantCulture) + "i"; + + // Value at a point, not printed form: a smoke test that asserted strings would fail on a + // reordering that the trimmer had nothing to do with. + private static Complex At(Entity expr, string variable, Complex value) + => expr.Substitute(variable, (Entity.Number.Complex)value).EvalNumerical().ToNumerics(); + + private static void Parsing() + { + Section("parse"); + + var parsed = MathS.FromString("x ^ 2 + 2 * x + 1"); + Check("parses to a sum", parsed is Entity.Sumf, parsed.GetType().Name); + CheckClose("value at x = 3", At(parsed, "x", 3), 16); + + // The parser is ANTLR-generated and the lexer tables are static data the trimmer has + // to keep; a trimmed build that lost them throws here rather than at load. + var set = MathS.FromString("{ 1, 2, 3 } \\/ { 3, 5 }").InnerSimplified; + Check("set union parses and evaluates", set is Entity.Set.FiniteSet { Count: 4 }, set.Stringize()); + + var piecewise = MathS.FromString("piecewise(1 provided x > 0, 0 provided x <= 0)"); + CheckClose("piecewise at x = 2", At(piecewise, "x", 2), 1); + } + + private static void Simplification() + { + Section("Simplify"); + + var simplified = "(x ^ 2 - 1) / (x - 1)".Simplify(); + CheckClose("(x2 - 1)/(x - 1) at x = 5", At(simplified, "x", 5), 6); + Check("(x2 - 1)/(x - 1) has no division left", !simplified.Nodes.Any(n => n is Entity.Divf), simplified.Stringize()); + + var trig = "sin(x) ^ 2 + cos(x) ^ 2".Simplify(); + Check("sin2 + cos2 collapses to 1", trig == 1, trig.Stringize()); + + var expanded = "(a + b) ^ 2".Expand().Simplify(); + CheckClose("(a+b)^2 at a = 2, b = 3", At(At2(expanded, "a", 2), "b", 3), 25); + } + + private static Entity At2(Entity expr, string variable, Complex value) + => expr.Substitute(variable, (Entity.Number.Complex)value); + + private static void Solving() + { + Section("Solve"); + + var roots = "x ^ 2 - 4 = 0".Solve("x"); + Check("x2 - 4 = 0 has two roots", roots is Entity.Set.FiniteSet { Count: 2 }, roots.Stringize()); + if (roots is Entity.Set.FiniteSet finite) + foreach (var root in finite) + CheckClose($"root {root.Stringize()} substituted back", At(MathS.FromString("x ^ 2 - 4"), "x", root.EvalNumerical().ToNumerics()), 0); + + var linear = "3 * x + 6 = 0".Solve("x"); + Check("3x + 6 = 0 solves to -2", linear is Entity.Set.FiniteSet { Count: 1 } one && one.First() == -2, linear.Stringize()); + + var system = MathS.Equations("x + y - 3", "x - y - 1").Solve("x", "y"); + Check("the 2x2 system has one solution", system is { RowCount: 1, ColumnCount: 2 }, system?.Stringize() ?? "null"); + } + + private static void Differentiation() + { + Section("Differentiate"); + + var d = "sin(x) * x".Differentiate("x"); + CheckClose("(x sin x)' at x = 1", At(d, "x", 1), Math.Cos(1) + Math.Sin(1)); + + var second = "x ^ 4".Differentiate("x").Differentiate("x").InnerSimplified; + CheckClose("(x^4)'' at x = 2", At(second, "x", 2), 48); + + var chain = "e ^ (x ^ 2)".Differentiate("x"); + CheckClose("(e^(x2))' at x = 1", At(chain, "x", 1), 2 * Math.E); + } + + private static void Integration() + { + Section("Integrate"); + + // Differentiated back rather than compared to a form: the antiderivative is a + // Derivation, so only the derivative is an equality anything may assert. + var antiderivative = "x ^ 2 + 3 * x".Integrate("x"); + var back = antiderivative.Differentiate("x").InnerSimplified; + CheckClose("d/dx of the antiderivative of x2 + 3x, at x = 2", At(back, "x", 2), 4 + 6); + } + + private static void Limits() + { + Section("Limit"); + + var limit = "sin(x) / x".Limit("x", 0).InnerSimplified; + CheckClose("sin(x)/x as x -> 0", limit.EvalNumerical().ToNumerics(), 1); + } + + // The compilation cases are also the before/after comparison for the de-reflection of + // CompilationProtocol: every function GetDef used to look up by string is here. + private static readonly (string Expr, Complex Arg)[] complexCases = + { + ("sin(x)", 0.7), + ("cos(x)", 0.7), + ("tan(x)", 0.7), + ("cotan(x)", 0.7), + ("sec(x)", 0.7), + ("cosec(x)", 0.7), + ("arcsin(x)", 0.3), + ("arccos(x)", 0.3), + ("arctan(x)", 0.3), + ("arccotan(x)", 0.3), + ("arcsec(x)", 2.5), + ("arccosec(x)", 2.5), + ("abs(x)", -1.25), + ("sgn(x)", -1.25), + ("x ^ 2.5", 1.75), + ("log(2, x)", 9.0), + ("ln(x)", 9.0), + ("x / (x + 1)", 4.0), + ("x + 2 * x - 3 * x ^ 2", 1.5), + ("sin(x) ^ 2 + cos(x) ^ 2", 0.9), + }; + + private static void Compilation() + { + Section("Compile"); + + var x = MathS.Var("x"); + var y = MathS.Var("y"); + + foreach (var (text, arg) in complexCases) + { + Complex got; + try + { + got = text.Compile(x)(arg); + } + catch (Exception e) + { + failed++; + Console.WriteLine($" FAIL compile {text}: threw {e.GetType().Name}: {e.Message}"); + continue; + } + + var expected = At(MathS.FromString(text), "x", arg); + CheckClose($"compile {text} at {Show(arg)}", got, expected, 1e-8); + } + + // double, not Complex: a different set of MathAllMethods overloads. + var d = "sin(x) + cos(x)".Compile(x); + CheckClose("compiled double sin(x) + cos(x) at 0.4", d(0.4), Math.Sin(0.4) + Math.Cos(0.4), 1e-12); + + // Two arguments, and the floored remainder that takes the sign of the divisor. + var mod = "x mod y".Compile(x, y); + Check("compiled -7 mod 3", mod(-7, 3) == 2, mod(-7, 3).ToString(CultureInfo.InvariantCulture)); + + // The one path that reached Type.GetMethod("IsNaN"): a double-valued tree converted + // to a nullable integral return type, where NaN has to become null. + var toNullable = "x + 1".Compile>(new(), typeof(long?), new[] { (typeof(double), x) }); + Check("compiled double -> long? at 4.5", toNullable(4.5) == 5, toNullable(4.5)?.ToString(CultureInfo.InvariantCulture) ?? "null"); + Check("compiled double -> long? at NaN", toNullable(double.NaN) is null, toNullable(double.NaN)?.ToString(CultureInfo.InvariantCulture) ?? "null"); + + // Booleans and a piecewise, which is the ConvertOtherNode path. + var boolean = "x > 0 and x < 10".Compile(x); + Check("compiled predicate at 5", boolean(5) && !boolean(50), $"{boolean(5)}, {boolean(50)}"); + + var piecewise = "piecewise(x provided x > 0, 0 provided x <= 0)".Compile(x); + Check("compiled piecewise at -3 and 3", piecewise(-3) == 0 && piecewise(3) == 3, $"{piecewise(-3)}, {piecewise(3)}"); + } + } +}