Skip to content

[Bug] Starting N animations in one task costs O(N²) in the next frame (updateAnimationsPromises) #2264

Closed

Description

@Ethan-Icet
  • OS: Linux (Ubuntu, x86_64)
  • Browser: Chromium 153. The numbers below are headless; we first saw it headed, in a G6 app (7–8 s frames at 2,000 nodes).
  • Version: @antv/g@6.3.1, @antv/g-lite@2.7.0, @antv/g-canvas@2.2.0 (latest); seen through @antv/g6@5.1.1. The animation code is unchanged on master.

Starting many animations at once makes the next frame cost time quadratic in their number. With G6, an animated draw() over a few thousand elements produces single frames of several seconds. Related to #1731, which is about the same list growing (memory), but this one is CPU time per frame.

Steps To Reproduce

  1. Create N circles on a canvas.
  2. In one synchronous loop, call circle.animate(...) on each and read .finished, as G6 does for every element animation.
  3. Time the next frame.

Link to code example:

import { Canvas, Circle } from '@antv/g';
import { Renderer } from '@antv/g-canvas';

const N = 4000;
const canvas = new Canvas({ container: 'container', width: 800, height: 600, renderer: new Renderer() });
await canvas.ready;
const circles = Array.from({ length: N }, (_, i) =>
  canvas.appendChild(new Circle({ style: { cx: (i % 80) * 10, cy: Math.floor(i / 80) * 10, r: 3, fill: '#1890ff' } })),
);
await new Promise(requestAnimationFrame);

const t0 = performance.now();
const finished = circles.map(
  (circle) => circle.animate([{ transform: 'translate(0, 0)' }, { transform: 'translate(10, 10)' }], { duration: 300 }).finished,
);
// G's frame callback was queued first, so this one runs right after it, in the same frame.
requestAnimationFrame(() => console.log('next frame after', performance.now() - t0, 'ms'));

On my machine (headless Chromium, one run): 614 ms for N = 4,000 and 142 ms for N = 2,000. With the change proposed below: 62 ms and 40 ms.

The same through G6 5.1.1: N nodes, then graph.updateNodeData(...) on all of them and graph.draw() with animation: { duration: 300 }. The longest frame at 4,000 nodes is 1.77 s.

The current behavior

The cost of that frame grows like N². Below, the fitted exponent k is from t ≈ c·N^k over N = 250…4,000 (medians, headless Chromium 153):

Log–log plots against N. updatePromises calls: 6.3.1 reaches 32.1M at N=4000 (k 1.92); with the fix 268k (k 0.95). CPU time of the next frame: 746 ms (k 2.05) against 6.32 ms (k 1.01). Longest frame with G6 5.1.1: 1.77 s against 296 ms.
N 250 500 1,000 2,000 4,000
updatePromises() calls 155,244 560,494 2,114,994 8,169,994 32,075,994
CPU time of the next frame (frames driven by hand, no vsync) 2.6 ms 8.3 ms 35 ms 145 ms 746 ms

Starting the same elements' animations again makes it worse. Finished animations stay in animationsWithPromises (#1731), so the second round at N = 4,000 has a 1,163 ms frame against 439 ms for the first. G6 prunes that list itself after each draw (release() in runtime/animation.ts), so with G6 only the N² inside one draw remains.

The expected behavior

The cost of starting N animations, and of the frame after it, is linear in N.

Cause

In packages/g/src/plugins/web-animations-api/dom/:

  1. AnimationTimeline.play → Animation.play() (Animation.ts:297) → timeline.applyDirtiedAnimation(this) (Animation.ts:307) → tick(...) (AnimationTimeline.ts:85).
  2. tick resets this.ticking (AnimationTimeline.ts:189). A new animation is pending, so tick ends with if (this.ticking) this.requestAnimationFrame(() => {}) (AnimationTimeline.ts:221). Each started animation queues one more no-op frame callback. restart() (AnimationTimeline.ts:99-106), called by the currentTime setter, does the same when seeking finished animations.
  3. The timeline's requestAnimationFrame (AnimationTimeline.ts:176-182) wraps every callback: updateAnimationsPromises(); f(x); updateAnimationsPromises();.
  4. updateAnimationsPromises (AnimationTimeline.ts:119-125) runs updatePromises() on every animation in animationsWithPromises. The ready/finished getters add animations to that list (Animation.ts:81, :116).

So the next processRafCallbacks (AnimationTimeline.ts:153-163) runs N+1 wrappers, and each one goes over the list twice: about 2·N² updatePromises() calls.

The repeated calls do nothing:

  • All callbacks of one frame run synchronously inside a single browser rAF callback, so no user code or promise reaction can run between them.
  • The wrapped functions (no-ops and webAnimationsNextTick) don't change anything updatePromises() reads.
  • updatePromises() called again with no state change is a no-op.

Proposed fix

Do the bookkeeping once before and once after the frame's callbacks, instead of around each one (+5/−5 lines in AnimationTimeline.ts):

   private processRafCallbacks = (t: number) => {
     ...
     this.animations = this.tick(t, true, this.animations)[0];
+    this.updateAnimationsPromises();
     processing.forEach((entry) => {
       entry[1](t);
     });
+    this.updateAnimationsPromises();
     this.applyPendingEffects();
   };

   private requestAnimationFrame(f: (ts: number) => void) {
-    return this.rAF((x: number) => {
-      this.updateAnimationsPromises();
-      f(x);
-      this.updateAnimationsPromises();
-    });
+    return this.rAF(f);
   }
  • The promise updates still happen in the same frame, after tick, over the list in the same order, so ready/finished settle when they did before.
  • I checked this with tests that record, frame by frame, when ready/finished settle and when playState changes: play, pause/play/reverse/updatePlaybackRate, cancel/finish()/seeking, chained animations, destroyed targets. The traces were recorded on 6.3.1, and the fixed code reproduces them exactly.
  • The existing unit tests pass.
  • There's no slowdown for small N: 0.59 / 1.24 / 7.39 ms against 0.65 / 1.35 / 7.91 ms of CPU for N = 1 / 10 / 100.

Two alternatives I looked at:

  • Not queuing a no-op callback while one is pending: fixes starting animations, but not seeking finished ones, which queue through restart().
  • A Set or flag instead of indexOf for animationsWithPromises: that would break G6, which replaces that array from outside (release()).

PR with the change and the tests: #2265.

Activity

  1. Ethan-Icet commented on Oct 30, 2026

    @Ethan-Icet
    ContributorAuthor

    Fixed by #2265.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions