add metering for bulk memory opcodes#79
Conversation
There was a problem hiding this comment.
We need proper opcode check versioning.
I will be adding it soon.
There was a problem hiding this comment.
I do not think we need. Like we might add some opcodes once per year, there is no need for that. SpaceVM can treat activations, there is no need to complicated the executor.
There was a problem hiding this comment.
I do not think we need. Like we might add some opcodes once per year, there is no need for that. SpaceVM can treat activations, there is no need to complicated the executor.
There was a problem hiding this comment.
Pull request overview
This PR adds metering support for bulk memory operations (MemoryCopy and MemoryFill) in WebAssembly through an opcode versioning mechanism. The implementation introduces V2 opcodes that include these bulk memory operations, while V1 maintains backward compatibility.
Changes:
- Introduced opcode versioning (V1/V2) to enable/disable bulk memory operations
- Added metering for MemoryCopy and MemoryFill with per-byte cost calculation
- Refactored API from
set_opcode_costtoset_opcode_configto include version information
Reviewed changes
Copilot reviewed 25 out of 25 changed files in this pull request and generated 13 comments.
Show a summary per file
| File | Description |
|---|---|
| vm-executor/src/opcode_version.rs | New enum defining opcode versions V1 and V2 with i32 conversion |
| vm-executor/src/opcode_config.rs | New struct combining opcode version and cost configuration |
| vm-executor/src/opcode_cost.rs | Added cost fields for MemoryCopy and MemoryFill operations |
| vm-executor/src/lib.rs | Exported new OpcodeConfig and OpcodeVersion types |
| vm-executor/src/executor.rs | Updated trait method from set_opcode_cost to set_opcode_config |
| vm-executor-wasmer/src/wasmer_opcode_cost.rs | Split opcode cost logic into V1 and V2 functions, added bulk memory ops to V2 |
| vm-executor-wasmer/src/wasmer_metering.rs | Added bulk memory metering with size backup global and per-byte cost injection |
| vm-executor-wasmer/src/wasmer_helpers.rs | Added helper to identify supported bulk memory operators |
| vm-executor-wasmer/src/wasmer_instance.rs | Updated to use OpcodeConfig instead of OpcodeCost |
| vm-executor-wasmer/src/wasmer_executor.rs | Updated executor to use OpcodeConfig with improved error handling |
| vm-executor-wasmer/src/lib.rs | Renamed metering_helpers to opcode_cost module |
| vm-executor-wasmer/tests/opcode_version_test.rs | Added test verifying V1 rejects and V2 accepts bulk memory ops |
| vm-executor-wasmer/tests/memory-copy.wat | Test WAT file demonstrating memory.copy operation |
| vm-executor-wasmer/Cargo.toml | Added std feature to log dependency |
| vm-executor-experimental/* | Updated to use OpcodeConfig (but missing bulk memory metering) |
| c-api/src/capi_metering.rs | Updated C API to accept opcode version parameter |
| c-api/libvmexeccapi.h | Updated C header with new function signature |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| pub(crate) fn is_supported_bulk_memory_operator(operator: &Operator) -> bool { | ||
| matches!( | ||
| operator, | ||
| Operator::MemoryCopy { src: _, dst: _ } | Operator::MemoryFill { mem: _ } |
There was a problem hiding this comment.
The pattern matching uses named fields (src: _, dst: _) and (mem: _) which are unnecessary since the values are being ignored. This can be simplified to Operator::MemoryCopy { .. } and Operator::MemoryFill { .. } for better readability and consistency with other operator matches in the codebase.
| Operator::MemoryCopy { src: _, dst: _ } | Operator::MemoryFill { mem: _ } | |
| Operator::MemoryCopy { .. } | Operator::MemoryFill { .. } |
| #[derive(Debug, Clone, Copy, PartialEq, Eq)] | ||
| pub enum OpcodeVersion { | ||
| V1, | ||
| V2, | ||
| } | ||
|
|
||
| impl OpcodeVersion { |
There was a problem hiding this comment.
The OpcodeVersion enum and its from_i32 method lack documentation explaining what each version means and when each should be used. Consider adding doc comments to explain that V1 is the legacy version without bulk memory support, and V2 adds support for bulk memory operations like MemoryCopy and MemoryFill.
| #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| pub enum OpcodeVersion { | |
| V1, | |
| V2, | |
| } | |
| impl OpcodeVersion { | |
| #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| /// Specifies which set of opcodes should be used by the VM. | |
| /// | |
| /// `V1` is the legacy opcode set that does **not** include support for bulk | |
| /// memory operations. `V2` extends `V1` by adding bulk memory instructions, | |
| /// such as `MemoryCopy` and `MemoryFill`. | |
| pub enum OpcodeVersion { | |
| /// Legacy opcode set without bulk memory support. | |
| /// | |
| /// Use this for modules or environments that were compiled or designed | |
| /// before bulk memory operations were introduced, or when compatibility | |
| /// with older tooling is required. | |
| V1, | |
| /// Opcode set with bulk memory support. | |
| /// | |
| /// Use this for modules that rely on bulk memory operations like | |
| /// `MemoryCopy` and `MemoryFill`, or when targeting newer runtimes that | |
| /// support these instructions. | |
| V2, | |
| } | |
| impl OpcodeVersion { | |
| /// Converts a numeric opcode version identifier into an `OpcodeVersion`. | |
| /// | |
| /// * `0` maps to [`OpcodeVersion::V1`] (legacy, no bulk memory support). | |
| /// * `1` maps to [`OpcodeVersion::V2`] (with bulk memory operations). | |
| /// | |
| /// Any other value returns `None`. This is typically used when decoding | |
| /// opcode version information from serialized formats or external | |
| /// configuration that represent the version as an integer. |
| /// Sets the opcode costs for the given executor. | ||
| fn set_opcode_cost(&mut self, opcode_cost: &OpcodeCost) -> Result<(), ExecutorError>; | ||
| /// Sets the opcode version and costs for the given executor. | ||
| fn set_opcode_config(&mut self, opcode_config: OpcodeConfig) -> Result<(), ExecutorError>; |
There was a problem hiding this comment.
This is a breaking API change: the function signature changed from accepting a reference to OpcodeCost to accepting an owned OpcodeConfig. This will require all consumers of the ExecutorLegacy trait to update their code. Additionally, the function now takes ownership of the OpcodeConfig instead of borrowing it, which changes the calling patterns. Consider whether taking ownership is necessary or if a reference would be more ergonomic.
| fn set_opcode_config(&mut self, opcode_config: OpcodeConfig) -> Result<(), ExecutorError>; | |
| fn set_opcode_config(&mut self, opcode_config: &OpcodeConfig) -> Result<(), ExecutorError>; |
| pub unsafe extern "C" fn vm_exec_set_opcode_costs( | ||
| pub unsafe extern "C" fn vm_exec_set_opcode_config( | ||
| executor_ptr: *mut vm_exec_executor_t, | ||
| opcode_vesion_code: i32, |
There was a problem hiding this comment.
The parameter name has a typo: 'opcode_vesion_code' should be 'opcode_version_code'.
| value: cost_per_byte as i64, | ||
| }, | ||
| Operator::I64Mul, | ||
| // points user += memory size * price |
There was a problem hiding this comment.
The inject_bulk_memory_cost function assumes the size operand is on top of the stack. However, for MemoryCopy, the WASM stack order is [dst, src, size], and for MemoryFill it's [dst, val, size]. After backing up the size with GlobalSet, the function attempts to restore it with GlobalGet at the end. This is correct, but the comment on line 200 is misleading: it says 'points user +=' but the actual operation is 'points_used += memory_size * price', which should be reflected more clearly in the comment.
| with_service(|service| { | ||
| service.update_last_error_str(format!( | ||
| "invalid opcode version code: {}", | ||
| opcode_vesion_code |
There was a problem hiding this comment.
The parameter name has a typo: 'opcode_vesion_code' should be 'opcode_version_code'. This is used in the error message formatting.
| enum vm_exec_result_t vm_exec_set_opcode_costs(struct vm_exec_executor_t *executor_ptr, | ||
| const struct vm_exec_opcode_cost_t *opcode_cost_ptr); | ||
| enum vm_exec_result_t vm_exec_set_opcode_config(struct vm_exec_executor_t *executor_ptr, | ||
| int32_t opcode_vesion_code, |
There was a problem hiding this comment.
The parameter name has a typo: 'opcode_vesion_code' should be 'opcode_version_code'. This typo also affects the C header file.
| int32_t opcode_vesion_code, | |
| int32_t opcode_version_code, |
| match option { | ||
| Some(cost) if is_supported_bulk_memory_operator(&operator) => { | ||
| self.inject_bulk_memory_cost(state, cost); | ||
| // immediatly insert out of gas check as this operation might be expensive |
There was a problem hiding this comment.
Typo in the comment: 'immediatly' should be 'immediately'.
| // immediatly insert out of gas check as this operation might be expensive | |
| // immediately insert out of gas check as this operation might be expensive |
| // This needs to be done before the metering logic, to prevent operators like `Call` from escaping metering in some | ||
| // corner cases. | ||
| let option = get_opcode_cost(&operator, &self.opcode_cost); | ||
| let option = get_opcode_cost(&operator, &self.opcode_config); |
There was a problem hiding this comment.
The experimental executor's metering middleware does not include support for bulk memory operations (MemoryCopy and MemoryFill) that was added to the wasmer executor. This creates an inconsistency between the two implementations where bulk memory operations would not be properly metered in the experimental executor, even when using OpcodeVersion V2.
| let option = get_opcode_cost(&operator, &self.opcode_config); | |
| let option = match &operator { | |
| // Explicit support for bulk memory operations to keep metering consistent | |
| Operator::MemoryCopy { .. } => Some(self.opcode_config.opcode_memorycopy), | |
| Operator::MemoryFill { .. } => Some(self.opcode_config.opcode_memoryfill), | |
| _ => get_opcode_cost(&operator, &self.opcode_config), | |
| }; |
| #[test] | ||
| fn opcode_version_test() { | ||
| let wasm_bytes = wat2wasm(MEMORY_COPY_WAT).unwrap(); | ||
|
|
||
| let mut executor = WasmerExecutor::new(Box::new(VMHooksLegacyDefault)); | ||
| let result = executor.new_instance(&wasm_bytes, &DUMMY_COMPILATION_OPTIONS.to_legacy()); | ||
| assert!(result.is_err()); | ||
|
|
||
| executor | ||
| .set_opcode_config(OpcodeConfig { | ||
| opcode_version: OpcodeVersion::V2, | ||
| opcode_cost: OpcodeCost::default(), | ||
| }) | ||
| .unwrap(); | ||
|
|
||
| let result = executor.new_instance(&wasm_bytes, &DUMMY_COMPILATION_OPTIONS.to_legacy()); | ||
| assert!(result.is_ok()); | ||
| } |
There was a problem hiding this comment.
This test verifies that bulk memory operations are rejected with the default opcode version (V1) and accepted with V2, but it doesn't test that the metering for bulk memory operations actually works correctly. Consider adding a test that verifies the gas consumption for memory.copy and memory.fill operations to ensure the metering implementation is correct.
The implementation is mostly complete, but it needs gas testing.
Also contains opcode versioning.