Skip to content

Avoid bit-loop lowering of 256-bit division and remainder#576

Draft
elle-j wants to merge 13 commits into
mainfrom
lj/div-mod-mulmod
Draft

Avoid bit-loop lowering of 256-bit division and remainder#576
elle-j wants to merge 13 commits into
mainfrom
lj/div-mod-mulmod

Conversation

@elle-j

@elle-j elle-j commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Description

Any 256-bit div/sdiv/mod/smod/mulmod/addmod whose operands are not provably narrow compiles to a bit-at-a-time shift/subtract loop (one quotient bit per iteration) with no hardware divide. LLVM's ExpandLargeDivRem expands any integer div/rem wider than 128 bits into that loop before instruction selection.

An existing pass already spares the operands it can prove fit a smaller type, so the gap is everything else (e.g. runtime values, mulmod internals). __mulmod alone is 8,480 bytes / 2,923 instructions with zero divu.

The issue was detected with a contract using mulmod in resolc-compiler-tests PR17 (which further explains runtime implications).

Note

This PR explores the feasibility/benefits/effects of algorithms that avoid the expensive bit-loop lowering. The word-width-microbench workload added in the resolc-compiler-tests PR ☝️ chains mulmod with the constant P-256 field prime, which additionally motivates the constant-modulus specialization explored here.

Changes

The target already has an efficient wide-divide primitive: an i128 divide lowers to the linked compiler-rt libcall __udivti3, which uses hardware 64-bit divu. The fix in this PR expresses 256-bit division with 128-bit "digits" so its core divide stays at i128 and never triggers the bit loop. On top of that, divisions and modular multiplications by compile-time constants skip runtime division entirely via Barrett reduction (multiply by a precomputed reciprocal plus a bounded number of conditional corrections).

  • stdlib.ll:
    • Adds __udivrem256 (Knuth Algorithm D, 128-bit digits)
    • Adds __udiv256/__urem256 wrappers
    • Adds __sdiv256/__srem256 signed wrappers (sign-magnitude over the unsigned routine)
    • Adds __urem512by256 for mulmod's 512-by-256 reduction
    • Adds __mulmod_barrett (constant-modulus Barrett reduction, HAC 14.42 at t = 256; needs no operand pre-reduction since its bound holds for any 512-bit product)
    • Rewrites __mulmod to use them
    • Removed the now-dead __ulongrem/__clz
      • __clz removed in favor of llvm.ctlz
    • Implemented and scrutinized by Claude Fable 5
  • llvm-context:
    • Extends the existing late narrowing pass into lower_wide_division: any i256 udiv/urem/sdiv/srem it cannot prove narrowable is rewritten into a call to the matching __udiv256/__urem256/__sdiv256/__srem256.
      • i256 udiv/urem by a non-power-of-two constant is instead rewritten into a generated per-constant Barrett helper (constant_division.rs), measurably faster per call than the routed routine.
      • It runs after LLVM's optimization passes and before backend codegen (i.e. before ExpandLargeDivRem) so it covers div/mod/sdiv/smod reductions.
      • Both the Yul and newyork pipelines share this, so this addresses both.
    • Adds specialize_constant_modulus_mulmod
      • Before the main pipeline inlines __mulmod away, a small constant-folding pre-pass exposes composed moduli and eligible calls are rewritten (256-bit constants to __mulmod_barrett, powers of two to inline mul+mask); lower_wide_division repeats the sweep post-pipeline. Skipped at -O0 and for modules without __mulmod calls.

Results

mulmod example

The table below is based only on the following contract example (Yul pipeline with -Oz, the default):

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.20;
contract Mulmod {
    function mm(uint256 a, uint256 b, uint256 m) external pure returns (uint256) {
        return mulmod(a, b, m);
    }
}
Metric Before After
PVM blob 11,917 B 8,188 B (−31.3%)
.L__mulmod symbol 8,480 B 982 B (−88.4%)
blob instructions 3,199 2,256 (−29.5%)
i256 bit-loop divisions 5 0 (−100%)
hardware divide none via __udivti3

