Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

timerlag

The timer resolution your process actually receives — measured, not asked for.

Windows has three built-in APIs that report the system timer resolution. On the machine below, at the same instant, they gave two different answers, and the one that was right was right by accident.

  Source                                    Value         Agrees with measured?
  ────────────────────────────────────────────────────────────────────────────
  NtQueryTimerResolution: current           1.0000 ms     yes
  GetSystemTimeAdjustment: increment        15.6250 ms    NO
  GetSystemTimeAdjustmentPrecise            15.6250 ms    NO
  System clock: observed step (measured)    1.0077 ms     (this is the measurement)
  GetTickCount64: observed step             16.0000 ms    measures a different thing

timerlag ignores all of them and measures the granularity physically, by watching the system clock step and by timing real Sleep() calls against QueryPerformanceCounter.

Zero dependencies. One PowerShell file. Read-only.


The thing it was built to find

On Windows 11 25H2, a process that Windows has decided to power-throttle does not get the fine timer it asks for — and timeBeginPeriod returns success anyway.

  STATE                                     SLOPE          SHAPE      MODEL FIT (RMS)
  ---------------------------------------------------------------------------------
  DEFAULT                                    0.014   ->   Flat        track 3.30  flat 0.08
  AFTER timeBeginPeriod(1)                   0.014   ->   Flat        track 3.30  flat 0.06
  AFTER ALSO OPTING OUT OF THROTTLING        0.971   ->   Tracking    track 0.16  flat 3.25
  AFTER RELEASING EVERYTHING                -0.011   ->   Flat        track 3.38  flat 0.06

  VERDICT
    -> Windows accepted your timer request and ignored it.
       Opting out was worth 7.8x on this machine.

Read the middle two rows together. timeBeginPeriod(1) returned 0 (TIMERR_NOERROR, success) and the ladder did not move — the waits stayed pinned at one fixed tick no matter what was asked for. Only after also clearing the throttling bit did the delivered time start tracking the requested time, a slope of 0.971 against an ideal 1.0. The fourth row is the control: release everything and the process falls straight back to where it started, which is how the tool proves it left nothing behind.

The call that fixes it is not timeBeginPeriod. It is SetProcessInformation(ProcessPowerThrottling, ControlMask = 0x4, StateMask = 0)PROCESS_POWER_THROTTLING_IGNORE_TIMER_RESOLUTION, cleared.

What it costs, in the unit that matters:

    60 fps      frame is 16.67 ms       one mistimed wait costs 0.92 frames
   120 fps      frame is  8.33 ms       one mistimed wait costs 1.84 frames
   144 fps      frame is  6.94 ms       one mistimed wait costs 2.21 frames
   240 fps      frame is  4.17 ms       one mistimed wait costs 3.68 frames

Install and run

git clone https://github.com/appsmypass/timerlag
cd timerlag
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\timerlag.ps1

The -ExecutionPolicy Bypass is not optional and not a security compromise. Most Windows machines block unsigned .ps1 files. That switch is scoped to the one process you just started and changes nothing on your system — timerlag never asks you to run Set-ExecutionPolicy, and you should be suspicious of anything that does.

Flag What it does
(none) Full measured run: the four-state ladder, the API comparison, the verdict
-Info The API comparison, the clock-step measurement and the process scan, but no Sleep ladders. Fast, and changes no timer state at all
-Json The whole report as JSON on stdout. Nothing else is ever written to stdout
-FromJson <path> Re-render a previously saved -Json report. No measurement
-Quiet Suppress the human report; warnings still go to stderr
-NoRequest Skip the timeBeginPeriod / opt-out states. Measures only what you already have
-NoProcessScan Skip the machine-wide process table
-Samples <n> Waits per rung. Default 40
-NoRun Define the functions and return, without measuring. For the test suites

No admin rights are required. Everything it reads about other processes uses PROCESS_QUERY_LIMITED_INFORMATION, and processes it cannot open are counted and reported as refused, never silently dropped.


Why you cannot just read the API

The advertised value can be flatly wrong

Captured on this machine: NtQueryTimerResolution reported 156,250 (15.625 ms) for an entire window during which the system clock was visibly stepping at 2.0 ms, six transitions in a row.

That has a consequence most tools get wrong. You cannot use the API's constancy to certify that the window was quiet, because the API is the thing under test. timerlag gates on the measured steps agreeing with each other instead, and requires both gates before it claims the two sides are comparable:

  RunStable            the observed deltas agree with one another, so the
                       measured side describes ONE granularity
  ResolutionHeldStill  the API returned the SAME value at every one of those
                       steps, so it does not matter which instant you compare

If either fails, the tool prints not comparable and explains which side moved. It does not guess.

The measurement carries its own control

GetSystemTimeAsFileTime is genuinely quantised — on this run it changed once per 39,369 reads. GetSystemTimePreciseAsFileTime, interpolated from the performance counter and read by the same loop, changed once per 1 read.

That is what proves the coarse clock's large steps are real quantisation and not just a slow measurement loop. Without it, a tool that was simply too slow to see fine steps would report exactly the same thing.

The resolution is sampled with a syscall inside the measuring loop, which could in principle move the very interval it is measuring. realcheck.ps1 settles that empirically rather than by assertion — and getting the experiment right turned out to be harder than running it. Comparing "median of five runs with sampling" against "median of five runs without" produced 15.7332 ms vs 2.0068 ms, which looks like a catastrophic observer effect and is nothing of the sort. Two structural mistakes, both worth knowing about:

  1. Two runs at two moments measure two different machines. The global resolution flips between 156,250 and 80,000 several times a second here. Over 40 probe pairs the measured step tracked the advertised value that was live at the time, not the sampling flag.
  2. The two sides were not gated equally. The measuring routine retries until the observed steps agree and the advertised value held still — but the second gate reads the API, so with sampling off it can never be satisfied and only the first gate applies. Measured over 40 runs of each: spread 0.9641 ms with sampling on, 14.7753 ms with it off. The unsampled side is not noisier because sampling calms it. It is noisier because it is the only side allowed to return a window that straddled a flip.

The fix is to supply the missing gate from outside: read the advertised value before, between and after each pair, keep only pairs where all three agree and both windows were self-stable, and compare each pair to itself. The result over 20 such pairs is a signed median of -0.0002 ms. The signed median is the claim that matches the words: a genuine observer effect is systematic and lands on one side, while the residual regime corruption that survives the bracket — a flip that leaves and returns inside a single window — is symmetric and lands on whichever side happened to be running.

GetTickCount64 is not a wrong answer, it is a different question

It advances on the fixed ~15.6 ms clock tick regardless of the timer resolution. timerlag reports it as "measures a different thing" rather than as a disagreement — which is why so much software is unaware of the problem.

Other things Windows gets confusing about, handled

  • NtQueryTimerResolution calls the coarsest interval Minimum. It is the largest number it returns. The tool prints the range unambiguously — Range the kernel will accept: 0.5000 ms (finest) to 15.6250 ms (coarsest), from a raw 156250 and 5000 — because read as minimum interval instead of minimum resolution you have it exactly backwards.
  • GetSystemTimeAdjustmentPrecise lives in kernelbase.dll, not kernel32.dll, and its unit is 1/64 of 100 ns. timerlag does not hardcode that: it resolves the divisor empirically by finding the one that reproduces the older API's value, and reports null if none does.
  • timeGetDevCaps reports milliseconds while every NT timer API reports 100 ns units.
  • PROCESS_POWER_THROTTLING_EXECUTION_SPEED (0x1, EcoQoS / "Efficiency mode" in Task Manager) and PROCESS_POWER_THROTTLING_IGNORE_TIMER_RESOLUTION (0x4) are different features. Reading one as the other produces a tool that opts out of the wrong thing and still prints a number.

