Евгений Пешков — Убийцы производительности

Евгений Пешков — Убийцы производительности01:01:47

Информация о загрузке и деталях видео Евгений Пешков — Убийцы производительности

Автор:

DotNext — конференция для .NET‑разработчиков

Дата публикации:

15.03.2024

Просмотров:

7.7K

Транскрибация видео

Спикер 1

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

Ну, наверное, чтобы оно работало качественно и быстро.

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

Но где-то кроются проблемы.

И о таких, наверное, неявных местах сейчас нам расскажет Евгений.

Евгений, тебе слово.

Удачи.

Спикер 2

Всем привет и давайте начинать.

Сегодня мы поговорим о том, как менялись подходы к написанию кода на Дутнете, почему код зачастую получается непроизводительным, хотя выглядит он в принципе прилично.

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

И в том числе, как вам приложить руку к ускорению программ на дотнете в целом.

Когда-то у нас была эра старого .NET фреймворка.

И чем она характеризовалась?

Ну, такое погружение в динозавров небольшое.

Тогда был закрыт исходный код.

Были доступны только референс-сорсы, и даже если удавалось...

Понять проблему со стороны менеджер кода, если она лежала где-то на уровне ниже, в джете, в рантайме, в garbage коллекторе, то зачастую можно было только угадывать истинную причину или пытаться ее понять, отваживая нативный код с помощью виндобг.

Также не было никакой обратной связи между пользователями .NET и разработчиками .NET.

Наверное, можно было купить какую-то очень дорогую платную подписку от Microsoft и получить премиум поддержку, но я не знаю, кто занимался вообще таким или нет.

Если кто-то таким занимался, то расскажите мне в дискуссионной зоне.

Также не хватало...

способов написания производительного кода.

Все спаны, мемори и остальные прикольные фичи, они уже появились гораздо позже.

Также в стандартной библиотеке часто встречался непроизводительный код с большим количеством локаций и блокировок.

И, соответственно, ради производительности некоторые фрагменты стандартной библиотеки приходилось переписывать.

И вот пример, что приходилось переписывать.

Есть метод task delay, который позволяет подождать некоторое время, как он работал внутри в те времена.

Он запускал таймер и ставил его в глобальную очередь.

Постановка таймера в глобальную очередь и извлечение таймера оттуда происходили под глобальной блокировкой.

Глобальная блокировка — это убийца производительности.

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

В современных версиях тут нет.

Эта проблема была решена тем, что таких очередей и локов их защищающих стало несколько по числу ядер процессора.

И теперь все потоки не ломятся на одну блокировку.

Когда DotNet стал опенсорсным, произошло нечто вот такое.

Во-первых, появился бенчмарк .NET, который позволил легко замерять производительность и привлек к этому много людей.

Появились более глобальные бенчмарки, которые позволяли сравнить .NET с другими платформами.

И эти бенчмарки стали интересны Microsoft, который решил продвигать свою платформу ради Azure.

Также появились конкуренты.

Kotlin позволил писать красивый код на JVM стеке, привлек туда тех, кого раньше отпугивало Java.

Появился Go, который продвигался как язык, который компилируется в нативный код и при этом имеет преимущество garbage-коллектора.

Node.js я бы сюда не показал.

Ну и, естественно, у .NET появилась

Команда разработчиков, которые понимают за перформанс.

Вот здесь Стивен Тауп, который пишет к каждому современному релизу .NET огромную простыню перформанс-улучшений на 250 страниц.

И где эти улучшения производительности концентрируются?

Во-первых, в самих языках.

Появляются фичи для написания производительного кода.

Например, рефритерны или стеклок, который можно присвоить к спану.

Стеклок был раньше, его прокачали.

Value tuple, то есть раньше, если нам нужен был tuple, то это получался объект на куче, теперь это значение на стеке.

Дальше.

В рослине.

В сути, рослин может тоже использоваться для оптимизации кода на еще уровне его преобразования в ИЛ.

Но такие оптимизации разработчики рослино не любят, потому что они усложняют устройство компилятора.

Но, тем не менее, такие оптимизации иногда бывают.

Например, если вы пишете код, который складывает три строки a, b, c, то это сложение заменится на один вызов метода string concat.

Дальше у нас идет рентайм.

Сюда входят оптимизации при джит-компиляции, микрооптимизации различные, ускорение в плане сборщика мусора.

И это, наверное, самая активно развивающаяся часть .NET, потому что в ней изменения можно вносить, не особо боясь за поломку обратной совместимости.

