Skip to content

fix(comp_bg2d): stop mutating the slot-16 backdrop under an in-flight DP compose (#1120) - #1123

Open
dfattal wants to merge 1 commit into
mainfrom
fix/1120-bg2d-sync
Open

fix(comp_bg2d): stop mutating the slot-16 backdrop under an in-flight DP compose (#1120)#1123
dfattal wants to merge 1 commit into
mainfrom
fix/1120-bg2d-sync

Conversation

@dfattal

@dfattal dfattal commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

What

Slot 16 hands the display processor a borrowed VkImageView. Since L11 (leia #176) the DP's compose pass is GPU-synced rather than CPU-waited, so when control returns to the runtime, frame N's compose can still be sampling st->image — and the runtime then mutates it with no synchronisation of its own:

  • comp_bg2d_teardown destroys the image/view/memory with no wait at all, reachable per frame (the BG2D_MAP_DROP branch, and any change of the upload dims).
  • bg2d_build re-uploads into the same image behind a barrier whose srcStageMask is TOP_OF_PIPE_BIT — that orders nothing against the previous submission's fragment reads.

Fix — two mutation kinds, two mechanisms

Mutation Fix Cost
Re-upload into a live image Name the compose's stage in the transition barrier (FRAGMENT_SHADER_BIT once uploaded_once). A barrier's first sync scope covers everything submitted earlier on the same queue, and the compose submits on the same vk->main_queue->queue this upload does. srcAccessMask stays 0 — WAR needs an execution dependency, not a memory one. zero
Destroy A barrier cannot protect a freed object → drain vk->main_queue->queue first. That is the exact field compose_pre_weave reads at submit time, both under the compositor lock, so it is the same queue by construction. Gated on uploaded_once. zero in steady state — nothing mutates unless the capture geometry actually changed

Rejected: a graveyard / deferred destroy. The runtime does not own the DP's cmp_fence, so any deferred drain would be a frame-count heuristic rather than a proof. The argument that usually favours it (avoid a steady-state CPU wait) does not apply here, because comp_bg2d only mutates on change.

Kill switches (both inert by default)

Dynamic evidence (NP02J, avatar in-process, T2 capture, bg2d=capture)

Churn proven live, not assumed — the new counter reports ~9 live-image teardowns/s sustained at jiggle=10 (200+ per run, both arms).

Arm runs frames flash frames max Δ
sync=0 (unfixed) + jiggle 10 4 2585 1 (one isolated 2-diff spike, max 10.7, mid-run) 0.54–10.7
sync=1 (fixed) + jiggle 10 2 clean 1571 0 0.35 / 0.37

COMPOSE_COST: 0.164–0.276 ms/frame CPU, gpu_sync=1 in both arms — the L11 ≤ 0.3 ms target holds even under continuous churn, i.e. the fix is free. No DEVICE_LOST, no VK errors in either arm.

What this does NOT claim

It does not cure the #1120 flash frames. Under forced churn the unfixed arm produced ~1 flash frame in 2585 frames — nowhere near the reported ~63/231-class alternation, and not attributable.

The reason is mechanical and worth recording: the vendor DP's alpha_gate_run ends every composed frame with vkQueueWaitIdle(vk->main_queue->queue) (leia_display_processor_cnsdk.cpp:2039), which drains compose N before frame N+1's teardown can run. So the defect is real but currently masked — by an invariant the runtime neither owns nor asserts. That drain is also the last CPU wait left after L11, i.e. the obvious next thing someone removes for perf.

This therefore lands as hardening, not as a cure: it makes the runtime self-sufficient for its own image lifetimes so that removing the vendor-side drain stays a local decision.

NOT tested

  • Device A/B is PENDING — the pad was reclaimed mid-sequence (two fix_* runs are contaminated: the app PID changed and one recording truncated to 178 frames). Two clean fixed-arm runs are reported above; the full matrix has not been re-run.
  • Rotation round-trip under armed capture (drop → re-shoot → re-bind) — not measured on this build.
  • bg2d=off regression check — not re-measured on this build (the path is untouched by this diff).
  • Windows / macOS / Linuxcomp_bg2d.c builds on every VK platform but only the Android leg was exercised; CI covers the compile.
  • The IPC/comp_multi leg — shares comp_bg2d verbatim and is fixed by the same change, but was not exercised.

Refs #1120

🤖 Generated with Claude Code
Session: https://claude.ai/code/session_01N3kzh1GTPNrm62TbEhFmqP

… DP compose (#1120)

Slot 16 hands the display processor a *borrowed* VkImageView. Since L11 (leia
 #176) the DP's compose pass is GPU-synced rather than CPU-waited, so when
control returns to the runtime, frame N's compose can still be sampling
`st->image` — and the runtime then mutates it, with no synchronisation of its
own:

  * `comp_bg2d_teardown` destroys the image/view/memory with no wait at all,
    and is reachable per frame (`BG2D_MAP_DROP`, and any change of the upload
    dims).
  * `bg2d_build` re-uploads into the same image behind a barrier whose
    `srcStageMask` is `TOP_OF_PIPE_BIT` — that orders nothing against the
    previous submission's fragment reads.

Two mutation kinds, two fixes:

  * RE-UPLOAD into a live image: name the compose's stage in the transition
    barrier (`FRAGMENT_SHADER_BIT` once `uploaded_once`). A barrier's first
    synchronisation scope covers everything submitted earlier on the same
    queue, and the compose submits on the same `vk->main_queue->queue` this
    upload does, so the write-after-read hazard is closed at zero CPU cost.
    `srcAccessMask` stays 0: WAR needs an execution dependency, not a memory
    one.
  * DESTROY: a barrier cannot protect a freed object, so drain
    `vk->main_queue->queue` first — the exact field `compose_pre_weave` reads
    at submit time, both under the compositor lock, so it is the same queue by
    construction. Gated on `uploaded_once`, and nothing here mutates unless the
    capture geometry actually changed, so the steady state pays nothing.

A graveyard/deferred-destroy was rejected: the runtime does not own the DP's
compose fence, so any deferred drain would be a frame-count heuristic rather
than a proof — and the argument that usually favours it (avoid a steady-state
CPU wait) does not apply, because bg2d only mutates on change.

Two knobs, both inert by default:

  * `debug.dxr.bg2d.sync` / `DXR_BG2D_SYNC` = 0 restores the pre-#1120
    ordering on the SAME build, so the A/B is one binary.
  * `debug.dxr.bg2d.jiggle` / `DXR_BG2D_JIGGLE` = HZ perturbs a local copy of
    the canvas rect (+16 x, -16 w) at HZ so the re-crop, teardown and re-upload
    path runs continuously — the race is only reachable when the canvas moves,
    and in the steady state it never does.

Measured on NP02J (avatar in-process, T2 capture, jiggle 10 Hz): ~9
live-image teardowns/s sustained, COMPOSE_COST 0.164-0.276 ms/frame with
gpu_sync=1 in BOTH arms — i.e. the fix is free even under continuous churn.

IMPORTANT — this does not claim to cure the #1120 flash frames. Under that
same forced churn the *unfixed* arm produced ~1 flash frame in 2585 frames,
which does not reproduce the reported ~63/231-class alternation. The reason is
in the vendor DP: `alpha_gate_run` ends every composed frame with
`vkQueueWaitIdle(vk->main_queue->queue)`, which drains compose N before frame
N+1's teardown can run. The defect is therefore real but currently *masked* by
an invariant the runtime neither owns nor asserts — and that drain is the last
CPU wait left after L11, i.e. the obvious next thing to remove. This lands as
hardening so that removing it stays a local decision.

Refs #1120

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3kzh1GTPNrm62TbEhFmqP
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