Repository navigation
The I219 throttles its interrupts to 128 µs, netstack reads a send pipe only when the kernel says it holds bytes, and one receive bound for every card owes the next pass at once - #832
Conversation
…t holds bytes On the T14, round 1's profile counted 101,400 reads of the download's send pipe in one 170 MB download and 101,395 of them empty: one a pass, because every pass over the streams read every send pipe [tcp] had room for, whether or not anything had been written. Each is a syscall, and the HTTPS client writes its request and nothing after it. A stream now remembers that its last read found the pipe empty, and a pass skips the read until the kernel answers its READABLE watch, or the client writes no more, when the empty pipe is the end and is read once for it. netstack already watches such a pipe for bytes while [tcp] has room, and the kernel's arm() fires a watch at registration when the pipe already holds bytes, so a byte written between the read and the watch is answered on the next wait. Node::bridge now takes the streams whose send pipe the kernel answered readable; netstack's Sockets collects them beside its one pass a wake. The receive, deadline, close, shutdown and accept passes call the node's own pass and read only pipes not found empty. Tests: a new one holds that a batch of frames, a deadline and a to-client answer pass over the stream without reading a send pipe found empty, and that the readable answer does. The batch test's send-pipe read count goes from 1 to 0. The stream harness answers a send pipe readable as the kernel does (bytes, no writer, or no pipe), and the client-that-left test gives both of the kernel's answers to a client's leaving before it asserts the stream is gone, since the reader's leaving alone no longer reads the send pipe to its end. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C
…ream wakes netstack about ten frames at a time On the T14 round 1 measured 101,403 netstack passes for 119,890 frames received in one 170 MB download (the frames-a-pass histogram b0=28798 b1=27134 b2=44612 b3_4=730): with ITR, RDTR and RADV all zero the part raised a message for almost every frame, and netstack's CPU was about 14 us a pass (0.890 of one CPU over 1.614 s). ITR is now 500, in its 256 ns units 128 us. The 8254x family manual's ITR (sec. 13.4.18, the 82574's sec. 10.2.4.2) "guarantees the CPU is not interrupted by the Ethernet controller for 128 us from the last interrupt" at 500, its own example: the interval runs from the last interrupt, so the first frame after a quiet spell interrupts at once. QEMU's e1000e model (hw/net/e1000e_core.c, e1000e_postpone_interrupt) does the same, firing when no throttle timer runs and arming one, and already floors ITR and EITR at 500 (E1000E_MIN_XITR), so every e1000e guest has always run under this interval. Against Linux's e1000e on this part: the I219's boards carry no FLAG2_DMA_BURST, so it writes RDTR 0 and moderates by ITR alone, in its dynamic conservative mode between 20,000 interrupts a second (50 us) for light traffic and 4,000 (250 us) for bulk. 128 us is one fixed interval between the two: at a gigabit a full-sized frame takes 12.3 us on the wire, so a stream's wake carries about ten. The receive timers stay off: the manual recommends ITR over them, and RDTR holds back every frame's interrupt, the first after a quiet spell too. The bring-up test pins the interval in nanoseconds. The LAN track's "interrupt moderation (off today)" now says what is true. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C
…tops at it owes the next without a wait The I219's driver bounded a pass at RX_BUDGET (64) frames, and virtio's had no bound at all. The I219's bound stranded what it left: begin_pass takes the claim's interrupt record and acknowledges ICR before the frames are read, and the claim reads ready only while it holds an untaken interrupt, so the frames a pass left at the bound waited for the next frame's interrupt or the next deadline. At the end of an exchange no next frame comes. Found by reading on #828 and filed as issues/netstacks-virtio-receive-has-no-per-pass-budget.md. Node::receive now takes at most RECEIVE_BUDGET (64) frames from any card and answers true when it stopped at that bound, and netstack's loop then waits zero, so the next pass begins at once and reads the rest. toyos_i219::RX_BUDGET, the driver's budget field and its test are gone; a host test in the node feeds receive a source that never empties, and sees it hand over 64 frames and answer that a pass is owed, and a source it empties answer none. This closes the issue: its exit was this test, the zero wait and RX_BUDGET gone. Its one durable line is RECEIVE_BUDGET's doc comment. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C
|
Mutation patches this PR's body rests on (each applied with m1-no-throttle.patchdiff --git a/toyos-i219/src/lib.rs b/toyos-i219/src/lib.rs
index 1b4ee51d3..6c598ece6 100644
--- a/toyos-i219/src/lib.rs
+++ b/toyos-i219/src/lib.rs
@@ -942,7 +942,7 @@ impl<R: Registers, C: Clock, D: DmaBuffers, I: Interrupts> I219<R, C, D, I> {
// The throttle alone: the two receive timers hold back every frame's
// interrupt, the first after a quiet spell too, and the throttle none
// of those.
- nic.regs.write32(regs::ITR, THROTTLE);
+ nic.regs.write32(regs::ITR, 0);
nic.regs.write32(regs::RDTR, 0);
nic.regs.write32(regs::RADV, 0);
nic.regs.write32(regs::TIDV, 0);m2-read-drained.patchdiff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 1201fa2e4..6114a51da 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -276,9 +276,6 @@ impl Stream {
let mut gave_up = false;
while let Some(pipe) = self.from_client.as_mut() {
- if self.drained && !self.done_writing {
- break;
- }
// Never more than [tcp] has room for, and so never a read of no bytes, whose answer
// would be the end's; [tcp] has none once the FIN is queued.
let room = stack.tcp_status(conn).writable.min(CHUNK);m3-readable-ignored.patchdiff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 1201fa2e4..9d14dbb85 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -398,9 +398,7 @@ impl Node {
/// pipe it said holds bytes.
pub fn bridge(&mut self, now: Instant, readable: impl IntoIterator<Item = StreamId>) {
for id in readable {
- if let Some(stream) = self.streams.live.get_mut(&id) {
- stream.drained = false;
- }
+ let _ = id;
}
self.pass(now, false);
}m4-serve-forgets-readable.patchdiff --git a/userland/netstack/src/serve.rs b/userland/netstack/src/serve.rs
index 7a95dbf02..8ae0caa43 100644
--- a/userland/netstack/src/serve.rs
+++ b/userland/netstack/src/serve.rs
@@ -789,9 +789,6 @@ impl Sockets {
}
Ok(met) if met & OTHER_END_GONE != 0 => node.pipe_gone(now, ends.stream, end),
Ok(_) => {
- if end == PipeEnd::FromClient {
- self.readable.push(ends.stream);
- }
self.bridge = true;
}
}m5-bound-owes-nothing.patchdiff --git a/userland/netstack/node/src/lib.rs b/userland/netstack/node/src/lib.rs
index 3393d1c38..35b32903b 100644
--- a/userland/netstack/node/src/lib.rs
+++ b/userland/netstack/node/src/lib.rs
@@ -164,7 +164,7 @@ impl Node {
let mut taken = 0usize;
let owed = loop {
if taken == RECEIVE_BUDGET {
- break true;
+ break false;
}
if !next(&mut |frame| {
self.stack.receive(now, frame);mut2-m1-no-throttle.logmut2-m2-read-drained.logmut2-m3-readable-ignored.logmut2-m5-bound-owes-nothing.logmut-m4-serve-forgets-readable.loggatesmetal-stage.log |
|
T14 measurement for #832, run by the orchestrator. It ran on the measurement-only branch
Ubuntu was measured by hand on the same machine, link and file: curl with |
|
Review of #832 at Net lines for this PR's own commits ( What #828's landing means for this branch. #828 is queued at
BLOCKER
NOTE
SEND BACK |
|
The same run's
What these show:
|
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C
…d pass instead of naming it RECEIVE_BUDGET was pub only so that a test could name it, and no caller outside the crate reads it. The test now takes the bound from the endless ring's first batch, fails rather than hangs on a batch with no bound, and shows a ring one frame short of it is emptied and owes no pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C
|
Round 2's mutations of the receive bound, at mut.sh#!/bin/sh
cd /Users/jan/Dev/jan/toyos-netperf2
S=$1; m=$2
{
echo "head $(git rev-parse HEAD) mutation $m"
git apply --check $S/$m.patch; echo CHECK=$?
git apply $S/$m.patch; echo APPLIED=$?
cargo test --manifest-path userland/netstack/node/Cargo.toml --test streams 2>&1; echo EXIT=$?
git apply -R $S/$m.patch; echo RESTORED=$?
echo "porcelain: [$(git status --porcelain --ignore-submodules=none)]"
} > $S/mut-$m.log 2>&1m5.patch--- a/userland/netstack/node/src/lib.rs
+++ b/userland/netstack/node/src/lib.rs
@@ -164,7 +164,7 @@ impl Node {
let mut taken = 0usize;
let owed = loop {
if taken == RECEIVE_BUDGET {
- break true;
+ break false;
}
if !next(&mut |frame| {
self.stack.receive(now, frame);m6.patch--- a/userland/netstack/node/src/lib.rs
+++ b/userland/netstack/node/src/lib.rs
@@ -163,7 +163,7 @@ impl Node {
pub fn receive(&mut self, now: Instant, mut next: impl FnMut(&mut dyn FnMut(&[u8])) -> bool, mut draw: impl FnMut() -> u32) -> bool {
let mut taken = 0usize;
let owed = loop {
- if taken == RECEIVE_BUDGET {
+ if taken == usize::MAX {
break true;
}
if !next(&mut |frame| {m7.patch--- a/userland/netstack/node/src/lib.rs
+++ b/userland/netstack/node/src/lib.rs
@@ -169,7 +169,7 @@ impl Node {
self.stack.receive(now, frame);
self.settle(now, &mut draw);
}) {
- break false;
+ break taken + 1 == RECEIVE_BUDGET;
}
taken = taken.saturating_add(1);
};mut-m5.logmut-m6.logmut-m7.log |
r2-node.log at 668aafc, part 1 of 1 |
r2-host.log at 668aafc, part 1 of 10 |
r2-host.log at 668aafc, part 2 of 10 |
r2-host.log at 668aafc, part 3 of 10 |
r2-host.log at 668aafc, part 4 of 10 |
r2-host.log at 668aafc, part 5 of 10 |
r2-host.log at 668aafc, part 6 of 10 |
r2-host.log at 668aafc, part 7 of 10 |
r2-host.log at 668aafc, part 8 of 10 |
r2-host.log at 668aafc, part 9 of 10 |
r2-host.log at 668aafc, part 10 of 10 |
r2-guest.log at 668aafc, part 1 of 2 |
r2-guest.log at 668aafc, part 2 of 2 |
|
Review of #832 at Round 1's BLOCKERs
Round 1's NOTEs: all four are fixed. Net lines ( BLOCKER
NOTE
SEND BACK |
|
T14 staging, head arm: |
|
T14 staging, base arm: |
|
|
|
T14 reading, base arm, at Judge: |
|
T14 reading, head arm, at Judge: |
|
Review of #832 at Round 2's BLOCKER
Round 2's NOTEs
Other checks at this head:
Net lines ( BLOCKERNone. NOTENone. LAND |
, #851 and #853, into consent tests/common/qemu.rs: Profile carries both Desktop and MetalNoX2apic (#841). userland/compositor/src/session.rs: the crate import takes main's list, without CURSOR_PX (#814), plus the branch's PROMPT_POLL_HANDLES. Cargo.lock is main's, re-resolved: grants, and filepicker's toyos-manifest and the build's toyos-desktop. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C
Builds on #828, now on
main; this branch mergedorigin/mainat8b2969d0f(b553508a8, no conflict), and its diff againstmainis this PR's own four commits.Round 2 of the netstack performance work: less CPU per MB on the T14's I219. Round 1's T14 reading (#828, latest comment) showed 101,403 netstack passes for 119,890 frames in one 170 MB download (histogram b0=28798 b1=27134 b2=44612), 101,395 empty send-pipe reads, and netstack at 0.890 of one CPU over 1.614 s, about 14 µs a pass.
What changed, per decision
1. The I219 throttles its interrupts to one each 128 µs (
toyos-i219,ITR = 500). Before this, ITR, RDTR and RADV were all 0, so almost every frame raised its own interrupt.hw/net/e1000e_core.c.e1000e_postpone_interruptfires at once when no throttle timer runs and then arms one. It also floors ITR and EITR at 500 (E1000E_MIN_XITR, the reset value too), so every e1000e guest has always run under exactly this interval.pch_spt/cnp/tgp... infos) carry noFLAG2_DMA_BURST, so Linux writes RDTR 0 and moderates by ITR alone. It uses dynamic conservative mode (InterruptThrottleRate=3): 20,000 interrupts a second (50 µs) for light traffic, down to 4,000 (250 µs) for bulk. 128 µs is one fixed interval between those two. A full-sized frame takes 12.3 µs at a gigabit, so a stream's wake carries about ten frames.tcp_ms/tls_ms(below) are the only reading, and they neither show nor rule out a cost: the arms differ by less than their own RTT samples do.ITR * 256 == 128_000), so a slip in units reds it.2. netstack reads a client's send pipe again only once the kernel says it holds bytes (
userland/netstack/node,userland/netstack/src/serve.rs).pipe_r_emptycounts netstack'sread_nonblockof each stream's from-client pipe. Every pass over the streams read every send pipe [tcp] had room for, and the HTTPS client writes one request and nothing after it.SYS_READcalls over both downloads are about one for each of netstack's 141,097 client-pipe writes, and none of them are empty. Nothing intoyos/std changes.drained). A pass skips that pipe until either the kernel answers its READABLE watch, or the client writes no more, at which point the empty pipe is the end and is read once.Node::bridge(now, readable)takes the streams whose send pipe was answered readable. netstack'sSocketscollects them beside its one pass a wake. The receive, deadline, close, shutdown and accept passes call the node's ownpass.Watch::readable). The kernel'sinbox::armfires a watch at registration when the object is already ready, and watches are re-registered every wait.3. One receive bound for every card, in
Node::receive; a pass that stops at it owes the next without a wait. This was found by reading on #828, filed asissues/netstacks-virtio-receive-has-no-per-pass-budget.md, and briefed into this round.begin_passtakes the interrupt record and acknowledgesICRbeforepoll_rxstopped atRX_BUDGET, and the claim reads ready only while it holds an untaken interrupt. The frames left at the bound therefore waited for the next frame's interrupt or a deadline, and after the last burst of an exchange no next frame comes. Virtio had no bound at all.Node::receivetakes at mostRECEIVE_BUDGET(64) frames from any card and answerstruewhen it stopped there. netstack's next wait is then zero.RECEIVE_BUDGETis private to the node crate: no caller outside it reads the bound, and its test takes the bound from the first batch that owes a pass rather than naming it.toyos_i219::RX_BUDGET, the driver's budget field and its test are deleted. The issue file is deleted; its exit is met, and its durable line isRECEIVE_BUDGET's doc comment.This round also takes up the LAN track's stage 2 "Throughput" item (
issues/the-lan-is-not-yet-production-grade.md), in its interrupt-moderation part. Decision 1 is that part's work. The item's "interrupt moderation (off today)" now reads "a fixed 128 µs throttle today, not adaptive". The item stays open, and so do its other parts (ring sizes, offloads, the line-rate target).Next per-MB costs, not done here:
frames_rx/frames_txis 2.34 cold and 2.24 warm; on the head it is 10.26 cold and 11.47 warm, so the throttle already sends fewer frames per received one. What remains is toyos-net-tcp's ACK policy, outside this round's fence.RDTwrite per received frame, and aTDTwrite per sent frame. No profile has measured these.pipe_w_refused=0on both downloads (the base, 22 cold and 1 warm), so it is not this download's cost.Tests and mutations
Every mutation was applied as a checked patch, run, and reverted in the same script; each log ends
RESTORED=0,porcelain: []. m1–m5 ran at47d4a6a09(patches and logs); m5–m7 ran at668aafc38, afterRECEIVE_BUDGETwent private (patches, script and whole logs).ITRwritten 0bring_up_programs_what_the_initialization_sections_namea_send_pipe_found_empty_is_read_again_only_once_the_kernel_says_it_holds_bytes,a_batch_of_frames_moves_…bridgeignoresreadablea_batch_stops_at_its_bound_and_says_a_pass_is_owed("a pass is owed")answeredforgets the readable streams (guest)https_fetch,libc_sockets,netstack_streams,netstack_streams_e1000eall STALL at their ceilings;netstack_socket_churnpassesserve.rsis the binary's own code, which no host suite builds.if owed { timeout = 0 }) has no test that reds it. Both rings hold 256 (virtio_net.rs'sRX_QUEUE_SIZE,toyos_i219::RX_RING), so nothing in the tree keeps a QEMU batch under 64; what holds is netstack hands the node a pass's received frames as one batch, about 15% fewer pipe calls on the T14, and a reset keeps the text received before it, as Linux and the BSDs do #828's observation under QEMU,b65=0. A guest test that reached the bound would rest on how fast the host refills a ring, which is timing.Test changes to existing tests:
bridge()answers a send pipe readable as the kernel does: it holds bytes, has no writer left, or is no pipe.saysnames its stream.Watch::writeris watched while the from-client end is held, so both answers come.Gates, at
668aafc38(the merge oforigin/mainat8b2969d0fplus theRECEIVE_BUDGETcommit)Each log is posted whole and headed by the head it ran at.
cargo test --manifest-path userland/netstack/node/Cargo.tomltext_the_pipe_had_no_room_for_outlives_the_reset, from #828, passes under this branch'sbridge()harnesscargo run -- --ci hostcargo test(whole guest suite)netstack_streams,netstack_streams_e1000e,netstack_socket_churn,https_fetch,libc_sockets; load averages 42.25 before, 29.08 after|
cargo run -- --build-only --arch aarch64| EXIT=0, "Build finished.", the aarch64 netstack withNode::receive'sowedamong it | aarch64 |cargo run -- --build-only(x86-64) was last run at47d4a6a09(EXIT=0). At668aafc38, the guest suite's build of the x86-64 image stands for it, and so do the head arm's staged T14 image builds (below).Net lines against
main(git diff --numstat origin/main HEAD): 12 files, +173 −138. Production is +87 −47; tests are +83 −58;issues/is +3 −33.T14
Two boots of
internet_download, one session, run by the orchestrator, base first. The readings, each whole and headed by its arm's commit, image sha256 checked at flashing,boot rc=0and the judge's exit: base, EXIT=0, 1 passed, 0 failed; head, EXIT=0, 1 passed, 0 failed. Every download on both arms delivered 170,439,044 bytes with sha2561a9ee8ca…d00e7f.The arms
Both are measurement-only branches; neither lands or gets a PR. The arms differ by exactly this PR's diff, plus the
owedcounter, which only the head has.git diff --statbetween the two tips matchesgit diff --stat 8b2969d0f 668aafc38file for file, except foruserland/netstack/src/main.rs.wt/toyos-netperf2-metal-baseatac6c448b9origin/mainat8b2969d0f(the merge base), plus #818's download row (a163f4d16,c928751cb,3ca71b04e, picked with the head arm's conflict resolution) and round 1's threeprofcommits (0350817cd,30c6678ec,a61698510, fromwt/toyos-netperf-metal)ee965f5a8eac1b8170bc939512e1e19ec321e442231c3ea2ed1df2979bd4e9afwt/toyos-netperf2-metalat6c376d647668aafc38, plus the same download row and this round's threeprofcommits (3b3202a79,33d9b051e,5f456a525), with no other change3ed49bbacf838ca9e29e19daf39657ca5a2b22b54ee55b7424eb265d9a8bee0fcargo test --test toyos-build -- --metal --metal-readback <dir> internet_download, from a clean tree.8b2969d0fare list entries.src/build.rstakes bothblockgrantcaseanddownloadcase.tests/toyos.rsaddshttps_downloadtoRUST_SKIPand dropslan_hold, a binarymainno longer has.The downloads
Cold is each boot's first download, warm its second. Each figure is the
[download]line's own field.mbps)cpu_ms_per_mb)cpu_s)tcp_ms)tls_ms)RTT (
rtt_ms), which each boot's two[download]lines report identically: base[14.3 15.5 14.9 15.0 15.0], head[17.5 15.6 16.0 15.8 15.0].netstack's
proflinesEach download's line is the one printed when its client left, after its
[download]line.passesframes_rxframes_txb0b1b2b3_4b5_8b9_16b17_64b65owedpipe_rpipe_r_emptypipe_w_refusedBase against head
owed: the bound was reached 9 times cold and never warm;b65=0on every line.What the readings do not support:
Round 1's reading, of
47d4a6a09on #828's kernel (figures,proflines), is superseded by the head arm's: the merge of8b2969d0fchanged the kernel's claim record, theisr,DeviceIrqRecordand the claimed function's message between the throttled message and netstack'sbegin_pass. Its tip stays reachable aswt/toyos-netperf2-metal-r1.Unsure
🤖 Generated with Claude Code
https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C