Posted on 15. September 2022

Arm64 Performance Improvements in .NET 7

Read this article in your language IT | EN | DE | ES

Покращення продуктивності 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<int>.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.

Обладнання з ОС Linux

 

Ampere Altra Arm64

Intel Cascade Lake x64

Intel Skylake x64

Обладнання з ОС Windows

У нас є широкий спектр машин під керуванням Windows 10, Windows 11, Windows Server 2022. Деякі з них є клієнтськими пристроями, як-от Surface Pro X Arm64 , а інші — важкими серверними пристроями, як-от Intel Cascade lake x64 , Ampere Altra Arm64 і Intel Skylake x64 .

Висновок

Підсумовуючи, у нас був чудовий випуск .NET 7 із багатьма вдосконаленнями в різних областях, від бібліотек до середовища виконання та генерації коду. Ми ліквідували розрив у продуктивності між x64 і Arm64 на певному обладнанні. Ми виявили багато критичних проблем, як-от погане масштабування пулу потоків і неправильне визначення розміру кешу L3, і вирішили їх у .NET 7. Ми покращили якість створеного коду, скориставшись перевагами режимів адресації Arm64, оптимізувавши роботу % та покращивши загальний доступ до масиву. Ми чудово співпрацювали з інженерами Arm @a74nh , @SwapnilGaikwad і @TamarChristinaArm від Arm зробив великий внесок, перетворивши деякий гарячий код бібліотеки .NET у використання внутрішніх компонентів. Ми хочемо подякувати багатьом учасникам, які зробили можливим розробку швидшої версії .NET 7 на пристроях Arm64.

 

Дякуємо, що знайшли час, щоб прочитати, і повідомте нам свій відгук про використання .NET на Arm64. Щасливого кодування на Arm64!

Source

 








Add comment


(Will show your Gravatar icon)

  Country flag

biuquote
  • Comment
  • Preview
Loading