[Перевод] Неожиданные причины торможения программ и систем

image-loader.svg


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

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

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


Именно эта тема вдохновила меня на создание поста.

Системы сборки перед этапом сборки часто имеют этап распознавания среды / конфигурирования. В мире UNIX преобладают сгенерированные autoconf скрипты configure. Также популярен CMake. Эти инструменты запускают код для проверки состояния текущей системы, чтобы конфигурация сборки подходила для текущей среды сборки. Например, они проверяют, какой компилятор использовать, его версию, баги и возможности.

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

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

Заметность этого расхождения зависит от количества ядер ЦП. Но в моей основной машине стоит 16-ядерный/32-потоковый Ryzen 5950X, и мне мучительно наблюдать за относительной медленностью этапа конфигурирования.

Но ещё более шокирующим мне кажется то, что время конфигурирования часто намного превышает время сборки даже для крупных проектов. Не знаю, справедливо ли это сегодня, но несколько лет назад Mozilla заметила, что сборка LLVM/Clang на инстансе с 96 vCPU EC2 требовала больше времени на cmake/конфигурирование, чем на компиляцию и компоновку! А ведь это очень крупный проект на C++ с тысячами файлов исходного кода!

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

Ещё одно решение этой проблемы — полное устранение проблемы распознавания среды. Если вы работаете в детерминированных и воспроизводимых средах сборки, то можно срезать углы, чтобы пропустить распознавание среды, которое вам больше не нужно. Примерно такой подход используют современные инструменты сборки наподобие Bazel. Мне любопытно, насколько ускоряется сборка в инструментах типа Bazel благодаря устранению этапа конфигурирования среды. Подозреваю, что сильно!


В 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, то оцените эффект, который оказывает относительно медленное создание процессов. Подумайте над альтернативами: многопоточной архитектурой и использованием долгоживущих демонов/фоновых процессов.


Много лет назад я профилировал 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 Гц, чтобы минимизировать трату лишнего времени в терминале.


Мы привыкли думать, что компьютер и его процессоры или включены, или выключены, но если бы всё было так просто…

В процессе работы процессоры постоянно меняют рабочий диапазон. Все приведённые ниже заявления справедливы (хотя и не каждый пункт относится ко всем машинам или моделям ЦП):

  • Количество МГц, на котором работает ядро ЦП, может сильно колебаться каждую секунду.
  • Ядра ЦП могут уходить в сон или в режим с очень низким энергопотреблением, даже если остальные продолжают работать.
  • При превышении порогового значения температуры ядра могут значительно снижать тактовую частоту. Они могут отказываться работать быстрее, прежде чем снизится температура. Неисправные датчики могут приводить к преждевременному срабатыванию защиты.
  • Ядра могут достигать своей максимальной частоты, если работают и другие ядра. Может иметь значение физическая близость других ядер.
  • Для разгона до полной скорости простаивающему ядру могут понадобиться десятки, сотни или даже тысячи миллисекунд.
  • Процесс изменения мощности может очень сильно варьироваться в зависимости от того, подключена ли машина к внешнему источнику питания, или работает от аккумулятора.
  • Изменение мощности может сильно варьироваться от того, заряжен ли аккумулятор полностью или почти разряжен.
  • Ноутбуки 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 и другие интерпретаторы. Например, система сборки 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) были виноваты в снижении производительности. Излишний ввод-вывод и ненужное сохранение изменений на устройство могут значительно снижать производительность. ПО накопителей обычно ошибается в сторону целостности (на мой взгляд, это правильное значение по умолчанию). Учитывая затраты, которые накладывает целостность, следует серьёзно поразмыслить над ослаблением гарантий и ускорением ввода-вывода, если этот вариант для вас приемлем.


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

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

  1. Узким местом является ввод-вывод (с накопителем или сетью), поэтому мы готовы потратить больше ресурсов ЦП для снижения объёмов ввода-вывода.
  2. В состоянии покоя накопитель затратен, поэтому мы готовы потратить больше ЦП для снижения использования/затрат накопителя.


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

Перенесёмся в 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) для архивирования данных (хранения где-нибудь сжатых данных, где они время от времени будут распаковываться), вам необходимо измерить и сравнить коэффициенты сжатия и линейные скорости различных форматов сжатия и их параметров. Как и в ситуации со сжатием в реальном времени, если распаковка снижает линейную скорость по сравнению с несжатыми данными, то вы искусственно замедляете доступ к данным. Возможно, это оправданно экономией на объ

© Habrahabr.ru