Skip to content

math: in-repo Lehmer 3072-bit modular inverse + std ceil_log_2 - #1051

Open
biryukovmaxim wants to merge 13 commits into
kaspanet:masterfrom
biryukovmaxim:lehmer-modinv
Open

math: in-repo Lehmer 3072-bit modular inverse + std ceil_log_2#1051
biryukovmaxim wants to merge 13 commits into
kaspanet:masterfrom
biryukovmaxim:lehmer-modinv

Conversation

@biryukovmaxim

@biryukovmaxim biryukovmaxim commented Jun 14, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds an in-repo, dependency-free modular inverse for the 3072-bit MuHash element, wires MuHash to use it, and replaces the consensus sync locator's malachite CeilingLogBase2 with a small std-based helper. Together these remove malachite from both of its production call sites: it is no longer a production dependency anywhere in the workspace, remaining only as a dev-only cross-check oracle. malachite was the workspace's only LGPL-3.0 (copyleft) dependency.

What's here

  • kaspa_math::ceil_log_2 — branchless ilog2-based ceil(log2(x)); the sync block-locator now uses it instead of malachite-base's CeilingLogBase2.
  • kaspa_math::lehmer — stack-allocated, variable-time modular inverse via Lehmer's extended GCD (Knuth TAOCP 4.5.2 Algorithm L). A two-word half-GCD guess peels ~62 bits per 2x2 reduction matrix, with an exact Euclidean step as the fallback. 100% safe Rust, no new dependencies; measured ~6-8% faster than the current malachite mod_inverse at 3072 bits (see Benchmark results).
  • MuHash wired to Lehmercrypto/muhash's U3072::inverse() now calls lehmer::invert, and the generic malachite-backed Uint::mod_inverse is removed (it had no other production caller).
  • malachite dropped from production — removed from math's [dependencies] and from the pub use malachite_* re-export in uint.rs. It is kept only as a [dev-dependency], used as the bit-for-bit cross-check oracle for the lehmer.rs/lib.rs tests and the candidates bench. cargo tree -p kaspa-math -e normal -i malachite-base is now empty.
  • Benchmark (benches/candidates.rs) — criterion comparison of the Lehmer inverse against the current malachite mod_inverse (the operation it replaces); the Lehmer arm is asserted equal to malachite's result before timing. Third-party candidates evaluated during selection (dashu, crypto-bigint safegcd, num-bigint, ruint) are kept commented out with their measured numbers, and their dev-dependencies were dropped once Lehmer won — so the benchmark pulls in no extra dependencies.
  • Fuzzing — new math/fuzz u3072 target drives lehmer::invert for the MuHash prime and compares bit-for-bit against a num-bigint extended-gcd reference. The generic-mod_inverse arms in the u128/u192/u256 targets were removed with the method.
  • lehmer-instrument feature + example — per-inverse op-count diagnostics; inert when the feature is off.

Benchmark results

cargo bench -p kaspa-math --bench candidates -- modinv_3072_muhash_prime (criterion, release, Linux x86_64, 13th Gen Intel Core i9-13900HX). Group time covers N=32 inverses; per-inverse = group / 32. Four consecutive runs:

run malachite (current) lehmer (this PR) lehmer faster by
1 14.68 µs 13.90 µs 5.3%
2 14.45 µs 13.56 µs 6.2%
3 14.52 µs 13.58 µs 6.5%
4 14.62 µs 13.49 µs 7.7%

Lehmer lands at ~13.5 µs/inverse vs malachite ~14.5 µs/inverse — roughly 6-8% faster, with non-overlapping 95% confidence intervals on every run, so the speedup is consistent (the exact margin drifts run-to-run with CPU turbo; the lehmer/malachite ratio is the stable figure). The inverse runs once per MuHash finalize, so this is a small but real win on top of dropping the copyleft dependency from the hot path.

Validation

  • lehmer::invert cross-checked bit-for-bit against malachite mod_inverse (now the dev-dep oracle) on 2000 random 3072-bit vectors, plus an oracle-independent v * inv == 1 (mod p) self-check, edge cases (1, p-1, 0), and tiny-value inputs that force the multi-limb-quotient div_rem fallback.
  • MuHash's own test_inverse / test_inverse_edge_case / test_mul_div now run through lehmer::invert and pass.
  • Fuzzing: the new u3072 target ran clean — 61,427 executions in 46 s, 0 crashes, 0 mismatches vs the num-bigint reference. The pre-existing muhash multiply/divide fuzz target (which exercises the inverse via inverse()) continues to pass.
  • ceil_log_2 checked against an independent oracle across power-of-two boundaries and u64::MAX, and for equivalence with the malachite routine it replaces.
  • cargo check --workspace, cargo clippy --workspace --all-targets, and cargo test -p kaspa-math -p kaspa-muhash are clean/green.

