Skip to content

Repository files navigation

PoolProof

Замеры вокруг 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/                    графики

Ссылки

About

Замеры ArrayPool на четырёх машинах: куда попадает буфер Stream.CopyTo, что остаётся в массиве после Return и сколько занимает аренда

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages