Skip to content

solidity: write struct fields directly into result memory on deserialize#97

Draft
deuszx wants to merge 1 commit into
zefchain:mainfrom
deuszx:solidity-struct-deser-direct
Draft

solidity: write struct fields directly into result memory on deserialize#97
deuszx wants to merge 1 commit into
zefchain:mainfrom
deuszx:solidity-struct-deser-direct

Conversation

@deuszx
Copy link
Copy Markdown
Contributor

@deuszx deuszx commented May 14, 2026

Summary

bcs_deserialize_offset_<Struct> previously held every decoded field as a stack local until the final Struct(field0, field1, ...) constructor. At ~2 stack slots per field (the value plus the second slot of the (new_pos, value) tuple return) Yul ran out of the 16-slot budget when these helpers were inlined into wider call graphs. The current linera-bridge workaround is a build.rs shim that rewrites every assembly { ... } block in the generated file to assembly ("memory-safe") { ... } so Yul's optimizer is allowed to spill across them.

Switch the generated code to declare the return value with a named binding (returns (uint256, <Struct> memory result)) and write each field through result.<field> as it is decoded. The only persistent locals are now pos, new_pos, and the result pointer — stack footprint is O(1) regardless of field count, and the final Struct(...) constructor call (which previously memcpy'd N locals into a freshly allocated struct) disappears.

Validation against linera-protocol#6294:

  • Before this change, with the in-tree codegen output and the memory-safe annotations stripped, forge build on linera-bridge/src/solidity fails with "Cannot swap Variable var_input_2223_mpos … too deep in the stack by 1 slots" inside the inlined verifyCertificate deserializer chain.
  • After regenerating BridgeTypes.sol from the same registry snapshot using this commit's codegen — still without any memory-safe annotations — forge build succeeds. The mark_assembly_memory_safe shim in linera-bridge/build.rs becomes obsolete.

Measured on linera-bridge after regen (via_ir, optimizer_runs=1, solc 0.8.30, evm_version=cancun):

Metric Old + shim New (no shim) Delta
LightClient runtime bytecode 23 296 B 22 725 B -571 B
EIP-170 headroom for LightClient 1 280 B 1 851 B +571 B
LightClient deployment size 24 785 B 24 214 B -571 B
FungibleBridge runtime bytecode 10 987 B 10 958 B -29 B
DeployLightClient script gas 10 838 349 10 600 945 -237 K

Synthetic gas/bytecode measurement on a 16x Inner struct (Inner = { String, u64, u128, Vec }, via_ir = true, optimizer_runs = 200, solc 0.8.33):

  • deserialize gas: 217 224 -> 210 076 (-7 148, -3.3 %)
  • harness bytecode: 3 088 B -> 2 759 B (-329 B, -11 %)
  • deployment gas: 715 222 -> 644 074 (-71 K, -10 %)

Test Plan

New test test_wide_struct_deserialize_round_trip was added.

`bcs_deserialize_offset_<Struct>` previously held every decoded field
as a stack local until the final `Struct(field0, field1, ...)`
constructor. At ~2 stack slots per field (the value plus the second
slot of the `(new_pos, value)` tuple return) Yul ran out of the
16-slot budget when these helpers were inlined into wider call
graphs. The current `linera-bridge` workaround is a `build.rs` shim
that rewrites every `assembly { ... }` block in the generated file
to `assembly ("memory-safe") { ... }` so Yul's optimizer is allowed
to spill across them.

Switch the generated code to declare the return value with a named
binding (`returns (uint256, <Struct> memory result)`) and write each
field through `result.<field>` as it is decoded. The only persistent
locals are now `pos`, `new_pos`, and the `result` pointer — stack
footprint is O(1) regardless of field count, and the final
`Struct(...)` constructor call (which previously memcpy'd N locals
into a freshly allocated struct) disappears.

Validation against linera-protocol#6294:

* Before this change, with the in-tree codegen output and the
  `memory-safe` annotations stripped, `forge build` on
  `linera-bridge/src/solidity` fails with
  "Cannot swap Variable var_input_2223_mpos … too deep in the stack
  by 1 slots" inside the inlined `verifyCertificate` deserializer
  chain.
* After regenerating `BridgeTypes.sol` from the same registry
  snapshot using this commit's codegen — still without any
  `memory-safe` annotations — `forge build` succeeds. The
  `mark_assembly_memory_safe` shim in `linera-bridge/build.rs`
  becomes obsolete.

Measured on linera-bridge after regen (via_ir, optimizer_runs=1,
solc 0.8.30, evm_version=cancun):

  Metric                              | Old + shim  | New (no shim) | Delta
  ------------------------------------|-------------|---------------|--------
  LightClient runtime bytecode        |   23 296 B  |   22 725 B    | -571 B
  EIP-170 headroom for LightClient    |    1 280 B  |    1 851 B    | +571 B
  LightClient deployment size         |   24 785 B  |   24 214 B    | -571 B
  FungibleBridge runtime bytecode     |   10 987 B  |   10 958 B    |  -29 B
  DeployLightClient script gas        | 10 838 349  | 10 600 945    | -237 K

Synthetic gas/bytecode measurement on a 16x Inner struct
(Inner = { String, u64, u128, Vec<u32> }, via_ir = true,
optimizer_runs = 200, solc 0.8.33):

  * deserialize gas: 217 224 -> 210 076 (-7 148, -3.3 %)
  * harness bytecode:  3 088 B -> 2 759 B (-329 B, -11 %)
  * deployment gas:  715 222   -> 644 074  (-71 K, -10 %)
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.

1 participant