Skip to content

libvmaf: release both pictures on every return of vmaf_read_pictures() - #1652

Open
lusoris wants to merge 1 commit into
Netflix:masterfrom
VMAFx:fix/read-pictures-release-on-error
Open

lusoris wants to merge 1 commit into
Netflix:masterfrom
VMAFx:fix/read-pictures-release-on-error

Conversation

@lusoris

@lusoris lusoris commented Oct 1, 2026 •

Copy link
Copy Markdown

libvmaf.h says VmafContext takes ownership of both pictures passed to vmaf_read_pictures(). When the call returns an error it does not: it returns with both references still held for the caller. A caller that follows the header leaks the pair, and a caller that took the pair from vmaf_preallocate_pictures() hangs in vmaf_close(). This makes the call release both pictures on every return, so the code does what the header says.

What breaks today

Master 8e7a1ac4e, a small C program over the public API (CPU, no feature other than psnr): a first 32x32 pair is accepted, then a 64x48 pair is submitted. validate_pic_params() rejects it with -22. The caller does not unref, as the header says.

case master this branch
pair from vmaf_picture_alloc(), ASan + UBSan (LeakSanitizer) 9272 bytes leaked in 6 allocations (both pictures, their VmafRef and private data) no leak
pair from vmaf_preallocate_pictures() (pool of 2), then vmaf_close() never returns; the 10 s alarm() ends it (exit 142). vmaf_close() waits in vmaf_picture_pool_close() until every pool picture is back returns

