Optimization: type-aware recv codegen, zero-copy views, #[abi] structs#1451
Optimization: type-aware recv codegen, zero-copy views, #[abi] structs#1451micahscopes wants to merge 23 commits into
Conversation
Add #[inline(always)] to SolDecoder and SolEncoder trait impl methods (read_word, write_word, accessors, alloc) and to core ABI dispatch functions (decode_payload_root, decode_field, encode_root, encode_field, etc). These are small, hot functions that the inliner skips under its default budget cap (~24 instructions), leaving residual overflow checks that could otherwise be eliminated by range analysis after inlining.
Add #[dispatch(linear)] and #[dispatch(binary_search)] contract attributes to override the selector dispatch strategy. Without an attribute, auto-select based on selector count: linear for 3 or fewer, binary search for 4 or more. Linear dispatch (O(n) if-else chain) is cheaper for small contracts. Binary search (O(log n) comparison tree) is cheaper for contracts with many selectors. This addresses review feedback requesting the strategy be configurable rather than hardcoded. Adds DispatchAttr enum to HIR, DispatchStrategy to MIR, and threads the strategy through to the synthetic body builder.
The ERC20 fixture has 14 selectors, exceeding the auto-selection threshold (>3). The snapshot now reflects the binary-search dispatch tree instead of the previous linear br_table.
Teach the synthetic builder to query msg_ty field types and specialize recv wrapper code generation for static all-word-scalar parameter types. For handlers like fn transfer(to: Address, amount: u256), the compiler now emits direct calldataload at known offsets (4, 36, ...) instead of the generic malloc + calldatacopy + SolDecoder + decode_fn path. For single word-scalar return types, emit direct mstore + return(0, 32) instead of calling encode_single_root_alloc. Adds DirectCalldataLoad and DirectScalarReturn variants to RuntimeInputPlan and RuntimeReturnPlan. Falls back to the generic path for dynamic types or non-word fields.
Three correctness fixes for the recv wrapper optimization:
- Restrict DirectCalldataLoad to compiler-generated msg structs only.
Manual MsgVariant impls may reorder fields in encode/decode, so
direct calldataload at canonical offsets would read wrong data.
- Emit Cast from u256 to the field's actual scalar class after
calldataload. The EVM opcode returns a raw 256-bit word but the
runtime verifier requires matching scalar representations (e.g.
u64 = Int{bits:64}, bool = Bool).
- Restrict DirectScalarReturn to u256/usize only. Smaller types
and signed types don't match the mstore verifier's requirement
of ScalarRepr::Int{bits:256, signed:false}.
Replace the is_abi_word_type() heuristic with actual queries of AbiSize::HEAD_SIZE, AbiSize::IS_DYNAMIC, and Encode::DIRECT_ENCODE via compile-time evaluation from the MIR layer. Add eval_trait_assoc_const() which bridges MIR to HIR CTFE: resolves trait impls, creates SemanticInstanceKey, and evaluates via eval_const_instance. Three query helpers: query_head_size, query_is_dynamic, query_direct_encode. DirectCalldataLoad now stores per-field HEAD_SIZE values instead of a flat field count, computing calldata offsets from accumulated sizes. DirectScalarReturn checks DIRECT_ENCODE on the return type. This is the "compiler reads what the type system already knows" root cause fix: the synthetic builder now uses the same const trait values that Fe library authors define, rather than a parallel type-structure heuristic.
ArrayView stores a ByteInput source and the byte offset where an array starts in ABI-encoded data. Elements are read on demand via get(index), paying only for what you use. Generic over ByteInput, so ArrayView<u256, 32, CallData> is a zero-copy view over calldata, consistent with BytesView<CallData> and StringView<CallData>. Also adds skip_bytes() to AbiDecoder and skip_field() utility for advancing past fields without materializing them. The event encoding optimization (46ee975) was not cherry-picked as master already uses encode_abi_payload/encode_alloc which subsumes it.
The Decode impl for ArrayView<T, N, I> has a type mismatch: d.input()
returns D::Input but ArrayView needs I, with no way to tie them.
Add SkippedArray<T, N> which stores only start offset, impls Decode
trivially (just advances cursor), and provides with_input() to bind a
byte source later. This enables the msg-parameter pattern:
msg Msg { proof: SkippedArray<u256, 32>, leaf: u256 }
The decoder skips the 32-element array (zero copy) and the handler
binds a CallData source: proof.with_input(CallData { base: 0 })
This is the pattern that achieved 2x Merkle improvement in ralph-loop.
Follow the SkippedArray pattern for dynamic ABI types. SkippedBytes
decodes by reading the length word and recording position without
allocating or copying data. SkippedString wraps SkippedBytes.
Handler calls with_input(CallData::new()) to get BytesView<CallData>
or StringView<CallData> for direct calldata reads.
Usage:
msg Msg { data: SkippedBytes, name: SkippedString }
// handler: data.with_input(calldata) -> BytesView<CallData>
Includes ABI descriptor support for correct Solidity JSON emission
and std re-exports.
Adds LazyCalldataLoad plan for message types with mixed scalar/aggregate fields. Non-mut scalar fields load directly via calldataload at their ABI offset, SkippedArray fields pass the calldata byte offset through, and remaining fields go through the standard decode pipeline. Adds FieldLoadStrategy enum (Direct/Decode/SkipWithOffset), helper functions for per-field strategy determination, and LazyCalddataLoad codegen in synthetic.rs. Consolidated from lazy-access branch commits 4fb116d85, f0b6e3b50, d9b6c8b30.
Add support for where { const_expr } syntax in Fe where clauses.
Const predicates are boolean expressions evaluated via CTFE at
monomorphization time. If the expression evaluates to false,
compilation fails with a diagnostic.
Implementation spans parser (WhereConstPredicate AST node), HIR
(const_predicates field on WhereClauseId), lowering (Body for const
predicate expressions), diagnostics (ConstPredicateFailed), and
call-site CTFE evaluation in callable.rs.
Consolidated from const-predicates branch commit 1e2ceba.
Replace SkippedArray/SkippedBytes/SkippedString with unified naming
using default type parameters:
- ArrayView<T, N, I = ()>: unbound when I=(), bound when I: ByteInput
- BytesView<I = ()>: unbound when I=(), bound when I: ByteInput
- StringView<I = ()>: unbound when I=(), bound when I: ByteInput
Rename with_input() to over() for binding a byte source.
Unbound views impl Decode for use in msg fields.
Bound views have element/byte access methods.
Example: msg { proof: ArrayView<u256, 32> }
Handler: let view = proof.over(CallData::with_base(4))
Extend lazy access builder to automatically generate zero-copy decode for non-mut BytesView and StringView parameters. The builder reads the dynamic offset pointer and length word directly from calldata, constructs the view without malloc/calddatacopy/SolDecoder. Add FieldLoadStrategy::LazyDynamicView for dynamic view types. Make LazyCalldataLoad::decode_fn optional (None when all fields are Direct/SkipWithOffset/LazyDynamicView, completely eliminating the decode pipeline). Codegen: push_dynamic_view_from_calldata() reads head offset, computes tail position, reads length, constructs BytesView/StringView aggregate directly from calldata values. 4 new tests, 71 total pass.
Add #[abi] attribute that auto-generates AbiSize, Encode<Sol>, and
Decode<Sol> impls for user-defined structs, enabling them as msg field
types mapped to Solidity tuple encoding.
All fields are encoded regardless of visibility (same as serde).
Generic structs are rejected. Follows the #[error] struct pattern,
reusing msg.rs helpers for impl generation.
Example:
#[abi]
pub struct G1Point { pub x: u256, pub y: u256 }
msg Msg {
#[selector = sol("verify((uint256,uint256),uint256)")]
Verify { point: G1Point, value: u256 } -> bool,
}
New file: crates/hir/src/core/lower/abi_struct.rs (363 lines)
10 files modified for wiring, diagnostics, and analysis pass.
All tests pass (fe-hir, fe-mir, fe, uitest).
StringView<()>::over() was calling self.bytes.over(input) which triggered a Fe type inference bug where the method-level generic J was unified with the impl's () type parameter. Bypass by constructing BytesView<J> directly via from_parts. Also remove stale .snap.new files superseded by accepted snapshot updates.
When a struct has more type params than the impl block (e.g. ArrayView<T, N, I=()> with impl<T, N> ArrayView<T, N>), the extra receiver args were applied to the method's own generics, clobbering them with the struct's default value. Fix: only apply parent impl's param count of args from the receiver. The method's own generics get fresh type variables as intended. Fixes: ArrayView.over(), BytesView.over(), StringView.over()
|
@micahscopes following up from your comment on #1448:
Would it be useful/feasible to merge some of the things on this branch before the Reflect derive stuff is ready? ABI encode/decode is the main gas issue right now, so it would be nice to land any improvements we can. |
|
@sbillig I'm not so sure if it makes sense to split it up. We could pull out some of the view types but the parts that make those most useful (automatically) for abi encode/decode are fairly wrapped up in the ctfe code. That said, it's possible that we could get this whole thing in now and overhaul as soon as the new reflection-based derive is done, but I'm not sure that's worth it, the derive stuff is coming along really nicely. (I'm currently consolidating the new codegen to be based around the desugaring and hir builder stuff used in the contract lowering and this is going going very well) |
|
C.c. @cburgdorf if you feel up for it take a look here? |
Summary
Gas optimizations for the ABI decode path and contract entry points. The builder now uses CTFE to query trait constants and emit specialized code per msg type.
These optimizations will need a total refactor once compile-time reflection + builder rework land (stay tuned).
Benchmark results (rosetta-fe, O2):
Changes
AbiSize::HEAD_SIZEandEncode::DIRECT_ENCODEvia CTFE. Static-only msg types get direct calldataload instead of malloc + decodeArrayView<T, N>,BytesView,StringViewwith default type params for zero-copy calldata access#[abi]attribute: auto-derivesAbiSize,Encode<Sol>,Decode<Sol>for user structsNotes
where { const_expr }) included but separate from the gas workusescapabilities), which is making good strides and will land separately