Skip to content

svac3: loop filters in a wavefront behind the reconstruction - #12

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

widgetii merged 2 commits into
masterfrom
svac3-wavefront

Conversation

@widgetii

Copy link
Copy Markdown
Member

The first of two follow-ups to #11, both informed by how the vendor decoder works (its symbol table: ThreadParseLcuRow, ProcessLcuRow, db_filter_ctu, SynchList…). Here the loop filters follow the reconstruction row by row instead of waiting for the whole picture. The next PR will decode several pictures at once, using the per-row progress this one establishes.

What runs when (filter_upto()):

  • Deblocking: band k (LCU row k) is deblocked once row k + 1 is reconstructed, its vertical edges then its horizontal ones. It cannot go earlier, because row k + 1's intra prediction reads band k's unfiltered bottom line.
  • SAO: LCU row k runs once band k is deblocked. Rows up to y1 − 4 are final by then (y1 being the next band's first row); the next band's top edge changes only y1 − 3 (y1 − 2 in chroma) and below.
  • SAO's copy: its deblocked copy is now built incrementally. Rows are copied just before the SAO pass that first changes them.

Why the output is identical: filtering band after band gives exactly what the whole picture at once gives. A band's horizontal edges read 4 rows above it. Those rows' vertical edges are already done, and no horizontal edge of the band above writes them (the edge 8 rows up writes at most 6 above). The corpus confirms it, as do every thread count and both patch layouts.

Scheduling

threads filters
1 on the decoding thread as rows complete
2 the whole picture at the end on the pool (as in #11): adding them to the reconstruction thread measured slower
≥ 3 a thread of their own, a third pipeline stage after parse and reconstruction, fed by a row-end record (REC_ROW)

Pictures whose patches are narrower than the picture, whose rows don't complete in order, are filtered at the end. Ready bands are filtered in one call.

Checks

  • ctest 34/34 in release, ASan+UBSan, and on AArch64.
  • Output identical at 1, 2, 3 and 4 threads on all 8 corpus streams and the 2560x1440 stream.
  • ThreadSanitizer clean on every corpus stream at 2, 3 and 4 threads, and on test_api.

Speed: idle Core Ultra 5 245K, P-cores, best of 3, master (with #11) against this branch:

stream -t 1 -t 2 -t 3 -t 4 vendor -t 4
1080p CBR 0.45 → 0.45 0.32 → 0.33 0.31 → 0.24 0.30 → 0.25 0.26
1080p QP 22 ×20 1.58 → 1.58 1.05 → 1.07 1.03 → 0.95 1.02 → 0.95 1.40
2560x1440 3.25 → 3.25 2.04 → 2.12 1.95 → 1.59 1.90 → 1.59 1.76

At 3 and 4 threads libsvac3d is now ahead of the vendor decoder at 4 threads on every stream. Beyond 3 threads the three stages are each one thread; decoding several pictures at once is the next PR.

Deblocking and SAO now follow the reconstruction LCU row by LCU row
instead of waiting for the whole picture. Band k is deblocked once row
k + 1 is reconstructed (its bottom samples are the next row's intra
references), its vertical edges and then its horizontal ones; SAO of LCU
row k runs once band k is, from a copy of the deblocked rows it reads
made just before they are first changed. Band after band gives what
filtering the whole picture at once gives: a band's horizontal edges
read only rows whose vertical edges are done and no later edge writes.

With 3 threads or more the filters run on a thread of their own, a third
stage after parsing and reconstruction. With 1 they run on the decoding
thread as rows complete; with 2, and for pictures whose patches are
narrower than the picture (rows not completing in order), the whole
picture is filtered at the end on the pool, as before.

svac3_deblock_rows() and svac3_sao_rows() filter a range of rows; the
row-end record (REC_ROW) carries the reconstruction's progress.
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Pipeline SVAC3 loop filters behind row reconstruction

✨ Enhancement 🕐 40+ Minutes

Grey Divider

AI Description

• Deblock and apply SAO as reconstruction completes successive LCU rows, preserving whole-picture
 output.
• Give filters a dedicated stage with three or more threads; retain end-of-picture filtering for two
 threads or narrow patches.
• Reuse filter buffers across cached pictures to support incremental SAO copies.
Diagram

graph TD
  Parse["Patch parser"] -->|records| Recon["Reconstruction"] --> Mode{"Filter mode"} -->|three or more threads| Worker["Filter worker"] --> Dbk["Deblocking"] --> Sao["SAO"]
  Mode -->|one thread| Dbk
  Mode -->|two threads or narrow patches| End["Picture end"] --> Dbk
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Keep whole-picture filtering for every thread count
  • ➕ Simpler scheduling and buffer lifecycle.
  • ➕ Avoids row-level synchronization and equivalence reasoning.
  • ➖ Cannot overlap filtering with reconstruction.
  • ➖ Forfeits the reported three- and four-thread speedups.

Recommendation: Keep the proposed conditional wavefront pipeline: it gains overlap where measured beneficial while retaining whole-picture filtering for two threads and out-of-order rows. Review should concentrate on row-readiness boundaries and filter-worker shutdown.

Files changed (3) +317 / -65

Enhancement (3) +317 / -65
svac3_loopfilter.cAdd range-based deblocking and SAO execution +53/-16

Add range-based deblocking and SAO execution

• Deblocking now accepts luma-row ranges while preserving vertical-before-horizontal ordering. SAO accepts LCU-row ranges and a caller-owned deblocked copy; the whole-picture entry points delegate to these range-based operations.

source/svac3/svac3_loopfilter.c

svac3_loopfilter.hDeclare row-range loop-filter interfaces +14/-0

Declare row-range loop-filter interfaces

• Adds declarations and contracts for deblocking luma-row ranges and applying SAO to LCU-row ranges from a deblocked source.

source/svac3/svac3_loopfilter.h

svac3_patch.cSchedule loop filters behind completed reconstruction rows +250/-49

Schedule loop filters behind completed reconstruction rows

• Adds row-end records, per-picture filter progress, reusable parameter and SAO-copy buffers, and an optional dedicated filter worker. Scheduling retains end-of-picture filtering for two-thread decoding and patches narrower than the picture.

source/svac3/svac3_patch.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


Remediation recommended

1. Small-LCU streams get chroma filtered at wrong rows ✓ Resolved
Description
filter_upto() passes svac3_deblock_rows() row ranges that are multiples of the LCU size
(pic->dbk_done * L, dbk_to * L), and svac3_deblock_rows() halves them for chroma (y0 / 2).
plane_edges() then starts its horizontal-edge loop at that chroma row and steps by 8. With 8x8
LCUs, which svac3_patch_supported() accepts (only log2_lcu_size_minus2 > 4 is rejected), every
odd band starts at a chroma row that is 4 mod 8. That band then filters a non-existent chroma edge,
so the 1- and ≥3-thread output differs from whole-picture deblocking. With 4x4 LCUs the luma rows
are misaligned the same way.
Code

source/svac3/svac3_patch.c[R1377-1379]

+            for (int k = pic->dbk_done; k < dbk_to; k++)
+                fill_units(pic, k);
+            svac3_deblock_rows(r->frame, &pic->dbk, pic->dbk_done * L, dbk_to * L, r->pool);
Evidence
The new header promises 'y0 and y1 are multiples of 16', but the caller passes multiples of
r->lcu_size. LCU size is 1  8 ? b0 : 8) and the loop steps e += 8 from b0. With chroma b0 = 4k
for odd k, e = 12, 20, … are filtered instead of the real edges at 8, 16, 24. Whole-picture
svac3_deblock always starts at b0 = 0 or at a multiple of 32, so it never hits this. The wavefront
path is the default whenever patches span the picture width, at 1 thread and at 3 or more.

source/svac3/svac3_loopfilter.c[75-102]
source/svac3/svac3_loopfilter.c[129-147]
source/svac3/svac3_loopfilter.h[37-43]
source/svac3/svac3_patch.c[1260-1261]
source/svac3/svac3_patch.c[1799-1799]
source/svac3/svac3_patch.c[1368-1382]

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

## Issue description
`filter_upto()` deblocks LCU-row bands whose boundaries are multiples of the LCU size. `svac3_deblock_rows()` requires multiples of 16 (chroma rows that are multiples of 8), because `plane_edges()` steps horizontal edges by 8 starting from the band's first row. With 8x8 (or 4x4) LCUs, which the decoder accepts, chroma (or luma) edges are filtered at positions off the 8-sample grid.
## Fix Focus Areas
- source/svac3/svac3_patch.c[1368-1382]
- source/svac3/svac3_patch.c[1478-1525]
- source/svac3/svac3_loopfilter.c[75-102]
## Recommended Fix
Pick one:
- Disable the wavefront (`pic->wavefront = 0`) in `filter_setup()` when `r->lcu_size < 16`, so those pictures are filtered whole at the end.
- Or round the deblock boundary passed to `svac3_deblock_rows()` down to a multiple of 16 luma rows, keeping `dbk_done` in rows rather than LCU rows, and recompute the SAO copy limit to match.
Alternatively, make `plane_edges()` start at the first multiple of 8 that is >= b0 (`e0 = (b0 + 7) & ~7`, at least 8).

ⓘ 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
plane_edges() took the range's first row as an edge position: with row
ranges that are not multiples of 8 in chroma (LCUs of 8 rows), it
filtered between the real edges. Horizontal edges now start at the first
multiple of 8 in the range, so each belongs to exactly one range. The
wavefront also needs LCUs of 16 rows or more, which is all the corpus
has; smaller ones filter the whole picture at the end.

test_lf deblocks random pictures in consecutive ranges of 8, 16, 24, 32
and 64 rows against deblocking them whole; without the fix 809 of 2000
differ.
@widgetii

Copy link
Copy Markdown
Member Author

Thanks, valid; fixed in the latest commit, in two ways.

  • The cause: plane_edges() now starts horizontal edges at the first multiple of 8 at or after the range's first row ((b0 + 7) & ~7), so every edge belongs to exactly one range. svac3_deblock_rows() is therefore correct for any consecutive ranges, not only multiples of 16, and its header says so.
  • The gate: the wavefront also requires LCUs of at least 16 rows. Smaller LCUs, which nothing in the corpus exercises, filter the whole picture at the end, the proven path.

New test_lf: it deblocks random pictures (random coding-unit layout, QPs, inter/intra, motion, filter type and offsets) in consecutive row ranges of 8, 16, 24, 32 and 64 rows, and compares against svac3_deblock() on the whole picture.

  • Without the rounding, 809 of 2,000 pictures differ (first at ranges of 8).
  • With it, all 2,000 agree.

ctest: 35/35.

@widgetii
widgetii merged commit 9b29d9e into master Oct 29, 2026
6 checks passed
@widgetii
widgetii deleted the svac3-wavefront branch October 29, 2026 11:37
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