0xDEADBEEF

RSS odkazy english edition
««« »»»

Paralelní souborové I/O přes io_uring

4. 9. 2021 #IO

Představte si následující situaci: Máte funkci, která musí načíst pár desítek záznamů z gigantického souboru a velice záleží na celkovém času. Záznamy jsou malé, menší než 4kB stránka a nepočítáte s tím, že se celý soubor bude nacházet v systémové page cache. Každá IO operace jde až na flash s plnou latencí SSD, kterou jsem tu nedávno měřil.

Existuje nějaký způsob, jak dotazy provést paralelně?

Klasické syscally preadv a pwritev blokují. Provedou jedno IO, vlákno stojí, druhé, stojí, třetí, stojí a tak dále. Výsledná latence je pak součet všech dílčích latencí. Disk v laptopu, na kterém tohle píšu, má latenci 279 μs a potřebuje přibližně 33 μs na odeslání 4 kB bloku dat. Stačí jen několik IO požadavků a dostáváme se do pozorovatelného teritoria milisekund.

Linux nabízí rodinu aio syscallů, ty ale mají své problémy a limitace a ve výsledku se o ně nejímá nikdo jiný než databázoví lidé.

Nic ale není ztraceno. Linuxové jádro ve své páté generaci dostalo do vínku subsystém io_uring, který řeší všechny problémy minulých pokusů a navrch přidává mnoho dalších bonusů. V podstatě io_uring není jen nástroj pro asynchronní I/O, ale pro asynchronní, dávkovou a velice efektivní komunikaci s kernelem.

Mě teď záleží jen na tom, že umí najednou odeslat dávku několika IO požadavků s tím, že kernel je může odbavit paralelně.

Ok, než budu pokračovat, musím zmínit technickou realitu testovacího prostředí: debian, kernel 5.10, BTFRS a SATA SSD. Pro srovnání nové NVMe rozhraní disponuje 65535 frontami s až 65535 příkazy, staré SATA má jedinou frontu a v ní maximálně 32 příkazů. Není to moc, ale nějaký paralelismus by se z toho dal vyždímat.

#include <liburing.h>

int gather_reads(int fd, uint64_t* lengths, uint64_t* offsets, uint64_t n, void* output) {
  struct io_uring ring;
  int ret;
  ret = io_uring_queue_init(n, &ring, 0);
  if (ret != 0) return ret;

  for (int i = 0; i < n; i++) {
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    io_uring_prep_read(sqe, fd, output, lengths[i], offsets[i]);
    output += lengths[i];
  }

  io_uring_submit_and_wait(&ring, n);
  io_uring_queue_exit(&ring);
  return 0;
}

Krok za krokem:

Testování probíhá z programu napsaném v D (nechci trpět víc C než je nezbytně nutné). Stojí v něm proti sobě verze sekvenčně volající pread a výše uvedená funkce gather_reads.

Výsledky jsou povzbudivé, nijak dramaticky, přesto došlo na skoro trojnásobné zrychlení u dávek 64 náhodných čtení.

průměrný čas na jednu IO operaci

Časy jsou pochopitelně měřené po vyčištění page cache. Nejpomalejší dotazy jsou ty první. Soubor není jen pointer na začátek souvislého pole dat, má vnitřní strukturu, může být rozdělen na několik částí, alokován do volných míst disku, nebo něco takového. Možná něco se dá vyčíst z dokumentace btrfs, ale na to teď nemám nervy. Při trasování sekvence pread IO operací na náhodná místa 4GB souboru přes blktrace, mi to ukazovalo dva sekvenční dotazy na metadata a po nich jeden na požadovaná data, takže uvnitř bude nějaká segmentace. Postupně počet těch extra IO klesl na nulu.

Když jsem se pak podíval na diskové operace vyvolané voláním io_uring přes blktrace -a issue -a complete -d /dev/sdp -o - | blkparse -i - bylo vidět, že kernel odešle dávku několika požadavků najednou a následně jejich odpovědi také zpracuje najednou. Pod kapotou skutečně běží IO paralelně.

Otázka zní: proč je zrychlení jen něco kolem 3x? V čem je limit? Moje SSD? Nedivil bych se, je levné a staré, bůh ví jak moc ho drží pohromadě už jen korekce chyb. SATA rozhraní? I/O plánovač? Samotný brtfs? Jak by se to chovalo na novém NVMe zařízení? To musím zjistit příště.

píše k47 (@kaja47, k47)