Skip to content

Reduce allocations on AnimationManager#35612

Merged
kubaflo merged 8 commits into
dotnet:inflight/currentfrom
pictos:pj/reduce-animation-allocations
Jun 1, 2026
Merged

Reduce allocations on AnimationManager#35612
kubaflo merged 8 commits into
dotnet:inflight/currentfrom
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

PureWeen and others added 4 commits May 21, 2026 16:04
<!-- Please let the below note in for people that find this PR -->
> [!NOTE]
> Are you waiting for the changes in this PR to be merged?
> It would be very helpful if you could [test the resulting
artifacts](https://github.com/dotnet/maui/wiki/Testing-PR-Builds) from
this PR and let us know in a comment if this change resolves your issue.
Thank you!

## What
Restrict the agentic-labeler to apply **exactly one `area-*` label** per
item, while still allowing multiple `platform/*` labels.

## Why
Backfilling the 26 items affected by the `max:1` bug (fixed in dotnet#35540)
revealed that the labeler occasionally applies multiple `area-*` labels
for ambiguous cases:

- **dotnet#35501** got both `area-layout` and `area-safearea`
- **dotnet#35490** got both `area-navigation` and `area-controls-tabbedpage`

The intended behavior is exactly one best-fit `area-*` per item (a
label-quota distinction not expressible via
`safe-outputs.add-labels.max:` — that field counts total labels, not
labels per prefix). The fix has to live in the agent's instructions.

## Changes

### `.github/skills/agentic-labeler/SKILL.md`
- Scope section: "Exactly one `area-*`" / "One or more `platform/*`".
- Area rules section: renamed heading, changed "pick one or more" →
"apply exactly one".
- New **tie-breaking heuristics** for the area-* selection:
- Specific control beats generic area (`area-controls-tabbedpage` over
`area-navigation`)
  - Sub-area beats parent area (`area-safearea` over `area-layout`)
  - Subject-matter focus beats incidental touch
  - When genuinely tied, prefer the user-visible feature
- Mixed-PR rule clarified: infra-primary PRs get only
`area-infrastructure` (no second product area).

### `.github/workflows/agentic-labeler.md`
- Added explicit reinforcement in the workflow prompt: "Apply exactly
one `area-*` label … and one or more `platform/*` labels".
- Fixed two stale `max: 1` comments left over from dotnet#35540 (the cap is
now `max: 10`).

### `.github/workflows/agentic-labeler.lock.yml`
- Regenerated via `gh aw compile`. Diff is frontmatter-hash + heredoc
rotations only — no semantic change to the compiled config.

## Validation
- Reviewed all 21 existing eval scenarios in `tests/eval.yaml` — none
assert multiple `area-*` labels, so no test updates needed.
- The `max: 10` cap in `safe-outputs` is preserved as a blast-radius
safeguard (one area + several platforms still fit comfortably).

## Follow-ups (not in this PR)
If accuracy of the "one area" rule drops below ~95% in eval runs,
consider adding a deterministic post-step that strips extra `area-*`
labels per a known precedence list (Option B from the design
discussion).

Co-authored-by: bot <bot@test>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
## Description

Extends the `maui-copilot` DevDiv pipeline (pipeline 27723) with a
3-stage architecture that runs real UI tests on platform-pool agents and
reports results directly in the AI summary PR comment.

### Pipeline Workflow

```
┌─────────────────────────────────────────────────────────┐
│  Stage 1: ReviewPR                                      │
│                                                         │
│  STEP 1: Branch Setup (checkout + cherry-pick PR)       │
│  STEP 2: Detect UI Test Categories                      │
│  STEP 3: Run Detected UI Tests (in-process, fast)       │
│  STEP 4: Regression Cross-Reference                     │
│  STEP 5: Gate — verify tests fail/pass before/after fix │
│  STEP 6: Code Review — deep analysis via Copilot agent  │
│                                                         │
│  Outputs → CopilotLogs artifact + detectedCategories    │
└──────────────────────┬──────────────────────────────────┘
                       │
┌──────────────────────▼──────────────────────────────────┐
│  Stage 2: RunDeepUITests (platform-pool agent)          │
│                                                         │
│  iOS: AcesShared Tahoe + iOS 26.4                       │
│  Android: ubuntu-22.04 + KVM + AVD                      │
│                                                         │
│  Runs BuildAndRunHostApp.ps1 per detected category      │
│  Outputs → drop-deep-uitests artifact (TRX + diffs)     │
└──────────────────────┬──────────────────────────────────┘
                       │
┌──────────────────────▼──────────────────────────────────┐
│  Stage 3: PostResults                                   │
│                                                         │
│  1. Download CopilotLogs (review content files)         │
│  2. Download drop-deep-uitests (TRX results)            │
│  3. Merge deep results into uitests/content.md          │
│  4. Post full AI Summary comment on PR                  │
│  5. Apply labels (s/agent-reviewed, etc.)               │
│                                                         │
│  One comment with everything — no patching needed       │
└─────────────────────────────────────────────────────────┘
```

### What's New

**Deep UI Test Execution (Stage 2)**
- Runs detected UI test categories on proper platform-pool agents (not
in-process on Linux)
- **iOS**: AcesShared Tahoe agents with iOS 26.4 simulator, iPhone 11
Pro (matching `ios-26` baselines from PR dotnet#35061)
- **Android**: ubuntu-22.04 with KVM, AVD boot with `-partition-size
2048`, `ignoreHiddenApiPolicyError` capability
- TRX results + snapshot-diff PNGs published as `drop-deep-uitests`
artifact

**Unified Comment Posting (Stage 3)**
- Comment posting and label application deferred to Stage 3 (after deep
tests complete)
- Single AI summary comment includes ALL results: code review + deep
test results
- Nested collapsible `<details>` for failed tests with full error +
stack trace
- Dynamic section title: `🧪 UI Tests — CollectionView, TabbedPage`
- Artifact download link for snapshot-diff PNGs

**Android Emulator Improvements**
- AVD boot step with proper partition size, ADB key pre-authorization,
boot wait
- `DEVICE_UDID` pass-through prevents double emulator boot
- Disk cleanup on hosted ubuntu agents (frees ~22GB)
- KVM enablement + `appium:ignoreHiddenApiPolicyError` for API 30

**iOS Simulator Improvements**
- Tahoe pool demand ensures macOS 26.x agents
- Explicit iOS 26.4 download via latest Xcode
- Auto-creates iPhone 11 Pro for baseline resolution match

### Validation

Tested across 30+ pipeline iterations on 6 PRs:

| PR | iOS | Android |
|---|---|---|
| 35358 (ViewBaseTests) | **112/112 ALL PASS** ✅ | **118/119 PASS** ✅ |
| 35359 (TabbedPage) | 44/50 (1 real failure) | 74/75 (1 real failure) |
| 35356 (CollectionView) | **415/417 PASS** ✅ | 593/619 (26 real
failures) |

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…35589)

> [!NOTE]
> Are you waiting for the changes in this PR to be merged?
> It would be very helpful if you could [test the resulting
artifacts](https://github.com/dotnet/maui/wiki/Testing-PR-Builds) from
this PR and let us know in a comment if this change resolves your issue.
Thank you!

Backport of dotnet#35460 to `main`.

/cc @PureWeen

Co-authored-by: HarishKumarSF4517 <harish.kumar@syncfusion.com>
@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.

MauiBot

This comment was marked as outdated.

@pictos
Copy link
Copy Markdown
Contributor Author

pictos commented May 29, 2026

@kubaflo this one is ready for review now

### Description of Change


https://github.com/GitOps-microsoft/GitOps.PullRequestIssueManagement/pull/262
(internal Microsoft link) changed the `${issueAuthor}` placeholder to
include the `@` character.

Remove the one we added so we don't duplicate it.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
@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

MauiBot

This comment was marked as outdated.

@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.

Comment thread src/Core/src/Animations/AnimationManager.cs Outdated
@pictos pictos force-pushed the pj/reduce-animation-allocations branch from 6ccab60 to cf8a3f0 Compare May 30, 2026 16:21
@kubaflo
Copy link
Copy Markdown
Contributor

kubaflo commented May 30, 2026

/review -b feature/refactor-copilot-yml

MauiBot

This comment was marked as outdated.

@pictos
Copy link
Copy Markdown
Contributor Author

pictos commented May 30, 2026

I don't like the try-fix-1 suggestion, I believe the trade-off isn't worth (use lock to avoid collection copy)

@MauiBot

This comment has been minimized.

@kubaflo kubaflo dismissed stale reviews from MauiBot, MauiBot, and MauiBot May 31, 2026 14:16

Superseded by a newer MauiBot review run.

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.

AI Review Summary

@pictos — new AI review results are available based on this last commit: cf8a3f0.
create array instead of list

Gate Skipped Code Review Needs Changes Confidence Medium Platform Android

Review Sessions — click to expand
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).


Pre-Flight — Context & Validation

Issue: Unknown - GitHub API unavailable; no linked issue could be fetched
PR: #35612 - Unknown title (local branch: pr-review-35612)
Platforms Affected: android (per prompt); also shared Core animation behavior
Files Changed: 12 implementation/pipeline files, 0 test files

Key Findings

  • GitHub PR metadata/comments could not be fetched because gh is unauthenticated in this environment; analysis used the checked-out PR branch and origin/main...HEAD diff.
  • Functional MAUI change is in src/Core/src/Animations/AnimationManager.cs: replace new List<Animation>(_animations).ForEach(...) with array snapshot + foreach in normal ticks and force-finish path.
  • Gate was already skipped: no tests detected in this PR. Nearby targeted coverage exists in TickerSystemEnabledTests and was used for try-fix regression checks.
  • Expert review found the PR fix is small and plausible but may only mitigate the symptom; _animations remains an unsynchronized mutable list.

Code Review Summary

Verdict: NEEDS_DISCUSSION
Confidence: medium
Errors: 1 | Warnings: 1 | Suggestions: 1

Key code review findings:

  • ❌ No regression test coverage for the mutation/concurrency scenario; gate was skipped.
  • ⚠️ src/Core/src/Animations/AnimationManager.cs: array snapshot avoids List<T>.ForEach checks but does not make _animations access atomic under concurrent mutation.
  • 💡 Minor extra blank line in ForceFinishAnimations in the PR diff.

Fix Candidates

# Source Approach Test Result Files Changed Notes
PR PR #35612 Array snapshot + foreach in AnimationManager ⚠️ Gate skipped src/Core/src/Animations/AnimationManager.cs Original PR; no detected tests

Code Review — Deep Analysis

Code Review — PR #35612

Independent Assessment

What this changes: The functional MAUI change updates AnimationManager to snapshot _animations into an array and iterate with foreach instead of constructing a List<Animation> snapshot and invoking List<T>.ForEach in OnFire and ForceFinishAnimations.

Inferred motivation: Avoid mutation/version-check failures or allocation overhead while animations are removed or force-finished during ticker callbacks, especially around Android animation-disable paths.

Reconciliation with PR Narrative

Author claims: GitHub API access was unavailable in this environment, so PR title/body/comments and linked issue could not be fetched. Local commit history indicates this PR also contains Copilot CI pipeline hardening/refactoring and one functional animation manager change.

Agreement/disagreement: Local code review agrees the array snapshot is a tactical improvement over List<T>.ForEach, but the MAUI expert review found it may not address all concurrent access scenarios for _animations.

Findings

❌ Error — No regression test coverage

Gate was already skipped because no tests were detected in the PR. The animation-manager change has existing nearby coverage in src/Controls/tests/Core.UnitTests/TickerSystemEnabledTests.cs, but the PR does not add a targeted regression for the mutation/concurrency scenario.

⚠️ Warning — Snapshot change may mask, not fix, concurrent list access

src/Core/src/Animations/AnimationManager.cs still uses a mutable List<Animation> without synchronization. Replacing a List<T> snapshot with an array snapshot avoids List<T>.ForEach version checks and reduces overhead, but snapshot construction is still not atomic if _animations is concurrently mutated.

💡 Suggestion — Minor formatting cleanup

The PR version of ForceFinishAnimations contains an extra blank line before End().

Devil's Advocate

The platform-specific Android callbacks likely run on the main thread in normal cases, so the immediate crash may be fully mitigated by avoiding List<T>.ForEach. Existing animation-disable tests pass with multiple alternative approaches. However, the base Ticker uses System.Timers.Timer, and AnimationManager itself does not enforce single-threaded access.

Verdict: NEEDS_DISCUSSION

Confidence: medium
Summary: The PR's functional fix is plausible and small, but it lacks a regression test and may be a symptom-level mitigation rather than a root-cause fix for unsynchronized _animations access. Alternative candidates show a lock-based approach is more robust, while allocation-free approaches pass existing tests but are narrower.


Fix — Analysis & Comparison

Fix Candidates

# Source Approach Test Result Files Changed Notes
1 try-fix Lock-protected _animations list; snapshot under lock and invoke callbacks outside lock ✅ PASS 1 file Most robust; addresses concurrent access window instead of only snapshot iteration
2 try-fix Reverse in-place iteration without snapshot allocation ✅ PASS 1 file Simpler/faster for single-threaded mutation; does not address cross-thread access
3 try-fix Two-phase tick then backward remove ✅ PASS 1 file Clear algorithm but changes cleanup timing and does not address cross-thread access
PR PR #35612 Array snapshot + foreach replacing List<T>.ForEach ⚠️ Gate skipped 1 functional file Small tactical original PR fix; no detected tests

Cross-Pollination

Model Round New Ideas? Details
maui-expert-reviewer 1 Yes Suggested lock, reverse iteration, two-phase remove, and source dispatch/thread-affinity ideas.
maui-expert-reviewer 2 No NO NEW IDEAS. Immutable-array and ticker reentrancy variants were considered not worth implementing because they are either more complex than locking or narrower than Candidate 1.

Exhausted: Yes
Selected Fix: Candidate #1 — Lock-protected animation list. It is larger than the PR's array snapshot but is the only tested candidate that closes the non-atomic snapshot/concurrent mutation window. Candidate #2 and #3 pass the existing regression tests but are not demonstrably better for Android/threading robustness.

Test Summary

All implemented candidates were tested with the targeted existing regression suite:

dotnet test src/Controls/tests/Core.UnitTests/Controls.Core.UnitTests.csproj --filter FullyQualifiedName~TickerSystemEnabledTests --verbosity minimal

The first candidate's initial --no-restore run failed because assets were missing; the retry with restore passed. Subsequent candidates used --no-restore and passed.


Report — Final Recommendation

Comparative Candidate Report

Inputs

  • PR: pr — array snapshot plus foreach in AnimationManager.
  • Reviewer-enhanced PR: pr-plus-reviewer — identical to pr; expert review produced no actionable inline findings.
  • try-fix-1: lock-protected _animations list; snapshots are taken under a private lock and callbacks run outside the lock.
  • try-fix-2: reverse in-place iteration without a snapshot.
  • try-fix-3: two-phase tick then cleanup/removal.

Gate verification for the PR was skipped because no tests were detected. The try-fix candidates were evaluated with the targeted existing TickerSystemEnabledTests suite; all three try-fix candidates passed. No candidate failed regression tests, so no candidate is demoted under the failed-regression rule.

Ranking

  1. try-fix-1 — Winner. This is the only tested candidate that addresses the likely Android race at the shared mutable _animations list instead of only changing iteration mechanics. It preserves snapshot callback semantics by copying under lock and invokes animation callbacks outside the lock to avoid deadlocks/reentrant callback hazards.
  2. pr / pr-plus-reviewer. The submitted PR is small, plausible, and low-risk. It preserves existing behavior and improves the snapshot implementation, but it does not make _animations access atomic and had no detected regression tests in the PR. pr-plus-reviewer ties with pr because expert review had no actionable changes.
  3. try-fix-2. Reverse iteration is allocation-efficient and passed the targeted suite, but it changes iteration semantics and still leaves cross-thread access unsynchronized.
  4. try-fix-3. The two-phase algorithm is clear and passed the targeted suite, but it changes same-frame cleanup timing and still does not address concurrent access.

Decision

Select try-fix-1. It has the strongest correctness story for Android because it fixes the shared-list synchronization gap while preserving the PR's snapshot-style callback isolation. The PR fix remains acceptable as a narrow tactical improvement, but it is less complete for the suspected root cause.


Future Action — alternative fix proposed (try-fix-1)

Automated review — alternative fix proposed

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

Why: try-fix-1 won because it passed the targeted regression suite and is the only candidate that addresses the shared _animations concurrency window while preserving snapshot callback semantics. The PR and pr-plus-reviewer are lower-risk but narrower; try-fix-2 and try-fix-3 pass tests but do not address cross-thread access.

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 2de13b3294..9bf205ef5d 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 object _animationsLock = new();
 		long _lastUpdate;
 		bool _disposedValue;
 
@@ -40,8 +41,12 @@ namespace Microsoft.Maui.Animations
 				return;
 			}
 
-			if (!_animations.Contains(animation))
-				_animations.Add(animation);
+			lock (_animationsLock)
+			{
+				if (!_animations.Contains(animation))
+					_animations.Add(animation);
+			}
+
 			if (!Ticker.IsRunning && AutoStartTicker)
 				Start();
 		}
@@ -49,12 +54,22 @@ namespace Microsoft.Maui.Animations
 		/// <inheritdoc/>
 		public void Remove(Animation animation)
 		{
-			_animations.TryRemove(animation);
+			lock (_animationsLock)
+				_animations.TryRemove(animation);
 
-			if (_animations.Count == 0)
+			if (AnimationsCount == 0)
 				End();
 		}
 
+		int AnimationsCount
+		{
+			get
+			{
+				lock (_animationsLock)
+					return _animations.Count;
+			}
+		}
+
 		void Start()
 		{
 			_lastUpdate = GetCurrentTick();
@@ -84,17 +99,22 @@ namespace Microsoft.Maui.Animations
 			var milliseconds = TimeSpan.FromMilliseconds(now - _lastUpdate).TotalMilliseconds;
 			_lastUpdate = now;
 
-			var animations = new List<Animation>(_animations);
-			animations.ForEach(OnAnimationTick);
+			Animation[] animations;
+			lock (_animationsLock)
+				animations = _animations.ToArray();
+
+			foreach (var animation in animations)
+				OnAnimationTick(animation);
 
-			if (_animations.Count == 0)
+			if (AnimationsCount == 0)
 				End();
 
 			void OnAnimationTick(Animation animation)
 			{
 				if (animation.HasFinished)
 				{
-					_animations.TryRemove(animation);
+					lock (_animationsLock)
+						_animations.TryRemove(animation);
 					animation.RemoveFromParent();
 					return;
 				}
@@ -103,7 +123,8 @@ namespace Microsoft.Maui.Animations
 
 				if (animation.HasFinished)
 				{
-					_animations.TryRemove(animation);
+					lock (_animationsLock)
+						_animations.TryRemove(animation);
 					animation.RemoveFromParent();
 				}
 			}
@@ -129,14 +150,20 @@ namespace Microsoft.Maui.Animations
 
 		void ForceFinishAnimations()
 		{
-			var animations = new List<Animation>(_animations);
-			animations.ForEach(ForceFinish);
+			Animation[] animations;
+			lock (_animationsLock)
+				animations = _animations.ToArray();
+
+			foreach (var animation in animations)
+				ForceFinish(animation);
+
 			End();
 
 			void ForceFinish(Animation animation)
 			{
 				animation.ForceFinish();
-				_animations.TryRemove(animation);
+				lock (_animationsLock)
+					_animations.TryRemove(animation);
 				animation.RemoveFromParent();
 			}
 		}

Copy link
Copy Markdown
Contributor

@albyrock87 albyrock87 left a comment

Choose a reason for hiding this comment

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

This class is clearly not thread safe.
Therefore I don't see a reason to copy _animations into a temporary array.

We can simply use our dear old for(var i = 0; i < _animations.Count; i++ and when the internal invocation removes the element, we can simply i--.

This also gives us the opportunity of providing the current i to the action inside the loop so that instead of TryRemove we can remove directly the element at the given index.

Take this AI

@kubaflo
Copy link
Copy Markdown
Contributor

kubaflo commented May 31, 2026

/review -b feature/enhanced-reviewer

@kubaflo kubaflo changed the base branch from main to inflight/current June 1, 2026 11:45
@kubaflo kubaflo merged commit 8c69e69 into dotnet:inflight/current Jun 1, 2026
31 checks passed
@github-actions github-actions Bot added this to the .NET 10.0 SR8 milestone Jun 1, 2026
@pictos pictos deleted the pj/reduce-animation-allocations branch June 2, 2026 11:21
PureWeen pushed a commit that referenced this pull request Jun 2, 2026
<!--
!!!!!!! MAIN IS THE ONLY ACTIVE BRANCH. MAKE SURE THIS PR IS TARGETING
MAIN. !!!!!!!
-->


### 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:

<img width="1436" height="138" alt="image"
src="https://github.com/user-attachments/assets/f0b57463-7a8f-4767-9f55-301d5696d4bf"
/>


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:

<img width="1440" height="113" alt="image"
src="https://github.com/user-attachments/assets/e9ab701b-29c6-42cc-9e86-906df4cc503e"
/>

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

<!-- Please make sure that there is a bug logged for the issue being
fixed. The bug should describe the problem and how to reproduce it. -->

Fixes #35654

<!--
Are you targeting main? All PRs should target the main branch unless
otherwise noted.
-->

From IA review:

<img width="658" height="198" alt="image"
src="https://github.com/user-attachments/assets/44b9be3a-c6ee-4ae9-a5d0-2bb5d9bcd603"
/>


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<Animation> (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<Animation> backing
array), just sampling variance

---------
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

7 participants