You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Upgrading the av wheel from 16.1.0 → 17.x increases resident memory of a
many-concurrent-decoder H.264 workload by ~12% RSS, driven by a ~5×
increase in the number of large anonymous memory mappings per decoder. The
increase reproduces with the bundled FFmpeg version held constant at 8.0.1
(both av==16.1.0 and av==17.0.1 ship FFmpeg 8.0.1), so it is not an
FFmpeg-version change — it is something in the PyAV 17 wheel (the Cython
layer and/or how the wheel builds its bundled FFmpeg).
Environment
Platform: Linux aarch64 (AWS Graviton), CPython 3.12, manylinux av wheels
Allocator: jemalloc via LD_PRELOAD
(narenas:4,dirty_decay_ms:1000,muzzy_decay_ms:1000,oversize_threshold:20MB)
Workload: one process decoding ~13–14 concurrent RTSP H.264 1280×720
streams, software decode, codec_context.thread_count = 1, ~4 reference
frames. Per frame: container.demux(stream) → packet.decode() → frame.to_ndarray(format="bgr24").
Controlled measurement (single variable = the av wheel)
Same site, same cameras, same process layout; only the av pin changed.
Per-process (~14 decoders), steady state:
av
PyAV
bundled FFmpeg
large anon mappings / process
je_alloc
RSS
13.1.0
13
7.0.2
~300
~610 MB
baseline
16.1.0
16
8.0.1
~270
~620 MB
baseline
17.0.1
17
8.0.1
~1550
~750 MB
+~12%
17.1.0
17
8.1.1
~1900
~680 MB
+~12%
That is ~19 large mappings/decoder on PyAV 16 → ~110/decoder on PyAV 17 (~5.5×).
Decisive comparison: 16.1.0 vs 17.0.1 — identical bundled FFmpeg (8.0.1),
large-mapping count jumps ~270 → ~1550. The only variable is PyAV 16 → 17.
Ruled out
FFmpeg version — 8.0.1 is fine under PyAV 16, regressed under PyAV 17;
8.0 → 8.1 makes no difference.
Frame threading — thread_count=1; no per-thread DPB/picture multiplication.
numpy / opencv — unchanged (numpy 2.4.6, opencv-python-headless 4.13.0.92)
across all rows.
Allocator fragmentation — jemalloc tuning (narenas:2, oversize_threshold
down to 128 KB, eager dirty/muzzy_decay) does not reclaim it. The extra
mappings behave as distinct live allocations, not reclaimable dirty/muzzy
pages → it is an allocation-count increase from libav/PyAV, not allocator behavior.
Single-decoder isolation looks normal — the effect only emerges at N
concurrent decoders; one decoder in isolation is comparable across versions.
Suggests per-decoder buffer pools that no longer share/reuse, fragmenting
the arena at scale.
Suspected root: the v17 zero-copy buffer-ownership change
PyAV 17.0.0 introduced a zero-copy refactor that changed buffer/memory ownership:
no-copy VideoFrame.to_ndarray() for padded frames (by @lgeiger)
zero-copy Packet init from buffer data (by @WyattBlue)
This same change is the documented root cause of #2223 "Writing packets to a
data stream broken in av>=17" —
where a packet ends up referencing memory whose ownership/lifetime changed
(absent in v16). Our memory regression appears to be another manifestation in
the decode path: if returned arrays/packets now hold references to
decoder-owned buffers, the decoder's buffer pool cannot recycle and allocates
fresh buffers each frame → the ~5× large-mapping growth observed.
Honest caveat: our pipeline calls to_ndarray(format="bgr24"), which converts
YUV→BGR and therefore cannot be strictly zero-copy — so the @lgeiger
padded-frame optimization may not hit us directly. But the broader v17
buffer-ownership refactor that broke #2223's packet path is the same suspect.
Unaddressed on main (verified)
main is 28 commits ahead of v17.1.0 (verified via the compare API). None
touches buffer-pool reuse, frame-buffer retention, refcounting, or a
memory/leak fix in the decode path. The only to_ndarray change adds a new
format (yuv420p10le); the two main commits mentioning "zero-copy" are new
DLPack export/import features (extending the zero-copy approach, not reverting
it); "Keep decoded frames when a packet ends with undecodable bytes" retains
more frames. So the regression is live on both the latest release (17.1.0)
and main, and #2223's zero-copy root is also still unfixed there.
Open question
Whether the regression is (a) PyAV 17's Cython frame/buffer code, or (b)
the av-17 wheel compiling its bundled FFmpeg differently from the av-16 wheel.
Clean split: build PyAV 17 from source against a self-built FFmpeg (same
version) and compare to the wheel — if source-built is fine, it's the wheel's
FFmpeg build; if still regressed, it's the Cython layer.
How the regression was established
The figures above were measured in a production deployment via jemalloc / /proc/<pid>/smaps (large-anonymous-mapping counts), by changing only the av wheel pin and holding the rest of the stack constant. The decisive single-variable comparison is av==16.1.0 vs av==17.0.1 — identical bundled FFmpeg (8.0.1), ~270 vs ~1550 large mappings/process.
I do not yet have a confirmed minimal standalone reproduction. In particular it is unverified whether the increase requires the decoded frames/arrays to be retained downstream (as the full pipeline does) to manifest, or whether N concurrent decoders alone is sufficient — earlier small-scale/single-decoder isolation did not show a difference. Happy to put together and confirm a minimal case if that would help triage.
Workaround
Pinning av~=16.1.0 (FFmpeg 8.0.1) avoids the regression. FFmpeg 8.0.1 still carries the fixes that motivated moving off 7.x — the HEVC output_corrupt/showall handling (commit bc1a3bfd2) and CVE-2025-1816 (both shipped in FFmpeg 8.0) — so it is a clean pin for our use.
Questions
Does the v17 zero-copy change (to_ndarray padded-frame / Packet buffer
init) cause decoded-frame or packet buffers to be retained longer than v16,
preventing decoder buffer-pool reuse?
Is there a way to opt back into copy semantics on the decode path without
pinning to v16?
Summary
Upgrading the
avwheel from 16.1.0 → 17.x increases resident memory of amany-concurrent-decoder H.264 workload by ~12% RSS, driven by a ~5×
increase in the number of large anonymous memory mappings per decoder. The
increase reproduces with the bundled FFmpeg version held constant at 8.0.1
(both
av==16.1.0andav==17.0.1ship FFmpeg 8.0.1), so it is not anFFmpeg-version change — it is something in the PyAV 17 wheel (the Cython
layer and/or how the wheel builds its bundled FFmpeg).
Environment
avwheelsLD_PRELOAD(
narenas:4,dirty_decay_ms:1000,muzzy_decay_ms:1000,oversize_threshold:20MB)streams, software decode,
codec_context.thread_count = 1, ~4 referenceframes. Per frame:
container.demux(stream)→packet.decode()→frame.to_ndarray(format="bgr24").Controlled measurement (single variable = the
avwheel)Same site, same cameras, same process layout; only the
avpin changed.Per-process (~14 decoders), steady state:
avThat is ~19 large mappings/decoder on PyAV 16 → ~110/decoder on PyAV 17 (~5.5×).
Decisive comparison:
16.1.0vs17.0.1— identical bundled FFmpeg (8.0.1),large-mapping count jumps ~270 → ~1550. The only variable is PyAV 16 → 17.
Ruled out
8.0 → 8.1 makes no difference.
thread_count=1; no per-thread DPB/picture multiplication.across all rows.
narenas:2,oversize_thresholddown to 128 KB, eager
dirty/muzzy_decay) does not reclaim it. The extramappings behave as distinct live allocations, not reclaimable dirty/muzzy
pages → it is an allocation-count increase from libav/PyAV, not allocator behavior.
concurrent decoders; one decoder in isolation is comparable across versions.
Suggests per-decoder buffer pools that no longer share/reuse, fragmenting
the arena at scale.
Suspected root: the v17 zero-copy buffer-ownership change
PyAV 17.0.0 introduced a zero-copy refactor that changed buffer/memory ownership:
VideoFrame.to_ndarray()for padded frames (by@lgeiger)Packetinit from buffer data (by@WyattBlue)This same change is the documented root cause of #2223 "Writing packets to a
data stream broken in av>=17" —
where a packet ends up referencing memory whose ownership/lifetime changed
(absent in v16). Our memory regression appears to be another manifestation in
the decode path: if returned arrays/packets now hold references to
decoder-owned buffers, the decoder's buffer pool cannot recycle and allocates
fresh buffers each frame → the ~5× large-mapping growth observed.
Unaddressed on
main(verified)mainis 28 commits ahead ofv17.1.0(verified via the compare API). Nonetouches buffer-pool reuse, frame-buffer retention, refcounting, or a
memory/leak fix in the decode path. The only
to_ndarraychange adds a newformat (
yuv420p10le); the twomaincommits mentioning "zero-copy" are newDLPack export/import features (extending the zero-copy approach, not reverting
it); "Keep decoded frames when a packet ends with undecodable bytes" retains
more frames. So the regression is live on both the latest release (17.1.0)
and
main, and #2223's zero-copy root is also still unfixed there.Open question
Whether the regression is (a) PyAV 17's Cython frame/buffer code, or (b)
the av-17 wheel compiling its bundled FFmpeg differently from the av-16 wheel.
Clean split: build PyAV 17 from source against a self-built FFmpeg (same
version) and compare to the wheel — if source-built is fine, it's the wheel's
FFmpeg build; if still regressed, it's the Cython layer.
How the regression was established
The figures above were measured in a production deployment via jemalloc /
/proc/<pid>/smaps(large-anonymous-mapping counts), by changing only theavwheel pin and holding the rest of the stack constant. The decisive single-variable comparison isav==16.1.0vsav==17.0.1— identical bundled FFmpeg (8.0.1), ~270 vs ~1550 large mappings/process.I do not yet have a confirmed minimal standalone reproduction. In particular it is unverified whether the increase requires the decoded frames/arrays to be retained downstream (as the full pipeline does) to manifest, or whether N concurrent decoders alone is sufficient — earlier small-scale/single-decoder isolation did not show a difference. Happy to put together and confirm a minimal case if that would help triage.
Workaround
Pinning
av~=16.1.0(FFmpeg 8.0.1) avoids the regression. FFmpeg 8.0.1 still carries the fixes that motivated moving off 7.x — the HEVCoutput_corrupt/showallhandling (commitbc1a3bfd2) and CVE-2025-1816 (both shipped in FFmpeg 8.0) — so it is a clean pin for our use.Questions
to_ndarraypadded-frame /Packetbufferinit) cause decoded-frame or packet buffers to be retained longer than v16,
preventing decoder buffer-pool reuse?
pinning to v16?
unaddressed on
main?