Vårt delingshjørne
Lars Espen
Nordhus
26. juni 2020
Dette er 1 av 3 deler som omhandler Ytelse og kode. Se del 2 her.
Pinterest så en stor økning i brukere (15%) da de klarte å redusere ventetid. BBC så at de mistet 10% av brukerne for hvert sekund det tok å laste.
I denne bloggserien vil du få et innblikk i hvordan jeg har jobbet med ytelse de siste 7 årene og hvilke tips og triks du burde være klar over når du skal kjempe kampen mot sekundene selv. Da dette er relativt store og tunge tema, har jeg valgt å dele opp i tre blogger. I disse vil jeg gå gjennom de vanligste flaskehalsene man kan ha i et program, si litt om løsningsmåter, si litt om O-notasjon og avslutte med et større praktisk eksempel.
Først en disclamer
Lesbarhet trumfer unødvendige ytelsesforbedringer.
Faktum er at ytelse som regel ikke er et problem! Da er det mye viktigere at koden er lett å lese, er lett å vedlikeholde, har godt testdekning og er godt dokumentert. Det er noen som bruker alt for mye tid på å diskutere int vs short på et felt som ikke påvirker ytelsen til produktet i det hele tatt! Unødvendig sparing kan også innføre begrensinger på prosjektet og i verste fall innføre rare bugs der data plutselig mister presisjon.
Ulike typer flaskehalser
Med det sagt, har de fleste prosjekter som regel en kritisk sti med noen flaskehalser, der noen må sette seg ned å bry seg litt om ytelse og kjøretid for å få et godt produkt. Som regel synes jeg det lønner seg å gjøre en naiv implementering av programmet først, for så å kjøre noen analyser på programmet, for å finne ut hvilke deler som faktisk er en flaskehals og hvilken type flaskehals det er. Det som ofte kjennetegner en flaskehals er den delen av koden som har mange brukere, mange integrasjoner, mye data og/eller mye tung logikk.
DET ER DUMT Å GJØRE STORE ARKITEKTURENDRINGER DERSOM EN INDEX I DATABASEN KUNNE LØST HELE PROBLEMET.
Det finnes flere typer flaskehalser du kan oppleve. Programmet kan:
Gå tom for tåder eller sockets mot en treg ressurs
Gå tom for minne
Gå tom for CPU
Begrenses av IO
Begrenses av sekvensiell del
Man kan gjerne også møte kombinasjoner av disse problemene, og hver av dem har forskjellige måter og løses på. Det som er utrolig viktig, er å finne ut hvilke problemer ditt program har, ved å kjøre noen diagnoser. Dette kan være for eksempel tidtaking, ytelsesgraf eller snapshot av minne, og bør kjøres tidlig for å være sikker på at du vet hva og hvor feilen ligger, før du begynner å gjøre større tiltak. Det er dumt å gjøre store arkitekturendringer dersom en index i databasen kunne løst hele problemet.
Tom for tråder eller tom for sockets mot en treg ressurs
Generelt kan man løse utfordringen med å gå tom for tråder, ved å passe på at trådene dør/gjenbrukes fort nok, samt å gi hver tråd mer å gjøre. Dette kalles ofte “tiling” av data, der man gir en større del av inputdataen inn til hver tråd, i stedet for en og en rad. Man kan også bruke thread pools for å ha et sett med tråder som skal gjenbrukes og at man passer på at man ikke oppretter flere tråder enn man har lyst til å kjøre i parallell. På denne måten kan man ha mer kontroll over hvor mye data som flyter gjennom CPU-en til enhver tid.
En del programmeringsspråk skiller også på tråder og prosesser, der tråder er lettvekt og deler alle lokale variabler på tvers, mens prosesser er tyngre å starte, men har til gjengjeld sitt eget scope. Ved å bruke prosesser kan kommunikasjon mellom disse være vanskeligere da alt som regel må serialiseres og begge prosesser må være klare til å sende eller motta. Eventuelt kan man skrive status eller meldinger til et felles lager. For tråder er det lettere å sette flagg, tukle med andre tråders state eller lignende som deles, men det er oftere også her man finner magiske og uforutsette bugs.
Det å gå tom for sockets mot en treg ressurs er mye av samme problematikk og man bør i det tilfellet passe på to ting. På din side av kallet er det viktig at du ikke lager uendelig med http-tilkoblinger for kommunikasjon utad. For eksempel i C# har det vært viktig å dele httpContext på tvers av instanser, men nå er dette blitt gjort om til en singleton by default i nyere versjoner.
DERSOM DU LAGER ET PROGRAM SOM BEHANDLER STØRRE DATAMENGDER, VIL DU AV OG TIL GÅ TOM FOR MINNE.
På den andre siden kan du gjøre at serveren du skal kalle på, ikke tillater flere tilkoblinger. På samme måte som tidligere kan man da enten bruke en semaphore, for å begrense antall kall gjort til en ressurs, eller øke størrelsen på data som sendes per melding til for eksempel en liste av meldinger, slik at man ikke får så mange kall. Ved å skrive om til lister av elementer, i stedet for ett element per melding, er det viktig å tenke på hvordan serveren skal kunne gi deg fornuftige feilmeldinger dersom deler av en batch feiler. Dette gir økt kompleksitet som ikke er ønsket dersom det er mulig å unngå.
Eksempel:
Et konkret eksempel jeg har støtt på i nyere tid er når jeg har prøvd å skrive til Azure Storage Queues i en massivt parallellisert applikasjon. Der har vi en applikasjon som skalerer opp til 20 instanser av azure functions, og hver av disse har et større sett med parallelle requests/tråder som alle prosesserer og skriver til denne køen. Utfordringen her er at køen blir overlastet og begynner å returnere feilmeldinger på en del av add-kallene.
Løsningen vi brukte var å dele opp i flere køer som ble filtrert ned til samme kode igjen. Det vil si flere azure functions, der eneste forskjellen er hvilken kø de lytter på. Dette fungerte opp til et visst antall (ca 7 køer og funksjoner), men vi fikk også her problem med feilmeldinger. Da vi i tillegg la til en begrensing på hvor mange skriveoperasjoner hver tråd instans fikk lov til å gjøre i parallell, og satt denne til 50, klarte vi å redusere problemet nok. Nå har vi litt over 3 millioner skrive- og leseoperasjoner gjennom denne koden per time.
Tom for minne
Dersom du lager et program som behandler større datamengder, vil du av og til gå tom for minne. Det kan enten være en kodefeil der du lagrer mer enn du hadde tenkt på, men ofte kan det også være at man ikke har plass til å holde hele datasettet i minne samtidig. Selv om programmet ditt ikke er helt oppe mot 100% minnebruk, kan det likevel være ødeleggende for ytelsen til systemet ditt, da du mye oftere får fragmentert lagring i minne.
DETTE ØKER IMIDLERTID OFTE KOMPLEKSITETEN, DA MAN HELE TIDEN MÅ PASSE PÅ AT ALLE DISSE DATAENE ER OPPDATERT TIL ENHVER TID, MEN MÅ MAN, SÅ MÅ MAN.
Dersom du er i denne situasjonen, kan du ofte skrive om algoritmen fra å være en rekursiv eller sekvensiell loop, der du holder på hele datasettet i minne til enhver tid, til å være en mer iterativ algoritme. Da kan du se på dataene som trengs til å regne en og en del av problemet og holde bare denne dataene i minne. Her kan du bruke lazy loading/generatorer for å iterere; noe som har blitt default i alle for-loops i python, for eksempel.
Tiltak nummer to er å finne ut hva du ikke trenger å ha i minne, og heller enten regne det ut på ny, beholde aggregater eller senke presisjon på dataen. Dette er et mer drastisk tiltak, da man må passe på at man for eksempel ikke skaper regnefeil ved tap av.
Dersom du mot formodning fortsatt har for mye data for minnet du har tilgjengelig, må du lagre dette et annet sted. Et virkemiddel som kan brukes er raske oppslagsverk som key-value stores og helst de av dem som kan kjøres in-memory på en server, dersom du kan avlaste analysen din med et kritisk oppslag, eller en type cache av de viktigste dataene. Dette øker imidlertid ofte kompleksiteten, da man hele tiden må passe på at alle disse dataene er oppdatert til enhver tid, men må man, så må man.
Eksempel:
I et av mine tidligere prosjekt, hadde vi lange tidsserier med store mengder data. Det var værdata med 12 målinger pr time for hver kilometer i Norge over de siste 36 årene. Dette betyr 3,78 millioner målinger pr kilometer i hele Norge. Vi trengte å lese disse inn og kombinere dem med andre målinger vi hadde, for å gjøre valg og kjøre tyngre algoritmer på dem.
Her kom vi fort inn i minneproblemer.
Et alternativ var å lese mindre data, men da fikk vi lavere presisjon på algoritmene våre. Den endelige løsningen ble å lagre soner med tidsserier som lignet på kommuner, der man bare hentet inn tidsseriene for den sonen du skulle regne på og de rundt i tilfelle det var grenseverdier. Selv om dette er en enkel løsning, krevde det at vi leste inn hele databasen og reorganiserte den i partisjoner som kunne bli brukt for oppslag senere. Dette gjorde at det å hente ut alt vær i Norge for en time, tok en god del lenger tid, da man først måtte slå opp i alle partisjoner og så finne riktig tidsintervall. Dette er eksempel på valg man må gjøre, der man må velge hastighet i forskjellige use-case. Det er umulig å lage et system som er perfekt på alt, og spesielt når systemet skal være vedlikeholdbar og skalerbar etterpå.
Oppsummering
Dette var en rask gjennomgang av to av utfordringene vi ofte ser knyttet til ytelse. I neste bloggpost vil jeg skrive mer om hvordan du kan ta tilbake kontrollen på ytelsen din innen CPU, I/O og sekvensielle begrensinger.
Dersom dere har noen kommentarer, spørsmål eller trenger å snakke med noen om ytelse så er det bare å skrible ned noe å sende til Hei@alv.no eller legge inn i kommentarfeltet under.