Skip to content

fix(gpu): let the backend flush own GPU extractors under --threads (ADR-1197) - #1343

Merged
lusoris merged 2 commits into
masterfrom
fix/gpu-threads-ctx-sync
Sep 6, 2026
Merged

lusoris merged 2 commits into
masterfrom
fix/gpu-threads-ctx-sync

Conversation

@lusoris

@lusoris lusoris commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Summary

vmaf --threads N failed on every GPU backend, for every N including 1, on every input:

libvmaf ERROR context could not be synchronized
exit 234

The message is wrong, and that is the first thing worth stating. Instrumenting flush_context_cuda() shows all four driver calls returning success — cuCtxPushCurrent=0 cuStreamSynchronize=0 cuCtxSynchronize=0 cuCtxPopCurrent=0 — while the accumulated err is -22. The context was healthy throughout; a feature extractor's return value shared a variable with the driver's and got reported under the driver's name.

Root cause: flush_context_threaded()'s first loop flushed every TEMPORAL extractor, GPU ones included, while its second loop already skipped VMAF_FEATURE_EXTRACTOR_CUDA deliberately. That asymmetry is the bug.

testdata/bench_all.sh hard-codes --threads 1, so its GPU rows were masked failures for as long as this existed.

Why the obvious fix is wrong (measured, not argued)

Draining the tail before the boundary collect does not only cause a duplicate write. It also emits the last batch-boundary frame without the min() against the following frame that motion2/motion3 are defined by. I built the narrow fix — extend the existing guard to cover the collect as well as the flush — and measured it:

pooled VMAF frame 39 integer_motion2_mmxv_18
CPU serial 82.816059 3.724276
CPU --threads 4 82.816059 3.724276
CUDA serial 82.814059 3.724278
CUDA --threads 4, narrow fix 82.823778 4.382255
CUDA --threads 4, this PR 82.814059 3.724278

Every other feature was bit-identical; exactly one frame was wrong. The narrow fix trades a crash for a silently wrong score, which is strictly worse, so it was rejected on evidence.

What this does

flush_context_threaded() no longer touches GPU extractors at all — its first loop now skips CUDA/SYCL flags, matching its second loop — and flush_context_cuda() / flush_context_sycl() own them in both modes, running collect-then-flush in the order the serial path uses. The thread-pool special case in flush_context_cuda() is deleted, not moved. SYCL had the same defect and never had a guard in any form.

flush_context_cuda() also keeps the extractor result and the driver result in separate variables, so "context could not be synchronized" now means that.

Type

  • fix — bug fix
  • sycl / cuda / simd — backend-specific

Checklist

  • Commits follow Conventional Commits.
  • make format && make lint is green locally — pre-commit run --files clean on all touched files; clang-tidy -p build core/src/libvmaf.c reports 0 findings, unchanged from master (the added condition pushed flush_context_threaded over the ADR-0141 size budget, so flush_non_temporal_cpu_extractors() was split out rather than suppressed with a NOLINT).
  • Unit tests pass: meson test --suite=fast → 139 ok, 4 failed. Those 4 (test_cuda_float_adm_parity, test_cuda_psnr_hvs_parity, test_cuda_ssimulacra2_parity, test_vmaf_cuda_gpumask) fail identically on unmodified origin/master, verified by reverting only libvmaf.c in the same build tree and re-running them.
  • If I touched any SIMD/GPU code path — threaded output is bit-identical to serial on CUDA and SYCL at N = 1, 2, 4, 8, compared per-frame across all 48 frames and every metric key, so the worst delta is 0 ULP. GPU-vs-CPU is unchanged by this PR and remains non-bit-exact by design.
  • If I touched a feature extractor with SIMD/GPU twins, I updated every twin. — no feature extractor is touched; the change is in the context flush paths.
  • If I added a new .c / .cpp / .cu / .h / .hpp, it has the license header. — no new C source; the new file is a shell test.
  • If this is a breaking change… — not breaking. A command that used to abort now succeeds; no working invocation changes behaviour.
  • If this PR adds an ADR, the row lives in docs/adr/_index_fragments/ and the slug is in _order.txt — number allocated with scripts/adr/next-free.sh --claim; README.md regenerated and --checked.

Bug-status hygiene (ADR-0165)

  • docs/state.md — T-GPU-CLI-THREADS-CTX-SYNC-2026-09-06 added to Recently closed with the misreport evidence, both consequences, the rejected narrow fix and its measured wrong score, and the bench_all.sh follow-up.

Netflix golden-data gate (ADR-0024)

  • I did not modify any assertAlmostEqual(...) score in the Netflix golden Python tests.

Cross-backend numerical results

feature                     cpu-vs-cuda   cpu-vs-sycl   (serial vs threaded, same backend)
pooled vmaf                  unchanged     unchanged     0 ULP  (82.814059 both, N=1,2,4,8)
integer_motion2_mmxv_18      unchanged     unchanged     0 ULP  (all 48 frames)
all other metric keys        unchanged     unchanged     0 ULP  (all 48 frames)

Deep-dive deliverables (ADR-0108)

  • Research digest — no digest needed: trivial. The investigation was instrumentation of one function; its findings are recorded in ADR-1197's Context rather than a separate digest.
  • Decision matrix — ADR-1197 ## Alternatives considered weighs five options, including the narrow guard extension that was built and measured before rejection.
  • AGENTS.md invariant note — no rebase-sensitive invariants: the two that matter are recorded in docs/rebase-notes.md, which is where this repo keeps core/src/libvmaf.c ones.
  • Reproducer / smoke-test command — below.
  • CHANGELOG fragment — changelog.d/fixed/adr-1197-gpu-threads-flush.md; CHANGELOG.md regenerated and --checked.
  • Rebase note — docs/rebase-notes.md, fix/gpu-threads-ctx-sync — threaded flush leaves GPU extractors alone (2026-09-06).

Reproducer

# Fails on master with exit 234, passes with this change:
meson setup build core -Denable_cuda=true -Denable_sycl=false -Db_lto=false
ninja -C build
meson test -C build test_vmaf_cuda_threads --print-errorlogs

# Or directly — note --threads 1 is enough to trigger it:
./build/tools/vmaf \
  --reference python/test/resource/yuv/src01_hrc00_576x324.yuv \
  --distorted python/test/resource/yuv/src01_hrc01_576x324.yuv \
  --width 576 --height 324 --pixel_format 420 --bitdepth 8 \
  --backend cuda --threads 1

The test asserts the threaded result equals the serial one per frame, not merely that the process exits 0 — checking only the exit code would have passed the wrong-score variant above.

Known follow-ups

…DR-1197)

`vmaf --threads N` failed on every GPU backend, for every N including 1, on
every input:

    libvmaf ERROR context could not be synchronized
    exit 234

The message is wrong, and that is worth stating first. Instrumenting
flush_context_cuda() shows all four driver calls returning success --
cuCtxPushCurrent=0 cuStreamSynchronize=0 cuCtxSynchronize=0 cuCtxPopCurrent=0
-- while the accumulated `err` is -22. The context was healthy throughout. A
feature extractor's return value shared a variable with the driver's result and
was reported under the driver's name.

Root cause: flush_context_threaded()'s first loop flushed every TEMPORAL
extractor, GPU ones included, while its second loop already skipped
VMAF_FEATURE_EXTRACTOR_CUDA deliberately. That asymmetry is the bug. Flushing a
temporal GPU extractor there ran its tail-batch drain BEFORE the pending
boundary collect in flush_context_cuda(), so the later collect re-emitted an
already-written index and the collector returned -EINVAL.

The duplicate write was not the only consequence, which is why the obvious fix
is wrong. Draining the tail before the boundary collect also emits the last
batch-boundary frame without the min() against the following frame that motion2
and motion3 are defined by. I built the narrow fix -- extend the existing guard
to cover the collect as well as the flush -- and measured it: --threads then
returned 82.823778 against serial 82.814059, with frame 39 of the Netflix pair
reading 4.382255 instead of 3.724278. CPU serial, CPU threaded and CUDA serial
all agree on the correct value. That fix trades a crash for a silently wrong
score, so it was rejected on evidence.

