Skip to content

Frame Pacing

M T edited this page Oct 4, 2026 · 1 revision

Frame Pacing

Frame pacing is an optional, experimental, off-by-default controller. It holds each finished panorama frame briefly before committing it to the GPU, so that new world pictures reach the compositor on an even cadence rather than whenever the engine happens to finish. It works on a nominal 90 Hz display grid. Each cadence ("rung") is a whole number of display periods: 2..6, that is 45, 30, 22.5, 18 or 15 fps. Recent busy times choose a sustainable rung with hysteresis, and overload runs free.

The pacer never changes the contents of a bearing, guest game time, or Halo's own frame limiter (frame_pacer.h:3-19). It does feed its cadence back to the panorama bearing budget, so the time a slower cadence leaves over is spent redrawing bearings rather than waiting.

The pacer sits at exactly one point: the zero-copy publication in metalwin_present_gpu, between reading the finished frame's metadata and mr_commit_async (EngineVisionRuntime.m:449-457). Frames on the CPU byte-copy path, dropped frames and incomplete frames are never paced.

Source files

File Role
native/EngineHost/frame_pacer.h Platform-independent controller (header-only): window, cost model, rung choice, slot computation, budget target; report and display structs.
native/EngineHost/frame_pacing_hooks.inc Host glue: clock, guest-derived limits, blocking wait, idle accounting, budget retargeting, locked report snapshot. Included into overrides.c after panorama_hooks.inc.
native/EngineHost/halo_settings.c HALO_FRAME_PACING seeding; live frame_pacing setting.
native/EngineHost/shims_kernel32.c host_frame_idle_wait: counts the pacer's wait as presenting-thread idle.
native/EngineVision/Sources/EngineVisionRuntime.m Display observations for the pacer (compositor latch times, publish lag), and the paced commit.
native/EngineVision/Sources/EngineWorldCadence.swift Presenter-side measurement of how evenly distinct world epochs were submitted.
native/EngineVision/Sources/EngineDiagnostics.swift framePacing diagnostics block.
native/EngineHost/tests/test_frame_pacer.c, frame_pacer_traces.inc, test_frame_pacing_host.c Controller arithmetic, trace-derived simulation, host-glue test.

Why it is opt-in

halo_settings.c says "Aggregate headset traces cannot establish a clear per-frame win. Keep experimental pacing opt-in, including after malformed input" (halo_settings.c:77-81). The test's header makes the same point: the trace simulation is "a regression test, not headset evidence and not sufficient to enable pacing by default".

The settings panel exposes it as "Even frame cadence". Its note warns that it "can lower the frame rate slightly", and that while pacing, "the pace rather than Keep at least sets how much of the sphere behind and beside you is redrawn" (EngineSettingsView.swift:94-97, EngineSettingsView.swift:154-158).

Per-frame flow

sequenceDiagram
    participant E as Engine thread (metalwin_present_gpu)
    participant H as host_frame_pacer_present
    participant C as Controller (frame_pacer.h)
    participant BU as Bearing budget
    participant M as Metal / compositor
    E->>E: host_panorama_frame(), incomplete? release slot, return (no pacing)
    E->>E: display_for_pacer(): latest latch, median interval, median publish lag
    E->>H: host_frame_pacer_present(display)
    H->>H: scene epoch changed? reset controller
    H->>H: mode = timedemo ? OFF : live setting
    H->>H: work = (now - last release) - idle since last release
    alt mode OFF
        H->>BU: if it was pacing: retarget to 1/target_fps
    else EVEN
        H->>C: halo_frame_pacer_slot(now, work, floor_work, ticks, latch, lead, min/max period)
        C-->>H: slot time, shortened flag
        H->>BU: if shortened: retarget to the new budget target
        H->>H: mach_wait_until(min(slot, now+100 ms)), host_frame_idle_wait(start,end)
    end
    H->>H: release = now, publish report snapshot (mutex)
    H-->>E: release_ns
    E->>M: mr_commit_async(gpu_published)
    M-->>E: completion: publish lag sample = completion - release
    M-->>E: compositor lease attempt: latch sample
Loading

Inputs

Display observations (Objective-C bridge)

HaloFramePacerDisplay (frame_pacer.h:31-36) is filled by display_for_pacer() (EngineVisionRuntime.m:91-115):

Field Source
latch_ns The last compositor lease attempt. display_latch_note() runs inside enginevision_panorama_gpu_latest under the runtime lock, "at the instant the compositor can select its picture". It is recorded even when a menu prevents a world lease.
period_ns Median of the last 8 non-zero latch intervals under 100 ms (halo_frame_pacer_median ignores zeros).
lag_ns Median of the last 16 publish-lag samples: GPU completion time minus the pacer's release time. Only accepted publications contribute; failed or superseded ones do not.

