Posted on 10. November 2023

.NET 8 Performance Improvements in .NET MAUI

.NET 8 Вдосконалення продуктивності .NET 8 в .NET MAUI

 

Основна увага в .NET MAUI у релізі .NET 8 приділяється якості. Тому ми зосередилися на виправленні помилок замість того, щоб гнатися за високими показниками продуктивності. У .NET 8 ми об'єднали 1 559 запитів, які закрили 596 проблем. Вони включають зміни від команди .NET MAUI, а також від спільноти .NET MAUI. Ми оптимістично налаштовані, що це повинно призвести до значного підвищення якості в .NET 8.

Проте! У нас є ще багато змін у продуктивності, які ми можемо продемонструвати. Спираючись на фундаментальні покращення продуктивності  в .NET 8 ми постійно знаходимо легку здобич, і на GitHub були проблеми з продуктивністю, які ми намагалися вирішити. Наша мета — продовжувати робити .NET MAUI швидшим з кожним релізом, читайте далі, щоб дізнатися подробиці!


Огляд покращень продуктивності в попередніх релізах можна знайти в наших статтях для .NET 6 і 7. Це також дасть вам уявлення про покращення, які ви побачите при переході з Xamarin.Forms на .NET MAUI:

        .NET 7 Покращення продуктивності в .NET MAUI

        .NET 6 Покращення продуктивності в .NET MAUI


Зміст


Нові функції

-      AndroidStripILAfterAOT

-      AndroidEnableMarshalMethods

-      NativeAOT на iOS


Побудова та продуктивність внутрішнього циклу

-         Відфільтруйте вихідні дані Android ps -A за допомогою grep

-         Перенесення використання vcmeta.dll з WindowsAppSDK на C#

-        Покращення віддалених збірок iOS на Windows

-        Покращення внутрішнього циклу Android

-        Компіляція XAML більше не використовує LoadInSeparateAppDomain


Покращення продуктивності або розміру програми

-        Структури та IEquatable в .NET MAUI

-        Виправлено проблему продуктивності в {AppThemeBinding}

-        Зверніться до CA1307 та CA1309 для отримання інформації про продуктивність

-        Зверніться до CA1311 для отримання інформації

-        Видалення невикористаної події ViewAttachedToWindow на Android

-        Видаліть непотрібні System.Reflection для {Binding}

-        Використовуйте StringComparer.Ordinal для словника та хеш-набору

-        Зменшення взаємодії з Java у MauiDrawable на Android

-        Покращення продуктивності верстки Label на Android

-        Зменшення викликів взаємодії Java для елементів керування в .NET MAUI

-        Покращення продуктивності Entry.MaxLength на Android

-        Зменшення використання пам'яті CollectionView на Windows

-        Використовуйте атрибут UnmanagedCallersOnlyAttribute на платформах Apple

-        Швидша взаємодія з Java для рядків на Android

-        Швидша взаємодія з Java для подій C# на Android

-        Використання функціональних покажчиків для JNI

-        Видалено Xamarin.AndroidX.Legacy.Support.V4

-        Дедуплікація генериків на iOS та macOS

-        Виправлено реалізацію System.Linq.Expressions на iOS-подібних платформах

-        Встановіть DynamicCodeSupport=false для iOS та Catalyst


Витоки пам'яті

-        Витоки пам'яті та якість

-        Діагностика витоків в .NET MAUI

-        Патерни, що призводять до витоків: Події C#

-        Кільцеві посилання на платформах Apple

-        Аналізатор Roslyn для платформ Apple

Інструменти та документація

-        Спрощені dotnet-trace та dotnet-dsrouter

-        Підтримка мобільних пристроїв dotnet-gcdump 


Нові функції

AndroidStripILAfterAOT

Одного разу у нас виникла геніальна думка: якщо AOT попередньо компілює методи C#, то чи потрібен нам керований метод? Видалення тіла методу C# дозволило б зменшити розмір збірок.  Додатки .NET iOS вже роблять це, так чому б не зробити це і для Android?

Хоча ідея проста, реалізація не була такою: iOS використовує "повний" AOT, який перетворює всі методи AOT у форму, що не вимагає використання JIT під час виконання. Це дозволило iOS запустити cil-strip видаляючи всі тіла методів з усіх керованих типів.

На той час Xamarin.Android підтримував лише "звичайний" AOT, а звичайний AOT вимагає JIT для певних конструкцій, таких як узагальнені типи та узагальнені методи. Це означало, що спроба запустити cil-strip призведе до помилок під час виконання, якщо буде видалено тіло методу, яке насправді потрібно під час виконання. Це було особливо погано, оскільки cil-strip могла видалити лише всі тіла методів!

Ми повторно впроваджуємо IL-розбірку для .NET 8. Додаємо нову властивість $(AndroidStripILAfterAOT) для MSBuild. При значенні true завдання буде відстежувати, які тіла методів були фактично AOT'ені, зберігаючи цю інформацію у %(_MonoAOTCompiledAssemblies.MethodTokenFile), а нове завдання буде оновлювати вхідні збірки, видаляючи всі тіла методів, які можна видалити.

 

За замовчуванням увімкнення $(AndroidStripILAfterAOT) замінить стандартне налаштування $(AndroidEnableProfiledAot), дозволяючи вилучити всі обрізані AOT'івські методи. Цей вибір було зроблено тому, що $(AndroidStripILAfterAOT) є найбільш корисним при AOT-компіляції всього вашого додатку. Профільоване вилучення AOT та IL можна використовувати разом, явно встановивши обидва параметри у файлі .csproj, але єдиною перевагою буде невелике зменшення розміру .apk:

.apk для нового додатку dotnet для Android:

Зауважте, що AndroidStripILAfterAOT=false та AndroidEnableProfiledAot=true є типовим середовищем конфігурації релізу, для 7.7MB.

Проєкт, який встановлює лише AndroidStripILAfterAOT=true, неявно встановлює AndroidEnableProfiledAot=false, призводить до отримання програми розміром 8.1 МБ.

Дивіться xamarin-android#8172 та dotnetdotnet/runtime#86722, щоб дізнатися більше про цю можливість.

AndroidEnableMarshalMethods

 

.NET 8 вводить нове експериментальне налаштування для конфігурацій Release:

Ми сподіваємося зробити цю функцію доступною за замовчуванням у .NET 9, але наразі ми надаємо цю можливість як експериментальну. Програми, які орієнтовані лише на одну архітектуру, наприклад RuntimeIdentifier=android-arm64, ймовірно, зможуть увімкнути цю функцію без проблем.

Загальні відомості про методи Маршала

Метод Маршала JNI - це вказівник на функцію, що викликається з JNI, який надається в JNIEnv::RegisterNatives(). Наразі маршальські методи JNI надаються через взаємодію між кодом, який ми генеруємо, та JNINativeWrapper.CreateDelegate():

-        Наш генератор коду видає "справжній" JNI метод, який можна викликати.

-         JNINativeWrapper.CreateDelegate() використовує System.Reflection.Emit для обгортки методу для обробки виключень.

Маршальські методи JNI потрібні для всіх переходів з Java на C#.

 

Розглянемо віртуальний метод Activity.OnCreate():

Activity.n_OnCreate_Landroid_os_Bundle_() - це метод маршала JNI, який відповідає за перетворення параметрів зі значень JNI у типи C#, перенаправлення виклику методу до Activity.OnCreate() та (за необхідності) перетворення значення, що повертається, назад до JNI.

Activity.GetOnCreate_Landroid_os_Bundle_Handler() є частиною інфраструктури реєстрації типів, надаючи екземпляр Delegate до RegisterNativeMembers .RegisterNativeMembers(), який згодом передається до JNIEnv::RegisterNatives().

Хоча це і працює, результат не є неймовірно продуктивним: якщо не використовувати один з оптимізованих типів делегатів, доданих в xamarin-android#6657, System.Reflection.Emit використовується для створення обгортки навколо методу marshal, чого ми намагалися уникати роками.

Отже, ідея: оскільки ми вже збираємо нативний інструментарій і використовуємо LLVM-IR для створення libxamarin-app.so, що, якби ми видали нативні імена методів Java і пропустили все, що було зроблено в рамках Runtime.register() і JNIEnv.RegisterJniNatives()?

 

Дано:

Під час збирання, libxamarin-app.so міститиме цю функцію:

Під час виконання програми, виклик Runtime.register(), присутній в Java викликається Java-обгортці, буде або пропущено, або не буде виконуватися, і Android/JNI натомість перетворить MyActivity.n_onCreate() у Java_crc..._MyActivity_n_1onCreate().

Ми називаємо цю роботу "LLVM Marshal Methods", яка наразі є експериментальною в .NET 8. Багато особливостей все ще досліджуються, і ця функція буде поширюватися на різні області.

Дивіться xamarin-android#7351, щоб дізнатися більше про цю експериментальну функцію.

NativeAOT на iOS

У .NET 7 ми почали експеримент, щоб побачити, що потрібно для підтримки NativeAOT на iOS. Перехід від прототипу до початкової реалізації: .NET 8 Preview 6 включав NativeAOT як експериментальну функцію для iOS.

 

Щоб вибрати NativeAOT у проєкті MAUI iOS, скористайтеся наступними налаштуваннями у файлі проєкту:

 

Потім створити додаток для iOS-пристрою:

Зауваження. Ми можемо розглянути можливість уніфікації та покращення назв властивостей MSBuild для цієї функції у майбутніх релізах .NET. Щоб виконати одноразову збірку у командному рядку, вам також може знадобитися вказати -p:PublishAotUsingRuntimePack=true на додаток до -p:PublishAot=true.

Однією з головних проблем першого релізу було те, як робоче навантаження iOS підтримує інтероперабельність Objective-C. Проблема була пов'язана в основному з системою реєстрації типів, яка є ключовим компонентом для ефективної підтримки iOS-подібних платформ (дивіться  документацію  для  деталей). У своїй реалізації система реєстрації типів залежить від токенів метаданих типів, які недоступні у NativeAOT. Тому, щоб скористатися перевагами високоефективного середовища виконання NativeAOT, нам довелося адаптуватися. dotnetdotnet/runtime#80912 містить обговорення того, як розв'язати цю проблему, і, нарешті, в xamarin-macios#18268 ми реалізували новий керований статичний реєстратор, який працює з NativeAOT. Новий керований статичний реєстратор не тільки сумісний з NativeAOT, але і набагато швидший за стандартний, і доступний для всіх підтримуваних середовищ виконання (див. у  документації for докладніше).

На цьому шляху нам дуже допомогла наша GH-спільнота, і їхній внесок (огляди коду, PR) був дуже важливим, щоб допомогти нам швидко рухатися вперед і випустити цю функцію вчасно. Ось деякі з багатьох PR, які допомогли нам і розблокували наш шлях:

-        dotnet/runtime#77956

-       dotnet/runtime#78280

-        dotnet/runtime#82317

-        dotnet/runtime#85996

і цей список можна продовжувати...

З появою .NET 8 Preview 6 ми нарешті змогли випустити нашу першу версію NativeAOT на iOS, яка також підтримує MAUI. Дивіться в  пост в блозі про .NET 8 Preview 6, щоб дізнатися більше про те, чого нам вдалося досягти в початковій версії.

У наступних випусках .NET 8 результати значно покращилися, оскільки ми виявляли та вирішували проблеми на цьому шляху. На графіку нижче показано порівняння розміру шаблонних додатків .NET MAUI для iOS у попередніх версіях:

Ми досягли стабільного прогресу і повідомили про очікувану економію розміру, завдяки вирішенню наступних проблем:

-        dotnet/runtime#87924 - виправлено серйозну проблему з розміром NativeAOT з AOT-несумісними шляхами коду у System.Linq.Expressions, а також зроблено повністю сумісними з NativeAOT при націлюванні на iOS

-        xamarin-macios#18332 - зменшено розмір розділу __LINKEDIT Export Info у вилучених двійкових файлах

Крім того, в останньому випуску RC 1 розмір додатків ще більше зменшився, досягнувши  на 50% меншого розміру для шаблонних додатків .NET MAUI для iOS порівняно з Mono. Найвпливовіші проблеми/ПР, які сприяли цьому:

-        xamarin-macios#18734 - Зробити Full  режимом з'єднання за замовчуванням для NativeAOT

-        xamarin-macios#18584 - Зробити обрізку кодової бази сумісним за допомогою серії PR.

Попри те, що розмір програми був основною метрикою, на якій ми зосередилися, для випуску RC 1 ми також виміряли час запуску для шаблонного додатка .NET MAUI для iOS, порівнюючи NativeAOT і Mono, де NativeAOT показав майже вдвічі швидший час запуску.

Основні висновки

Для сценаріїв NativeAOT на iOS зміна режиму зв'язування за замовчуванням на Full (xamarin-macios#18734), ймовірно, є найбільшим покращенням для розміру програми. Але водночас, ця зміна може призвести до поломки додатків, які не є повністю сумісними з AOT та обрізкою. У режимі Full, тример може обрізати несумісні з AOT шляхи коду (згадайте про використання рефлексії), доступ до яких здійснюється динамічно під час виконання. Режим зв'язування Full  не є типовою конфігурацією у разі використання середовища виконання Mono, тому деякі програми можуть бути не повністю сумісними з AOT.

Підтримка NativeAOT на iOS є експериментальною функцією і все ще перебуває в процесі розробки, і ми плануємо вирішувати потенційні проблеми з режимом повного посилання поступово:

-        Як перший крок, ми увімкнули попередження про обрізання, AOT та однофайлові попередження за замовчуванням у xamarin-macios#18571. Увімкнені попередження мають попередити наших клієнтів про те, що використання певного фреймворку, бібліотеки або деяких конструкцій C# у їхньому коді несумісне з NativeAOT - і може призвести до аварійного завершення роботи під час виконання. Ця інформація має допомогти нашим клієнтам писати  код сумісний з AOT, а також допомогти нам покращити наші фреймворки та бібліотеки з тією ж метою — повністю використати переваги компіляції AOT.

-        Другим кроком було усунення всіх попереджень від збірок Microsoft.iOS та System.Private.CoreLib, про які повідомлялося для шаблонного додатку iOS з: xamarin-macios#18629 та dotnetdotnet/runtime#91520.

-        У наступних випусках ми плануємо вирішити проблеми, пов'язані з фреймворком MAUI, та покращити загальний користувацький досвід. Наша мета — створити повністю сумісний з AOT та обрзкою фреймворк.

NET 8 буде підтримувати таргетинг платформ iOS з NativeAOT як опціональну функцію і демонструє великий потенціал, генеруючи на 50% менший і на 50% швидший запуск у порівнянні з Mono. Враховуючи високу продуктивність, яку обіцяє NativeAOT, будь ласка, допоможіть нам на цьому шляху і випробуйте свої додатки з NativeAOT та повідомляйте про будь-які потенційні проблеми. Водночас давайте нам знати, коли NativeAOT працює без проблем одразу.

Щоб стежити за подальшим прогресом, див. dotnetdotnet/runtime#80905. І останнє, але не менш важливе, ми хотіли б подякувати нашим учасникам GH, які допомагають нам зробити NativeAOT на iOS можливим.

Побудова та продуктивність внутрішнього циклу

Відфільтруйте вихідні дані Android ps -A за допомогою grep

При перегляді внутрішнього циклу Android для проєкту .NET MAUI з допомогою PerfView ми виявили, що близько 1,2% процесорного часу витрачається лише на отримання ідентифікатора процесу запущеного Android-додатку.

Змінивши багатослівність виводу на Diagnostics -  Tools > Options > Xamarin > Xamarin Diagnostics output , ви зможете побачити:

Розширення Xamarin/.NET MAUI у Visual Studio щосекунди опитує, чи не завершено роботу програми. Це корисно для зміни стану кнопки відтворення/зупинки, якщо ви примусово закриваєте програму тощо.

 

Тестуючи на Pixel 5, ми побачили, що команда насправді виводить 762 рядки!

Натомість ми могли б зробити щось на кшталт:

Де ми передаємо вивід ps -A до команди grep на пристрої Android. Так, Android має підмножину unix-команд! Ми фільтруємо або за рядком, що містить PID, або за назвою пакунка вашої програми.

 

В результаті тепер IDE розбирає лише 4 рядки:

Це не тільки оптимізує пам'ять, яка використовується для розділення та аналізу цієї інформації в C#, але adb також передає набагато менше байт через ваш USB-кабель або віртуально з емулятора.

Ця можливість з'явилася в останніх версіях Visual Studio 2022, що покращує цей сценарій для всіх користувачів Xamarin і .NET MAUI.

Перенесення використання vcmeta.dll з WindowsAppSDK на C#

 

Ми виявили, що кожна інкрементна збірка проєкту .NET MAUI, що працює під Windows, витрачає час:

Це компілятор XAML для WindowsAppSDK, який компілює XAML для WinUI3 (не .NET MAUI XAML). У проєктах .NET MAUI дуже мало XAML цього типу, фактично, єдиним файлом є файл Platforms/Windows/App.xaml у шаблоні проєкту.

 

Цікаво, що якщо в інсталяторі Visual Studio встановити Desktop development with C++ workload, то цей час просто зникне!

Компілятор XAML WindowsAppSDK звертається до власної бібліотеки з робочого навантаження C++, vcmeta.dll, щоб обчислити хеш для файлів збірки .NET. Це використовується для прискорення інкрементальних збірок — якщо хеш змінюється, компілюйте XAML заново. Якщо vcmeta.dll не було знайдено на диску, компілятор XAML фактично "перекомпілював все" при кожній інкрементальній збірці.

Для початкового виправлення ми просто включили невелику частину навантаження C++ як залежність .NET MAUI у Visual Studio. Дещо більший розмір інсталяції був гарним компромісом для економії до 4 секунд часу інкрементальної збірки.

 

Далі ми реалізували функціонал хешування vcmeta.dll звичайною мовою C# за допомогою System.Reflection.Metadata, щоб обчислювати ідентичні хеш-значення, як і раніше. Ця реалізація була не тільки кращою, оскільки ми могли позбутися залежності від робочого навантаження C++, але й швидшою! Час обчислення одного хешу:

Деякі з причин, чому це було швидше:

-       Не залучені p/invoke або COM-інтерфейси.

-        System.Reflection.Metadata має швидкий API на основі структур, що ідеально підходить для ітерацій над типами в .NET збірці та обчислення хеш-значення.

В результаті CompileXaml може бути навіть швидшим за 9 мс в інкрементних збірках.

 

Цю можливість було включено до WindowsAppSDK 1.3, який тепер використовується .NET MAUI у .NET 8. Докладні відомості про це вдосконалення див. у WindowsAppSDK#3128.

Покращення віддалених збірок iOS на Windows

Порівнюючи продуктивність внутрішнього циклу для iOS, було виявлено значний розрив між "віддаленою iOS" розробкою на Windows і локальною розробкою на macOS. Було зроблено багато невеликих удосконалень, заснованих на порівнянні файлів .binlog внутрішнього циклу, записаних на macOS, з файлами, записаними всередині Visual Studio на Windows.

Деякі приклади включають:

-        maui#12747: не копіювати явно файли на сервер збірки

-        xamarin-macios#16752: не копіювати файли на сервер збірки для операції видалення

-        xamarin-macios#16929: пакетне видалення файлів через DeleteFilesAsync

-        xamarin-macios#17033: кешувати шлях до компілятора AOT

-       Розширення Xamarin/MAUI Visual Studio: під час запуску dotnet-install.sh на віддалених хостах збірки встановіть явний прапорець процесора для комп'ютерів M1 Mac.

Ми також зробили деякі покращення для всіх проектів iOS та MacCatalyst, такі як:

-         xamarin-macios#16416: не обробляйте збірки знову і знову

Покращення внутрішнього циклу Android

Ми також зробили багато невеликих поліпшень у "внутрішній цикл" на Android  —  більшість з них були зосереджені на конкретній області.

Раніше проєкти Xamarin.Forms мали розкіш бути організованими в декілька проєктів, таких як:

-        YourApp.Android.csproj: Проєкт програми Xamarin.Android

-        YourApp.iOS.csproj: Проєкт додатку Xamarin.iOS

-        YourApp.csproj: бібліотека класів netstandard2.0

Де майже вся логіка програми Xamarin.Forms містилася в проекті netstandard2.0. Майже всі інкрементні збірки були б змінами до XAML або C# у бібліотеці класів. Така структура дозволила Xamarin.Android MSBuild-цілям повністю пропустити багато специфічних для Android кроків MSBuild. У .NET MAUI функція "єдиного проекту" означає, що кожна інкрементальна збірка повинна виконувати ці специфічні для Android кроки збірки.

Зосередившись саме на покращенні цієї сфери, ми внесли багато невеликих змін, таких як:

-        java-interop#1061: уникнення string.Format()

-        java-interop#1064: покращено ToJniNameFromAttributesForAndroid

-        java-interop#1065: уникнення перевірки File.Exists()

-        java-interop#1069: виправлено більше місць використання TypeDefinitionCache

-        java-interop#1072: використання меншої кількості System.Linq для користувацьких атрибутів

-        java-interop#1103: використання MemoryMappedFile при використанні Mono.Cecil

-        xamarin-android#7621: уникнення перевірки File.Exists()

-        xamarin-android#7626: покращено роботу LlvmIrGenerator.

-        xamarin-android#7652: швидкий шлях для

-        xamarin-android#7653: затримка ToJniName під час генерації AndroidManifest.xml

-        xamarin-android#7686: ліниве заповнення пошуку ресурсів

Ці зміни мають покращити інкрементні збірки у всіх типах проектів .NET 8 для Android.

Компіляція XAML більше не використовує LoadInSeparateAppDomain

Переглядаючи звіт JITStats в PerfView (для MSBuild.exe):

Схоже, що Microsoft.Maui.Controls.Build.Tasks.dll проводив багато часу в JIT. Що було незрозуміло, так це те, що це була інкрементна збірка, де все вже повинно бути завантажено. Робота JIT вже повинна бути виконана?

Причиною є використання атрибута [LoadInSeparateAppDomain], визначеного за допомогою у .NET MAUI. Це функція MSBuild, яка дозволяє завданням MSBuild запускатися в ізольованому AppDomain - з очевидним недоліком продуктивності. Однак, ми не могли просто видалити його, оскільки це призвело б до ускладнень...

[LoadInSeparateAppDomain] також зручно скидає весь статичний стан при повторному запуску >. Це означає, що майбутні інкрементні збірки потенційно використовуватимуть старі (сміттєві) значення. Існує декілька місць, які кешують об'єкти Mono.Cecil з міркувань продуктивності. Якщо цього не зробити, можуть виникнути дуже дивні помилки.

Для того, щоб зробити цю зміну, ми переробили весь статичний стан у компіляторі XAML, щоб він зберігався у полях та властивостях екземпляра. Це загальне покращення дизайну програмного забезпечення, на додаток до можливості безпечно видалити [LoadInSeparateAppDomain].

 

Результати цієї зміни для інкрементальної збірки на ПК з Windows:

Це дозволило заощадити близько 587 мс на інкрементних збірках на всіх платформах, що становить покращення на 82%. Це ще більше допоможе у великих рішеннях з декількома проєктами .NET MAUI, де виконується декілька разів.

Дивіться maui#11982 для більш детальної інформації про це покращення.

Покращення продуктивності або розміру програми

Структури та IEquatable в .NET MAUI

 

За допомогою профілю Visual Studio .NET Object Allocation Tracking на прикладі клієнтського додатку .NET MAUI ми побачили:


Це здається непомірним обсягом пам'яті для запуску прикладу програми!

 

Заглибимось, щоб побачити, де ці структури були створені:

Основна проблема полягала в тому, що ця структура не реалізовувала IEquatable і використовувалась як ключ для словника. Правило аналізу коду CA1815 було розроблено для виявлення цієї проблеми. Це правило не вмикається за замовчуванням, тому проєкти повинні вибрати його.

Щоб вирішити це:

-        Підписка є внутрішньою для .NET MAUI, і її використання дозволило зробити структуру доступною лише для читання. Це стало просто додатковим покращенням.

·        Ми зробили CA1815 помилкою збірки у всьому репозиторії dotnet/maui.

·        Ми реалізували IEquatable для всіх типів struct.

