Summary
On a specific H.264 clip, VideoReader.get_batch(idx) enters an infinite native loop (100% CPU on one core, never returns) when idx spans the whole file via forward seeks and ends at one of the last two nominal frame indices.
The root cause is a frame-count mismatch: len(VideoReader) trusts the container's nominal nb_frames (431 = duration 10.033s × 30 fps), but the file only has 300 actually-decodable frames. Indices computed from len(vr) therefore point past real EOF, and the decoder spins forever trying to reach a frame that does not exist.
Because the hang is in native C holding the GIL, it cannot be interrupted by Python signals / SIGALRM / thread timeouts — the only recovery is killing the process. This makes it especially nasty inside batch pipelines (e.g. Qwen3-Omni's qwen_omni_utils, which generates exactly these indices via torch.linspace(0, len-1, nframes)).
Environment
- decord 0.6.0 (pip)
- Python 3.11, Linux x86_64
- system ffmpeg 4.4.2
- Reproduces with no torch imported — pure decord. (Originally seen inside a torch process, but torch is a red herring; the trigger is the index pattern, not torch/OpenMP.)
The smoking gun
$ ffprobe -v error -select_streams v:0 \
-show_entries stream=nb_frames,avg_frame_rate,duration -of default=nw=1 clip.mp4
duration=10.033000
avg_frame_rate=30/1
nb_frames=431 # <- nominal; decord's len(vr) returns this
$ ffprobe -v error -select_streams v:0 -count_frames \
-show_entries stream=nb_read_frames -of default=nw=1 clip.mp4
nb_read_frames=300 # <- actually decodable frames
len(VideoReader(clip)) == 431, but only 300 frames really decode.
Minimal reproduction
import faulthandler, sys, time
from decord import VideoReader
faulthandler.dump_traceback_later(20, repeat=True)
vr = VideoReader(sys.argv[1])
print("len(vr):", len(vr)) # 431 (real decodable frames: 300)
idx = [0,16,32,48,64,80,96,111,127,143,159,175,191,207,
223,239,255,271,287,303,319,334,350,366,382,398,414,430]
print("get_batch...", flush=True)
vr.get_batch(idx).asnumpy() # hangs forever, 100% CPU, holds GIL
print("done")
Run it under an outer timeout so it can't freeze your shell:
timeout --signal=KILL 60 python minrepro.py clip.mp4
What hangs vs what works (bisection)
All of these were run as fresh processes on the same clip:
| Access pattern |
Result |
get_batch([0]), [207], [430] (any single frame) |
✅ OK |
get_batch(range(431)) (full contiguous read) |
✅ OK, 2.1s |
get_batch([0,16,…,414]) (27 strided, ends at 414) |
✅ OK |
get_batch([0,16,…,414,428]) (ends at 428) |
✅ OK |
get_batch([0,16,…,414,429]) (ends at 429) |
❌ HANG |
get_batch([0,16,…,414,430]) (ends at 430 = len-1) |
❌ HANG |
get_batch([366,382,398,414,430]) (short tail only) |
✅ OK |
So the deadlock requires both: a long forward-seeking batch across the file and a final index ≥ len-2 (429/430), i.e. past the 300 real frames. A single near-EOF read, or a short read, takes a different (clamping) path and is fine.
Native stack while hung (faulthandler)
Thread 0x... (most recent call first):
File ".../decord/_ffi/_ctypes/function.py", line 173 in __call__
File ".../decord/video_reader.py", line 175 in get_batch
File "minrepro.py", line ... in <module>
100% CPU on one core, no progress; uninterruptible via Python signals because the GIL is held in the native call.
Expected behavior
Either:
len(VideoReader) should reflect the number of decodable frames (not the container's nominal nb_frames), or
get_batch() should clamp/raise on out-of-range indices instead of looping forever at EOF.
A bounded error would let callers skip the clip; an infinite native loop cannot be recovered without killing the process.
Workarounds (for others hitting this)
- Switch video backend away from decord. For Qwen2.5-VL / Qwen3-Omni
qwen_omni_utils: FORCE_QWENVL_VIDEO_READER=torchvision (or torchcodec).
- Decode in a killable subprocess with a wall-clock timeout (only reliable way to interrupt a GIL-holding native hang).
- Pre-validate clips with
ffprobe -count_frames and clamp requested indices to the real frame count.
Summary
On a specific H.264 clip,
VideoReader.get_batch(idx)enters an infinite native loop (100% CPU on one core, never returns) whenidxspans the whole file via forward seeks and ends at one of the last two nominal frame indices.The root cause is a frame-count mismatch:
len(VideoReader)trusts the container's nominalnb_frames(431 =duration 10.033s × 30 fps), but the file only has 300 actually-decodable frames. Indices computed fromlen(vr)therefore point past real EOF, and the decoder spins forever trying to reach a frame that does not exist.Because the hang is in native C holding the GIL, it cannot be interrupted by Python signals /
SIGALRM/ thread timeouts — the only recovery is killing the process. This makes it especially nasty inside batch pipelines (e.g. Qwen3-Omni'sqwen_omni_utils, which generates exactly these indices viatorch.linspace(0, len-1, nframes)).Environment
The smoking gun
len(VideoReader(clip)) == 431, but only 300 frames really decode.Minimal reproduction
Run it under an outer timeout so it can't freeze your shell:
What hangs vs what works (bisection)
All of these were run as fresh processes on the same clip:
get_batch([0]),[207],[430](any single frame)get_batch(range(431))(full contiguous read)get_batch([0,16,…,414])(27 strided, ends at 414)get_batch([0,16,…,414,428])(ends at 428)get_batch([0,16,…,414,429])(ends at 429)get_batch([0,16,…,414,430])(ends at 430 = len-1)get_batch([366,382,398,414,430])(short tail only)So the deadlock requires both: a long forward-seeking batch across the file and a final index ≥
len-2(429/430), i.e. past the 300 real frames. A single near-EOF read, or a short read, takes a different (clamping) path and is fine.Native stack while hung (faulthandler)
100% CPU on one core, no progress; uninterruptible via Python signals because the GIL is held in the native call.
Expected behavior
Either:
len(VideoReader)should reflect the number of decodable frames (not the container's nominalnb_frames), orget_batch()should clamp/raise on out-of-range indices instead of looping forever at EOF.A bounded error would let callers skip the clip; an infinite native loop cannot be recovered without killing the process.
Workarounds (for others hitting this)
qwen_omni_utils:FORCE_QWENVL_VIDEO_READER=torchvision(ortorchcodec).ffprobe -count_framesand clamp requested indices to the real frame count.