Aplikační WireGuard-Go je teď dvakrát rychlejší než jaderná implementace

15. 12. 2022
Doba čtení: 8 minut

Sdílet

Autor: Depositphotos
Článek o tom, že implementace VPN v jádře nemusí být vždy rychlejší než ta běžná v procesu. Příběh optimalizace nástroje WireGuard-Go, který vedl k několikanásobnému zrychlení jen díky správně používaným službám jádra.

WireGuard je populární a moderní VPN, která byla původně napsaná pro linuxové jádro a později se rozšířila také do ostatních operačních systémů. Autor WireGuardu, Jason Donenfeld, vytvořil implementaci v linuxovém jádře, ale také aplikační variantu WireGuard-Go. Ta běží jako klasický proces a využívá síťového rozhraní typu TUN, stejně jako například známá OpenVPN.

Co se dozvíte v článku
  1. Jádro vs. proces
  2. Hledání brzdy
  3. TCP segmentace
  4. TCP Segmentation Offload (TSO)
  5. TSO a GRO v ovladači TUN
  6. Odeslání a přijetí více paketů naráz
  7. Dvojnásobné zrychlení

Sám Jason o implementaci v Go píše: Bude fungovat i v Linuxu, ale místo toho byste měli použít modul jádra, který je rychlejší a lépe integrovaný do operačního systému. Je to logické, protože při využití VPN v uživatelském prostoru je nutné kopírovat data z jádra do paměti procesu a zpět, což nás stojí určitý výkon. Verze v jádře je tedy preferovaná a ta psaná v Go je určena jen pro prostředí, kde máme třeba starší jádro bez podpory WireGuardu.

WireGuard je velmi elementární řešení tunelu mezi dvěma síťovými rozhraními a chybí mu nějaká robustnější infrastruktura pro výměnu klíčů, automatickou konfiguraci a podobně. Řadě uživatelů to vyhovuje, protože se nechtějí zabývat ničím složitějším. Existuje ale řada služeb, které WireGuard vzaly a postavily na něm vlastní řešení, doplněné třeba o zmíněné pohodlí. Takovou službou je i Tailscale, což je jakýsi komerční WireGuard s lidskou tváří.

Jádro vs. proces

Tailscale využívá pro samotné posílání dat právě implementaci WireGuard-Go. Její výkon je pro celkový výkon VPN naprosto klíčový, takže se vývojáři Tailscale podívali na to, jestli není možné někde rozšířit úzké hrdlo. Na začátku provedli hrubý benchmark pomocí nástroje iperf na dvou velmi výkonných instancích AWS v jedné lokalitě. Mezi nimi se podařilo naměřit přenosovou rychlost 9,53 Gbit/s.

Pokud byl mezi nimi navázán tunel pomocí WireGuardu v linuxovém jádře, přenášelo se v něm 2,66 Gbit/s. Stejný tunel implementovaný pomocí WireGuard-Go pak dosáhl na 2,42 Gbit/s, což není vůbec špatná hodnota a je překvapivě blízko té z linuxového jádra. Pokud jste čekali řádový rozdíl, asi jste příjemně překvapeni poklesem jen o 10 %.

Testy na čisté síti a uvnitř WireGuardu se ovšem lišily v podstatném parametru: hodnotě TCP Maximum segment size (MSS), která v případě čisté sítě činila 8949, zatímco uvnitř tunelu pak jen 1368. MSS určuje, jak velký segment dat je možné přenést v jednom TCP segmentu. Obecně platí, že čím více, tím lépe, protože každý segment sebou nese určitou režii v podobě hlaviček a potřebě zpracování v síťovém stacku.

Na třetí síťové vrstvě (IP) ovšem AWS dovoluje poslat pakety o velikosti (MTU) až 9001 bytů. Zdá se tedy, že tunel trestuhodně špatně využíval podkladovou síť a počítal se standardním MTU 1500. Dalším krokem tedy bylo zvýšení MTU uvnitř tunelu, což vedlo ke zvýšení propustnosti na 7,88 Gbit/s. Tedy na více než trojnásobek původní hodnoty.

To dokazuje, že režie spojená se zpracováním kratších segmentů je obrovská a má naprosto zásadní dopad na celkovou propustnost sítě. Nastal čas se podívat do hlubin linuxového jádra a zjistit, kde je úzké hrdlo.

Hledání brzdy

Linuxové jádro disponuje funkcí perf, která dovoluje analyzovat výkon a podrobně sledovat, kde se pálí nejvíce procesorového času. Z naměřených dat je možné vytvářet krásné interaktivní grafy, které pomohou zjistit, ve kterých voláních tráví jádro nejvíce času.

Pokud zanedbáme náročné kryptografické funkce, které prostě k fungování WireGuardu potřebujeme, pak nám z grafů vyplynou tři nejpomalejší volání: sendmsg() na UDP socketu, write() k ovladači TUN a read() z ovladače TUN. Na přijímací straně je to podobné: write() na TUN, recvmsg() na UDP socket a sendmsg() na UDP socket.

Z toho plyne, že režie spojená se zpracováním jednotlivého paketu je veliká. Předchozím zvýšením MTU na rozhraní TUN se snížil počet systémových volání na TUN a UDP socket, což pochopitelně vedlo k celkovému nárůstu výkonu. Otázka tedy zní: jak snížit počet těchto volání a zároveň zachovat rozumné MTU, které je použitelné v internetu?

TCP segmentace

TCP umožňuje přenos libovolného proudu dat mezi dvěma sockety. Uživatelská aplikace (proces) se nezabývá žádnými detaily přenosu, prostě jen požádá o otevření socketu a poté do něj zapisuje pomocí write() nebo z něj čte pomocí read(). Aplikace může zapsat dva bajty a hned potom dva tisíce dalších, neřeší opakování ztracených segmentů, změnu pořadí či signalizaci. To vše je schované za službou jádra, která se o všechno postará.

Zatímco aplikace může do „tunelu“ strkat libovolná data o různé velikosti, úlohou implementace TCP v jádře je tato data nasegmentovat tak, aby prošla celým síťovým prostředím. Existuje už zmíněný limit MSS, který je signalizován při třícestném handshaku na začátku TCP spojení.

Hodnota MSS je obvykle odvozena od MTU na nižší vrstvě v místním síťovém segmentu. MTU označuje maximální velikost segmentu na síťové vrstvě, zatímco MSS se týká vyšší vrstvy v podobě TCP. Obě strany si při seznámení sdělí svůj požadavek na limit MSS, přičemž nižší hodnota vyhrává. Obě strany se pak tímto omezením řídí a neposílají si větší segmenty.

Cílem je nepřekročit limit MTU, protože tak velký segment by sítí neprošel. Buď by byl po cestě fragmentován, tedy rozsekán na menší kousky s další režií, nebo by byl jednoduše zahozen a zpět by bylo signalizováno, že byla překročena velikost. V každém případě by to ale mělo negativní dopad na výkon linky, takže se tomu snaží obě strany vyhnout a limity nepřekračovat.

Implementace TCP v jádře se tedy snaží nekonečný proud dat zpracovat a rozumně ho nasegmentovat tak, aby hladce prošel sítí až do cíle. Zároveň se ale snaží nesegmentovat na příliš malé kousky, protože to přináší velikou režii. Pokud se podaří počet průchodů síťovým stackem snížit, klesá zatížení procesoru a zpoždění způsobené zpracováváním.

TCP Segmentation Offload (TSO)

Tady přichází ke slovu TCP Segmentation Offload (TSO), což je technika umožňující segmentaci podle MSS přenechat až síťovému zařízení. Do síťového stacku tak mohou vstupovat segmenty o velikosti 64 KB, které jsou před vstupem do sítě správně segmentovány dle aktuálního MSS. Nedávné úpravy v linuxovém jádře dokonce dovolují takto zpracovávat až 256KB segmenty. Větší kusy dat znamenají méně spáleného procesorového času na režii.

Pokud se používá TSO, musí do hry přijít také přídavná metadata, která popisují, jak je nastaveno MSS, tedy na jaké segmenty musí být později data rozdělena. Zároveň to znamená třeba přepočítat kontrolní součty pro TCP hlavičky, protože se mění velikost nákladu. Hlavičky jednotlivých paketů se tedy budou nakonec mírně lišit.

Pokud si představíme typickou síť s MTU na hodnotě 1500 a odpovídající TCP MSS na 1460, pak nám vyjde, že TSO dokáže snížit počet průchodů stackem 44×. To už rozhodně stojí za pozornost.

Na přijímací straně je pak možné využít Generic receive offload (GRO), což je vlastně totéž v opačném gardu. Síťové rozhraní sloučí příchozí pakety tak, aby vytvořilo původní proud dat, přičemž nerozbije implementaci TCP. Výsledkem je opět snížení zátěže při zpracování.

TSO a GRO v ovladači TUN

WireGuard-Go využívá ke své činnosti síťové rozhraní typu TUN. To funguje jako předávací bod mezi jádrem a uživatelským procesem. Jakmile dorazí provoz na rozhraní, proces si jej může vyzvednout a libovolně zpracovat. Totéž v opačném směru, pokud potřebuje proces dostat provoz do sítě, může ho zapsat do zařízení typu TUN a tím se dostane do jádra.

Vývojáři Tailscale se začali zajímat o kód ovladače TUN, konkrétně o funkci set_offload(), která umožňuje zapnout TSO a GRO pomocí ioctl(). Překvapivě nejde o žádnou novinku, tahle funkce je v linuxovém jádře od verze 2.6.27 z roku 2008, jen zůstala nepovšimnuta a používá se jen uvnitř jádra ve frameworku VirtIO, pro který byla původně napsána.

Pokud se na rozhraní zapnou TSO a GRO, stane se přijímající proces zodpovědným za zpracování datového proudu, tedy za výše popsanou segmentaci při odesílání a slučování na přijímací straně. WireGuard-Go tedy vlastně sedí mezi rozhraním TUN a odesílajícím UDP socketem. Pokud přijme provoz přes TUN, zařídí jeho segmentaci, zašifrování a odeslání pomocí UDP. Stejně tak obráceně, data přijatá přes UDP sloučí, dešifruje a předá do síťového stacku pomocí TUN.

Odeslání a přijetí více paketů naráz

Dalším dílkem do výkonnostní skládačky jsou volání sendmmsg() a recvmmsg(), která dokáží odeslat a přijmout více paketů najednou z UDP socketu během jednoho jaderného volání. Pokud jsme tedy dostali větší množství dat z rozhraní TUN, máme také několik paketů k odeslání a dokážeme je odeslat do UDP na jedno volání.

Stejně tak při přijetí po UDP můžeme slučovat pakety do jednoho proudu a je tu tedy potenciál ke snížení počtu volání jádra, pokud dostaneme víc paketů najednou. Teprve po jejich sloučení a zpracování je pak zase jako velký balík dat odešleme najednou do rozhraní TUN.

Pokud tohle dáme dohromady s TSO a GRO, můžeme výrazně snížit počet volání jádra na obou stranách zpracování uvnitř procesu WireGuard-Go. Můžeme tak data rychleji načítat, ale i odesílat, ať už směrem od TUN k UDP nebo obráceně z UDP k TUN. Mělo by to mít velký dopad na celkový výkon při zpracování dlouhého proudu dat.

Dvojnásobné zrychlení

Pokud se všechny zmíněné techniky použijí ve WireGuard-Go, výsledkem je více než dvojnásobné zrychlení. Takto upravený proces je schopen v testu pomocí iperf dosáhnout na 5,36 Gbit/s, což je 2,2× více než v původním stavu. Navíc je MSS nastaveno na 1368, což zachová průchodnost běžným internetem a neomezí nás na uzavřené prostředí vlastní sítě.

Paradoxně se tak výkon implementace v uživatelském prostoru dostal na dvojnásobek výkonu současné implementace v linuxovém jádře. 

bitcoin školení listopad 24

Porovnání výkonu jednotlivých implementací WireGuardu

Autor: Tailscale

Tohle je tedy současný stav, ale dá se předpokládat, že podobně bude upravena i implementace v linuxovém jádře. Může se k datovému proudu chovat úplně stejně a dosáhnout stejného navýšení datového toku. Dokonce už je to součástí plánů na zvýšení výkonu.

Ukázalo se, že brzdou při využití sítě je pomalé rozhraní linuxového jádra a že by to chtělo něco rychlejšího. Naštěstí už takové rozhraní vlastně existuje velmi dlouho a stačilo ho jen správně využít. Jak píší Jordan Whited a James Tucker z Tailscale v zápisku na firemním blogu: Uživatelský prostor není pomalý, jen některá volání linuxového jádra ano.

Autor článku

Petr Krčmář pracuje jako šéfredaktor serveru Root.cz. Studoval počítače a média, takže je rozpolcen mezi dva obory. Snaží se dělat obojí, jak nejlépe umí.