А вот дальше есть еще оптимизации на уровне BCL, стандартной библиотеки.

Сюда относятся

Новые API, которые позволяют писать более производительный код, а также крупные оптимизации, например, те, которые не может сделать джит самостоятельно, например, эффективно убрать блокировку в каком-то месте.

Это можно сделать только алгоритмически.

И к BCL относятся также микрооптимизации наподобие векторизации различных хаков.

Допустим, написать код таким образом, чтобы джид убрал проверку на выход за границу массива.

BCL немного прикрывает недостатки нынешнего джета и вообще на самом деле в BCL сейчас наибольшее количество всех оптимизаций.

Его очень хорошо прокачали по сравнению с временами полного фреймворка.

Основная сложность всех оптимизаций – это сохранить обратную совместимость и сохранить простоту разработки на дотнете.

То есть, наверное, можно взять и превратить дотнет в раст.

Но тогда возникает вопрос – зачем?

И будет ли после этого приятно писать код на .NET языках и решать с его помощью бизнес-задачи?

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

Но изменений появилось довольно много.

И почти в каждой версии современного .NET какие-то улучшения были.

Например, .NET Core 2.0

Значительно прокачали Concurrent Queue, удалив оттуда почти все аллокации.

В .NET-фреймворке из Concurrent Queue просто вылезал memory трафик в невероятных объемах.

В Netcore 2.1, по сути, появился тот .NET, который мы сейчас знаем в отношении перформанса.

Появились спаны, memory, появилась возможность переиспользовать объекты внутри value-тасков для синхронного ожидания без аллокаций.

Появился новый HTTP-клиент, более эффективный.

В Netcore 3.0 наконец-то избавились от завязки на стороннюю библиотеку.

В самом датнете, в рантайме.

Нет, он создан...

И сделали System Text JSON свою более производительную реализацию, в которой меньше динамической магии.

В Net5 появились source-генераторы, то есть тоже средство, которое позволяет избавиться от разной магии в рантайме.

Кода генерации вынести все это на этап компиляции кода.

В .NET 6 появилось улучшили интерполированные строки.

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

В .NET 7 появилось очень много перформанс-улучшений.

Например, в RegEx добавили API, которые позволяют работать не со строками, а со спанами.

И в .NET 8, который скоро выйдет, появились Frozen коллекции.

Это хэш-таблицы, в которых ничего нельзя добавить, но за счет того, что они знают набор ключей заранее, они могут вычислить более эффективную функцию хэш-кода, чтобы избежать коллизий.

Или вот inline array еще одна фича, которая появится в .NET 8, о ней еще позже поговорим.

И вот сверху на все улучшения перформанса, которые есть в dotnet runtime, накладываются следующие вещи.

Сторонние библиотеки.

Вряд ли есть много программ, которые используют только стандартную библиотеку.

И, естественно, пользовательский код.

И если с рантаймом у нас все относительно хорошо, то с этими двумя вещами есть некоторые проблемы.

И какие это проблемы?

Разработка многих сторонних библиотек была начата еще во времена dotnet фреймворка.

Соответственно, они были основаны еще на непроизводительных абстракциях.

И они не успели еще обновиться.

Также часто обновиться до современного .NET мешает необходимость поддерживать старые рантаймы, предыдущие версии.

И у авторов библиотек часто нет желания разделять.

Вот сборка у нас для .NET Core 3.1, вот для .NET 8, например.

И часто...

У разработчика библиотеки цели не такие, как у пользователя библиотеки.

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

Ну и вечная проблема open-source — это недостаток разработчиков, мейнтейнеров,

их времени, ну и денег, мотивации на какие-то улучшения.

Некоторые мейнтейнеры в последнее время не выдерживают, меняют лицензии, добавляют RedSleep в процесс билда, что, естественно, тоже никаким хорошим образом ни на перформансе, ни на репутации таких библиотек не сказывается.

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

В некоторых случаях библиотеку проще не брать и проще переписать с нуля.

Но, тем не менее, очень приятно всегда пооптимизировать что-то под общий случай.

Я прошелся по программам предыдущих .next и нашел вот такие доклады про проблемы со сторонними библиотеками.

Первые два доклада про MongoDB клиента и SMB Library делал Станислав Сидористый.

Доклад про... Доклад про...

S3-клиенты — это сегодняшний доклад, который был до моего.

Ну и еще есть доклад про медиатор.

К сожалению, забыл, чей именно.

И доклад про HTTP-клиенты мой, про REST-FARB библиотеку.

Там упоминалось, что она до сих пор основана на старом API для отправки HTTP-запросов.

Но я посмотрел...

Недавно то, что в REST Sharp все-таки перешли на современный HTTP клиент.

Пока еще не успел протестировать, насколько переход на HTTP клиент помог улучшить перформанс-проблемы, которые были в REST Sharp.

Но то, что библиотека развивается, это однозначно приятно.

И вспомним про SMB library из доклада Стаса Сидористова.

Это библиотека для передачи файлов по протоколу SMB.

На винде можно просто используя опишки файла работать с SMB шарами удаленными.

Напомню, вот такие пути есть.

Два обратных слэша, имя сервера, обратный слэш и путь до файла.

Вот это путь до файла по протоколу SMB.

Ну и в SMB протоколе есть вот такие пакеты.

Если упрощать, то они делятся на две части.

Мы хотим отправить какой-то файл на сервер, чтобы его записать.

И для этого нам надо сформировать заголовки пакета, посчитать хэш-сумму, а вслед за этим отправить содержимое файла.

Вот такие вот два фрагмента данных.

Они в одном пакете должны отправиться, и вот тогда сервер это поймет.

И недостатки SMB Library.

Во-первых, то, что это полностью синхронный API, используется для сетевых операций.

Ну, это такой, конечно, недостаток, потому что иногда синхронный API может быть и быстрее.

Но основной недостаток — это огромное количество локаций.

То есть как работает SMB Library, когда ей надо отправить контент,

Вот байтовый массив контент, оно берет, вычисляет заголовки, хэш-сумму и дальше копирует все это в новый массив.

То есть оно создает массив такой, чтобы в него поместились и заголовки, и контент.

Дальше записывает в начало этого массива заголовки, в конец этого массива записывает контент и отправляет этот пакет.

Здесь возникают две проблемы.

Первое, то, что мы авоцируем очень большой буфер, который может быть размером несколько килобайт, может попасть в кучу больших объектов, и то, что мы делаем лишнее копирование.

Как предлагалось это решать в предыдущем появлении SMB Library на WebNext?

Предлагалось использовать пулинг.

Пуллинг решает проблему с аллокацией большого массива, но тем не менее у нас все равно остается копирование контента.

Но в целом подход с пулингом имеет несколько недостатков, и не всегда стоит на него целиком полагаться.

Во-первых, отслеживать жизненный цикл объектов, которые мы взяли...

Из пула это очень сложная работа.

На самом деле это ничуть не проще, чем управлять памятью целиком вручную.

Ну и все еще зависит от того, насколько производительная реализация пула нам попадется.

А вот попасться нам могут разные по производительности реализации пулов.

Разберем это на примере стандартных ray-пулов.

В Дутнете есть две стандартные реализации.

Первая – это ArrayPoolShared.

Это глобальный пул, который никак настроить нельзя.

И arrayPool, который создается с помощью метода create для конкретного случая.

И второй вид пула можно параметризовать.

Можно задать максимальную длину массива, которая в этом пуле будет храниться, и сколько массивов одного размера может в этом пуле находиться.

И по производительности эти две реализации отличаются.

Вот здесь я запросил из пула несколько массивов по килобайту, вернул их в этот пул и повторил это несколько раз.

И получилось так, что RayPool Shared действительно позволил улучшить производительность.

А вот array pool, созданный с помощью метода create, ухудшил производительность по сравнению с аллокацией, несмотря на то, что memory трафика стало меньше.

Как же такое произошло?

Посмотрим внутрь ArrayPool, который создается с помощью метода create.

Реализация называется внутри configurable ArrayPool.

Configurable, потому что ей можно задать два параметра.

Максимальная длина массива и максимальное число массивов одного размера, которое здесь выделено красным.

И здесь пул устроен довольно просто.

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

И этот стек, так как стек не потокобезопасный, он защищен спинлоком.

И как раз производительность в нашем случае просела из-за того, что наш код стал висеть на спинлоке.

А вот то, что код висит на спинлоке, ничего хорошего в этом на самом деле нет.

Даже если бы здесь был обычный лог, мы его хотя бы смогли бы увидеть в...

С помощью PerfView, когда профилировали бы нашу программу, а со спинлоком мы увидим просто, что у нас есть какой-то горячий метод, и нам еще надо очень, очень должно нам повезти, чтобы этот метод не заинвайнился, и мы проблему быстро вычислили.

Но в целом такая реализация хороша, если массивы большие и работа с ними занимает существенно больше времени, чем вытаскивание и затаскивание этих массивов в пул.

RayPool Shared – это более продвинутая реализация, и она в том числе оптимизирована под хранение массивов небольшого размера.

И RayPool Shared в целом соответствует тому, как должна выглядеть хорошая реализация пулинга производительного.

Во-первых, здесь есть...

Несколько уровней пула.

И первый уровень – это Threat Static поле, в котором хранятся по массиву на каждый из размеров.

То, что у нас есть вот этот Threat Static пул на первом уровне, оно снижает количество взаимодействия потоков между собой.

Меньше синхронизации, больше производительность.

Второй уровень пула, он тоже сделан довольно хитро.

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

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

И только в том случае, если мы не смогли извлечь...

нужный нам массив из своего стека, мы можем пойти и посмотреть в стеке соседних ядер.

Но, конечно, недостаток такой реализации, что если мы будем здесь хранить какие-то очень большие массивы, например, размером по гигабайту, то все эти массивы могут осесть на ThreadStatic уровне и

неэффективно переиспользоваться.

То есть один поток пришел, запросил массив размером гигабайт, такого массива не нашлось, и пришлось выделить новый.

Хотя на самом деле подходящий массив был в ThreadStatic пуле у другого потока.

Естественно, если у нас есть здесь какие-то большие массивы, потоки часто запускаются, останавливаются, то они могут вылететь из пула и создать фрагментацию в куче больших объектов.

Поэтому в целом вывод здесь такой, что arrayPoolShared подходит для массивов небольшого размера, а arrayPool, который создается с помощью метода create, стоит использовать только для больших массивов.

Но вернемся к нашему примеру с SMB Library.

Вот у нас здесь есть сейчас вариант с пулингом.

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

Мы можем использовать подход, который называется scattergather.io.

В чем суть такого подхода?

А в том, что мы работаем с нашими контентами не как с массивами, а как с набором некоторых фрагментов.

И, например, в сокет мы можем отправить один контент в виде двух ray-сегментов.

Вот.

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

А вот если большие, то такой подход имеет смысл попробовать.

Также здесь используется array-сегмент, а не read-only-memory, как мы бы, наверное, здесь хотели видеть в современном дутнете.

Все потому, что это API появилось очень давно.

Также здесь могут быть тоже лишние копирования самого нашего листа, который мы туда передали, а также на разных платформах такая API может работать по-разному.

В целом доработка вот этого API для сокетов запланирована в .NET Runtime и на .NET Runtime.

тогда, наверное, уже появится перегрузка с read-only memory, а не с array-сегментом.

Ну и в целом, если писать какую-то библиотеку с подходом scattergather.io, то может оказаться полезным пакет под названием pipelines.sockets.unofficial.

Это адаптер для работы с сокетами с помощью API System.io.pipeline.

Read-only sequence, это тоже структура, которая объединяет в себе связанный список нескольких меморий.

Пакет, в принципе, достаточно хорошо написан с точки зрения производительности, хотя само API System IEO Pipelines довольно сложное.

Также, используя идею scattergather.io, мы можем сделать chunked коллекции.

Это может нам помочь в том случае, если нам надо хранить множество контентов разных размеров.

Допустим, эти массивы у нас берутся из array-пула.

У нас может оказаться так, что в array-пуле тогда будет какой-то огромный массив на гигабайт, который нам когда-то был нужен, а его никто не берет, потому что всем нужны массивы более маленького размера.

Такая неоднородность может приводить к фрагментации памяти и неэффективному ее использованию в виде хранения этих лишних массивов.

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

128 килобайт, в принципе, хорошее здесь значение.

То есть таким образом нам не надо будет заморачиваться о том, что нам надо делать пул массивов разных размеров.

Нам будет достаточно поддерживать пул массивов размером 128 килобайт.

По-моему, задача звучит гораздо легче.

А почему 128 килобайт?

Потому что тогда такие массивы попадут в кучу больших объектов.

Очень часто считается, что куча больших объектов – это абсолютное зло, и надо любыми способами избегать попадания объектов туда.

Но она появилась не зря.

Если какие-то большие буферы будут храниться в куче для маленьких объектов, то garbage collector будет пытаться их перемещать и тратить на это лишнее время.

И ГЦ будут происходить.

Очень долго.

То есть зло — это не сама куча больших объектов.

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

Никакие объекты поставить не получается, допустим, потому что эти дырки слишком маленькие.

У нас был массив размером 128 килобайт.

Он собрался, в куче осталась дырка на 128 килобайт, в нее авоцировался еще один объект на 80 килобайт, и получается 48 килобайт у нас дырка, нам в программе уже нечем ее заполнять, потому что на 48 килобайт этот объект еще просто даже в кучу больших объектов не попадет.

И, в принципе, этот подход используется, и есть готовый пакет Microsoft IO Recyclable Memory Stream.

Это готовая альтернатива Memory Stream, как раз основанная на том, что наш контент хранится не в одном большом массиве, а нарезан на несколько массивов размером поменьше.

Какие из этого выводы?

Пуллинг не всегда эффективен.

И чем меньше у нас синхронизации потоков, тем лучше.

Ну и можно использовать подход с нарезанием каких-то больших массивов на более маленькие.

Следующая большая тема у нас это логирование и его влияние на производительность.

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

Но с другой стороны, логирование довольно сложно устроено технически.

Это большое количество работы со строками, это аллокации в куче различных промежуточных объектов, лог-эвентов, параметров тех же самых строк.

Это синхронизация потоков, потому что log файл — это последовательная структура, а потоков в нашей программе обычно больше, чем один.

Ну и, естественно, это ввод-вывод, потому что если мы что-то залогировали, то мы хотим это записать.

Как вообще устроен логгер?

Обычно у него есть фасад интерфейс, куда что-то логируется, допустим, log, information и какие-то параметры.

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

И самая важная часть логгера, которая уже занимается вводом-выводом, это аппендер.

Аппендер это то, что уже доставляет лог-ивенты до файла, до консоли, до сетевого хранилища.

И с аппендером тоже проблем с производительностью может быть много.

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

И оказалось, что log4j для Java чуть быстрее, чем бутнетные логгеры.

Но при этом он тоже не лишен недостатков.

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

А среди датнетных логеров в целом обнаружилось, что консольный логер довольно-таки отстает.

Ну, в принципе, консольный, как мы видим, здесь везде отстает от файлового лога.

И сейчас мы попробуем понять, почему так происходит.

Вот в последней колонке это...

Производительность записи в файлы консоль без использования библиотеки логирования.

То есть, как мы видим, библиотека логирования свой overhead создает.

Тут, конечно, надо, глядя на эту картинку, понимать, что здесь пропускная способность гостям анлога из коробки тысяча.

1 миллион 600 тысяч лог сообщений в секунду.

Понятно, что в целом, наверное, вряд ли у кого-то есть потребность столько писать.

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

Так вот, консольный логер серый лога.

Почему вообще консольный логгер медленнее файлового?

Это как раз пример, когда потребности разработчика...

которыми была написана библиотека, очень отличаются от потребностей пользователей этой библиотеки.

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

И тем не менее, часто консольные логеры используются и на продакшене тоже.

Причем без каких-либо изменений по сравнению с тем, что есть с обычным консольным логгером, который используется для отладки.

И в некоторых случаях даже для отладки консольные логгеры могут быть медленными, потому что реальная консоль, она рендерит текст логгера.

берет символы, которые на консоль надо записать, и рисует их в окошке консоли.

Процесс тоже довольно замедляет перформанс, и поэтому, если, допустим, отваживаете какую-то большую многопоточную программу, то наличие в ней логов может тоже повредить процесс.

Или в каких-то случаях наоборот не повредить, а выявить какие-то проблемы с синхронизацией потоков.

Что где-то появятся задержки и в итоге все станет, в итоге выстрелит где-то проблема, связанная с тем, что программа пришла в некорректное состояние.

Конкретно с серый логом проблема, почему он оказался настолько медленным у него, консольный аппендер, в том, что он пишет цветные логи.

Вот у нас есть лог-строчка, и в ней есть параметры, которые в эту строчку были подставлены.

Вот эти параметры, они раскрашиваются при выводе на консоль, а на Windows раскраска производится с использованием отдельного медленного API.

Это можно исправить.

Можно переключиться с API консоли винды на раскраску логов с помощью escape последовательности.

И тогда производительность подрастет.

Мы уже можем записать не 1700 сообщений за секунду, а 16000 логов сообщений.

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

Но не будем на этом останавливаться.

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

Такое сделать настройками лонгера не получится.

крайней мере, консольного аппендера серволога.

Поэтому надо его скопировать, например, себе в проект, и тогда такой эксперимент сделать можно.

Хорошо, что там код несложный, собирается он запросто.