Після цих змін ми більше не змогли знайти Microsoft.Maui.WeakEventManager+Subscription у знімках пам'яті взагалі. Що дозволило заощадити ~21 МБ розподіленої пам'яті у цьому прикладі програми. Якщо у ваших проєктах використовується struct, то варто зробити CA1815 помилкою збірки.

Меншу цільову версію цієї зміни було перенесено до MAUI у .NET 7. Докладні відомості про це покращення наведено у maui#13232.

Виправлено проблему продуктивності в {AppThemeBinding}

 

Аналізуючи зразок додатка .NET MAUI від клієнта, ми помітили багато часу, який витрачається на {AppThemeBinding} та WeakEventManager під час скролінгу:

У цьому додатку відбувалося наступне:

-        Стандартний шаблон проєкту .NET MAUI має багато {AppThemeBinding} у файлі Styles.xaml за замовчуванням. Це підтримка світлих та темних тем.

-        {AppThemeBinding} підписується на Application.RequestedThemeChanged

-        Отже, кожен перегляд MAUI підписується на цю подію - можливо, кілька разів.

-        Передплатники — це Dictionary>>, де відбувається пошук за словником, за яким слідує O(N) пошук операцій відписки.

Тут є потенційна можливість створити узагальнений шаблон "слабкої події" для .NET. Реалізація в .NET MAUI прийшла з Xamarin.Forms, але узагальнений шаблон може бути корисним для .NET розробників, які використовують інші фреймворки інтерфейсу користувача.

Щоб зробити цей сценарій швидким, поки що в .NET 8:

Раніше:

-        Для будь-якого {AppThemeBinding} він викликає обидва:

-        RequestedThemeChanged -= OnRequestedThemeChanged O(N) times

-       RequestedThemeChanged += OnRequestedThemeChanged постійний час

-       Де -= помітно повільніше, через, можливо, сотні підписників.

Після:

-        Створіть булеву функцію _attached, щоб ми знали "стан", чи вона приєднана, чи ні.

-        Нові прив'язки викликають лише +=, тоді як -= тепер викликатиметься {AppThemeBinding} лише у рідкісних випадках.

-        Більшість програм .NET MAUI не "скасовують" прив'язки, але -= буде використано лише у цьому випадку.

Повну інформацію про це виправлення див. в maui#14625. Див. dotnetdotnet/runtime#61517 про те, як ми можемо реалізувати "слабкі події" у .NET у майбутньому.

Зверніться до CA1307 та CA1309 для отримання інформації про продуктивність

 

Переглядаючи зразок додатка .NET MAUI від клієнта, ми помітили час, що витрачається на "культурно-орієнтовані" операції з рядками:

Цю ситуацію можна покращити, просто викликавши натомість ToLowerInvariant(). У деяких випадках ви можете навіть розглянути можливість використання string.Equals() з StringComparer.Ordinal. У цьому випадку наш код було додатково переглянуто та оптимізовано у статтях Зменшення  Java  взаємодії з  у  MauiDrawable  на  Android.

У .NET 7 ми додали правила аналізу коду CA1307 і CA1309 для виявлення подібних випадків, але, схоже, ми пропустили деякі з них у Microsoft.Maui.Graphics.dll. Ці правила можуть бути корисними для використання у ваших власних додатках .NET MAUI, оскільки уникнення всіх культурно-залежних операцій з рядками може мати значний вплив на мобільні пристрої.

Дивіться maui#14627, щоб дізнатися більше про це покращення.

Зверніться до CA1311 для отримання інформації

Після розгляду правил аналізу коду CA1307 і CA1309 ми пішли далі і розглянули CA1311.

 

Як згадувалося в турецькому  прикладі, подібні дії:

Можуть спричинити неочікувану поведінку в турецьких регістрах, оскільки в турецькій мові символ I (Юнікод 0049) вважається верхнім регістром іншого символу ý (Юнікод 0131), а i (Юнікод 0069) вважається нижнім регістром ще одного символу Ý (Юнікод 0130).

ToLowerInvariant() і ToUpperInvariant() також краще для продуктивності, оскільки інваріантна операція ToLower / ToUpper працює трохи швидше. Це також дозволяє уникнути завантаження поточної культури, що покращує продуктивність запуску.

 

Існують випадки, коли вам потрібно використовувати поточну культуру, наприклад, у випадку типу CaseConverter у .NET MAUI. Для цього вам просто потрібно чітко вказати, яку культуру ви хочете використовувати:

Мета цього CaseConverter - показати користувачеві текст у верхньому або нижньому регістрі. Тому має сенс використовувати для цього CurrentCulture.

Дивіться maui#14773, щоб дізнатися більше про це покращення.

Видалення невикористаної події ViewAttachedToWindow на Android

 

Кожен Label в .NET MAUI був підписаний:

Це залишилося після рефакторингу, але з'явилося у виводі дотнет-траси як:

Де перше — це підписка, а друге — подія, що викликається з Java на C# - тільки для запуску порожнього керованого методу.

 

Просто видаливши підписку на цю подію та порожній метод, ми залишили лише кілька елементів управління для підписки на цю подію за потреби:

Дивіться maui#14833, щоб дізнатися більше про це покращення.

Видаліть непотрібні System.Reflection для {Binding}

 

Всі прив'язки в .NET MAUI зазвичай потрапляють до шляху коду:

Де ~53% часу, витраченого на застосування прив'язки, з'явилося в dotnet-трасі в методі MethodInfo.GetParameters():

 

Вищенаведений код C# просто знаходить тип властивості. Він використовує обхідний спосіб використання першого параметра задавача властивості, який можна спростити:

Ми могли побачити результати цієї зміни в бенчмарку BenchmarkDotNet:

Де ++ позначає нові зміни.

Дивіться maui#14830 для більш детальної інформації про це покращення.

Використання StringComparer.Ordinal для словника та хеш-набору

 

Проаналізувавши зразок додатку .NET MAUI від клієнта, ми помітили, що 4% часу під час прокрутки витрачається на пошук у словнику:

Спостерігаючи за стеком викликів, деякі з них надходили з культурно-орієнтованого пошуку рядків у .NET MAUI:

      



  - microsoft.maui!Microsoft.Maui.PropertyMapper.GetProperty(string)

  -microsoft.maui!Microsoft.Maui.WeakEventManager.AddEventHandler(System.EventHandler,string)

- microsoft.maui!Microsoft.Maui.CommandMapper.GetCommand(string)

Які відображаються в dotnet-trace у вигляді суміші рядкових порівнянь:

У випадку Dictionary або HashSet ми можемо використовувати StringComparer.Ordinal у багатьох випадках, щоб пришвидшити пошук у словнику. Це має дещо покращити продуктивність обробників та всіх елементів управління .NET MAUI на всіх платформах.

Дивіться maui#14900, щоб дізнатися більше про це покращення.

Зменшення взаємодії з Java у MauiDrawable на Android

 

Профілюючи зразок .NET MAUI клієнта під час скролінгу на Pixel 5, ми побачили, як цікаво він проводив час:

У цьому прикладі знаходиться всередині >, тому ви можете бачити, як це відбувається під час прокрутки.

 

Зокрема, ми розглянули код в .NET MAUI, такий як:

Це п'ять викликів з C# на Java. Створення нового методу в PlatformInterop.java дозволило нам скоротити його до одного разу.

 

Ми також покращили наступний метод, який виконує багато викликів з C# на Java:

Щоб бути більш лаконічно реалізованим на Java як:

 

Що зводить нашу нову реалізацію на стороні C# до одного виклику Java та створення структури Android.Graphics.Color:

Після цих змін ми побачили такий вивід dotnet-trace:

Це збільшує продуктивність будь-якого (та інших фігур) на Android і зменшує використання процесора на ~1% під час прокрутки у цьому прикладі.

Дивіться maui#14933 для більш детальної інформації про це покращення.

Покращення продуктивності верстки Label на Android

 

Тестуючи різні зразки додатків .NET MAUI на Android, ми помітили, що близько 5,1% часу витрачається на PrepareForTextViewArrange():

 

Більшість часу витрачається на виклик Android.Views.View.Context, щоб потім мати змогу викликати метод розширення:

Виклик властивості Context може бути дорогим через взаємодію між C# та Java. Java повертає хендл на екземпляр, після чого нам доводиться шукати всі наявні керовані об'єкти C# для Context. Якщо всієї цієї роботи можна просто уникнути, це може значно підвищити продуктивність.

У .NET 7 ми зробили перевантаження для ToPixels(), що дозволяє отримати те саме значення за допомогою Android.Views.View

 

Тож замість цього ми можемо зробити так:

Ця зміна не тільки покращила результати dotnet-trace, але й помітно вплинула на наші LOLs  в  секунду у нашому тестовому додатку порівняно з минулим роком:

Дивіться maui#14980, щоб дізнатися більше про це покращення.

Зменшення викликів взаємодії Java для елементів керування в .NET MAUI

Огляд чудового прикладу .NET MAUI "Surfing App" від @jsuarezruiz:

 

Ми помітили, що багато часу витрачається на взаємодію з Java під час прокрутки:

Ці методи були глибоко вкладені, виконуючи взаємодію з Java -> C# -> Java на багатьох рівнях. У цьому випадку, переміщення деякого коду з C# на Java може призвести до того, що він буде виконувати менше операцій, а в деяких випадках взагалі не буде виконувати жодної операції!

 

Так, наприклад, раніше DispatchDraw() перевизначався у C#, щоб реалізувати поведінку відсікання:

 

Створивши PlatformContentViewGroup.java, ми можемо зробити щось на зразок

setHasClip() викликається, коли відсікання увімкнено/вимкнено у будь-якому елементі управління .NET MAUI. Це дозволило загальному шляху взагалі не взаємодіяти з C#, а лише з тими елементами управління, які ввімкнули відсікання. Це дуже добре, тому що dispatchDraw() викликається досить часто під час верстки Android, скролінгу тощо.

Таку саму обробку було також зроблено з кількома іншими внутрішніми типами .NET MAUI, такими як WrapperView: покращено загальну ситуацію, щоб взаємодія відбувалася лише тоді, коли перегляди вибрали відсікання або відкидання тіней.

 

Для тестування впливу цих змін ми використовували Google's FrameMetricsAggregator від Google який можна налаштувати в Platforms/Android/MainActivity.cs будь-якого додатка .NET MAUI:

API FrameMetricsAggregator є дещо дивним, але дані, які ми отримуємо, є досить корисними. Результатом є таблиця пошуку, де ключ — це тривалість в мілісекундах, а значення — кількість "кадрів", які зайняли цю тривалість. Ідея полягає в тому, що будь-який кадр, який триває довше 16 мс, вважається "повільним" або "смиканим", як іноді кажуть в документації до Android.

 

Приклад .NET MAUI "Surfing App", що працює на Pixel 5:

 

Після внесення змін до ContentViewGroup та WrapperView ми отримали дуже гарне покращення! Навіть у додатку, що інтенсивно використовує обрізки та тіні:

Перегляньте maui#14275 для більш детальної інформації про ці зміни.

Збільшення продуктивності Entry.MaxLength на Android

Досліджуємо зразок клієнта .NET MAUI:

-        Навігація з випадаючого вікна Shell.

-        На нову сторінку з кількома елементами керування введенням.

-        Була помітна затримка у виконанні.

 

Під час профілювання на Pixel 5 одним з “гарячих шляхів” був Entry.MaxLength:

-        Виклики EditTextExtensions.UpdateMaxLength()

-        Getter та setter EditText.Text

-        Виклики EditTextExtensions.SetLengthFilter()

-        EditText.Get/SetFilters()

В результаті ми пересилаємо рядки та IInputFilter[] між C# та Java для кожного елемента управління Entry. Всі елементи управління Entry проходять через цей шлях коду (навіть ті, що мають значення MaxLength за замовчуванням), тому є сенс перенести частину цього коду з C# на Java.

 

Наш код на C# раніше:

Натомість перейшли на Java (з ідентичною поведінкою):

Це дозволяє уникнути маршування (копіювання!) значень рядків і масивів з C# на Java і навпаки. Завдяки цим змінам виклики EditTextExtensions.UpdateMaxLength() стали настільки швидкими, що вони повністю відсутні у виводі dotnet-trace, заощаджуючи ~19 мс при переході на сторінку у клієнтському прикладі.

Дивіться maui#15614, щоб дізнатися більше про це покращення.

Зменшення використання пам'яті CollectionView на Windows

 

Ми розглянули зразок клієнта .NET MAUI з CollectionView на 150 000 прив'язаних до даних рядків. Налагоджуючи те, що відбувається під час виконання, .NET MAUI ефективно виконував:

А потім кожен елемент створюється в міру прокручування:

Це був не найкращий підхід, але для покращення ситуації:

-        використовуйте натомість  Dictionary>, просто дозвольте йому динамічно змінювати розмір.

-        використовуйте TryGetValue(..., out var context), щоб кожен виклик звертався до індексатора на один раз менше, ніж раніше.

-        використовуйте або розмір пов'язаної колекції, або 64 (залежно від того, що менше) як приблизну оцінку того, скільки зображень може поміститися на екрані за один раз

 

Наш код змінюється на:

Після внесення цих змін, знімок пам'яті програми після запуску наступний:

Що економить близько 1 МБ пам'яті при запуску. У цьому випадку краще просто дозволити Словнику самому визначати свій розмір з оцінкою того, якою буде його ємність.

Дивіться maui#16838, щоб дізнатися більше про це покращення.

Використовуйте UnmanagedCallersOnlyAttribute на платформах Apple

Коли некерований код викликає керований код, наприклад, виклик зворотного виклику з Objective-C, раніше в Xamarin.iOS, Xamarin.Mac і .NET 6+ для цієї мети використовувався атрибут [MonoPInvokeCallbackAttribute]. Атрибут [UnmanagedCallersOnlyAttribute] з'явився як сучасна заміна цієї функції Mono, яка реалізована з урахуванням продуктивності.

На жаль, є кілька обмежень при використанні цього нового атрибуту:

·        Метод повинен бути позначений як статичний.

·        Не можна викликати з керованого коду.

·        Повинні бути тільки аргументи, що розбиваються на частини.

·        Не повинен мати параметрів узагальненого типу або міститися в узагальненому класі.

Нам довелося не тільки рефакторити "генератор коду", який створює багато прив'язок для API Apple для AppKit, UIKit і т.д., але й багато ручних прив'язок, які потребували б такої ж обробки.

В результаті більшість функцій зворотного виклику з Objective-C на C# повинні працювати швидше в .NET 8, ніж раніше. Дивіться xamarin-macios#10470 та xamarin-macios#15783 для отримання детальної інформації про ці покращення.

Швидша взаємодія з Java для рядків на Android

При зв'язуванні членів, що мають типи параметрів або типів повернення java.lang.CharSequence, член "перевантажується" для заміни CharSequence на System.String, а "оригінальний" член має суфікс Formatted.

 

Наприклад, розглянемо android.widget.TextView який має функцію getText() та setText() методи, які мають типи параметрів та повертають типи java.lang.CharSequence:

У зв'язаному вигляді це призводить до двох властивостей:

 

"Неформатоване перевантаження" працює шляхом створення тимчасового об'єкта String для виклику форматованого перевантаження, ось так виглядає фактична реалізація:

TextView.Text набагато легше зрозуміти і простіше використовувати для .NET розробників, ніж TextView.TextFormatted.

Проблемою такого підходу є продуктивність: створення нового екземпляру Java.Lang.String вимагає:

1.         Створення керованого однорангового об'єкту (екземпляру Java.Lang.String),

2.         Створення нативного однорангового екземпляру (екземпляр java.lang.String),

3.        І реєстрація співвідношення між (1) і (2)

А потім одразу цим і користуйтесь ..

 

Це особливо помітно у додатках .NET MAUI. Розглянемо приклад клієнта, який використовує XAML для встановлення прив'язаних до даних значень Text  у CollectionView, які зрештою потрапляють до TextView.Text. Профілювання показує:

6.3% часу скролінгу витрачається на встановлення властивості TextView.Text!

 

Цей випадок можна частково оптимізувати: якщо член *Formatted є (1) властивістю і (2) не є віртуальним, то ми можемо безпосередньо викликати метод встановлювача Java.Це дозволяє уникнути необхідності створення керованого однорангового пристрою та реєстрації зв'язку між одноранговими пристроями:

В результаті чого:

Час виклику встановлювача властивості TextView.Text зменшено до 20% від попереднього середнього часу виклику.

Зауважте, що віртуальний випадок є проблематичним з інших причин, але, на щастя, TextView.setText() не є віртуальним і, ймовірно, є одним з найбільш поширених API Android.

Дивіться java-interop#1101 для більш детальної інформації про це покращення.

Швидша взаємодія з Java для подій C# на Android

 

Проаналізувавши вибірку клієнтів .NET MAUI під час прокрутки на Pixel 5, ми побачили ~2,2% часу, витраченого на конструктор IOnFocusChangeListenerImplementor, через підписку на подію View.FocusChange:

MAUI підписується на Android.Views.View.FocusChange для кожного подання, розміщеного на екрані, що відбувається під час прокрутки у цьому прикладі.

 

Переглядаючи згенерований код конструктора IOnFocusChangeListenerImplementor, ми бачимо, що він все ще використовує застарілі API JNIEnv:

Які ми можемо змінити, щоб використовувати новіші/швидші API Java.Interop:

Вони кращі, оскільки еквівалентний виклик JNIEnv.FindClass() кешується, серед іншого. Це був лише один з випадків, який ми випадково пропустили, коли впроваджували нові API Java.Interop в Xamarin. Нам просто потрібно було оновити наш генератор коду, щоб він видавав кращу прив'язку C# для цього випадку.

 

Після цих змін ми побачили натомість результати в dotnet-trace:

Це повинно покращити продуктивність усіх подій C#, які обгортають слухачів Java, шаблон дизайну, який зазвичай використовується у програмах на Java та Android. Сюди входить подія FocusedChanged, яка використовується всіма представленнями .NET MAUI на Android.

 

Дивіться java-interop#1105 для більш детальної інформації про це покращення.

Використання функціональних покажчиків для JNI

 

Існують різні механізми та згенерований код, які роблять можливим взаємодію Java з C#. Візьмемо, наприклад, наступний метод екземпляру foo() в Java:

 

Метод C# з назвою CallObjectMethod відповідає за виклик власного інтерфейсу Java (JNI), який викликає JVM для виклику методу Java:

 

У Xamarin.Android, .NET 6 та .NET 7 всі виклики на Java проходять через java_interop_jnienv_call_object_method_a p/invoke, сигнатура якого має такий вигляд:

Що реалізовано на мові C наступним чином:

C# 9 вводить функції вказівники  що дозволило нам дещо спростити роботу — і, як наслідок, пришвидшити її.

Отже, замість того, щоб використовувати p/invoke у .NET 8, ми могли б викликати новий небезпечний метод з назвою CallObjectMethodA:

Який викликає покажчик на функцію C# напряму:

Цей покажчик на функцію оголошено з використанням нового синтаксису, введеного в C# 9:

Порівняння двох реалізацій з ручним бенчмарком:

У Release-збірці середній час виклику JIFunctionPointersTiming займає 97% від часу JIPinvokeTiming, тобто виходить на 3% швидше. Крім того, використання вказівників на функції C# 9 означає, що ми можемо позбутися всіх функцій C java_interop_jnienv_*(), що зменшує libmonodroid.so на ~55 КБ для кожної архітектури.

Дивіться xamarin-android#8234 та java-interop#938 для більш детальної інформації про це покращення.

Видалено Xamarin.AndroidX.Legacy.Support.V4

 

Переглядаючи залежності .NET MAUI для Android, ми помітили підозрілий пакет:

Якщо ви знайомі з Бібліотеками Підтримки Android, це набір пакунків, які Google надає для "переливання" API у попередні версії Android. Це дає їм можливість додавати нові API до старих версій ОС, оскільки екосистема Android (OEM-виробники тощо) оновлюється набагато повільніше, ніж, наприклад, iOS. Цей конкретний пакет, Legacy.Support.V4, насправді підтримує Android аж до Android API 4! Мінімальна підтримувана версія Android в .NET - це Android API 21, яка була випущена в 2017 році.

Виявляється, ця залежність була перенесена з Xamarin.Forms і насправді не була потрібна. Як і очікувалося, ця зміна призвела до того, що з додатків .NET MAUI було видалено багато Java-коду. Настільки багато, що додатки .NET 8 MAUI тепер підпадають під ліміт мультидексів - весь байт-код Dalvik може зафіксувати в одному файлі classes.dex.

 

Детальна розбивка змін розміру за допомогою apkdiff:

Дивіться dotnetdotnet/maui#12232, щоб дізнатися більше про це покращення.

Дедуплікація генериків на iOS та macOS

У .NET 7 додатки для iOS збільшилися у розмірі через використання узагальнень C# у декількох збірках .NET. Коли моно AOT-компілятор .NET 7 зустрічає узагальнений екземпляр, який не обробляється спільним використанням, він видає код цього екземпляра. Якщо той самий екземпляр зустрічається під час AOT-компіляції у декількох збірках, код буде видано кілька разів, що збільшить розмір коду.

У .NET 8 нові опції командного рядка dedup-skip і dedup-include передаються компілятору Mono AOT. Створено нову збірку aot-instances.dll для спільного використання цієї інформації в одному місці у всій програмі.

Зміна була протестована в додатку MySingleView і тестах Monotouch в кодовій базі xamarin/xamarin-macios:

Дивіться xamarin-macios#17766 для більш детальної інформації про це покращення.

Виправлено реалізацію System.Linq.Expressions на iOS-подібних платформах

У .NET 7 кодові шляхи в System.Linq.Expressions контролювалися різними прапорцями, такими як:

-        CanCompileToil

-        CanEmitObjectArrayDelegate

-       CanCreateArbitraryDelegates

 

Ці прапорці керують кодовими шляхами, які є "дружніми до AOT", а які ні. Для десктопних платформ NativeAOT визначає наступну конфігурацію для AOT-сумісного коду:

Що стосується iOS-подібних платформ, то бібліотека System.Linq.Expressions була зібрана з увімкненим константним розмноженням та вилученими керуючими змінними. Це призвело до того, що перелічені вище перемикачі функцій NativeAOT не мали жодного ефекту (не обрізалися під час збирання програми), що потенційно могло призвести до того, що компіляція AOT йшла непідтримуваними шляхами коду на цих платформах.

У .NET 8 ми уніфікували збірку System.Linq.Expressions.dll, щоб забезпечити однакову збірку для всіх підтримуваних платформ і середовищ виконання, а також спростили ці перемикачі для врахування IsDynamicCodeSupported, щоб тример .NET міг видаляти відповідні IL в System.Linq.Expressions.dll під час збірки додатку.

Дивіться dotnetdotnet/runtime#87924 та dotnetdotnet/runtime#89308 для отримання детальної інформації про це покращення.

Встановіть DynamicCodeSupport=false для iOS та Catalyst

У .NET 8 перемикач функцій $(DynamicCodeSupport) встановлено у значення false для платформ:

-        Де неможливо публікувати без компілятора AOT.

-        Коли перекладач не ввімкнений.

Це стосується додатків, що працюють на iOS, tvOS, MacCatalyst тощо.

DynamicCodeSupport=false дозволяє тримеру .NET видаляти шляхи коду в залежності від RuntimeFeature.IsDynamicCodeSupported, наприклад цей  приклад  у  System.Linq.Expressions.

 

Орієнтовна економія становить:

У поєднанні з System.Linq.Expressions  на  iOS-подібних  платформах це дало гарне загальне покращення розміру програми:

Дивіться xamarin-macios#18555 для більш детальної інформації про це покращення.

Витоки пам'яті

Витоки пам'яті та якість

Враховуючи, що основною темою .NET MAUI в .NET 8 є якість, проблеми, пов'язані з пам'яттю, стали основною темою цього випуску. Деякі з виявлених проблем існували навіть у кодовій базі Xamarin.Forms, тому ми раді працювати над створенням фреймворку, на який розробники можуть покластися при створенні крос-платформних .NET додатків.

Більш детальну інформацію про роботу, виконану в .NET 8, можна знайти в різних прес-релізах та випусках, пов'язаних з проблемами пам'яті, за посиланням:

-        Запити

-        Проблеми

Ви можете бачити, що в .NET 8 був досягнутий значний прогрес в цій області.

Якщо порівняти .NET 7 MAUI з .NET 8 MAUI у прикладі додатку, що працює на Windows, виводячи результати виконання GC.GetTotalMemory() на екран:

Порівняння на Windows: .NET 7 проти .NET 8, зображення

Потім порівняйте приклад програми, що працює на macOS, але з набагато більшою кількістю сторінок у стеку навігації:

Порівняння на Mac: .NET 7 проти .NET 8, зображення

Дивіться приклад  коду  для  цього  проекту  на  GitHub для більш детальної інформації.

Діагностика витоків в .NET MAUI

Симптомом витоку пам'яті в .NET MAUI додатку може бути щось на кшталт цього:

-        Перейдіть з цільової сторінки на підсторінку.

-        Поверніться.

-        Знову перейдіть на підсторінку.

-        Повторіть.

-        Пам'ять постійно зростає, поки операційна система не закриє програму через нестачу пам'яті.

 

У випадку Android ви можете побачити такі повідомлення журналу, як

У цьому прикладі маса розміром 116 МБ є досить великою для мобільного додатку, а також понад 46 000 об'єктів обгортки C# <-> Java!

Щоб точно визначити, чи підсторінка втрачає, ми можемо зробити кілька модифікацій у додатку .NET MAUI:

 

1.         Додати реєстрацію у фіналізаторі. Наприклад:

Під час навігації по вашому додатку ви можете виявити, що цілі сторінки живуть вічно, якщо повідомлення журналу ніколи не відображається. Це поширений симптом витоку, оскільки будь-яке подання містить .Parent.Parent.Parent і т.д. аж до об'єкта Page.

 

2.      Викликати GC.Collect() десь у додатку, наприклад, у конструкторі підсторінки:

Це робить GC більш детермінованим, оскільки ми змушуємо його працювати частіше. Кожного разу, коли ми переходимо на підсторінку, ми з більшою ймовірністю спричиняємо зникнення старих підсторінок. Якщо все працює належним чином, ми повинні побачити повідомлення журналу від фіналізатора.

Зауважте, що GC.Collect() призначено лише для налагодження. Після завершення дослідження вам не знадобиться ця функція у вашому додатку, тому обов'язково видаліть її після завершення.

3.   Після внесення цих змін протестуйте релізну збірку вашого додатка.

На iOS, Android, macOS тощо ви можете переглядати консольний вивід вашого додатку, щоб визначити, що насправді відбувається під час виконання. adb  logcat наприклад, є способом перегляду цих логів на Android.

Якщо ви працюєте під Windows, ви також можете скористатися Debug > Windows > Diagnostic Tools у Visual Studio для створення знімків пам'яті у Visual Studio. У майбутньому ми хотіли б, щоб засоби діагностики Visual Studio підтримували програми .NET MAUI, що працюють на інших платформах.

Дивіться нашу вікі-сторінку про пам'яті витоки  wiki, для отримання додаткової інформації про витоки пам'яті у додатках .NET MAUI.

Патерни, що призводять до витоків: Події C#

Події C#, такі як поля, властивості тощо, можуть створювати сильні зв'язки між об'єктами. Розгляньмо ситуацію, коли все може піти не так.

 

Візьмемо, наприклад, кросплатформну властивість Grid.ColumnDefinitions:

-       Grid має сильне відношення на колекцію ColumnDefinitionCollection через BindableProperty.

-    ColumnDefinitionCollection має сильне відношення на Grid через подію ItemSizeChanged.

Якщо ви поставите точку зупинки на рядку з ItemSizeChanged +=, ви побачите, що подія має об'єкт EventHandler, де Target є сильним посиланням назад на Grid.

У деяких випадках такі циклічні посилання цілком прийнятні. Збирачі сміття середовища виконання .NET знають, як збирати цикли об'єктів, які вказують один на одного. Якщо немає "кореневого" об'єкта, який утримує обидва об'єкти, вони обидва можуть зникнути.

Проблема виникає з часом життя об'єктів: що станеться, якщо ColumnDefinitionCollection існуватиме протягом усього життя програми?

 

Розглянемо наступний Style у Application.Resources або Resources/Styles/Styles.xaml:

Якщо ви застосували цей стиль до сітки на довільній сторінці:

- Основний словник ресурсів програми містить стиль.

-  Стиль містить ColumnDefinitionCollection.

-  ColumnDefinitionCollection містить сітку.

-  На жаль, Grid утримує сторінку через .Parent.Parent.Parent і т.д.

Ця ситуація може призвести до того, що цілі сторінки будуть жити вічно!

Зауваження Проблему з Grid виправлено у maui#16145, але вона є чудовим прикладом ілюстрації того, як події C# можуть піти не так, як треба.

Кільцеві посилання на платформах Apple

Ще з перших днів існування Xamarin.iOS існувала проблема з "циклічними посиланнями", навіть у середовищі виконання, що збирає сміття, як .NET. Об'єкти C# співіснують зі світом посилань на платформах Apple, і тому об'єкт C#, який має підкласи NSObject, може потрапити в ситуацію, коли він випадково може жити вічно — витік пам'яті. Це не є специфічною проблемою .NET, оскільки ви можете так само легко створити таку ж ситуацію в Objective-C або Swift. Зверніть увагу, що цього не відбувається на платформах Android або Windows.

 

Візьмемо, наприклад, наступне кільцеве посилання:

У цьому випадку:

-  parent -> view через Subviews

-  view -> parent через властивість Parent

-  Кількість посилань на обидва об'єкти відмінна від нуля.

- Обидва об'єкти живуть вічно.

 

Ця проблема не обмежується полем або властивістю, ви можете створювати подібні ситуації з подіями C#:

У цьому випадку:

-  MyView -> UIDatePicker через Subviews

- UIDatePicker -> MyView через ValueChanged та EventHandler.Target

- Обидва об'єкти живуть вічно.

Рішення для цього прикладу — зробити метод OnValueChanged static, що призведе до значення null Target в екземплярі EventHandler.

 

Іншим рішенням може бути розміщення OnValueChanged у підкласі, який не є NSObject:

Це шаблон, який ми використовуємо у більшості обробників .NET MAUI та інших підкласах UIView.

Подивіться на MemoryLeaksOniOS якщо ви хочете ізольовано погратися з деякими з цих сценаріїв у додатку для iOS без .NET MAUI.

Аналізатор Roslyn для платформ Apple

 

Також у нас є експериментальний  аналізатор Roslyn, який може виявляти такі ситуації під час збірки. Щоб додати його до проєктів net7.0-ios, net8.0-ios тощо, ви можете просто встановити пакет NuGet:

Деякі приклади попередження можуть бути наступними:

Зауважте, що аналізатор може попередити про можливу проблему, тому його ввімкнення у великій наявній кодовій базі може бути досить шумним. Перевірка пам'яті під час виконання — найкращий спосіб визначити, чи дійсно є витік пам'яті.

Інструменти та документація

Спрощені dotnet-trace та dotnet-dsrouter

У .NET 7 профілювання мобільних додатків було дещо складним завданням. Вам потрібно було запустити dotnet-dsrouter і dotnet-trace разом і виконати всі налаштування правильно, щоб мати змогу отримати .nettrace або speedscope для дослідження продуктивності. Також не було вбудованої підтримки dotnet-gcdump для підключення до dotnet-dsrouter для отримання знімків пам'яті запущеної програми .NET MAUI.

У .NET 8 ми спростили цей сценарій, створивши нові команди для dotnet-dsrouter, які спрощують робочий процес.

 

Щоб переконатися, що у вас встановлені найновіші діагностичні засоби, ви можете встановити їх за посиланням:

Переконайтеся, що у вас встановлені версії цих інструментів не нижче 8.x:

 

Щоб профілювати Android-додаток на емуляторі Android, спочатку створіть і встановіть свій додаток у режимі Release, наприклад, так:

Далі відкрийте термінал для запуску dotnet-dsrouter

 

Потім у другому вікні терміналу ми можемо встановити системну властивість debug.mono.profile Android, як замінник $DOTNET_DiagnosticPorts:

Зауважте, що Android не має належної підтримки змінних оточення, таких як $DOTNET_DiagnosticPorts. Ви можете створити текстовий файл AndroidEnvironment  для встановлення змінних середовища, але системні властивості Android можуть бути простішими, оскільки для їх встановлення не потрібно перезбирати програму.

Після запуску програми для Android вона повинна мати можливість підключитися до dotnet-dsrouter -> dotnet-trace і записати інформацію про профілювання продуктивності для дослідження. Аргумент --format є необов'язковим і за замовчуванням має значення .nettrace. Однак файли .nettrace можна переглянути лише за допомогою Perfview у Windows, тоді як JSON-файли speedscope можна переглянути "на" macOS або Linux, завантаживши їх на https://speedscope.app.

Зауваження Якщо ви надаєте ідентифікатор процесу програмі dotnet-trace, вона знає, як визначити, чи є ідентифікатор процесу dotnet-dsrouter, і з'єднатися через нього належним чином.

dotnet-dsrouter має наступні нові команди для спрощення робочого процесу:

-  dotnet-dsrouter android: Пристрої Android

- dotnet-dsrouter android-emu: Емулятори Android

- dotnet-dsrouter ios: пристрої iOS

- dotnet-dsrouter ios-sim: симулятори iOS

Дивіться вікі .NET MAUI для отримання додаткової інформації про профілювання додатків .NET MAUI на кожній платформі.

Підтримка мобільних пристроїв dotnet-gcdump 

У .NET 7 у нас був дещо складний метод (див.  вікі) для отримання знімка пам'яті програми у середовищі виконання Mono (наприклад, iOS або Android). Вам потрібно було використовувати спеціальний для Mono провайдер подій, наприклад:

А потім ми поклалися на Філіпа Навару (Filip Navara) та його інструмент  mono-gcdump (дякуємо Філіпу!), щоб перетворити файл .nettrace у .gcdump, який можна відкрити у Visual Studio або PerfView.

 

У .NET 8 ми тепер маємо dotnet-gcdump для мобільних сценаріїв. Якщо ви хочете отримати знімок пам'яті запущеної програми, ви можете використовувати dotnet-gcdump так само як і dotnet-trace:

Зауваження Для цього потрібні такі самі налаштування, як і для dotnet-trace, наприклад, -p:AndroidEnableProfiler=true, dotnet-dsrouter, команди adb тощо.

 

Це значно спрощує наш робочий процес дослідження витоків пам'яті в додатках .NET MAUI. Дивіться нашу  wiki вікі-сторінку про витоки пам'яті    для отримання додаткової інформації.

 

Source






Posted on 15. September 2022

Arm64 Performance Improvements in .NET 7

Покращення продуктивності Arm64 у .NET 7

Команда .NET продовжує вдосконалювати продуктивність у .NET 7, як загалом, так і для Arm64. Ви можете ознайомитись із загальними вдосконаленнями в чудовому та детальному блозі Стівена Тоуба « Покращення продуктивності в .NET 7» . Дотримуючись ліній продуктивності ARM64 у .NET 5 , у цій публікації автор опише покращення продуктивності, які були зроблені для Arm64 у .NET 7, і позитивний вплив, який він мав на різні тести. Стівен торкнувся деяких робіт у своєму дописі в блозі, але тут я розповім про деякі додаткові деталі та, де це можливо, додам покращення, які ми побачили після оптимізації певної області.

 

Коли ми запускали .NET 7, ми хотіли зосередитися на тестах, які мали б вплив на широке коло клієнтів. Разом із апаратною командою Microsoft ми провели багато досліджень і обдумали, які контрольні показники слід вибрати, щоб підвищити продуктивність як клієнтських, так і хмарних сценаріїв. У цьому блозі я почну з опису характеристик продуктивності, які ми вважали важливими мати, методології, яку ми використовували, критерії, які ми оцінювали для вибору тестів, які використовувалися під час роботи над .NET 7. Після цього я розповім про неймовірну роботу, спрямовану на покращення продуктивності .NET на пристроях Arm64.

Методика аналізу ефективності

Архітектура набору інструкцій або ISA x64 і Arm64 відрізняється для кожного з них, і ця різниця завжди виявляється у вигляді показників продуктивності. Хоча ця різниця існує між двома платформами, ми хотіли зрозуміти, наскільки продуктивним є .NET під час роботи на платформах Arm64 порівняно з x64, і що можна зробити, щоб підвищити його ефективність. Наша мета в .NET 7 полягала не тільки в тому, щоб досягти паритету продуктивності між x64 і Arm64, але й надати чіткі вказівки нашим клієнтам щодо того, чого очікувати, коли вони переносять свої програми .NET з x64 на Arm64. Для цього ми розробили чітко визначений процес для проведення досліджень продуктивності Arm64 за еталонними тестами. Перш ніж вирішити, які тести використати, ми звузили характеристики ідеального тесту.

- Він має представляти реальний код, який будь-який розробник .NET може написати для свого програмного забезпечення.

- Має бути легко виконувати та збирати вимірювання з мінімальними попередніми кроками.

- Нарешті, він має бути виконуваним для всіх платформ, на яких ми зацікавлені у проведенні вимірювань продуктивності.

 

Базуючись на цих характеристиках, нижче наведено деякі контрольні показники, які ми обрали для наших досліджень.

BenchmarkGames

 

Гра Computer Language Benchmarks є одним із популярних тестів, оскільки вони реалізовані кількома мовами, що дозволяє легко вимірювати та порівнювати продуктивність різних мов. Лише деяк з них: fannkuch-9 , knucleotide і mandelbrot-5 - були одними з контрольних показників, які ми вибрали. Значною частиною цих тестів є те, що на машині розробника не потрібні додаткові налаштування, їх можна просто створити та виконати за допомогою доступних інструментів. З іншого боку, вони являють собою вузьку частину обчислень, які налаштовані вручну і можуть не бути хорошим представленням коду користувача.

TechEmpower

 

Тести TechEmpower (надалі я називатиму їх «тести TE») — це визнані галуззю тести навантаження серверів, які порівнюють різні фреймворки між мовами. Вони є прототипами реальних робочих навантажень клієнт/сервер. На відміну від BenchmarkGames, ці тести покладаються на мережевий стек і можуть не домінувати ЦП. Ще одна перевага TE Benchmarks полягає в тому, що ми можемо порівнювати відносну продуктивність x64 і Arm64 у різних середовищах виконання та мовах, що дає нам корисний набір критеріїв для оцінки продуктивності .NET.

Bing.com

Хоча всі наведені вище контрольні тести дадуть нам деякі легкодоступні плоди в просторі кодування, було б цікаво зрозуміти характеристики реальних хмарних додатків на Arm64. Після того, як Bing перейшов на .NET Core , команда .NET тісно співпрацювала з ними, щоб виявити вузькі місця продуктивності. Аналізуючи продуктивність Bing на Arm64, ми отримали уявлення про те, на що може розраховувати великий клієнт хмари, якщо він переведе свої сервери на машини Arm64.

ImageSharp

 

ImageSharp — це популярний інструмент .NET, який широко використовує внутрішні властивості у своєму коді. Їхні тести базуються на структурі BenchmarkDotnet , і ми мали PR, щоб дозволити виконання цих тестів на Arm64 . Ми також маємо видатний PR, щоб включити тести ImageSharp уdotnet/performance .

Paint.NET

Paint.NET — це програма для редагування зображень і фотографій , написана мовою .NET. Мало того, що це реальна програма, аналіз продуктивності таких програм дав би нам зрозуміти, як програми інтерфейсу користувача працюють на різному апаратному забезпеченні та чи існують інші області, які ми повинні вивчити, щоб оптимізувати продуктивність середовища виконання .NET.

Мікро бенчмарки

Команда Dotnet Performance проводить щовечора понад 4600 мікротестів , а результати різних платформ можна побачити на цій сторінці індексу .

Ми розпочали наш шлях до .NET 7, зібравши відомі робочі елементи продуктивності Arm64 у dotnet/runtime#64820 . Ми почали вимірювати різницю в продуктивності між x64 і Arm64 за різними контрольними тестами (робоче навантаження на клієнта та сервер) і помітили (через кілька тестів), що продуктивність Arm64 була дуже низькою під час робочого навантаження на сервер, і тому ми почали наше дослідження на тесті TE.

 

Коли я обговорюю різноманітні оптимізації, які ми зробили для .NET 7, я буду взаємозамінно посилатися на один або кілька контрольних тестів, які покращилися. Зрештою, ми хотіли переконатися, що ми вплинули на якомога більше тестів і якомога більше реального коду. 

Покращення часу виконання

.NET побудовано на трьох основних компонентах. «Бібліотеки» .NET містять керований код (переважно C#) для загальної функціональності, яка використовується в екосистемі .NET, а також розробниками .NET. «Генератор коду RyuJIT» використовує представлення проміжної мови .NET і перетворює його на машинний код на льоту, процес, широко відомий як Just-in-time або JIT. Нарешті, «виконання» полегшує виконання програми, маючи інформацію про систему типів, генерацію коду планування, збирання сміття, власну підтримку певних бібліотек .NET тощо. Середовище виконання є важливою частиною будь-якої платформи керованої мови, і ми хотіли уважно стежити за тим, чи є якась область у середовищі виконання, яка не є оптимальною для Arm64. Ми знайшли кілька фундаментальних проблем, які я висвітлю нижче.

Розмір кешу L3

Після початку нашого дослідження ми виявили, що тести TE були в 4-8 разів повільнішими на Arm64 порівняно з x64. Однією з ключових проблем було те, що Gen0 GC відбувався в 4 рази частіше на Arm64. Незабаром ми зрозуміли, що неправильно зчитували розмір кешу L3 з машини Arm64 , яка використовувалася для вимірювання продуктивності. Зрештою ми з’ясували, що розмір кешу L3 недоступний з ОС на деяких машинах, включаючи Ampere Altra (пізніше це було виправлено в оновленні мікропрограми). У dotnet/runtime#71029 ми змінили нашу евристику таким чином, що якщо машина не може прочитати розмір кешу L3, середовище виконання використовує приблизний розмір на основі кількості ядер, присутніх на машині.

Завдяки цій зміні ми побачили близько 670+ покращень мікротесту в Linux/arm64 і 170+ покращень у Windows/arm64. У dotnet/runtime#64576 ми почали використовувати інформацію про сучасне апаратне забезпечення для macOS 12+, щоб точно зчитувати розмір кешу L3.

Масштабування пулу потоків

Ми спостерігали значне зниження продуктивності на машинах з більш високим ядром (32+). Наприклад, на машинах Ampere продуктивність впала майже на 80%. Іншими словами, ми спостерігали вищу кількість запитів/секунда (RPS) на 28 ядрах, ніж на 80 ядрах. Основна проблема полягала в тому, як потоки використовували та опитували спільну «глобальну чергу». Коли робочий потік потребував додаткової роботи, він надсилав запит глобальній черзі, щоб побачити, чи потрібно виконати ще роботу. На машинах із більш високим ядром із задіяною більшою кількістю потоків під час доступу до глобальної черги виникало багато суперечок. Кілька потоків намагалися отримати блокування глобальної черги перед доступом до її вмісту. Це призвело до зупинки та, отже, до погіршення продуктивності, як зазначено в dotnet/runtime#67845. Проблема масштабування пулу потоків не обмежується лише машинами Arm64, але стосується будь-яких машин із більшою кількістю ядер. Цю проблему було вирішено в dotnet/runtime#69386 і dotnet/aspnetcore#42237 , як показано на графіку нижче. Хоча це демонструє значні покращення, ми можемо помітити, що продуктивність не масштабується лінійно з більшою кількістю машинних ядер. У нас є ідеї щодо покращення цього в наступних випусках.

LSE atomics

У паралельному коді часто виникає потреба отримати доступ до області пам’яті виключно за допомогою одного потоку. Розробники використовують атомарні API, щоб отримати ексклюзивний доступ до критичних регіонів. На машинах x86-x64 операцію читання-зміни-запису (RMW) у місці пам’яті можна виконати за допомогою однієї інструкції, додавши префікс, lock як показано в прикладі нижче. Метод Do_Transaction викликає один із C++ API Interlocked*, і, як видно з коду, згенерованого компілятором Microsoft Visual C++ (MSVC), операція виконується за допомогою однієї інструкції на x86-x64.

Однак до недавнього часу на машинах Arm операції RMW були заборонені, і всі операції виконувалися через регістри. Тому для сценаріїв паралелізму вони мали пару інструкцій. «Load Acquire» ( ldaxr) отримає ексклюзивний доступ до області пам’яті, так що жодне інше ядро ​​не зможе отримати до нього доступ, а «Store Release» ( stlxr) звільнить доступ для інших ядер. Між цією парою виконувались критичні операції, як показано у згенерованому коді нижче. Якщо stlxr операція не вдалася через те, що якийсь інший ЦП працював із пам’яттю після того, як ми завантажили вміст за допомогою ldaxr, буде код для повторної спроби, ( cbnz перескочить назад) щобповторити операцію.

 

Arm представив атомні інструкції LSE у v8.1. За допомогою цих інструкцій такі операції можна виконувати з меншою кількістю коду та швидше, ніж у традиційній версії. Повний бар'єр пам'яті dmb ish в кінці такої операції також можна усунути за допомогою атомарних інструкцій.

Ви можете дізнатися більше про те, що таке ARM atomics і чому вони важливі в цьому та цьому блогах.

RyuJIT підтримує LSE atomics з перших днів . Для потокових взаємоблокованих API JIT генеруватиме швидші атомарні інструкції, якщо виявить, що машина, на якій виконується програма, підтримує можливості LSE. Однак це працювало лише для Linux. У dotnet/runtime#70600 ми розширили підтримку Windows і побачили підвищення продуктивності приблизно на 45% у сценаріях із блокуванням.

 

У .NET 7 ми хотіли не лише використовувати атомарні інструкції лише для керованого коду, але й скористатися ними у рідному коді середовища виконання. Ми активно використовуємо Interlocked API для різних сценаріїв, таких як пул потоків, помічники для lock інструкцій .NET, збирання сміття тощо. Ви можете уявити, наскільки повільними були б усі ці сценарії без атомарних інструкцій, і це певною мірою виявилося як частина розриву продуктивності порівняно з x64. Щоб підтримувати широкий спектр апаратного забезпечення, на якому працює .NET, ми можемо генерувати лише інструкції з найменшим загальним знаменником, які можуть виконуватися на всіх машинах. Це називається «базова лінія архітектури», а для Arm64 це ARM v8.0. Іншими словами, для сценарію завчасної компіляції (на відміну від своєчасної компіляції) ми повинні бути консервативними та генерувати лише інструкції, сумісні з ARM v8.0ARM v8.0. Це також стосується середовища виконання .NET власного коду. Коли ми компілюємо середовище виконання .NET за допомогою MSVC/clang/gcc, ми явно повідомляємо компілятору про обмеження створення сучасних інструкцій, які були введені після Arm v8.0. Це гарантує, що .NET може працювати на старішому апаратному забезпеченні, але водночас ми не використовували переваги сучасних інструкцій, таких як atomics, на нових машинах. Зміна базової лінії на ARM v8.1 не була варіантом, оскільки це зупинило б роботу .NET на машинах, які не підтримують атомарні інструкції. Новіша версія clang додала прапорець -moutline-atomics, який генерував би обидві версії коду, повільнішу, яка має ldaxr/stlxrcasal, та швидшу, яка має casal і додає перевірку під час виконання, щоб виконати відповідну версію коду залежно від можливостей машини. На жаль, .NET все ще використовує clang9 для своєї компіляції і -moutline-atomics не присутній у ньому. Подібним чином у MSVC також відсутній цей прапорець, і єдиним способом генерації атомів було використання перемикача компілятора /arch:armv8.1

Замість того, щоб покладатися на компілятори C++, у dotnet/runtime#70921 і dotnet/runtime#71512 ми додали дві окремі версії коду та перевірку в середовищі виконання .NET, яка виконувала б оптимальну версію коду на певній машині. Як видно з dotnet/perf#6347 і dotnet/perf#6770 , ми побачили перемогу в різних тестах.10% ~ 20%

Environment.ProcessorCount

 

З документації Environment.ProcessorCount повертає кількість логічних процесорів на машині або, якщо процес виконується зі спорідненістю ЦП, кількість процесорів, з якими процес пов’язаний. Починаючи з Windows 11 і Windows Server 2022 , процеси більше не обмежуються однією групою процесорів за замовчуванням. На тестованій нами 80-ядерній машині Ampere було дві групи процесорів: одна мала 16 ядер, а друга — 64 ядра. На таких машинах значення Environment.ProcessorCount інколи поверталося 16, а інколи поверталося 64. Оскільки багато коду користувача сильно залежить від Environment.ProcessorCount, вони спостерігали б значну різницю в продуктивності свого застосунку на цих машинах залежно від того, яке значення було отримано під час запуску процесу. Ми виправили цю проблему в dotnet/runtime#68639 , щоб врахувати кількість ядер у всіх групах процесів.

Покращення бібліотек

Продовжуючи нашу традицію оптимізації коду бібліотек для Arm64 за допомогою внутрішніх компонентів, ми внесли кілька покращень у деякі гарячі методи. У .NET 7 ми виконали багато роботи в dotnet/runtime#53450 , dotnet/runtime#60094 і dotnet/runtime#61649 , щоб додати помічники міжплатформного апаратного забезпечення для Vector64, Vector128 і Vector256 як описано в dotnet/runtime#49397. Ця робота допомогла нам уніфікувати логіку шляхів коду кількох бібліотек, усунувши специфічні апаратні компоненти та використавши натомість апаратні агностичні внутрішні компоненти. Завдяки новим API розробнику не потрібно знати різноманітні внутрішні API апаратного забезпечення, які пропонуються для кожної базової архітектури апаратного забезпечення, а він може зосередитися на функціональності, яку він хоче досягти, використовуючи простіші апаратні агностичні API. Це не тільки спростило наш код, але ми також отримали значні покращення продуктивності на Arm64, просто скориставшись перевагами апаратно-агностичних внутрішніх API. У dotnet/runtime#64864 ми також додали LoadPairVector64 і LoadPairVector128 API до бібліотек, які завантажують пару значень Vector64 або Vector128і повертають їх як кортеж.

Покращення обробки тексту

@a74nh переписав System.Buffers.Text.Base64 API, EncodeToUtf8 та реалізацію DecodeFromUtf8 на основі SSE3 на API на основі dotnet/runtime#70654 і отримав до 60% покращення в деяких наших тестах. Подібна зміна, внесена шляхом переписування HexConverter::EncodeToUtf16 в dotnet/runtime#67192, дала нам до 50% перемог у деяких тестах.

@SwapnilGaikwad також конвертував NarrowUtf16ToAscii()у dotnet/runtime#70080 і GetIndexOfFirstNonAsciiChar() у dotnet /runtime#71637 , щоб отримати приріст продуктивності до 35%.

Крім того, dotnet/runtime#71795 і dotnet/runtime#73320 векторизували, методи ToBase64String(), ToBase64CharArray() та TryToBase64Chars()  та покращили продуктивність (див. win1 , win2 , win3 , win4 і win5 ) Convert.ToBase64String()загалом, не лише для x64, але й для Arm64. В dotnet/runtime#67811 покращено IndexOf для сценаріїв, коли немає відповідності, тоді як в dotnet/runtime#67192 прискорено HexConverter::EncodeToUtf16 для використання нещодавно написаних кросплатформних внутрішніх API Vector128 з хорошими перевагами.

Зворотні поліпшення

@SwapnilGaikwad також переписав Reverse() у dotnet/runtime#72780 оптимізацію для Arm64 і отримав до 70% перемоги

Покращення генерації коду

У цьому розділі я розповім про різноманітну роботу, яку ми виконували для покращення якості коду для пристроїв Arm64. Це не лише підвищило продуктивність .NET, але й зменшило кількість створюваного коду.

Покращення режиму адресації

Режим адресації - це механізм, за допомогою якого інструкція обчислює адресу пам'яті, до якої вона хоче отримати доступ. На сучасних машинах адреси пам'яті мають довжину 64 біти. На обладнанні x86-x64, де інструкції мають різну ширину, більшість адрес можна безпосередньо вбудовано в саму інструкцію. Навпаки, Arm64, будучи кодуванням фіксованого розміру, має фіксований розмір інструкції 32 біти, найчастіше 64-бітні адреси не можна вказати як «негайне значення» всередині інструкції. Arm64 надає різні способи виявлення адреси пам'яті. Детальніше про це можна прочитати в цій чудовій статті про режим адресації Arm64. Код, створений RyuJIT, не використовував повною мірою переваги багатьох режимів адресації, що призводило до нижчої якості коду та повільнішого виконання. У .NET 7 ми працювали над покращенням коду, щоб скористатися перевагами цих режимів адресації.

До .NET 7 під час доступу до елемента масиву ми обчислювали адресу елемента масиву в два кроки. На першому кроці, залежно від індексу, ми б обчислили зміщення відповідного елемента від базової адреси масиву, а на другому кроці додаємо це зміщення до базової адреси, щоб отримати адресу елемента масиву, який нас цікавить. У прикладі нижче, щоб отримати доступ до data[j], ми спочатку завантажуємо значення j в x0, а оскільки воно має тип int, множимо його на 4  за допомогою lsl x0, x0, #2 . Ми отримуємо доступ до елемента, додаючи обчислене значення зсуву до базової адреси x1 за допомогою ldr w0, [x1, x0]. За допомогою цих двох інструкцій ми обчислили адресу, виконавши операцію *(Base + (Index << 2)). Подібні кроки виконуються для розрахунку data[i].

 

Arm64 має режим адресації «непрямий реєстр з індексом», який багато хто також називає «режимом масштабованої адресації». Це можна використати у сценарії, який ми щойно бачили, де зсув присутній у беззнаковому регістрі та його потрібно зсунути лыворуч на постійне значення (у нашому випадку розмір типу елемента масиву). У dotnet/runtime#60808 ми почали використовувати «режим масштабованої адресації» для таких сценаріїв, що призвело до коду, показаного нижче. Тут ми змогли завантажити значення елемента масиву data[j] в одній інструкції ldr w0, [x1, x0, LSL #2]. Не тільки покращилася якість коду, але й зменшився розмір коду методу з 40 байт до 32 байт.

У dotnet/runtime#66902 ми зробили подібні зміни, щоб покращити продуктивність коду, який працює з byte масивами.

У dotnet/runtime#65468 ми оптимізували коди, які працюють з float масивами.

Це дало нам приблизно 10% перемоги в деяких тестах, як показано на графіку нижче.

У dotnet/runtime#70749 ми оптимізували код, який працює з object масивами, що дає нам підвищення продуктивності більш ніж на 10% .

У dotnet/runtime#67490 ми вдосконалили режими адресації векторів SIMD, які завантажуються з немасштабованими індексами. Як показано нижче, для виконання таких операцій ми тепер використовуємо лише 1 інструкцію замість 2 інструкцій.

Покращення бар'єра пам'яті

Arm64 має відносно слабшу модель пам'яті, ніж x64. Процесор може змінити порядок інструкцій доступу до пам'яті, щоб покращити продуктивність програми, і розробник не дізнається про це. Він може виконувати інструкції таким чином, щоб мінімізувати вартість доступу до пам'яті. Це може вплинути на функціональність багатопоточних програм, а «бар’єр пам’яті» — це спосіб для розробника повідомити процесору, щоб він не перевпорядковував певні шляхи коду. Бар'єр пам'яті гарантує, що всі попередні записи перед інструкцією будуть завершені перед будь-якими наступними операціями з пам'яттю.

У .NET розробники можуть передати цю інформацію компілятору, оголосивши змінну як volatile. Ми помітили, що ми генерували односторонній бар’єр, використовуючи семантику випуску магазину для доступу до змінних під час використання з Volatileclass , але не робили те саме для тих, що були оголошені за допомогою volatile ключового слова, як показано нижче, через що продуктивність volatile2X сповільнювалася на Arm64.

dotnet/runtime#62895 і dotnet/runtime#64354 виправили ці проблеми, що призвело до значного підвищення продуктивності .

Інструкції бар'єра пам’яті даних dmb ish* є дорогими, і вони гарантують, що звернення до пам'яті, які з'являються у програмному порядку перед dmb, будуть виконані перед будь-яким зверненням до пам'яті, що відбувається після інструкції dmb  у програмному порядку. Часто ми генерували дві такі інструкції одна до одної. dotnet/runtime#60219 вирішив цю проблему, усунувши наявні бар’єри надлишкової пам’яті dmb.

Інструкції додано як частину ARMv8.3 для підтримки слабшої моделі RCpc (Release Consistent, узгодженої з процесором), де Store-Release, а потім Load-Acquire на іншу адресу, можна змінити. dotnet/runtime#67384 додано підтримку цих інструкцій у RyuJIT, щоб машини (наприклад, Apple M1, який підтримує) могли скористатися ними.

Вирази для підйому

Говорячи про доступ до елементів масиву, у .NET 7 ми змінили спосіб обчислення базової адреси масиву. Доступ до елемента масиву someArray[i] здійснюється шляхом пошуку адреси пам’яті, де зберігається перший елемент масиву, а потім додавання до нього відповідного індексу. Уявіть someArray, що був збережений за адресою A, а фактичні елементи масиву починаються після байтів із B з A. Адреса першого елемента масиву буде такою  A + B , а щоб дістатися до i- го елемента, ми додамо i * sizeof(array element type). Отже, для масиву int, повна операція буде (A + B) + (i * 4). Якщо ми отримуємо доступ до масиву всередині циклу за допомогою змінної індексу циклу i, термін A + B  є інваріантом, і нам не потрібно його повторно обчислювати. Замість цього ми можемо просто обчислити його поза циклом, кешувати та використовувати всередині циклу. Однак у RyuJIT внутрішньо ми представляли цю адресу як (A + (i * 4)) + B замість того, що забороняло нам переміщувати вираз  A + B за межі циклу. dotnet/runtime#61293 вирішив цю проблему, як показано в прикладі нижче, і дав нам розмір коду, а також підвищення продуктивності ( тут , тут і тут ).

Проводячи наше дослідження Arm64 у реальних додатках, ми помітили 4-рівневий вкладений цикл for у кодовій базі ImageSharp, який неодноразово виконував ті самі обчислення у вкладених циклах. Ось спрощена версія коду.

Як видно, багато обчислень навколо IndexBits і IndexAlphaBits повторюються і можуть бути виконані лише один раз поза відповідним циклом. Хоча компілятор повинен подбати про таку оптимізацію, на жаль, до .NET 6 ці інваріанти не були виведені з циклу, і нам довелося вручну перемістити їх у код C#. У .NET 7 ми вирішили цю проблему в dotnet/runtime#68061 , увімкнувши виведення виразів із багатовкладеного циклу. Це підвищило якість коду таких циклів, як показано на знімку екрана нижче. Багато коду було переміщено з IG05циклу (b-loop) до зовнішнього циклу IG04(g-loop) і IG03(r-loop).

Хоча ця оптимізація є дуже загальною та застосовною для всіх архітектур, причина, чому я згадав про неї в цій публікації в блозі, полягає в її значенні та впливі на код Arm64. Пам’ятайте, що Arm64 використовує 32-бітове кодування інструкцій фіксованої довжини, для виявлення 64-бітної адреси потрібні 3-4 інструкції. Це можна побачити в багатьох місцях, де код намагається завантажити адресу методу або глобальну змінну. Наприклад, наведений нижче код отримує доступ до static змінної всередині вкладеного циклу, що є дуже поширеним сценарієм.

Щоб отримати доступ до static змінної, ми виявляємо адресу змінної, а потім читаємо її значення. Хоча частина «відображення адреси змінної» є інваріантною і може бути винесена з i-циклу, до .NET 6 ми лише переміщали її з j-циклу, як показано в збірці нижче.

У .NET 7 із dotnet/runtime#68061 ми змінили порядок, у якому ми оцінюємо цикли, і це дозволило перемістити формування адреси за межі i-циклу.

Покращене вирівнювання коду

У .NET 6 ми додали підтримку вирівнювання циклу для платформ x86-x64. У dotnet/runtime#60135 ми також розширили підтримку платформ Arm64. У dotnet/runtime#59828 ми почали вирівнювати методи на 32-байтовій межі адреси. Обидва ці пункти були зроблені для покращення продуктивності та стабільності програм .NET, що працюють на машинах Arm64. Нарешті, ми хотіли переконатися, що вирівнювання не спричиняє негативного впливу на продуктивність, тому в dotnet/runtime#60787 ми покращили код, приховавши інструкції вирівнювання, коли це можливо, за безумовним переходом або в блоках коду. Як видно на знімку екрана нижче, раніше ми вирівнювали цикл IG06 додавши відступ безпосередньо перед початком циклу (в кінці IG05 прикладу нижче). Тепер ми вирівнюємо його, додаючи відступи IG03після безумовного переходу b G_M5507_IG05.

Покращення вибору інструкцій

Було багато неефективності коду через поганий вибір інструкцій, і ми виправили більшість проблем під час .NET 7. Деякі з можливостей оптимізації в цьому розділі були знайдені в результаті аналізу тестів BenchmarkGames.

Ми покращили довгоочікувані проблеми з продуктивністю навколо роботи по модулю. Немає інструкції Arm64 для обчислення по модулю, і компілятори повинні перекладати операцію a % b в a - (a / b) * b. Однак, якщо divisor є степенем 2, ми можемо перевести операцію в a & (b - 1) замість цього. dotnet/runtime#65535 оптимізував це для unsigned a, dotnet/runtime#66407 оптимізував це для signed a та dotnet/runtime#70599 оптимізував для a % 2. Якщо divisor не є степенем 2, ми отримуємо три інструкції для виконання операції за модулем.

Arm64 має інструкцію, яка може поєднувати множення та віднімання в одну інструкцію msub. Так само множення з наступним додаванням можна об’єднати в одну інструкцію madd. dotnet/runtime#61037 і dotnet/runtime#66621 усунули ці проблеми, що призвело до кращої якості коду, ніж у .NET 6, і більшої продуктивності ( тут , тут і тут ).

Нарешті, у dotnet/runtime#62399 ми перетворили операцію x % 2 == 0 на x & 1 == 0 раніше на етапі циклу компіляції, що дало нам кращу якість коду.

 

Підсумовуючи, ось кілька методів, які використовують роботу мода.

Зауважте, що в Test4, ми також усунули деякі перевірки OVERFLOWта THROWDIVZEROз цими оптимізаціями. Ось графік, який показує вплив.

Під час дослідження ми виявили подібну проблему з компілятором MSVC. Він також іноді не створював оптимальних madd інструкцій, і це буде виправлено у VS 17.4 .

У dotnet/runtime#64016 ми оптимізували згенерований код Math.Round(MindpointRounding.AwayFromZero) для уникнення виклику помічника, а замість цього використовували інструкцію frinta.

У dotnet/runtime#65584 ми почали використовувати fmin і fmax інструкції для варіантів float та double в Math.Min()та Math.Max()  відповідно. Завдяки цьому ми створили мінімальний код, необхідний для виконання таких операцій.

У порівняльних сценаріях dotnet/runtime#61617 для float/ double ми виключили додаткову інструкцію для переміщення 0 в реєстр і замість цього вбудовано 0 безпосередньо в fcmp інструкцію. І в dotnet/runtime#62933 , dotnet/runtime#63821 і dotnet/runtime#64783 ми почали вилучати зайвий 0 для векторних порівнянь, як видно з наведених нижче відмінностей.

Подібним чином у dotnet/runtime#61035 ми виправили спосіб обліку миттєвого значення 1 при додаванні та відніманні, що дало нам значні покращення розміру коду . Як показано нижче, 1 було легко закодовано в саму інструкцію add, і це заощадило нам 1 інструкцію (4 байти) для таких випадків використання.

У dotnet/runtime#61045 ми вдосконалили вибір інструкцій для операцій зсуву вліво, які мають відомі константи.

dotnet/runtime#61549 покращив деякі послідовності інструкцій для використання розширених регістрових операцій , які є коротшими та продуктивнішими. У наведеному нижче прикладі раніше ми б розширили значення в реєстрі w20 та зберегли його в x0 а потім додали б інше значення, наприклад x19. Тепер ми використовуємо розширення SXTW безпосередньо як частину інструкції add.

dotnet/runtime#62630 виконала оптимізацію оглядового отвору, щоб усунути розширення нуля/знаку, яке було зроблено після завантаження його значення в попередній інструкції, за допомогою ldr яка сама виконує необхідне розширення нуля/знаку.

Ми також покращили конкретний сценарій, який передбачає векторне порівняння з Zero вектором. Розглянемо наступний приклад.

Раніше ми виконували цю операцію, використовуючи 3 кроки:

1. Порівняйте зміст vec та Vector128.Zero використовуючи інструкцію cmeq. Якщо вміст рівний, інструкція встановлює кожен біт векторного елемента на 1, інакше встановлює на 0.

2. Далі ми знаходимо мінімальне значення для всіх векторних елементів за допомогою uminv та вилучаємо його. У нашому випадку якщо vec == 0, це було б 1 і якби vec != 0, було б 0.

3. Нарешті, ми порівнюємо, чи був результат uminvІ,  0 або 1, щоб визначити, чи vec == 0, чи vec != 0.

У .NET 7 ми вдосконалили цей алгоритм, щоб натомість виконувати такі дії:

1. Перегляньте елементи vec та знайдіть максимальний елемент за допомогою інструкції umaxv.

2. Якщо максимальний елемент, знайдений на кроці 1, більший за 0, тоді ми знаємо, що вміст vec не дорівнює нулю.

 

Ось відмінність коду між .NET 6 і .NET 7.

Завдяки цьому ми отримали близько 25% перемоги в різних тестах.

У dotnet/runtime#69333 ми почали використовувати внутрішнє сканування бітів , і це дало нам хорошу пропускну здатність для компіляції Arm64.

Покращення ініціалізації пам'яті

На початку методу розробники найчастіше ініціалізують свої змінні значенням за замовчуванням. Нативно, на рівні збірки, ця ініціалізація відбувається шляхом запису  zero value  в пам'ять стека (оскільки локальні змінні знаходяться в стеку). Типове обнулення послідовності інструкцій пам'яті складається з переміщення нульового значення регістра до пам'яті стека, наприклад  mov xzr, [fp, #80]. Залежно від кількості змінних, які ми ініціалізуємо, кількість інструкцій для обнулення пам’яті може збільшуватися. У dotnet/runtime#61030 , dotnet/runtime#63422 і dotnet/runtime#68085, ми перейшли на використання регістрів SIMD для ініціалізації пам’яті. Регістри SIMD мають довжину 64 або 128 байт і можуть значно зменшити кількість інструкцій, щоб звільнити пам'ять. Подібна концепція застосовна для блокової копії. Якщо нам потрібно скопіювати велику пам’ять, раніше ми використовували пару 8-байтових регістрів, які завантажували б і зберігали значення по 16 байт за раз від джерела до місця призначення. Ми перейшли на використання пар регістрів SIMD, які натомість працюватимуть на 32-байтовій ініціалізації в одній інструкції. Для адрес, які не вирівняні на 32-байтовій межі, алгоритм швидко повернеться до 16-байтової або 8-байтової ініціалізації, як показано нижче.

У dotnet/runtime#64481 ми зробили кілька оптимізацій, усунувши обнулення пам’яті, коли вона не потрібна, і використали кращі інструкції та режим адресації. До .NET 7, якщо пам’ять для копіювання/ініціалізації була достатньо великою, ми вводили цикл для виконання операції, як показано нижче. Тут ми обнуляємо пам’ять на [sp, #-16], зменшуємо значення sp на 16 (режим адресації після індексу), а потім виконуємо цикл x11, поки значення не стане 0.

У .NET 7 ми почали розгортати частину цього коду, якщо розмір пам’яті не перевищує 128 байт. У наведеному нижче коді ми починаємо з обнулення [sp-16], щоб дати підказку ЦП про послідовне обнулення та спонукати його перейти в потоковий режим запису .

Говорячи про «режим потокового запису», ми також визнали в dotnet/runtime#67244 необхідність використання інструкцій DC ZVA і запропонували це команді MSVC .

Ми зазначили, що в певних ситуаціях для таких операцій, як memset і memmove, у x86-x64 ми перенаправляли виконання до реалізації CRT, але в Arm64 у нас була рукописна збірка для виконання такої операції.

Ось код x64, створений для тесту CopyBlock128 .

Однак до .NET 6 код складання Arm64 був таким:

Ми давно перейшли до використання CRT-реалізації memmove та memset  для windows/linux x64 , а також для linux arm64 . У dotnet/runtime#67788 ми також перейшли на використання реалізації CRT для windows/arm64 і побачили до 25% покращень у таких тестах.

Найважливішою оптимізацією, яку ми додали в .NET 7, є інструкції умовного виконання . З dotnet/runtime#71616 @a74nh від Arm зробив внесок у створення інструкцій умовного порівняння, а в майбутньому з dotnet/runtime#73472 незабаром ми матимемо інструкції «умовного вибору».

 

За допомогою «умовного порівняння» ccmp ми можемо робити порівняння на основі результату попереднього порівняння. Давайте спробуємо зрозуміти, що відбувається в наведеному нижче прикладі. Раніше ми б порівнювали !x використовуючи cmp w2, #0 і, якщо вірно, встановлювали 1 в x0 використовуючи інструкцію cset. Подібним чином умова y == 9 перевіряється за допомогою cmp w1, #9 і 1 встановлюється в x3, якщо вони рівні. Нарешті, він порівнює вміст w0 і w3 шоб перевірити, чи він збігається, і переходить відповідно. У .NET 7 ми виконуємо те саме порівняння !x, що й спочатку. Але потім ми виконуємо ccmp w1, #9, c, eq  яке порівнює рівність (eq наприкінці) w1 і 9, якщо вони рівні, встановлюємо позначку нуль. Якщо результати не збігаються, встановлюється перенесення c прапора. Наступна інструкція просто перевіряє, чи встановлено нуль, і виконує відповідний перехід. Якщо ви бачите різницю, це усуває інструкцію порівняння та покращує продуктивність.

Покращення інструментів

Як багато хто знає, якщо розробник хоче побачити розбирання свого коду під час розробки, він може вставити фрагмент коду на https://sharplab.io/. Однак він лише показує розбирання для x64. Не було подібного онлайн-інструменту для відображення розбирання Arm64. @hez2010 додав підтримку .NET (C#/F#/VB) у godbolt. Тепер розробник може вставити свій код .NET і перевірити розбирання для всіх платформ, які ми підтримуємо, включаючи Arm64. Існує також розширення Visual Studio Disasmo , яке можна встановити, щоб перевірити розбирання, але для того, щоб використовувати його, вам потрібно мати наявний та створений на вашій локальній машині репозиторій dotnet/runtime.

Вплив

 

Як видно з різних графіків вище, завдяки нашій роботі над .NET 7 багато тестів Micro покращилися на 10~60%. Я просто хочу поділитися ще одним графіком тестів TE, які виконуються в ОС Linux у лабораторії продуктивності asp.net. Як показано нижче, коли ми починали роботу з .NET 7, кількість запитів на секунду (RPS) була нижчою для Arm64, але в міру прогресу лінія піднімається до x64 у порівнянні з різними попередніми переглядами .NET 7. Подібним чином затримка (вимірюється в мілісекундах) знижується з .NET 6 до .NET 7.

Тестове середовище

Як ми можемо обговорювати продуктивність і не згадувати середовище, в якому ми проводили наші вимірювання? Для контексту Arm64 ми регулярно запускаємо два набори тестів і відстежуємо продуктивність цих тестів над створенням. Тести TE виконуються в лабораторії продуктивності, що належить команді ASP.NET, використовуючи кранк інфраструктуру. Результати опубліковані на https://aka.ms/aspnet/benchmarks. Ми проводимо тести на машинах Intel Xeon x64 і Ampere Altra Arm64, як видно зі списку середовища на нашому сайті результатів. Ми працюємо як на операційній системі Linux, так і на Windows.

Інші набори тестів, які регулярно виконуються, — це мікротести . Вони виконуються в лабораторії продуктивності, що належить команді .NET, а результати публікуються на цьому сайті . Як середовище тестування TE, ми запускаємо ці тести на різноманітних пристроях, таких як Surface Pro X, Intel, та AMDAmpere машинах.

У .NET 7 ми додали інструмент Paint.NET Ріка Брюстера до наших тестів. Він відстежує різні аспекти інструменту інтерфейсу користувача, як-от запуск, стан готовності та рендеринг, як показано на графіку нижче, і ми відстежуємо ці показники як для x64, так і для Arm64.