Archive for the ‘Array’ Category

En overraskelse og 1000+ doubles

Friday, July 23rd, 2010

Dette indlæg rummer egentlig ingen ny viden, men på trods af at jeg efterhånden har brugt lang tid på finurlige detaljer omkring .NETs afviklingsmiljø, er jeg først nu stødt på denne detalje, så set i det lys vil jeg tillade mig at betegne emnet for dette indlæg som esoterisk og gentage noget, der muligvis ikke er nyt for alle.

Som det forhåbentlig er kendt for læserne af denne blog, foregår dynamisk allokering af hukommelse i .NET enten i generation 0 af heapen eller på Large Object Heap (LOH) i fald den allokerede instans er på 85.000 bytes eller mere.

Derfor vil small pege på et array allokeret i generation 0 og large pege på et array allokeret på LOH i nedenstående eksempel.

var small = new byte[1000];
var large = new byte[85000];

Det kan vi verificere via WinDbg eller ved at kalde GC.GetGeneration(), der som forventet returnerer henholdsvis 0 og 2 for de to referencer (bemærk, at metoden ikke skelner mellem generation 2 og LOH, da de i forhold til garbage collection behandles i samme ombæring). Bruger vi debuggeren, får vi lidt mere nøjagtig information, og her kan vi se, at large faktisk peger på en instans på LOH.

Gentager vi øvelsen for double[], burde vi igen kunne forudsige placeringen af de enkelte instanser. En double fylder 8 bytes, så for at komme over den magiske grænse, skal vi have et array med lidt over 10.000 elementer, lad os bare sige 11.000. Med det in mente burde vi altså kunne konkludere at small og large endnu en gang peger på instanser i henholdsvis generation 0 og på LOH.

var small = new double[1000];
var large = new double[11000];

Det er bare ikke sådan det forholder sig. Begge arrays bliver allokeret på LOH!

CLRen benytter nemlig en forholdsvis esoterisk optimering i dette tilfælde. Arrays af double med 1000 eller flere elementer bliver mod forventning altid allokeret på LOH. Det er nyt for mig, men hvis man nærlæser kommentarerne til dette gamle blogindlæg, kan man se, at det er ”by design”. Det er altså ikke en fejl, det er en feature.

Argumentationen er, at objekter på LOH altid ligger på 8 bytes skel, og derfor giver bedre performance ved opslag af elementerne. Jeg har ikke kunne finde nogen forklaring på, hvorfor grænsen på 1000 elementer er valgt, men sådan forholder det sig nu engang.

Det er fristende at prøve, om vi kan eftervise effekten af denne optimering, men det er ikke let i praksis, da vi har meget begrænset kontrol over og indsigt i allokering og adresselæsninger i managed code, så derfor må vi tage Microsofts ord for pålydende her.

Konsekvenserne af denne optimering kan være mange og ikke alle nødvendigvis til vores fordel. Hvis vi antager, at optimeringen har sin berettigelse, så må vi gå ud fra, at læsning af elementerne i et double[], nyder gavn af den gunstige placering i hukommelsen.

Til gengæld er disse arrays pludselig dyrere at allokere, eftersom allokering på LOH benytter en free list, hvilket kan være mange gange langsommere end den simple pointeroperation, der skal til ved allokering i generation 0. Ligeledes vil levetiden af disse arrays stige markant. Er der tale om midlertidige arrays, går de fra hurtig oprydning i generation 0 til en lang levetid på LOH. Det kan have indflydelse på hvor stor belastning garbage collection lægger på applikationen, ligesom det kan give yderligere problemer i form af fragmentering af LOH.

Konsekvenserne afhænger af den konkrete applikations forbrug af hukommelse, men givet er det, at det kan være en særdeles vigtig detalje i nogle scenarier. Så med mange års forsinkelse gør jeg hermed mit til at udbrede kendskabet til en godt bevaret hemmelighed.

Når 64 bit < 32 bit

Monday, July 6th, 2009

I en kommentar til mit indlæg om tilgængelig hukommelse i .NET applikationer skriver Jonathan Jørgensen følgende meget relevante kommentar, ”Maks. 2GB per objekt i 64bit applikationer”. Jeg var godt klar over, at denne begrænsning eksisterer i 32 bit .NET, men jeg var faktisk ikke opmærksom på, at den også gælder i 64 bit-verdenen.

Jonathan fortsætter, ”Det er dog de færreste der vil opleve disse problemer da klasser (reference types) som eksempelvis List jo selv referer til flere objekter internt.”

Det kan godt være, at der ikke er mange, der vil opleve problemet, men hvis jeg forstår Jonathan korrekt, tager han desværre fejl med hensyn til List<T>. Problemet er nemlig, at List<T> benytter et array til at gemme sine referencer, og dette array er naturligvis også underlagt 2 GB-grænsen. Ergo er List<T> og andre typer, der benytter arrays begrænset i størrelse.

