Repository navigation
Conversation
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
force-pushed
the
feature/perf_locks
branch
from
October 16, 2026 14:34
5d6768e to
75ffc4b
Compare
miogds
force-pushed
the
feature/perf_batch1
branch
from
October 16, 2026 14:34
da8ed5a to
ef201da
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #54 (
feature/perf_batch1). Fork only, like the rest of the performance work.What
Utils/EngineLock.swift):EngineLock,EngineProtected<State>andEngineRecursiveLock, each created with a name. The primitive is chosen in one place.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.EngineStatsSnapshot.locks, the recorder summary and aLocks:line in the verbose output, so PerfBench reports locks like any other metric (run_bench.sh macos --lock-diagnostics).Globals.core,Globals.runtime,ECS.componentIds,Scene.channelRenderMode(98 % of the frame's lock calls,docs/proposals/ActorsAndLocks.mdsection 6).Numbers (Mac16,5, release)
Uncontended lock and unlock:
os_unfair_lockEngineLockEngineProtectedEngineRecursiveLockNSLockNSRecursiveLockDiagnostics build,
renderer.drawat 1,000 cubes: 125,663 lock calls per frame, none contended.Globals.coreECS.componentIdsScene.channelRenderModeGlobals.runtimeFrame time, normal build, before and after interleaved on an idle machine:
A small gain, as the research predicted for a better primitive. The large one is removing the calls, which is the next step.
Verified
ExternalRenderExtensionPackageTests.EngineLockTests: 9 in a normal build, 14 in a diagnostics build, clean under Thread Sanitizer.Sessions and lock scenes (added 2026-10-03)
locks-api,locks-loading,locks-threads.run_bench.sh --session quick | locks | full.quickis the gate for every change, about three minutes.locksjudges lock calls and lock wait time per frame against its own baseline.Globals.coreis contended about 2,600 times per frame and the frame waits 0.9 ms for it.primitives-1klocks-apilocks-loadinglocks-threadsFirst
quicksession of this branch against the 120 Hz baseline: no regressions,primitives-1kencode 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.runtimeas a plain lock, then the scene borrow. The remainingNSLockdeclarations move to the engine types in slices.