Skip to content
152 changes: 151 additions & 1 deletion pallets/subtensor/src/benchmarks/benchmarks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use crate::Pallet as Subtensor;
use crate::staking::lock::LockState;
use crate::subnets::mechanism::GLOBAL_MAX_SUBNET_COUNT;
use crate::subnets::subnet::{NetworkRegistrationInfo, RefusalWindow};
use crate::*;
use codec::{Compact, Encode};
use frame_benchmarking::v2::*;
Expand Down Expand Up @@ -301,10 +302,50 @@ mod pallet_benchmarks {
let amount: u64 = 100_000_000_000_000u64.saturating_mul(2);
add_balance_to_coldkey_account::<T>(&coldkey, amount.into());

frame_system::Pallet::<T>::set_block_number(1u32.into());
fill_subnets_below_limit::<T>(&account("FillOwner", 0, seed));

#[extrinsic_call]
_(RawOrigin::Signed(coldkey.clone()), hotkey.clone());
}

/// The queueing outcome of `do_register_network`: at `SubnetLimit` the call prunes a subnet and
/// queues rather than creating one. Not an extrinsic; it exists so the dispatches that can reach
/// this branch have a worst case to charge.
///
/// This also covers the third outcome, `wait_to_cleanup`. That path reaches the same queueing
/// block without running `get_network_to_prune` or `do_dissolve_network`, so at equal queue
/// depth it is strictly cheaper than this one. Queue depth itself is measured at neither: both
/// queues are decoded unconditionally on every call and neither is a `BoundedVec`, so there is
/// no maximum to benchmark against.
#[benchmark]
fn register_network_pruning() {
let seed: u32 = 1;
let coldkey: T::AccountId = account("Test", 0, seed);
let hotkey: T::AccountId = account("TestHotkey", 0, seed);

Subtensor::<T>::set_network_rate_limit(1);
let amount: u64 = 100_000_000_000_000u64.saturating_mul(2);
add_balance_to_coldkey_account::<T>(&coldkey, amount.into());

frame_system::Pallet::<T>::set_block_number(1u32.into());
fill_subnets_to_limit::<T>(&account("FillOwner", 0, seed));

#[block]
{
assert_ok!(Subtensor::<T>::do_register_network(
RawOrigin::Signed(coldkey.clone()).into(),
&hotkey,
1,
Some(max_subnet_identity()),
));
}

// The prune branch specifically. `NetworkRegistrationQueue` grows on the window branch
// too, so its length alone cannot tell the two apart; a started teardown can.
assert!(!DissolveCleanupQueue::<T>::get().is_empty());
}

#[benchmark]
fn commit_weights() {
let tempo: u16 = 1;
Expand Down Expand Up @@ -1449,13 +1490,16 @@ mod pallet_benchmarks {
fn register_network_with_identity() {
let coldkey: T::AccountId = whitelisted_caller();
let hotkey: T::AccountId = account("Alice", 0, 1);
let identity: Option<SubnetIdentityOfV3> = None;
let identity: Option<SubnetIdentityOfV3> = Some(max_subnet_identity());

Subtensor::<T>::set_network_registration_allowed(1.into(), true);
Subtensor::<T>::set_network_rate_limit(1);
let amount: u64 = 9_999_999_999_999;
add_balance_to_coldkey_account::<T>(&coldkey, amount.into());

frame_system::Pallet::<T>::set_block_number(1u32.into());
fill_subnets_below_limit::<T>(&account("FillOwner", 0, 1));

#[extrinsic_call]
_(
RawOrigin::Signed(coldkey.clone()),
Expand Down Expand Up @@ -1900,6 +1944,112 @@ mod pallet_benchmarks {
assert_eq!(TokenSymbol::<T>::get(netuid), new_symbol);
}

/// Answering a challenge scans the queue for the challenger, removes them and releases their
/// lock, so the queue is filled to its bound and the matching entry put last, which is the
/// worst case for both the scan and the write-back. The bound is one queued challenge per
/// subnet that can have a window open at once.
#[benchmark]
fn exercise_first_refusal() {
let coldkey: T::AccountId = whitelisted_caller();
let netuid = NetUid::from(1);
Subtensor::<T>::init_new_network(netuid, 1);
SubnetOwner::<T>::insert(netuid, coldkey.clone());
NetworkImmunityPeriod::<T>::set(1);

frame_system::Pallet::<T>::set_block_number(1u32.into());
let offer = TaoBalance::from(1_000_000_u64);
add_balance_to_coldkey_account::<T>(&coldkey, offer.saturating_mul(2.into()));
TotalIssuance::<T>::mutate(|total| *total = total.saturating_add(offer));

let challenger_lock_id = u32::from(Subtensor::<T>::get_max_subnets());
NetworkRegistrationQueue::<T>::set(
(0..=challenger_lock_id)
.map(|lock_id| NetworkRegistrationInfo::<T::AccountId> {
coldkey: account("Challenger", 0, lock_id),
hotkey: account("ChallengerHotkey", 0, lock_id),
mechid: 1,
identity: Some(max_subnet_identity()),
lock_amount: offer,
median_subnet_alpha_price: U64F64::saturating_from_num(1),
registration_block: 1,
lock_id,
})
.collect::<Vec<_>>(),
);

SubnetRefusalWindow::<T>::insert(
netuid,
RefusalWindow {
offer,
expires_at: u64::MAX,
challenger_lock_id,
immunity_period: 1,
},
);

// The lock the call releases has to exist, or the `Locks` write is not measured.
let challenger: T::AccountId = account("Challenger", 0, challenger_lock_id);
add_balance_to_coldkey_account::<T>(&challenger, offer.saturating_mul(2.into()));
assert_ok!(Subtensor::<T>::lock_network_registration_cost(
&challenger,
offer.into(),
challenger_lock_id
));

#[extrinsic_call]
_(RawOrigin::Signed(coldkey), netuid);

assert!(!SubnetRefusalWindow::<T>::contains_key(netuid));
assert!(NetworkImmuneUntil::<T>::get(netuid) > 1);
assert!(
!NetworkRegistrationQueue::<T>::get()
.iter()
.any(|entry| entry.lock_id == challenger_lock_id)
);
}

/// Cancelling scans the queue for the caller's entry and writes back what is left, so the queue
/// is filled to its bound and the caller's entry put last, the worst case for both.
#[benchmark]
fn cancel_network_registration() {
let coldkey: T::AccountId = whitelisted_caller();
let lock_amount = TaoBalance::from(1_000_000_u64);
add_balance_to_coldkey_account::<T>(&coldkey, lock_amount.saturating_mul(2.into()));

let lock_id = u32::from(Subtensor::<T>::get_max_subnets());
NetworkRegistrationQueue::<T>::set(
(0..=lock_id)
.map(|id| NetworkRegistrationInfo::<T::AccountId> {
coldkey: if id == lock_id {
coldkey.clone()
} else {
account("Queued", 0, id)
},
hotkey: account("QueuedHotkey", 0, id),
mechid: 1,
identity: Some(max_subnet_identity()),
lock_amount,
median_subnet_alpha_price: U64F64::saturating_from_num(1),
registration_block: 1,
lock_id: id,
})
.collect::<Vec<_>>(),
);
assert_ok!(Subtensor::<T>::lock_network_registration_cost(
&coldkey,
lock_amount.into(),
lock_id
));

#[extrinsic_call]
_(RawOrigin::Signed(coldkey), lock_id);

assert_eq!(
NetworkRegistrationQueue::<T>::get().len(),
usize::from(Subtensor::<T>::get_max_subnets())
);
}

#[benchmark]
fn commit_timelocked_weights() {
let hotkey: T::AccountId = whitelisted_caller();
Expand Down
141 changes: 141 additions & 0 deletions pallets/subtensor/src/benchmarks/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,147 @@ pub(super) fn set_reserves<T: Config>(
SubnetAlphaIn::<T>::insert(netuid, alpha_in);
}

/// The lock held by the challenger behind every window `fill_subnets` stamps.
///
/// Nonzero so it cannot collide with the id `NetworkRegistrationLockId` hands the registration
/// under measurement, which would put two entries in the queue under one lock.
pub(super) const LAPSED_CHALLENGER_LOCK_ID: u32 = 1;

/// A refusal window that closed at block zero, so the candidate is evictable at any later block.
///
/// The at-limit benchmark uses this because clearing a lapsed window and then evicting is the
/// heaviest way step 5 can go. Opening a fresh window instead does strictly less work, and being
/// refused for a live one does less still.
///
/// A window is only lapsed while its challenger is still queued: `refusal_state` tests challenger
/// presence first, so a window naming a lock id absent from `NetworkRegistrationQueue` reports as
/// `Unchallenged` and the registration opens a fresh window rather than pruning. `fill_subnets`
/// therefore queues the matching entry, and without it this fixture measures the wrong branch.
pub(super) fn lapsed_refusal_window() -> RefusalWindow {
RefusalWindow {
offer: TaoBalance::from(0u64),
expires_at: 0,
challenger_lock_id: LAPSED_CHALLENGER_LOCK_ID,
immunity_period: 0,
}
}

/// The largest identity `is_valid_subnet_identity` accepts: 6,656 bytes across the eight fields.
///
/// Both registration outcomes carry the identity. Creation writes it to `SubnetIdentitiesV3`;
/// queueing encodes it into the `NetworkRegistrationQueue` entry and again into the event. `None`
/// measures neither.
pub(super) fn max_subnet_identity() -> SubnetIdentityOfV3 {
SubnetIdentityOfV3 {
subnet_name: vec![b'n'; 256],
github_repo: vec![b'g'; 1024],
subnet_contact: vec![b'c'; 1024],
subnet_url: vec![b'u'; 1024],
discord: vec![b'd'; 256],
description: vec![b'e'; 1024],
logo_url: vec![b'l'; 1024],
additional: vec![b'a'; 1024],
}
}

/// Fill the chain with prunable subnets so a registration benchmark runs against a realistic
/// `NetworksAdded` map rather than an empty one.
///
/// `do_register_network` counts every entry in `NetworksAdded` on every call, and once that count
/// reaches `SubnetLimit` it also walks `get_network_to_prune`. Registering into an empty chain
/// measures neither. Callers pick which outcome they are measuring via `subnets`: below the limit
/// the call creates a subnet (many writes), at the limit it prunes and queues instead (far more
/// reads). Neither dominates the other.
///
/// Immunity is set to one block and `NetworkRegisteredAt` to zero so no candidate is skipped.
/// Requires a current block above zero.
///
/// Every subnet is made dynamic with a funded pool, and both prices are staggered per subnet.
///
/// Two independent scans walk `NetworksAdded`, and a fixture of stable, empty-pool subnets makes
/// both of them exit early on every entry:
///
/// - `get_network_to_prune` calls `get_moving_alpha_price`, which returns on `SubnetMechanism == 0`
/// without reading `SubnetMovingPrice`.
/// - `get_median_subnet_alpha_price` calls swap `current_price`, which returns on a non-dynamic
/// mechanism, and on a dynamic one still returns before reading the TAO reserve or the balancer
/// unless the alpha reserve is nonzero.
///
/// So the scans would walk 128 entries while pricing none, which is the same class of mistake as
/// benchmarking against an empty chain. Staggering matters as well as seeding: the median builds a
/// `BTreeMap` keyed on price, and identical reserves collapse it to a single entry.
pub(super) fn fill_subnets<T: Config>(owner: &T::AccountId, subnets: u16) {
NetworkImmunityPeriod::<T>::set(1);

for netuid in 1..=subnets {
let offset = u64::from(netuid);
let netuid = NetUid::from(netuid);

Subtensor::<T>::init_new_network(netuid, 1);
NetworkRegisteredAt::<T>::insert(netuid, 0);
SubnetOwner::<T>::insert(netuid, owner.clone());
SubnetMechanism::<T>::insert(netuid, 1);
set_reserves::<T>(
netuid,
TaoBalance::from(150_000_000_000_u64.saturating_add(offset.saturating_mul(1_000_000))),
AlphaBalance::from(100_000_000_000_u64),
);
SubnetMovingPrice::<T>::insert(
netuid,
I96F32::saturating_from_num(1_000_u64.saturating_add(offset))
.saturating_div(I96F32::saturating_from_num(1_000)),
);
SubnetRefusalWindow::<T>::insert(netuid, lapsed_refusal_window());
}

// Queue depth is measured at one entry per subnet slot, and two paths reach it. The scan skips
// a subnet whose window is still live and names the next candidate, so every evictable subnet
// can carry its own window and its own queued challenger at the same time. The
// `wait_to_cleanup` path reaches the same depth from the other direction, with registrations
// queued behind dissolutions already in flight and `DissolveCleanupQueue` unbounded. Every
// entry carries a maximum identity because the whole queue is decoded and written back when
// the registration under measurement is pushed. `LAPSED_CHALLENGER_LOCK_ID` goes last, the
// worst case for the scan that resolves the window.
let mut queued: Vec<NetworkRegistrationInfo<T::AccountId>> = (0..subnets)
.map(|index| NetworkRegistrationInfo::<T::AccountId> {
coldkey: account("QueuedChallenger", 0, u32::from(index)),
hotkey: account("QueuedChallengerHotkey", 0, u32::from(index)),
mechid: 1,
identity: Some(max_subnet_identity()),
lock_amount: TaoBalance::from(0u64),
median_subnet_alpha_price: U64F64::saturating_from_num(1),
registration_block: 0,
lock_id: LAPSED_CHALLENGER_LOCK_ID
.saturating_add(u32::from(index))
.saturating_add(1),
})
.collect();
if let Some(last) = queued.last_mut() {
last.lock_id = LAPSED_CHALLENGER_LOCK_ID;
}
let next_lock_id = LAPSED_CHALLENGER_LOCK_ID
.saturating_add(u32::from(subnets))
.saturating_add(1);
NetworkRegistrationQueue::<T>::set(queued);
NetworkRegistrationLockId::<T>::set(next_lock_id);

let price = Subtensor::<T>::get_network_lock_cost();
add_balance_to_coldkey_account::<T>(owner, price.saturating_mul(2.into()));
TotalIssuance::<T>::mutate(|total| *total = total.saturating_add(price));
}

/// One below `SubnetLimit`: the registration still creates a subnet, but pays the full count.
pub(super) fn fill_subnets_below_limit<T: Config>(owner: &T::AccountId) {
let limit = Subtensor::<T>::get_max_subnets();
fill_subnets::<T>(owner, limit.saturating_sub(1));
}

/// At `SubnetLimit`: the registration prunes a subnet and queues instead of creating one.
pub(super) fn fill_subnets_to_limit<T: Config>(owner: &T::AccountId) {
let limit = Subtensor::<T>::get_max_subnets();
fill_subnets::<T>(owner, limit);
}

pub(super) fn benchmark_registration_burn() -> TaoBalance {
TaoBalance::from(1_000_000)
}
Expand Down
20 changes: 19 additions & 1 deletion pallets/subtensor/src/coinbase/root.rs
Original file line number Diff line number Diff line change
Expand Up @@ -296,8 +296,17 @@ impl<T: Config> Pallet<T> {
LastRateLimitedBlock::<T>::remove(rate_limit_key);
}

/// Whether a subnet is currently shielded from pruning, either by the immunity its
/// registration block grants or by immunity its owner has paid for. `registered_at` is
/// taken as an argument because every caller already holds it.
pub fn is_subnet_immune(netuid: NetUid, registered_at: u64, current_block: u64) -> bool {
current_block < registered_at.saturating_add(Self::get_network_immunity_period())
|| current_block < NetworkImmuneUntil::<T>::get(netuid)
}

pub fn get_network_to_prune() -> Option<NetUid> {
let current_block: u64 = Self::get_current_block_as_u64();
let queue = NetworkRegistrationQueue::<T>::get();

let mut candidate_netuid: Option<NetUid> = None;
let mut candidate_price: U64F64 = U64F64::saturating_from_num(u128::MAX);
Expand All @@ -311,7 +320,16 @@ impl<T: Config> Pallet<T> {
let registered_at = NetworkRegisteredAt::<T>::get(netuid);

// Skip immune networks.
if current_block < registered_at.saturating_add(Self::get_network_immunity_period()) {
if Self::is_subnet_immune(netuid, registered_at, current_block) {
continue;
}

// Skip a subnet whose owner is still inside the window to answer. Passing over it
// rather than refusing the registration outright is what stops one open window from
// holding up every registration on the chain: the scan falls through to the next
// candidate, and freezing registrations now costs a challenger one full lock per
// evictable subnet held at once rather than one lock total.
if Self::refusal_window_is_live(netuid, current_block, &queue) {
continue;
}

Expand Down
Loading