Бенчмаркая ArrayPool: подстава при копировании потоков — 131 072 байта в LOH

от автора

Уважаемые читатели, в этой статье я хочу разобраться, что происходит с буфером при аренде из ArrayPool, и представить свои выводы.

Пул берут, чтобы переиспользовать массивы вместо создания новых. Особенно крупные: массив от 85 000 байт попадает в кучу больших объектов, а она собирается только вместе со вторым поколением. На части размеров получается наоборот. Пул отправляет массив в эту кучу, а new оставляет его в нулевом поколении. Копирование файлов идёт через буфер из этих размеров.

Сравниваются три способа получить массив на n байт:

  • new byte[n] — обычное выделение, память обнуляется;

  • GC.AllocateUninitializedArray<byte>(n) — выделение без обнуления;

  • ArrayPool<byte>.Shared.Rent(n) с последующим Return — аренда из общего пула.

Будет 5 историй:

  • почему буфер на 81 920 байт превращается в 131 072 и попадает в LOH;

  • где Stream.CopyTo попадает в LOH, а где нет;

  • что происходит с данными при возврате массива в пул;

  • сколько занимает выдача массива;

  • сколько пул удерживает буферов и от чего зависит это число.

Исходники обоих пулов уже разобраны в статье epeshk «ArrayPool<T>: подводные камни». Здесь другое: те же механизмы, но со стороны замеров, на четырёх машинах и трёх рантаймах.

Код — в репозитории PoolProof. Четыре машины:

CPU

Ядра/потоки

Частота

ОС

Процессоров

.NET 10

№1

AMD Ryzen 9 5950X

16 / 32

3,4 ГГц, до 4,9 в турбо

Windows 10 1809

32

10.0.5

№2

Intel Core i9-10900KF

10 / 20

3,7 ГГц, до 5,3 в турбо

Windows 10 22H2

20

10.0.10

№3

2 × Intel Xeon Silver 4314

2×16 / 64

2,4 ГГц, до 3,4 в турбо

Windows Server 2022

64

10.0.1

№4

Intel Xeon W-2255

10 / 20

3,7 ГГц, до 4,5 в турбо

Windows Server 2022

20

10.0.1

BenchmarkDotNet 0.15.8, Release, .NET 8, 9 и 10. Отчёты сняты на обычном сборщике и на серверном, числа совпали, поэтому дальше даны без разделения. Патч-версии рантайма — в последней колонке.

История 1. Откуда берётся 131 072

Пул хранит массивы степенями двойки. Номер бакета считается одной строкой — Utilities.cs:

// dotnet/runtime, System/Buffers/Utilities.cs[MethodImpl(MethodImplOptions.AggressiveInlining)]internal static int SelectBucketIndex(int bufferSize){    // Buffers are bucketed so that a request between 2^(n-1) + 1 and 2^n is given a buffer of 2^n    // Bucket index is log2(bufferSize - 1) with the exception that buffers between 1 and 16 bytes    // are combined, and the index is slid down by 3 to compensate.    // Zero is a valid bufferSize, and it is assigned the highest bucket index so that zero-length    // buffers are not retained by the pool. The pool will return the Array.Empty singleton for these.    return BitOperations.Log2((uint)bufferSize - 1 | 15) - 3;}

Для 81 920 это бакет 13, а длина массива в нём — 131 072 байта.

Строка 16 << 13 — это 16 * 2¹³: пул хранит массивы степенями двойки, начиная с шестнадцати байт, и номер бакета задаёт степень.

// PoolProof, вывод dotnet run -c Release -f net10.0 -- extra=== Где проходит порог кучи больших объектов ===порог из документации: 85 000первая длина byte[], попадающая во второе поколение: 84 976размер бакета для неё: 131 072наибольшая длина, остающаяся в нулевом поколении: 84 975

84 976 + 24 = 85 000. Двадцать четыре байта — заголовок массива на x64: синхроблок, указатель типа, длина. К этому числу вернусь во второй истории: если сдвинуть порог, те же 24 байта отнимутся от нового значения.

Для расчёта хватает двух чисел. На каких размерах пул отправляет массив в LOH, хотя без пула он остался бы в обычной куче:

Запрос, байт

Бакет

Длина массива, байт

В LOH

А без пула

65 536

12

65 536

нет

нет

65 537

13

131 072

да

нет

81 920

13

131 072

да

нет

84 975

13

131 072

да

