Skip to content

Reduce allocations on AnimationManager#35612

Open
pictos wants to merge 1 commit into
dotnet:mainfrom
pictos:pj/reduce-animation-allocations
Open

Reduce allocations on AnimationManager#35612
pictos wants to merge 1 commit into
dotnet:mainfrom
pictos:pj/reduce-animation-allocations

Conversation

@pictos
Copy link
Copy Markdown
Contributor

@pictos pictos commented May 25, 2026

Description of Change

In order to provide a good reference, I repeated the flow twice and collect the results. With the main, we do have this memory footprint:

image

Those System.Action and DisplayClass allocations are caused by the usage of ForEach method, they fancy but they come with a cost. Since they're inside a loop the compiler couldn't cache them (my theory) which causes a lot of allocations.

To remove all those allocations I rewrite the code using the boring foreach loop. And you can see the memory footprint below:

image

Where the only allocation now is related with the List that is created inside the method and I wasn't able to remove on this PR. I'll let it for the future me or contributors.

Issues Fixed

Fixes #35654

From IA review:

image

The 19 → 26 difference is just sampling noise. GCAllocationTick fires every ~100 KB of allocations, not on every individual allocation. The improved run had 2,017 sampled events vs 2,000 in the baseline (17 more), which means a slightly different sampling window was captured. That's enough to shift counts on any type by a few ticks.

What actually changed:

✅ Action (30 → 0): gone — the ForEach(delegate) no longer creates a delegate on each call
✅ <>c__DisplayClass20_0 (? → 0): the compiler-generated closure class is gone too
➡️ Animation[] (19 → 26): same root cause (new List backing array), just sampling variance

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 25, 2026

🚀 Dogfood this PR with:

⚠️ WARNING: Do not do this without first carefully reviewing the code of this PR to satisfy yourself it is safe.

curl -fsSL https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 35612

Or

  • Run remotely in PowerShell:
iex "& { $(irm https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1) } 35612"

@dotnet-policy-service dotnet-policy-service Bot added the community ✨ Community Contribution label May 25, 2026
@dotnet-policy-service
Copy link
Copy Markdown
Contributor

Hey there @@pictos! Thank you so much for your PR! Someone from the team will get assigned to your PR shortly and we'll get it reviewed.

@kubaflo
Copy link
Copy Markdown
Contributor

kubaflo commented May 27, 2026

/review -b feature/refactor-copilot-yml

Copy link
Copy Markdown
Collaborator

@MauiBot MauiBot left a comment

Choose a reason for hiding this comment

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

Expert Review — 3 findings

See inline comments for details.

Comment thread src/Core/src/Animations/AnimationManager.cs Outdated
Comment thread src/Core/src/Animations/AnimationManager.cs Outdated
Comment thread src/Core/src/Animations/AnimationManager.cs Outdated
@MauiBot MauiBot added s/agent-fix-win AI found a better alternative fix than the PR s/agent-reviewed PR was reviewed by AI agent workflow (full 4-phase review) labels May 27, 2026
@pictos pictos force-pushed the pj/reduce-animation-allocations branch from 300a2cb to 7418c16 Compare May 27, 2026 19:27
@kubaflo
Copy link
Copy Markdown
Contributor

kubaflo commented May 28, 2026

/review -b feature/refactor-copilot-yml

@@ -130,7 +136,13 @@ public void Dispose()
void ForceFinishAnimations()
{
var animations = new List<Animation>(_animations);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If the _animations were an immutable object:

-readonly List<Animation> _animations = new();
+ImmutableArray<Animation> _animations = [];

the allocations would move from "iterations" to "modifications" (adding/removing elements). Not sure if possible, perhaps it's worth a try.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks for your suggestion, I'll not do it on this PR. The main goal was to remove the lambdas allocations. Removing the List allocation would be a bonus, but looks like it's needed, all my attempts to remove resulted in IndexOutOfRangeExceptions. To improve would need a deeper look inside the animation engine.

Copy link
Copy Markdown
Collaborator

@MauiBot MauiBot left a comment

Choose a reason for hiding this comment

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

🤖 Automated review — alternative fix proposed

The expert-reviewer evaluation compared the PR fix against #1 automatically generated candidates and selected try-fix-1 as the strongest fix.

Why: try-fix-1 won because it passed the runnable Core regression suite, preserves the PR's callback-mutation-safe snapshot semantics, and removes recurring snapshot-list object allocations with less complexity than the ArrayPool candidate. The PR-based candidates were ranked lower because their gate validation was skipped and they retain the hot-path snapshot allocation.

Please consider applying the candidate diff below (or use it as guidance). Once you push an update, this workflow will re-trigger and re-evaluate.

Candidate diff (`try-fix-1`)
diff --git a/src/Core/src/Animations/AnimationManager.cs b/src/Core/src/Animations/AnimationManager.cs
index 2de13b3..98469a8 100644
--- a/src/Core/src/Animations/AnimationManager.cs
+++ b/src/Core/src/Animations/AnimationManager.cs
@@ -7,6 +7,7 @@ namespace Microsoft.Maui.Animations
 	public class AnimationManager : IAnimationManager, IDisposable
 	{
 		readonly List<Animation> _animations = new();
+		readonly List<Animation> _animationsSnapshot = new();
 		long _lastUpdate;
 		bool _disposedValue;
 
@@ -84,8 +85,20 @@ namespace Microsoft.Maui.Animations
 			var milliseconds = TimeSpan.FromMilliseconds(now - _lastUpdate).TotalMilliseconds;
 			_lastUpdate = now;
 
-			var animations = new List<Animation>(_animations);
-			animations.ForEach(OnAnimationTick);
+			_animationsSnapshot.Clear();
+			_animationsSnapshot.AddRange(_animations);
+
+			try
+			{
+				foreach (var animation in _animationsSnapshot)
+				{
+					OnAnimationTick(animation);
+				}
+			}
+			finally
+			{
+				_animationsSnapshot.Clear();
+			}
 
 			if (_animations.Count == 0)
 				End();
@@ -129,8 +142,21 @@ namespace Microsoft.Maui.Animations
 
 		void ForceFinishAnimations()
 		{
-			var animations = new List<Animation>(_animations);
-			animations.ForEach(ForceFinish);
+			_animationsSnapshot.Clear();
+			_animationsSnapshot.AddRange(_animations);
+
+			try
+			{
+				foreach (var animation in _animationsSnapshot)
+				{
+					ForceFinish(animation);
+				}
+			}
+			finally
+			{
+				_animationsSnapshot.Clear();
+			}
+
 			End();
 
 			void ForceFinish(Animation animation)

@MauiBot
Copy link
Copy Markdown
Collaborator

MauiBot commented May 28, 2026

🤖 AI Summary

👋 @pictos — new AI review results are available. Please review the latest session below.

📊 Review Session7418c16 · use regular foreach instead of the functional one · 2026-05-28 13:08 UTC
🚦 Gate — Test Before & After Fix

Gate Result: ⚠️ SKIPPED

No tests were detected in this PR.

Recommendation: Add tests to verify the fix using the write-tests-agent.


🧪 UI Tests

Full UI test matrix will run (no specific categories detected from PR changes).


🔍 Regression Cross-Reference

🔍 Regression Cross-Reference

🟢 No regression risks detected. No labeled bug-fix PRs in the last 6 months touched the modified files.


🔍 Pre-Flight — Context & Validation

Issue: N/A - No linked issue provided
PR: #35612 - [WiP] Reduce allocations on AnimationManager
Platforms Affected: all platforms, including android
Files Changed: 1 implementation, 0 test

Key Findings

  • PR modifies shared Core animation hot-path code in src/Core/src/Animations/AnimationManager.cs.
  • The current PR keeps the original snapshot allocation (new List<Animation>(_animations)) but replaces List<T>.ForEach(...) with explicit foreach loops.
  • The current PR is behavior-preserving for callback-driven _animations mutations because it still iterates a snapshot.
  • Gate was already skipped because no tests were detected in this PR; no gate files were created or modified.
  • The PR body is incomplete and marked "Please do not review yet"; no linked issue was provided.

Code Review Summary

Verdict: LGTM
Confidence: high
Errors: 0 | Warnings: 0 | Suggestions: 1

Key code review findings:

  • 💡 src/Core/src/Animations/AnimationManager.cs:94 and src/Core/src/Animations/AnimationManager.cs:145 leave commented-out animations.ForEach(...) calls that should be deleted before merge.

Fix Candidates

# Source Approach Test Result Files Changed Notes
PR PR #35612 Replace List<Animation>.ForEach(...) delegate calls with explicit foreach loops over the same fresh snapshot list ⚠️ SKIPPED (Gate: no tests detected) src/Core/src/Animations/AnimationManager.cs Original PR; safe but only removes delegate invocation allocation and keeps per-tick snapshot list allocation

🔬 Code Review — Deep Analysis

Code Review — PR #35612

Independent Assessment

What this changes: Replaces List<Animation>.ForEach(...) delegate calls with explicit foreach loops over the same snapshot list in AnimationManager.OnFire() and ForceFinishAnimations().

Inferred motivation: Reduce delegate/callback allocation in animation hot paths while preserving the snapshot semantics that protect against _animations mutation during callbacks.

Reconciliation with PR Narrative

Author claims: "[WiP] Reduce allocations on AnimationManager"; PR body says "Please do not review yet."

Agreement/disagreement: The code matches the allocation-reduction claim, but only partially: it removes ForEach delegate allocation while still allocating the snapshot List<Animation>. Existing earlier review comments about unsafe direct-list iteration are outdated for the current head because the latest diff keeps snapshot iteration.

CI: latest visible checks, including maui-pr, are successful in the code-review sub-agent's GitHub context. Local gh checks were unavailable because this environment is not authenticated.

Findings

💡 Suggestion — Remove commented-out old implementation

src/Core/src/Animations/AnimationManager.cs:94 and src/Core/src/Animations/AnimationManager.cs:145 leave the previous animations.ForEach(...) calls as comments. Since the replacement is straightforward, these should be deleted before merge to avoid dead-code clutter.

Devil's Advocate

The biggest risk would be mutation during animation callbacks, but the current code still snapshots _animations before iterating, so it preserves the prior safety behavior. The optimization is modest because the snapshot allocation remains, but that is a tradeoff rather than a correctness issue.

Verdict: LGTM

Confidence: high
Summary: The current code preserves existing behavior while removing delegate-based iteration in two animation paths. I found no correctness, lifecycle, or platform-specific blocking issues; only minor cleanup is recommended.


🔧 Fix — Analysis & Comparison

Fix Candidates

# Source Approach Test Result Files Changed Notes
1 try-fix-1 Reusable private List<Animation> snapshot buffer with try/finally clearing ✅ PASS for runnable tests; Android build blocked by environment 1 file Best balance: preserves snapshot semantics, removes recurring snapshot-list object allocations, and keeps code straightforward
2 try-fix-2 ArrayPool<Animation> rented snapshot arrays copied from _animations ✅ PASS for runnable tests; Android build skipped after same blocker 1 file Viable but more complex; requires careful rent/copy/clear/return lifecycle
PR PR #35612 Explicit foreach over a freshly allocated new List<Animation>(_animations) snapshot ⚠️ SKIPPED (Gate: no tests detected) 1 file Safe but retains per-tick snapshot-list allocation and leaves commented-out old code

Cross-Pollination

Model Round New Ideas? Details
maui-expert-reviewer 1 Yes Proposed a reusable private snapshot list to preserve callback-mutation safety while amortizing snapshot storage.
maui-expert-reviewer 2 Yes After try-fix-1 passed and Android validation was environment-blocked, proposed ArrayPool<Animation> as a distinct no-retained-list alternative.

Exhausted: Yes — the meaningful alternatives for preserving snapshot semantics while reducing allocations are reusable retained snapshot storage or pooled temporary snapshot storage. Further variants would mostly refactor helper shape rather than change the approach.
Selected Fix: Candidate #1 — It is demonstrably better than the PR's current fix for allocation reduction, passed the available Core regression suite, preserves the original snapshot safety invariant, removes the commented-out dead code, and is simpler than the pooled-array candidate.

Test Summary

  • Gate was not rerun. Existing gate result: skipped because no tests were detected in this PR.
  • try-fix-1: dotnet test src/Core/tests/UnitTests/Core.UnitTests.csproj --verbosity minimal passed with 806 passed and 3 skipped.
  • try-fix-2: dotnet test src/Core/tests/UnitTests/Core.UnitTests.csproj --no-restore --verbosity minimal passed with 806 passed and 3 skipped.
  • Android validation: dotnet build src/Core/src/Core.csproj -f net10.0-android was blocked by NETSDK1005 because the restored assets did not include a net10.0-android target in this environment.

📋 Report — Final Recommendation

Comparative Report - PR #35612

Candidates compared

Candidate Approach Regression result Assessment
pr Explicit foreach over a freshly allocated new List<Animation>(_animations) snapshot; leaves commented-out old ForEach calls. Gate skipped; no tests detected. Correct and behavior-preserving, but only modestly reduces allocation overhead and leaves maintainability cleanup.
pr-plus-reviewer Same as pr, with the expert-reviewer feedback applied by removing the commented-out old ForEach calls. Gate skipped; no tests detected. Better than pr for maintainability, but still retains the recurring snapshot-list allocation.
try-fix-1 Reusable private List<Animation> snapshot buffer with try/finally clearing. PASS for runnable Core unit tests: 806 passed, 3 skipped. Android build was environment-blocked by missing net10.0-android assets. Best balance: preserves snapshot semantics, removes recurring snapshot-list object allocations after the retained buffer is created, clears references after iteration, and keeps the code straightforward.
try-fix-2 ArrayPool<Animation> rented snapshot arrays copied from _animations, cleared and returned in finally. PASS for runnable Core unit tests: 806 passed, 3 skipped. Android build skipped after the same environment blocker. Viable and allocation-aware, but more complex and easier to misuse than the reusable-list snapshot without a demonstrated correctness or test advantage.

Ranking

  1. try-fix-1 - Passed runnable regression tests and provides the strongest allocation reduction while preserving callback-mutation safety.
  2. try-fix-2 - Passed runnable regression tests and avoids retained list storage, but introduces ArrayPool<T> lifecycle complexity for no clear benefit over try-fix-1.
  3. pr-plus-reviewer - Improves the PR's maintainability by applying expert feedback, but regression coverage is absent and allocation reduction is still limited.
  4. pr - Correct but least polished: no detected tests, retained per-tick snapshot allocation, and commented-out dead code remains.

Winner

try-fix-1 is the winning candidate. It ranks above both PR-based candidates because candidates with passing runnable regression tests must rank higher than candidates with skipped regression validation, and it ranks above try-fix-2 because it is simpler while achieving the same snapshot-safety goal and better allocation behavior than the PR.


@pictos
Copy link
Copy Markdown
Contributor Author

pictos commented May 29, 2026

@kubaflo this one is ready for review now

@kubaflo kubaflo changed the title [WiP] Reduce allocations on AnimationManager Reduce allocations on AnimationManager May 29, 2026
Copy link
Copy Markdown
Contributor

@kubaflo kubaflo left a comment

Choose a reason for hiding this comment

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

What do you think about the ai's suggestions?

@pictos
Copy link
Copy Markdown
Contributor Author

pictos commented May 29, 2026

@kubaflo I believe the review was made on a previous commit. It says

Explicit foreach over a freshly allocated new List(_animations) snapshot; leaves commented-out old ForEach calls.

but I didn't remove the new List<Animation>(_animations) allocation. Is it possible to rerun to have a fresh review?

@kubaflo
Copy link
Copy Markdown
Contributor

kubaflo commented May 29, 2026

/review -b feature/refactor-copilot-yml

Copy link
Copy Markdown
Collaborator

@MauiBot MauiBot left a comment

Choose a reason for hiding this comment

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

🤖 Automated review — alternative fix proposed

The expert-reviewer evaluation compared the PR fix against #1 automatically generated candidates and selected try-fix-1 as the strongest fix.

Why: try-fix-1 wins because it preserves AnimationManager's existing mutation-safe snapshot semantics while eliminating both the PR-targeted delegate/display-class allocations and the recurring snapshot List allocation after ArrayPool warm-up. It passed the available focused Core animation tests with restore; Android device validation was unavailable.

Please consider applying the candidate diff below (or use it as guidance). Once you push an update, this workflow will re-trigger and re-evaluate.

Candidate diff (`try-fix-1`)
diff --git a/src/Core/src/Animations/AnimationManager.cs b/src/Core/src/Animations/AnimationManager.cs
index d6a3035fa3..16d441a208 100644
--- a/src/Core/src/Animations/AnimationManager.cs
+++ b/src/Core/src/Animations/AnimationManager.cs
@@ -1,4 +1,5 @@
 using System;
+using System.Buffers;
 using System.Collections.Generic;
 
 namespace Microsoft.Maui.Animations
@@ -84,14 +85,16 @@ namespace Microsoft.Maui.Animations
 			var milliseconds = TimeSpan.FromMilliseconds(now - _lastUpdate).TotalMilliseconds;
 			_lastUpdate = now;
 
-			var animations = new List<Animation>(_animations);
-
-			foreach(var animation in animations)
+			var animations = RentAnimationsSnapshot(out var animationsCount);
+			try
 			{
-				OnAnimationTick(animation);
+				for (var n = 0; n < animationsCount; n++)
+					OnAnimationTick(animations[n]);
+			}
+			finally
+			{
+				ArrayPool<Animation>.Shared.Return(animations, clearArray: true);
 			}
-
-			//animations.ForEach(OnAnimationTick);
 
 			if (_animations.Count == 0)
 				End();
@@ -135,14 +138,17 @@ namespace Microsoft.Maui.Animations
 
 		void ForceFinishAnimations()
 		{
-			var animations = new List<Animation>(_animations);
-			
-			foreach(var animation in animations)
+			var animations = RentAnimationsSnapshot(out var animationsCount);
+			try
+			{
+				for (var n = 0; n < animationsCount; n++)
+					ForceFinish(animations[n]);
+			}
+			finally
 			{
-				ForceFinish(animation);
+				ArrayPool<Animation>.Shared.Return(animations, clearArray: true);
 			}
-			
-			//animations.ForEach(ForceFinish);
+
 			End();
 
 			void ForceFinish(Animation animation)
@@ -153,6 +159,14 @@ namespace Microsoft.Maui.Animations
 			}
 		}
 
+		Animation[] RentAnimationsSnapshot(out int count)
+		{
+			count = _animations.Count;
+			var animations = ArrayPool<Animation>.Shared.Rent(count);
+			_animations.CopyTo(animations);
+			return animations;
+		}
+
 		internal virtual double AdjustSpeed(double elapsedMilliseconds)
 		{
 			return elapsedMilliseconds * SpeedModifier;

@pictos
Copy link
Copy Markdown
Contributor Author

pictos commented May 29, 2026

I'm not sure about the ArrayPool usage here. The minimum length array returned by the pool is of 16 and it grows in power of 2, so it seems an overkill, memory-wise, because I don't believe there will be too many items inside that list. Also, Animation is a reference type which demands the code clean up the array during Rent, which is an extra job.

That change would make sense in a future work, with more data around it to validate the benefits, and I can't do it right now. I would like to merge it as it's, focusing on the delegate allocation reduction.

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

Labels

area-core community ✨ Community Contribution s/agent-fix-win AI found a better alternative fix than the PR s/agent-reviewed PR was reviewed by AI agent workflow (full 4-phase review)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

AnimationManager is allocating a lot

4 participants