Замеры Enumerable.Chunk: сколько массивов он создаёт, где проходит граница
кучи больших объектов и во что это обходится по времени.
Проект к статье «Бенчмаркая Enumerable.Chunk: почему батчей меньше, а проход до ×2,7 дольше».
Чанк вырос с 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/ то же при поднятом пороге кучи
- Chunk.cs —
ArrayChunkIteratorиEnumerableChunkIterator - Enumerable.Chunk
- Куча больших объектов
- Настройки сборщика мусора —
DOTNET_GCLOHThreshold - Обращение dotnet/runtime #115079

