Put a held present out under a lock of its own that submitting threads only try, so that it goes out once, whole and before the next present.

With the replay on the submitting thread, a present that waits for event-ordered work (vk_backend.c PH) goes out after
the submission that replays the last of that work, at the next present, or after PH_MAX submissions. The game submits
from several threads while one of them presents, so that submission may be another thread's. PH had no lock: two
submitting threads could both pass ph_flush's check of PH.on and put one frame out twice; a thread reading PH's fields
while the presenting thread armed the next frame could put out a job mixed from two frames; and a held frame put out on
a submitting thread could reach the present thread after the presenting thread's next frame, a picture back in time.

runtime/vk_present_held.h now holds PH's job, its arming, putting out and counts behind PH.lock. Submitting threads
only try the lock (ph_flush_try) and go on when another thread holds it: that thread is putting a present out or arming
one, and the next submission, the next present or PH_MAX checks again, the bound there was before. The presenting
thread takes it for every synchronous present while held presents are on (ph_flush_next, ph_present), so the
synchronous present_image calls run one at a time and in the game's order. That is the one new wait, and only the
presenting thread makes it: only while another thread puts a held present out, for at most that present_image. It is
counted (lock_waits, lock_wait_ns, lock_wait_max) and timed as PERF_DRAIN. No path holds the lock across held_resume,
which is a replay. Neither 0.10.0 fresh log has a held present at all ("went out" and "presents that waited" lines),
so the wait is not expected in normal play. present_held = 0 turns held presents off, and with them every use of the
lock.

Not reproduced before the change. Checks that passed: the new tests/present_held_check (one presenting and three
submitting threads over 200,000 frames: every frame out once, in order and never two at once; the unlocked 0.10.1
shape through the same harness gave about 205,000 presents for 200,000 frames, 6,000 to 8,000 out of order or twice and
8,000 to 12,000 at the same time; step-by-step cases; a submitting thread gives up on a held lock at once and the
presenting thread's wait is counted), pass_cc_check on every runtime file, no new -Wall -Wextra warnings in
vk_backend.c.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
chrg6
2026-11-13 19:02:54 -05:00
parent 8b5ddd0865
commit a789bd8fb0
5 changed files with 439 additions and 36 deletions

View File

@@ -767,6 +767,13 @@ A change to the copy of each event-ordered queue's first held wait (`runtime/vk_
`vk_exec_held_ready`) passes `python tests\evo_peek_check.py`, which builds the header alone: one writer republishes
while three readers copy, no copy mixes two publishes, and no reader waits for the writer. No GPU, no game files.
A change to the present that waits for held work (`runtime/vk_present_held.h`, `runtime/vk_backend.c` `submit_sync`,
`vk_be_present`, `ph_out`) passes `python tests\present_held_check.py`, which builds the header with the platform layer:
one presenting and three submitting threads over 200,000 frames, every frame out once, in order and never two at once,
each reason a held present goes out seen; step by step what keeps and what puts out a held present; and with the lock
held elsewhere a submitting thread gives up at once while the presenting thread waits and counts its wait. No GPU, no
game files.
A change to how `runtime/memory.c` `mem_copy_readable` reads the lazy heap (`NX2NAVI_HEAP_LAZY=1`) passes
`python tests\heap_lazy_copy_check.py`, which builds `memory.c` with the Windows platform layer and its fault handler:
every copy returns, a heap page nothing touched yet reads zero and stays reserved, and pages past the heap's end are