TheCacheWorks
52 subscribers
62 links
Power in knowledge
Download Telegram
​​Закон Протекающих Абстракций, часть 3

Как ни странно, одним из корней зла является такая штука, как realloc.

Если вы думаете, что вас это не касается - попробуйте реализовать хоть какой-нибудь контейнер без реаллокаций. Realloc мало того, что одна из самых опасных функций libC. Она еще и одна из самых фрагментирующих. И медленных (нет, mremap не панацея. У нее куча оговорок и ограничений и она платформенно-специфична. Нет, оптимизировать memcpy дьявольски сложно и сделать лучше отцов-основателей никому не удавалось. Да, даже с учетом SIMD).

Что дальше-то? А ничего. Новомодные и не очень ЯП чхать хотели на текущие абстракции памяти - купите чумадан плашек. Вас сильно припекло? Напишите свою STL. Как фейсбук или Электроник Артс. Если сможете.

Можно попробовать написать свой аллокатор общего назначения. Что лишь кажется простенькой задачкой. Потому что он должен быть именно глобальным, и охватывать все краевые случаи. В идеале иметь сложность O(1) - мы же не хотим ждать неведомо сколько, пока операционка отхрюкает и найдет нам участок памяти нужного размера; и - нет, оверкоммит тут не помощник - и околонулевую фрагментацию.

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

Ну, если, разумеется, вас не устраивает рестарт инжиниринг (а, помните, были времена, когда аптаймами мерялись?).

Менять сложившиеся абсолютно ублюдочные парадигмы разработки, разумеется, ради умников никто не станет. Равно как и переписывать халявные ОС, компиляторы и ЯП (хорошая попытка, Раст и Go, но нет). А равно как и тратить время на оптимизации кода - преждевременная оптимизация корень всех зол, а потом уже поздняк метадзе. Да и сисадмины толковые больше не нужны, всё есть код, нам лучше знать, с чего начинать.

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

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

PS. Возможно, кого-то сильно удивит - однако даже микрооптимизации обладают синергическим эффектом. Если ими не пренебрегать, они в совокупности достаточно часто дают значимый эффект. Хотя бы в виде снижения утилизации CPU. Протекающими абстракциями довольно часто можно воспользоваться к всеобщей выгоде.
​​Не мешай. Машине. Работать.

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

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

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

Есть такая штука, как кэш-линия L1. И - нет, она далеко не на всех процессорах имеет размер 8 байт.

Флаг блокировки (мы, разумеется, говорим про атомики) может оказаться в одной кэш-линии с другим shared мембером структуры. Что, в случае тредов, может запросто привести к такой неприятной и малоизвестной вещи, как false sharing. Которая, как следствие, при высокой конкурентности может привести к резкому росту внутрипроцессорного трафика и падению производительности.

Лечится это не просто, а относительно просто. Надо всего лишь выровнять флаг блокировки на размер кэш-линии процессора (успехов в его определении; там свои заморочки), после чего он окажется один на кэш-линию (с паддингом) и проблема false sharing будет практически решена.

И обратите внимание вот на что. Выравнивание самой структуры != выравниванию всех ее полей по кэш-линиям. Они могут быть выровнены - в зависимости от размеров-битности сборки-кэш-линии. А могут и не быть.

Однако, у всего есть своя цена. Итоговый размер структуры несколько вырастет. Может ощутимо вырасти. С соответствующими последствиями. Так что это trade off, который, кстати говоря, касается не только флагов блокировки, но и других членов структуры.

Ситуация усложняется с использованием мьютексов. Вы, без сомнения, в курсе, что std::mutex это обертка над pthread_mutex_t, который совсем не POD. А структура, причем структура, специфическая для конкретной платформы в смысле реализации.

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

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

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

Все же код, прежде всего, должен быть хорош для того, кто его исполняет.

Да и комментарии уже минимум полстолетия как изобрели.
​​Скрытая угроза

Манера удалять не глядя coredumps без исследования, равно как и недостаточное внимание к работе с памятью, эффективно скрывают такую устрашающую вещь, как UAF - use after free (а еще DF - double free, heap corruption).

Что, собственно, происходит, когда вы освобождаете память free/delete? В нормальной системе искаропки, поставленной по принципу Next-next-next-finish? (А, по сути, в любой системе)

Правильно. Указатель на блок памяти попадает в очередь освободившихся блоков ОС, вместе с этим блоком памяти. Память не очищается, если вы сами ее не почистите перед освобождением. Указатель продолжает оставаться валидным, если вы сами его не занулите (не присвоите nullptr, если мы говорим о C++).

Это нормальное поведение (ОС, вообще говоря, в приоритете имеет несколько иные вещи, чем сборка мусора), которое, однако не описывается жирным шрифтом в руководствах (которых, разумеется, никто не читает). Причем мы говорим про вообще любые ОС, кроме, может быть ОС РВ. И это не баг, не недосмотр - это нормальное документированное поведение.

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

Вы можете сказать "Ха! И еще раз - ха! Умные указатели уже изобрели!"

Да, их изобрели. Однако - надо читать стандарт и реализации внимательно - unique_ptr, например, при выходе из скоупа указатель не зануляет. Это полбеды. Где-нибудь в середине кода вы можете вынуть из него сырые данные посредством std::data, присвоить это содержимое другому указателю. И благополучно про него забыть - а он не будет автоматически очищен деструктором. Дело осложняется тем, что в unique_ptr может лежать, к примеру, массив сырых указателей. На что угодно. И их надо либо освобождать кастомным деструктором, либо отдельно зачищать. Забыли? Получите утечку памяти - в лучшем случае, в худшем - UAF или DF.

shared_ptr, конечно, со своим счетчиком ссылок, спасает гигантов мысли. Но вы представляете, да, насколько он тяжелее unique_ptr? И во что выльется простая лобовая замена - можете себе представить. Всё плохо.

Заметьте вот что.

Первое - такие вещи чрезвычайно трудно отловить. Статический анализ их почти не способен выловить (кроме самых очевидных случаев). Отловить их (в некоторых случаях) способен кастомный аллокатор, не имеющий очередей и немедленно, честно освобождающий блоки. На уровне своих метаданных. Он, по крайней мере, посредством кордампа, вам просигналит, что вот это вот приложение имеет в себе опасный баг (а вы его в прод затащили, просто потому, что - да откуда бы вам знать, что там UAF? Код, что ли, читать? Еще чего, пусть миллион мух напрягается. Да и не особо поможет чтение, справедливости ради).

Второе - раст не спасёт. Под ним все тот же рантайм Си, а под ним ядро ОС. Что происходит вне раста - не проблема раста.

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

Резюме неутешительно. "ТщательнЕй надо, ребята. ТщательнЕй!"

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

А для начала, об этом просто надо знать.

PS. Практический опыт показывает, что приложений с такими багами - релизных, в продакшене - почти 30-40%. Причем среди них находится, в частности, такой рекордсмен сетапов, как OpenSSL/LibreSSL. Вот теперь можете начинать бояться.
TheCacheWorks pinned «​​Скрытая угроза Манера удалять не глядя coredumps без исследования, равно как и недостаточное внимание к работе с памятью, эффективно скрывают такую устрашающую вещь, как UAF - use after free (а еще DF - double free, heap corruption). Что, собственно, происходит…»
​​Ядра чистый изумруд

Тот факт, что логические ядра совсем не эквивалент физическим и что HT/SMT это чистый маркетинг, было понятно любому здоровому человеку еще на момент появления этой технологии.

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

Дело, собственно говоря, в следующем.

Задач, которые HT/SMT хотя бы немного ускоряет (совсем не вдвое) - по пальцам одной руки пересчитать.

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

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

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

По факту, на тредовых задачах HT/SMT лишь увеличивает конкуренцию за кэши и порты (внутрипроцессорную), путается под ногами у NUMA, и провоцирует проблемы с безопасностью (здравствуйте, Meltdown&Spectre).

Реальные тредовые тесты показывают более, чем двукратное снижение производительности тредовых приложений на HT/SMT.

Проблема на уровне программирования осложняется тем фактом, что отличить логические "ядра" от реальных физических весьма непросто - std::thread::hardware_concurrency(), например, их не различает. Ну и мир не ограничивается архитектурами x86/ARM, и cpuid есть совсем не в каждом процессоре на планете.

Отдельная печаль - это поядерное лицензирование софта. Хотя логические ядра != физическим, очень многие вендоры с этим не согласны и на голубом глазу выставляют лицензии по числу логических ядер.

Очень показателен пример процессора SPARC M8. Заявленная тактовая частота которого составляет 4,5 ГГц, имеющего 32 физических ядра по 8 аппаратных тредов на ядро, что в сумме составляет впечатляющие, на первый взгляд, 256 ядер на сокет. Увы и ах - реальная скорость этих логических ядер совсем не равна частоте процессора, она много ниже. А скорость тредовых приложений падает просто драматически при выполнении в таком режиме работы CPU.

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

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

А специалистов по производительности просто никто не удосужился спросить и хоть как-то принять во внимание их мнение.

Так и получилось, что в высшей степени сомнительные в своей эффективности решения на десятилетия заняли совершенно неподобающее место в мозгах и серверных.
​​UB or not UB

That is the serious question.

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

Нет, господа. Нет, Гэри.

Это не копиляторы кривые. Это ваш код кривой.

Высокие уровни оптимизации (выше -O2) вытаскивают из-под ковра на свет божий все UB, скрытые и не очень.

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

Безопасность памяти это вообще отдельная тема.

Вы, наверное, в курсе, что обеспечение этой самой безопасности - это не прерогатива компилятора (здравствуй, Раст! напишите без unsafe что-то сколько-нибудь сложное, быстрое и интегрируемое - а, главное, обойдите рантайм libC при этом), не прерогатива системного (или несистемного) аллокатора памяти (и не надо ждать от него несвойственных ему функций, он не обязан ловить ваши DF/UAF/heap corruption - вообще, от слова "абсолютно". Он в таких случаях обязан с грохотом упасть в кордамп и не более того - и - нет, не замести ошибку под ковер).

Это прерогатива программиста.

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

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

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

Anyway.

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

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

Однако в долгосроке всегда окупается лучше, нежели TTM.

PS. Информация к размышлению - разница в производительности кода, собранного с уровнем оптимизации -O2 и -O3 может составлять до 30%.
​​Nice to have

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

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

В частности, это касается такой, казалось бы, обыденной вещи, как фичафлаги второ- и третьестепенных функций. Которые nice to have, а не must to have.

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

Как правило, это рассыпаные по коду ifы с булевским флагом.

Казалось бы, что может пойти не так?

Да всё вообще. Нежданный if, который нужно выполнять при каждом проходе по коду, это ассемблерный джамп. Который нарушает последовательность выполнения и опустошает конвейер предвыборки процессорного ядра.

В общем и целом, такой дополнительный if в горячем месте может съесть в среднем до 10% от производительности. Иногда и больше.

Можно ли с этим что-то сделать?

В идеале - условная компиляция. Да, фичи на хардкоде. Да, это противоречит позиции Комитета "шаблоны вместо макросов, макросы однозначное зло". Сервер, конечно же, железянный, но законы физики никто не отменял. Нет кода - нет проблем.

Но - это идеал. Никакого лишнего кода, если он не нужен.

Как быть, если он все-таки нужен и надо что-нибудь втереть, или вправить или еще что-нибудь?

Первое - поможет короткое замыкание. Если проверка условия фичафлага такое допускает (иногда такое бывает в динамических фичафлагах). Считаем минимум.

Второе - хинт expect. Несмотря на многократный shame на SO, он помогает довольно эффективно купировать лишнее ветвление. Работает это на уровне компиляции. Компилятор более вероятную ветвь просто распрямляет, переставляя джамп в другое место, с тем, чтобы конвейер предвыборки команд процессора по-возможности не опустошался.

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

И третье - иногда помогает вдумчивое реструктурирование кода с ветвлениями. Да, вплоть до рефакторинга. Меньше ifов - выше скорость.

Если резюмировать все вышесказанное, то вывод снова неутешительный: "Тщательней надо, ребята! Тщательней!"

Ну и еще раз хорошенько подумать - а нужно ли превращать проект в Nero Burning ROM.

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

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

Простыми словами - либо вы экономите память, либо работаете быстро. Третьего не дано.

Практически не существует физической возможности соединить низкий O по сложности с низким O по памяти. Это всегда две противоположные сложности.

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

В реляционных БД есть канонический пример этой закономерности. Джойны. Квадратичный nested loops и логарифмический hash join. Первый расходует мало памяти, и работает долго-долго, особенно когда вторая табличка сколько-нибудь большенькая и растет. Второй жрет память как не в себя, но зато логарифм. И где-то посредине, причем ближе ко второму краю, болтается trade-off sort merge. Исключение, подтверждающее правило.

Мало кто желает вникать в такие глубины наших глубин. Большинство хотят и рыбку съесть и шкурку сдать. И начинают хотеть странного и невозможного.

Особенно характерно это работает в отношении аллокаторов - системных или кастомных. "Мы хотим от аллокатора временную сложность 'амортизированная O(1)' в наихудшем случае, а также чтобы он немедленно отдавал память операционной системе по free() - соответственно, держал низкий RSS - и имел околонулевую внешнюю фрагментацию". А это невозможно, ребята. Ни архитектурно, ни алгоритмически. Вы же понимаете, что это невозможно? Либо O(1), либо низкий RSS - но в последнем случае забудьте про нулевую фрагментацию - она у вас будет дичайшая. Это если не принимать во внимание тот очевидный факт, что ни один из традиционных системных или кастомных аллокаторов и так не отдает память ОС немедленно - из чего вытекают почти все RCE, связанные с памятью.

Аллокаторы общего назначения с временной сложностью O(1) или амортизированная O(1) всегда будут иметь повышенный RSS. Хотите вы этого или нет. Который, однако же, стабильно стоит. Это гарантируется архитектурой. Они никогда не отдают память ОС немедленно, иначе они околонулевую фрагментацию держать не смогут. Совместить все три требования теоретически возможно - в диком кастоме embedded, на основе статических хранилищ. Но не в общем назначении.

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

Finally. Существует лишь один алгоритм, имеющий совпадающую пространственную и временную сложность O(1).

Это хеллоуворлд.

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

...при подходе к ̶Ж̶и̶в̶ч̶и̶к̶у̶ IDE.

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

Рассмотрим простенький пример из реальной кодовой базы.
 <typename S>
bool checkExcludes(const S& p_str)
{
bool v_found = false;
for (const auto& e : c_Excludes)
if (p_str.find(e) != std::string::npos) {
v_found = true;
break;
}
return v_found;
}

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

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

Однако, стоит лишь ее переписать:
 <typename S>
bool checkExcludes(const S& p_str)
{
for (const auto& e : c_Excludes)
if (p_str.find(e) != std::string::npos)
return true;
return false;
}

как цикл, ее вызывающий, начинает выполняться в полтора раза быстрее. Казалось бы, какой-то несчастный bool, один байт, однако hot path.

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

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

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

Смысл всего этого заключается в том, что парадигма features first порочна по своей сути. Не то, чтобы не следовало гнаться, теряя тапки, за TTM. Кушать очень хочется. Суть в том, что, программируя, стоит, вообще говоря, в голове держать семантику того, что пишешь.

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

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

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

Для девопсов - которые наполовину программисты, наполовину сисадмины - ну, так предполагается, во всяком случае - это вообще не должно представлять никакой проблемы.
​​С Новым годом! Да не настигнет вас никогда UB! И всегда, всегда, ВСЕГДА делайте бэкапы!
​​Здесь живут драконы, часть 1: Причуды компиляторов, краткий ликбез

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

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

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

Но вот читать и понимать ассемблер все же необходимо.

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

В ассемблере нет никаких for, while и даже if как таковых.

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

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

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

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

Картина сильно упрощена, но в целом примерно так и происходит выполнение программного кода.

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

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

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

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

Это страшно дорого и ужасно больно. В hot path падение производительности может составить от 30% (это обычный минимум) вплоть до десятичного порядка или более - если branch misprediction происходит часто.

Не следует переоценивать "интеллект" предсказателя переходов. В процессоре физически нет места для сколько-нибудь сложной логики предсказания. Грубо, вероятность правильно угадать нужную ветвь в реальном коде в среднем составляет 50% - то ли угадал, то ли нет.

В некоторой степени подкорректировать ситуацию позволяют хинты expect. Они не то, чтобы добавляют ума предсказателю переходов, а с учетом руками определенной приоритетности переходов влияют на формирование ассемблерных блоков таким образом, чтобы джампы по наиболее вероятному пути происходили реже, а последовательность команд оставалась линейной в окрестности ветвления.
​​Здесь живут драконы, часть 2: Причуды компиляторов - герои меча и магии

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

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

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

Результат предсказуем - падение производительности в разы, никак не компенсируемое мощностями железа.

Рассмотрим пример из реального кода.
if (unlikely(!x.load(std::memory_order_relaxed))) return;
while (l.load(std::memory_order_relaxed) || l.CAS(std::memory_order_acquire)) {
  ++cnt;
  if (likely(cnt < pred.load(std::memory_order_relaxed) << 1))
    continue;
  // Some code...
}
pred.fetch_add((cnt - pred.load(std::memory_order_relaxed)) >> 3, std::memory_order_relaxed);

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

