From 8c716064faecd744b2421668d61e38002e85f7fa Mon Sep 17 00:00:00 2001 From: Rafael Vuijk Date: Mon, 2 Nov 2026 13:13:36 +0000 Subject: [PATCH] Compute a large factorial as a balanced product, and keep only the small ones Every factorial up to the largest one asked for was kept, each made from the one before by multiplying the whole running product by one more factor. (10^5)! took 21 s, allocated 12.9 GB, and left 8.6 GB on the heap after a full collection, for the rest of the process. Factorials up to 1000! are still cached (half a megabyte in all). Past that the factorial is a balanced product tree in System.Numerics.BigInteger, which multiplies large numbers subquadratically, and is not kept: | | before | after | |---|---|---| | (10^5)! mod 1000003 | 21 s, 13.7 GB | 0.11 s, 8.8 MB | | 20000!, allocated | 276 MB | 1.2 MB | | heap after (10^5)!, after a full GC | 8.6 GB | 3 MB | The same tree in EInteger took 0.44 s and 453 MB for 1 2 ... 10^5; BigInteger takes 0.1 s and 7 MB, and the conversion back is 2 ms. https://github.com/asc-community/AngouriMath/pull/2327#issuecomment-6575158568 Co-Authored-By: Claude Opus 5.5 --- .../Functions/InternalAMExtensions.cs | 33 ++++++++++ .../UnitTests/Common/ALargeFactorialTest.cs | 60 +++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 Sources/Tests/UnitTests/Common/ALargeFactorialTest.cs diff --git a/Sources/AngouriMath/Functions/InternalAMExtensions.cs b/Sources/AngouriMath/Functions/InternalAMExtensions.cs index 848eb7704..c83818653 100644 --- a/Sources/AngouriMath/Functions/InternalAMExtensions.cs +++ b/Sources/AngouriMath/Functions/InternalAMExtensions.cs @@ -1135,6 +1135,12 @@ public static EInteger Factorial(int n) { if (n < 0) throw new ArgumentOutOfRangeException(nameof(n), "Illegal factorial(n) for n < 0: n = " + n); + // Past the cached ones it is a balanced product, and it is not kept. The cache held + // every factorial up to the largest asked for, so (10^5)! left 8.6 GB behind after a + // full collection, and took 21 s multiplying the whole running product by one factor + // at a time. https://github.com/asc-community/AngouriMath/pull/2327#issuecomment-6575158568 + if (n > MaxCachedFactorial) + return EInteger.FromBytes(ProductOfRange(1, n).ToByteArray(), littleEndian: true); // One volatile read, then an array that is never written again. var cache = factorialCache; if (n < cache.Length) @@ -1156,6 +1162,33 @@ public static EInteger Factorial(int n) } } + /// + /// The largest factorial kept in . Every one below it is kept as + /// well, half a megabyte in all at this bound; their total size grows as the square of it. + /// + private const int MaxCachedFactorial = 1000; + + /// + /// from (from + 1) ... to, for 1 <= from <= to + 1, as a balanced tree of + /// products, so that the multiplications are of numbers of like size. Multiplying a running + /// product by one small factor at a time copies the whole product every time. In + /// , whose multiplication of large numbers is + /// subquadratic: 1 2 ... 10^5 takes 0.1 s and 7 MB there, and 0.44 s and 453 MB in + /// . + /// + private static System.Numerics.BigInteger ProductOfRange(int from, int to) + { + if (to - from < 16) + { + var product = System.Numerics.BigInteger.One; + for (var i = from; i <= to; i++) + product *= i; + return product; + } + var middle = from + (to - from) / 2; + return ProductOfRange(from, middle) * ProductOfRange(middle + 1, to); + } + public static EInteger Factorial(this EInteger n) => n.CanFitInInt32() ? Factorial(n.ToInt32Checked()) diff --git a/Sources/Tests/UnitTests/Common/ALargeFactorialTest.cs b/Sources/Tests/UnitTests/Common/ALargeFactorialTest.cs new file mode 100644 index 000000000..75e9a5b60 --- /dev/null +++ b/Sources/Tests/UnitTests/Common/ALargeFactorialTest.cs @@ -0,0 +1,60 @@ +// +// 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.Numerics; +using AngouriMath; +using Xunit; +using static AngouriMath.Entity.Number; + +namespace AngouriMath.Tests.Common +{ + /// + /// An exact factorial. Every factorial up to the largest one asked for was kept, each made from + /// the one before by a single multiplication, so (10^5)! took 21 s and left 8.6 GB + /// behind after a full collection. Past the small ones it is now a balanced product and is not + /// kept. https://github.com/asc-community/AngouriMath/pull/2327#issuecomment-6575158568 + /// + [Trait("Area", "Common")] + public sealed class ALargeFactorialTest + { + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(17)] + [InlineData(999)] + [InlineData(1000)] + [InlineData(1001)] + [InlineData(1017)] + [InlineData(2500)] + public void IsTheProductOfTheWholeNumbersUpToIt(int n) + { + var product = BigInteger.One; + for (var i = 2; i <= n; i++) + product *= i; + Assert.Equal(Integer.Create(PeterO.Numbers.EInteger.FromString(product.ToString())), ((Entity)$"{n}!").Evaled); + } + + // 1000003 is prime, and the remainder is Python's math.factorial(10**5) % 1000003. + [Fact] + public void TheRemainderOfAHundredThousandFactorial() + => Assert.Equal(Integer.Create(353746), ((Entity)"(10^5)! mod 1000003").Evaled); + + // The running product copied the whole of itself once per factor: 276 MB for 20000!, which + // is 33 KB. The balanced product allocates about 1 MB. Bytes allocated on this thread are + // what is measured, so other tests running beside it do not move the figure. + [Fact] + public void IsNotMadeOneFactorAtATime() + { + Entity factorial = "20000!"; + var before = GC.GetAllocatedBytesForCurrentThread(); + _ = factorial.Evaled; + var allocated = GC.GetAllocatedBytesForCurrentThread() - before; + Assert.True(allocated < 20_000_000, $"20000! allocated {allocated} bytes"); + } + } +}