Я одержим оптимизацией производительности и максимальным повышением эффективности программ. За многие годы я сталкивался с конкретными случаями и распространёнными паттернами, замедляющими работу ПО или компьютеров. В этом посте я расскажу о некоторых из них.
Я назвал пост Surprisingly Slow потому, что замедление было для меня неожиданным, или неоптимальные практики, ведущие к замедлению, настолько распространены, что многие программисты будут удивлены их существованию.
Разделы поста чаще всего никак не связаны друг с другом, поэтому можете выбирать самые интересные для вас.
Распознавание среды в системах сборки (например, configure и cmake)
Именно эта тема вдохновила меня на создание поста.
Системы сборки перед этапом сборки часто имеют этап распознавания среды / конфигурирования. В мире UNIX преобладают сгенерированные autoconf скрипты configure
. Также популярен CMake. Эти инструменты запускают код для проверки состояния текущей системы, чтобы конфигурация сборки подходила для текущей среды сборки. Например, они проверяют, какой компилятор использовать, его версию, баги и возможности.
Такое распознавание среды и конфигурирование являются необходимым злом, потому что машины и среды часто сильно различаются и эти различия нужно учитывать.
Проблема в том, что этот этап конфигурирования часто занимает больше времени, чем сама сборка! Системы сборки в случае мелких программ или библиотек часто тратят по десять с лишним секунд на выполнение configure
, а сама компиляция и компоновка выполняются за малую долю от этого времени. Другими словами, подготовка к сборке занимает больше времени, чем сама сборка!
Заметность этого расхождения зависит от количества ядер ЦП. Но в моей основной машине стоит 16-ядерный/32-потоковый Ryzen 5950X, и мне мучительно наблюдать за относительной медленностью этапа конфигурирования.
Но ещё более шокирующим мне кажется то, что время конфигурирования часто намного превышает время сборки даже для крупных проектов. Не знаю, справедливо ли это сегодня, но несколько лет назад Mozilla заметила, что сборка LLVM/Clang на инстансе с 96 vCPU EC2 требовала больше времени на cmake/конфигурирование, чем на компиляцию и компоновку! А ведь это очень крупный проект на C++ с тысячами файлов исходного кода!
Конфигурирование сборки часто является отдельным этапом, выполняемым последовательно перед тем, что большинство людей считает настоящей сборкой. Для повышения эффективности конфигурирование сборки необходимо распараллелить. Ещё лучше было бы, если бы она интегрировалась в сам основной DAG сборки, чтобы можно было начать выполнять части сборки без необходимости ожидания конфигурирования сборки. К сожалению, многие популярные инструменты конфигурирования сборки нельзя с лёгкостью адаптировать к этой модели. Поэтому многие из нас почти ничего не могут с этим поделать.
Ещё одно решение этой проблемы — полное устранение проблемы распознавания среды. Если вы работаете в детерминированных и воспроизводимых средах сборки, то можно срезать углы, чтобы пропустить распознавание среды, которое вам больше не нужно. Примерно такой подход используют современные инструменты сборки наподобие Bazel. Мне любопытно, насколько ускоряется сборка в инструментах типа Bazel благодаря устранению этапа конфигурирования среды. Подозреваю, что сильно!
Лишнее время на новый процесс в Windows
В Windows новые процессы не могут создаваться так же быстро, как в операционных системах на основе POSIX, например в Linux. Стоит ожидать, что в Windows создание нового процесса займёт 10-30 мс. В Linux создание новых процессов (часто посредством fork()
+ exec()
) занимает максимум единицы миллисекунд.
Однако создание потоков в Windows выполняется очень быстро (порядка десятков микросекунд).
Подробнее об этом можно прочитать в темах на Stack Overflow: первая и вторая.
Несколько десятков миллисекунд в контексте ЦП — целая бесконечность. И это достаточно много для задач, которые воспринимаются людьми как мгновенные. Возможно, в том числе из-за этого кажется, что Windows медленнее Linux.
Если архитектура вашей программы состоит из постоянного создания новых процессов (что часто встречается в мире UNIX), то в Windows это может создавать проблемы с производительностью, так как лишнее время на создание нового процесса в Windows может сильно масштабироваться:
- 10 мс * 1000 вызовов = 10 с
- 20 мс * 10000 вызовов = 200 с
- 30 мс * 100000 вызовов = 3000 с
Возьмём для примера файлы configure из предыдущего раздела поста, которые часто бывают скриптами оболочки. А скрипты оболочки часто выполняют свою работу, создавая другие процессы, например, grep
, sed
и sort
. Даже оператор [
может быть новым процессом (серьёзно: в вашем POSIX-окружении, вероятно, есть исполняемый файл /usr/bin/[
). (Хотя [
может быть встроен в оболочку.) Цепочки конвейеров команд (например, command | grep | awk
) последовательно создают несколько процессов и их выполнение может казаться медленным. Cкрипт конфигурации может создавать тысячи новых процессов. Если предположить, что на каждый тратится по 10 мс, то при 1000 вызовов лишь на новые процессы будет потрачено 10 с лишнего времени! Это усугубляет проблему, описанную в предыдущем разделе!
Если ваше ПО работает в Windows, то оцените эффект, который оказывает относительно медленное создание процессов. Подумайте над альтернативами: многопоточной архитектурой и использованием долгоживущих демонов/фоновых процессов.
Закрытие дескрипторов файлов в Windows
Много лет назад я профилировал Mercurial, чтобы повысить скорость контрольной проверки папок в Windows, потому что пользователи замечали, что время проверки в Windows было гораздо выше, чем в Linux, даже на одной и той же машине.
Я думал, что можно свести это к разнице между файловыми системами NTFS и Linux или эффективности на общем уровне ядра/ОС. Но на самом деле я выяснил нечто гораздо более удивительное.
Когда я начал профилировать Mercurial в Windows, то заметил, что большинство API ввода-вывода выполняет свою работу за несколько десятков микросекунд, иногда за одну-две миллисекунды. Производительность Windows/NTFS казалась отличной!
За исключением CloseHandle()
. Для выполнения этих вызовов часто требовалось 1-10 и более миллисекунд. Мне казалось странным, что запись в файлы (даже непрерывная запись, которой было достаточно для выхода за пределы объёмов любой буферизации) оказывалась быстрой, но закрытие медленным. Ещё больше поражало, что CloseHandle()
был медленным, даже при использовании портов завершения (т.е. асинхронного ввода-вывода). Такое поведение портов завершения противоречило тому, что должно происходить по документации MSDN (функция должна мгновенно выполнять возврат, а её состояние можно получать позже).
Хотя тогда я этого не понимал, но причиной такого поведения был/является Windows Defender. Windows Defender (и другое антивирусное/сканирующее ПО) обычно при своей работе в Windows устанавливает нечто под названием «драйвер фильтра файловой системы» (filesystem filter driver). Это драйвер ядра, который, по сути, подключается к ядру и получает обратные вызовы событий ввода-вывода и файловой системы. Оказалось, что обратный вызов закрытия файла запускает сканирование записанных данных. И это сканирование выполняется синхронно, не позволяя CloseHandle()
выполнить возврат. Это добавляет миллисекунды лишнего времени. В сумме скорость ввода-вывода изменения файлов в Windows значительно снижается Windows Defender и другими антивирусными сканерами.
Насколько я понимаю, если запущен Windows Defender (и, предположительно, другие антивирусные сканеры), невозможно обеспечить устойчиво высокую скорость API ввода-вывода Windows. Можно отключить антивирусное сканирование (на свой страх и риск). Но сложность в том, что Mercurial использует (в дальнейшем это эмулируется rustup и другими инструментами) его, чтобы использовать пул потоков для вызова CloseHandle()
. Даже если вы выполняете все операции ввода-вывода открытия и записи файлов в одном потоке и используете фоновый пул потоков только для вызова CloseHandle()
, то заметите трёхкратное ускорение записи файлов.
В идеале эту оптимизацию должно использовать любое ПО, создающее или изменяющее даже всего несколько сотен файлов в Windows. В список такого ПО входят инструменты контроля версий, установщики и инструменты распаковки архивов. Забавный факт: rustup
может распаковывать файлы tar в Windows быстрее, чем опенсорсные и коммерческие быстрые инструменты распаковки/копирования, потому что использует этот и другие трюки. Мне кажется, rustup
в Windows на самом деле быстрее распаковывает архивы tar, чем в Linux!
Искусственная задержка ввода-вывода, добавляемая сканирующим ПО наподобие Windows Defender, очень раздражает. Однако рост производительности благодаря обходу этой проблемы при помощи фонового пула потоков часто оправдывает его сложность. Я не сомневаюсь, что будь эта оптимизация включена в популярные инструменты Windows (а именно установщики), то люди были бы поражены, насколько быстро всё может работать.
Запись в терминалы
Как мейнтейнер системы сборки Firefox, я получил десятки отчётов от людей, жалующихся на то, что у них сборки происходят медленнее, чем у коллег на таком же оборудовании. Хотя для этого может быть много причин, одной из самых неожиданных является влияние терминала на скорость сборки.
Запись в терминал обычно выполняется быстро. Но бывают исключения.
Я выяснил, что запись кучи выходных данных или усложнение записи в терминал (например, запись цветов, перемещение позиции курсора для перезаписи предыдущего контента) может значительно замедлить приложения.
Запись в терминал при помощи stderr/stdout с большой вероятностью выполняется с блокированием ввода-вывода. Поэтому если код, управляющий вашим write()
(эмулятором терминала) не завершает обработку вовремя, процесс просто ждёт, пока терминал выполнит свою задачу.
Мы выяснили, что разные терминалы обладают собственными особенностями. Исторически, командная строка Windows и встроенное в macOS приложение Terminal.app очень медленно обрабатывали большой объём выводимых данных. Я помню (хотя не могу найти найти этот баг или коммит в Firefox), что когда мы сделали систему сборки «немой» по умолчанию, в некоторых конфигурациях это снизило время сборки на минуты.
Несколько лет назад npm внедрил печально известный снижающий производительность индикатор прогресса. Хотя я не знаю, что было сильнее виновато, медленность терминала или слишком частый вызов кода обновления прогресса, терминал с большой вероятностью сыграл свою роль, потому что у терминалов есть ограничение на частоту принятия входящих данных для отрисовки.
Я обнаружил, что современные терминалы лучше справляются с записью кучи простого текста, чем в 2012 году, когда я решал эти проблемы в системе сборки Firefox. Но я всё равно бы проявлял максимальную осмотрительность со сложными фишками терминала, например, раскраской текста, отрисовкой колонтитулов и т.п. Всегда используйте буферизированный ввод-вывод для минимизации количества происходящих в терминале операций write()
, при необходимости выполняя сброс (по возможности в свободное время). Подумайте над использованием асинхронного потока для записи в stdout/stderr. Фиксируйте общее время, потраченное на блокировку ввода-вывода к stdout/stderr, чтобы можно было замерить задержку ввода-вывода терминала. И периодически при запуске программы сравнивайте дельту фактического времени между подключённым к терминалу stdout/stderr и /dev/null, чтобы увидеть, не слишком ли велико различие. Также можно подумать о регулировании записей в терминал. Вместо записи нижнего колонтитула после каждой строки вывода, попробуйте буферизировать строки в течение нескольких миллисекунд и одновременно выводить все линии плюс новый колонтитул. При отрисовке индикатора прогресса, вращающегося индикатора или чего-то подобного я бы ограничивал частоту отрисовки примерно 10 Гц, чтобы минимизировать трату лишнего времени в терминале.
Тепловой троттлинг/состояния ACPI C/P/троттлинг процессора
Мы привыкли думать, что компьютер и его процессоры или включены, или выключены, но если бы всё было так просто…
В процессе работы процессоры постоянно меняют рабочий диапазон. Все приведённые ниже заявления справедливы (хотя и не каждый пункт относится ко всем машинам или моделям ЦП):
- Количество МГц, на котором работает ядро ЦП, может сильно колебаться каждую секунду.
- Ядра ЦП могут уходить в сон или в режим с очень низким энергопотреблением, даже если остальные продолжают работать.
- При превышении порогового значения температуры ядра могут значительно снижать тактовую частоту. Они могут отказываться работать быстрее, прежде чем снизится температура. Неисправные датчики могут приводить к преждевременному срабатыванию защиты.
- Ядра могут достигать своей максимальной частоты, если работают и другие ядра. Может иметь значение физическая близость других ядер.
- Для разгона до полной скорости простаивающему ядру могут понадобиться десятки, сотни или даже тысячи миллисекунд.
- Процесс изменения мощности может очень сильно варьироваться в зависимости от того, подключена ли машина к внешнему источнику питания, или работает от аккумулятора.
- Изменение мощности может сильно варьироваться от того, заряжен ли аккумулятор полностью или почти разряжен.
- Ноутбуки Apple могут уходить в тепловой троттлинг, когда заряжаются с левой стороны. (Да, серьёзно: всегда заряжайте свой MacBook Pro справа. А если ваши сотрудники используют ноутбуки Apple для задач, активно занимающих ЦП, то сообщите им о необходимости зарядки справа. Или даже лучше — установите ПО, проверяющее, выполняется ли зарядка слева, и выдающее уведомление. Однако я пока не смог найти ПО или API, способное это распознавать.)
- Ядро может замедлять работу для обработки определённых команд (например, AVX-512).
Современные ЦП — очень динамичные устройства, и режим их работы часто кажется непредсказуемым. Более того, модели ЦП могут сильно отличаться друг от друга. Например, процессор EPYC или Xeon, скорее всего, будет вести себя иначе, чем Ryzen или Core i7/i9, которые тоже ведут себя по-разному в десктопах и ноутбуках. (Несколько лет назад я заметил, что ядра Xeon не так легко переходят в турбо-режим, как ЦП потребительского уровня.)
Колебания мощности и их влияние на производительность — одна из причин чрезвычайной сложности проведения точных бенчмарков. При выполнении бенчмарков необходимо отслеживать переменную мощности или, по крайней мере, сообщать о его состоянии, чтобы результаты квалифицировались соответствующим образом. Я очень скептично отношусь к результатам бенчмарков, не указывающих конфигурацию мощности и методологию его фиксации (к сожалению, таково большинство бенчмарков), и особенно к бенчмаркам, проводимым на ноутбуках, поскольку работающие от аккумуляторов устройства гораздо сильнее подвержены троттлингу мощности, чем десктопы или серверы.
Лично мой MacBook Pro уходил в тепловой троттлинг, потому что открутился внутренний винт и мешал раскрутке кулера. macOS не предупредила меня: я знал только то, что мои сборки Firefox без каких-то причин стали медленнее в два-три раза! Также я сталкивался с нагревом MacBook Pro из-за зарядки с левой стороны. Зарядка справа волшебным образом ускорила работу.
Когда мы начали выкатывать десктопы на Xeon для сотрудников в Mozilla, стали получать отчёты о сильно меняющихся скоростях сборки. В некоторых операционных системах (Mozilla имела очень небрежное централизованное управление машинами, позволяя сотрудникам полностью управлять переданным компанией оборудованием), по умолчанию состояния ACPI C/P были такими, что ядра ЦП масштабировались по-разному.
Мы заметили, что этап компиляции сборки был нормальным. Однако некоторые люди сообщали, что компоновка в 2-4 раза медленнее (от десятков секунд до минут), чем у других на аналогичных конфигурациях! Это стало большой проблемой, потому что фактическое время инкрементной/неполной сборки в основном тратится на компоновку. Со временем мы разобрались, что на медленных машинах занимающееся компоновкой ядро ЦП работало только на 25-50% от своего потенциала, то есть на 1,0-1,5 ГГц. Но если пользователь запускал дополнительные «тяжёлые» нагрузки на ЦП, частота ядра подскакивала. Мы выяснили, что у разных операционных систем используются разные стандартные значения для состояний ACPI C/P. При более консервативных настройках ядра ЦП не масштабируют свою частоту, если только нет достаточной нагрузки на ЦП. Переключение на более агрессивные параметры мощности обеспечило более качественные и стабильные результаты.
Ноутбуки сильно подвержены тепловому троттлингу и агрессивному троттлингу мощности для экономии заряда. Я считаю, что в целом ноутбуки слишком переменчивы, чтобы обеспечивать надёжную производительность. При наличии выбора я бы предпочёл, чтобы тяжёлые нагрузки на ЦП выполнялись в контролируемых и наблюдаемых десктопных или серверных средах.
Но и серверы не защищены полностью от этой проблемы: их параметры состояний ACPI C и P могут значительно влиять на производительность. Можно настроить их на максимум, чтобы все ядра работали на полную мощь (или были готовы к работе на полную через несколько миллисекунд). Однако это может сильно повысить энергопотребление. Это можно сделать у некоторых поставщиков облачных услуг (например, у AWS) без непосредственных затрат для вас. Однако повышение энергопотребления плохо для окружающей среды. Выбросы углекислого газа при использовании дата-центров уже равны объёмам выбросов авиаперевозок (до пандемии), и эти объёмы растут. Поэтому подумайте о своей ответственности, прежде чем настраивать серверы, потенциально увеличивая их мощность на мегаватты.
Запуск Python, Node.js, Ruby и других интерпретаторов
Сложные системы во время своей работы тысячи или более раз выполняют Python, Node.js и другие интерпретаторы. Например, система сборки Firefox вызывает тысячи процессов Python, выполняющих стандартные задачи, например, обёртывание и вызов компилятора. А средства тестирования Mercurial вызывают тысячи процессов Python, запуская по ходу тестирования hg
. Я слышал подобные истории о Node.js, Ruby и других интерпретаторах, часто в контексте использования в системах сборки.
При запуске нового процесса интерпретатора игнорируют тот факт, что для инициализации интерпретатора часто требуются миллисекунды или десятки миллисекунд, т.е. новый процесс тратит время в начале выполнения процесса просто на то, чтобы добраться до кода, который вы приказали ему выполнить. Иногда излишние траты ресурсов на новый процесс настолько велики, что торможение заметно и приводит к отказу от технологии. Исторически этим печально известен JVM, и поэтому использование Java обычно приводит к выполнению меньшего количества долгоживущих процессов вместо большего количества процессов с узкой областью действия.
Я уже писал ранее о лишнем времени на запуск Python. В 2014 году я замерил, что средства тестирования Mercurial тратят 10-18% от общего процессорного времени только на то, чтобы добраться до точки, где интерпретатор/процесс сможет выполнить байт-код, а 30-38% от общего процессорного времени — на то, чтобы добраться до точки, где Mercurial выполняет диспетчеризацию команд (дополнительное время здесь в основном тратится на импорт модулей).
Можно подумать, что несколько лишних потраченных миллисекунд не особо важны. Но если умножить это на 1000, 10000, 100000 или более, миллисекунды становятся важными:
- 1 мс * 1000 вызовов = 1 с
- 10 мс * 10000 вызовов = 100 с
- 100 мс * 100000 вызовов = 10000 с (2,77 часа)
В Windows эта проблема усугубляется относительно медленным запуском новых процессов (см. выше).
Программисты должны тщательно продумывать модель вызова процессов. Задумайтесь об использовании меньшего количества процессов и/или другого языка программирования, не тратящих лишних ресурсов, если это может стать проблемой (обычно подходит всё, что компилируется до уровня ассемблера).
Почти весь ввод-вывод накопителя
Особая страсть у меня есть к оптимизации ввода-вывода. Я считаю, что основная причина этого заключается в огромном разрыве между потенциалом современных устройств хранения и тем, что достигнуто на самом деле. Теоретически, ПО может получать примерно в 10 раз бОльшую производительность с современными устройствами хранения, чем мы видим обычно.
Современные устройства хранения до абсурда быстры. Накопитель NVMe в моём основном PC может обеспечивать скорость чтения больше 3 ГБ/с (больше 6 ГБ/с при последовательном чтении), записи около 1 ГБ/с (4 ГБ/с при последовательной записи), способен выполнять больше 500 тысяч операций ввода-вывода в секунду и обслуживать множество операций ввода-вывода в интервале задержки 10 микросекунд. Современные накопители NVMe с точки зрения пропускной способности находятся примерно на одном уровне с производительностью DDR2 DRAM (выпущенной в 2003 году) (задержка чуть больше, но порядок 10 мкс особой роли не играет).
Для сравнения: жёсткий диск Western Digital Caviar Black на 1 ТБ, извлечённый из моего PC несколько недель назад, может выполнять последовательное чтение и запись со скоростью всего 90 МБ/с, произвольное чтение и запись — со скоростью 1-2 МБ/с, и имеет время доступа порядка 12 мс. Не знаю точно, какой у него IOPS, но учитывая время доступа порядка 12 мс и физическую структуру вращающихся дисков, значение не может быть больше нескольких сотен.
Современный накопитель NVMe на 1,5-3 порядка быстрее лучших жёстких дисков, произведённых чуть больше десятка лет назад. Так почему же все операции ввода-вывода с накопителями не выполняются почти мгновенно?
Если вкратце, то большинство ПО не справляется с использованием потенциала современных устройств хранения, или даже хуже того — саботирует их плохими практиками.
О неиспользовании потенциала можно прочитать в превосходной статье Modern Storage is Plenty Fast. It is the APIs That are Bad [перевод на Хабре]. tl;dr статьи: можно воспользоваться полной мощью современного устройства хранения, обойдя стандартные примитивы ввода-вывода ОС/ядра и передавая операции ввода-вывода напрямую устройству. То есть программные абстракции ОС/ядра съедают большую часть потенциала.
Что касается ПО, саботирующего потенциал устройств хранения, то я вкратке расскажу о нём на примере POSIX-функции fsync()
. Вызывая эту функцию, вы, по сути, говорите: гарантируй сохранение этого дескриптора файла на устройстве хранения или я не хочу терять любые внесённые изменения.
Целостность и надёжность хранения данных важны. Но цена достижения этой цели может быть абсурдно высокой. И как оказывается, в её правильной реализации на практике есть множество мелких сложностей. Рекомендую прочитать превосходный пост Дэна Луу Files are Hard. Представленные в посте ссылки на статьи отрезвляют. Подкреплю посыл поста статьёй PostgreSQL's fsync() surprise, в которой приведена хроника того, как мейнтейнеры PostgreSQL выясняли, что Linux способен напрямую сбрасывать ошибки при выполнении ввода-вывода с устройством, что ведёт к повреждению данных. Ого!
Но вернёмся к fsync()
. Концепция fsync()
вполне чёткая: гарантировать то, что этот файл сохранён на устройство хранения. Однако в реализации часто встречается куча неэффективных решений, ведущих к торможению.
Во многих файловых системах Linux (в том числе и ext4) реализация fsync()
такова, что при вызовах все несброшенные на накопитель операции записи сохраняются на него. То есть если процесс A выполняет запись из файла на 1 ГБ, а процесс B записывает 1 байт в другой файл и вызывает fsync()
для этой операции записи единственного байта, Linux/ext4 обязан записать на устройство хранения 1 ГБ, а не 1 байт. То есть в Linux/ext4 любому процессу достаточно вызвать fsync()
, чтобы все элементы кэша грязной страницы обязаны были сброситься на накопитель. В большинстве систем обычно что-то непрерывно вызывает ввод-вывод на запись, поэтому объём ввода-вывода устройства хранения, вызванный fsync()
, почти всегда больше, чем объём сохраняемого изменённого файла/каталога.
Такое поведение может вызывать множество проблем. Во-первых, оно искусственно повышает задержку ввода-вывода. Разработчик рассчитывает, что вызов fsync()
после внесения минимальных изменений должен выполняться почти мгновенно. Однако если есть множество грязных страниц для сброса, операция может занимать несколько секунд. У моего нынешнего работодателя мы столкнулись именно с этой проблемой в GitHub Enterprise, имеющем монолитную архитектуру. База данных MySQL работала в той же файловой системе ext4, что и репозитории Git. MySQL часто вызывала fsync()
для обеспечения транзакций, а журнал транзакций сохранялся на накопитель. Но если был запущен сборщик мусора Git (GC), а Git только что закончил запись многогигабайтного pack-файла, команда fsync()
MySQL тормозила, ожидая, пока завершится сохранение на накопитель большого объёма данных Git. Это приводило к замедлению будущих транзакций MySQL и даже к таймаутам на уровне приложения. Когда люди говорят, что базы данных и другие хранилища должны изолироваться в отдельные разделы/файловые системы, одной из причин этого является неуклюжее поведение fsync()
.
К счастью, более новые версии Linux/ext4 имеют функцию быстрых коммитов, меняющую поведение и обеспечивающую более дробный сброс fsync()
на накопитель, как это и написано в документации. Но так как эта функция довольно свежая, для стабилизации и внедрения в дистрибутивы может понадобиться время. А я уже никак не могу этого дождаться!
Ещё одна проблема fsync()
заключается в том, что её вызывают гораздо чаще, чем следует. Да, если у вас есть критически важные данные, требующие целостности и надёжности хранения, следует вызывать fsync()
тогда, когда необходимо. Но в реальности многие нагрузки обработки данных и машинные среды не требуют абсолютных гарантий сохранности данных!
Для примера можно взять поды Kubernetes или раннеры CI. Или даже серверы для stateless-сервиса. Задайтесь вопросом: что самое плохое может случиться, если отключится питание машины и потеряются данные в локальной файловой системе? В большинстве случаев ответом будет ничего. Вы спроектировали свою систему как stateless и устойчивую к сбоям. Вы управляете серверами как cattle. Вы работаете с локальными файловыми системами как с временными устройствами. Поэтому если машина сбойнёт, вы создадите новую ей на замену. В таких сценариях fsync()
почти ничего не даёт вам, но многого стоит!
Затраты на вызовы fsync()
, без которых вполне можно обойтись, могут быть значительными. В сочетании с неэффективным поведением глобального сброса на накопитель в Linux/ext4 это может сильно снижать производительность, особенно при медленных устройствах хранения. К счастью, есть и другие варианты. У многих баз данных и другого популярного ПО есть способы обхода вызова fsync()
. Если ваши данные временные, то подумайте над тем, чтобы отключить fsync()
, скорее всего, вы получите значительный рост производительности! Для ПО, не поддерживающего отключение fsync()
, можно использовать инструмент eatmydata и библиотеку LD_PRELOAD, ослабляющие эффект fsync()
, а также схожую с ними функциональность, перехватывая вызовы функции и превращая их в no-op. И последнее: для временных машин можно собрать пропатченное ядро Linux, превращающее fsync()
и её коллег в no-op. (Не уверен, пользуется ли этим кто-нибудь, но рассматривал такую возможность, потому что внедрение eatmydata, например, в запущенные контейнеры — это мучительный процесс.)
Завершу этот раздел я ссылкой на свой любимый коммит в репозиторий Firefox: Disable Places during reftests, preventing 50 GB of I/O. Хотя этот коммит не только отключает fsync()
, вызовы fsync()
(и её аналогов в Windows) были виноваты в снижении производительности. Излишний ввод-вывод и ненужное сохранение изменений на устройство могут значительно снижать производительность. ПО накопителей обычно ошибается в сторону целостности (на мой взгляд, это правильное значение по умолчанию). Учитывая затраты, которые накладывает целостность, следует серьёзно поразмыслить над ослаблением гарантий и ускорением ввода-вывода, если этот вариант для вас приемлем.
Сжатие данных
На тему сжатия данных и его повсеместного неоптимального использования я могу написать целый пост. Здесь я приведу краткую версию.
По своей сути, сжатие данных — это компромисс между использованием ЦП и ввода-вывода. Обычно присутствует один из следующих сценариев:
- Узким местом является ввод-вывод (с накопителем или сетью), поэтому мы готовы потратить больше ресурсов ЦП для снижения объёмов ввода-вывода.
- В состоянии покоя накопитель затратен, поэтому мы готовы потратить больше ЦП для снижения использования/затрат накопителя.
С первых дней развития компьютеров накопители были медленными и дорогими по сравнению с ЦП. Поэтому обмен ресурсов ЦП на экономию использования накопителя казался хорошим компромиссом.
Перенесёмся в 2021 год.
Как я говорил в предыдущем разделе, ввод-вывод современных накопителей до абсурда быстр.
Сети тоже стали быстрее. На текущий момент фактически стандартом стал 1 Гбит/с (125 МБ/с). 2,5 Гбит/с (312 МБ/с) внедряются в пользовательских и офисных средах. 10 Гбит/с (1250 МБ/с) распространены в дата-центрах. И скорости выше 10 Гбит/с уже возможны.
Тем временем, в последнее десятилетие производительность одного ядра ЦП примерно находилась на плато. Мы на несколько лет застряли примерно на 4 ГГц. Весь рост производительности ЦП происходил благодаря добавлению в корпус большего количества ядер ЦП и повышения эффективности выполнения команд за такт (instructions per cycle, IPC) (из-за этой работы с IPC мы также получили ужасающие уязвимости безопасности наподобие Spectre и Meltdown).
Всё это означает, что относительная разница производительностей между ЦП и вводом-выводом сильно уменьшилась. Около 30 лет назад ЦП работал с частотой примерно 100 МГц, а Интернет работал по коммутируемому соединению, скажем 50 кбит/с, или 0,05 Мбит/с, или 6,25 кбод/с. Это составляет 16000 тактов на байт. Сегодня мы имеем примерно 4 ГГц и сети на 1 ГБит/с / 125 МБ/с. Это 32 такта на байт, коэффициент уменьшился в 500 раз. (Если по справедливости, он становится больше, учитывая наличие нескольких ядер ЦП, конкурирующих за ввод-вывод, и улучшение показателя IPC. Но мы всё равно говорим, что относительная разница между ЦП и вводом-выводом уменьшилась на 1-1,5 порядка.) Много лет назад обмен ресурсов ЦП на снижение нагрузки ввода-вывода часто был совершенно верным решением. Сегодня из-за повышения производительности ввода-вывода относительно ЦП и значительно снизившегося соотношения тактов к байту ввода-вывода всё далеко не так однозначно.
Не способствует ясности и преобладание древних алгоритмов сжатия. Алгоритм DEFLATE, используемый в вездесущей библиотеке zlib и формате данных gzip, был придуман примерно 30 лет назад. DEFLATE был спроектирован в эпоху, когда у компьютеров был 1 МБ ОЗУ и жёсткие диски на 100 МБ. В другие времена.
DEFLATE/zlib стали очень популярными в мире, где ввод-вывод был гораздо медленнее, а сжатие часто являлось необходимостью. Если не использовать сжатие в при подключении через модем, то разница в производительности будет существенной! А из-за их популярности в первые дни Интернета, DEFLATE/zlib имеются в стандартной библиотеке многих языков программирования. Похоже, это первый формат сжатия, который используют люди, когда кто-то решает добавить сжатие.
Вездесущесть zlib хороша с точки зрения зависимостей: читать zlib/gzip могут все. Однако в случаях, когда ты контролируешь и считывающее, и записывающее устройство, использование zlib в 2021 году является халатностью, потому что его производительность отстаёт от современных решений. Современные библиотеки сжатия (мой фаворит — zstandard) могут обеспечивать значительно более высокие скорости сжатия и распаковки, имея более высокие показатели сжатия с большинством наборов данных. Подробности есть в моей статье 2017 года Better Compression with Zstandard. (Я подумывал вернуться к этому посту, потому что в последующих релизах zstandard было внедрено множество ускорений на 10 и более процентов, из-за чего он становится ещё привлекательнее.) Если вам не нужна вездесущесть zlib (например, вы контролируете и чтение, и запись), нет почти никаких причин выбирать zlib вместо чего-то более современного. По сравнению со zlib современные библиотеки сжатия наподобие zstandard ближе всего к волшебной палочке, которой можно прикоснуться к своему ПО, чтобы обеспечить бесплатный рост производительности.
Если вы используете компрессию (особенно zlib) для сжатия в реальном времени (отправки сжатых данных куда-нибудь, где они мгновенно будут распакованы), вам необходимо измерить линейную скорость систем сжатия и распаковки. А затем сравнить это с линейной скоростью передачи несжатых данных. Становится ли ввод-вывод узким местом в случае несжатой передачи? Если нет, требуются ли вам экономия полосы пропускания или ресурса ввода-вывода при помощи сжатия? Если нет, то зачем вообще использовать сжатие? Вы только что выяснили, что сжатие только искусственно замедляет ваше ПО без всяких причин! Учитывая то, что сжатию zlib часто не удаётся заполнить полностью канал в 1 Гбит/с, существует очень высокая вероятность того, что использование сжатия добавляет искусственное «бутылочное горлышко» на стороне ЦП!
Если вы используете сжатие (особенно zlib) для архивирования данных (хранения где-нибудь сжатых данных, где они время от времени будут распаковываться), вам необходимо измерить и сравнить коэффициенты сжатия и линейные скорости различных форматов сжатия и их параметров. Как и в ситуации со сжатием в реальном времени, если распаковка снижает линейную скорость по сравнению с несжатыми данными, то вы искусственно замедляете доступ к данным. Возможно, это оправданно экономией на объёме накопителей. Однако во многих случаях можно перейти на другую библиотеку сжатия и получить схожие или даже лучшие коэффициенты сжатия, достигнув при этом повышенных скоростей сжатия/распаковки. Кому не захочется бесплатно получить рост производительности и снижение затрат на накопители?
Одной из причин моей любви к zstandard стало то, что его можно настраивать в интервале от чего-то невероятно быстрого (скорости сжатия и распаковки в гигабайтах в секунду), до чего-то очень медленного по сжатию, но достигающего потрясающих коэффициентов сжатия с сохранением скоростей распаковки в гигабайтах в секунду. Это позволяет использовать один и тот же формат для совершенно разных случаев применения. Также можно динамически менять характеристики хранения данных. Например, изначально можно записывать данные с быстрой настройкой, чтобы устройство записи не было ограничено ЦП. А затем можно пакетно пересжимать данные с более агрессивными настройками, сильно уменьшая их в размере. Это совсем непохоже на zlib, интервал параметров сжатия которого находится в пределах от довольно медленного и не очень хороших коэффициентов сжатия до очень медленного и всё равно с не очень хорошими коэффициентами сжатия.
Когда знаешь, что искать, неэффективность, связанную с неоправданным использованием сжатия или неиспользованием современных библиотек сжатия, можно найти повсюду. Вот примеры частых операций моего повседневного рабочего процесса, узким местом которых стало использование медленных форматов сжатия. Ускорить их можно было бы использованием другого формата сжатия:
- Установка пакетов Apt (пакеты сжаты gzip). (Забавный факт: установка пакетов apt тоже подвержена описанному выше замедлению
fsync()
, потому что менеджер пакетов вызываетfsync()
не менее одного раза для каждого пакета.) - Установка пакетов Homebrew (пакеты сжаты gzip).
- Установка пакетов Python через pip (архивы исходников — это tarball-ы gzip, а wheels — это файлы zip со сжатием zlib).
- Push/pull образов Docker (слои внутри образов Docker сжаты gzip).
- Git (обмен данными по wire protocol и сохранение данных на диск используют zlib). (Когда я добавил в Mercurial поддержку zstandard, это снизило объём передаваемых данных от серверов на 89%, а использование ЦП на стороне серверов снизилось до 60%.)
В корпоративном мире существуют петабайтные хранилища данных, озёра данных, колизеи данных (честно говоря, отстал от жизни и не знаю, как они называются сейчас), хранящие данные в gzip. Вероятно, можно было бы сэкономить десятки терабайт, перейдя на что-нибудь вроде zstandard. При использовании LZMA (имеющего чрезвычайно медленные скорости распаковки) малы затраты на хранение, однако доступ к данным чрезвычайно медленен, из-за чего замедляется выполнение запросов данных. У меня не было возможности измерить, но подозреваю, что частично Hadoop и другие системы Big Data получили репутацию медленных из-за того, что их ЦП ограничен неоптимальным использованием сжатия.
По моему опыту, многие программисты не понимают компромиссов и нюансов сжатия и/или им не хватает знаний о существовании более современных и совершенных библиотек сжатия. Мнение общества таково: сжатие — это хорошо, используй сжатие [zlib]. Как и во многих аспектах программирования, в реальном мире есть множество тонкостей и нюансов. Динамика относительной мощности и затрат компонентов компьютера сдвинула маятник в сторону увеличения, а не экономии затрат при сжатии. Усугубляет ситуацию и то, что отрасль до сих пор активно использует тридцатилетний формат сжатия (DEFLATE/zlib), далёкий от идеала для современных компьютеров. Если вы займётесь измерениями, то найдёте множество ситуаций, в которых сжатие или не рекомендуется, или выиграет от использования более современной библиотеки сжатия (например, zstandard).
Двоичные файлы x86_64 в пакетах дистрибутивов Linux
В дистрибутивах Linux часто имеются заранее собранные двоичные файлы, устанавливаемые через программы для работы с пакетами (например, apt install
или yum install
).
Чтобы не усложнять и обеспечить максимальную совместимость, эти заранее собранные двоичные файлы собираются таким образом, чтобы запускаться на максимально возможном количестве компьютеров. В настоящее время многие дистрибутивы Linux (в том числе RHEL и Debian) имеют двоичную совместимость с первым процессором x86_64 — AMD K8, выпущенным в 2003 году. В этих процессорах появились современные наборы команд наподобие MMX, 3DNow!, SSE и SSE2.
Это означает, что по умолчанию двоичные файлы в составе многих дистрибутивов Linux не содержат команды из современных архитектур наборов команд (ISA). Никаких SSE4, AVX, AVX2 и подобного. (Строго говоря, двоичные файлы могут содержат более новые команды. Но с большой вероятностью они не будут находиться на путях выполнения кода по умолчанию и будет присутствовать код диспетчеризации во время выполнения для выбора их использования.)
Более того, компиляторы C/C++ (наподобие Clang и GCC) тоже по умолчанию в качестве целевой платформы используют уровень древней микроархитектуры x86_64 (отсюда и берутся параметры по умолчанию совместимости двоичных файлов дистрибутивов). Поэтому если вы компилируете собственный код и не указываете параметры типа -march
или -mtune
для изменения стандартных параметров целевых платформ, то скомпилированные двоичные файлы не будут использовать SSE4, AVX и т.п. Вы всё равно можете заставить своё приложение отправлять эти команды в динамических путях выполнения кода без переопределения -march
/-mtune
. Но для этого вам понадобится повышать сложность кода.
Из-за используемых по умолчанию параметров целевых микроархитектур в компиляторах и двоичных файлах дистрибутивов почти двадцать лет работы над ISA и повышением эффективности при помощи мощных ISA (например, сверлинейных векторизированных команд) остаются без дела. Меня раздражает, когда мои PR висят непросмотренными больше дня. А представьте, каково это — быть инженером AMD или Intel и знать, что для крупномасштабного внедрения твоей работы над ISA потребуются десятилетия!
Честно говоря, я не знаю точно, какую часть производительности мы теряем из-за этой обратной совместимости ISA. Это очень сильно будет зависеть от нагрузки. Но я не сомневаюсь, что есть очень крупные дата-центры, выполняющие требовательные к ЦП нагрузки, которые могли бы сильно повысить эффективность при помощи современных ISA. Если у вас работают тысячи серверов и нагрузка на ЦП обеспечивается не JIT-языком наподобие Java (JIT могут отправлять команды для машины, на которой запущены, потому что они компилируются точно в срок), то крайне рекомендую компилировать тяжёлые для ЦП пакеты (и их зависимости, конечно) из исходников, целевым для которых является уровень современной микроархитектуры, чтобы вы могли пользоваться преимуществами современных ISA. Но имейте в виду: использование современных ISA — это не «серебряная пуля»! Выполнение некоторых команд на самом деле может привести к снижению частоты ЦП, из-за чего использующий эти команды код может стать быстрым, а остальной код — медленным.
Поддержание совместимости двоичных файлов с исчезающе малым количеством древних ЦП ценой производительности на современных ЦП кажется… спорным решением. К счастью, дистрибутивы Linux и Clang/GCC обращают на это внимание.
GCC 11 и Clang 12 определяют уровни архитектуры x86_64-{v2, v3, v4}
, целевыми платформами для которых являются Nehalem (выпущена в 2008 году), Haswell (выпущена в 2013 году) и AVX-512 (примерно 2015 год). То есть можно добавить -march=x86_64-v3
, чтобы целевыми архитектурами стали процессоры с эпохи Haswell и далее, а компилятор создавал SSE4, AVX, AVX2 и другие современных команды.
RHEL9 повысит минимальные требования к архитектуре с x86_64
до x86_64-v2
, то есть, по сути, будет требовать ЦП от 2008 года и более новые, а не от 2003 года.
Если вы хотите подробнее узнать об этой теме, начните с этой статьи Phoronix и переходите по ссылкам на другие статьи и обсуждения в списках рассылки.
Стоит заметить, что на момент написания статьи EC2-инстансы четвёртого поколения AWS (c4
, m4
, and r4
) поддерживают AVX2 и, кажется, совместимы с целевой архитектурой x86_64-v3
GCC/Clang. А инстансы Intel пятого поколения имеют AVX-512, что теоретически делает их совместимыми с x86_64-v4
. Поэтому даже если дистрибутив использует в качестве целевой платформы x86_64-v2
, всё равно есть потенциал бесплатной производительности более новых ISA.
Если бы я управлял набором серверов, состоящим из тысяч машин, у меня было бы сильное искушение скомпилировать все пакеты из исходников, выбрав в качестве целевого уровень современной микроархитектуры. Это было бы затратно с точки зрения сложности, однако при некоторых нагрузках рост производительности стоил бы усилий. А такая стратегия консервативного выбора целевой платформы может оправдать запуск оптимизированных под современность дистрибутивов Linux или дистрибутивов Linux поставщиков облачных (например, Amazon Linux). Не уверен, выиграют ли от этого дистрибутивы наподобие Amazon Linux. Если нет, то разработчикам стоит изучить эту возможность!
В следующем разделе есть пример того, как неиспользование современных ISA оборачивается снижением производительности.
Многие реализации Myers Diff и других строчных алгоритмов Diff
Это достаточно специфическая тема, но я нахожу её наглядным примером, потому что здесь поведение достаточно контринтуитивно!
Разным видам ПО необходимо получать два текстовых документа и создавать текстовый diff их содержимого. Допустим, вспомните, что отображает git diff
.
Для генерирования diff текста есть различные алгоритмы. Вероятно, самый знаменитый — это Myers Diff. Время выполнения алгоритмов пропорционально количеству строк. Скорее всего, O(nlog(n))
или O(n^2)
.
Эти текстовые алгоритмы поиска различий часто работают на уровне строк (а, допустим, не на уровне байтов или элементов кодового пространства), потому что это значительно ограничивает пространство поиска и минимизирует n
, чтобы обеспечить хорошее время выполнения алгоритма.
За долгие годы многие люди осознали, что при поиске различий между двумя текстовыми документами большие части данных на входе почти одинаковы (кто будет искать различия между никак не связанным содержимым?). Поэтому в большинстве реализаций алгоритмов diff есть множество оптимизаций, ограничивающих количество сравниваемых строк. Две популярные оптимизации заключаются в определении и исключении одинакового префикса и суффикса данных на входе.
Если говорить очень просто, алгоритмы поиска различий в тексте часто работают так:
- Разделяем данные на строки.
- Хэшируем каждую строку для упрощения быстрого тестирования эквивалентности строк (сравнение контрольной суммы в
u32
илиu64
намного быстрее, чемmemcmp()
илиstrcmp()
). - Обнаруживаем и исключаем строки общего префикса и суффикса.
- Передаём оставшиеся строки алгоритму поиска различий.
Идея заключается в том, что шаги 1-3 (которые должны выполняться за O(n)
) снижают объём работы алгоритма (шаг 4) со сложностью времени выполнения выше, чем O(n)
. Теоретически выглядит хорошо.
Но что происходит на самом деле?
Если профилировать несколько таких реализаций diff, то выяснится, что шаги 1-3 на самом деле занимают больше времени, чем предположительно медленный/затратный алгоритм! Как такое может быть?!
Одним из виновников является разбиение на строки. Даже если допустить, что мы можем использовать 0-copy/ссылки в памяти для хранения содержимого строк (а не выделяет новую строку для хранения каждой распарсенной строки, что может быть намного менее эффективно), разделение текста на строки может быть ужасно неэффективным!
Причины могут быть разными. Возможно, вы декодируете текст в элементы кодового пространства, а не работаете с байтами (для поиска новых строк вы не должны обязательно декодировать все входящие данные). Возможно, вы проходите по файлу по одному символу/байту за раз в поисках LF.
Эффективным решением этой проблемы будет использование векторизированных команд ЦП (например, AVX/AVX2), которые могут сканировать несколько байтов за раз, ища контрольное значение или сопоставляя байтовую маску. Поэтому вместо 1 команды на байт входящих данных у вас будет 1/n. Вероятно, в вашей библиотеке времени выполнения C есть ассемблерные реализации memchr()
, strchr()
и похожих функций, и она автоматически выбирает самые новые/быстрые ассемблерные инструкции/команды, поддерживаемые ЦП времени выполнения (так делает glibc).
Теоретически, компиляторы распознают такие паттерны и автоматически создают современные векторизированные команды. Однако в реальности из-за того, что целевые ISA компиляторов по умолчанию достаточно древни по сравнению с тем, на что способен ваш ЦП (см. предыдущий раздел), вам остаются старые команды и линейное сканирование. Лучшим вариантом для вас будет использование функций во время выполнения C, которые предположительно поддерживаются ассемблерным кодом. (Однако аккуратнее с лишней тратой ресурсов на вызов функций.)
Ещё одним виновником неэффективности является хэширование каждой строки. Хэширование выполняется для сведения тестирования эквивалентности к сравнению u32
/u64
вместо выполнения strcmp()
. Похоже, многие реализации не уделяют внимания алгоритму хэширования и используют что-нибудь типа crc32 или djb2. Неэффективность здесь заключается в том, что многие старые алгоритмы хэширования работают на уровне байтов: им нужно передавать по одному байту за раз, обновлять состояние (часто используется XOR), а затем передавать следующий байт. Это неэффективно, поскольку не используется конвейерная обработка команд и суперскалярные свойства современных ЦП. Лучше использовать алгоритм хэширования, одновременно получающий по 4, 8 или более байт. Повторюсь, это снижает время выполнения с ~n тактов на байт до ~1/n.
Ещё один распространённый источник неэффективности — вычисление строк и хэшей содержимого в общем префиксе и суффиксе. Более эффективно использование memcmp()
(или даже лучше: собственного ассемблерного кода, дающего смещение первого различия), потому что библиотека времени выполнения C, скорее всего, имеет реализации на ассемблере memcmp()
, способные сравнивать входящие данные почти с нативной скоростью памяти.
Мне очень нравится этот пример, потому что он демонстрирует, как нечто, кажущееся O(n)
медленнее, чем O(nlog(n))
/O(n^2)
. Так получилось потому, что часто результат оптимизации снижает n
затратного алгоритма до столь низкого значения, что его вычислительная сложность тривиальна. Компиляторы, выбирающие в качестве целевых древние микроархитектуры и не использующие векторизированные команды, обеспечивающие сверхлинейную производительность, ещё сильнее сдвигают время в сторону оптимизаций O(n)
.
Вывод
Компьютеры и ПО по неожиданным причинам могут быть неожиданно медленными. Хотя мой пост был длинным и в нём затронуто множество тем, он едва коснулся поверхности всех потенциальных вопросов. Я с лёгкостью могу найти ещё 10 тем, о которых стоит написать. Но это придётся оставить на другой пост.
Кроме того, компьютеры и ПО сложны. Когда дело касается производительности и оптимизаций, следует всегда измерять результаты. Описанные мной проблемы могут проявляться в вашем ПО и средах, однако попытки их решения могут и не оправдывать отдачи. Компьютеры и ПО, как и жизнь, полны компромиссов. Производительность — это только один из компромиссов. Не делайте из моих советов карго-культа и всегда пользуйтесь критическим
Автор:
PatientZero