Notes

  • Bumps workspace MSRV 1.91 -> 1.95 (core::hint::cold_path, used by the inverse, is stable as of 1.95); CI Lints toolchain and the docker chef images are bumped to match.
  • malachite is now a dev-dependency only — it backs no production code, just the test/bench cross-check oracle. The product_is_one self-check, the fuzz target, and the muhash tests give oracle-independent coverage, so the dev-dep can be dropped later with no loss of a production guarantee.

Extended fuzzing results (3-day soak)

All four targets ran for 72 hours with strict-asserts enabled (promoted debug_assert! to assert!):

Target Executions lehmer.rs line coverage Result
u3072 139.8M 92.5% 0 crashes
u128 1.65B 97.1% 0 crashes
u192 831M 95.6% 0 crashes
u256 657M 95.6% 0 crashes

Coverage reports: gist (u3072, u128, u192, u256)
Grown corpora: kaspanet/rusty-kaspa-corpus#1

…c locator

Branchless ilog2-based helper for the sync block-locator capacity hint;
removes the consensus locator's only malachite-base import.
core::hint::cold_path, used by the modular-inverse routine added next, is
stable as of 1.95.
Stack-allocated variable-time modular inverse for the MuHash element via
Lehmer's extended GCD (Knuth TAOCP 4.5.2 Algorithm L): a two-word half-GCD
guess peels ~62 bits per 2x2 reduction matrix, with an exact Euclidean step
as the fallback. 100% safe Rust with no new dependencies; cross-checked
bit-for-bit against the existing malachite mod_inverse on 3072-bit vectors.
The optional lehmer-instrument feature exposes per-inverse op counters.
Criterion comparison of the in-repo Lehmer inverse against malachite (the
current implementation), dashu, and crypto-bigint's safegcd, alongside the
other production big-uint ops (blue-work sum, difficulty window, calc_work).
Every candidate is asserted equal to the current malachite result before it
is timed. dashu-int and crypto-bigint are bench-only dev-dependencies.
examples/lehmer_stats.rs reports the operation breakdown (matrices, divides,
apply-limb counts) for lehmer::invert under the lehmer-instrument feature, to
show where the per-inverse work goes. Inert when the feature is off.
Match the workspace MSRV: the Lints job pinned rustc 1.93.0 and the docker
chef images pinned 1.91, both below 1.95.0, so cargo aborted with
"requires rustc 1.95.0". rust:1.95-alpine3.23 confirmed on Docker Hub.
… deps

Comment out the dashu/crypto-bigint/num-bigint/ruint candidate arms and remove the dashu-int + crypto-bigint dev-deps; drop the dashu-comparison production-pattern groups. Bench now covers only the two malachite ops replaced on this branch (mod_inverse vs lehmer, ceil_log2 vs std).
…-dep

- muhash 3072-bit inverse() now calls lehmer::invert; generic Uint::mod_inverse removed
- malachite dropped from production deps and the uint.rs re-export; kept only as a dev-dep
  cross-check oracle for the lehmer/lib tests and the candidates bench
- fuzz: add math/fuzz u3072 target (lehmer::invert vs num-bigint, ran clean 61k execs);
  remove the mod_inv arms from u128/u192/u256
- bench.rs mod_inv arm and test oracles repointed off the removed method
@biryukovmaxim
biryukovmaxim requested a review from elichai June 14, 2026 10:45

@elichai elichai left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

first partial review