Так как код - критическая секция, да еще и hot path, общая скорость работы всей программы падает в среднем на 30% по результатам бенчмарка.

Если просто поменять условие раннего выхода на противоположное:
if (unlikely(x.load(std::memory_order_relaxed))) {
  while (l.load(std::memory_order_relaxed) || l.CAS(std::memory_order_acquire)) {
    ++cnt;
    if (likely(cnt < pred.load(std::memory_order_relaxed) << 1))
      continue;
    // Some code...
  }
}
pred.fetch_add((cnt - pred.load(std::memory_order_relaxed)) >> 3, std::memory_order_relaxed);

то производительность возвращается. Кодогенерация изменилась, branch misprediction снизился.

Если оставить в стороне споры "ранний выход vs цепочка if" (при том, что в соседнем методе, с меньшим числом строк и сложностью, ранний выход ускоряет работу), следствие довольно очевидное - хотя здесь налицо противоречие всем бест практисам и code style - нужно оставлять новый вариант и подкрепить его комментарием - почему здесь написано именно так.

Есть и еще один пример, когда фиктивный, вычисляемый на этапе компиляции, if в hot path позволил избежать двукратного общего падения производительности в сравнении с настоящим if в рантайме. Этот пример мы не будем рассматривать, так как он требует масштабных объяснений вне формата поста.

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

Совсем не от хорошей жизни в STL стараются как можно больше сделать посредством constexpr. И это при том, что средний if транслируется всего в 4-5 ассемблерных команд.

Неочевидное следствие - хотя бы критические hot path следует писать в стиле Кармака, после исследования кодогенерации и бенчмарков. В особенности при кроссплатформенной разработке.

Потраченное время может окупиться приличным приростом производительности.
​​Здесь живут драконы, часть 3: Проклятие оверхеда не сказки

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

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

Всего лишь копеечный if.

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

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

Который может составлять до 20-30%, в среднем.

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

Означает ли это триумфальное возвращение bare metal?

К сожалению, не означает.

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

Да, этот код читаемый, даже местами примитивный.

Но он заточен под понимание человеком, совсем не под его эффективное исполнение процессором.

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

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

Просто стоит писать комментарии, внятно отвечающие на вопрос "Почему?"

И, разумеется, документировать код.

Это противоречит устоявшимся стереотипам и методологиям, однако другого пути просто не существует.

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

Именно за такую работу software engeneers и должны получать свои деньги.
​​Здесь тоже живут драконы: синтаксис vs семантика

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

Скомпилированный код обязан лишь иметь видимое поведение (в соответствие со стандартами), соответствующее синтаксису. Однако чем выше уровень оптимизации, тем агрессивнее оптимизация и семантика ассемблера все больше и больше отличается от исходного кода. При выполнении оптимизаций компилятор может сделать практически что угодно. Выкинуть блокировку, если считает, что она не нужна в конкретном месте. Переупорядочить блоки инструкций. Выкинуть код, который, как ему кажется, не влияет на видимое поведение. Здравствуй, UB.

Однако речь сейчас не об UB, а о производительности vs читаемости кода.

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

Рассмотрим простенький пример.

#include <cstddef>

ptrdiff_t x { 1024 };

int main()
{
size_t y, z;

y = x / 2;
z = x >> 1;

return y + z;
}

Он же на Godbolt. Можно с интересом поиграться с уровнями оптимизации и типами данных и посмотреть, что генерируется для двух идентичных операций целочисленного деления.

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

В данном конкретном примере, разница существенная. Битовый сдвиг генерирует одну инструкцию. Деление - пять инструкций. Разумеется, читаемость выше у деления. На SO утверждают, будто бы на O1-O3 будет одно и то же.

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

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

Умудренные опытом скажут, что каст опасен - смешивание знаковых и беззнаковых в арифметике - прямая дорога к UB. Быстро, но опасно.

Кстати, антипример - tcmalloc переполнен int'ами и выражениями где смешаны int и size_t. Можно догадаться, чем пахнут спелые фиги и применение такого щедрого подарка в продакшене. Стоит, в общем, читать исходники опенсурсной халявы, прежде, чем тащить в прод.

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

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

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

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

И все, в конечном итоге, исключительно в руках программиста. Это ему решать - к умным или к красивым.
TheCacheWorks pinned «​​Не надо хотеть невозможного Если вы имеете хоть какое-то отношение к IT, возможно, вам известна фундаментальная системная закономерность - временная и пространственная сложности алгоритмов противоположны. Простыми словами - либо вы экономите память, либо…»
​​Драконье логово: обертки оберток

