Поиск по сайту:

Все, что вам нужно знать об оптимизации производительности .NET


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

Как рассчитать время вашего кода

Самый простой способ измерить, сколько времени занимает функция, — это использовать секундомер. Этот класс может выполнять очень точные измерения кода при отладке и даже может использоваться программно для таких вещей, как установка тайм-аутов.

Stopwatch watch = System.Diagnostics.Stopwatch.StartNew();

// the code that you want to measure
watch.Stop();
double elapsedMs = watch.Elapsed.TotalMilliseconds;

Несмотря на то, что он довольно точен, он может привести к некоторым неожиданным результатам — он не учитывает «фоновый шум», такой как JIT-компиляция функции .NET, что часто может привести к тому, что самое первое выполнение будет длиннее. Для получения более точных результатов следует взять среднее значение по многим прогонам.

Однако для более удобной отладки следует использовать профилировщик производительности. В Visual Studio есть отличные встроенные средства отладки, которые могут даже отображать графики, показывающие, какие функции выполняются дольше всего.

Вы также можете использовать профилировщик производительности для отладки использования памяти. Просто нажмите «Сделать снимок», и вы сможете просмотреть все выделенные объекты.

Проверьте свои циклы

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

Однако, если вы делаете что-то несколько раз, или делаете что-то на каждом тике или на каждом запросе, стандарты сразу же меняются. Даже небольшие улучшения гораздо важнее, потому что более 10 000 циклов могут быстро складываться.

Другой похожий сценарий — это функции с неограниченным размером параметров. Например, у вас может быть функция, которая хорошо работает с небольшой строкой или небольшим списком, но если вы передадите ей список из 10 000 элементов, могут проявиться плохо оптимизированные алгоритмы.

Помните об обозначении Big O

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

Обозначение Big O используется для классификации скорости и сложности алгоритмов. Вы можете думать об этом как о графике с размером ввода (например, количеством элементов списка) на оси x и временем выполнения на оси y.

Реальный мир, очевидно, очень запутан, поэтому вместо того, чтобы пытаться описать точное время выполнения, предполагается, что нотация Big O представляет форму самой кривой. Вы можете быстро увидеть, где что-то вроде O(N^2) или полиномиальное время может быть проблемой; со 100 элементами списка он может работать нормально, но с 10 000 элементов списка он может остановиться.

Однако нотация Big O предназначена не только для времени выполнения — ее также можно использовать для описания использования других ресурсов, таких как дисковое пространство и объем памяти.

В .NET одним из самых больших улучшений алгоритма, которое вы можете сделать, является замена поиска по спискам структурами данных на основе хэшей, такими как Dictionary и HashSet, вместо поиска в больших списках путем итерации. .

Например, этот код будет перебирать каждый объект Person в большом списке, проверяя, не назван ли кто-то из них именем Стив. Для небольших списков это не имеет большого значения, но этот запрос выполняется за время O(N) или за линейное время и будет масштабироваться, если вам нужно делать это часто.

Если вы обычно ищете по имени, вы можете вместо этого использовать словарь, в котором хранится уникальный список объектов на основе хэша данного ключа. В этом случае он хранит хэш имени каждого человека, хотя вам нужно будет сгенерировать его вручную и синхронизировать с любыми другими поисковыми запросами, которые у вас могут быть.

Поиск словарного значения выполняется за O(1) или за фиксированное время. Независимо от размера списка, хеширование и поиск стоят одинаково. Это также относится к прямому поиску в массиве, например array[i], который представляет собой просто прямой доступ к памяти.

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

Наконец, запросы LINQ фактически используют отложенное выполнение. Например, .Where() проверяет каждый элемент на соответствие запросу. За исключением того, что это не возвращает новый список — он возвращает IEnumerable, который откладывает выполнение до тех пор, пока он не будет повторен или вручную не возвращен обратно в список с помощью .ToList().

Это означает, что если вы выполняете два запроса, вы выполняете не две итерации, а просто выполняете две проверки за одну итерацию, что устраняет все накладные расходы, связанные с выполнением ненужных циклов.

Уменьшите свой мусор, избегайте ненужных распределений

C# и другие языки .NET используют сборщик мусора, который периодически сканирует память и очищает объекты, на которые больше нет ссылок. Это замечательно для разработчиков, так как позволяет им писать более простой код гораздо быстрее.

Но поскольку бесплатного обеда не бывает, за него приходится платить — производительность. Когда GC запускается, он должен останавливать выполнение программы, что влияет на производительность. Однако то, что C# является сборщиком мусора, не означает, что вам нужно создавать ненужный мусор. Все, что вы можете сделать, чтобы уменьшить нагрузку на GC и запускать GC меньше раз, приведет к повышению производительности.

Но что такое мусор? В .NET существует два разных типа типов: типы значений и ссылочные типы. Типы значений имеют фиксированные размеры, такие как int, bool, float и другие структуры фиксированного размера. Они хранятся в стеке, представляющем собой очень быструю структуру данных в памяти.

Стек не быстрый, потому что это особая память, он быстрый, потому что это полностью управляемая структура данных «последний пришел, первый вышел» (LIFO). Переменные, которые вы выделяете, по существу складываются друг над другом в ведро. Когда переменные выходят за пределы области видимости, например, когда функция существует, ваш компьютер обращается к корзине и удаляет верхний элемент один за другим, пока стек снова не очистится. Это делает его очень быстрым. (Хотя выделение стека дешево, оно не совсем бесплатно.)

Значения другого типа хранятся по ссылке и используются для любого типа, который не имеет фиксированного размера, такого как списки и string. Переменная, содержащая ссылку, хранится в стеке, но основная память хранится в куче.

По сравнению со стеком куча представляет собой неуправляемую яму распределений памяти. Мало того, что его намного сложнее очистить, требуя использования сборщика мусора, так еще и намного сложнее выделить объекты. Конечно, это все еще довольно быстро, но по сравнению с выделением стека это намного медленнее.

Каждый раз, когда вы инициализируете что-то с помощью ключевого слова new , помните, что вы создаете память, которую нужно очистить позже. Например, в следующем коде используется только одна переменная, но поскольку вы выделяете два новых объекта, первый отбрасывается.

Fruit apple = new Fruit();
apple = new Fruit();

Когда вы сбрасываете эту переменную, вы просто устанавливаете для нее другой адрес памяти. Сборщик мусора поймет, что вам больше не нужен первый адрес, но может быть легко не заботиться, когда вы не занимаетесь очисткой.

Пул дорогих объектов, когда это возможно

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

Например, следующий код выполняется 10 000 раз и оставляет в конце 10 000 списков мусора.

Дело в том, что этой функции требуется только один список памяти, но на самом деле она использует в 10 000 раз больше памяти, чем ей нужно.

При использовании пула объектов вместо прямого выделения нового объекта вы запрашиваете его из пула. Пул может создать новый объект, если он недоступен. Затем, когда вы закончите с этим, вы отпустите этот объект обратно в пул.

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

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

Если вы хотите использовать пулы объектов, Microsoft предлагает реализацию в Microsoft.Extensions.ObjectPool. Если вы хотите реализовать это самостоятельно, вы можете открыть исходный код, чтобы проверить, как это работает в DefaultObjectPool. Как правило, у вас будет свой пул объектов для каждого типа с максимальным количеством объектов, которые нужно хранить в пуле.

Советы по сериализации JSON

Сериализация и десериализация JSON часто является серьезным узким местом в производительности. В конце концов, он работает с гигантскими строками и огромными кусками памяти. К счастью, есть несколько приемов, позволяющих ускорить процесс.

Десериализовать из потоков

Если вы работаете с большими фрагментами JSON, особенно с размерами более 85 КБ, вам потребуется десериализовать только из потока памяти. Это связано с тем, что для работы со строками размером более 85 КБ они должны храниться в куче больших объектов, что не способствует производительности. Когда вместо этого вы выполняете десериализацию из потока памяти, это не имеет значения, потому что за раз читается только небольшой фрагмент.

HttpClient client = new HttpClient();

using (Stream s = client.GetStreamAsync("http://www.test.com/large.json").Result)
using (StreamReader sr = new StreamReader(s))
using (JsonReader reader = new JsonTextReader(sr))
{
    JsonSerializer serializer = new JsonSerializer();
    Person p = serializer.Deserialize<Person>(reader);
}

Ручная сериализация

Помимо работы с большим объемом памяти, сериализация JSON медленная по другой причине — рефлексии. Чтобы десериализовать универсальный объект в любой заданный тип, большинство преобразователей JSON должны смотреть на структуру передаваемого ему типа, что является медленной операцией.

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

public static string ToJson(this Person p)
{
    StringWriter sw = new StringWriter();
    JsonTextWriter writer = new JsonTextWriter(sw);

    writer.WriteStartObject();

    writer.WritePropertyName("name");
    writer.WriteValue(p.Name);

    writer.WritePropertyName("likes");
    writer.WriteStartArray();
    foreach (string like in p.Likes)
    {
        writer.WriteValue(like);
    }
    writer.WriteEndArray();

    writer.WriteEndObject();

    return sw.ToString();
}

Рассмотрите возможность использования Джил

Jil — это сериализатор/десериализатор .NET JSON от StackExchange с рядом «несколько безумных приемов оптимизации». Он более чем в два раза быстрее при сериализации, чем NewtonSoft, и почти в два раза быстрее при десериализации (хотя и за счет использования большего объема памяти). Если вам интересно, вы можете прочитать все об их оптимизации производительности на их странице GitHub.

Используйте StringBuilder для изменяемых строк

Строки — это, по сути, замаскированные списки, и при работе с ними довольно легко забыть об этом факте. Не помогает и то, что они неизменяемые. Это может привести к значительно большему выделению памяти, чем необходимо.

Например, возьмите следующий код, который работает с парой строк:

string first = "Hello, ";
first += "World";

Интуитивное понимание состоит в том, что это просто добавляет «Мир» в конец строки и изменяет значение переменной. Но на самом деле он создает совершенно новую строку с отдельным фрагментом памяти и отбрасывает первую строку. Переменная first изменена и указывает на новый адрес памяти.

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

Чтобы использовать их, создайте new StringBuilder(), который вы можете инициализировать базовой строкой и предварительно выделить определенное количество символов. Затем вы можете добавить к нему другие строки или символы, а затем, когда вы хотите создать реальную строку, вы можете вызвать для нее .ToString() .

static void Main()
{
    // Create a StringBuilder that expects to hold 50 characters.
    // Initialize the StringBuilder.
    StringBuilder sb = new StringBuilder("Hello, ", 50);
    sb.Append("World");
    Console.WriteLine(sb.ToString());
}

Используйте stackalloc, когда это применимо

stackalloc – это функция, используемая для размещения списка в стеке, которая выполняется намного быстрее, чем выделение в куче, и не создает мусора. Раньше для этого требовалось использование указателей в unsafe контексте, но начиная с C# 7.2 вы можете использовать его с Span, чтобы сделать это безопасно:

Span<byte> data = stackalloc byte[256];

Конечно, с большими возможностями приходит и большая ответственность, и stackalloc следует использовать очень экономно в определенных сценариях. Если вам интересно, вы можете прочитать эту статью о том, что нужно и что нельзя делать, но общее правило заключается в том, чтобы использовать ее только для локальных переменных в относительно небольших списках фиксированного размера, которые выходят за рамки быстро, когда функция выходит. Вы никогда не должны использовать его с переменной длиной выделения или в цикле переменной длины.

Используйте задачи и сопрограммы для длительных функций

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

Обычно это делается с задачами, которые после запуска ставятся в очередь в отдельном потоке и могут ожидать другие задачи, например получение веб-запроса или чего-либо из базы данных. Как только это будет сделано, задача уничтожается, и к результату можно получить доступ из основного потока.

Большим преимуществом задач является то, что, поскольку они находятся в другом потоке, их можно обрабатывать одновременно. Например, вы можете начать десериализацию JSON в другом потоке, затем выполнить другую обработку в основном потоке и ожидать результат JSON, когда он вам понадобится. Для некоторых приложений вы можете выполнять многопоточный процесс, позволяя ему использовать всю мощность процессора.

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

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

Избегайте ненужной упаковки и распаковки

object – это основной базовый класс в .NET. Даже типы значений, такие как int и bool, являются объектами. По этой причине методы и классы, которые принимают object , могут быть очень полезны для полиморфизма. Но в большинстве случаев вам следует вместо этого использовать параметры универсального типа, чтобы избежать ненужной упаковки.

Упаковка — это концепция, которая используется для передачи типов значений методам, принимающим объекты. Всякий раз, когда вы упаковываете тип значения, он копируется в кучу, что вызывает выделение кучи и копирование памяти. Это, очевидно, менее производительно, поэтому каждый раз, когда вы можете избежать использования параметров универсального типа и избежать бокса, вы должны это сделать.

В частности, это включает в себя ArrayList, старый формат списка, в котором активно использовалась упаковка. От него отказались в пользу более производительного List, который вам обязательно следует использовать вместо него.

Оптимизация итераций 2D-списка для предварительной выборки кэша

Это немного странно, но для двумерных списков порядок итераций действительно имеет значение. Например, рассмотрим Array[3][4], например:

[1 2 3 4],
[5 6 7 8],
[9 1 2 3]

Вы бы сначала получили доступ к каждому элементу, присвоив ему значение Y, а затем присвоив ему значение X.

list[y][x]

Проблема в том, что .NET хранит этот список в порядке слева направо, спускаясь по списку. Если бы вы сначала перебирали Y (то есть сверху вниз, двигаясь вдоль каждого столбца), вы бы перебирали большие куски памяти.

Поскольку современные ЦП активно используют предварительную выборку из кеша (по сути, извлечение следующих 64 непрерывных байтов до того, как они будут фактически запрошены) для повышения производительности, вам нужно перебирать список в том порядке, в котором он хранится в памяти. Разница может быть существенной — для больших списков неправильное повторение может быть более чем в 10 раз медленнее.