Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/ic-management-canister-types/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

- Added the `PATCH` variant to the `HttpMethod` enum used by canister HTTPS outcalls (`http_request`). The variant is plumbed through the type but not yet enabled on replicated subnets.
- Types for `subnet_metrics`: added the types `SubnetMetricsArgs` and `SubnetMetricsResult`.

## [0.8.0] - 2026-05-13

Expand Down
58 changes: 58 additions & 0 deletions packages/ic-management-canister-types/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1372,6 +1372,64 @@ pub struct SubnetInfoResult {
pub registry_version: u64,
}

/// # Subnet Metrics Args.
///
/// Argument type of [`subnet_metrics`](https://docs.internetcomputer.org/references/management-canister/#subnet_metrics).
#[derive(
CandidType, Serialize, Deserialize, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone,
)]
pub struct SubnetMetricsArgs {
/// Subnet ID.
pub subnet_id: Principal,
}

/// # Subnet Metrics Result.
///
/// Result type of [`subnet_metrics`](https://docs.internetcomputer.org/references/management-canister/#subnet_metrics).
///
/// This API is EXPERIMENTAL and may evolve in a non-backward-compatible way.
///
/// # Freshness
///
/// Only `block_height` is current as of the block in which the call is executed.
/// The other four are read from the subnet's aggregated metrics, which the replica
/// updates at the *end* of a round, so they describe the state as of an earlier
/// block:
///
/// - `num_canisters`, `update_transactions_total` and `consumed_cycles_total` are
/// as of the end of the previous round.
/// - `canister_state_bytes` is recomputed only every 10 rounds, because summing it
/// over every canister is expensive and it does not need to be exact. It can
/// therefore be up to ten rounds stale, and reads as `0` for the first rounds
/// after a subnet's first canister appears.
///
/// These are the same values, with the same staleness, that `read_state` returns
/// for the `/subnet/<subnet_id>/metrics` path, so the two agree.
#[derive(
CandidType, Serialize, Deserialize, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone,
)]
pub struct SubnetMetricsResult {
/// Current block height of the subnet, i.e. the height of the block in whose
/// execution the call is processed. Monotonically non-decreasing for a given
/// subnet; heights of different subnets are unrelated.
pub block_height: Nat,
/// Number of canisters on the subnet, as of the end of the previous round.
pub num_canisters: Nat,
/// Total size in bytes of the state taken by canisters on the subnet.
///
/// Refreshed only every 10 rounds, so this can be up to ten rounds stale (and
/// reads as `0` for the first rounds of a subnet's life). See the type-level
/// "Freshness" note.
pub canister_state_bytes: Nat,
/// Total cycles removed from circulation on the subnet by all current and
/// deleted canisters, as of the end of the previous round.
pub consumed_cycles_total: Nat,
/// Total number of transactions processed on the subnet, i.e. the total
/// number of messages executed in replicated mode, as of the end of the
/// previous round.
pub update_transactions_total: Nat,
}

/// # Canister ID Range.
///
/// A closed range of canister IDs, both endpoints inclusive.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,11 @@ fn node_metrics_history(_: NodeMetricsHistoryArgs) -> NodeMetricsHistoryResult {
unimplemented!()
}

#[candid_method(update)]
fn subnet_metrics(_: SubnetMetricsArgs) -> SubnetMetricsResult {
unimplemented!()
}

#[candid_method(update)]
fn provisional_create_canister_with_cycles(
_: ProvisionalCreateCanisterWithCyclesArgs,
Expand Down
20 changes: 20 additions & 0 deletions packages/ic-management-canister-types/tests/ic.did
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,25 @@ type node_metrics_history_result = vec record {
node_metrics : vec node_metrics;
};

type subnet_metrics_args = record {
subnet_id : principal;
};

type subnet_metrics_result = record {
// Current block height of the subnet, i.e. the height of the block in
// whose execution this call is processed.
block_height : nat;
// Current number of canisters on the subnet.
num_canisters : nat;
// Current total size in bytes of the state taken by canisters on the subnet.
canister_state_bytes : nat;
// Total cycles removed from circulation on the subnet by all current and
// deleted canisters.
consumed_cycles_total : nat;
// Total number of transactions processed on the subnet.
update_transactions_total : nat;
Comment thread
Dfinity-Bjoern marked this conversation as resolved.
};

type subnet_info_args = record {
subnet_id : principal;
};
Expand Down Expand Up @@ -701,6 +720,7 @@ service ic : {

// metrics interface
node_metrics_history : (node_metrics_history_args) -> (node_metrics_history_result);
subnet_metrics : (subnet_metrics_args) -> (subnet_metrics_result);

// subnet info
subnet_info : (subnet_info_args) -> (subnet_info_result);
Expand Down
2 changes: 1 addition & 1 deletion rs/canonical_state/src/encoding/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -744,7 +744,7 @@ impl
// `consumed_cycles_total` (which no longer double counts deleted
// canisters) plus the cycles consumed by all non-deleted canisters.
let consumed_cycles_total = if certification_version >= CertificationVersion::V29 {
metrics.consumed_cycles_total() + consumed_cycles_by_canisters
metrics.consumed_cycles_total_including_canisters(consumed_cycles_by_canisters)
} else {
metrics.consumed_cycles_total_v28()
};
Expand Down
91 changes: 88 additions & 3 deletions rs/embedders/src/wasmtime_embedder/system_api/routing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ use ic_management_canister_types_private::{
NodeMetricsHistoryArgs, Payload, ProvisionalTopUpCanisterArgs, ReadCanisterSnapshotDataArgs,
ReadCanisterSnapshotMetadataArgs, RenameCanisterArgs, ReshareChainKeyArgs,
SchnorrPublicKeyArgs, SetupInitialDKGArgs, SignWithECDSAArgs, SignWithSchnorrArgs,
StoredChunksArgs, SubnetInfoArgs, TakeCanisterSnapshotArgs, UninstallCodeArgs,
UpdateSettingsArgs, UploadCanisterSnapshotDataArgs, UploadCanisterSnapshotMetadataArgs,
UploadChunkArgs, VetKdDeriveKeyArgs, VetKdPublicKeyArgs,
StoredChunksArgs, SubnetInfoArgs, SubnetMetricsArgs, TakeCanisterSnapshotArgs,
UninstallCodeArgs, UpdateSettingsArgs, UploadCanisterSnapshotDataArgs,
UploadCanisterSnapshotMetadataArgs, UploadChunkArgs, VetKdDeriveKeyArgs, VetKdPublicKeyArgs,
};
use ic_replicated_state::NetworkTopology;
use itertools::Itertools;
Expand Down Expand Up @@ -202,6 +202,28 @@ pub(super) fn resolve_destination(
Ok(Ic00Method::NodeMetricsHistory) => {
Ok(NodeMetricsHistoryArgs::decode(payload)?.subnet_id)
}
Ok(Ic00Method::SubnetMetrics) => {
// Rejected explicitly, mirroring `FetchCanisterLogs` below, rather than
// relying on the composite-query path failing later in
// `QueryContext::handle_request` (where `get_active_canister` cannot
// resolve a subnet principal). That indirect guarantee holds today, but
// it would evaporate the moment `subnet_metrics` were added to
// `QueryMethod`: the query path has no round-instruction accounting, so
// the `O(|hot canisters|)` fold would run unmetered on query threads
// against a different state snapshot. Keeping the rejection here makes
// that a compile-time-visible decision rather than an accident.
if is_composite_query {
Err(ResolveDestinationError::UserError(UserError::new(
ic_error_types::ErrorCode::CanisterRejectedMessage,
format!(
"{} API cannot be called from a composite query",
Ic00Method::SubnetMetrics
),
)))
} else {
Ok(SubnetMetricsArgs::decode(payload)?.subnet_id)
}
}
Ok(Ic00Method::SubnetInfo) => Ok(SubnetInfoArgs::decode(payload)?.subnet_id),
Ok(Ic00Method::FetchCanisterLogs) => {
if is_composite_query {
Expand Down Expand Up @@ -1204,4 +1226,67 @@ mod tests {
};
}
}

/// `subnet_metrics` names its target subnet in the payload, so an ordinary
/// (non-composite-query) call routes there.
#[test]
fn resolve_subnet_metrics_routes_to_named_subnet() {
let logger = no_op_logger();
let target_subnet = subnet_test_id(1);
assert_eq!(
resolve_destination(
&network_with_ecdsa_subnets(),
&Ic00Method::SubnetMetrics.to_string(),
&Encode!(&SubnetMetricsArgs {
subnet_id: target_subnet.get()
})
.unwrap(),
subnet_test_id(2),
canister_test_id(1),
false,
&logger,
)
.unwrap(),
target_subnet.get()
);
}

/// ...but a composite query is rejected outright, mirroring
/// `fetch_canister_logs`.
///
/// The composite-query path has no round-instruction accounting, so the
/// `O(|hot canisters|)` fold that `subnet_metrics` performs must never run on
/// query threads. This is the in-process guard for that arm; the system test
/// `subnet_metrics_composite_query_fails` asserts the same thing end to end but
/// is Linux-only.
#[test]
fn resolve_subnet_metrics_rejects_composite_query() {
let logger = no_op_logger();
let err = resolve_destination(
&network_with_ecdsa_subnets(),
&Ic00Method::SubnetMetrics.to_string(),
&Encode!(&SubnetMetricsArgs {
subnet_id: subnet_test_id(1).get()
})
.unwrap(),
subnet_test_id(2),
canister_test_id(1),
true,
&logger,
)
.unwrap_err();
match err {
ResolveDestinationError::UserError(err) => {
assert_eq!(
err.code(),
ic_error_types::ErrorCode::CanisterRejectedMessage
);
assert_eq!(
err.description(),
"subnet_metrics API cannot be called from a composite query"
);
}
other => panic!("Unexpected error: {other:?}"),
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,7 @@ impl SystemStateModifications {
| Ok(Ic00Method::BitcoinSendTransaction)
| Ok(Ic00Method::BitcoinGetCurrentFeePercentiles)
| Ok(Ic00Method::NodeMetricsHistory)
| Ok(Ic00Method::SubnetMetrics)
| Ok(Ic00Method::SubnetInfo)
| Ok(Ic00Method::FetchCanisterLogs)
| Ok(Ic00Method::UploadChunk)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ mod ecdsa;
mod http_request;
mod install_code;
mod list_canisters;
mod subnet_metrics;
mod update_settings;
mod utils;

Expand All @@ -20,6 +21,7 @@ fn all_benchmarks(c: &mut Criterion) {
http_request::http_request_benchmark(c);
install_code::install_code_benchmark(c);
list_canisters::list_canisters_benchmark(c);
subnet_metrics::subnet_metrics_benchmark(c);
update_settings::update_settings_benchmark(c);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
use criterion::{BenchmarkGroup, Criterion, criterion_group, criterion_main};
use ic_base_types::{NumBytes, NumSeconds};
use ic_replicated_state::canister_state::canister_snapshots::CanisterSnapshots;
use ic_replicated_state::canister_state::system_state::SystemState;
use ic_replicated_state::{CanisterState, CanisterStates, SchedulerState};
use ic_test_utilities_types::ids::canister_test_id;
use ic_types_cycles::{CanisterCyclesCostSchedule, CompoundCycles, Cycles, Instructions};
use std::sync::Arc;

/// Builds one hot canister with non-zero consumed cycles.
///
/// A non-zero `heap_delta_debit` keeps a canister out of the cold pool
/// (`CanisterState::is_cold`), which is what makes a fully hot pool the worst case
/// for `CanisterStates::total_consumed_cycles()`: the fold is `O(|hot|)`, the cold
/// pool being a precomputed aggregate.
fn hot_canister(id: u64) -> Arc<CanisterState> {
let mut system_state = SystemState::new_running_for_testing(
canister_test_id(id),
canister_test_id(u64::MAX).get(),
Cycles::new(1 << 60),
NumSeconds::new(100_000),
);
system_state.consume_cycles(CompoundCycles::<Instructions>::new(
Cycles::new(1_000 + id as u128),
CanisterCyclesCostSchedule::Normal,
));
Arc::new(CanisterState::new(
system_state,
None,
SchedulerState {
heap_delta_debit: NumBytes::new(1),
..SchedulerState::default()
},
CanisterSnapshots::default(),
))
}

/// Builds a `CanisterStates` holding `canisters_number` hot canisters, allocated
/// and inserted in ascending canister-ID order.
///
/// This is the *favourable* memory layout: the `BTreeMap` nodes and the `Arc`
/// payloads are laid out in the order the fold visits them.
fn hot_canister_states(canisters_number: u64) -> CanisterStates {
let mut states = CanisterStates::default();
for id in 0..canisters_number {
states.insert(hot_canister(id));
}
assert_eq!(states.hot_len() as u64, canisters_number);
states
}

/// As [`hot_canister_states`], but with the allocation and insertion order
/// scrambled and with allocator churn interleaved, so the `BTreeMap` nodes and the
/// `Arc<CanisterState>` payloads are scattered rather than laid out in visit
/// order.
///
/// This is the adversarial-locality variant, and it is the one
/// `INSTRUCTIONS_PER_HOT_CANISTER` is justified against: a production hot pool is
/// built up over a long period from independently allocated, long-lived canisters,
/// not in one tight loop. In practice it measures only ~13% above the favourable
/// layout, because `size_of::<CanisterState>()` is ~2.5KB, so at 100k canisters the
/// pool is ~254MB and the fold is DRAM-bound either way.
fn shuffled_hot_canister_states(canisters_number: u64) -> CanisterStates {
/// Deterministic pseudo-random value, so the benchmark needs no RNG
/// dependency and is reproducible run to run.
fn scramble(i: u64) -> u64 {
let mut x = i.wrapping_mul(0x9E37_79B9_7F4A_7C15);
x ^= x >> 31;
x.wrapping_mul(0xBF58_476D_1CE4_E5B9)
}

// Fisher-Yates over `0..n`, rather than rejection-sampling a scrambled index
// until every residue has been hit: this is `O(n)` with a static termination
// bound, where the rejection loop terminates only in expectation (~12n
// iterations by coupon collector, and in principle never).
let mut order: Vec<u64> = (0..canisters_number).collect();
for i in (1..order.len()).rev() {
order.swap(i, (scramble(i as u64) % (i as u64 + 1)) as usize);
}

let mut states = CanisterStates::default();
let mut ballast: Vec<Vec<u8>> = Vec::new();
for (inserted, id) in order.into_iter().enumerate() {
// Churn: allocate, keep some, free some, so canister allocations are
// interleaved with unrelated live objects.
ballast.push(vec![0_u8; 4096]);
if ballast.len() > 64 {
let victim = inserted % ballast.len();
ballast.swap_remove(victim);
}
states.insert(hot_canister(id));
}
// Drop the ballast, leaving holes in the heap.
drop(ballast);
assert_eq!(states.hot_len() as u64, canisters_number);
states
}

/// Measures `CanisterStates::total_consumed_cycles()` over a fully hot pool.
/// The slope of this measurement is what `INSTRUCTIONS_PER_HOT_CANISTER` in
/// `subnet_metrics_instructions` must cover.
fn bench_consumed_cycles_fold<M: criterion::measurement::Measurement>(
group: &mut BenchmarkGroup<M>,
bench_name: &str,
states: CanisterStates,
) {
group.bench_function(bench_name, |b| {
b.iter(|| std::hint::black_box(states.total_consumed_cycles()));
});
}

pub fn subnet_metrics_benchmark(c: &mut Criterion) {
let mut group = c.benchmark_group("subnet_metrics_consumed_cycles_fold");
for n in [0_u64, 1_000, 10_000, 100_000] {
let label = match n {
0 => "0".to_string(),
n if n % 1_000 == 0 => format!("{}k", n / 1_000),
n => n.to_string(),
};
bench_consumed_cycles_fold(
&mut group,
&format!("hot/{label}/sequential"),
hot_canister_states(n),
);
bench_consumed_cycles_fold(
&mut group,
&format!("hot/{label}/shuffled"),
shuffled_hot_canister_states(n),
);
}
group.finish();
}

criterion_group!(benchmarks, subnet_metrics_benchmark);
criterion_main!(benchmarks);
Loading
Loading