Собственно, по стандарту, как обычно себя ведут консольные логгеры, они записывают записи на консоли по одной штуке.

В какой-то мере это логично.

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

Соответственно, для локальной отладки выгоднее сразу показывать все лог-записи по одной, чтобы они не задерживались во внутренних буферах.

А если мы используем такое логирование на сервере, то там никто не смотрит глазами, и поэтому безразлично, как быстро лог-записи на консоль появятся.

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

В этом все просядет появление записи, а пропускная способность возрастет.

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

И включение буферизации поднимает пропускную способность еще в три раза.

До 110 тысяч лог-сообщений в секунду.

Что, конечно, не дотягивает до полутора миллионов в случае с файловым логером.

Но, тем не менее, уже...

гораздо лучше, чем 1700 Vox сообщений, с которых мы начинали.

Еще, работая с консоли, всегда стоит помнить, что в ней есть другая сторона.

Когда мы пишем в файл, то мы зависим от производительности диска, на который мы пишем.

Когда мы пишем на консоль, мы зависим также от производительности того процесса, который наше логосообщение пишет.

И вот у нас есть процесс 1, который пишет логи на stdout, и есть буфер внутри операционной системы, в которой эти лог-записи сохраняются.

Дальше есть процесс 2.

который захватывает вывод первого процесса, то есть вычитывает этот буфер, который находится в ядре операционной системы.

И вот взаимодействие построено таким образом.

Вот здесь проблема в том, что если процесс 2 замедлится и не сможет вычитать содержимое этого буфера, то буфер переполнится, и процесс 1, который в этот буфер пойдет писать, он заблокируется на попытке в этот буфер сделать запись.

Ну и если логирование у нас происходит синхронно,

то в таком случае у нас остановится все приложение, которое процесс один.

И в целом, если, допустим, будете запускать какой-то код с пользованием консольного логера и стандартной консоли Windows, и, например, консоли в райдере, то можно легко заметить, что в райдере консоль работает быстрее.

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

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

Пропускная способность — это сколько сообщений за секунду пролезает.

Latency — это время добавления события в логгер.

Как мы уже обсуждали, миллионы лог-записей в секунду, наверное, нам не так важны.

Нам важно, чтобы логирование наименьшим образом влияло на метрики нашего приложения.

Соответственно, нам важно latency, что мы генерируем лог-запись, это происходит мгновенно.

Вот.

А дальше она уже может где-то внутри записываться долго.

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

Как...

вообще уменьшить latency логирования.

Но самое очевидное – использовать асинхронный аппендер.

Асинхронный – это тот, который вызывается в потоке, который изначально логирование инициирует.

А асинхронный – он помещает лог сообщения в очередь.

И где-то уже на другом потоке это лог сообщения попадет в приемник.

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

То есть это...

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

Непосредственно запись нас интересует для минимизации latency чуть меньше.

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

И вот, допустим, для серволога есть пендер, который называется Fast Console.

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

И каким образом мы, допустим, можем поработать с уменьшением latency?

Например, из сервивога.

Как мы знаем, сервивог — это структурный воггер, и у него должны быть именованные параметры.

Вот A и B, они именованы именами A и B.

Слева пример — это использование интерполяции строк.

И вот здесь у нас имена параметров этих теряются.

А в примере справа мы используем механизм серволога, который называется method template.

То есть у нас есть строчка, которая похожа на интерполированную, но интерполированной не является.

В ней на месте подстановок написаны имена параметров.

А дальше отдельно перечислено значение этих параметров, как при использовании метода string формат.

Cerelog внутри парсит method в template, выделяет из него имена и другую информацию о параметрах, а дальше подставляет значения в нужные места.

Но распарсить вот этот method в template, извлечь из него информацию о параметрах, это задача небыстрая.

Поэтому в Cerelog есть кэш этих method в template.

Кэш этот сделан в виде словаря, где по строке method template хранится результат парсинга шаблона.

Но здесь можно заметить, что если мы будем использовать не method template, а использовать интерполяцию строк, то тогда мы получим…

Неэффективное использование кэша.

У нас кэш будет заполняться уникальными строками, вытесняя настоящие методы темплейта.

И в итоге от такого кэширования никакой пользы не будет.

Тем более там еще понадобится блокировка на каждое добавление в кэш, ну и код станет однопоточным.

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

А кто-то видит, в чем еще проблема кэша, в котором по строке хранится вот этот результат парсинга шаблона.

Вот подсказка.