нет

84 976

13

131 072

да

да

Бакет посчитан по SelectBucketIndex, порог для byte[] взят из отчёта extra

Пул отправляет массив в кучу больших объектов на размерах от 65 537 до 84 975 байт. new на этих же размерах оставляет массив в нулевом поколении. Ниже 65 537 округление даёт 65 536 и в LOH не попадает. С 84 976 байт массив идёт в LOH и без пула. Буфер CopyTo попадает в этот диапазон.

Замер такой: 16, 32 и 64 буфера по 81 920 байт удерживаются одновременно.

Прирост кучи больших объектов, буферы удерживаются одновременно. Одинаково на четырёх машинах, трёх рантаймах и обоих сборщиках

Прирост кучи больших объектов, буферы удерживаются одновременно. Одинаково на четырёх машинах, трёх рантаймах и обоих сборщиках

Из графика можно сделать выводы:

  • 16 буферов из пула дают 2 048 КБ, ровно шестнадцать массивов по 131 072 байта;

  • 32 буфера дают 4 097 КБ при ожидаемых 4 096;

  • 64 буфера дают 8 195 КБ при ожидаемых 8 192;

  • new во всех трёх случаях даёт ноль: 81 920 байт остаются в нулевом поколении.

Каждый замер идёт отдельным процессом. Иначе 32 буфера возьмутся из тех, что вернул в пул предыдущий замер, и прирост получится вдвое меньше настоящего.

Худший случай выглядит так. Результат меняется, если буфер возвращать сразу:

Запросов подряд

Прирост, КБ

Удержанием было бы, КБ

Выделено, Б

64

128

8 192

139 296

Те же 64 запроса, каждый буфер возвращается сразу. Одинаково на четырёх машинах

Пулу хватает одного массива на все 64 запроса — 139 296 выделенных байт. Массив остаётся в куче больших объектов до конца работы процесса, но он там один. Отсюда и разница: 128 КБ против 8 192 КБ.

История 2. Stream.CopyTo

81 920 — это размер буфера по умолчанию у Stream.CopyTo. Константу выбирали так, чтобы буфер не попал в кучу больших объектов.

Комментарий из Stream.cs:

// dotnet/runtime, System/IO/Stream.csprivate int GetCopyBufferSize(){    // This value was originally picked to be the largest multiple of 4096 that is still smaller than the large object heap threshold (85K).    // The CopyTo{Async} buffer is short-lived and is likely to be collected at Gen0, and it offers a significant improvement in Copy    // performance.  Since then, the base implementations of CopyTo{Async} have been updated to use ArrayPool, which will end up rounding    // this size up to the next power of two (131,072), which will by default be on the large object heap.  However, most of the time    // the buffer should be pooled, the LOH threshold is now configurable and thus may be different than 85K, and there are measurable    // benefits to using the larger buffer size.  So, for now, this value remains.    const int DefaultCopyBufferSize = 81920;

В том же файле, внутри CopyToAsync:

// dotnet/runtime, System/IO/Stream.csbyte[] buffer = ArrayPool<byte>.Shared.Rent(bufferSize);

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

Обращение dotnet/runtime issue #43179 закрыто 25 апреля 2025 года. Константа осталась прежней, аренда из общего пула тоже — обе строки выше сняты с текущего Stream.cs.

Дальше копирование. В таблице по две колонки на машину: прирост кучи больших объектов и выделено байт. Первая показывает, сколько памяти осталось после сборки, вторая — сколько её запросили.

Что копируем

№1

№2

№3

№4

Выделено, Б

MemoryStream точного типа

0

0

0

0

8 200

наследник MemoryStream

0

128

128

0

139 296

он же второй раз в том же процессе

0

0

0

0

2 944 на №2 и №3, 138 768 на №1 и №4

наследник, CopyTo(dest, 65 536)

0

0

0

0

74 688

наследник, CopyToAsync(dest)

0

128

128

0

139 296

FileStream, CopyTo(dest)

0

128

128

0

139 296

Прирост кучи больших объектов в КБ по машинам и выделено байт. Каждая строка снята своим процессом. Счётчик выделенных байт округляет до 8 КБ, поэтому в базовой строке 8 200, а не 300

Из таблицы можно сделать выводы:

  • MemoryStream буфер не арендует: 8 200 выделенных байт, это погрешность счётчика;

  • наследник MemoryStream арендует, выделенные байты показывают 139 296 на четырёх машинах, трёх рантаймах и двух сборщиках;

  • размер 65 536 задан явно, выделено 74 688, арендован меньший массив;

  • FileStream и асинхронный вариант дают те же 139 296;

  • прирост кучи виден только на двух машинах из четырёх.

Разница между первой и второй строкой — в MemoryStream.cs:

// dotnet/runtime, System/IO/MemoryStream.cspublic override void CopyTo(Stream destination, int bufferSize){    // If we have been inherited into a subclass, the following implementation could be incorrect    // since it does not call through to Read() which a subclass might have overridden.    // To be safe we will only use this implementation in cases where we know it is safe to do so,    // and delegate to our base class (which will call into Read) when we are not sure.    if (GetType() != typeof(MemoryStream))    {        base.CopyTo(destination, bufferSize);        return;    }    ...}

MemoryStream отдаёт свой массив напрямую, когда GetType() возвращает именно MemoryStream. У наследника GetType() возвращает наследника, условие даёт false, и работа уходит в базовую реализацию, а она арендует буфер. FileStream свой CopyTo не переопределяет — отсюда 131 072 байта при копировании файла.

Почему не везде есть прирост

Ответ даёт третья строка таблицы. На машинах №2 и №3 второе копирование выделяет 2 944 байта: буфер лежит в пуле и переиспользуется. На №1 и №4 выделяется 138 768 байт — буфер создаётся заново. В том месте, где прирост кучи нулевой, массива в пуле уже нет.

Пул выбрасывает массив после сборки второго поколения. О каждой такой сборке он узнаёт через Gen2GcCallback и освобождает лишние массивы — механизм разобран у epeshk. Замер прироста делает полную сборку до копирования и после него. На части машин пул успевает освободить буфер между двумя чтениями.

Это видно в отдельном отчёте того же прогона:

Что проверяем

№1

№2

№3

№4

массивов вернулось из 32 без сборки

32

32

32

32

массивов вернулось из 32 после полной сборки

31

32

32

31

Отчёт reports. Единица теряется ровно на тех двух машинах, где прирост кучи в таблице выше нулевой

Два независимых числа сходятся на одних и тех же машинах. В более раннем прогоне прирост был 128 КБ, то есть дело не в машине, а в том, дошла ли до пула сборка. Аренда 131 072 байт держится во всех прогонах и не зависит ни от машины, ни от времени запуска.

Что даёт настройка порога

Второе утверждение из комментария — порог настраивается. Те же отчёты с DOTNET_GCLOHThreshold=0x30000, порог 196 608:

// PoolProof, вывод при DOTNET_GCLOHThreshold=0x30000=== Где проходит порог кучи больших объектов ===порог из документации: 85 000первая длина byte[], попадающая во второе поколение: 196 584размер бакета для неё: 262 144наибольшая длина, остающаяся в нулевом поколении: 196 583

196 584 + 24 = 196 608. Заголовок в 24 байта подтвердился на втором пороге. FileStream.CopyTo даёт нулевой прирост на всех четырёх машинах, 64 запроса с возвратом тоже дают ноль вместо 128 КБ. Буфер на 131 072 в эту кучу больше не попадает.

История 3. Возврат в пул

У Return второй параметр по умолчанию выключен, а в комментарии к методу записано, что один и тот же массив возвращают ровно один раз:

// dotnet/runtime, System/Buffers/ArrayPool.cs/// Once a buffer has been returned to the pool, the caller gives up all ownership of the buffer/// and must not use it. The reference returned from a given call to <see cref="Rent"/> must only be/// returned via <see cref="Return"/> once.  The default <see cref="ArrayPool{T}"/>/// may hold onto the returned buffer in order to rent it again, or it may release the returned buffer/// if it's determined that the pool already has enough buffers stored.public abstract void Return(T[] array, bool clearArray = false);

Что проверяем

Результат

байт от прошлого арендатора из 16 после возврата без очистки

16

живых объектов из 64 в массиве ссылок после возврата без очистки

64

живых объектов из 64 после возврата с очисткой

0

две аренды после двойного возврата вернули один и тот же массив

да

возврат массива из другого пула

ArgumentException

Отчёты reports и extra, четыре машины, три рантайма, оба сборщика

