Короли оверхеда
Откройте для себя RUNPATH.
Возможно, для кого-то это станет открытием, однако DLL Hell можно в большинстве случаев объехать простыми, можно сказать - простейшими - методами, неоднократно описанными в документации.
Достаточно сложить нужные библиотеки рантаймов в нужное место (не в системные директории), указать этот путь в RUNPATH при сборке и включить эти библиотеки в дистрибутивный пакет (это умеет делать не только любимый CMake, но даже - тадааааам! - autotools). Всё. Вы прекрасны. Вы только что объехали DLL Hell и можете совершенно безнаказанно (в пределах совместимости системных libC/glibC) обновлять ОС хоста, ее отдельные пакеты, что угодно. Без всяких докеров/флатпаков. Никакого влияния на основную систему.
Помимо версионирования библиотек (на уровне имен и на уровне ABI) и совершенно очевидных проверок API как при компиляции, так и в рантайме - для чего надо приложить лишь некий минимум усилий при разработке - существует совершенно замечательная возможность задать пути поиска динамических библиотек для приложения. Как программными средствами, при компиляции и линковке - так и - в продвинутых случаях - пользуясь штатными методами динамического линковщика.
Обратите внимание на тот факт, что, зная и используя эти существующие методы, можно обойтись без совершенно ненужного и необоснованного оверхеда от виртуализации/контейнеризации, используемых с целью избавиться от DLL Hell.
Если вы на полном серьезе верите бодрым заявлениям разработчиков, что виртуализация/контейнеризация почти не привносит оверхеда, то вам следует знать, что это самое "почти" составляет до 20% от производительности барметал. Каждый слой абстракций может привносить такой оверхед. Вряд ли кто-то ограничивается одним докером в погоне за модой и стилем.
Вряд ли кто-то, будучи в здравом уме, может считать оверхед от 20 до 80% приемлемой платой за так называемое удобство и, в особенности, снижение когнитивной сложности.
Давайте взглянем на эти цифры с другой стороны. Вы покупаете 1000 серверов для развертывания своей системы. Скажем, по 10000$ за сервер. Это составляет 10 млн долларов.
Если вы снижаете оверхед на 20%, вы экономите на покупке оборудования 2 млн долларов. Если вы снижаете оверхед на 80% (это экстремальный случай, однако вполне возможный - выкидываем докер, кубер, виртуализацию, отключаем ненужные митигации Spectre/Meltdown, без виртуализации они практически бессмысленны, тюним операционную систему) - вы экономите на железе 8 млн долларов. При этом мы не считаем полную TCO, разумеется. И не рассуждаем о сложности администрирования оставшихся 200 серверов, ансибл уже изобрели.
Нужно считать дальше, варианты с облаками итп.? И так всё очевидно, верно?
Безусловно, отсутствие каких бы то ни было стандартов для библиотек и привело к существующей ситуации. Однако стандарты де-факто все же существуют и поддерживаются строителями компиляторов и binutils. И существуют средства разрешить возникшую проблему.
Надо всего лишь о них знать. И слегка напрячь свою когнитивную сложность. Один раз. При разработке.
Оверхед таких порядков не игрушки. И, разумеется, вам никто из евангелистов не сознается, что он существует и имеет именно такие порядки.
И лишь вам решать - вам к умным или к красивым.
Откройте для себя RUNPATH.
Возможно, для кого-то это станет открытием, однако DLL Hell можно в большинстве случаев объехать простыми, можно сказать - простейшими - методами, неоднократно описанными в документации.
Достаточно сложить нужные библиотеки рантаймов в нужное место (не в системные директории), указать этот путь в RUNPATH при сборке и включить эти библиотеки в дистрибутивный пакет (это умеет делать не только любимый CMake, но даже - тадааааам! - autotools). Всё. Вы прекрасны. Вы только что объехали DLL Hell и можете совершенно безнаказанно (в пределах совместимости системных libC/glibC) обновлять ОС хоста, ее отдельные пакеты, что угодно. Без всяких докеров/флатпаков. Никакого влияния на основную систему.
Помимо версионирования библиотек (на уровне имен и на уровне ABI) и совершенно очевидных проверок API как при компиляции, так и в рантайме - для чего надо приложить лишь некий минимум усилий при разработке - существует совершенно замечательная возможность задать пути поиска динамических библиотек для приложения. Как программными средствами, при компиляции и линковке - так и - в продвинутых случаях - пользуясь штатными методами динамического линковщика.
Обратите внимание на тот факт, что, зная и используя эти существующие методы, можно обойтись без совершенно ненужного и необоснованного оверхеда от виртуализации/контейнеризации, используемых с целью избавиться от DLL Hell.
Если вы на полном серьезе верите бодрым заявлениям разработчиков, что виртуализация/контейнеризация почти не привносит оверхеда, то вам следует знать, что это самое "почти" составляет до 20% от производительности барметал. Каждый слой абстракций может привносить такой оверхед. Вряд ли кто-то ограничивается одним докером в погоне за модой и стилем.
Вряд ли кто-то, будучи в здравом уме, может считать оверхед от 20 до 80% приемлемой платой за так называемое удобство и, в особенности, снижение когнитивной сложности.
Давайте взглянем на эти цифры с другой стороны. Вы покупаете 1000 серверов для развертывания своей системы. Скажем, по 10000$ за сервер. Это составляет 10 млн долларов.
Если вы снижаете оверхед на 20%, вы экономите на покупке оборудования 2 млн долларов. Если вы снижаете оверхед на 80% (это экстремальный случай, однако вполне возможный - выкидываем докер, кубер, виртуализацию, отключаем ненужные митигации Spectre/Meltdown, без виртуализации они практически бессмысленны, тюним операционную систему) - вы экономите на железе 8 млн долларов. При этом мы не считаем полную TCO, разумеется. И не рассуждаем о сложности администрирования оставшихся 200 серверов, ансибл уже изобрели.
Нужно считать дальше, варианты с облаками итп.? И так всё очевидно, верно?
Безусловно, отсутствие каких бы то ни было стандартов для библиотек и привело к существующей ситуации. Однако стандарты де-факто все же существуют и поддерживаются строителями компиляторов и binutils. И существуют средства разрешить возникшую проблему.
Надо всего лишь о них знать. И слегка напрячь свою когнитивную сложность. Один раз. При разработке.
Оверхед таких порядков не игрушки. И, разумеется, вам никто из евангелистов не сознается, что он существует и имеет именно такие порядки.
И лишь вам решать - вам к умным или к красивым.
TheCacheWorks pinned «Короли оверхеда Откройте для себя RUNPATH. Возможно, для кого-то это станет открытием, однако DLL Hell можно в большинстве случаев объехать простыми, можно сказать - простейшими - методами, неоднократно описанными в документации. Достаточно сложить нужные…»
Divide et impera или Все дороги ведут в
Как вы думаете, зачем отцы-основатели, на чьих плечах мы лишь перхоть, раскидали библиотеки в системе по разным директориям?
и так далее?
И для чего изобрели
Отцам-основателям уже было известно, чем пахнут спелые фиги - DLL Hell. И они с ним достаточно успешно справлялись, отделяя мух от котлет. Вдумайтесь только - одна переменная среды или один конфиг
Однако вернемся к нашим барашкам.
И все было хорошо, с бородатыми сисадминами в уродливых свитерах с оленями на первоюниксах.
Однако некий мудрила, родом из пекла, не иначе, как по наущению дьявола, подкинул идейку - "А давайте все вообще библиотеки свалим в /lib. Вообще все. Системные, юзерлэндовские, приложений и пакетов. Ненуачо, удобнажы. И приколотим их гвоздями друг к другу и к libC, будем обновлять дистры как единое целое".
И разверзся портал в Ад.
У вас больше нет операционной системы общего назначения - с одной стороны, и кучки устанавливаемых пользователем приложений - с другой.
У вас есть 2048+ специализированных дистров для врачей-венерологов, водителей-дальнобойщиков, школьников младшего школьного возраста, школьников среднего школьного возраста...
В которых всё приколочено ко всему гвоздями-сотками. Сколько-нибудь серьезные обновления возможны лишь целиком - а вы попробуйте поставить в Дебиан 11 - OpenSSH 9.9 из Дебиан 12, не сломав всю систему к чертовой матери, попробуйте. Все депенденты в одной точке, разгрести эту кучу в стопочки не представляется возможным - что, кто-то из фанбоев любовно собирает сам, из исходников, с пеной у рта вытребываемых у разработчиков, свои сервисы и приложения, раскладывая библиотеки куда нужно и с пониманием, почему в большинстве пакетов префиксом по умолчанию является /usr/local?
В которых сколько-нибудь стабильная эксплуатация сколько-нибудь значимого и стабильного приложения за пределами LTS - как вам LTS в три года, кстати? Вы, наверное, в курсе, что в нормальном, не пластмассовом, мире LTS - это 10+ лет, не так ли? - возможна исключительно в контейнерах/песочницах. Доводы про безопасность контейнеров развеиваются, как дым - вы по факту замораживаете в контейнерах легаси. Что, серьезно? Обновлять целиком контейнер? То, что контейнеры по факту и изоляции-то сколько-нибудь полной не обеспечивают, как те же гипервизоры - ратующим за безопасность как-то не приходит в светлые головы. Не на том хардвере. Аппаратный
Вот так нежелание вдумчиво читать отцов-основателей, на чьих плечах мы лишь перхоть, и неудержимое желание реформаторства ради реформаторства, без желания сесть и задуматься - "Может быть, для того, что было сделано - были какие-то веские причины?" - и привело к тому, что мы имеем сейчас.
/libКак вы думаете, зачем отцы-основатели, на чьих плечах мы лишь перхоть, раскидали библиотеки в системе по разным директориям?
/lib - система/usr/lib - юзерлэнд и operational environment/X11/lib - для гуя/usr/local/lib - для библиотек локально установленного софтаи так далее?
И для чего изобрели
LD_LIBRARY_PATH и RUNPATH? А в наиболее продвинутых и на сегодняшний, 2025й, год, системах сделали тонко настраиваемый ld.so? Для чего изобрели версии API в библиотеках? И их версионирование в принципе?Отцам-основателям уже было известно, чем пахнут спелые фиги - DLL Hell. И они с ним достаточно успешно справлялись, отделяя мух от котлет. Вдумайтесь только - одна переменная среды или один конфиг
ld.so - и проблемы не существует. Не нужно никаких наворотов в виде слоев абстракции, не нужно этих соплей докеров-контейнеров-песочниц. Единственный сколько-нибудь разумный довод - высосанная из пальца якобы безопасность, в эпоху Мелтдауна и Спектров довод просто никакой критики не выдерживает. А их митигации, с 40% шлепком по производительности, это как раз тот самый случай, когда лекарство хуже болезни. Как химиотерапия при неоперабельном раке.Однако вернемся к нашим барашкам.
И все было хорошо, с бородатыми сисадминами в уродливых свитерах с оленями на первоюниксах.
Однако некий мудрила, родом из пекла, не иначе, как по наущению дьявола, подкинул идейку - "А давайте все вообще библиотеки свалим в /lib. Вообще все. Системные, юзерлэндовские, приложений и пакетов. Ненуачо, удобнажы. И приколотим их гвоздями друг к другу и к libC, будем обновлять дистры как единое целое".
LD_LIBRARY_PATH? Не, не слыхал. RUNPATH? Не, не слыхал. Да и вообще, к чему эти точки монтирования, свалим всё в один раздел, пусть система приходит вся и сразу (то, что для разделения системы на разделы тоже есть веские причины - не, не слыхал).И разверзся портал в Ад.
У вас больше нет операционной системы общего назначения - с одной стороны, и кучки устанавливаемых пользователем приложений - с другой.
У вас есть 2048+ специализированных дистров для врачей-венерологов, водителей-дальнобойщиков, школьников младшего школьного возраста, школьников среднего школьного возраста...
В которых всё приколочено ко всему гвоздями-сотками. Сколько-нибудь серьезные обновления возможны лишь целиком - а вы попробуйте поставить в Дебиан 11 - OpenSSH 9.9 из Дебиан 12, не сломав всю систему к чертовой матери, попробуйте. Все депенденты в одной точке, разгрести эту кучу в стопочки не представляется возможным - что, кто-то из фанбоев любовно собирает сам, из исходников, с пеной у рта вытребываемых у разработчиков, свои сервисы и приложения, раскладывая библиотеки куда нужно и с пониманием, почему в большинстве пакетов префиксом по умолчанию является /usr/local?
В которых сколько-нибудь стабильная эксплуатация сколько-нибудь значимого и стабильного приложения за пределами LTS - как вам LTS в три года, кстати? Вы, наверное, в курсе, что в нормальном, не пластмассовом, мире LTS - это 10+ лет, не так ли? - возможна исключительно в контейнерах/песочницах. Доводы про безопасность контейнеров развеиваются, как дым - вы по факту замораживаете в контейнерах легаси. Что, серьезно? Обновлять целиком контейнер? То, что контейнеры по факту и изоляции-то сколько-нибудь полной не обеспечивают, как те же гипервизоры - ратующим за безопасность как-то не приходит в светлые головы. Не на том хардвере. Аппаратный
no_exec_user_stack на попсовых архитектурах как класс отсутствует.Вот так нежелание вдумчиво читать отцов-основателей, на чьих плечах мы лишь перхоть, и неудержимое желание реформаторства ради реформаторства, без желания сесть и задуматься - "Может быть, для того, что было сделано - были какие-то веские причины?" - и привело к тому, что мы имеем сейчас.
Почему самый лучший аллокатор не ускоряет IO?
TL;DR: Потому, что не может.
Очевидное объяснение.
Даже самый отбитый и отмороженный программист (исключая, возможно, сорокапятилетнего вкатуна - таксиста) не будет писать ввод-вывод с аллокациями-деаллокациями на каждый пук (грусти).
Любой, хоть сколько-нибудь вменяемый, программист напишет так: одна аллокация буфера в начале - долгий и печальный ввод-вывод, ограниченный а) алгоритмикой и б) пропускной способностью железа - одна деаллокация буфера в конце.
Соответственно, на фоне затяжного IO, аллокации занимают проценты (в самом плохом случае) времени.
Очевидно же, правда?
С базами данных (реляционными), на 80% состоящих из этого самого IO, картина другая. Там просто один раз аллоцируются разделяемые (shared) буферы функцией mmap. И движок базы потом в этих буферах сам разруливает. Как арены в игростроительстве. Иногда совсем умные люди даже PGA делают. Для сортировок и прочего.
Действительно ли с базами всё так и приватных аллокаций вообще нет?
Не совсем.
Приватные аллокации в БД выполняют процессы базы. И еще - аллокации приватных страниц выполняют курсоры. Открывающиеся и закрывающиеся.
Отсюда этот видимый (и измеримый) эффект ускорения - чего? - SELECT. Обратите внимание - не DML, лишь в некоторой степени SELECT FOR UPDATE, не DDL с DCL. Не аггрегирующие запросы с чудовищными полными сканами и сортировками.
А стеной идущие относительно мелкие - селекты.
И эффект там значимый. В разы. С одновременным снижением утилизации CPU. Примерно этак вдвое.
Вот что имели в виду бородатые ДБА, которые туманно называли это "снижением оверхэда".
Что же касается влияния супераллокатора на CPU bound и, несколько в меньшей степени, Memory bound, то это уже другая история.
Которая будет рассмотрена позднее.
That's all, folks! 😉
TL;DR: Потому, что не может.
Очевидное объяснение.
Даже самый отбитый и отмороженный программист (исключая, возможно, сорокапятилетнего вкатуна - таксиста) не будет писать ввод-вывод с аллокациями-деаллокациями на каждый пук (грусти).
Любой, хоть сколько-нибудь вменяемый, программист напишет так: одна аллокация буфера в начале - долгий и печальный ввод-вывод, ограниченный а) алгоритмикой и б) пропускной способностью железа - одна деаллокация буфера в конце.
Соответственно, на фоне затяжного IO, аллокации занимают проценты (в самом плохом случае) времени.
Очевидно же, правда?
С базами данных (реляционными), на 80% состоящих из этого самого IO, картина другая. Там просто один раз аллоцируются разделяемые (shared) буферы функцией mmap. И движок базы потом в этих буферах сам разруливает. Как арены в игростроительстве. Иногда совсем умные люди даже PGA делают. Для сортировок и прочего.
Действительно ли с базами всё так и приватных аллокаций вообще нет?
Не совсем.
Приватные аллокации в БД выполняют процессы базы. И еще - аллокации приватных страниц выполняют курсоры. Открывающиеся и закрывающиеся.
Отсюда этот видимый (и измеримый) эффект ускорения - чего? - SELECT. Обратите внимание - не DML, лишь в некоторой степени SELECT FOR UPDATE, не DDL с DCL. Не аггрегирующие запросы с чудовищными полными сканами и сортировками.
А стеной идущие относительно мелкие - селекты.
И эффект там значимый. В разы. С одновременным снижением утилизации CPU. Примерно этак вдвое.
Вот что имели в виду бородатые ДБА, которые туманно называли это "снижением оверхэда".
Что же касается влияния супераллокатора на CPU bound и, несколько в меньшей степени, Memory bound, то это уже другая история.
Которая будет рассмотрена позднее.
That's all, folks! 😉
TheCacheWorks pinned «Почему самый лучший аллокатор не ускоряет IO? TL;DR: Потому, что не может. Очевидное объяснение. Даже самый отбитый и отмороженный программист (исключая, возможно, сорокапятилетнего вкатуна - таксиста) не будет писать ввод-вывод с аллокациями-деаллокациями…»
Камень Всех Камней
Самые аллоцирующие задачи - это CPU Bound.
Не обязательно чисто вычислительные - хотя и они тоже. Любая обработка данных, упирающаяся в частотность ядер.
Зачастую, до прочтения кода или сбора статистики (именно malloc/calloc/realloc/free, не mmap/munmap) никто и не задумывается, как много аллокаций в секунду выполняют такие задачи.
До 40% (а иногда и больше) работы в CPU Bound - это именно аллокации в куче.
Чтобы перекрыть такой оверхед, мало аллоцировать быстро. Нужно аллоцировать с постоянной скоростью, что, как все понимают, труднодостижимо. Дополнительным бременем являются системы с оверкоммитом - это просто какая-то болезнь модная, причем чрезвычайно заразная. На запрос malloc() немедленно возвращается указатель без каких-либо гарантий того, что память действительно выделена. Реальная аллокация происходит лишь после прикосновения к этой памяти, причем не просто прикосновения - это должна быть операция записи. Например, инициализация (что, кстати, достаточно глупо само по себе - mmap() с некоторых пор гарантирует аллокацию инициализированной памяти). То есть, нужно выполнять двойную работу, по факту. Ну или получить указатель в никуда, нежданчиком, начав работать с этой, якобы аллоцированной, памятью. Со всеми отсюда вытекающими последствиями.
Таким образом, оверкоммит (включенный по умолчанию) такое же неизбывное зло, как и THP. Ну, если, конечно, вы не считаете нормальным внезапные падения и рестарт приложений или внезапно же начинающийся на ровном месте свопинг (с отключенным свопом, ха-ха. И как мы раньше-то жили, без оверкоммита и с включенными свопами, трудно представить, правда же?).
Возвращаясь к CPU Bound.
Откуда там аллокации-то? По сути - отовсюду. Элементарный printf - это аллокация. Минимум одна. fprintf - аллокация (да, там можно переназначить буфер на стек - если кто не знает; и подавить аллокацию в куче). Крестовый cout - это очень много аллокаций. Чувствуете, чем пахнут логи? Локальный контейнер в функции (любой контейнер, даже array) - аллокация на входе - хорошо, если одна, если он не ресайзится - и деаллокация на выходе. Хорошо, если разработчик знает о методе reserve(). И использует его. Тогда аллокации можно слегка уменьшить. Но кто, положа руку на сердце, станет на это загоняться? Ну и так далее. Не смотрите вглубь - большое количество даже библиотечных функций являются неявно аллоцирующими (неявно - потому что - кто вообще будет читать их исходники?).
Кастомные аллокаторы принципиально не решают проблемы, будучи приложенными точечно. Потому что все остальные процессы рядом тупят - эффект шумных соседей. И - нет, микросервисы кардинально проблемы не решают. Это просто заметание проблемы под ковер. И - нет, серверлесс тоже не решает проблемы, потому что это маркетинговый термин, а код исполняется не на Великом Небесном Сервере, где-то в варпе, а на вполне реальном железе внутри вполне реальной ОС.
Как известно, под ковер никто не заглядывает.
Чтобы как-то значимо решить данную проблему, не переписывая весь код строка за строкой, нужна одна простая вещь.
Нужно глобально заменить системный аллокатор (и прочие велосипеды) на нечто более лучшее, но тоже общего назначения (это важно; кастомы глобально проблему не снимут и сколько-нибудь заметно ее не облегчат). Для этого это нечто нужно сперва разработать - и, как мы все понимаем, ничего сколько-нибудь высококачественного на гитхаб никто в здравом уме не выложит.
Со всем вышесказанным хорошо знакомы игроделы - и никто более, потому что всем остальным на уровень ниже фич глубоко начхать. Нет ни времени ни желания этим заниматься и даже просто задумываться.
Что ж, кто не хочет кормить своих специалистов - кормит чужих. И эксперты по оптимизации не совсем даром кушают свой хлеб.
Если, конечно, кто-то догадается их спросить - кто виноват и что делать.
Самые аллоцирующие задачи - это CPU Bound.
Не обязательно чисто вычислительные - хотя и они тоже. Любая обработка данных, упирающаяся в частотность ядер.
Зачастую, до прочтения кода или сбора статистики (именно malloc/calloc/realloc/free, не mmap/munmap) никто и не задумывается, как много аллокаций в секунду выполняют такие задачи.
До 40% (а иногда и больше) работы в CPU Bound - это именно аллокации в куче.
Чтобы перекрыть такой оверхед, мало аллоцировать быстро. Нужно аллоцировать с постоянной скоростью, что, как все понимают, труднодостижимо. Дополнительным бременем являются системы с оверкоммитом - это просто какая-то болезнь модная, причем чрезвычайно заразная. На запрос malloc() немедленно возвращается указатель без каких-либо гарантий того, что память действительно выделена. Реальная аллокация происходит лишь после прикосновения к этой памяти, причем не просто прикосновения - это должна быть операция записи. Например, инициализация (что, кстати, достаточно глупо само по себе - mmap() с некоторых пор гарантирует аллокацию инициализированной памяти). То есть, нужно выполнять двойную работу, по факту. Ну или получить указатель в никуда, нежданчиком, начав работать с этой, якобы аллоцированной, памятью. Со всеми отсюда вытекающими последствиями.
Таким образом, оверкоммит (включенный по умолчанию) такое же неизбывное зло, как и THP. Ну, если, конечно, вы не считаете нормальным внезапные падения и рестарт приложений или внезапно же начинающийся на ровном месте свопинг (с отключенным свопом, ха-ха. И как мы раньше-то жили, без оверкоммита и с включенными свопами, трудно представить, правда же?).
Возвращаясь к CPU Bound.
Откуда там аллокации-то? По сути - отовсюду. Элементарный printf - это аллокация. Минимум одна. fprintf - аллокация (да, там можно переназначить буфер на стек - если кто не знает; и подавить аллокацию в куче). Крестовый cout - это очень много аллокаций. Чувствуете, чем пахнут логи? Локальный контейнер в функции (любой контейнер, даже array) - аллокация на входе - хорошо, если одна, если он не ресайзится - и деаллокация на выходе. Хорошо, если разработчик знает о методе reserve(). И использует его. Тогда аллокации можно слегка уменьшить. Но кто, положа руку на сердце, станет на это загоняться? Ну и так далее. Не смотрите вглубь - большое количество даже библиотечных функций являются неявно аллоцирующими (неявно - потому что - кто вообще будет читать их исходники?).
Кастомные аллокаторы принципиально не решают проблемы, будучи приложенными точечно. Потому что все остальные процессы рядом тупят - эффект шумных соседей. И - нет, микросервисы кардинально проблемы не решают. Это просто заметание проблемы под ковер. И - нет, серверлесс тоже не решает проблемы, потому что это маркетинговый термин, а код исполняется не на Великом Небесном Сервере, где-то в варпе, а на вполне реальном железе внутри вполне реальной ОС.
Как известно, под ковер никто не заглядывает.
Чтобы как-то значимо решить данную проблему, не переписывая весь код строка за строкой, нужна одна простая вещь.
Нужно глобально заменить системный аллокатор (и прочие велосипеды) на нечто более лучшее, но тоже общего назначения (это важно; кастомы глобально проблему не снимут и сколько-нибудь заметно ее не облегчат). Для этого это нечто нужно сперва разработать - и, как мы все понимаем, ничего сколько-нибудь высококачественного на гитхаб никто в здравом уме не выложит.
Со всем вышесказанным хорошо знакомы игроделы - и никто более, потому что всем остальным на уровень ниже фич глубоко начхать. Нет ни времени ни желания этим заниматься и даже просто задумываться.
Что ж, кто не хочет кормить своих специалистов - кормит чужих. И эксперты по оптимизации не совсем даром кушают свой хлеб.
Если, конечно, кто-то догадается их спросить - кто виноват и что делать.
Memory Bound - все сложно
По определению, Memory Bound - производительность ограничивается скоростью доступа к памяти, а не частотностью процессорных ядер.
В эту категорию могут попадать задачи с высокой пространственной сложностью - а могут и не попадать. Все зависит от характера алгоритма.
Типичный Memory Bound - данные постоянно перемещаются в память и из памяти, при этом процессор находится в состоянии Idle в ожидании этих самых данных.
Как ни странно, вычисления на GPU, казалось бы, ограниченные объемом видеопамяти, Memory Bound не являются. Если посмотреть вглубь, память там выделяется огромными (и, зачастую, shared) кусками, внутри которых и оперируют процессорные ядра. Однако, внешние по отношению к GPU алгоритмы, которые закачивают данные в видеопамять, могут стать Memory Bound - в силу дисбаланса скоростей и по иным причинам.
Важный момент - Memory Bound может стать любая задача в случае внешней или внутренней фрагментации памяти (здравствуй, Линус, твоя поделка по дефолту дьявольски фрагментирует память - но это же ваш собственный выбор, верно?).
Фрагментация памяти вообще страшная вещь. Причем эффект был замечен еще в доисторические времена, на буферных пулах традиционных реляционных БД - при его раздувании сверх разумных пределов эффективность кэширования и скорость резко падала, причем мало кто понимал, почему и продолжал его увеличивать.
Частые мелкие или многопоточные аллокации могут являться ограничивающим фактором Memory Bound - в силу конкуренции за блокировки (привет, системные аллокаторы на мьютексах), кэш-промахов из-за плохой локальности, TLB-misses. Пропускная способность шины памяти также может являться ограничивающим фактором - и тут мы во всей красе встречаем такие факторы, как CAS-latency, а также, здравствуй, NUMA - не в том узле лежат данные.
В целом, по статистике, порядка 40% истинных Memory Bound задач зависят от качества аллокатора. И сколько-нибудь качественных среди известных как-то не наблюдается.
Можно ли что-нибудь втереть или вправить или еще что-нибудь?
Можно несколько снизить остроту проблемы, если:
- минимизировать число аллокаций (здравствуй, метод reserve()!)
- бороться с фрагментацией доступными методами (прочь, THP; долой чрезмерное выделение огромных кусков памяти - да, огромные shared сегменты БД фрагментируются изнутри, сюрприз, и это сжирает весь потенциальный выигрыш), фрагментацию нужно учитывать, хотите вы этого или нет
- NUMA уважайте до опасения, может привести к прямо противоположному эффекту
Если все это не так, вас не спасет ни DDR5 ни DDR10.
Резюмируя вышесказанное, как и во все времена, все определяется качеством алгоритмов. Не методика ХХП - а вдумчивое и тщательное кодирование, тщательная настройка и оптимизация операционного окружения, сбалансированное железо - вот ключ к быстрой и эффективной обработке данных.
По определению, Memory Bound - производительность ограничивается скоростью доступа к памяти, а не частотностью процессорных ядер.
В эту категорию могут попадать задачи с высокой пространственной сложностью - а могут и не попадать. Все зависит от характера алгоритма.
Типичный Memory Bound - данные постоянно перемещаются в память и из памяти, при этом процессор находится в состоянии Idle в ожидании этих самых данных.
Как ни странно, вычисления на GPU, казалось бы, ограниченные объемом видеопамяти, Memory Bound не являются. Если посмотреть вглубь, память там выделяется огромными (и, зачастую, shared) кусками, внутри которых и оперируют процессорные ядра. Однако, внешние по отношению к GPU алгоритмы, которые закачивают данные в видеопамять, могут стать Memory Bound - в силу дисбаланса скоростей и по иным причинам.
Важный момент - Memory Bound может стать любая задача в случае внешней или внутренней фрагментации памяти (здравствуй, Линус, твоя поделка по дефолту дьявольски фрагментирует память - но это же ваш собственный выбор, верно?).
Фрагментация памяти вообще страшная вещь. Причем эффект был замечен еще в доисторические времена, на буферных пулах традиционных реляционных БД - при его раздувании сверх разумных пределов эффективность кэширования и скорость резко падала, причем мало кто понимал, почему и продолжал его увеличивать.
Частые мелкие или многопоточные аллокации могут являться ограничивающим фактором Memory Bound - в силу конкуренции за блокировки (привет, системные аллокаторы на мьютексах), кэш-промахов из-за плохой локальности, TLB-misses. Пропускная способность шины памяти также может являться ограничивающим фактором - и тут мы во всей красе встречаем такие факторы, как CAS-latency, а также, здравствуй, NUMA - не в том узле лежат данные.
В целом, по статистике, порядка 40% истинных Memory Bound задач зависят от качества аллокатора. И сколько-нибудь качественных среди известных как-то не наблюдается.
Можно ли что-нибудь втереть или вправить или еще что-нибудь?
Можно несколько снизить остроту проблемы, если:
- минимизировать число аллокаций (здравствуй, метод reserve()!)
- бороться с фрагментацией доступными методами (прочь, THP; долой чрезмерное выделение огромных кусков памяти - да, огромные shared сегменты БД фрагментируются изнутри, сюрприз, и это сжирает весь потенциальный выигрыш), фрагментацию нужно учитывать, хотите вы этого или нет
- NUMA уважайте до опасения, может привести к прямо противоположному эффекту
Если все это не так, вас не спасет ни DDR5 ни DDR10.
Резюмируя вышесказанное, как и во все времена, все определяется качеством алгоритмов. Не методика ХХП - а вдумчивое и тщательное кодирование, тщательная настройка и оптимизация операционного окружения, сбалансированное железо - вот ключ к быстрой и эффективной обработке данных.
Закон Протекающих Абстракций, часть 1
Всеобщая тенденция заменять шарящих на дешевых почему-то постоянно разбивается о Закон Протекающих Абстракций.
Возьмем, к примеру, комитет по стандартизации - хоть Си, хоть С++.
С одной стороны, идет перманентная движуха по наворачиванию всевозможных высокоуровневых штуковин. Для примера, хотя бы такая база, как reference.
Хотели убежать от указателей, да? Константная ссылка на инициализированный объект? Задумано-то неплохо, безопасность, тодасё. Однако - под капотом все тот же сырой указатель, и тут же сверху куча оберток
Другой пример - выравнивание. Откуда растут ноги? Ноги растут от аппаратной архитектуры. Процессор и его внутренние структуры оперируют машинным словом. А оперативная память адресуется побайтно. И тут начинаются дикие пляски с побайтным чтением из памяти, границей слова и прочим. С++17 наворачивает супервыравнивание в аллокациях new. Затем выясняется, что в многоядерниках с доступом к shared data в кэше надо бы выравнивать по размеру кэш-линии L1 процессора (вы правда думаете, что все на свете CPU имеют кэш-линию 64 бита?), в противном случае false sharing и трындец производительности в горячих местах в многопотоке. SIMD стоит отдельно и размахивает огромным красным знаменем.
Блестяще, мало того, что надо загоняться в выравнивание - его весьма часто надо по размеру кэш-линии выполнять. Комитет умнейшие ребята,
Выравнивание вообще отдельная печальная песня. Каждый раз, тыкаясь в память по указателю, на низком уровне надо проверять - выравнены ли данные? Это неслабый такой оверхэд. Спросите - на кой? Ну, как минимум, скорость доступа к невыровненным данным хуже. Как максимум, можно вообще словить UB на многих архитектурах.
В полном соответствии с Законом Протекающих Абстракций, комитет принимает assume aligned.
Ну, то есть, программист, как и в случае с кастами, говорит дословно - "Мамой клянусь, данные выровнены! Не генерируй проверку на выравнивание при доступах!".
Ладно, молчим про текущие абстракции, смотрим - реализовано? Да, реализовано, и давно - суперзелено!
Проверяем - GCC 15.1. Проверочного макроса нет. Вот вообще нет. От слова "совсем". Да, есть интринсик - причем ему сто лет в обед. Да, старые компиляторы интринсик поддерживают, понимают, и генерируют более эффективный ассемблер.
И - оппа! - свежие компиляторы мало того, что не содержат якобы реализованной функциональности. Они еще и интринсик молча игнорируют.
Как же так, Карл? Это же С++, ЯВУ, все низкоуровневые вещи должны быть давно попрятаны в подвал, нейроразнообразные должны фичи пилить, а не о железе и ассемблере думать. Компиляторы умные, они давно должны всё сами-сами-сами делать. Префетчи, restricted reference, assume aligned...
Что? Не выходит Каменный Цветок, Данила-мастер?
Не выходит. Либо думать над кодом и смотреть, что генерируется.
Либо застраивать планету датацентрами с тысячами серверов, исполняющих писанину на питоне и Go.
Всеобщая тенденция заменять шарящих на дешевых почему-то постоянно разбивается о Закон Протекающих Абстракций.
Возьмем, к примеру, комитет по стандартизации - хоть Си, хоть С++.
С одной стороны, идет перманентная движуха по наворачиванию всевозможных высокоуровневых штуковин. Для примера, хотя бы такая база, как reference.
Хотели убежать от указателей, да? Константная ссылка на инициализированный объект? Задумано-то неплохо, безопасность, тодасё. Однако - под капотом все тот же сырой указатель, и тут же сверху куча оберток
std::ref, std::data и прочее и прочее. Да, объект обязан быть инициализированным. Потому что доступ через разадресацию указателя происходит. И что дальше? А дальше то, что на практике оберток оказывается недостаточно и в целой куче случаев надо кастовать - снова в указатель. Уродливо, отвратительно, многословно, небезопасно. Крестовые касты, имеющие несовместимый с сишными ABI. Оно того стоило, а?Другой пример - выравнивание. Откуда растут ноги? Ноги растут от аппаратной архитектуры. Процессор и его внутренние структуры оперируют машинным словом. А оперативная память адресуется побайтно. И тут начинаются дикие пляски с побайтным чтением из памяти, границей слова и прочим. С++17 наворачивает супервыравнивание в аллокациях new. Затем выясняется, что в многоядерниках с доступом к shared data в кэше надо бы выравнивать по размеру кэш-линии L1 процессора (вы правда думаете, что все на свете CPU имеют кэш-линию 64 бита?), в противном случае false sharing и трындец производительности в горячих местах в многопотоке. SIMD стоит отдельно и размахивает огромным красным знаменем.
Блестяще, мало того, что надо загоняться в выравнивание - его весьма часто надо по размеру кэш-линии выполнять. Комитет умнейшие ребята,
std::hardware_destructive_interference_size, компилятор вам скажет, какая кэш-линия. Документация утверждает, что это давно реализовано, зелено, суперзелено, есть проверочный макрос... шта? А компиляторщики сказали - "Э, а как мы определим размер кэш-линии камня, если cpuid есть только у x86? И то не у всех?" (знаете, как кроссплатформенно определить размер кэш-линии L1? Если ни процессор, ни компилятор ничего об этом не говорят? Долго и печально, нехилой такой функцией, измеряющей скорости доступа по массиву данных) Но мы отвлеклись.Выравнивание вообще отдельная печальная песня. Каждый раз, тыкаясь в память по указателю, на низком уровне надо проверять - выравнены ли данные? Это неслабый такой оверхэд. Спросите - на кой? Ну, как минимум, скорость доступа к невыровненным данным хуже. Как максимум, можно вообще словить UB на многих архитектурах.
В полном соответствии с Законом Протекающих Абстракций, комитет принимает assume aligned.
Ну, то есть, программист, как и в случае с кастами, говорит дословно - "Мамой клянусь, данные выровнены! Не генерируй проверку на выравнивание при доступах!".
Ладно, молчим про текущие абстракции, смотрим - реализовано? Да, реализовано, и давно - суперзелено!
Проверяем - GCC 15.1. Проверочного макроса нет. Вот вообще нет. От слова "совсем". Да, есть интринсик - причем ему сто лет в обед. Да, старые компиляторы интринсик поддерживают, понимают, и генерируют более эффективный ассемблер.
И - оппа! - свежие компиляторы мало того, что не содержат якобы реализованной функциональности. Они еще и интринсик молча игнорируют.
Как же так, Карл? Это же С++, ЯВУ, все низкоуровневые вещи должны быть давно попрятаны в подвал, нейроразнообразные должны фичи пилить, а не о железе и ассемблере думать. Компиляторы умные, они давно должны всё сами-сами-сами делать. Префетчи, restricted reference, assume aligned...
Что? Не выходит Каменный Цветок, Данила-мастер?
Не выходит. Либо думать над кодом и смотреть, что генерируется.
Либо застраивать планету датацентрами с тысячами серверов, исполняющих писанину на питоне и Go.
Закон Протекающих Абстракций, часть 2
Оставим в покое компиляторы и компиляторщиков с их придурями.
Поговорим о памяти и фрагментации.
Если вспомнить о LBA, SSD и файловых системах - кто мешает сделать индексную адресацию последовательности байт физической памяти? И скрыть детали физической реализации, скажем, за сбалансированным B-деревом, как в базах данных? В которых путь от вершины до любого из листьев занимает O(1), а еще можно сделать преобразование адресов и на верхнем уровне представлять непрерывную последовательность (на кой это надо мы обсудим чуть позже).
Да, в общем-то, никто не мешает. Кроме двух вещей. Это медленно (подумайте о скоростях работы процессора и вообразите, что вы в самом горячем месте вертите деревья и пересчитываете адреса байт) и печально, а еще - дорого. Треть, а то и половину памяти займут - что? - метаданные. Их же надо где-то хранить, верно?
Даже в нынешние расточительные времена, когда в сервер можно влупить 24 Тб оперативы, оттяпать даже треть памяти под метаданные представляется безумием.
Вы скажете - окей, а как же виртуальная памяти? Она же, типа, транслирует виртуальные адреса в физические, ну и пусть заодно деревья вертит.
Не-а. У виртуальной памяти другое назначение. Она транслирует виртуальные адреса на совокупность адресов физической памяти плюс пространство подкачки. Это нужно для вытесняющей многозадачности, ну и, попутно, дефрагментация памяти через подкачку выполняется.
Что? Вы считаете подкачку ненужной и выключили ее? Поздравления, вы только что резко усилили фрагментацию, и, мало того, приблизили момент OOM и/или кернел паник.
Память на системном уровне выделяется страницами. Непрерывными последовательностями байт. Адресная арифметика. От которой не убежать. Почему? Потому, что процессор это машина Тьюринга, по большому счету (прости, Алан Мэтисонович). Оперирующая простейшими операциями. Нет, оперативная память это не хранилище с произвольным доступом (см.выше). На самом деле, на нижнем уровне мы оперируем указателями на байты и последовательности байт. Инкремент-декремент адресов и вот это вот всё. Накладывается на это тот факт, что мы оперируем буферами и файлами в буферах. С адресной арифметикой (вы правда надеялись избавиться от переполнения буфера и выхода за границы массива как концепции? Оп-па, абстракция снова потекла по усам Раста). Более-менее произвольный доступ по факту у нас лишь в списочных и графовых структурах из толпы указателей.
Соответственно, аллоцируя память, мы ищем непрерывные фрагменты нужного нам размера. И, на практике, очень скоро случается так, что суммарно свободных байт полно - но это пространство все нарезано мелкими кусочками. И мы, на очередной malloc внезапно получаем OOM. А то и вовсе кернел паник.
Технически, операционная система может выкрутиться из такой ситуации, сбросив в подкачку наиболее старые страницы и освободив место. Если у нее есть такая возможность.
Но, согласно современной моде, нет у нее такой возможности. Пусть падают сервисы, пусть хоть вся ось упадет. У нас есть кубер. Мы не дадим работать операционной системе так, как задумано; нам лучше знать, как должно быть. Плевать на оверинжиниринг, вообще на всё плевать. Бизнес заплатит, Боливару не снести двоих. Кого вообще волнует такая ерунда, как аптайм?
Собственно, натуралам хорошо известны бест-практисы, позволяющие избежать такой лютой дичи, как OOM Killer - и где! в ОС, позиционируемой как промышленная! Они просты, как три рубля: пространство подкачки (не файл, а раздел) обязано быть, иметь в размере не менее одного объема физической памяти, сколько бы этой физической памяти не было (помните? Дефрагментация).
Подкачка, к сожалению, не решает всех проблем, связанных с фрагментацией. Она снижает нарастание фрагментации со временем, но не предотвращает полностью. Более того, чем больше памяти на борту, тем быстрее она фрагментируется. ОС стремится освобожденные страницы памяти вернуть ядру, в общий фрагментированный пул свободной памяти. Мы же хотим RSS как можно меньше, верно?
Оставим в покое компиляторы и компиляторщиков с их придурями.
Поговорим о памяти и фрагментации.
Если вспомнить о LBA, SSD и файловых системах - кто мешает сделать индексную адресацию последовательности байт физической памяти? И скрыть детали физической реализации, скажем, за сбалансированным B-деревом, как в базах данных? В которых путь от вершины до любого из листьев занимает O(1), а еще можно сделать преобразование адресов и на верхнем уровне представлять непрерывную последовательность (на кой это надо мы обсудим чуть позже).
Да, в общем-то, никто не мешает. Кроме двух вещей. Это медленно (подумайте о скоростях работы процессора и вообразите, что вы в самом горячем месте вертите деревья и пересчитываете адреса байт) и печально, а еще - дорого. Треть, а то и половину памяти займут - что? - метаданные. Их же надо где-то хранить, верно?
Даже в нынешние расточительные времена, когда в сервер можно влупить 24 Тб оперативы, оттяпать даже треть памяти под метаданные представляется безумием.
Вы скажете - окей, а как же виртуальная памяти? Она же, типа, транслирует виртуальные адреса в физические, ну и пусть заодно деревья вертит.
Не-а. У виртуальной памяти другое назначение. Она транслирует виртуальные адреса на совокупность адресов физической памяти плюс пространство подкачки. Это нужно для вытесняющей многозадачности, ну и, попутно, дефрагментация памяти через подкачку выполняется.
Что? Вы считаете подкачку ненужной и выключили ее? Поздравления, вы только что резко усилили фрагментацию, и, мало того, приблизили момент OOM и/или кернел паник.
Память на системном уровне выделяется страницами. Непрерывными последовательностями байт. Адресная арифметика. От которой не убежать. Почему? Потому, что процессор это машина Тьюринга, по большому счету (прости, Алан Мэтисонович). Оперирующая простейшими операциями. Нет, оперативная память это не хранилище с произвольным доступом (см.выше). На самом деле, на нижнем уровне мы оперируем указателями на байты и последовательности байт. Инкремент-декремент адресов и вот это вот всё. Накладывается на это тот факт, что мы оперируем буферами и файлами в буферах. С адресной арифметикой (вы правда надеялись избавиться от переполнения буфера и выхода за границы массива как концепции? Оп-па, абстракция снова потекла по усам Раста). Более-менее произвольный доступ по факту у нас лишь в списочных и графовых структурах из толпы указателей.
Соответственно, аллоцируя память, мы ищем непрерывные фрагменты нужного нам размера. И, на практике, очень скоро случается так, что суммарно свободных байт полно - но это пространство все нарезано мелкими кусочками. И мы, на очередной malloc внезапно получаем OOM. А то и вовсе кернел паник.
Технически, операционная система может выкрутиться из такой ситуации, сбросив в подкачку наиболее старые страницы и освободив место. Если у нее есть такая возможность.
Но, согласно современной моде, нет у нее такой возможности. Пусть падают сервисы, пусть хоть вся ось упадет. У нас есть кубер. Мы не дадим работать операционной системе так, как задумано; нам лучше знать, как должно быть. Плевать на оверинжиниринг, вообще на всё плевать. Бизнес заплатит, Боливару не снести двоих. Кого вообще волнует такая ерунда, как аптайм?
Собственно, натуралам хорошо известны бест-практисы, позволяющие избежать такой лютой дичи, как OOM Killer - и где! в ОС, позиционируемой как промышленная! Они просты, как три рубля: пространство подкачки (не файл, а раздел) обязано быть, иметь в размере не менее одного объема физической памяти, сколько бы этой физической памяти не было (помните? Дефрагментация).
Подкачка, к сожалению, не решает всех проблем, связанных с фрагментацией. Она снижает нарастание фрагментации со временем, но не предотвращает полностью. Более того, чем больше памяти на борту, тем быстрее она фрагментируется. ОС стремится освобожденные страницы памяти вернуть ядру, в общий фрагментированный пул свободной памяти. Мы же хотим RSS как можно меньше, верно?
Закон Протекающих Абстракций, часть 3
Как ни странно, одним из корней зла является такая штука, как realloc.
Если вы думаете, что вас это не касается - попробуйте реализовать хоть какой-нибудь контейнер без реаллокаций. Realloc мало того, что одна из самых опасных функций libC. Она еще и одна из самых фрагментирующих. И медленных (нет, mremap не панацея. У нее куча оговорок и ограничений и она платформенно-специфична. Нет, оптимизировать memcpy дьявольски сложно и сделать лучше отцов-основателей никому не удавалось. Да, даже с учетом SIMD).
Что дальше-то? А ничего. Новомодные и не очень ЯП чхать хотели на текущие абстракции памяти - купите чумадан плашек. Вас сильно припекло? Напишите свою STL. Как фейсбук или Электроник Артс. Если сможете.
Можно попробовать написать свой аллокатор общего назначения. Что лишь кажется простенькой задачкой. Потому что он должен быть именно глобальным, и охватывать все краевые случаи. В идеале иметь сложность O(1) - мы же не хотим ждать неведомо сколько, пока операционка отхрюкает и найдет нам участок памяти нужного размера; и - нет, оверкоммит тут не помощник - и околонулевую фрагментацию.
Однако других вариантов просто нет. Игнорировать текущие абстракции вам не даст операционное окружение, а бьют они весьма больно. И - нет, замести это в контейнеры, свалить с больной головы на здоровую в serverless тоже не получится.
Ну, если, разумеется, вас не устраивает рестарт инжиниринг (а, помните, были времена, когда аптаймами мерялись?).
Менять сложившиеся абсолютно ублюдочные парадигмы разработки, разумеется, ради умников никто не станет. Равно как и переписывать халявные ОС, компиляторы и ЯП (хорошая попытка, Раст и Go, но нет). А равно как и тратить время на оптимизации кода - преждевременная оптимизация корень всех зол, а потом уже поздняк метадзе. Да и сисадмины толковые больше не нужны, всё есть код, нам лучше знать, с чего начинать.
Отсюда вытекает не менее ублюдочное понятие современной оптимизации производительности - завалим процессорными ядрами, чемоданами плашек памяти - а там запинаем. Вот и вся оптимизация. ДЦ размером с футбольные поля, гигаватты электричества. Производители лопат пьют до синевы за здоровье программистов.
А всего-то начинали с протекающих абстракций, которые было сравнительно легко купировать грамотной парадигмой и реализациями.
PS. Возможно, кого-то сильно удивит - однако даже микрооптимизации обладают синергическим эффектом. Если ими не пренебрегать, они в совокупности достаточно часто дают значимый эффект. Хотя бы в виде снижения утилизации CPU. Протекающими абстракциями довольно часто можно воспользоваться к всеобщей выгоде.
Как ни странно, одним из корней зла является такая штука, как realloc.
Если вы думаете, что вас это не касается - попробуйте реализовать хоть какой-нибудь контейнер без реаллокаций. Realloc мало того, что одна из самых опасных функций libC. Она еще и одна из самых фрагментирующих. И медленных (нет, mremap не панацея. У нее куча оговорок и ограничений и она платформенно-специфична. Нет, оптимизировать memcpy дьявольски сложно и сделать лучше отцов-основателей никому не удавалось. Да, даже с учетом SIMD).
Что дальше-то? А ничего. Новомодные и не очень ЯП чхать хотели на текущие абстракции памяти - купите чумадан плашек. Вас сильно припекло? Напишите свою STL. Как фейсбук или Электроник Артс. Если сможете.
Можно попробовать написать свой аллокатор общего назначения. Что лишь кажется простенькой задачкой. Потому что он должен быть именно глобальным, и охватывать все краевые случаи. В идеале иметь сложность O(1) - мы же не хотим ждать неведомо сколько, пока операционка отхрюкает и найдет нам участок памяти нужного размера; и - нет, оверкоммит тут не помощник - и околонулевую фрагментацию.
Однако других вариантов просто нет. Игнорировать текущие абстракции вам не даст операционное окружение, а бьют они весьма больно. И - нет, замести это в контейнеры, свалить с больной головы на здоровую в serverless тоже не получится.
Ну, если, разумеется, вас не устраивает рестарт инжиниринг (а, помните, были времена, когда аптаймами мерялись?).
Менять сложившиеся абсолютно ублюдочные парадигмы разработки, разумеется, ради умников никто не станет. Равно как и переписывать халявные ОС, компиляторы и ЯП (хорошая попытка, Раст и Go, но нет). А равно как и тратить время на оптимизации кода - преждевременная оптимизация корень всех зол, а потом уже поздняк метадзе. Да и сисадмины толковые больше не нужны, всё есть код, нам лучше знать, с чего начинать.
Отсюда вытекает не менее ублюдочное понятие современной оптимизации производительности - завалим процессорными ядрами, чемоданами плашек памяти - а там запинаем. Вот и вся оптимизация. ДЦ размером с футбольные поля, гигаватты электричества. Производители лопат пьют до синевы за здоровье программистов.
А всего-то начинали с протекающих абстракций, которые было сравнительно легко купировать грамотной парадигмой и реализациями.
PS. Возможно, кого-то сильно удивит - однако даже микрооптимизации обладают синергическим эффектом. Если ими не пренебрегать, они в совокупности достаточно часто дают значимый эффект. Хотя бы в виде снижения утилизации CPU. Протекающими абстракциями довольно часто можно воспользоваться к всеобщей выгоде.
Не мешай. Машине. Работать.
Как вы думаете, зачем деды придумали упорядочивать структуры по убыванию размеров мемберов? Чтобы ухудшить читаемость и усложнить жизнь потомкам?
Нет. Такое неочевидное выравнивание (это может показаться глупым современным программистам) позволяет за счет минимизации паддинга уменьшить размер структуры. Так как структуры, как правило, являются элементами контейнеров - зачастую, нешуточных - это уменьшает потребность в памяти. Отсюда же растут ноги у прагмы упаковки структуры.
Как и во всех случаях, дилемма "либо память - либо скорость" справедлива не только в отношении древних одноядерников, но и - в особенности - в случае многоядерных современных процессоров.
Есть такая штука, как кэш-линия L1. И - нет, она далеко не на всех процессорах имеет размер 8 байт.
Флаг блокировки (мы, разумеется, говорим про атомики) может оказаться в одной кэш-линии с другим shared мембером структуры. Что, в случае тредов, может запросто привести к такой неприятной и малоизвестной вещи, как false sharing. Которая, как следствие, при высокой конкурентности может привести к резкому росту внутрипроцессорного трафика и падению производительности.
Лечится это не просто, а относительно просто. Надо всего лишь выровнять флаг блокировки на размер кэш-линии процессора (успехов в его определении; там свои заморочки), после чего он окажется один на кэш-линию (с паддингом) и проблема false sharing будет практически решена.
И обратите внимание вот на что. Выравнивание самой структуры != выравниванию всех ее полей по кэш-линиям. Они могут быть выровнены - в зависимости от размеров-битности сборки-кэш-линии. А могут и не быть.
Однако, у всего есть своя цена. Итоговый размер структуры несколько вырастет. Может ощутимо вырасти. С соответствующими последствиями. Так что это trade off, который, кстати говоря, касается не только флагов блокировки, но и других членов структуры.
Ситуация усложняется с использованием мьютексов. Вы, без сомнения, в курсе, что
С учетом вышесказанного, делать мьютекс частью структуры - идея так себе. Да, и false sharing. И выравнивание субструктуры проблемное.
По этой причине, если вас хоть сколько-нибудь волнует производительность, мьютексы логично вынести за пределы каких бы то ни было структур и сделать глобальными. При возможности. В противном случае, к органическим проблемам мьютексов добавятся вышеописанные эффекты. Которые, конечно, игнорируются программистами, что и приводит, как следствие, к заваливанию проблем кода железом и котлетами денег.
Финализируя вышесказанное, читаемость кода - это не критерий его качества. Конечно, возможность понимания даже людьми, обученными по пошаговым туториалам, дорогого стоит. Однако, возможно, им просто следовало избрать другую профессию?
Все же код, прежде всего, должен быть хорош для того, кто его исполняет.
Да и комментарии уже минимум полстолетия как изобрели.
Как вы думаете, зачем деды придумали упорядочивать структуры по убыванию размеров мемберов? Чтобы ухудшить читаемость и усложнить жизнь потомкам?
Нет. Такое неочевидное выравнивание (это может показаться глупым современным программистам) позволяет за счет минимизации паддинга уменьшить размер структуры. Так как структуры, как правило, являются элементами контейнеров - зачастую, нешуточных - это уменьшает потребность в памяти. Отсюда же растут ноги у прагмы упаковки структуры.
Как и во всех случаях, дилемма "либо память - либо скорость" справедлива не только в отношении древних одноядерников, но и - в особенности - в случае многоядерных современных процессоров.
Есть такая штука, как кэш-линия 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++).
Это нормальное поведение (ОС, вообще говоря, в приоритете имеет несколько иные вещи, чем сборка мусора), которое, однако не описывается жирным шрифтом в руководствах (которых, разумеется, никто не читает). Причем мы говорим про вообще любые ОС, кроме, может быть ОС РВ. И это не баг, не недосмотр - это нормальное документированное поведение.
В этой очереди освобожденные блоки могут оставаться достаточно долго, и блок доступен.
Вы можете сказать "Ха! И еще раз - ха! Умные указатели уже изобрели!"
Да, их изобрели. Однако - надо читать стандарт и реализации внимательно -
Заметьте вот что.
Первое - такие вещи чрезвычайно трудно отловить. Статический анализ их почти не способен выловить (кроме самых очевидных случаев). Отловить их (в некоторых случаях) способен кастомный аллокатор, не имеющий очередей и немедленно, честно освобождающий блоки. На уровне своих метаданных. Он, по крайней мере, посредством кордампа, вам просигналит, что вот это вот приложение имеет в себе опасный баг (а вы его в прод затащили, просто потому, что - да откуда бы вам знать, что там UAF? Код, что ли, читать? Еще чего, пусть миллион мух напрягается. Да и не особо поможет чтение, справедливости ради).
Второе - раст не спасёт. Под ним все тот же рантайм Си, а под ним ядро ОС. Что происходит вне раста - не проблема раста.
Третье - языки со сборщиками мусора это хорошо, конечно. Если написано без ошибок. Но адски медленно. Так как закон Мура почил в бозе, нам уже не настолько все равно на производительность кода.
Резюме неутешительно. "ТщательнЕй надо, ребята. ТщательнЕй!"
Данный класс ошибок - это безопасность. Прямая дорога к зеродэям. Их нельзя просто проигнорировать и пусть падает, кубернетес под перезапустит.
А для начала, об этом просто надо знать.
PS. Практический опыт показывает, что приложений с такими багами - релизных, в продакшене - почти 30-40%. Причем среди них находится, в частности, такой рекордсмен сетапов, как OpenSSL/LibreSSL. Вот теперь можете начинать бояться.
Манера удалять не глядя 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.
Проблема на уровне программирования осложняется тем фактом, что отличить логические "ядра" от реальных физических весьма непросто -
Отдельная печаль - это поядерное лицензирование софта. Хотя логические ядра != физическим, очень многие вендоры с этим не согласны и на голубом глазу выставляют лицензии по числу логических ядер.
Очень показателен пример процессора SPARC M8. Заявленная тактовая частота которого составляет 4,5 ГГц, имеющего 32 физических ядра по 8 аппаратных тредов на ядро, что в сумме составляет впечатляющие, на первый взгляд, 256 ядер на сокет. Увы и ах - реальная скорость этих логических ядер совсем не равна частоте процессора, она много ниже. А скорость тредовых приложений падает просто драматически при выполнении в таком режиме работы CPU.
Однако наивные пользователи свято верят, что имеют 256 ядер. А потом горько плачут, когда реальная производительность на этих ядрах оказывается просто удручающей.
Опыт показывает, что в подавляющем большинстве серверных приложений HT/SMT не просто не нужны, а откровенно вредят. И их надо отключать, если вы хотите получить хоть сколько-нибудь приемлемую лейтенси. Если это отключение вообще возможно (подсказка - совсем не всегда).
А специалистов по производительности просто никто не удосужился спросить и хоть как-то принять во внимание их мнение.
Так и получилось, что в высшей степени сомнительные в своей эффективности решения на десятилетия заняли совершенно неподобающее место в мозгах и серверных.
Тот факт, что логические ядра совсем не эквивалент физическим и что 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%.
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. И имена разработчиков не будут полоскать в связи с нецензурными эпитетами.
Как вам, должно быть, известно, в оптимизации производительности мелочей нет.
Даже микрооптимизации способны давать синергический эффект, если на них не забивать.
В частности, это касается такой, казалось бы, обыденной вещи, как фичафлаги второ- и третьестепенных функций. Которые 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).
Это хеллоуворлд.
Ноги у этой системной закономерности растут из фундаментальных ограничений фон-неймановской архитектуры. И о ней необходимо помнить каждый раз, когда вы снова и снова будете хотеть от алгоритмов невозможного.
Если вы имеете хоть какое-то отношение к 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" - на деле оборачивается размазанными тонким слоем по всей кодовой базе капканами для производительности.
Рассмотрим простенький пример из реальной кодовой базы.
Вот хорошая, красивая, читаемая и понятная маленькая функция. Во всех отношениях прекрасная и способная пройти ревью у любого сеньора. Если не принимать во внимание контекст ее выполнения.
Вызывается она в горячем цикле, каждый раз конструируя несчастный bool. Что тут такого-то, пусть сервер пашет, он железянный. Она же работает.
Однако, стоит лишь ее переписать:
как цикл, ее вызывающий, начинает выполняться в полтора раза быстрее. Казалось бы, какой-то несчастный bool, один байт, однако hot path.
И таких мест в коммерческом коде можно найти немерено. Обычно на такие неочевидные рефакторинги просто закрывают глаза, их даже в рамках вялотекущей борьбы с техдолгом не рассматривают.
Не говоря уже о таких совершенно неочевидных вещах, как скрытые вызовы конструкторов копирования. О которых мало, что никто не задумывается, так большинство еще и не догадывается об их существовании.
Есть и значительно более устрашающие примеры, когда мы видим в горячей функции локальный контейнер, с неочевидными аллокациями и реаллокациями, когда скорость выполнения, как в трясину, уходит в поведение нижележащей ОС и ее вызовов, и проблема скорости выполнения разрастается как снежный ком, поскольку начинает определяться не столько кодом и его семантикой, сколько текущим состоянием операционной среды. Что приводит к совершенно неожиданным эффектам в рантайме, даже при выполнении целенаправленных оптимизаций.
Смысл всего этого заключается в том, что парадигма features first порочна по своей сути. Не то, чтобы не следовало гнаться, теряя тапки, за TTM. Кушать очень хочется. Суть в том, что, программируя, стоит, вообще говоря, в голове держать семантику того, что пишешь.
То есть непрерывно представлять в уме, что вот там, под капотом, будет выполняться. И как оно будет выполняться. И в каком окружении будет выполняться. И крайне желательно четко себе представлять, как это окружение работает в процессе выполнения.
Из вот таких вот мелочей в тысячах мелких функций, из всех этих типа микрооптимизаций, в финале зачастую складывается оптимизация вполне себе не микро. Синергический эффект имеет место быть.
Вывод из вышесказанного, возможно, уже набил оскомину и до смерти надоел, однако некоторые вещи никогда не меняются: при программировании желательно мыслить семантически. То есть прямо в уме, при написании кода, прокручивать тот машинный код, который будет генерироваться. И - да, иметь сисадминский бэкграунд - то есть неплохо себе представлять, что происходит снаружи кода в процессе его выполнения.
Для девопсов - которые наполовину программисты, наполовину сисадмины - ну, так предполагается, во всяком случае - это вообще не должно представлять никакой проблемы.
...при подходе к ̶Ж̶и̶в̶ч̶и̶к̶у̶ 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. Они не то, чтобы добавляют ума предсказателю переходов, а с учетом руками определенной приоритетности переходов влияют на формирование ассемблерных блоков таким образом, чтобы джампы по наиболее вероятному пути происходили реже, а последовательность команд оставалась линейной в окрестности ветвления.
Программирование на сравнительно высоком уровне - и даже программирование на Си - это уровень абстракции, который весьма успешно скрывает слой, находящийся под ним - ассемблер.
Уже много лет ассемблер поминают исключительно в саркастическом ключе - и совершенно напрасно.
При разработке ПО, которое хоть сколько-нибудь критично по скорости, писать на ассемблере непосредственно, может быть, и избыточно.
Но вот читать и понимать ассемблер все же необходимо.
Несмотря на всю навороченность современных процессоров, нужно учитывать тот факт, что внизу, на уровне исполнения, нет никаких сколько-нибудь высокоуровневых конструкций.
В ассемблере нет никаких for, while и даже if как таковых.
Есть лишь кучка команд копирований, перемещений, сдвигов, test и кучка условных джампов и безусловный джамп. Ну и набор простейших арифметических и логических операций, вызовы подпрограмм и возврат из них (что можно рассматривать как разновидность джампа, осложненного необходимостью сохранять и восстанавливать стек).
При помощи всего этого, с разной степенью эффективности, реализуются высокоуровневые программные конструкции, которые выглядят как циклы, ветвления, и так далее.
Ассемблерный код, генерируемый компиляторами, таким образом, представляет собой по большей части набор блоков разного размера, с меткой в начале и условным или безусловным джампом на метку в конце. Иногда еще джампы есть в середине. И весь поток выполнения, в основном, представляет последовательность таких блоков, по цепочке передающих управление друг другу.
Блок, как правило, предвыбирается в конвейер команд и этот конвейер полностью или частично очищается когда выполнение доходит до джампа в другой блок - так как теперь текущая последовательность команд неактуальна и надо предвыбрать ту последовательность, в которую передано управление.
Картина сильно упрощена, но в целом примерно так и происходит выполнение программного кода.
Идеальный, с точки зрения скорости выполнения процессором, вариант - когда ассемблерные блоки относительно крупные, джампов в них мало, идут они через конвейер команд в основном последовательно, без хаотических скачков из блока в блок.
По этой причине так эффективен инлайнинг и его так активно стараются использовать компиляторы на сколько-нибудь высоких уровнях оптимизации.
Дабы, по возможности, избежать дорогостоящего сброса конвейера предвыборки команд, процессоры обычно имеют предсказатель переходов, используемый для того, чтобы заранее, для экономии процессорного времени, начать предвыбирать новый блок команд, куда, по мнению процессора, сейчас будет осуществлен переход потока выполнения.
Если повезло и процессор на основе эвристик угадал, куда надо перейти - все хорошо, все идеально; происходит плавный переход в новый блок команд. Если не повезло - произошел branch misprediction - конвейер команд надо очистить, и загрузить совсем другой блок, в другой ветви.
Это страшно дорого и ужасно больно. В hot path падение производительности может составить от 30% (это обычный минимум) вплоть до десятичного порядка или более - если branch misprediction происходит часто.
Не следует переоценивать "интеллект" предсказателя переходов. В процессоре физически нет места для сколько-нибудь сложной логики предсказания. Грубо, вероятность правильно угадать нужную ветвь в реальном коде в среднем составляет 50% - то ли угадал, то ли нет.
В некоторой степени подкорректировать ситуацию позволяют хинты expect. Они не то, чтобы добавляют ума предсказателю переходов, а с учетом руками определенной приоритетности переходов влияют на формирование ассемблерных блоков таким образом, чтобы джампы по наиболее вероятному пути происходили реже, а последовательность команд оставалась линейной в окрестности ветвления.
Здесь живут драконы, часть 2: Причуды компиляторов - герои меча и магии
Существует распространенное - и ошибочное - мнение, что компиляторы умнее программистов. И выполнят все необходимые оптимизации сами, достаточно лишь писать чистый код.
К огромному сожалению, это не так. Более того, код, выглядящий чисто, как правило, в значительной степени неэффективен.
Мало кто заглядывает под капот - на уровень ассемблера; еще меньше людей, тратящих время на бенчмаркинг.
Результат предсказуем - падение производительности в разы, никак не компенсируемое мощностями железа.
Рассмотрим пример из реального кода.
Ранний выход это отдельная стратегия, дискуссионная сама по себе. Довольно часто работает, в данном конкретном случае нет. Компилятор дробит относительно крупный блок кода на несколько мелких частей, появляется полдюжины джампов.
Так как код - критическая секция, да еще и hot path, общая скорость работы всей программы падает в среднем на 30% по результатам бенчмарка.
Если просто поменять условие раннего выхода на противоположное:
то производительность возвращается. Кодогенерация изменилась, branch misprediction снизился.
Если оставить в стороне споры "ранний выход vs цепочка if" (при том, что в соседнем методе, с меньшим числом строк и сложностью, ранний выход ускоряет работу), следствие довольно очевидное - хотя здесь налицо противоречие всем бест практисам и code style - нужно оставлять новый вариант и подкрепить его комментарием - почему здесь написано именно так.
Есть и еще один пример, когда фиктивный, вычисляемый на этапе компиляции, if в hot path позволил избежать двукратного общего падения производительности в сравнении с настоящим if в рантайме. Этот пример мы не будем рассматривать, так как он требует масштабных объяснений вне формата поста.
Даже из этих единичных примеров можно сделать вывод, что чрезмерное увеличение цикломатической сложности (читай - обвешивение кода ветвлениями) производительности не способствует.
Совсем не от хорошей жизни в STL стараются как можно больше сделать посредством constexpr. И это при том, что средний if транслируется всего в 4-5 ассемблерных команд.
Неочевидное следствие - хотя бы критические hot path следует писать в стиле Кармака, после исследования кодогенерации и бенчмарков. В особенности при кроссплатформенной разработке.
Потраченное время может окупиться приличным приростом производительности.
Существует распространенное - и ошибочное - мнение, что компиляторы умнее программистов. И выполнят все необходимые оптимизации сами, достаточно лишь писать чистый код.
К огромному сожалению, это не так. Более того, код, выглядящий чисто, как правило, в значительной степени неэффективен.
Мало кто заглядывает под капот - на уровень ассемблера; еще меньше людей, тратящих время на бенчмаркинг.
Результат предсказуем - падение производительности в разы, никак не компенсируемое мощностями железа.
Рассмотрим пример из реального кода.
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 следует писать в стиле Кармака, после исследования кодогенерации и бенчмарков. В особенности при кроссплатформенной разработке.
Потраченное время может окупиться приличным приростом производительности.