diff --git a/Cargo.lock b/Cargo.lock index c77d77bc5d9..64d3ba6413f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4099,6 +4099,7 @@ dependencies = [ name = "aptos-table-natives" version = "0.1.0" dependencies = [ + "aptos-gas-algebra", "aptos-gas-schedule", "aptos-native-interface", "aptos-types", diff --git a/aptos-move/aptos-gas-meter/src/meter.rs b/aptos-move/aptos-gas-meter/src/meter.rs index 379a2d39108..8f1a7a0ad9c 100644 --- a/aptos-move/aptos-gas-meter/src/meter.rs +++ b/aptos-move/aptos-gas-meter/src/meter.rs @@ -6,6 +6,7 @@ use aptos_gas_algebra::{Fee, FeePerGasUnit, NumTypeNodes}; use aptos_gas_schedule::{ gas_feature_versions::*, gas_params::{instr::*, txn::*}, + value_graph_walk_cost, }; use aptos_types::{ contract_event::ContractEvent, state_store::state_key::StateKey, write_set::WriteOpSize, @@ -31,6 +32,10 @@ use move_vm_types::{ /// consisting all the gas parameters, which it can lookup when performing gas calculations. pub struct StandardGasMeter { algebra: A, + /// When set, a cache-miss resource load bills for the deserialized graph. + /// Off by default so existing `new()` call sites keep historical costs + /// until the VM opts in via [`AptosGasMeter::enable_value_graph_load_billing`]. + bill_value_graph_on_load: bool, } impl StandardGasMeter @@ -38,7 +43,10 @@ where A: GasAlgebra, { pub fn new(algebra: A) -> Self { - Self { algebra } + Self { + algebra, + bill_value_graph_on_load: false, + } } pub fn feature_version(&self) -> u64 { @@ -216,6 +224,21 @@ where if self.feature_version() <= 8 && val.is_none() && bytes_loaded != 0.into() { return Err(PartialVMError::new(StatusCode::UNKNOWN_INVARIANT_VIOLATION_ERROR).with_message("in legacy versions, number of bytes loaded must be zero when the resource does not exist ".to_string())); } + // Interpreter `charge_load_resource` runs on a cache miss: the value + // was just deserialized. IO gas below tracks stored bytes only; bill + // the graph walk so a compact blob cannot materialize a huge value + // for free. Cache hits never enter this callback. + if self.bill_value_graph_on_load { + if let Some(loaded) = &val { + let graph_size = self + .vm_gas_params() + .misc + .abs_val + .abstract_value_size(loaded, self.feature_version())?; + self.algebra + .charge_execution(value_graph_walk_cost(graph_size))?; + } + } let cost = self .io_pricing() .calculate_read_gas(val.is_some(), bytes_loaded); @@ -599,4 +622,101 @@ where .charge_execution(KEYLESS_BASE_COST) .map_err(|e| e.finish(Location::Undefined)) } + + fn enable_value_graph_load_billing(&mut self, enabled: bool) { + self.bill_value_graph_on_load = enabled; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{traits::GasAlgebra, AptosGasMeter, StandardGasAlgebra}; + use aptos_gas_schedule::{ + value_graph_walk_cost, InitialGasSchedule, VMGasParameters, LATEST_GAS_FEATURE_VERSION, + }; + use aptos_vm_types::{ + resolver::NoopBlockSynchronizationKillSwitch, storage::StorageGasParameters, + }; + use move_core_types::{ + account_address::AccountAddress, + gas_algebra::{InternalGas, NumBytes}, + language_storage::TypeTag, + }; + use move_vm_types::{gas::GasMeter, values::Value, views::TypeView}; + + struct DummyType; + + impl TypeView for DummyType { + fn to_type_tag(&self) -> TypeTag { + TypeTag::Bool + } + } + + fn execution_after_load(bill: bool, val: &Value) -> InternalGas { + let kill = NoopBlockSynchronizationKillSwitch {}; + let mut meter = StandardGasMeter::new(StandardGasAlgebra::new( + LATEST_GAS_FEATURE_VERSION, + VMGasParameters::initial(), + StorageGasParameters::latest(), + false, + 10_000_000u64, + &kill, + )); + meter.enable_value_graph_load_billing(bill); + meter + .charge_load_resource(AccountAddress::ZERO, DummyType, Some(val), NumBytes::new(8)) + .expect("load charge must succeed"); + meter.algebra().execution_gas_used() + } + + #[test] + fn resource_load_is_free_to_walk_the_graph_until_billing_is_enabled() { + let blob = Value::vector_u8((0u8..200).collect::>()); + let off = execution_after_load(false, &blob); + assert_eq!( + u64::from(off), + 0, + "IO gas is not execution gas; the walk must stay unbilled when the flag is off" + ); + } + + #[test] + fn resource_load_bills_execution_gas_for_the_deserialized_graph() { + let blob = Value::vector_u8((0u8..200).collect::>()); + let on = execution_after_load(true, &blob); + let expected = { + let size = VMGasParameters::initial() + .misc + .abs_val + .abstract_value_size(&blob, LATEST_GAS_FEATURE_VERSION) + .expect("size"); + value_graph_walk_cost(size) + }; + assert_eq!(on, expected); + assert!(u64::from(on) > 1_101); + } + + #[test] + fn missing_resource_does_not_pay_a_walk_charge() { + let kill = NoopBlockSynchronizationKillSwitch {}; + let mut meter = StandardGasMeter::new(StandardGasAlgebra::new( + LATEST_GAS_FEATURE_VERSION, + VMGasParameters::initial(), + StorageGasParameters::latest(), + false, + 10_000_000u64, + &kill, + )); + meter.enable_value_graph_load_billing(true); + meter + .charge_load_resource( + AccountAddress::ZERO, + DummyType, + Option::::None, + NumBytes::new(0), + ) + .expect("absent resource is billable for IO only"); + assert_eq!(u64::from(meter.algebra().execution_gas_used()), 0); + } } diff --git a/aptos-move/aptos-gas-meter/src/traits.rs b/aptos-move/aptos-gas-meter/src/traits.rs index a18e66127d5..e39df5108a3 100644 --- a/aptos-move/aptos-gas-meter/src/traits.rs +++ b/aptos-move/aptos-gas-meter/src/traits.rs @@ -263,4 +263,13 @@ pub trait AptosGasMeter: MoveGasMeter { .inject_balance(extra_balance) .map_err(|e| e.finish(Location::Undefined)) } + + /// When enabled, resource loads also bill for walking the deserialized + /// value graph (CPU work that blob-length IO gas does not capture). + /// + /// Default is a no-op so wrappers that do not track the flag stay + /// backward compatible. Production meters override this. + fn enable_value_graph_load_billing(&mut self, enabled: bool) { + let _ = enabled; + } } diff --git a/aptos-move/aptos-gas-profiling/src/profiler.rs b/aptos-move/aptos-gas-profiling/src/profiler.rs index 87433532c79..a78b5ac492f 100644 --- a/aptos-move/aptos-gas-profiling/src/profiler.rs +++ b/aptos-move/aptos-gas-profiling/src/profiler.rs @@ -575,6 +575,10 @@ where ) -> PartialVMResult<()>; } + fn enable_value_graph_load_billing(&mut self, enabled: bool) { + self.base.enable_value_graph_load_billing(enabled); + } + fn charge_io_gas_for_transaction(&mut self, txn_size: NumBytes) -> VMResult<()> { let (cost, res) = self.delegate_charge(|base| base.charge_io_gas_for_transaction(txn_size)); diff --git a/aptos-move/aptos-gas-schedule/src/lib.rs b/aptos-move/aptos-gas-schedule/src/lib.rs index 93156c25266..a6d7371bef4 100644 --- a/aptos-move/aptos-gas-schedule/src/lib.rs +++ b/aptos-move/aptos-gas-schedule/src/lib.rs @@ -28,8 +28,10 @@ mod gas_schedule; mod traits; +mod value_graph; mod ver; pub use gas_schedule::*; pub use traits::{FromOnChainGasSchedule, InitialGasSchedule, ToOnChainGasSchedule}; +pub use value_graph::value_graph_walk_cost; pub use ver::{gas_feature_versions, LATEST_GAS_FEATURE_VERSION}; diff --git a/aptos-move/aptos-gas-schedule/src/value_graph.rs b/aptos-move/aptos-gas-schedule/src/value_graph.rs new file mode 100644 index 00000000000..7360f358627 --- /dev/null +++ b/aptos-move/aptos-gas-schedule/src/value_graph.rs @@ -0,0 +1,59 @@ +// Copyright © Aptos Foundation +// SPDX-License-Identifier: Apache-2.0 + +//! Pricing for walking an already-materialized Move value. +//! +//! BCS blobs and storage items can expand into a much larger in-memory node +//! graph than their byte length suggests. Charging only for those bytes leaves +//! a free-amplification hole: a cheap load or `from_bytes` can produce a huge +//! graph. The walk billed here is the same class of work as `cmp::compare` +//! (visit every node). We price it at three times that native so a traversal +//! cannot undercut a comparison of the same value. +//! +//! The multipliers are derived from the current `move_stdlib.cmp.compare` +//! schedule (`base = 367`, `per_abs_val_unit = 14`). They are not on-chain +//! parameters yet; a later gas-schedule version can promote them. + +use aptos_gas_algebra::{AbstractValueSize, InternalGas, InternalGasPerAbstractValueUnit}; + +/// Three times `move_stdlib.cmp.compare.base`. +const WALK_BASE: InternalGas = InternalGas::new(1_101); + +/// Three times `move_stdlib.cmp.compare.per_abs_val_unit`. +const WALK_PER_ABS_UNIT: InternalGasPerAbstractValueUnit = InternalGasPerAbstractValueUnit::new(42); + +/// Execution gas for visiting `abstract_units` of a value graph. +/// +/// Addition and multiplication saturate, so a huge graph over-charges rather +/// than wrapping to a cheap cost. +pub fn value_graph_walk_cost(abstract_units: AbstractValueSize) -> InternalGas { + WALK_BASE + WALK_PER_ABS_UNIT * abstract_units +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_graph_still_pays_the_base() { + let cost = value_graph_walk_cost(AbstractValueSize::new(0)); + assert_eq!(u64::from(cost), 1_101); + } + + #[test] + fn cost_grows_linearly_with_abstract_size() { + let small = value_graph_walk_cost(AbstractValueSize::new(10)); + let large = value_graph_walk_cost(AbstractValueSize::new(1_000)); + assert!(u64::from(large) > u64::from(small) * 20); + assert_eq!(u64::from(small), 1_101 + 42 * 10); + assert_eq!(u64::from(large), 1_101 + 42 * 1_000); + } + + #[test] + fn saturated_product_does_not_wrap_to_a_cheap_cost() { + // u64::MAX abstract units * 42 saturates; the billed amount must stay + // enormous rather than wrapping toward zero. + let cost = value_graph_walk_cost(AbstractValueSize::new(u64::MAX)); + assert_eq!(u64::from(cost), u64::MAX); + } +} diff --git a/aptos-move/aptos-memory-usage-tracker/src/lib.rs b/aptos-move/aptos-memory-usage-tracker/src/lib.rs index d8e4adb74b4..bef02ccd585 100644 --- a/aptos-move/aptos-memory-usage-tracker/src/lib.rs +++ b/aptos-move/aptos-memory-usage-tracker/src/lib.rs @@ -562,4 +562,8 @@ where fn charge_keyless(&mut self) -> VMResult<()>; } + + fn enable_value_graph_load_billing(&mut self, enabled: bool) { + self.base.enable_value_graph_load_billing(enabled); + } } diff --git a/aptos-move/aptos-native-interface/src/context.rs b/aptos-move/aptos-native-interface/src/context.rs index 606799a9064..b28747d10aa 100644 --- a/aptos-move/aptos-native-interface/src/context.rs +++ b/aptos-move/aptos-native-interface/src/context.rs @@ -6,13 +6,14 @@ use aptos_gas_algebra::{ AbstractValueSize, DynamicExpression, GasExpression, GasQuantity, InternalGasUnit, }; use aptos_gas_schedule::{ - gas_feature_versions::RELEASE_V1_32, AbstractValueSizeGasParameters, MiscGasParameters, - NativeGasParameters, + gas_feature_versions::RELEASE_V1_32, value_graph_walk_cost, AbstractValueSizeGasParameters, + MiscGasParameters, NativeGasParameters, }; use aptos_types::on_chain_config::{Features, TimedFeatureFlag, TimedFeatures}; use move_binary_format::errors::{PartialVMResult, VMResult}; use move_core_types::{ - gas_algebra::InternalGas, identifier::Identifier, language_storage::ModuleId, + account_address::AccountAddress, gas_algebra::InternalGas, identifier::Identifier, + language_storage::ModuleId, }; use move_vm_runtime::{native_functions::NativeContext, Function}; use move_vm_types::values::Value; @@ -128,6 +129,44 @@ impl SafeNativeContext<'_, '_, '_, '_> { .abstract_value_size(val, self.gas_feature_version) } + /// Heap size and abstract graph size of a resource already in the + /// session cache. `None` if the slot is empty (deleted / never stored). + /// + /// Field borrows stay disjoint: layout parameters are immutable while + /// the session cache is read through `inner`. + pub fn session_cached_resource_graph_sizes( + &mut self, + address: AccountAddress, + ty: &move_vm_types::loaded_data::runtime_types::Type, + ) -> PartialVMResult> { + let version = self.gas_feature_version; + let params = &self.misc_gas_params.abs_val; + let gv = self.inner.session_cached_resource(address, ty)?; + gv.view() + .map(|val| { + let heap = params.abstract_heap_size(&val, version)?; + let graph = params.abstract_value_size(&val, version)?; + Ok((u64::from(heap), graph)) + }) + .transpose() + } + + /// Bills execution gas for walking a materialized value graph, but only + /// when [`TimedFeatureFlag::MeterValueNodesOnDeserialize`] is on. + /// + /// Used after deserialize (and for serialize-side BCS walks). A caller + /// that already knows the flag is off should skip the size computation. + #[must_use = "must always propagate the error returned by this function to the native function that called it using the ? operator"] + pub fn bill_value_graph_walk( + &mut self, + abstract_units: AbstractValueSize, + ) -> SafeNativeResult<()> { + if !self.timed_feature_enabled(TimedFeatureFlag::MeterValueNodesOnDeserialize) { + return Ok(()); + } + self.charge(value_graph_walk_cost(abstract_units)) + } + /// Computes the abstract size of the input value. pub fn abs_val_size_dereferenced(&self, val: &Value) -> PartialVMResult { self.misc_gas_params diff --git a/aptos-move/aptos-vm/src/aptos_vm.rs b/aptos-move/aptos-vm/src/aptos_vm.rs index bbfa49b4703..fcff72a3806 100644 --- a/aptos-move/aptos-vm/src/aptos_vm.rs +++ b/aptos-move/aptos-vm/src/aptos_vm.rs @@ -1958,6 +1958,10 @@ impl AptosVM { initial_balance, code_storage, ); + gas_meter.enable_value_graph_load_billing( + self.timed_features() + .is_enabled(TimedFeatureFlag::MeterValueNodesOnDeserialize), + ); let (status, output) = self.execute_user_transaction_impl( resolver, @@ -2352,6 +2356,10 @@ impl AptosVM { max_gas_amount.into(), &NoopBlockSynchronizationKillSwitch {}, ); + gas_meter.enable_value_graph_load_billing( + vm.timed_features() + .is_enabled(TimedFeatureFlag::MeterValueNodesOnDeserialize), + ); let resolver = state_view.as_move_resolver(); let module_storage = state_view.as_aptos_code_storage(&env); @@ -2858,6 +2866,10 @@ impl VMValidator for AptosVM { initial_balance, &NoopBlockSynchronizationKillSwitch {}, ); + gas_meter.enable_value_graph_load_billing( + self.timed_features() + .is_enabled(TimedFeatureFlag::MeterValueNodesOnDeserialize), + ); let storage = TraversalStorage::new(); // Increment the counter for transactions verified. diff --git a/aptos-move/aptos-vm/src/testing.rs b/aptos-move/aptos-vm/src/testing.rs index 8ac4539510d..bac5be854bb 100644 --- a/aptos-move/aptos-vm/src/testing.rs +++ b/aptos-move/aptos-vm/src/testing.rs @@ -78,6 +78,7 @@ impl AptosVM { gas_meter_balance: u64, ) -> (VMStatus, VMOutput) { use crate::gas::make_prod_gas_meter; + use aptos_gas_meter::AptosGasMeter; use move_vm_runtime::module_traversal::{TraversalContext, TraversalStorage}; let txn_data = TransactionMetadata::new(txn, self.timed_features()); @@ -101,6 +102,9 @@ impl AptosVM { gas_meter_balance.into(), &NoopBlockSynchronizationKillSwitch {}, ); + gas_meter.enable_value_graph_load_billing(self.timed_features().is_enabled( + aptos_types::on_chain_config::TimedFeatureFlag::MeterValueNodesOnDeserialize, + )); let change_set_configs = &self .storage_gas_params(&log_context) diff --git a/aptos-move/e2e-move-tests/src/tests/mod.rs b/aptos-move/e2e-move-tests/src/tests/mod.rs index 9ea95e79242..3be7b588525 100644 --- a/aptos-move/e2e-move-tests/src/tests/mod.rs +++ b/aptos-move/e2e-move-tests/src/tests/mod.rs @@ -64,5 +64,6 @@ mod token_objects; mod transaction_context; mod type_too_large; mod upgrade_compatibility; +mod value_graph_gas; mod vector_numeric_address; mod vm; diff --git a/aptos-move/e2e-move-tests/src/tests/value_graph_gas.rs b/aptos-move/e2e-move-tests/src/tests/value_graph_gas.rs new file mode 100644 index 00000000000..10e97c57690 --- /dev/null +++ b/aptos-move/e2e-move-tests/src/tests/value_graph_gas.rs @@ -0,0 +1,330 @@ +// Copyright © Aptos Foundation +// SPDX-License-Identifier: Apache-2.0 + +//! Clean-room coverage for value-graph gas on deserialize and serialize. +//! +//! Invariant: materializing or walking a Move value must not be free relative +//! to the node count of that value. A compact BCS blob / stored item that +//! expands into many nodes has to cost more once +//! `MeterValueNodesOnDeserialize` is on. + +use crate::{assert_success, tests::common, MoveHarness}; +use aptos_framework::BuildOptions; +use aptos_language_e2e_tests::account::Account; +use aptos_package_builder::PackageBuilder; +use aptos_types::{account_address::AccountAddress, move_utils::MemberId}; +use std::str::FromStr; + +/// Elements in the bushy vector. Each `Cell` is a short wrapper chain +/// ending in four `u64`s, so a few hundred input bytes become thousands +/// of value nodes. +const BUSHY_LEN: u64 = 80; +const UNPACK_ITERS: u64 = 40; +const TABLE_KEYS: u64 = 8; + +fn bushy_module_source() -> String { + // Wrapper chain of length 8 (not a 120-deep single-field spine). Each + // leaf holds four integers so the graph is wide as well as nested. + r#" +module 0xcafe::bushy { + use std::vector; + use std::bcs; + use aptos_std::copyable_any; + use aptos_std::table::{Self, Table}; + + struct Leaf has copy, drop, store { a: u64, b: u64, c: u64, d: u64 } + struct W1 has copy, drop, store { x: Leaf } + struct W2 has copy, drop, store { x: W1 } + struct W3 has copy, drop, store { x: W2 } + struct W4 has copy, drop, store { x: W3 } + struct W5 has copy, drop, store { x: W4 } + struct W6 has copy, drop, store { x: W5 } + struct W7 has copy, drop, store { x: W6 } + struct Cell has copy, drop, store { x: W7 } + + struct Vault has key { cells: vector } + struct Packed has key { item: copyable_any::Any } + struct Shelf has key { items: Table> } + + fun one_cell(): Cell { + let leaf = Leaf { a: 1, b: 2, c: 3, d: 4 }; + let w1 = W1 { x: leaf }; + let w2 = W2 { x: w1 }; + let w3 = W3 { x: w2 }; + let w4 = W4 { x: w3 }; + let w5 = W5 { x: w4 }; + let w6 = W6 { x: w5 }; + let w7 = W7 { x: w6 }; + Cell { x: w7 } + } + + fun grow(n: u64): vector { + let out = vector::empty(); + let i = 0; + while (i < n) { + vector::push_back(&mut out, one_cell()); + i = i + 1; + }; + out + } + + public entry fun stash(s: &signer, n: u64) { + move_to(s, Vault { cells: grow(n) }); + } + + public entry fun peek(addr: address) acquires Vault { + let v = borrow_global(addr); + let _n = vector::length(&v.cells); + } + + public entry fun stash_packed(s: &signer, n: u64) { + move_to(s, Packed { item: copyable_any::pack(grow(n)) }); + } + + public entry fun unpack_packed(addr: address) acquires Packed { + let Packed { item } = move_from(addr); + let _cells = copyable_any::unpack>(item); + } + + public entry fun unpack_loop(n: u64, rounds: u64) { + let packed = copyable_any::pack(grow(n)); + let i = 0; + while (i < rounds) { + let _cells = copyable_any::unpack>(copy packed); + i = i + 1; + }; + } + + public entry fun serialize_loop(n: u64, rounds: u64) { + let cells = grow(n); + let i = 0; + while (i < rounds) { + let _bytes = bcs::to_bytes(&cells); + i = i + 1; + }; + } + + public entry fun stash_shelf(s: &signer, n: u64, keys: u64) { + let items = table::new>(); + let k = 0; + while (k < keys) { + table::add(&mut items, k, grow(n)); + k = k + 1; + }; + move_to(s, Shelf { items }); + } + + public entry fun peek_shelf(addr: address, keys: u64) acquires Shelf { + let shelf = borrow_global(addr); + let k = 0; + while (k < keys) { + let _n = vector::length(table::borrow(&shelf.items, k)); + k = k + 1; + }; + } +} +"# + .to_string() +} + +fn publish_bushy(h: &mut MoveHarness, acc: &Account) { + let mut builder = PackageBuilder::new("bushy"); + builder.add_source("bushy", &bushy_module_source()); + builder.add_local_dep( + "AptosStdlib", + &common::framework_dir_path("aptos-stdlib").to_string_lossy(), + ); + builder.add_local_dep( + "MoveStdlib", + &common::framework_dir_path("move-stdlib").to_string_lossy(), + ); + let dir = builder.write_to_temp().unwrap(); + assert_success!(h.publish_package_with_options( + acc, + dir.path(), + BuildOptions::move_2().set_latest_language(), + )); +} + +fn entry(name: &str) -> MemberId { + MemberId::from_str(&format!("0xcafe::bushy::{name}")).unwrap() +} + +/// Two epochs (four hours) crosses the three-hour testing activation. +fn enable_value_graph_billing(h: &mut MoveHarness) { + h.new_epoch(); + h.new_epoch(); +} + +fn u64_arg(v: u64) -> Vec { + bcs::to_bytes(&v).unwrap() +} + +fn addr_arg(addr: &AccountAddress) -> Vec { + bcs::to_bytes(addr).unwrap() +} + +#[test] +fn from_bytes_pays_for_the_value_graph() { + let mut h = MoveHarness::new(); + h.modify_gas_schedule(|params| { + params.vm.txn.max_execution_gas = 40_000_000_000.into(); + }); + let acc = h.new_account_at(AccountAddress::from_hex_literal("0xcafe").unwrap()); + publish_bushy(&mut h, &acc); + + let args = vec![u64_arg(BUSHY_LEN), u64_arg(UNPACK_ITERS)]; + let gas_off = h.evaluate_entry_function_gas(&acc, entry("unpack_loop"), vec![], args.clone()); + enable_value_graph_billing(&mut h); + let gas_on = h.evaluate_entry_function_gas(&acc, entry("unpack_loop"), vec![], args); + + assert!( + gas_on > gas_off, + "from_bytes must not stay free of graph work: on={gas_on} off={gas_off}" + ); + assert!( + gas_on > gas_off + 20, + "repeated unpack should make the walk dominate: on={gas_on} off={gas_off}" + ); +} + +#[test] +fn serialize_pays_for_walking_the_value() { + let mut h = MoveHarness::new(); + h.modify_gas_schedule(|params| { + params.vm.txn.max_execution_gas = 40_000_000_000.into(); + }); + let acc = h.new_account_at(AccountAddress::from_hex_literal("0xcafe").unwrap()); + publish_bushy(&mut h, &acc); + + let args = vec![u64_arg(BUSHY_LEN), u64_arg(UNPACK_ITERS)]; + let gas_off = + h.evaluate_entry_function_gas(&acc, entry("serialize_loop"), vec![], args.clone()); + enable_value_graph_billing(&mut h); + let gas_on = h.evaluate_entry_function_gas(&acc, entry("serialize_loop"), vec![], args); + + assert!( + gas_on > gas_off, + "to_bytes must not walk the graph for free: on={gas_on} off={gas_off}" + ); + assert!( + gas_on > gas_off + 20, + "repeated to_bytes should make the walk dominate: on={gas_on} off={gas_off}" + ); +} + +#[test] +fn borrow_global_pays_for_the_deserialized_resource() { + let mut h = MoveHarness::new(); + h.modify_gas_schedule(|params| { + params.vm.txn.max_execution_gas = 40_000_000_000.into(); + }); + let acc = h.new_account_at(AccountAddress::from_hex_literal("0xcafe").unwrap()); + publish_bushy(&mut h, &acc); + + assert_success!(h.run_entry_function(&acc, entry("stash"), vec![], vec![u64_arg(BUSHY_LEN)])); + + let args = vec![addr_arg(acc.address())]; + let gas_off = h.evaluate_entry_function_gas(&acc, entry("peek"), vec![], args.clone()); + enable_value_graph_billing(&mut h); + let gas_on = h.evaluate_entry_function_gas(&acc, entry("peek"), vec![], args); + + assert!( + gas_on > gas_off, + "resource load must bill the graph: on={gas_on} off={gas_off}" + ); +} + +#[test] +fn large_resource_still_loads_when_the_flag_is_on() { + let mut h = MoveHarness::new(); + h.modify_gas_schedule(|params| { + params.vm.txn.max_execution_gas = 40_000_000_000.into(); + }); + let acc = h.new_account_at(AccountAddress::from_hex_literal("0xcafe").unwrap()); + publish_bushy(&mut h, &acc); + + assert_success!(h.run_entry_function(&acc, entry("stash"), vec![], vec![u64_arg(BUSHY_LEN)])); + enable_value_graph_billing(&mut h); + assert_success!(h.run_entry_function( + &acc, + entry("peek"), + vec![], + vec![addr_arg(acc.address())], + )); +} + +#[test] +fn table_borrow_pays_for_each_fresh_deserialize() { + let mut h = MoveHarness::new(); + h.modify_gas_schedule(|params| { + params.vm.txn.max_execution_gas = 40_000_000_000.into(); + }); + let acc = h.new_account_at(AccountAddress::from_hex_literal("0xcafe").unwrap()); + publish_bushy(&mut h, &acc); + + assert_success!(h.run_entry_function( + &acc, + entry("stash_shelf"), + vec![], + vec![u64_arg(BUSHY_LEN), u64_arg(TABLE_KEYS)], + )); + + let args = vec![addr_arg(acc.address()), u64_arg(TABLE_KEYS)]; + let gas_off = h.evaluate_entry_function_gas(&acc, entry("peek_shelf"), vec![], args.clone()); + enable_value_graph_billing(&mut h); + let gas_on = h.evaluate_entry_function_gas(&acc, entry("peek_shelf"), vec![], args); + + assert!( + gas_on > gas_off, + "table deserialize must bill the graph: on={gas_on} off={gas_off}" + ); +} + +#[test] +fn large_table_value_still_loads_when_the_flag_is_on() { + let mut h = MoveHarness::new(); + h.modify_gas_schedule(|params| { + params.vm.txn.max_execution_gas = 40_000_000_000.into(); + }); + let acc = h.new_account_at(AccountAddress::from_hex_literal("0xcafe").unwrap()); + publish_bushy(&mut h, &acc); + + assert_success!(h.run_entry_function( + &acc, + entry("stash_shelf"), + vec![], + vec![u64_arg(BUSHY_LEN), u64_arg(1)], + )); + enable_value_graph_billing(&mut h); + assert_success!(h.run_entry_function( + &acc, + entry("peek_shelf"), + vec![], + vec![addr_arg(acc.address()), u64_arg(1)], + )); +} + +#[test] +fn unpack_is_charged_not_rejected() { + let mut h = MoveHarness::new(); + h.modify_gas_schedule(|params| { + params.vm.txn.max_execution_gas = 40_000_000_000.into(); + }); + let acc = h.new_account_at(AccountAddress::from_hex_literal("0xcafe").unwrap()); + publish_bushy(&mut h, &acc); + + assert_success!(h.run_entry_function( + &acc, + entry("stash_packed"), + vec![], + vec![u64_arg(BUSHY_LEN)], + )); + enable_value_graph_billing(&mut h); + assert_success!(h.run_entry_function( + &acc, + entry("unpack_packed"), + vec![], + vec![addr_arg(acc.address())], + )); +} diff --git a/aptos-move/framework/move-stdlib/src/natives/bcs.rs b/aptos-move/framework/move-stdlib/src/natives/bcs.rs index 91c5616446a..a048c10ed76 100644 --- a/aptos-move/framework/move-stdlib/src/natives/bcs.rs +++ b/aptos-move/framework/move-stdlib/src/natives/bcs.rs @@ -10,6 +10,7 @@ use aptos_native_interface::{ safely_pop_arg, RawSafeNative, SafeNativeBuilder, SafeNativeContext, SafeNativeError, SafeNativeResult, }; +use aptos_types::on_chain_config::TimedFeatureFlag; use move_core_types::{ account_address::AccountAddress, gas_algebra::{NumBytes, NumTypeNodes}, @@ -69,6 +70,13 @@ fn native_to_bytes( // implement it in a more efficient way. let val = ref_to_val.read_ref()?; + // Serialization walks the whole value. Output-byte gas alone lets a + // wide graph with a tiny BCS encoding run almost free. + if context.timed_feature_enabled(TimedFeatureFlag::MeterValueNodesOnDeserialize) { + let graph_size = context.abs_val_size_dereferenced(&val)?; + context.bill_value_graph_walk(graph_size)?; + } + let function_value_extension = context.function_value_extension(); let max_value_nest_depth = context.max_value_nest_depth(); let serialized_value = match ValueSerDeContext::new(max_value_nest_depth) @@ -111,8 +119,15 @@ fn native_serialized_size( let reference = safely_pop_arg!(args, Reference); let ty = ty_args.pop().unwrap(); + // TODO(#14175): Reading the reference performs a deep copy, and we can + // implement it in a more efficient way. + let value = reference.read_ref()?; + if context.timed_feature_enabled(TimedFeatureFlag::MeterValueNodesOnDeserialize) { + let graph_size = context.abs_val_size_dereferenced(&value)?; + context.bill_value_graph_walk(graph_size)?; + } - let serialized_size = match serialized_size_impl(context, reference, &ty) { + let serialized_size = match serialized_size_impl(context, &value, &ty) { Ok(serialized_size) => serialized_size as u64, Err(_) => { context.charge(BCS_SERIALIZED_SIZE_FAILURE)?; @@ -130,12 +145,9 @@ fn native_serialized_size( fn serialized_size_impl( context: &mut SafeNativeContext, - reference: Reference, + value: &Value, ty: &Type, ) -> PartialVMResult { - // TODO(#14175): Reading the reference performs a deep copy, and we can - // implement it in a more efficient way. - let value = reference.read_ref()?; let ty_layout = context.type_to_type_layout(ty)?; let function_value_extension = context.function_value_extension(); @@ -144,7 +156,7 @@ fn serialized_size_impl( .with_legacy_signer() .with_func_args_deserialization(&function_value_extension) .with_delayed_fields_serde() - .serialized_size(&value, &ty_layout) + .serialized_size(value, &ty_layout) } fn native_constant_serialized_size( diff --git a/aptos-move/framework/src/natives/object.rs b/aptos-move/framework/src/natives/object.rs index fe4adb302bb..3a202f4ae01 100644 --- a/aptos-move/framework/src/natives/object.rs +++ b/aptos-move/framework/src/natives/object.rs @@ -6,7 +6,9 @@ use aptos_native_interface::{ safely_assert_eq, safely_pop_arg, RawSafeNative, SafeNativeBuilder, SafeNativeContext, SafeNativeResult, }; -use aptos_types::transaction::authenticator::AuthenticationKey; +use aptos_types::{ + on_chain_config::TimedFeatureFlag, transaction::authenticator::AuthenticationKey, +}; use better_any::{Tid, TidAble}; use move_core_types::{ account_address::AccountAddress, @@ -71,6 +73,17 @@ fn native_exists_at( context.charge( OBJECT_EXISTS_AT_PER_ITEM_LOADED + OBJECT_EXISTS_AT_PER_BYTE_LOADED * num_bytes, )?; + // exists_at deserializes into the session cache on a miss. Byte + // charges above miss the node graph; bill it here when the flag is + // on. Cache hits (`num_bytes == None`) were already billed. + if context.timed_feature_enabled(TimedFeatureFlag::MeterValueNodesOnDeserialize) { + if let Some((heap, graph_size)) = + context.session_cached_resource_graph_sizes(address, &type_)? + { + context.use_heap_memory(heap)?; + context.bill_value_graph_walk(graph_size)?; + } + } } Ok(smallvec![Value::bool(exists)]) diff --git a/aptos-move/framework/src/natives/util.rs b/aptos-move/framework/src/natives/util.rs index 444cc143e3f..5b81cca5709 100644 --- a/aptos-move/framework/src/natives/util.rs +++ b/aptos-move/framework/src/natives/util.rs @@ -6,6 +6,7 @@ use aptos_native_interface::{ safely_pop_arg, RawSafeNative, SafeNativeBuilder, SafeNativeContext, SafeNativeError, SafeNativeResult, }; +use aptos_types::on_chain_config::TimedFeatureFlag; use move_core_types::gas_algebra::NumBytes; use move_vm_runtime::native_functions::NativeFunction; use move_vm_types::{ @@ -58,6 +59,13 @@ fn native_from_bytes( }, }; + // Blob-length gas above does not cover building the value graph. A + // compact payload can still explode into many nodes. + if context.timed_feature_enabled(TimedFeatureFlag::MeterValueNodesOnDeserialize) { + let graph_size = context.abs_val_size(&val)?; + context.bill_value_graph_walk(graph_size)?; + } + Ok(smallvec![val]) } diff --git a/aptos-move/framework/table-natives/Cargo.toml b/aptos-move/framework/table-natives/Cargo.toml index fa40942a0de..0f4355769dd 100644 --- a/aptos-move/framework/table-natives/Cargo.toml +++ b/aptos-move/framework/table-natives/Cargo.toml @@ -12,6 +12,7 @@ repository = { workspace = true } rust-version = { workspace = true } [dependencies] +aptos-gas-algebra = { workspace = true } aptos-gas-schedule = { workspace = true } aptos-native-interface = { workspace = true } aptos-types = { workspace = true } diff --git a/aptos-move/framework/table-natives/src/lib.rs b/aptos-move/framework/table-natives/src/lib.rs index bef54e0cdcc..f241f6377d9 100644 --- a/aptos-move/framework/table-natives/src/lib.rs +++ b/aptos-move/framework/table-natives/src/lib.rs @@ -10,6 +10,7 @@ //! See [`Table.move`](../sources/Table.move) for language use. //! See [`README.md`](../README.md) for integration into an adapter. +use aptos_gas_algebra::AbstractValueSize; use aptos_gas_schedule::gas_params::natives::table::*; use aptos_native_interface::{ safely_pop_arg, RawSafeNative, SafeNativeBuilder, SafeNativeContext, SafeNativeError, @@ -296,10 +297,36 @@ pub fn table_natives( }) } +/// Abstract size of a table value that was just deserialized (cache miss). +/// Cache hits return `None` so a repeated borrow cannot be billed twice. +fn graph_size_if_freshly_materialized( + context: &SafeNativeContext, + gv: &GlobalValue, + loaded: Option>, +) -> PartialVMResult> { + if loaded.is_none() + || !context.timed_feature_enabled(TimedFeatureFlag::MeterValueNodesOnDeserialize) + { + return Ok(None); + } + gv.view() + .map(|val| { + context + .abs_val_gas_params() + .abstract_value_size(&val, context.gas_feature_version()) + }) + .transpose() +} + fn charge_load_cost( context: &mut SafeNativeContext, loaded: Option>, + freshly_materialized_graph: Option, ) -> SafeNativeResult<()> { + if let Some(graph_size) = freshly_materialized_graph { + context.bill_value_graph_walk(graph_size)?; + } + context.charge(COMMON_LOAD_BASE_LEGACY)?; match loaded { @@ -396,6 +423,7 @@ fn native_add_box( } else { None }; + let materialized_graph = graph_size_if_freshly_materialized(context, gv, loaded)?; let res = match gv.move_to(val) { Ok(_) => Ok(smallvec![]), @@ -411,7 +439,7 @@ fn native_add_box( if let Some(amount) = mem_usage { context.use_heap_memory(amount)?; } - charge_load_cost(context, loaded)?; + charge_load_cost(context, loaded, materialized_graph)?; res } @@ -425,7 +453,8 @@ fn native_borrow_box( assert_eq!(args.len(), 2); context.charge(BORROW_BOX_BASE)?; - let fix_memory_double_counting = context.timed_feature_enabled(TimedFeatureFlag::FixTableNativesMemoryDoubleCounting); + let fix_memory_double_counting = + context.timed_feature_enabled(TimedFeatureFlag::FixTableNativesMemoryDoubleCounting); let function_value_extension = context.function_value_extension(); let table_context = context.extensions().get::(); @@ -453,6 +482,7 @@ fn native_borrow_box( } else { None }; + let materialized_graph = graph_size_if_freshly_materialized(context, gv, loaded)?; let res = match gv.borrow_global() { Ok(ref_val) => Ok(smallvec![ref_val]), @@ -468,7 +498,7 @@ fn native_borrow_box( if let Some(amount) = mem_usage { context.use_heap_memory(amount)?; } - charge_load_cost(context, loaded)?; + charge_load_cost(context, loaded, materialized_graph)?; res } @@ -511,6 +541,7 @@ fn native_contains_box( } else { None }; + let materialized_graph = graph_size_if_freshly_materialized(context, gv, loaded)?; let exists = Value::bool(gv.exists()?); drop(table_data); @@ -520,7 +551,7 @@ fn native_contains_box( if let Some(amount) = mem_usage { context.use_heap_memory(amount)?; } - charge_load_cost(context, loaded)?; + charge_load_cost(context, loaded, materialized_graph)?; Ok(smallvec![exists]) } @@ -563,6 +594,7 @@ fn native_remove_box( } else { None }; + let materialized_graph = graph_size_if_freshly_materialized(context, gv, loaded)?; let res = match gv.move_from() { Ok(val) => Ok(smallvec![val]), @@ -578,7 +610,7 @@ fn native_remove_box( if let Some(amount) = mem_usage { context.use_heap_memory(amount)?; } - charge_load_cost(context, loaded)?; + charge_load_cost(context, loaded, materialized_graph)?; res } diff --git a/third_party/move/move-vm/runtime/src/native_functions.rs b/third_party/move/move-vm/runtime/src/native_functions.rs index ed7f60aa00c..01c4a04a999 100644 --- a/third_party/move/move-vm/runtime/src/native_functions.rs +++ b/third_party/move/move-vm/runtime/src/native_functions.rs @@ -24,8 +24,11 @@ use move_core_types::{ vm_status::StatusCode, }; use move_vm_types::{ - gas::NativeGasMeter, loaded_data::runtime_types::Type, natives::function::NativeResult, - resolver::ResourceResolver, values::Value, + gas::NativeGasMeter, + loaded_data::runtime_types::Type, + natives::function::NativeResult, + resolver::ResourceResolver, + values::{GlobalValue, Value}, }; use std::{ collections::{HashMap, VecDeque}, @@ -163,6 +166,21 @@ impl<'b, 'c> NativeContext<'_, 'b, 'c> { }) } + /// Resource already resident in this session's data cache. + /// + /// Used after [`Self::exists_at`] (which materializes the value on a + /// cache miss) so natives can bill for the graph they just built + /// without changing the exists/borrow APIs. + pub fn session_cached_resource( + &mut self, + address: AccountAddress, + ty: &Type, + ) -> PartialVMResult<&GlobalValue> { + self.data_store + .get_resource_mut(&address, ty) + .map(|gv| &*gv) + } + pub fn type_to_type_tag(&self, ty: &Type) -> PartialVMResult { self.module_storage.runtime_environment().ty_to_ty_tag(ty) } diff --git a/types/src/on_chain_config/timed_features.rs b/types/src/on_chain_config/timed_features.rs index fe5e895a1a0..c82a6922980 100644 --- a/types/src/on_chain_config/timed_features.rs +++ b/types/src/on_chain_config/timed_features.rs @@ -29,6 +29,14 @@ pub enum TimedFeatureFlag { /// Uses full transaction size when computing transaction metadata. UseFullTransactionSizeForTransactionMetadata, + + /// Bill execution gas for walking a materialized Move value graph. + /// + /// Covers deserialize paths (`from_bytes`, resource / table / object + /// loads) and serialize-side BCS walks (`to_bytes`, `serialized_size`). + /// Without this, a small BCS blob can expand into a huge node graph + /// while only paying for the blob length. + MeterValueNodesOnDeserialize, } /// Representation of features that are gated by the block timestamps. @@ -81,8 +89,20 @@ impl TimedFeatureFlag { use TimedFeatureFlag::*; match (self, chain_id) { - (UseFullTransactionSizeForTransactionMetadata, MOVEMAINNET | MOVETESTNET) => Los_Angeles - .with_ymd_and_hms(2026, 5, 4, 9, 45, 0) + (UseFullTransactionSizeForTransactionMetadata, MOVEMAINNET | MOVETESTNET) => { + Los_Angeles + .with_ymd_and_hms(2026, 5, 4, 9, 45, 0) + .unwrap() + .with_timezone(&Utc) + }, + // Must precede the Movement catch-all so this flag does not + // activate on 2025-08-11 (already in the past). + (MeterValueNodesOnDeserialize, MOVETESTNET) => Los_Angeles + .with_ymd_and_hms(2026, 10, 15, 9, 0, 0) + .unwrap() + .with_timezone(&Utc), + (MeterValueNodesOnDeserialize, MOVEMAINNET) => Los_Angeles + .with_ymd_and_hms(2026, 10, 22, 9, 0, 0) .unwrap() .with_timezone(&Utc), (_, MOVEMAINNET | MOVETESTNET) => Los_Angeles @@ -153,6 +173,22 @@ impl TimedFeatureFlag { // Irrelevant for us except for testing (UseFullTransactionSizeForTransactionMetadata, _) => BEGINNING_OF_TIME, + // Three hours after the Unix epoch so a single `new_epoch()` + // (two hours) leaves the flag off. Tests that need the charge + // advance two epochs. Existing tests that only roll one epoch + // keep the historical unmetered behavior. + (MeterValueNodesOnDeserialize, TESTING) => { + Utc.with_ymd_and_hms(1970, 1, 1, 3, 0, 0).unwrap() + }, + (MeterValueNodesOnDeserialize, TESTNET) => Los_Angeles + .with_ymd_and_hms(2026, 10, 15, 14, 0, 0) + .unwrap() + .with_timezone(&Utc), + (MeterValueNodesOnDeserialize, MAINNET) => Los_Angeles + .with_ymd_and_hms(2026, 10, 22, 14, 0, 0) + .unwrap() + .with_timezone(&Utc), + // For chains other than testnet and mainnet, a timed feature is considered enabled from // the very beginning, if left unspecified. (_, TESTING | DEVNET | PREMAINNET) => BEGINNING_OF_TIME, @@ -239,6 +275,7 @@ impl TimedFeatures { #[cfg(test)] mod test { use super::*; + use crate::chain_id::NamedChain; use claims::assert_ok; #[test] @@ -378,4 +415,44 @@ mod test { "EntryCompatibility should be enabled on Nov 15, 2024 on mainnet" ); } + + #[test] + fn value_graph_flag_stays_off_until_its_own_activation() { + use TimedFeatureFlag::*; + + let genesis = 0; + let two_hours_micros = 2 * 3_600 * 1_000_000; + let four_hours_micros = 4 * 3_600 * 1_000_000; + + let testing_genesis = TimedFeaturesBuilder::new(ChainId::test(), genesis); + assert!( + !testing_genesis.is_enabled(MeterValueNodesOnDeserialize), + "flag must be off at testing genesis so historical gas stays unchanged" + ); + + let after_one_epoch = TimedFeaturesBuilder::new(ChainId::test(), two_hours_micros); + assert!( + !after_one_epoch.is_enabled(MeterValueNodesOnDeserialize), + "a single two-hour epoch must not enable the charge" + ); + + let after_two_epochs = TimedFeaturesBuilder::new(ChainId::test(), four_hours_micros); + assert!( + after_two_epochs.is_enabled(MeterValueNodesOnDeserialize), + "two epochs (four hours) must cross the three-hour testing gate" + ); + + // Movement's unnamed-flag catch-all is 2025-08-11; this flag must not + // inherit that date or it would be live immediately. + let movement_now = Utc + .with_ymd_and_hms(2026, 9, 6, 0, 0, 0) + .unwrap() + .timestamp_micros() as u64; + let movement = + TimedFeaturesBuilder::new(ChainId::new(NamedChain::MOVETESTNET.id()), movement_now); + assert!( + !movement.is_enabled(MeterValueNodesOnDeserialize), + "Movement testnet must wait for the explicit October 2026 activation" + ); + } }