Skip to content

svac3: pictures decoded side by side - #13

Merged
widgetii merged 2 commits into
masterfrom
svac3-frames
Oct 29, 2026
Merged

widgetii merged 2 commits into
masterfrom
svac3-frames

Conversation

@widgetii

Copy link
Copy Markdown
Member

With N threads, N pictures are decoded at a time. The caller's thread
keeps the picture buffer and the reference lists in decoding order: at a
picture's first patch it works out the references and a slot, and once
the patches are all in it hands the picture, with a copy of its headers
and patch data, to a picture thread. Slots that a picture in flight uses
are held until it is output.

A P picture waits only for the reference rows its motion reaches. Before
each prediction it asks for the rows the 8-tap luma and 4-tap chroma
interpolation read, and before co-located motion it asks for that row of
16x16 temporal motion. The patch decoder now reports how far a picture is:
the rows no later filter step can change (8 above the last SAO band, 6
above the last deblocked one without SAO) and the temporal-motion rows,
stored per LCU row. Each picture thread also runs its picture's
reconstruction and filters behind its parser, as one thread with a pool
of 3 did, which is what speeds up I pictures.

Pictures come out in decoding order, with the same bits for any number of
threads, through an output function, up to N pictures late.
svac3d_flush() returns the rest at the end of the stream, and
svac3d_decode_tag() tags each picture with the buffer its first patch came
in. A picture that fails reports its error when it is output, and leaves
the buffer; the pictures made from it fail too. One thread decodes as
before.

The FFmpeg wrapper drains at EOF (AV_CODEC_CAP_DELAY) and takes each
frame's pts, dts and duration from its own packet by tag. The library
version is 0.3.0.

On a six-P-core Core Ultra 5 245K, at 4 threads: 1080p CBR 0.24 s -> 0.13 s,
2560x1440 1.56 s -> 0.79 s, QP 22 (an I picture in six) 0.95 s -> 0.67 s.

Verification

  • Every corpus stream, and a 2560x1440 stream kept out of the repo, is bit-exact against the golden MD5s at 1, 2, 3, 4 and 8 threads, on x86-64 (AVX2) and AArch64 (NEON).
  • Through FFmpeg, framemd5 equals the golden hashes at -threads 1, 2, 4 and 8. Timestamps are identical between 1 and 4 threads.
  • TSan is clean at 2, 4 and 8 threads, including test_api and test_api -lost. ASan/UBSan ctest passes, plus threaded decodes.
  • test_api covers:
    • threaded output through svac3d_flush(), whole stream and access unit by access unit;
    • each picture keeping its access unit's tag;
    • reset dropping the pictures in flight;
    • a lost patch giving the same pictures and error with 4 threads as with 1.
  • New CI fuzz target fuzz_frames (3 picture threads). The TSan job now also runs 2 threads and the lost-patch test.

Note: with N threads, up to about 3N threads can be busy, because each picture runs its own recon and filter threads. Within a fixed core budget this was as fast or faster than one thread per picture on every stream measured.

With N threads, N pictures are decoded at a time. The caller's thread
keeps the picture buffer and the reference lists in decoding order: at a
picture's first patch it works out the references and a slot, and once
the patches are all in it hands the picture, with a copy of its headers
and patch data, to a picture thread. Slots that a picture in flight uses
are held until it is output.

A P picture waits only for the reference rows its motion reaches. Before
each prediction it asks for the rows the 8-tap luma and 4-tap chroma
interpolation read, and before co-located motion it asks for that row of
16x16 temporal motion. The patch decoder now reports how far a picture is:
the rows no later filter step can change (8 above the last SAO band, 6
above the last deblocked one without SAO) and the temporal-motion rows,
stored per LCU row. Each picture thread also runs its picture's
reconstruction and filters behind its parser, as one thread with a pool
of 3 did, which is what speeds up I pictures.

