Кто-то открыл PR с реализацией readahead в Turso. Реализация была черновой, но стала хорошим поводом измерить поведение io_uring и разобраться в нём подробнее.
У Turso есть два бэкенда. syscall использует pread(2). io_uring использует io_uring и открывает файл базы данных с флагом O_DIRECT без возможности буферизованного ввода-вывода. O_DIRECT отключает readahead ядра, поэтому вернуть эту функциональность можно только реализовав её на уровне приложения.1
Результаты PR впечатляют. io_uring с буфером приложения оказывается быстрее. Стоит разобраться, почему.
Без readahead io_uring отправляет только один запрос за раз. Проблема в отсутствии конкурентности: каждое чтение ждёт завершения предыдущего. Приложение знает, что «сейчас нужна страница 100», а значит: Turso отправляет SQE на чтение страницы 100, затем ждёт его выполнения. Сканирование продолжается. Теперь Turso нужна страница 101, и она отправляет новый SQE уже для неё.
При включённом readahead всё меняется. Turso нужна страница 100, обнаруживается последовательный доступ, и вместо запроса одной страницы отправляются запросы на чтение страниц с 100 по 131. Теперь одновременно в полёте находится 32 чтения.
Алекс Миллер обратил внимание на момент, который изначально не был учтён: подобный readahead — это ставка. Детектор следит за физическим порядком — возрастающими смещениями, — но страницы B-дерева могут храниться в произвольном порядке. Ставка оправдывается только когда таблица кластеризована, то есть логический порядок страниц совпадает с физическим порядком в файле. Используемый бенчмарк — как раз благоприятный случай для такой ставки. В запросе Q6 блочный слой слил почти все чтения, поэтому сканирование вело себя как последовательное. Но причина этого не была проверена, поскольку готовый файл .db был просто загружен. Долго работающая нагрузка, с вставками и удалениями данных, изменением размера индексов и так далее, фрагментировала бы файл и разрушила бы эту ставку. Миллер также указал на более удачное место для readahead в базе данных на B-деревьях: сам обход дерева. Внутренний узел уже перечисляет страницы, которые идут дальше, поэтому обход может забирать именно те страницы, которые понадобятся запросу, вместо того чтобы гадать по смещениям.
Приведённые ниже измерения используют TPC-H2 — стандартный бенчмарк для аналитических баз данных — на базе данных объёмом 1.2 ГиБ.
- Q6, измеряемый запрос, выполняет полное сканирование
lineitem— самой большой таблицы бенчмарка. offозначаетPRAGMA prefetch_pages=0: код из PR без readahead.onозначает окно в 32 страницы.
Слияние запросов
Было интересно посмотреть, что readahead меняет в пути ввода-вывода: сколько запросов отправляет Turso и что в итоге доходит до устройства. Для этого параллельно с Q6 запускался iostat -dxm 1 sda, а также perf stat, считающий трейспоинт io_uring:io_uring_submit_req.
| off (n=1) | on (n=1) | |
|---|---|---|
| Отправлено SQE | 195 207 | 218 212 |
| Запросов к устройству | ~196 000 | ~16 300 |
rareq-sz |
4.37 КиБ | 56.53 КиБ |
%rrqm |
~0 | 91-93% |
rareq-sz — средний размер запроса на чтение, поступающего на устройство. %rrqm — процент запросов на чтение, слитых с другим запросом перед отправкой. «Устройство» здесь — виртуальный диск гостевой системы: эти цифры не относятся к физическому хранилищу под ВМ.
При включённом readahead Turso отправляет на 23 005 SQE больше и забирает больше страниц, чем нужно, из-за чего устройство читает больше байт. Но само устройство получает меньше запросов.
Если два запроса в очереди покрывают соседние секторы, блочный слой объединяет их в один более крупный запрос. Это работает только если оба запроса находятся в очереди одновременно. Количество слияний подсчитывалось через трейспоинты perf.
При выключенном readahead слилось лишь 140 из 195 516 bio. Это логично, поскольку в очереди находится только один SQE и сливать нечего. При включённом readahead слилось 202 539 из 218 493 bio, и устройство получило всего 15 951 запрос. Поскольку в очереди было множество SQE, ядро смогло их объединить.
Поток опроса
Turso использует io_uring с sqpoll. sqpoll задействует поток, который постоянно проверяет в цикле, поступила ли работа и должно ли ядро что-то сделать. Это поток, который отслеживает работу ценой постоянного спина.
Время выполнения sqpoll с prefetch_pages=32 измерялось, чтобы понять, куда уходит время: 8.22 с реального времени, 3.70 с пользовательского времени и 8.46 с системного времени (медиана по семи запускам). Системное время больше реального, а это возможно только если два потока тратят процессорное время одновременно.
Ожидалось, что циклы уйдут в код запроса, но:
io_sq_thread, поток опроса ядра, занимает 65% циклов. Профиль получен на пересобранном хосте.Поэтому Turso была пересобрана без SQ polling, заменив билдер setup_sqpoll на обычный IoUring::new, который и так используется Turso как запасной вариант при неудачной настройке sqpoll.
Обычное/стандартное кольцо: 8.62 с реального времени, 3.62 с пользовательского и 1.27 с системного времени. Удаление потока опроса немного ухудшило реальное время, но сильно уменьшило системное. Системное время не равно нулю, потому что io_uring_enter(2) всё ещё вызывается на каждую отправку, а Turso отправляет по одному SQE за раз.
Оправдан ли polling, зависит от машины. Didona и соавторы измерили это в статье SYSTOR 2022 года: submission polling с одним NVMe-накопителем и одним ядром CPU достигал лишь 13 KIOPS (13 тысяч операций ввода-вывода в секунду) — двум потокам приходилось делить одно ядро, поэтому они работали по очереди. При добавлении второго ядра производительность полностью восстанавливалась. Используемая машина имеет 4 vCPU. Запрос однопоточный и использует одно кольцо с одним потоком опроса, так что ядер хватало, чтобы поток опроса работал, не конкурируя с самим запросом.
Промахи кэша
При отключённом SQ polling Q6 снова замерили на обоих бэкендах: 8.55 с на io_uring и 3.02 с на syscall. Разница значительная. Чтобы понять, куда уходит это дополнительное время, через perf были подсчитаны инструкции и промахи кэша. Стоит отметить: счётчики в этой таблице получены на пересобранном хосте (никто не хочет платить Hetzner за простой), поэтому их абсолютные значения не совпадают с указанными выше замерами времени.
| бэкенд | циклы (медиана, n=7) | инструкции (медиана, n=7) | IPC | промахи кэша | процент промахов |
|---|---|---|---|---|---|
| io_uring обычный | 5.374 млрд | 21.497 млрд | 3.999 | 10.666 млн | 13.255% |
| syscall | 4.734 млрд | 21.297 млрд | 4.499 | 5.889 млн | 7.691% |
io_uring выполняет на 0.2 млрд инструкций больше, что почти ничего не значит, но получает на 4.8 млн больше промахов кэша.
Оба бэкенда используют DMA: аппаратура диска сама записывает данные в RAM, без участия CPU. Но между бэкендами есть и различия: при буферизованном чтении диск записывает данные в page cache.3 Затем ядро копирует данные из page cache в буфер процесса. Это копирование — обычная работа CPU: процессор читает байты и записывает их в другое место. Побочный эффект копирования — данные оказываются в кэшах CPU (L1/L2/L3).
При использовании O_DIRECT, однако, диск записывает данные напрямую в буфер процесса без этапа копирования, а значит CPU не участвует в процессе, и данные не попадают в кэши CPU.
Это объяснение — гипотеза: счётчики показывают, что у io_uring больше промахов кэша, чем у syscall, но связь этих промахов именно с отсутствующим этапом копирования не была прослежена напрямую.
Цена вопроса
После всех этих замеров сложилось определённое мнение о цене каждой модели:
- io_uring с O_DIRECT и без readahead на стороне приложения работает с единственным SQE в кольце. Один SQE означает отсутствие конкурентности, а без конкурентности блочному слою нечего сливать. Это была самая медленная конфигурация из всех измеренных.
- sqpoll — разумная модель, когда на машине больше одного vCPU. Но всё равно нужно понимать, как приложение использует ресурсы. Если приложение и так активно нагружает CPU, поток опроса будет конкурировать с ним за процессорное время. В этом случае стоит измерить эффект от выделения одного vCPU под поток ядра, прежде чем включать sqpoll.
- Обычный/стандартный io_uring тоже разумный вариант, поскольку позволяет батчинг. Один вызов
io_uring_enter(2)может отправить сразу несколько SQE. В статье SYSTOR это измерено: 1.01 системного вызова на операцию ввода-вывода при глубине очереди 64.
Помимо самих моделей, стоит отметить ещё два момента о цене:
- При буферизованном чтении ядро копирует данные из page cache в буфер процесса, и это копирование задействует CPU. У копирования есть побочный эффект: данные оказываются «тёплыми» в кэшах CPU. O_DIRECT пропускает копирование, но данные всё равно должны в какой-то момент дойти до CPU. При буферизованном чтении это происходит во время копирования. При O_DIRECT — во время самого запроса, в виде промахов кэша.
- Readahead тратит часть работы впустую. Turso отправила на 23 005 SQE больше, забрала страницы, которые запрос так и не использовал, и устройство прочитало больше байт. Но именно дополнительные запросы держат очередь заполненной — без них очередь вернулась бы к состоянию с одним запросом за раз.
-
В блоге ScyllaDB есть отличное объяснение внутреннего устройства ввода-вывода: https://www.scylladb.com/2024/11/25/database-internals-working-with-io/ ↩︎
-
TPC-H был выбран, поскольку это рекомендованный подход в CONTRIBUTION.md проекта Turso: https://github.com/tursodatabase/turso/blob/main/CONTRIBUTING.md#tpc-h ↩︎
-
Интересный пост о пользовательском дисковом вводе-выводе, если тема интересна. ↩︎