How the classifier works, and when it refuses to answer

Each state produces a ladder of four rungs — Sleep(1), Sleep(3), Sleep(5), Sleep(10) — timed with QueryPerformanceCounter. Two competing explanations are fitted to it:

  tracking:  delivered = requested + c     (one fixed overhead)
  flat:      delivered = k                 (one fixed tick, the ask ignored)

The winner is whichever leaves the smaller RMS residual, but a winner is only accepted if it also fits well in absolute terms (≤ 1.5 ms) and the least-squares slope agrees. When a ladder is taken while the machine's global resolution is flipping between fine and coarse, neither model fits — and the honest answer is Ambiguous, which is a first-class outcome here, not a failure. When a ladder is decidable the separation is not marginal: in the run above the losing model's residual is 20x the winner's at worst and 56x at best, which is why the classification survives a noisy machine.

The slope is least squares over every rung, not (max-min)/(max-min) over the endpoints. A real captured ladder read 5.9 / 15.6 / 15.9 / 15.8 ms — obviously flat with one odd rung — and the endpoint-span form scored it 1.115, i.e. "tracks perfectly". That bug is why the least-squares form is there.

A wait that returns before its own deadline is physically impossible, so any ladder containing one is rejected rather than reported. Across 320 real samples on the development machine it never fired once, and the worst slack was +0.02 ms — which is the point: a check that fires on healthy hardware is a broken check, and one that cannot fire proves nothing, so realcheck.ps1 re-runs it against the same samples scaled down by half and requires every rung to be rejected.

PowerShell's Start-Sleep does not honour that floor, and that is worth knowing on its own. Measured over 1,000 waits of 10 ms on this machine:

Primitive Returned early Shortest
Start-Sleep -Milliseconds 10 4 of 1,000 8.8835 ms
[Threading.Thread]::Sleep(10) 0 of 1,000 10.6163 ms
Win32 Sleep(10) (what the tool measures) 0 of 1,000 10.2528 ms

The invariant belongs to Win32 Sleep, so a test that reaches for the convenient cmdlet is measuring a different primitive and will fail for a reason that says nothing about the code under test. realcheck.ps1 uses Thread.Sleep for exactly this reason, and says so at the call site.


Read-only, and proved two different ways

timerlag writes no files, touches no registry key, and starts no service. It needs no undo, and it has no -Fix mode.

Structurally, selftest.ps1 parses the source with the PowerShell tokenizer, blanks out every comment, and then greps the remainder for every mutating call — Set-ItemProperty, New-Item, Remove-Item, Set-Content, SetEnvironmentVariable, Stop-Process and friends. The test carries its own control: a known phrase that exists only inside a comment must be gone after blanking, and the file length must be unchanged, or the blanking silently did nothing and the grep proved nothing.

Behaviourally, realcheck.ps1 counts and sizes every file in the tool's own folder before and after a full run, and — the claim that actually matters — verifies that after releasing everything, the process measures the same ladder shape it started with. That is a direct measurement of the tool's own delivered granularity.

That release proof is three-valued, and it has to be. Written as after.Shape -eq before.Shape it looked right and was a cry-wolf bug: on a busy machine one of the two ladders comes back Ambiguous, the labels stop matching, and the tool reported ReleasedItsOwnTimer = falseaccusing itself of leaking a timer it had in fact released. It now reports true, false, or null for undecidable, printing Undecidable this run: and the reason. false is reserved for the one comparison that stays decisive under noise: still Tracking after the release when it was not Tracking before. That is a real leak; a label that merely stopped matching is not.

The system-wide timer resolution is deliberately not asserted unchanged. It is a gauge that every process on the machine moves, many times a second. During one verification run it went 156,250 → 10,000 all by itself. Comparing its two endpoints would make a match luck and a difference somebody else, so the tool reports it as an observation and rests the claim on the ladder.