The error returns of vmaf_read_pictures() that leave the pair with the caller on master: validate_pic_params(), check_picture_pool(), check_ring_buffer() and the reference translate_picture() (CUDA builds), an extractor failure in the dispatch loop, and a failed enqueue in threaded_read_pictures_batch() (it drops the extra references it took itself, not the caller's), and the call after the context was flushed.

Fix

Once the call has a context and both pictures, each of those returns drops both references through the caller's pointers (release_picture_pair()). vmaf_picture_unref() clears the struct it is given (picture.c, the memset at its end), so a caller that unrefs after the error gets -EINVAL and nothing is released twice. A call without a context, with only one of the two pictures, and the flush call (both NULL) take nothing, as before. In vmaf_read_pictures() the flushed test moves below the flush branch: a flush after a flush still returns -EINVAL; a pair after a flush is now released as well.

On the threaded path a failed vmaf_thread_pool_enqueue() unrefs the three references threaded_read_pictures_batch() took (pic_a, pic_b, the previous pictures) and returns the error without touching the caller's pair; the caller's references are now released once, in vmaf_read_pictures().

libvmaf.h gets two sentences: the ownership holds on an error return too, and which calls take nothing.

Callers

FFmpeg's libavfilter/vf_libvmaf.c (master at 663a37f7f9d3, the latest commit touching the file; read on 2026-10-01): lines 163-166 and 790-793 log and return AVERROR(EINVAL) after a failed vmaf_read_pictures() and do not unref pic_ref / pic_dist (the unref at line 159 is for the earlier copy_picture_data() failure). It follows the header, so it leaked the pair on an error and is correct with this change. In this tree tools/vmaf.c does not touch the pictures after an error either, and vmaf_close() is not reached on that path (it returns after the flush), so the CLI is unaffected. A caller that unrefs after an error today (the tests added by #1639 and #1621 do) still works: the struct is cleared, the unref returns -EINVAL and releases nothing.

Test

libvmaf/test/test_read_pictures_release.c (new, registered with a 20 s timeout):

  • a rejected pair: with an extra reference taken by the test, the count is back to 1 after the call (2 on master);
  • the caller's structs are cleared and a second unref returns -EINVAL;
  • one picture only, no context, and the flush call take nothing, and a second flush is rejected;
  • a rejected pair from the pool: the two pool pictures can be fetched again, and vmaf_close() returns.

The pool case hangs on master. The bound is meson's timeout : 20 on the test() entry, so a regression fails the test instead of hanging the suite; run directly, the binary would block. On master the test fails at the first case ("rejected ref still referenced by the context"); with only the pool case it ends in TIMEOUT after 20 s; with the change it passes.

Validation

x86-64 Linux, GCC 16.2.1, RTX 4090 with CUDA 13.4.

Rebased on master 9e48141b (2026-10-02). The release and sanitizer builds, the new test and the Netflix pair comparison were re-run on it. The new test's failure on unpatched master (with libvmaf.c and libvmaf.h reverted, LeakSanitizer) was measured on cea2b4d8 and not repeated: neither file changed since. The CUDA build line below was measured on 8e7a1ac4e and not repeated.

  • Release meson test with -Denable_float=true -Denable_checkasm=true: 25/25 on master, 26/26 here (the new test), re-run on 9e48141b. Default options (22/22 and 23/23) were measured on cea2b4d8 and not repeated.
  • -Db_sanitize=address,undefined -Db_lto=false, same options: master 22 pass and 3 fail of 25; here 23 pass and 3 fail of 26. The failures are the same three on both: test_predict and test_pic_preallocation (SIGABRT, LeakSanitizer) and checkasm (heap-buffer-overflow in adm_dwt2_16, integer_adm.c:2603); this change touches none of them.
  • The three Netflix pairs, .engagement/golden_compare.py master binary vs this branch: all identical (default dispatch and cpumask=-1), e.g. src01 vmaf_mean 76.667831 on both.
  • CUDA build (-Denable_cuda=true, libvmaf/build-cuda), under the device lock: it compiles; test_read_pictures_release and test_pic_preallocation pass; test_cuda_pic_preallocation fails with SIGSEGV here and on unpatched master. vmaf --gpumask 0 on src01 gives vmaf_mean 76.668905 on both.
  • Applying this commit on top of the branches of libvmaf: reject picture indices that do not increase in vmaf_read_pictures() #1639 and libvmaf: reject pictures whose bit depth does not match #1621 (the former needs the index check to release the pair too, see below): both build and pass with the same two sanitizer failures.

Not covered

  • CUDA error paths that cannot be reached from the API without fault injection, and so were not run: translate_picture_device() leaves the freshly allocated host picture allocated when vmaf_cuda_picture_download_async() fails; the return value of the dist translate_picture() call is never checked; and on an error in the dispatch loop with device input, the host copy made for the CPU extractors is not released. cuda: complete device-picture cleanup after threaded dispatch #1613 does not touch those error paths (it changes the download mask to 0x7, adds a stream synchronize that returns -EIO without freeing the host picture, and moves the device cleanup after the threaded dispatch). They stay as they are.
  • Callers outside this tree that unref after an error and rely on the struct being intact afterwards.

Relation to other changes

@lusoris
lusoris force-pushed the fix/read-pictures-release-on-error branch from 7bbfa9f to 8781fe5 Compare October 2, 2026 05:24
@lusoris
lusoris force-pushed the fix/read-pictures-release-on-error branch from 8781fe5 to 6852db9 Compare October 2, 2026 18:45
4KVCD added a commit to 4KVCD/libvmaf-fast that referenced this pull request Oct 5, 2026
…AF alone and with NEG, VMAF v1; RTX 5090, Intel iGPU, CPU), and every claim checked against the code, git and the runs

Problem
The landing page of 14a1322 had wrong or unsupported statements:
- Its speed table came from older runs: libvmaf's CUDA code only from host
  memory (46 fps at 4K), no route from GPU memory, VMAF v1 on another film
  (3840x1608), and app-level numbers from VideoMetricsLab.
- "Moving a 4K frame pair through system memory costs the CPU more than the
  GPU half of VMAF v1 saves": not so (the hybrid from system memory is 1.7x
  the CPU's speed at 4K with fewer cores busy).
- "It is also faster than the CUDA code it ports": only for VMAF and NEG
  together, or from system memory. For VMAF alone from GPU memory,
  libvmaf's CUDA code is faster (465 against 382 fps at 4K).
