diff --git a/Cargo.lock b/Cargo.lock index 6f6e954ba..70d9b36fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2479,6 +2479,7 @@ dependencies = [ "hex", "lazy_static", "libsecp256k1", + "minstant", "multihash 0.18.1", "num-traits", "rand", @@ -3462,9 +3463,9 @@ dependencies = [ [[package]] name = "minstant" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc5dcfca9a0725105ac948b84cfeb69c3942814c696326743797215413f854b9" +checksum = "7df94bf4a15ed69e64ea45405e504ef293a3614413e7d8f5529112c5acd4a114" dependencies = [ "ctor", "libc", diff --git a/fvm/src/call_manager/default.rs b/fvm/src/call_manager/default.rs index 9806b7a06..4fc061626 100644 --- a/fvm/src/call_manager/default.rs +++ b/fvm/src/call_manager/default.rs @@ -901,11 +901,18 @@ where /// If an actor aborts, the last layer should be discarded (discard_last_layer). This will also /// throw away any events collected from subcalls (and previously merged, as those subcalls returned /// normally). -#[derive(Default)] pub struct EventsAccumulator { events: Vec, idxs: Vec, } +impl Default for EventsAccumulator { + fn default() -> Self { + Self { + events: Vec::with_capacity(128), + idxs: Vec::with_capacity(8), + } + } +} pub(crate) struct Events { root: Option, diff --git a/fvm/src/gas/price_list.rs b/fvm/src/gas/price_list.rs index e570d07ff..45c6d8108 100644 --- a/fvm/src/gas/price_list.rs +++ b/fvm/src/gas/price_list.rs @@ -312,14 +312,14 @@ lazy_static! { // TODO(#1817): Per-entry event validation cost. These parameters were benchmarked for the // EVM but haven't been revisited since revising the API. event_per_entry: ScalingCost { - flat: Gas::new(1750), - scale: Gas::new(25), + flat: Gas::new(2000), + scale: Gas::new(1400), }, // TODO(#1817): Cost of validating utf8 (used in event parsing). utf8_validation: ScalingCost { - flat: Zero::zero(), - scale: Zero::zero(), + flat: Gas::new(500), + scale: Gas::new(16), }, // Preloaded actor IDs per FIP-0055. @@ -627,6 +627,15 @@ impl PriceList { GasCharge::new("OnHashing", gas, Zero::zero()) } + #[inline] + pub fn on_utf8_validation(&self, len: usize) -> GasCharge { + GasCharge::new( + "OnUtf8Validation", + self.utf8_validation.apply(len), + Zero::zero(), + ) + } + /// Returns gas required for computing unsealed sector Cid. #[inline] pub fn on_compute_unsealed_sector_cid( @@ -966,11 +975,11 @@ impl PriceList { GasCharge::new( "OnActorEvent", - // Charge for validation/storing events. - mem + validate_entries + validate_utf8, + // Charge for validation/storing/serializing events. + mem * 2u32 + validate_entries + validate_utf8, // Charge for forming the AMT and returning the events to the client. // one copy into the AMT, one copy to the client. - hash + (mem * 2u32), + hash + mem, ) } diff --git a/fvm/src/kernel/default.rs b/fvm/src/kernel/default.rs index 9d622f6b7..0dcfad2e9 100644 --- a/fvm/src/kernel/default.rs +++ b/fvm/src/kernel/default.rs @@ -1061,6 +1061,11 @@ where return Err(syscall_error!(IllegalArgument; "total event value lengths exceeded the max size: {} > {MAX_TOTAL_VALUES_LEN}", event_values.len()).into()); } + // We validate utf8 all at once for better performance. + let event_keys = std::str::from_utf8(event_keys) + .context("invalid event key") + .or_illegal_argument()?; + let mut key_offset: usize = 0; let mut val_offset: usize = 0; @@ -1098,10 +1103,6 @@ where .context("event entry key out of range") .or_illegal_argument()?; - let key = std::str::from_utf8(key) - .context("invalid event key") - .or_illegal_argument()?; - let value = &event_values .get(val_offset..val_offset + header.val_len as usize) .context("event entry value out of range") @@ -1125,6 +1126,10 @@ where let actor_evt = ActorEvent::from(entries); let stamped_evt = StampedEvent::new(self.actor_id, actor_evt); + // Enable this when performing gas calibration to measure the cost of serializing early. + #[cfg(feature = "gas_calibration")] + let _ = fvm_ipld_encoding::to_vec(&stamped_evt).unwrap(); + self.call_manager.append_event(stamped_evt); t.stop(); diff --git a/testing/calibration/shared/src/lib.rs b/testing/calibration/shared/src/lib.rs index 9b85ccf35..b5cd46c02 100644 --- a/testing/calibration/shared/src/lib.rs +++ b/testing/calibration/shared/src/lib.rs @@ -19,7 +19,7 @@ pub enum Method { OnRecoverSecpPublicKey, /// Measure sends OnSend, - /// Emit events, driven by the selected mode. See EventCalibrationMode for more info. + /// Emit events OnEvent, /// Read/write blocks with different numbers of CBOR fields & links. OnScanIpldLinks, @@ -64,18 +64,11 @@ pub struct OnRecoverSecpPublicKeyParams { pub seed: u64, } -#[derive(Serialize, Deserialize)] -pub enum EventCalibrationMode { - /// Produce events with the specified shape. - Shape((usize, usize, usize)), - /// Attempt to reach a target size for the CBOR event. - TargetSize(usize), -} - #[derive(Serialize, Deserialize)] pub struct OnEventParams { pub iterations: usize, - pub mode: EventCalibrationMode, + // Total size of the values. + pub total_value_size: usize, /// Number of entries in the event. pub entries: usize, /// Flags to apply to all entries. diff --git a/testing/integration/Cargo.toml b/testing/integration/Cargo.toml index 5ccf6f153..57676b202 100644 --- a/testing/integration/Cargo.toml +++ b/testing/integration/Cargo.toml @@ -41,8 +41,9 @@ serde_json = "1.0" bls-signatures = { version = "0.13", default-features = false } wat = "1.0.66" hex = "0.4.3" +minstant = "0.1.3" [features] default = [] m2-native = [] -calibration = [] +calibration = ["fvm/gas_calibration"] diff --git a/testing/integration/tests/gas_calibration_test.rs b/testing/integration/tests/gas_calibration_test.rs index 95d42b223..88a30d70b 100644 --- a/testing/integration/tests/gas_calibration_test.rs +++ b/testing/integration/tests/gas_calibration_test.rs @@ -3,6 +3,7 @@ mod calibration; #[cfg(feature = "calibration")] use calibration::*; +#[cfg(feature = "calibration")] use fvm_gas_calibration_shared::*; #[test] @@ -81,140 +82,148 @@ fn on_block() { } } -// TODO (fridrik): Enable this test after closing #1699 -//#[test] -#[allow(dead_code)] +#[test] #[cfg(feature = "calibration")] -fn on_event_evm_shapes() { +fn on_event_by_value_size() { use fvm_shared::event::Flags; use rand::{thread_rng, Rng}; - const CHARGE_VALIDATE: &str = "OnActorEventValidate"; - const CHARGE_ACCEPT: &str = "OnActorEventAccept"; + const CHARGE: &str = "OnActorEvent"; const METHOD: Method = Method::OnEvent; - let entries = 1..=5; - let (key_size, value_size) = (2, 32); // 2 bytes per key, 32 bytes per value (topics) - let last_entry_value_sizes = (5u32..=13).map(|n| u64::pow(2, n) as usize); // 32 bytes to 8KiB (payload) - let iterations = 500; - - let (mut validate_obs, mut accept_obs) = (Vec::new(), Vec::new()); - let mut te = instantiate_tester(); - let mut rng = thread_rng(); - for entry_count in entries { - for last_entry_value_size in last_entry_value_sizes.clone() { - let label = format!("{entry_count:?}entries"); + let mut obs = Vec::new(); + + let entry_counts = &[1usize, 16, 127, 255]; + for &entries in entry_counts { + for total_value_size in (8..=13).map(|x| usize::pow(2, x)) { + let label = format!("{entries}-entries"); let params = OnEventParams { iterations, // number of entries to emit - entries: entry_count, - mode: EventCalibrationMode::Shape((key_size, value_size, last_entry_value_size)), + entries, + total_value_size, flags: Flags::FLAG_INDEXED_ALL, seed: rng.gen(), }; let ret = te.execute_or_die(METHOD as u64, ¶ms); - // Estimated length of the CBOR payload (confirmed with observations) - // 1 is the list header; 5 per entry CBOR overhead + flags. - let len = 1 - + ((entry_count - 1) * value_size) - + last_entry_value_size - + entry_count * key_size - + entry_count * 5; - - { - let mut series = collect_obs(&ret.clone(), CHARGE_VALIDATE, &label, len); - series = eliminate_outliers(series, 0.02, Eliminate::Top); - validate_obs.extend(series); - }; - - { - let mut series = collect_obs(&ret.clone(), CHARGE_ACCEPT, &label, len); - series = eliminate_outliers(series, 0.02, Eliminate::Top); - accept_obs.extend(series); - }; + let mut series = collect_obs(&ret.clone(), CHARGE, &label, total_value_size); + series = eliminate_outliers(series, 0.02, Eliminate::Top); + obs.extend(series); } } - for (obs, name) in vec![(validate_obs, CHARGE_VALIDATE), (accept_obs, CHARGE_ACCEPT)].iter() { - let regression = run_linear_regression(obs); + let regression = run_linear_regression(&obs); - export(name, obs, ®ression).unwrap(); - } + export("OnActorEventValue", &obs, ®ression).unwrap(); } -// intentionally left disabled since we're not interested in these observations at this stage. -#[allow(dead_code)] -fn on_event_target_size() { - const CHARGE_VALIDATE: &str = "OnActorEventValidate"; - const CHARGE_ACCEPT: &str = "OnActorEventAccept"; - const METHOD: Method = Method::OnEvent; - - use calibration::*; +#[test] +#[cfg(feature = "calibration")] +fn on_event_by_entry_count() { use fvm_shared::event::Flags; use rand::{thread_rng, Rng}; - let mut config: Vec<(usize, usize)> = vec![]; - // 1 entry, ranging 8..1024 bytes - config.extend((3u32..=10).map(|n| (1usize, u64::pow(2, n) as usize))); - // 2 entry, ranging 16..1024 bytes - config.extend((4u32..=10).map(|n| (2usize, u64::pow(2, n) as usize))); - // 4 entries, ranging 32..1024 bytes - config.extend((5u32..=10).map(|n| (4usize, u64::pow(2, n) as usize))); - // 8 entries, ranging 64..1024 bytes - config.extend((6u32..=10).map(|n| (8usize, u64::pow(2, n) as usize))); - // 16 entries, ranging 128..1024 bytes - config.extend((7u32..=10).map(|n| (16usize, u64::pow(2, n) as usize))); - // 32 entries, ranging 256..1024 bytes - config.extend((8u32..=10).map(|n| (32usize, u64::pow(2, n) as usize))); - // 64 entries, ranging 512..1024 bytes - config.extend((9u32..=10).map(|n| (64usize, u64::pow(2, n) as usize))); + const CHARGE: &str = "OnActorEvent"; + const METHOD: Method = Method::OnEvent; let iterations = 500; - - let (mut validate_obs, mut accept_obs) = (Vec::new(), Vec::new()); - let mut te = instantiate_tester(); - let mut rng = thread_rng(); - for (entries, target_size) in config.iter() { - let label = format!("{entries:?}entries"); - let params = OnEventParams { - iterations, - // number of entries to emit - entries: *entries, - // target size of the encoded CBOR; this is approximate. - mode: EventCalibrationMode::TargetSize(*target_size), - flags: Flags::FLAG_INDEXED_ALL, - seed: rng.gen(), - }; + let mut obs = Vec::new(); - let ret = te.execute_or_die(METHOD as u64, ¶ms); + let total_value_sizes = &[255, 1024, 4096, 8192]; + for &total_value_size in total_value_sizes { + for entries in (1..=8).map(|x| usize::pow(2, x) - 1) { + let label = format!("{total_value_size}-size"); + let params = OnEventParams { + iterations, + // number of entries to emit + entries, + total_value_size, + flags: Flags::FLAG_INDEXED_ALL, + seed: rng.gen(), + }; - { - let mut series = collect_obs(&ret.clone(), CHARGE_VALIDATE, &label, *target_size); - series = eliminate_outliers(series, 0.02, Eliminate::Top); - validate_obs.extend(series); - }; + let ret = te.execute_or_die(METHOD as u64, ¶ms); - { - let mut series = collect_obs(&ret.clone(), CHARGE_ACCEPT, &label, *target_size); + let mut series = collect_obs(&ret.clone(), CHARGE, &label, entries); series = eliminate_outliers(series, 0.02, Eliminate::Top); - accept_obs.extend(series); - }; + obs.extend(series); + } } - for (obs, name) in vec![(validate_obs, CHARGE_VALIDATE), (accept_obs, CHARGE_ACCEPT)].iter() { - let regression = run_linear_regression(obs); + let regression = run_linear_regression(&obs); + + export("OnActorEventEntries", &obs, ®ression).unwrap(); +} + +#[test] +#[cfg(feature = "calibration")] +fn utf8_validation() { + use fvm::gas::price_list_by_network_version; + use fvm_shared::version::NetworkVersion; + use rand::{distributions::Standard, thread_rng, Rng}; - export(name, obs, ®ression).unwrap(); + let mut chars = thread_rng().sample_iter(Standard); + const CHARGE: &str = "OnUtf8Validate"; + + let iterations = 500; + let price_list = price_list_by_network_version(NetworkVersion::V21); + + let mut obs = Vec::new(); + #[derive(Debug, Copy, Clone)] + enum Kind { + Ascii, + MaxUtf8, + RandomUtf8, + } + use Kind::*; + for size in (0..=8).map(|x| usize::pow(2, x)) { + for kind in [Ascii, RandomUtf8, MaxUtf8] { + let mut series = Vec::new(); + for _ in 0..iterations { + let rand_str: String = match kind { + Ascii => "a".repeat(size), + MaxUtf8 => char::REPLACEMENT_CHARACTER.to_string().repeat(size / 2), + RandomUtf8 => chars + .by_ref() + .take_while({ + let mut total: usize = 0; + move |c: &char| { + total += c.len_utf8(); + total < size + } + }) + .collect(), + }; + let charge = price_list.on_utf8_validation(rand_str.len()); + let start = minstant::Instant::now(); + let _ = std::hint::black_box(std::str::from_utf8(std::hint::black_box( + rand_str.as_bytes(), + ))); + let time = start.elapsed(); + series.push(Obs { + charge: CHARGE.into(), + label: format!("{:?}-validate", kind), + elapsed_nanos: time.as_nanos(), + variables: vec![rand_str.len()], + compute_gas: charge.compute_gas.as_milligas(), + }) + } + obs.extend(eliminate_outliers(series, 0.02, Eliminate::Both)); + } } + + let regression = run_linear_regression(&obs); + + export(CHARGE, &obs, ®ression).unwrap(); } #[test] diff --git a/testing/test_actors/actors/fil-gas-calibration-actor/src/actor.rs b/testing/test_actors/actors/fil-gas-calibration-actor/src/actor.rs index 28a9dca81..0ab0c9201 100644 --- a/testing/test_actors/actors/fil-gas-calibration-actor/src/actor.rs +++ b/testing/test_actors/actors/fil-gas-calibration-actor/src/actor.rs @@ -160,10 +160,26 @@ fn on_send(p: OnSendParams) -> Result<()> { } fn on_event(p: OnEventParams) -> Result<()> { - match p.mode { - EventCalibrationMode::Shape(_) => on_event_shape(p), - EventCalibrationMode::TargetSize(_) => on_event_target_size(p), + let mut value = vec![0; p.total_value_size]; + + let iterations = p.iterations as u64; + for i in 0..iterations { + random_mutations(&mut value, p.seed + i, MUTATION_COUNT); + + let entries: Vec<_> = random_chunk(&value, p.entries, p.seed + i) + .into_iter() + .map(|d| Entry { + flags: p.flags, + key: char::MAX.to_string(), + codec: IPLD_RAW, + value: d.into(), + }) + .collect(); + + fvm_sdk::event::emit_event(&ActorEvent::from(entries))?; } + + Ok(()) } // Makes approximately fixed-sized test objects with the specified number of fields & links. @@ -207,7 +223,7 @@ fn on_scan_ipld_links(p: OnScanIpldLinksParams) -> Result<()> { let mut test_cids = vec![fvm_sdk::sself::root().unwrap()]; for i in 0..p.iterations { let obj = make_test_object( - &mut test_cids, + &test_cids, p.seed + i as u64, p.cbor_field_count, p.cbor_link_count, @@ -220,80 +236,6 @@ fn on_scan_ipld_links(p: OnScanIpldLinksParams) -> Result<()> { Ok(()) } -fn on_event_shape(p: OnEventParams) -> Result<()> { - const MAX_DATA: usize = 8 << 10; - - let EventCalibrationMode::Shape((key_size, value_size, last_value_size)) = p.mode else { panic!() }; - let mut value = vec![0; value_size]; - - // the last entry may not exceed total event values over MAX_DATA - let total_entry_size = (p.entries - 1) * value_size; - let mut tmp_size = last_value_size; - if tmp_size + total_entry_size > MAX_DATA { - tmp_size = MAX_DATA - total_entry_size; - } - let mut last_value = vec![0; tmp_size]; - - for i in 0..p.iterations { - random_mutations(&mut value, p.seed + i as u64, MUTATION_COUNT); - let key = random_ascii_string(key_size, p.seed + p.iterations as u64 + i as u64); // non-overlapping seed - let mut entries: Vec = std::iter::repeat_with(|| Entry { - flags: p.flags, - key: key.clone(), - codec: IPLD_RAW, - value: value.clone(), - }) - .take(p.entries - 1) - .collect(); - - random_mutations(&mut last_value, p.seed + i as u64, MUTATION_COUNT); - entries.push(Entry { - flags: p.flags, - key, - codec: IPLD_RAW, - value: last_value.clone(), - }); - - fvm_sdk::event::emit_event(&ActorEvent::from(entries))?; - } - - Ok(()) -} - -fn on_event_target_size(p: OnEventParams) -> Result<()> { - let EventCalibrationMode::TargetSize(target_size) = p.mode else { panic!() }; - - // Deduct the approximate overhead of each entry (3 bytes) + flag (1 byte). This - // is fuzzy because the size of the encoded CBOR depends on the length of fields, but it's good enough. - let size_per_entry = ((target_size.checked_sub(p.entries * 4).unwrap_or(1)) / p.entries).max(1); - let mut rand = lcg64(p.seed); - for _ in 0..p.iterations { - let mut entries = Vec::with_capacity(p.entries); - for _ in 0..p.entries { - let (r1, r2, r3) = ( - rand.next().unwrap(), - rand.next().unwrap(), - rand.next().unwrap(), - ); - // Generate a random key of an arbitrary length that fits within the size per entry. - // This will never be equal to size_per_entry, and it might be zero, which is fine - // for gas calculation purposes. - let key = random_ascii_string((r1 % size_per_entry as u64) as usize, r2); - // Generate a value to fill up the remaining bytes. - let value = random_bytes(size_per_entry - key.len(), r3); - entries.push(Entry { - flags: p.flags, - codec: IPLD_RAW, - key, - value, - }) - } - fvm_sdk::event::emit_event(&ActorEvent::from(entries))?; - } - - Ok(()) -} - fn random_bytes(size: usize, seed: u64) -> Vec { lcg8(seed).take(size).collect() } @@ -307,11 +249,22 @@ fn random_mutations(data: &mut Vec, seed: u64, n: usize) { } } -/// Generates a random string in the 0x20 - 0x7e ASCII character range -/// (alphanumeric + symbols, excluding the delete symbol). -fn random_ascii_string(n: usize, seed: u64) -> String { - let bytes = lcg64(seed).map(|x| ((x % 95) + 32) as u8).take(n).collect(); - String::from_utf8(bytes).unwrap() +fn random_chunk(inp: &[u8], count: usize, seed: u64) -> Vec<&[u8]> { + if count == 0 { + Vec::new() + } else if seed % 2 == 0 { + inp.chunks((inp.len() / count).max(1)) + .chain(std::iter::repeat(&[][..])) + .take(count) + .collect() + } else if inp.len() >= count { + let (prefix, rest) = inp.split_at(inp.len() - (count - 1)); + std::iter::once(prefix).chain(rest.chunks(1)).collect() + } else { + let mut res = vec![&[][..]; count]; + res[0] = inp; + res + } } /// Knuth's quick and dirty random number generator.