These are "app submission/publication observations, not display scanout timestamps". They are written by the compositor and Metal completion threads through atomic slots and may be one update old.

Host-side derivation

In host_frame_pacer_present (frame_pacing_hooks.inc:69-150):

Value Computation
work Time since the previous release minus host_yield_spin_ns accumulated since then. This is the engine's busy time between publications, excluding the guest limiter's spin/sleep and the pacer's own previous wait.
floor_work halo_panorama_budget_floor_busy(budget, work): the same frame at the lightest tier's floor (1.625 passes per frame).
ticks u16 at [0x006F1D6C] + 0x10, the tick count 00470BF0 left in the time globals. -1 if the globals pointer is null.
display_period Always the nominal 1/90. The observed period is only reported. "A median observed interval of 22 ms can represent missed compositor callbacks, so it must not halve all pacing rungs."
lag Observed median if non-zero and under 100 ms, else 4 ms. Smoothed with an EMA (0.1).
lead lag + 0.5 * (1/90): aim for GPU completion half a display period before the compositor samples.
locked A latch exists, is not in the future, and is under 250 ms old.
min_period 1/30 when Halo's framerate_throttle byte 0x006894BA is set or the cinematic flag ([0x006F187C] + 9) is set; otherwise 1/45. "Keep at least" is the budget's goal, "not a speed cap. A goal of 27 Hz must not forbid the 30 Hz cadence" (frame_pacing_hooks.inc:18-27).
max_period 1/15, Halo's maximum step. Frames slower than this slow the game, since those steps are clamped.
mode HALO_PACER_OFF while timedemo is active (0x007196D8 non-zero): "Leave benchmark timing alone even when the user has enabled pacing". Otherwise the live setting.

The controller

State

HaloFramePacer (frame_pacer.h:91-111) holds:

  • rung and the last slot;
  • a 48-frame ring of (work, floor_work, ticks);
  • tick_cost;
  • work_ema and floor_ema (for the report);
  • late_ema;
  • the want rung and how long it has been wanted;
  • time on the current rung;
  • cumulative counters.

halo_frame_pacer_reset forgets timing but keeps the counters. It is used for a load, a menu, or pacing switched off.

Constants

Constant Value Meaning
HALO_PACER_RUNGS 7 Report slots: 0 = free-running, 2..6 = allowed rungs (1 is never used).
HALO_PACER_WINDOW 48 Frames a rung is judged by ("two to three seconds of play").
HALO_PACER_ALLOWANCE 0.15 Maximum modelled period overhead versus running free (about 13% fps loss). "A selection estimate, not a bound on scheduler/GPU stalls."
HALO_PACER_SLOW_SHARE 0.1 Maximum share of frames that would exceed Halo's 1/15 s step.
HALO_PACER_ENGAGE_SECONDS 0.5 How long a rung must be wanted before pacing engages.
HALO_PACER_LEAVE_SECONDS 0.75 How long before leaving a rung that costs too much.
HALO_PACER_SMOOTHER_SECONDS 2.0 How long before moving to a slower, smoother rung.
HALO_PACER_MARGIN 0.0005 s How early a frame must reach its slot to make it.
guard 0.0002 s Wake-up allowance in slot computation.

Allowed rungs

halo_frame_pacer_allowed(n) requires 2 <= n < 7, n * D >= 0.99 * min_period and n * D <= max_period. With Halo's limiter or a cinematic active, rung 2 (45 Hz) is excluded.

Cost model: halo_frame_pacer_cost

For a candidate rung n with period P = n * D (frame_pacer.h:156-195):

  1. Tick counts. A rung determines how many 30 Hz ticks each frame runs: ticks = P * 30. 22.5 fps alternates 1, 1, 2. Each windowed frame is evaluated at the low tick count with weight 1 - frac and at the high count with weight frac.
  2. Adjusting each frame. Each frame's floor work is moved to that tick count at the measured tick_cost per tick: w = floor + (target_ticks - observed_ticks) * cost + MARGIN. When tick_cost is unknown it is taken as 15% of the mean floor work, and frames with unknown ticks are not adjusted.
  3. Paced time. max(n, ceil(w / D)) * D. A frame that overruns takes as many display periods as it needs.
  4. Free time. max(w, min_period). Running free is never faster than Halo's cap.
  5. Outputs.
    • loss = paced_total / free_total - 1;
    • slow: the share of frames with w > max_period;
    • late: the share with w > P.