Pictures come out in decoding order, with the same bits for any number of
threads, through an output function, up to N pictures late.
svac3d_flush() returns the rest at the end of the stream, and
svac3d_decode_tag() tags each picture with the buffer its first patch came
in. A picture that fails reports its error when it is output, and leaves
the buffer; the pictures made from it fail too. One thread decodes as
before.

The FFmpeg wrapper drains at EOF (AV_CODEC_CAP_DELAY) and takes each
frame's pts, dts and duration from its own packet by tag. The library
version is 0.3.0.

On a six-P-core Core Ultra 5 245K, at 4 threads: 1080p CBR 0.24 s -> 0.13 s,
2560x1440 1.56 s -> 0.79 s, QP 22 (an I picture in six) 0.95 s -> 0.67 s.
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Decode SVAC3 pictures concurrently with row-level reference waits

✨ Enhancement 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Decode multiple pictures concurrently while preserving decoding-order output and bit-exact
 results.
• Add row-level reference waits, delayed-output flushing, and per-picture tags for packet
 timestamps.
• Extend FFmpeg integration, API tests, fuzzing, and ThreadSanitizer coverage for threaded decoding.
Diagram

graph TD
  FF["FFmpeg wrapper"] --> API(["Decode API"]) --> Planner["DPB planner"] --> Workers["Picture workers"] --> Pipeline["Patch and filters"] --> Progress["Row progress"]
  Progress -->|unblocks references| Workers
  Workers --> Output(["Ordered output"]) --> FF
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Wait for complete reference pictures
  • ➕ Simpler synchronization and progress reporting.
  • ➖ Serializes dependent P pictures and sacrifices much of the intended throughput.
2. Use one thread per picture without per-picture pipeline threads
  • ➕ Lower thread count and simpler resource budgeting.
  • ➖ Loses parser, reconstruction, and filter overlap, particularly for I pictures.

Recommendation: Keep the row-level waits and per-picture pipeline: they allow dependent pictures to overlap while retaining the existing intra-picture speedup. Review synchronization and resource use closely, since N picture workers can occupy roughly 3N threads.

Files changed (12) +996 / -156

Enhancement (8) +903 / -146
0001-avcodec-add-SVAC-3.0-decoding-via-libsvac3d.patchAdapt FFmpeg wrapper to delayed tagged frames +55/-17

Adapt FFmpeg wrapper to delayed tagged frames

• Requires libsvac3d 0.3.0, retains packet timing by picture tag, drains pictures at EOF, and advertises delayed output.

ffmpeg/0001-avcodec-add-SVAC-3.0-decoding-via-libsvac3d.patch

svac3_dec.cSchedule concurrent pictures with ordered retirement +571/-77

Schedule concurrent pictures with ordered retirement

• Separates decoding-order reference and slot planning from worker-side picture decoding. Holds in-flight slots, coordinates row-level waits, propagates reference failures, and retires or flushes pictures in order.

source/svac3/svac3_dec.c

svac3_dec.hExpose internal threaded-output controls +37/-4

Expose internal threaded-output controls

• Adds an output callback carrying picture metadata and errors, plus flush and tag controls for the picture decoder.

source/svac3/svac3_dec.h

svac3_patch.cTrack finalized rows and wait before reference reads +76/-7

Track finalized rows and wait before reference reads

• Waits for required image or temporal-motion rows before prediction. Reports filter-safe rows and stores temporal motion incrementally so dependent pictures can proceed.

source/svac3/svac3_patch.c

svac3_patch.hDefine reference-wait and progress callbacks +18/-0

Define reference-wait and progress callbacks

• Extends reference metadata with image-row and temporal-motion wait hooks and adds a picture-progress callback interface.

source/svac3/svac3_patch.h

svac3d.cDeliver tagged and flushed pictures through the public API +68/-16

Deliver tagged and flushed pictures through the public API

• Adds tagged decoding and flush entry points, routes worker results through the caller’s output callback, and returns delayed picture errors when their results are delivered.

