From e4334f3921dd0e5109a0cf64c0b95e32f70bb9f5 Mon Sep 17 00:00:00 2001 From: Rafael Vuijk Date: Fri, 4 Sep 2026 09:05:52 +0000 Subject: [PATCH] The integrator stops answering the same question over and over `Simplify` of an integral it cannot evaluate took a minute and a half. Tracing every entry to the integrator on `sin(x)/(x^2 + 1)^2` says why: 562 top-level calls for 3 distinct integrands, one of them asked 500 times, and those 3 are one integral written three ways. Inside a single call there is a second layer of it -- 5330 entries for 23 distinct integrands on `e^x/(x^2 + 1)^2` -- because the solvers overlap, substitution and partial fractions and splitting a sum and by parts each decomposing the integrand differently into pieces that coincide. So the answers are kept, and the whole difficulty is knowing when to stop trusting them. An answer depends on the ambient settings -- Codomain decides whether the logarithms carry an abs, MaxExpansionTermCount bounds the by-parts recursion -- and a setting is scoped to a flow rather than a thread, so "nothing has changed" is not something a per-thread cache may assume. SettingsState answers it by comparing what every setting reads as. A state and not a change count, which is the part that had to be measured rather than reasoned about: a scope opened and closed leaves every setting exactly as it found it while moving a counter twice, and the library opens such scopes constantly -- one Simplify of one unevaluated integral opened 3520 of them, all four kinds from numeric downcasting in Number/Operators.cs. A counter therefore reports "everything has changed" continuously, and the first version of this, built on one, gave back almost none of the improvement. The state of a setting is the reference to the frame on top of its stack, which is exact rather than a hash: releasing a scope restores the very frame object that was there before it, since the chain below a pushed frame is never rebuilt. Comparing snapshots is a handful of reference comparisons with no chance of collision, and a balanced open-and-close compares equal, which is the point. Reads are lock-free because registering happens while the settings are constructed and the reader is on the integrator's hot path. The dictionary is emptied at 4096 entries. Nothing in it expires on its own -- the settings holding still is the whole condition for keeping an answer -- so without a cap a long-lived process integrating a stream of different expressions would hold every one of them for ever. Measured, both arms on one machine. Simplify of the three integrands with no elementary antiderivative: 114,851 ms to 1163, 85,683 to 1517, 51,072 to 188, and 1/(x^4 + 1)^2 from 3811 to 9. A single cold Integrate in a fresh process is faster too, on all nine tried: sqrt(tan(x)) 154.5 ms to 79.1, x^2*sin(x) 21.0 to 10.2, 1/(x^3 + 1) 49.8 to 31.2. The three tests that matter are the ones on the codomain, since a cache like this does not fail by returning nonsense but by returning a correct answer worked out under settings the caller is no longer standing in. `1/x` integrates to ln(abs(x)) on the reals and ln(x) on the complex plane, asked in both orders and with a scope entered while an answer is already held. All three fail with the state comparison disabled, which is what makes them worth having. Full suite 9550 passed, 0 failed. Closes #1156. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012sonx8iAspMiwRwokT1Ura --- Sources/.editorconfig | 3 + .../AngouriMath/Convenience/SettingClass.cs | 101 ++++++++++++++- .../Integration/Integration.Definition.cs | 89 +++++++++++++ .../Calculus/IntegralAnswerCacheTest.cs | 119 ++++++++++++++++++ 4 files changed, 309 insertions(+), 3 deletions(-) create mode 100644 Sources/Tests/UnitTests/Calculus/IntegralAnswerCacheTest.cs diff --git a/Sources/.editorconfig b/Sources/.editorconfig index 35cd5c5f6..a6f7ce704 100644 --- a/Sources/.editorconfig +++ b/Sources/.editorconfig @@ -192,3 +192,6 @@ file_header_template=\nCopyright (c) 2019-2026 Angouri.\nAngouriMath is licensed [Tests/UnitTests/Calculus/RepeatedQuadraticIntegralTest.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/UnitTests/Calculus/IntegralAnswerCacheTest.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/Convenience/SettingClass.cs b/Sources/AngouriMath/Convenience/SettingClass.cs index f9e9a8099..a6ee5a1ca 100644 --- a/Sources/AngouriMath/Convenience/SettingClass.cs +++ b/Sources/AngouriMath/Convenience/SettingClass.cs @@ -10,6 +10,88 @@ namespace AngouriMath.Convenience { + /// A setting, without its value's type — enough to ask what it currently reads as. + internal interface ISettingState + { + /// + /// The object identifying this setting's value in the calling flow, or + /// where nothing is set and the default applies. + /// + object? CurrentState { get; } + } + + /// + /// What every setting reads as, taken together, so that something holding a result computed + /// under the settings of a moment can tell whether those settings still hold. + /// + /// + /// + /// A state and not a change count. Counting changes looks equivalent and is not: a + /// scope opened and closed leaves every setting exactly as it found it while moving a + /// counter twice, and the library opens such scopes constantly — a single + /// Simplify of one unevaluated integral opened 3520 of them, all four from + /// numeric downcasting in Number/Operators.cs. A counter therefore reports + /// "everything has changed" continuously and is useless to a cache. Measured, not assumed: + /// the counter was built first and threw away the whole benefit. + /// + /// + /// The state of one setting is the reference to the frame on top of its stack, which + /// is exact rather than a hash — releasing a scope restores the very frame object that was + /// there before it, since the chain below a pushed frame is never rebuilt. So comparing + /// snapshots is a handful of reference comparisons with no chance of collision, and a + /// balanced open-and-close compares equal, which is the whole point. + /// + /// + internal static class SettingsState + { + /// + /// Every setting there is. Replaced rather than appended to, so that a reader can walk it + /// without a lock: registering happens as the settings are constructed and a reader is on + /// the integrator's hot path, where taking a lock per call would be contention bought for + /// a list that stops changing before any real work starts. + /// + [ConcurrentField] private static ISettingState[] registered = System.Array.Empty(); + + [ConcurrentField] private static readonly object registering = new(); + + internal static void Register(ISettingState setting) + { + lock (registering) + { + var grown = new ISettingState[registered.Length + 1]; + System.Array.Copy(registered, grown, registered.Length); + grown[^1] = setting; + System.Threading.Volatile.Write(ref registered, grown); + } + } + + /// What every setting reads as right now, in this flow. + internal static object?[] Snapshot() + { + var settings = System.Threading.Volatile.Read(ref registered); + var snapshot = new object?[settings.Length]; + for (var i = 0; i < snapshot.Length; i++) + snapshot[i] = settings[i].CurrentState; + return snapshot; + } + + /// + /// Whether is still what every setting reads as. One taken + /// before a setting existed has a different length and is reported stale, which is what + /// should happen — that setting's value was not part of it. + /// + internal static bool StillHolds(object?[] snapshot) + { + var settings = System.Threading.Volatile.Read(ref registered); + if (snapshot.Length != settings.Length) + return false; + for (var i = 0; i < snapshot.Length; i++) + if (!ReferenceEquals(snapshot[i], settings[i].CurrentState)) + return false; + return true; + } + } + /// /// This class for configuring some internal mechanisms from outside /// @@ -34,7 +116,7 @@ namespace AngouriMath.Convenience /// chain and assign it, rather than editing one. /// /// - public sealed class Setting where T : notnull + public sealed class Setting : ISettingState where T : notnull { /// /// One pushed value, and the chain below it. Never mutated once built. @@ -58,7 +140,20 @@ private sealed class Frame private readonly AsyncLocal frames = new(); private long lastId; - internal Setting(T defaultValue) => Default = defaultValue; + internal Setting(T defaultValue) + { + Default = defaultValue; + SettingsState.Register(this); + } + + /// + /// The frame this setting currently reads from, as the identity of its state. Released + /// in order — which using guarantees — a scope restores the very frame that was + /// on top before it, so this compares equal across a balanced open and close. Out of + /// order the chain above is rebuilt and this reads as a change, which is conservative + /// and not wrong. + /// + object? ISettingState.CurrentState => frames.Value; /// /// Sets the new value for the setting @@ -98,7 +193,7 @@ private void Remove(long id) if (top.Id == id) { frames.Value = top.Next; - return; + return; } var above = new System.Collections.Generic.List(); var current = top; diff --git a/Sources/AngouriMath/Functions/Continuous/Integration/Integration.Definition.cs b/Sources/AngouriMath/Functions/Continuous/Integration/Integration.Definition.cs index 5aaef26c5..52bfd6564 100644 --- a/Sources/AngouriMath/Functions/Continuous/Integration/Integration.Definition.cs +++ b/Sources/AngouriMath/Functions/Continuous/Integration/Integration.Definition.cs @@ -5,6 +5,7 @@ // Website: https://am.angouri.org. // +using AngouriMath.Convenience; using AngouriMath.Core.Transformations; using AngouriMath.Extensions; using AngouriMath.Functions.Algebra; @@ -109,10 +110,98 @@ internal static partial class Integration private static Entity Normalized(Entity expr) => expr.Replace(Patterns.GatherPowersOfOneBase); + /// + /// The integrals already answered under the settings in force, so that the same question + /// is not worked out again. + /// + /// + /// + /// Why an integral is asked for twice at all. Two layers of it. Within one call the + /// solvers overlap — substitution, partial fractions, splitting a sum and integration by + /// parts each decompose the integrand differently and produce pieces that coincide — and + /// across calls Simplify asks about one integral through every + /// rewritten candidate it generates. Traced on sin(x)/(x^2 + 1)^2, which has no + /// elementary antiderivative and so runs the search to exhaustion, that is 562 + /// top-level calls for 3 distinct integrands, one of them asked 500 times; on + /// e^x/(x^2 + 1)^2 a single call enters the integrator 5330 times for 23. + /// + /// + /// The answers are held across calls and not only within one, which is where the + /// repetition is: discarding them at the end of each top-level call leaves the 562 to be + /// paid in full. Measured on those three integrands, keeping them takes a + /// Simplify from 115, 86 and 51 seconds to about 1.2, 1.5 and 0.2, and a single + /// cold Integrate of sqrt(tan(x)) from 155 ms to 79. + /// https://github.com/asc-community/AngouriMath/issues/1156 + /// + /// + /// Why it is sound to hold them. An answer depends on the ambient settings — + /// decides whether the logarithms carry an + /// abs, MaxExpansionTermCount bounds the by-parts recursion — and a setting + /// is scoped to a flow rather than to a thread, so "the settings have not changed" is not + /// something a per-thread cache can assume. answers it + /// exactly, by comparing what every setting reads as rather than by counting changes — + /// which matters, because the library opens and closes thousands of balanced scopes while + /// simplifying and a change count is therefore never still. + /// + /// + [System.ThreadStatic] private static Dictionary<(Entity, Entity.Variable, bool), Entity?>? answered; + + /// The settings was filled under. + [System.ThreadStatic] private static object?[]? answeredUnder; + + /// + /// How many answers to hold before starting over. Comfortably above what one call needs + /// — the worst measured is 23 distinct integrands within a call and 3 across the + /// candidates of one Simplify — and low enough that a process integrating + /// different things all day does not accumulate them. + /// + private const int MostAnswersKept = 4096; + /// Does not add the constant of integration because this is called recursively. internal static Entity? ComputeIndefiniteIntegral(Entity expr, Entity.Variable x, bool integrateByParts = true) { expr = Normalized(expr); + + if (answered is null || answeredUnder is null || !SettingsState.StillHolds(answeredUnder)) + { + // A fresh dictionary rather than Clear(), because this flow may have inherited + // the reference from the one that started it, and emptying it in place would + // empty that one's too. + answered = new(); + answeredUnder = SettingsState.Snapshot(); + } + + // Held as locals over the recursive call, which reaches this method again and may + // replace both fields on the way. Writing the answer into whatever the fields hold + // afterwards would put it in a dictionary stamped with different settings. + var into = answered; + var stamp = answeredUnder; + + var key = (expr, x, integrateByParts); + if (into.TryGetValue(key, out var already)) + return already; + + var computed = ComputeIndefiniteIntegralUncached(expr, x, integrateByParts); + + // Only kept if those settings still hold. Working the answer out runs simplification, + // which opens scopes of its own; one still open means this answer was computed under + // settings the stamp does not describe. + if (SettingsState.StillHolds(stamp)) + { + // Emptied rather than grown without end. Nothing here expires on its own — the + // settings holding still is the whole condition for keeping an answer — so a + // long-lived process integrating a stream of different expressions would hold + // every one of them for ever. What this is for is being asked the same question + // again, which needs the recent answers and not all of them. + if (into.Count >= MostAnswersKept) + into.Clear(); + into[key] = computed; + } + return computed; + } + + private static Entity? ComputeIndefiniteIntegralUncached(Entity expr, Entity.Variable x, bool integrateByParts) + { if (!expr.ContainsNode(x)) return expr * x; // base case, handle here if ((IntegralPatterns.TryStandardIntegrals(expr, x)) is { } answer) return answer; // The flag has to be handed on. Every one of these recurses, and a solver that diff --git a/Sources/Tests/UnitTests/Calculus/IntegralAnswerCacheTest.cs b/Sources/Tests/UnitTests/Calculus/IntegralAnswerCacheTest.cs new file mode 100644 index 000000000..0daa68877 --- /dev/null +++ b/Sources/Tests/UnitTests/Calculus/IntegralAnswerCacheTest.cs @@ -0,0 +1,119 @@ +// +// 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.Diagnostics; +using AngouriMath; +using AngouriMath.Core; +using AngouriMath.Extensions; +using Xunit; + +namespace AngouriMath.Tests.Calculus +{ + /// + /// The integrator keeps the answers it has already worked out, because it is asked the same + /// question over and over — Simplify puts one integral through every rewritten + /// candidate it generates, which measured at 562 calls for 3 distinct integrands. + /// https://github.com/asc-community/AngouriMath/issues/1156 + /// + /// + /// The tests that matter here are the ones below on the codomain. A cache like this does not + /// fail by returning nonsense; it fails by returning a correct answer that was worked + /// out under settings the caller is no longer standing in. + /// + [Trait("Area", "Calculus")] + public sealed class IntegralAnswerCacheTest + { + /// + /// 1/x integrates to ln(abs(x)) on the reals and ln(x) on the complex + /// plane, so it separates the two codomains in one expression. Asked under each in turn, + /// in one flow, which is where a cache that does not notice the setting would answer the + /// second question with the first one's answer. + /// + [Fact] + public void ANarrowedCodomainIsNotAnsweredFromTheWiderOne() + { + var complex = "1 / x".ToEntity().Integrate("x").Stringize(); + + string real; + using (MathS.Settings.Codomain.Set(Domain.Real)) + real = "1 / x".ToEntity().Integrate("x").Stringize(); + + Assert.Contains("abs", real); + Assert.DoesNotContain("abs", complex); + } + + /// The same, asked in the other order — a cache is only sound in both. + [Fact] + public void AWidenedCodomainIsNotAnsweredFromTheNarrowerOne() + { + string real; + using (MathS.Settings.Codomain.Set(Domain.Real)) + real = "1 / x".ToEntity().Integrate("x").Stringize(); + + var complex = "1 / x".ToEntity().Integrate("x").Stringize(); + + Assert.Contains("abs", real); + Assert.DoesNotContain("abs", complex); + } + + /// + /// And with the scope entered while an answer for the same integrand is already held, + /// which is the ordering that a cache keyed on anything coarser than the settings + /// themselves gets wrong. + /// + [Fact] + public void AnAnswerHeldFromBeforeAScopeIsNotUsedInsideIt() + { + var before = "1 / x".ToEntity().Integrate("x").Stringize(); + Assert.DoesNotContain("abs", before); + + using (MathS.Settings.Codomain.Set(Domain.Real)) + Assert.Contains("abs", "1 / x".ToEntity().Integrate("x").Stringize()); + + Assert.DoesNotContain("abs", "1 / x".ToEntity().Integrate("x").Stringize()); + } + + /// + /// Repeating a question gives the same answer, which is the property a cache is allowed + /// to have and the one that would break first if a stale entry were served. + /// + [Theory] + [InlineData("1 / (x ^ 2 + 1)")] + [InlineData("1 / (x ^ 2 + 1) ^ 2")] + [InlineData("x * ln(x)")] + [InlineData("sqrt(tan(x))")] + [InlineData("sin(x) / (x ^ 2 + 1) ^ 2")] + public void AskingTwiceAnswersTheSame(string integrand) + { + var first = integrand.ToEntity().Integrate("x"); + var second = integrand.ToEntity().Integrate("x"); + Assert.Equal(first, second); + } + + /// + /// An integrand with no elementary antiderivative, which runs every solver to exhaustion + /// and so pays the full cost of the search. It took 115 seconds before the answers + /// were kept, and about one after. + /// + /// + /// The bound is two orders of magnitude above what it now takes and a quarter of what it + /// used to, so it is a guard against the regression rather than a measurement of the + /// machine. A timing assertion tight enough to be a benchmark would only flake. + /// + [Fact] + public void AnIntegralWithNoAnswerStillFinishesQuickly() + { + var watch = Stopwatch.StartNew(); + var answer = "sin(x) / (x ^ 2 + 1) ^ 2".ToEntity().Integrate("x").Simplify(); + watch.Stop(); + + Assert.Contains("integral(", answer.Stringize()); + Assert.True(watch.Elapsed.TotalSeconds < 30, + $"took {watch.Elapsed.TotalSeconds:F1}s; it was 115s before the answers were kept"); + } + } +}