Skip to content

Benchmark register_network against a full subnet map - #3022

Closed
philipmaymin wants to merge 3 commits into
RaoFoundation:mainfrom
philipmaymin:fix/register-network-benchmark-saturates-limit
Closed

Benchmark register_network against a full subnet map#3022
philipmaymin wants to merge 3 commits into
RaoFoundation:mainfrom
philipmaymin:fix/register-network-benchmark-saturates-limit

Conversation

@philipmaymin

@philipmaymin philipmaymin commented Jul 31, 2026

Copy link
Copy Markdown

What

register_network's benchmark registers into a chain with no subnets on it. Everything expensive the extrinsic does scales with how many subnets already exist, so none of it lands in the measurement.

Three costs scale with the subnet count and the benchmark misses all three.

do_register_network counts the subnet map on every call:

let current_count: u16 = NetworksAdded::<T>::iter()
    .filter(|(netuid, added)| *added && *netuid != NetUid::ROOT)
    .count() as u16;

That runs unconditionally. On a chain at the 128-subnet limit the iterator reads all 129 entries, root included, before the filter drops it. The weight this replaces annotates NetworksAdded (r:3 w:1).

If the count has reached SubnetLimit, get_network_to_prune walks the map a second time, reading NetworkRegisteredAt and the moving price for every entry, and do_dissolve_network runs on whichever subnet loses. get_median_subnet_alpha_price then walks it a third time, asking the swap pallet for each subnet's price. That one runs on both outcomes, on every call that clears the lock-cost check.

Measured against a full chain instead of an empty one, register_network is 671 reads and 25.18 ms of charged weight, against the 41 reads and 5.98 ms it charges today. Proof size goes from 9,947 to 327,961.

Why now

The benchmark measures a branch mainnet does not take. At block 8,739,071, NetworksAdded holds 128 live non-root entries against a SubnetLimit of 128. The chain sits exactly at the cap, so every registration on finney right now takes the at-limit path: the full count, the median scan, the prune scan, then the queue. That path measures at 916 reads and charges 41.

The gap also gets worse soon. The v440 notes say the price of entry should fall toward the cost of the registration transaction, which leaves the weight as most of what a registration costs.

The fix

A helper that fills NetworksAdded before the measurement, and a second benchmark for the branch that was never measured at all.

Filling the map isn't enough on its own. If you're checking the fixture, check this: both price scans exit early on a subnet that is stable or has an empty pool.

  • get_moving_alpha_price returns on SubnetMechanism == 0 without reading SubnetMovingPrice. init_new_network doesn't set a mechanism, so a naively built subnet is stable.
  • Swap current_price 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.

A fixture of stable, empty-pool subnets therefore walks 128 entries while pricing none, which is the same mistake in a subtler form. So every synthetic subnet gets a mechanism of 1 and a funded pool, and the prices are staggered rather than identical, because the median builds a BTreeMap keyed on price and one repeated value collapses it to a single entry.

Where an identity is in play it is measured at its maximum of 6,656 bytes, the largest is_valid_subnet_identity accepts. register_network takes no identity argument, so that row measures None; register_network_with_identity and register_network_pruning carry a full one. It matters most on the queueing path, which encodes the identity into the queue entry and again into the event.

The three outcomes

do_register_network has three successful ends, not two:

what happens
below the limit creates a subnet
at the limit prunes one and queues
at the limit, cleanups already in flight queues to wait

The third joins the second at the same queueing block but gets there without running get_network_to_prune or do_dissolve_network, so at equal queue depth it is strictly cheaper and needs no separate measurement.

The first two are mutually exclusive:

ref_time proof_size reads writes charged
creating, one below the limit 3,505,837,000 327,961 671 49 25.18 ms
pruning, at the limit 4,090,472,000 333,284 916 10 27.99 ms

So register_network is benchmarked one below the limit, where it still pays the count and the median scan but takes the creation path, and a new register_network_pruning covers the other one. register_network_pruning is not an extrinsic. It exists only so the dispatches that can reach that branch have a worst case to charge.

Both dispatches charge the componentwise .max() of the pair. Pruning happens to dominate both dimensions under RocksDbWeight, but that isn't structural: creation does 39 more writes and 245 fewer reads, so the ordering depends on the read/write ratio in DbWeight. .max() is correct whichever way that falls.

What this still doesn't measure

Two pieces of the state these weights depend on have no bound, so no constant can cover them:

  • sudo_set_subnet_limit only checks ensure_root. The subnet count is root-settable with no ceiling.
  • DissolveCleanupQueue and NetworkRegistrationQueue are plain Vec rather than BoundedVec, and both get decoded on every call before any branch is chosen. Reaching the queueing branch appends to the second one.

I measured the second one on register_network, so it gets a number instead of a caveat. Every figure above is taken with both queues empty. A NetworkRegistrationInfo entry encodes to 6,775 bytes at a maximum identity, so filling the queue to one entry per subnet slot adds 860,805 bytes of proof, 3.6x the table. The annotation set does not change: no new key is read, the keys already being read just hold more. register_network_pruning moves the same way for the same reason. I am not quoting a figure for it, because the only measurement I have of that path came off a branch carrying unrelated changes.

