Skip to content

Add support for encoding partial movie files in parallel - #4899

Merged
behackl merged 17 commits into
ManimCommunity:mainfrom
pjfo:perf/parallel-partial-movie-encoding
Aug 8, 2026
Merged

Add support for encoding partial movie files in parallel#4899
behackl merged 17 commits into
ManimCommunity:mainfrom
pjfo:perf/parallel-partial-movie-encoding

Conversation

@pjfo

@pjfo pjfo commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Overview: What does this pull request change

Adds a new ability to parallelise the encoding after the render pass to speed up the encoding output of the scene, with a new max_inflight_encoders config option (defaulting to 1) bounding how many partial movie files may be encoding while the scene continues rendering.

The default value of 1 keeps the existing serial behaviour: each animation's file is fully encoded and closed before the next animation starts. Values greater than 1 overlap encoding with rendering. In testing for the performance testing a value of 4 was used.

Output is byte-identical in both modes for mp4, mov, transparent mov, gif, and PNG. Output for webm output has bytes differing run-to-run either way because the Matroska muxer generates random TrackUIDs.

Each partial movie file is now encoded by a self-contained _PartialMovieEncodeJob owning its container, stream, bounded frame queue, and worker thread.

  • Closing a stream seals the job and defers its join
  • Jobs are joined oldest-first once the cap is reached, when a cache lookup or a new stream targets the same path, and all are drained at scene end and on the interactive rerun path (which previously replaced the file writer while jobs could still be writing).
  • Worker failures are captured first-exception-wins across the encode, flush, and close stages and re-raised at join
  • A failed job deletes its truncated partial file so a later run cannot cache-hit it.
  • The former SceneFileWriter internals (listen_and_write, encode_and_write_frame, queue, writer_thread, video_container, video_stream) are removed as part of this restructuring (as equivalents now live in the _PartialMovieEncodeJob class).

Added tests covering failure propagation and its precedence, removal of failed partial files, the encoder cap and FIFO join order, same-path guards, cache behavior across renders, success logging, and encoder-thread cleanup.

Added the new config flag to the list of all config options in docs/source/guides/configuration.rst (but noted that some other new flags [format, media_embed, save_sections, seed, disable_caching_warning, and zero_pad] are missing from this manual list)

Motivation and Explanation: Why and how do your changes improve the library?

In the library as it stands, the default serial approach where rendering and encoding are performed serially, leaves performance on the table for those who have the hardware to enable parallelism.

During testing the biggest speed improvement is in encoder heavy scenes, where caching is not used. Using an encode-heavy 30-animation benchmark the wall-clock time was reduced from 9.3 s to 4.1 s (-56 %).

In a real world render heavy video generation, consisting of 8 scenes, some with many mobjects, from an as yet unreleased personal project, rendered serially, the wall clock improvement was more modest (~8% - 11% reduction depending on whether or not cache was utilised).

The full video render performance results are:

Leg Mean [s] Min [s] Max [s]
serial (v0.20.1, cache on) 2500.610 ± 20.606 2486.039 2515.180
serial, --disable_caching 1457.823 ± 1.960 1456.437 1459.209
parallel branch, cap 4, cache on 2297.244 ± 0.367 2296.984 2297.503
parallel branch, cap 4, --disable_caching 1299.374 ± 0.252 1299.195 1299.552
  • For each run, the media_dir was wiped before rendering started, so there was an apples-to-apples comparison for all four legs.
  • Pre change runtimes:
    - cached: ~41.5 mins
    - uncached: ~ 24.5 mins
  • Parallel vs Pre-change: -8.1% runtime reduction (cached), -10.9% runtime reduction (no-cache)
    - ~3.5 min saved per full-video pass when caching was on
    - ~2.5 mins saved when caching was off
  • The parallel run legs are more runtime stable (variance in parallel runtimes less than 0.4 s compared to ~20.6s for the current cached and ~2s for the current uncached).

For the encoder heavy performance benchtest this class was used:

class Bench30(Scene):
    def construct(self):
        for i in range(16):
            square = Square(0.35)
            square.shift(LEFT * 3.5 + RIGHT * 0.45 * i + UP * 1.5)
            square.set_fill(BLUE, opacity=0.6)
            self.play(FadeIn(square), run_time=0.2)
        for i in range(15):
            circle = Circle(0.18)
            circle.shift(LEFT * 3.5 + RIGHT * 0.45 * i + DOWN * 1.5)
            circle.set_fill(RED, opacity=0.6)
            self.play(GrowFromCenter(circle), run_time=0.2)
        self.clear()
        marker_a = Square(0.5)
        marker_a.set_fill(GREEN, opacity=0.6)
        self.play(FadeIn(marker_a), run_time=0.2)
        self.clear()
        marker_b = Square(0.5)
        marker_b.set_fill(GREEN, opacity=0.6)
        self.play(FadeIn(marker_b), run_time=0.2)

