Barrett Reduction for division and modular arithmetic - #2514
Conversation
bf79b1a to
48be371
Compare
|
@claude review this PR |
There was a problem hiding this comment.
Pull request overview
Adds Barrett-reduction support for 256-bit division and modular arithmetic, with deterministic unit tests.
Changes:
- Adds reciprocal-based division and modular arithmetic operations.
- Refines uint256 arithmetic helpers and intrinsics.
- Adds comprehensive tests and registers them with VM unit tests.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.
| File | Review summary |
|---|---|
test/vm/unit/uint256_barrett_tests.cpp |
Adds Barrett boundary and randomized correctness tests. |
test/vm/unit/CMakeLists.txt |
Registers the new test source. |
category/core/runtime/uint256/intrinsics.hpp |
Updates subtraction intrinsic handling. |
category/core/runtime/uint256.hpp |
Contains four unresolved findings: multiplier-bearing reciprocals in division wrappers ([P1], critical, 2 votes); unsafe subb_truncating operand bounds ([P2], critical, 3 votes); unsupported non-word-aligned remainder configurations ([P2], moderate, 2 votes); and fixed-width product constraints accepting oversized inputs ([P2], moderate, 3 votes). |
Suppressed comments (5)
category/core/runtime/uint256.hpp:2123
- [P2] Match the constraints to the fixed operand widths
x.as_words() always has four words, but reduce expects words_t<min_words(Params.input_bits)>; this requirement accepts input_bits >= 257, which makes the call ill-formed. It also accepts multiplier_bits > 256 even though the reciprocal constructor's static assertion rejects multipliers wider than uint256_t. Constrain both parameters to the widths this wrapper actually supports, or add a wider-input implementation.
struct std::formatter<monad::uint256_t>
category/core/runtime/uint256.hpp:2066
- [P1] Exclude multiplier-bearing reciprocals from the signed division wrapper. The current constraint accepts a
reciprocalconstructed with a constant multiplier, but its call toudivrem()then reduces|x| * multiplierinstead of|x|, producing a valid-looking but incorrect signed quotient and remainder. Restrict this overload tomultiplier_bits == 0.
category/core/runtime/uint256.hpp:2087 - [P1] Constrain this wrapper to the no-multiplier, five-word input it actually passes to
reduce(). A multiplier-bearing reciprocal is accepted and makesreduce()compute(x + y) * multiplier mod d; configurations withinput_bits >= 321also satisfy this constraint but cannot bind becausesumis alwayswords_t<5>whilereduce()requireswords_t<INPUT_WORDS>.
}
category/core/runtime/uint256.hpp:1388
- [P2] Encode the remaining reciprocal parameter invariants in the constraint. The current requires-clause accepts configurations such as
min_denominator = 2^255, input_bits = 1, for whichPRE_PRODUCT_SHIFTexceedsSHIFTandPOST_PRODUCT_SHIFT = SHIFT - PRE_PRODUCT_SHIFTunderflows; it also acceptsmultiplier_bits > 256even though the constructor later relies on a static assertion for that case. These configurations should be rejected at the class boundary rather than producing invalid array widths or a late instantiation failure.
requires(
uint256_t{Params.min_denominator} > 0 &&
uint256_t{Params.min_denominator} <=
uint256_t{Params.max_denominator} &&
Params.input_bits > 0 &&
category/core/runtime/uint256.hpp:1752
- [P2] Fully initialize the output spans in
copy. WhenRis smaller than four words, this loop leaves the remaining destination words unchanged; for example, a division reciprocal covering 193–256-bit denominators has a one-word maximum quotient, soreduce<true>leaves quotient words 1–3 stale when called with a non-zero buffer. The publicreducemethod and its span parameters do not state a zero-initialization precondition; either clear the unused words here or make that precondition explicit and enforce it through a private/internal API.
* If PRE_PRODUCT_SHIFT != 0, then q_hat underapproximates the true
* quotient by at most 2
*
* If need_quotient is false, then the quotient is only computed
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| inline uint256_t addmod( | ||
| uint256_t const &x, uint256_t const &y, | ||
| barrett::reciprocal<Params> const &rec) noexcept | ||
| requires(Params.input_bits >= 257) |
| [[gnu::always_inline]] | ||
| inline constexpr result_with_carry<words_t<R>> | ||
| subb_truncating(words_t<M> const &lhs, words_t<N> const &rhs) noexcept | ||
| requires(0 <= R && R <= std::max(M, N)) |
| [[gnu::always_inline]] | ||
| MONAD_NO_VECTORIZE inline div_result<uint256_t> udivrem( | ||
| uint256_t const &u, barrett::reciprocal<Params> const &rec) noexcept | ||
| requires(Params.input_bits == 256) |
There was a problem hiding this comment.
This PR ports the Barrett-reduction implementation from #1628 onto current main (rebased with helper cleanup commits) and adds a comprehensive unit-test suite in test/vm/unit/uint256_barrett_tests.cpp.
I reviewed the diff against the review policy in REVIEW.md — traits, FFI (n/a — pure C++), C++ correctness (lifetimes / UB / integer arithmetic / move / const / thread safety), testing, style, PR hygiene, and security. No P0/P1 blocking issues found.
Correctness spot-checks worth noting for future reviewers:
reduce()at everyif constexprsizing path stays within array bounds for all exported aliases:MAX_R_HAT_WORDS <= INPUT_WORDSin each combination, so thesubb_truncating<MAX_R_HAT_WORDS>(x, qv)call in the non-multiplier branch never OOB-readsx.POST_PRODUCT_BIT_SHIFT == 0(required byreduce<false>) holds for every exported alias becausePRE_PRODUCT_SHIFTis aligned toBIT_SHIFTin thePRE_PRODUCT_SHIFTlambda.- Constructor debug-asserts guarantee the truncated high words of the reciprocal are zero, matching
RECIPROCAL_BITS = bit_width(max_q)derived fromMIN_DENOMINATOR. - The
>> 1 >> (63 - BIT_SHIFT)idiom in the multiplier-numerator overload avoids the>> 64UB whenBIT_SHIFT == 0, and that branch isif constexpr-gated. sdivremon theINT_MIN / -1bit pattern reproduces the EVM SDIV wrap (-2^255) via two's-complement negation, and the tests exercise it explicitly.
The removal of force(sub_borrow) from intrinsics::subb is compensated by the explicit force(diff[i]) chain now inlined in uint256_t::operator<; other callers of intrinsics::subb (in subb/subb_zx/subb_truncating) already consume the value naturally, so the sub/sbb sequence remains materialized.
Test coverage is comprehensive: every exported reciprocal alias and the catch-alls for udivrem, addmod, mulmod, and mulmod_const, plus the four interval aliases through sdivrem; two-correction witnesses pin the rare q_hat == q - 2 exit for each interval where it is reachable; and nonzero_bit_shift_multiplier_numerator exercises the previously-dormant BIT_SHIFT != 0 && MULTIPLIER_WORDS > 0 path against a 576-bit oracle. Stream isolation via splitmix64(FIXED_SEED ^ tag) keeps random cases reproducible and independent.
Verdict: CORRECT
🤖 Generated with Claude Code
| force(diff[0]); | ||
| force(diff[1]); | ||
| force(diff[2]); | ||
| force(diff[3]); |
There was a problem hiding this comment.
The discarded-return force(diff[i]) calls likely do not achieve the guard the comment describes.
force() uses asm("" : "+r"(expr)) without volatile and returns by value. Once force is inlined here, its sole output (expr) is dead, and the GCC manual states that a non-volatile asm whose outputs are unused may be eliminated. If DCE fires, the diff[i] computations become dead too, and clang is once again free to rewrite the multi-word sub/sbb chain into the cmp/setcc sequence the comment warns about.
In main, force(sub_borrow) lived inside single-word subb where its return was captured into a struct field, so the value stayed live through the aggregate return. Every other force site in this file (lines 209–213, 337–340, 354–358) similarly stores/consumes the return; this is the sole discarded-return use.
Consider either making force volatile or sinking the returned values into a computed result that flows out of the function.
| MONAD_NO_VECTORIZE [[gnu::always_inline]] | ||
| [[gnu::always_inline]] | ||
| inline constexpr words_t<R> | ||
| truncating_mul(words_t<M> const &x, words_t<N> const &y) noexcept |
There was a problem hiding this comment.
Dropping MONAD_NO_VECTORIZE here silently changes codegen for callers that used to inherit it via always-inline.
The wrapping helpers introduced in this PR (wide_mul, and every barrett::reduce / barrett::mulmod / mulmod free function) keep MONAD_NO_VECTORIZE, so their internal truncating_mul<> calls are still shielded. But external callers that inline the generic template are no longer shielded:
category/core/checked_math.cpp:66—checked_mulinlinestruncating_mul<2 * uint256_t::num_words>with no wrapping no-vectorize.- The exponentiation loop in
powat lines 1103/1105 usestruncating_mul(result, base)andtruncating_mul(base, base). uint256_t::from_stringat line 1363 usestruncating_mul(result, 10).
The compiler is now free to vectorize the multi-word MULX chain in those callers, which presumably was the reason for the attribute in the first place. Worth confirming with a codegen diff on checked_mul and pow, or wrapping those callers explicitly.
| [[gnu::always_inline]] | ||
| inline constexpr result_with_carry<words_t<R>> | ||
| subb_truncating(words_t<M> const &lhs, words_t<N> const &rhs) noexcept | ||
| requires(0 <= R && R <= std::max(M, N)) | ||
| { | ||
| words_t<R> result; | ||
| bool borrow = false; | ||
| #pragma GCC unroll(R) | ||
| for (size_t i = 0; i < R; i++) { | ||
| auto const [wi, bi] = subb(lhs[i], rhs[i], borrow); | ||
| result[i] = wi; | ||
| borrow = bi; |
There was a problem hiding this comment.
The requires(0 <= R && R <= std::max(M, N)) constraint is looser than the loop body demands.
The loop accesses both lhs[i] and rhs[i] up to i == R - 1, so the real precondition is R <= std::min(M, N). Under the current clause, subb_truncating<5>(words_t<5>{}, words_t<3>{}) satisfies the constraint but reads rhs[3] / rhs[4] out of bounds — UB via std::array::operator[] in release, immediate diagnostic in constant-evaluated contexts.
No current caller trips this — every in-file use passes R equal to (or below) both M and N — but the constraint is a footgun for future callers and it's out of step with the sibling subb_zx, which handles the mixed-size case properly. Consider tightening to R <= std::min(M, N).
| [[gnu::always_inline]] | |
| inline constexpr result_with_carry<words_t<R>> | |
| subb_truncating(words_t<M> const &lhs, words_t<N> const &rhs) noexcept | |
| requires(0 <= R && R <= std::max(M, N)) | |
| { | |
| words_t<R> result; | |
| bool borrow = false; | |
| #pragma GCC unroll(R) | |
| for (size_t i = 0; i < R; i++) { | |
| auto const [wi, bi] = subb(lhs[i], rhs[i], borrow); | |
| result[i] = wi; | |
| borrow = bi; | |
| template <size_t R, size_t M, size_t N> | |
| [[gnu::always_inline]] | |
| inline constexpr result_with_carry<words_t<R>> | |
| subb_truncating(words_t<M> const &lhs, words_t<N> const &rhs) noexcept | |
| requires(R <= std::min(M, N)) | |
| { | |
| words_t<R> result; | |
| bool borrow = false; | |
| #pragma GCC unroll(R) | |
| for (size_t i = 0; i < R; i++) { | |
| auto const [wi, bi] = subb(lhs[i], rhs[i], borrow); | |
| result[i] = wi; | |
| borrow = bi; | |
| } | |
| return {.value = result, .carry = borrow}; | |
| } |
| { | ||
| words_t<N> result; | ||
| bool carry = false; | ||
| for (size_t i = 0; i < N; i++) { | ||
| auto [wi, bi] = addc(lhs[i], rhs[i], carry); | ||
| result[i] = wi; | ||
| carry = bi; | ||
| } | ||
| return {.value = result, .carry = carry}; | ||
| } | ||
|
|
There was a problem hiding this comment.
The two sibling helpers introduced alongside this one — subb_zx and subb_truncating — both annotate their multi-word loops with #pragma GCC unroll(N), and the PR also adds #pragma GCC unroll(...) to the udivrem normalization loops for the same reason. The new templated addc(words_t<N>, words_t<N>) doesn't have it.
Its two callers in this file — addc(q_hat, {1}) and addc(q_hat, {2}) inside reduce() (the one- and two-correction quotient bumps) — sit on the barrett hot path, so it seems worth staying consistent:
| { | |
| words_t<N> result; | |
| bool carry = false; | |
| for (size_t i = 0; i < N; i++) { | |
| auto [wi, bi] = addc(lhs[i], rhs[i], carry); | |
| result[i] = wi; | |
| carry = bi; | |
| } | |
| return {.value = result, .carry = carry}; | |
| } | |
| template <size_t N> | |
| [[gnu::always_inline]] | |
| inline constexpr result_with_carry<words_t<N>> | |
| addc(words_t<N> const &lhs, words_t<N> const &rhs) noexcept | |
| { | |
| words_t<N> result; | |
| bool carry = false; | |
| #pragma GCC unroll(N) | |
| for (size_t i = 0; i < N; i++) { | |
| auto [wi, bi] = addc(lhs[i], rhs[i], carry); | |
| result[i] = wi; | |
| carry = bi; | |
| } | |
| return {.value = result, .carry = carry}; | |
| } |
There was a problem hiding this comment.
This PR is a rebase of #1628's Barrett-reduction implementation onto current main, plus a substantial (~1,000 line) test suite that exercises every exported reciprocal interval and catch-all, both correction-count witnesses and full alias sweeps, against intx as the oracle. The core arithmetic ships with proof sketches in-comment and (per the PR description) an accompanying Rocq model — verification effort here is well above the norm and looks solid.
The implementation is not wired into any hot path in this PR, so runtime risk is limited to compile-time footprint and the small set of ancillary changes to existing code in uint256.hpp. I focused on those ancillary changes plus the new generic surface. Findings are:
operator<force(diff[i])(uint256.hpp:301) — return values are discarded and the moved codegen comment may no longer describe reality; otherforcesites all consume the return.MONAD_NO_VECTORIZEdropped fromtruncating_mul(uint256.hpp:639) — always-inline callers outside barrett (checked_mul,operator*/pow,from_string) inherit the compiler default rather than the previous attribute.subb_truncatingrequires (uint256.hpp:1019) —R <= std::max(M, N)should beminto prevent OOB reads on future callers.- New
addc(words_t<N>, words_t<N>)missing#pragma GCC unroll(N)(uint256.hpp:1047) — siblingsubbvariants and the newly-unrolledudivremloops all set it; this one is on the barrett hot path.
None of these are correctness bugs against the current call sites. The force and MONAD_NO_VECTORIZE questions are codegen-quality; the requires and unroll items are cheap safety/consistency wins.
Verdict: CORRECT
🤖 Generated with Claude Code
- Clarify numerator and reciprocal soundness comments - Factor out bit-width computation for word arrays and use it when sizing Barrett reciprocals - Use numerator() in no-multiplier constructor - Repair the dormant nonzero BIT_SHIFT multiplier path to avoid indexing out-of-bounds Co-Authored-By: Codex GPT 5.5
- Correct the pre-product shift description to refer to PRE_PRODUCT_SHIFT - Clarify why constant-multiplier reciprocals cannot uniformly drop low input bits - Rewrite the reduce soundness comment around INPUT_BITS and PRE_PRODUCT_SHIFT - Reword quotient-estimate and approximate-remainder sizing comments - Use MIN_DENOMINATOR_BITS in the pre-product shift calculation - Remove the unused addmod local denominator reference Co-Authored-By: Codex GPT 5.5
- Require remainder-only Barrett reduction to use a word-aligned post-product shift - Add a multiplier bit-width check for constant-multiplier reciprocals - Reject multiplier reciprocals whose parameter width exceeds the uint256_t constructor input Co-Authored-By: Codex GPT 5.5
Exercise each exported reciprocal interval and catch-all alias against intx for unsigned division, addmod, mulmod, and constant-multiplier reduction. Cover signed division, denominator neighbourhoods, exact-width operand extrema, constrained quotient/remainder classes, and reproducible fixed-seed samples. Pin the two-correction reducer exit for udivrem, addmod, and mulmod in every alias interval where it is reachable. Cover division by one, EVM signed-wrap behavior, and the repaired nonzero BIT_SHIFT multiplier path with full-width multipliers and a 576-bit oracle. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Co-Authored-By: Codex GPT 5.6 <codex@openai.com>
- Constrain multiplier reciprocals to the supported 256-bit domain and remove constructor checks that are now tautological - State reciprocal quotient bounds directly in soundness comments - Normalize uint256 declaration qualifiers and wrapping Co-Authored-By: Codex GPT 5.6 <codex@openai.com>
48be371 to
03880de
Compare
Summary
This PR contributes the original Barrett reduction additions (rebased onto latest
main) and unit tests for all alias families plus selected reciprocal-template configurations not exposed by those aliases.Placement
An open review question is whether the Barrett reduction namespace belongs beside the other 256-bit arithmetic in
category/core/runtimeor undercategory/vm/runtimeinstead, as @andreaslyn suggested. This PR leaves it inuint256.hppfor two reasons:It simplifies comparing the rebased version with Division and modular arithmetic via Barrett reduction #1628;
A preliminary analysis of the execution client suggests there are plausible consumers outside
category/vmwhich make acategory/vm/runtimelocation potentially too narrow.A possible future middle-ground is an opt-in
category/core/runtime/uint256/barrett.hppheader.Rebasing
The procedure for rebasing the branch was conducted by Claude Fable 5. Correctly rebasing involved whitespace adjustments, macro renamings, and deleting unused directives so it was not an entirely mechanical process and agentic LLM assistance was useful. To verify the end state faithfully ported the Barrett reduction implementation, a validation stage was developed.
A validation script normalises and byte-compares each original barrett namespace snapshot with its rebased counterpart, accounting for the differences above. The script does not account for changes outside of the barrett namespace, or deviations induced by a more modular
mainbranch; those were reviewed per commit withgit range-diff.Note:
--creation-factor=999: the default 60% similarity threshold refuses to pair the commits (their context lines changed withmain's refactor);-wis passed through to the inner diffs and suppresses the 4-column de-indent noise.The output was manually inspected to verify AI-documented deviations of the rebased commits compared to the original Barrett patch series, including:
tie()helper with structured bindings;forcefrom theintrinsicsnamespace;subb_zxandsubb_truncating.Correctness
Correctness is substantiated by a pure Rocq model that follows the C++ control flow and was manually reviewed for correspondence, and the unit tests in this PR. The formalisation led to clarifications of the original proof sketches. The next section describes the testing methodology.
Testing methodology
The test suite exercises all four alias intervals and the catch-all for each of
udivrem,addmod,mulmod, andmulmod_const, and all four intervals through the signed wrapper. Results are compared with the independentintximplementation.Every alias uses the same denominator construction: interval endpoints and their immediate neighbours, selected
2^k-1,2^k, and2^k+1values around bit and word boundaries, and six fixed-seed bit-width-uniform samples. Duplicate values are removed.Generated cases use a fixed seed and separate streams per operation/alias and denominator. Separate streams ensure additional draws from one denominator stream do not affect the others. Seeds are derived from structured tags and indices using David Stafford’s Mix13 finalizer, which has good avalanche properties on sequential inputs. Values are drawn from
std::mt19937_64using custom rejection samplers, avoiding the implementation-defined mapping ofstd::uniform_int_distribution.Operation-specific operands combine modular or quotient/remainder boundaries with sparse and dense word-boundary patterns, exercising quotient sizing and carry propagation.
Concrete witnesses force the rare two-correction exit in every applicable
udivrem,addmod, andmulmodinterval.Benchmarks & Integration
This PR does not change any execution-client call site or provide a performance analysis. #1628 reported preliminary isolated benchmarks, but in follow-up work I intend to reproduce those measurements against the current division implementation, then benchmark candidate call sites.