Instruction counts are the Instructions totals reported in the pvmasm dump (final code after --gc-sections).

Dynamic cost

The table shows dynamic cost, measured (by Claude Fable 5 via revive-runner) as pallet-revive ref_time (of an entire contract call) per call on the same runner (full-width operands, runtime divisors unless marked constant).

Fixed per-call overhead is included, it is not the ref_time of the division instruction sequence alone. Thus the table's ratios will understate the speedup of the division itself. (Barrett reduction added in commit 6b87577.)

Op Before After (routing only) After (routing + Barrett)
mulmod (m >= 2^128) 1,824,813,639 292,305,859 (6.24x) same as routing only
mulmod (m < 2^128) 1,960,393,448 134,140,552 (14.61x) same as routing only
mulmod (a, b already < m) 258,395,757 156,926,513 (1.65x) same as routing only
div 973,193,345 126,117,010 (7.72x) same as routing only
mod 1,063,201,232 126,183,831 (8.43x) same as routing only
sdiv 2,033,308,510 104,132,901 (19.53x) same as routing only
smod 2,037,919,159 103,598,333 (19.67x) same as routing only
div by constant (x / 5) 1,555,897,335 91,790,915 (16.95x) 59,519,491 (26.14x)
mod by constant (x % 5) 1,643,949,927 91,662,819 (17.93x) 59,307,474 (27.72x)
mulmod by constant m (two-moduli contract) 1,756,336,009 293,778,256 (5.98x) 80,587,651 (21.79x)
mulmod by constant m (single-site contract) 59,308,650 288,031,112 (0.21x ❌) 75,293,850 (0.79x ❌)
mulmod chain, constant P-256 modulus (2000 ops/call) 3,273,730,193,986 272,284,595,482 (12.02x) 102,152,763,278 (32.05x)

@elle-j elle-j changed the title Avoid bit-loop lowering of unsigned 256-bit div/mod/mulmod Avoid bit-loop lowering of 256-bit division and remainder Jul 14, 2026
@elle-j
elle-j requested review from kvpanch and xermicus July 15, 2026 08:01
Comment thread crates/stdlib/stdlib.ll
%prodl = trunc i512 %prode to i256
%prodeh = lshr i512 %prode, 256
%prodh = trunc i512 %prodeh to i256
%res = call i256 @__ulongrem(i256 %prodl, i256 %prodh, i256 %modulo)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we still have uses of _ulongrem ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nope, __mulmod was its only caller, which is now using __urem512by256 instead.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can it be removed ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You mean _ulongrem? That is already removed.

Comment thread crates/stdlib/stdlib.ll

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Were these functions generated using some high level PL ? Perhaps, instead of keeping this ll file, we could describe algos in Rust and then convert to ll file as a part of the cargo build

Also can you please add comment with pseudo-code for each new function ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Were these functions generated using some high level PL?

The preexisting code here was adopted from https://github.com/matter-labs/era-compiler-llvm/blob/v1.4.0/llvm/lib/Target/EraVM/eravm-stdlib.ll. So the ll changes here are handwritten (implemented and scrutinized by Claude Fable 5). It was reported along with this issue that revm uses a handwritten mulmod using Knuth Algorithm as well. What Fable has referenced precisely is "Knuth TAOCP §4.3.1 Algorithm D with 128-bit digits. The 128/64 step is Hacker's Delight §9-4 divlu". (Fable also validated the functions using millions of fuzz vectors against big-integer oracles plus our integration tests.)

Perhaps, instead of keeping this ll file, we could describe algos in Rust and then convert to ll file as a part of the cargo build

I'm not entirely sure of the feasibility of that. Perhaps having Rust emit the IR via inkwell would also be an alternative (still need to build the IR lines one-by-one though). May be worth exploring the benefits.

