Skip to content

Speed up Cairo renderer by 2.2x on animation-heavy scenes#4695

Open
HamdiBarkous wants to merge 8 commits intoManimCommunity:mainfrom
HamdiBarkous:performance-optimizations
Open

Speed up Cairo renderer by 2.2x on animation-heavy scenes#4695
HamdiBarkous wants to merge 8 commits intoManimCommunity:mainfrom
HamdiBarkous:performance-optimizations

Conversation

@HamdiBarkous
Copy link
Copy Markdown

Summary

Two small, behavior-preserving optimizations to the Cairo renderer hot
path, plus a real-world benchmark to measure them.

Optimizations

1. Vectorized path building in set_cairo_context_path
Replace Python generators (gen_subpaths_from_points_2d,
gen_cubic_bezier_tuples_from_points) and per-curve tuple
unpacking with numpy-based subpath splitting and direct flat-array
indexing. Same Cairo API calls, same output, just less Python
overhead per bezier segment.

2. Eliminate redundant numpy copies

  • Camera.reset() / set_frame_to_background(): replace
    set_pixel_array() -> convert_pixel_array() -> np.array() (copy) -> slice assignment (second copy) with a single np.copyto().
  • CairoRenderer.get_frame(): replace np.array() with .copy()
    to skip dtype inference on an already-typed array.

Both changes are purely Python-side and leave rendering output
pixel-identical.

Benchmark

Adds benchmarks/bench_lissajous.py, a heavy real-world workload
(adapted from Abhijith Muthyala's Lissajous project) with a grid
of circles + updaters tracing Lissajous curves. Stresses the per-frame
render path far more than static gallery scenes.

Results

Measured with bench_lissajous.py on the same machine, 1920x1080 @ 60fps:

Scene Baseline Optimized Speedup
RadiusOne 66,674 ms 49,758 ms 1.34x
RadiusHalf 1,883,353 ms (31m) 799,578 ms (13m) 2.36x
RadiusThreeFourths 319,071 ms 171,342 ms 1.86x
TOTAL 37m 49s 17m 00s 2.22x

A ~20-minute wall-clock reduction on a single scene render.

Test plan

  • Existing test suite passes (graphical reference frames unchanged)
  • python benchmarks/bench_lissajous.py runs to completion
  • Rendered output visually identical to baseline

HamdiBarkous and others added 5 commits April 9, 2026 02:05
…flat array indexing

Replace Python generators and tuple unpacking with numpy-based subpath
splitting and direct flat-array indexing for bezier point lookups.
Same Cairo calls, same output, ~2-7x faster path building.

- Replace gen_subpaths_from_points_2d generator with vectorized numpy
  boundary detection using np.arange + boolean masking
- Replace gen_cubic_bezier_tuples_from_points generator with direct
  integer-range iteration over pre-flattened xy array
- Eliminate per-curve numpy slice creation (*p[:2] splat)
- Cache method references (ctx.curve_to → local) to avoid attribute
  lookup per call

Benchmarks (1920x1080 @ 60fps):
- set_path: 2-7x faster across scene types
- Overall: up to 1.5x faster on shape/text-heavy scenes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- camera.reset(): Replace set_pixel_array() → convert_pixel_array() →
  np.array() (copy) → slice assignment (second copy) with a single
  np.copyto() call. Removes one full-frame copy per frame.
- set_frame_to_background(): Same optimization for static frame restore.
- renderer.get_frame(): Replace np.array() with .copy() — avoids
  dtype inference overhead on an already-typed array.

Benchmarks (1920x1080 @ 60fps):
- camera_reset: 3-10x faster (e.g. 390ms → 120ms on AnimatedTransforms)
- Overall: ~2x faster across scene types when combined with set_path opt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds benchmarks/bench_lissajous.py — a heavy real-world animation
workload with grid-of-circles updaters tracing Lissajous curves.
Stresses the per-frame render path far more than static gallery
scenes, making rendering optimizations visible end-to-end.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
self.background is typed as PixelArray | None (from __init__ param)
but is guaranteed non-None after init_background() runs during
construction. Add an assert to satisfy mypy.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@behackl
Copy link
Copy Markdown
Member

behackl commented Apr 15, 2026

This looks quite promising -- but the claim of pixel-identical ouput appears to be wrong, given our pipelines. We can accept some variation in the tests, but we would need to understand (and verify individually) why the output has changed and that it still is practically correct.

HamdiBarkous and others added 2 commits April 15, 2026 12:03
When a VMobject had exactly nppcc points (one cubic curve, e.g. Line),
np.arange(nppcc, n_pts, nppcc) returned an empty array and the function
exited before drawing. The original code handled this via split_indices
of [0, n_pts], yielding one subpath of all 4 points.

Handle the empty-boundary case explicitly as a single subpath.

Verified pixel-identical vs main across 12 scenes (Line, Dot, Square,
Circle, Arrow, Text, MathTex, Polyline, DashedLine, OpenPath, mixed,
animated).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@HamdiBarkous
Copy link
Copy Markdown
Author

@behackl Thanks for catching this. Fixed in the latest commit. Verified pixel-identical against main across 12 scenes (Line, Dot, Square, Circle, Arrow, Text, MathTex, Polyline, DashedLine, open polyline, mixed, animated). Can you please confirm?

@behackl
Copy link
Copy Markdown
Member

behackl commented Apr 15, 2026

Looking much better, indeed -- I'll make sure to review this in more detail as soon as I can; very impressive!

Our CI says that the documentation build and specifically the rendering of the VMobjectDemo example currently fails, this is the relevant snippet from the stack trace:

File "<string>", line 29, in <module>
[146](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--146)	      File "/home/docs/checkouts/readthedocs.org/user_builds/manimce/envs/4695/lib/python3.13/site-packages/manim/scene/scene.py", line 270, in render
[147](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--147)	        self.renderer.scene_finished(self)
[148](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--148)	        ~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^
[149](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--149)	      File "/home/docs/checkouts/readthedocs.org/user_builds/manimce/envs/4695/lib/python3.13/site-packages/manim/renderer/cairo_renderer.py", line 278, in scene_finished
[150](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--150)	        self.update_frame(scene)
[151](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--151)	        ~~~~~~~~~~~~~~~~~^^^^^^^
[152](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--152)	      File "/home/docs/checkouts/readthedocs.org/user_builds/manimce/envs/4695/lib/python3.13/site-packages/manim/renderer/cairo_renderer.py", line 159, in update_frame
[153](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--153)	        self.camera.capture_mobjects(mobjects, **kwargs)
[154](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--154)	        ~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^
[155](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--155)	      File "/home/docs/checkouts/readthedocs.org/user_builds/manimce/envs/4695/lib/python3.13/site-packages/manim/camera/camera.py", line 566, in capture_mobjects
[156](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--156)	        self.display_funcs[group_type](list(group), self.pixel_array)
[157](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--157)	        ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
[158](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--158)	      File "/home/docs/checkouts/readthedocs.org/user_builds/manimce/envs/4695/lib/python3.13/site-packages/manim/camera/camera.py", line 665, in display_multiple_vectorized_mobjects
[159](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--159)	        self.display_multiple_non_background_colored_vmobjects(
[160](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--160)	        ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^
[161](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--161)	            batch,
[162](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--162)	            ^^^^^^
[163](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--163)	            pixel_array,
[164](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--164)	            ^^^^^^^^^^^^
[165](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--165)	        )
[166](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--166)	        ^
[167](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--167)	      File "/home/docs/checkouts/readthedocs.org/user_builds/manimce/envs/4695/lib/python3.13/site-packages/manim/camera/camera.py", line 685, in display_multiple_non_background_colored_vmobjects
[168](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--168)	        self.display_vectorized(vmobject, ctx)
[169](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--169)	        ~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^
[170](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--170)	      File "/home/docs/checkouts/readthedocs.org/user_builds/manimce/envs/4695/lib/python3.13/site-packages/manim/camera/camera.py", line 702, in display_vectorized
[171](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--171)	        self.set_cairo_context_path(ctx, vmobject)
[172](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--172)	        ~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^
[173](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--173)	      File "/home/docs/checkouts/readthedocs.org/user_builds/manimce/envs/4695/lib/python3.13/site-packages/manim/camera/camera.py", line 747, in set_cairo_context_path
[174](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--174)	        ends = points[boundary_indices - 1, :2]  # end of previous curve
[175](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--175)	               ~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^
[176](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--176)	    TypeError: list indices must be integers or slices, not tuple

Could you take a look at this?

@chopan050
Copy link
Copy Markdown
Member

The docbuild issue happens because of this piece of code in the VMobjectDemo example in https://docs.manim.community/en/stable/guides/deep_dive.html:

        my_vmobject = VMobject(color=GREEN)
        my_vmobject.points = [
            np.array([-2, -1, 0]),  # start of first curve
            np.array([-3, 1, 0]),
            np.array([0, 3, 0]),
            np.array([1, 3, 0]),  # end of first curve
            np.array([1, 3, 0]),  # start of second curve
            np.array([0, 1, 0]),
            np.array([4, 3, 0]),
            np.array([4, -2, 0]),  # end of second curve
        ]

When doing this, my_vmobject.points is not a 2D NumPy array of points itself, but rather a list of 1D NumPy arrays representing individual points. This causes the error TypeError: list indices must be integers or slices, not tuple: you're trying to multi-index a list which is not supported: doing arr[1, 2] is equivalent to doing arr[(1, 2)], i.e. you're indexing arr with the tuple (1, 2), which explains why the error says you're trying to index a list with a tuple.

You can fix this by changing the list above to a 2D NumPy array, but IMO a cleaner solution is to use the existing VMobject.set_points() method:

        my_vmobject = VMobject(color=GREEN).set_points(
            [
                [-2, -1, 0],  # start of first curve
                [-3, 1, 0],
                [0, 3, 0],
                [1, 3, 0],  # end of first curve
                [1, 3, 0],  # start of second curve
                [0, 1, 0],
                [4, 3, 0],
                [4, -2, 0],  # end of second curve
            ]
        )

The vectorized path builder uses numpy fancy indexing (e.g.
points[boundary_indices - 1, :2]), which fails when vmobject.points
is a plain Python list. The documented VMobjectDemo example sets
points this way, which broke the docs build.

np.asarray the points array once on entry; it's a no-op when the
input is already an ndarray.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@HamdiBarkous
Copy link
Copy Markdown
Author

The docs' VMobjectDemo sets vmobject.points to a Python list, which broke my numpy fancy indexing. Fixed by normalizing to ndarray on entry (no-op when it already is one, which is the default).

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.

3 participants