Skip to content

feat(app-rules): INV-4.10 Vulkan frame-submit discipline — rule + lint + scaffolder (#1917) - #1942

Merged
dfattal merged 2 commits into
mainfrom
feat/inv-vk-frame-discipline
Oct 11, 2026
Merged

dfattal merged 2 commits into
mainfrom
feat/inv-vk-frame-discipline

Conversation

@dfattal

@dfattal dfattal commented Oct 11, 2026

Copy link
Copy Markdown
Collaborator

Rule — INV-4.10 (new, in §4 Swapchain sizing & tiling)

Vulkan: keep ≥2 frames in flight, ONE vkQueueSubmit per frame per queue, never wait the frame you just submitted. Also: no vkQueueWaitIdle / vkDeviceWaitIdle on the steady-state frame path, and command buffers allocated once and reset, never alloc+free per frame.

Why §4 and not a new §12: §4 is where the doc covers how the app renders into its swapchain (INV-4.4 tiling, INV-4.7 write every pixel, INV-4.8 per-slice depth clear), and how the atlas gets submitted belongs with those. §12 (checklist) and §13 (history) are already taken and have inbound anchors, so a new section would mean renumbering both. A checklist row is added to §12.

Why

Mobile GPU governors pick the clock from GPU busy %. A submit → wait → submit loop leaves the GPU idle while the CPU waits. On Adreno 740 (msm-adreno-tz, ADPF unsupported) the GPU then sits near 220 of 680 MHz at ~10 fps (#1917; android-build-guide § Low frame rate with an idle-looking GPU). Allocating a command buffer every frame adds a zeroed allocation in the Adreno driver each frame (#1935 / #1936, the same fix already made in vk_native). On a desktop dGPU the only cost is lost CPU/GPU overlap, which is why this survives desktop testing. Evidence: displayxr-demo-modelviewer#170 went from 13 to 30 fps on the phone, and the only change was 3 frames in flight with one submit per frame.

The rule names one sanctioned pattern: dxr::VkFrameRing (displayxr-common common/vk_frame_ring.h, ≥ v2.28.0, DisplayXR/displayxr-common#74). The frame goes beginFrame(), then the draws with tileBarrier() between tiles, then endFrame() (the one submit), then xrReleaseSwapchainImage, then xrEndFrame. Exceptions: one-off uploads go through submitImmediate(), resize and teardown use waitAll(), and resources still in use go through deferDestroy().

Lint heuristic + exemption (scripts/check_displayxr_app.py, WARN, so CI is unaffected)

For each vkQueueSubmit in a .c/.cc/.cpp/.cxx/.mm source, the check finds the enclosing function by brace matching: the innermost {…} whose header looks like a function, lambda or ObjC method. Control-flow blocks and initializers are skipped, as are braces inside string literals. If no function can be found, it uses a ±60-line window instead. The function is only judged if it is on the frame path, meaning it does at least one of:

  • records a draw, render pass or dispatch;
  • calls xrWaitFrame / xrBeginFrame / xrEndFrame;
  • has render (but not Renderer), draw, frame or present in its name.

Any other function is treated as a one-off upload and is not reported. The swapchain acquire/release calls are deliberately not markers, because one-off static quad-layer fills make those calls too (that was a false positive on the Windows and macOS cubes). Inside a frame-path function it flags:

  • (a) vkQueueWaitIdle / vkDeviceWaitIdle anywhere in the body;
  • (b) vkWaitForFences after the submit, on the same handle passed as the submit's fence;
  • (c) vkAllocateCommandBuffers and vkFreeCommandBuffers both in the body;
  • (d), as INFO only: one fence without an index, waited at the top and signalled by the submit. That means at most one frame in flight. It is INFO because a fence reached through a slot pointer looks the same to a text matcher.

Exemption: // dxr-lint: allow-blocking-submit <reason> on the submit line or the function's signature line. Known blind spots, also written into the rule: a drain hidden behind a helper in another function, and submit counts per frame.

Tests: scripts/tests/test_check_displayxr_app_vk_frame.py has 12 hermetic cases:

  • positive: the Android record_atlas shape, the desktop wait-own-fence shape, and a frame-named function with no draw;
  • negative: VkFrameRing, a hand-rolled ring, a one-off upload, a *Renderer init function, lazy alloc without free, commented-out code, and headers/JVM files;
  • the exemption on the submit line and on the signature line, and the single-fence INFO.

Mutation-checked: disabling the frame-path gate, the exemption, the fence match, the drain check or the alloc+free pairing each fails at least one test. The tests are wired into lint.yml next to the INV-4.6 tests.

Evidence: lint on the cubes today (WARNs are expected; that is the point)

$ python3 scripts/check_displayxr_app.py test_apps/handle/cube_handle_vk_android
WARN  src/main/cpp/main.cpp:2250  [INV-4.10] vkAllocateCommandBuffers + vkFreeCommandBuffers in a frame-path function in `record_atlas()` — per-frame command-buffer churn (Adreno pays a fresh zeroed allocation each time, #1935/#1936).
WARN  src/main/cpp/main.cpp:2440  [INV-4.10] vkQueueWaitIdle in the same frame-path function as vkQueueSubmit in `record_atlas()` — the CPU drains the GPU every frame, so nothing overlaps; on mobile GPU governors (Adreno msm-adreno-tz) the clock collapses to ~1/3 (#1917).
WARN  test_apps/handle/cube_handle_vk_android:1  [INV-9.1] No *.displayxr.json found — the app won't appear in any workspace launcher.

0 error(s), 3 warning(s). See docs/guides/displayxr-app-rules.md for the full rules.
(exit 0)
$ python3 scripts/check_displayxr_app.py test_apps/handle/cube_handle_vk_win
WARN  vk_renderer.cpp:1539  [INV-3.1] Render/eye loop bounded by a literal 2.
WARN  main.cpp:1205  [INV-4.10] vkAllocateCommandBuffers + vkFreeCommandBuffers in a frame-path function in `RenderThreadFunc()` — per-frame command-buffer churn (Adreno pays a fresh zeroed allocation each time, #1935/#1936).
WARN  main.cpp:1255  [INV-4.10] vkQueueWaitIdle in the same frame-path function as vkQueueSubmit in `RenderThreadFunc()` — the CPU drains the GPU every frame, so nothing overlaps; on mobile GPU governors (Adreno msm-adreno-tz) the clock collapses to ~1/3 (#1917).
WARN  vk_renderer.cpp:1498  [INV-4.10] vkWaitForFences on `renderer.frameFence` right after submitting with it in `RenderScene()` — waiting the frame you just submitted serialises CPU and GPU (zero frames in flight).

0 error(s), 4 warning(s). See docs/guides/displayxr-app-rules.md for the full rules.
(exit 0)
$ python3 scripts/check_displayxr_app.py test_apps/handle/cube_handle_vk_macos
ERROR main.mm:2939  [INV-5.9] App calls vkCreateDevice -> XR_KHR_vulkan_enable (enable1). enable1 forfeits the runtime-owned VkQueue the #868 weave-rate decoupling needs and the late-weave pacing (both hang off who creates the VkDevice).
WARN  test_apps/handle/cube_handle_vk_macos:1  [INV-9.1] No *.displayxr.json found — the app won't appear in any workspace launcher.
INFO  main.mm:1853  [INV-4.10] Single fence `renderer.frameFence` waited at the top of the frame and signalled by its submit in `RenderScene()` — at most one frame in flight (INV-4.10 asks for >=2).

1 error(s), 1 warning(s). See docs/guides/displayxr-app-rules.md for the full rules.
(exit 1)
$ python3 scripts/check_displayxr_app.py test_apps/cube_handle_vk_linux
WARN  main.cpp:1833  [INV-4.10] vkWaitForFences on `renderer.frameFence` right after submitting with it in `RenderScene()` — waiting the frame you just submitted serialises CPU and GPU (zero frames in flight).

0 error(s), 1 warning(s). See docs/guides/displayxr-app-rules.md for the full rules.
(exit 0)

The macOS INV-5.9 ERROR and the INV-9.1 / INV-3.1 WARNs are already on main (checked by linting main without this branch). The only new findings are the INV-4.10 lines.

INV-4.10 findings across every *_vk_* app (number of INV-4.10 lines):
cube_handle_vk_android 2 · cube_zones_vk_android 2 · cube_handle_vk_win 3 · cube_zones_vk_win 2 · cube_zones_texture_vk_win 2 · cube_handle_vk_linux 1 · cube_zones_vk_linux 2 · cube_hosted_legacy_vk_{win,linux,macos} 1 each · cube_handle_vk_macos 1 (INFO) · cube_zones_vk_macos 1 (INFO) · weave_client_vk_android 1 · probes (weave_*, windowspace_handle_vk_win) 0.

Also in this PR

  • docs/getting-started/android-build-guide.md: the DVFS paragraph links to INV-4.10.
  • /new-displayxr-app:
    • accepts platform=linux|android (VK only), with the reference map pointing to cube_handle_vk_linux / cube_zones_vk_linux / legacy/cube_hosted_legacy_vk_linux / handle/cube_handle_vk_android / zones/cube_zones_vk_android;
    • notes how those apps are built (build_linux.sh --apps list, root settings.gradle);
    • adds a guardrail not to copy the cubes' current submit loop.
  • templates/app-CLAUDE.md gains an INV-4.10 bullet.

Local CI

All of the lint.yml Python steps pass locally:

  • gen_adr_index --check
  • check_doc_paths
  • gen_extensions_index --check
  • check_cnsdk_pins
  • both app-linter unit-test files

Deliberately NOT done

  • No test app is converted. That follows once displayxr-common v2.28.0 is tagged with vk_frame_ring.h.
  • Severity stays WARN (not ERROR) so existing apps keep passing.
  • No check for one-submit-per-tile. That needs per-frame call counting across functions, which a text matcher can't do reliably.

Follow-ups

  • Convert cube_*_vk_{android,win,linux,macos} (including the zones, texture and hosted-legacy variants) and weave_client_vk_android to dxr::VkFrameRing after displayxr-common v2.28.0 is tagged. The WARNs above are the checklist.
  • Demo adoptions: earthview, mediaplayer, avatar, gaussiansplat (modelviewer already done in Decouple per-demo and runtime release cadences #170).

🤖 Generated with Claude Code

dfattal and others added 2 commits October 11, 2026 01:54
check_displayxr_app.py gains an advisory (WARN) check for the submit
shapes that park a mobile GPU governor at ~1/3 clock: per enclosing
function of each vkQueueSubmit (brace-matched, +-60-line fallback), and
only when that function is on the frame path (draw / render pass /
dispatch / xrWaitFrame-BeginFrame-EndFrame, or a render/draw/frame/present
name), it flags (a) vkQueueWaitIdle/vkDeviceWaitIdle in the body, (b)
vkWaitForFences after the submit on the fence it just passed, (c)
vkAllocateCommandBuffers + vkFreeCommandBuffers in the same body, and as
INFO (d) one un-indexed fence waited at the top and signalled by the
submit (one frame in flight). One-off upload helpers are left alone;
`// dxr-lint: allow-blocking-submit <reason>` on the submit or signature
line exempts a deliberate blocking submit.

Hermetic unit tests (positive Android/desktop shapes, VkFrameRing and
hand-rolled-ring negatives, upload helper, exemptions) run in lint.yml.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… linux/android

New INV-4.10 in §4: >=2 frames in flight, one vkQueueSubmit per frame
per queue, never wait the frame just submitted, no queue/device drains on
the frame path, command buffers allocated once. Why (Adreno msm-adreno-tz
DVFS, #1917; per-frame cmd-buffer alloc, #1935/#1936), evidence
(modelviewer#170, 13 -> 30 fps), the sanctioned dxr::VkFrameRing pattern
(displayxr-common >= v2.28.0), the allowed exceptions and the lint
heuristic. Checklist row + a cross-reference from the android build
guide's DVFS paragraph.

/new-displayxr-app accepts platform=linux|android with the VK cube
references mapped, warns not to copy the cubes' current submit loop, and
the per-app CLAUDE.md template states INV-4.10. No test app is changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dfattal
dfattal requested a review from a team as a code owner October 11, 2026 08:55
@dfattal
dfattal merged commit 1bbdecb into main Oct 11, 2026
36 checks passed
@dfattal
dfattal deleted the feat/inv-vk-frame-discipline branch October 11, 2026 09:05
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