Instead the threaded flush no longer touches GPU extractors at all, and
flush_context_cuda / flush_context_sycl own them in both modes, running
collect-then-flush in the same order the serial path uses. The thread-pool
special case in flush_context_cuda is deleted rather than moved. SYCL had the
same defect and never had a guard in any form.

flush_context_cuda now keeps the extractor result and the driver result in
separate variables and reports them separately, so "context could not be
synchronized" means what it says.

flush_non_temporal_cpu_extractors() is split out of flush_context_threaded to
keep it inside the ADR-0141 function-size budget after the added condition;
clang-tidy reports 0 findings on libvmaf.c, unchanged from master.

Verified bit-identical to serial at N = 1, 2, 4 and 8, per-frame across all 48
frames and every metric key, on CUDA and on SYCL. The new
core/tools/test/test_vmaf_gpu_threads.sh asserts equality rather than exit
status -- checking only the exit code would have passed the wrong-score variant
-- and is registered for each GPU backend that is compiled in. It fails with
exit 234 on master and passes with this change.

meson test --suite=fast: 139 ok, 4 failed. Those 4
(test_cuda_float_adm_parity, test_cuda_psnr_hvs_parity,
test_cuda_ssimulacra2_parity, test_vmaf_cuda_gpumask) fail identically on
unmodified origin/master, verified by reverting only libvmaf.c in the same
build tree.

testdata/bench_all.sh hard-codes --threads 1, so its GPU rows were masked
failures for as long as this existed; noted as a follow-up in the state.md row.

no digest needed: trivial

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@lusoris lusoris added this to the 1.0.0 — First release milestone Sep 6, 2026
…ate)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@lusoris
lusoris marked this pull request as ready for review September 6, 2026 00:40
@lusoris
lusoris merged commit ee43938 into master Sep 6, 2026
118 of 119 checks passed
@lusoris
lusoris deleted the fix/gpu-threads-ctx-sync branch September 6, 2026 01:07
lusoris added a commit that referenced this pull request Sep 6, 2026
T-GPU-MOTION-FLUSH-DOUBLE-EMIT-2026-09-06 has the right root cause -- the
tail-batch drain and the pending collect emitting the same index, whose
duplicate-write -EINVAL surfaced as a context-sync error -- and PR #1343
(ADR-1197) fixed it on master.

Its trigger was mis-stated. The row claimed any clip long enough to leave a
partial motion batch, and that no GPU backend completes a scored run. Not
reproducible: on the pre-fix build cd52f26, --backend cuda with --frame_cnt
13, 17, 45 and 47 all exit 0. The trigger is --threads on an EOF-terminated
read, which this epic's own harness hard-codes to 1; pre-fix --threads 1 with
no --frame_cnt exits 234, post-fix it exits 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
lusoris added a commit that referenced this pull request Sep 6, 2026
T-GPU-MOTION-FLUSH-DOUBLE-EMIT-2026-09-06 has the right root cause -- the
tail-batch drain and the pending collect emitting the same index, whose
duplicate-write -EINVAL surfaced as a context-sync error -- and PR #1343
(ADR-1197) fixed it on master.

