.NET і CLR: Погляд зсередини



Що таке C#? Об'єктно-орієнтований есперанто зі складальником сміття, функціональними примочками і безкоштовним масажем після обіду. Він дозволяє писати Дійсно Важливі Речі, приховуючи від нас непотрібні деталі роботи з пам'яттю, процесором та інше низькорівневе програмування. Природно, знаходяться люди з підвищеним рівнем цікавості в крові, бажають знати як же .NET працює насправді (само собою, вони вивчають .NET виключно заради підвищення продуктивності розроблюваного софта). Сьогодні з нами розмовляють:

  • Саша Гольдштейн. Регулярний спікер DotNext, автор “Pro .NET Performance" і багато разів MVP. Останнім часом йому мало продуктивності безпосередньо мови, і він вирішив вичавити максимум з заліза.
  • Карлен szKarlen Симонян. Автор atomics.net, початківець WebKit коммиттер, а також фахівець з Just-In-Time Compilation.


Саша Гольдштейн


Доброго дня, Саша. Як ти добрався до hardware? Інтерес або сувора необхідність?

Я консультант, працюю з багатьма клієнтами. Іноді зустрічаються ситуації, коли необхідно оптимізувати алгоритм або шматок коду, який іншими способами вже не оптимізується. Ти можеш знати .NET на більш високому рівні, але варіантів крім hardware – ні. При цьому, якщо ти розумієш, як працює процесор, у тебе в голові є чітке розуміння, як він влаштований – ти можеш інакше організувати код: орієнтуватися на інші інструкції процесора, оптимальніше використовувати кеш. Деякі приклади, що начебто векторизації, я вже наводив на однією з DotNext, про деяких буду розповідати на найближчій.

Так це інструмент останньої надії? Або його можна використовувати на постійній основі?

It depends. Для багатьох додатків сильна оптимізація не особливо потрібна, в деяких ситуаціях ти просто не можеш спуститися на нижній рівень. З іншого боку, якщо у тебе стоять машини з купою герц на борту, недешеві машини, є сенс вичавлювати з них максимум. У моїй практиці приблизно 10 відсотків клієнтів потрапляли в ситуації, коли використання подібних оптимізацій було виправдано.

Чому C#? В .NET світі за продуктивністю ходять до плюсів.

З одного боку, З++ мова більш гнучкий, його компілятори сильніше спрямовані на оптимізацію заліза. З іншого боку, є ціна підтримки З++ коду, бо не кожен шарпист знає плюси. Якщо ти обробляєш сигнали\зображення, і ти знаєш плюси – вперед. В іншому випадку – на жаль. Як приклад – хлопці з stackoverflow будуть показувати приклади, коли їм потрібно досягти максимальної продуктивності далеко не тривіальний шматку їх кодової бази, і вставити туди плюсів не виправдана: немає відповідних спеціалістів, складність підтримки подвоюється. Вводити ж у команду фахівця З++ – небезпечно, фактор автобуса ніхто не відміняв.

Який типовий ефект низькорівневих оптимізацій?

Само собою, це сильно залежить від ситуації. Векторизація може прискорити алгоритм до 8 разів. Кеш – до десяти разів. Кілька десятків відсотків за рахунок вибору правильних інструкцій. В сумі можна прискорити ділянку коду в сотню разів. Знову-таки, можливо, знайдеться тільки один цикл, який можна прискорити векторизацией\параллелизацией. А може, подібних місць буде кілька десятків. Само собою, сумарний ефект десяти оптимізацій буде помітніше (в абсолютних величинах).

Як шукати хардварные боттлнеки? Досвід, інтуїція, готовий інструмент?

Я не вірю в оптимізацію навмання. Intel і AMD зараз випускають утиліти для відстеження поведінки заліза: для Intel це Amplifier, для AMD – утиліти Catalyst. Раніше вони були досить примітивними: визначали промахи в кеші, трохи допомагали з інструкціями, зараз же вони дають різні підказки, від оптимізації пам'яті до застосування векторизації. Від розробника потрібні знання, як втілити ці ініціативи в життя, але аналізують утиліти на відмінно.

