Skip to content

tcp: new data is paced, and HyStart++ grows by all an ACK acknowledged - #1114

Merged
Japabu merged 7 commits into
mainfrom
tcp-pace
Nov 1, 2026
Merged

Japabu merged 7 commits into
mainfrom
tcp-pace

Conversation

@Japabu

@Japabu Japabu commented Oct 31, 2026 •

Copy link
Copy Markdown
Collaborator

Reviewed in two rounds; head abae9a3 on main 24a538c. The parity boot ran: the 300 ms mark, p50 and loss pass; the 100 ms mark misses the pre-set rule by 30 KB, so the ramp issue stays open with the reading.

Problem

On the T14's LAN path a new ToyOS connection delivers 2–3 MB in its first 100 ms and about 24 MB by 300 ms, against Linux's 6 MB and 28–29 MB (issues/tcp-opens-a-connections-window-slower-than-linux-in-its-first-300-ms.md). In #1112's session a diagnostic arm, L = ∞ without pacing, reached Linux's marks (5.2–6.4 MB and 28.3–29.7 MB) and resent nothing. So RFC 9406 §4.3's L = 8 is what holds the ramp back. That section gives L = ∞ only to a paced sender, so the unpaced arm could not land.

Change

  • Pacing, per connection in [tcp] (src/pace.rs, RFC 9002 §7.7). A token bucket fills at N · cwnd / srtt, and every data segment sent spends from it.
    • Capacity: the bucket holds 1 ms at the rate, at most half of cwnd, and never less than the initial window.
    • The millisecond departs from §7.7, which bounds a burst at the initial window unless the path is known to absorb more. The millisecond sizes a fast sender's bursts, and so its wakes, by time rather than by the initial window.
    • The half-window bound keeps the bucket below cwnd at any srtt, so a window past the initial one always leaves in more than one burst. That is what lets L = ∞ hold whatever the round trip (review r1: before it, at srtt ≤ ~2 ms the bucket was cwnd or more and never held).
    • Release: new data the bucket can't cover is held, and offered again when half the bucket has come back. The sender wakes once per half the bucket's refill time, and a wake up to that much late loses no rate. That is half a millisecond while the millisecond sets the bucket, less where half the window sets it (125 µs at cwnd 146,000 and srtt 1 ms), and more where the initial window does. In a small window that wait is half the initial window's time at the rate, even when one segment would do.
    • Wakes: the release instant is a connection deadline. Netstack already waits on Tcp::next_deadline, so no thread or timer is added.
  • Spent but never held, each with its reason in the module header:
    • retransmissions: recovery's pipe and a timeout's one-segment window bound them;
    • new data in SACK recovery: RFC 6675's pipe and the ACK clock bound it, as they do retransmissions;
    • a loss probe of new data: RFC 8985 §7.3's single segment at the PTO, which a hold would only delay;
    • a persist probe of new data: one segment into a closed window (RFC 9293 §3.8.6.1);
    • ACK-only segments aren't paced at all (§7.7).
    • Without a smoothed RTT nothing is paced; cwnd is at most the initial window until the first sample.
  • L = ∞ (RFC 9406 §4.3, for a paced sender): HyStart++ grows the initial slow start by all an ACK acknowledged, or a quarter of it in CSS.
  • N is 1.25 in avoidance, the value §7.7 gives, and 2 in slow start. Slow start doubles cwnd across the round trip, so a round's data is twice the window it began with, and 1.25 · cwnd / srtt would hold the doubling back. The Q arm below measured 1.25 throughout and lost to 2 at 100 ms in every pair.
  • limited: a pacing hold leaves Cc::limited as the last send set it. Clearing it would stop slow start's growth and CUBIC's clock while the pacer spreads a window that is in use.
  • Counter: the time a pacing hold keeps data waiting is counted as tcp.send-limited.pacing.ns, and osbench reads it.
  • The departure record (tcp: an ACK is timed from its newest segment's departure, at the clock's resolution #1112): a send joins a short run only if it is short too, as before tcp: an ACK is timed from its newest segment's departure, at the clock's resolution #1112's review r2. HTTP's shape is a short header, then a window of body. Without this rule the body joins the header's run, and all but its first partway ACK go untimed. The bound returns to two runs per SMSS in flight plus two, about 16 KB at 250 segments. This was review r3's optional suggestion on tcp: an ACK is timed from its newest segment's departure, at the clock's resolution #1112.
  • Tests changed:
    • s_cc_017 and s_cc_018 follow L = ∞. s_cc_017's boundary is now 10 of the window's 20 segments.
    • Two net.rs downloads on a 15 ms gigabit path asserted that the receive window reaches the whole 4 MiB buffer. The window grows to twice what is read per round trip, 3.75 MB on that path. Unpaced, it reached the whole buffer; paced, it stops 3 KB short. They now assert the rule's bound, which still separates them from the 65,535 they guard against.
  • The ramp issue stays open. It gains the A/B as relative evidence and the parity boot's reading, which misses the pre-set rule at 100 ms by 30 KB (Metal below).

Tests

  • pace.rs (unit tests):
    • no smoothed RTT, no pacing
    • a slow bucket holds the initial window and releases at half (1.25 ms at 2 · 29,200 / 10 ms)
    • avoidance's 1.25 (2 ms)
    • a fast bucket holds a millisecond (250 segments at srtt 8 ms)
    • new: a bucket holds at most half the window (50 of 100 segments at srtt 1 ms)
    • an idle bucket fills only to its capacity
  • tests/pacing.rs, through the connection:
    • a window past the bucket leaves 5 + 5 at 11.25 and 12.5 ms, and the 2.5 ms hold is counted
    • an ACK during a pacing hold grows cwnd
    • an acknowledgment leaves through a hold
  • s_rt_025: a 200 B header, then four segments of body 1 ms later; four ACKs give four samples. Its control (any send joining a short run) fails it with exit 101.
  • At 3a0744d: [tcp] cargo test --no-fail-fast 442 passed, exit 0; clippy on the crate clean. f7c9eb2 and ff0dc5a change only comments (pace.rs's header, one issue bullet), so these checks and the parity boot of 3a0744d still apply; abae9a3 changes only the issue; the pacer's unit tests and clippy were rerun on it, clean.
  • Eight controls at 3a0744d, each applied, run and reverted, all exit 101:
    • N = 1.25 in slow start
    • release at one segment
    • no initial-window floor
    • no millisecond term
    • no half-window bound (fails the new test)
    • a hold clearing limited
    • no pacing deadline
    • L = 8
  • Not covered by a test: data that spends but isn't held spending from the bucket. No harness scenario made the spend observable.
  • cargo run -- --ci host at 3a0744d: 107 steps, all green, exit 0.
  • Evidence: https://github.com/ToyOSOrg/evidence/tree/bdee450e90704beb00206486e052500b4d099c47/r6 (this round and the staged boot; earlier rounds in r1–r5)

Metal

Parity boot, 2026-11-01 (3a0744d on current main, between two Linux runs; image and binary hashes checked first; every run rc 0, the judge exit 0). The Linux binaries were rebuilt at this head, and the peer's are byte-identical to the A/B's.

p50 MB/s 100 ms 300 ms 1 s 2 s resent pacing hold, ms css / entry rounds RTT samples, departure / echo
Linux L1, L2 113, 113 6.10, 6.38 28.63, 29.56 109.1, 110.5 224.6, 226.1 0
ToyOS p1 114 6.07 29.19 110.7 227.0 0 73.3 420/134 33,319/0
  • The rule set before the run (at or above the lower Linux run at 100 and 300 ms, p50 within 1 MB/s, nothing resent) is not met. 300 ms, p50 and loss pass. The 100 ms mark is 30 KB short (0.5 %).
  • That gap is under the marks' own spread (280 KB between the two Linux runs, 260 KB across the A/B's P runs), so one boot can't separate it from noise.
  • The ramp issue stays open with this reading (abae9a3). Closing it needs another boot, or a judgement on the rule, which is yours to make.
  • The pacer held 73 ms in the row, against 33–41 ms with the A/B's bucket: the half-window bound holds more on this short round trip, and the ramp held.
  • Evidence: https://github.com/ToyOSOrg/evidence/tree/8f08a8e9399817fc669e395dc717ee236a18ed35/r7/t14

A/B, 2026-10-31 (relative evidence; every boot rc 0, every judge exit 0). Peer: the AMD bench's Linux over its Wi-Fi. Order: Linux L1, c1 p1 q1 c2 p2 q2 c3 p3 q3, Linux L2.

Marks are each row's mean over five connections, received by the peer, in MB:

p50 MB/s 100 ms 300 ms 1 s resent pacing hold, ms css / entry rounds RTT samples, departure / echo
Linux L1, L2 112, 112 5.68, 5.71 26.07, 26.23 106.4, 105.7 0
C 112, 112, 112 2.84, 3.07, 2.03 24.99, 25.35, 23.83 105.4–106.8 1,448 B in c1 0 678/166, 564/165, 462/137 32,805/2, 33,038/0, 32,647/0
P 114, 114, 114 6.34, 6.39, 6.60 29.29, 29.87, 29.80 110.9–111.1 0 32.7, 40.6, 40.7 374/104, 488/130, 619/136 33,398/0, 33,002/0, 33,378/0
Q 114, 114, 114 5.89, 6.25, 6.27 29.12, 29.45, 29.53 110.2–111.0 0 95.4, 96.4, 79.7 439/114, 763/205, 599/190 32,656/0, 33,628/0, 33,177/0

This needs metal because the claim is the ramp on a real gigabit path against Linux on the same machine. The host harness establishes the pacer's arithmetic and the connection's behaviour, but not a real path's queue.

Risks

  • The half-window bound costs wakes on short round trips. A held connection wakes once per half bucket, which in slow start is every min(0.5 ms, srtt / 8), and never more than once per half initial window sent. At the T14's early ~2 ms srtt that is about 4,000 a second per connection while it ramps. The parity boot read the result, not the cost.
  • On this path the line itself (1 Gb/s) spread most bursts. The pacer's burst bound for a slower bottleneck downstream is reasoned, not measured.
  • 1 ms is chosen, not measured. A smaller span bounds bursts tighter at more wakes.
  • N = 2 in slow start can overshoot more than 1.25 before HyStart++'s delay exit. On this path neither arm lost anything; a path with a shallow buffer is unmeasured.

🤖 Generated with Claude Code

@Japabu
Japabu force-pushed the tcp-pace branch 2 times, most recently from dc4f576 to b3aee3b Compare October 31, 2026 17:49
Japabu added a commit that referenced this pull request Oct 31, 2026
…goes

On the T14's LAN path, #1114's A/B (evidence branch pr-1114, r4/t14 at
783d377ae1ee56592aae53465a74bf60c3beb839) read the paced sender with
L = infinity (P) against #1112's head (C) and Linux in the same session:

             p50 MB/s   by 100 ms   by 300 ms
  Linux      112, 112   5.7         26.1-26.2 MB
  C          112 x3     2.0-3.1     23.8-25.4
  P          114 x3     6.3-6.6     29.3-29.9

P beat C at 300 ms in every pair, resent nothing, and is at or above
Linux at every mark. That meets the record's exit, so the record goes.
The comparison record's pointer to it now names the PRs and the
evidence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Base automatically changed from tcp-rtt to main October 31, 2026 22:12
Japabu added a commit that referenced this pull request Oct 31, 2026
…goes

On the T14's LAN path, #1114's A/B (evidence branch pr-1114, r4/t14 at
783d377ae1ee56592aae53465a74bf60c3beb839) read the paced sender with
L = infinity (P) against #1112's head (C) and Linux in the same session:

             p50 MB/s   by 100 ms   by 300 ms
  Linux      112, 112   5.7         26.1-26.2 MB
  C          112 x3     2.0-3.1     23.8-25.4
  P          114 x3     6.3-6.6     29.3-29.9

P beat C at 300 ms in every pair, resent nothing, and is at or above
Linux at every mark. That meets the record's exit, so the record goes.
The comparison record's pointer to it now names the PRs and the
evidence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Japabu and others added 4 commits November 1, 2026 00:16
RFC 9406 §4.3 caps a non-paced sender's growth at L = 8 segments an ACK
and gives L = infinity to a paced one. On the T14's LAN path the cap is
what held a new connection's ramp: with L = infinity and no pacing (a
diagnostic arm in #1112's session) ToyOS reached Linux's marks at 100 and
300 ms, and nothing was resent. That arm had no pacing, so it did not
land.

[tcp] now paces each connection (RFC 9002 §7.7). A token bucket fills at
N * cwnd / srtt. It holds the larger of the initial window, the burst
§7.7 bounds a sender to, and 1 ms at the rate. Every data segment spends
from it. New data that it cannot cover is held until half the bucket has
come back, so a wake up to half a millisecond late loses no rate. The
release instant is a connection deadline, so netstack wakes through the
existing Tcp::next_deadline. No thread or timer is added.

- N is 1.25 in avoidance, the value §7.7 gives. In slow start it is 2:
  cwnd doubles across the round trip, so the round's data is twice the
  window it began with, and 1.25 would hold the doubling back.
- A pacing hold leaves `limited` as the last send set it. Clearing it
  would stop growth and CUBIC's clock while the pacer is spreading a
  window that is in use.
- Retransmissions spend but are not held. Recovery's pipe and a
  timeout's one-segment window already bound them.
- ACK-only segments are not paced (§7.7).
- Without a smoothed RTT nothing is paced, and cwnd is at most the
  initial window then.
- The time a pacing hold keeps data waiting is
  `tcp.send-limited.pacing.ns`. osbench reads it.

Two net.rs downloads on a 15 ms gigabit path asserted that the receive
window reaches the whole 4 MiB buffer. The window grows to twice what is
read per round trip, 3.75 MB on that path. The unpaced sender carried it
to the whole buffer; paced, it stops 3 KB short. They now assert the
rule's bound.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Evidence branch pr-1112 was rewritten to f2909c1f without the commit
that published an unmasked temp path; c990b1f2 no longer exists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With any send joining a run shorter than SMSS, a window of body sent
after a short header joined the header's run. The run then spanned two
instants, so only its first partway ACK was timed: HTTP's shape lost
most of its samples. A send now joins a short run only if it is short
too, as before #1112's review r2. The record's bound returns to two runs
per SMSS in flight plus two, about 16 KB at a 250-segment flight.

s_rt_025: a 200 B header, then four segments of body 1 ms later. Four
ACKs give four samples.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ue stays open

The bucket held max(IW, 1 ms at the rate). At srtt up to about 2 ms in
slow start, or 1.25 ms in avoidance, a millisecond at the rate is at least
cwnd, so the pacer never held there. HyStart++ still took L = infinity
there, which is the unpaced L = infinity RFC 9406 §4.3 rules out. The
bucket is now max(IW, min(1 ms at the rate, cwnd / 2)). A window past the
initial one always leaves in more than one burst, at any srtt. The cost
is more wakes on short round trips: a half bucket every srtt / 8 in slow
start.

The module header now gives the bucket's two bounds and their floor, and
says where the millisecond departs from RFC 9002 §7.7's initial-window
burst. It also names the data that spends but is never held, each with
its reason: retransmissions, new data in SACK recovery, and a loss probe
of new data. `hold`'s doc says when the held data is offered again.

The ramp issue is no longer deleted. The A/B ran on trees from before
#1106 and with the old bucket, so it is recorded as relative evidence
(P over C, N = 2 over 1.25) at its full evidence hash. Parity waits on
a boot of this head.

- a_bucket_holds_at_most_half_the_window: at srtt 1 ms the bucket is 50
  of a 100-segment window.
- a_fast_bucket_holds_a_millisecond now uses srtt 8 ms, where the
  millisecond is under half the window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Japabu and others added 3 commits November 1, 2026 01:01
…ver holds

The header said a fast sender wakes at most every half millisecond, and
that a wake up to half a millisecond late loses no rate. Both hold only
while the millisecond sets the bucket. Where half the window sets it
(srtt under 4 ms in slow start, 2.5 ms in avoidance), the wake comes
every half refill time and the slack is that much. At cwnd 146,000 and
srtt 1 ms that is 125 µs. Both are now stated as half the bucket's
refill time.

The list of data spent but never held now has persist probes of new
data. New data in SACK recovery is bounded by RFC 6675's pipe and the
ACK clock, not "a segment at a time": next_segment repeats while
cwnd - pipe >= SMSS.

The issue's A/B bullet names 3a0744d as the head whose pacer holds at
most half the window. Comments only; no code changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e bucket

Where the initial-window floor sets the bucket, it holds more than a
millisecond at the rate, so the half-refill wake interval is longer than
half a GRANULARITY, not shorter. Comment only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… record stays open

One T14 boot of 3a0744d on current main, with Linux before and after it
(evidence branch pr-1114, r7/t14 at
8f08a8e9399817fc669e395dc717ee236a18ed35):

             p50 MB/s   by 100 ms        by 300 ms
  Linux      113, 113   6.10, 6.38 MB    28.63, 29.56 MB
  ToyOS      114        6.07 MB          29.19 MB

The rule set before the run asked for both marks at or above the lower
Linux run's. The 300 ms mark, the p50 and loss (none) passed. The 100 ms
mark missed by 30 KB, 0.5 %, so the record stays open with this reading.
That gap is under the runs' own spread (280 KB between the two Linux
runs, 260 KB across the A/B's three P runs), which one boot cannot
separate it from. The record's opening now gives the paced sender's
numbers and keeps the pre-#1114 ones beside them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Japabu
Japabu marked this pull request as ready for review November 1, 2026 01:22
@Japabu
Japabu added this pull request to the merge queue Nov 1, 2026
Merged via the queue into main with commit 1f74a55 Nov 1, 2026
10 checks passed
@Japabu
Japabu deleted the tcp-pace branch November 1, 2026 01:41
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.

1 participant