Its trigger was mis-stated. The row claimed any clip long enough to leave a
partial motion batch, and that no GPU backend completes a scored run. Not
reproducible: on the pre-fix build cd52f26, --backend cuda with --frame_cnt
13, 17, 45 and 47 all exit 0. The trigger is --threads on an EOF-terminated
read, which this epic's own harness hard-codes to 1; pre-fix --threads 1 with
no --frame_cnt exits 234, post-fix it exits 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
lusoris added a commit that referenced this pull request Sep 6, 2026
…aselines (ADR-1185) (#1335)

* feat(bench): per-backend performance baseline harness and refreshed baselines (ADR-1185)

Refresh the per-backend throughput table, which had drifted: its rows were
captured against model/vmaf_v0.6.1.json before vmaf_v1.0.16_3d0h became the
default model (ADR-1169), and nothing recorded what a caller who passes no
--model actually pays.

Adds testdata/bench_backends.py, the repetition-and-median companion to
testdata/bench_all.sh. It reports the median of N timed runs (default 3, after
a discarded warmup) with min/max spread and the 1-minute load average sampled
around every cell, engages exactly one backend per run through the exclusive
--backend selector, and records the frames[0].metrics key count as a
backend-engagement check. bench_all.sh is left alone because its stdout shape
is consumed by the MCP run_benchmark tool (ADR-0517).

Two defects surfaced while measuring and are filed in docs/state.md rather
than fixed here, so a dedicated PR can carry the cross-backend parity gate:

- T-GPU-MOTION-FLUSH-DOUBLE-EMIT-2026-09-06: on cd52f26 no GPU backend
  completes a scored run on a clip longer than one motion batch. CUDA, SYCL
  and HIP all abort with "problem flushing context" because the tail-batch
  re-emit in the motion twins is not idempotent while the feature collector
  rejects duplicate writes. CI cannot see it: the only lane building all three
  backends together runs on a GPU-less runner.
- T-CUDA-MOTION-PARITY-576P-1.5E-2-2026-09-06: with CUDA unblocked, the
  576x324 golden pair pools 1.50e-2 away from CPU, against the 6-dp match the
  same table recorded at commit 4130149.

The published CUDA rows were measured with that flush defect patched locally
and are labelled as not reproducible from master; SYCL and HIP rows are
BLOCKED. CPU rows are master behaviour.

Headline for retrain planning: the default model costs 1.6x the CPU time of
v0.6.1 on the 576x324 golden pair, and on CUDA it gives up the entire GPU
speedup - 4K throughput falls from 167.16 to 9.00 fps, below the CPU's own
17.52 fps, with the process at ~97% CPU and the 4090 at ~34% utilisation
(ADR-1183 twin gating dispatching unsupported twins back to the host).

Also corrects two stale claims that the measurement disproved: the
docs/benchmarks.md reproduce block named a source root that stopped existing
at ADR-0700, and core/AGENTS.md asserted that --backend cuda initialises CUDA
but scores on the CPU, which is no longer true.

No Netflix golden assertion touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(bench): record the pristine-master cross-check for the CPU baseline rows

Re-running the 576x324 CPU cells against an unpatched all-backends build of
the same commit reproduces the pooled scores exactly (76.667831 / 82.816062),
confirming the published CPU rows are master behaviour and not an artefact of
the locally-patched build the CUDA rows needed. Throughput landed at 551.24 /
349.46 fps against the published 613.71 / 379.15 at roughly twice the load,
which is recorded as the honest scale of session-to-session movement on this
host.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(state): close the double-emit row and correct its trigger

T-GPU-MOTION-FLUSH-DOUBLE-EMIT-2026-09-06 has the right root cause -- the
tail-batch drain and the pending collect emitting the same index, whose
duplicate-write -EINVAL surfaced as a context-sync error -- and PR #1343
(ADR-1197) fixed it on master.

Its trigger was mis-stated. The row claimed any clip long enough to leave a
partial motion batch, and that no GPU backend completes a scored run. Not
reproducible: on the pre-fix build cd52f26, --backend cuda with --frame_cnt
13, 17, 45 and 47 all exit 0. The trigger is --threads on an EOF-terminated
read, which this epic's own harness hard-codes to 1; pre-fix --threads 1 with
no --frame_cnt exits 234, post-fix it exits 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(state): drop the duplicate rows a keep-both rebase created

Each dropped row restates one origin/master already carries; master is the
authoritative record. Verified with scripts/ci/check-state-md-rows.sh.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Lusoris <lusoris@pm.me>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@lusoris lusoris added the type:bug Something isn't working label Sep 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type:bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant