Skip to content

AV1 on dav1d decodes frames in parallel, each picture with its packet's timestamp, outside the decoder lock - #1377

Closed
MetaIX wants to merge 1 commit into
seedds:mainfrom
MetaIX:pr/av1-frame-threads
Closed

MetaIX wants to merge 1 commit into
seedds:mainfrom
MetaIX:pr/av1-frame-threads

Conversation

@MetaIX

@MetaIX MetaIX commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

What and why

Symptom: on a Mac without hardware AV1 (before M3), dav1d decodes a heavy 60 fps movie too slowly for the game to show it at 60. Ocarina of Time's 1080p60 title movie (0400486024DEC000 v131072, profile in #1299) took 9–20 ms a packet on average in-game on an M1 Max, with single packets up to 134 ms, against the 16.7 ms a frame allows. For reference, ffmpeg's libdav1d needs 9.3 ms of CPU a frame for that movie on one thread.

Cause:

  • dav1d ran one frame at a time (max_frame_delay 1, 2 threads). More threads on one frame barely help (6 measured the same as 2): dav1d scales by decoding frames in parallel.
  • The decode ran under the decoder's lock, so the game's Release… and Acquire… calls waited behind it.

Change:

  • dav1d gets 8 threads and 8 frames in flight.
  • A picture then comes some packets after its own, sometimes two together. mav1_decode_ts passes the packet's timestamp through dav1d (Dav1dData.m) to its picture (a new ts field in mav1_picture). The wrapper queues pictures; mav1_next gives the others and mav1_drain gives those still decoding at the end of the stream.
  • dav1d is asked for a picture once per input, because a second ask with no new data drains (waits for the frames in flight).
  • cv_dec_submit decodes AV1 under a decode lock of its own (lock order: decode lock, then the decoder lock) and takes the decoder lock only to hold the pictures. Reset and Finalize take the decode lock first.
  • A packet may add up to CV_BURST (8) pictures past CV_HOLD; packets are still refused at CV_HOLD.
  • VideoToolbox, H.264, VP8 and VP9 are unchanged.

Checked

Docs

  • No game files, keys or data extracted from a game in the change or the attachments

A follow-up PR, stacked on this one, hands pictures out by the movie's clock when the game shows fewer a second than the movie has (Ocarina's 60 fps movies at 28 fps ran at 0.44×).

🤖 Generated with Claude Code

…'s timestamp, outside the decoder lock

One frame at a time (max_frame_delay 1, 2 threads) took 9-20 ms a packet of Ocarina of Time's 1080p60 title movie on an M1 Max, single
packets up to 134 ms; more threads on one frame did not help. 8 threads and 8 frames in flight: at most 26 ms. A picture then comes some
packets after its own: mav1_decode_ts carries the packet's timestamp through dav1d (Dav1dData.m), the wrapper queues pictures
(mav1_next) and gives the frames still decoding at the end of the stream (mav1_drain), asking dav1d once per input, since a second
ask drains. cv_dec_submit decodes AV1 under a decode lock of its own, so Release and Acquire do not wait on dav1d; a packet may add up
to CV_BURST pictures past CV_HOLD, where packets are still refused. t_codec_video: a lone frame's picture comes at the end of the
stream; 20 packets give 20 pictures in order with their timestamps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot added the host The Horizon host: SVCs, services, threads, memory (dm/host) label Nov 5, 2026
gnosticgeek pushed a commit to gnosticgeek/nxforge that referenced this pull request Nov 5, 2026
…lel and pictures follow the movie's clock
gnosticgeek pushed a commit to gnosticgeek/nxforge that referenced this pull request Nov 6, 2026
) replaces the pull request heads

Upstream landed Ocarina of Time in main (chain adec7d81d970: "holds 60 fps in
Kokiri Forest, 4 GB less") and closed the pull requests it folded in, with
reworded and partly different code. Mixing a pull request head with its landed
version hunk by hunk silently broke things (two GlobalToStorage tables, a
duplicate AF2ip, two mtk_pso definitions), so the rule here is one version per
feature:

- Pull requests upstream closed (seedds#1299, seedds#1300, seedds#1312, seedds#1343, seedds#1345, seedds#1353,
  seedds#1354, seedds#1359, seedds#1360, seedds#1373, seedds#1428, seedds#1430, seedds#1434): their hunks are taken out
  of our side before the three-way merge, so upstream's landed version stands
  alone. Gone with them: bindless pool table patching (reg_bump), the ghost
  table's per-submission hold, the growing raw pool table and its write-log
  sizing, seedds#1428's pool_buf spares (upstream's pool_table_take is the same
  idea), the ixc-based indirect mesh draw (upstream uses mdx), and their tests
  (t_pool_table, t_mesh_indirect, t_gmem_raw, finalized_bindless_pool,
  test_bnsh_nvn2, test_ndve).
- Open pull requests that upstream's landing supersedes: seedds#1394 (upstream's
  mtl_pool_addressed) and seedds#1393 pool_skip_retired (upstream's budgeted pool
  walk; the profile does not use it) are dropped with their tests and docs.
  seedds#1368's mesh pipeline prewarm is dropped too: upstream kept the background
  build in nvn_geometry.inc, and the prewarm's mtk_build / mtk_claim /
  g_warm_mps went with it.
- Kept, as upstream does not have them: NXForge's own features (GPU watchdog,
  present filters and anti-aliasing, aniso floor, quick settings, pipeline
  archive compaction, upload breakdown, memory report lines) and the still
  open pull requests seedds#1304, seedds#1305, seedds#1308, seedds#1320, seedds#1331, seedds#1332, seedds#1355, seedds#1356,
  seedds#1369, seedds#1376, seedds#1377, seedds#1379, seedds#1382, seedds#1390, seedds#1391, seedds#1392, seedds#1414, seedds#1420,
  seedds#1436, seedds#1439, seedds#1446, seedds#1453.
- Combined where both sides changed the same lines: GW_N 1 << 16 with seedds#1320's
  wide-write intervals (GW_PAGES = SBW_WPAGES, 128 slots), the CPU upload log
  with upstream's linear upload route, arch_open_end before the compaction
  limit, cbn_prog in geo_gs_one (seedds#1382).
- Translator: upstream's sources and binaries, plus seedds#1414's
  InstEmitAmpereMem diff and the LDSM SubgroupBarrier in
  InstEmitAmpereTensor.cs. The binaries and dmsh_chain.lock are upstream's
  until the Mac rebuilds them (dotnet publish, tool_lock --update-lock,
  dmsh_chain --header); dmsh_chain.h is regenerated for now.
- RED_ONCE keys: upstream took 153 and 154, so the present anti-aliasing lines
  move to 156 and 157 and seedds#1420's packaged-entry line to 158 (array 159).
- Host: hle_net.c and t_net_eventfd.c are upstream's (its eventfd poll waits
  out the whole timeout, which our 5d7920c fixed another way).
- Kept ours: dm/tools/romfs_image.py and its test, so the 26 GB RomFS image is
  not packed again.

dm/tools tests on Linux: same failures as on 875449e (Linux-only quirks),
plus test_nlg4 and test_dmsh_pool_arrays, which fail on upstream main too.
Not yet built on the Mac.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016qteK29T18U8p1c9LsVY8g
seedds added a commit that referenced this pull request Nov 12, 2026
…d on 4 threads with 3 frames at once, so movies no longer stutter at a steady 60 fps (PR #2240): a submission queues its packet and returns, as the SDK's does (its decoder is another process). Decoded inside Submit…InputBuffer, a packet held the client's thread for the whole decode; Ocarina of Time's movie reader, which also gives the shown pictures back, was held up to 120 ms, and its title movie (AV1, 1080p at 60 fps, dav1d) showed a picture again in 546 and 569 of about 2,900 frames on an M2 Pro at a steady 60 fps

- hle_codec_video.c: cv_worker decodes the packets cv_dec_submit queues. The submission-ready event and the refusal count the pictures waiting for a released buffer, the packets queued and the one in decode together (cv_pending), so a client sees the same event whatever the decoder's speed. The decoder's thread waits for room instead of dropping a picture, and signals the sync point events itself. Stop and Flush drop the queued packets and number the stream anew, so the pictures of a packet still in decode are dropped; Finalize joins the thread. Guest calls take the decoder's lock as a host lock. One log line when a run of 600 packets left more released buffers waiting than the decoder has (`the decoder is behind the movie`), and the totals at Finalize
- hle_movie_av1.c: dav1d on 4 threads, 3 frames at once (15.4 -> 7.1 ms a packet over the title movie's heaviest five seconds on the M2 Pro). Pictures come out later than their units, so each unit's time rides with its picture (mav1_picture.ts) and mav1_next gives the pictures after a unit, draining when no packet waits behind it or the stream ends. A drain that has given a picture goes to its end: dav1d 1.5.4's drain, left halfway by new data, leaves a live frame's task never run and dav1d_submit_frame waits for ever (a workaround of the library's bug, in the code comment and the reference entry)
- t_codec_video.c: the event and the refusal with the packets decoded or not, which thread signals what, flushes and a Finalize with undecoded packets, AV1 key frames through the protocol with their times in order, a mid-stream drain and 600 rounds of drains at random places. The AV1 backend loop checks mav1_backend() rather than its pass: on a machine that reports hardware AV1 but cannot make the session (CI's, -12906) the VideoToolbox pass decodes with dav1d and gave its picture only on the drain. The four Initialize calls pass empty parameter sets: ccd834e added those arguments to both overloads and the test no longer compiled
- docs/reference/movies.md: `nn::codec` packets decoded inside the submission; dav1d decoded one frame at a time; the entries they change. The Ocarina of Time note, Kirby Air Riders' busy line; symptom index regenerated

PR #1377 (MetaIX) found the frame-threading cause first and carried each packet's time through Dav1dData.m the same way; it decoded on the client's thread under a lock of its own, and is superseded by this.

Checked on an M4 Pro (48 GB), macOS 27.2: t_codec_video 3 runs, and 5 runs under ThreadSanitizer with no report; the rest of the host tests' run list (t_hid does not link on main: hle_hid.c's dm_gfx_addr_note, dm_gfx_bufsnap, probe_atrace) and tsan-threads; test_docs; lint_c.py tidy on both changed files, 0 findings. Games, headless, A/B with hosts from the tree at 918e9f3^ (chain cb2e91c138e3, libraries on disk) without and with this host diff: Ocarina of Time's title for 54 s with DM_AV1_DAV1D=1 and DM_PROBE on the player's advance (main+0x3b61ed8 failed, main+0x3b61f80 good): 361 failed of 3,041 before, 0 of 3,057 after, the same frame times and red lines. Mario Kart World's cup opening movie (VP9 2560x1440, libvpx): all 1,423 pictures on both, frame p95 18.8-19.3 ms in the movie on both, 3.24 ms a packet and 13.4 ms the longest, no new red line. The PR's own runs: Ocarina's title on an M2 Pro, ten runs, 0 or 1 failed advance; DecodeAsync 0.10 ms at the median and 1.1 ms at the 99th percentile, from 1.9 and 120.

Not run: Fire Emblem Fortune's Weave (SDK 22's native images; its shader split on disk is older than the map), Donkey Kong Bananza, Mario Tennis Fever, Kirby Air Riders, an iPhone or iPad decoding AV1 with dav1d, regress.py (games chosen by hand: the impact map has no usage data for the codec games)

Co-authored-by: SailorSnoW <49660929+SailorSnoW@users.noreply.github.com>
@seedds

seedds commented Nov 12, 2026

Copy link
Copy Markdown
Owner

Thank you for this. You found the cause first: dav1d scales by frames in parallel, not by threads on one frame, and you carried each packet's time through Dav1dData.m. Main now has the same dav1d change in 09a91c5 (from #2240, which names your finding in its commit message), so I'm closing this one as superseded.

The difference is where the decode runs. #2240 gives each decoder a thread of its own, since the SDK's Submit…InputBuffer only queues the packet (on the console the decoder runs in another process). Here the decode still ran on the game's thread, under a lock of its own, so Ocarina's reader still waited up to ~27 ms a packet. #2240 also drains dav1d whenever no packet waits behind one (so a game that waits for each picture still gets it), runs every drain to its end (dav1d 1.5.4 deadlocks when new data interrupts one), and waits for room instead of growing the queue past CV_HOLD.

#1379 is built on this PR's interface and on decoding in the game's thread. If you'd like it considered, please rebase it onto main: the late-picture skip would sit where the decoder thread hands pictures back. It will get its own design review then.

@seedds seedds closed this Nov 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

host The Horizon host: SVCs, services, threads, memory (dm/host)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants