Бенчмаркая StringBuilder: подстава на длинном тексте

от автора

Уважаемые читатели, в этой статье я хочу рассказать, что StringBuilder делает с длинными строками, — и представить свои выводы.

StringBuilder не хранит текст одним массивом. Он делит его на чанки, а предел чанка — 8000 символов. Сделано это ради одного: чтобы чанки не попадали в кучу больших объектов. Порог 85 000 байт, символ занимает два, значит около 40 000 символов — предел взят в четыре раза ниже, с запасом. 

Предел срабатывает не всегда. Append строки, которая не влезает в текущий чанк, создаёт под её остаток отдельный, и если остаток длиннее 8000 символов, предел на него не действует — на 200 000 символов это массив под 400 КБ в той самой куче, ради которой предел и задавали. Ни компилятор, ни анализаторы про это не сообщают.

Будет 3 истории:

  • длинная строка в одном Append обходит предел чанка — и почему граница проходит по 42 488 символам, а не по 85 000;

  • второе поколение собирается вдвое чаще, а в куче держится 24 МБ, пока живы 64 экземпляра StringBuilder;

  • Clear не выбрасывает чанки, а объединяет их в один массив — и текст, собранный отрезками по 8000, тоже оказывается в LOH.

Весь код проверки — в репо BuilderLohProof. Машины те же, что в статье про ArrayPool:

  • Ryzen 9 5950X;

  • Core i9-10900KF;

  • 2 × Xeon Silver 4314;

  • Xeon W-2255.

Все на .NET 8, 9 и 10, BenchmarkDotNet 0.15.8.

История 1. Чанк на 200 000 символов

Текст на 200 000 символов, собранный семью способами. Чанки читаются рефлексией по полям m_ChunkChars и m_ChunkPrevious.

Одна длинная строка вместо частей по 1000: 2 чанка вместо 29, и второй занимает 199 984 символа — это 400 КБ и куча больших объектов

Одна длинная строка вместо частей по 1000: 2 чанка вместо 29, и второй занимает 199 984 символа — это 400 КБ и куча больших объектов

Частями по 1000 получается 29 чанков, наибольший на 8000 — предел не нарушен. Одна длинная строка укладывается в 2 чанка: первый на 16 символов, второй на 199 984. С отрезками по 8000 наибольший чанк снова 8000.

Чанки можно посмотреть и без рефлексии: StringBuilder.GetChunks() отдаёт их публично, по ReadOnlyMemory<char> на каждый. 

Причина — в одной строке ExpandByABlock:

// dotnet/runtime, System/Text/StringBuilder.csint newBlockLength = Math.Max(minBlockCharCount,      // сколько нужно под остаток строки                    Math.Min(Length, MaxChunkSize));  // и сколько разрешает пределm_ChunkChars = GC.AllocateUninitializedArray<char>(newBlockLength);

Math.Max выбирает большее. Если под остаток нужно 199 984 символа, предел в 8000 в решении не участвует. Комментарий рядом с MaxChunkSize этот случай не упоминает:

internal const int MaxChunkSize = 8000; // Completely arbitrary, but a nice number of chars.// We want to keep chunk arrays out of large object heap (< 85K bytes ~ 40K chars) to be sure.// Making the maximum chunk size big means less allocation code called, but also more waste// in unused characters and slower inserts / replaces.

Где граница?

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

42 488 × 2 + 24 = 85 000: два байта на символ и 24 байта заголовка массива на x64

42 488 × 2 + 24 = 85 000: два байта на символ и 24 байта заголовка массива на x64

Заголовок — это 24 байта служебных данных: указатель на таблицу методов, поле синхронизации и длина. Для Append граница на 16 символов выше: первые 16 попадают в начальный чанк, а под остаток создаётся массив длиной на 16 символов меньше самой строки.

Размер чанка зависит от длины строки:

На 20 000 чанк уже втрое больше предела, но в LOH ещё не попадает

На 20 000 чанк уже втрое больше предела, но в LOH ещё не попадает

Проверить объяснение можно настройкой порога. С DOTNET_GCLOHThreshold равным 0xC0000 граница смещается на 393 204 символа, и строка на 200 000 в эту кучу уже не попадает.

История 2. Сборки второго поколения и занятая память

Чанк в куче больших объектов сам по себе не ошибка: результат верный, память освободится. Но освобождается она при сборке второго поколения. Число сборок на 200 документах по 200 000 символов:

Вдвое больше сборок второго поколения: 49 против 24 на трёх машинах, 40 против 21 на четвёртой

Вдвое больше сборок второго поколения: 49 против 24 на трёх машинах, 40 против 21 на четвёртой

Один документ — 400 КБ в этой куче. На 1000 документов в секунду это 390 МБ/с туда, где сборка идёт только вместе со вторым поколением.

При этом объём выделенной памяти почти одинаков: 160,06 МБ у длинной строки и 160,44 МБ у частей по 1000. Работа совпадает, различается только куча, в которую попадает память.

По времени результат менее устойчив. На трёх машинах длинная строка обрабатывается в 1,62–1,67 раза медленнее на длинах 50 000 и 200 000. На Xeon W-2255 отличий не видно: 163,8 мкс против 186,5 при разбросе 12,3 мкс — погрешность замера перекрывает разницу. На длине 20 000 чанк ещё вне LOH, и отличий нет ни на одной машине — причина именно в этой куче.

У всех способов прирост кучи в этом замере близок к нулю, и это не ошибка замера. Экземпляры StringBuilder теряют последнюю ссылку сразу после ToString, а полная сборка перед чтением их освобождает. Занятая память видна там, где эти экземпляры продолжают жить — в пуле или кэше.

64 живых экземпляра StringBuilder: 24 МБ в куче больших объектов против нуля

64 живых экземпляра StringBuilder: 24 МБ в куче больших объектов против нуля

24 МБ — это 64 чанка по 400 КБ. Куча больших объектов по умолчанию не уплотняется, поэтому освободившиеся участки останутся разрозненными до следующей полной сборки.

А на серверном сборщике мусора?

На серверном сборщике все три способа дают одинаковый результат: 13 сборок, удвоения нет. Порог для запуска сборки там выше, поэтому разница не проявляется. 

При этом занятая память та же: 64 экземпляра StringBuilder дают 25 001 КБ против нуля. На сервере вопрос не в частоте сборок, а в 25 МБ, которые остаются в куче без уплотнения. Серверный сборщик убирает разницу в числе сборок, но не занятую память.

История 3. Clear объединяет чанки в один массив

Третий случай — очистка StringBuilder. Отчёт reuse показывает, что остаётся после Clear:

Текст собран частями по 8000 и вне LOH — после Clear остаётся один чанк на 390 КБ, уже в LOH

Текст собран частями по 8000 и вне LOH — после Clear остаётся один чанк на 390 КБ, уже в LOH

Clear устанавливает Length в ноль. Сеттер находит чанк с нулевым индексом и, если тот оказался не последним, создаёт новый массив — такого размера, чтобы прежняя вместимость сохранилась. Код в StringBuilder.cs:

// dotnet/runtime, System/Text/StringBuilder.cs, сеттер LengthStringBuilder chunk = FindChunkForIndex(value);       // чанк с индексом 0if (chunk != this)                                    // а это не последний чанк{    int capacityToPreserve = Math.Min(Capacity,        Math.Max(Length * 6 / 5, m_ChunkChars.Length)); // сколько вместимости сохранить    int newLen = capacityToPreserve - chunk.m_ChunkOffset;    if (newLen > chunk.m_ChunkChars.Length)    {        char[] newArray = GC.AllocateUninitializedArray<char>(newLen); // один массив на всё        Array.Copy(chunk.m_ChunkChars, newArray, chunk.m_ChunkLength);        m_ChunkChars = newArray;    }}

Вместимость сохраняется намеренно: StringBuilder рассчитывает на такой же объём текста при следующем заполнении. Взамен мелкие чанки заменяются одним массивом, и на длинном тексте он выходит за порог.

Clear удваивает объём выделенной памяти: к чанкам добавляется ещё один массив на 390 КБ

Clear удваивает объём выделенной памяти: к чанкам добавляется ещё один массив на 390 КБ

Пулы StringBuilder попадают под это чаще всего. У StringBuilderPooledObjectPolicy из ObjectPool задан предел вместимости в 4096 символов, и разросшийся экземпляр в пул не возвращается — отчёт reuse это подтверждает. Пул, написанный вручную без такой проверки, будет держать в куче по массиву на каждый экземпляр.

Что помогает, а что нет

Когда все части текста известны заранее, StringBuilder не нужен:

string.Concat и string.Create выделяют вдвое меньше памяти: у них нет чанков, только итоговая строка

string.Concat и string.Create выделяют вдвое меньше памяти: у них нет чанков, только итоговая строка

Про буфер из пула: ArrayPool<char>.Shared.Rent(200 000) выдаёт массив на 262 144 символа, и он тоже оказывается в куче больших объектов — один на весь процесс, а не по одному на вызов. Подробный разбор — в статье про ArrayPool.

Отдельно про ValueStringBuilder. В рантайме он internal, из своего кода доступен только пакетом LinkDotNet.StringBuilder, а растёт через ArrayPool<char>.Shared.Rent. На 200 000 символов пул выдаст массив на 262 144 — те же 512 КБ в куче больших объектов.

Разница в том, что массив берётся из пула и переиспользуется, а не создаётся заново. При параллельной работе пул выдаст несколько таких массивов, а без Dispose арендованный вообще не вернётся.

Вместимость часто задают заранее, но выше 42 488 символов первый же чанк попадает в кучу больших объектов:

Разница вместимости — 1000 символов, разница по времени — 27 мкс

Разница вместимости — 1000 символов, разница по времени — 27 мкс

Комментарий в рантайме обещает медленные вставки на крупных чанках, но замер этого не показал. Вставка в начало при нескольких чанках — 85,4 нс против 26,7 на одном большом: StringBuilder перебирает чанки с конца, пока не найдёт нужный. Replace по всему тексту даёт 6086 нс против 6076, разницы нет. 

Вставка в середину не поддаётся замеру: она делит чанк на два, и после 100 вставок 26 чанков превращаются в 126 — каждый следующий вызов работает с другим набором данных.

Выводы

По цифрам:

  • предел чанка в 8000 символов не срабатывает при добавлении длинной строки одним вызовом: чанк создаётся по размеру её остатка;

  • граница кучи больших объектов для char[] — 42 488 символов: 42 488 × 2 + 24 = 85 000; для Append она начинается с 42 504;

  • длинная строка одним вызовом даёт вдвое больше сборок второго поколения: 49 против 24 на трёх машинах и 40 против 21 на четвёртой;

  • 64 экземпляра StringBuilder, остающиеся в памяти, занимают 24 220 КБ вместо нуля;

  • Clear при нескольких чанках создаёт один массив под прежнюю вместимость: выделено 783 КБ против 392, в куче 24 222 КБ против нуля;

  • на серверном сборщике число сборок одинаковое — 13 у всех способов, а занятая память та же;

  • все перегрузки Append дают один результат: 272–283 мкс и 781,5 КБ, другая перегрузка от попадания в LOH не спасёт;

  • на .NET 8, 9 и 10 результат один и тот же — в рантайме это не меняли.

Что стоит помнить:

  • длинный текст, приходящий одной строкой, лучше добавлять частями по 8000 символов через Append(ReadOnlySpan<char>);

  • capacity выше 42 488 символов приводит к тому, что первый чанк попадает в кучу больших объектов;

  • string.Concat и string.Create выделяют вдвое меньше памяти, когда все части текста известны заранее;

  • в пуле StringBuilder, написанном вручную, Capacity проверяется перед возвратом — как в StringBuilderPooledObjectPolicy из ObjectPool;

  • Clear не освобождает чанки, а создаёт один массив под прежнюю вместимость;

  • на серверном сборщике смотреть надо не на число сборок, а на занятую память.

Код из статьи

  • BuilderLohProof — 8 классов замеров, 5 отчётов, прогоны на четырёх машинах и трёх рантаймах

Ссылки

Всем удачи и до новых встреч!

ссылка на оригинал статьи https://habr.com/ru/articles/1066728/