source/svac3/svac3d.c

svac3d.hPublish libsvac3d 0.3.0 delayed-output API +31/-9

Publish libsvac3d 0.3.0 delayed-output API

• Adds picture tags and declares tagged decode and flush functions. Documents threaded output latency and the revised error timing.

source/svac3/svac3d.h

svac3dec.cDrain threaded pictures in the decoder CLI +47/-16

Drain threaded pictures in the decoder CLI

• Writes worker-produced pictures through an output callback and flushes at end of stream while retaining single-thread output behavior.

tools/svac3dec.c

Documentation (1) +23 / -4
README.mdDocument picture parallelism and delayed output +23/-4

Document picture parallelism and delayed output

• Explains concurrent picture decoding, reference-row waits, flush and tagging APIs, thread costs, and measured throughput.

README.md

Other (3) +70 / -6
ci.ymlExpand threaded sanitizer and fuzz coverage +13/-1

Expand threaded sanitizer and fuzz coverage

• Runs corpus decoding at two and four threads under ThreadSanitizer, adds the lost-patch API test, and builds and runs a three-picture-thread fuzz target.

.github/workflows/ci.yml

fuzz_patch.cExercise picture threads in the patch fuzzer +7/-1

Exercise picture threads in the patch fuzzer

• Allows the existing harness to run with a configured number of picture threads and flushes pending work before teardown.

tests/fuzz/fuzz_patch.c

test_api.cVerify ordered output, tags, flush, and recovery +50/-4

Verify ordered output, tags, flush, and recovery

• Compares threaded output against single-thread hashes for whole-stream and access-unit input. Checks picture tags, repeated flush, reset behavior, and lost-patch results.

tests/test_api.c

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Negative motion can read unfinished pixels ✓ Resolved
Description
ref_wait() skips the reference-row wait when its motion-derived requirement is nonpositive,
although interpolation clamps out-of-frame reads to reference row zero. A block near the top edge
with sufficiently negative vertical motion can therefore predict from row zero while another picture
is still reconstructing it.
Code

source/svac3/svac3_patch.c[R691-695]

+    int need = luma > chroma ? luma : chroma;
+    if (need > p->pic_h)
+        need = p->pic_h;
+    if (need > 0)
+        p->refs->wait(p->refs->ctx[ref], need);
Evidence
The new calculation can omit the callback for a nonpositive need, but interpolation still reads
row zero after clamping negative coordinates.

source/svac3/svac3_patch.c[685-695]
source/svac3/svac3_inter.c[35-43]
source/svac3/svac3_inter.c[48-70]
source/svac3/svac3_dec.c[506-520]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Negative motion can make `ref_wait()` skip synchronization even though interpolation reads the clamped first reference row.
## Fix Focus Areas
- source/svac3/svac3_patch.c[685-695]
## Recommended Fix
Clamp the required row count to at least one whenever prediction reads a reference, while retaining the upper picture-height bound; test top-edge blocks with large negative vertical motion under concurrent decoding.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Large patches remain allocated after output ✓ Resolved
Description
queue_patch() retains a separate payload allocation for every patch, and spare_task() recycles
the task without freeing those allocations. A stream containing a picture with many large patches
can leave all of their high-water allocations resident until the decoder is destroyed, even after
that picture has been output.
Code

source/svac3/svac3_dec.c[R743-750]

