Skip to content

perf(g): update animation promises once per frame - #2265

Open
Ethan-Icet wants to merge 2 commits into
antvis:releasefrom
Ethan-Icet:fix/animation-promises-quadratic
Open

Ethan-Icet wants to merge 2 commits into
antvis:releasefrom
Ethan-Icet:fix/animation-promises-quadratic

Conversation

@Ethan-Icet

Copy link
Copy Markdown

🤔 This is a ...

  • New feature
  • Bug fix
  • Site / Document optimization
  • TypeScript definition update
  • Refactoring
  • Performance improvement
  • Code style optimization
  • Test Case
  • Branch merge
  • Other (about what?)

🔗 Related issue link

Fixes #2264. Related: #1731.

💡 Background and solution

Starting N animations in one task makes the next frame call Animation.updatePromises() about 2·N² times. G6 does exactly this on every animated draw().

  • Every started animation queues its own no-op frame callback. AnimationTimeline.tick does it, and so does restart() when finished animations are seeked.
  • The timeline wraps each frame callback in two updateAnimationsPromises() calls, and each call goes over every animation with promises.

At N = 4,000 that's 32 million calls and a 746 ms frame of CPU time; through G6, a 1.77 s frame.

Solution: run updateAnimationsPromises() once before and once after the frame's callbacks, instead of around each callback.

The calls this removes were no-ops:

  • The callbacks of one frame run synchronously inside one browser rAF callback, so nothing can run between them.
  • The wrapped functions (no-ops and webAnimationsNextTick) change nothing updatePromises() reads.
  • A repeated updatePromises() with no state change does nothing.

The remaining two calls run at the same points as before (after the frame's tick, before applyPendingEffects), over the list in the same order. So ready and finished settle in the same frames as before.

Log–log plots against N. updatePromises calls: 32.1M at N=4000 before (k 1.92), 268k after (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.

Headless Chromium 153; k is the fitted exponent of t ≈ c·N^k over N ≥ 250.

before after
updatePromises() calls, N = 4,000 32,075,994 267,994
CPU time of the next frame, N = 4,000 746 ms 6.3 ms
CPU time until all finished, N = 1 / 10 / 100 0.65 / 1.35 / 7.91 ms 0.59 / 1.24 / 7.39 ms
Longest frame, G6 5.1.1, 4,000 nodes 1,768 ms 296 ms

Tests: new __tests__/unit/dom/animation.promises.spec.ts.

  • Six trace tests. Frames are driven by hand through the canvas's requestAnimationFrame option. Each test records, per frame, when ready/finished resolve or reject, onfinish/oncancel, and playState. Scenarios: plain play; 50 animations started together; pause/play/reverse/updatePlaybackRate; cancel/finish()/seeking; animations started from promise callbacks; a destroyed target. The inline snapshots were recorded on the code before this change, and they pass unchanged after it.
  • Three scaling tests. They count updatePromises() calls in the frame after 1,000 and 2,000 animations are started, paused and seeked, or finished and seeked, in one task. Before: about 2,000,000 calls at n = 1,000 in each case. After: 5,000 / 11,000, 3,000 / 6,000 and 2,000 / 4,000.

pnpm test:unit passes: 281 tests, the 272 existing plus the 9 new.

Not changed: finished animations still stay in animationsWithPromises (#1731). Dropping them changes when a later read of finished gets a fresh promise, so it belongs in a separate change.

📝 Changelog

Language Changelog
🇺🇸 English Starting many animations at once no longer makes the next frame cost O(N²): animation promises are updated once per frame instead of once per started animation.
🇨🇳 Chinese 同时启动大量动画时,下一帧的开销不再是 O(N²):动画的 Promise 状态改为每帧更新一次,而不是每启动一个动画更新一次。

☑️ Self Check before Merge

  • Doc is updated/provided or not needed
  • Demo is updated/provided or not needed
  • TypeScript definition is updated/provided or not needed
  • Changelog is provided or not needed

🤖 Generated with Claude Code

Every animation started in a task queued its own no-op frame callback
(AnimationTimeline.tick, and restart() when seeking), and the timeline
wrapped each frame callback in two calls to updateAnimationsPromises,
which goes over every animation with promises. Starting N animations
in one task (G6 does this on every animated draw) therefore cost about
2·N² updatePromises calls in the next frame: 32 million for N = 4000.

Run updateAnimationsPromises once before and once after the frame's
callbacks instead. Nothing can change promise state between two
callbacks of the same frame, so the extra calls did nothing.

Tests: six traces of when ready/finished settle relative to frames and
playState, recorded on 6.3.1 and unchanged; three tests that the
bookkeeping stays linear when starting, or seeking paused or finished,
animations in one task (6.3.1: ~2 million calls for n = 1000).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 50d7d7b

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@antv/g Patch
@antv/react-g Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@hustcc

hustcc commented Oct 10, 2026

Copy link
Copy Markdown
Member

@Ethan-Icet Does this only affect animation performance? Will it improve first-screen rendering performance?

Comment thread __tests__/unit/dom/animation.promises.spec.ts Fixed
The helper read `animation.finished` only for its side effect (it puts
the animation on the timeline's promise list), which CodeQL reports as
an expression with no effect. Assert on it instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ethan-Icet added a commit to Ethan-Icet/G that referenced this pull request Oct 10, 2026
@Ethan-Icet

Copy link
Copy Markdown
Author

Hello @hustcc!

It only changes the animation timeline's bookkeeping, so drawing itself is unchanged: a first screen without animations renders exactly as before. But in G6 the first screen is animated by default, so it benefits too. The base theme gives nodes and edges enter: 'fade', and animation is on unless set to false, so graph.render() starts one animation per node and edge in one task. That's the O(N²) case.

I measured the impact with a new benchmark:
G6 5.1.1 first render with default options (headless Chromium 153, median of 3 runs):

6.3.1 this PR
2,000 nodes + 1,955 edges: longest frame 5.37 s 0.59 s
2,000 nodes + 1,955 edges: render() resolves 6.09 s 1.71 s
4,000 nodes + 3,937 edges: longest frame 29.9 s 1.09 s
4,000 nodes + 3,937 edges: render() resolves 31.2 s 2.35 s
4,000 nodes, no edges: longest frame 5.49 s 0.54 s
4,000 nodes, animation: false (control): longest frame 547 ms 551 ms

With this PR, the 4,000-node first frame costs the same as with animation: false (544 against 547 ms). The time that's left is G/G6 drawing, not animation bookkeeping.

Frame timeline of a G6 first render, 2,000 nodes and 1,955 edges. 6.3.1: a 615 ms frame, then a 5.37 s frame; render() resolves at 6.08 s. This PR: a 610 ms frame, then about a dozen 70–90 ms frames of the fade-in; render() resolves at 1.71 s.

Side by side, same 2,000 nodes and edges, recorded in headless Chromium. With 6.3.1 the canvas stays blank for about 6 s and then the graph appears all at once: the 1 s fade-in is over before the page gets another frame. With this PR it fades in and is done at about 1.7 s.

G6 first render, 6.3.1 on the left, this PR on the right: the left canvas stays blank until about 6 s, the right one fades in by about 1.7 s.

I also pushed a small follow-up for the CodeQL note (the test reads finished on purpose, so it's now an assertion).

@hustcc

hustcc commented Oct 10, 2026

Copy link
Copy Markdown
Member

@Ethan-Icet Thanks for the PR. Looking at the data, it's a very good and professional PR.

We'll use this patch to run regression validation on all cases across G2, G6, and S2 as soon as possible to make sure there are no major issues before merging. If you're interested, you're also welcome to help with the regression testing.

Also, we're very much looking forward to further optimizing G6's performance 🤗

@Ethan-Icet

Copy link
Copy Markdown
Author

@hustcc Thank you for the quick response, it's much appreciated!

I'm working on a side project in my free time (a chess visualization tool built around graphs). I'm not a great UI/UX developer, so it's mostly a vibe-coded toy where I burn my unused weekly Claude quota. It involves benchmarking on quite big graphs, which is how I found this bug.

It's not in a shareable state yet. I'm still working on the internals, so the visuals are a bit crude. I'm developing two parallel branches, one with G6 and one with PixiJS, and I try to keep their renders similar. G6 is currently a bit behind in my first tests and I'd like to narrow that gap, so if I run into anything else along the way, I'll share it 🙂

Thanks for the offer, but I'll leave the regression testing in your hands: my time for this is limited, and you know G2, G6 and S2 much better than I do. Thanks again for reviewing this so quickly!

@wang1212
wang1212 changed the base branch from master to release October 14, 2026 11:41

This branch has not been deployed

No deployments
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.

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

3 participants