# io_uring SQPOLL: «шум» выделенных kernel-поллеров

Наблюдение, всплывшее при работе над П-9 (optimistic pre-search для SkipList,
PR #43). При прогоне бенч-гейта `insert_scaling` end-to-end фазы (B/C —
`ConstTree`) показывали CPU-ratio ≈ 15× даже при `T=1`: процесс жёг почти все
ядра, хотя прикладной поток был один. Источник — выделенные kernel-потоки
io_uring SQPOLL, по одному на шард. К самому П-9 (raw SkipList, фаза A — без
диска) это **не относится**; зафиксировано отдельно как кандидат в бэклог.

Дата: 2026-06-13. Машина наблюдения: Ryzen 7 7700 (8 физических ядер / 16
логических, Zen 4), Linux.

---

## Что такое SQPOLL и где он включён

io_uring с флагом `IORING_SETUP_SQPOLL` поднимает **выделенный kernel-поток**
(`iou-sqp-<pid>`), который busy-polling'ом опрашивает submission queue ring'а.
Смысл — приложению не нужно делать syscall `io_uring_enter` на каждую отправку:
поток ядра сам подхватывает новые SQE из разделяемой памяти. Цена — этот поток
**крутит CPU**, пока активен.

В armdb он включается здесь:

```rust
// armdb/src/io/uring.rs:19
let ring = IoUring::builder()
    .setup_sqpoll(2000)        // 2000 = sq_thread_idle, миллисекунды
    // .setup_sqpoll_cpu(cpu_id)   // TODO: пиннинг поллера на ядро — закомментирован
    .build(256)
```

`2000` — это параметр `sq_thread_idle` (мс): поток-поллер продолжает busy-poll
**ещё 2 секунды** после последнего submission, и только потом засыпает. Спящего
поллера нужно будить флагом `IORING_ENTER_SQ_WAKEUP` (дополнительный syscall на
холодном пути).

---

## Топология: сколько поллеров и на что они навешаны

| Уровень | Факт | Ссылка |
|---------|------|--------|
| `UringWriter` | один на **шард**, поле `uring_writer` | `shard.rs:119`, создаётся в `shard.rs:288` и `shard.rs:382` |
| Шарды | создаются **один раз на `Db`** (engine), не на коллекцию | `engine.rs:189-190` |
| Коллекции | `TypedMap`/`ConstTree`/… делят шарды движка через `engine.shards()` — свои шарды **не** заводят | `typed_map.rs:138`, `typed_tree.rs:173` |

Вывод:

> Число SQPOLL-потоков = `shard_count` на один `Db`.
> **Не** на коллекцию, **не** «по ядру на каждую коллекцию».

Открытие десяти коллекций на одном `Db` с `shard_count = 8` даёт 8 поллеров, а не
80.

---

## Дефолтный `shard_count`

`Config::default()` (`armdb/src/config.rs:160-187`):

| Сборка | Формула | На Ryzen 7700 (16 логич.) |
|--------|---------|---------------------------|
| release | `available_parallelism() / 2`, clamp `[1, 8]` | **8** шардов → 8 SQPOLL-потоков |
| debug | `available_parallelism() / 2`, clamp `[1, 4]` | 4 |

Бенчмарк `insert_scaling` **явно завышает** до 16 (`insert_scaling.rs:167`,
`config.shard_count = 16`), чтобы шард-мьютексы гарантированно не были узким
местом, — то есть вдвое выше дефолтного релизного потолка. Это усиливает
наблюдаемый шум в самом бенче, но в production с дефолтами поллеров ≤ 8.

---

## Влияние на production

Это **не** тестовый артефакт — поллеры реальны и в production:

- Каждый поллер — настоящий kernel-поток. Пока есть submission'ы (и ещё 2с
  после), он **занимает ядро** busy-poll'ом.
- При случайной записи, размазанной по всем шардам, **все** N поллеров
  просыпаются одновременно → CPU-oversubscription. Особенно больно, когда
  `shard_count` > числа физических ядер (как в бенче: 16 поллеров на 8 ядер) —
  поллеры конкурируют и друг с другом, и с прикладными потоками за исполнительные
  юниты.
- На холостом ходу засыпают (наблюдение: idle 3с → cpu_delta ≈ 0.00), так что
  вред проявляется именно под нагрузкой с разбросом по шардам, а не в покое.

### Главная неэффективность — нет `ATTACH_WQ`

В коде только `setup_sqpoll(2000)` (и закомментированный `setup_sqpoll_cpu`).
Флага `IORING_SETUP_ATTACH_WQ` нет. Значит каждый ring поднимает **независимый**
поллер, вместо того чтобы делить один общий kernel worker-pool / поток на все
шарды. N независимых поллеров — это и есть корень «шума».

### Прочие эффекты `setup_sqpoll`

- Исторически требовал привилегий (`CAP_SYS_NICE`); с ядра 5.13 работает для
  unprivileged, но с ограничениями.
- `sq_thread_idle = 2000` (2с) — длинный хвост busy-poll. Для бурстовой записи
  это «крутимся вхолостую 2с после каждого бёрста».
- Без пиннинга (`setup_sqpoll_cpu` закомментирован) планировщик гоняет поллеры по
  ядрам, добавляя миграции и порчу кэша.

---

## Кандидаты в бэклог

Не правим в рамках П-9; фиксируем направления для отдельной задачи.

- **Общий поллер через `ATTACH_WQ`.** Поднять SQPOLL-поток один раз и
  приаттачить остальные ring'и к нему (`IORING_SETUP_ATTACH_WQ`). N поллеров → 1.
  Самый прямой способ убрать шум.
- **Короче `sq_thread_idle`.** 2000мс → 10–50мс: поллер засыпает быстрее после
  бёрста. Дешёвая правка, частично снимает холостой busy-poll ценой более частых
  `SQ_WAKEUP` на холодном пути.
- **Пиннинг через `setup_sqpoll_cpu`** (уже есть TODO в коде). Прибить поллеры к
  выделенным ядрам, чтобы не конкурировали с прикладными потоками и не
  мигрировали.
- **Условный/отложенный SQPOLL.** Включать SQPOLL только для шардов под реальной
  нагрузкой; на остальных — обычный submit без выделенного поллера.
- **Пересмотреть дефолт `shard_count` относительно физических, а не логических
  ядер**, чтобы число поллеров по умолчанию не превышало число физических ядер.

### Как воспроизвести / измерить

- `insert_scaling` фазы B/C (`cargo run --release -p armdb --features
  bench-internals --example insert_scaling`) — колонка `CPU x` показывает
  process-CPU / wall; при `T=1` шум виден как `CPU x` ≫ 1.
- `top -H -p <pid>` или `ps -L` — увидеть потоки `iou-sqp-*` и их CPU.
- Сравнить `CPU x` до/после `ATTACH_WQ` либо при уменьшенном `sq_thread_idle`.

---

## Связь с П-9

Фаза A гейта (raw SkipList, index only, no disk) io_uring **не трогает**, поэтому
плато масштабирования 8→16 в фазе A объясняется **не** SQPOLL, а SMT-границей (8
физических ядер) плюс серийной link-секцией под `write_lock`. SQPOLL-шум искажает
только end-to-end фазы B/C и к выводам по П-9 не относится.

---

## Дополнение: GPT-5 Codex review (2026-06-13)

Модель: **GPT-5 Codex**.

Вердикт: проблема выглядит реальной, но текущую формулировку гипотезы стоит
уточнить. `SQPOLL` включён безусловно в `UringWriter::new()`, а `UringWriter`
создаётся на каждый обычный Bitcask shard. Это делает цену SQPOLL реальной для
Bitcask-backed коллекций на Linux.

Главное уточнение к топологии: тезис «не на коллекцию» верен только внутри
одного `Engine`. В standalone API `ConstTree::open` / `TypedTree::open` /
`TypedMap::open` открывают собственный `Engine`. В `armour::Db` каждая named
collection тоже открывается через `self.tree_path(meta.name)` и получает свой
storage directory, свой `Engine` и свои shard'ы. Поэтому на уровне Armour Db
оценка такая:

```text
SQPOLL threads = сумма shard_count по всем открытым Bitcask-коллекциям
```

То есть 10 Bitcask-коллекций с `shard_count = 8` могут дать 80 `iou-sqp-*`
threads, а не 8. FixedStore сейчас не входит в эту оценку: `fixed/shard.rs`
использует `FileExt::write_all_at` / `read_exact_at`, без `UringWriter`.

Вторая важная деталь: текущий `UringWriter` не использует io_uring как
асинхронный батчер. Он кладёт один `Write` или `Fsync` SQE и сразу делает
`submit_and_wait(1)`. Caller всё равно блокируется, часто под shard lock, а
SQPOLL-потоки остаются жить и busy-poll'ить. Поэтому польза SQPOLL здесь
меньше, чем в классическом high-throughput async submission path, а цена по CPU
остаётся.

### Осторожно с `ATTACH_WQ`

`ATTACH_WQ` не стоит считать доказанным фикс-кандидатом «N SQPOLL pollers → 1»
без отдельного PoC. В локальном `rustix-uring` `setup_attach_wq(fd)` описан как
sharing async worker backend; Linux header комментирует `SQPOLL` как `SQ poll
thread`, а `ATTACH_WQ` как attach to existing workqueue. Это может шарить
`io-wq` workers, но не обязательно убирает отдельные `iou-sqp-*` threads для
каждого ring'а.

Перед тем как ставить `ATTACH_WQ` первым решением, нужно маленькое измерение:
создать N rings с `SQPOLL + ATTACH_WQ`, затем сравнить число `iou-sqp-*`
threads через `ps -L` / `top -H` с текущим вариантом.

### Приоритет экспериментов

1. **Current SQPOLL vs io_uring без SQPOLL.** Убрать `.setup_sqpoll(2000)`,
   оставить `io_uring`, сравнить `CPU x`, throughput и p99.
2. **Current SQPOLL vs plain `pwrite_at`.** Для Bitcask flush path один
   blocking `pwrite` большого write buffer может оказаться дешевле, чем
   SQPOLL-инфраструктура.
3. **1 коллекция vs много коллекций.** Отдельно измерить Armour Db с несколькими
   Bitcask collections, потому что именно там множитель `collection × shards`
   проявляется сильнее всего.
4. **Flusher on/off.** Default flusher в `armour::Db` тикает раз в секунду, а
   `sq_thread_idle = 2000ms`; при регулярных flush submissions SQPOLL threads
   могут почти не успевать засыпать.
5. **Меньший `sq_thread_idle`: 10-50ms.** Это не лечит sustained load, но
   уменьшает post-burst CPU tail.

### Варианты решения

- Сделать SQPOLL opt-in, а не default. Для текущего синхронного `submit_and_wait`
  это самый безопасный кандидат.
- Для default Bitcask path рассмотреть plain `pwrite_at` или io_uring без
  SQPOLL. Это особенно разумно при buffered I/O и уже существующем write buffer.
- Если SQPOLL действительно нужен, рассмотреть один shared ring на process / Db
  или отдельный I/O service, а не ring на shard. Это крупнее, но предсказуемее,
  чем надежда на `ATTACH_WQ`.
- FixedStore исследовать отдельно. Перенос slot writes на async io_uring меняет
  семантику: после возврата из write операция уже не гарантированно дошла даже
  до page cache, если не ждать completion. Для FixedStore безопаснее сначала
  проектировать batching / write-behind contract, а не просто подключать
  `UringWriter`.

Практический вывод: сначала сделать benchmark matrix `current SQPOLL` /
`io_uring no SQPOLL` / `plain pwrite`, с 1 и N Bitcask-коллекциями, flusher
on/off. Если `plain pwrite` не хуже по throughput и заметно лучше по `CPU x`,
его стоит сделать default, а SQPOLL оставить opt-in для доказанных high-I/O
профилей.