Означає, по гайду для Intel and AMD. Наскільки вони різняться? Доведеться ще вивчати щось ще?

Для десктопа і серверів .NET світі достатньо цих двох, гайди майже однакові, як і набори інструкції. Якщо хто-небудь ще і вийде на цей ринок, інструкції, швидше за все, будуть практично ті ж.

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

Зараз є девайси для майнінг BitCoin, є CUDA, з'являлися чутки про залізо для нейромереж. Чи чекає нас епоха «кожної задачі – своя плата»? Або різноманітність фреймворків залишиться лише в JS?

Сподіваюся, такого ж різноманітності в hardware ми не побачимо. Навіть зараз, крім спеціалізованих плат той же биткоин можна майнить на звичайних відеокартах. Тренд, звичайно, цікаве, але навряд чи він отримає сильний розвиток. Занадто негибкое рішення: чи виробляти плату для окремої задачі. Intel намагається досягти тієї точки, коли останні процесори сумісні по ціні\якості з відеокартами

А що щодо компіляторів? У джавистов їх багато, а у нас?

Відповідь на це питання складається з двох частин. З одного боку, зараз у нас є достатньо спритний і легко розширюваний компілятор Roslyn, і його достатньо. З іншого боку, з JIT-компіляцією справи йдуть гірше. Поглянь на CoreCLR, дуже багато можливих оптимізацій не використовуються: економиться час. Втім, проект ще молодий, робота над ним триває. У будь-якому випадку: якщо нові компілятори з'являться і вони будуть здатні компілювати код на будь-якій машині, будь-якій платформі – це благо. Якщо ж почнеться конкуренція стандартів – екосистема лише постраждає.

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

Це взагалі постійна проблема, в багатьох областях: пам'ять, I/O. Ти тестируешь програму на SSD, в хмарі вона крутиться на більш повільному пристрої, і ти отримуєш серйозну проблему з продуктивністю. Якщо говорити про хмарах: ти замовляєш собі ті процесори, які тобі потрібні, які влаштовують тебе по продуктивності і ціні. Можливо, при міграції і виникнуть проблеми, але за кілька років я з таким не стикався. У будь-якому випадку, навряд чи Microsoft вирішить поміняти всі процесори одного типу за завтрашній ранок. Апдейт йде инкрементально, так що можна розраховувати на кілька років міграції, а цього достатньо для підготовки.

щодо складності коду? Скільки часу займають оптимізації такого рівня?

Якщо в команді лише одна людина здатна на таке – краще докупити обладнання. Але зазвичай подібні речі можна локалізуються в одному «чорному ящику»: стандартний додаток надсилає запити до бази\сервісу, (де)серіалізует JSON\XML, пише логи, місць для серйозних оптимізацій небагато. Враховуй також, що дійсно складні оптимізації використовуються рідко, зазвичай застосовується проста векторизація. Тут як з колекціями: найчастіше застосовують стандартні списки\масиви\словники, і набагато рідше розглядаються інші варіанти.

Наскільки ймовірна ситуація, коли ви створюєте якесь рішення, ховаєте його в чорний ящик, а потім колега вбиває його? Наприклад, працюєте з кешем, а потім колега викликає по 10 потоків на процесор, викликаючи численні промахи.

Це можливо. Ваші колеги повинні розуміти обмеження вашого чорного ящика. Плюс потрібні автотесты з регресом.

Андрій DreamWalker Акіньшин розповідав, що автотесты продуктивності – штука небезпечна і не цілком надійна. Windows оновиться, чи інший софт на машині з агентом, так і всі агенти повинні бути налаштовані абсолютно однаково.

Так, це стандартна проблема автотестів з бенчмарками. Зазвичай можна проігнорувати перший-другий фейли, але якщо йдуть 5 фейлов поспіль – вже треба запускати ручної тест.

Наскільки корисні знання заліза при роботі з іншими мовами (не .NET)?

своєму виступі я буду розглядати в основному C#, трохи З++. В цілому, відповідь залежить від мови: чим простіше доступ до пам'яті – тим більше користі. Той же C# дозволяє працювати з покажчиками, Java, наскільки я пам'ятаю, немає. А в JS подібними речами морочитися взагалі безглуздо. Але в цілому ці знання явно будуть корисні при роботі з серверними мовами.

Карлен Симонян



Карлен, ти давно вивчаєш нутрощі .NET. Це інтерес або практична необхідність? Можеш навести декілька найбільш ефективних прикладів JIT-оптимізації зі своєї практики?

Спочатку це, звичайно ж, був інтерес. Але вже останні 4 роки я займаюся розробкою багатопоточних і розподілених додатків .NET, що вимагає знання конкретних можливостей платформи, її структури. Не можна просто займатися лише вивченням роботи GC, наприклад – все дуже взаємопов'язано. І API фреймворку, і JIT відіграють важливу роль. До речі, останній безпосередньо відповідає за працездатність нашого коду.

Мені здається, що одним з найбільш ефективних оптимізацій/можливостей є кооперація самого рантайма з JIT'ом. Так в CLR дуже цікаво влаштована диспетчеризація методів у інтерфейсів. Один з блоків моїй доповіді як раз і буде присвячено цій темі.

DotNext 2016 Moscow ти зосередишся на RyuJit або розкажеш про JIT-тери загалом?

На конференції ми будемо розглядати і RyuJIT, і оновлений x86. Це обумовлено двома факторами. По-перше, рано чи пізно 32-бітові додатки повинні будуть піти зі сцени. По-друге, RyuJIT – тільки для 64-бітних додатків, та й багато сил кинуто на його розвиток, в тому числі порт на ARM. Поглиблене вивчення поведінки x86 компілятора (яке досить передбачувано) розумно, але лише в короткостроковій перспективі. Є ще legacy x64 JIT, але він менш ефективний, ніж x86.

Сподіваюся, що інформація з моєї доповіді нагоді не тільки в рамках .NET'a, але і дозволить зрозуміти деякі основні концепції у світі керованих платформ. Акцент буде зроблений на кооперації GC і JIT'a, пристрої методів і об'єктів, а також бенчмарках.

Вибір правильної колекції або алгоритму дозволяє прискорити програму на порядок. Наскільки ефективні JIT-оптимізації? Їх можна вважати регулярним інструментом?

JIT-компілятор постійно балансує між ефективність коду і власною продуктивністю, тобто видається код повинен оптимізованим, а сам процес генерації – швидким.

Так, багато оптимізації відомі з теорії і реалізуються в C++ компіляторах, попутно перекочувавши в RyuJIT. Деякі можуть здатися несподіваними, особливо техніка copy-propagation, яка може поламати «поганий» код, який працює без побічних ефектів з x86 JIT'ом, але выявляющийся з legacy x64 і RyuJIT.

З приходом RyuJIT з'явилася досить цікава фіча SIMD. Саме тут розкривається один з плюсів Just-in-time compilation: компілятор на льоту визначає архітектуру процесора, і код з використанням нового Vectors API перетворюється або в SSE2, або в інструкції AVX (або будь-який ін. набір SIMD), на відміну від AOT-компіляції, де потрібно вказати мінімальну архітектуру CPU. Є варіанти, наприклад, «розумних» C++ компіляторів, які підтримують умовну компіляцію ділянок коду, але це вже з іншої опери».

Основний імператив розробки – управління складністю. Наскільки ускладнюється код, що враховує особливості JIT? Наскільки ускладнюється підтримка?

Головна мета будь-якого компілятора – не змінювати поведінку результуючого коду, використовуючи різні оптимізації. І тут виявляється головна проблема: навіть неоптимізований код може працювати по-різному на різних архітектурах. Звучить у дусі КО, але це так. Виною всьому-любов деяких CPU до надмірного out-of-order виконання. Зараз на десктопах, так і на серверах, домінує архітектура x86-64, яка дуже консервативна в плані перестановок інструкцій, коли як на мобільних системах всюди ARM. Це варто враховувати, т. к. C# вже влаштувався на мобільних пристроях, а ми пишемо код на x86-системах, де емулятори, відповідно, також споживають x86-код.