Det har den ret overraskende effekt, at siden 64 bit referencer er dobbelt så store som 32 bit referencer, kan en instans af List<T> kun rumme halvt så mange referencer under 64 bit .NET i forhold til samme liste under 32 bit .NET. Det er ikke just indlysende, og det vil betyde, at applikationer, der kører fint under 32 bit .NET risikerer at fejle med OutOfMemoryException under 64 bit!

Et array kan i teorien være 2 GB stort på 32 bit .NET. I praksis er den en anelse mindre, da selve array-instansen også har det sædvanlige overhead, der kendes fra referencetyper. Derfor vil List<T> kunne rumme ca. 2 GB / 4 elementer på 32 bit, mens tallet er ca. 2 GB / 8 elementer på 64 bit.

I teorien kan vi derfor have 536.870.897 referencer i en List<T> på 32 bit, men som min forrige artikel illustrerede, kan vi ikke regne med at kunne allokere mere end ca. 1,5 GB sammenhængende hukommelse under 32 bit Windows, og derfor vil vi i praksis ikke kunne oprette en liste med det antal elementer. Det faktiske antal elementer afhænger af det største sammenhængende område i processens adresserum.

På 64 bit er resultatet dog endnu mere begrænset. Her vil vi maksimalt kunne allokere en List<T> med 268.435.448 referencer.

Under 32 bit Windows vil vi i bedste fald kunne oprette et array med ca. 1,5 GB / 4, svarende til ca. 400.000.000 elementer. Det er en del mere end det maksimale antal elementer i et array under 64 bit, og derfor kan vi komme i den bizarre situation, at det faktisk kan give bagslag at flytte sin applikation til 64 bit.

Det er ikke svært, at lave sine egne klasser, der kommer uden om denne begrænsning ved f.eks. at bruge en hægtet liste eller ved at splittet indholdet op i et antal arrays, når det bliver nødvendigt, men det ville have været rart, om standardtyperne under 64 bit .NET havde givet bedre eller i det mindste bare de samme muligheder som under 32 bit.

List<T> under overfladen

Wednesday, December 5th, 2007

Med introduktionen af .NET 2.0 fandt ArrayList en afløser i List<T>. Skønt ordet ”array” ikke længere indgår i navnet, er List<T> faktisk stadig baseret på Array, hvilket vi kan konstatere ved hjælp af Reflector.

De største fordele ved List<T> i forhold til ArrayList er typefastheden og en ganske betydelig performanceforbedring i forbindelse med håndtering af værdityper (value types). Typefastheden leder til simplere kode, da vi undgår at skulle undersøge type på og om nødvendig caste elementer, når vi piller dem ud af listen. Performanceforbedringen kommer af, at vi undgår at box/unboxe i forbindelse med værdityper.

Så for at gøre en lang historie kort: List<T> er langt at foretrække frem for ArrayList, så for at få mest ud af den, er det en god ide at se på, hvordan den er implementeret. Som nævnt er List<T> internt baseret på en Array. Det betyder, at skønt List<T> præsenterer en datastruktur uden fast størrelse, er dette blot en illusion. Når man opretter en List<T>, har den en størrelse, og når der er behov for mere plads, er typen således nødsaget til at oprette en ny, større struktur og flytte alle elementer til denne. Det er selvsagt ikke en billig operation.

Som udgangspunkt afsættes der faktisk ikke plads i en List<T>, hvilket man kan verificere ved at konstatere at Capacity indledningsvis sættes til 0. Så snart der tilføjes et element, afsættes der plads til 4 elementer, og herefter fordobles kapaciteten, hver gang behovet opstår. Det vil sige, at det femte element (og her tænker jeg ikke på filmen) resulterer i at Capacity stiger til 8, det niende element får Capacity til at stige til 16 og så fremdeles. Det betyder, at der for små lister kan spildes meget tid på disse interne omrokeringer, og derfor er det en god ide, at oprette sine List<T>-instanser med en passende kapacitet via den tilhørende constructor.

For meget store lister er dette overhead mindre betydende, men her kan man optimere pladsforbruget ved at sætte en passende kapacitet, så man kan undgå, at der afsættes for meget plads til referencer. Har man f.eks. en liste med 1,1 million navne, vil standardopførelsen gøre, at der afsættes plads til 2^21 eller 2.097.152 elementer. Så uanset hvad er det en god ide at sætte en indledende kapacitet, hvis man på nogen måde har mulighed for at komme med et kvalificeret gæt på den nødvendige kapacitet.

Mange implementeringer af en List-type er baseret på det, man kalder en hægtet listet (linked list), i hvilken brugeren har en reference til det første element i listen, og hvert element desuden har en reference til det efterfølgende element (eller null for sidste element i listen). En sådan implementering vil typisk tilbyde en Insert()- og en Add()-metode ganske som tilfældet er for List<T>. I dette tilfælde vil Insert() almindeligvis være særdeles effektiv, idet det kun kræver justering af to referencer at indsætte et element forrest i listen. Med mindre listen også gemmer en reference til det sidste element, vil det til gengæld være dyrt at indsætte elementer sidst i listen, da dette kræver, at vi løber hele listen igennem via referencerne i de enkelte elementer.

