Skip to content

Repository files navigation

BuilderLohProof

StringBuilder делит текст на чанки, и предел чанка — 8000 символов. Сделано это ради одного: чтобы чанки не попадали в кучу больших объектов. Здесь проверяется, всегда ли предел срабатывает.

Повод — открытое обращение dotnet/runtime #107482.

BenchmarkDotNet 0.15.8, Release, .NET 8, .NET 9 и .NET 10. Четыре машины: AMD Ryzen 9 5950X, Intel Core i9-10900KF, 2 × Intel Xeon Silver 4314 и Intel Xeon W-2255.

Что показали замеры

Append строки, которая не влезает в текущий чанк, создаёт под её остаток отдельный. Если остаток длиннее 8000 символов, предел на него не действует: на тексте в 200 000 символов получается массив на 400 КБ прямо в куче больших объектов.

Чанки StringBuilder

Граница этой кучи для char[] — 42 488 символов: 42 488 × 2 + 24 = 85 000, где 24 байта уходят на заголовок массива.

Где начинается куча больших объектов

Один такой чанк ничего не ломает, но эта куча освобождается при сборке второго поколения. На 200 документах по 200 000 символов длинная строка даёт вдвое больше таких сборок.

Сборки второго поколения

Пока 64 экземпляра StringBuilder остаются в памяти, в куче держится 24 МБ против нуля у сборки частями.

Куча больших объектов при 64 живых экземплярах

Отдельно — Clear. Он не выбрасывает чанки, а создаёт один массив под прежнюю вместимость. Текст, собранный частями по 8000 и лежащий вне кучи, после Clear оказывается в ней.

Что остаётся после Clear

Полные отчёты — в Results, по папке на машину.

Что меряется

Класс Что сравнивает
AppendBench текст частями, одной строкой и отрезками по 8000; четыре длины вокруг порога, три рантайма
AppendKindBench та же строка через Append(string), Append(span), Append(char[]), Append(char, count), AppendFormat, интерполяцию и Insert
CapacityBench вместимость 0, 8 000, 42 000, 43 000 и 200 000
MutateBench Replace и Insert в начало при нескольких чанках против одного большого
DocumentBench длинный шаблон плюс мелкие вставки, три рантайма
AlternativeBench StringBuilder против string.Concat, string.Create и буфера из ArrayPool
ReuseBench новый экземпляр против переиспользования через Clear
ClearBench сколько выделяет сам Clear: сборка против сборки с очисткой

Отчёты вне BenchmarkDotNet:

Отчёт Что печатает
chunks чанки для каждого способа: сколько, какой длины, что попало в LOH
threshold границы: первая длина char[], первая вместимость и первая длина строки в Append, дающие LOH
loh прирост кучи, выделено байт и число сборок второго поколения; 16 сценариев
reuse что остаётся после Clear и вернётся ли экземпляр в пул
checks сверка: все способы дают одну строку

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

Нужны SDK .NET 8, 9 и 10 — BenchmarkDotNet поднимает по процессу на каждый рантайм:

dotnet --list-sdks

Весь прогон одной командой, без аргументов, около часа:

all.bat

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

Вручную, без скрипта:

dotnet run -c Release -f net10.0 -- checks
dotnet run -c Release -f net10.0 -- chunks
dotnet run -c Release -f net10.0 -- threshold
dotnet run -c Release -f net10.0 -- loh long
dotnet run -c Release -f net10.0 -- reuse
dotnet run -c Release -f net10.0
dotnet run -c Release -f net10.0 -- --filter *AppendBench*

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

Все измеряемые способы лежат в Subjects.cs, у каждого свой метод с NoInlining: иначе компилятор встроит его в тело замера и выбросит как ненужную работу. Итог у всех способов одинаковый, это проверяет отчёт checks и роняет прогон при расхождении.

Чанки читаются рефлексией по полям m_ChunkChars и m_ChunkPrevious. Имена взяты из исходников рантайма и между версиями могут поменяться, поэтому Inspector.Available говорит, нашлись ли они: без этой проверки отчёт молча печатал бы нули. То же самое доступно и публично, через StringBuilder.GetChunks().

Порог кучи больших объектов не зашит числом, а ищется пробами: создаётся char[] растущей длины, и у только что созданного массива читается поколение. Между созданием и проверкой ничего не выделяется, поэтому сборка не успевает вмешаться. Дальше чанки проверяются по длине, а не по поколению: чанк, переживший пару сборок, тоже числится во втором поколении, не будучи в этой куче.

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

Рядом с приростом стоят ещё два счётчика. GC.GetTotalAllocatedBytes меряет, сколько памяти запрошено, а не сколько её осталось после сборки, и от сборки не зависит. GC.CollectionCount(2) показывает, чем это оборачивается для приложения. Параметр precise намеренно false: с true рантайм ради точного счёта делает сборку мусора, а сборка портила бы соседнюю колонку.

У сценариев, где экземпляры не остаются в памяти, прирост близок к нулю, и это не ошибка: экземпляры теряют последнюю ссылку сразу, а полная сборка перед чтением их освобождает. Сценарии hold держат 64 экземпляра живыми и дают прирост как он есть; ToString в них не вызывается, потому что итоговая строка сама длиннее порога.

Каждый сценарий отчёта loh снимается своим процессом. Все они работают с одним типом, и в одном процессе первый оставлял бы после себя мусор, а следующий показывал бы прирост, которого не делал.

Отчёты loh и reuse снимаются дважды: на обычном сборщике и на серверном. Порог кучи настраивается, поэтому часть отчётов повторяется с DOTNET_GCLOHThreshold=0xC0000. Отчёт threshold сам показывает, применилась ли переменная: если граница осталась прежней, значит нет.

Кодировка вывода задаётся в Program.cs. Консоль на разных машинах пишет по-разному, а отчёты уезжают в репозиторий одним набором.

Что где лежит

BuilderLohProof.csproj       многоцелевой: net8.0, net9.0, net10.0
BuilderLohProof.slnx
Program.cs                   точка входа, разбор аргументов
Subjects.cs                  все измеряемые способы
README.md
all.bat                      весь прогон одной командой
Benchmarks/                  восемь классов, по одному на файл
Types/
    Payloads.cs              наборы данных
Diagnostics/
    Inspector.cs             чтение полей StringBuilder и работа с кучей
    Chunks.cs                чанки по способам сборки
    Threshold.cs             границы кучи больших объектов
    LohGrowth.cs             прирост кучи, выделено байт, сборки gen2
    Reuse.cs                 что остаётся после Clear и что держит пул
    Checks.cs                сверка ответов, код возврата
Docs/                        картинки к статье
Results/
    <машина>/                выгрузки прогона
    <машина>/bench/          отчёты BenchmarkDotNet

Ссылки

About

Замеры StringBuilder на четырёх машинах: когда чанки по 8000 не спасают от LOH, где проходит граница 42 488 символов и что делает Clear

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages