Skip to content

MLP211: large per-frame allocation inside a per-frame callback

qual reports MLP211 when a call inside a per-frame callback provably allocates a large buffer on every invocation, multiplying allocator and memory-bandwidth cost by the frame count.

  • Default severity: info
  • Minimum confidence: medium
  • Implementation phase: 3
  • Fix: none
  • Superseded by MLP220 on the same span (declared by MLP220, DESIGN §7.3)

Provable arms (DESIGN §7.3, gates cited in evidence):

  • a recognized ndarray construction (numpy.zeros / ones / empty / full) whose literal shape proves at least 64 KiB per invocation (per-frame-allocation-gate). Literal integer lengths and literal tuple / list shapes (np.zeros((400, 400))) are analyzable; the element size comes from the literal dtype or NumPy's float64 default. Non-literal shapes and unknown dtypes stay Unknown and silent — never guessed. Small coordinate vectors are excluded by the byte gate itself.
  • a hot call whose resolved target object has a confirmed point count of at least 1024 (large-points-gate); calls already owned by the receiver-sized rules (MLP202 / MLP203 / MLP224) are excluded so one call yields one diagnostic.

The catalog's third arm — any allocation inside a loop over the family within a hot callback — is out of scope: hot-context provenance records entry points and helper calls, not enclosing loops inside callback bodies, so "family loop" containment is not provable from the current fact layers.

Proven execution (liveness)

A hot context only proves the call is reachable from a per-frame entry point. The diagnostic additionally requires at least one play where the callback provably executes per frame (DESIGN 3.2/3.3):

  • the registration is active at the play (registered on every path and not removed before it),
  • the host is in the scene family there,
  • the host is not suspended: Animation.begin() suspends the animated mobject's updaters — recursively over its family — for the whole play (animation.py), unless the animation was constructed with suspend_mobject_updating=False,
  • the play actually runs callbacks: a play(...), or a wait that is provably dynamic (scene.py should_update_mobjects). A one-argument updater leaves the default wait() a static frame.

self.add(label); self.play(FadeIn(label), run_time=2); self.wait(3) therefore stays silent: the only play suspends the updater and the wait is static, so the callback runs about once — never once per frame. Evidence lists the counted plays (execution) so the claim is auditable. A callback that provably never runs per frame is silent; when execution is proven the message states the per-frame claim; when it is only possible (maybe plays, or an entry the lifecycle facts cannot resolve) the rule still fires at its medium catalog confidence with qualitative wording — the allocation size itself is statically proven either way.

Wrong

class Demo(Scene):
    def construct(self):
        anchor = Circle()
        sq = Square()
        sq.add_updater(lambda m: m.move_to(np.zeros((400, 400))[0]))  # 1.2 MiB per frame
        self.add(sq, anchor)
        self.play(FadeIn(anchor), run_time=2)  # sq stays live: its updater runs
class Demo(Scene):
    def construct(self):
        buffer = np.zeros((400, 400))  # allocated once
        anchor = Circle()
        sq = Square()
        sq.add_updater(lambda m: buffer.fill(0.0))  # reused in place
        self.add(sq, anchor)
        self.play(FadeIn(anchor), run_time=2)