The one state change it does make — timeBeginPeriod(1) and the throttling opt-out — is scoped to its own process and released in a finally block.


Exit codes

The exit code says whether the tool worked, not what it found.

Code Meaning
0 The tool ran. This includes finding that your timer request was ignored
1 The tool could not produce a result: no such file, malformed JSON, the native layer would not build, or a measurement that was physically impossible

RequestIgnored is a finding. It is raised as a warning on stderr, is included in the Warnings array of -Json, and exits 0.

Warnings and errors always go to stderr, in every output mode, so -Json output on stdout is always parseable — including when the tool fails. This was a real bug: findings used to be raised inside the renderer, which -Json never calls, so the JSON's Warnings array was always empty and an impossible measurement exited 0 in JSON mode while exiting 1 in human mode. An exit code that depends on the output format is not a contract.

Every flag means the same thing on every input source. -FromJson and -Json together used to be a silent no-op: the replay branch left the quiet flag on, the renderer routed every line through the quiet-aware writer, and the process printed nothing and exited 0. Running timerlag.ps1 -FromJson in.json -Json > out.json produced an empty file and a success code. Neither flag fails on its own and the human replay renders a full report either way, so the only thing that catches it is replaying a replay — which is now a shipped assertion, together with a check that the second replay is character-identical to the first.


Testing

Three suites, all shipped, all runnable by you.

powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\selftest.ps1    # 283 assertions
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\realcheck.ps1   # ~152 assertions, 36 mutations
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\mutate.ps1      # 39 mutants
Suite Result What it proves
selftest.ps1 283 passed, 0 failed Every computed number matches ground truth this suite planted. Hermetic, no hardware
realcheck.ps1 151-152 passed, 0 failed, 36 of 36 mutations killed The tool agrees with Windows on real data, and the comparison is capable of disagreeing
mutate.ps1 39 of 39 killed, 2 proven equivalent, 1 invalid control detected selftest.ps1 itself is capable of failing

realcheck.ps1 measures a live machine, so its exact assertion count moves by one or two depending on what your machine was doing. That is deliberate — see When the machine will not cooperate below.

Every cross-check has a negative control. A comparison that tolerates anything proves nothing, so after each real comparison passes, the same check is replayed against deliberately corrupted values — an inverted boolean, a field read from the wrong offset, two fields swapped — and every one must be rejected.

Ground truth is planted inside real data. realcheck.ps1 appends synthetic records with known values to genuine Get-Process output and genuine native parser output, then requires the tool to find exactly those values while surrounded by hundreds of real, irrelevant ones — and requires none of the real ones to leak into the result.

Independent reference implementations, written with a different technique. The tool parses its native layer's output with IndexOf/Substring; realcheck.ps1 re-parses the same bytes with regular expressions and compares field for field. The process scan is cross-checked against Get-Process. The timing is cross-checked three ways — QueryPerformanceCounter, Stopwatch and DateTime.UtcNowon the same iteration, because comparing the medians of three nested brackets measures three different intervals and reports scheduler noise as disagreement.

The headline feature is proved by generating the condition it detects, not by trusting an API that returned a number without erroring:

  coarse before : slope 0.244  -> Flat
  fine timer    : slope 0.957  -> Tracking
  coarse after  : slope 0.010  -> Flat

When the machine will not cooperate

A real machine will not always be in the state a check needs, and load varies between runs. Every one of these was written to stay honest about that rather than to go green.

A coarse baseline may not exist. If another application is already holding the global timer resolution at 1 ms, every process gets fine waits without asking, and there is nothing coarse to improve on — the tool's legitimate AlreadyFine case. The suite retries for a coarse window, always asserts the two claims that hold either way (the opt-out state tracks; releasing returns the process to its starting shape), and asserts the full directional proof only when the precondition was actually met. When it was not, it says so and asserts the non-free-pass alternative instead: that both states agreed and the tool invented no difference.

No window may be comparable. The two-gate check can legitimately be shut for an entire run while the global resolution is being flipped by something else. That is the tool being right, but it would leave "no mismatches" vacuously true. So the claim that the gate can open is proved by constructionGet-Comparability is driven across its whole truth table, machine independently — and the real windows are an observation. When none of them opens, the suite requires every shut window to name a side that actually moved, and requires each gate to have been seen open at least once, so a gate that was hardwired shut still fails.

That second one was found by running the suite three times in a row rather than once. The first two runs saw seven or eight comparable windows out of eight; the third saw zero.

An Ambiguous ladder is the classifier working, not the suite failing. On a loaded machine — one verification run took 58 s where the calm baseline takes 13 s — the shape classifier correctly refuses to label ladders it cannot separate, and three separate assertions failed purely because they read that refusal as a mismatch. The rule the suite now follows: when both sides are decidable, assert the strong claim; when either is undecidable, record why and assert a weaker but still decidable alternative — never a free pass. That is how the release check became three-valued, and how the mode-2 check falls back from a shape label to a raw slope threshold.

Every one of the three ship-gate rounds this tool went through caught a different real defect, and each one was invisible to a single run, because what changed between them was machine load and nothing else.

On equivalent mutants

Two mutations survive on purpose, and are excluded from the score with proofs rather than counted as passes:

  • Both percentile clamps, removed together. Get-Percentile returns early for Q ≤ 0 and Q ≥ 1, so for everything in between ceil(q*n)-1 is already inside [0, n-1]. Verified across n = 1..400 and 9 quantiles.

  • The tracking-branch slope gate. This one is exact algebra. Writing dx = x - x̄ and dy = y - ȳ:

    flatSS  = Σ dy²                = Syy
    trackSS = Σ (dy - dx)²         = Syy - 2·Sxy + Sxx
    
    trackSS ≤ flatSS   ⟺   Sxx ≤ 2·Sxy   ⟺   Sxy/Sxx ≥ 0.5   ⟺   slope ≥ 0.5
    

    TrackingSlopeMin is exactly 0.50, so reaching the tracking branch already implies the gate passes — it can never fire. The flat gate is not symmetric: flat winning only implies slope < 0.5, while FlatSlopeMax is 0.25, so it genuinely rejects the 0.25–0.5 band. mutate.ps1 kills that one, and prints the count of random ladders that fall in exactly that band as a control.

Both gates stay in the source. They state the rule the branch relies on.

On dead anchors

A mutation whose anchor text does not exist in the source silently tests nothing and would be reported as a kill. mutate.ps1 validates that every anchor appears exactly once, reports anything else as an invalid control counted separately from survivors — and carries one deliberately broken anchor so you can see the detector fire.


Requirements

  • Windows 10 or 11
  • Windows PowerShell 5.1 (in-box). No PowerShell 7 syntax is used anywhere
  • No admin rights, no installs, no downloads, no internet access at runtime

The native layer is embedded C# compiled by the in-box .NET compiler via Add-Type. There is nothing to install.


See also

Same idea, different number Windows will not tell you straight:

  • truehz — your display's exact refresh rate. Windows rounds 59.994970 Hz to 59 or 60, three built-in APIs give three different wrong answers, and the exact rational was sitting in QueryDisplayConfig all along
  • cpuclock — the clock your cores actually deliver. MaxClockSpeed is the nominal clock, not the maximum, and CurrentClockSpeed is frozen
  • framecheck — why a recording dropped or duplicated frames
  • obs-4k60-recorder — drive OBS to record at your monitor's native resolution and 60fps

License

MIT — see LICENSE.

About

You asked Windows to sleep for 1 millisecond. It slept for 15.6. Windows 11 silently ignores timeBeginPeriod for throttled processes and returns success anyway, and NtQueryTimerResolution reports a global number your process never receives. timerlag measures the timer resolution your process actually gets. Zero dependencies, read-only.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages