Add BigInteger.ModInverse and BigInteger.LeastCommonMultiple - #133031
Open
su-senka wants to merge 6 commits into
Open
Add BigInteger.ModInverse and BigInteger.LeastCommonMultiple#133031su-senka wants to merge 6 commits into
su-senka wants to merge 6 commits into
Conversation
added 6 commits
September 1, 2026 20:01
…rations Adds the approved public surface for dotnet#130021 with NotImplementedException stubs so the tree compiles and the new tests fail for the right reason: - ref assembly entries in alphabetical position - Arithmetic_ModInverseDoesNotExist resource string - leastcommonmultiple.cs and modinverse.cs registered in the test csproj - fully documented stubs on BigInteger leastcommonmultiple.cs exercises ModInverse in CarmichaelFunctionUsage, so both methods must exist before either test file can compile.
Returns zero when either operand is zero, and is otherwise computed as Abs(left / gcd) * Abs(right), dividing before multiplying so the intermediate never exceeds the result. The result is always non-negative and the method never throws. Mirrors the structure of GreatestCommonDivisor with a power-of-two fast path, since TryGetPowerOfTwoExponent matches on magnitude and the least common multiple of two powers of two is the larger of the two.
Establishes the correctness baseline for the API. The core carries the cofactor of the value alongside the remainder sequence and reads the greatest common divisor off the last non-zero remainder, so non-coprimality is detected by the algorithm itself rather than by a separate greatest common divisor pass. The value is reduced to its canonical representative first, which makes a value congruent to zero modulo the modulus fall out as a gcd of the modulus and throw like any other non-coprime input. This deliberately simple implementation is replaced by a Lehmer-based extended greatest common divisor in a following commit.
Adds BigIntegerCalculator.ModInverse, which reuses the Lehmer guessing step and Jebelean termination condition from Gcd and carries a cofactor alongside the remainder sequence. Cofactors are held as unsigned magnitudes with the recurrence t[i+1] = t[i-1] + q[i] * t[i], which only ever adds. The alternating sign of the extended Euclidean algorithm is carried as the parity of the remainder index instead, matching the calculator's unsigned-throughout design. Because the remainder matrix and the cofactor matrix differ only in the sign of their off-diagonal entries, the two subtractions in LehmerCore become additions on magnitudes, and the existing odd-iteration swap doubles as the parity flip. The identity t[i] * r[i-1] + t[i-1] * r[i] == modulus bounds every cofactor by the modulus, so cofactor buffers carry one spare limb. The schoolbook implementation is retained for now as a debug-only oracle so every inverse computed under a debug build is cross-checked.
Inverting modulo 2^k does not need the Lehmer path at all. Hensel lifting in Newton iteration form doubles the number of correct bits every step: if value * x == 1 (mod 2^m) then x * (2 - value * x) is correct modulo 2^2m, because the two factors are conjugate and the cross terms cancel. The seed is the inverse modulo a single limb, taken from ComputeMontgomeryInverse. That returns the negated inverse, since that is what Montgomery reduction consumes, so the sign is corrected before use. The lift works in whole limbs and the top limb is masked back to the bits the modulus spans, which is sound because 2^k divides the limb radix raised to the limb count. Only odd values are invertible, so an even value now reports the missing inverse without running the general path. Adds exhaustive bit-length coverage for the new path, walking every length through 300 plus the larger limb boundaries.
The schoolbook extended Euclid was kept alongside the Lehmer path as a debug-only cross-check while the span implementation and the power-of-two fast path were being developed. Both are now covered by the test suite directly, so the oracle and the second implementation it needed are no longer earning their place.
|
Azure Pipelines: Successfully started running 3 pipeline(s). 13 pipeline(s) were filtered out due to trigger conditions. There may be pipelines that require an authorized user to comment /azp run to run. |
Contributor
|
Tagging subscribers to this area: @dotnet/area-system-numerics |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Add BigInteger.ModInverse and BigInteger.LeastCommonMultiple
Implements the two APIs approved in #130021:
Both match the shape signed off by @bartonjs in API review. Nothing outside the approved
surface is added — in particular there is no
ExtendedEuclidianand noTryModInverse.Semantics
ModInverse(value, modulus)xin[0, modulus)withvalue * x == 1 (mod modulus).valuemay be negative; it is reduced to its canonical representative first.Zerowhenmodulusis1, for anyvalueincluding0.ArgumentOutOfRangeExceptionwhenmodulus <= 0, withParamName"modulus".ArithmeticExceptionwhenvalueandmodulusare not coprime, which includesthe case where
valueis congruent to zero modulomodulus.LeastCommonMultiple(left, right)The proposal did not pin down the edge cases, so these were posted on the issue and drew
no objection before implementation started:
their common multiples.
Zerowhen either operand is zero, including(0, 0). Zero is a multipleof every integer, so it is the least common multiple in that case rather than an error.
It is computed as
Abs(left / gcd) * Abs(right), dividing before multiplying so theintermediate value never exceeds the result.
Documentation wording
The
<returns>text onModInversestates the contract as a congruence:rather than the
(value * x) % modulus == 1phrasing used in the original proposal text.That phrasing is wrong. C#'s
%truncates toward zero, so for negativevaluetheremainder is negative and the stated equality does not hold even when the returned
xiscorrect.
ModInverse(-3, 7)is2, and(-3 * 2) % 7is-6, not1. The congruenceform is true for every input the method accepts.
Implementation
The work landed in five reviewable steps, one commit each.
Schoolbook baseline first.
ModInversewas first implemented as a plain extendedEuclidean algorithm over
BigIntegeroperations, purely to establish correctness. It wasthen retained temporarily as a debug-only differential oracle — a
Debug.Assertcomparingevery result computed by the real implementation against the schoolbook one — while the
optimized paths were developed. The final commit removes both the oracle and the second
implementation, so this is development scaffolding that does not ship.
Lehmer-based extended GCD. The general path is
BigIntegerCalculator.ModInverseinBigIntegerCalculator.GcdInv.cs, reusing the existing Lehmer guessing step and Jebeleantermination condition from
Gcd. The Bézout cofactor is carried alongside the remaindersequence using the recurrence
which satisfies
r[i] == (-1)^(i+1) * t[i] * value (mod modulus). Every cofactor isnon-negative and the recurrence only ever adds, so the cofactors stay unsigned like the
rest of the calculator and the alternating sign is carried as the parity of the remainder
index alone.
This falls out especially cleanly for the Lehmer step.
LehmerCoreappliesx' = a*x - b*y,y' = d*y - c*xto the remainders. Cofactors take the same linear map,and substituting the alternating signs cancels both subtractions exactly, leaving
u' = a*u + b*v,v' = c*u + d*von magnitudes — the same matrix with the signs dropped,not its transpose. The existing odd-iteration swap that restores remainder ordering
doubles as the parity flip, so no separate sign bookkeeping is needed.
Buffer sizing comes from the identity
t[i] * r[i-1] + t[i-1] * r[i] == modulus, whichbounds every cofactor by the modulus, including while one is being accumulated. Cofactor
buffers therefore carry exactly one spare limb.
Hensel lifting for power-of-two moduli. Inverting modulo
2^kdoes not need theLehmer path.
ModInversePowerOfTworuns Newton iteration on the 2-adic inverse: ifvalue * x == 1 (mod 2^m)thenx * (2 - value * x)is correct modulo2^2m, becausewriting
value * x = 1 + e * 2^mgives(1 + e*2^m) * (1 - e*2^m) = 1 - e^2 * 2^2m. Eachstep doubles the number of correct bits, so the lift terminates after
ceil(log2(limbCount))steps.The seed is the inverse modulo a single limb, taken from the existing
ComputeMontgomeryInverseinBigIntegerCalculator.PowMod.cs. That helper returns thenegated inverse, because that is what Montgomery reduction consumes, so the sign is
corrected before use. The lift works in whole limbs and the top limb is then masked back
to the bits the modulus spans, which is sound because
2^kdivides the limb radix raisedto the limb count.
Note for future contributors to this file
BigIntegerCalculator.Multiplyrequires a destination of exactlyleft.Length + right.Lengthlimbs, not merely one that is large enough. The documented assert only saysbits.Length >= left.Length + right.Length, but the Karatsuba path splits the destinationas
bits[..2n]/bits[2n..]and assertsbitsLow.Length >= bitsHigh.Length, which failsfor an oversized destination. Reusing one generously sized scratch buffer across several
multiplies of differing widths — the obvious thing to do — trips this. It is worth knowing
before writing new code against that API.
32-bit
One branch in this change is architecture-conditional and was never executed locally,
because this was developed and tested on arm64:
BigIntegerCalculator.GcdInv.cs:321—int lehmerMinimum = nint.Size == 4 ? 3 : 2;This is the minimum operand length before Lehmer's guess can be attempted, mirroring
ExtractDigits' own asserts (>= 3limbs on 32-bit,>= 2on 64-bit). Too low andExtractDigitsreads past the operand; too high only costs performance. It deservesattention from 32-bit CI legs.
The surface was deliberately kept this small. The cofactor combination helpers use a
UInt128accumulator withBitsPerLimbshifts and so are architecture-neutral, and theEuclid step was written as a fully general fallback used whenever operands are too short
for Lehmer, rather than as a separate terminal path that only 32-bit would reach. The
power-of-two fast path introduces no architecture-conditional branch at all.
Testing
Two new test files,
modinverse.csandleastcommonmultiple.cs, add 84 tests. The fullSystem.Runtime.Numericssuite is 8718 tests, 0 failures, up from a baseline of 8634.The tests verify the defining mathematical identities directly rather than diffing against
a reference implementation: the congruence
value * x == 1 (mod modulus), canonicality ofthe representative,
ModInversebeing an involution, agreement withModPowvia Fermat'slittle theorem for Mersenne prime moduli,
lcm(a,b) * gcd(a,b) == |a*b|, coprimality ofthe LCM cofactors, associativity, commutativity, and identity. This means they remain
meaningful independently of how the implementation is written.
Coverage includes exhaustive power-of-two bit lengths — every length through 300 plus the
larger limb boundaries — operand widths from 1 to 512 bytes, asymmetric operand sizes,
Mersenne and shifted-limb structured operands, and the non-coprime and non-positive-modulus
error paths.
Mutation testing was used to check the tests are actually sensitive to the errors most
likely to occur here, since a green suite alone says little about a sign convention:
reachable only on large operands: 6 failures, including
LargeOperandsandInverseOfInverseIsReducedValue.ComputeMontgomeryInversesign correction: caught.Separately, during development an out-of-tree stress harness exercised roughly 480,000
exhaustive small cases, 300 consecutive Fibonacci pairs (the worst case for Euclid step
counts, and so for parity churn), and randomized operands across 26 widths, with every call
cross-checked against the schoolbook oracle described above.
Performance
Measured on a Release build of
clr+libs. The harness prints the resolvedSystem.Runtime.Numericslocation and itsDebuggableAttributeoptimizer status beforerunning, and refuses to present numbers from a Debug assembly — this code carries per-limb
Debug.Asserts, including an O(n) buffer scan, that would otherwise dominate.Takeaways:
4096 bits.
GreatestCommonDivisoris the baseline precisely so this overhead is visible;it is the price of the extended algorithm over the plain one on the shared Lehmer path.
The rise to 2.67x at 4096 bits is outside the noise: cofactors grow toward modulus width
while remainders shrink, so cofactor updates become a larger share of each Lehmer block
as operands grow.
cost of a GCD. That is Hensel lifting's
O(M(n) log n)against Lehmer'sO(n^2).LeastCommonMultiplecosts GCD plus about 15%, consistently — one exact division andone multiplication on top of the GCD it has to compute anyway.
The
GreatestCommonDivisorbaseline shows zero allocation because its operands are coprimeby construction, as they must be for an inverse to exist, so it returns
1, whichBigIntegerrepresents inline. The work is a full-length Lehmer run either way; only theallocation column is asymmetric.