30 января 2022 г.

Are You Sure You Want to Use MMAP in Your Database Management System?

Кажется, что отображение файлов в память (mmap) удобно использовать для реализации баз данных, потому что можно не реализовывать пул страниц, а бесплатно использовать всю машинерию виртуальной памяти, уже существующую в ОС – работать с данными как будто они лежат в памяти, и ОС позаботится почти обо всех деталях.

В статье Are You Sure You Want to Use MMAP in Your Database Management System?1 авторы аргументируют, что это удобство – иллюзия. Memory mapped files только выглядят удобно, у них есть неочевидные на первый взгляд недостатки, аккуратный обход которых потребует усилий, сравнимых с самостоятельной реализацией пула страниц.

Самая интересная и неожиданная для меня часть: одним из крупных преимуществ mmap часто называют производительность – отображение файлов в память позволяет уменьшить количество syscalls, и избежать копирования kernel -> user space, и потому должно быть быстрее обычных методов IO.

Авторы показывают, что и это тоже иллюзия: на скоростях современных SSD/NVM и при современных объемах данных пропускная способность чтения данных с диска через отображаемые в память файлы в 2-20 раз уступает пропускной способности прямого чтения в обход файлового кэша. Происходит это потому, что на больших скоростях и больших объемах ОС приходится очень часто загружать и выгружать страницы, и узким местом становится сама машинерия виртуальной памяти – обновление таблицы страниц (page table) и выгрузка страниц на диск (page eviction).

Статья распадается на две части: перечисление особенностей memory-mapped files, которые легко не заметить на стадии прототипа, но которые могут дорого обойтись на стадии промышленной эксплуатации – и развенчание мифа о том, что доступ к файлам через отображение их в память более производителен 2.

  1. Особенности, о которых идет речь, это обратная сторона достоинств mmapped files: работа файлового кэша слишком хорошо изолирована от прикладной программы – прикладная программа не может контролировать что делает файловый кэш под капотом. И это очень удобно на стадии прототипа, когда все просто работает, но вызывает проблемы по мере развития приложения, когда такой контроль неминуемо начинает быть нужен.

  2. Самый интересный и неожиданный твист в статье – это что реализация подсистемы виртуальной памяти в linux может не успевать за скоростью работы современных SSD/NVM, причем узким местом оказывается выгрузка страниц из памяти, и обновление page table.

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

Из этого авторы делают вывод: если вы собираетесь реализовывать что-то вроде базы данных и:

  • и ваш типичный объем данных превосходит объем RAM в десятки и более раз
  • и вы планируете работу на современных устройствах хранения (SSD/NVM), и хотите использовать их на полную катушку

то хорошенько подумайте, прежде чем применять mmapped files – разработчики некоторых баз данных, изначально применявшие mmap (InfluxDB, MongoDB), уже были вынуждены уйти от него, столкнувшись с этими проблемами.

Немного подробнее обо всем этом

Основной недостаток mmap это неуправляемость

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

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

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

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

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

Наконец, загрузка страниц в память происходит синхронно: поток, который попытался обратиться к еще не загруженной странице, останавливается, и будет заблокирован, пока машинерия загрузки отработает. И избежать этого нельзя, потому что страница может быть выгружена в любой момент, так что даже если заранее "потрогать" страницу – нет никакой гарантии, что она все еще будет в памяти через мгновение, когда приложение снова обратится к ней для реальной надобности. То есть mmapped files IO – синхронное, оно блокирует потоки, ожидающие IO. А современные асинхронные IO API, типа io_uring позволяют потоку продолжать заниматься своими делами, обрабатывать другие запросы, параллельно с работой подсистемы хранения.

Проблемы со временем отклика и пропускной способностью

Проблемы с производительностью для mmapped files возникают потому, что обновление таблицы страниц (page table) не бесплатно – например, требует межпоточной синхронизации, и это создает задержки, когда много потоков делают mmap/unmap, либо другим образом триггерят изменения в page table.

Но более важная причина – что вытеснение (eviction) страниц из памяти оказывается неожиданно сложным делом.

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

Почему так происходит: вытеснением (eviction) страниц из памяти, и сохранением их содержимого на диск в linux занимается отдельный процесс (kswapd), и большое количество вытесняемых страниц загружает этот процесс так, что он упирается в CPU3, и не справляется с нагрузкой.

kswapd не справляется с нагрузкой потому, что вытеснение страницы из памяти – не такая уж простая задача. Чтобы страница памяти оказалась в unmapped состоянии, ее адрес нужно удалить из page table, что требует межпоточной синхронизации. Но этого мало: каждое ядро процессора кэширует используемые записи из таблицы страниц в своем TLB. Когда адрес удаляется из глобальной таблицы страниц, он не удаляется автоматических из всех TLB всех ядер – аппаратной когерентности TLB современные процессоры не реализуют. Чтобы действительно удалить мэппинг для страницы, нужно не только удалить его из глобальной page table, но и обойти TLB всех ядер, и удалить соответствующий адрес оттуда, если он там есть. И это довольно дорогая операция, требующая межпроцессорного прерывания.

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

И усовершенствовать текущую реализацию page eviction не так просто: по словам авторов статьи, решить проблему с доступом к page table и CPU-bound kswapd – более-менее понятная задача, а вот когерентность TLB задача гораздо более сложная, требующая либо изменений в процессорах, либо серьезных переделок в ОС. То есть вряд ли мы увидим решение этой проблемы в ближайшее время.

Практические примеры

Это не теоретические построения: трудно объяснимые всплески времени отклика из-за межпроцессорных прерываний в ходе page eviction наблюдались в InfluxDB, а проседания производительности авторы продемонстрировали в своих тестах.

Например, в одном из тестов SSD с декларируемой пропускной способостью в 7 Гб/сек читался последовательно, в 20 потоках через mmap (с флагом MADV_SEQ, который подсказывает ОС префетчить последовательные страницы). В первые 20 сек пропускная способность была 6+ Гб/сек – что близко к теоретическому значению – но после 20 сек она упала почти втрое, примерно до 2 Гб/сек.

20 секунд на 6 Гб/сек примерно соответствуют 100Гб RAM, выделенным для файлового кэша в этом эксперименте – то есть скорость чтения упала, как только память заполнилась, и потребовалось освобождать старые страницы, чтобы загрузить новые.

Чтение тех же данных через O_DIRECT (в обход файлового кэша) позволяет получить 6+ Гб/сек стабильно, безо всякой деградации со временем.

Дальше больше: если объединить 10 таких SSD в RAID 0, то можно получить теоретическую пропускную способность 70Гб/сек. И в этом случае чтение через mmap-ed file даже в первые секунды не приближается к этой цифре – удается получить лишь те же 6+ Гб/сек, что и в предыдущем эксперименте, с тем же снижением скорости до 2+Гб/сек после заполнения доступной памяти. А вот прямое чтение в обход файлового кэша позволяет получить стабильные 55-60 Гб/сек – что гораздо ближе к теоретическому пределу. По-видимому, тут узким местом уже является само обновление page table.

Мой опыт

На последней работе я поддерживал приложение – распределенный протокол транзакций – где mmapped file использовался для сохранения протокола транзакций.

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

Тем не менее с обработкой ошибок, например, я намучался: весь остальной IO выбрасывает IOException, по которым можно понять, что именно пошло не так – а ошибка сохранения в mmapped file приводит к SIGBUS, который перехватить в джаве еще можно, но ничего осмысленного с ним сделать не получится, кроме как что-нибудь успеть записать в лог перед тем, как корректно завершиться.

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

И для задач тестирования mmap подкладывал свинью: я писал обвязку для тестирования поведения приложения при сбоях в файловой системе. Сбои обычного ввода-вывода неплохо тестировались через CharybdeFS4FUSE-файловую систему, которая позволяет эмулировать почти любые сбои IO поверх обычных файлов. Но виртуальная память работает на уровне ниже FUSE, поэтому если файл уже отобразился в память, то все ошибки, которые там дальше пытается эмулировать CharybdeFS – ему по-барабану. Так что конкретно этот кусок IO приходилось тестировать другим способом, и радости это не доставляло.

Синхронность – точнее, отсутствие асинхронности – тоже доставляло проблем. Приложение в целом работало как reactor loop, и весь IO – как дисковый, так и сетевой – был асинхронным. А с mmapped файлами асинхронность не прокатывала: если приложение читает или пишет в ту часть лога, которая сейчас в памяти не загружена, то весь поток reactor loop останавливается, и ждет, пока ОС подгрузит соответствующие страницы – а это приводит к непредсказуемым всплескам времени отклика всего приложения.

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

Ссылки и примечания


  1. Видео-презентация на youtube: https://www.youtube.com/watch?v=1BRGU_AS25c↩︎

  2. Речь в статье идет про Linux, как самую ходовую серверную ОС. Насколько я понимаю, многое из описанного верно и для Unix/FreeBSD, потому что у них много общего в API работы с mmapped files, да и в реализации. Сложно сказать, насколько это применимо к Windows, где эта система реализовывалась независимо.↩︎

  3. Это немного удивительно: пишут, что kswapd освобождает страницы в фоновом режиме, когда нет дефицита, но если он не успевает, то потоки прикладных приложений ему помогают – освобождают страницы синхронно, в том процессе, который ощутил нехватку свободных страниц:

    .. sometimes the rate of page allocation is so high that kswapd can not keep up. When that happens, the applications that are allocating pages will help free pages themselves, by calling the function try_to_free_pages. This has the effect of throttling the heavy memory allocators and (on NUMA systems) focussing the pageout code on those memory zones which the heavily allocating processes allocate from.↩︎

  4. CharybdeFS – подпроект базы данных ScyllaDB, которым авторы Scylla пользуются для тестирования своей базы данных. Это FUSE-файловая система, которая создает виртуальную папку поверх реальной, и позволяет вносить разные ошибки IO при работе в этой виртуальной папке.↩︎

7 комментариев:


  1. На последней работе я поддерживал приложение – распределенный протокол транзакций – где mmapped file использовался для сохранения протокола транзакций.


    почему нельзя было использовать /dev/shm и не иметь всех вышеперечисленных проблем как класса?

    ОтветитьУдалить
    Ответы
    1. Не понимаю, что вы имеете в виду. /dev/shm хорошо использовать как инструмент межпроцессной коммуникации, но это не наш случай: у нас процесс один, но данных больше, чем доступной памяти. Если бы данные влезали в память -- их можно просто держать в памяти.

      Удалить
    2. тогда да :) у меня обычно другая ситуация - все в память влезает, но нужно не тормозить "на виражах". в вашем случае надо смотреть в сторону io_uring & xnvme https://www.usenix.org/sites/default/files/conference/protected-files/vault20_slides_lund.pdf мысль замапить файл с диска и обозвать это highperformance - это только chronicle так шутит :)) зато ява чемпионы и на каждой конфе выступают :)

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

      Спасибо за ссылку

      Удалить
  2. У разработчиков apache ignite был хороший доклад по io на одной из java конференций. Вспоминали Оракл с raw-devices, когда БД пишется в неразмеченную ос область диска.

    ОтветитьУдалить
  3. А зачем страница сбрасывается на диск, если в ней не было изменений? Её же можно просто освободить.

    ОтветитьУдалить
    Ответы
    1. Это к какому месту вопрос?

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

      Удалить