tick_cost is estimated in halo_frame_pacer_note from the two most common tick counts in the window (each needing at least 6 samples), as the difference of their mean floor work divided by the tick difference. It is clamped at 0 and smoothed with weight 0.1. "A rung that runs one count only (30 or 15 fps) keeps the last estimate."

Choosing the rung: halo_frame_pacer_choose

The search goes from rung 6 down to 2 and returns the first allowed rung with loss <= 0.15 and slow <= 0.1, or 0 (free-running) if none qualifies (frame_pacer.h:197-208). This picks the slowest cadence whose throughput cost is within the allowance. That is the smoothest affordable one, since every picture is held equally long.

The test fixture gives concrete results (constant work, Halo cap on, phase locked):

Steady work Chosen rung Cadence
20 ms 3 30 fps
39 ms 4 22.5 fps
49 ms 5 18 fps
60 ms 6 15 fps
75 ms 0 free (over 1/15 s)

Hysteresis: halo_frame_pacer_slot

Source: frame_pacer.h:247-333.

stateDiagram-v2
    [*] --> Free
    Free --> Paced: a rung wanted for 0.5 s
    Paced --> Paced: slower rung wanted 2 s, on rung 2 s, its loss at most 0.12
    Paced --> Paced: current loss above 0.20 or slow share above 0.2, other rung wanted 0.75 s
    Paced --> Paced: current rung no longer allowed, switch at once
    Paced --> Free: free running wanted, same leave rules
    Paced --> Free: reset on gap over 1 s, work of 0.25 s or more, invalid work, or mode OFF
    Free --> Free: no measured work yet
Loading
  1. Reset. A gap over 1 s since the last call, work >= 0.25 s, negative or non-finite work, or mode == OFF resets the controller. If a rung was active, rung_changes++ and shortened is set. "A load, severe hitch, disabled mode or invalid sample must not keep pacing from an old low-cost window."

  2. Measure. Frames with 0 < work < 0.25 enter the window and EMAs.

  3. Free. With no measured work, or mode OFF, the frame is released immediately (rung_frames[0]++).

  4. Choose. Compute want. Track how long it has been wanted.

    • Engage from free after 0.5 s.
    • Switch at once if the current rung became disallowed.
    • Leave quickly (after 0.75 s) if the current rung's loss exceeds 0.20 or its slow share exceeds 0.2.
    • Go smoother only after 2 s of both wanting and being on the current rung, and only if the new rung's loss is at most 0.12.

    "The margins either side keep a scene on the edge where it is."

  5. Shortened. Set when the new rung is faster (smaller n) or free. The host then retargets the budget immediately.

  6. Slot.

    • target = previous slot + n * D.
    • When phase-locked, phase = latch - lead and the target is snapped to the nearest grid point phase + k * D.
    • If target < now + guard, the frame is late (if a previous slot existed). It goes at the next grid point, phase + ceil((now + guard - phase) / D) * D, or immediately when not locked. "Missed deadlines slip a display period rather than an entire engine frame." The cadence continues from there.
    • If the target is more than P + D in the future, it is replaced by now: "a clock that moved, not a frame that is early".
    • late_ema is updated with weight 0.05.

Budget coupling

halo_frame_pacer_budget_target(p, D, fallback) (frame_pacer.h:210-239) returns the busy-time ceiling the bearing budget should keep:

  • Not pacing: the fallback, 1 / panorama_target_fps.
  • Pacing: P - 2 * MARGIN - excess, where excess is the 88th percentile of windowed work minus its mean (computed once at least 8 samples exist). This accounts for "the tick that every third frame runs at 22.5 fps, the extra pass of the heavy tiers' alternate frames".
  • If late_ema > 0.1, it subtracts a further 0.5 * P * (late_ema - 0.1).
  • The result is clamped to [0.6 P, 0.9 P].

host_frame_pacer_budget_target(fallback) returns the fallback when the pacer has never run, the live setting is off, or the budget's scene epoch has moved on since the pacer last saw it (frame_pacing_hooks.inc:152-156).

The budget calls it twice per panorama frame: for observe_timed and for motion_fill. In return, the pacer chooses its rung from the budget's floor_busy estimate. That way a rung is not abandoned only because the budget filled spare time with extra bearings, and the budget is retargeted to a heavier tier the moment the rung gets faster (halo_panorama_budget_retarget). The test budget_checks shows that the same 30 ms busy time earns more extra bearings under a paced 22.5 fps target than under the plain 1/30 target.

Host glue details

  • Scene entry. When panorama_budget.scene_epoch differs from the pacer's copy, the pacer resets (counting a rung change if one was active), and clears its release time and idle baseline. This happens at the front end closing or a >1 s load gap. Old menu/load timings "must not hold this publication to an old slot" (frame_pacing_hooks.inc:72-81).
  • Turning off. If the setting goes off while pacing, the controller resets and the budget is retargeted to 1/target_fps. Counters continue (free_frames, rung_frames[0]).
  • Waiting. The deadline is clamped to 100 ms ahead and converted to mach_absolute_time units with 128-bit arithmetic. mach_wait_until is retried up to 4 times, re-checking the clock between attempts. "There is no spin" (frame_pacing_hooks.inc:29-40).
  • Idle accounting. Only the blocking interval (measured after the controller's own work) is passed to host_frame_idle_wait(start, end). On the presenting thread this adds it to host_yield_spin_ns and host_present_sleep_ns, and clears the Sleep(0) timestamp so the next yield does not time the same gap again. With pacing off nothing calls it, and Sleep/SleepEx accounting is unchanged (shims_kernel32.c:269-284).
  • Logging. Rung changes are logged as [pacer] rung A -> B (fps) work= floor= period= lead= locked=, up to 64 times.
  • Threading. All controller state belongs to the engine thread. The report is copied into frame_pacer_snapshot under frame_pacer_report_lock. host_frame_pacer_report copies it out under the same mutex and overwrites mode with the live setting, so "a live toggle is visible before the next engine frame publishes". The bridge holds no lock while the pacer waits; PanoramaPublicationValidation.m asserts that runtime_lock is free during the call.
  • No guest writes. test_frame_pacing_host.c compares all 8 MiB of guest memory before and after settling and requires them to be identical.

Telemetry

HaloFramePacerReport

Reported in diagnostics as framePacing (EngineDiagnostics.swift:111-130).

Field Diagnostics key Meaning
mode mode 0 off, 1 even (live setting).
rung rung, cadenceHz Display frames per engine frame; 0 free. cadenceHz = 1000 / (rung * display_period_ms).
latch_locked latchLocked Pacing and phase-locked to recent compositor latches.
display_period_ms displayPeriodMilliseconds Nominal 11.111 ms.
observed_display_period_ms observedDisplayPeriodMilliseconds Median latch interval (informational).
target_period_ms targetPeriodMilliseconds rung * display_period.
lead_ms, publish_lag_ms leadMilliseconds, publishLagMilliseconds Phase lead and smoothed commit-to-completion lag.
work_ms, floor_work_ms workMilliseconds, floorWorkMilliseconds Busy-time EMAs.
budget_target_ms budgetTargetMilliseconds Ceiling handed to the bearing budget.
late_share lateShare EMA of missed slots.
frames, paced_frames, late_frames, free_frames, rung_changes, rung_frames[7] same / framesByRung Cumulative counts. frames = paced + free = sum(rung_frames).
wait_seconds waitSeconds Actual measured blocking time.
requested_wait_seconds requestedWaitSeconds Sum of slot - now the controller asked for.

World submission cadence

EngineWorldCadence (EngineWorldCadence.swift) is the presenter-side check on evenness. The diagnostics comment says "Judge evenness against worldSubmissionCadence".

For every compositor submission, the presenter records (scene_epoch, source_epoch, uptime). A submission is eligible only when tracking is present and the mode is panorama or retainedPanorama (EngineImmersive.swift:139-146). New epochs within one scene produce an interval sample (ring of 4096) and the number of submissions that showed the previous epoch. A scene change or ineligible submission restarts tracking.

Statistics keys:

  • uniqueSubmittedWorldFrames, repeatedSubmittedWorldFrames, intervalSamples;
  • uniqueIntervalP50/P95/P99Milliseconds;
  • uniqueIntervalModal90HzPeriods (mode of round(ms * 0.09)) and its share;
  • uniqueIntervalModalSubmissionFrames and its share;
  • uniqueIntervalStdDevMilliseconds, stallsOver100ms, maximumRetainedMilliseconds.

These measure "submission, not proof that the display scanned out a particular frame".

Pass-time telemetry

The per-pass timer in the panorama hook feeds host_pass_profile_add, which is always enabled (d3d9_render.inc:406-412). It accumulates two counters:

  • an interval counter, reset by the HALO_DRAW_PROFILE log every 120 frames;
  • a lifetime total, host_pass_profile_total_ns().

engine_thread_sample() reads the lifetime total at every Present for the core-telemetry frame split (EngineVisionRuntime.m:67-88). The split separates time inside bearing passes from time outside them. See Diagnostics and Telemetry.

Environment variables

Variable Default Effect Read at
HALO_FRAME_PACING off 1, even or on enables. Anything else (0, off, 2, empty, malformed) is off. Seeds the live "Even frame cadence" toggle. halo_settings.c:79-81
HALO_PANORAMA_TARGET_FPS 30 (10..60) The budget's fallback target when not pacing. Does not cap the pacer's cadence. halo_settings.c:63
HALO_PANORAMA_GPU on (visionOS) / off (Mac) Pacing runs only on the zero-copy publication path. EngineVisionRuntime.m:704-710
HALO_CORE_TELEMETRY on 0 disables the frame-split sampling at each Present (pass-time totals still accumulate). EngineVisionRuntime.m:657-659
HALO_DRAW_PROFILE unset Any value: per-draw timers and the 120-frame [draw-profile] log, which resets the interval pass counter. d3d9_render.inc:413-414

Traces and tests

frame_pacer_traces.inc

frame_pacer_traces.inc holds two anonymised headset traces of aggregate observations in active panorama play. They are not per-frame timings. Each row is {end_s, seconds, frames, busy_ms (EMA snapshot), ppf, pass_ms, segment}. A new segment starts after any excluded interval (menu, transition, no frames, invalid counters, or an interval outside (0, 2] s).

Array Source summary in file
build75_b30 (trace A) 248 source records; 214 retained intervals in 4 segments; 6739 frames / 238.6 s. SHA-256 of the source given.
build74_b30 (trace B) 223 source records; 205 retained intervals in 1 segment; 3066 frames / 229.4 s.

test_frame_pacer.c

test_frame_pacer.c has three parts.

controller_checks covers:

  • the work-to-rung table above, with exact spacing and phase alignment;
  • a single late frame slips by at most one display period and the cadence resumes;
  • relative cadence without phase;
  • rung 2 is chosen when uncapped;
  • a load gap, OFF and a 300 ms hitch release immediately and clear the window;
  • a slow scene returns to 30 Hz after sustained headroom;
  • at most 4 rung changes over 1800 frames of borderline load;
  • the median ignores zeros.

budget_checks covers the budget-target bounds, floor_busy, and that paced targets earn more extras.

simulation_checks replays both traces through a synthetic per-frame reconstruction:

  • The native limiter is modelled on simulation samples.
  • Log-normal jitter with sigma 0, 0.1 or 0.2, over 3 seeds.
  • GPU latency is 4 ms +/- 20%.
  • Each run is compared against itself with pacing off.

Assertions per model:

  • the same frame count;
  • paced fps and displayed fps at least 90% of free;
  • for trace A, adjacent hold changes at most 75% of free;
  • for trace B, fps at least 97% of free;
  • game speed within 0.03;
  • hold SD at most 110% + 0.5 ms;
  • at most 12 rung changes per minute.

The file states plainly that these are synthetic reconstructions, not headset evidence.

test_frame_pacing_host.c

test_frame_pacing_host.c builds the real frame_pacing_hooks.inc with fake clocks and waits, plus the real halo_settings.c and budget. It checks:

  • HALO_FRAME_PACING parsing in fresh child processes;
  • off by default, with no waits;
  • the nominal 90 Hz grid even when callbacks arrive every 22.2 ms;
  • the guest throttle and cinematic caps;
  • 20, 27 and 30 Hz "Keep at least" goals do not forbid 30 Hz;
  • timedemo bypass;
  • scene-entry reset without a wait;
  • the live toggle;
  • stale and future latches unlock;
  • only the actual blocking interval is charged as idle (7 ms limiter idle, 2 ms controller cost, 1 ms oversleep injected);
  • interrupted waits retry with one accounting interval;
  • guest memory unchanged;
  • concurrent report snapshots are coherent.

Other tests

Test What it asserts
PanoramaPublicationValidation.m The bridge calls the pacer with the latest latch and without holding runtime_lock, then commits with the paced release time. Incomplete frames neither pace nor commit. Publish-lag samples come only from accepted completions. Latch intervals ignore repeated timestamps and gaps over 100 ms.
WorldCadenceValidation.swift Even 30 fps gives 3 periods, share 1, SD 0. Holds of 5,6,7,6 give a mode of 6 at share 0.5. Half-rate submission is distinguished from display holds. Stalls and maximum retention are counted. The ring caps at 4096.
test_pass_time_telemetry.c The interval pass counter resets with the draw-profile log, but the lifetime total survives (19 ms), so the frame split's outside-pass time (31 ms) is not inflated. Mac only.

All of these except the Swift/ObjC ones run in tools/run_source_checks.py. frame_pacing_host and pass_time_telemetry run on macOS only. The ObjC publication test and WorldCadenceValidation are also built there on macOS.

Related pages

Clone this wiki locally