So this correction is partial. Empty-queue is not a defensible worst case, and neither is any particular depth while the type is unbounded. Fixing it properly means bounding the storage or adding a weight component, which needs its own migration and review. I kept that out of a measurement fix, but I'll write it if you want both together.

The same empty-chain problem exists in a sibling I have not touched here. register_leased_network (pallets/subtensor/src/benchmarks/benchmarks.rs:1797 on this branch) does not fill the subnet map either, so a lease that succeeds below the cap on a populated chain walks all of NetworksAdded and all of SubnetOwner, then refunds post-dispatch to a constant measured against an empty one. For a two-contributor lease that is a refund from roughly 1.30 MB down to about 15.4 KB. It is the same bug this PR describes, it predates this PR, and it is a two-line fixture change on top of the helper added here. I can include it if you want.

One consequence

LinearWeightToFee is degree 1 with coeff_frac of 500,000 parts per billion, so the weight component of the fee tracks the declared weight directly. Correcting the weight raises that component from about 0.00299 TAO to about 0.0140 TAO. pallet_transaction_payment charges a base and a length fee on top, so those figures are not the whole of what a registration pays, only the part this PR moves. Both are small next to the lock, but the change is real, so it belongs in the description.

Because that is a runtime behavior change, spec_version goes 440 to 441. The weight constants live in the runtime wasm, so a corrected measurement only reaches a chain through a release. Mainnet is on 440. No migration: nothing moves storage or call index, so transaction_version stays 1.

Testing

Existing tests unchanged and passing. No new tests: this changes a benchmark fixture and the weights generated from it, and the benchmark is the measurement. Weights measured locally with the repo's own flags from scripts/benchmark_action.sh. run-benchmarks.yml skips forks, so the bot will need to re-run these.

Related Issue(s)

None that this fixes. #3024 depends on it, and #3023 is stacked on this branch: both cite these numbers, so a change here moves them.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • Other (please describe):

Breaking Change

No API or storage change. The declared weight of register_network and register_network_with_identity rises, which raises the weight component of the fee they pay from about 0.00299 TAO (0.00292 for the identity variant) to about 0.0140 TAO. That is the intended correction rather than a side effect, and it is why spec_version goes to 441.

Checklist

  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have run ./scripts/fix_rust.sh to fix formatting and clippy issues
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • Any dependent changes have been merged and published in downstream modules

Additional Notes

run-benchmarks.yml gates the self-hosted runner on head.repo.fork == false, so the benchmark bot will not run against this branch. Every number here was measured on my hardware with the repo's own flags from scripts/benchmark_action.sh. Treat them as a claim about the shape of the gap rather than as constants to ship, and re-run them on yours before merging. If it helps, add the run-benchmarks label or push the branch into the repo and I will rebase onto whatever it produces.

This is deliberately a partial correction, for the reasons in "What this still doesn't measure" above. Landing the measurement that is already wrong beat bundling a redesign into it. Tell me if you want both at once.

register_network's benchmark registers into a chain with no subnets on
it, so neither of the two costs that scale with subnet count lands in
the measurement: the unconditional NetworksAdded::iter() count, and the
get_network_to_prune scan that runs once the count reaches SubnetLimit.

Fill the map before measuring, and add register_network_pruning for the
branch that was never measured at all. The two outcomes are mutually
exclusive and neither dominates: creation costs more ref_time, pruning
more proof_size. Both dispatches charge the componentwise max.

register_network measures 293 reads and 14.39 ms of charged weight
against the 41 reads and 5.98 ms it currently declares.
fill_subnets created stable subnets with empty pools, so both price scans over
NetworksAdded exited early on every entry. get_moving_alpha_price returns on
SubnetMechanism == 0 without reading SubnetMovingPrice, and swap current_price
returns before reading the TAO reserve or the balancer unless the alpha reserve
is nonzero. The map was walked while nothing on it was priced.

Every synthetic subnet now gets a dynamic mechanism and a funded pool. Reserves
and moving prices are staggered per subnet so the median's BTreeMap holds
distinct keys instead of collapsing to a single entry.

register_network_with_identity and register_network_pruning now carry a maximum
6,656 byte identity. Creation writes it to storage; queueing encodes it into the
queue entry and again into the event. Passing None measured neither, which is
why main charges register_network_with_identity less than register_network on
every dimension despite it doing strictly more work.

register_network 293 -> 671 reads, register_network_pruning 408 -> 916,
register_network_with_identity 292 -> 670.

do_register_network has three successful outcomes, not two. The third,
wait_to_cleanup, reaches the same queueing block without running
get_network_to_prune or do_dissolve_network, so at equal queue depth it is
dominated by the pruning measurement. Comments corrected to say so.
The weight constants in pallets/subtensor live in the runtime wasm, so the
corrected register_network measurement only reaches a chain through a runtime
release. Mainnet is on 440.
@vercel

vercel Bot commented Jul 31, 2026

Copy link
Copy Markdown

@philipmaymin is attempting to deploy a commit to the RaoFoundation Team on Vercel.

A member of the Team first needs to authorize it.

@UnArbosSix

Copy link
Copy Markdown
Collaborator

this will be covered here #2997

@UnArbosSix UnArbosSix closed this Jul 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants