diff --git a/Sources/AngouriMath/Functions/Compilation/Compile.FE.Definition.cs b/Sources/AngouriMath/Functions/Compilation/Compile.FE.Definition.cs index 181c38b4f..e4d53f80d 100644 --- a/Sources/AngouriMath/Functions/Compilation/Compile.FE.Definition.cs +++ b/Sources/AngouriMath/Functions/Compilation/Compile.FE.Definition.cs @@ -23,7 +23,8 @@ partial record Entity /// /// A statement compiles to its truth value: 1 where it holds, 0 where it does not, and NaN /// where it has none, as i > 1 has none. A provided or a piecewise is NaN - /// where no predicate holds. https://github.com/asc-community/AngouriMath/issues/2195 + /// where no predicate holds, and computes only the case it takes, as evaluation does. + /// https://github.com/asc-community/AngouriMath/issues/2195 /// public FastExpression Compile(params Variable[] variables) => Compiler.Compile(this, variables); diff --git a/Sources/AngouriMath/Functions/Compilation/IntoFE/Compiler.cs b/Sources/AngouriMath/Functions/Compilation/IntoFE/Compiler.cs index ec81631b9..a91b2dfa3 100644 --- a/Sources/AngouriMath/Functions/Compilation/IntoFE/Compiler.cs +++ b/Sources/AngouriMath/Functions/Compilation/IntoFE/Compiler.cs @@ -1,4 +1,4 @@ -// +// // Copyright (c) 2019-2022 Angouri. // AngouriMath is licensed under MIT. // Details: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md. @@ -48,6 +48,7 @@ internal void InnerCompile(Compiler compiler) cacheLine = ~cacheLine; compiler.Cache[this] = cacheLine; compiler.Instructions.Add(new(InstructionType.SAVE_CACHE, cacheLine)); + compiler.Saved.Add(this); } } } @@ -536,29 +537,48 @@ public partial record Providedf { private protected override bool CompilesItself => true; + // The value where the predicate holds, and NaN elsewhere, the value not being computed there. private protected override void CompileNode(Compiler compiler) { + var start = compiler.Saved.Count; Predicate.InnerCompile(compiler); + var unless = compiler.Jump(InstructionType.JUMP_UNLESS_HOLDS); Expression.InnerCompile(compiler); - compiler.Instructions.Add(new(InstructionType.CALL_PROVIDED)); + var past = compiler.Jump(InstructionType.JUMP); + compiler.Land(unless); + compiler.Instructions.Add(new(InstructionType.PUSH_CONST, Value: double.NaN)); + compiler.Land(past); + compiler.Forget(start); } } - // The first case whose predicate holds, and NaN where none does: from the last case up, - // each is kept where its predicate holds and the cases below it are kept elsewhere. + // The first case whose predicate holds, and NaN where none does. Each predicate is tried in + // turn, and the first that holds has its case computed and jumps past the rest, so that a case + // not taken is not computed: one may be undefined, or costly, where it is not taken. public partial record Piecewise { private protected override bool CompilesItself => true; private protected override void CompileNode(Compiler compiler) { - compiler.Instructions.Add(new(InstructionType.PUSH_CONST, Value: double.NaN)); - foreach (var @case in Cases.Reverse()) + var start = compiler.Saved.Count; + var past = new List(); + foreach (var @case in Cases) { @case.Predicate.InnerCompile(compiler); + var unless = compiler.Jump(InstructionType.JUMP_UNLESS_HOLDS); + // What the predicates computed holds for the cases after them, as reaching one + // means every predicate before it was computed; what a case computed does not. + var predicates = compiler.Saved.Count; @case.Expression.InnerCompile(compiler); - compiler.Instructions.Add(new(InstructionType.CALL_SELECT)); + compiler.Forget(predicates); + past.Add(compiler.Jump(InstructionType.JUMP)); + compiler.Land(unless); } + compiler.Instructions.Add(new(InstructionType.PUSH_CONST, Value: double.NaN)); + foreach (var jump in past) + compiler.Land(jump); + compiler.Forget(start); } } @@ -1010,6 +1030,33 @@ public partial class FastExpression internal sealed record Compiler(List Instructions, IReadOnlyDictionary VarNamespace, IDictionary Cache) { + /// + /// The subexpressions saved to the cache, in the order their instructions were emitted. A + /// subexpression saved where a jump may pass over it has no value in its slot after the jump, + /// so once the code that may be passed over is compiled it is -ed back to + /// unsaved, and is computed again where it is next met: a slot is read only where every path + /// to the read has written it, as relies on. + /// + internal List Saved { get; } = new(); + + /// Marks every subexpression saved since were as unsaved. + internal void Forget(int count) + { + for (var i = count; i < Saved.Count; i++) + Cache[Saved[i]] = ~Cache[Saved[i]]; + Saved.RemoveRange(count, Saved.Count - count); + } + + /// Emits a jump whose target is not yet known, and returns where it is. + internal int Jump(InstructionType type) + { + Instructions.Add(new(type)); + return Instructions.Count - 1; + } + + /// Makes the jump at land on the next instruction emitted. + internal void Land(int jump) => Instructions[jump] = Instructions[jump] with { Reference = Instructions.Count }; + /// Returns a compiled expression. Allows to boost substitution a lot /// The function to be compiled /// Must be equal to func's variables (ignoring constants) diff --git a/Sources/AngouriMath/Functions/Compilation/IntoFE/FastExpression.cs b/Sources/AngouriMath/Functions/Compilation/IntoFE/FastExpression.cs index 37ce14fd9..dc88de476 100644 --- a/Sources/AngouriMath/Functions/Compilation/IntoFE/FastExpression.cs +++ b/Sources/AngouriMath/Functions/Compilation/IntoFE/FastExpression.cs @@ -21,6 +21,12 @@ internal enum InstructionType LOAD_CACHE, SAVE_CACHE, + // Control flow, to the instruction its Reference names: a jump, and a jump taken + // unless the truth value popped holds, as a case of a piecewise is passed over unless + // its predicate holds. + JUMP = 20, + JUMP_UNLESS_HOLDS, + // 1-arg functions CALL_SIN = 50, CALL_COS, @@ -86,8 +92,6 @@ internal enum InstructionType CALL_OR, CALL_XOR, CALL_IMPLIES, - // A value where its predicate holds, and NaN elsewhere. - CALL_PROVIDED, CALL_BINOMIAL, CALL_FALLING_FACTORIAL, CALL_RISING_FACTORIAL, @@ -98,10 +102,8 @@ internal enum InstructionType CALL_VALUATION, CALL_DIVIDES, - // 3-arg: a value where its predicate holds, and the one below it elsewhere. - CALL_SELECT = 150, - // Whether two numbers are congruent modulo a third. - CALL_CONGRUENT, + // 3-arg: whether two numbers are congruent modulo a third. + CALL_CONGRUENT = 150, } internal sealed partial record Instruction(InstructionType Type, int Reference = -1, System.Numerics.Complex Value = default) { @@ -136,7 +138,7 @@ private sealed class Scratch [System.ThreadStatic] private static Scratch? scratch; - private readonly List instructions; + private readonly Instruction[] instructions; private readonly int varCount; private readonly int cacheCount; @@ -147,7 +149,7 @@ private sealed class Scratch internal FastExpression(int varCount, List instructions, int cacheCount) { this.varCount = varCount; - this.instructions = instructions; + this.instructions = instructions.ToArray(); this.cacheCount = cacheCount; } @@ -171,9 +173,20 @@ public System.Numerics.Complex Substitute(params System.Numerics.Complex[] value var cache = mine.Cache; if (cache.Length < cacheCount) mine.Cache = cache = new System.Numerics.Complex[cacheCount]; - foreach (var instruction in instructions) + for (var next = 0; next < instructions.Length; next++) + { + var instruction = instructions[next]; switch (instruction.Type) { + // Every jump is forward, so a call ends. A piecewise computes the case it takes + // and no other: https://github.com/asc-community/AngouriMath/issues/2195 + case InstructionType.JUMP: + next = instruction.Reference - 1; + break; + case InstructionType.JUMP_UNLESS_HOLDS: + if (Truth(stack.Pop()) != true) + next = instruction.Reference - 1; + break; case InstructionType.PUSH_VAR: stack.Push(values[instruction.Reference]); break; @@ -364,12 +377,6 @@ public System.Numerics.Complex Substitute(params System.Numerics.Complex[] value case InstructionType.CALL_IMPLIES: stack.Push(Truth(Logic.Implies(Truth(stack.Pop()), Truth(stack.Pop())))); break; - case InstructionType.CALL_PROVIDED: - { - var value = stack.Pop(); - stack.Push(Truth(stack.Pop()) == true ? value : double.NaN); - break; - } // The binomial coefficient, the factorial powers and the double factorial. // https://github.com/asc-community/AngouriMath/issues/2195 case InstructionType.CALL_BINOMIAL: @@ -426,15 +433,8 @@ public System.Numerics.Complex Substitute(params System.Numerics.Complex[] value case InstructionType.CALL_CONGRUENT: stack.Push(Truth(AngouriMath.Numerics.NumberStatements.Congruent(stack.Pop(), stack.Pop(), stack.Pop()))); break; - case InstructionType.CALL_SELECT: - { - var value = stack.Pop(); - var holds = Truth(stack.Pop()) == true; - var otherwise = stack.Pop(); - stack.Push(holds ? value : otherwise); - break; - } } + } if (stack.Count != 1) throw new AngouriBugException($"Unused values remain in the stack: {stack.Count} instead of 1"); return stack.Pop(); @@ -447,6 +447,6 @@ public System.Numerics.Complex Substitute(params System.Numerics.Complex[] value private static bool? Truth(System.Numerics.Complex value) => value == 1 ? true : value == 0 ? false : null; /// Might be useful for debug if a function works too slowly - public override string ToString() => string.Join(" \n| ", instructions); + public override string ToString() => string.Join(" \n| ", instructions); } } \ No newline at end of file diff --git a/Sources/AngouriMath/Functions/Compilation/IntoLinq/IntoLinqCompiler.cs b/Sources/AngouriMath/Functions/Compilation/IntoLinq/IntoLinqCompiler.cs index e3cc1d133..6fa2929c7 100644 --- a/Sources/AngouriMath/Functions/Compilation/IntoLinq/IntoLinqCompiler.cs +++ b/Sources/AngouriMath/Functions/Compilation/IntoLinq/IntoLinqCompiler.cs @@ -201,6 +201,22 @@ IUnaryNode oneArg => protocol.ConvertMembership( BuildTree(element, cachedSubexpressions, variableAssignments, newLocalVars, protocol, constantType), set), + // The value of a case is computed only where it is taken, inside the conditional, so that a case + // that is undefined, or costly, where it is not taken is not computed there. Every subexpression is + // otherwise a local assigned before the expression's value is, which would compute each case. + // https://github.com/asc-community/AngouriMath/issues/2195 + Providedf(var value, var predicate) + => protocol.ConvertBinaryNode( + Conditional(value, cachedSubexpressions, protocol, constantType), + BuildTree(predicate, cachedSubexpressions, variableAssignments, newLocalVars, protocol, constantType), + expr), + + // The first predicate is always computed; the others only where every one before them fails. + Piecewise piecewise + => protocol.ConvertOtherNode(piecewise.DirectChildren.Select((child, i) => i == 1 + ? BuildTree(child, cachedSubexpressions, variableAssignments, newLocalVars, protocol, constantType) + : Conditional(child, cachedSubexpressions, protocol, constantType)).ToArray(), expr), + IBinaryNode twoArg => protocol.ConvertBinaryNode( BuildTree(twoArg.NodeFirstChild, cachedSubexpressions, variableAssignments, newLocalVars, protocol, constantType), @@ -216,5 +232,23 @@ IBinaryNode twoArg newLocalVars.Add(newVar); return newVar; } + + /// + /// as a block of its own, computed only where the block is: what it computes is + /// cached inside the block and not outside it, where the block may not have been computed. What was + /// cached before it is read as it is everywhere else. + /// + private static Expression Conditional( + Entity expr, + Dictionary cachedSubexpressions, + CompilationProtocol protocol, + Type? constantType) + { + var cached = new Dictionary(cachedSubexpressions); + var locals = new List(); + var assignments = new List(); + var tree = BuildTree(expr, cached, assignments, locals, protocol, constantType); + return assignments.Count == 0 ? tree : Expression.Block(locals, assignments.Append(tree)); + } } } diff --git a/Sources/Tests/AotSmokeTest/Program.cs b/Sources/Tests/AotSmokeTest/Program.cs index b3516aed4..129658f38 100644 --- a/Sources/Tests/AotSmokeTest/Program.cs +++ b/Sources/Tests/AotSmokeTest/Program.cs @@ -294,6 +294,11 @@ private static void Compilation() 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)}"); + // A case computed only where it is taken is a block with locals of its own. + // https://github.com/asc-community/AngouriMath/issues/2195 + var computedCases = "piecewise(x^2 + 1 provided x > 0, x^2 - 1) + x^2".Compile(x); + Check("compiled piecewise of computed cases at -3 and 3", computedCases(-3) == 17 && computedCases(3) == 19, + $"{computedCases(-3)}, {computedCases(3)}"); // A comparison of numbers that are not ordered has no truth value, and the connectives // follow Kleene's tables, in the interpreter a native build compiles to. diff --git a/Sources/Tests/UnitTests/Common/BranchNotTakenCompilationTest.cs b/Sources/Tests/UnitTests/Common/BranchNotTakenCompilationTest.cs new file mode 100644 index 000000000..f169785ef --- /dev/null +++ b/Sources/Tests/UnitTests/Common/BranchNotTakenCompilationTest.cs @@ -0,0 +1,161 @@ +// +// 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.Linq; +using System.Numerics; +using AngouriMath; +using AngouriMath.Extensions; +using Xunit; + +namespace AngouriMath.Tests.Common +{ + /// + /// A compiled provided or piecewise computes the case it takes and no other, as + /// evaluation does. Both compilers computed every case and kept one, so that + /// piecewise(primepi(x) provided x < 100, 0) at 9999991 sieved ten million numbers for a + /// value it threw away: 18 ms a call, where evaluation takes 13 µs. + /// #2195 + /// + [Trait("Area", "Common")] + public sealed class BranchNotTakenCompilationTest + { + public static readonly TheoryData Expressions = new() + { + // A subexpression met first in a case that is not taken, and again where it is computed. + "piecewise(sin(x) provided x > 0, 0) + sin(x)", + "sin(x) + piecewise(sin(x) provided x > 0, 0)", + "piecewise(sin(x) provided x > 1, sin(x) + 1 provided x > 0, sin(x) + 2)", + "(cos(x) provided x > 0) + piecewise(cos(x) provided x < 0, 1)", + // One shared by a predicate and the cases after it. + "piecewise(x^2 provided x^2 > 1, x^2 + 1)", + "piecewise(1 provided x^3 > 8, x^3 provided x^3 > 0, x^3 - 1)", + // Nested. + "piecewise(piecewise(cos(x) provided x > 2, cos(x) + 1) provided x > 0, cos(x) - 1) * cos(x)", + "(x provided x > 0) provided x < 2", + // A case with no value where it is not taken. + "piecewise(1/x provided not x = 0, 0)", + "piecewise(ln(x) provided x > 0, ln(-x) provided x < 0, 0) + x", + "piecewise(sqrt(x - 1) provided x >= 1, sqrt(1 - x))", + "x provided x > 1", "iverson(x > 0) * piecewise(x provided x > 1, 2 x)", + }; + + // Real, negative, complex and NaN, and each visited twice in both orders, so that a value + // a call leaves behind cannot pass for the next call's. + private static readonly (Entity At, Complex Value)[] points = + { + (2, 2), ("1/2", 0.5), (0, 0), ("-13/10", -1.3), (-3, -3), ("i", Complex.ImaginaryOne), + ("1 + i", new(1, 1)), ("-1 + 2i", new(-1, 2)), (MathS.NaN, double.NaN), + }; + + private static Complex Interpreted(string expression, Entity at) + => expression.ToEntity().Substitute("x", at).EvalNumerical().ToNumerics(); + + private static bool Agrees(Complex expected, Complex actual) + => double.IsNaN(expected.Real) || double.IsNaN(expected.Imaginary) + ? double.IsNaN(actual.Real) || double.IsNaN(actual.Imaginary) + : Complex.Abs(expected - actual) <= 1e-12 * Math.Max(1, Complex.Abs(expected)); + + private static void AgreesAtEveryPoint(string expression, Func compiled, string compiler) + { + foreach (var order in new[] { points, points.AsEnumerable().Reverse().ToArray(), points }) + foreach (var (at, value) in order) + { + var expected = Interpreted(expression, at); + var actual = compiled(value); + Assert.True(Agrees(expected, actual), $"{compiler}: {expression} at {at}: {expected} interpreted, {actual} compiled"); + } + } + + [Theory] + [MemberData(nameof(Expressions))] + public void FastExpressionIsWhatEvaluationSays(string expression) + { + var compiled = expression.ToEntity().Compile("x"); + AgreesAtEveryPoint(expression, value => compiled.Call(value), "FastExpression"); + } + + [Theory] + [MemberData(nameof(Expressions))] + public void LinqOverTheComplexNumbersIsWhatEvaluationSays(string expression) + { + var compiled = expression.ToEntity().Compile("x"); + AgreesAtEveryPoint(expression, value => compiled(value), "LINQ over Complex"); + } + + [Theory] + [MemberData(nameof(Expressions))] + public void LinqOverTheRealsIsWhatEvaluationSaysWhereItIsReal(string expression) + { + var compiled = expression.ToEntity().Compile("x"); + foreach (var (at, value) in points) + { + if (value.Imaginary != 0) + continue; + var expected = Interpreted(expression, at); + if (expected.Imaginary != 0) + expected = double.NaN; + var actual = compiled(value.Real); + Assert.True(Agrees(expected, actual), $"LINQ over double: {expression} at {at}: {expected} interpreted, {actual} compiled"); + } + } + + // primepi sieves up to its argument, and at 9999991 the sieve is 5 million bits, 625 KB, on every + // call. What a call allocates is the measure, not how long it takes, which is the machine's. + public static readonly TheoryData NotTakenAt9999991 = new() + { + "piecewise(primepi(x) provided x < 100, 0)", + "primepi(x) provided x < 100", + "piecewise(1 provided x > 100, primepi(x) provided primepi(x) > 0, 2)", + "piecewise(piecewise(primepi(x) provided x < 50, 1) provided x < 100, 2) + 1", + }; + + private const double Untaken = 9999991; + private const long WellBelowTheSieve = 64 * 1024; + + private static long Allocated(Func call) + { + call(); + var before = GC.GetAllocatedBytesForCurrentThread(); + call(); + return GC.GetAllocatedBytesForCurrentThread() - before; + } + + [Theory] + [MemberData(nameof(NotTakenAt9999991))] + public void FastExpressionDoesNotComputeACaseItDoesNotTake(string expression) + { + var compiled = expression.ToEntity().Compile("x"); + Assert.True(Agrees(Interpreted(expression, Untaken), compiled.Call(Untaken))); + var bytes = Allocated(() => compiled.Call(Untaken)); + Assert.True(bytes < WellBelowTheSieve, $"{expression} at {Untaken} allocated {bytes} bytes"); + } + + [Theory] + [MemberData(nameof(NotTakenAt9999991))] + public void LinqDoesNotComputeACaseItDoesNotTake(string expression) + { + var overComplex = expression.ToEntity().Compile("x"); + var overReals = expression.ToEntity().Compile("x"); + Assert.True(Agrees(Interpreted(expression, Untaken), overComplex(Untaken))); + Assert.True(Agrees(Interpreted(expression, Untaken), overReals(Untaken))); + var bytes = Allocated(() => overComplex(Untaken)); + Assert.True(bytes < WellBelowTheSieve, $"{expression} over Complex at {Untaken} allocated {bytes} bytes"); + bytes = Allocated(() => overReals(Untaken)); + Assert.True(bytes < WellBelowTheSieve, $"{expression} over double at {Untaken} allocated {bytes} bytes"); + } + + /// Where the case is taken, it is computed: the sieve is there. + [Fact] + public void ACaseThatIsTakenIsComputed() + { + var compiled = "piecewise(primepi(x) provided x > 100, 0)".ToEntity().Compile("x"); + Assert.Equal(new Complex(664579, 0), compiled.Call(Untaken)); + Assert.True(Allocated(() => compiled.Call(Untaken)) > WellBelowTheSieve); + } + } +}