Comment thread math/src/lib.rs
Comment on lines +19 to +22
pub const fn ceil_log_2(x: u64) -> u64 {
// power of two -> floor; not a power of two -> floor + 1
x.ilog2() as u64 + (!x.is_power_of_two()) as u64
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

You mentioned branchless, note that this isn't actually branchless, as ilog2 panics if x == 0
see assembly: https://godbolt.org/z/E8GasceMx

This function is basically the same as:

pub fn ceil_log_2(x: u64) -> u32 {
    assert!(x > 0);
    u64::BITS - (x - 1).leading_zeros()
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The original one also panicked, we can either panic, return Option, or make the function return 0 on a 0 input

@biryukovmaxim biryukovmaxim Jun 14, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

we have only one caller of the function, which never passes zero. returning zero sounds fine to me

Comment thread math/src/lehmer.rs Outdated
}

/// Limb count of the operands (3072 bits in base 2^64).
const N: usize = 48;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

can we make this a const generic to support fuzzing with u128/u192 etc.?

@biryukovmaxim biryukovmaxim Jun 14, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

there are two places where I use exact number:
div_rem_step and reduce_mod

i think it would require to either convert the whole function to be the method of structure so we can get access to corresponding uint type or pass a closure/fn pointer to calc div_rem and mod
or we can add one more generic with concrete Uint type so we can get these function from there.

any suggestions, other ideas?

@michaelsutton

Copy link
Copy Markdown
Contributor

(pls coordinate the merge of this pr bcs i think such a change justifies a master testing period)

@elichai

elichai commented Jun 15, 2026

Copy link
Copy Markdown
Member

I let claude+codex grind for a while over this, they didn't find any issues with it, but It's a lot of logic to verify, I think the following can make me more comfortable with those:

  1. Find a way to generalize it to u128 or even to u64 so the fuzzer can do more meaningful work exhausting inputs.
  2. Run the fuzzers for a few days, go over the corpuses coverage to make sure they've hit all branches.
  3. Try to prove some of the invariants with kani (e.g. "entries are `< 2^63").
  4. turn all debug_assert into regular asserts, and try to add many more asserts.

I wonder also how it fairs against using https://github.com/mhogrefe/malachite/blob/master/malachite-nz/src/natural/arithmetic/gcd/extended_gcd.rs but replacing heap allocs with stack allocs, but this leaves us with the licensing issue

biryukovmaxim and others added 5 commits July 10, 2026 18:52
- LimbArray/LehmerOps/LehmerInvert in lehmer.rs; construct_uint! impls LehmerOps, plus primitive u64/u128 impls (native-division oracles)
- construct_uint! made $crate-hygienic (js_sys/wasm_bindgen/kaspa_utils/serde) so external crates can use it standalone
- pre-trait fixed-48 snapshot kept in benches/support as a candidates bench arm for A/B and asm diffing; bench parity within noise
- width parity tests: exhaustive u64 sweep, u128/Uint192/256/320 vs malachite
- u128/u192/u256 fuzz both value and modulus full-width against a shared num-bigint oracle in utils.rs
- u128 also cross-checks the primitive u128 impl
Replace ilog2() (which panics on 0) with leading_zeros() arithmetic,
guarded to return 0 when x=0. Eliminates panic case and maintains performance.
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
@biryukovmaxim

Copy link
Copy Markdown
Collaborator Author

@elichai

I let claude+codex grind for a while over this, they didn't find any issues with it, but It's a lot of logic to verify, I think the following can make me more comfortable with those:

  1. Find a way to generalize it to u128 or even to u64 so the fuzzer can do more meaningful work exhausting inputs.
  2. Run the fuzzers for a few days, go over the corpuses coverage to make sure they've hit all branches.
  3. Try to prove some of the invariants with kani (e.g. "entries are `< 2^63").
  4. turn all debug_assert into regular asserts, and try to add many more asserts.

I wonder also how it fairs against using https://github.com/mhogrefe/malachite/blob/master/malachite-nz/src/natural/arithmetic/gcd/extended_gcd.rs but replacing heap allocs with stack allocs, but this leaves us with the licensing issue

Addressing fuzzing/coverage/asserts points:

1. Generalized to smaller widths

Done in recent force-push. Lehmer is now generic via LimbArray/LehmerOps/LehmerInvert traits, with implementations for u64, u128, Uint192, Uint256, Uint320, and Uint3072. The fuzz harness has four targets (u128, u192, u256, u3072) each cross-checking against independent oracles (native u128 arithmetic or num-bigint extended GCD).

2. Multi-day fuzzing soak with coverage analysis

All four targets ran for 3 days (~72 hours each) with strict-asserts enabled (all debug_assert! promoted to panicking assert!). Zero crashes, zero timeouts, zero OOMs across the board.

Target Executions Corpus Coverage edges lehmer.rs line cov
u3072 139.8M 21,768 entries (87 MB) 1,130 92.5%
u128 1.65B 5,437 entries (22 MB) 745 97.1%
u192 831M 5,515 entries (22 MB) 1,078 95.6%
u256 657M 7,215 entries (29 MB) 1,250 95.6%

u3072 ran with -fork=4; the others single-threaded. All used -use_counters=1 -use_value_profile=1.

Coverage reports (lehmer.rs + uint.rs + int.rs + lib.rs)

The uncovered lehmer.rs lines are mostly the lehmer-instrument diagnostics feature gate and a few edge-case early-return paths (e.g. single-limb modulus fast path for u3072, which is unreachable because the MuHash prime is always 48 limbs).

Corpus

Grown corpora submitted as kaspanet/rusty-kaspa-corpus#1.

4. Promoted asserts

Done. The strict-asserts feature promotes all debug_assert! calls to runtime assert!. The fuzz harness enables this feature (features = ["strict-asserts"] in fuzz/Cargo.toml), so the entire 3-day soak ran with full assertion checking. No assertion failures were triggered.

@biryukovmaxim
biryukovmaxim requested a review from elichai July 14, 2026 08:27
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.

3 participants