- "integer arithmetic only": the shaders use float for estimates the result
  does not depend on (ADM's angle pre-check, VIF's division), and double
  with the optional NATIVE_F64.
- The AMD fault was described as a read "at a negative index"; it is the
  table's entries for values below -1, read at their usual place.
- The self-test was called the engine's; it is the Python bindings' probe().
- VMAF v1's 71-case matrix was said to be identical "on all three GPUs"; the
  Radeon 780M ran 48 frames of film, not the matrix.
- "libvmaf runs the two feature sets separately": it runs motion once and
  VIF and ADM twice (fex_ctx_vector.c merges extractors with equal options).
- libvmaf's CUDA code "within about 0.001" of the CPU: that was before Netflix#1644;
  now only motion differs, by at most 0.000029 on the benchmark video.
- Also: "fully on the GPU" (libvmaf predicts on the CPU); the five-frame
  window and moving average belong to the HFR models only; Netflix#1612's leak and
  Netflix#1652's hang were missing; the GPU half of VMAF v1 "alone at 655 fps" and
  "2.9 cores busy instead of 7.7" could not be reproduced here (dropped); the
  whole-film check ran before the AMD fix to one shader.
- Of the ten shaders, only common.slang carried the Netflix and NVIDIA
  copyright notice the page says the engine keeps.
- Source comments repeated "no float or double on the GPU" and pointed to
  fast/README.md for the pull requests, which the root README lists.

Change
- fast/tests/bench_readme.py (new): the page's tables. Frames decoded into
  memory first, then each route scores them in a loop: libvmaf on the CPU at
  4-24 threads; libvmaf's CUDA code from pinned host pictures and from
  device pictures filled on the GPU; Vulkan from system memory and from GPU
  memory (its buffers imported into CUDA); VMAF v1 with each GPU. Speed and
  the process's CPU time per route; scores compared within each kind (GPU
  routes of VMAF v0.6.1 against libvmaf's CUDA code). --vmaf-only for VMAF
  without NEG.
- README.md rewritten from the measurements and the checks:
  - Speed: one table for VMAF and VMAF + NEG at 4K and 1080p (CPU, CUDA and
    Vulkan from system and GPU memory, Intel iGPU), one for VMAF v1 with
    cores busy, and what they show, including where CUDA is faster.
  - How it was done: what is reproduced exactly and how (per-pixel float and
    double, tables, rounding of partial sums), where float is still used,
    the Intel and AMD driver faults as found, the self-tests as the
    bindings' probe(), VMAF v1's work split measured on the benchmark video
    (ADM3 + motion3 68% at 4K, 63% at 1080p), the route from GPU memory.
  - The pull request table with complete fixes; Netflix#1477 merged upstream on
    2026-10-02; which seven changed since; Netflix#1562's second cause.
  - How it is checked: the two matrices' actual cases, real video, the whole
    film, and exactly what was run on the Radeon 780M.
- fast/vulkan/shaders/*.slang: the libvmaf notice (Netflix 2016-2023, NVIDIA
  2021, BSD+Patent) on the nine that lacked it; motion_v1 and vif_filter
  name the CPU files they come from. common.slang's note on arithmetic says
  where float and double are used.
- fast/vulkan/vmaf_vulkan.cpp, common.slang, build_libvmaf_cuda.ps1: comments
  point to README.md for the pull requests.
- fast/python/vmaf_fast: vulkan.py and v1.py docstrings no longer say
  "integer arithmetic only", "several times faster" or native/vmaf_vulkan;
  v1.py drops app numbers from VideoMetricsLab.
- fast/README.md: bench_readme.py under Testing.

Effect
No change to any library: vmaf_vulkan.dll rebuilt from this tree is byte
for byte the release's (SHA-256 47b95eeb...), so v3.2.0-fast.1 stays
current. The landing page states only what was measured or read here.

Verification (RTX 5090, Core Ultra 9 285K and its Intel GPU)
- bench_readme.py, HoneyBee 3840x2160 10-bit against its x265 encode, 48
  frames in memory: 480 pairs at 4K, 960 at 1920x1080, with and without
  --vmaf-only. Repeat runs within about 1%. libvmaf's CUDA code from
  pinned host pictures with 0, 2, 4 and 8 threads: 58-65 fps at 4K.
- compare_vmaf_vulkan.py --matrix (45) and compare_vmaf_v1.py --matrix (71),
  each with --device 0 and 1: ALL IDENTICAL. diagnose_vmaf_vulkan.py: every
  sum and buffer is the reference's, on both.
- Real video, 48 frames of HoneyBee at 4K 10-bit and 1080p 8-bit, both GPUs:
  VMAF, NEG and VMAF v1 IDENTICAL.
- libvmaf CUDA against CPU, feature by feature on the benchmark video: VIF
  and ADM (VMAF and NEG) bit-identical; motion2 at most 2.87e-5 (4K) and
  4.53e-6 (1080p).
- VMAF v1 per feature, libvmaf on one thread, 96 frames: ADM3 55.2 ms,
  motion3 6.0, CAMBI 12.4, SpEED 16.0 at 4K.
- The whole Beekeeper film again (VideoMetricsLab's
  scripts/verify_vmaf_vulkan_movie.py, the release's DLLs, RTX 5090):
  151,919 of 151,919 frames, all 11 features bit-identical to CUDA, VMAF
  and NEG identical (means 94.927743 and 90.299552). The Intel run of
  2026-10-04 (earlier build) is cited as such.
- The pull requests' states, authors and head commits read with gh on
  2026-10-05; upstream's tree has no GPU code but CUDA.

Limits
- One PC. The Radeon 780M results are the other PC's reports, not re-run.
- Cores busy are compared at different speeds; libvmaf's CPU time per frame
  grows with its thread count, so they are not a per-frame cost.
- libvmaf's CUDA code from device pictures crashed in this benchmark with
  libvmaf's thread pool on (threads > 0); its table row is with threads = 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
libvmaf.h says the context takes ownership of both pictures, but a call
that returned an error left them with the caller: a caller that followed
the header leaked the pair, and one that took the pair from
vmaf_preallocate_pictures() hung in vmaf_close(), which waits for every
pool picture.

Once the call has a context and two pictures, every return now drops
both references through the caller's pointers. vmaf_picture_unref()
clears the struct, so a caller that unrefs after the error gets -EINVAL
and releases nothing twice. A call without a context, with only one of
the two pictures, or the flush call still takes nothing.

The new test submits a pair of another shape than the stream's and
checks the references, the harmless second unref, the calls that take
nothing, and that vmaf_close() returns with a pool pair; its meson entry
carries a timeout so a regression fails instead of hanging.
@lusoris
lusoris force-pushed the fix/read-pictures-release-on-error branch from 6852db9 to dd51642 Compare October 7, 2026 10:05
@lusoris

lusoris commented Oct 7, 2026

Copy link
Copy Markdown
Author

Rebased onto acdd937; the new head is dd51642. The conflict was in libvmaf.c at the top of vmaf_read_pictures(), where convert_pictures() now runs. It is placed after the flushed check, and a failure of it releases both pictures like every other return. Both pictures are released through the caller's pointers, so this holds after a successful conversion too (the converted pictures have replaced the originals in the caller's structs). The new conversion-failure return is not covered by a test of its own; test_read_pictures_release covers the other returns (validation, pool, flushed, one-sided calls) and the pool-pair hang in vmaf_close().

Release build with checkasm: all 28 meson tests pass, and test_read_pictures_release runs 4 tests, all passing. ASan/UBSan build: 24 pass and 4 fail (test_predict, checkasm, test_read_pictures_convert, test_pic_preallocation); the same 4 fail on unpatched master acdd937, and test_read_pictures_release is not among them.

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