diff --git a/Sources/AngouriMath/Functions/Evaluation/Evaluation.Continuous/Evaluation.Continuous.Arithmetics.Classes.cs b/Sources/AngouriMath/Functions/Evaluation/Evaluation.Continuous/Evaluation.Continuous.Arithmetics.Classes.cs index 3e4513f13..81926e59b 100644 --- a/Sources/AngouriMath/Functions/Evaluation/Evaluation.Continuous/Evaluation.Continuous.Arithmetics.Classes.cs +++ b/Sources/AngouriMath/Functions/Evaluation/Evaluation.Continuous/Evaluation.Continuous.Arithmetics.Classes.cs @@ -12,6 +12,56 @@ namespace AngouriMath { partial record Entity { + /// + /// The operand a sum or a difference simplifies first, or for any + /// other node. + /// + private static Entity? LeftOperandOfASum(Entity node) + => node switch { Sumf sum => sum.Augend, Minusf difference => difference.Minuend, _ => null }; + + /// How far apart the nodes simplified ahead along a long left spine are. + private const int SpineCheckpointSpacing = 32; + + /// + /// Simplifies a long chain of sums from the bottom up before is + /// simplified, so that no recursion through it goes deeper than + /// . + /// + /// + /// + /// A sum simplifies its left operand first, and that operand's left operand, and so on, so + /// a chain of n sums nested to the left is simplified by a recursion n deep. A sum built + /// one term at a time is that shape. CollectLikeTerms builds one, and so does + /// anything that adds terms up in a loop. The integrals of + /// (q + p x)/(c + b x + a x^2)^3 simplified one about 900 deep, which overflowed a + /// 1 MB stack in a Debug build and 384 KB in Release. + /// https://github.com/asc-community/AngouriMath/issues/2166 + /// + /// + /// Every node's simplification is cached, so simplifying every + /// th node of the spine first, deepest first, leaves + /// each of those recursions stopping at the one below it, and then the sum's own. The + /// same nodes are simplified as before, each once, in another order. Under twice the + /// spacing nothing is done, so an ordinary sum pays for a walk of a few nodes and nothing + /// else. + /// + /// + private static void SimplifyALongLeftSpineFirst(Entity sum, bool isExact) + { + var depth = 0; + for (var node = LeftOperandOfASum(sum); node is not null && depth < 2 * SpineCheckpointSpacing; node = LeftOperandOfASum(node)) + depth++; + if (depth < 2 * SpineCheckpointSpacing) + return; + var checkpoints = new List(); + depth = 0; + for (var node = LeftOperandOfASum(sum); node is Sumf or Minusf; node = LeftOperandOfASum(node)!) + if (++depth % SpineCheckpointSpacing == 0) + checkpoints.Add(node); + for (var i = checkpoints.Count - 1; i >= 0; i--) + _ = checkpoints[i].InnerSimplified(isExact); + } + /// /// scaled by , or /// where the factor's sign is not known. @@ -83,8 +133,10 @@ public partial record Sumf { private protected override Entity IntrinsicCondition => Boolean.True; /// - protected override Entity InnerSimplify(bool isExact) => - ExpandOnTwoArguments(Augend, Addend, + protected override Entity InnerSimplify(bool isExact) + { + SimplifyALongLeftSpineFirst(this, isExact); + return ExpandOnTwoArguments(Augend, Addend, (augend, addend) => (augend, addend) switch { (Complex a, Complex b) when !isExact => a + b, @@ -98,14 +150,17 @@ _ when Core.Sets.Cardinality.Absorbed(augend, addend) is { } larger => larger, _ => null }, (@this, a, b) => ((Sumf)@this).New(a, b), isExact); + } } public partial record Minusf { private protected override Entity IntrinsicCondition => Boolean.True; /// - protected override Entity InnerSimplify(bool isExact) => - ExpandOnTwoArguments(Minuend, Subtrahend, + protected override Entity InnerSimplify(bool isExact) + { + SimplifyALongLeftSpineFirst(this, isExact); + return ExpandOnTwoArguments(Minuend, Subtrahend, (augend, addend) => (augend, addend) switch { (Complex a, Complex b) when !isExact => a - b, @@ -127,6 +182,7 @@ _ when Core.Sets.Cardinality.HasNoValue(augend, addend) => MathS.NaN, _ => null }, (@this, a, b) => ((Minusf)@this).New(a, b), isExact); + } } public partial record Mulf { diff --git a/Sources/Tests/UnitTests/Common/LongSumStackDepthTest.cs b/Sources/Tests/UnitTests/Common/LongSumStackDepthTest.cs new file mode 100644 index 000000000..03b200300 --- /dev/null +++ b/Sources/Tests/UnitTests/Common/LongSumStackDepthTest.cs @@ -0,0 +1,45 @@ +// +// 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.Linq; +using System.Threading; +using AngouriMath; +using AngouriMath.Extensions; +using Xunit; + +namespace AngouriMath.Tests.Common +{ + /// + /// A sum built one term at a time is nested as deep as it is long, and a sum simplifies its + /// left operand first, so it was simplified by a recursion as deep as the sum. Expanding a + /// product of two sums of 30 terms collects 900, and that overflowed a 256 KB stack in a + /// Release build. In C# Test's Debug build on Windows, the integral of + /// (q + p x)/(c + b x + a x^2)^3 did the same to a 1 MB stack. + /// #2166 + /// + /// + /// Run on a thread of a fixed stack size, because the test runner's threads have whatever + /// stack the platform gives them: 8 MB on Linux, 512 KB on macOS. A regression here is a + /// stack overflow, which takes the test host down rather than failing one test. + /// + [Trait("Area", "Common")] + public sealed class LongSumStackDepthTest + { + [Fact] + public void ASumOf900TermsIsCollectedOnASmallStack() + { + var left = string.Join(" + ", Enumerable.Range(1, 30).Select(i => "a_" + i)).ToEntity(); + var right = string.Join(" + ", Enumerable.Range(1, 30).Select(i => "b_" + i)).ToEntity(); + Entity? expanded = null; + var worker = new Thread(() => expanded = (left * right).Expand(), 256 * 1024); + worker.Start(); + worker.Join(); + Assert.NotNull(expanded); + Assert.Equal(900, Entity.Sumf.LinearChildren(expanded!).Count()); + } + } +}