Любителям оберток и оберток оберток посвящается.

Рассмотрим снова простенький пример.
#include <thread>
std::thread::id thread_id = std::this_thread::get_id();

#include <pthread.h>
pthread_t thread_id = pthread_self();

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

Если посмотреть на ассемблерный код, на любых уровнях оптимизации, первый вызов дополнительно порождает две ассемблерных команды (на уровне оптимизации -O3) - вызов конструктора типа std::thread::id. Для типобезопасности.

Казалось бы, какая мелочь. Что там конструировать, знаковое целое, INT_MAX, о чем там волноваться?

Если бы. В горячем пути эта разница дает 2,5% потери производительности (для кроссплатформенного и типобезопасного get_id()).

О чем беспокоиться? Вот о чем - эти 2,5% - при единственном вызове в горячем пути. Да, всего один вызов и один запуск конструктора типа. Ничего особенного.

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

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

Процессорные циклы. Наносекунды складываются в секунды, а секунды в боль.

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

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

Закон Мура подошел к своим теоретическим пределам, тактовые частоты выше 4 ГГц не могут подняться уже свыше 10 лет, размеры техпроцесса не могут быть меньше размера атома - а квантовые эффекты начнутся сильно раньше.

Время задуматься об оптимизации. И игнорировать даже микрооптимизации больше не получится.
​​Федорино core

В glibC 2.42 (Федора 43 и другие роллинги) сделали защиту от DF. Причем сделали так, что лучше бы не делали.

Как всегда, напишем простенький тест:
#include <iostream>
#include <vector>
#include <functional>
#include <cstdlib>

int main() {
void* dangling_ptr = nullptr;

// Queue of callbacks
std::vector<std::function<void()>> callbacks;

// Callback #1: allocate block and save dangling pointer
callbacks.push_back([&dangling_ptr]() {
void* block = malloc(128);
dangling_ptr = block; // save dangling pointer
free(block); // block goes back to thread cache
});

// Callback #2: allocate new block of same size
callbacks.push_back([]() {
void* new_block = malloc(128);
(void)new_block; // pretend to use
});

// Callback #3: free using old dangling pointer
callbacks.push_back([&dangling_ptr]() {
std::cout << "dangling_ptr=" << dangling_ptr << std::endl;
std::cout << "calling free(dangling_ptr)..." << std::endl;
free(dangling_ptr); // crash on allocator
});

// Execute callbacks in order
for (auto& cb : callbacks) {
cb();
}
}

Скомпилируем и запустим:
BIT=-m64

g++ -O3 $BIT -std=c++11 -c -o test1.o test1.cc
g++ $BIT -s -o test1 test1.o
./test1

dangling_ptr=0x2d235430
calling free(dangling_ptr)...
free(): double free detected in tcache 2
./test1.sh: line 7: 2749 Aborted (core dumped) ./test1

Посмотрим на стектрейс (на КДПВ). Вам он хоть что-нибудь говорит? Нам не говорит тоже. Сегфолт прилетает из глубин glibC, ни единого намека на четкий и ясный код, вызвавший DF - как, вообще говоря, должно быть. Что такое tcache 2? Должно быть, некая структура метаданных бэкэнда системного аллокатора, не так ли? А какая именно? И как она связана с DF в нашем коде?

Как программист должен догадаться, где произошел DF - в сколько-нибудь сложном коде, если в стектрейсе ничего вообще на это не указывает?

Телепаты загорают на Бали.

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

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

UPD. Помимо полной неясности, где именно произошел double-free, есть еще один, гораздо более серьезный, недостаток. Простейшие и самоочевидные DF эта защита ловит. Но уже чуть менее тривиальные случаи она не отлавливает и просто заметает под ковер без всяких сегфолтов. Что значительно хуже. Неполноценная защита едва ли лучше ее полного отсутствия.
​​ Где находится double-free

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

А как вообще можно на уровне аллокатора или его бэкэнда отловить double-free в прикладном коде?

Собственно, это в значительной степени зависит от архитектуры аллокатора.

Допустим, что аллокатор (или его бэкэнд) построен на основе односвязных списков - фрилистов. При выполнении вызова free(), указатель на блок добавляется в список, и затем, спустя какое-то время, фрилист уходит на утилизацию ОС либо в некое центральное хранилище (кэш).

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

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

Если немного подумать, простейшая и наивная защита от DF - при освобождении указателя перед его помещением во фрилист, проитерироваться по фрилисту с проверкой указателя - а не находится ли он уже в списке свободных? Всё бы ничего, но это O(n) прямо в горячем пути. Падение производительности аллокатора будет не драматическим - а астрономическим, на пару десятичных порядков примерно.

Вариант продвинутый - использовать дополнительную память, поверх фрилиста выстроить хэш-таблицу и проверять вхождение по хэшу. И все вариации на эту тему. O(n) уже по памяти. Плюс расчет хэшей.

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

Определив, что указатель уже был недавно освобожден, возможны два варианта поведения. Первый - замести под ковер. Просто молча сглотнуть указатель и больше его во фрилист не добавлять. Плохо, потому что это скрытый 0-day. Который неизбежно найдут и используют, а вы даже знать об этом не будете. Второй - плюнуть сегфолтом в приложение. Вариант неплох, вы сразу получите стектрейс с указанием, где примерно в коде приложения был DF.

Однако накладные расходы на защиту будут ужасные. Все это происходит в горячих путях и жрет - с учетом интенсивности malloc/free - процессорные циклы как не в себя.

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

Что это означает на практике? На практике это означает, что нам всего-то навсего архитектурно нужна очень жесткая reuse policy. То есть, free() должно означать "немедленный free, блок сразу же, через наносекунду, попадает во фрилист и будет переиспользован". В этом случае молниеносно происходит смена владения и аллокатор, обнаружив, что кто-то уже трогает память, которая была освобождена и переиспользована, незамедлительно кинет сегфолт. Что, по цепочке, приведет к образованию внятного стектрейса приложения (не бэкэнда, не компонентов ядра ОС), с free() на вершине стека.

Одна маленькая проблемка. Ни один из существующих аллокаторов так не реализован.

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

Нет, Rust не спасёт и не сохранит. Нет, статический анализ не обязан видеть DF.

Единственная, по сути, защита - это строгая дисциплина программирования - всегда зануляйте указатели после освобождения памяти - и жесткая reuse policy аллокаторов. Которая - единственная - при обнаружении DF немедленно уронит приложение, а не закопает ошибку владения под ковер.
​​Corner cases и стандарты

О бедном reallocarray() замолвите слово.

Спустя 30 лет, неожиданно оказалось, что произведения elems * size (двух беззнаковых целых типа size_t) недостаточно и нужно проверять на переполнение еще и экзотический вариант realloc, в котором тоже может быть переполнение произведения беззнаковых целых.

Вообще говоря, wrap around беззнаковых целых не редкость при безалаберном программировании.

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

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

Собственно, это одна из причин, по которой POSIX неохотно согласились включить такую проверку в calloc() (calloc при переполнении warp around, до выполнения аллокации, возвращает NULL) и отказались включать reallocarray() в стандарт.

Мотивация появления reallocarray() вообще в высшей степени мутная. "Если внезапно понадобится резко выполнить сверхкрупную реаллокацию...". Что значит "Внезапно?" Внезапно захотели из килобайта получить терабайт? Из-под камня вылез wrap around и закорраптил кучу? Вы не проверяете входящие значения?

Не то, чтобы size_t было достаточно для любого. Но сам по себе корнер кейс исчезающе редкий. Да и не должен быть аллокатор нянькой для программиста.

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

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

То же самое относится к классическому realloc. Стандартное поведение: если size = 0, то память по указателю освобождается и указатель зануляется. Если программист не подумал о таком корнер кейсе и просто написал ptr = realloc(ptr, size) и в результате потерял данные после ошибочного поступления нулевого size - то это, вообще-то говоря, его проблема.

realloc() штука небезопасная, такое поведение описано в стандарте, это, по сути, контракт. Обвязка для проверок входных данных на корректность и для сохранения данных по указателю в корнер кейсах, если необходимо продолжить обработку в такой ситуации - это прерогатива программиста. Аллокатор не нянька, он работает в соответствие со стандартом. Передал нулевой размер - получил free(p) и p = NULL.

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

NB: статические анализаторы могут поймать realloc(p, 0). А могут и не поймать.

Anyway, границы слоев проверок и защит от дурака постепенно размываются. И незаметно плывут в сторону рукосуев. Как слово "кофе" плавно стало среднего рода. Ценой - разумеется - увеличения накладных расходов. А что такого-то, процессор железный, он все перемолотит.

Это неправильный подход. Не случайно POSIX послали reallocarray() в сад, а рукосуи правдами и неправдами пытаются его зафиксировать как стандарт де-факто, пропихивая в libC/glibC изо всех сил.

Хотите вручную управлять памятью - пожалуйста, но перечитайте, пожалуйста, стандарт и не делайте допущений, что в implementation details будут подложены все и всякие подушки и подушечки на случай дефективной прокладки между сиденьем и клавиатурой.

PS. Если что-то ходит, как костыль, бегает, как костыль, крякает как костыль и выглядит, как костыль - то это и есть костыль.
​​Диагностика и оптимизация производительности

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

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

Если начать от частного к общему, то в двух словах проблема звучит так - top это не тот инструмент, который сходу определит бутылочное горлышко (bottleneck).

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

Ботлнек либо невозможно четко определить, либо невозможно легко - либо хоть как-то - исправить.

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

Никакая автоматизированная система этими свойствами не обладает и обладать не может. Именно это объясняет гомерические провалы, в частности, попыток Oracle создать автотюнер. Ни решение на базе экспертной системы (Oracle Expert; на минуточку, 1998 год) с базой данных и базой правил, ни последующие поползновения Diagnostics/Tuning Pack, ни любые другие шаблонные и формализованные решения к успеху не привели и маловероятно, что приведут. А ведь здесь речь идет о достаточно простой, в общем-то, вещи - реляционной базе данных, всего-то навсего. У которой, как оказалось, существует окружение - ОС, железо, сбалансированные и не очень конфигурации, многочисленные ошибки архитекторов, программистов и сисадминов... Тот огромный и, как оказывается, значимый контекст, который невозможно описать на двух страничках А4 языком популярных телепередач.

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

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

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

Рынок, однако, сделал свой выбор. Верхнеуровневые средства + интуиция - вот то, что осталось. Техношаманство как система.

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

Между тем, аксиома "Оптимизация производительности - требует прежде всего глубокого понимания архитектуры" никуда не делась. И диагностика - это фундамент любой оптимизации. Которая в начале разработки преждевременная, а потом невозможная.
​​RDBMS != InMemory database

Эта прописная истина не доходит до DBA с тех самых пор, как реляционные БД пихают где надо и где не надо, во все места и при этом скорость хоть сколько-нибудь критична.

Реляционные БД - это не InMemory БД. Это аксиома. Для RDBMS нормально иметь постоянный IO со сториджем, и, следовательно, 99% cache hit ratio это абсолютно нездоровая метрика.

Почему?

По очевидным причинам.

OLTP это постоянно идущие транзакции. Записи на диск - в блоки данных и логи и чекпойнты. Такие данные, которые движутся в одну сторону, кэшировать бессмысленно - это просто впустую занимать оперативную память. Изменили - записали - выполнили чекпойнт - покинули кэш. Лишь в случаях повторного чтения кэш уменьшит лейтенси; причем, с учетом того факта, что мы говорим об OLTP - объем записей маленький, чтение со сториджа, который не сатурирован, происходит быстро. Нет, это не рандомное чтение - слой кэша файловой системы плюс упреждающая выборка на уровне ОС - они существуют - скомпенсируют сторидж (разумеется, грамотно построенный и сконфигурированный). Запись при OLTP преимущественно последовательная.

DWH/OLAP - тоже выполняют IO. И тоже в одну сторону. Чтение фактовых таблиц большого размера в ad hoc запросах - это дорога в одну сторону, загрузили, обработали, освободили кэш.

Это ожидаемое поведение.

Попытки целиком закэшировать реляционную БД в надежде достичь 99% cache hit на практике приводят к чрезмерному раздуванию кэша с последующей дикой внутренней фрагментацией и падению скорости. Приходилось видеть совершенно копеечные реляционные БД с кэшем размером 1 Тб (!) и непонятным замедлением и рандомными скачками лейтенси.

Нормальными показателями cache hit ratio для подавляющего большинства активных реляционных БД любого типа является величина 80-90%. Максимум.

При условии, что время отклика запросов находится в пределах бейслайна (baseline).

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

Почему этот миф оказался настолько живучим?

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

Так вот, это не более, чем заблуждение.

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

PS. Игнорирование нижележащих абстракций всегда ведет к заблуждениям при тюнинге. В оптимизации дьявол в деталях и необходимо учитывать их все. К слову, случаи direct mount относительно редки, но даже там попытки кэшировать базу целиком добром не заканчиваются. Базы тоже выполняют read ahead и write behind. Отнюдь не по одному блоку.