Išmaniojo kontrakto fuzerio optimizavimas

Išmaniojo kontrakto fuzerio optimizavimas

Autorius Sam Alws

Žiemos metu taikiau kodo analizės įrankius, pvz., GHC Haskell profiliuotoją, kad pagerinčiau Echidna išmaniojo kontrakto fuzzerio efektyvumą. Dėl to Echidna dabar yra daugiau nei šešis kartus greitesnė!

Echidnos apžvalga

Norėdami naudoti Echidna, vartotojai pateikia išmaniąsias sutartis ir sąlygų, kurios turi būti įvykdytos, kad ir kas atsitiktų, sąrašą (pvz., „vartotojas niekada negali turėti neigiamo monetų skaičiaus“). Tada Echidna sugeneruoja daugybę atsitiktinių operacijų sekų, iškviečia sutartis su šiomis operacijų sekomis ir patikrina, ar sąlygos vis dar tenkinamos po to, kai sutartys įvykdomos.

Echidna naudoja aprėptį valdomą purškimą; tai reiškia, kad operacijų sekoms generuoti naudojamas ne tik atsitiktinumas, bet ir atsižvelgiama į tai, kokią sutarties kodo dalį pasiekė ankstesnės atsitiktinės sekos. Aprėptis leidžia greičiau rasti klaidas, nes ji teikia pirmenybę sekoms, kurios patenka į programą giliau ir paliečia daugiau jos kodo; tačiau daugelis vartotojų pastebėjo, kad Echidna veikia daug lėčiau, kai įjungta aprėptis (mano kompiuteryje daugiau nei šešis kartus lėčiau). Mano užduotis atliekant stažuotę buvo susekti lėto vykdymo laiko šaltinius ir pagreitinti Echidnos veikimo laiką.

Haskell programų optimizavimas

Haskell programų optimizavimas labai skiriasi nuo būtinų programų optimizavimo, nes vykdymo tvarka dažnai labai skiriasi nuo kodo rašymo tvarkos. Viena problema, kuri dažnai iškyla Haskell, yra labai didelis atminties naudojimas dėl tingaus vertinimo: skaičiavimai, pateikti kaip „tunks“, išsaugomi, kad būtų įvertinti vėliau, todėl krūva plečiasi, kol pritrūksta vietos. Kita paprastesnė problema yra ta, kad lėta funkcija gali būti iškviečiama pakartotinai, kai ją reikia iškviesti tik vieną kartą, o jos rezultatą išsaugoti vėlesniam naudojimui (tai yra bendra programavimo problema, kuri nėra būdinga Haskell). Derindamas Echidną turėjau spręsti abi šias problemas.

Haskell profiliuotojas

Viena iš Haskell ypatybių, kuria plačiai naudojau, buvo profiliavimas. Profiliavimas leidžia programuotojui pamatyti, kurios funkcijos užima daugiausiai atminties ir procesoriaus laiko, ir pažvelgti į liepsnos grafiką, rodantį, kurios funkcijos iškviečia kurias kitas funkcijas. Naudoti profiliuotoją yra taip paprasta, kaip pridėti vėliavėlę kompiliavimo metu (-prof) ir kita vėliavėlių pora vykdymo metu (+RTS -p). Tada, atsižvelgiant į sugeneruotą paprasto teksto profilio failą (kuris yra labai naudingas pats savaime), naudojant šį įrankį galima sudaryti liepsnos grafiką; štai pavyzdys:

Šis liepsnos grafikas rodo, kiek laiko užėmė kiekviena funkcija. Kiekviena juosta reiškia funkciją, o jos ilgis parodo, kiek laiko ji užtruko. Ant kitos juostos sukrauta juosta reiškia vieną funkciją, iškviečiančią kitą. (Juostų spalvos parenkamos atsitiktinai dėl estetinių priežasčių ir aiškumo.)

