Skip to content

Clock reads go via the scheduler - #146

Open
rnro wants to merge 6 commits into
apple:mainfrom
rnro:clock-reads-through-the-scheduler
Open

rnro wants to merge 6 commits into
apple:mainfrom
rnro:clock-reads-through-the-scheduler

Conversation

@rnro

@rnro rnro commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

This change threads clock reads through the scheduler and exposes new methods for reading the system time with names which clearly communicate that is what is happening.

This PR is probably best reviewed per-commit. The first shifts the reads to go through the scheduler, the second modifies Pacer.getSendTime to more efficiently use the new APIs and the third removes the process-global manual clock since matching control is now offered per-context.

Motivation

Datapath code read NetworkClock.Instant.now directly, so no test could control/mock time. This makes tests slow and in many cases racey.

Description

The scheduler already owns the timers, so it is the natural owner of time as well.

  • NetworkContext.Scheduler gains now and nowAbsolute, with DefaultScheduler the one conformance allowed to read the real clock.
  • NetworkClock.Instant.now becomes package-visible systemNow, so a syscall read is more visible/intentional at the call site.
  • QUICConnection stamps the continuous and absolute clocks as a pair per batch, so Pacer converts between the domains without reading either - it previously read both once per paced packet.
  • The process-global manual clock behind NETWORK_INTERNAL_TESTS is removed. The scheduler gives the same control per context, so this replaces it rather than dropping it; NETWORK_INTERNAL_TESTS is gone from CI and the README.

Performance

This change should be a marginal improvement but certainly not negative.

  • The read moves from a static that inlines within the module to a non-devirtualisable witness call, this should be indistinguishable.
  • Stamping the absolute clock alongside the continuous one on the batch removes two clock reads per-paced-packet.
  • This change causes no per-connection or per-packet allocation.

In test runs of QUICTransfer it seems to shave a few mega cycles, but hard to say with the spread:

Before:
    61.42G, 62.04G, 62.20G

After:
    61.51G, 61.85G, 61.31G

Datapath code read `NetworkClock.Instant.now` directly, so no test could control it: the value came
from `mach_continuous_time` however the code was driven. The scheduler already owns the timers, so
it is the natural owner of time as well; one that holds scheduled tasks rather than arming an OS
timer has to report the instant it fires them at.

* Added `now` and `nowAbsolute` to `NetworkContext.Scheduler`, and to `DefaultScheduler` as the one
  conformance allowed to read the real clock.
* Renamed `NetworkClock.Instant.now` to `package systemNow`, and `nowAbsolute` to
  `systemNowAbsolute`, so a direct read is visible at the call site rather than looking ordinary.
* Routed the library's remaining reads through `context.scheduler` or the instant the caller
  already holds.
* Passed a scheduler to `QLog`, whose start time and two event timestamps had nowhere else to get
  a clock from.
* Left `QUICStreamLoad` reading the system clock, since a benchmark measuring wall-clock duration
  wants exactly that.

Source-breaking for anything outside the package that conforms to `Scheduler`.
`Pacer.getSendTime` runs once per paced packet and read both clocks on every call, converting between the domains by subtracting one reading from the other. Two reads per packet, and a difference that picks up the gap between them as though it were domain offset.

* Added `currentAbsoluteTimestamp` beside the existing batch timestamp, stamped and cleared with it at both the inbound and outbound boundaries.
* Added `QUICConnection.nowAbsolute`, falling back to the scheduler when no batch is in flight.
* Pointed `Pacer` at the pair, so inside a batch it reads no clock at all.

The offset error was the read gap, well under the pacing interval, so this is a hot-path saving rather than a fix. `Pacer` already went through the scheduler, so it was not blocking clock injection.
`NetworkClock.Instant` carried its own manual clock behind `NETWORK_INTERNAL_TESTS`: a static box
that `useManualTime` froze and `advanceManualTime` moved, checked on every read of the clock. Being
process-global, it could not be scoped to one connection or one test, and the two readers paid for a
branch on a hot path to consult it. The scheduler now provides the same control per context, so this
is a replacement rather than a removal.

* Removed `useSystemTime`, `useManualTime`, `advanceManualTime` and the backing store, and with them
  the branch each clock read carried.
* Removed `SwiftNetworkManualClockTests`, the only caller. The 30 tests in
  `SwiftNetworkClockTests` alongside it cover `NetworkDuration` and `Instant` arithmetic and are
  untouched.
* Dropped `-DNETWORK_INTERNAL_TESTS` from both CI scripts and the README, since nothing is behind it
  any more.
@rnro
rnro requested a review from agnosticdev as a code owner September 14, 2026 18:45
@rnro rnro added the ⚠️ semver/major Breaks existing public API. label Sep 14, 2026
@rnro
rnro force-pushed the clock-reads-through-the-scheduler branch from 7aa378c to 44f410f Compare September 14, 2026 18:47
* Added `NetworkContext.now` and `nowAbsolute` beside `resetTimer`,
  forwarding to the scheduler..
* Routed the eight clock reads in `QUICConnection` and `IPProtocol` through the context, which is
  also one member shorter at each site.
* Changed `QLog` to hold the context rather than an `any NetworkContext.Scheduler`, of which that
  build has no instance to hand it.
}
}
}

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 we have a test that shows how we can manually advance the clock?

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, thanks! This was split out of some larger work which exercised this code in tests but it should stand alone.

@rpaulo

rpaulo commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Doesn't this make it more difficult to test smaller objects that don't have a NetworkContext to begin with? How would we test congestion control now? Does that require us passing a NetworkContext to the congestion controller?

@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.

Thank you for checking the performance results on this change!

@rnro

rnro commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

Doesn't this make it more difficult to test smaller objects that don't have a NetworkContext to begin with? How would we test congestion control now? Does that require us passing a NetworkContext to the congestion controller?

I don't think so. We haven't had to add a context to parts of the codebase which have no interest in the context in this PR and the tests still pass for example. Because we have threaded now through the stack you can still use NetworkClock.Instant.testBase, .advanced(by:)) to reference specific times without a context.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

⚠️ semver/major Breaks existing public API.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants