all posts

ENGINEERING NOTES

Один mmap для вас — или два SIGBUS для кого-то ещё?

Кратко. Amber может работать как библиотека внутри другого процесса, а это предъявляет особые требования к обработке сбоев: любую ошибку ввода-вывода, которую нельзя безопасно обработать, система должна чисто, локально и явно остановить. Нельзя продолжать работу незаметно, повреждать данные и оставлять систему в неопределённом состоянии. Это важно. Очень.

1. Коротко о mmap

mmap отображает байты файла как обычный срез в адресном пространстве. После этого «чтение файла» выглядит как обращение к памяти, а ядро по мере необходимости лениво загружает страницы и хранит их в файловом кэше страниц.

import "syscall"

f, _ := os.Open("segment.fidx")
defer f.Close()
fi, _ := f.Stat()

// data — []byte размером с файл, но сами данные ещё не обязательно в RAM:
data, err := syscall.Mmap(int(f.Fd()), 0, int(fi.Size()),
    syscall.PROT_READ, syscall.MAP_SHARED)
if err != nil { /* ... */ }
defer syscall.Munmap(data)

// Вот и «чтение». Выглядит как доступ к памяти за наносекунды:
v := binary.BigEndian.Uint64(data[off : off+8])

То же самое с явным вводом-выводом. pread (в Go — ReadAt) читает данные по указанному смещению: один системный вызов, N байт начиная с off, без изменения общего смещения файла, результат — (n, err). Последняя часть — именно то, что нам нужно: ошибка представлена значением и видна вызывающему коду.

var buf [8]byte
if _, err := f.ReadAt(buf[:], off); err != nil {
    return err            // ошибка — значение, её можно обработать
}
v := binary.BigEndian.Uint64(buf[:])

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

Что на самом деле происходит при data[off]

Строка v := data[off] скрывает стоимость. Если страницы со смещением off ещё нет в памяти:

  1. CPU вызывает исключение page fault.
  2. Управление переходит ядру, которое проверяет наличие страницы в файловом кэше.
  3. Если страницы там нет, ядро синхронно читает её с диска, а поток ждёт.
  4. Страница отображается, инструкция запускается повторно, и data[off] «возвращает значение».

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

Спасибо, планировщик

При вызове file.ReadAt горутина входит в системный вызов через entersyscall и переходит в состояние _Gsyscall, а её P — в _Psyscall. P не отсоединяется сразу: среда выполнения оптимистично оставляет его на случай быстрого завершения вызова. Но если чтение действительно ждёт диск, sysmon замечает P в системном вызове и передаёт его другому M через handoffp. Другие горутины могут продолжить работу на этом P. Передача не происходит мгновенно, но возможна, поскольку состояние горутины видно среде выполнения.

Page fault при доступе к mmap — не системный вызов Go. Это исключение CPU, которое ядро обрабатывает прозрачно. Среда выполнения об этом не знает: нет entersyscall и смены состояния горутины. С её точки зрения горутина всё ещё _Grunning на P в состоянии _Prunning, хотя M застрял в обработчике ошибки страницы. Здесь sysmon бессилен: он не может передать P, потому что тот не в _Psyscall, и не может вытеснить M — тот находится в ядре и не дойдёт до безопасной точки, пока ошибка страницы не разрешится.

Итак, mmap не просто скрывает ввод-вывод. Он скрывает его и от среды выполнения Go, лишая планировщик информации, на которой тот основан.

2. Зачем mmap понадобился amber

В FTS-индексе есть «уникальная секция»: отсортированные 64-битные хеши токенов с df==1, примерно 13 МБ на сегмент. Сегментов десятки. Если держать всё это в памяти, для редко используемых данных потребуются сотни мегабайт RAM.

mmap как будто решает задачу бесплатно: вся секция доступна по адресам, резидентными становятся только затронутые страницы, холодные страницы вытесняет ядро, а бинарный поиск по отсортированным хешам остаётся простым и не требует системных вызовов на каждом шаге.

Это классический сценарий для mmap: большой файл только для чтения, отсортированный набор, случайные точечные запросы и желание использовать кэш страниц ОС. На этом построена LMDB. Вопрос «почему не мы?» вполне честный.

Компромиссы mmap в статье CIDR 2022

У mmap есть четыре группы проблем:

  1. Транзакционная надёжность. mmap может сбрасывать грязные страницы в произвольный момент; msync не обеспечивает строгого порядка и способен нарушить гарантии WAL. В нашем случае данные только читаются, поэтому это почти не относится к делу.
  2. Задержки ввода-вывода. Любое обращение может вызвать page fault и неявно заблокироваться. Мы теряем контроль над моментом ввода-вывода и не можем осмысленно упреждать чтение.
  3. Обработка ошибок. Ошибки ввода-вывода при page fault приходят как SIGBUS, а не как возвращаемые значения. Для нас это фатально.
  4. Производительность при вытеснении страниц. Вытеснение страниц в ядре однопоточное; на многих ядрах каждая операция может требовать TLB shootdown через межпроцессорные прерывания. Прозрачные огромные страницы способны добавлять скачки задержки.

Важно быть справедливым: mmap не плох сам по себе. Для данных, которые преимущественно читаются, помещаются в RAM и лежат на стабильном локальном хранилище одного узла, это отличный инструмент. Даже если не учитывать fail-stop, для нашей нагрузки mmap не выглядит безусловной победой. Но решающим становится наш инвариант.

3. Требование amber: fail-stop

Fail-stop — это контракт: обнаружив ошибку, которую нельзя безопасно обработать, система чисто, локально и явно останавливается, а не продолжает работу в искажённом или неоднозначном состоянии. На этом построен amber.

mmap несовместим с таким контрактом. SIGBUS — противоположность fail-stop.

Fail-stop требует обнаружить ошибку, чисто остановиться и вернуть её как значение. SIGBUS завершает процесс на произвольной инструкции; превратить это в аккуратно обработанную ошибку на практике нереально. В C можно попробовать обработчики сигналов и sigsetjmp/siglongjmp; в среде выполнения Go с горутинными стеками и собственной обработкой сигналов это нерабочий вариант. Во встроенной библиотеке всё ещё хуже: пришлось бы перехватывать сигналы в чужом процессе.

Amber работает внутри процесса пользователя. SIGBUS из-за нестабильного диска убивает не только amber, но и хост-приложение. Нельзя выпускать библиотеку, которая завершает чужой процесс из-за сбоя сетевого диска.

Поэтому контракт исключает mmap: эта абстракция делает ввод-вывод невидимым. Хранилище построено на инварианте, что операции ввода-вывода и их ошибки должны быть видны и возвращаться как значения.

К чему это привело

Горячая секция нового формата индекса читается по требованию через явный pread: каждый запрос возвращает (n, err). Повреждённый блок становится ошибкой Go, которую можно передать выше; у читателя есть счётчик ссылок; сбой локализован; процесс хоста продолжает работать. Этот формат называется AFT2.

Главное: pread не лишает нас кэша страниц. Ядро кэширует страницы файла при ReadAt так же, как и при mmap. Мы не теряем основную выгоду mmap, а платим только за системные вызовы: O(log n) обращений на поиск вместо нуля при mmap. Для секции df==1 это около 20 чтений по холодному, редкому пути — небольшая цена за явные ошибки и возможность планировщика видеть ввод-вывод.

4. AFT2 — формат нашего индекса

AFT2 заменил radix-снимок из fts-engine.

Как мы пришли к формату

  1. Сначала использовался radix-снимок fts-engine. Каждая запись posting хранилась десятичной строкой DocID внутри сериализованных узлов дерева. Для сегмента на 100 тысяч записей результат занимал 38 МБ на диске, 150 МБ в RAM и разбирался 400 мс. Файлы .fidx составляли 92% хранилища amber и доминировали в RSS.
  2. Распределение токенов в логах: примерно 80% имеют df==1, то есть встречаются ровно в одной записи. Это UUID, ID трассировок и идентификаторы. Обычный словарь строк требует около 40 байт заголовков строки и среза на токен — примерно 50 МБ заголовков на сегмент в LRU исполнителя.
  3. Разделим данные по частоте документов. Две группы — два представления.

Структура .fidx (AFT2)

[ магическое значение "AFT2" ]
[ секция df>=2: отсортированный словарь токенов + posting lists uint64 с delta-varint ]
[ секция df==1: два плоских массива 8-байтовых значений:                       ]
[              fnv64a(token), по возрастанию                                    ]
[              || параллельные полные ID записей                                ]
[ CRC32 ]

В df>=2 — десятки тысяч токенов, в основном слова из шаблонов сообщений. Секция резидентна и ищется бинарным поиском по словарю. Токенов немного, поэтому это недорого.

df==1 — оставшиеся 80%. Заголовки строк не нужны: только хеши и ID, по 8 байт каждый. Коллизии не теряют записи: одинаковые хеши стоят рядом, поиск возвращает все записи с этим хешем. 64-битная коллизия внутри сегмента (при примерно 1 млн токенов вероятность коллизии по дню рождения — около 3e-8) может добавить постороннюю запись, но не потеряет нужную; в бенчмарках это выявляют проверки равенства числа результатов.

Механика

В загруженном режиме секция df==1 не находится в памяти: поиск выполняется через pread по файлу. После Save() индекс переводится на файловое хранение: резидентные массивы хешей (около 13 МБ на сегмент) очищаются, остаются только path + offset + count. Только что запечатанный индекс занимает в кэше исполнителя те же несколько мегабайт, что и загруженный.

Бинарный поиск из lookupUnique в файловом режиме выглядит так:

h := tokenHash(token)
var buf [8]byte
readU64 := func(off int64) (uint64, bool) {
    if _, err := file.ReadAt(buf[:], off); err != nil {
        return 0, false          // ошибка блока — значение, а не SIGBUS
    }
    return binary.BigEndian.Uint64(buf[:]), true
}

lo, hi := 0, n
for lo < hi {
    mid := (lo + hi) / 2
    v, ok := readU64(uniqOff + int64(mid)*8)
    if !ok { return nil }        // fail-stop в миниатюре
    if v < h { lo = mid + 1 } else { hi = mid }
}

Всё дело в строке if !ok. С mmap здесь было бы data[mid*8] и не было бы проверки !ok; единственный способ отказа — завершение процесса. pread превращает «процесс погиб» в «функция вернула ошибку».

all posts