Skip to content

Commit 2d92c39

Browse files
committed
fix(core): Stopwatch::Frequency is the clock's own (#2326, SR-AUD-130)
Frequency was 10,000,000 and GetTimestamp() divided the clock's nanosecond count by 100. .NET reports the platform's frequency -- 1,000,000,000 on Unix. Landed under SA-8, option A of the review's two. THE OLD PAIR WAS SELF-CONSISTENT AND STILL A DEFECT, and that distinction is the ticket. Every elapsed time it computed was correct; what it could not do was RESOLVE anything finer than 100 ns, because GetTimestamp() threw the low two digits away -- two events 30 ns apart got the same timestamp here and different ones in .NET. A test asserts the resolution directly rather than the constant. THE VALUE IS DERIVED FROM Clock::period, NOT TRANSCRIBED. .NET's Windows build returns QueryPerformanceFrequency, but this port samples std::chrono:: steady_clock, so reporting QPC's frequency would be a lie about a different timer. It also stays constexpr where .NET's is a runtime static readonly, which is a stronger guarantee, not a weaker one. THE REVIEW'S WARNING WAS CORRECT AND WAS ACTED ON. Twelve tests across two suites had to be rescaled, and none was a regression: they are CCF-004's evidence that the subtraction is DEFINED, which is untouched -- the delta still wraps to exactly the two's-complement value. What moved is the reported tick count, and every new expectation is .NET's own (long)((end - start) * 0.01), hand-computed rather than taken from the implementation, because a test that mirrors its own formula proves nothing. Two needed more than a number: SignedSweepAtUnitFrequency and PrecisionAbovePow2_53IsAPreExistingProperty are about double rounding at unit scale, and the system provider only HAPPENED to have unit frequency. They now construct a FixedFrequencyProvider(TicksPerSecond), which is what their names always claimed. Instance measurement is completely unaffected -- it never went through Frequency. Gate 17,306 run, 0 failed. Downstream: zero sites in either consumer.
1 parent 76564b1 commit 2d92c39

7 files changed

Lines changed: 311 additions & 61 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

audit/AUDIT_FINDINGS_INDEX.md

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
<!-- SPDX-License-Identifier: MIT -->
2+
<!-- Copyright (c) Robert Vokac and contributors -->
3+
4+
# Migration — `Stopwatch::Frequency` is the clock's, not the `TimeSpan` tick rate (ticket #2326)
5+
6+
*2026-08-18.* `Stopwatch::Frequency` was `10'000'000` and `GetTimestamp()` divided the clock's
7+
nanosecond count by 100. .NET reports the **platform's** frequency — 1,000,000,000 on Unix.
8+
9+
Landed under `docs/StandingApprovals.md` **SA-8**. **Timestamp values change scale by 100×** and
10+
`GetElapsedTime`'s conversion is no longer the identity. No signature, layout, vtable or
11+
`noexcept` change, and `Frequency` stays `constexpr`.
12+
13+
---
14+
15+
## 1. What changed
16+
17+
| | Was | Is |
18+
|---|---|---|
19+
| `Stopwatch::Frequency` | `10'000'000` | **`1'000'000'000`** |
20+
| `GetTimestamp()` | nanoseconds **÷ 100** | the clock's raw nanosecond count |
21+
| `GetElapsedTime(a, b)` scale factor | exactly `1.0` | **`0.01`** — .NET's `s_tickFrequency` |
22+
| `GetElapsedTime(1000, 3000)` | `2000` ticks | **`20`** ticks (2000 ns *is* 20 ticks) |
23+
| `TimeProvider::getTimestampFrequencyProperty()` | `10'000'000` | **`1'000'000'000`** (it forwards) |
24+
| `Elapsed`, `ElapsedMilliseconds`, `ElapsedTicks` on an instance || **unchanged** |
25+
| every measured duration || **unchanged, and finer** |
26+
27+
## 2. Why the old value was a defect, not merely a different choice
28+
29+
The old pair was **self-consistent**: `Frequency` was `TimeSpan::TicksPerSecond`, timestamps were
30+
100-ns ticks, and every elapsed time it computed was correct. What it could not do was **resolve**
31+
anything finer than 100 ns — `GetTimestamp()` threw the low two digits away, so two events 30 ns
32+
apart received the *same* timestamp here and different ones in .NET.
33+
34+
A test asserts the resolution directly: two back-to-back `GetTimestamp()` calls must be able to
35+
differ by less than 100.
36+
37+
## 3. The value is derived, not transcribed
38+
39+
```cpp
40+
static constexpr longcs Frequency = Clock::period::den / Clock::period::num;
41+
```
42+
43+
.NET's Windows build returns `QueryPerformanceFrequency`. This port samples
44+
`std::chrono::steady_clock`, so **reporting QPC's frequency would be a lie about a different
45+
timer**. Reporting the frequency of the clock actually read is the correct answer, and on both
46+
libstdc++ and MSVC `steady_clock::period` is `std::nano` — .NET's own Unix answer.
47+
48+
It also stays `constexpr`, where .NET's is a runtime `static readonly`. That is a *stronger*
49+
guarantee than .NET gives, not a weaker one.
50+
51+
## 4. To migrate
52+
53+
A stored timestamp from a previous build is no longer comparable with a new one — but that was
54+
never a durable value anyway (`steady_clock`'s epoch is unspecified and process-local).
55+
56+
```cpp
57+
// converting a timestamp delta to seconds: unchanged, and now more precise
58+
double seconds = double(t2 - t1) / Stopwatch::Frequency;
59+
60+
// code that assumed timestamps were TimeSpan ticks
61+
TimeSpan span = TimeSpan::FromTicks(t2 - t1); // WRONG since #2326
62+
TimeSpan span = Stopwatch::GetElapsedTime(t1, t2); // right, and always was
63+
```
64+
65+
Instance measurement — `Start`/`Stop`/`Elapsed`/`ElapsedTicks` — is **completely unaffected**; it
66+
never went through `Frequency`.
67+
68+
## 5. What this ticket had to re-measure, and why
69+
70+
The review warned that `TimeProvider::GetElapsedTime`'s scaling factor was *"exactly 1.0 precisely
71+
because `Frequency == TimeSpan::TicksPerSecond`"*, and that changing `Frequency` re-enables a
72+
non-unit scale on a path carrying CCF-004 saturation work. That was correct, and twelve tests
73+
across two suites had to be rescaled.
74+
75+
**None of them was a regression.** They were CCF-004's evidence that the subtraction is *defined*
76+
rather than undefined, and that claim is untouched — the delta still wraps to exactly the
77+
two's-complement value. What moved is the reported tick count, because a scale is now applied, and
78+
every new expectation is **.NET's own**: `(long)((end - start) * 0.01)` with the saturating
79+
float-to-integer conversion modern .NET adopted.
80+
81+
Two of them needed more than a number. `SignedSweepAtUnitFrequency` and
82+
`PrecisionAbovePow2_53IsAPreExistingProperty` are about `double` rounding at unit scale, and the
83+
system provider only *happened* to have unit frequency; they now construct a
84+
`FixedFrequencyProvider(TicksPerSecond)`, which is what their names always claimed.
85+
86+
Two saturation pins stopped reaching the guard, because `0.01 × INT64_MAX` is comfortably
87+
representable. The guard is still exercised — by `CustomFrequency_ScalingLeavingRangeSaturates`,
88+
which supplies its own frequency and is unaffected.
89+
90+
## 6. Downstream, measured
91+
92+
Neither `cna` nor `mobile-eggbert` references `Stopwatch::Frequency`, `Stopwatch::GetTimestamp` or
93+
`getTimestampFrequencyProperty`**zero sites in both**. Neither repository was modified.

modules/core/include/System/Diagnostics/Stopwatch.hpp

Lines changed: 57 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -106,17 +106,44 @@ namespace System::Diagnostics {
106106

107107
/**
108108
* @brief Gets the frequency of the timer as the number of ticks per second.
109-
* Returns 10,000,000 to match the .NET tick resolution (100 ns per tick).
109+
*
110+
* C++ counterpart of .NET `Stopwatch.Frequency`, which is `public static readonly long`
111+
* and **platform-determined**: `Stopwatch.Unix.cs:8-12` returns 1,000,000,000 and the
112+
* Windows build returns `QueryPerformanceFrequency`.
113+
*
114+
* @par This was 10,000,000 until ticket #2326, and the old value cost RESOLUTION
115+
* The port reported .NET's `TimeSpan` tick rate rather than its own clock's, and
116+
* `GetTimestamp()` divided by 100 to match — so two events 30 ns apart received the
117+
* **same** timestamp here and different ones in .NET. The pair was self-consistent, and
118+
* every elapsed time it computed was correct; what it could not do was resolve anything
119+
* finer than 100 ns.
120+
*
121+
* The value is derived from `Clock::period`, which is the frequency of the clock this
122+
* type actually reads. That is deliberate and is not the same as transcribing .NET's
123+
* Windows branch: reporting `QueryPerformanceFrequency` while sampling
124+
* `std::chrono::steady_clock` would be a lie about a different timer. On libstdc++ and
125+
* MSVC alike `steady_clock::period` is `std::nano`, so this is 1,000,000,000 — .NET's
126+
* own Unix answer.
127+
*
128+
* @note It stays `constexpr`, where .NET's is a runtime `static readonly`, because
129+
* `Clock::period` is a compile-time property. That is a *stronger* guarantee than
130+
* .NET gives, not a weaker one, so no caller can be surprised by it.
110131
*/
111-
static constexpr longcs Frequency = 10'000'000LL;
132+
static constexpr longcs Frequency =
133+
static_cast<longcs>(Clock::period::den) / static_cast<longcs>(Clock::period::num);
112134

113135
/** @brief Indicates whether the Stopwatch timer is based on a high-resolution performance counter. */
114136
static constexpr bool IsHighResolution = true;
115137

116-
/** @brief Returns the current high-frequency timestamp in Frequency units (100-ns ticks since epoch). */
138+
/**
139+
* @brief Returns the current high-frequency timestamp, in `Frequency` units.
140+
*
141+
* Since ticket #2326 this is the clock's own raw count — nanoseconds on every platform
142+
* this port builds for — rather than that count divided by 100. The division was what
143+
* cost the resolution described on `Frequency`.
144+
*/
117145
[[nodiscard]] static longcs GetTimestamp() {
118-
return std::chrono::duration_cast<std::chrono::nanoseconds>(
119-
Clock::now().time_since_epoch()).count() / 100LL;
146+
return static_cast<longcs>(Clock::now().time_since_epoch().count());
120147
}
121148

122149
/**
@@ -129,10 +156,15 @@ namespace System::Diagnostics {
129156

130157
/**
131158
* @brief Gets the elapsed time between two timestamps previously retrieved via
132-
* GetTimestamp(). Both @p startingTimestamp and @p endingTimestamp are already expressed
133-
* in Frequency (100-ns tick) units on this port, so no unit conversion is needed --
134-
* real .NET's own s_tickFrequency scaling factor reduces to exactly 1.0 here since
135-
* Frequency == TimeSpan.TicksPerSecond.
159+
* GetTimestamp().
160+
*
161+
* @par The scaling factor is no longer 1.0 (#2326)
162+
* It used to be, because `Frequency` was `TimeSpan::TicksPerSecond` and the timestamps
163+
* were already `TimeSpan` ticks. Now `Frequency` is the clock's own — 1,000,000,000 —
164+
* so this applies .NET's `s_tickFrequency = TimeSpan.TicksPerSecond / Frequency`
165+
* (`Stopwatch.cs:18`) like the reference does. The delta is still computed in the
166+
* unsigned domain first, for the CCF-004 reason below, and the scaling saturates rather
167+
* than overflowing.
136168
*/
137169
[[nodiscard]] static System::TimeSpan GetElapsedTime(longcs startingTimestamp, longcs endingTimestamp) {
138170
// CCF-004 class A -- defined wrap (docs/DefinedArithmeticBoundaryPlan.md section 4.1,
@@ -144,7 +176,22 @@ namespace System::Diagnostics {
144176
// one-argument door, so the same wrap is produced in the unsigned domain and
145177
// converted back. Every representable difference is byte-identical to before, and the
146178
// four wrapping shapes keep the exact values they had (SR-AUD-131, ticket #2218).
147-
return System::TimeSpan::FromTicks(subtractTimestamps(endingTimestamp, startingTimestamp));
179+
const longcs delta = subtractTimestamps(endingTimestamp, startingTimestamp);
180+
if constexpr (Frequency == System::TimeSpan::TicksPerSecond) {
181+
return System::TimeSpan::FromTicks(delta);
182+
} else {
183+
// .NET's own conversion, and it must SATURATE rather than overflow: the domain of
184+
// a nanosecond delta is wider than the TimeSpan tick domain by a factor of 100,
185+
// so a large-but-representable delta scales past INT64_MAX. The bounds are the
186+
// same ones TimeProvider::GetElapsedTime uses, for the same reason.
187+
const double scaled = static_cast<double>(delta) *
188+
(static_cast<double>(System::TimeSpan::TicksPerSecond) /
189+
static_cast<double>(Frequency));
190+
constexpr double twoPow63 = 9223372036854775808.0; // 2^63 exactly
191+
if (scaled >= twoPow63) return System::TimeSpan::FromTicks(std::numeric_limits<longcs>::max());
192+
if (scaled < -twoPow63) return System::TimeSpan::FromTicks(std::numeric_limits<longcs>::min());
193+
return System::TimeSpan::FromTicks(static_cast<longcs>(scaled));
194+
}
148195
}
149196

150197
/** @brief Returns the elapsed time formatted the same way as Elapsed.ToString(). */

0 commit comments

Comments
 (0)