Оптимізація .NET додатку – це просто ! / ?
Презентація доповіді
Оптимізація .NET додатку здається складною та стомлюючою задачею. Проте, не будемо поспішати з висновками і розглянемо декілька прикладів з реальних проектів.
Для цього ми:
- Заглянемо під капот додатку з реального проєкту;
- Визначимо метрику, за якою будемо проводити оптимізацію;
- Виберемо необхідні інструменти;
- Знайдемо bottlenecks /memory leaks і best practice для боротьби з ними.
Покращуючи додаток крок за кроком ми переконаємося в тому, що не складним аналізом і простими best practice можна зменшити загальну кількість ресурсів, що він використовує.
- Senior software developer в інфраструктурній команді, Covent IT
- 15+ років комерційної розробки програмного забезпечення та баз даних.
- 10+ років досвіду використання Microsoft SQL Server (розробка SQL запитів, SQL процедур, аналіз та оптимізація SQL запитів, дизайн та адміністрація баз даних).
- Багатий досвід розробки з використанням Microsoft .NET Framework (ASP.NET, WebAPI, desktop application, windows services).
- Багатий досвід в оптимізації додатків (складні математичні обчислення, алгоритми, багатопоточність, benchmarking, аналіз продуктивності додатків).
- Сильна математична освіта (алгоритми, моделювання, чисельні методи) з 2013 року кандидат фізико-математичних наук.
- Багатий досвід в юніт та інтеграційних тестах.
- LinkedIn, GitHub
Транскрипція доповіді
Дякую, Антоне. Це була дуже чудова презентація мене як спікера. Я вважаюся кандидатом з фізико-математичних наук у галузі комп'ютерних наук та теоретичної кібернетики. Основні мої технічні інтереси – це оптимізація додатків, написання на C#, такого ж швидкого коду, як на C++, і просування дебагінгу. Я дуже часто брав участь у проектах, де потрібно було виконувати складні математичні обчислення або обробляти великий об'єм даних. Зараз я є розробником програмного забезпечення у команді інфраструктури компанії Covent IT.
Наш план на сьогодні такий: спершу ми розглянемо виклик перед нами, а потім подивимося, як ми можемо вимірювати цей виклик. Після аналізу performance-репортів ми будемо оцінювати, чи немає зайвого у нашому додатку. Далі перейдемо до другого челенджу, оберемо метрики для аналізу і будемо виявляти можливі бутлнеки у нашому додатку. Підсумуємо все сказане і перейдемо до запитань і відповідей, якщо такі будуть.
Тепер наш перший челендж. Перед тим я хочу зауважити, що кожен кейс, який ми розглядатимемо тут, трохи виривається з контексту, оскільки степів оптимізації для кожного додатку було багато. Я вибрав ті, які найбільше цікаві або можуть виникнути в будь-якому іншому додатку. Весь source code у презентації адаптований для відображення логіки та поліпшень коду з метою зменшення метрик. У нас є консольний .NET додаток, який читає дані з власних файлів, оброблює їх в багатопотоковому режимі, записує результати у файли Microsoft Excel, використовує .NET Framework 4.6.2 і працює під операційною системою Windows. Головна його риса – він працює валідно. Ми провели аналіз та виміри, щоб з'ясувати, чи він оптимально виконує свою роботу.
Для збору основних метрик з нашого додатку можна використовувати .NET Benchmark, Visual Studio Performance Profiler, Performance Monitor, PerfView, Resharper.Trace, Resharper.Memory. У більшості випадків на проєкті була використана Resharper, яка мала події .Trace та .Memory. Їхній дружній інтерфейс та зручність використання робили їх вибором без додаткових пошукових зусиль. Я оцінив їхню ефективність. Зробивши замір .Memory, я побачив Performance Report, де зелена область (друге покоління) забивала значну частину пам'яті.
Також ми бачимо фіолетову частину – це Large Object Heap, яка може і не створювала основне пікове навантаження за пам'яттю, але вона могла бути причиною того, що друге покоління не вичищалося і створювало цей пік майже у 6 ГБ. .Memory дозволяє створити пік. Зараз ми можемо зробити снапшоти, в яких ми можемо більш детально подивитися, що створюється, які класи, які функції створюють ці об'єкти. Ми бачимо, що у нас доволі сильно фрагментований Large Object Heap, тобто частина цього Heap була порожньою і не використовувалася додатком, але створювала це пікове навантаження. Друге покоління було дуже велике. На діаграмах Large Size і Large Retained Size ми бачимо, що дуже багато об'єктів переживало колапс пам'яті після запуску Garbage Collector. Тобто щось з нашого трафіку пам'яті залишалося і, можливо, це було причиною великого піка.
Для відстеження трафіку пам'яті можна використовувати Dot Trace, який показує повний трафік пам'яті нашого додатку і має менший розмір репортів. Так, там менше інформації, але трафік пам'яті дуже добре відображено. Dot Memory збирає більш детальний репорт, але він великий і потребує більше часу на збір даних. Тому в даному випадку я використовував лише Dot Trace, щоб бачити трафік. У лівому нижньому куті ми бачимо ім'я класу і трафік, який він створював. Для аналізу додатка я вибрав клас із найбільшим трафіком. У цьому репорті видно, яка функція його створювала. Я дивився у вихідний код і намагався зрозуміти, чи не створюється забагато у цій функції.
Перший випадок, який ми розглянемо: у додатку було дуже багато Code Issues. Це не пов'язано з використанням Performance Report, я просто запустив ReSharper Code Analyzer і побачив, що у цьому додатку дуже багато зауважень чи порад. Я виправив майже 2500 з них і побачив, що в більшості випадків це були невикористані локальні змінні, проперті в Data Transfer Object або моделях, які ніде не використовувалися. Після очищення більше не створювалися об'єкти, які не використовувалися, дані, які не потрібні, вже не завантажувалися з файлів, і проперті, які ніде не використовувалися, не збільшували розмір наших моделей чи об'єктів передачі даних. В результаті я побачив, що трафік пам'яті зменшився майже на 50 ГБ або на 14%. Це сталося лише після того, як був почищений код від непотрібного.
Наступний кейс – це був перший та найбільший за трафіком клас, в якому деякі ключі створювали величезний трафік. Причиною цього був Concurrent Dictionary, в якому ключем була структура з певним набором полів. Concurrent Dictionary базується на Hash Table і оптимізований для роботи з generic-класами, але коли використовується Value Type, такий як структура, з'являється Boxing. Рішення було знайдено швидко – структура повинна імплементувати інтерфейс IEquatable, створюючи явно типізований метод Equals. Це дозволило зменшити трафік на 65%.
Наступний кейс – ми помітили трафік System.Double, який опинився в SmallObject.Heap. Причиною був generic-клас, де T була структура, і багато методів, що використовували цю структуру. Приведення типів приводило до Boxing. Шлях вирішення – перевірити весь код і використовувати клас явно типізовано, лише з double як T. Це зменшило трафік на 7%. Далі ми розглянули випадок, де багато char-масивів створювалося. Виявилось, що це було пов'язано з функцією, яка читала дані з файлу, і було вирішено переводом використання char-масивів на роботу зі строки. Усі ці оптимізації допомогли значно зменшити трафік пам'яті та покращити ефективність додатка. Вона для чогось усі символи цього рядка накопичувала у листі типу char, а потім конвертувала його у масив і повертала як результат своєї дії. Можливо, це здавалося не оптимальним або незрозумілим для чого, але все ж таки це було потрібно.
І для того, щоб створити цей масив, накопичуючи ці символи, можна було використовувати StringBuilder, який створювався один раз і використовувався для читання усього файлу. Тому що читання файлу – це зазвичай процес, який виконується в одному потоці, і ми можемо робити зміни, який пере використовується. Таким чином, кожного разу StringBuilder очищувався, коли ми читали наступний рядок, а коли ми його дочитали і зібрали усі символи, ми записували його просто у результатовий масив і повертали його. Таким чином, у нас не було нових виділень пам'яті, StringBuilder покривав будь-якої довжини свій рядок, і якщо потрібно було збільшити внутрішній буфер, StringBuilder збільшує його на 8000 байтів, на відміну від того, що List кожного разу свій внутрішній буфер за потреби подвоює. Тобто зростання пам'яті, яка потрібна для внутрішнього буфера, тут значно менше. І як результат, ми знову ж таки зменшили трафік пам'яті майже на 16 ГБ, або на 27%.
І наступний кейс. Я побачив такий дивний клас, CDISPLAY-клас 26, underscore 0. Він створював не надто великий трафік пам'яті, але було цікаво, що це. Виявилося, що це лямбда. І причиною тому був дікшенері, в який по ключу клався не об'єкт, а лямбда, яка створювала новий об'єкт, з якихось змінних, які були у замиканні цієї лямбди. Це можливо було б доречним і корисним, якщо ці дані були би якимось чином змінені. Але у нашому випадку все, що ми використовували, було лише для читання. Тобто ніхто нічого не змінював. І таким чином ця лямбда у цьому дікшенері була просто зайвою. І я її прибрав. І одразу, коли створювався ключ, я створював об'єкт без лямбди. І таким чином ще був зменшений трафік на 1 ГБ або на 3%. Теж дуже малими і не зашкоджуючи основній логіці нашого проєкту.
Тож переходимо до нашого наступного челенджу. Це був WinForms.NET застосунок, який читав бінарні та текстові файли. Він обробляв біти, виймав з них якусь корисну інформацію, обробляв її і пакував у новий формат. Записував результати в бінарні та текстові файли. Він використовував Framework 4.0 і запускався під операційною системою Windows x64. І він робив все, що мав робити. І це було коректно. Тобто не треба було щось змінювати або підлагоджувати цей проєкт. Я знову ж таки виміряв метрики. Спочатку я почав з dotMemory. Я побачив, що пікове навантаження було досить незначним – 93 МБ, це не багато. Навіть з урахуванням того, що цей пік створював Large Object Heap. Це фіолетова область на цьому скриншоті. А друге покоління, це зелена область, здавалася такою маленькою і незначною, що... Я одразу зрозумів, що тут трафік пам'яті не треба буде моніторити і якось зменшувати пікове навантаження. Тому я зробив snapshot dotTrace. Побачив, що навіть трафік пам'яті у 370 ГБ не створював ніяких проблем для нас.
Але я побачив, що наш додаток виконував свої дії майже 45 хвилин. Так, у цьому репорті можна було побачити, яка функція скільки часу виконувалась. Але у dotTrace є інший режим - режим sampling. Коли у нас можна побачити для кожної функції час її виконання. І цей режим, він цю метрику збирає більш точно, ніж коли ми робимо timeline. Тому що це основний... Основна його ціль – збирати якомога точніше час виконання кожної функції. Тому у наступних перфоманс-репортах я збирав лише цю метрику і дивився, чи не є щось нашим bottleneck-ом у нашому додатку.
Перший кейс – це LinkU. 71% – це LinkU, який у більшості випадків, ми бачимо, що це якийсь concat-ітератор. І він щось там Конкатенує. Причиною цьому була функція, яку ми збирали з маленьких масивів, один великий. І ToArray у цій функції доволі повільний. Конкатенація використовує ForEach. І кожного разу ми створювали новий масив, тому що ToArray, він матеріалізував цю LinkU. Що можна було зробити? Використовувати функцію BlockCopy. Вона копіювала побайтово один масив в інший. З якимось... З сумою та кількістю елементів. Все це працювало дуже швидко. Наш код був змінений таким чином, що ми використовували BlockCopy. І, як результат, зміни незначні, але ми зробили наш додаток швидший на 88%, майже у 9 разів. І трафік пам'яті зменшився на 250 ГБ на 68%, або у 3 рази. Незначні зміни, але великий результат для перфомансу.
Далі я побачив, що функція GetLength, вона також значну частину часу виконання нашого додатку використовує. Вона використовувалась лише у двох випадках, коли ми вичитували бінарний файл і калькулювали кількість бінарних послідовниць, які у ньому були. У загальному вигляді виглядало так. Ось наш BaseStreamLength, це він використовував цю функцію GetLength. Це очевидно. Далі він робив це у цьому циклі while. Це також створювало додаткове навантаження, і ми віднімали 4 константу кожного разу у циклі while. Це теж було доволі зайва операція. Також ми бачимо, що у нас є дві функції RedoIntensity. RedoIntensity2. Результат яких ніде ніяк не використовувався. Тож, що ми можемо у даному випадку зробити? Ми бачимо, що ми можемо винести BaseLengthStream у локальну зміну, тому що довжина цього стріму ніколи не буде змінна.
Це файл, який не змінюється паралельно. Далі ми об'єднали два int32 у int64. Це буде те саме. Результат нам не важливий, тому це можливо. І в результаті наш код теж був не суттєво змінений, але в результаті у нас додаток став швидше на 24%, а трафік пам'яті очікувано не змінився. Далі була кастомна функція ScaleGrad, яка мала 100% onTime. Тобто все, що там було написано, це був наш код, і там можливі були якісь потенційні місця, де ми могли зробити щось неоптимальне. І можемо покращити. Що робила ця функція? Ця функція для цілого числа Х повертала індекс в упорядкованій шкалі, в який інтервал він попадав. Функція ця виглядала так. Коли я цей код показував колегам, вони казали, що це майже ідеальний код, тому що в один рядок він вирішував свою задачу.
Але треба подивитися на цю функцію не з позиції кількості рядків коду, а з того, що вона робить. По-перше, скейл, масив упорядкованої шкали, він був типу double, а наш int – це ціле число. Ми порівнюємо int з double – це не дуже легка операція. Далі, у нас цей метод просто перебирав один за одним елементи і шукав, в який інтервал попадає наш Х. Це теж не дуже легка операція. Добре, оскільки ми залежимо від того, які дані ми подаємо на вхід цієї функції, буде різний перфоманс. І цей алгоритм, він був доволі складний. У нього складність була O від N. І можна було використовувати бінарний пошук, оскільки наша шкала була упорядкована. Тобто у нас був пошук у упорядкованому масиві.
Таким чином, ми могли переробити це. Ми додали доволі багато коду, який імплементував цей бінарний кастомний пошук. Але в результаті ми зробили наш додаток ще на 11% швидше. Трафік пам'яті в даному випадку також очікувано не змінився. Тож, які ми можемо зробити висновки? Потрібно уникати боксінгу там, де він не потрібен. Тримати наш код чистим. Використовувати лямбду доволі обережно. Завжди використовувати StringBuilder там, де ми працюємо з символами або строками. LinkYou – це дуже потужний і зручний інструмент від Дотнету. Але ним користуватися теж потрібно дуже обережно.
Нам потрібно знову ж таки уникати якихось операцій з числами з плаваючою точкою. Або якихось надто складних математичних обчислювань. Якщо ми можемо обійтися без них. І завжди, коли ми щось кастомно імпліментуємо, використовувати більш оптимальні алгоритми, такі як QuickSource, Binary Search та таке інше. Тому що це дуже сильно впливає на перфоманс. Якщо вам цікаві усі степи оптимізації для цих додатків, будь ласка, приєднуйтесь до нашого вебінару, воркшопу, який буде 20 червня о 18 годині. Там ми будемо детально розглядати всі степи. І побачимо, як крок за кроком ми дійшли до доволі суттєвих результатів зменшення часу виконання або трафіку пам'яті у наших додатках. Дякую всім за увагу. Слухачам за приділений час. Організаторам конференції за можливість виступати тут і зараз.