Skip to content

feat: [DSM-148] Larger XNet pool - #11343

Open
alin-at-dfinity wants to merge 3 commits into
masterfrom
alin/DSM-148-larger-XNet-pool
Open

feat: [DSM-148] Larger XNet pool#11343
alin-at-dfinity wants to merge 3 commits into
masterfrom
alin/DSM-148-larger-XNet-pool

Conversation

@alin-at-dfinity

Copy link
Copy Markdown
Contributor

Increase the XNet pool soft byte limit from 10 MB to 128 MB. Apply a dynamic pooled slice size limit of 1/16 of the space left (8 MB or two XNet payloads' worth initially, less as it fills up), so per-slice limits taper.

This is necessary in order to provide real fairness across XNet peers: with the earlier limits (10 MB pool size, fixed 4 MB max per slice) a couple of slices could fill the pool, particularly once we switched to adverts (the existing sweep at least gives everyone equal chances).

Increase the XNet pool soft byte limit from 10 MB to 128 MB. Apply a dynamic pooled slice size limit of 1/16 of the space left (8 MB or two XNet payloads' worth initially, less as it fills up), so per-slice limits taper.

This is necessary in order to provide real fairness across XNet peers: with the earlier limits (10 MB pool size, fixed 4 MB max per slice) a couple of slices could fill the pool, particularly once we switched to adverts (the existing sweep at least gives everyone equal chances).
@alin-at-dfinity
alin-at-dfinity requested a review from a team as a code owner August 27, 2026 08:04
@github-actions github-actions Bot added the feat label Aug 27, 2026
@zeropath-ai

zeropath-ai Bot commented Aug 27, 2026

Copy link
Copy Markdown

No security or compliance issues detected. Reviewed everything up to 4442c87.

Security Overview
Detected Code Changes
Change Type Relevant files
Enhancement ► rs/xnet/payload_builder/src/lib.rs
    Increase POOL_BYTE_SIZE_SOFT_CAP from 10 MiB to 128 MiB
► rs/xnet/payload_builder/src/lib.rs
    Rename POOL_SLICE_BYTE_SIZE_MAX to POOLED_SLICE_BYTE_SIZE_MAX and adjust logic to use a dynamic divisor
► rs/xnet/payload_builder/src/lib.rs
    Add POOLED_SLICE_BYTE_SIZE_DIVISOR and adjusted_byte_limit(...) helper
► rs/xnet/payload_builder/src/lib.rs
    Modify refill_stream_slice_indices(...) to compute remaining pool space and dynamic per-slice size
► rs/xnet/payload_builder/src/lib.rs
    Update slice size calculations when rebuilding indices and limits
► rs/xnet/payload_builder/src/lib.rs
    Adjust XNetClient usage to limit body size with 2 * POOLED_SLICE_BYTE_SIZE_MAX instead of 5 * POOL_SLICE_BYTE_SIZE_MAX
► rs/xnet/payload_builder/src/lib.rs
    Update testing module imports to include new constants and helper functions (adjusted_byte_limit, POOLED_SLICE_BYTE_SIZE_DIVISOR, POOL_BYTE_SIZE_SOFT_CAP) and adjust expected calculations
► rs/xnet/payload_builder/src/test_fixtures.rs
    Update PAYLOAD_BYTES_LIMIT to 4 MiB constant using new calculation
► rs/xnet/payload_builder/tests/xnet_payload_builder.rs
    Import updated constants and helpers; utilize adjusted_byte_limit in tests
► rs/xnet/payload_builder/tests/xnet_payload_builder.rs
    Add tests for empty pool and non-empty pool behavior with new per-slice size logic
► rs/xnet/payload_builder/tests/xnet_payload_builder.rs
    Adjust test expectations to reflect new byte limit calculations using adjusted_byte_limit and POOLED_SLICE_BYTE_SIZE_DIVISOR

Comment on lines +1372 to +1375
// Maximum byte size of any one pooled slice, applied uniformly across slices:
// as the pool fills up, we aim to fill what is left of it with (more, but)
// smaller slices.
let slice_byte_size_max = pool_bytes_left / POOLED_SLICE_BYTE_SIZE_DIVISOR;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I understand correctly, this means whenever bytes from one subnet are included in a block, the capacity in the pool increases for all other subnets. As a result, we will pull new data from all subnets and append them to their existing slices. Throughout the call to append we are holding the same lock that the payload builder needs to acquire.

How expensive is this append? I imagine if it takes very long then that would impact the finalization rate. Or is the assumption that, in general, cross-net traffic should be low?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The limit for the next pull increases, yes. But I'm pretty sure that we rarely reach even the 10 MB limit, leave alone 128 MB.

As for how expensive it is, we decode a protobuf-encoded slice (although we could do that before grabbing the lock), take the messages (as blobs) and move them next to the messages already in the pool, then do some basic validation. Overall, not particularly cheap, but also not expensive enough to really matter. It's all in-memory shuffling of pointers.

Looking at it differently, given our maximum throughput of ~10 MB/s and a maximum of 2.5 new slices per second per subnet, this is far from being a bottleneck. Likely not even if you repeatedly appended one zero-payload message at a time at a rate of 10 MB/s.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we rarely reach even the 10 MB limit, leave alone 128 MB

Then do we really need to increase the limit if we don't even expect to reach the current one?

It's all in-memory shuffling of pointers

To me it looks like we also verify a BLS signature, clone the data twice, and proto encode/decode a second time

given our maximum throughput of ~10 MB/s and a maximum of 2.5 new slices per second

I guess this assumes that consensus can keep up with the rate of ingestion and no XNet backlog is building up? To me the problem is that the complexity of append seems to be O(merged slice) and not O(suffix)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then do we really need to increase the limit if we don't even expect to reach the current one?

We do, because reaching the current limit means we cannot provide any kind of fairness. Whatever algorithm we use for fairly picking slices for the next block, will be forced to pick between 3 slices, with everyone else kept out of the pool. Even ignoring that, if we start with an empty pool; add a couple of huge slices and a whole lot of smaller ones; then we will essentially end up including those exact pool contents (in whatever order) before we can pool any new slices. So subnets producing a trickle of messages are necessarily limited by our throughput vs the volume of a couple of noisy streams.

To me it looks like we also verify a BLS signature, clone the data twice, and proto encode/decode a second time

You are, unfortunately, right. I had missed the decode_certified_stream_slice() call. I suppose we can split append() (and put()) into two steps: a first step that retrieves the existing slice under a readonly lock and does the work; and a second step that (under a read-write lock) conditionally replaces the pooled slice iff it has not changed in the meantime. It will break the abstraction, but I suppose it can't be helped. It's also something for a separate PR.

I guess this assumes that consensus can keep up with the rate of ingestion and no XNet backlog is building up?

It looks at the worst-case scenario (for the XNet pool) of Consensus running at a full 2.5 blocks/s, yes. And whether or not there's a backlog, its growth (or rather advance) would necessarily be limited to an average of 10 MB/s by payload builder throughput. There's not much point in the pool having a much higher worst-case throughput than that.

To me the problem is that the complexity of append seems to be O(merged slice) and not O(suffix)

Streams are limited to 10 MB (and 10k messages) on the sender side (also for reasons of fairness; if we were to dump 100 MB from a single canister into the stream, other canisters would have to wait minutes before their messages could be picked up; and given a maximum of 5 minutes TTL for bounded wait calls, minutes of waiting in either direction would cause most calls to time out).

So one can also look at this as constant time. For a reasonably large constant. (o;

But yeah, you are right. It's all down to verification (which we may choose to skip in the pool at the cost of making it impossible to pinpoint misbehaving peers) and particularly to doing said verification while holding the lock on the pool.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Streams are limited to 10 MB

Ah this is something I missed, then maybe it isn't as bad as I thought. Still could be interesting to have some benchmarks to see how the (new) limits behave under higher load than we might see today. But I agree it could be done in a different PR.

pub const POOL_SLICE_BYTE_SIZE_MAX: usize = 4 << 20;
/// Hard maximum slice size in bytes. We only pool up to 8 MB (two blocks' worth
/// of payload) from any one stream.
pub const POOLED_SLICE_BYTE_SIZE_MAX: usize = 8 << 20;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude tells me there are some histogram buckets that we should update for this new limit

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also some grafana panels that make assumptions about xnet_pool_size_bytes

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was only able to find two histograms that track stream slice sizes: xnet_pool_take_size_bytes and xnet_builder_slice_payload_size_bytes. And they both track slices in / going into XNet payloads. These are still limited by the same 4 MB payload size, so no change there.

As for xnet_pool_size_bytes, it is used to estimate "artifact pool latency" (mean time spent in the XNet pool and/or the ingress artifact pool) on the Subnet Load and IC Health dashboards; and displayed in the Memory Usage, by Component of the IC Health dashboard. But neither of them have fixed ranges. The Subnet Load dashboard has a threshold at 2 seconds of latency displayed as a filled region, but that gets displayed if the X axis exceeds 2 seconds and not displayed at all otherwise, it's not a hard limit.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose that the 2 second threshold does make an assumption about xnet_pool_size_bytes (i.e. it should be smaller than ~20 MB under ideal conditions), but bumping the pool size does not change the fact that 2 seconds end-to-end latency is an (arbitrary) threshold beyond which user experience is meaningfully affected.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about these (they also say 10 B - 5 MB)?

  • xnet_builder_response_body_size_bytes
  • xnet_endpoint_slice_payload_size_bytes
  • xnet_endpoint_response_size_bytes

/// we're polling multiple subnets in parallel and we don't want to discard
/// slices that we've already pulled.
pub const POOL_BYTE_SIZE_SOFT_CAP: usize = 10 << 20;
pub const POOL_BYTE_SIZE_SOFT_CAP: usize = 128 << 20;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this limit actually be reached still (as the name and comment would suggest)?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you have enough streams with enough large messages (which we never do, AFAIK), yes.

In the current "poll everyone" implementation, if there are N bytes available in the pool, we poll every other subnet with a limit of N/16. If more than 16 subnets respond with enough bytes, we may well exceed the limit.

In the future, with advert triggered polling, while we won't be polling everyone at once with the same byte limit, there's still a race condition between when we compute the limit (before querying) and when we pool the response. And any (or well, a limited) number of other pulls could happen concurrently, with the same or a similar limit.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I think I was confused by POOL_BYTE_SIZE_SOFT_CAP being both an asymptotic limit (which should never be reached), but also a soft cap in the sense that it may be exceeded transiently

Comment thread rs/xnet/payload_builder/src/test_fixtures.rs Outdated
Comment thread rs/xnet/payload_builder/tests/xnet_payload_builder.rs Outdated
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants