Repository navigation
Description
Description
We are developing a real-time roguelike game (GnollHack) using .NET MAUI with SkiaSharp (SKGLView) for rendering on Android. The game targets 60 FPS and achieves this consistently on .NET 10 with the Mono runtime.
As per the post by @davidortinau last week, we tested .NET MAUI 11 Preview 6 on Android to see how it compares with Mono Runtime in .NET 10. After upgrading to .NET 11 Preview 6+ (which uses CoreCLR exclusively on Android), we observe periodic (every 5 seconds or so) frame rate drops from 60 FPS to approximately 50–54 FPS (6-10 consecutive frames drop every 5 seconds or so), causing severe stutters in the game and making the game to feel continuously very jerky. These drops occur at regular intervals regardless of scene complexity, even on screens with very little drawing. The drops are clearly visible as jerkiness/microstutters during gameplay.
This appears to be a performance regression introduced by the Mono → CoreCLR transition on Android.
If @jfversluis or @jonathanpeppers or someone else already know a solution working for this in .NET MAUI 11, please let me know.
Expected Behavior
Consistent 60 FPS rendering, matching .NET 10 (Mono) performance.
Actual Behavior
Frame rate periodically drops to 50–54 FPS at regular intervals, causing visible jerkiness. The drops occur:
- From the very start of the application
- Regardless of scene complexity (even with minimal drawing)
- At consistent, periodic intervals
Attempted Mitigations (none helped)
| Setting | Result |
|---|---|
DOTNET_TieredCompilation=0 |
No improvement |
DOTNET_GCGen0MaxBudget=8000000 (128 MB) |
No improvement |
DOTNET_gcServer=0 (Workstation GC) |
No improvement |
| Default CoreCLR settings (no tuning) | Same periodic drops |
Steps to Reproduce
- Create a .NET MAUI application targeting
net11.0-androidwith a SkiaSharpSKGLViewrendering at 60 FPS. - Build and deploy to an Android device.
- Observe periodic frame rate drops from 60 to ~54 FPS at regular intervals.
- Compare with the same application targeting
net10.0-android(Mono runtime) — no such drops occur.
Environment
- .NET SDK: 11.0.100-preview.6 (or later)
- Runtime: CoreCLR (Mono no longer available in Preview 6+)
- Platform: Android (arm64)
- Rendering: SkiaSharp SKGLView (OpenGL ES)
- Target FPS: 60
- Device: (Insert your test device and Android version here)
- Comparison baseline: .NET 10 with Mono runtime — consistent 60 FPS, no drops
Additional Context
- The application is a complex roguelike game with native C libraries linked via P/Invoke, FMOD audio, and multi-layer tile rendering.
- The frame drops are not correlated with scene complexity, suggesting the cause is runtime-level periodic work (GC, thread pool, diagnostics, or other background activity) rather than application code.
- We also encountered a separate issue where
DOTNET_DiagnosticPortsinEnvironment.txt(previously used for Mono debugging) caused the CoreCLR runtime to suspend at startup and never resume. Removing it fixed that issue, but the periodic FPS drops remain. <UseMonoRuntime>true</UseMonoRuntime>is no longer supported in .NET 11 Preview 6+, so we cannot fall back to Mono to verify the regression on the same SDK version.
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsNo status
Activity
janne-hmp commentedon Jul 26, 2026
I made a test app to show the periodic drop in FPS: https://github.com/hyvanmielenpelit/Net11FPSBenchmark Here it happens, every 10 to 20 s or so. It has various extra switches to test things, and also the iOS issue separately, but it currently best works for demonstrating this problem on Android. There is also a script to alternate between .NET 10 and .NET 11. In .NET 10 you can observe a much smoother FPS behavior.
dotnet-policy-service commentedon Jul 26, 2026
Tagging subscribers to this area: @agocke
See info in area-owners.md if you want to be subscribed.
dotnet-policy-service commentedon Jul 26, 2026
Tagging subscribers to 'arch-android': @vitek-karas, @simonrozsival, @steveisok, @akoeplinger
See info in area-owners.md if you want to be subscribed.
steveisok commentedon Jul 26, 2026
Could be something in the GC bridge. dotnet/android#11567 seems to be showing something similar.
steveisok commentedon Jul 27, 2026
Important
Corrected 2026-08-10. Two claims below were wrong and are struck through in place.
WeakReference.Targetbridge blocking is a contributor — it is worth ~41% of the lostframe time. My original experiment used a strong-reference arm as a control, but that was not a
control: dotnet/android resolves peers through weak references on the UI thread irrespective of
what the probe reads, so both arms already contained the mechanism. Symbolized off-CPU profiling
puts
GCHandle_InternalGetBridgeWait→WaitForGCBridgeFinishat 31.34% of UI-threadoff-CPU time with peers and absent without them, with the probe's own read loop off (
reads=0).Reduce weak ref blocking with java interop #131952 addresses this.
200 ms, because bridge objects are force-promoted whether or not the request is sent, so
skipping it buys nothing and lets more of them accumulate.
Suggested fix #1 (skip the SCC pass when the client is busy) still holds.
I was curious so I did some analysis:
Reproduced with a small Android probe: a frame-pacing harness that runs two modes allocating the same number of objects and bytes each frame. The only difference is that one mode gives each object a Java peer and the other doesn't. The no-peer mode is the control — if frames drop there too, the cause is allocation, not the bridge.
Mechanism. When a .NET object is paired with a Java object, neither runtime can free its side alone — they have to compare notes. So whenever a .NET GC finds even one such pair where the .NET side is dead, it asks Android to run a Java GC to check whether the Java side is dead too. That request is unthrottled, so a gen0 collection is enough to trigger a full ART collection.
The problem isn't the ART collection itself — it's what it does to the UI thread. Drawing a frame creates Java objects, and creating one takes a lock that the running ART collection holds for long stretches. So the UI thread sleeps ~15 ms at a time and misses its frame deadline.
Perfetto and simpleperf confirm each step: every stall (60/60) lands inside an ART collection; the same managed work costs 39.4 ms during one vs 1.30 ms outside (~30×); and 80.6% of the UI thread's waiting is in
art::JavaVMExt::AddGlobalRef— the lock in question. The control mode shows none of this, so it's the bridge, not the allocation.On the regression framing. Mono also drops frames (60 → ~57). It fires ~2× more explicit GCs (396 vs 212) yet loses ~4× less time (283 ms vs 1175 ms per 5 s) — so per-event cost dominates, not trigger frequency.
Suggested fixes — runtime side.
Throttle how often new requests are produced (e.g.Withdrawn — measured neutral at 50 ms and harmful at 200 ms, for exactly the reason the strikethrough text concedes: the objects are promoted whether or not the request is sent.System.GC.BridgeMinIntervalMs, default 50 ms), gen0 only — bridge objects are force-promoted regardless, so leaving gen1+ unthrottled bounds retention to the next gen1 GC.Suggested fix — dotnet/android side.
GCBridge::trigger_java_gccallsRuntime.gc()unconditionally on every bridge request, with no rate limiting or coalescing. Since the request is advisory, a minimum interval or a "skip if a collection just ran" check there would cut trigger rate independently of the runtime change — and it's the layer that can see how recently ART actually collected. Worth considering whether every request needs a full explicit collection at all.The larger remaining share is the force-promotion itself: every dead bridge object is copied out of gen0 on every collection, which is worth ~59% of the lost frame time and is not addressed by any of the above.
Numbers are from an emulator (understates cost); any throttle default would need tuning on real arm64.
Ruled out:
debug.mono.log=gc,greffabricates this bug via logcat volume — don't measure with it on.Probe, prototypes and raw runs: https://github.com/steveisok/android-gcbridge-investigation
Note
This analysis was generated with GitHub Copilot.
dotnet-policy-service commentedon Jul 27, 2026
Tagging subscribers to this area: @anicka-net, @dotnet/gc
See info in area-owners.md if you want to be subscribed.
1 remaining item
[CoreCLR] Boost GC bridge thread CPU frequency via ADPF
janne-hmp commentedon Jul 30, 2026
@steveisok @jonathanpeppers We made now even further investigation on this. We went through our codebase and heavily optimized our render loop to reduce per-frame allocations (object pooling, struct usage, caching), and we managed to reduce memory pressure to approximately 1/6th of what it was before, which has improved things regardless of the employed runtime.
After measuring GC pause durations across platforms with Mono on .NET 10, I noticed something striking:
The ~100× difference between Android and iOS collection times for the same GC engine points directly to the Android JNI/GC bridge. On Android, every GC collection must also:
On iOS, none of this exists, since Objective-C uses ARC (reference counting), so Mono's GC only traces managed objects. The periodic stuttering on Android with CoreCLR in .NET 11 may not thus be caused by the GC algorithm itself being worse than Mono's but instead each GC collection on Android carries a fixed ~50–90 ms bridge tax. With Mono, we could set
MONO_GC_PARAMS=nursery-size=128mto make collections extremely infrequent, effectively paying this bridge cost only at controlledGC.Collect()call sites between game states. With CoreCLR's adaptive GC, we cannot prevent it from collecting more frequently, and each collection triggers the same expensive bridge processing.This would explain:
DOTNET_GCGen0MaxBudget=8000000had no effect (unsupported/ignored internal knob)As to mitigants, I'm aware of these existing CoreCLR mechanisms:
GCSettings.LatencyMode = SustainedLowLatency: Suppresses Gen 2 collections but not Gen 0/Gen 1. On Android, even a Gen 0 collection triggers the bridge overhead, so this doesn't help.GC.TryStartNoGCRegion(budget): Requires knowing the allocation budget upfront, which is hard in a complex game loop with variable-cost frames (menus vs. combat vs. animations). Also, if you exceed the budget, the GC fires mid-frame anyway.GCSettings.LatencyModeruntime switching: Useful for phase transitions (loading vs. gameplay), but doesn't address the fundamental problem of per-collection bridge cost during gameplay.These APIs are designed for scenarios where individual GC pauses are cheap (microseconds to low milliseconds). On Android, where each pause carries a 50–90 ms bridge tax, the only viable strategy is to make collections as infrequent as possible, which is exactly what Mono's
nursery-sizeenabled.As CoreCLR replaces Mono on mobile, game developers and other interactive application developers (CAD, DAWs, digital art tools, scientific visualization) lose access to Mono's direct GC tuning. Over the course of the analysis, we came up with three features would significantly help these workloads:
1. Configurable Gen 0 target size to reduce collection frequency
A supported runtime configuration option such as:
or equivalent MSBuild property. This would be the direct CoreCLR analog of Mono's
nursery-size. The GC could still adapt around this target rather than treating it as a hard limit; the key is giving developers a way to say "I'd prefer Gen 0 to be around 128 MB; I'm willing to trade memory for fewer collections."The internal knob
GCGen0MaxBudgetalready suggests this concept exists in the implementation. The request is to make it officially supported and reliable.This is the single most impactful feature for Android, where each collection triggers the expensive JNI bridge processing. Reducing collection frequency is the only viable strategy when individual collections carry a fixed 50–90 ms overhead.
2. Frame-boundary API to control collection timing
A
BeginFrame()/EndFrame()pair that tells the runtime when the application is in a latency-critical rendering phase:The GC could:
BeginFrame()andEndFrame()This would benefit any application with a real-time loop such as games, video editors, audio workstations, simulation tools.
3. Pause-duration target to limit collection duration
A quantitative extension of the existing
GCSettings.LatencyMode:Today,
SustainedLowLatencyis a qualitative hint ("low pauses please") but doesn't let you specify how low. A quantitative target would let the GC:However, on Android, this cannot help with the JNI bridge overhead, which is an indivisible ~50–90 ms cost per collection. The pause target would primarily benefit platforms without bridge overhead (Windows, iOS) and would complement the Gen 0 target size (which addresses Android by reducing collection frequency rather than duration).
Summary
The core issue for real-time applications on Android is that each GC collection carries a large, fixed bridge overhead. The most effective mitigation is reducing collection frequency, which Mono enabled via
nursery-sizebut CoreCLR currently does not support.In any case, many thanks to the team for the hard work on .NET 11 and the CoreCLR transition so far. We hope that the CoreCLR team could consider these improvements to control CoreCLR better in order to mitigate especially the conditions on Android.
[CoreCLR] Boost GC bridge thread CPU frequency via ADPF
Avoid redundant Java GC bridge graph computation (#131764)
Avoid redundant Java GC bridge graph computation (dotnet#131764)
agocke commentedon Aug 17, 2026
@BrzVlad Have we retested this scenario with the fixes?
BrzVlad commentedon Aug 18, 2026
Yes. I've tested on the https://github.com/hyvanmielenpelit/Net11FPSBenchmark repro app, where I would no longer get the regular FPS drops.
@janne-hmp
GC.TryStartNoGCRegionwas designed for issues like this. Mono didn't support it. With CoreCLR, is there a reason why this wouldn't achieve what you are looking for ?janne-hmp commentedon Aug 18, 2026
@BrzVlad Hi Vlad, I tested this now with .NET 11 Preview 7 and actually used in GnollHack (which has a higher memory pressure than the test app) the following:
which aligns with the 128MB nursery size that we used in Mono, and I believe that now it works just like it did under Mono (i.e., we practically postpone the collections until we can manually clean the nursery during level changes and other appropriate spots).
So I think the issue is indeed resolved this way, assuming that there are no unknown side effects from these settings compared to Mono, but nothing was observed on my side, so the chances are that everything is working equally well as under Mono. Thanks a lot for the effort!
Regarding 2 and 3, it is good to know something like
GC.TryStartNoGCRegiondoes exist already, and it is indeed a Java GC bridge / java GC collection / wait for it that is taking a lot of time.Finally, while we have successfully mitigated the problem by postponing collections, I think other animation-using apps on Android (so anything where the user wanted to make it a bit nicer) may suffer from the same jankiness caused by the java collection wait delay, which may easily cause all animations on Android to be janky if there is sufficient memory pressure or GC feels otherwise particularly active; I in fact heard about the GC nursery size increase solution from the developer of one such business app who was complaining about his animations being janky. Not sure if anything can be done about the GC bridge performance, but I think surprisingly many Android apps may hit this jankiness issue in practice, so it might be worthwhile to think going forward if it would be possible to make some structural changes in the future that would make the java bridge / collection to cause much shorter delays (e.g., of the order of a couple of ms only), therefore enabling consistently smooth animations in Android apps.
simonrozsival commentedon Sep 1, 2026
Follow-up investigation: Android scheduling, CoreCLR GC, and the Java GC bridge
We performed a series of source investigations and on-device experiments to understand why the CoreCLR Java GC bridge causes longer frame-time stalls than Mono, and whether Android scheduling controls can close the gap.
Test environment
SM-S911B), arm64, Android 16 / API 36Net11FPSBenchmarksched_switch,sched_waking, andcpu_frequencytracesrelease/11.0runtime source corresponding to the Android SDK runtime packThese are single-device experimental results, not cross-device performance claims.
Confirmed baseline mechanism
The CoreCLR and Mono bridge carrier threads had the same elevated scheduler priority:
Thread-6GLThread 3CoreCLR was not being starved by scheduler priority, and it was not running on little CPUs: it used the S23 performance cluster. The performance cores were simply near their minimum operating point when the short bridge burst began.
Perfetto showed the utilization-history difference directly:
Runtime.gc()The Linux PELT utilization signal has an approximately 32 ms half-life. A pthread that sleeps for several seconds and then performs a 10-25 ms burst is a worst-case workload for PELT/schedutil ramp-up.
Why Mono runs on a hot thread
Mono does not select or dispatch to the UI/render thread.
For blocking SGen collections:
Runtime.gc().In this benchmark, the GL thread allocated most aggressively, so it statistically triggered most collections. It was already hot from rendering and from completing the entire managed collection. This is workload-dependent, not an explicit Mono scheduling policy.
CoreCLR invokes
TriggerClientBridgeProcessingin the middle of mark processing, before weak/finalization scans and before plan/relocate/compact/sweep. The callback isGC_NOTRIGGERand must return immediately, so dotnet/android publishes the argument block to a persistent detached pthread. That thread cannot enter the managed validation callback until CoreCLR restarts the world, and then begins the expensive JNI/ART phase with little utilization history.Experiments
Dispatching to the Android main thread
Posting
Runtime.gc()through the mainALooperdeadlocked. The bridge thread waited for main-looper work while the main thread was already waiting on bridge-dependent peer resolution. Delaying activation avoided startup deadlock but still deadlocked during steady state.This also would not reproduce Mono: Mono used the GL thread, not Android's main thread.
Running JNI and ART during CoreCLR STW
We ran native bridge preparation,
Runtime.gc(), and cleanup synchronously inside CoreCLR's existing mid-mark callback, then used the semaphore thread only for managed completion after restart.The path completed repeatedly without a crash in the test workload, but ART became slower:
The inline thread remained around 0.8-0.98 GHz and ART ran nested inside CoreCLR's STW interval. This path also bypassed managed validation before dereferencing bridge contexts and has workload-dependent dual-runtime deadlock risk. It is not a viable product design.
Skipping or coalescing Java GC
Skipping
Runtime.gc()entirely:Running ART only every second or fourth bridge round produced no repeatable throughput improvement and worsened tail latency and retention. This agrees with the earlier finding that bridge objects are promoted whether or not a request is sent.
CPU affinity
An unprivileged app can set its own thread affinity. Dynamic big-cluster affinity successfully restricted the S23 bridge thread to CPUs 3-7, but performance regressed:
The unrestricted scheduler was already choosing performance CPUs. Affinity restricted placement but did not request a higher frequency.
Direct
uclamp.minThe emulator kernel accepted
uclamp.minvalues 256, 512, 768, and 1024. The stock S23 rejected every positive value withEPERM, despiteCONFIG_UCLAMP_TASK=y.Android kernels require
CAP_SYS_NICEto increase a per-task clamp. Ordinary applications can reduce their clamp but cannot raise the default zero request. Direct uclamp is therefore not a deployable stock-device fallback.Java
PerformanceHintManagerfeedbackAn API 31 Java hint session was accepted and bound to the bridge TID, with a 4 ms target. Reporting bridge duration after each round did not help:
The API is designed for frequent periodic work. A bridge thread sleeping approximately seven seconds between reports does not retain a useful boost.
API 36 workload spike on the bridge thread
APerformanceHint_notifyWorkloadSpike()resolved successfully and returned0. Calling it immediately before bridge work:This matches the earlier Pixel result that the spike-only addition was worth roughly 4%. Notifying immediately before a 15 ms operation leaves little time for the Power HAL and hardware to react.
Full managed-GC lifecycle hints
CoreCLR already has internal lifecycle hooks:
GCToEEInterface::GcStartWork->Interop::OnGCStartedGCToEEInterface::GcDone->Interop::OnGCFinishedThey bracket mark/plan/relocate/sweep for every generation. A local exact-base CoreCLR experiment added three modes:
observe: timing only;feedback: one process-wide ADPF session and duration feedback;spike: the same session plus API 36 spike notification at GC start.The Android bridge TID and the two recurring GC-work TIDs were grouped into one reusable session.
For managed gen0 work alone, the result was promising and consistent:
However, the asynchronous bridge did not inherit that gain:
Approximate managed-GC-plus-bridge time was therefore unchanged or slightly worse: 25.99 ms observe, 26.55 ms feedback, and 26.68 ms spike.
OnGCFinishedreports the managed cycle before the asynchronous bridge begins. Grouping the bridge TID into the session did not preserve the managed-GC boost across that cycle boundary.Combining full-GC hints with bridge work on the GC thread
We combined the full-GC hooks with native bridge/ART work inside the existing CoreCLR mid-mark callback. The semaphore helper performed only managed
FinishCrossReferenceProcessingafter restart.Pooled combined duration:
Spike averaged approximately 5% faster, but the two spike trials were inconsistent (30.99 ms and 22.85 ms), and FPS/tail metrics did not improve. This remained the unsafe mid-STW design, not Mono's post-complete/post-restart ordering.
Conclusions
Remaining options
1. True post-complete, post-restart continuation
For Workstation blocking GC:
GCHeap::GarbageCollectGenerationalready has a common post-restart/post-lock point for Workstation and Server triggering threads. Background GC has no equivalent originating mutator and would retain the dedicated-thread fallback.This is the only remaining experiment that reproduces Mono's ordering rather than only its thread identity. It requires a dotnet/runtime interface extension and careful reentrancy/weak-reference testing.
2. Continue deferred-ART/WREF work
The investigation in dotnet/android#12436 showed that natural ART collection can improve typical bridge latency and frame pacing, but it needs:
3. Land precise weak-reference waiting
#131952 remains orthogonal and valuable: unrelated weak-reference reads should not wait for a bridge round. It addresses a substantial part of UI-thread off-CPU time even though it does not make ART itself faster.
4. Retain GC-size tuning as the practical workaround
For latency-critical applications, increasing both:
reduces how often the bridge tax is paid. This remains the only proven deployable mitigation available across existing devices.
Related work
Reduce weak ref blocking with java interop (#131952)