Може здатися, що вся складність лягає на наші плечі як розробників, частково це так, але і сам JIT намагається видавати код сумісний з x86-64.

Взагалі, на питання допустимості перестановок, можливих оптимізацій і їх сполучуваності повинна відповідати модель пам'яті (Memory Model). На жаль, в .NET'е (тобто реалізації CLI) це погано описано. Вірніше, стандарт ECMA335 дає нам визначення моделі пам'яті в розділі «12.6 Memory model and optimizations», але воно описує систему зі слабкою (weak) моделлю, а x86 є архітектурою зі строгою (strong) моделлю, тобто використовується семантика acquire/release за замовчуванням. Починаючи з CLR 2.0 модель сама стала ближче до x86, а сам JIT став генерувати код для ARM та Itanium (який вже не підтримується) сумісний з ним.

Як видно, дана тема досить нетривіальна. Відповіддю на питання «Що робити»? є використання готових примітивів і API, де вирішуються дані проблеми.

Зазвичай перед оптимізацією шукають «пляшкове горлечко». Які підходи і інструменти дозволяють шукати його на нижніх рівнях?

Головним інструментом є профилятор, будь то performance profiler, або memory.
Я б виділив два класи проблем, які найчастіше виникають локально в додатку: неоптимальне використання кешу процесора + робота з невыровненными даними і занадто дорога синхронізація.

Таке поняття як False Sharing відомо давно у вузьких колах, але лише останнім часом набирає обертів. Для зменшення таких побічних ефектів іноді доводиться рефакторіть структури даних, що веде до загального поліпшення чутливості системи.

Рішення проблеми дорогий синхронізації, насправді, є вельми нетривіальним: перейти до lock-free кодом непросто. А верифікувати – ще важче. Ще й закон Амдала ніхто не відміняв.

Якщо побіжний погляд на код і профилятор не виявляють проблему, то слід опуститися на рівень нижче, тобто CPU. Тут на допомогу приходять hardware performance counters, тобто «залізні» лічильники. Можна досліджувати cache miss і т. п. Їх зазвичай багато. Для читання можна скористатися API операційних систем, але це значить самому створювати такий профилятор. Найкраще користуватися готовими інструментами від самих виробників цільових процесорів.

Бенчмарки. Чим менше час виконання – тим більше граблів. Які інструменти ти використовуєш для замірів?

Повністю погоджуюся з озвученими тезою. Але перед тим як виміряти що-небудь, насамперед необхідно визначитися з метриками, тобто що ми будемо вимірювати: споживання пам'яті, ефективність використання CPU, які ділянки можна змінювати і т. д. Справа в тому, що .NET – світ, GC, який значно збільшує ентропію системи. З одного боку, бенчмарк може показати, що дана ділянка коду «швидкий». Але це не означає, що ефективний. Можливо споживання пам'яті досить велика, що дасть про себе знати потім при самій збірці сміття. Тому з визначення питання: «Що нам треба?» – я і починаю дослідження.

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

Існує два підходи до побудови бенчмарка: використовувати вбудовування «лічильників» в код (а-ля підхід, яким користується профилятор), або тестувати окремі шматки програми та/або робити микробенчмаркинг.

Перший – досить комплексний підхід, і краще всього використовувати Performance profiler разом з Memory Profiler. Результати можуть бути дещо відрізнятися від реального коду (все-таки код переписується), але зате показує повну картину. Такого роду інструментів багато – тут стоїть питання зручності/переваги при їх виборі.

Другий шлях – також непростий, що вимагає точного вимірювання, блоку статистики і мінімального впливу самого коду бенчмарка на результати. Писати такий код важко і клопітно. З появою чудової бібліотеки BenchmarkDotNet цю справу стало простіше. Їй я і користуюся.

В останні пару років світ .NET став багатшим на компілятори. Чи вийде у цьому плані наздогнати Java? Чи варто це робити?

Звичайно, JIT у HotSpot'e відмінний. Але кожна платформа орієнтується на свої сценарії. Так в мові Java всі методи за замовчуванням – віртуальні. Це створює проблему: як оптимально реалізувати виклики методів? На допомогу приходить техніка динамічної деоптимизации, тобто при відсутності перевизначення методу він вважається non-virtual. Якщо з'явиться спадкоємець, то JIT перекомпилирует код. В .NET ж все JIT компілятори використовують техніку перекомпіляції деяких ділянок коду (stubs). Дуже цікаво справа йде з інтерфейсами, як я вже говорив вище.

Багато можливі оптимізації в HotSpot дозволені, на мій погляд, з-за більш суворого підходу до можливостей середовища .NET і C# більш близькі до залозу і кооперації з нативним кодом, в той час як Java – ні. Наприклад, оптимальним вирівнюванням полів класу займаються обидві середовища, і JVM, і CLR, але остання дозволяє ручне управління, прямо як у C. Для Java з'явилася анотація @Contended, але вона не еквівалентна повністю StructLayout і FieldOffset атрибутів .NET. Також більш агресивне використання регістрів CPU і більш агресивний inliner, а також C2 (фінальний оптимізуючий компілятор) дають JVM безсумнівний плюс, тоді як CLR JIT слід лише fastcall угоди викликів і менше инлайнит.

Зате у .NET є unsafe. Хтось використовує його, а хтось-ні. Якщо оптимізацій JIT'a не вистачає, то береш і оптимізуєш руками.

RyuJIT – ще молодий компілятор, але я помічаю в деяких задачах підвищення якості коду (порівняно з legacy x86 і x64), що не може не радувати.

Наскільки отримується в результаті оптимізації код крихкий? Яка ймовірність, що менш досвідчений колега зламає його (із кращих спонукань)?

Мені здається, що на таких оптимізованих ділянках коду треба залишати коментарі на кшталт «Do not touch!». Оптимізації зазвичай зачіпають будова структур, розташування полів, черговість аргументів у методі і т. д. Що може призводити до погано читання коду. При написанні продуктивного коду, наприклад, LINQ не повинен зустрічатися взагалі. Або ж замість нього повинні використовуватися свої методи, які не аллоцируют стільки багато пам'яті, або скрізь повинні використовуватися прості конструкції а-ля цикли for. Новачкам це здається «негарним», і вони починають правити код.

Стандарт оптимізацій – алгоритми\кеш\паралелізація. Як кеш і паралелізація впливають на JIT-оптимізації? Блокують? Взаємодоповнюють?

Одна з головних оптимізацій, що проводяться сучасними JIT компіляторами – loop unrolling. Важко переоцінити наскільки це допомагає branch predictor'у сучасних CPU. Правда, саму розмотування вміє робити legacy x64 JIT, і лише при виконанні ряду умов. RyuJIT підтягується, x86 виробляє розмотування зовсім рідко. Паралельне виконання інструкцій іноді може сильно прискорити код. Нехай JIT'и в .NET не самі просунуті в цьому плані, але загальна продуктивність генерованого коду – на висоті.

Ще приємним бонусом є більш агресивний инлайнинг у RyuJIT, що не може не радувати.

JIT-оптимізації обмежують міграції Win-Linux, Intel Amd-ARM?

Самі оптимізації не обмежують. CLR сильно зав'язаний на Win32 API (і CoreCLR – не виняток). Так, у нього є Platform Abstraction Layer, але такі речі як легковагі блокування (критичні секції), наприклад, були портіровани на інші ОС. Ще до недавнього часу Background GC був недоступний для CoreCLR на Linux, але зусиллями спільноти патч виправив цю ситуацію.

Якщо говорити про Intel, Amd, то мені здається, що більше орієнтуються на особливості процесорів Intel. Зараз активно розробляється порт RyuJIT на ARM. Цей процес досі триває – є баги з кодогенерацией, але спільнота допомагає з їх закриттям. Якщо код однопотоковий, то різницю в поведінці на ARM'ах важко помітити. Повністю out-of-order виконання може дати про себе знати на багатопотоковому коді. Але і це питання вирішуємо.



Якщо ви прочитали ці інтерв'ю і вам мало — приходьте на DotNext 2016 Moscow . Крім Саші і Карлена про продуктивність там розповідають:

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.