Profiliuose, sugeneruotuose paleidus Echidna pavyzdinėse įvestyse, buvo rodomos dažniausiai numatytos funkcijos: funkcijos, kurios vykdo išmaniąsias sutartis, funkcijos, generuojančios įvestis ir pan. Tačiau viena, kuri patraukė mano dėmesį, yra funkcija, vadinama getBytecodeMetadata, kuris nuskaito sutarties baitų kodus ir ieško skyriaus, kuriame yra sutarties metaduomenys (pavadinimas, šaltinio failas, licencija ir kt.). Šią funkciją reikėjo iškviesti tik kelis kartus, kai pradėjo veikti fuzeris, tačiau ji sunaudojo didelę procesoriaus ir atminties dalį.

Atminties taisymas

Ieškodamas kodų bazėje radau problemą, lėtinančią vykdymo laiką: getBytecodeMetadata funkcija pakartotinai iškviečiama tą patį mažą sutarčių rinkinį kiekviename vykdymo cikle. Išsaugodami grąžinamąją vertę nuo getBytecodeMetadata ir vėliau, užuot perskaičiavę, peržiūrėję, galėtume žymiai pagerinti kodų bazės vykdymo laiką. Ši technika vadinama atmintinė.

Pridėjęs pakeitimą ir išbandęs jį su kai kurių pavyzdinėmis sutartimis, sužinojau, kad vykdymo laikas sumažėjo iki mažiau nei 30 % pradinio laiko.

valstybinis pataisymas

Kita problema, kurią radau, buvo susijusi su Ethereum operacijomis, kurios vykdomos ilgą laiką (pvz., a for kilpa su skaitikliu iki vieno milijono). Šių operacijų nepavyko apskaičiuoti, nes Echidna pritrūko atminties. Šios problemos priežastis buvo atsainus Haskello vertinimas, pripildęs krūvą neįvertintų šūvių.

Laimei, šios problemos sprendimą kažkas jau rado ir pasiūlė GitHub. Pataisymas buvo susijęs su Haskell’s State duomenų tipas, kuris naudojamas tam, kad būtų patogiau (ir mažiau išsamiai) rašyti funkcijas, kurios pereina aplink būsenos kintamuosius. Pataisymas iš esmės yra vengimas naudoti State duomenų tipą tam tikroje funkcijoje ir rankiniu būdu perduodant būsenos kintamuosius. Pataisa nebuvo įtraukta į kodų bazę, nes ji davė skirtingus rezultatus nei dabartinis kodas, nors tai turėjo būti paprasta našumo pataisa, kuri neturėjo įtakos elgesiui. Išsprendęs šią problemą ir išvalęs kodą, sužinojau, kad tai ne tik išsprendė atminties problemą, bet ir pagerino Echidnos greitį. Išbandęs pavyzdinių sutarčių pataisą, sužinojau, kad vykdymo laikas paprastai sumažėjo iki 50 % pradinio laiko.

Norėdami paaiškinti, kodėl šis pataisymas suveikė, pažvelkime į paprastesnį pavyzdį. Tarkime, kad turime šį kodą, kuris naudoja State duomenų tipas, kad galėtumėte paprastai pakeisti visų skaičių būseną nuo 50 milijonų iki 1:

import Control.Monad.State.Strict

-- if the state is even, divide it by 2 and add num, otherwise just add num
stateChange :: Int -> Int -> Int
stateChange num state
| even state = (state div 2) + skaičius
| kitaip = būsena + skaičius

stateExample :: Int -> State Int Int
stateExample 0 = get
stateExample n = modify (stateChange n) >> stateExample (n - 1)

main :: IO ()
main = print (execState (stateExample 50000000) 0)

Ši programa veikia gerai, bet sunaudoja daug atminties. Parašykime tą pačią kodo dalį be State duomenų tipas:

stateChange :: Int -> Int -> Int
stateChange num state
| even state = (state div 2) + skaičius
| kitaip = būsena + skaičius

stateExample’ :: Int -> Int -> Int
stateExample’ state 0 = state
stateExample’ state n = stateExample’ (stateChange n state) (n - 1)

main :: IO ()
main = print (stateExample’ 0 50000000)

Šis kodas naudoja daug mažiau atminties nei originalus (46 KB, palyginti su 3 GB mano kompiuteryje). Taip yra dėl Haskell kompiliatoriaus optimizavimo. Sudariau su -O2 vėliava ghc -O2 file.hs; ./file, or ghc -O2 -prof file.hs; ./file +RTS -s atminties paskirstymo statistikai.

Neoptimizuota, antrojo pavyzdžio skambučių grandinė turėtų būti stateExample’ 0 50000000 = stateExample’ (stateChange 50000000 0) 49999999 = stateExample’ (stateChange 49999999 $ stateChange 50000000 0) 49999998 = stateExample’ (stateChange 49999998 $ stateChange 49999999 $ stateChange 50000000 0) 49999997 = …. Atkreipkite dėmesį į nuolat augančią (... $ stateChange 49999999 $ stateChange 50000000 0) terminas, kuris plečiasi užpildydamas vis daugiau atminties, kol galiausiai priverstas vieną kartą įvertinti n pasiekia 0.

Tačiau Haskell kompiliatorius yra protingas. Jis supranta, kad galutinės būsenos galiausiai vis tiek prireiks, ir sugriežtina šį terminą, todėl neužima daugybės atminties. Kita vertus, kai Haskell sudaro pirmąjį pavyzdį, kuriame naudojamas State duomenų tipą, yra per daug abstrakcijos sluoksnių ir jis negali suprasti, kad tai gali padaryti (... $ stateChange 50000000 0) terminas griežtas. Nenaudodami State duomenų tipą, mes palengviname kodo skaitymą Haskell kompiliatoriui ir taip palengviname būtinų optimizacijų įgyvendinimą.

Tas pats nutiko ir su Echidna atminties problema, kurią padėjau išspręsti: iki minimumo sumažinti State duomenų tipas padėjo Haskell kompiliatoriui suprasti, kad terminas gali būti griežtas, todėl labai sumažėjo atminties naudojimas ir padidėjo našumas.

Alternatyvus pataisymas

Kitas būdas išspręsti atminties problemą naudojant aukščiau pateiktą pavyzdį yra pakeisti eilutės apibrėžimą stateExample n su šiais dalykais:

stateExample n = do
s <- get
put $! stateChange n s
stateExample (n-1)

Atkreipkite dėmesį į naudojimą $! trečioje eilutėje. Tai verčia naujosios valstybės vertinimą būti griežtą, nebereikia optimizuoti, kad ji mums būtų griežta.
Nors tai taip pat išsprendžia problemą mūsų paprastame pavyzdyje, viskas tampa sudėtingesnė naudojant Haskell’s Lens biblioteką, todėl nusprendėme nenaudoti put $! Echidnoje; vietoj to nusprendėme nebenaudoti State.

Išvada

Mūsų pristatyti našumo patobulinimai jau pasiekiami 2.0.0 versijoje. Nors šiame etape pasiekėme didelę sėkmę, tai nereiškia, kad mūsų darbas, susijęs su nepaprastu Echidnos greičiu, atliktas. Haskell yra kalba, sudaryta pagal vietinį kodą, ir ji gali būti labai greita, jei reikia pakankamai profiliavimo pastangų. Mes ir toliau lyginsime Echidna ir stebėsime lėtus pavyzdžius. Jei manote, kad susidūrėte su tokia problema, atidarykite problemą.

Man labai patiko dirbti su Echidna kodų baze savo žiemos metu. Sužinojau daug apie Haskell, Solidity ir Echidna, taip pat įgijau patirties spręsdamas našumo problemas ir dirbdamas su gana didelėmis kodų bazėmis. Ypač norėčiau padėkoti Arturui Cyganui už skirtą laiką vertingiems atsiliepimams ir patarimams pateikti.

*** Tai saugos tinklaraštininkų tinklo sindikuotas tinklaraštis iš tinklaraščio „Trail of Bits“, kurio autorius yra „Trail of Bits“. Skaitykite originalų įrašą adresu https://blog.trailofbits.com/2022/03/02/optimizing-a-smart-contract-fuzzer/

Leave a Comment

Your email address will not be published. Required fields are marked *