Оптимизация по памяти: сложно, но порой необходимо

в 8:08, , рубрики: .net, dotnext, Блог компании JUG.ru Group

В преддверии DotNext 2017 мы поговорили со специалистом по оптимизации в том числе .NET приложений из компании JetBrains Андреем Акиньшиным. На конференции он будет рассказывать о том, как отслеживать и устранять различные проблемы работы с памятью, как общего характера, так и специфичные для .NET. В качестве предисловия к докладу мы поговорили о том, какое место оптимизация по памяти вообще занимает в борьбе за производительность приложения.

Оптимизация по памяти: сложно, но порой необходимо - 1


— Расскажите о себе и своей работе. Какую роль в вашей работе играют оптимизации по памяти?

Андрей Акиньшин: Меня зовут Андрей Акиньшин, и я работаю в компании JetBrains, где много времени уделяю оптимизации приложений. Среди прочего мы занимаемся разработкой кроссплатформенной .NET IDE под названием Rider, основанной на платформе IntelliJ и ReSharper.
Это очень большой продукт, который много чего умеет. Естественно, он должен быть хорошо оптимизирован с точки зрения работы с памятью. Нужно сделать так, чтобы работа с памятью не тормозила продукт, а расход этой памяти был как можно меньше. Для подобной оптимизационной работы надо хорошо понимать, как эта самая память устроена и какие проблемы с ней могут возникнуть.

В свободное время я также разрабатываю проект с открытым исходным кодом BenchmarkDotNet. На текущий момент он уже достаточно большой, разработка проходит при поддержке .NET Foundation. Эта библиотека помогает людям писать бенчмарки, замеряющие производительность тех или иных вещей.

Вообще, когда речь идет о памяти, аккуратно замерить производительность достаточно сложно. Приходится понимать и учитывать очень много факторов, которые могут повлиять на перформанс. Поэтому зачастую мало провести какой-то один эксперимент (бенчмарк), чтобы сделать выводы о производительности памяти в целом. Также важно понимать, что для разных процессорных архитектур и JIT-компиляторов приходится проводить отдельные исследования по производительности. BenchmarkDotNet упрощает эту работу.

— На ваш взгляд, на каком месте в процессе оптимизации вообще должна стоять работа с памятью? Или все индивидуально и зависит от деталей приложения?

Андрей Акиньшин: Конечно же, многое зависит от приложения. Очень важно понимать узкое место в производительности конкретно вашей программы. Например, если вы много работаете с базами данных, вполне вероятно, что узкое место — это база данных, т.е. в первую очередь нужно думать о ней. А может быть, вы 99% времени тратите на сетевые операции. Всегда необходимо понимать, из чего в принципе складывается ваша производительность.

Тем не менее, в очень большом количестве разных проектов часто возникают ситуации, когда мы выполняем какие-то операции не над базой данных, не над сетью или дисками, а именно над основной памятью компьютера. И нам хотелось бы, чтобы эти места работали как можно быстрее.

Но если вам кажется, что какой-то компонент работает медленно, не нужно сразу кидаться оптимизировать память, да и вообще что угодно. Во-первых, стоит с самого начала осознать, что у вас есть некая проблема, из-за которой ухудшается производительность приложения. Во-вторых, надо сформулировать, насколько она вас не устраивает и насколько вам нужно разогнать приложение, потому что оптимизация ради оптимизации — это не то, чем стоит заниматься. Важно хорошо понимать бизнес-цели, которые стоят за этой работой. И только когда мы определились с целями, когда мы понимаем, какие у нас есть объективные метрики, которые мы хотим улучшить (насколько мы их хотим разогнать) — тогда уже стоит смотреть, во что по скорости упирается программа. И если это память — необходимо оптимизировать память. Если это что-то другое — нужно работать в другом направлении.

— Почему бенчмаркинг памяти такой сложный?

Андрей Акиньшин: Дело в том, что если мы два раза померяем время работы одной и той же программы, мы получим два разных результата. Выполнив очень много замеров, мы получим какое-то распределение с определенной дисперсией. И в случае работы с основной памятью эта дисперсия оказывается достаточно большой. Чтобы улучшить программу с точки зрения производительности по доступу к памяти, нужно очень хорошо понимать, как выглядит это распределение и что может независимо от нашей непосредственной логики влиять на итоговую скорость работы программы.

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

— В своем докладе на DotNext вы собираетесь рассказывать о большом количестве низкоуровневых вещей. Действительно ли .NET-программистам нужно понимать нюансы устройства CPU для оптимизационных работ?

Андрей Акиньшин: В большинстве случаев — нет. Основные перфоманс-проблемы — не особо интеллектуальные, их можно решать, используя общие знания. Но когда простые проблемы решены, производительность все еще упирается в память, а скорость работы надо как-то увеличивать, низкоуровневые знания лишними не будут.

Начну я со знакомой многим вещи — с кэша процессора. Очень важно писать алгоритмы, которые достаточно дружественны к кэшу. Это не так сложно и не требует каких-то больших знаний, а по скорости можно выгадать очень много.
К сожалению, многие профайлеры не позволяют просто так получить количество промахов кэша (cache miss). Но есть специализированные инструменты, позволяющие смотреть именно хардварную информацию. Я использую Intel VTune Amplifier, это очень хороший инструмент — он показывает проблемы не только с кэшем, но и с другими вещами, например, с выравниванием (если у вас много доступа к невыровненным данным, из-за этого может проседать производительность).

Есть такие штуки, о которых многие не знают или не задумываются, — например store forwarding или 4K aliasing: они могут легко испортить наши бенчмарки и привести к некорректным выводам. Мы можем получить проседание по скорости, просто обратившись одновременно по двум адресам, расстояние между которыми оказалось «неудачным». Оптимизация по этим вещам вряд ли даст вам какой-то гигантский прирост по перформансу, но зато может помочь там, где уже ничего другое не поможет. А непонимание внутренней кухни может легко привести к написанию некорректных бенчмарков. Поэтому полезно уметь смотреть и анализировать хардварные счетчики и делать выводы о том, в какую сторону стоит продолжать оптимизационные работы.

— Сейчас очень многие говорят о разработке кроссплатформенных .NET-приложений. Есть ли какие-нибудь различия между работой с памятью под разными рантаймами и операционными системами?

Андрей Акиньшин: Разумеется. Главным образом, это высокоуровневые проблемы, связанные со сборкой мусора. Например, алгоритмы сборки мусора под Mono и полным фреймворком совершенно разные. Идет разная работа с поколениями, по-разному обрабатываются большие объекты.

Кстати говоря, большие объекты — это довольно распространенная проблема. В полном фреймворке у нас есть куча больших объектов (Large Object Heap, LOH), в которую попадают объекты, размер которых превышает 85000 байт. И если обычную кучу сборщик мусора постоянно проверяет, вычищает и дефрагментирует, то над кучей больших объектов операция дефрагментации проводится достаточно редко (по умолчанию она вообще не проводится, но в последних версиях фреймворка ее можно вызвать вручную, если вы считаете, что такая необходимость есть). Поэтому с этой кучей нужно работать очень аккуратно: следить, чтобы у нас больших объектов по возможности не появлялось.

А под Mono у нас уже имеется не 3 поколения, а 2; концепт больших объектов там тоже есть, но работа с ним ведется совершенно иначе. И наши старые эвристики, которые мы применяли для больших объектов под Windows, на Linux под Mono работать не будут.

Оптимизация по памяти: сложно, но порой необходимо - 2

— Расскажите, пожалуйста, о какой-нибудь проблеме из продакшн?

Андрей Акиньшин: В Rider-е есть много проблем с той же кучей больших объектов.
Если мы создаем очень большой массив (который попадает в LOH) на не очень большой промежуток времени, это не очень хорошо, т.к. увеличивает фрагментированность LOH. Классическим решением в этой ситуации является создание вспомогательного класса — так называемого чанк-листа (chunk list): вместо выделения большого массива мы создаем несколько маленьких (чанков), каждый из которых достаточно мал, чтобы не попасть в LOH. Снаружи мы оборачиваем их в красивый интерфейс, чтобы для пользователя они выглядели как единый список. Это спасает нас от большого LOH и дает приятный выигрыш по расходу памяти.

Подобное решение у нас в ReSharper используется достаточно давно. Однако сейчас, когда мы пишем Rider (т.е. по сути запускаем ReSharper на Mono под Linux), этот хак не работает по задумке: как я уже говорил, в Mono логика работы с хипом совершенно другая. И такое дробление не только не дает положительного эффекта в плане производительности, но в некоторых случаях даже негативно сказывается на работе с памятью. Поэтому сейчас мы смотрим, как бы лучше соптимизировать такие места, чтобы они у нас эффективно работали не только под Windows, но и под другими ОС (Linux или MacOS).

— С чего вообще следует начинать работу с памятью в рамках повышения производительности приложения?

Андрей Акиньшин: Первое, с чего всегда нужно начинать, — это с замеров. Я видел очень многих программистов, которые пытаются на взгляд что-то понять («наверняка у нас здесь выделяется слишком много объектов — давайте прямо сейчас начнем вот это место оптимизировать»). Но этот подход, особенно в большом приложении, редко заканчивается чем-то хорошим. Конечно же, нужно выполнять замеры.
Есть различные профайлеры памяти для .NET. Например — dotMemory. Это очень хороший инструмент, он позволяет искать утечки памяти, выявлять различные проблемы, смотреть, какие объекты сколько памяти занимают и как они распределены по кучам, поколениям и т.д.
Под Windows профайлеров много, и все они меряют достаточно честно. Если мы говорим про Linux/Mac и mono, там инструментарий для профилировки по памяти намного более скудный, не всегда удается померить то, что хочется.

— Каких инструментов в плане профилирования вам больше не хватает?

Андрей Акиньшин: В первую очередь — нормального профайлера под Linux и Mac.
Например, mono имеет возможности встроенной профилировки — нужно его для этого со специальными ключиками запустить, но возможности там достаточно скудные, работать сложно, результатам можно верить не всегда. С CoreCLR все инструменты по профилированию также находятся в достаточно сыром состоянии. На эту тему много полезной информации в недавних постах Саши Гольдштейна, там рассказывается, как вообще начать с этим работать. Увы, не сказал бы, что есть особое удобство в запуске профайлинг-сессий и анализе результатов. Поэтому лично я жду, когда наконец-то появится удобный кроссплатформенный инструмент для профилирования (памяти в том числе).

— Кстати, об эволюции. По мере эволюции .NET и сопутствующего инструментария упрощается ли работа с памятью? Или головной боли становится только больше из-за появления новых механизмов?

Андрей Акиньшин: Я бы сказал, что жизнь постепенно становится лучше. Если мы говорим про Windows, то на полном фреймворке каких-то значимых изменений не было уже достаточно давно, все привыкли к тому, как работает сборщик мусора. Те, кому нужно, знают внутренности и как с ним правильно обращаться, чтобы всем было хорошо. Сейчас набирает популярность кроссплатформенная разработка — под Linux и Mac. И там, к сожалению, с инструментарием не так хорошо. Но постепенно он тоже развивается.

Более подробно об оптимизации приложений .NET, в особенности — о работе с памятью и другими компонентами, Андрей Акиньшин расскажет в рамках своего доклада на DotNext 2017 "Поговорим о памяти".

Автор: JUG.ru Group

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js