+    if (len - 2 > pb->cap) {
+        uint8_t *b = realloc(pb->data, len - 2);
+        if (!b) {
+            drop_collect(d);
+            return SVAC3_ERR_NOMEM;
+        }
+        pb->data = b;
+        pb->cap = len - 2;
Evidence
Payloads are allocated per patch and retained in the task; recycling does not release them, while
free_task() releases them only when the task is freed.

source/svac3/svac3_dec.c[133-141]
source/svac3/svac3_dec.c[596-600]
source/svac3/svac3_dec.c[731-756]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Threaded tasks retain every patch payload, including large allocations on recycled tasks, for the decoder's lifetime.
## Fix Focus Areas
- source/svac3/svac3_dec.c[596-600]
- source/svac3/svac3_dec.c[731-756]
## Recommended Fix
Set an aggregate limit for collected patch bytes and release or shrink oversized patch allocations when a task becomes spare. Preserve buffering needed for pictures in flight, and return an allocation error when the limit is exceeded.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Packets without pictures grow a queue ✓ Resolved
Description
libsvac3d_receive_frame() appends a PacketProps record for every input packet, but the output
callback is the only place that drains those records. A long sequence of packets that yields no
picture, such as parameter-only or rejected packets, can therefore grow the timing queue for the
entire sequence.
Code

ffmpeg/0001-avcodec-add-SVAC-3.0-decoding-via-libsvac3d.patch[R348-350]

++            s->err = av_fifo_write(s->props, &p, 1);
++            if (s->err >= 0)
++                ret = svac3d_decode_tag(s->dec, s->pkt->data, s->pkt->size, p.tag,
Evidence
The new FIFO is auto-growing and receives one entry per packet; its only normal drain is inside the
picture-output callback.

ffmpeg/0001-avcodec-add-SVAC-3.0-decoding-via-libsvac3d.patch[233-243]
ffmpeg/0001-avcodec-add-SVAC-3.0-decoding-via-libsvac3d.patch[269-275]
ffmpeg/0001-avcodec-add-SVAC-3.0-decoding-via-libsvac3d.patch[344-353]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The timing queue grows with packets even when decoding produces no picture to drain their records.
## Fix Focus Areas
- ffmpeg/0001-avcodec-add-SVAC-3.0-decoding-via-libsvac3d.patch[233-243]
- ffmpeg/0001-avcodec-add-SVAC-3.0-decoding-via-libsvac3d.patch[344-353]
## Recommended Fix
Bound or retire timing records that cannot be needed by a later picture while preserving the record for a picture whose first patch arrived in an earlier packet. Cover long picture-free packet sequences and pictures spanning packets.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. Turning threads on mid-stream crashes the next P picture ✓ Resolved
Description
wait_on() locks s->dec->lock, but s->dec and s->rows are only set in dispatch(). A slot
decoded on the caller's thread therefore has dec == NULL and rows == 0, and the first threaded P
picture that references it dereferences NULL in wait_rows(). In the other direction, a slot
decoded by a picture thread that later fails keeps failed = 1 after a switch back to one thread.
If that slot is refilled on the caller's thread and threads are turned on again, any picture
referencing it fails for no reason.
Code

source/svac3/svac3_dec.c[R506-513]

+static void wait_on(pic_t *s, const int *v, int rows)
+{
+    if (load(v) >= rows)
+        return;
+    svac3_dec_t *d = s->dec;
+    pthread_mutex_lock(&d->lock);
+    while (load(v) < rows)
+        pthread_cond_wait(&d->cond, &d->lock);
Evidence
Only dispatch() sets s->dec, rows, tmv_rows and failed;
start_picture()/decode_patch_nal() on one thread never touch them. svac3_dec_set_threads()
drains and starts workers, but it keeps the buffer, so references decoded on one thread stay in use.
Its header says pictures in flight are output first when the number changes, which suggests changing
the number mid-stream is supported.

source/svac3/svac3_dec.c[660-671]
source/svac3/svac3_dec.c[506-527]
source/svac3/svac3_dec.c[828-842]
source/svac3/svac3_dec.c[404-413]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Slots decoded on the caller's thread keep `dec = NULL`, `rows = 0` and a possibly stale `failed`. After `svac3_dec_set_threads(d, >=2)` mid-stream, `wait_rows()` on such a reference dereferences NULL, and a stale `failed` makes later pictures fail.
## Fix Focus Areas
- source/svac3/svac3_dec.c[404-413]
- source/svac3/svac3_dec.c[461-468]
- source/svac3/svac3_dec.c[828-842]
## Recommended Fix
When a picture commits on the caller's thread (in `decode_patch_nal`), set `slot->dec = d`, `slot->rows = slot->h`, `slot->tmv_rows = (slot->h + 15) >> 4` and `slot->failed = 0`. Alternatively, have `svac3_dec_set_threads()` do this for every used slot before starting workers. In `wait_on()`, return early when `s->dec` is NULL.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View medium (1)
5. A failed threaded picture still prunes the buffer ✓ Resolved
Description
dispatch() calls commit() before the worker validates the picture, pruning keep[] references
and, for a library picture, clearing every other library slot; if decoding or finalization fails,
retire() clears only the failed picture’s slot. With corrupt patch data followed by pictures that
need those references, including display pictures that reference the previous library picture,
threaded decoding can fail where one-thread decoding succeeds, and which call reports the error
depends on whether retirement precedes planning of the dependent pictures.
Code

source/svac3/svac3_dec.c[R660-664]

+static void dispatch(svac3_dec_t *d, task_t *t)
+{
+    plan_t *pl = &t->plan;
+    commit(d, pl);
+    pic_t *s = pl->slot;
Evidence
The one-thread path calls commit() only after svac3_picture_end_cached() succeeds, while
threaded dispatch calls it before decoding. commit() sets ordinary slots’ used flags from
keep[] and clears other library slots for a library picture, but retire() on failure clears only
the failed slot’s used flag. Thus a worker failure leaves reference pruning that the one-thread
path would never apply; the -lost test does not exercise this case because a missing patch is
detected on the caller’s thread before dispatch.

source/svac3/svac3_dec.c[415-430]
source/svac3/svac3_dec.c[454-469]
source/svac3/svac3_dec.c[612-634]
source/svac3/svac3_dec.c[660-664]
source/svac3/svac3_dec.c[529-563]
source/svac3/svac3_dec.c[629-647]
source/svac3/svac3_dec.c[659-681]
source/svac3/svac3_dec.c[460-467]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Threaded dispatch prunes references before the worker knows whether its picture is valid. If decoding fails, retirement discards the failed picture but leaves otherwise valid references pruned, including a previous library picture.
## Fix Focus Areas
- source/svac3/svac3_dec.c[415-430]
- source/svac3/svac3_dec.c[612-647]
- source/svac3/svac3_dec.c[659-681]
## Recommended Fix
Keep dispatched pictures provisional and visible for subsequent reference planning without permanently pruning prior references. Apply their planned pruning only on successful decoding and discard a provisional picture on failure, including when it would replace a library picture. If retaining dispatch-time pruning instead, save the prior `used` flags and library-slot state and restore entries on failure only where no later committed picture has changed them. Add a test with corrupt patch data, rather than only a dropped patch, and compare outputs at 1 and 4 threads.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

6. Lost-patch message names the wrong picture ✓ Resolved
Description
In svac3dec, a SVAC3_ERR_CORRUPT from svac3_dec_nal() prints sink.pics and
svac3_dec_stats(dec)->first_bad_lcu. With picture threads, both describe the last picture output,
which can be up to N pictures behind. The picture number and LCU printed for a lost or out-of-order
patch are therefore wrong whenever -t is above 1.
Code

tools/svac3dec.c[R92-95]

      if (ret == SVAC3_ERR_CORRUPT && svac3_nal_is_patch(nal[1] & 31u)) {
          fprintf(stderr, "picture %d: patch data out of sync from LCU %u, or reference missing\n",
-                    pics, svac3_dec_stats(dec)->first_bad_lcu);
+                    sink.pics, svac3_dec_stats(dec)->first_bad_lcu);
          goto out;
Evidence
svac3_dec_stats() is documented in the PR as the statistics of the last picture output when
threads are on, and retire() sets d->st from the retired task. sink.pics counts only pictures
written, so it lags the picture whose patch was lost.

source/svac3/svac3_dec.h[77-79]
source/svac3/svac3_dec.c[629-634]
tools/svac3dec.c[86-96]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
With threads, `svac3dec`'s lost-patch message reports the output count and the stats of the last picture output, not of the picture that lost a patch.
## Fix Focus Areas
- tools/svac3dec.c[92-96]
## Recommended Fix
When threads > 1, leave `first_bad_lcu` out of the message, or count pictures started in decoding order and print that count instead of `sink.pics`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can add REVIEW.md to your repo root and Qodo follows it on every PR

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread source/svac3/svac3_patch.c Resolved
Comment thread source/svac3/svac3_dec.c Resolved
Comment thread source/svac3/svac3_dec.c Resolved
Comment thread source/svac3/svac3_dec.c Resolved
Comment thread tools/svac3dec.c Resolved
A picture handed to a picture thread is in the buffer for the pictures
after it to reference, but prunes it only once it has decoded, in
decoding order, and then only of the pictures taken before it. One that
fails prunes nothing and leaves the buffer, as on one thread. A picture
whose reference has since failed, or been decoded again, has its
references found again among the pictures before it and is decoded again
on the caller's thread once nothing in flight reads it; one not yet handed
out has them found again first. A library picture replacing one with the
same index no longer makes the pictures in flight lose it. A damaged I
picture in the library stream gave 44 pictures on one thread and 40 on
four; it now gives 44 on both. test_api -lost damages a P picture and the
second I picture, and compares 1 and 4 threads (also on the library
stream, now in ctest and the TSan job).

A slot decoded on the caller's thread is marked complete, so turning
threads on mid-stream with it as a reference no longer dereferences a
NULL decoder; test_api changes the number at an access unit both ways.

A reference read above the picture is of its first row: the wait asks
for at least one row. Recycled tasks free patch buffers the next picture
need not reuse. svac3dec reports a lost patch by NAL unit with threads,
and the FFmpeg wrapper keeps at most 256 packets' timing.
@widgetii

Copy link
Copy Markdown
Member Author

Review addressed in a91d4f6:

  1. Negative motion: fixed. The wait now asks for at least one row, since reads above the picture are of row 0.
  2. Patch buffers: fixed. A recycled task frees buffers past the patches it used, and any buffer more than twice (+64 KB) the size its patch needed.
  3. Timing FIFO: bounded at 256 packets. A picture comes out at most threads pictures after its packet, so an entry that old is not needed.
  4. Threads turned on mid-stream: fixed. A slot decoded on the caller's thread is marked complete, failed is cleared, and wait_on returns early when there is no decoder. test_api now changes the thread count at an access unit in both directions; the old code segfaulted on it.
  5. Pruning before decoding: fixed.
    • A dispatched picture enters the buffer, but it prunes only at in-order retirement and only pictures taken before it, ordered by sequence number. A failure prunes nothing.
    • This alone was not enough: pictures dispatched while a failing picture is in flight have already resolved their references to it.
    • Such a picture, or one whose reference was itself redone, now re-resolves its references among earlier pictures, as one thread would. It is then decoded again on the caller's thread, once nothing in flight reads it. A not-yet-dispatched one re-resolves at dispatch.
    • The new test_api -lost damages a P picture and the second I picture and compares 1 vs 4 threads. On the library stream the old code gave 44 pictures on 1 thread vs 40 on 4; it now gives 44 on both. That case is now in ctest and the TSan job.
  6. svac3dec message: with threads it now names the NAL unit, not the last picture output.

Bit-exact at 2, 4 and 8 threads on every stream. TSan, ASan/UBSan and the ARM64 ctest are clean.

@widgetii
widgetii merged commit 6b58796 into master Oct 29, 2026
6 checks passed
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