From c25df29c8dc6c75fa642bf96a3b6993f7bf23633 Mon Sep 17 00:00:00 2001 From: Rafael Vuijk Date: Tue, 27 Oct 2026 13:18:58 +0000 Subject: [PATCH] A long chain of sums is simplified from the bottom up, not by a recursion as deep as the chain A sum simplifies its left operand first, and that operand's left operand, so a chain of n sums nested to the left was simplified by a recursion n deep. A sum built one term at a time is that shape: CollectLikeTerms builds one, and Simplify's search hands the collected chain on. The integral of (q + p x)/(c + b x + a x^2)^3 with its symbols pinned reached about 900, which overflowed C# Test's Debug build on a 1 MB stack on most Windows runs since 2026-09-15 (#2165 moved that leg to Release), and needed more than 384 KB in Release, where macOS gives 512 KB. Sumf and Minusf now simplify every 32nd node of a left spine at least 64 deep first, deepest first. Every node's simplification is cached, so each of those recursions stops at the checkpoint below it. The same nodes are simplified, each once, in a different order, and an ordinary sum pays for a walk of a few nodes and nothing else. On a thread of a given stack size: cubic row, Release 384 KB overflow (909 repeats) -> 256 KB passes cubic row, Debug 1 MB overflow (654 repeats) -> 1 MB passes (a_1+..+a_30)(b_1+..+b_30) expanded, Release, 256 KB: 400 terms overflow -> 900 and 1,600 pass What overflows next on the cubic row, at 192 KB in Release, is CostModel.ChildrenCost, 872 deep: a different recursion, with 2.7x headroom on macOS. Unit suite 17,128 passed, 0 failed. Against master on one machine: allocation is identical (23,356 B per round of InnerSimplified on five ordinary sums; 15.79 MB per round of Simplify on ten expressions), and time is within noise (InnerSimplified 2,525 ms vs 2,557 ms, mean of three 200,000-round runs whose spread in one arm is 5%). Fixes https://github.com/asc-community/AngouriMath/issues/2166 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012sonx8iAspMiwRwokT1Ura --- ...aluation.Continuous.Arithmetics.Classes.cs | 64 +++++++++++++++++-- .../UnitTests/Common/LongSumStackDepthTest.cs | 45 +++++++++++++ 2 files changed, 105 insertions(+), 4 deletions(-) create mode 100644 Sources/Tests/UnitTests/Common/LongSumStackDepthTest.cs 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()); + } + } +}