Мы можем посмотреть на метод getHashCode у строки.

Как мы видим, в getHashCode строки нет никакого кэширования результата вычисления хэш-кода.

Если мы вызываем этот getHashCode на строке, то у нас значение хэш-кода считается всегда заново.

И поэтому строковый ключ в хэш-таблице это не то чтобы очень-то эффективно.

Так как строки сравниваются по значению, а никакого кэширования getHashCode нет, то на каждый поиск в хэш-таблице по строке нам придется вначале вычислить хэш-код, а потом еще сделать несколько сравнений с теми строками, которые нашлись.

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

И как это исправить?

Обратим внимание на то, что обычно наши Message Template это константы в коде или интернированные строки, которые взяты из ресурсов, если у нас есть локализация этих строк.

Соответственно, если у нас это константы или интернированные строки, то каждый раз, когда мы вызываем метод логирования с этим Message Template,

то мы всегда используем один и тот же объект этой строки.

И используя это, мы можем оптимизировать этот кэш темплейтов, используя сравнение строк по ссылке, а не по значению.

То есть как это сделать?

Мы можем сравнивать их через reference equals, а метод getHashCode не использовать, вместо него брать хешкод из заголовков объектов с помощью метода runtime helpers getHashCode.

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

Первый по ссылке, а второй уже по значению.

В таком случае мы сможем ускорить для того случая, когда у нас есть попадание в кэш по ссылке.

И такое...

Изменение, оно в принципе повышает, снижает latency на порядок.

Ну и также эта разница зависит от длины method в template.

То есть вот здесь это на самом деле не длинное это количество.

Если мы увеличим размер method в template, то у нас...

Разница станет еще больше между кэшом, который хранит строки по значению и по ссылке.

Ну и к вопросу о потоке безопасности.

Дикшнеры обычный, он не потокобезопасный.

И захват блокировки и на чтение, и на запись в таком кэше, оно, конечно же, убьет производительность влагирования.

Concurrent dictionary в целом хорошее решение, быстрое на чтение, очень быстрое, но тем не менее для concurrent dictionary надо для его хэштаблиц сохранить большой граф объектов в памяти.

Ну и в сериалоге используется некоторое промежуточное решение, а именно нет generic hash table.

Эта коллекция еще из, тут нет фреймворка, версии 1.1 существует, и у нее есть свойства.

Она позволяет читать одновременно с записью, но при этом поддерживает только одного одновременного писателя.

То есть если запись в хэш-таблицу случилась, то при чтении затем будет делаться ретрай.

Называется optimistic concurrence.

Мы читаем из хэш-таблицы, думая, что в это время никто не пишет в нее.

А если кто-то записал, то мы узнаем в конце о том, что запись случилась, и делаем новую попытку перечитать значение.

Но хештейбл не очень удобная коллекция, потому что в ней хранятся только референс-типы, в ней хранятся объекты, она не дженерик, ретраи делаются на любую запись.

И поэтому, чтобы исправить эти недостатки хештейбла для каких-то своих его применений, я сделал библиотеку под названием Concurrency Toolkit.

Вот в этой библиотеке есть класс single writer dictionary.

Это, по сути, аналог хештейбла, только дженерик.

Подход там точно такой же, как в хештейбле, optimistic concurrency.

При неудачном чтении, если пришел писатель, то делается ретрай.

Но, тем не менее, ретраев делается меньше, потому что

Там записи затрагивают меньшее количество бакетов.

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

А если запись была где-то далеко, в другой части хэштаблицы, то у нас все хорошо и чтение мы можем не ретраить.

Еще какую оптимизацию удалось сделать для стереолога, это авокация на стеке.

Вот это метод записи лог-эвента.

И как мы видим, он довольно странный на вид.

Вот здесь у него есть generic параметры для типов логов.

вот property value 0 и property value 1, под них сделаны отдельные generic параметры.

А дальше что происходит?

Дальше все равно происходит боксинг этих параметров.

И еще кроме боксинга параметров создается массив параметров,

в которой эти параметры сохраняются.

Здесь особого смысла в этих generic параметрах нет.

Единственное, чему они помогают, что здесь не будет боксинга в том случае, если log level выключен.

Дальше, если поизучать, как устроен этот код, можно заметить, что вот этот массив,

который new object, он не покидает пределов нашего стека вызова.

Он дальше попадает в метод write, там из него эти значения вычитываются, параметров, и все, этот массив выбрасывается.

Он никуда не сохраняется.

За пределы нашего стека вызовов он не выходит.

Соответственно, раз он не выходит за пределы нашего стека вызовов,

то мы можем заменить эту авокацию на хранение этих параметров на стеке.

Вот такую оптимизацию JIT сам сделать не сможет.

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

И как мы можем это сделать?

А мы можем сделать это вот так.

Выделить с помощью stack-a-lock.

Но на самом деле не можем.

Будет ошибка компиляции.

Кто знает, почему будет ошибка компиляции?

Ошибка компиляции будет по той причине, что stack-a-lock не умеет работать с непрометивными типами.

У нас здесь reference-тип объекта.

Как мы можем это обойти, это ограничение конструкции StackLog?

Мы можем использовать inline array.

Мы можем создать структуру с двумя полями и использовать ее в качестве массива.

в качестве массива, который находится на стеке, а не в куче.

Ну и вот такая оптимизация немного позволяет сократить memory traffic.

Хоть и совсем немного.

К сожалению, с боксингом самих значений и свойств нам здесь особо ничего не сделать.

Но нам пришлось делать вот такую структуру.

Inline array для двух элементов.

А это неудобно.

Мне, допустим, в Concurrency Toolkit для некоторых inline RAF пришлось сделать куда более большие структуры на 16 и на 256 элементов.

И в коде это выглядит просто ужасно.

Сгенерировал, вставил эту портянку из 256 свойств,

Полей, извиняюсь.

И никогда больше это не трогаешь.

Лучше свернуть в регион, чтобы вообще больше никогда этот код не видеть.

Но разработчики .NET тоже хотят делать подобные оптимизации, хотят делать их более лаконично.

И придумалось вот такое решение.

Оно какое-то совсем не свойственное для CFARP, но тем не менее работать оно будет.

Здесь мы объявляем структуру inline array для двух элементов.

Делаем здесь поле, соответствующее нулевому элементу и помечаем всю эту структуру атрибутом inline array.

И вот компилятор в этом случае возьмет и сделает нам здесь столько полей, сколько мы попросили в атрибуте inline array.

Я не знаю, уже утвердили эту фичу для CFRP 12 или нет, но вот такой драфт есть.

По-моему, выглядит как кандидат на удаление в будущем.

Потому что синтаксис очень страшный для C-sharp, и вообще не кажется, что это какое-то последовательное развитие языка.

И внезапно в .NET Runtime есть pull-request, который добавляет в Runtime поддержку констант в качестве дефенерик параметров.

Здесь мы можем объявить generic параметр и сказать, что этот generic параметр у нас это число.

И дальше уже...

При создании структуры указать в generic параметры не тип, а константное значение.

Если вы программировали на расте, то, скорее всего, вам подобная механика знакома.

Или если на плюсах программировали с шаблонами, то, наверное, помните, что в STL есть STD array, который тоже параметризуется константой.

Но, тем не менее, это не подобранная для .NET фича, а просто концепт, на который можно зайти и посмотреть.

В целом, наверное, кажется то, что

Если бы вот эта фича появилась раньше, то у нас бы не появилась вот эта странная фича с inline RAM, которая задается отдельным атрибутом, магией, заклинанием с этой структурой с одним полем.

Но это уже к вопросу о том, что вот эта фича сложная, а inline array мы хотим сейчас.

Наверное, если бы рекорды в C-sharp появились уже после source-генерации, то они использовали бы в себе именно source-генерацию, работали поверх нее, а не отдельных конструкций в компиляторе.

отдельной логикой для генерации кода с рекордами.

Потому что с рекордами не очень приятно работать.

Нельзя их унаследовать от класса, класс нельзя унаследовать от рекорда.

И вообще кажется, что это хороший кандидат на source-генерацию.

А какие из всего этого выводы?

Во-первых, DotNet действительно становится быстрее, но на производительность программ куда больше влияет не то, какой быстрый у нас рентайм, а то, как сами программы написаны.

Если код программ медленный и неоптимальный, то быстро они работать из-за магии рентайма не будут.

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

Я вот просто зашел в сервивок, запустил дотрейс и уже нашел два места, которые можно оптимизировать абсолютно понятным способом.

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

Собственно, все.

Спикер 1

Жень, спасибо большое за вот эти вот кишки, мясо и вот эту всю красоту.

Друзья, к сожалению, у нас нет времени для того, чтобы задать вопросы прямо с зала, поэтому ловите Евгения в дискуссионной зоне и задавайте свои вопросы.

Жень, спасибо тебе большое.