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 , увімкнувши виведення виразів із багатовкладеного циклу. Це підвищило якість коду таких циклів, як показано на знімку екрана нижче. Багато коду було переміщено з