Sådan er det ikke med List<T>. Her forholder det sig faktisk lige omvendt. At tilføje et element via Add() er særdeles effektivt, men vil dog medføre et overhead, hvis kapaciteten skal udvides. Faktisk fremgår det af dokumentationen, at Add() som udgangspunkt er en O(1) operation. Skal listen udvides bliver det dog en O(n) operation.

Insert() er derimod en O(n) operation ifølge dokumentationen, men hvis vi tager et kig på implementeringen ved hjælp af Reflector (se nedenfor), ser vi, at dette faktisk ikke gælder, hvis Insert() resulterer i at elementet indsættes i slutningen af listen. I det tilfælde er Insert() stort set lige så hurtig som Add() – og i realiteten er der tale om O(1).

public void Insert(int index, T item)
{
   if (index > this._size) {
      ThrowHelper.ThrowArgumentOutOfRangeException(ExceptionArgument.index,
         ExceptionResource.ArgumentOutOfRange_ListInsert);
   }
   if (this._size == this._items.Length) {
      this.EnsureCapacity(this._size + 1);
   }
   if (index < this._size) {
      Array.Copy(this._items, index, this._items, index + 1, this._size - index);
   }
   this._items[index] = item;
   this._size++;
   this._version++;
}

Som det også fremgår af ovenstående, ligger Insert() naturligvis under for de samme restriktioner som Add() i det tilfælde, at kapaciteten skal udvides.

Så hvad kan vi lære af at se på implementeringen af List<T>? For det første, vil det som sagt være en god ide at vælge en passende kapacitet ved oprettelsen af sin liste for at undgå de tunge omallokeringer og det voldsomme overhead ved store lister. Dernæst kan vi konstatere, at i og med at List<T> ikke er implementeret som en hægtet liste, er Add() langt at foretrække frem for Insert(). Faktisk er omkostningerne ved sidstnævnte så store, at man virkelig kun skal bruge den som sidste udvej. Og sidst men ikke mindst, er der ingen grund til fortsat at bruge ArrayList.

Indeksering af arrays

Saturday, November 10th, 2007

Jeg læste for nylig i en ældre C#-bog, at 1.x compileren optimerede løkker med arrays, hvis man anvendte Length af det pågældende array som grænseværdi for gennemløbet. Det gjorde mig nysgerrig, og jeg satte mig derfor for at undersøge forskellen på nedenstående tre implementeringer i håb om at finde nogle retningslinjer for valg af metode til gennemløb af arrays. Læg mærke til at der er tale om gennemløb, hvor vi har brug for at indeksere os frem til de enkelte elementer. For gennemløb uden dette behov, er syntaksen ved foreach klart at foretrække efter min mening.

public void Length() {
   int[] numbers = new int[1000000];

   for (int i = 0; i < numbers.Length; i++) {
      numbers[i] = i;
   }
}

public void Constant() {
   int[] numbers = new int[1000000];

   for (int i = 0; i < 1000000; i++) {
      numbers[i] = i;
   }
}

public void Argument(int length) {
   int[] numbers = new int[length];

   for (int i = 0; i < length; i++) {
      numbers[i] = i;
   }
}

Det første sted at undersøge er naturligvis den resulterende IL-kode, men her er absolut intet at hente. I Release mode genererer compileren stort set identisk kode for de tre varianter. Afvigelserne er udelukkende i forhold til måden at finde værdierne for længden. Der er altså ikke noget, der tyder på at den ene implementering vil være mere effektiv end den anden.

Næste skridt er derfor at måle den faktiske performance, og til det formål lavede jeg et instrumenteringsprojekt med det indbyggede Performance Tool i Visual Studio. Det er måske ikke så fancy som visse dedikerede performanceværktøjer, men det er let at bruge, og det løser opgaven i mange tilfælde.

Målingerne på min maskine viste, at jeg skulle helt op på en million elementer i min array, før Length-varianten var marginalt hurtigere end de andre. For lavere antal var den faktisk den langsomste, men i alle tilfælde lå målingerne tæt på hinanden. Ved meget lave antal elementer er prisen for et metodekald (properties er jo bare metoder, med smart syntaks) signifikant, og derfor bliver Length-varianten taberen her. (rettet: det er noget vrøvl – CLRen har support for arrays, så Length er ikke en almindelig metode, men er direkte understøttet af IL-kommandoen ldlen, men under normale omstændigheder ville metoden blive kaldt ved hvert gennemløb.)

Jeg har således ikke kunne finde argumentation for at favorisere den ene fremgangsmåde frem for den anden, så derfor vil min anbefaling være, at man anvender den, der giver det mest robuste kode, og det vil være Length-varianten, da den undgå dublering af information.