Skip to content

Perf/metal msm optimizations - #100

Merged
moven0831 merged 22 commits into
zkmopro:mainfrom
RajeshRk18:perf/metal-msm-optimizations
Aug 6, 2026
Merged

Perf/metal msm optimizations#100
moven0831 merged 22 commits into
zkmopro:mainfrom
RajeshRk18:perf/metal-msm-optimizations

Conversation

@RajeshRk18

@RajeshRk18 RajeshRk18 commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

perf: 8-9x faster Metal MSM using bunch of optimizations

Summary

This PR rewrites the hot paths of the Metal MSM pipeline end to end. On
M4 Air, metal_variable_base_msm
is 4.4-9.5x faster than v0.2.0 across 2^12-2^22 and now beats Arkworks CPU
MSM from 2^16 upward. Peak
GPU memory at the top sizes is lower than v0.2.0 despite the pipeline doing
strictly more on-device.

n v0.2.0 this PR speedup peak GPU mem (was → now) Arkworks CPU
2^12 59.6 ms 13.5 ms 4.4x 5.8 → 34 MB* 5.0 ms
2^14 ~124 ms 16.7 ms ~7.4x — → 37 MB 15.2 ms
2^16 208 ms 22.1 ms 9.4x 77 → 51 MB 51 ms
2^18 51.2 ms — → 107 MB 185 ms
2^20 1540 ms 187 ms 8.2x 410 → 296 MB 699 ms
2^22 ~5.4 s 661 ms ~8x 1170 → 1017 MB 3045 ms

(Medians of 5 runs)

What changed

  • Build fix: shader compilation now reruns when MSM_COMPILE_ALL_SHADERS
    changes, so the test metallib can no longer silently miss test kernels on
    cached build-script runs.
  • Single-sync host pipeline: all dispatches are encoded up front against
    device-resident buffers and committed back-to-back with one host sync per
    MSM. Before, every stage did a full wait, readback, and re-upload.
  • 32-bit limb field arithmetic: 8x32-bit limbs with ulong fused
    multiply-add CIOS replace the WGSL-inherited 16x16-bit representation. 4x
    fewer partial products per Montgomery multiplication and half the register
    footprint.
  • Guarded mixed addition in bucket accumulation: affine inputs use the
    madd-2007-bl formula (11 field muls instead of 16).
  • XYZZ coordinates for bucket accumulation: cheaper mixed and general
    additions with far fewer field add/subs; the identity is encoded as ZZ = 0
    so zero-initialized buffers still mean infinity. The host converts final
    sums to Jacobian without inversions via scale equivalence.
  • Parallel sparse transpose: the serial one-thread-per-subtask kernel
    becomes three parallel kernels: histogram with relaxed atomics, prefix sum,
    and scatter with per-column atomic slot claims.
  • Zero-copy Montgomery input: arkworks stores Fq in Montgomery form with
    the same radix the GPU uses, so coordinates pass through raw, deleting a
    CPU Montgomery reduction and a GPU multiplication per coordinate that
    converted a number into itself. SMVP reads the input buffer directly as
    interleaved affine records.
  • GPU bucket-reduction finish: per-subtask partial sums are tree-reduced
    on the GPU in threadgroup memory, so the host only Horner-combines one
    point per subtask. This freed PBPR to use 256-thread workgroups.
  • Single SMVP dispatch: all subtasks launch at once instead of chunks of
    four, so the scheduler can overlap load-imbalance tails.
  • Segmented load-balanced SMVP: threads own fixed-size slices of the
    bucket-sorted point stream, giving uniform per-thread work. Sortedness
    guarantees a run starting and ending inside a slice covers its bucket
    completely and is written exclusively without atomics; only a slice's first
    and last runs can be partial, so each thread spills at most two entries,
    and a merge kernel folds spills and mirror rows into signed buckets. This
    also fixes the window-12/14 load pathology, so every window size is safe.
  • Segmented-buffer memory cap: row-sum and spill buffers are reused
    across subtask chunks of four, keeping the segmented overhead to a few
    tens of MB.
  • Retuned parameters: window 13 below 2^19, smaller decoupled workgroup
    scale factors, and env knobs for future machines: MSM_WINDOW_SIZE,
    MSM_SCALE_FACTOR, MSM_SMVP_L, MSM_LEGACY_SMVP, and MSM_PROFILE=1
    for per-dispatch timings and occupancy.

Some Attempts

  • LogJumps modular reduction: implemented and benchmarked 8-10% slower than
    CIOS for BN254. rho is close to p, forcing a second conditional
    subtraction that cancels the saved multiplications(?)
  • Batched-affine accumulation: needs batch sizes of at least 128 to beat
    XYZZ, and assembling such batches on Apple GPUs costs more than it saves.

Summary by CodeRabbit

Performance

  • Improved Metal-based multi-scalar multiplication with faster GPU processing, pipelined execution, parallel data handling, and workload balancing.
  • Updated arithmetic processing to use a more efficient 32-bit representation.

Reliability

  • Improved elliptic-curve point handling, including identity, inverse, doubling, and mixed-addition cases.
  • Expanded validation for scalar decomposition, point operations, arithmetic, and GPU reductions.

Maintenance

  • Removed obsolete arithmetic and benchmark paths and streamlined shader management.

…nges

The build script gated test-kernel compilation on this env var but never
declared it to cargo, so a cached build-script run could leave the
metallib without test kernels.
Previously every stage committed its own command buffer, blocked on
wait_until_completed, copied results back to host Vecs and re-uploaded
them as fresh buffers for the next stage, and every MSM call rebuilt all
pipeline states from the metallib.

Now all dispatches are encoded up front against device-resident buffers,
committed back-to-back (one command buffer per dispatch so the GPU
watchdog never sees a long-running one) with a single host sync at the
end; only the final per-subtask points are read back. Zero-filled
buffers come from newBufferWithLength instead of host-side staging
vectors, and the pipeline object is cached per thread across calls.

The old per-stage structs are inlined into execute_pipeline.
The 16-bit x 16 limb representation was inherited from WGSL, which has
no 64-bit integers. Metal does: a widening 32x32->64 multiply-add is a
single fused operation on Apple GPUs, so 8x32-bit limbs quarter the
partial products in Montgomery multiplication (256 -> 64) and remove
all mask-and-shift carry handling. Field elements shrink 64 -> 32 bytes
and Jacobian points 192 -> 96, halving register pressure in the
ILP-1 bucket accumulation chain.

- CIOS Montgomery multiplication rewritten with ulong accumulators
- coords/scalars buffers are now plain little-endian words; the convert
  kernel converts to Montgomery form via mont_mul(x, R^2), replacing the
  Barrett reduction path entirely (deleted along with the 12/15-bit
  Montgomery variants that only work for narrow limbs)
- host: 32-bit-safe to_limbs/from_limbs fast paths; fixed u32 shift
  overflow in calc_rinv_and_n0 and calc_nsafe for log_limb_size = 32
- constants.metal regenerated (N0 = -p^-1 mod 2^32, R^2, 8-limb tables)
- tests swept to the 8/32 config; obsolete Barrett and narrow-limb
  Montgomery tests removed

Measured: 2^16 e2e 180 -> 106 ms, 2^20 1351 -> 949 ms.
SMVP input points are affine (z = 1), but the inner loop paid the full
Jacobian-Jacobian addition (11M + 5S) by manufacturing a z = 1 point.
jacobian_madd_safe implements madd-2007-bl (7M + 4S, ~31% fewer field
muls) with the edge cases the textbook formula silently breaks on:
accumulator at infinity, P + P (falls back to doubling) and P + (-P)
(returns infinity). The u2/s2 values the guards compare are needed by
the formula anyway, and mont_mul outputs are canonical, so the checks
are limb-wise equality.

Bucket-merge additions (both operands Jacobian) keep the general path.

Measured: 2^20 e2e 949 -> 793 ms.
The transpose ran one thread per subtask (~20 threads on the whole GPU),
each doing three serial passes over up to 2^20 elements. It is a
counting sort, so it splits into three kernels:

- histogram: one thread per (element, subtask), relaxed atomic adds on
  per-column counters
- prefix sum: still one thread per subtask, but over 2^w columns
  instead of n elements
- scatter: one thread per (element, subtask); each thread claims a
  unique slot via an atomic per-column counter

Within-column order becomes nondeterministic, which is sound because
the only consumer sums each column and point addition commutes.
The legacy serial kernel is kept for its unit test.

Measured: 2^20 e2e 793 -> 372 ms, overtaking Arkworks CPU (~700 ms).
Implements mont_mul_logjumps (Domb/Koh: replace each Montgomery round's
quotient computation with a shift plus t0 * rho, rho = 2^-32 mod p),
saving n-1 of n^2+n limb products and one serial dependency per round.

Not enabled: for BN254, rho ~ 0.89p, so the exact worst-case recurrence
reaches 2.89p before reduction and needs a second conditional
subtraction, which together with the in-loop round branch costs more
than the saved multiplications. Measured 8-10% slower end-to-end than
CIOS on Apple GPUs. Kept for reference and for primes/limb widths where
rho is small; call sites now go through a mont_mul alias so the
implementation can be swapped in one place.
arkworks stores Fq internally in Montgomery form with R = 2^256 -- the
same radix the GPU uses. The old path converted out of Montgomery form
on the CPU (one REDC per coordinate via into_bigint) only for the GPU
to convert straight back (mont_mul by R^2 per coordinate).

Coordinates now flow raw from pt.x.0: no host reduction, no GPU
conversion, and SMVP reads the input buffer directly as interleaved
affine points (one contiguous 64-byte record per gather instead of two
cache lines in two buffers). The convert kernel shrinks to scalar
decomposition only (decompose_scalars.metal); scalars still need
into_bigint because window decomposition slices the standard bit
pattern. The separate point_x/point_y buffers (2 x 32 MB at 2^20) are
gone.

Peak GPU allocation at 2^20 drops 410 -> 263 MB combined with the
earlier removal of over-allocations.
- window 13 for all inputs below 2^19 (was 8 below 2^14): at small n
  the binding constraint is dispatch overhead and GPU occupancy, not
  the Pippenger add count; measured 2^14 56 -> 37 ms, 2^12 26 -> 23 ms
- halve workgroup scale factors: more, smaller threadgroups pack better
  around variable-length buckets; 2^16 66 -> 60 ms, 2^20 -4%
- MSM_WINDOW_SIZE / MSM_SCALE_FACTOR env overrides for tuning runs

Note from the sweep: window sizes 12 and 14 are pathological (254 mod w
leaves a 2-bit top window, cramming all points into ~4 buckets whose
serial accumulation stalls the dispatch ~17x); 13/15/16 are safe.
Window 8 also has a known size-dependent correctness bug at n = 1024
(pre-existing, both old and new code; defaults never select it).
It measured 8-10% slower than CIOS end-to-end (BN254's rho = 2^-32 mod p
is ~0.89p, forcing a second conditional subtraction that cancels the
saved multiplications) and nothing calls it. The mont_mul alias stays as
the single swap point and documents the result; the implementation and
the BN254_RHO constant live in the previous commit if rho-friendly
parameters ever make it worth revisiting.
…ory tracking

- examples/quick_bench: min/median timings per size, correctness
  asserted against Arkworks, and peak GPU memory via a background
  thread sampling MTLDevice currentAllocatedSize (relevant for mobile
  targets; VRAM is now reported on every run)
- MSM_PROFILE=1 runs each pipeline dispatch synchronously with wall
  times and prints per-pipeline occupancy info
- benches/shaders: replace benches of deleted kernels (convert/Barrett)
  with a decompose_scalars bench; port SMVP bench to the interleaved
  point layout and the 8x32 limb config
- examples/debug_w8: stage-by-stage GPU-vs-CPU validation harness used
  to isolate the pre-existing w=8/n=1024 correctness bug (PBPR stage)
- README: optimization notes with per-change measurements, LogJumps and
  Elastic MSM findings, Metal 4 status, updated future work
The 4-subtask chunking was inherited from the WebGPU implementation
(which has per-dispatch workgroup limits Metal doesn't have). Separate
dispatches serialize on the queue, so each chunk's slowest bucket
stalled the GPU before the next chunk could start; a single dispatch
lets the scheduler overlap the load-imbalance tail across all subtasks.

Measured: 2^16 60 -> 53 ms, 2^20 358 -> 339 ms, 2^22 1056 -> 977 ms.
XYZZ (X, Y, ZZ = z^2, ZZZ = z^3) trades one extra stored coordinate for
cheaper group operations everywhere the pipeline is hot:

- mixed addition (SMVP inner loop): 8M+2S with ~5 field add/subs,
  vs Jacobian madd's 7M+4S with ~13 add/subs
- general addition (bucket merge, PBPR running sums): 12M+2S vs 11M+5S
  with roughly half the add/subs

The identity is ZZ = 0, so zero-initialized bucket buffers still encode
infinity for never-written slots. The host converts the final partial
sums to Jacobian without inversions via the scale-equivalence
(X*ZZ*ZZZ^2, Y*ZZ^3*ZZZ^2, ZZ*ZZZ). Jacobian code stays for its unit
tests and the scalar-mul kernel.

Measured: 2^16 53 -> 47 ms, 2^20 339 -> 305 ms, 2^22 977 -> 945 ms;
peak GPU memory +3% (fourth bucket coordinate).

Cumulative vs v0.2.0 on this machine: 2^12 2.9x, 2^16 4.4x, 2^20 5.0x.
Raising it to 256 cut bpr_stage_1 from 9 to 2 ms at 2^20 but added
~40 ms of host-side final reduction (8x more partial sums to convert
and add on the CPU) -- a net loss at every size. Keep it tied to the
scale factor; moving the final reduction onto the GPU is the
prerequisite for revisiting this.
…groups

Follow-up to the documented b_workgroup_size trade-off: the host used to
convert and add num_subtasks * b_workgroup_size partial sums, which made
larger PBPR workgroups a net loss (cheap GPU time traded for expensive
CPU time).

bpr_reduce collapses each subtask's partial sums to a single point on
the GPU: one threadgroup per subtask, grid-stride accumulate into 128
threads, then a barrier tree reduction in threadgroup memory (16 KB of
XYZZ points). The host now reads back and Horner-combines only
num_subtasks points, so b_workgroup_size rises to 256 for free
(bpr_stage_1 drops from ~9 to ~2 ms at 2^20).

Measured: 2^16 47.5 -> 39.1 ms, 2^20 300 -> 288 ms, 2^22 926 -> 869 ms.
One-thread-per-bucket SMVP finishes when the fattest bucket does; bucket
sizes are Poisson-distributed, so SIMD groups idle on the tail, and
window sizes whose top digit is narrow (12/14) were catastrophically
imbalanced (~17x).

smvp_accumulate assigns threads fixed-size slices of the bucket-sorted
point stream instead -- uniform work by construction. Because the stream
is sorted, a run that starts and ends inside a slice covers its bucket
completely and is written exclusively (no atomics); only a slice's first
and last runs can be partial, so each thread spills at most two entries.
smvp_merge combines spills (a row's spills occupy the contiguous slice
range [row_ptr[r]/L, (row_ptr[r+1]-1)/L] -- no searching) and folds
mirror rows into signed buckets as before.

Slice length L = 128 (MSM_SMVP_L to override); the classic kernel
remains available via MSM_LEGACY_SMVP=1 and for its unit test.

Measured (medians): 2^12 18.9 -> 13.7 ms, 2^16 39.1 -> 21.8 ms,
2^20 288 -> 175-188 ms, 2^22 869 -> 652 ms. Pathological windows fixed:
w=12 at 2^20 6365 -> 238 ms; all window sizes are now load-safe.
Peak GPU memory +~100-200 MB at the top sizes (row-sum and spill
buffers) -- reducible later by storing row sums in affine form.
Batched-affine bucket accumulation was worked through in detail and
rejected on paper for this architecture: with XYZZ mixed addition at 10
mult-ops, batched affine needs its ~380-mul batch inversion amortized
over k >= 128 additions to win, and every way of assembling such a batch
on Apple GPUs (threadgroup Blelloch scans + ~30 barriers/round, 32-wide
SIMD batches, or Yrrid-style per-thread multi-bucket accumulators)
costs more than the ~3 mult-ops it would save. CUDA MSMs profit from it
via warp-synchronous k = 1024 batches against a 16-mul Jacobian
baseline -- neither condition holds here.
The segmented SMVP's row-sum and spill buffers scaled with num_subtasks
(+100-200 MB at the top sizes). Subtasks are now processed in chunks of
4 (when num_columns >= 32768) sharing one chunk-sized set of buffers;
Metal's hazard tracking serializes the reuse, and stale data is safe
because row sums are only read for rows direct-written in the same
chunk and every spill slot is rewritten (value or sentinel) each chunk.

Note: the earlier idea of affine row storage died with batched-affine --
converting XYZZ to affine at flush time needs the same inversion that
made batched affine uneconomic. Chunked reuse saves more anyway.

Measured: peak GPU memory 2^20 375 -> 296 MB, 2^22 1176 -> 1017 MB;
timings unchanged within noise (2^20 ~186 ms, 2^22 ~650 ms).
Session tooling, not library surface: the Criterion benches (e2e,
shaders) remain the supported benchmark path. The debug_w8 harness's
finding (pre-existing w=8/n=1024 PBPR bug) stays documented in the
tuning commit; both files remain recoverable from git history.
The XYZZ migration added a fourth bucket/partial-sum coordinate to the
smvp and pbpr kernel signatures, but benches/shaders.rs still bound the
old 3-coordinate buffer sets; the unbound slot made the smvp bench case
hang the GPU. Buckets now bind x/y/zz/zzz (pbpr inputs seed ZZ = ZZZ = 1
in Montgomery form for z = 1 points), matching the kernels.
@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9ea86754-7db5-4dee-b3db-1aa1ea5633f3

📥 Commits

Reviewing files that changed from the base of the PR and between ab0a5c2 and fb9e49d.

📒 Files selected for processing (3)
  • mopro-msm/benches/shaders.rs
  • mopro-msm/src/msm/metal_msm/host/shader.rs
  • mopro-msm/src/msm/metal_msm/metal_msm.rs
💤 Files with no reviewable changes (1)
  • mopro-msm/benches/shaders.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • mopro-msm/src/msm/metal_msm/host/shader.rs
  • mopro-msm/src/msm/metal_msm/metal_msm.rs

📝 Walkthrough

Walkthrough

The PR migrates Metal MSM to eight 32-bit limbs and XYZZ coordinates. It adds GPU scalar decomposition, parallel transpose, segmented SMVP, GPU bucket reduction, pipelined execution, updated shader constants, and corresponding benchmarks and tests.

Changes

Metal MSM pipeline

Layer / File(s) Summary
32-bit field representation and arithmetic
mopro-msm/src/msm/metal_msm/shader/..., mopro-msm/src/msm/metal_msm/utils/..., mopro-msm/src/msm/metal_msm/tests/...
Field constants, Montgomery arithmetic, bigint operations, limb conversion, and tests now use eight 32-bit limbs.
XYZZ and GPU MSM kernels
mopro-msm/src/msm/metal_msm/shader/curve/..., mopro-msm/src/msm/metal_msm/shader/cuzk/...
XYZZ point operations replace Jacobian operations in SMVP and PBPR. New kernels provide scalar decomposition, transpose stages, segmented SMVP, and BPR reduction.
GPU pipeline orchestration
mopro-msm/src/msm/metal_msm/host/..., mopro-msm/src/msm/metal_msm/metal_msm.rs, mopro-msm/build.rs
Shader loading, zeroed buffers, pipelined command execution, GPU-resident intermediate results, pipeline caching, and runtime configuration are added or updated.
Benchmark and integration validation
mopro-msm/benches/shaders.rs, mopro-msm/src/msm/metal_msm/tests/cuzk/...
Benchmarks and CUZK tests use packed 32-bit inputs, XYZZ buffers, standalone scalar decomposition, and XYZZ result decoding.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant MetalMSM
  participant ShaderManager
  participant MetalHelper
  participant MetalKernels
  MetalMSM->>ShaderManager: Load MSM shader pipelines
  MetalMSM->>MetalHelper: Allocate GPU-resident buffers
  MetalMSM->>MetalHelper: Encode and commit shader operations
  MetalHelper->>MetalKernels: Run decomposition, transpose, SMVP, and reduction
  MetalKernels-->>MetalMSM: Return per-subtask XYZZ results
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies performance optimizations for the Metal MSM pipeline, which is the main focus of the changes.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
mopro-msm/src/msm/metal_msm/metal_msm.rs (1)

567-596: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Validate the MSM_WINDOW_SIZE and MSM_SCALE_FACTOR overrides.

Both overrides accept any value that parses as the target integer type. Out-of-range values fail later in confusing ways:

  • MSM_WINDOW_SIZE=0 makes num_columns 1 and half_columns 0, so b_workgroup_size becomes 0 and the BPR dispatch requests a zero-size threadgroup.
  • MSM_WINDOW_SIZE=64 or larger overflows 1 << window_size and panics.
  • MSM_SCALE_FACTOR=0 makes c_workgroup_size 0, so input_size / c_workgroup_size at Line 146 divides by zero.

Clamp both values to the supported range before use.

🛡️ Proposed fix
     let window_size = if let Ok(w) = std::env::var("MSM_WINDOW_SIZE") {
-        w.parse().unwrap_or(13)
+        w.parse::<usize>().ok().filter(|&w| (1..=20).contains(&w)).unwrap_or(13)
     } else if input_size < 524288 {
     let scale_factor = if let Ok(s) = std::env::var("MSM_SCALE_FACTOR") {
-        s.parse().unwrap_or(1)
+        s.parse::<usize>().ok().filter(|&s| s > 0).unwrap_or(1)
     } else if input_size <= 65536 {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mopro-msm/src/msm/metal_msm/metal_msm.rs` around lines 567 - 596, Validate
the parsed overrides in the window-size and scale-factor selection logic: clamp
MSM_WINDOW_SIZE to a supported positive range below the shift-overflow limit,
and clamp MSM_SCALE_FACTOR to a supported positive range that keeps
c_workgroup_size nonzero. Preserve the existing size-based defaults when the
environment variables are absent or invalid.
mopro-msm/src/msm/metal_msm/utils/limbs_conversion.rs (1)

556-580: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Extend the parallel-packing test to cover the 32-bit zero-copy path.

test_parallel_packing_correctness only configures log_limb_size: 16, num_limbs: 16. The new log_limb_size == 32 branch in pack_affine_and_scalars (Lines 390-439) takes a materially different code path: it copies arkworks' internal Montgomery-form limbs directly (pt.x.0.0), instead of calling into_bigint() to get the standard-form value used by pack_affine_and_scalars_sequential. No test currently checks that this zero-copy output matches the correct Montgomery-domain reference values.

Add a dedicated test (or a separate reference implementation using into_bigint() plus manual Montgomery conversion via calc_mont_radix) that exercises log_limb_size: 32, num_limbs: 8 and validates the packed coordinates and scalars.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mopro-msm/src/msm/metal_msm/utils/limbs_conversion.rs` around lines 556 -
580, Extend test_parallel_packing_correctness with a dedicated log_limb_size:
32, num_limbs: 8 case that exercises the zero-copy branch in
pack_affine_and_scalars. Compare its packed coordinates and scalars against a
correct Montgomery-domain reference built from into_bigint() and
calc_mont_radix, preserving the existing 16-bit assertions.
🧹 Nitpick comments (14)
mopro-msm/benches/shaders.rs (2)

64-69: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The transpose benchmark still declares 16 limbs of 16 bits.

Every other benchmark block in this file moved to log_limb_size: 32 and num_limbs: 8. The transpose kernel does not use field limbs, so behavior does not change, but the stale values now contradict the rest of the file. Update them for consistency.

♻️ Proposed change
         let config = MetalConfig {
-            log_limb_size: 16,
-            num_limbs: 16,
+            log_limb_size: 32,
+            num_limbs: 8,
             shader_file: "cuzk/transpose.metal".to_string(),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mopro-msm/benches/shaders.rs` around lines 64 - 69, Update the MetalConfig
for the transpose benchmark, identified by kernel_name "transpose", to use
log_limb_size 32 and num_limbs 8, matching the other benchmark configurations
while leaving the shader_file and kernel name unchanged.

402-411: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

smvp_gpu allocates and binds bucket_zzz_buf but never returns it.

This helper creates four bucket buffers and passes all four to the kernel, then returns only X, Y, and ZZ. The equivalent helper in mopro-msm/src/msm/metal_msm/tests/cuzk/smvp.rs lines 14-112 is now identical except that it returns all four coordinates. Two near-duplicate helpers with different return arities will drift further.

For a benchmark, the readback is only there to force completion, so returning four values costs nothing and removes the divergence.

Also applies to: 441-445, 481-485, 496-498

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mopro-msm/benches/shaders.rs` around lines 402 - 411, Update smvp_gpu to
return all four bucket coordinate buffers, including bucket_zzz_buf, matching
the equivalent cuzk smvp helper. Adjust the function’s tuple return type and
every return/destructuring site in the benchmark, while preserving the existing
readback used to force GPU completion.
mopro-msm/src/msm/metal_msm/tests/cuzk/convert_point_coords_and_decompose_scalars.rs (1)

140-143: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

pack_limbs is now an identity function, and the file name no longer matches its contents.

With 32-bit limbs the helper just clones the slice. Two call sites (Line 37 and Line 71) would read the same with scalars used directly. The file is also still named convert_point_coords_and_decompose_scalars.rs, but the point-coordinate conversion test was removed, so only scalar decomposition remains. Rename the file to decompose_scalars.rs and remove the identity helper.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@mopro-msm/src/msm/metal_msm/tests/cuzk/convert_point_coords_and_decompose_scalars.rs`
around lines 140 - 143, Remove the identity helper pack_limbs and update both
call sites to use the scalar limb slices directly. Rename the test file from
convert_point_coords_and_decompose_scalars.rs to decompose_scalars.rs,
preserving the existing scalar decomposition behavior.
mopro-msm/src/msm/metal_msm/metal_msm.rs (2)

239-262: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider removing the collapsed chunk loop in the legacy SMVP path.

num_subtask_chunk_size equals num_subtasks, so the step_by loop always runs exactly one iteration, offset is always 0, and remaining_subtasks always equals num_subtasks. The two vectors smvp_params_bufs and smvp_dims hold one element each. Flattening this to a single dispatch would remove the unused chunking arithmetic and make the "single dispatch" comment self-evident.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mopro-msm/src/msm/metal_msm/metal_msm.rs` around lines 239 - 262, Remove the
collapsed chunk loop around the legacy SMVP setup and construct a single params
buffer and dimension entry directly. Replace the derived offset,
remaining_subtasks, and chunking arithmetic with the equivalent
num_subtasks-based values while preserving the existing smvp_params_bufs and
smvp_dims contents and single-dispatch behavior.

540-543: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Replace the thread-local MSM pipeline cache with a shared cache.

metal_variable_base_msm stores one MetalMSMPipeline per Rust thread in PIPELINE, and the cache is never released. Because each pipeline has a device and compiled shader pipeline states, calls from many short-lived threads can grow process memory with the number of distinct threads. Use a process-wide shared cache instead, if MetalMSMPipeline is Send + Sync and concurrent execute_pipeline calls are safe.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mopro-msm/src/msm/metal_msm/metal_msm.rs` around lines 540 - 543, Replace the
thread-local PIPELINE cache used by metal_variable_base_msm with a process-wide
shared OnceCell, verifying that MetalMSMPipeline is Send + Sync and concurrent
execute_pipeline calls are safe; otherwise synchronize access appropriately.
Preserve lazy initialization and reuse the single pipeline across MSM calls.
mopro-msm/src/msm/metal_msm/shader/bigint/bigint.metal (1)

14-22: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Pin the 32-bit limb assumption with a compile-time check.

The new add and subtract paths use (uint) truncation in place of & MASK. That is exact only when LOG_LIMB_SIZE == 32. write_constants still accepts any log_limb_size, so a future regeneration with a smaller limb size would produce unmasked limbs and silently wrong field arithmetic.

Add a static assertion so the mismatch fails at compile time.

♻️ Proposed addition near the top of the file
 `#pragma` once
 
 using namespace metal;
 `#include` "../misc/get_constant.metal"
+
+// The carry/borrow paths below rely on (uint) truncation acting as the limb mask.
+static_assert(LOG_LIMB_SIZE == 32, "bigint.metal requires 32-bit limbs");

Also applies to: 49-57

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mopro-msm/src/msm/metal_msm/shader/bigint/bigint.metal` around lines 14 - 22,
Add a compile-time assertion near the top of bigint.metal that requires
LOG_LIMB_SIZE to equal 32, covering both the addition and subtraction paths that
rely on uint truncation. Keep the existing limb arithmetic unchanged and ensure
generation with any other limb size fails during compilation.
mopro-msm/src/msm/metal_msm/host/metal_wrapper.rs (1)

239-244: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Check the command buffer status before you return.

The function waits on the last command buffer but ignores its status. If the GPU aborts a stage, the caller reads stale buffer contents and treats them as a result. Inspect the status of each committed command buffer, or at minimum of the last one, and surface the error.

♻️ Proposed change
     if let Some(last) = command_buffers.last() {
         last.wait_until_completed();
+        for (i, cb) in command_buffers.iter().enumerate() {
+            if cb.status() == MTLCommandBufferStatus::Error {
+                panic!("Metal command buffer {} failed", i);
+            }
+        }
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mopro-msm/src/msm/metal_msm/host/metal_wrapper.rs` around lines 239 - 244,
Update the command-buffer completion handling after the commit loop to inspect
the status of the completed buffer, at minimum the buffer returned by
command_buffers.last() after wait_until_completed(). Propagate a GPU execution
failure to the caller instead of continuing with potentially stale output, while
preserving successful result handling.
mopro-msm/src/msm/metal_msm/shader/curve/jacobian.metal (1)

168-232: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the unused jacobian_madd_safe helper.

jacobian_madd_safe has no callers, while the XYZZ path already provides xyzz_madd_safe. Keeping this guarded mixed-addition only adds dead shader code.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mopro-msm/src/msm/metal_msm/shader/curve/jacobian.metal` around lines 168 -
232, Remove the unused jacobian_madd_safe helper and its entire implementation.
Leave the existing xyzz_madd_safe path and all other Jacobian helpers unchanged.
mopro-msm/src/msm/metal_msm/shader/cuzk/decompose_scalars.metal (2)

57-69: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add a comment for the intentional unsigned wraparound.

slice_val can be negative at Line 68. uint(slice_val) + s then relies on unsigned wraparound to produce the biased digit in [0, l). The behavior is correct, but a reader can easily read it as a bug. State that the wraparound is intentional.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mopro-msm/src/msm/metal_msm/shader/cuzk/decompose_scalars.metal` around lines
57 - 69, Add a concise comment immediately before the chunks[id + offset]
assignment in the scalar decomposition flow explaining that converting negative
slice_val to uint and adding s intentionally uses unsigned wraparound to produce
the biased digit in [0, l). Do not alter the existing computation.

50-55: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Document the assumed window_size and num_subtask relationship.

The last-chunk shift is ((num_subtask * window_size - 256u) + 16u) - window_size. It computes in unsigned arithmetic. If num_subtask * window_size < 240u + window_size, the expression underflows and the shift count becomes very large, which is undefined. The scalar_bytes halfword layout also assumes window_size <= 16. Add a comment stating both preconditions, or clamp the shift.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mopro-msm/src/msm/metal_msm/shader/cuzk/decompose_scalars.metal` around lines
50 - 55, Document the preconditions directly above the last-chunk calculation in
the decompose-scalars shader: require window_size <= 16 and num_subtask *
window_size >= 240u + window_size to prevent unsigned shift underflow. Keep the
existing calculation unchanged unless implementing an equivalent clamp.
mopro-msm/src/msm/metal_msm/tests/bigint/bigint_add_wide.rs (1)

48-62: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the wide carry limb.

The buffer holds num_limbs + 1 limbs, but the test reads and compares only num_limbs. The overflow case therefore never verifies the top carry limb, which is the only part of bigint_add_wide that differs from bigint_add_unsafe. Read num_limbs + 1 limbs and assert the last limb equals the expected carry.

♻️ Proposed test change
-    let result_limbs = helper.read_results(&result_buf, config.num_limbs);
+    let all_limbs = helper.read_results(&result_buf, config.num_limbs + 1);
+    let result_limbs = all_limbs[..config.num_limbs].to_vec();
     let expected_limbs = expected.to_limbs(config.num_limbs, config.log_limb_size);
     assert_eq!(result_limbs, expected_limbs);
+    let expected_carry = if require_overflow { 1u32 } else { 0u32 };
+    assert_eq!(all_limbs[config.num_limbs], expected_carry, "carry limb mismatch");

run_bigint_add_test needs the require_overflow flag passed in for this assertion.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mopro-msm/src/msm/metal_msm/tests/bigint/bigint_add_wide.rs` around lines 48
- 62, Update run_bigint_add_test to accept and use a require_overflow flag, read
num_limbs + 1 limbs from result_buf, and compare the full result against the
expected limbs including the carry. When require_overflow is enabled, explicitly
assert that the final limb equals the expected carry value.
mopro-msm/src/msm/metal_msm/shader/cuzk/transpose.metal (1)

5-8: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The production transpose path has no dedicated test.

The comment states that the legacy single-kernel transpose is kept for tests while the pipeline uses the three new kernels. Test coverage then applies only to the unused kernel. Add a test that runs transpose_histogram, transpose_prefix_sum, and transpose_scatter, and that compares the result against the legacy kernel with per-column sorting, since column order is unspecified.

I can draft that test or open an issue to track it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mopro-msm/src/msm/metal_msm/shader/cuzk/transpose.metal` around lines 5 - 8,
Add dedicated coverage for the production transpose pipeline by invoking
transpose_histogram, transpose_prefix_sum, and transpose_scatter in sequence.
Compare its output with the legacy transpose result after sorting values within
each column, preserving the unordered-column behavior while ensuring both paths
produce equivalent contents.
mopro-msm/src/msm/metal_msm/shader/mont_backend/mont.metal (1)

65-96: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider reusing conditional_reduce for the final subtraction.

The file already defines conditional_reduce, and BigInt provides >= and -. The inlined comparison and borrow-subtraction duplicate that logic. If the manual version exists only for performance, add a short comment stating that; otherwise build the BigInt from t and call conditional_reduce.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mopro-msm/src/msm/metal_msm/shader/mont_backend/mont.metal` around lines 65 -
96, Replace the duplicated final comparison and borrow-subtraction logic in the
surrounding Montgomery reduction function with the existing conditional_reduce
helper, constructing a BigInt from t and relying on BigInt’s >= and - operators.
If retaining the manual implementation for performance, add a concise comment
documenting that rationale.
mopro-msm/src/msm/metal_msm/shader/misc/get_constant.metal (1)

14-18: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a compile-time guard for unsupported NUM_LIMBS.

If NUM_LIMBS != 8, MODULUS becomes an empty initializer and every limb is zero. Montgomery arithmetic then silently produces wrong results instead of failing the build. Add an #else branch with #error.

🛡️ Proposed guard
 constant BigInt MODULUS = {
 `#if` (NUM_LIMBS == 8)
     LIMBS_8
+#else
+#error "Unsupported NUM_LIMBS: only 8 x 32-bit limbs are supported"
 `#endif`
 };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mopro-msm/src/msm/metal_msm/shader/misc/get_constant.metal` around lines 14 -
18, Add an `#else` branch to the NUM_LIMBS preprocessor conditional initializing
MODULUS, and emit a compile-time `#error` for every value other than 8. Preserve
the existing LIMBS_8 initializer for NUM_LIMBS == 8 so unsupported
configurations fail compilation instead of creating a zero modulus.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@mopro-msm/src/msm/metal_msm/metal_msm.rs`:
- Around line 291-295: Validate the parsed `MSM_SMVP_L` value in the
segment-length initialization before calculating `num_segments`. Ensure zero is
rejected and replaced with the existing default length of 128, so the division
in the `num_segments` calculation always uses a positive `seg_len`.
- Around line 144-164: Update the dispatch setup around c_num_y_workgroups to
use ceiling division by c_workgroup_size * c_num_x_workgroups, ensuring all
input scalars are covered. In decompose_scalars, guard threads whose global
index is >= input_size so they return before any out-of-range reads or writes.

In `@mopro-msm/src/msm/metal_msm/utils/limbs_conversion.rs`:
- Around line 389-440: The 32-bit zero-copy path assumes exactly eight 32-bit
limbs but does not enforce that invariant. Add a debug assertion at the
`msm_config.log_limb_size == 32` guard requiring `msm_config.num_limbs == 8`,
and update public/default API paths that enable this mode to enforce the same
requirement without changing other limb-size behavior.

---

Outside diff comments:
In `@mopro-msm/src/msm/metal_msm/metal_msm.rs`:
- Around line 567-596: Validate the parsed overrides in the window-size and
scale-factor selection logic: clamp MSM_WINDOW_SIZE to a supported positive
range below the shift-overflow limit, and clamp MSM_SCALE_FACTOR to a supported
positive range that keeps c_workgroup_size nonzero. Preserve the existing
size-based defaults when the environment variables are absent or invalid.

In `@mopro-msm/src/msm/metal_msm/utils/limbs_conversion.rs`:
- Around line 556-580: Extend test_parallel_packing_correctness with a dedicated
log_limb_size: 32, num_limbs: 8 case that exercises the zero-copy branch in
pack_affine_and_scalars. Compare its packed coordinates and scalars against a
correct Montgomery-domain reference built from into_bigint() and
calc_mont_radix, preserving the existing 16-bit assertions.

---

Nitpick comments:
In `@mopro-msm/benches/shaders.rs`:
- Around line 64-69: Update the MetalConfig for the transpose benchmark,
identified by kernel_name "transpose", to use log_limb_size 32 and num_limbs 8,
matching the other benchmark configurations while leaving the shader_file and
kernel name unchanged.
- Around line 402-411: Update smvp_gpu to return all four bucket coordinate
buffers, including bucket_zzz_buf, matching the equivalent cuzk smvp helper.
Adjust the function’s tuple return type and every return/destructuring site in
the benchmark, while preserving the existing readback used to force GPU
completion.

In `@mopro-msm/src/msm/metal_msm/host/metal_wrapper.rs`:
- Around line 239-244: Update the command-buffer completion handling after the
commit loop to inspect the status of the completed buffer, at minimum the buffer
returned by command_buffers.last() after wait_until_completed(). Propagate a GPU
execution failure to the caller instead of continuing with potentially stale
output, while preserving successful result handling.

In `@mopro-msm/src/msm/metal_msm/metal_msm.rs`:
- Around line 239-262: Remove the collapsed chunk loop around the legacy SMVP
setup and construct a single params buffer and dimension entry directly. Replace
the derived offset, remaining_subtasks, and chunking arithmetic with the
equivalent num_subtasks-based values while preserving the existing
smvp_params_bufs and smvp_dims contents and single-dispatch behavior.
- Around line 540-543: Replace the thread-local PIPELINE cache used by
metal_variable_base_msm with a process-wide shared OnceCell, verifying that
MetalMSMPipeline is Send + Sync and concurrent execute_pipeline calls are safe;
otherwise synchronize access appropriately. Preserve lazy initialization and
reuse the single pipeline across MSM calls.

In `@mopro-msm/src/msm/metal_msm/shader/bigint/bigint.metal`:
- Around line 14-22: Add a compile-time assertion near the top of bigint.metal
that requires LOG_LIMB_SIZE to equal 32, covering both the addition and
subtraction paths that rely on uint truncation. Keep the existing limb
arithmetic unchanged and ensure generation with any other limb size fails during
compilation.

In `@mopro-msm/src/msm/metal_msm/shader/curve/jacobian.metal`:
- Around line 168-232: Remove the unused jacobian_madd_safe helper and its
entire implementation. Leave the existing xyzz_madd_safe path and all other
Jacobian helpers unchanged.

In `@mopro-msm/src/msm/metal_msm/shader/cuzk/decompose_scalars.metal`:
- Around line 57-69: Add a concise comment immediately before the chunks[id +
offset] assignment in the scalar decomposition flow explaining that converting
negative slice_val to uint and adding s intentionally uses unsigned wraparound
to produce the biased digit in [0, l). Do not alter the existing computation.
- Around line 50-55: Document the preconditions directly above the last-chunk
calculation in the decompose-scalars shader: require window_size <= 16 and
num_subtask * window_size >= 240u + window_size to prevent unsigned shift
underflow. Keep the existing calculation unchanged unless implementing an
equivalent clamp.

In `@mopro-msm/src/msm/metal_msm/shader/cuzk/transpose.metal`:
- Around line 5-8: Add dedicated coverage for the production transpose pipeline
by invoking transpose_histogram, transpose_prefix_sum, and transpose_scatter in
sequence. Compare its output with the legacy transpose result after sorting
values within each column, preserving the unordered-column behavior while
ensuring both paths produce equivalent contents.

In `@mopro-msm/src/msm/metal_msm/shader/misc/get_constant.metal`:
- Around line 14-18: Add an `#else` branch to the NUM_LIMBS preprocessor
conditional initializing MODULUS, and emit a compile-time `#error` for every value
other than 8. Preserve the existing LIMBS_8 initializer for NUM_LIMBS == 8 so
unsupported configurations fail compilation instead of creating a zero modulus.

In `@mopro-msm/src/msm/metal_msm/shader/mont_backend/mont.metal`:
- Around line 65-96: Replace the duplicated final comparison and
borrow-subtraction logic in the surrounding Montgomery reduction function with
the existing conditional_reduce helper, constructing a BigInt from t and relying
on BigInt’s >= and - operators. If retaining the manual implementation for
performance, add a concise comment documenting that rationale.

In `@mopro-msm/src/msm/metal_msm/tests/bigint/bigint_add_wide.rs`:
- Around line 48-62: Update run_bigint_add_test to accept and use a
require_overflow flag, read num_limbs + 1 limbs from result_buf, and compare the
full result against the expected limbs including the carry. When
require_overflow is enabled, explicitly assert that the final limb equals the
expected carry value.

In
`@mopro-msm/src/msm/metal_msm/tests/cuzk/convert_point_coords_and_decompose_scalars.rs`:
- Around line 140-143: Remove the identity helper pack_limbs and update both
call sites to use the scalar limb slices directly. Rename the test file from
convert_point_coords_and_decompose_scalars.rs to decompose_scalars.rs,
preserving the existing scalar decomposition behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 20449c43-863d-4093-9ca6-20c7eb0f22f5

📥 Commits

Reviewing files that changed from the base of the PR and between d0320d5 and ab0a5c2.

📒 Files selected for processing (51)
  • mopro-msm/benches/shaders.rs
  • mopro-msm/build.rs
  • mopro-msm/src/msm/metal_msm/host/metal_wrapper.rs
  • mopro-msm/src/msm/metal_msm/host/shader.rs
  • mopro-msm/src/msm/metal_msm/host/shader_manager.rs
  • mopro-msm/src/msm/metal_msm/metal_msm.rs
  • mopro-msm/src/msm/metal_msm/shader/bigint/bigint.metal
  • mopro-msm/src/msm/metal_msm/shader/constants.metal
  • mopro-msm/src/msm/metal_msm/shader/curve/jacobian.metal
  • mopro-msm/src/msm/metal_msm/shader/curve/xyzz.metal
  • mopro-msm/src/msm/metal_msm/shader/cuzk/barrett_reduction.metal
  • mopro-msm/src/msm/metal_msm/shader/cuzk/convert_point_coords_and_decompose_scalars.metal
  • mopro-msm/src/msm/metal_msm/shader/cuzk/decompose_scalars.metal
  • mopro-msm/src/msm/metal_msm/shader/cuzk/kernel_barrett_reduction.metal
  • mopro-msm/src/msm/metal_msm/shader/cuzk/kernel_field_mul.metal
  • mopro-msm/src/msm/metal_msm/shader/cuzk/pbpr.metal
  • mopro-msm/src/msm/metal_msm/shader/cuzk/smvp.metal
  • mopro-msm/src/msm/metal_msm/shader/cuzk/smvp_segmented.metal
  • mopro-msm/src/msm/metal_msm/shader/cuzk/transpose.metal
  • mopro-msm/src/msm/metal_msm/shader/misc/get_constant.metal
  • mopro-msm/src/msm/metal_msm/shader/mont_backend/mont.metal
  • mopro-msm/src/msm/metal_msm/shader/mont_backend/mont_mul_cios_benchmarks.metal
  • mopro-msm/src/msm/metal_msm/shader/mont_backend/mont_mul_modified.metal
  • mopro-msm/src/msm/metal_msm/shader/mont_backend/mont_mul_modified_benchmarks.metal
  • mopro-msm/src/msm/metal_msm/shader/mont_backend/mont_mul_optimised.metal
  • mopro-msm/src/msm/metal_msm/shader/mont_backend/mont_mul_optimised_benchmarks.metal
  • mopro-msm/src/msm/metal_msm/tests/bigint/bigint_add_unsafe.rs
  • mopro-msm/src/msm/metal_msm/tests/bigint/bigint_add_wide.rs
  • mopro-msm/src/msm/metal_msm/tests/bigint/bigint_sub.rs
  • mopro-msm/src/msm/metal_msm/tests/curve/jacobian_add_2007_b1.rs
  • mopro-msm/src/msm/metal_msm/tests/curve/jacobian_dbl_2009_l.rs
  • mopro-msm/src/msm/metal_msm/tests/curve/jacobian_madd_2007_bl.rs
  • mopro-msm/src/msm/metal_msm/tests/curve/jacobian_neg.rs
  • mopro-msm/src/msm/metal_msm/tests/curve/jacobian_scalar_mul.rs
  • mopro-msm/src/msm/metal_msm/tests/cuzk/barrett_reduction.rs
  • mopro-msm/src/msm/metal_msm/tests/cuzk/convert_point_coords_and_decompose_scalars.rs
  • mopro-msm/src/msm/metal_msm/tests/cuzk/mod.rs
  • mopro-msm/src/msm/metal_msm/tests/cuzk/pbpr.rs
  • mopro-msm/src/msm/metal_msm/tests/cuzk/smvp.rs
  • mopro-msm/src/msm/metal_msm/tests/cuzk/transpose.rs
  • mopro-msm/src/msm/metal_msm/tests/field/ff_add.rs
  • mopro-msm/src/msm/metal_msm/tests/field/ff_reduce.rs
  • mopro-msm/src/msm/metal_msm/tests/field/ff_sub.rs
  • mopro-msm/src/msm/metal_msm/tests/misc/get_constant.rs
  • mopro-msm/src/msm/metal_msm/tests/mont_backend/mod.rs
  • mopro-msm/src/msm/metal_msm/tests/mont_backend/mont_benchmarks.rs
  • mopro-msm/src/msm/metal_msm/tests/mont_backend/mont_mul_cios.rs
  • mopro-msm/src/msm/metal_msm/tests/mont_backend/mont_mul_modified.rs
  • mopro-msm/src/msm/metal_msm/tests/mont_backend/mont_mul_optimised.rs
  • mopro-msm/src/msm/metal_msm/utils/limbs_conversion.rs
  • mopro-msm/src/msm/metal_msm/utils/mont_params.rs
💤 Files with no reviewable changes (15)
  • mopro-msm/src/msm/metal_msm/shader/mont_backend/mont_mul_optimised_benchmarks.metal
  • mopro-msm/src/msm/metal_msm/shader/cuzk/kernel_field_mul.metal
  • mopro-msm/src/msm/metal_msm/shader/mont_backend/mont_mul_modified.metal
  • mopro-msm/src/msm/metal_msm/shader/mont_backend/mont_mul_optimised.metal
  • mopro-msm/src/msm/metal_msm/shader/mont_backend/mont_mul_modified_benchmarks.metal
  • mopro-msm/src/msm/metal_msm/shader/mont_backend/mont_mul_cios_benchmarks.metal
  • mopro-msm/src/msm/metal_msm/shader/cuzk/convert_point_coords_and_decompose_scalars.metal
  • mopro-msm/src/msm/metal_msm/shader/cuzk/kernel_barrett_reduction.metal
  • mopro-msm/src/msm/metal_msm/tests/mont_backend/mont_benchmarks.rs
  • mopro-msm/src/msm/metal_msm/tests/mont_backend/mont_mul_optimised.rs
  • mopro-msm/src/msm/metal_msm/tests/mont_backend/mod.rs
  • mopro-msm/src/msm/metal_msm/tests/mont_backend/mont_mul_modified.rs
  • mopro-msm/src/msm/metal_msm/tests/cuzk/barrett_reduction.rs
  • mopro-msm/src/msm/metal_msm/tests/cuzk/mod.rs
  • mopro-msm/src/msm/metal_msm/shader/cuzk/barrett_reduction.metal

Comment on lines 144 to +164
let c_workgroup_size = self.simd_width * scale_factor;
let c_num_x_workgroups = c_workgroup_size;
let c_num_y_workgroups = input_size / c_workgroup_size / c_num_x_workgroups;
let c_num_z_workgroups = 1;

let (point_x, point_y, scalar_chunks) = stage1.execute(
&coords,
&scals,
input_size,
window_size,
num_columns,
num_subtasks,
c_num_x_workgroups,
c_num_y_workgroups,
c_num_z_workgroups,
c_workgroup_size,
)?;

// Stage 2: Transpose
let stage2 = Transpose::new(&self.shader_manager);

let t_num_x_workgroups = 1;
let t_num_y_workgroups = 1;
let t_num_z_workgroups = 1;
let t_workgroup_size = num_subtasks;

let (csc_col_ptr, csc_val_idxs) = stage2.execute(
&scalar_chunks,
num_subtasks,
input_size,
num_columns,
t_num_x_workgroups,
t_num_y_workgroups,
t_num_z_workgroups,
t_workgroup_size,
)?;
let convert_params_buf = helper.create_buffer(&vec![
input_size as u32,
window_size as u32,
num_columns as u32,
num_subtasks as u32,
]);

// Stage 3: Sparse Matrix-Vector Multiplication
// according to the write-up (https://github.com/z-prize/2023-entries/tree/main/prize-2-msm-wasm/webgpu-only/tal-derei-koh-wei-jie#thread-count)
// the threads is (input size / 2) * num_subtasks
// after testing, 1D dim config is better than 3D dim config
let stage3 = SMVP::new(&self.shader_manager);
ops.push(ShaderOperation {
pipeline_state: convert_shader.pipeline_state.clone(),
buffers: vec![&scalars_buf, &scalar_chunks_buf, &convert_params_buf],
thread_group_count: helper.create_thread_group_size(
c_num_x_workgroups as u64,
c_num_y_workgroups.max(1) as u64,
1,
),
threads_per_threadgroup: helper.create_thread_group_size(c_workgroup_size as u64, 1, 1),
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🔴 Critical | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Locate the decompose_scalars kernel and inspect its bounds check.
fd -t f 'decompose_scalars.metal' | while IFS= read -r f; do
  echo "=== $f ==="
  cat -n "$f"
done

Repository: zkmopro/gpu-acceleration

Length of output: 3090


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Locate metal_msm.rs:"
fd -t f '^metal_msm\.rs$' .

echo
echo "Find relevant symbols/line ranges:"
rg -n "metal_variable_base_msm|decompose_scalars|create_thread_group_size|simd_width|c_workgroup_size|c_num_x_workgroups|c_num_y_workgroups|scale_factor" mopro-msm/src -S

echo
echo "Inspect metal_msm.rs around relevant functions:"
wc -l mopro-msm/src/msm/metal_msm/metal_msm.rs
sed -n '1,240p' mopro-msm/src/msm/metal_msm/metal_msm.rs

echo
echo "Inspect remaining file around variable_base_msm usages:"
sed -n '240,440p' mopro-msm/src/msm/metal_msm/metal_msm.rs

Repository: zkmopro/gpu-acceleration

Length of output: 33529


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Inspect variable_base_msm and remainder of execute_pipeline:"
sed -n '440,720p' mopro-msm/src/msm/metal_msm/metal_msm.rs

echo
echo "Search for scalar decomposition bound checks and tests with non-power-of-two sizes:"
rg -n "input_size|num_subtasks|id <|global index|thread_position_in_grid|metal_variable_base_msm|convert_point|decompose" mopro-msm/src/msm/metal_msm -S

echo
echo "Standalone arithmetic check for dispatched threads and coverage:"
python3 - <<'PY'
cases = [
    (32, 2, 100000),
    (32, 1, 100000),
    (32, 2, 8192),
    (32, 2, 8193),
]
for simd_width, scale_factor, input_size in cases:
    c_workgroup_size = simd_width * scale_factor
    c_num_x_workgroups = c_workgroup_size
    c_num_y_workgroups = input_size // c_workgroup_size // c_num_x_workgroups
    dispatched = c_workgroup_size * (max(1, c_num_y_workgroups)) * c_num_x_workgroups
    covered = min(input_size, dispatched)
    rest = input_size - covered
    ceiling_y = (input_size + c_workgroup_size * c_num_x_workgroups - 1) // (c_workgroup_size * c_num_x_workgroups)
    ceiling_dispatched = c_workgroup_size * ceiling_y * c_num_x_workgroups
    print({
        "simd_width": simd_width,
        "scale_factor": scale_factor,
        "input_size": input_size,
        "c_workgroup_size": c_workgroup_size,
        "current_y": c_num_y_workgroups,
        "current_dispatched": dispatched,
        "rest": rest,
        "ceiling_y": ceiling_y,
        "ceiling_dispatched": ceiling_dispatched,
    })
PY

echo
echo "Inspect Metal wrapper for thread limit availability:"
sed -n '90,130p' mopro-msm/src/msm/metal_msm/host/metal_wrapper.rs

Repository: zkmopro/gpu-acceleration

Length of output: 32840


Dispatch scalar decomposition with ceiling division

metal_variable_base_msm can set scale_factor = 2 for 65,537–1,048,576 entries. With simd_width = 32, that makes c_workgroup_size = 64, and the current floor-based grid only dispatches c_workgroup_size^2 * floor(input_size / c_workgroup_size^2) threads. For 100,000 scalars, 1,696 chunks stay zero, and later stages use that partial result. Compute c_num_y_workgroups with ceiling division over c_workgroup_size * c_num_x_workgroups, and add a guard in decompose_scalars so threads with global index >= input_size do not read/write out of range.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mopro-msm/src/msm/metal_msm/metal_msm.rs` around lines 144 - 164, Update the
dispatch setup around c_num_y_workgroups to use ceiling division by
c_workgroup_size * c_num_x_workgroups, ensuring all input scalars are covered.
In decompose_scalars, guard threads whose global index is >= input_size so they
return before any out-of-range reads or writes.

Comment on lines +291 to +295
let seg_len: usize = std::env::var("MSM_SMVP_L")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(128);
let num_segments = (input_size + seg_len - 1) / seg_len;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Guard MSM_SMVP_L against zero.

MSM_SMVP_L=0 parses successfully, so seg_len becomes 0 and num_segments divides by zero. That panics the MSM call.

🛡️ Proposed fix
             let seg_len: usize = std::env::var("MSM_SMVP_L")
                 .ok()
                 .and_then(|v| v.parse().ok())
+                .filter(|&v| v > 0)
                 .unwrap_or(128);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let seg_len: usize = std::env::var("MSM_SMVP_L")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(128);
let num_segments = (input_size + seg_len - 1) / seg_len;
let seg_len: usize = std::env::var("MSM_SMVP_L")
.ok()
.and_then(|v| v.parse().ok())
.filter(|&v| v > 0)
.unwrap_or(128);
let num_segments = (input_size + seg_len - 1) / seg_len;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mopro-msm/src/msm/metal_msm/metal_msm.rs` around lines 291 - 295, Validate
the parsed `MSM_SMVP_L` value in the segment-length initialization before
calculating `num_segments`. Ensure zero is rejected and replaced with the
existing default length of 128, so the division in the `num_segments`
calculation always uses a positive `seg_len`.

Comment on lines +389 to +440

if msm_config.log_limb_size == 32 {
// Limbs are plain little-endian 32-bit words.
//
// Coordinates are copied in arkworks' internal Montgomery representation
// (R = 2^256, identical to the GPU's Montgomery radix), so no
// Montgomery reduction happens on the host and no to-Montgomery
// conversion is needed on the GPU. Scalars go through into_bigint()
// because window decomposition needs the standard representation.
let words_per_coord = msm_config.num_limbs;
let coords_per_point = words_per_coord * 2;
let mut coords = vec![0u32; num_elements * coords_per_point];
let mut scalars_u32 = vec![0u32; num_elements * words_per_coord];

let write_words = |v: &[u64; 4], out: &mut [u32]| {
for (i, w) in v.iter().enumerate() {
out[2 * i] = *w as u32;
out[2 * i + 1] = (*w >> 32) as u32;
}
};

const CHUNK_SIZE: usize = 1024;
bases
.par_chunks(CHUNK_SIZE)
.zip(scalars.par_chunks(CHUNK_SIZE))
.zip(coords.par_chunks_mut(CHUNK_SIZE * coords_per_point))
.zip(scalars_u32.par_chunks_mut(CHUNK_SIZE * words_per_coord))
.for_each(
|(((base_chunk, scalar_chunk), coord_chunk), scalar_u32_chunk)| {
for (i, (pt, sc)) in base_chunk.iter().zip(scalar_chunk.iter()).enumerate() {
let coord_start = i * coords_per_point;
write_words(
&pt.x.0 .0,
&mut coord_chunk[coord_start..coord_start + words_per_coord],
);
write_words(
&pt.y.0 .0,
&mut coord_chunk
[coord_start + words_per_coord..coord_start + coords_per_point],
);
let scalar_start = i * words_per_coord;
write_words(
&sc.into_bigint().0,
&mut scalar_u32_chunk[scalar_start..scalar_start + words_per_coord],
);
}
},
);

return (coords, scalars_u32);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Find all MetalConfig construction sites and check log_limb_size/num_limbs pairing.
rg -n -B2 -A6 'MetalConfig\s*\{' --type rust

Repository: zkmopro/gpu-acceleration

Length of output: 162


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Files matching config/limbs names:"
fd -i 'config|limbs|metal|mopro' . | sed -n '1,120p'

echo
echo "Find MetalConfig type/struct definitions and usages:"
rg -n -i 'MetalConfig|log_limb_size|num_limbs|limbs_conversion|write_words' --type rust | sed -n '1,240p'

Repository: zkmopro/gpu-acceleration

Length of output: 3153


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Repo files:"
git ls-files | sed -n '1,120p'

echo
echo "Locate limbs_conversion.rs:"
fd -a limbs_conversion.rs . || true

echo
echo "Relevant source around lines 360-450:"
file="$(fd 'limbs_conversion.rs' . | head -n 1)"
if [ -n "${file:-}" ]; then
  wc -l "$file"
  sed -n '1,520p' "$file"
fi

Repository: zkmopro/gpu-acceleration

Length of output: 24818


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Find struct/type/defaults definitions:"
rg -n -i 'struct MetalConfig|type MetalConfig|derive\(Default|metal_config|Ms' mopro-msm/src/msm/metal_msm mopro-msm/src/msm/mod.rs mopro-msm/src/lib.rs --type rust

echo
echo "Inspect host files likely containing config/default:"
for f in mopro-msm/src/msm/metal_msm/host/errors.rs \
         mopro-msm/src/msm/metal_msm/host/gpu.rs \
         mopro-msm/src/msm/metal_msm/host/metal_wrapper.rs \
         mopro-msm/src/msm/metal_msm/metal_msm.rs \
         mopro-msm/src/msm/metal_msm/mod.rs \
         mopro-msm/src/lib.rs \
         mopro-msm/src/msm/mod.rs; do
  [ -f "$f" ] || continue
  echo "--- $f ---"
  ast-grep outline "$f" 2>/dev/null | sed -n '1,160p' || true
done

echo
echo "Search for default-like config values and field names:"
rg -n 'Default|Default.*Metal|log_limb_size|num_limbs|const .*MONT|MONT|Mont' mopro-msm/src/msm --type rust | sed -n '1,240p'

Repository: zkmopro/gpu-acceleration

Length of output: 50380


Add an invariant check for the zero-copy 32-bit limb path.

write_words only packs a fixed 4-element u64 coordinate/scalar into 8 u32 limbs. If log_limb_size: 32 can be used with num_limbs != 8, this path can panic or truncate the Montgomery value. Add a debug assert at the guard and update any public/default API paths to require num_limbs == 8 for this zero-copy mode.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mopro-msm/src/msm/metal_msm/utils/limbs_conversion.rs` around lines 389 -
440, The 32-bit zero-copy path assumes exactly eight 32-bit limbs but does not
enforce that invariant. Add a debug assertion at the `msm_config.log_limb_size
== 32` guard requiring `msm_config.num_limbs == 8`, and update public/default
API paths that enable this mode to enforce the same requirement without changing
other limb-size behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@moven0831
moven0831 merged commit ba00866 into zkmopro:main Aug 6, 2026
1 check passed
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