Замеры вокруг ArrayPool. Один и тот же массив получают тремя способами —
new byte[n], GC.AllocateUninitializedArray<byte>(n) и
ArrayPool<byte>.Shared.Rent(n) с возвратом, — и смотрят, что при этом
происходит с памятью и со временем. Отдельно проверяется, куда попадает буфер
Stream.CopyTo.
BenchmarkDotNet 0.15.8, Release. Всё на .NET 8, .NET 9 и .NET 10, на обычном сборщике и на серверном, на четырёх машинах.
Пул хранит массивы степенями двойки. Запрос на 81 920 байт — размер буфера
по умолчанию у Stream.CopyTo — попадает в бакет 13, и длина массива в нём
131 072 байта. Порог кучи больших объектов для byte[] проходит по длине
84 976: к длине добавляется заголовок в 24 байта, и 84 976 + 24 = 85 000.
Аренда отправляет массив в эту кучу на размерах от 65 537 до 84 975 байт,
new на тех же размерах оставляет массив в нулевом поколении.
FileStream.CopyTo, CopyToAsync и наследники MemoryStream выделяют
139 296 байт на первом копировании — на четырёх машинах, трёх рантаймах
и двух сборщиках. MemoryStream без наследования не выделяет ничего.
Прирост самой кучи при этом виден не всегда: пул через Gen2GcCallback
освобождает лишние массивы после сборки второго поколения, и на части машин
это происходит между двумя чтениями размера кучи. Поэтому рядом с приростом
стоит второй счётчик — выделено байт.
64 буфера по 81 920 байт, удерживаемых одновременно, дают 8 195 КБ в этой куче. Те же 64 запроса с возвратом после каждого — 128 КБ.
Return по умолчанию массив не чистит: следующий арендатор получает все
16 байт из 16, а массив ссылок держит все 64 объекта из 64. Двойной возврат
пул принимает без ошибки, массив из другого пула отклоняет исключением.
Аренда идёт по нижней границе — по времени готового массива. Вместимость пула зависит от числа логических процессоров: на 1 024 удерживаемых буферах машины с 20 процессорами выделяют 401 КБ на вызов, машины с 32 и 64 — ноль.
Полные отчёты в Results.
| № | CPU | Ядра/потоки | ОС | Процессоров |
|---|---|---|---|---|
| №1 | AMD Ryzen 9 5950X | 16 / 32 | Windows 10 1809 | 32 |
| №2 | Intel Core i9-10900KF | 10 / 20 | Windows 10 22H2 | 20 |
| №3 | 2 × Intel Xeon Silver 4314 | 2×16 / 64 | Windows Server 2022 | 64 |
| №4 | Intel Xeon W-2255 | 10 / 20 | Windows Server 2022 | 20 |
Нужны SDK .NET 8, 9 и 10 — BenchmarkDotNet поднимает по процессу на каждый рантайм. Проверить, что все три на месте:
dotnet --list-sdks
Весь прогон одной командой, без аргументов:
all.bat
Скрипт собирает проект, снимает текстовые отчёты на трёх рантаймах и двух
сборщиках, повторяет часть отчётов с поднятым порогом кучи больших объектов,
запускает замеры и складывает всё в Results\<имя машины>. Имя папки берётся
из имени машины.
Вручную, без скрипта:
dotnet run -c Release -f net10.0 -- reports
dotnet run -c Release -f net10.0 -- extra
dotnet run -c Release -f net10.0 -- loh 64
dotnet run -c Release -f net10.0 -- seq
dotnet run -c Release -f net10.0 -- copy file
dotnet run -c Release -f net10.0
dotnet run -c Release -f net10.0 -- --filter *PoolTouchBench*
| Класс | Что сравнивает |
|---|---|
| PoolRentBench | аренда с возвратом против new, по размерам, на трёх рантаймах |
| PoolThresholdBench | тот же вопрос мелким шагом: с какого размера аренда быстрее |
| PoolUninitializedBench | аренда против выделения без обнуления, без записи в массив |
| PoolTouchBench | то же самое, но массив заполняется целиком |
| PoolClearBench | возврат с очисткой против возврата без неё |
| PoolWasteBench | те же способы на размерах из разных профилей |
| PoolThreadBench | возврат в своём потоке против возврата в чужом |
| PoolParallelBench | аренда против выделения в нескольких потоках сразу |
| PoolCapacityBench | сколько буферов пул удерживает, пока не начнёт выделять |
Отчёты вне BenchmarkDotNet:
| Отчёт | Что печатает |
|---|---|
reports |
округление запроса до бакета, поколение массива, что пул держит после возврата |
extra |
где проходит порог кучи больших объектов, до какого размера пул хранит массивы, потеря на округлении |
loh |
прирост кучи больших объектов и выделено байт, когда буферы удерживают одновременно |
seq |
то же самое, когда буфер возвращают сразу |
copy |
то же самое на Stream.CopyTo, по одному виду потока на запуск |
Все измеряемые методы лежат в Subjects.cs, у каждого способа свой метод
с NoInlining.
Прирост кучи больших объектов читается через GC.GetGCMemoryInfo, а этот метод
возвращает состояние на момент последней завершённой сборки. Поэтому перед
каждым чтением идёт принудительная полная сборка, и притом дважды: после первой
могут отработать финализаторы.
Рядом с приростом стоит второй счётчик — GC.GetTotalAllocatedBytes. Он меряет
другое: сколько памяти запрошено, а не сколько её осталось после сборки. Две
величины расходятся там, где пул успел освободить массив между двумя чтениями,
и без второго счётчика такой случай не отличить от того, где буфер вообще
не арендовали. Параметр precise намеренно false: с true рантайм ради
точного счёта делает сборку мусора, а сборка здесь и есть то, на что реагирует
пул.
Отчёты разнесены по разным аргументам намеренно. Все они работают с общим
пулом, и в одном процессе первый оставлял бы после себя массивы, а следующий
показывал бы нулевой прирост там, где его быть не должно. По той же причине
каждый вид потока в отчёте copy и каждое количество буферов в отчёте loh
снимаются своим процессом.
В PoolTouchBench массив после получения заполняется целиком. Без записи
сравнение с GC.AllocateUninitializedArray неполное: new обнуляет память
и тем самым обращается ко всем страницам, а выделение без обнуления переносит
эту работу на первую запись. Строка Existing — нижняя граница: массив создан
и уже заполнен, остаётся только запись.
Порог кучи больших объектов настраивается. Скрипт повторяет отчёты extra,
copy file и seq с DOTNET_GCLOHThreshold=0x30000, то есть с порогом
196 608 — выше бакета в 131 072. Отчёт показывает, применилась ли переменная:
если граница осталась на 84 976, значит нет.
Кодировка вывода задаётся в Program.cs. Консоль на разных машинах пишет
по-разному, а отчёты уезжают в репозиторий одним набором.
PoolProof.csproj многоцелевой: net8.0, net9.0, net10.0
PoolProof.slnx
Program.cs точка входа, разбор аргументов
Subjects.cs все измеряемые методы
README.md
all.bat весь прогон одной командой
Benchmarks/ девять классов, по одному на файл
Enums/ SizeProfile
Diagnostics/
Diagnostic.cs округление, поколение, что держит пул
Extra.cs порог кучи, вместимость по размеру, потеря
LohGrowth.cs прирост кучи и выделено байт
PayloadStream.cs наследник MemoryStream для проверки CopyTo
Results/
<машина>/ выгрузки прогона
<машина>/bench/ отчёты BenchmarkDotNet
Docs/ графики
- ArrayPool<T>
- ArrayPool<T>.Return
- ArrayPool.cs — комментарий к
Return - Utilities.cs — расчёт бакета
- SharedArrayPool.cs — хранение по процессорам
- Gen2GcCallback.cs — как пул узнаёт о сборке
- Stream.cs — размер буфера и аренда
- MemoryStream.cs — проверка
GetTypeвCopyTo - Куча больших объектов
- Настройки сборщика мусора
- GC.AllocateUninitializedArray
- BenchmarkDotNet

