Cover the remainder-only Mod kernels' rare Knuth paths - #116
Merged
Conversation
Mod's kernels answer x % y without forming the quotient, and their branches divide by how far the quotient estimate is off: the D3 correction, its second iteration, the saturating digit where the window's top limb equals the divisor's, and the add-back that fires on a borrow out of a limb no correction carried into. Instrumenting those branches showed the suite reaches all of them except one, and reaches several only incidentally, because operands drawn at random almost never put the estimate near its bound. So the cases here are constructed rather than sampled: x = q*y + r with q and r on the boundaries that drive those branches, swept over every shape and every normalising shift. Two operand pairs found by search are pinned as literals, for the four-limb kernel's second D3 correction - it needs a divisor whose normalised top limb sits just above 2^63 together with a dividend that fills limb 3, which a randomised search reaches about eight times in twenty million pairs. Expected values come from BigInteger, never from limb arithmetic, so a shared mistake in the limb code cannot make a test agree with itself. Each pair is also checked against the remainder implied by Divide, which still goes through the full-quotient routine: a separate body of limb code, so the two agreeing is independent evidence and a mismatch says which of the two is wrong. Every kernel branch is now covered, and injected faults are caught - a dropped add-back, an add-back that ignores the carry, a missing saturation guard, a digit skipped when it was needed, and the second correction removed from the two-limb kernel. The second correction in the three- and four-limb kernels survives removal, because both have an add-back and Knuth's test firing at all means the estimate was too large, so one correction plus the add-back reaches the same remainder; the two-limb kernel has no add-back and its corrections must be exact. A hundred cases, a few seconds on top of the suite.
benaadams
approved these changes
Sep 3, 2026
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.
Follow-up to #115, which landed the remainder-only
Modkernels. Those kernels' branches divide by how far the Knuth quotient estimate is off — the D3 correction, its second iteration, the saturating digit where the window's top limb equals the divisor's, and the add-back that fires on a borrow out of a limb no correction carried into.I instrumented all 24 of those branches and ran the suite against the counters. It reaches 23 of 24, and several only incidentally: operands drawn at random almost never put the estimate near its bound. The one it never executes is the four-limb kernel's second D3 correction.
What is in it
One new test file,
ModKernelTests.cs. Cases are constructed rather than sampled:x = q*y + rwithqandron the boundaries that drive those branches, swept over every (dividend limbs)x(divisor limbs) shape and every normalising shift, plus bit boundaries, the signed sign-combination arms, and single-limb moduli forMultiplyMod.Two operand pairs found by search are pinned as literals for the uncovered branch. It needs a divisor whose normalised top limb sits just above 2^63 together with a dividend that fills limb 3, which a randomised search reaches about eight times in twenty million pairs — not something a generator should be relied on to rediscover.
Expected values come from
BigInteger, never from limb arithmetic, so a shared mistake in the limb code cannot make a test agree with itself. Each pair is also checked against the remainder implied byDivide, which still goes through the full-quotient routine — a separate body of limb code, so the two agreeing is independent evidence, and a mismatch localises which of the two is wrong.Validation
Branch coverage, from the instrumented build: 24 of 24, this file alone.
Injected faults, to check the tests bite rather than just pass — 8 of 10 caught:
borrowinstead ofborrow & (rcarry ^ 1))The two survivors are not test escapes. Both kernels have an add-back, and Knuth's D3 test firing at all means the estimate was too large, so one correction plus the add-back reaches the same remainder — the second iteration is a performance refinement there, not a correctness requirement. Disabling it in the four-limb kernel moved two operations from the correction onto the add-back and changed no answer. The two-limb kernel has no add-back and its corrections must be exact, which is why removing its second correction fails 66 tests.
That is worth a look on its own: if the second correction is behaviourally dead in the three- and four-limb kernels, and it fires roughly eight times in twenty million operations, it may not be paying for the code and the branch. Not changed here — this PR only adds tests.
Separately,
Mod/Divide/MultiplyMod/AddMod/SubtractMod/Int256.Modwere fuzzed againstBigIntegerover ~27M pairs with zero mismatches, in the default, no-AVX-512, no-AVX2 and no-intrinsics configurations — the last being the reciprocal path ARM64 takes. That fuzzing is what found the pinned pairs; it is not checked in.dotnet format whitespace --verify-no-changesclean,git diff --checkclean.