C ++ optimalisaasje: kombinearje snelheid en heech nivo. Yandex rapport

Wat beynfloedet de snelheid fan C ++ programma en hoe te berikken it op in hege koade nivo? Leadûntwikkelder fan 'e CatBoost-bibleteek Evgeniy Petrov beantwurde dizze fragen mei foarbylden en yllustraasjes út syn ûnderfining dy't wurke oan CatBoost foar x86_64.

Fideo ferslach

Spielje fideo


- Hoi allegearre. Ik optimalisearje de CatBoost-masine-learbibleteek foar CPU. It haaddiel fan ús bibleteek is skreaun yn C++. Hjoed sil ik jo fertelle hokker ienfâldige manieren wy snelheid berikke.

C ++ optimalisaasje: kombinearje snelheid en heech nivo. Yandex rapport

De snelheid fan berekkeningen bestiet út twa dielen. It earste diel is it algoritme. As wy in flater meitsje by it kiezen fan in algoritme, dan kinne wy ​​it net fluch meitsje. It twadde diel is hoe optimalisearre ús algoritme is foar it komputersysteem dat wy hawwe, mei syn prestaasjes en trochset.

C ++ optimalisaasje: kombinearje snelheid en heech nivo. Yandex rapport

Gegevensútwikseling en berekkeningen moatte apart rekken holden wurde fanwegen it grutte ferskil yn har snelheid. As wy de snelheid fan ûnthâld beskôgje as de snelheid fan in fuotgonger, dan is de berekkeningssnelheid sawat de krússnelheid fan in passazjiersfleantúch.

Om dit ferskil glêd te meitsjen, hat de arsjitektuer ferskate nivo's fan caching. De fluchste en lytste is L1-cache. Dan is d'r in grutter en stadiger cache op it twadde nivo. En d'r is in heul grutte cache, dy't tsientallen megabytes kin wêze, in cache op tredde nivo, mar it is de stadichste.

C ++ optimalisaasje: kombinearje snelheid en heech nivo. Yandex rapport

Fanwegen de ferskillende snelheid fan gegevensútwikseling fan berekkeningen is de berekkeningskoade ferdield yn twa klassen. Ien klasse wurdt beheind troch bânbreedte, dat is de snelheid fan gegevens útwikseling. De twadde klasse wurdt beheind troch de snelheid fan 'e prosessor. De grins tusken harren wurdt ynsteld ôfhinklik fan it oantal operaasjes dy't wurde útfierd mei ien byte fan gegevens. Dit is normaal in koade-spesifike konstante.

De measte fan 'e swiere berekkeningskoade is in lange tiid skreaun, is tige goed optimalisearre, en d'r binne in grut oantal bibleteken, dus it makket sin, as jo swiere berekkening yn jo koade sjogge, om te sykjen nei in bibleteek dy't kin dwaan it foar dy.

C ++ optimalisaasje: kombinearje snelheid en heech nivo. Yandex rapport

Fan 'e rest kinne gearstallers net alles dwaan, om't in heul beheind persintaazje boarnen wurdt bestege oan har ûntwikkeling. Hokker fan harren ûntwikkelje hjoed mear of minder aktyf, dat is, se stypje noarmen en besykje se te kontrolearjen? Dit is in frontend EDG dy't brûkt wurdt yn ferskate derivatives, lykas de Intel-kompiler; LLVM; GNU en frontend Microsoft.

Om't d'r in pear fan binne, stypje kompilatoren allinich frekwinsjekontrôlepatroanen en gegevensôfhinklikens. As wy nei kontrôle sjogge, dan binne dit lineêre seksjes en ienfâldige syklusen, dat is in folchoarder fan ynstruksjes en werhelling. Se leare frekwinsjeôfhinklikens fan gegevens fan reduksje, as wy, sizze, in protte eleminten yn ien opsomje, ynstoarten en elemint-by-elemint operaasjes útfiere op ien of mear arrays.

Wat bliuwt foar ûntwikkelders? Dit kin rûchwei ferdield wurde yn fjouwer dielen. De earste is de applikaasje-arsjitektuer; gearstallers kinne it gewoan net foar ús opkomme.

C ++ optimalisaasje: kombinearje snelheid en heech nivo. Yandex rapport

Parallelisaasje is ek in lestich ding foar gearstallers. Wurkje mei ûnthâld - om't it echt lestich is: jo moatte rekken hâlde mei de arsjitektuer, en parallelisaasje, en alles byinoar. Derneist wite kompilatoren net hoe't de kwaliteit fan optimalisaasje goed evaluearje kin, hoe fluch de koade is. Wy, de ûntwikkelders, moatte dit ek dwaan, in beslút nimme - om fierder te optimalisearjen of te stopjen.

Oan 'e arsjitektuerkant sille wy sjen nei de amortisaasje fan' e overhead, de firtuele oproppen, wêrop in protte fan 'e arsjitektuer basearre is.

Litte wy parallelisaasje út 'e fergeliking litte. Oangeande it brûken fan ûnthâld: dit is ek yn in sin, ôfskriuwing en korrekt wurk mei gegevens, harren krekte pleatsing yn it ûnthâld. Yn termen fan it evaluearjen fan effisjinsje, sille wy prate oer profilearjen en hoe't jo kinne sykje nei knelpunten yn 'e koade.

It brûken fan ynterfaces en abstrakte gegevenstypen is ien fan 'e fûnemintele ûntwerptechniken. Litte wy nei ferlykbere berekkeningskoade sjen fan masine learen. Dit is in betingstkoade dy't de prognose bywurket mei in gradientmetoade.

C ++ optimalisaasje: kombinearje snelheid en heech nivo. Yandex rapport

As wy in bytsje nei binnen sjogge en besykje te begripen wat der binnen bart, hawwe wy in IDerCalcer-ynterface foar it berekkenjen fan derivatives fan 'e ferliesfunksje, en in funksje dy't de prognose (ús foarsizzing) ferskowt yn oerienstimming mei de gradient fan' e ferliesfunksje.

Oan de rjochterkant fan de slide kinne jo sjen wat dit betsjut foar de twadiminsjonale gefal. En yn masine learen is de grutte fan 'e prognose net twa of trije, mar miljoenen, tsientallen miljoenen eleminten. Litte wy sjen hoe goed dizze koade is foar in fektor fan sawat 10 miljoen eleminten.

C ++ optimalisaasje: kombinearje snelheid en heech nivo. Yandex rapport

Litte wy de standertdeviaasje nimme as de doelfunksje en mjitte hoe't it wurket, hoe lang it sil nimme om dizze prognose te ferskowen. De ôflieding fan dizze objektive funksje wurdt werjûn op 'e dia. De wurktiid op in betingstmasine, dy't dan fêst bliuwt, is 40 ms.

C ++ optimalisaasje: kombinearje snelheid en heech nivo. Yandex rapport

Litte wy besykje te begripen wat hjir mis is. It earste ding dat de oandacht lûkt is firtuele petearen. As jo ​​sjogge nei de profiler, kinne jo sjen dat ôfhinklik fan it oantal parameters, dit giet om fiif oant tsien ynstruksjes. En as, lykas yn ús gefal, it berekkenjen fan 'e derivative sels mar twa arithmetyske operaasjes is, dan kin dit maklik in wichtige overhead wêze. Foar in grut lichem by it berekkenjen fan derivaten is dit ca. Foar in koarte lichem dat de derivative berekkent - sis net iens 500 ynstruksjes, mar 20, 50 of noch minder - sil dit yn 'e tiid al in signifikant persintaazje wêze. Wat te dwaan? Litte wy besykje de firtuele funksje-oprop te amortisearje troch de ynterface te feroarjen.

C ++ optimalisaasje: kombinearje snelheid en heech nivo. Yandex rapport

Yn earste ynstânsje berekkene wy ​​derivatives puntsgewijs, foar elk elemint fan 'e fektor apart. Litte wy oergean fan elemint-foar-elemint-ferwurking nei fektorferwurking. Lit ús nimme in standert C ++ sjabloan wêrmei jo te wurkjen mei in sicht op in vector. Of, as jo kompilator de lêste standert net stipet, kinne jo in ienfâldige selsmakke klasse brûke dy't in oanwizer opslaat foar de gegevens en grutte. Hoe sil de koade feroarje? Wy sille bliuwe mei ien oprop dy't berekkent derivaten, en dan sille moatte tafoegje in lus dat sil eins bywurkje de prognose.

C ++ optimalisaasje: kombinearje snelheid en heech nivo. Yandex rapport

Neist it tafoegjen fan in syklus sille wy ek in twadde kear nei de gegevens moatte sjen, dat is, de prognosevektor sels lêze en de gradientvektor dy't wy krekt in twadde kear berekkene hawwe.

C ++ optimalisaasje: kombinearje snelheid en heech nivo. Yandex rapport

Litte wy it nochris op deselde masine besykje en sjen dat it slimmer útkaam, der gie wat mis. Litte wy útfine wat der bard is mei de koade.

C ++ optimalisaasje: kombinearje snelheid en heech nivo. Yandex rapport

D'r is gjin punt om de syklus fan neat te fermoedzjen, om't dit krekt itselde frekwinsjepatroan is dat kompilatoren goed werkenne en optimalisearje. It oantal operaasjes per gegevenselemint dêr sil minder wêze as de kosten fan in firtuele oprop.

C ++ optimalisaasje: kombinearje snelheid en heech nivo. Yandex rapport

Mar it meitsjen fan in grutte fektor en kearen troch it trochgean - dit is wêr't jo in probleem moatte fermoedzje. Foar in begripe wêrom't dit is min en fertraget, Jo moatte yntinke wat der bart yn it ûnthâld as de koade wy sjogge op de dia oan de rjochterkant rint.

C ++ optimalisaasje: kombinearje snelheid en heech nivo. Yandex rapport

As de fektor fan derivaten wurdt berekkene, komt it ta in lus dy't de prognose ferskowt. Foar dizze syklus sil allinich in heul lyts diel fan 'e gegevens yn' e flugge LXNUMX-cache bliuwe, dy't rint op prosessorsnelheid. Op de slide is it grien by in ferkearsljocht. De oerbleaune gegevens wurde út 'e cache yn it ûnthâld skood, en as de syklus begjint mei it bywurkjen fan de prognosen, moatte de gegevens in twadde kear út it ûnthâld lêzen wurde. Mar foar ús wurket it yn 't algemien hiel stadich, op kuiersnelheid.

C ++ optimalisaasje: kombinearje snelheid en heech nivo. Yandex rapport

As wy in prognose bywurkje, hoege wy net alle derivatives tagelyk te lêzen. It is genôch om se yn grutte pakketten te tellen om firtuele petearen op te nimmen. Dêrom is it logysk om de berekkening fan derivaten te splitsen en de prognose te aktualisearjen yn lytse blokken en dizze twa aksjes te mingjen. Wat sil dit liede ta as wy sjogge nei wêr't de gegevens sille wurde lêzen út?

C ++ optimalisaasje: kombinearje snelheid en heech nivo. Yandex rapport

Dit sil liede ta it feit dat wy de hiele tiid gegevens sille nimme, en ta it feit dat de gegevens yn 'e L1-cache bliuwe en gjin tiid hawwe om yn traach ûnthâld te gean. En dan moatte wy begripe wa't ús dizze blokgrutte sil fertelle.

C ++ optimalisaasje: kombinearje snelheid en heech nivo. Yandex rapport

It is logysk om dit oan 'e derivative rekkenmasine sels te fertrouwen, om't allinich hy wit hoefolle cache hy nedich is. Folgjende moatte wy de lus opnij skriuwe dy't troch de array socht. It moat wurde ferdield yn twa. De bûtenste lus sil troch de blokken gean, en binnen sille wy twa kear troch de eleminten fan it blok gean.

C ++ optimalisaasje: kombinearje snelheid en heech nivo. Yandex rapport

Hjir is it, ekstern yn blokken.

C ++ optimalisaasje: kombinearje snelheid en heech nivo. Yandex rapport

En hjir is de ynterne foar de eleminten fan it blok.

C ++ optimalisaasje: kombinearje snelheid en heech nivo. Yandex rapport

Wy nimme rekken mei dat it lêste blok kin wêze ûnfolslein.

C ++ optimalisaasje: kombinearje snelheid en heech nivo. Yandex rapport

Litte wy sjen wat der fan komt. Wy sjogge dat wy goed rieden, goed begrepen wat der bart, en op kosten fan frij lytse feroarings yn 'e koade, hawwe wy de operaasjetiid mei acht prosint fermindere. Mar wy kinne noch mear. Wy moatte nochris kritysk sjen nei wat wy skreaun hawwe. Sjoch nei de funksje dy't derivatives foar ús berekkent. It jout ús in fektor fan derivaten wêrfan de eleminten traach sille wêze om tagong te krijen yn ûngeunstige situaasjes.

C ++ optimalisaasje: kombinearje snelheid en heech nivo. Yandex rapport

D'r binne hjir twa redenen. Earst, it pleatsen fan de vector op 'e heap. De kâns is grut dat dizze vector wurdt makke en ferneatige protte kearen. De twadde neidiel yn termen fan snelheid is dat eltse kear wy sille ûntfange ûnthâld, wierskynlik op in nij adres. Dit ûnthâld sil "kâld" wêze fanút it eachpunt fan 'e cache, wat betsjuttet dat foardat it skriuwt, de prosessor in auxiliary read sil útfiere om de gegevens yn' e cache te inisjalisearjen.

Om dit te reparearjen, moatte jo de tawizing út 'e loop nimme. Om dit te dwaan, moatte wy de ynterface wer feroarje, stopje mei it werombringen fan vectoren en begjinne te skriuwen derivatives yn it ûnthâld dat wy ûntfange fan 'e opropkoade.

C ++ optimalisaasje: kombinearje snelheid en heech nivo. Yandex rapport

Dit is in standert technyk - it ferwiderjen fan alle manipulaasjes mei boarnen fan knelpunten yn 'e komputerkoade. Litte wy noch ien parameter tafoegje oan 'e CalcDer-metoade, werjefte fan' e fektor wêr't de derivatives moatte gean.

C ++ optimalisaasje: kombinearje snelheid en heech nivo. Yandex rapport

De koade sil ek feroarje op foar de hân lizzende manieren. De vector fan derivatives sil ien wêze, bûten alle loops, en in nije parameter sil gewoan wurde tafoege oan 'e metoade.

C ++ optimalisaasje: kombinearje snelheid en heech nivo. Yandex rapport

Litte wy sjen. It docht bliken dat wy hawwe opdien sa'n acht prosint mear yn ferliking mei de foarige ferzje, en yn ferliking mei de basis ferzje - al 15%.

It is dúdlik dat optimisaasjes net beheind binne ta amortisaasje fan overheadkosten, dat d'r oare soarten knyppunten binne.

C ++ optimalisaasje: kombinearje snelheid en heech nivo. Yandex rapport

Om te yllustrearjen hoe't jo nei knelpunten sykje, hawwe wy noch ien ienfâldige testkoade nedich. Bygelyks, ik naam de matrix transpose. Wy hawwe in matrix approx en in matriks approxByCol wêr't wy de transponearre gegevens moatte pleatse. En in ienfâldich nêst fan twa loops. D'r binne hjir gjin firtuele oproppen of vector-skepping. It is gewoan it oerdragen fan gegevens. De loop is relatyf kompilerfreonlik.

Lit ús mjitte hoe't dizze koade wurket op in frij grutte matrix en op in spesifike masine.

C ++ optimalisaasje: kombinearje snelheid en heech nivo. Yandex rapport

Bygelyks, ik naam it oantal rigen te wêzen 1000, it oantal kolommen te wêzen 100 000. De masine is in Intel tsjinner, ien kearn. Unthâld is krekt sa, dit is wichtich foar ús, omdat alle wurk mei ûnthâld en snelheid sil ôfhingje fan de snelheid fan ûnthâld. Wy mjitten it en krigen 1,4 s. Is it in protte of in bytsje? Wat dogge wy yn dizze tiid?

C ++ optimalisaasje: kombinearje snelheid en heech nivo. Yandex rapport

Wy slagje 800 megabytes te lêzen, dit is gjin transponearre matrix, mar de orizjinele. En ek lêze en skriuwe 1,6 GB, dit is al in transponearre matrix. De prosessor fiert in auxiliary read foar it skriuwen om de gegevens yn 'e cache te inisjalisearjen.

C ++ optimalisaasje: kombinearje snelheid en heech nivo. Yandex rapport

Litte wy berekkenje hoefolle bânbreedte wy brûkber brûkten. It docht bliken dat de trochfier fan ús koade wie 1,7 GB / s.

C ++ optimalisaasje: kombinearje snelheid en heech nivo. Yandex rapport

Dit wie in teoretyske berekkening. Dêrnei kinne jo in profiler brûke dy't de snelheid fan wurkjen mei ûnthâld kin mjitte. Ik naam VTune. Litte wy sjen wat hy toant. Toant in ferlykbere figuer - 1,8 GB. Yn prinsipe komt it goed oerien, want yn ús berekkening hawwe wy der net rekken mei hâlden dat wy de rigenadressen en kolomadressen lêze moatte. Plus, VTune logt eftergrûnaktiviteit yn it bestjoeringssysteem. Dêrom is ús model konsistint mei de realiteit.

Om te begripen oft 1,7 GB in protte of in bytsje is, moatte wy útfine wat de maksimale bânbreedte foar ús beskikber is.

Om dit te dwaan, moatte jo de spesifikaasjes fan 'e prosessor lêze. Der is in spesjale webside ark.intel.com, wêr kinne jo fine út alles oer eltse prosessor. As wy spesifyk nei ús server sjogge, sjogge wy dat it acht kearnen hat en it rapste DDR3-ûnthâld dat it stipet is by steat om oer te setten op sawat 60 GB / s ien manier.

C ++ optimalisaasje: kombinearje snelheid en heech nivo. Yandex rapport

Mar hjir moatte wy rekken hâlde dat wy mar ien kearn brûke en ús ûnthâld is stadiger, dat is, wy moatte dizze 60 GB skaalje oan ús betingsten yn ferhâlding mei it oantal kearnen en ûnthâldfrekwinsje.

It docht bliken dat ús koade 5,3 GB ien manier koe brûke. En om't jo parallel lêze en skriuwe kinne, ideaal, as wy gewoan gegevens fan plak nei plak kopiearje, soene wy ​​10,6 berikke. Sûnt wy hawwe twa lêzen en ien skriuwe, it moat wêze likernôch 8 GB / s. Wy ûnthâlde dat wy 1,7 krigen. Dat is, wy brûkten sa'n 20%.

Wêrom bart dit? Nochris moatte jo de arsjitektuer begripe. It feit is dat gegevens tusken ûnthâld en cache wurde oerdroegen net yn willekeurige pakketten, mar yn krekt 64 bytes, net mear en net minder. Dit is de earste konsideraasje.

C ++ optimalisaasje: kombinearje snelheid en heech nivo. Yandex rapport

De twadde konsideraasje is dat wy transponearre gegevens net sekwinsjele, mar willekeurich skriuwe, om't de rigen fan 'e matrix op in ûnfoarspelbere manier yn it ûnthâld lizze.

It docht bliken dat foar it skriuwen fan ien echt getal, wy moatte lêze 64 bytes oan gegevens. As wy de grutte fan 'e matrix as N oantsjutte, dan krije wy yn stee fan de optimale wurktiid (N/5,3 + N/10,6) (8*N/5,3 + N/10,6). Earne fjouwer oant fiif kear mear, dat ferklearret dizze 20% effisjinsje.

C ++ optimalisaasje: kombinearje snelheid en heech nivo. Yandex rapport

Wat der oan te dwaan? Jo moatte ophâlde mei it skriuwen fan gegevens ien kolom op in tiid en begjinne te skriuwen safolle kolommen as passe yn ien cache line (64 bytes). Om dit te dwaan, sille wy de lus lâns kolommen splitse yn in lus oer cache-rigels en in nêste lus oer cache-line-eleminten.

C ++ optimalisaasje: kombinearje snelheid en heech nivo. Yandex rapport

Hjir binne se, iteraasjes lâns cache rigels.

C ++ optimalisaasje: kombinearje snelheid en heech nivo. Yandex rapport

En hjir binne se, iteraasjes binnen de cacheline. Hjir, foar ienfâld, wy oannimme dat de gegevens binne ôfstimd op de cache line grins. Litte wy no kontrolearje wat der bart mei VTune.

C ++ optimalisaasje: kombinearje snelheid en heech nivo. Yandex rapport

Wy sjogge dat it resultaat tichtby de berekkene acht gigabyte per sekonde is - 7,6. Mar it is net in feit dat al dizze 7,6 nuttich wurk binne. Miskien binne guon fan harren boppe de holle.

Om te begripen hoefolle foardiel wy krigen, litte wy de wurktiid nei optimalisaasje mjitte. It docht bliken 0,5 s op deselde masine. De bânbreedte troch transposysje sels is 4,8 GB / s wurden. It is te sjen dat der noch in reserve is dy't wy net selektearre hawwe, mar dochs, fan 20 prosint effisjinsje krigen wy 60 prosint.

Mei help fan de profiler kinne wy ​​útfine wêrom't wy net krigen 80% of 95%.

C ++ optimalisaasje: kombinearje snelheid en heech nivo. Yandex rapport

It feit is dat wy matrices opslaan as in vector fan vectoren, dat is, wy brûke ûnthâld tagong mei dûbele ynrjochting.

C ++ optimalisaasje: kombinearje snelheid en heech nivo. Yandex rapport

Mei VTune kinne jo sjen hokker ynstruksjes wurde generearre om tagong te krijen ta array-eleminten. Ynstruksjes dy't de adressen fan 'e kolommen fan' e transponearre matrix lêze, wurde links yn giel markearre. En dit binne, yn it foarste plak, ekstra ynstruksjes, en twadde, ekstra gegevens oerdrachten. Mar wy sille net fierder optimalisearje, litte wy stopje en gearfetsje.

C ++ optimalisaasje: kombinearje snelheid en heech nivo. Yandex rapport

Wat haw ik dy hjoed ferteld? In nuttige tip foar it wurkjen mei berekkeningskoade is om te ferwurkjen yn blokken, amortisearjen fan de overhead dy't bygelyks assosjearre is mei firtuele oproppen. Plus, troch blokkearjen, ferbetteret de gegevenslokaasje, en wy krije hegere tagongsnelheden.

It fuortheljen fan allocaasjes út knelpunten is ek har amortisaasje. En ek tanimmende tagong snelheid troch fixing tydlike buffers yn it ûnthâld.

Oangeande profilearring. As earste is profilearjen in nuttige technyk foar it finen fan knelpunten "yn 't algemien." Twadder lit it ús de effisjinsje fan 'e koade evaluearje, beslute oft wy tefreden binne mei de snelheid of wolle fierder optimalisearje, en lit ús sjen yn hokker rjochting wy moatte bewegen.

Dat is alles foar my. As jo ​​​​CatBoost brûke of der foar it earst oer heard hawwe en wolle witte wat it is, lês dan artikels oer Habré, kom besykje ús by GitHub, Skriuw nei telegram. Tige tank foar jo oandacht.

Boarne: www.habr.com