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 КБ прямо в куче больших
объектов.
Граница этой кучи для char[] — 42 488 символов: 42 488 × 2 + 24 = 85 000,
где 24 байта уходят на заголовок массива.
Один такой чанк ничего не ломает, но эта куча освобождается при сборке второго поколения. На 200 документах по 200 000 символов длинная строка даёт вдвое больше таких сборок.
Пока 64 экземпляра StringBuilder остаются в памяти, в куче держится 24 МБ
против нуля у сборки частями.
Отдельно — Clear. Он не выбрасывает чанки, а создаёт один массив под прежнюю
вместимость. Текст, собранный частями по 8000 и лежащий вне кучи, после 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
- Обращение dotnet/runtime #107482 — с него начался замер
- StringBuilder.cs —
MaxChunkSize,ExpandByABlockи сеттерLength - StringBuilderPooledObjectPolicy.cs — предел вместимости в пуле
- ValueStringBuilder.cs — рост через
ArrayPool - LinkDotNet.StringBuilder —
ValueStringBuilderпакетом - Куча больших объектов
- Настройки сборщика мусора —
DOTNET_GCLOHThreshold - GC.GetGCMemoryInfo — чтение размера кучи




