Slutrapport, Projekt Hiper. Oktober 2006 Bakgrund libcurl är ett utvecklingsbibliotek för filöverföringar som stöder HTTP, HTTPS, FTP, FTPS, FILE, TELNET, DICT m.fl. Följande rapport är skriven utan att alltför mycket förkunskaper om libcurl måste finnas hos läsaren, men då libcurl är ett väletablerat och gammalt projekt (startades 1998) så kan jag inte beskriva samtliga detaljer här. Grundfakta kan inhämtas från libcurls webbsajt: http://curl.haxx.se/. Inför projektet Den 24:e oktober 2005 tog Daniel emot ett diplom och det officiella beskedet om det beviljade ekonomiska stödet från II Stiftelsen, under Internetdagarna 2005. Projektet fick namnet Hiper av Daniel, som en förkortning av High Performance projektet syftar till att optimera libcurls prestanda vid väldigt många samtidiga överföringar. I projektet lade Daniel till en punkt (nr 3) som inte fanns med i hans ansökan, så att de blev tre till antalet: 1. nytt API för att undvika select() relaterade problem 2. support för HTTP pipelining 3. ett zero copy interface Redan innan detta bidrag beviljades hade projektet efter många och långa diskussioner en ide om hur API:t skulle utformas för att bli så bra som möjligt, men under projektets gång kom det ändå att omformas och justeras på flera sätt flera gånger. Min plan fick en extrapunkt (zero copy interface) i ett infall av ambition som jag hade precis i början, men jag gjorde under projektets tid bedömningen att detta inte är en speciellt viktig förändring eller någon som efterfrågas på något sätt, så för att spara tid och verkligen komma i mål snyggt så strök jag denna punkt igen och lämnar den till att implementeras i framtiden istället. Mätningar av existerande läge (De mätningar och tider som refereras till i följande rapport är gjorda på en 2083 Mhz AMD Athlon XP 2800+ körandes Linux 2.6. Testerna har gjorts utan att swap använts, allting har fått plats i RAM.) Projektet började med ganska detaljerade mätningar på det existerande gränssnittet och exakt hur lång 1 av 5
(extra ) tid det tar pga den select() centrerade designen. Problemen med select() är i korthet: 1. stöder endast ett (compile time) begränsat antal sockets 2. när man får veta att en eller flera sockets har trafik, kan man inte enkelt får reda på vilken det är utan man måste iterera över allihopa för att ta redan på vilken eller vilka det var 3. långsamt ju fler sockets man lägger till ju längre tar varje anrop till select() 4. det är också ett problem i sig att vi är låsta till att applikationen måste använda select() eftersom det inte passar alla Det existerande gränssnittet krävde att applikationen först gjorde ett anrop till curl_multi_fdset() för att sedan kunna göra select() för att vänta på trafik. När trafik noterats, eller timeout inträffat, skall curl_multi_perform() anropas. Då går libcurl igenom samtliga koppel och gör vad som skall göras, vilket inkluderar läsning och skrivning av de koppel som är redo. Mätningarna visade att vid 9000 koppel utan trafik och en med konstant trafik, så tog enbart select() 8ms per anrop och curl_multi_perform() 32 ms (libcurl var då patchad att endast läsa max 1 byte per anrop). Vi såg också att tiden dessa anrop tar är direkt proportionell med antalet sockets/koppel. 20000 Illustration 1: tidsåtgång per funktionsanrop koppel skulle alltså ta totalt 89 ms per anrop! Det gör förstås att vi maximalt gör recv() anrop med en frekvens på cirka 11Hz, vilket vid full datahastighet på det enda kopplet med trafik (och 16KB läsbuffrar som libcurl använder) endast ger 180KB/sekund. Design av curl_multi_socket() För att undvika att select() måste användas så måste libcurl exponera exakt de sockets som den använder, och huruvida applikationen ska vänta på att socket blir skrivbar, läsbar eller både och. Vi vill heller inte att applikationen ska behöva plocka fram alla sockets i stil med curl_multi_fdset() då det ju också blir en massa jobb att utföra repetetivt. Slutsatsen blev att den nya funktionaliteten vi gör, måste informera applikationen om socket förändringar 2 av 5
med hjälp av en callback. Då endast förändringar rapporteras kan applikationen hela tiden hålla kvar information de sockets den redan fått info om, och se till att vänta på trafik på dem enligt samma info. Vi måste också tillåta att applikationen berättar för libcurl exakt vilket socket som den har noterat trafik på, och då skall libcurl kunna använda den direkt utan att behöva leta igenom tusentals andra. Alternativ till select() Vi har svängt in på och fokuserat ganska mycket på att se till att vi har ett API som är lätt att använda tillsammans med libevent 1. Men genom att göra så, gör vi det också det enkelt att använda andra eventbaserade system i samma anda. Helt enkelt system som kan vänta på trafik på en eller flera sockets och berätta exakt vilken som hade trafik (typ kqueue, epoll och liknande). På libevents webbsajt ser vi en graf över hur libevent skalar i jämförelse med select. Det framgå r inte exakt på vilken typ av maskin som tiderna är uppmätta, men det finns anledningar att tro att det är en långsammare maskin än den jag använt. Siffrorna där visar en konstant användning av 50 mikrosekunder för det enda aktiva kopplet, hur många samtidiga tysta koppel man än lagt till. Jag beslöt mig för att inte försöka upprepa libevents mätningar utan tror på dem, mest för att jag inte är insatt i dess interna funktionalitet och det därmed är lite svårt att göra bra Illustration 2: prestanda graf från libevents webbsajt mätningar. Test av curl_multi_socket() Då vi räknade med att undvika select() (genom t.ex libevent), byggde jag en applikation som trots allt var baserad på select() men endast för utvecklingsfasen. Sedan såg jag till att mäta tiden curl_multi_socket() tog på sig att hantera det enda koppel som hade trafik. Resultatet var mycket inspirerande: det tog i genomsnitt 7 mikrosekunder (fortfarande patchad att endast läsa en byte). De första testerna var dock fortfarande rätt simpla och implementerade inte hela API:et på rätt sätt. För att det skulle fungera rätt så var jag tvungen att implementera en hash uppslagning för socket => intern struct plus att jag stoppade in en hantering av timeout (med hjälp av splay träd) så att applikationen kan fråga 1 http://www.monkey.org/~provos/libevent/ 3 av 5
libcurl hur länge den bör vänta som längst innan den anropar libcurl (för intern timeout och omförsökshantering etc). Denna extra hantering, samt att vi är tvungna att kontrollera och hantera eventuella timeouter i varje anrop till curl_multi_socket() lade på några mikro mer anrop. Jämförelse av curl_multi_perform och curl_multi_socket Med ett aktiv koppel och ett stort antal koppel så ligger vi nu nästan konstant på totalt mindre än 60 mikrosekunder (när vi läser en enda byte) per event, tiden växer lite långsamt pga hashtabellsuppslagningen. För 10000 koppel var vi alltså fortfarande under 10 mikrosekunder per curl_multi_socket anrop. I ett extremfall med 20000 tysta koppel och ett som är aktivt, och räknat på att läsningen av data går lika fort för 16KB som för 1 byte (vilket ju inte är fallet i verkligheten, men skillnaden kan ignoreras för tillfället) så når vi en anropsfrekvens på 16666 Hz, vilket motsvarar en datahastighet på 260MB/sekund. Skillnaden i hastighet mellan det gamla gränssnittet och det nya är alltså uppemot faktor 1:1500! HTTP Pipelining När prestandan på socket nivå förbättrats gick jag vidare till nätverks nivån och implementerade stöd för HTTP Pipelining. Pipelining betyder här i korthet att man skickar flera förfrågningar på en gång, utan att invänta svaret, och sedan tar man emot flera svar när de väl kommer. Den stora fördelen kommer förstås när man vill hämta många små filer från servrar med lång fördröjning (latency). HTTP Pipelining introducerades med HTTP 1.1 och skall stödjas av alla HTTP servrar som implementerar 1.1, vilket i dagens läge är i princip alla. För att möjliggöra pipelining fick jag först se till att ett multi handle (dvs det objekt som används när man använder libcurls mult interface, oavsett om man använder det gamla curl_multi_perform eller den nya curl_multi_socket) fick en gemensam koppel cache. Detta för att möjliggöra att man lägger till nya förfrågningar som ska kunna koppla på sig på en redan existerande för att bilda kedjor pipelines. Resultat I och med curl releasen 7.16.0 (släppt 30 oktober 2006) är både curl_multi_socket() och Pipelining supportat. Därmed är projekt Hiper i mål! Idag, den 25 november 2006 skickar jag slutrapporten. 4 av 5
Användning av tillfördelade medel Daniel har under det gångna året fått totalt 150000 SEK, fördelat på tre stycken utbetalningar, från II Stiftelsen för genomförandet av detta projekt. Pengarna har används till att köpa RAM minne till utvecklingsdatorn samt till den allra största delen ersätta arbetstid för att möjliggöra att Daniel arbetat deltid med projekt Hiper under praktiskt taget ett helt år. Tack! Jag hade självklart inte kunnat genomföra detta utan II Stiftelsens generösa gåva. Jag har även haft stor hjälp och input från följande individer: Ravi Pratap, Jamie Lokier, Sun Yi Ming, Jeff Pohlmeyer, Alexander Lazic 5 av 5