Align the guest TSC with the partition reference clock on KVM - #4290
Align the guest TSC with the partition reference clock on KVM#4290Robert Nowotny (bitranox) wants to merge 3 commits into
Conversation
A machine reset returns the partition's reference time counter to zero, because `reset_all` sets every state element to its at-reset value and `ReferenceTime`'s is zero. It asks the same of each vp's `Tsc`, whose at-reset value is also zero, and on KVM that half silently does not happen. The `Tsc` element is written through `MSR_IA32_TSC`, and `kvm_synchronize_tsc` (arch/x86/kvm/x86.c) reads a host write of exactly zero as "userspace is creating or synchronizing this vcpu" rather than as a value to store: it sets `synchronizing = true` on that branch, comment "Force synchronization when creating a vCPU, or when userspace explicitly writes a zero value", and then substitutes `kvm->arch.cur_tsc_offset` for whatever offset the write implied. So zero, the one value a reset needs to deliver, is the one value that path cannot deliver. Measured on a live guest: TSC 5403506220, wrote 0, read back 5403522705. A control write of 0x4000000000000000 landed, so the write itself was reaching the kernel. The guest is then left holding two clocks that disagree by the previous boot's uptime. A guest hypervisor calibrates its reference clock off the TSC and arms a one-shot synthetic timer at an absolute deadline on the old timeline (38.50 s measured) while the partition counter reads 2.24 s. The deadline is about 36 real seconds away, the clock init polls for it with a bounded budget, retires the timer when it never fires, runs out of candidates and bugchecks, which presents as a hang at the firmware logo. Reset the counter for real, through the vcpu device attribute `KVM_VCPU_TSC_CTRL`/`KVM_VCPU_TSC_OFFSET`. `kvm_arch_tsc_set_attr` hands the caller's value straight to `__kvm_synchronize_tsc` with no heuristic in between, so it lands verbatim. Writing a non-zero value close to the target through the MSR instead would dodge the zero special case, but not the one after it: a non-zero write within a second of the previous one is folded onto the existing offset by the slop branch once `kvm->arch.user_set_tsc` is set, and a reset that follows the previous write inside a second is inside that window. Three things about that attribute drive the shape of this change: * It is an offset, not a counter value. The guest reads `scale(host TSC) + offset`, so restarting the guest near zero means writing roughly minus the scaled host TSC. This derives it from the current pair (`new = old_offset - guest_tsc`) rather than from `rdtsc`, which is exact under TSC scaling without having to know the ratio, since `scale(host) == guest_tsc - old_offset` by the same identity. * Every vp must get the same offset, computed once. The kernel reads unequal offsets as unsynchronized vcpus, so a separately sampled value per vp would open a new TSC generation on each write and drop the partition out of masterclock mode, which is what the reference clock is built on. One read, one value, one pass over the vps keeps them in a single generation. * The residual error is the host time between the read and the writes, so the guest restarts a few microseconds' worth of cycles above zero rather than exactly at it. That is the same order as the skew a cold start has anyway, and the next commit removes both. The per-vp `set_tsc` accessor is deliberately left alone. A restore writes a real non-zero value there and does not hit the zero heuristic, and routing it through the offset attribute would make it a per-vp write of an individually sampled value, which is exactly the generation churn described above. The reset now logs both halves of the partition clock, before and after, at info level. A reset that moves only one of them is the failure mode this fixes, and the pair is the only reading that tells that apart from a healthy reset. The TSC is read back after the write rather than assumed, since a write that reported success and changed nothing is the whole defect. On a kernel without the attribute (it landed in 5.16) `KVM_HAS_DEVICE_ATTR` says so up front and the reset warns and proceeds. Failing the reset outright would be a worse outcome than a counter that does not restart, and the warning keeps a later guest bugcheck from being unexplained.
…head of it
The partition has two views of time and a guest hypervisor calibrates one against
the other, so they have to start together. The previous commit fixed the reset
half by targeting zero. The creation half is untouched, and it is the larger of
the two.
On a cold start the kernel zeroes the reference clock when the vm is created
(`kvm_arch_init_vm` sets `kvmclock_offset` to minus the current base time) and
fixes the guest counter's origin only when the bsp vcpu is created
(`kvm_arch_vcpu_postcreate` -> `kvm_synchronize_tsc(vcpu, NULL)`). openvmm builds
the whole partition between those two ioctls - the supported-cpuid query, the leaf
build, capability derivation - so the guest starts life carrying that interval as
a constant disagreement between the counter and the clock. Measured on this host
at 832 to 977 us over seven starts. It lands 1:1 in the horizon a guest hypervisor
computes for a synthetic timer deadline (regressing horizon on offset across those
runs gives slope 1.0107 over a 10,153-tick span), which shortens a 1.978 ms
one-shot to about 1.15 ms and puts half the arms in the past: about 1.88 M
past-dated re-arms a second, and a nested guest that never reaches a usable
desktop.
Both halves are the same operation, so they become one function called from two
places: read the clock, read the counter, write the corrected offset to every vp.
At creation it runs after the `add_vp` loop, so the loop's own duration is not left
in the answer. At reset it runs after `reset_all`, which has just returned the
clock to zero, and it now targets the clock rather than zero - so the counter no
longer restarts a few microseconds behind the clock the way it did (measured
-6.52 us post-reset before this change).
The correction moves the counter to the clock, never the other way. The clock is
the partition's authority on time: every synthetic timer deadline is expressed in
it and `GetReferenceTime` reads it directly, while the counter is a per-vp view of
the same instant.
The target is not a zero lead
The comparison that decides a deadline is in `stimer_start` (arch/x86/kvm/hyperv.c):
time_now = get_time_ref_counter(hv_stimer_to_vcpu(stimer)->kvm);
...
stimer->exp_time = stimer->count;
if (time_now >= stimer->count) {
/* ... expire immediately ... */
`stimer->count` is written by the guest and computed from the guest's own counter:
`counter_now + horizon`. `time_now` is the partition reference counter, which
`get_time_ref_counter` derives from the kvmclock. With the counter behind the
clock by D, `counter_now` reads D low, the deadline lands D early, and the branch
is taken for every horizon shorter than D. A long horizon is harmless and a short
one is the failure - that principle is right, and the sign it maps to is the
counter-intuitive part.
So exactly-on-the-clock is not the safe target either: the comparison is `>=`, so
at a lead of zero a zero-horizon arm still fires immediately. That was measured
rather than argued, interleaved over 3 rounds x 2 cells x {cold, warm}. At a 20 us
lead: 0.027 past-dated arms a second, no near-class arm at all in 565 s over 12
boots. At zero: 0.104 a second, 46 of 63 forming a near class from -8.2 to
-78.9 us, median -19.5 us, none within 3 us of zero. The control is that the main
horizon cluster moved 2007.6 -> 1987.6 us, exactly the 20.0 us of lead, so nothing
else changed. A deeper class (-277 us to -193 ms) sits at ~0.027/s in both arms; no
lead of this size touches it.
Firing early to cover a latency that cannot be removed is what KVM already does for
the LAPIC timer: `lapic_timer_advance` (arch/x86/kvm/lapic.c), whose comment gives
the same argument - KVM "programs the host timer event to fire early ... to account
for the delay between taking the VM-Exit ... and the subsequent VM-Enter" - applied
in `start_sw_tscdeadline` as `ktime_sub_ns(expire, timer_advance_ns)` under a
`ns > timer_advance_ns` guard, sized `LAPIC_TIMER_ADVANCE_NS_INIT` 1000 /
`LAPIC_TIMER_ADVANCE_NS_MAX` 5000 and tuned by `adjust_lapic_timer_advance`. What
differs here is the stage. That advance is applied when a deadline becomes a host
timer, which is past the point where this goes wrong: by then `stimer_start` has
already sorted the arm into past or future, and the sorting is the defect. So the
same compensation moves one step earlier, onto the origin the deadline is computed
from.
KVM's accepted remedy for a horizon shorter than the latency - `apic_timer_expired`
in `start_sw_tscdeadline`, `xen_timer_callback` in `kvm_xen_start_timer` - is not
available for this timer. Both are terminal: a LAPIC or Xen one-shot that fires
immediately fires once and the guest loses a tick. The Hyper-V direct-mode
auto-enable one-shot RE-ARMS on delivery, so firing immediately hands the guest
back a deadline it recomputes from the same trailing counter and re-arms just as
short. That loop is the storm. For a timer that re-arms, this function has already
picked the other answer: `stimer_start`'s periodic branch re-anchors a past-dated
deadline strictly into the future (`div64_u64_rem(time_now - exp_time, count,
&remainder); exp_time = time_now + (count - remainder)`) and discards the missed
ticks. This does that to the one-shot's inputs, which is where a one-shot allows it.
`kvm_xen_start_timer` is worth naming for the other half, since it hits our exact
defect and stops short of this: it computes the guest's view of the clock the way
the guest does, refusing `get_kvmclock_ns()` for having "a systemic error ...
because it scales directly from host TSC to nanoseconds, and doesn't scale first to
guest TSC and *then* to nanoseconds as the guest does", and then accepts the
residual between its own `ktime_get()` and `rdtsc()` uncompensated. That is a
defensible trade for a timer whose immediate-fire is terminal. Stock KVM needs
neither, because `compute_tsc_page_parameters` derives the reference TSC page from
the kvmclock and `get_time_ref_counter` reads the same struct back, so guest and
kernel share one origin by construction. The gap exists only where something else
establishes the counter.
The lead is a floor plus a runtime term. The floor covers the delay L between the
guest reading its counter to compute a deadline and KVM evaluating that deadline
against the clock; over L the clock advances and the guest's number does not, so
a zero-horizon arm reads as already past whenever the lead is at or under L. L was
measured directly, pairing `kvm_exit(MSR_WRITE)` with the `set_count` and the
`get_time_ref_counter` in `stimer_start`: ~98k arms per run, 100% pairing, two
independent arms agreeing. p50 3.2 us, p90 3.9, p99 12-14, p99.9 18.4-18.7, max
72-123. The floor is 20 us because that is L's p99.9 rounded up - a percentile of a
measured distribution, not a multiple of its median. It is also the bottom of the
21-to-38 us achieved band that measured clean at under 0.05 past-dated arms a
second, so it sits at the edge of the data rather than under it. The cost is a
synthetic timer delivered 20 us late, about 1% of the 1.978 ms one-shot period this
guest uses.
The floor is static where `timer_advance_ns` is adaptive, and deliberately so.
`adjust_lapic_timer_advance` closes a loop on `guest_tsc - tsc_deadline`, which it
has on every expiry; there is no such signal here. This runs twice in a partition's
life, at creation and at reset, and would have to converge on a property of the
host's exit latency that nothing downstream observes. An adaptive lead means first
putting the ftrace pairing above into the arm path, which is future work if the
static floor proves wrong on some host, not now.
The runtime term is the pairing's own worst-case error, zero when the pairing is
exact, so the achieved lead is at least the floor even at the worst residual and
an exact pairing pays nothing for accuracy it did not need.
The pairing comes from the kernel, and is verified
`KVM_GET_CLOCK` reports `host_tsc` at the instant it was called and computes the
clock it returns from exactly that value (`__get_kvmclock` ends with
`__pvclock_read_cycles` on `data->host_tsc`); the guest's view of it is
`host_tsc + offset`. The bracket of two counter reads around the ioctl is kept in
a different job: as the bound the exact value has to lie inside, which turns "the
counter is not scaled" from an assumption into a checked one. On a host that
scales the guest counter the translation misses the bracket by orders of
magnitude, and the fallback says so in the log rather than degrading quietly.
The field is filled only on the masterclock branch, and `use_master_clock` is a
cached bool that only `pvclock_update_vm_gtod_copy` writes. At partition build it
was computed once, in `kvm_arch_init_vm`, when the vm had no vcpus and the
matching-tsc test could not hold; the vcpu creations since made that test true but
only raised `KVM_REQ_MASTERCLOCK_UPDATE`, which a vp has to run to service, and
none has. Measured there: 4 of 4 reads carried no host counter, every read once the
guest was running did. `KVM_SET_CLOCK` is the way out, because
`kvm_vm_ioctl_set_clock` calls `pvclock_update_vm_gtod_copy` on the calling thread,
so writing the clock back at the value just read recomputes the flag with no vp.
That rebases `kvmclock_offset` and rewinds the clock by the read-to-write gap,
which is acceptable at that one point and nowhere else: nothing has observed the
clock yet, and the counter is then put on whatever it reads afterwards. On a
machine reset the caller has just written the clock anyway, so the first read
there already carries the counter.
And the result is measured rather than assumed. After the write the pair is read
again under the new offset, the lead it produced is computed from that reading,
and it is classified: within the band both measurements allow, outside it, or
under the floor. The last is an error-level line naming the lead, because a host
where this goes wrong would otherwise produce a timer storm and no signal at all.
Two things the arithmetic has to get right, because both were wrong first and both
were caught by that verification rather than by a test:
* The clock and the lead are converted to ticks as one ceiling over the whole sum.
Converting them separately - the clock truncating, the lead ceiling - leaves the
clock's own fractional tick discarded, which the lead's ceiling cannot always
make up, so the target lands under `clock + lead` depending on the clock's value.
* The achieved lead is differenced in the counter's own units and converted once,
and it is judged at the resolution the conversions actually have (two whole
nanoseconds plus a counter tick), kept separate from the band tolerance so the
pairing error cannot excuse a genuine shortfall. Converting both counters to
nanoseconds and subtracting puts the quantization error of both into an answer
that is one part in fifty thousand of either.
`kvm::Partition::get_clock_ns` becomes `get_clock` and returns a `ClockReading`
whose `host_tsc` and `realtime` are options, so the flag bit and the field it
guards stay together and no caller can read an unfilled field as a value.
Converting the clock to ticks needs the guest's own rate, so this adds
`KVM_GET_TSC_KHZ` on the bsp vcpu, asked of the vcpu rather than the vm so the
answer is the rate the guest sees where the hardware scales the counter. The
multiply is widened to 128 bits, since a long-running partition's clock times a
GHz-scale rate leaves 64 bits well before the counter it describes does.
Unit tests cover the offset arithmetic (a counter behind the clock, ahead of it,
already on it, a conversion too large for 64 bits, a correction carrying the offset
below zero), the bracket midpoint and its error bound against either endpoint, a
bracket spanning the counter wrap, and the direction stated as the inequality a
reader cares about. The conversion tests drive the production conversions rather
than a test helper, compare against `clock + lead` as exact rationals, sweep the
clock as well as the rate, and carry a negative control that fails if no swept case
defeats the earlier piecewise form. The origin gap itself is only observable live.
|
This PR modifies files containing For more on why we check whole files, instead of just diffs, check out the Rustonomicon |
There was a problem hiding this comment.
Pull request overview
This PR fixes a KVM timebase mismatch by actively aligning the guest TSC (guest-visible counter) to the partition reference clock during partition creation and machine reset, preventing Hyper-V synthetic timer storms and reset-time boot hangs caused by divergent clock origins.
Changes:
- Add a KVM-specific alignment routine to compute and apply a consistent vCPU TSC offset derived from a paired reference-clock/counter sample (with post-write verification).
- Invoke the alignment after vCPU creation (partition build) and after
reset_all()(machine reset) to keep both partition time views consistent. - Extend the
vm/kvmwrapper to expose richer kvmclock reads (host_tsc/realtimeasOption) plus vCPU TSC rate/offset attribute ioctls, and update reference-time reads to use the new API.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| vmm_core/virt_kvm/src/arch/x86_64/vm_state.rs | Switch reference-time save/restore reads to the new get_clock() API and clock_ns. |
| vmm_core/virt_kvm/src/arch/x86_64/mod.rs | Implement and apply guest-TSC-to-reference-clock alignment on partition creation and reset, including verification logic and unit tests for the arithmetic. |
| vm/kvm/src/lib.rs | Introduce ClockReading, replace get_clock_ns() with get_clock(), and add KVM vCPU TSC rate/offset support plumbing. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| fn classify_achieved_lead(achieved_ns: i64, against: &LeadTolerances) -> LeadVerdict { | ||
| // Judged at the resolution of the instrument. A reported shortfall smaller than what | ||
| // the verification's own conversions discard says nothing about where the counter is, | ||
| // and firing on it costs the alarm its meaning: measured on this host, the bare | ||
| // comparison fired on about a third of boots at 1 to 2 ns under a 20 us floor. | ||
| let resolution_ns = against.resolution_ns.min(i64::MAX as u64) as i64; | ||
| if achieved_ns.saturating_add(resolution_ns) < GUEST_TSC_LEAD_FLOOR_NS as i64 { | ||
| return LeadVerdict::BelowFloor; | ||
| } | ||
| let low = against.requested_ns.saturating_sub(against.band_ns); | ||
| let high = against.requested_ns.saturating_add(against.band_ns); | ||
| if (achieved_ns as u64) < low || achieved_ns as u64 > high { | ||
| return LeadVerdict::OutsideBand; | ||
| } | ||
| LeadVerdict::AsIntended | ||
| } |
There was a problem hiding this comment.
Fixed, though not by the path described.
lead_measurement_resolution_ns is 2 + ceil(1_000_000 / khz), so for resolution_ns to
reach the 20 us floor the counter would have to run below about 51 kHz. At any rate a host
actually reports, the floor test catches every negative lead before the cast is reached, so
the misclassification is not reachable as written.
The cast is still worth removing. Its soundness rests on a coupling between
GUEST_TSC_LEAD_FLOOR_NS and the resolution formula that nothing states: lower the floor
towards a microsecond and a small negative lead starts clearing the floor test, widens into
a large positive on the cast, and the counter trailing the clock gets reported as a harmless
overshoot. That is the dangerous direction rendered as the safe one, in the check that
exists to catch it.
The sign is now settled first, on its own terms. Added a test that fails on the previous
code with left: OutsideBand, right: BelowFloor.
| /// Returns whether the kernel implements the vcpu TSC-offset attribute. | ||
| /// | ||
| /// The attribute landed in Linux 5.16, so an older kernel answers `ENXIO` here | ||
| /// rather than failing the write later. Callers that only want the TSC to move | ||
| /// can degrade quietly on `false`. | ||
| #[cfg(target_arch = "x86_64")] | ||
| pub fn supports_tsc_offset(&self) -> bool { | ||
| // SAFETY: KVM_HAS_DEVICE_ATTR reads only the struct; `addr` is unused for it. | ||
| unsafe { | ||
| ioctl::kvm_has_device_attr( | ||
| self.get().vcpu.as_raw_fd(), | ||
| &kvm_device_attr { | ||
| group: KVM_VCPU_TSC_CTRL, | ||
| attr: KVM_VCPU_TSC_OFFSET as u64, | ||
| addr: 0, | ||
| flags: 0, | ||
| }, | ||
| ) | ||
| .is_ok() | ||
| } |
There was a problem hiding this comment.
The comment was wrong and is corrected: any error is read as unsupported, not only the
ENXIO a kernel older than the attribute answers with.
Leaving the signature alone. Every caller's response to a failed probe is the same
degrade-and-warn path whatever the errno, so a Result here would hand the caller a
decision it has no way to act on differently.
I did try logging the errno, and backed it out: vm/kvm carries no logging dependency, and
neither does the sibling wrapper crate, so one debug line meant adding a dependency edge to
a crate that is deliberately pure FFI. The caller already warns that the alignment was
skipped, so what the errno would have added is detail, not the fact of it.
| #[cfg(target_arch = "x86_64")] | ||
| pub fn tsc_khz(&self) -> Result<u32> { | ||
| // SAFETY: the request carries no payload; the rate comes back as the result. | ||
| let khz = unsafe { | ||
| ioctl::kvm_get_tsc_khz(self.get().vcpu.as_raw_fd()).map_err(Error::GetTscKhz)? | ||
| }; | ||
| Ok(khz as u32) | ||
| } |
There was a problem hiding this comment.
Fixed. tsc_khz now rejects a rate that is not strictly positive instead of casting it,
and carries the raw signed value in the error so the zero case stays distinguishable from
the negative one.
The negative is the worse of the two. Zero at least collapses the conversions to nothing
and reads afterwards as an absent lead; a negative widens into a rate near 4.29e9 kHz and
produces an offset that looks entirely plausible, which the verification has no reason to
flag.
What the unusable rate then does to the alignment is the other thread on this PR.
| let guest_tsc_khz = bsp.tsc_khz()?; | ||
| let offset = bsp.tsc_offset()?; | ||
|
|
||
| prime_reference_clock_pairing(vm)?; | ||
|
|
||
| let sample = sample_reference_clock_against_counter(vm, &bsp, offset, guest_tsc_khz)?; | ||
| if let CounterPairing::Disagrees(by_ticks) = sample.pairing { | ||
| // Worth saying out loud rather than silently degrading: the kernel offered a | ||
| // pairing and the guest's view of it is not where the bracket says the counter | ||
| // was. On a host that scales the guest counter that is expected and the fallback | ||
| // is correct; anything else means one of the two readings is not what it claims. | ||
| tracing::warn!( | ||
| by_ticks, | ||
| "kvm reported a host counter for the reference clock that is not the guest's \ | ||
| view of it; falling back to bracketing" | ||
| ); | ||
| } | ||
|
|
||
| let before = sample.guest_tsc; | ||
| let requested_lead_ns = guest_tsc_lead_ns(sample.error_ns); | ||
| let new_offset = tsc_offset_aligned_to_reference_clock( | ||
| offset, | ||
| before, | ||
| sample.reference_clock_ns, | ||
| guest_tsc_khz, | ||
| requested_lead_ns, | ||
| ); |
There was a problem hiding this comment.
Fixed, and this was the one with teeth.
At a rate of zero the target counter falls out at zero ticks whatever the reference clock
reads, so the offset written to every vp puts the guest counter on an origin unrelated to
the clock. The verification afterwards does notice, reading the lead as absent, but only
after the write has landed, and noticing is not undoing. That makes it strictly worse than
the no-op of leaving the counter where it was, which is at least self-consistent.
The rate is now rejected at the wrapper, and the caller degrades exactly the way it already
does when the kernel cannot express the attribute at all: warn, and return without aligning.
The alignment is best effort by design, so an unreadable rate must not fail the partition
build.
The leaf conversions keep their own zero guards. Those keep the arithmetic defined; they
were never enough to keep the answer meaningful, which is why the check belongs at the
entry point as well.
Three review findings on the alignment, all of them about an input the code trusted rather than checked. kvm::Processor::tsc_khz cast the ioctl's signed result straight to u32, so a negative rate widened into one near 4.29e9 kHz and a zero passed through untouched. Either one scales the entire alignment: the target counter falls out at zero ticks and the offset written to every vp puts the guest counter on an origin unrelated to the reference clock, which is worse than not aligning at all, and the verification afterwards can report the missing lead but cannot undo the write. Reject a non-positive rate at the wrapper, and have the caller degrade the way it already does when the kernel cannot express the attribute - warn, and leave the counter where it was - rather than fail the partition build over a correction that is best effort by design. classify_achieved_lead settled the sign only as a side effect of the floor test. That holds today because a resolution derived from any plausible counter rate cannot reach the floor, but it is a coupling between two constants that nothing states: lower the floor towards a microsecond and a small negative lead clears the floor test, then widens through an unsigned cast into a large positive, so the counter trailing the clock is reported as a harmless overshoot. Decide the sign first, on its own terms. supports_tsc_offset already did the right thing with an error and only its comment was wrong, naming ENXIO as though a kernel older than 5.16 were the sole way the probe can fail. Say what the code does instead: any error means the attribute is unavailable, and the reason is dropped deliberately, because no caller has a response to one that differs from its response to another.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
vm/kvm/src/lib.rs:31
ioctl_none_badis only used in an#[cfg(target_arch = "x86_64")]ioctl definition, but theuse nix::ioctl_none_bad;import is unconditional. On non-x86_64 builds this becomes an unused import and can fail CI under-D warnings. Gate the import with the same cfg as the use site.
#[cfg(target_arch = "x86_64")]
use nix::errno::Errno;
use nix::ioctl_none_bad;
use nix::ioctl_read;
| pairing = alignment.pairing, | ||
| "guest tsc lead is under its floor; the guest may arm past-dated synthetic timers" | ||
| ), | ||
| } |
There was a problem hiding this comment.
Declining this one: the code compiles.
The match ending at this line is in STATEMENT position, not value position. Every arm is a
tracing::warn! / tracing::info! call, so the match evaluates to (), and Rust does not require
a semicolon after a block-like expression used as a statement. The Ok(Some(alignment)) below it
is the function's tail expression rather than a second expression sharing a statement with it.
The match this change actually added is the other one, in align_guest_tsc_to_reference_clock:
let guest_tsc_khz = match bsp.tsc_khz() { ... };. That one IS in value position, and it does
carry its semicolon.
Verified rather than argued, on this head: cargo clippy --workspace --all-targets -- -D warnings
passes, which compiles every crate in the workspace; so do cargo build -p openvmm and
cargo test -p virt_kvm (45 passed).
This replaces #4253, which I closed after review pointed out, correctly, that it froze the reference counter instead of moving the counter that had failed to move. This one fixes the half that was actually wrong, and the same root cause turns out to affect partition creation as well as reset.
The defect
A partition has two views of time, and a guest hypervisor calibrates one against the other, so they have to start together. On KVM they do not, at either of the two points where the partition's clock starts.
stimer_start()inarch/x86/kvm/hyperv.cdecides whether a synthetic timer has already expired:The two sides of that comparison come from different clocks.
stimer->countis written by the guest and computed from the guest's own counter:counter_now + horizon.time_nowis the partition reference counter, whichget_time_ref_counterderives from the kvmclock (the TSC page's scale and offset are computed incompute_tsc_page_parametersfromhv_clock->system_time, so the reference counter follows the kvmclock whatever the TSC offset is).So if the guest counter trails the clock by D,
counter_nowreads D low, every deadline lands D early, and the immediate-expire branch is taken for every horizon shorter than D. Both start points leave it trailing:kvm_arch_init_vmsetskvmclock_offsetto minus the current base time) and fixes the guest counter's origin only when the BSP vCPU is created (kvm_arch_vcpu_postcreatecallskvm_synchronize_tsc(vcpu, NULL)). openvmm builds the whole partition between those two ioctls: the supported-CPUID query, the leaf build, capability derivation. The guest then carries that interval as a constant disagreement for its whole life.reset_allsets the reference clock to its at-reset value of zero and asks the same of each vp'sTsc. That second half silently does not happen. The write goes throughMSR_IA32_TSC, andkvm_synchronize_tscreads a host write of exactly zero as "userspace is creating or synchronizing this vcpu" rather than as a value to store, substitutingkvm->arch.cur_tsc_offsetfor the offset the write implied. Zero, the one value a reset needs to deliver, is the one value that path cannot deliver.What it measured
The creation gap ran 832 to 977 us over seven cold starts on the host below. It lands 1:1 in the horizon a guest hypervisor computes for a synthetic timer deadline: regressing horizon on offset across those runs gives slope 1.0107 over a 10,153-tick offset span. That shortens the 1.978 ms one-shot this guest arms to about 1.15 ms and puts roughly half the arms in the past, giving about 1.88 M past-dated re-arms a second and a nested guest that never reached a usable desktop. After the change, 0.012 to 0.024 a second, and the guest boots and stays up.
The reset half is coarser and easier to see. A guest hypervisor calibrates its reference clock off the counter and arms a one-shot at an absolute deadline on the previous boot's timeline, 38.50 s measured, while the partition counter reads 2.24 s. The deadline is then about 36 real seconds away, the clock init polls for it with a bounded budget, retires the timer when it never fires, runs out of candidates and bugchecks, which presents as a hang at the firmware logo. The MSR behaviour was confirmed directly on a live guest: counter at 5403506220, wrote 0, read back 5403522705, while a control write of 0x4000000000000000 landed.
The fix
Both halves are the same operation, so they are one function called from two places: read the clock, read the counter, write the corrected offset to every vp. At creation it runs after the
add_vploop, so the loop's own duration is not left in the answer. At reset it runs afterreset_all, which has just returned the clock to zero.The correction moves the counter to the clock and never the other way. The clock is the partition's authority on time: every synthetic timer deadline is expressed in it and
GetReferenceTimereads it directly, while the counter is a per-vp view of the same instant.The write goes through the vCPU device attribute
KVM_VCPU_TSC_CTRL/KVM_VCPU_TSC_OFFSETrather thanMSR_IA32_TSC, because the MSR path infers what userspace meant from the value's distance to where the counter would be anyway. A write of exactly zero is read as vCPU creation, which is the reset defect above, and a non-zero write within a second of the previous one is folded onto the existing offset by the slop branch oncekvm->arch.user_set_tscis set. The creation half needs the counter at the clock's value, which is inside that window by construction, and a reset following the previous write inside a second is inside it too.kvm_arch_tsc_set_attrhas no such heuristic and stores what it is given. On a kernel without the attribute, which landed in 5.16,KVM_HAS_DEVICE_ATTRsays so up front and openvmm warns and proceeds rather than failing the partition.Every vp gets the same offset, computed once. The kernel takes unequal offsets as unsynchronized vcpus, so one pass with one value opens a single TSC generation that every vp joins, where a separately sampled value per vp would open one generation per write and drop the partition out of masterclock mode, which the reference clock is built on.
Why the counter is left ahead of the clock
The category is precedented. The placement is not, and that is the honest way to put it.
KVM already programs a timer to fire early to cover a latency it cannot remove. That is
lapic_timer_advanceinarch/x86/kvm/lapic.c, and its comment is this argument in other words: KVM "programs the host timer event to fire early, i.e. before the deadline expires, to account for the delay between taking the VM-Exit ... and the subsequent VM-Enter".start_sw_tscdeadlineapplies it asktime_sub_ns(expire, timer_advance_ns)behind a guard ofns > timer_advance_ns, which is a do-not-schedule-closer-than rule written as code. The sizes areLAPIC_TIMER_ADVANCE_NS_INIT1000 andLAPIC_TIMER_ADVANCE_NS_MAX5000, tuned between 100 and 10000 cycles in eighths byadjust_lapic_timer_advance, withlimit_periodic_timer_frequencyas the companion floor on period.What is new is the stage.
timer_advance_nsis applied when a deadline becomes a host timer. That stage is not reachable from here: by the timestimer_startgets to it, the arm has already been sorted into past or future, and the sorting is the part that goes wrong. So the same compensation moves one step earlier, into the origin the deadline is computed from. Not a new idea, and not an already-blessed placement either.A reviewer will find
kvm_xen_start_timer(arch/x86/kvm/xen.c) sooner or later, so I would rather raise it myself. It names our defect precisely and fixes it the same way, by computing the guest's view of the clock the way the guest computes it instead of from a host-side approximation, refusingget_kvmclock_ns()for having "a systemic error in its results because it scales directly from host TSC to nanoseconds, and doesn't scale first to guest TSC and then to nanoseconds as the guest does". And then it stops. It names the residual left between its ownktime_get()andrdtsc()and accepts it uncompensated. If matching the origins and living with the residual is enough for Xen timers, why is it not enough here?Because of where the residual leads. Xen's answer to a non-positive delta is
xen_timer_callbackcalled inline, and KVM's answer instart_sw_tscdeadlineisapic_timer_expired. Both are terminal. A one-shot that fires immediately fires once, the guest loses a tick, life goes on. The Hyper-V direct-mode auto-enable one-shot re-arms on delivery. Firing it immediately does not cost a tick, it hands the guest back a deadline that the guest recomputes from the same trailing counter and re-arms just as short. Nothing in that loop terminates it. That is the storm, 1.88 M past-dated re-arms a second. So the remedy KVM accepts for a horizon shorter than the latency is structurally unavailable here, and the compensation has to land before the comparison rather than after it.For a timer that re-arms, the kernel has already decided which way a past deadline should go, and it decided it in this same function.
stimer_start's periodic branch does not fire a past-dated deadline immediately. It re-anchors it strictly into the future and throws the missed ticks away:Push forward, do not fire, when firing would only come straight back. This change does that to the one-shot's inputs, which is the only place a one-shot allows it.
Stock KVM needs none of this, because there the guest and the kernel share one origin by construction:
compute_tsc_page_parametersderives the reference TSC page from the kvmclock andget_time_ref_counterreads that same struct back. The problem exists only where something else establishes the counter, which is our case.That leaves the sign and the magnitude, which are two different kinds of claim.
The sign holds anywhere. It is read straight off
time_now >= stimer->count: at a lead of exactly zero a zero-horizon arm still satisfies the comparison and fires immediately, so only a strictly positive lead is safe. The intuition runs backwards here, since a counter that trails makes deadlines read early rather than far away. A zero lead was measured rather than argued about, interleaved, 3 rounds by 2 cells by cold and warm. At 20 us: 0.027 past-dated arms a second, with no near-class arm at all across 565 s and 12 boots. At zero: 0.104 a second, and 46 of the 63 formed a near class between -8.2 and -78.9 us, median -19.5 us, none of them within 3 us of zero. So the class the lead removes is real, and its absence is not the same thing as arms that were genuinely due. The control is that the main horizon cluster moved from 2007.6 to 1987.6 us, exactly 20.0 us, so the lead was the only variable. There is a deeper class from -277 us out to -193 ms that sits at about 0.027 a second in both arms; no lead of this size touches it and none is claimed to.The magnitude covers one delay, L, between the guest reading its counter to compute a deadline and KVM evaluating that deadline against the clock. Over L the clock advances and the guest's number does not, so a zero-horizon arm reads as past whenever the lead is at or under L. L was measured directly rather than inferred from a symptom, pairing
kvm_exit(MSR_WRITE)with theset_countand theget_time_ref_counterinstimer_start: about 98k arms per run, 100% pairing, and two independent arms agreeing. p50 3.2 us, p90 3.9, p99 12 to 14, p99.9 18.4 to 18.7, max 72 to 123. So 20 us is L's p99.9 rounded up, not a multiple of its median. Anyone can rebuild that histogram on their own host and check the number, which is the main thing it has going for it. It is also the bottom of the 21-to-38 us achieved band that measured clean, so it sits at the edge of measured data rather than under it.An adaptive lead is the obvious next question, given that
timer_advance_nsis adaptive and this is not.adjust_lapic_timer_advancecan close a loop because it hasguest_tsc - tsc_deadlinein hand on every expiry. There is no comparable signal here. This correction runs twice in a partition's life, at creation and at reset, and what it would have to converge on is a property of the host's exit latency rather than of anything the code sees afterwards. Making it adaptive means first building the measurement, which is the ftrace pairing above, and then putting it in the arm path. That is worth doing if the static floor turns out to be wrong somewhere, and not before. It is future work, and I would rather say so than imply the static value is the last word.The floor is not the whole lead: the request adds the pairing's own worst-case error, so the achieved lead is at least the floor even at the worst residual, and a partition whose pairing is exact pays nothing for accuracy it did not need. And the result is measured rather than assumed. After the write the pair is read again under the new offset, the lead it produced is computed from that reading and classified: inside the band both measurements allow, outside it, or under the floor. The last one is an error-level line naming the lead, because a host where this goes wrong would otherwise produce a timer storm and no signal at all.
The cost is a synthetic timer delivered up to 20 us late, about 1% of the 1.978 ms period this guest arms.
The counter/clock pairing
KVM_GET_CLOCKreportshost_tscat the instant it was called and computes the clock it returns from exactly that value:__get_kvmclockends with__pvclock_read_cyclesondata->host_tsc. The guest's view of it ishost_tsc + offset. Using that makes the correction exact instead of reconstructed.The bracket, two counter reads around the ioctl, is kept but does a different job now: it is the bound the exact value has to lie inside, which turns "the guest counter is not scaled" from an assumption into a checked one. On a host that scales the counter the translation misses the bracket by orders of magnitude, and the fallback says so in the log rather than degrading quietly.
One subtlety is worth calling out, because it is what makes the field usable at partition build.
KVM_CLOCK_HOST_TSCis set only on the masterclock branch, anduse_master_clockis a cached bool that onlypvclock_update_vm_gtod_copywrites. At build time it was computed once, inkvm_arch_init_vm, when the VM had no vcpus and the matching-TSC test could not hold. The vCPU creations since made that test true but only raisedKVM_REQ_MASTERCLOCK_UPDATE, which a vp has to run to service, and none has. Measured there: 4 of 4 reads carried no host counter, and every read once the guest was running did.KVM_SET_CLOCKis the way out, becausekvm_vm_ioctl_set_clockcallspvclock_update_vm_gtod_copyon the calling thread, so writing the clock back at the value just read recomputes the flag with no vp needing to run. That rebaseskvmclock_offsetand rewinds the clock by the read-to-write gap, which is acceptable at that one point and nowhere else: nothing has observed the clock yet, and the counter is then put on whatever it reads afterwards. On a machine reset the caller has just written the clock anyway, so the first read there already carries the counter.Testing
Unit tests cover the offset arithmetic (a counter behind the clock, ahead of it, already on it, a conversion too large for 64 bits, and a correction that carries the offset below zero), the bracket midpoint and its error bound against either endpoint, a bracket spanning the counter wrap, and the direction itself stated as the inequality a reader cares about: the corrected counter ends strictly ahead of the clock.
The verification's own arithmetic is tested against the production conversions rather than a test helper, comparing counters to
clock + leadas exact rationals, sweeping the clock as well as the TSC rate, and carrying a negative control that fails if no swept case defeats the earlier form. That control exists because an earlier version of this test read the same clock value the correction was computed from, so the quantization cancelled and the test agreed with the write by construction.Live: 8 boots with no shortfall reported, and the host-side error counters stayed flat across 155 further alignments.
No Guide change: nothing here is user-facing. There is no new flag and no changed command line, only different behaviour on the two paths described above.
Scope, and what has not been covered
Everything here was developed and measured on one host: a Xeon E5-2697 v2 running a nested guest hypervisor. Some of it generalises and some of it plainly does not, so here they are apart.
The sign generalises. It is derived from
time_now >= stimer->countinstimer_startand from nothing else. A lead of zero admits an immediate expiry on any host, and a strictly positive lead is required on any host. No measurement of mine is load-bearing for that.The magnitude does not. 20 us is L's p99.9 on this machine, and L is an exit-and-handle latency, so it will be a different number elsewhere. It is a floor rather than a target, which biases it the safe way: a host with a smaller L pays slightly more delivery latency than it needs, and a host with a larger L is the case to watch. That case is at least visible rather than silent, since the achieved lead is measured after the write and a shortfall is logged at error level. The 832 to 977 us creation gap is in the same category, being this host's build time between two ioctls and not a constant.
The scaled-counter path is untested, and this host cannot test it. The Xeon E5-2697 v2 has no VMX TSC scaling at all, so the guest counter is never scaled here and the only exercise the path gets is the bracket check detecting the case and taking the fallback. On current hardware the branch this machine cannot reach is the one that runs, so a reviewer's own box will exercise it the first time it runs the code. That is the part I would most like read closely.
The origin gap itself is only observable live, so the unit tests cover the arithmetic around it rather than the gap.