Skip to content

Commit e126ffd

Browse files
perf(felt252): compute felt252_mul via a 2-CIOS runtime binding
Route felt252 multiplication through a runtime binding instead of the inline i512 multiply-and-reduce. The inline lowering legalized into huge limb sequences that made O3 register allocation of multiplication-heavy contracts pathologically slow (e.g. the Falcon account contract: 655s -> 173s). The binding computes the product with a 2-CIOS canonical multiply, which is also ~1.77x faster than the naive 4-CIOS form. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 1c3473a commit e126ffd

3 files changed

Lines changed: 210 additions & 14 deletions

File tree

src/libfuncs/felt252.rs

Lines changed: 40 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use crate::{
77
runtime_bindings::{ExtendedEuclideanWidth, RuntimeBindingsMeta},
88
MetadataStorage,
99
},
10-
utils::{felt_to_unsigned, ProgramRegistryExt, PRIME},
10+
utils::{felt_to_unsigned, get_integer_layout, ProgramRegistryExt, PRIME},
1111
};
1212
use cairo_lang_sierra::{
1313
extensions::{
@@ -128,21 +128,47 @@ pub fn build_binary_operation<'ctx, 'this>(
128128
entry.trunci(result, felt252_ty, location)?
129129
}
130130
Felt252BinaryOperator::Mul => {
131-
let lhs = entry.extui(lhs, i512, location)?;
132-
let rhs = entry.extui(rhs, i512, location)?;
133-
let result = entry.muli(lhs, rhs, location)?;
134-
135-
let prime = entry.const_int_from_type(context, location, PRIME.clone(), i512)?;
136-
let result_mod = entry.append_op_result(arith::remui(result, prime, location))?;
137-
let is_out_of_range =
138-
entry.cmpi(context, CmpiPredicate::Uge, result, prime, location)?;
131+
// The field multiplication (a full 252x252 -> 504 bit multiply
132+
// followed by a reduction modulo the prime) is delegated to a
133+
// runtime binding. Lowering it inline produces i512 multiply and
134+
// remainder operations that the LLVM backend legalizes into huge
135+
// sequences of limb operations, which makes codegen of
136+
// multiplication-heavy contracts pathologically slow.
137+
let layout_i256 = get_integer_layout(256);
138+
let lhs_ptr =
139+
helper
140+
.init_block()
141+
.alloca1(context, location, i256, layout_i256.align())?;
142+
let rhs_ptr =
143+
helper
144+
.init_block()
145+
.alloca1(context, location, i256, layout_i256.align())?;
146+
let dst_ptr =
147+
helper
148+
.init_block()
149+
.alloca1(context, location, i256, layout_i256.align())?;
150+
151+
let lhs_i256 = entry.extui(lhs, i256, location)?;
152+
let rhs_i256 = entry.extui(rhs, i256, location)?;
153+
entry.store(context, location, lhs_ptr, lhs_i256)?;
154+
entry.store(context, location, rhs_ptr, rhs_i256)?;
139155

140-
let result = entry.append_op_result(arith::select(
141-
is_out_of_range,
142-
result_mod,
143-
result,
156+
let runtime_bindings_meta = metadata
157+
.get_mut::<RuntimeBindingsMeta>()
158+
.to_native_assert_error(
159+
"Unable to get the RuntimeBindingsMeta from MetadataStorage",
160+
)?;
161+
runtime_bindings_meta.felt252_mul(
162+
context,
163+
helper.module,
164+
entry,
165+
dst_ptr,
166+
lhs_ptr,
167+
rhs_ptr,
144168
location,
145-
))?;
169+
)?;
170+
171+
let result = entry.load(context, location, dst_ptr, i256)?;
146172
entry.trunci(result, felt252_ty, location)?
147173
}
148174
Felt252BinaryOperator::Div => {

src/metadata/runtime_bindings.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ enum RuntimeBinding {
5353
U64SquareRoot,
5454
U128SquareRoot,
5555
U256SquareRoot,
56+
Felt252Mul,
5657
CircuitArithOperation,
5758
DictIntoEntries,
5859
QM31Add,
@@ -87,6 +88,7 @@ impl RuntimeBinding {
8788
Self::U64SquareRoot => "cairo_native__u64_square_root",
8889
Self::U128SquareRoot => "cairo_native__u128_square_root",
8990
Self::U256SquareRoot => "cairo_native__u256_square_root",
91+
Self::Felt252Mul => "cairo_native__felt252_mul",
9092
Self::CircuitArithOperation => "cairo_native__circuit_arith_operation",
9193
Self::DictIntoEntries => "cairo_native__dict_into_entries",
9294
Self::QM31Add => "cairo_native__libfunc__qm31__qm31_add",
@@ -134,6 +136,7 @@ impl RuntimeBinding {
134136
Self::U64SquareRoot => cairo_native__u64_square_root as *const (),
135137
Self::U128SquareRoot => cairo_native__u128_square_root as *const (),
136138
Self::U256SquareRoot => cairo_native__u256_square_root as *const (),
139+
Self::Felt252Mul => cairo_native__felt252_mul as *const (),
137140
Self::ArenaAlloc => cairo_native__arena_alloc as *const (),
138141
Self::ExtendedEuclideanAlgorithm(_) | Self::CircuitArithOperation => return None,
139142
#[cfg(feature = "with-cheatcode")]
@@ -349,6 +352,34 @@ impl RuntimeBindingsMeta {
349352
.into())
350353
}
351354

355+
/// Register if necessary, then invoke `cairo_native__felt252_mul`, which
356+
/// computes `(lhs * rhs) mod STARK_PRIME`. The pointers reference 32-byte
357+
/// little-endian felt252 buffers; the result is written to `dst_ptr`.
358+
#[allow(clippy::too_many_arguments)]
359+
pub fn felt252_mul<'c, 'a>(
360+
&mut self,
361+
context: &'c Context,
362+
module: &Module,
363+
block: &'a Block<'c>,
364+
dst_ptr: Value<'c, '_>,
365+
lhs_ptr: Value<'c, '_>,
366+
rhs_ptr: Value<'c, '_>,
367+
location: Location<'c>,
368+
) -> Result<OperationRef<'c, 'a>>
369+
where
370+
'c: 'a,
371+
{
372+
let function =
373+
self.build_function(context, module, block, location, RuntimeBinding::Felt252Mul)?;
374+
375+
Ok(block.append_operation(
376+
OperationBuilder::new("llvm.call", location)
377+
.add_operands(&[function])
378+
.add_operands(&[dst_ptr, lhs_ptr, rhs_ptr])
379+
.build()?,
380+
))
381+
}
382+
352383
/// Builds, if necessary, the circuit operation function, used to perform
353384
/// circuit arithmetic operations.
354385
///
@@ -973,6 +1004,7 @@ pub fn setup_runtime(find_symbol_ptr: impl Fn(&str) -> Option<*mut c_void>) {
9731004
RuntimeBinding::U64SquareRoot,
9741005
RuntimeBinding::U128SquareRoot,
9751006
RuntimeBinding::U256SquareRoot,
1007+
RuntimeBinding::Felt252Mul,
9761008
#[cfg(feature = "with-cheatcode")]
9771009
RuntimeBinding::VtableCheatcode,
9781010
] {

src/runtime.rs

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,59 @@ pub extern "C" fn cairo_native__u256_square_root(lo: u128, hi: u128) -> u128 {
7979
.expect("the square root of a u256 always fits in a u128")
8080
}
8181

82+
/// Compute `(lhs * rhs) mod STARK_PRIME` for two felt252 values, each given as
83+
/// a 32-byte little-endian buffer, writing the field product to `dst`.
84+
///
85+
/// felt252 values are stored canonically. A naive `Felt::from_bytes_le(lhs) *
86+
/// Felt::from_bytes_le(rhs)` then `.to_bytes_le()` costs four Montgomery
87+
/// multiplications: two canonical->Montgomery conversions on the inputs, the
88+
/// multiply, and one Montgomery->canonical conversion on the output.
89+
///
90+
/// We cut that to two by exploiting that `Felt::from_raw`/`to_raw` are free
91+
/// (they reinterpret the limbs as the internal Montgomery representation,
92+
/// without any conversion):
93+
/// - `from_raw(canonical(a))` yields a `Felt` whose *value* is `a * R⁻¹`,
94+
/// - multiplying it by `from_bytes_le(b)` (value `b`) gives value `a * b * R⁻¹`,
95+
/// whose raw representation is exactly `a * b`,
96+
/// - so `to_raw` reads the canonical product straight out.
97+
///
98+
/// Only the `from_bytes_le(rhs)` conversion and the multiply itself cost a
99+
/// Montgomery multiplication.
100+
pub extern "C" fn cairo_native__felt252_mul(dst: &mut [u8; 32], lhs: &[u8; 32], rhs: &[u8; 32]) {
101+
// value = a * R⁻¹ (free: reinterpret canonical bytes as raw limbs).
102+
let lhs = felt_from_raw_le_bytes(lhs);
103+
// value = b (one Montgomery multiplication).
104+
let rhs = Felt::from_bytes_le(rhs);
105+
// value = a * b * R⁻¹, so its raw representation is the canonical `a * b`.
106+
*dst = felt_raw_to_le_bytes(&(lhs * rhs));
107+
}
108+
109+
/// Build a `Felt` by interpreting a 32-byte little-endian buffer as the felt's
110+
/// raw internal (Montgomery) representation. The inverse of
111+
/// [`felt_raw_to_le_bytes`]. Zero-cost: no canonical<->Montgomery conversion.
112+
///
113+
/// `Felt::from_raw` takes limbs most-significant-first, while the buffer is
114+
/// little-endian, so the limbs are read least-significant-first then reversed.
115+
fn felt_from_raw_le_bytes(buffer: &[u8; 32]) -> Felt {
116+
let mut limbs = [0u64; 4];
117+
for (i, limb) in limbs.iter_mut().enumerate() {
118+
*limb = u64::from_le_bytes(buffer[i * 8..i * 8 + 8].try_into().unwrap());
119+
}
120+
limbs.reverse();
121+
Felt::from_raw(limbs)
122+
}
123+
124+
/// Little-endian image of a `Felt`'s raw internal (Montgomery) representation.
125+
/// The inverse of [`felt_from_raw_le_bytes`]. Zero-cost.
126+
fn felt_raw_to_le_bytes(value: &Felt) -> [u8; 32] {
127+
let limbs = value.to_raw_reversed(); // least-significant limb first
128+
let mut buffer = [0u8; 32];
129+
for (i, limb) in limbs.iter().enumerate() {
130+
buffer[i * 8..i * 8 + 8].copy_from_slice(&limb.to_le_bytes());
131+
}
132+
buffer
133+
}
134+
82135
/// Allocate `size` bytes with `align` alignment from the per-execution arena.
83136
pub unsafe extern "C" fn cairo_native__arena_alloc(size: u64, align: u64) -> *mut u8 {
84137
EXECUTION_ARENA.with(|arena| {
@@ -976,6 +1029,91 @@ mod tests {
9761029
os::fd::AsRawFd,
9771030
};
9781031

1032+
/// The 2-CIOS `cairo_native__felt252_mul` must equal the canonical field
1033+
/// product for every input, including 0, 1, -1, and `PRIME`-adjacent values.
1034+
#[test]
1035+
fn felt252_mul_matches_field_product() {
1036+
let cases = [
1037+
Felt::ZERO,
1038+
Felt::ONE,
1039+
Felt::THREE,
1040+
Felt::from(-1),
1041+
Felt::from(-2),
1042+
Felt::from(1234567890123456789u64),
1043+
Felt::from_hex_unchecked(
1044+
"0x4d6e41de886ac83938da3456ccf1481182687989ead34d9d35236f0864575a0",
1045+
),
1046+
Felt::MAX,
1047+
];
1048+
for &a in &cases {
1049+
for &b in &cases {
1050+
let mut dst = [0u8; 32];
1051+
cairo_native__felt252_mul(&mut dst, &a.to_bytes_le(), &b.to_bytes_le());
1052+
assert_eq!(
1053+
Felt::from_bytes_le(&dst),
1054+
a * b,
1055+
"felt252_mul({a:#x}, {b:#x}) gave the wrong product"
1056+
);
1057+
}
1058+
}
1059+
}
1060+
1061+
/// `felt_from_raw_le_bytes`/`felt_raw_to_le_bytes` must round-trip the raw
1062+
/// (Montgomery) representation.
1063+
#[test]
1064+
fn felt_raw_le_bytes_round_trip() {
1065+
for value in [Felt::ZERO, Felt::ONE, Felt::from(-1), Felt::MAX] {
1066+
assert_eq!(felt_from_raw_le_bytes(&felt_raw_to_le_bytes(&value)), value);
1067+
}
1068+
}
1069+
1070+
/// Clock-independent head-to-head of the mul implementations in one process:
1071+
/// the 2-CIOS `cairo_native__felt252_mul` vs the naive 4-CIOS form
1072+
/// (from_bytes_le x2 + mul + to_bytes_le). Run with:
1073+
/// cargo test --release -p cairo-native --lib felt252_mul_microbench -- --nocapture --ignored
1074+
#[test]
1075+
#[ignore = "timing microbenchmark; run manually with --nocapture"]
1076+
fn felt252_mul_microbench() {
1077+
use std::hint::black_box;
1078+
use std::time::Instant;
1079+
1080+
let a = Felt::from_hex_unchecked(
1081+
"0x4d6e41de886ac83938da3456ccf1481182687989ead34d9d35236f0864575a0",
1082+
)
1083+
.to_bytes_le();
1084+
let b = Felt::from(1234567890123456789u64).to_bytes_le();
1085+
let n = 20_000_000u64;
1086+
1087+
// 4-CIOS naive form.
1088+
let t = Instant::now();
1089+
let mut acc = [0u8; 32];
1090+
for _ in 0..n {
1091+
let lhs = Felt::from_bytes_le(black_box(&a));
1092+
let rhs = Felt::from_bytes_le(black_box(&b));
1093+
acc = (lhs * rhs).to_bytes_le();
1094+
}
1095+
black_box(acc);
1096+
let four = t.elapsed();
1097+
1098+
// 2-CIOS form (the shipping implementation).
1099+
let t = Instant::now();
1100+
let mut dst = [0u8; 32];
1101+
for _ in 0..n {
1102+
cairo_native__felt252_mul(&mut dst, black_box(&a), black_box(&b));
1103+
black_box(&dst);
1104+
}
1105+
let two = t.elapsed();
1106+
1107+
println!(
1108+
"felt252_mul over {n} iters: 4-CIOS={:?} ({:.1} ns/op), 2-CIOS={:?} ({:.1} ns/op), speedup={:.2}x",
1109+
four,
1110+
four.as_nanos() as f64 / n as f64,
1111+
two,
1112+
two.as_nanos() as f64 / n as f64,
1113+
four.as_nanos() as f64 / two.as_nanos() as f64,
1114+
);
1115+
}
1116+
9791117
pub fn felt252_short_str(value: &str) -> Felt {
9801118
let values: Vec<_> = value
9811119
.chars()

0 commit comments

Comments
 (0)