Also can you please add comment with pseudo-code for each new function ?

Sure!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update: The function comments have been updated.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The preexisting code here was adopted from https://github.com/matter-labs/era-compiler-llvm/blob/v1.4.0/llvm/lib/Target/EraVM/eravm-stdlib.ll. So the ll changes here are handwritten (implemented and scrutinized by Claude Fable 5). It was reported along with this issue that revm uses a handwritten mulmod using Knuth Algorithm as well. What Fable has referenced precisely is "Knuth TAOCP §4.3.1 Algorithm D with 128-bit digits. The 128/64 step is Hacker's Delight §9-4 divlu". (Fable also validated the functions using millions of fuzz vectors against big-integer oracles plus our integration tests.)

Got it, thanks. Assuming it's taking from Matter labs and also validated, I don't need to read it

I'm not entirely sure of the feasibility of that. Perhaps having Rust emit the IR via inkwell would also be an alternative (still need to build the IR lines one-by-one though). May be worth exploring the benefits.

I meant to do something like that, i.e. describe the algorithm in Rust and compile it to LLVM IR. In this case it will be way easier to understand the code.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to clarify, the preexisting ll was from Matter Labs. The new/added implementation is Fable 5 generated and reviewed as described.

The Rust algorithm approach seems interesting. Yeah I agree it's not the most ideal review-wise with LLVM IR diffs. The algorithms rely on i256/i384/i512 SSA values though.

@kvpanch kvpanch left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

overall, lgtm

elle-j and others added 4 commits July 17, 2026 18:09
Divisions and modular multiplications whose divisor or modulus is a
compile-time constant no longer perform any runtime division: they
multiply by a precomputed reciprocal with a bounded number of
conditional corrections (Barrett reduction).

* `lower_wide_division` gains a second rung: an i256 `udiv`/`urem` by a
  non-power-of-two constant is rewritten into a generated per-constant
  helper (`constant_division.rs`) with floor(2^256/C) baked in and
  exactly one correction, ~1.5x faster per call than the routed
  long-division routine.

* Constant-modulus mulmod is rewritten to the new stdlib routine
  `__mulmod_barrett` (HAC 14.42 at t = 256, two corrections, no operand
  pre-reduction needed) for any 256-bit non-power-of-two modulus, which
  covers field primes like P-256 and secp256k1; power-of-two moduli
  become an inline mul+mask. The rewrite runs BEFORE the main pipeline
  (`specialize_constant_modulus_mulmod`, behind a
  mem2reg/sccp/instcombine pre-pass that folds solc's NOT-form
  constants), because the pipeline inlines `__mulmod` into callers and
  no rewritable call site survives it; `lower_wide_division` repeats the
  sweep afterwards for moduli that only become constant during
  optimization. The hook is skipped at -O0 and for modules without
  `__mulmod` calls, leaving other contracts untouched.
Adds `make coverage` (cargo-llvm-cov over the full workspace including
revive-llvm-builder), a new "Code coverage" book chapter that briefly
explains how to run it, and a PR workflow that runs coverage and
comments on the PR (it updates the same comment if it already exists)
when the `measure code coverage` label is applied (see e.g. [this
comment](#529 (comment))).

Building instrumented LLVM and generating an LLVM C++ coverage report is
also possible, see accompanying [PR description
#575](#575) for more details.

---------

Signed-off-by: Cyrill Leutwiler <[email protected]>
Signed-off-by: xermicus <[email protected]>
Co-authored-by: Cyrill Leutwiler <[email protected]>
Co-authored-by: xermicus <[email protected]>
Co-authored-by: elle-j <[email protected]>
Co-authored-by: LJ <[email protected]>
The branch-free body is one ~700-instruction polkavm basic block. The
O1+ inliner concatenated one copy per call site, exceeding
pallet-revive's instruction limit. `noinline` caps every block at the
single-body size.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants