Skip to content

Thread 'now' through the stack - #116

Open
rnro wants to merge 5 commits into
apple:mainfrom
rnro:quic-thread-now-through-datapath
Open

Thread 'now' through the stack#116
rnro wants to merge 5 commits into
apple:mainfrom
rnro:quic-thread-now-through-datapath

Conversation

@rnro

@rnro rnro commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

This PR's primary goal is to lay the groundwork for making a clock/time injectable: while datapath code reads NetworkClock.Instant.now directly, no test can control it. This threads the instant through from the caller instead, so a later change can supply it, e.g. from a scheduler.

Whilst doing this work I discovered a couple of potential issues/improvements.

Sampling the clock wherever a time was needed meant one inbound batch read it repeatedly, and loss detection compared recorded deadlines against an instant sampled later than the one its timer fired at.

Changes:

  • Congestion control and loss detection take now from their caller; QUICPath supplies the instant QUICConnection already caches for the batch.
  • QLog events take the timestamp of the event rather than of the write.
  • Timer closures receive the instant the timer fired instead of reading the clock again.

Two behavior changes:

  • The frames of an inbound batch share one receive timestamp, so inter-arrival within a batch now reads zero. An @autoclosure keeps the read out of stacks that never ask for timestamps.
  • CubicTests.testCubicCongestionLimited and its Prague analogue now seed from a fixed instant. From the system clock they had send times in their own future, so every loss opened a new recovery period instead of one per round.

A few NetworkClock.Instant.now reads remain; those are where a scheduler-supplied clock would hook in.

rnro added 3 commits August 24, 2026 16:11
Each of them read `NetworkClock.Instant.now` where it needed a time, so one inbound batch
sampled the clock repeatedly and figures that should have agreed did not. The `= .now`
defaults on `Timer` and `Recovery.findLostPacket` are the worst of it: they let a caller
take a time without saying which one it meant, and loss detection ended up comparing
recorded deadlines against an instant sampled later than the one its timer fired at.

`CongestionControlProtocol` and its three implementations now take `now`, `QUICPath`
supplies the instant `QUICConnection` already caches for the batch, and the defaults are
gone so a caller has to name what it means. `NetworkClock.Instant.testBase` and two `Ack`
conveniences over it keep the ACK-bookkeeping callsites free of `now:` noise.

`IPProtocol.processInbound` resolves one instant per inbound batch, lazily, so a stack that
never asks for receive timestamps pays nothing. This changes what a receive timestamp means:
the frames of a batch share one instant where each used to carry its own, so inter-arrival
within a batch now reads zero.

`CubicTests.testCubicCongestionLimited` and its Prague twin assert an exact congestion
window. Seeded from the system clock they had dated send times into their own future, so
every loss opened a new recovery period instead of one per round.
The `timestamp: NetworkClock.Instant = .now` defaults stamped an entry when it was written
rather than when the thing it describes happened, so entries could be ordered differently
from the events. The defaults are gone from the methods whose callers hold an instant, and
`Recovery` and `QUICConnection` pass theirs.

`congestionControlUpdated` and `logCongestionStateUpdated` keep theirs. Their callers do not
hold an instant, and the ordering is not worth threading one down to them; what these two
want is a clock to default from rather than an argument.
`Timer.timerFired(timeNow:)` compares each deadline against the instant it was given, but
called its closures with no argument, so every closure read the clock again. The ACK, recovery
and PMTUD handlers therefore ran against an instant strictly later than the one that decided
they were due.

`TimerEntry.closure` now takes a `NetworkClock.Instant` and `timerFired` passes `timeNow`
rather than the threshold-slackened value it compares against. The migration, keepalive,
idle and draining handlers ignore it, since none of them measures against a deadline.
@rnro rnro added the 🔨 semver/patch No public API change. label Aug 24, 2026

@agnosticdev agnosticdev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Given that NetworkClock.Instant is a copyable struct, and that you are passing this through functions in the IP data path can you profile QUICTransfer with and without this change to make sure we are not adding extra CPU calls for outlined copy of NetworkClock.Instant and then outlined destroy of NetworkClock.Instant? We'll want to make sure of that before proceeding here.

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.

Since this in the QUICTests directory, does the file name need "QUIC"?

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.

Good point, I've dropped the QUIC

/// the real clock makes every duration depend on how long the test itself took to run.
///
/// Non-zero because much of the stack treats `.zero` as "unset".
static var testBase: NetworkClock.Instant {

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 we re-use the value from the other test?

final class SwiftNetworkManualClockTests: NetTestCase {
    private let base = NetworkClock.Instant(milliseconds: 1000)

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 we did that we'd have to introduce a dependency across test targets or create a new common re-usable target and I'm not sure that's worth the benefit.

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.

Why not put it under NETWORK_INTERNAL_TESTS in NetworkClock?

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 personally don't think it's good practice to put this in the Source tree. Having it accessible from production code pollutes the namespace and creates a footgun. I'm also hoping to be able to remove NETWORK_INTERNAL_TESTS in the future if we have a fully injectable timing mechanism.

Comment thread Tests/QUICTests/QUICTestClock.swift Outdated
/// and not about time at all, so restating `now:` several hundred times would be noise.
/// The default here is a *fixed* instant, so those tests stay deterministic. A test
/// whose premise is timing calls the full form and passes its own instant.
@available(Network 0.1.0, *)

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.

Shouldn't this be in AckTests?

@rnro

rnro commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

Given that NetworkClock.Instant is a copyable struct, and that you are passing this through functions in the IP data path can you profile QUICTransfer with and without this change to make sure we are not adding extra CPU calls for outlined copy of NetworkClock.Instant and then outlined destroy of NetworkClock.Instant? We'll want to make sure of that before proceeding here.

I was pretty sure that the compiler would just optimize through such a trivial object but to check I attempted to solve this by inspecting the binary. I ran
nm ./QUICTransfer | swift demangle | grep -i 'outlined.*NetworkClock.Instant'
which found no symbols produced for any outlined calls for that object, so I don't think they can have the performance implication you're concerned about. (I also ran the same command but grepping for outlined.*Network) and saw lots of results so I think this is a genuine result.

@agnosticdev

Copy link
Copy Markdown
Collaborator

Given that NetworkClock.Instant is a copyable struct, and that you are passing this through functions in the IP data path can you profile QUICTransfer with and without this change to make sure we are not adding extra CPU calls for outlined copy of NetworkClock.Instant and then outlined destroy of NetworkClock.Instant? We'll want to make sure of that before proceeding here.

I was pretty sure that the compiler would just optimize through such a trivial object but to check I attempted to solve this by inspecting the binary. I ran nm ./QUICTransfer | swift demangle | grep -i 'outlined.*NetworkClock.Instant' which found no symbols produced for any outlined calls for that object, so I don't think they can have the performance implication you're concerned about. (I also ran the same command but grepping for outlined.*Network) and saw lots of results so I think this is a genuine result.

What does it say when you run the benchmark though in Instruments?

@rnro

rnro commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

Given that NetworkClock.Instant is a copyable struct, and that you are passing this through functions in the IP data path can you profile QUICTransfer with and without this change to make sure we are not adding extra CPU calls for outlined copy of NetworkClock.Instant and then outlined destroy of NetworkClock.Instant? We'll want to make sure of that before proceeding here.

I was pretty sure that the compiler would just optimize through such a trivial object but to check I attempted to solve this by inspecting the binary. I ran nm ./QUICTransfer | swift demangle | grep -i 'outlined.*NetworkClock.Instant' which found no symbols produced for any outlined calls for that object, so I don't think they can have the performance implication you're concerned about. (I also ran the same command but grepping for outlined.*Network) and saw lots of results so I think this is a genuine result.

What does it say when you run the benchmark though in Instruments?

I think any change if one exists is within the noise

Before change: 64.50 G, 64.13 G, 64.77 G
After change:  64.63 G, 64.40 G, 64.66 G

@rnro
rnro force-pushed the quic-thread-now-through-datapath branch from 25fcef8 to f29e5da Compare August 26, 2026 18:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🔨 semver/patch No public API change.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants