Очень важно правильно подбирать умолчания. Да и вообще всегда максимально конкретно и полно указывать, а что именно тебе нужно. И не только в программировании, кстати. Иначе, случайно можно проделать много лишней работы.

Введение

Всякие красивые UI — это конечно прекрасно. Они экономят много времени, наглядно демонстрируют нам всё самое важное и полезное. Но иногда за красивыми картинками теряется что-нибудь интересное. А простая визуализация, например в консоли, напротив, иногда позволяет увидеть то, что прячется за вычурными интерфейсами.

Иногда лень тащить дамп с прода с Linux-виртуалки на рабочую станцию с помощью scp, вспоминая про chmod (причём только после того, как не получилось скопировать), сделав сжатие, потому что понял, что копирование N GB идёт не быстро. А ещё это не безопасно. Иногда приятно просто сделать на лету dotnet dump analyze и провести какой-то первичный анализ прямо из консоли прямо на машине, где снят дамп.

Сейчас я покажу, как я заметил … назовём это «лесенки». А затем мы рассмотрим один очень простой, но при этом популярный способ оптимизации, который очень легко случайно не заметить, легко забыть. При этом его полезность, в зависимости от ситуации, может быть высокой. И даже не столь важен результат конкретно этой истории. Интересна сама идея, приятна простота оптимизации, красива (на мой вкус) и наглядна демонстрация.

Дамп

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

Так получилось, что она будет о контуровской реализации распределённой трассировки. Так исторически сложилось, что она существовала ещё до того, как появился Open Telemetry и его реализация для Dotnet.

Знакомимся с одним интересным типом

Начал с команды сбора статистики по всем объектам dumpheap -stat:

> dumpheap -stat
          MT     Count     TotalSize Class Name
...
7fa70894eff8     9,868     2,422,464 Vostok.Commons.Collections.ImmutableArrayDictionary<System.String, System.Object>+Pair[]
...

Среди большой портянки статистики по всем типам в процессе нас интересует только приведённая выше строчка. А именно, тип Vostok.Commons.Collections.ImmutableArrayDictionary<System.String, System.Object>+Pair[].

Это массив пар ключ-значение, который лежит внутри «абстракции» ImmutableArrayDictionary. А тип пары <System.String, System.Object> подсказывает нам (а команда gcroot на живых экземплярах подтверждает), что это массив свойств у спанов (которые элементы распределённой трассировки). Это те свойства, которые: {code: 200, execution_time: 1385, method: foo/bar, application: AwesomeApi, ...}.

Как подтвердить: вызываем dumpheap -mt 7fa70894eff8 -live. Эта команда выведет адреса объектов типа (mt) 7fa70894eff8 (это наш Vostok.Commons.Collections.ImmutableArrayDictionary<System.String, System.Object>+Pair[]), причём только тех, которые «живые» (-live), то есть те, до которых можно построить дерево ссылок. А затем на любом из адресов вызвать команду gcroot <address>.

Пример такого gcroot для объекта по адресу 7f78204d90a0:

gcroot 7f78204d90a0
... много-много строчек ...
          -> 7f78204d9260     Vostok.Tracing.Extensions.Http.HttpRequestClusterSpanBuilder
          -> 7f78204d8e10     Vostok.Tracing.SpanBuilder
          -> 7f78204d9520     Vostok.Commons.Collections.ImmutableArrayDictionary<System.String, System.Object>
          -> 7f78204d90a0     Vostok.Commons.Collections.ImmutableArrayDictionary<System.String, System.Object>+Pair[]

Как видно, массив пар (+Pair[]) лежит внутри самого ImmutableArrayDictionary, который лежит внутри Vostok.Tracing.SpanBuilder, который лежит внутри HttpRequestClusterSpanBuilder, и так далее. В общем, однозначно видно, что это внутренности спана из распределённой трассировки. А не какой-то другой массив пар какого-то другого ImmutableArrayDictionary.

Изучаем этих подозрительных типов

Давайте рассмотрим все объекты этого типа. Для этого распечатаем их адреса (и заодно команда напечатает нам размер каждого объекта) с помощью команды dumpheap -mt <mt типа объекта>.

> dumpheap -mt 7fa70894eff8
         Address               MT           Size
         ...
    7fa2bd30c7c8     7fa70894eff8            120
    7fa2bd30c908     7fa70894eff8            216
    7fa2bd30cb20     7fa70894eff8            408
    7fa2bd3141a0     7fa70894eff8            120
    7fa2bd3142e0     7fa70894eff8            216
    7fa2bd3144f8     7fa70894eff8            408
    7fa2bd314f70     7fa70894eff8            120
    7fa2bd317168     7fa70894eff8            216
    7fa2bd317380     7fa70894eff8            408
    7fa2bd3185c0     7fa70894eff8            120
    7fa2bd318700     7fa70894eff8            216
    7fa2bd318918     7fa70894eff8            408
    7fa2bd3193f0     7fa70894eff8            120
    7fa2bd319530     7fa70894eff8            216
    7fa2bd319748     7fa70894eff8            408
    7fa2bd31dcb0     7fa70894eff8            120
    7fa2bd31ddf0     7fa70894eff8            216
    7fa2bd31e008     7fa70894eff8            408
    7fa2bd321080     7fa70894eff8            120
    7fa2bd321d68     7fa70894eff8            120
    7fa2bd321ea8     7fa70894eff8            216
    7fa2bd3220c0     7fa70894eff8            408
    7fa2bd322b40     7fa70894eff8            120
    7fa2bd322c80     7fa70894eff8            216
    7fa2bd322e98     7fa70894eff8            408
    7fa2bd325248     7fa70894eff8            120
    7fa2bd325358     7fa70894eff8            216
    7fa2bd325598     7fa70894eff8            408
         ...

Метод, естественно, напечатал нам все 9 тысяч объектов. Смотреть их все мы, конечно же, не будем. Нам сейчас достаточно посмотреть на любой под-отрезок вывода этой команды (я специально проверял в разных местах списка, вывод получался одинаковый).

Видите интересную закономерность? Она почти без ошибок повторяется в примере выше.

Надо обратить внимание на Size. И на то, что объекты отсортированы по адресу. Ведь мы все знаем, что объекты всегда создаются «справа» от всех предыдущих объектов в этой куче. То есть у только что созданного объекта адрес (внутри этой кучи) будет больше, чем у всех уже существовавших объектов.

А именно, видно, что все объекты друг за дружкой имеют размеры 120, 216, 408. Затем снова 120, 216, 408. И так далее. Все девять тысяч почти без ошибок идут такими «тройками».

Для объекта типа «массив» размер важен. Для не списочных типов (не строки, не массивы) размер был бы всегда одинаковый. А у массивов он разный. И зависит от длины массива.

Кстати, если изучить только живые экземпляры объектов (dumpheap -mt 7fa70894eff8 -live), то там будут только массивы размером 408 (и их будет сильно меньше). Потому что на все промежуточные, мелкие, ссылок уже никто не держит.

На что эти типы намекают?

Это даёт нам очень жирный намёк: кто-то регулярно делает resize массива. Сначала он создаёт массив с size 120, затем ресайзит до size 216, затем ресайзит до size 408, и как будто бы дальше обычно не растёт. Очень похоже на то, что кто-то не знает финальный размер массива, начинает с «какого-то», динамически добавляет туда элементы. А когда они не влазят, пересоздаёт массив размером побольше. И «всегда» останавливается на каком-то определённом размере.

Важно, что мы сейчас говорим о size, а не о Length. То есть 120, 216 и 408 — это не длины массива. В Size массива входит 24 байта его «описания» + длины (выровненные по 8 байт). Затем идут все объекты массива.

В нашем случае у нас массив Pair:

struct Pair
{
   public readonly TKey Key;
   public readonly TValue Value;
   public readonly int Hash;
   public volatile bool IsOccupied;
...
}

Такая структура занимает 24 байта (8 байт — ссылка на TKey, 8 байт — ссылка на TValue, 8 байт — упаковка int Hash и bool IsOccupied в «удобные» для компилятора 8 байт).

Итого, size 120 байт = (120 - 24 (размер «описания»)) / 24 (размер структуры) = length 4. А size 216 = length 8. А size 408 = length 16. Стандартный рост размера массива x2 на каждый resize при его динамическом наполнении.

То есть, при работе со спанами, заполняя спан свойствами, мы как будто «всегда» начинаем с размера массива 4, а заканчиваем размером массива 16. То есть, создаём массив размера 4, затем создаём массив размера 8 (копируя предыдущие 4), затем создаём массив размера 16 (копируя предыдущие 8).

Из всего этого напрашивается логичный вывод:

А мы можем создавать массив сразу размера 16 и не заниматься лишними аллокациями массивов промежуточного размера?

Чисто технически это возможно?

Изучив код, как именно создаются трассировки и наполняются спаны, гипотеза подтвердилась. Модуль трассировок (транзитивно используя ImmutableArrayDictionary) создаёт массив размером именно 4, а затем наполняет его всяческими свойствами. Действительно реаллоцируя массив, удваивая его длину, если свойства не влезают (транзитивно через методы ImmutableArrayDictionary).

Изучаем реальный мир

Строго говоря, реальный мир несколько сложнее. Мы, вообще-то, сняли дамп с одного конкретного приложения. И именно в нём размер массива останавливается на 16.

Краткий экскурс в связь спана с реальным миром

Стоит дать краткое описание о том, что такое трассировочный спан. А точнее, когда он появляется в жизни приложения.

Рассмотрим самый простой пример. В ваше приложение прилетел запрос. Http-сервер создаёт серверный спан, в котором заполняет разные свойства: кто отправил запрос, по какому url, каким кодом закончился запрос, за какое время, размер тела, и тому подобное. Потом на основе этой информации мы изучаем дерево распределённой трассировки и смотрим разные красивые графики. Аналогичный спан создаётся с клиентской стороны, когда клиент отправляет запрос в какой-то сервис.

Таким образом, оверхед от создания и наполнения трассировочного спана «линейно» зависит от «входящего RPS» в сервис. И, конечно же, хочется, чтобы это было как можно дешевле. Если в сервис прилетит 100 запросов, значит мы 100 раз создадим и заполним серверный спан. И хотелось бы делать не 300 аллокаций массива свойств, пока мы заполняем свойства, а всего 100.

Анализируем все известные трассировки

В среднем в наших типичных спанах 11-18 свойств, причем их количество заранее не известно.

Формат трассировки, а точнее, каждого конкретного спана в ней, не фиксированный. Там есть несколько обязательных полей, но, строго говоря, любой потребитель может самостоятельно наполнять спаны свойствами так, как он захочет.

Подавляющее большинство спанов возникают в коде инфраструктурных библиотек и никто в них не вмешивается самостоятельно. Все такие места нам известны и их легко было изучить. Так, среднестатистический кластерный спан обычно состоит из 15-18 свойств, клиентский — из 13-14 свойств, а серверный — из 11-14. Зависит это, например, от хостинга. Приложения в облачном хостинге обогащаются номером инстанса, id приложения и id окружения, а selfhosted приложения — нет. Или от некоторых булевых флажков, например, флаг «был стриминг тела» выставляется, только если он был.

Ещё бывают такие спаны, которые заполняются каким-то прикладным кодом. Например, у наших сервисов есть собственная инструментация Кассандра-клиента, которая создаёт трассировочный спан с информацией о походе в Кассандру (тоже существовало задолго до появления OTel). У таких я насчитал 15-18 свойств.

И совсем редко бывают какие-то «неправильные» спаны, которые вообще без свойств. Но обычно, это какие-то «баги» в попытках самостоятельно их составлять, и таких совсем мало.

Кроме того, наполнение спана не очень детерминировано, и, к тому же, размазано во времени. То есть мы, увы, заранее не знаем, сколько свойств хотим заполнять. Это, скорее, следствие «правильного кода»: абстракция «спана», «базовые свойства», наследования наполняторов спана, и тому подобное. А ещё, часть свойств очень удобно заполнить в начале исполнения запроса, а часть — спустя «много» времени, в конце исполнения запроса. А ещё, часть свойств добавляются «внешним» по отношению к библиотеке кодом. А ещё, у потребителя есть возможность произвольно кастомизировать наполнение свойств. В общем, это всё не позволяет заранее точно сосчитать итоговый размер спана, чтобы создать массив с идеально точным размером.

Какие ограничения

Есть одно фундаментальное соображение. Если мы начнём создавать в спане массив с изначально излишне большой длиной, это может привести к бОльшему (и бесполезному) потреблению RAM в приложении. Правда, скорее всего незначительному на фоне активности приложения, если только приложение не занимается только лишь созданием спанов.

При этом, нам хочется подобрать такой изначальный размер массива, чтобы поменьше раз делать resize с новыми аллокациями.

То есть надо подобрать новую константу так, чтобы и не сильно больше среднего, и, желательно, не меньше среднего.

Решение

Заменяем вот это:

var annotations = new ImmutableArrayDictionary<string, object>(4);

на это:

var annotations = new ImmutableArrayDictionary<string, object>(16);

Всё. Заменили один символ на два других.

Почему 16?

Во-первых, степень двойки. Ну не поднялась рука написать что-то другое!

Во-вторых, 16 не меньше количества практически всех среднестатистических спанов, кроме некоторых кластерных и Кассандровских.

В-третьих, 16 не сильно больше всех «нормальных» спанов. «Пустот» в этом массиве всегда будет мало. «Нормальные» спаны имеют размер от 11 (что больше 8, предыдущей степени двойки).

Сейчас

То есть, сейчас все спаны, у которых количество свойств <= 16, когда заполняются, сделают всего одну аллокацию (new T[16]).

А те редкие спаны, у которых их будет > 16, сделают всего две аллокации (new T[16] => new T[32]).

Раньше

А раньше все спаны, у которых количество свойств <= 16, когда заполнялись, делали три аллокации (new T[4] => new T[8] => new T[16]).

А те редкие спаны, у которых их было > 16, делали четыре аллокации (new T[4] => new T[8] => new T[16] => new T[32]).

По пути

Ну и по пути в некоторых местах наполнения http-спанов сделаны всякие мелкие оптимизации, как, например, работа со Span<char> вместо Substring.

Бенчмарк

Куда же без него.

Бенчмарк проводился на реальном коде, которым наполняются свойства у спанов. Для имитации наполнения Кассандра-спана все свойства заполняются руками (таким же кодом наполняются http-спаны в методах http-билдеров).

Uri uri = new Uri("http://localhost:80/foo/bar?q=1");
 
[Benchmark]
public void HttpClientSpan()
{
    using var spanBuilder = tracer.BeginHttpClientSpan();
    spanBuilder.SetRequestDetails(uri, "GET", null);
    spanBuilder.SetTargetDetails("service", "env");
    spanBuilder.SetResponseDetails(200, 42);
}
 
[Benchmark]
public void HttpClusterSpan()
{
    using var spanBuilder = tracer.BeginHttpClusterSpan();
    spanBuilder.SetClusterStrategy("fokring");
    spanBuilder.SetRequestDetails(uri, "GET", null);
    spanBuilder.SetTargetDetails("service", "env");
    spanBuilder.SetResponseDetails(200, 42);
    spanBuilder.SetClusterStatus("Success");
}
 
[Benchmark]
public void HttpServerSpan()
{
    using var spanBuilder = tracer.BeginHttpServerSpan();
    spanBuilder.SetRequestDetails("/foo/bar?q=1", "GET", null);
    spanBuilder.SetClientDetails("service", IPAddress.Loopback);
    spanBuilder.SetResponseDetails(200, 42);
}
 
[Benchmark]
public void EmptySpan()
{
    using var spanBuilder = tracer.BeginSpan();
}
 
[Benchmark]
public void CassandraSpan()
{
   using var spanBuilder = tracer.BeginSpan();
   spanBuilder.SetAnnotation("kind", "custom-request-cluster");
   spanBuilder.SetAnnotation("service.name", "Api.Over.Cassandra");
   spanBuilder.SetAnnotation("host.name", "some-host");
   spanBuilder.SetAnnotation("component", "Vostok.Tracer");
   spanBuilder.SetAnnotation("operation", "PreparedSelect.data");
   spanBuilder.SetAnnotation("status", "success");
   spanBuilder.SetAnnotation("custom.db.cluster", "MyCassandra");
   spanBuilder.SetAnnotation("custom.db.consistency", "Quorum");
   spanBuilder.SetAnnotation("custom.db.instance", "127.0.0.1");
   spanBuilder.SetAnnotation("custom.db.keyspace", "my_keyspace");
   spanBuilder.SetAnnotation("custom.db.table", "data");
   spanBuilder.SetAnnotation("custom.db.type", "cassandra");
   spanBuilder.SetAnnotation("custom.request.targetEnvironment", "default");
   spanBuilder.SetAnnotation("custom.request.targetService", "MyCassandra");
   spanBuilder.SetAnnotation("deployment.environment.name", "default");
   spanBuilder.SetAnnotation("hosting.app", "some-app-uuid");
   spanBuilder.SetAnnotation("hosting.env", "some-env-uuid");
   spanBuilder.SetAnnotation("instance", "42");
}

Вот результаты.

Клиентский спан:

| Method             | Mean     | Gen0   | Allocated |
|------------------- |---------:|-------:|----------:|
| OldHttpClientSpan  | 796.8 ns | 0.1554 |    1960 B |
| NewHttpClientSpan  | 687.1 ns | 0.1211 |    1520 B |

На 14% быстрее, на 22% меньше сборок мусора, выделили на 22% меньше памяти.

Кластерный спан:

| Method             | Mean     | Gen0   | Allocated |
|------------------- |---------:|-------:|----------:|
| OldHttpClusterSpan | 835.9 ns | 0.1621 |    2040 B |
| NewHttpClusterSpan | 788.9 ns | 0.1268 |    1600 B |

На 6% быстрее, на 22% меньше сборок мусора, выделили на 22% меньше памяти.

Серверный спан:

| Method             | Mean     | Gen0   | Allocated |
|------------------- |---------:|-------:|----------:|
| OldHttpServerSpan  | 735.7 ns | 0.1535 |    1928 B |
| NewHttpServerSpan  | 640.1 ns | 0.1154 |    1448 B |

На 13% быстрее, на 25% меньше сборок мусора, выделили на 25% меньше памяти.

«Пустой» спан. Добавлен для демонстрации, что тут кое-что станет похуже. Но на практике таких спанов быть не должно.

| Method             | Mean     | Gen0   | Allocated |
|------------------- |---------:|-------:|----------:|
| OldEmptySpan       | 283.1 ns | 0.0558 |     704 B |
| NewEmptySpan       | 254.6 ns | 0.0758 |     952 B |

На 11% быстрее, но на 35% больше сборок мусора, выделили на 35% больше памяти.

Спан к Кассандре:

| Method             | Mean     | Gen0   | Allocated |
|------------------- |---------:|-------:|----------:|
| OldCassandraSpan   | 984.3 ns | 0.2251 |    2840 B |
| NewCassandraSpan   | 885.1 ns | 0.1955 |    2464 B |

На 10% быстрее, на 14% меньше сборок мусора, выделили на 14% меньше памяти.

Выводы

Практические

  • Теперь работа с трассировками будет чуточку быстрее. 

  • Кто-то может спросить: «а зачем оптимизировать legacy телеметрию, если перед нами маячит переход к otel-реализациям?». И вопрос будет правильным. Но я верю, что ещё долгое время большое количество кода будет использовать legacy-реализации наполнения спанов. И оптимизация в пару символов стоит того.

Теоретические

Если у вас есть List<T> или подобные структуры, которые динамически наполняются элементами, важно знать, что находящийся внутри массив пересоздаётся каждый раз, когда в него не вмещаются элементы. Обычно, рост происходит примерно x2 (иногда рост происходит до какого-то простого числа, близкому к x2 от предыдущего).

Если вы заранее знаете итоговый размер, очень полезно указать его явно при создании. Так, можно много сэкономить на аллокациях. Например, если в список добавится всего тысяча элементов, если начать рост с дефолтного размера 4, придётся аллоцировать массивы размером: 4, 16, 32, 64, 128, 256, 512, 1024. Да, по пути создастся аж 8 массивов. А можно сразу один, размером 1024.

Если итоговый размер заранее не известен, будет полезно подобрать какое-то умолчательное значение. Оно должно быть достаточно не напряжным для «самых мелких случаев» и как можно более близким (чуть больше) к «популярным» итоговым размерам.

Если ты заранее подумал о такой оптимизации и указал нужный размер коллекции, ты молодец. Но как найти все такие пропущенные? Не изучать же в коде каждый вызов конструктора каждой коллекции. Анализируя дампы, особенно, используя консольный dotnet dump analyze, можно увидеть, в том числе, все мёртвые объекты, которые ещё не собрал GC. Если поизучать все экземпляры какой-нибудь коллекции, которые в сумме светятся в топе дампа, можно увидеть там паттерны-лесенки. Скорее всего, «живыми» объектами будут только самые «большие», то есть последние ресайзы. А «мёртыми» — все промежуточные. Такие лесенки будут намекать на те самые места для оптимизаций. Естественно, такие места можно обнаружить и с помощью трейса, если удастся удачно заметить какой-нибудь конкретный метод Resize и если это не схлопнется в просто «new T[] много намусорил вон в том билдере», проглотив все детали про resize.

Комментарии (0)