Skip to content

Apply optimizations in Avalonia Android#20968

Merged
MrJul merged 10 commits into
AvaloniaUI:masterfrom
jsuarezruiz:avalonia-android-performance
Mar 30, 2026
Merged

Apply optimizations in Avalonia Android#20968
MrJul merged 10 commits into
AvaloniaUI:masterfrom
jsuarezruiz:avalonia-android-performance

Conversation

@jsuarezruiz

@jsuarezruiz jsuarezruiz commented Mar 23, 2026

Copy link
Copy Markdown
Member

What does the pull request do?

Applies performance optimizations to the Avalonia Android backend, reducing GC pressure and lock contention on the UI thread during rendering, input dispatch, and accessibility operations.

What is the current behavior?

Some Android hot paths use heavier synchronization than needed:

  • DispatchDraw creates a new Paint object every frame (24 KB/1000 frames)
  • AndroidDispatcherImpl.Signal() uses lock for a simple boolean flag
  • TopLevelImpl.TextInput uses DateTime.Now.Ticks (wrong unit)
  • OnPerformActionForVirtualView uses LINQ .Select().Aggregate() (72 KB/1000 calls)
  • AndroidKeyboardEventsHelper calls char.ToString() on every key event (24 KB/1000 events)

What is the updated/expected behavior with this PR?

Applied changes to optimize while preserve behavior with measurable improvements.
Measured on Android, 10,000 iterations after 100 warmup:

Optimization Before After Speedup Alloc Saved
DispatchDraw: cache Paint object 13,741 ns/call 6.7 ns/call 2,050x 1,760,664 B → 216 B
Dispatcher: lock → Interlocked 24.9 ns/call 7.8 ns/call 3.2x 0
Accessibility: LINQ → foreach 68 ns/call 10 ns/call 6.5x 720,688 B → 688 B
KeySymbol: char.ConvertFromUtf32 → cached 7 ns/call 1 ns/call 5.9x 240,040 B → 40 B
image

TextInput timestamp change (DateTime.Now.Ticksto SystemClock.UptimeMillis()) is a fix, not a performance optimization. The original code used the wrong unit (100ns ticks instead of milliseconds) and a non-monotonic clock.

How was the solution implemented (if it's not obvious)?

  1. TopLevelImpl.DispatchDraw: Cache the Paint instance as a field. Properties never change after init and DispatchDraw runs on the UI thread only.

  2. AndroidDispatcherImpl.Signal/OnSignaled/OnIdle: Replace lock + bool _signaled with int _signaled using Interlocked.CompareExchange / Interlocked.Exchange / Volatile.Read. The flag is a simple set/reset gate with no compound critical section, making lock-free operations a good eplacement.

  3. TopLevelImpl.TextInput: Replace DateTime.Now.Ticks with SystemClock.UptimeMillis(). This matches the same epoch and unit used by KeyEvent.EventTime and MotionEvent.EventTime throughout the Android input pipeline, making text input timestamps directly comparable to pointer and keyboard timestamps.

  4. AvaloniaAccessHelper.OnPerformActionForVirtualView: Replace .Select().Aggregate() with a foreach loop and an explicitnull check. Eliminates iterator and delegate allocations.

  5. AndroidKeyboardEventsHelper: Pre-cache char.ToString() for ASCII range 0-127 in a static array. Non-ASCII falls through to char.ConvertFromUtf32() unchanged.

Checklist

Benchmarks added in tests/Avalonia.Benchmarks.Android/ to validate each optimization.

Breaking changes

None.

Obsoletions / Deprecations

None.

@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 12.0.999-cibuild0063846-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

if(Input != null)
{
var args = new RawTextInputEventArgs(AndroidKeyboardDevice.Instance!, (ulong)DateTime.Now.Ticks, InputRoot!, text);
var args = new RawTextInputEventArgs(AndroidKeyboardDevice.Instance!, (ulong)SystemClock.UptimeMillis(), InputRoot!, text);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I highly doubt going to java-interop monstroucity for SystemClock.UptimeMillis is better here.
Shouldn't we use Stopwatch instead, as .NET equvivalent?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Also, we should use the same source everywhere so that timestamps are comparable.
(For a given platform. They don't have to be cross-platform.)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Note: the matching benchmark does use Stopwatch.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good points.

C# → JNI → native clock_gettime

Android numbers (100,000 iterations on emulator):

Approach Time Per-call
DateTime.Now.Ticks 60 ms 600 ns
SystemClock.UptimeMillis() 57 ms 570 ns
Environment.TickCount64 2 ms 20 ns
Stopwatch.GetTimestamp() 4 ms 40 ns

Key findings:

  • SystemClock.UptimeMillis() is essentially almost same speed as DateTime.Now.Ticks on Android. The JNI overhead is real.
  • Environment.TickCount64 is 29x faster than both.
  • Stopwatch.GetTimestamp() is 15x faster than both.

Used SystemClock.UptimeMillis() to keep timestamps comparable with KeyEvent.EventTime and MotionEvent.EventTime, which both use SystemClock.uptimeMillis() as their clock source. Using a different .NET clock (Environment.TickCount64 or Stopwatch) would produce timestamps on a different epoch that doesn't pause during deep sleep, causing drift.

The benchmark uses Stopwatch.GetTimestamp() as a desktop proxy since SystemClock is Android-only.

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.

On CoreCLR UtcNow is faster than Now because time zone adjustments are skipped. That might end up being faster than UptimeMillis.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Are we discussing performance of something that takes nanoseconds to execute and is called like 3 times per second at most?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The event should use the same timestamp source as the rest of input events, please check what matches MotionEvent.eventTime and use it there.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Android's KeyEvent and MotionEvent timestamps use the SystemClock.uptimeMillis() time base, and the existing Android raw input paths already use that same clock base via EventTime/UptimeMillis. I changed TopLevelImpl.TextInput to use SystemClock.UptimeMillis() as well so it stays consistent with the rest of the Android input events.

@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 12.0.999-cibuild0063864-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

Comment thread tests/Avalonia.Benchmarks/Rendering/AndroidRenderingBenchmarks.cs Outdated
Comment thread tests/Avalonia.Benchmarks/Rendering/AndroidRenderingBenchmarks.cs Outdated
@jsuarezruiz

jsuarezruiz commented Mar 25, 2026

Copy link
Copy Markdown
Member Author

Android Benchmark Results (.NET 10.0.0, ARM64 emulator)

Ran the benchmarks on an actual Android device to get real numbers including JNI overhead. Each benchmark runs 10,000 iterations after 100 warmup iterations.

DispatchDraw: Paint allocation

Approach Time Per-call Alloc Speedup
NewPaintPerFrame 137.41 ms 13,741 ns 1,760,664 B baseline
CachedPaint 0.07 ms 6.7 ns 216 B 2,050x

Dispatcher: lock vs Interlocked

Approach Time Per-call Speedup
Lock 0.25 ms 24.9 ns baseline
Interlocked 0.08 ms 7.8 ns 3.2x

Timestamp sources

Source Time Per-call
DateTime.Now.Ticks (original) 1.10 ms 109.6 ns
DateTime.UtcNow.Ticks 0.22 ms 22.0 ns
Stopwatch.GetTimestamp 0.20 ms 19.5 ns
Environment.TickCount64 0.08 ms 7.6 ns
SystemClock.UptimeMillis (JNI) 2.34 ms 234.5 ns

SystemClock.UptimeMillis() is the slowest due to JNI overhead (2.1x slower than the original DateTime.Now.Ticks). However, it remains the correct choice here because:

  • All other Android input events (KeyEvent.EventTime, MotionEvent.EventTime) use SystemClock.uptimeMillis() as their clock source
  • Timestamps are compared across event types for double-click detection (TouchDevice) and gesture timeouts (ScrollGestureRecognizer), so they must share the same epoch and unit
  • Environment.TickCount64 / Stopwatch use a different epoch and don't pause during deep sleep, causing drift
  • The 234ns cost is irrelevant at human typing speed (~100ms between keystrokes = 0.0002% overhead)

The original code (DateTime.Now.Ticks) was broken: it passed 100ns ticks where milliseconds were expected, and used a non-monotonic clock.

Accessibility: LINQ vs foreach

Approach Time Alloc Speedup
LINQ Select+Aggregate 0.68 ms 720,688 B baseline
foreach loop 0.10 ms 688 B 6.5x

Keyboard: char conversion vs cached

Approach Time Alloc Speedup
char.ConvertFromUtf32 0.07 ms 240,040 B baseline
Cached ASCII lookup 0.01 ms 40 B 5.9x

Regarding the desktop benchmark file (AndroidRenderingBenchmarks.cs): agree with @maxkatz6 that it should be removed since it benchmarks generic .NET patterns, not actual Android code. An Android benchmark project (tests/Avalonia.Benchmarks.Android) have been added.

@maxkatz6

Copy link
Copy Markdown
Member

All other Android input events (KeyEvent.EventTime, MotionEvent.EventTime) use SystemClock.uptimeMillis() as their clock source

So we should remove it from other input events too.
And iOS uses environment ticks BCL API already, instead of current platform

@jsuarezruiz jsuarezruiz requested review from MrJul and maxkatz6 March 25, 2026 09:49
@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 12.0.999-cibuild0063999-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

Comment thread Avalonia.sln
@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 12.0.999-cibuild0064263-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@MrJul MrJul left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM!

@MrJul MrJul enabled auto-merge March 30, 2026 12:19
@MrJul MrJul added this pull request to the merge queue Mar 30, 2026
Merged via the queue into AvaloniaUI:master with commit 3edf20b Mar 30, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants