Как стать автором
Обновить

Снижение аллокации при замыкании (closure)

.NET *C# *

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

Что такое замыкание в C#?

Замыкания (closure) это очень крутая штука, которая помогает писать более лаконичный код на C#. Под капотом, замыкание это более-менее обычный класс, который "захватывает" ссылки на переменные, которые участвуют в замыкании.

Думаю вы видели, как многие IDE честно подсказывают, что в месте использования замыкания возникает захват переменных:

Уведомление о closure в IDE Rider
Уведомление о closure в IDE Rider

Что же происходит "под капотом"? В этом же классе создаётся класс, который представляет из себя то самое замыкание. Класс специально называется хитрым образом (в моём случае он называется DisplayClass4_0) и помечается атрибутом CompilerGenerated.

В специальном классе создаётся набор полей по количеству переменных, захватываемых замыканием. Также, создаётся метод, ссылка на который передаётся в метод Task.Run.

В декомпилированном коде (я использую dotPeek) это выглядит примерно вот так:

Декомпилированный код с замыканием в C#
Декомпилированный код с замыканием в C#

Почему происходит именно так? Потому что "замыкание" это не механизм платформы .NET, а языка C#. Если угодно, это синтаксический сахар, который делает язык красивым и выразительным. Однако, на более низком уровне, любой синтаксический сахар требует низкоуровневой реализации - и это она и есть. Подробнее о замыканиях написал Сергей Тепляков и никому не известный Стефан Тауб. У их написано много, объясняется сравнительно легко, в том числе затрагиваются особенности работы с замыканиями.

Аллокация при замыкании

Можно заметить, что при каждом замыкании "под капотом" создаётся инстанс класса замыкания, в его поля помещаются захватываемые значения, а в нужный нам метод передаётся ссылка на метод closure-класса, где и происходит выполнение логики, указанной в замыкании. Напомню, что инстанс класса размещается в куче, откуда его потом удалит GC.

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

Однако, если место использования замыкания горячее (часто вызывается), то GC может не успеть удалить инстансы closure-класса. При самых печальных сценариях, это может привести к "выживанию" классов вплоть до Gen2, с последующим stop the world для проведения вдумчивой очистки кучи.

Более того, не надо забывать, что не все имплементации платформы работают одинаково. Например, игровой движок Unity имеет особенный GC с одним поколением. Это требует от разработчиков очень внимательно относиться к тому, кто и что аллоцирует и в каких количествах.

Имплементация собственного замыкания

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

private sealed class Closure<T> {
  private readonly Action<T> _action;
  private readonly Action _closure;
  private T _value;

  public Closure(Action<T> action) {
    _action = action;
    _closure = Execute;
  }

  [MethodImpl(MethodImplOptions.AggressiveInlining)]
  public void Clear() => _value = default;

  [MethodImpl(MethodImplOptions.AggressiveInlining)]
  public Action Prepare(T value) {
    _value = value;
    return _closure;
  }

  private void Execute() => _action(_value);
}

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

for (var i = 0; i < _objects.Length; i++) {
    _tasks[i] = Task.Run(_closures[i].Prepare(in _objects[i]))
}

Task.WaitAll(_tasks);

foreach (var closure in _closures) {
    closure.Clear();
}

При использовании собственного класса-замыкания при декомпиляции кода мы можем наблюдать более понятную картину без "магии под капотом". Бонусом, мы избавились от создания new Action при передачи ссылки на метод замыкания в нужный нам метод.

Декомпилированный код с собственным замыканием в C#
Декомпилированный код с собственным замыканием в C#

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

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

Уменьшение аллокации при замыкании

Какие же конкретные числа мы можем получить при замене стандартного механизма замыкания на собственный велосипед? Были произведены замеры с использованием известного фреймворка для микробенчмаркинга BenchmarkDotNet. Код бенчмарка находится тут.

Method

Runtime

Mean

Ratio

Gen 0

Gen 1

Gen 2

Allocated

AutoClosure

.NET 6.0

6.131 ms

1.00

203.1250

-

-

1,711 KB

ParallelFor

.NET 6.0

1.850 ms

0.30

265.6250

-

-

2,158 KB

ParallelForeach

.NET 6.0

1.916 ms

0.31

271.4844

-

-

2,221 KB

SelfClosure

.NET 6.0

5.883 ms

0.96

93.7500

-

-

774 KB

AutoClosure

.NET Core 3.1

6.727 ms

1.00

203.1250

-

-

1,713 KB

ParallelFor

.NET Core 3.1

1.925 ms

0.29

257.8125

-

-

2,108 KB

ParallelForeach

.NET Core 3.1

2.015 ms

0.30

265.6250

-

-

2,182 KB

SelfClosure

.NET Core 3.1

6.880 ms

1.02

93.7500

-

-

773 KB

AutoClosure

.NET Framework 4.6.1

8.776 ms

1.00

296.8750

46.8750

15.6250

1,890 KB

ParallelFor

.NET Framework 4.6.1

1.726 ms

0.20

208.9844

9.7656

1.9531

1,290 KB

ParallelForeach

.NET Framework 4.6.1

1.861 ms

0.21

216.7969

9.7656

1.9531

1,345 KB

SelfClosure

.NET Framework 4.6.1

8.380 ms

0.95

140.6250

15.6250

-

954 KB

Приятно, что скорость осталась примерно прежней. Это говорит о том, что сделано более менее правильно.

Столбец "Allocated" бодро рапортует нам о том, что аллокация меньше почти в два раза. Но, собственно, почему же она есть? Если вы посмотрите код бенчмарка, то вы заметите, что я пытаюсь минимизировать аллокацию при запуске Task'ов. Это достаточно распространенный случай использования замыкания. Цифры, которые можно увидеть в столбце Allocated включают в себя затраты платформы на создание Task'ов.

Parallel.For и Parallel.ForEach

В бенчмарке, также, можно найти результаты для Parallel.For и Parallel.ForEach. Их использование значительно повышает скорость работы и, к сожалению, существенно увеличивают аллокацию. Дьявол кроется в деталях: Parallel.ForEach принимает в качестве аргумента IEnumerable<T>, который возвращает IEnumerator<T>. Это объект, который будет расположен в куче, а значит будет нагружать GC. Ну а Parallel.For принимает делегат, где снова создаётся объект-замыкания, что также влияет на аллокацию. Спасибо комментатору @Deosis.

И снова дьявол кроется в деталях. При увеличении количества заданий (изначально их было 10) начинает стремительно выигрывать имплементация на Parallel.For и Parallel.ForEach. Во-первых, она просто быстрая, а во-вторых, создание enumerator'a и аллокация замыкания - это фиксированная плата, никак не зависящая от количества. Бенчмарк это явно показывает. Спасибо @soalexmn. В его комментарии количество заданий увеличено до 400 и цифры там совсем драматичные.

Method

Runtime

Tasks

Mean

Ratio

Gen 0

Gen 1

Gen 2

Allocated

AutoClosure

.NET 6.0

10

6.252 ms

1.00

203.1250

-

-

1,709 KB

ParallelFor

.NET 6.0

10

1.891 ms

0.30

263.6719

-

-

2,154 KB

ParallelForeach

.NET 6.0

10

1.909 ms

0.31

269.5313

-

-

2,220 KB

SelfClosure

.NET 6.0

10

5.747 ms

0.88

93.7500

-

-

773 KB

AutoClosure

.NET Core 3.1

10

6.706 ms

1.00

203.1250

-

-

1,710 KB

ParallelFor

.NET Core 3.1

10

1.900 ms

0.28

259.7656

-

-

2,115 KB

ParallelForeach

.NET Core 3.1

10

2.016 ms

0.30

265.6250

-

-

2,190 KB

SelfClosure

.NET Core 3.1

10

6.740 ms

1.01

93.7500

-

-

774 KB

AutoClosure

.NET Framework 4.6.1

10

8.514 ms

1.00

296.8750

46.8750

15.6250

1,896 KB

ParallelFor

.NET Framework 4.6.1

10

1.667 ms

0.20

208.9844

13.6719

1.9531

1,292 KB

ParallelForeach

.NET Framework 4.6.1

10

1.768 ms

0.21

216.7969

9.7656

1.9531

1,344 KB

SelfClosure

.NET Framework 4.6.1

10

8.455 ms

0.99

140.6250

15.6250

-

950 KB

AutoClosure

.NET 6.0

100

65.939 ms

1.00

2000.0000

-

-

16,368 KB

ParallelFor

.NET 6.0

100

3.455 ms

0.05

347.6563

-

-

2,831 KB

ParallelForeach

.NET 6.0

100

4.040 ms

0.06

367.1875

-

-

2,975 KB

SelfClosure

.NET 6.0

100

60.615 ms

0.92

750.0000

-

-

6,982 KB

AutoClosure

.NET Core 3.1

100

68.076 ms

1.00

2000.0000

-

-

16,370 KB

ParallelFor

.NET Core 3.1

100

3.467 ms

0.05

335.9375

-

-

2,726 KB

ParallelForeach

.NET Core 3.1

100

3.796 ms

0.06

351.5625

-

-

2,845 KB

SelfClosure

.NET Core 3.1

100

69.871 ms

1.03

750.0000

-

-

6,980 KB

AutoClosure

.NET Framework 4.6.1

100

78.586 ms

1.00

2857.1429

571.4286

142.8571

18,094 KB

ParallelFor

.NET Framework 4.6.1

100

3.343 ms

0.04

289.0625

3.9063

-

1,788 KB

ParallelForeach

.NET Framework 4.6.1

100

4.095 ms

0.05

304.6875

7.8125

-

1,912 KB

SelfClosure

.NET Framework 4.6.1

100

78.101 ms

0.99

1285.7143

142.8571

-

8,685 KB

P.S.: Начал писать в телегу и, внезапно, Дзен. Заглядывайте, если интересно.

Теги:
Хабы:
Всего голосов 8: ↑8 и ↓0 +8
Просмотры 6.8K
Комментарии Комментарии 22