Links to added or changed documentation pages

Expected:
Gen Index

Configuration

ManimConfig

SceneFileWriter

manim._config.utils source

manim.scene.scene source

manim.scene.scene_file_writer source

Unexpected (likely due to commits to main after 0.20.1):
Camera

Images

Further Information and Comments

Reviewer Checklist

  • The PR title is descriptive enough for the changelog, and the PR is labeled correctly
  • If applicable: newly added non-private functions and classes have a docstring including a short summary and a PARAMETERS section
  • If applicable: newly added functions and classes are tested

pjfo added 2 commits July 26, 2026 12:15
… files concurrently with rendering

Add a max_inflight_encoders config option (default 1) bounding how many partial movie files may be encoding while the scene continues rendering. The default keeps the existing serial behavior: each animation's file is fully encoded and closed before the next animation starts. Values > 1 overlap encoding with rendering; 4 is a good value on typical hardware and cuts wall-clock time on an **encode-heavy** 30-animation benchmark from 9.3 s to 4.1 s (-56 %). Output is byte-identical in both modes for mp4, mov, transparent mov, gif, and PNG; webm bytes differ run-to-run either way because the Matroska muxer generates random TrackUIDs.

Each partial movie file is now encoded by a self-contained _PartialMovieEncodeJob owning its container, stream, bounded frame queue, and worker thread.
 - Closing a stream seals the job and defers its join
 - Jobs are joined oldest-first once the cap is reached, when a cache lookup or a new stream targets the same path, and all are drained at scene end and on the interactive rerun path (which previously replaced the file writer while jobs could still be writing).
 - Worker failures are captured first-exception-wins across the encode, flush, and close stages and re-raised at join
 - A failed job deletes its truncated partial file so a later run cannot cache-hit it.
 - The former SceneFileWriter internals (listen_and_write, encode_and_write_frame, queue, writer_thread, video_container, video_stream) are removed as part of this restructuring (as equivalents now live in the _PartialMovieEncodeJob class).

Added tests covering failure propagation and its precedence, removal of failed partial files, the encoder cap and FIFO join order, same-path guards, cache behavior across renders, success logging, and encoder-thread cleanup.
Comment thread manim/scene/scene_file_writer.py Dismissed
Comment thread manim/scene/scene_file_writer.py Dismissed
Comment thread manim/scene/scene_file_writer.py Dismissed
Comment thread manim/scene/scene_file_writer.py Dismissed
@pjfo

pjfo commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

Note, the python 3.13 builds are failing precisely because of the error identified in this other PR I submitted a couple of days ago): #4896

The basics of the issue are:

  • _Memoizer._already_processed (manim/utils/hashing.py:50,153-155) is one set of raw ints mixing hash() values and id() addresses, reset once per play.
  • When a rate function (e.g. smooth) is serialized, its closure-vars dict is built as a temporary (hashing.py:203-204), id-memoized, then freed. A later allocation in the same play can land on the same heap address, falsely match the stale id in the set, and serialize as the "AP" placeholder instead of its real content.
  • I did a diff of these two runs and it shows: rate_func.nonlocals is the full dict in run 1 and the string "AP" in run 2.
  • Whether a given play was rendered or skipped-via-cache changes the allocator state, so run 2 (all cache hits) computes different hashes than run 1 for every other play → systematic cache misses. On 3.13 specifically, allocation patterns make this deterministic rather than the rare flake the test's own comment already warns about.

I validated this by running a python 3.13, with the commit prior to this PR, I ran the two runs for the test, and the second render, instead of cache hitting all 32 plays, it re-renders 15 of them, which triggers the assert in this new test. The diff is attached.

The particular scene I ran for both runs is this

from manim import FadeIn, Scene, Square


class ParallelEncodingCacheScene(Scene):
    def construct(self):
        for index in range(30):
            square = Square(side_length=0.2 + index / 100)
            self.play(FadeIn(square), run_time=0.1)
            self.clear()

        self.play(FadeIn(Square(side_length=0.75)), run_time=0.1)
        self.clear()
        self.play(FadeIn(Square(side_length=0.75)), run_time=0.1)

Would you prefer I merge that PR into this one? or should this one wait on the other PR to complete?

(The .txt file wouldn't upload, so instead the following image is an extract from the diff file [taken from Notepad++])
image

@pjfo

pjfo commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

bench is rerunning on this, and I want to check feasibility of adding a command line flag for the new config option

@behackl

behackl commented Aug 4, 2026

Copy link
Copy Markdown
Member

Sounds like a good idea! I'd go for --max-inflight-encoders to match the config setting (and no underscores; migrating the current underscore based CLI options is on my medium term list of improvements to make -- eg --disable_caching --> --disable-caching)

Expose the max_inflight_encoders config option (the cap on how many partial movie files may be encoded concurrently while the scene continues rendering) as a render CLI flag, so it can be set per-run without a config file. The flag validates with IntRange(min=1) and defaults to None so config-file values still apply when it is omitted; tests cover flag-beats-config precedence, config preservation when absent, and rejection of non-positive values.
@pjfo

pjfo commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

@behackl bench ran, and cli flag added.

The updated full video bench results are summarised below:

Command Mean [s] Min [s] Max [s] vs serial counterpart
serial (cap 1), cached 1790.631 ± 13.909 1780.796 1800.466 -
serial (cap 1), --disable_caching 1633.942 ± 14.556 1623.650 1644.234 -
parallel (cap 4), cached 1578.303 ± 28.882 1557.880 1598.726 -11.89%
parallel (cap 4), --disable_caching 1407.699 ± 5.058 1404.123 1411.276 -13.85%

After updating for all the latest performance related PRs it now means that running this parallel cached is faster than the default serialised uncached result, and the updated performance improvement when parallelising the encoding is between 11.5% & 14%.

The behaviour of the feature is:

  • If the CLI flag is used then that overrides a locally configured value in manim.cfg
  • If the CLI flag is not used then it will use any value configured in manim.cfg
  • If neither is in place, it defaults to 1, which replicates the pre PR behaviour.

@behackl behackl changed the title Perf/parallel partial movie encoding Add support for encoding partial movie files in parallel Aug 7, 2026
@behackl

behackl commented Aug 7, 2026

Copy link
Copy Markdown
Member

I’ve pushed a few follow-up improvements from review. Serial encoding now preserves the previous unbounded frame queue when max_inflight_encoders is 1, keeping the default rendering flow as close as possible to existing behaviour.

Parallel encoding uses a configurable bounded queue through the new encoder_queue_size config option / --encoder-queue-size CLI flag, defaulting to 8 pending frame buffers per encoder (as discussed on Discord).

I also strengthened failure handling: if an early job join fails, all remaining in-flight jobs are drained while preserving the original exception, and a failure to delete an incomplete partial file no longer masks the encoder error. Finally, the integration test now actually runs with parallel encoding enabled at cap 3, uses a smaller scene, verifies exact cache reuse, and checks that no encoder threads survive the render.

I'll read over everything once more tomorrow, but this is looking pretty good to me already; thanks for your efforts!

Comment thread manim/scene/scene_file_writer.py Dismissed
@pjfo

pjfo commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Pushed the two fixes mentioned on discord, plus the tests as three separate commits:

d8e8a38: fail-fast on encoder failure. write_frame now checks whether the current job's worker has captured an exception; if so it seals the job, detaches it, and joins it, so the failure surfaces at the first write after it was captured (usually the animation that caused it) instead of at a join site several animations later. join() already handles unlinking the truncated partial and re-raising.

06cdb67: teardown for renders that abort mid-play. New SceneFileWriter.abort_encode_jobs(), called from Scene.render() when any exception (including KeyboardInterrupt) escapes, and from the rerun handler. Previously a mid-play exception (e.g. from a user updater) left the current encode job unsealed, and its user process worker stayed blocked on queue.get(), then the interpreter then hangs at exit. That hang is the same shape on main with the serial encode thread, so this fixes a pre-existing issue too. The aborted job's partial file is deleted unconditionally: it's structurally valid but truncated, so it would cache-hit on a later run. The rerun path still re-raises encoder failures (a rerun shouldn't continue past corrupt output); the exception path logs them so the primary exception stays visible.

I reproduced the hang before the fix, a scene whose updater raises during the second play prints its traceback and then hangs until killed. After the fix it exits nonzero promptly. That's now a subprocess test with a timeout (test_mid_play_exception_does_not_hang_process). One subtlety baked into the test scene: the updater only raises when dt > 0, because compile_animation_data pre-runs updaters with dt=0 before the partial movie stream opens.

aa73ed3: the tests mentioned above: cap-1 vs cap-4 byte-identity on the same scene, an end-to-end render at cap 4 (restoring end to end coverage of the raised cap now that the cache test runs at cap 3), plus the smaller gaps (freeze-frame num_frames repetition, is_already_cached return values, guard paths).

I also re-ran my 32-partial bench scene before/after: partial-file manifests byte-identical for every format except webm (which differs run-to-run on my box even at identical code).

@behackl behackl left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've reviewed your changes, everything looks fine to me now. Good job, thank you very much!

In order to have the rendering deep dive guide reflect the most recent changes I've pushed one more commit to update it accordingly.

Happy to get this merged once the pipeline passes! 🚀

@behackl
behackl merged commit ba50f81 into ManimCommunity:main Aug 8, 2026
17 checks passed
@behackl behackl added highlight For contributions that should be highlighted explicitly in the next changelog new feature Enhancement specifically adding a new feature (feature request should be used for issues instead) labels Aug 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

highlight For contributions that should be highlighted explicitly in the next changelog new feature Enhancement specifically adding a new feature (feature request should be used for issues instead) performance

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants