Skip to content

[Feature] Engine lock types with diagnostics as a build mode, and the four busiest locks moved - #67

Draft
miogds wants to merge 4 commits into
feature/perf_batch1from
feature/perf_locks
Draft

miogds wants to merge 4 commits into
feature/perf_batch1from
feature/perf_locks

Conversation

@miogds

@miogds miogds commented Oct 3, 2026 •

Copy link
Copy Markdown
Member

Stacked on #54 (feature/perf_batch1). Fork only, like the rest of the performance work.

What

  • One set of lock types for the engine (Utils/EngineLock.swift): EngineLock, EngineProtected<State> and EngineRecursiveLock, each created with a name. The primitive is chosen in one place.
  • Diagnostics as a build mode. Compiled with ENGINE_LOCK_DIAGNOSTICS, the same types count acquisitions and re-entries, detect contention and time the wait and the hold, per named lock and optionally per source location. A normal build carries none of it.
  • Lock numbers in the engine stats: EngineStatsSnapshot.locks, the recorder summary and a Locks: line in the verbose output, so PerfBench reports locks like any other metric (run_bench.sh macos --lock-diagnostics).
  • The frame's four busiest locks moved to the new types: Globals.core, Globals.runtime, ECS.componentIds, Scene.channelRenderMode (98 % of the frame's lock calls, docs/proposals/ActorsAndLocks.md section 6).

Numbers (Mac16,5, release)

Uncontended lock and unlock:

ns
os_unfair_lock 1.73
EngineLock 1.74
EngineProtected 1.75
EngineRecursiveLock 2.91
NSLock 5.88
NSRecursiveLock 11.25

Diagnostics build, renderer.draw at 1,000 cubes: 125,663 lock calls per frame, none contended.

Lock Taken per frame Re-entries per frame
Globals.core 55,747 10,068
ECS.componentIds 48,599 0
Scene.channelRenderMode 10,398 0
Globals.runtime 851 0

Frame time, normal build, before and after interleaved on an idle machine:

Scene Before (ms) After (ms)
1,000 cubes 6.51 / 6.55 / 6.73 6.39 / 6.41 / 6.50
4,000 cubes 26.39 to 31.96, median 27.20 25.49 to 27.10, median 25.78

A small gain, as the research predicted for a better primitive. The large one is removing the calls, which is the next step.

Verified

  • Core suite: 1523 tests, one failure, the known checkout-folder-name dependency of ExternalRenderExtensionPackageTests.
  • Async mesh loading and loading gate render tests: 31 pass.
  • EngineLockTests: 9 in a normal build, 14 in a diagnostics build, clean under Thread Sanitizer.
  • Strict-concurrency guardrail script.
  • PerfBench: macOS build and run in diagnostics mode, visionOS simulator build.

Sessions and lock scenes (added 2026-10-03)

  • Three lock scenes on top of the 1k grid: locks-api, locks-loading, locks-threads.
  • run_bench.sh --session quick | locks | full. quick is the gate for every change, about three minutes. locks judges lock calls and lock wait time per frame against its own baseline.
  • Lock baseline for Mac16,5. With a second thread reading the scene, Globals.core is contended about 2,600 times per frame and the frame waits 0.9 ms for it.
  • The comparer now records and uses how busy the machine was. With other processes on three to six cores, frame pacing is reported as information and sub-millisecond CPU times are not judged.
Scene (diagnostics build) Lock calls per frame Wait per frame
primitives-1k 167,262 0.000 ms
locks-api 213,590 0.000 ms
locks-loading 166,988 0.008 ms
locks-threads 178,954 0.91 ms

First quick session of this branch against the 120 Hz baseline: no regressions, primitives-1k encode 5.33 ms against 6.32 ms.

Next

Component ids without a lock, a lock-free fast path for the scene-channel check, per-frame constants read once per pass, Globals.runtime as a plain lock, then the scene borrow. The remaining NSLock declarations move to the engine types in slices.

Javier Segura added 4 commits October 16, 2026 13:44
One set of lock types for the engine, so the primitive is chosen in one place
and every lock can be measured the same way (docs/proposals/ActorsAndLocks.md):

- EngineLock: non-recursive, an os_unfair_lock.
- EngineProtected<State>: a value reachable only while holding its lock.
- EngineRecursiveLock: an unfair lock with an owner word and a depth, for the
  few places where re-entry is real. The owner word is read without the lock
  through C11 atomics (new C target CEngineAtomics; Synchronization.Atomic
  needs macOS 15 and iOS 18).

In a normal build each call compiles down to the bare os_unfair_lock call.
Uncontended lock and unlock, release build, M4 Max: os_unfair_lock 1.73 ns,
EngineLock 1.74 ns, EngineProtected 1.75 ns, EngineRecursiveLock 2.91 ns,
against NSLock 5.88 ns and NSRecursiveLock 11.25 ns
(UNTOLD_LOCK_COST=1, EngineLockTests/test_cost_uncontendedLockAndUnlock).

Built with the compile-time flag ENGINE_LOCK_DIAGNOSTICS the same types count
every acquisition and re-entry, detect contention with a trylock and time the
wait and the hold, per named lock, with an optional per source location count
(UNTOLD_LOCK_SITES=1). EngineLockDiagnostics reports them; the stats monitor
puts the per-frame numbers in EngineStatsSnapshot.locks, the recorder keeps
lock calls, hold time, contention and wait per lock name in its summary, and
the verbose stats output gains a Locks line. It is a build mode rather than a
runtime switch because a frame takes hundreds of thousands of locks.

No engine lock uses the new types yet.
…types

The four locks that make up 98 % of the frame's lock calls
(docs/proposals/ActorsAndLocks.md, section 6, items 1, 2, 3 and 5) now use the
engine lock types, under these names:

- Globals.core: CoreRuntimeGlobals (scene, renderInfo, the pipelines), an
  EngineRecursiveLock in place of an NSRecursiveLock. Recursive on purpose: a
  _modify accessor holds it across the caller's mutation.
- Globals.runtime: RuntimeGlobalsStore, likewise.
- ECS.componentIds: the component type to id map, an EngineProtected in place
  of a bare OSAllocatedUnfairLock.
- Scene.channelRenderMode: SceneChannelVisibilityState, an EngineLock in place
  of an NSLock.

A diagnostics build now sees them. renderer.draw at 1,000 cubes: 125,663 lock
calls per frame (Globals.core 55,747 taken and 10,068 re-entered, component
ids 48,599, channel render mode 10,398, Globals.runtime 851), none contended
on the frame path, against 3,273 calls left on NSLock. LockCensusTests prints
this report next to its NSLock census when built with ENGINE_LOCK_DIAGNOSTICS.

Frame time in a normal release build (renderer.draw wall time in the census
harness, before and after interleaved on an idle machine):
  1,000 cubes  6.51 / 6.55 / 6.73 ms  ->  6.39 / 6.41 / 6.50 ms
  4,000 cubes  26.39 .. 31.96 ms (5 runs, median 27.20)  ->  25.49 .. 27.10 ms
               (7 runs, median 25.78)
About 2 % and 3 to 5 %: the calls are cheaper, there are as many as before.

Verified: core suite (1523 tests; the one failure is the known checkout-folder
name dependency of ExternalRenderExtensionPackageTests), the async mesh loading
and loading gate render tests (31), EngineLockTests under Thread Sanitizer, the
strict-concurrency guardrail, PerfBench macOS and visionOS simulator builds.
run_bench.sh --lock-diagnostics builds the engine with ENGINE_LOCK_DIAGNOSTICS
(into its own build folder, so switching modes does not rebuild the engine) and
each scene then reports lock calls per frame, contention and wait time; the
per-lock numbers are in the scene summaries and in every recorded frame.

Frame times of such a build are not comparable with a normal one, so the run
records render.lockDiagnostics, the comparer keys baselines on it and the
script skips the baseline comparison in this mode.

First run (Mac16,5, 60 Hz): cube 942 lock calls per frame, primitives-1k
169,612, none contended.
… the machine is busy

Lock scenes (the 1k grid plus one kind of work against shared state):
- locks-api: game code reads and moves every entity through the public API
  each frame.
- locks-loading: an asset loads, is destroyed and loads again while the frame
  renders.
- locks-threads: a second thread reads every entity's transform in a loop, as a
  render thread reads what the main thread updates on visionOS.
Scenes get a per-frame update hook, and the runner waits for a scene to become
quiescent (a load in flight, a reader thread) before destroying its entities.

Sessions in run_bench.sh:
- quick: cube, primitives-1k, animation-16, stadium, 10 s, three repeats. The
  gate for every change, about three minutes.
- locks: a build with ENGINE_LOCK_DIAGNOSTICS on primitives-1k and the three
  lock scenes, judged on lock calls and lock wait time per frame, per lock name,
  against a lock baseline of its own (perf/baselines/<model>/<platform>-lockdiag).
- full: every scene with three repeats, then the locks session.

Lock baseline for Mac16,5 (diagnostics build, two runs): primitives-1k 167,262
lock calls per frame, locks-api 213,590, locks-loading 166,988, locks-threads
178,954 with Globals.core contended about 2,600 times and 0.9 ms of waiting per
frame. Lock calls repeat to within 0.02 % between runs. Normal-build baselines
for the three lock scenes are added as well.

The gate on a Mac that is not idle. Other processes (photo and media analysis
daemons, here) used three to six cores during a quick session; in that state
light scenes skip refreshes in bursts and their sub-millisecond CPU times read
two to four times higher, in the baseline branch as well. So:
- each scene records how many cores other processes used while it was recorded
  and the fraction of frames the app was frontmost; the console line says
  MACHINE BUSY from one core up;
- when even the quietest repeat was that busy, the comparer reports the scene's
  frame pacing as information and says so at the end;
- a per-system CPU mean is judged only when the baseline's value is at least
  1 ms.

First quick session on this branch against the 120 Hz baseline: no regressions,
primitives-1k encode 5.33 ms against 6.32 ms (-15.7 %), the light scenes within
1 % of the baseline in their best run.
@miogds
miogds force-pushed the feature/perf_locks branch from 5d6768e to 75ffc4b Compare October 16, 2026 14:34
@miogds
miogds force-pushed the feature/perf_batch1 branch from da8ed5a to ef201da Compare October 16, 2026 14:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant