Paralelní souborové I/O přes io_uring
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 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 verze 5.1 dostalo do vínku
subsystém io_uring
, který řeší 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:
io_uring
fronty s místem pron
požadavků se inicializují přesio_uring_queue_init
- Postupně naplním frontu jednotlivými požadavky.
io_uring_get_sqe
vrátí nepoužitou submission queue entry aio_uring_prep_read
ji nastaví na ekvivalentpread
syscallu. (Funkce může vrátit null, pokud je fronta plná, ale to se nás netýká, protože jsme alokovali místo přesně pro n požadavků.) io_uring_submit_and_wait
odešle nové požadavky kernelu a čeká dokud jichn
(tedy všechny) nebudou dokončené. Čekání není nezbytně nutné, variantaio_uring_submit
jen upozorní kernel a program může dělat něco jiného zatímco se IO a všechny odeslané požadavky zpracovávají.io_uring_queue_exit
všechno ukončí. Opět to není nutné,io_uring
je zamýšlen, aby se opakovaně používal, například v serverech, kde požadavky přicházejí a jsou zpracovávány neustále, program přidá několik požadavků do fronty, dělá něco jiného, podívá se, co je hotové, zpracuje to, přihodí další požadavky a tak dokola. Inicializace a zrušení struktury má značnou režii (je potřeba zavolatmunmap
atd). Navíc tady se vůbec nezabývám completion queue, druhou frontou do níž kernel průběžně dává informace o požadavcích, které doběhly.io_uring_prep_read
dostal adresu bufferu do nějž se mají nakopírovat data, takže když všechno doběhne, data tam budou. Samozřejmě, completion queue je třeba se zabývat, protože jen z ní můžu zjistit, jestli nějaký požadavek neskončil chybou nebo nedošlo jen k dílčímu čtení.
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í.
Č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ě.