Skip to content

Repository files navigation

ChunkProof

Замеры Enumerable.Chunk: сколько массивов он создаёт, где проходит граница кучи больших объектов и во что это обходится по времени.

Проект к статье «Бенчмаркая Enumerable.Chunk: почему батчей меньше, а проход до ×2,7 дольше».

Чанк 25 000 против чанка 20 000

Чанк вырос с 20 000 до 25 000 элементов, вызовов стало меньше — а времени тратится вдвое больше при том же объёме выделенной памяти.

Проверка причины

С порогом кучи больших объектов в 196 608 байт вместо 85 000 разница между теми же размерами чанка исчезает на трёх машинах из четырёх.

Что меряется

Класс Что сравнивается
ChunkSourceBench массив, List<int> и метод с yield return; четыре размера чанка, три рантайма
ChunkSourceSizeBench те же источники плюс Skip с Take на коротких наборах: 100, 1 000 и 10 000 элементов
ChunkAlternativeBench Chunk против ReadOnlySpan<int>.Slice, CollectionsMarshal.AsSpan, своего буфера и буфера из ArrayPool
FirstBlockBench первый чанк против второго: у первого массив растёт удвоением
ChunkValueBench чанки int вокруг границы кучи больших объектов: 21 243 и 21 244
ChunkHeldBench чанки собираются в список и остаются в памяти до конца обработки
ChunkHeldServerBench та же таблица на серверном сборщике

Отчёты вне BenchmarkDotNet:

Отчёт Что печатает
blocks число чанков, их размер, попадание в кучу больших объектов, рост массива первого чанка и имя типа, который Chunk возвращает на самом деле
threshold границы кучи больших объектов, найденные перебором, для ссылки, long, int и byte
memory выделено байт, занято после сборки, занято в куче больших объектов и число сборок на одном проходе
collect сколько сборок какого поколения приходится на проход и сколько байт выделено за проход
checks сверка: все способы разбивают данные одинаково, чанки не переиспользуются, заголовок массива измерен верно, граница совпадает с ответом сборщика

Как воспроизвести

Нужны SDK .NET 8, .NET 9 и .NET 10. В коде есть field из C# 14, поэтому собирается SDK .NET 10 и новее.

all.bat Comp_1

В репозитории уже лежит прогон на четырёх машинах:

Папка Машина
Comp_1 AMD Ryzen 9 5950X, 16 ядер и 32 потока
Comp_2 Intel Core i9-10900KF, 10 ядер и 20 потоков
Comp_3 Intel Xeon W-2255, 10 ядер и 20 потоков
Comp_4 2 × Intel Xeon Silver 4314, 32 ядра и 64 потока

Скрипт проверяет наличие всех трёх SDK, собирает проект, сверяет ответы, снимает отчёты на трёх рантаймах, повторяет часть на серверном сборщике и с поднятым порогом кучи, запускает замеры и складывает всё в Results\<имя машины>.

Семь классов, 166 замеров, около двух с половиной часов. Причинная проверка ниже добавляет ещё около сорока минут.

Отдельным шагом идёт причинная проверка. Порог кучи больших объектов поднимается до 196 608 байт переменной DOTNET_GCLOHThreshold, и те же чанки снимаются заново. Если замедление вызвано именно этой кучей, при поднятом пороге чанк в неё не попадает и разница обязана исчезнуть. Не исчезла — значит дело не в ней, и вывод меняется. Результаты лежат в bench-loh192k и в файлах с пометкой loh192k.

По отдельности:

dotnet run -c Release -f net10.0 -- checks
dotnet run -c Release -f net10.0 -- blocks
dotnet run -c Release -f net10.0 -- threshold
dotnet run -c Release -f net10.0 -- memory chunkHeld 25000
dotnet run -c Release -f net10.0 -- collect chunkArray 21244
dotnet run -c Release -f net10.0
dotnet run -c Release -f net10.0 -- --filter *ChunkHeldBench*

Как устроен замер

Каждый способ читает содержимое чанка, а не только его длину: без чтения компилятор вправе выбросить и запись в буфер, и сам проход, и тогда замер сравнивал бы работу Chunk с пустым циклом. Результат складывается в контрольную сумму, и она печатается.

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

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

Отчёты memory и collect снимают каждый вид своим процессом. В одном процессе первый вид оставлял бы после себя мусор, а следующий показывал бы прирост, которого не делал. По той же причине набор данных создаётся только тот, который меряется, а список собирается поэлементно, а не из массива: временный массив на всю длину — это ещё несколько мегабайт мусора в куче, которая не уплотняется.

Колонок про память три, и каждая отвечает на свой вопрос. «Занято после сборки» — разность GC.GetTotalMemory до и после, снятая после сборки второго поколения: занятое во всей управляемой куче. «Живого в LOH» — то же самое, но только по куче больших объектов: размер её сегмента за вычетом свободного места внутри. Вычитание обязательно: эта куча по умолчанию не уплотняется, сегмент не сжимается обратно, и чанки могут лечь в дыру от чего-то, что жило раньше. Сам сегмент печатается третьей колонкой как есть — по разнице видно, сколько в куче дыр.

Способы с пометкой Held возвращают список чанков наружу, и он живёт до GC.KeepAlive после чтения счётчика. Без этого список умирал бы на выходе из метода, сборка забирала бы чанки, и прирост выходил бы нулевым во всех строках.

Один проход по миллиону элементов не выбирает бюджет нулевого поколения, поэтому колонки сборок в отчёте memory часто нулевые. Число сборок меряет отчёт collect: сорок проходов подряд, счётчики делятся на их число.

Отдельная реализация Chunk для массива не берётся из исходников, а проверяется: отчёт blocks печатает имя типа, который Chunk возвращает, для массива, списка и метода с yield return на каждом рантайме.

Состав проекта

ChunkProof.csproj            многоцелевой: net8.0, net9.0, net10.0
ChunkProof.slnx
Program.cs                   точка входа, разбор аргументов
Subjects.cs                  все измеряемые способы
all.bat                      полный прогон одной командой
README.md
Benchmarks/                  семь классов, по одному на файл
Types/
    Payloads.cs              наборы данных
    Outcome.cs               итог прохода: сумма, число чанков и ссылка на них
Diagnostics/
    Inspector.cs             работа с кучей и счётчики памяти
    Blocks.cs                сколько массивов создаёт Chunk и какой итератор
    Threshold.cs             границы кучи больших объектов
    Memory.cs                выделено байт, занято после сборки, занято в LOH
    Collections.cs           число сборок по поколениям на проход
    Checks.cs                сверка ответов, код возврата
Results/
    Comp_1 ... Comp_4/       выгрузки прогона по машинам
    Comp_N/bench/            отчёты BenchmarkDotNet
    Comp_N/bench-loh192k/    то же при поднятом пороге кучи

Ссылки

About

Замеры Enumerable.Chunk на .NET 8, 9 и 10: где проходит граница кучи больших объектов, сколько массивов уходит на первый чанк и почему массив и List<int> попадают в разные реализации. BenchmarkDotNet, прогон на четырёх машинах, отчёты и причинная проверка с поднятым порогом LOH.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages