From 9423566a01eeb51aa285d1849d1d8aead5f15e31 Mon Sep 17 00:00:00 2001 From: Rafael Vuijk Date: Mon, 7 Sep 2026 15:28:04 +0000 Subject: [PATCH] Simplify registers a candidate once, and costs a subtree once: SimplifyHard -44% allocation Two changes, each answer-identical by construction, found by attributing SimplifyHard's 3.62 GB per call to the steps of Simplificator.Alternate with a temporary hook on AddHistory. The first is the whole of the win. AddHistory registered every candidate twice: once as it was, and once as expr.Rewrite(InvertNegativePowers) -- each registration a CanonicalOrder rewrite, an inner simplification and two ratings. On an input with no negative power to invert the second tree is the first tree, so the second registration re-sorted an identical tree, rated it again, and offered the history set an entity it already held. Measured, the registrations were 862 MB of a level's 976 MB. The second registration now runs only where the inversion produced a different tree. The second is small and free. CostModel.DefaultCost recursed through itself and scanned divisor.Nodes for every quotient, so a candidate's rating re-walked subtrees shared with every candidate before it. It recurses through Entity.DefaultCostCached now -- the slot SimplifiedRate already fills on its unset path, so the two cannot disagree -- and walks children by index, allocating nothing per node. The arithmetic is the same expression in the same order, so every rate is the same double. Measured by the inter-version benchmark on the machine the 1930th column was taken on this morning, allocation being deterministic -- and the solvers move too, because Solve simplifies inside: SimplifyHard 3,662,375,240 -> 2,027,771,744 bytes -44.6% 1.60 s -> 0.92 s SolveHard 1,452,700,104 -> 1,057,996,016 bytes -27.2% 879 ms -> 772 ms SolveMediumHard 165,611,184 -> 126,162,152 bytes -23.8% 88 ms -> 78 ms SimplifyEasy 128,460 -> 115,763 bytes -9.9% 110 us -> 89 us Every other row is unchanged or inside its floor. Timings are one run against one run on one machine and carry the file's usual rider; the allocation is the evidence. Of it the cost cache alone is -1.15% and -1.3% on the two Simplify rows. Where the rest of SimplifyHard goes is named for next time: the remaining registration (431 MB), and the three expansion candidates -- res.Expand().Simplify(-level), the rule-based factorisation at level 2, and the opened angles expanded -- at 177, 170 and 82 MB, each a recursive simplification of an expanded tree. The performance gate's baseline is updated in the same change, which its own document names as the one case where a red gate means nothing is wrong: the allocation went down. Part of #746. Full suite 9610 passed, 0 failed -- no answer moved. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012sonx8iAspMiwRwokT1Ura --- Sources/AngouriMath/Core/CostModel.cs | 63 ++++++++++++++---- .../Core/Entity/Entity.Definition.cs | 11 ++++ .../WhatsNew/version_performance_control.md | 31 +++++++++ .../Functions/Simplification/Simplificator.cs | 9 ++- .../DotnetBenchmark/performance-baseline.json | 66 +++++++++---------- 5 files changed, 133 insertions(+), 47 deletions(-) diff --git a/Sources/AngouriMath/Core/CostModel.cs b/Sources/AngouriMath/Core/CostModel.cs index 21e08dc83..c21790550 100644 --- a/Sources/AngouriMath/Core/CostModel.cs +++ b/Sources/AngouriMath/Core/CostModel.cs @@ -147,14 +147,21 @@ private static double Feature(Entity expr, Func predicate) private const double HeavyWeight = 8.0; private const double ExtraHeavyWeight = 12.0; + // Recurses through Entity.DefaultCostCached rather than through itself, so a subtree is + // costed once per instance however many candidates share it; and walks children by + // index rather than through Nodes or a LINQ Sum, so that costing a node allocates + // nothing. The arithmetic is the same expression in the same order, so every rate is + // the same double it was -- the selection between candidates cannot move. internal static double DefaultCost(Entity expr) => expr switch { // Weigh provided predicates much less but nested provideds heavy Providedf(var inner, var predicate) => - DefaultCost(inner) + 0.1 * DefaultCost(predicate) + ExtraHeavyWeight * (inner.Nodes.Count(n => n is Providedf) + predicate.Nodes.Count(n => n is Providedf)), + inner.DefaultCostCached + 0.1 * predicate.DefaultCostCached + + ExtraHeavyWeight * (ProvidedsIn(inner) + ProvidedsIn(predicate)), Piecewise { Cases: var cases } => cases.Sum(@case => - DefaultCost(@case.Expression) + 0.1 * DefaultCost(@case.Predicate) + ExtraHeavyWeight * (@case.Expression.Nodes.Count(n => n is Providedf) + @case.Predicate.Nodes.Count(n => n is Providedf))), + @case.Expression.DefaultCostCached + 0.1 * @case.Predicate.DefaultCostCached + + ExtraHeavyWeight * (ProvidedsIn(@case.Expression) + ProvidedsIn(@case.Predicate))), Variable => Weight, // Number of variables // A root in a denominator, which the rationalising rule clears out. // Without a weight here the two forms tie -- 1 / (sqrt(3) + 5) and @@ -162,17 +169,47 @@ private static double Feature(Entity expr, Func predicate) // whichever candidate was generated first, which is not a preference // so much as an accident. This states the preference instead. // https://github.com/asc-community/AngouriMath/issues/205 - Divf(_, var divisor) when divisor.Nodes.Any(node => node is Powf(_, Rational and not Integer)) - => MinorWeight + Weight + expr.DirectChildren.Sum(DefaultCost), - Divf => MinorWeight + expr.DirectChildren.Sum(DefaultCost), // Number of divides - Rational(Integer(1 or -1), _) and not Integer => Weight + expr.DirectChildren.Sum(DefaultCost), // Number of rationals with unit numerator - Powf(_, Real { IsNegative: true }) => HeavyWeight + expr.DirectChildren.Sum(DefaultCost), // Number of negative powers - Logf => TinyWeight + expr.DirectChildren.Sum(DefaultCost), // Number of logarithms - Phif => ExtraHeavyWeight + expr.DirectChildren.Sum(DefaultCost), // Number of phi functions - Real { IsNegative: true } => MajorWeight + expr.DirectChildren.Sum(DefaultCost), // Number of negative reals - ComparisonSign when expr.DirectChildren[0] == 0 => Weight + expr.DirectChildren.Sum(DefaultCost), // 0 < x is bad. x > 0 is good. - Notf(Equalsf eq) => -Weight + DefaultCost(eq), // (not x = 0) is equally complex as (x = 0) - _ => expr.DirectChildren.Sum(DefaultCost) + Divf(_, var divisor) when HasFractionalPower(divisor) + => MinorWeight + Weight + ChildrenCost(expr), + Divf => MinorWeight + ChildrenCost(expr), // Number of divides + Rational(Integer(1 or -1), _) and not Integer => Weight + ChildrenCost(expr), // Number of rationals with unit numerator + Powf(_, Real { IsNegative: true }) => HeavyWeight + ChildrenCost(expr), // Number of negative powers + Logf => TinyWeight + ChildrenCost(expr), // Number of logarithms + Phif => ExtraHeavyWeight + ChildrenCost(expr), // Number of phi functions + Real { IsNegative: true } => MajorWeight + ChildrenCost(expr), // Number of negative reals + ComparisonSign when expr.DirectChildren[0] == 0 => Weight + ChildrenCost(expr), // 0 < x is bad. x > 0 is good. + Notf(Equalsf eq) => -Weight + eq.DefaultCostCached, // (not x = 0) is equally complex as (x = 0) + _ => ChildrenCost(expr) } + Weight; // Number of nodes + + /// The children's costs, summed in order -- what DirectChildren.Sum(DefaultCost) summed. + private static double ChildrenCost(Entity expr) + { + var children = expr.DirectChildren; + var sum = 0d; + for (var i = 0; i < children.Count; i++) + sum += children[i].DefaultCostCached; + return sum; + } + + /// How many nodes of the tree, the root included, are a Providedf -- what Nodes.Count(n => n is Providedf) counted. + private static int ProvidedsIn(Entity expr) + { + var count = expr is Providedf ? 1 : 0; + var children = expr.DirectChildren; + for (var i = 0; i < children.Count; i++) + count += ProvidedsIn(children[i]); + return count; + } + + /// Whether any node of the tree, the root included, is a power to a non-whole rational -- what the Nodes.Any asked. + private static bool HasFractionalPower(Entity expr) + { + if (expr is Powf(_, Rational and not Integer)) return true; + var children = expr.DirectChildren; + for (var i = 0; i < children.Count; i++) + if (HasFractionalPower(children[i])) return true; + return false; + } } } diff --git a/Sources/AngouriMath/Core/Entity/Entity.Definition.cs b/Sources/AngouriMath/Core/Entity/Entity.Definition.cs index 77a7e486b..96d58f826 100644 --- a/Sources/AngouriMath/Core/Entity/Entity.Definition.cs +++ b/Sources/AngouriMath/Core/Entity/Entity.Definition.cs @@ -760,6 +760,17 @@ public double SimplifiedRate } private LazyPropertyA simplifiedRate; + /// + /// of this node, computed once per instance -- the + /// same slot fills on its unset path, so the two never + /// disagree. DefaultCost recurses through this rather than through itself: a + /// candidate the simplifier ranks shares most of its subtrees with the candidates + /// before it, and re-walking a shared subtree for every candidate was 3.66 GB of + /// allocation per SimplifyHard, some 315 whole-tree walks of 11.6 MB each. + /// + internal double DefaultCostCached + => simplifiedRate.GetValue(MathS.Settings.ComplexityCriteria.Default, this); + /// Checks whether the given expression contains variable /// /// diff --git a/Sources/AngouriMath/Docs/WhatsNew/version_performance_control.md b/Sources/AngouriMath/Docs/WhatsNew/version_performance_control.md index c9d3d51af..f22713371 100644 --- a/Sources/AngouriMath/Docs/WhatsNew/version_performance_control.md +++ b/Sources/AngouriMath/Docs/WhatsNew/version_performance_control.md @@ -239,6 +239,37 @@ large rows reproduce to the byte or nearly; the compile rows sit inside their fl Timings from the same run are in the generated table and are deliberately not reproduced here, for the reason the 1844th section gives. +## The 1931st: a candidate registered once + +The same afternoon, on the same machine. `Simplificator.Alternate` registered every candidate it +generated twice — once as it was and once as `expr.Rewrite(InvertNegativePowers)`, each +registration a `CanonicalOrder` rewrite, an inner simplification and two ratings — and on an +input with nothing to invert the second tree is the first tree, so the second registration +re-sorted an identical tree to offer the history set an entity it already held. Found by +attributing `SimplifyHard`'s bytes to the steps of one level: the registrations were 862 MB of +976. The second registration now runs only where the inversion produced a different tree, and +`CostModel.DefaultCost` costs a subtree once per instance instead of re-walking it for every +candidate that shares it. No answer moves — the same candidates reach a set that already +deduplicated the repeat, and every rate is the same double. + +| benchmark | 1930th | 1931st | | +|---|--:|--:|--:| +| `SimplifyHard` | 3,662,375,240 | 2,027,771,744 | **−44.6%** | +| `SolveHard` | 1,452,700,104 | 1,057,996,016 | **−27.2%** | +| `SolveMediumHard` | 165,611,184 | 126,162,152 | **−23.8%** | +| `SimplifyEasy` | 128,460 | 115,763 | −9.9% | + +Bytes allocated; every other row unchanged or inside its floor. The solvers move because `Solve` +simplifies inside. Timings, one run against one run and so carrying this file's usual rider: +`SimplifyHard` 1.60 s → 0.92 s, `SolveHard` 879 → 772 ms, `SolveMediumHard` 88 → 78 ms, +`SimplifyEasy` 110 → 89 µs. The gate's baseline was taken from this run, which is the case its +section above names as the one where a red gate means nothing is wrong. + +What is left of a `SimplifyHard` level, for whoever comes next: the remaining registration +(431 MB), and the three expansion candidates — `res.Expand().Simplify(-level)`, the rule-based +factorisation at level 2, and the opened angles expanded — at 177, 170 and 82 MB, each a +recursive simplification of an expanded tree. + ## The 1915th, and every release beside it on one machine The first column measured by `Sources/Utils/benchmark_key_commits.sh` reaching all five entries in diff --git a/Sources/AngouriMath/Functions/Simplification/Simplificator.cs b/Sources/AngouriMath/Functions/Simplification/Simplificator.cs index 43cb7f2c9..e91c26780 100644 --- a/Sources/AngouriMath/Functions/Simplification/Simplificator.cs +++ b/Sources/AngouriMath/Functions/Simplification/Simplificator.cs @@ -133,7 +133,14 @@ void __IterAddHistory(Entity expr) history[ncompl] = new HashSet { n }; } __IterAddHistory(expr); - __IterAddHistory(expr.Rewrite(RewriteRules.InvertNegativePowers)); + // Only where inverting the negative powers produced a different tree: on the + // same tree the second registration re-sorts it, inner-simplifies it and rates it + // again to add an entity the set already holds. Measured on SimplifyHard, the + // registrations were 862 MB of a level's 976 MB, and this input never has a + // negative power to invert. + var inverted = expr.Rewrite(RewriteRules.InvertNegativePowers); + if (!inverted.Equals(expr)) + __IterAddHistory(inverted); MultithreadingFunctional.ExitIfCancelled(); } diff --git a/Sources/Tests/DotnetBenchmark/performance-baseline.json b/Sources/Tests/DotnetBenchmark/performance-baseline.json index 5b45a6372..93f07d19a 100644 --- a/Sources/Tests/DotnetBenchmark/performance-baseline.json +++ b/Sources/Tests/DotnetBenchmark/performance-baseline.json @@ -1,82 +1,82 @@ { "comment": "Allocated bytes and mean nanoseconds per operation for the popular use cases of https://github.com/asc-community/AngouriMath/issues/746. Checked by PerformanceGate.cs; see Sources/AngouriMath/Docs/WhatsNew/version_performance_control.md for when updating it is legitimate.", "benchmark": "CommonFunctionsInterVersion", - "commit": "6b93b4012d54c354537821cc8e768ed8570563ce", - "measuredOn": "2026-08-23", + "commit": "5408dc4967d7e259e7411019c66aa265fe865092", + "measuredOn": "2026-09-07", "runtime": ".NET 10.0.10", "machine": "Ubuntu 26.04 LTS, X64, 8 logical cores", "cases": { "CompileEasy": { - "allocatedBytes": 16230, - "meanNanoseconds": 217683.91618652345 + "allocatedBytes": 10970, + "meanNanoseconds": 191900.1083984375 }, "CompileHard": { - "allocatedBytes": 37733, - "meanNanoseconds": 373193.7995876736 + "allocatedBytes": 20396, + "meanNanoseconds": 317031.0014272836 }, "Derivate": { - "allocatedBytes": 52824, - "meanNanoseconds": 17214.95599332063 + "allocatedBytes": 52823, + "meanNanoseconds": 14309.438617960612 }, "EvalEasy": { "allocatedBytes": 0, - "meanNanoseconds": 0.984684884129092 + "meanNanoseconds": 1.894969120089497 }, "EvalTrig": { - "allocatedBytes": 1341376, - "meanNanoseconds": 867492.3806423611 + "allocatedBytes": 1341377, + "meanNanoseconds": 707465.154296875 }, "EvalTrigPrecise": { "allocatedBytes": 12742205, - "meanNanoseconds": 26845828.055484693 + "meanNanoseconds": 22813158.20535714 }, "ParseEasy": { - "allocatedBytes": 18064, - "meanNanoseconds": 6918.623293346829 + "allocatedBytes": 18125, + "meanNanoseconds": 5989.073775155203 }, "ParseHard": { - "allocatedBytes": 3522432, - "meanNanoseconds": 1706600.9333379837 + "allocatedBytes": 3531225, + "meanNanoseconds": 1542264.4018229167 }, "RunEasy": { "allocatedBytes": 0, - "meanNanoseconds": 23.025308667736894 + "meanNanoseconds": 20.112695195845195 }, "RunHard": { "allocatedBytes": 0, - "meanNanoseconds": 345.98671662012737 + "meanNanoseconds": 294.40666042055403 }, "RunMedium": { "allocatedBytes": 0, - "meanNanoseconds": 202.93889632432357 + "meanNanoseconds": 169.9120227779661 }, "SimplifyEasy": { - "allocatedBytes": 128100, - "meanNanoseconds": 102040.96768465909 + "allocatedBytes": 115763, + "meanNanoseconds": 89245.44518025716 }, "SimplifyHard": { - "allocatedBytes": 3620668728, - "meanNanoseconds": 2160215953.7802196 + "allocatedBytes": 2027771744, + "meanNanoseconds": 923699884.4666667 }, "SolveEasy": { - "allocatedBytes": 8852269, - "meanNanoseconds": 6083866.387073863 + "allocatedBytes": 8853834, + "meanNanoseconds": 4907726.978125 }, "SolveEasyMedium": { - "allocatedBytes": 95795, - "meanNanoseconds": 37080.269161224365 + "allocatedBytes": 97335, + "meanNanoseconds": 30335.062217203777 }, "SolveHard": { - "allocatedBytes": 1431860248, - "meanNanoseconds": 1056388966.8536586 + "allocatedBytes": 1057996016, + "meanNanoseconds": 771550577.6666666 }, "SolveMedium": { - "allocatedBytes": 658130, - "meanNanoseconds": 567993.1599434095 + "allocatedBytes": 662923, + "meanNanoseconds": 462935.250788762 }, "SolveMediumHard": { - "allocatedBytes": 162304592, - "meanNanoseconds": 99270727.66666667 + "allocatedBytes": 126162152, + "meanNanoseconds": 77942153.13333334 } }, "ungated": {