Из таблицы можно сделать выводы:

  • массив байт отдаёт следующему арендатору все 16 байт от предыдущего;

  • массив ссылок держит все 64 объекта: сборщик их не заберёт, пока кто-нибудь не перезапишет ячейки;

  • возврат с очисткой обнуляет ячейки, и все 64 объекта уходят в сборку;

  • пул принимает двойной возврат без ошибки, и две следующие аренды получают один массив.

Последняя строка расходится с комментарием выше: контракт требует возвращать массив один раз, а проверки нет. Два независимых участка кода получают один буфер и не знают об этом. Документация на Return относит двойной возврат к уязвимостям высокой степени опасности и ссылается на CWE-415 и CWE-416 — двойное освобождение памяти и обращение к ней после освобождения. Массив из другого пула при этом отклоняется исключением: проверка в пуле есть, но не на этот случай.

Очистка занимает время, и оно зависит от длины массива:

Во сколько раз возврат с очисткой медленнее возврата без неё, .NET 10, колонка Ratio из отчёта PoolClearBench. Шкала логарифмическая

Во сколько раз возврат с очисткой медленнее возврата без неё, .NET 10, колонка Ratio из отчёта PoolClearBench. Шкала логарифмическая

Из графика можно сделать выводы:

  • на 1 024 байтах очистка добавляет чуть больше половины: 1,56–1,65;

  • на 16 384 байтах отношение доходит до 9,51–14,46;

  • на 81 920 байтах — 95,20–221,24;

  • на мегабайте — 1 014,69–3 002,97.

Аренда с возвратом без очистки занимает 8,0–13,7 нс на любом размере от 1 024 байт до мегабайта. Пул ничего не копирует, он меняет ссылку. Всё, что выше единицы на графике, — это обнуление.

История 4. Сколько времени уходит на аренду массива

Замер пишет в массив, иначе сравнение выйдет неполным. new обнуляет память и тем самым обращается ко всем страницам, а GC.AllocateUninitializedArray к ним не обращается и переносит работу на первую запись. Запись уравнивает все три способа.

Первая строка каждого блока — нижняя граница. Массив создан и уже заполнен, остаётся только запись.

Что меряем

№1 Ryzen

№2 i9

№3 Xeon Silver

№4 Xeon W

массив уже есть, 81 920

803

1 341

2 544

1 601

new + запись

2 351

3 858

6 431

7 471

без обнуления + запись

1 535

2 439

4 407

3 762

из пула + запись

814

1 346

2 584

1 609

массив уже есть, 1 МБ

11 802

24 753

32 775

21 169

new + запись

284 999

86 904

144 645

148 997

без обнуления + запись

273 621

68 416

138 113

109 088

из пула + запись

12 035

24 758

32 279

21 558

Наносекунд на вызов, .NET 10, отчёт PoolTouchBench. У строк new и без обнуления на мегабайте разброс доходит до 5% против 0,4% у соседних, между прогонами эти числа заметно расходятся. Строки с готовым массивом и из пула повторяются от прогона к прогону

Из таблицы можно сделать выводы:

  • на 81 920 байтах аренда идёт по нижней границе: 814 против 803 на первой машине и 1 609 против 1 601 на четвёртой;

  • на мегабайте отношение то же: 12 035 против 11 802 и 21 558 против 21 169;

  • new и выделение без обнуления на мегабайте занимают десятки и сотни тысяч наносекунд.

Отношение аренды к нижней границе по восьми замерам двух прогонов лежит между 0,985 и 1,035. Пул не делает ничего сверх записи в массив. Абсолютные числа new и выделения без обнуления зависят от загрузки машины, отношение — нет.

История 5. Сколько буферов удерживает пул

Все предыдущие числа сняты так: буфер возвращается сразу. Удержание буферов исчерпывает запас пула, и аренда переходит на выделение памяти. Замер берёт один размер и удерживает все буферы разом, а границу показывает колонка Allocated.

Удерживается буферов

№1, 32 проц.

№2, 20 проц.

№3, 64 проц.

№4, 20 проц.

512 по 1 024 байта

0

0

0

0

1 024 по 1 024 байта

0

401 384

0

401 385

512 по 81 920 байт

8

0

0

32

1 024 по 81 920 байт

32

50 209 365

0

50 209 383

Выделено байт на вызов, .NET 10, отчёт PoolCapacityBench. В шапке после номера машины — число логических процессоров

Из таблицы можно сделать выводы:

  • на 512 удерживаемых буферах все четыре машины выделяют не больше 32 байт;

  • машины с двадцатью процессорами на 1 024 удерживаемых буферах выделяют 401 КБ при размере буфера 1 024 байта и 50 МБ при размере 81 920;

  • машины с 32 и 64 процессорами на тех же 1 024 буферах выделяют 32 байта и ноль.

Эти две пары отличает только число процессоров. Общий пул хранит массивы в стеках, привязанных к процессорам, и вместимость растёт вместе с их количеством. Устройство разобрано у epeshk, исходник — SharedArrayPool.cs.

Отсюда следствие: замер вместимости с рабочей машины не переносится на сервер с другим числом процессоров.

Отдельный вопрос — до какого размера пул хранит массивы. Общий пул хранит всё, что проверялось, до 134 217 728 байт. Пул из ArrayPool.Create с параметрами по умолчанию — до 1 048 576 включительно:

// PoolProof, вывод dotnet run -c Release -f net10.0 -- extra=== До какого размера пул хранит массивы ===        Размер    Shared    Create()       524 288    хранит      хранит     1 048 576    хранит      хранит     2 097 152    хранит         нет     4 194 304    хранит         нет

Выводы

По цифрам:

  • порог кучи больших объектов для byte[] — 84 976 байт, а не 85 000: к длине добавляется заголовок в 24 байта. При поднятом пороге 196 608 граница проходит по 196 584, те же 24 байта;

  • аренда отправляет массив в эту кучу на размерах от 65 537 до 84 975 байт, new оставляет его в нулевом поколении;

  • 64 буфера по 81 920 байт, удерживаемые одновременно, дают 8 195 КБ; те же 64 с возвратом после каждого — 128 КБ и 139 296 выделенных байт;

  • new на 81 920 байт не увеличивает эту кучу ни при каком количестве буферов;

  • FileStream.CopyTo, CopyToAsync и наследники MemoryStream выделяют 139 296 байт на первом копировании, а сам MemoryStream не выделяет ничего;

  • прирост кучи виден не всегда: пул через Gen2GcCallback отдаёт буфер по сборке второго поколения, и на части машин это происходит до второго чтения;

  • то же копирование при DOTNET_GCLOHThreshold=0x30000 прироста кучи не даёт;

  • возврат без очистки отдаёт следующему арендатору все 16 байт и держит все 64 объекта, возврат с очисткой не оставляет ни одного объекта; пул отклоняет исключением массив из другого пула, а двойной возврат принимает без ошибки;

  • возврат с очисткой медленнее возврата без неё в 1,56–1,65 раза на 1 024 байтах и в 1 014,69–3 002,97 раза на мегабайте; аренда с возвратом занимает 8,0–13,7 нс на любом размере;

  • аренда идёт по нижней границе: отношение к времени готового массива 0,985–1,035 по восьми замерам двух прогонов;

  • машины с 20 процессорами на 1 024 удерживаемых буферах выделяют 401 КБ при размере буфера 1 024 байта и 50 МБ при размере 81 920, машины с 32 и 64 процессорами — 32 байта и ноль;

  • серверный сборщик не меняет ни одну из таблиц.

Что делать на практике:

  • создавать разовый буфер от 65 537 до 84 975 байт через new: аренда отправляет массив в кучу больших объектов, new оставляет его в нулевом поколении;

  • явно задавать размер буфера при копировании потоков, если нужно остаться вне этой кучи: CopyTo(dest, 65 536);

  • поднимать порог через DOTNET_GCLOHThreshold, когда размер буфера задать нельзя: на замерах прирост пропадает;

  • возвращать буфер сразу после работы: в куче больших объектов остаётся один массив вместо одного на вызов;

  • возвращать с очисткой массивы ссылок и буферы с данными, которые не должны попасть следующему арендатору: без очистки пул не даёт сборщику собрать объекты, а на мегабайте очистка в тысячу с лишним раз медленнее обычного возврата;

  • возвращать массив один раз и не обращаться к нему после возврата: пул двойной возврат не отклоняет;

  • не переносить замер вместимости с рабочей машины на сервер: вместимость зависит от числа логических процессоров;

  • мерить прирост кучи больших объектов вместе с выделенными байтами: сборка мусора меняет прирост, но не меняет выделенные байты.

Код из статьи

  • PoolProof — замеры, отчёты и прогоны на четырёх машинах

Ссылки

Всем удачи и до новых встреч!

ссылка на оригинал статьи https://habr.com/ru/articles/1065518/