Condividi tramite


Writing Faster Managed Code: Know What Things Cost (Scrittura di codice gestito più veloce: essere consapevoli dei costi)

 

Grigio jan
Microsoft CLR Performance Team

Giugno 2003

Si applica a:
   Microsoft® .NET Framework

Riepilogo: Questo articolo presenta un modello a basso costo per il tempo di esecuzione del codice gestito, in base ai tempi di operazione misurati, in modo che gli sviluppatori possano prendere decisioni di codifica più informate e scrivere codice più veloce. (30 pagine stampate)

Scaricare CLR Profiler. (330 KB)

Contenuto

Introduzione (e impegno)
Verso un modello di costo per il codice gestito
Costo degli elementi nel codice gestito
Conclusione
Risorse

Introduzione (e impegno)

Esistono diversi modi per implementare un calcolo e alcuni sono molto migliori di altri: più semplice, pulito, più facile da gestire. Alcuni modi sono molto veloci e alcuni sono sorprendentemente lenti.

Non perpetrate codice lento e grasso sul mondo. Non si disprezza il codice? Codice che viene eseguito in modo appropriato e avviato? Codice che blocca l'interfaccia utente per secondi in fase di tempo? Codice che blocca la CPU o esegue il thrash del disco?

Evitare di farlo. Invece, alzarsi e impegnarsi insieme a me:

"Prometto di non spedire codice lento. La velocità è una funzionalità che mi interessa. Ogni giorno mi darò attenzione alle prestazioni del mio codice. Misurerò regolarmente e metodicamente la velocità e le dimensioni. Apprenderà, compila o acquista gli strumenti che devo fare. È mia responsabilità."

(Davvero.) Allora hai promesso? Buon per te.

Come si scrive il giorno più veloce e più stretto del codice in e giorno? È una questione di scelta consapevole del modo frugale in preferenza per l'extravagant, rigonfiato, di nuovo e di nuovo, e una questione di pensiero attraverso le conseguenze. Qualsiasi pagina di codice specifica acquisisce decine di decisioni di piccole dimensioni.

Ma non è possibile fare scelte intelligenti tra le alternative se non si conosce il costo delle cose: non è possibile scrivere codice efficiente se non si conosce il costo delle cose.

Era più facile nei vecchi giorni. Buoni programmatori C sapevano. Ogni operatore e operazione in C, ad esempio l'assegnazione, l'intero o la matematica a virgola mobile, la dereferenza o la chiamata di funzione, hanno eseguito il mapping di una o meno a uno a un'unica operazione di macchina primitiva. True, a volte sono state necessarie diverse istruzioni del computer per inserire gli operandi corretti nei registri corretti e a volte un'unica istruzione potrebbe acquisire diverse operazioni C (famosamente *dest++ = *src++;), ma è in genere possibile scrivere (o leggere) una riga di codice C e sapere dove stava andando l'ora. Sia per il codice che per i dati, il compilatore C era WYWIWYG: "ciò che si scrive è quello che si ottiene". L'eccezione è stata, ed è, le chiamate di funzione. Se non si conosce i costi della funzione, non si conosce correttamente.

Negli anni '90, per godere dei numerosi vantaggi di ingegneria software e produttività dell'astrazione dei dati, della programmazione orientata agli oggetti e del riutilizzo del codice, il settore software PC ha effettuato una transizione da C a C++.

C++ è un superset di C ed è "paga come si passa": le nuove funzionalità non costano nulla se non vengono usate, quindi le competenze di programmazione C, incluso il modello di costo interno, è direttamente applicabile. Se si accetta un codice C funzionante e ricompilarlo per C++, il tempo di esecuzione e il sovraccarico dello spazio non dovrebbero cambiare molto.

D'altra parte, C++ introduce molte nuove funzionalità del linguaggio, tra cui costruttori, distruttori, nuovi, eliminatori, single, multiple e ereditarietà virtuale, cast, funzioni membro, funzioni virtuali, operatori di overload, puntatori ai membri, matrici di oggetti, gestione delle eccezioni e composizioni della stessa, che comportano costi nascosti non semplici. Ad esempio, le funzioni virtuali costano due indirette aggiuntive per chiamata e aggiungono un campo puntatore della tabella virtuale nascosto a ogni istanza. Oppure prendere in considerazione che questo codice innocuo:

{ complex a, b, c, d; … a = b + c * d; }

compila in circa tredici chiamate di funzione membro implicite (speriamo inlined).

Nove anni fa abbiamo esaminato questo argomento nel mio articolo C++: Under the Hood. Ho scritto:

"È importante comprendere come viene implementato il linguaggio di programmazione. Tale conoscenza impedisce la paura e la meraviglia di "Che cosa sulla terra sta facendo il compilatore?"; impartisce fiducia per usare le nuove funzionalità; e fornisce informazioni dettagliate durante il debug e l'apprendimento di altre funzionalità del linguaggio. Offre anche un'idea dei costi relativi delle diverse scelte di codifica che è necessario scrivere il codice più efficiente giorno al giorno."

A questo punto si esaminerà un'occhiata simile al codice gestito. Questo articolo illustra i costi di tempo e spazio bassi dell'esecuzione gestita, in modo da rendere più intelligenti i compromessi nel nostro giorno per la codifica.

E mantenere le nostre promesse.

Perché il codice gestito?

Per la maggior parte degli sviluppatori di codice nativo, il codice gestito è una piattaforma migliore e più produttiva per l'esecuzione del software. Rimuove tutte le categorie di bug, ad esempio i danneggiamenti dell'heap e gli errori di tipo array-index-out-of-bound che spesso portano a sessioni di debug di late-night frustranti. Supporta requisiti moderni, ad esempio codice mobile sicuro (tramite sicurezza di accesso al codice) e servizi Web XML e rispetto all'invecchiamento Win32/COM/COM/ATL/MFC/VB, .NET Framework è una progettazione di slate pulita, in cui è possibile eseguire più operazioni con meno sforzo.

Per la community degli utenti, il codice gestito consente applicazioni più avanzate e più affidabili, migliorando la vita tramite software migliore.

Che cos'è il segreto per scrivere codice gestito più veloce?

Solo perché è possibile fare di più con meno sforzo non è una licenza per abdicare la responsabilità di codice in modo saggio. Prima di tutto, devi ammetterlo a te stesso: "Sono un newbie". Sei un newbie. Anch'io sono un nuovo bie. Siamo tutti babes nel territorio del codice gestito. Stiamo ancora imparando le corde, incluse le cose che costano.

Quando si tratta di .NET Framework ricco e pratico, è come siamo bambini nello store di caramelle. "Wow, non devo fare tutte le strncpy cose noiose, posso solo stringhe '+' insieme! Wow, posso caricare un megabyte di XML in un paio di righe di codice! Chio-hoo!"

È tutto così facile. Così facile, davvero. Così facile da bruciare megabyte di RAM analizzando i infoset XML solo per estrarre alcuni elementi da loro. In C o C++ è stato così doloroso pensare due volte, forse si creerà un computer di stato in un'API simile a SAX. Con .NET Framework è sufficiente caricare l'intero infoset in un unico gulp. Forse lo fai anche sopra e sopra. Forse l'applicazione non sembra più veloce. Forse ha un set di lavoro di molti megabyte. Forse dovresti pensare due volte a ciò che i metodi facili costano...

Purtroppo, a mio parere, la documentazione corrente di .NET Framework non illustra in modo adeguato le implicazioni delle prestazioni dei tipi e dei metodi framework, non specifica nemmeno i metodi che potrebbero creare nuovi oggetti. La modellazione delle prestazioni non è facile da coprire o documentare; ma ancora, il "non sapere" rende che molto più difficile per noi prendere decisioni informate.

Dal momento che siamo tutti nuovibie qui, e dal momento che non sappiamo quali costi, e dal momento che i costi non sono chiaramente documentati, cosa dobbiamo fare?

Misurarlo. Il segreto è misurarlo e essere vigile. Dobbiamo entrare nell'abitudine di misurare il costo delle cose. Se andiamo ai problemi di misurare il costo delle cose, non saremo quelli che chiamano inavvertitamente un nuovo metodo whizzy che costa dieci volte i costi previsti.

Per ottenere informazioni più approfondite sulle prestazioni di base della libreria di classi di base BCL o CLR, prendere in considerazione l'analisi dell'interfaccia della riga di comando di origine condivisa, a.k.a. Rotor. Il codice rotore condivide una linea sanguigna con .NET Framework e CLR. Non è lo stesso codice, ma anche così, ti prometto che uno studio pensieroso di Rotor ti darà nuove informazioni sui andamenti sotto il cofano del CLR. Tuttavia, assicurarsi di esaminare prima la licenza SSCLI!)

Conoscenza

Se si aspira a essere un conducente di taxi a Londra, è prima necessario guadagnare La conoscenza. Gli studenti studiano per molti mesi per memorizzare le migliaia di piccole strade a Londra e imparare i migliori percorsi da luogo a posto. E vanno fuori ogni giorno su scooter per esplorare intorno e rafforzare il loro libro apprendimento.

Analogamente, se si vuole essere uno sviluppatore di codice gestito ad alte prestazioni, è necessario acquisire la conoscenza del codice gestito. È necessario apprendere i costi di ogni operazione di basso livello. È necessario apprendere quali funzionalità come delegati e costi di sicurezza di accesso al codice. È necessario imparare i costi dei tipi e dei metodi usati e quelli scritti. E non fa male scoprire quali metodi potrebbero essere troppo costosi per l'applicazione, e quindi evitarli.

La conoscenza non è in nessun libro, alas. È necessario uscire dallo scooter ed esplorare, ovvero crank up csc, ildasm, the VS.NET debugger, CLR Profiler, your profiler, alcuni timer perf e così via, e vedere cosa costa il codice in tempo e spazio.

Verso un modello di costo per il codice gestito

Preliminari, si consideri un modello di costo per il codice gestito. In questo modo sarà possibile esaminare un metodo foglia e indicare a un'occhiata quali espressioni e istruzioni sono più costose; e sarà possibile fare scelte più intelligenti durante la scrittura di nuovo codice.

In questo modo non verranno affrontati i costi transitivi di chiamare i metodi o i metodi di .NET Framework. Questo dovrà attendere un altro articolo in un altro giorno.

In precedenza ho dichiarato che la maggior parte del modello di costo C si applica ancora negli scenari C++. Analogamente, gran parte del modello di costo C/C++ si applica ancora al codice gestito.

Come può essere? Si conosce il modello di esecuzione CLR. Si scrive il codice in uno dei diversi linguaggi. La compilazione viene compilata in formato CIL (Common Intermediate Language), inserita in pacchetti in assembly. Si esegue l'assembly dell'applicazione principale e viene avviato l'esecuzione del CIL. Ma non è che un ordine di grandezza più lento, come gli interpreti bytecode dei vecchi?

Compilatore JUST-in-Time

No, non è. CLR usa un compilatore JIT (just-in-time) per compilare ogni metodo in CIL in codice x86 nativo e quindi esegue il codice nativo. Sebbene sia presente un piccolo ritardo per la compilazione JIT di ogni metodo come viene chiamato per la prima volta, ogni metodo denominato esegue codice nativo puro senza sovraccarico interpretivo.

A differenza di un processo di compilazione C++ tradizionale, il tempo trascorso nel compilatore JIT è un ritardo "tempo di orologio a muro", in ogni utente, quindi il compilatore JIT non ha il lusso di passaggi di ottimizzazione esaustivi. Anche in questo caso, l'elenco delle ottimizzazioni eseguite dal compilatore JIT è impressionante:

  • Riduzione di costanti
  • Propagazione costante e copia
  • Eliminazione di sottoespressioni comuni
  • Movimento del codice di invarianti ciclo
  • Eliminazione del codice dead store e dead
  • Registrare l'allocazione
  • Inlining del metodo
  • Annullamento della registrazione ciclo (cicli di piccole dimensioni con corpi piccoli)

Il risultato è paragonabile al codice nativo tradizionale, almeno nello stesso ballpark.

Per quanto riguarda i dati, si userà una combinazione di tipi di valore o tipi di riferimento. Tipi di valore, inclusi tipi integrali, tipi a virgola mobile, enumerazioni e struct, in genere vivono nello stack. Sono così piccoli e veloci come le variabili locali e gli struct sono in C/C++. Come per C/C++, probabilmente è consigliabile evitare il passaggio di struct di grandi dimensioni come argomenti di metodo o valori restituiti, perché il sovraccarico di copia può essere eccessivamente costoso.

Tipi di riferimento e tipi di valore boxed vivono nell'heap. Vengono risolti dai riferimenti agli oggetti, che sono semplicemente puntatori a computer come i puntatori a oggetti in C/C++.

Quindi il codice gestito jitted può essere veloce. Con alcune eccezioni descritte di seguito, se si ha un'idea per il costo di un'espressione nel codice C nativo, non si andrà molto male a modellare il relativo costo come equivalente nel codice gestito.

Dovrei anche menzionare NGEN, uno strumento che "in anticipo" compila il CIL negli assembly di codice nativo. Anche se NGEN'ing gli assembly non ha attualmente un impatto significativo (valido o negativo) sul tempo di esecuzione, può ridurre il lavoro totale impostato per gli assembly condivisi caricati in molti AppDomains e processi. Il sistema operativo può condividere una copia del codice NGEN in tutti i client, mentre il codice jitted non è in genere condiviso tra AppDomains o processi. Ma vedere anche LoaderOptimizationAttribute.MultiDomain.)

Automatic Memory Management

La partenza più significativa del codice gestito (da nativa) è la gestione automatica della memoria. Allocare nuovi oggetti, ma CLR Garbage Collector (GC) li libera automaticamente quando diventano inarrivabili. GC viene eseguito ora e di nuovo, spesso in modo impercettibile, in genere arrestando l'applicazione solo per un millisecondo o due, occasionalmente più lungo.

Diversi altri articoli illustrano le implicazioni delle prestazioni del Garbage Collector e non verranno recapitate qui. Se l'applicazione segue le raccomandazioni in questi altri articoli, il costo complessivo di Garbage Collection può essere insignificante, un numero limitato di tempo di esecuzione, competitivo con o superiore all'oggetto new C++ tradizionale e delete. Il costo ammortizzato della creazione e del recupero automatico di un oggetto è sufficientemente basso che è possibile creare molti milioni di piccoli oggetti al secondo.

Ma l'allocazione degli oggetti non è ancora gratuita. Gli oggetti occupano spazio. L'allocazione degli oggetti rampante porta a cicli di Garbage Collection più frequenti.

Molto peggio, inutile conservare i riferimenti ai grafici a oggetti inutili li mantiene attivi. A volte vediamo programmi modesti con set di lavoro lamentati da 100 MB, i cui autori negano la loro colpa e invece assegnano le prestazioni scarse ad alcuni misteriosi, non identificati (e quindi intratrabili) problema con il codice gestito stesso. È tragico. Tuttavia, uno studio di un'ora con CLR Profiler e cambia in poche righe di codice taglia l'utilizzo dell'heap da un fattore di dieci o più. Se si sta riscontrando un problema di set di lavoro di grandi dimensioni, il primo passaggio consiste nel guardare nello specchio.

Quindi non creare oggetti inutilmente. Solo perché la gestione automatica della memoria rimuove le molte complessità, problemi e bug di allocazione e liberamento degli oggetti, perché è così veloce e così pratico, si tende naturalmente a creare più e più oggetti, come se crescono sugli alberi. Se si vuole scrivere codice gestito molto veloce, creare oggetti in modo pensieroso e appropriato.

Questo vale anche per la progettazione API. È possibile progettare un tipo e i relativi metodi in modo da richiedere ai client di creare nuovi oggetti con abbandono selvaggio. Non farlo.

Costo degli elementi nel codice gestito

Si consideri ora il costo del tempo di varie operazioni di codice gestito a basso livello.

La tabella 1 presenta il costo approssimativo di un'ampia gamma di operazioni di codice gestito di basso livello, in nanosecondi, su un pc 1.1 GHz Pentium-III che esegue Windows XP e .NET Framework v1.1 ("Everett"), raccolti con un set di cicli di temporizzazione semplici.

Il driver di test chiama ogni metodo di test, specificando un numero di iterazioni da eseguire, ridimensionato automaticamente per iterare tra 218 e 2 30 iterazioni, in base alle esigenze per eseguire ogni test per almeno50 ms. In genere, questo è abbastanza lungo per osservare diversi cicli di Garbage Collection di generazione 0 in un test che esegue un'allocazione intensa degli oggetti. La tabella mostra i risultati mediati oltre 10 prove, nonché la versione di valutazione migliore (tempo minimo) per ogni soggetto di test.

Ogni ciclo di test viene rollbackato da 4 a 64 volte, se necessario, per ridurre il sovraccarico del ciclo di test. Ho controllato il codice nativo generato per ogni test per assicurarsi che il compilatore JIT non ottimizzasse il test via, ad esempio in diversi casi ho modificato il test per mantenere attivi i risultati intermedi durante e dopo il ciclo di test. Analogamente, ho apportato modifiche per impedire l'eliminazione comune della sottoespressione in diversi test.

Tabella 1 Tempi primitivi (media e minima) (ns)

Avg Min Primitiva Avg Min Primitiva Avg Min Primitiva
0.0 0,0 Control 2.6 2.6 nuovo valtype L1 0,8 0,8 isinst up 1
1.0 1.0 Aggiungere int 4,6 4,6 nuovo valtype L2 0,8 0,8 isinst down 0
1.0 1.0 Sub int 6.4 6.4 nuovo valtype L3 6.3 6.3 isinst down 1
2.7 2.7 Mul int 8.0 8.0 nuovo valtype L4 10,7 10.6 isinst (fino 2) giù 1
35.9 35.7 Int div 23.0 22.9 nuovo valtype L5 6.4 6.4 isinst down 2
2.1 2.1 Int shift 22,0 20.3 nuovo reftype L1 6.1 6.1 isinst down 3
2.1 2.1 aggiunta prolungata 26.1 23.9 nuovo reftype L2 1.0 1.0 ottenere il campo
2.1 2.1 sub long 30.2 27.5 nuovo reftype L3 1.2 1.2 get prop
34.2 34.1 mulo lungo 34.1 30.8 nuovo reftype L4 1.2 1.2 campo set
50,1 50,0 long div 39.1 34,4 nuovo reftype L5 1.2 1.2 set prop
5,1 5,1 spostamento lungo 22,3 20.3 nuovo reftype vuoto ctor L1 0.9 0.9 ottenere questo campo
1.3 1.3 aggiunta float 26.5 23.9 nuovo reftype vuoto ctor L2 0.9 0.9 ottenere questo prop
1.4 1.4 sub float 38.1 34.7 nuovo reftype vuoto ctor L3 1.2 1.2 impostare questo campo
2.0 2.0 mul float 34.7 30.7 nuovo reftype vuoto ctor L4 1.2 1.2 impostare questa proprietà
27.7 27.6 float div 38.5 34.3 nuovo reftype vuoto ctor L5 6.4 6.3 get virtual prop
1.5 1.5 double add 22.9 20.7 nuovo ctor reftype L1 6.4 6.3 set virtual prop
1.5 1.5 double sub 27.8 25.4 nuovo ctor reftype L2 6.4 6.4 barriera di scrittura
2.1 2.0 mul doppio 32.7 29.9 nuovo ctor reftype L3 1,9 1,9 load int array elem
27.7 27.6 double div 37.7 34.1 nuovo reftype ctor L4 1,9 1,9 store int array elem
0,2 0,2 chiamata statica inlined 43.2 39.1 nuovo reftype ctor L5 2,5 2,5 load obj array elem
6.1 6.1 chiamata statica 28.6 26.7 new reftype ctor no-inl L1 16,0 16,0 store obj array elem
1.1 1,0 chiamata all'istanza inlined 38.9 36.5 new reftype ctor no-inl L2 29.0 21.6 box int
6.8 6.8 chiamata all'istanza 50.6 47.7 new reftype ctor no-inl L3 3,0 3,0 unbox int
0,2 0,2 inlined questa chiamata inst 61.8 58.2 new reftype ctor no-inl L4 41.1 40.9 delegate invoke
6.2 6.2 chiamata a questa istanza 72.6 68.5 new reftype ctor no-inl L5 2.7 2.7 matrice sum 1000
5.4 5.4 chiamata virtuale 0,4 0,4 cast up 1 2.8 2.8 matrice sum 10000
5.4 5.4 questa chiamata virtuale 0,3 0,3 cast down 0 2,9 2.8 matrice sum 100000
6.6 6.5 chiamata all'interfaccia 8.9 8.8 cast down 1 5.6 5.6 matrice sum 1000000
1.1 1,0 chiamata dell'istanza inst itf 9.8 9.7 cast (su 2) giù 1 3,5 3,5 elenco somma 1000
0,2 0,2 chiamata all'istanza itf 8.9 8.8 cast down 2 6.1 6.1 elenco somma 10000
5.4 5.4 chiamata virtuale inst itf 8.7 8,6 cast down 3 22,0 22,0 elenco somma 100000
5.4 5.4 questa chiamata virtuale itf       21.5 21.4 elenco somma 1000000

Una dichiarazione di non responsabilità: non prendere questi dati troppo letteralmente. Il test del tempo viene intasato con il rischio di effetti imprevisti del secondo ordine. Una casualità potrebbe inserire il codice jitted o alcuni dati cruciali, in modo che si estende su righe della cache, interferisca con qualcos'altro o con ciò che si ha. È un po' come il principio di incertezza: i tempi e le differenze temporali di 1 nanosecondo o così via sono ai limiti dell'osservabile.

Un'altra dichiarazione di non responsabilità: questi dati sono pertinenti solo per scenari di codice e dati di piccole dimensioni che rientrano interamente nella cache. Se le parti "a caldo" dell'applicazione non rientrano nella cache su chip, è possibile che si verifichino problemi di prestazioni diversi. Abbiamo molto di più da dire sulle cache vicino alla fine del documento.

E un'altra dichiarazione di non responsabilità: uno dei vantaggi sublime della spedizione dei componenti e delle applicazioni come assembly di CIL è che il programma può ottenere automaticamente più velocemente ogni secondo, e ottenere più velocemente ogni anno, "più veloce ogni secondo" perché il runtime può (in teoria) riattivare il codice compilato JIT durante l'esecuzione del programma; e "sempre più velocemente" perché con ogni nuova versione del runtime, algoritmi migliori, più intelligenti e veloci possono prendere un nuovo scaglionamento per ottimizzare il codice. Quindi, se alcuni di questi tempi sembrano meno ottimali in .NET 1.1, prendere il cuore che dovrebbero migliorare nelle versioni successive del prodotto. Di seguito è riportato che qualsiasi sequenza di codice nativo del codice specificata riportata in questo articolo può cambiare nelle versioni future di .NET Framework.

Le dichiarazioni di non responsabilità, i dati offrono un'idea ragionevole per le prestazioni correnti di varie primitive. I numeri hanno senso e sostantino l'asserzione che la maggior parte del codice gestito jitted esegue "vicino al computer" proprio come fa il codice nativo compilato. Le operazioni primitive integer e mobile sono chiamate di metodo veloci e di vari tipi in modo minore, ma (considerano attendibile) ancora paragonabili a C/C++native; e tuttavia si nota anche che alcune operazioni che sono in genere economiche nel codice nativo (cast, matrici e archivi di campi, puntatori a funzione (delegati)) sono ora più costose. Perché? Vediamo.

Operazioni aritmetiche

Tabella 2 Tempi di operazione aritmetica (ns)

Avg Min Primitiva Avg Min Primitiva
1.0 1.0 int add 1.3 1.3 float add
1.0 1.0 int sub 1.4 1.4 sub float
2.7 2.7 int mul 2.0 2.0 mul float
35.9 35.7 int div 27.7 27.6 float div
2.1 2.1 int shift      
2.1 2.1 aggiunta prolungata 1.5 1.5 double add
2.1 2.1 sub long 1.5 1.5 double sub
34.2 34.1 mulo lungo 2.1 2.0 mul doppio
50,1 50,0 long div 27.7 27.6 doppia div
5,1 5,1 spostamento lungo      

Nei vecchi giorni, la matematica a virgola mobile era forse un ordine di grandezza più lento rispetto alla matematica integer. Come illustrato nella tabella 2, con le unità a virgola mobile con pipeline moderne, viene visualizzato un numero minimo o nessuna differenza. È incredibile pensare che un PC notebook medio sia una macchina di classe gigaflop (per problemi che si adattano nella cache).

Esaminiamo una riga di codice jitted dall'intero e dai test a virgola mobile:

Disassembly 1 Aggiungere e float aggiungere e float

int add               a = a + b + c + d + e + f + g + h + i;
0000004c 8B 54 24 10      mov         edx,dword ptr [esp+10h] 
00000050 03 54 24 14      add         edx,dword ptr [esp+14h] 
00000054 03 54 24 18      add         edx,dword ptr [esp+18h] 
00000058 03 54 24 1C      add         edx,dword ptr [esp+1Ch] 
0000005c 03 54 24 20      add         edx,dword ptr [esp+20h] 
00000060 03 D5            add         edx,ebp 
00000062 03 D6            add         edx,esi 
00000064 03 D3            add         edx,ebx 
00000066 03 D7            add         edx,edi 
00000068 89 54 24 10      mov         dword ptr [esp+10h],edx 

float add            i += a + b + c + d + e + f + g + h;
00000016 D9 05 38 61 3E 00 fld         dword ptr ds:[003E6138h] 
0000001c D8 05 3C 61 3E 00 fadd        dword ptr ds:[003E613Ch] 
00000022 D8 05 40 61 3E 00 fadd        dword ptr ds:[003E6140h] 
00000028 D8 05 44 61 3E 00 fadd        dword ptr ds:[003E6144h] 
0000002e D8 05 48 61 3E 00 fadd        dword ptr ds:[003E6148h] 
00000034 D8 05 4C 61 3E 00 fadd        dword ptr ds:[003E614Ch] 
0000003a D8 05 50 61 3E 00 fadd        dword ptr ds:[003E6150h] 
00000040 D8 05 54 61 3E 00 fadd        dword ptr ds:[003E6154h] 
00000046 D8 05 58 61 3E 00 fadd        dword ptr ds:[003E6158h] 
0000004c D9 1D 58 61 3E 00 fstp        dword ptr ds:[003E6158h] 

Qui vediamo che il codice jitted è vicino a ottimale. int add Nel caso, il compilatore ha anche registrato cinque delle variabili locali. Nel caso di aggiunta float, ero costretto a creare variabili a tramite h statiche di classe per sconfiggere l'eliminazione comune di sottoespressione.

Chiamate al metodo

In questa sezione vengono esaminati i costi e le implementazioni delle chiamate al metodo. L'oggetto di test è una classe T che implementa l'interfaccia I, con vari tipi di metodi. Vedere Elenco 1.

Elencare i metodi di test delle chiamate al metodo 1

interface I { void itf1();… void itf5();… }
public class T : I {
    static bool falsePred = false;
    static void dummy(int a, int b, int c, …, int p) { }

    static void inl_s1() { } …    static void s1()     { if (falsePred) dummy(1, 2, 3, …, 16); } …    void inl_i1()        { } …    void i1()            { if (falsePred) dummy(1, 2, 3, …, 16); } …    public virtual void v1() { } …    void itf1()          { } …    virtual void itf5()  { } …}

Prendere in considerazione la tabella 3. Sembra che, per una prima approssimazione, un metodo sia inlined (l'astrazione non costa nulla) o meno (l'astrazione costa >5X un'operazione integer). Non sembra esserci una differenza significativa nel costo non elaborato di una chiamata statica, una chiamata di istanza, una chiamata virtuale o una chiamata di interfaccia.

Tabella 3 Metodo Call Times (ns)

Avg Min Primitiva Chiamato Avg Min Primitiva Chiamato
0,2 0,2 chiamata statica inlined inl_s1 5.4 5.4 chiamata virtuale v1
6.1 6.1 chiamata statica s1 5.4 5.4 questa chiamata virtuale v1
1.1 1,0 chiamata di istanza inlined inl_i1 6.6 6.5 chiamata all'interfaccia itf1
6.8 6.8 chiamata di istanza i1 1.1 1,0 chiamata di istanza itf inst itf1
0,2 0,2 inlined questa chiamata inst inl_i1 0,2 0,2 questa chiamata di istanza itf itf1
6.2 6.2 chiamata all'istanza i1 5.4 5.4 chiamata virtuale inst itf itf5
        5.4 5.4 questa chiamata virtuale itf itf5

Tuttavia, questi risultati sono casi migliori non rappresentativi, l'effetto dell'esecuzione di cicli di temporizzazione stretti milioni di volte. In questi test case, i siti di chiamata al metodo virtuale e dell'interfaccia sono monomorfi (ad esempio per ogni sito di chiamata, il metodo di destinazione non cambia nel tempo), quindi la combinazione dei meccanismi di invio del metodo virtuale e del metodo di interfaccia (i puntatori e le voci della mappa del metodo) e le voci del mapping delle interfacce, in modo spettacolare, consente al processore di eseguire un processo non realisticamente efficace chiamando tramite questi metodi altrimenti difficili da prevedere, rami dipendenti dai dati. In pratica, una cache dei dati non viene eseguita in uno dei dati del meccanismo di invio o su una mancata prepredizione di un ramo ,ad esempio una mancata capacità obbligatoria o un sito di chiamata polimorfica, può e rallenta le chiamate virtuali e di interfaccia per decine di cicli.

Esaminiamo più attentamente ognuno di questi tempi di chiamata al metodo.

Nel primo caso, chiamata statica inlined, chiamiamo una serie di metodi s1_inl() statici vuoti e così via. Poiché il compilatore è completamente inline tutte le chiamate, viene generato il time-time di un ciclo vuoto.

Per misurare il costo approssimativo di una chiamata al metodo statico, i metodi s1() statici e così grandi che non sono utilizzabili per inline nel chiamante.

Osservare che è anche necessario usare una variabile falsePreddi predicato false esplicita. Se abbiamo scritto

static void s1() { if (false) dummy(1, 2, 3, …, 16); }

il compilatore JIT eliminerebbe la chiamata morta a dummy e l'intero corpo del metodo (ora vuoto) come prima. In questo modo, alcuni dei 6.1 n di chiamata devono essere attributi al test del predicato (false) e saltare all'interno del metodo s1statico denominato . In questo modo, un modo migliore per disabilitare l'inlining è l'attributo CompilerServices.MethodImpl(MethodImplOptions.NoInlining) .

Lo stesso approccio è stato usato per la chiamata all'istanza inlined e il tempo di chiamata di istanza regolare. Tuttavia, poiché la specifica del linguaggio C# garantisce che qualsiasi chiamata a un riferimento a un oggetto Null genera un valore NullReferenceException, ogni sito di chiamata deve assicurarsi che l'istanza non sia Null. Questa operazione viene eseguita dereferendo il riferimento all'istanza; se è Null, genererà un errore che viene trasformato in questa eccezione.

In Disassembly 2 viene usata una variabile statica come istanza, perché quando si usa una variabile t locale

    T t = new T();

il compilatore ha eseguito l'archiviazione dell'istanza null del ciclo.

Sito di chiamata del metodo di disassembly 2 Istanza con istanza null "check"

               t.i1();
00000012 8B 0D 30 21 A4 05 mov         ecx,dword ptr ds:[05A42130h] 
00000018 39 09             cmp         dword ptr [ecx],ecx 
0000001a E8 C1 DE FF FF    call        FFFFDEE0 

I casi della chiamata all'istanza inlined e questa chiamata di istanza sono uguali, ad eccezione dell'istanza è this. Qui il controllo Null è stato elideto.

Disassembly 3 This instance method call site

               this.i1();
00000012 8B CE            mov         ecx,esi
00000014 E8 AF FE FF FF   call        FFFFFEC8

Le chiamate al metodo virtuale funzionano esattamente come nelle implementazioni C++ tradizionali. L'indirizzo di ogni metodo virtuale appena introdotto viene archiviato all'interno di un nuovo slot nella tabella dei metodi del tipo. Ogni tabella del metodo del tipo derivato è conforme a e estende quella del tipo di base e qualsiasi metodo virtuale sostituisce l'indirizzo del metodo virtuale del tipo di base con l'indirizzo del metodo virtuale del tipo derivato nello slot corrispondente nella tabella dei metodi del tipo derivato.

Nel sito di chiamata, una chiamata al metodo virtuale comporta due carichi aggiuntivi rispetto a una chiamata di istanza, uno per recuperare l'indirizzo della tabella del metodo (sempre trovato in *(this+0)), e un altro per recuperare l'indirizzo del metodo virtuale appropriato dalla tabella del metodo e chiamarlo. Vedere Disassembly 4.

Disassembly 4 Sito di chiamata al metodo virtuale

               this.v1();
00000012 8B CE            mov         ecx,esi 
00000014 8B 01            mov         eax,dword ptr [ecx] ; fetch method table address
00000016 FF 50 38         call        dword ptr [eax+38h] ; fetch/call method address

Infine, si arriva alle chiamate al metodo di interfaccia (Disassembly 5). Questi non hanno un equivalente esatto in C++. Qualsiasi tipo specificato può implementare qualsiasi numero di interfacce e ogni interfaccia richiede logicamente la tabella del metodo. Per eseguire l'invio su un metodo di interfaccia, cercare la tabella del metodo, la relativa mappa dell'interfaccia, la voce dell'interfaccia in tale mappa e quindi chiamare indiretto tramite la voce appropriata nella sezione dell'interfaccia della tabella del metodo.

Disassembly 5 Interface metodo call site

               i.itf1();
00000012 8B 0D 34 21 A4 05 mov        ecx,dword ptr ds:[05A42134h]; instance address
00000018 8B 01             mov        eax,dword ptr [ecx]         ; method table addr
0000001a 8B 40 0C          mov        eax,dword ptr [eax+0Ch]     ; interface map addr
0000001d 8B 40 7C          mov        eax,dword ptr [eax+7Ch]     ; itf method table addr
00000020 FF 10             call       dword ptr [eax]             ; fetch/call meth addr

Il resto dei tempi primitivi, la chiamata all'istanza itf, questa chiamata di istanza itf, la chiamata virtuale itf, questa chiamata virtuale itf evidenzia l'idea che ogni volta che un tipo derivato implementa un metodo di interfaccia, rimane chiamabile tramite un sito di chiamata del metodo di istanza.

Ad esempio, per il test di questa chiamata di istanza itf, una chiamata all'implementazione di un metodo di interfaccia tramite un riferimento di istanza (non interfaccia), il metodo di interfaccia viene inserito correttamente e il costo passa a 0 ns. Anche un'implementazione del metodo di interfaccia è potenzialmente inlineabile quando la si chiama come metodo di istanza.

Chiamate a metodi ancora jitte

Per le chiamate al metodo statico e dell'istanza (ma non per le chiamate al metodo virtuale e di interfaccia), il compilatore JIT genera attualmente sequenze di chiamate di metodo diverse a seconda che il metodo di destinazione sia già stato jitted dal momento in cui il sito di chiamata viene jitted.

Se il metodo di chiamata (metodo di destinazione) non è ancora stato jitted, il compilatore genera una chiamata indiretta tramite un puntatore che viene inizialmente inizializzato con un "stub prejit". La prima chiamata al metodo di destinazione arriva al stub, che attiva la compilazione JIT del metodo, la generazione di codice nativo e l'aggiornamento del puntatore per risolvere il nuovo codice nativo.

Se la chiamata è già stata jitted, l'indirizzo del codice nativo è noto in modo che il compilatore genera una chiamata diretta.

Creazione di nuovi oggetti

La creazione di un nuovo oggetto è costituita da due fasi: allocazione di oggetti e inizializzazione degli oggetti.

Per i tipi di riferimento, gli oggetti vengono allocati nell'heap garbage collection. Per i tipi di valore, sia in pila che incorporati all'interno di un altro tipo di riferimento o valore, l'oggetto tipo di valore viene trovato in un offset costante dalla struttura di inclusione, senza allocazione necessaria.

Per gli oggetti di tipo di riferimento di piccole dimensioni, l'allocazione heap è molto veloce. Dopo ogni Garbage Collection, ad eccezione della presenza di oggetti aggiunti, gli oggetti live della generazione 0 vengono compattati e promossi alla generazione 1 e quindi l'allocatore di memoria ha un'ampia arena di memoria contigua contigua con cui lavorare. La maggior parte delle allocazioni di oggetti comporta solo un aumento del puntatore e un controllo dei limiti, che è più economico rispetto all'allocatore di elenco gratuito C/C++ tipico (malloc/operator new). Il Garbage Collector tiene conto anche delle dimensioni della cache del computer per cercare di mantenere gli oggetti di generazione 0 nel punto rapido della gerarchia cache/memoria.

Poiché lo stile di codice gestito preferito consiste nell'allocare la maggior parte degli oggetti con durata breve e recuperarli rapidamente, sono inclusi anche (nel costo del tempo) il costo ammortizzato della Garbage Collection di questi nuovi oggetti.

Si noti che il Garbage Collector non impiega tempo a piangere oggetti morti. Se un oggetto è morto, GC non lo vede, non lo cammina, non lo dà un pensiero nanosecondo. GC è preoccupato solo per il benessere della vita.

(Eccezione: gli oggetti dead finalizzabili sono un caso speciale. GC tiene traccia di tali oggetti e promuove specialmente oggetti finalizzabili non finalizzabili alla prossima generazione in attesa di finalizzazione. Questo è costoso e nel peggiore dei casi può promuovere in modo transitivo i grafici a oggetti morti di grandi dimensioni. Pertanto, non rendere gli oggetti finalizzabili a meno che non siano strettamente necessari; e se è necessario, considerare l'uso del modello Dispose, chiamando GC.SuppressFinalizer quando possibile. A meno che Finalize il metodo non contenga riferimenti dall'oggetto finalizzabile ad altri oggetti.

Naturalmente, il costo GC ammortizzato di un oggetto di breve durata è maggiore del costo di un piccolo oggetto a breve durata. Ogni allocazione di oggetti ci porta molto più vicino al prossimo ciclo di Garbage Collection; gli oggetti più grandi fanno in modo che molto prima di quelli piccoli. Prima (o poi), il momento del conteggio verrà. I cicli GC, in particolare le raccolte di generazione 0, sono molto veloci, ma non sono liberi, anche se la maggior parte dei nuovi oggetti sono morti: per trovare (contrassegnare) gli oggetti live, è prima necessario sospendere i thread e quindi camminare stack e altre strutture di dati per raccogliere riferimenti a oggetti radice nell'heap.

Forse più significativamente, meno oggetti più grandi si adattano alla stessa quantità di cache degli oggetti più piccoli. Gli effetti di mancata memorizzazione nella cache possono facilmente dominare gli effetti della lunghezza del percorso del codice.

Una volta allocato lo spazio per l'oggetto, rimane per inizializzarlo (crearlo). CLR garantisce che tutti i riferimenti a oggetti siano preiniziali a Null e tutti i tipi scalari primitivi vengano inizializzati su 0, 0,0, false e così via. Pertanto, non è necessario farlo con ridondanza nei costruttori definiti dall'utente. Naturalmente, si sente libero. Tuttavia, tenere presente che il compilatore JIT attualmente non ottimizza necessariamente gli archivi ridondanti.

Oltre a zero dei campi dell'istanza, CLR inizializza (solo tipi di riferimento) i campi di implementazione interni dell'oggetto: il puntatore alla tabella del metodo e la parola di intestazione dell'oggetto, che precede il puntatore alla tabella del metodo. Le matrici ottengono anche un campo Length e le matrici di oggetti ottengono campi Lunghezza e tipo di elemento.

Il CLR chiama quindi il costruttore dell'oggetto, se presente. Il costruttore di ogni tipo, sia definito dall'utente o generato dal compilatore, chiama innanzitutto il costruttore del tipo di base, quindi esegue l'inizializzazione definita dall'utente, se disponibile.

In teoria questo potrebbe essere costoso per scenari di ereditarietà profonda. Se E estende D estende C estende B (estende System.Object), l'inizializzazione di un E comporta sempre cinque chiamate di metodo. In pratica, le cose non sono così cattive, perché il compilatore inlinese (in nulla) chiama i costruttori di tipi di base vuoti.

Facendo riferimento alla prima colonna della tabella 4, osservare che è possibile creare e inizializzare uno struct D con quattro campi int in circa 8 int-add-time. Disassembly 6 è il codice generato da tre cicli di intervallo diversi, la creazione di A, C e E. All'interno di ogni ciclo viene modificata ogni nuova istanza, che mantiene il compilatore JIT dall'ottimizzazione di tutto.

Tabella 4 Value and Reference Type Object Creation Times (ns)

Avg Min Primitiva Avg Min Primitiva Avg Min Primitiva
2.6 2.6 nuovo valtype L1 22,0 20.3 nuovo reftype L1 22.9 20.7 nuovo ctor L1
4,6 4,6 nuovo valtype L2 26.1 23.9 nuovo reftype L2 27.8 25.4 new rt ctor L2
6.4 6.4 new valtype L3 30.2 27.5 nuovo reftype L3 32.7 29.9 new rt ctor L3
8.0 8.0 new valtype L4 34.1 30.8 nuovo reftype L4 37.7 34.1 new rt ctor L4
23.0 22.9 new valtype L5 39.1 34,4 nuovo reftype L5 43.2 39.1 nuovo rt ctor L5
      22,3 20.3 new rt empty ctor L1 28.6 26.7 new rt no-inl L1
      26.5 23.9 new rt empty ctor L2 38.9 36.5 new rt no-inl L2
      38.1 34.7 new rt empty ctor L3 50.6 47.7 new rt no-inl L3
      34.7 30.7 new rt empty ctor L4 61.8 58.2 new rt no-inl L4
      38.5 34.3 new rt empty ctor L5 72.6 68.5 new rt no-inl L5

Disassembly 6 Costruzione dell'oggetto tipo valore

               A a1 = new A(); ++a1.a;
00000020 C7 45 FC 00 00 00 00 mov     dword ptr [ebp-4],0 
00000027 FF 45 FC         inc         dword ptr [ebp-4] 

               C c1 = new C(); ++c1.c;
00000024 8D 7D F4         lea         edi,[ebp-0Ch] 
00000027 33 C0            xor         eax,eax 
00000029 AB               stos        dword ptr [edi] 
0000002a AB               stos        dword ptr [edi] 
0000002b AB               stos        dword ptr [edi] 
0000002c FF 45 FC         inc         dword ptr [ebp-4] 

               E e1 = new E(); ++e1.e;
00000026 8D 7D EC         lea         edi,[ebp-14h] 
00000029 33 C0            xor         eax,eax 
0000002b 8D 48 05         lea         ecx,[eax+5] 
0000002e F3 AB            rep stos    dword ptr [edi] 
00000030 FF 45 FC         inc         dword ptr [ebp-4] 

I cinque tempi successivi (nuovo reftype L1, ... new reftype L5) sono per cinque livelli di ereditarietà di tipi A di riferimento, ..., E, sans costruttori definiti dall'utente:

    public class A     { int a; }
    public class B : A { int b; }
    public class C : B { int c; }
    public class D : C { int d; }
    public class E : D { int e; }

Confrontando i tempi del tipo di riferimento con i tempi del tipo di valore, si noterà che l'allocazione ammortizzata e il costo di liberamento di ogni istanza è di circa 20 ns (20X int add time) nel computer di test. Questo è veloce, ovvero l'allocazione, l'inizializzazione e il recupero di circa 50 milioni di oggetti di breve durata al secondo, sostenuti. Per gli oggetti di dimensioni ridotte di cinque campi, l'allocazione e la raccolta fanno parte solo della metà del tempo di creazione dell'oggetto. Vedere Disassembly 7.

Disassembly 7 Costruzione di oggetti di tipo riferimento

               new A();
0000000f B9 D0 72 3E 00   mov         ecx,3E72D0h 
00000014 E8 9F CC 6C F9   call        F96CCCB8 

               new C();
0000000f B9 B0 73 3E 00   mov         ecx,3E73B0h 
00000014 E8 A7 CB 6C F9   call        F96CCBC0 

               new E();
0000000f B9 90 74 3E 00   mov         ecx,3E7490h 
00000014 E8 AF CA 6C F9   call        F96CCAC8 

Gli ultimi tre set di cinque intervalli presentano variazioni in questo scenario di costruzione di classi ereditate.

  1. Nuovo ctor rt vuoto L1, ..., nuovo ctor rt vuoto L5: Ogni tipo A, ..., E ha un costruttore vuoto definito dall'utente. Questi elementi sono tutti inline e il codice generato è uguale a quello precedente.

  2. Nuovo rt ctor L1, ..., nuovo ctor L5: Ogni tipo A, ..., E ha un costruttore definito dall'utente che imposta la variabile di istanza su 1:

        public class A     { int a; public A() { a = 1; } }
        public class B : A { int b; public B() { b = 1; } }
        public class C : B { int c; public C() { c = 1; } }
        public class D : C { int d; public D() { d = 1; } }
        public class E : D { int e; public E() { e = 1; } }
    

Il compilatore inline ogni set di chiamate del costruttore della classe base annidata nel new sito. (Disassembly 8).

Disassembly 8 Costruttori ereditati deeply inlined

               new A();
00000012 B9 A0 77 3E 00   mov         ecx,3E77A0h 
00000017 E8 C4 C7 6C F9   call        F96CC7E0 
0000001c C7 40 04 01 00 00 00 mov     dword ptr [eax+4],1 

               new C();
00000012 B9 80 78 3E 00   mov         ecx,3E7880h 
00000017 E8 14 C6 6C F9   call        F96CC630 
0000001c C7 40 04 01 00 00 00 mov     dword ptr [eax+4],1 
00000023 C7 40 08 01 00 00 00 mov     dword ptr [eax+8],1 
0000002a C7 40 0C 01 00 00 00 mov     dword ptr [eax+0Ch],1 

               new E();
00000012 B9 60 79 3E 00   mov         ecx,3E7960h 
00000017 E8 84 C3 6C F9   call        F96CC3A0 
0000001c C7 40 04 01 00 00 00 mov     dword ptr [eax+4],1 
00000023 C7 40 08 01 00 00 00 mov     dword ptr [eax+8],1 
0000002a C7 40 0C 01 00 00 00 mov     dword ptr [eax+0Ch],1 
00000031 C7 40 10 01 00 00 00 mov     dword ptr [eax+10h],1 
00000038 C7 40 14 01 00 00 00 mov     dword ptr [eax+14h],1 
  1. New rt no-inl L1, ..., new rt no-inl L5: Ogni tipo A, ..., E ha un costruttore definito dall'utente che è stato scritto intenzionalmente per essere troppo costoso inline. Questo scenario simula il costo della creazione di oggetti complessi con gerarchie di ereditarietà profonda e costruttori largish.

      public class A     { int a; public A() { a = 1; if (falsePred) dummy(…); } }
      public class B : A { int b; public B() { b = 1; if (falsePred) dummy(…); } }
      public class C : B { int c; public C() { c = 1; if (falsePred) dummy(…); } }
      public class D : C { int d; public D() { d = 1; if (falsePred) dummy(…); } }
      public class E : D { int e; public E() { e = 1; if (falsePred) dummy(…); } }
    

Gli ultimi cinque intervalli nella tabella 4 mostrano l'overhead aggiuntivo della chiamata dei costruttori di base annidati.

Interlude: CLR Profiler Demo

Per una rapida demo di CLR Profiler. CLR Profiler, in precedenza noto come Allocation Profiler, usa le API di profilatura CLR per raccogliere i dati degli eventi, in particolare per chiamare, restituire ed eseguire l'allocazione degli oggetti e gli eventi di Garbage Collection, durante l'esecuzione dell'applicazione. Il profiler CLR è un profiler "invasivo", il che significa che purtroppo rallenta notevolmente l'applicazione profilata. Dopo aver raccolto gli eventi, si usa CLR Profiler per esplorare l'allocazione della memoria e il comportamento GC dell'applicazione, inclusa l'interazione tra il grafico delle chiamate gerarchico e i modelli di allocazione della memoria.

CLR Profiler vale la pena di imparare perché per molte applicazioni di codice gestito "con problemi di prestazioni", la comprensione del profilo di allocazione dei dati fornisce le informazioni critiche necessarie per ridurre il working set e quindi offrire componenti e applicazioni veloci e frugali.

CLR Profiler può anche rivelare quali metodi allocano più spazio di archiviazione del previsto e possono individuare casi in cui si mantengono inavvertitamente riferimenti a grafici di oggetti inutili che altrimenti potrebbero essere recuperati da GC. Un modello di progettazione di problemi comune è una cache software o una tabella di ricerca di elementi che non sono più necessari o sono sicuri da ricostituire in un secondo momento. È tragico quando una cache mantiene vivi gli oggetti grafici oltre la loro vita utile. Assicurarsi invece di annullare i riferimenti agli oggetti non più necessari.

La figura 1 è una visualizzazione della sequenza temporale dell'heap durante l'esecuzione del driver di test di temporizzazione. Il modello sawtooth indica l'allocazione di molte migliaia di istanze di oggetti C (magenta), D (viola) e E (blu). Ogni pochi millisecondi, abbiamo masticato un altro ~150 KB di RAM nell'heap del nuovo oggetto (generazione 0) e il Garbage Collector viene eseguito brevemente per riciclarlo e alzare di livello qualsiasi oggetto attivo alla generazione 1. È notevole che anche in questo ambiente di profilatura invasivo (lento) nell'intervallo di 100 ms (da 2,8 s a 2,9s), siamo sottoposti a ~8 cicli GC di generazione 0. Quindi a 2.977 s, rendendo spazio per un'altra E istanza, Il Garbage Collector esegue una Garbage Collection di generazione 1, che raccoglie e compatta l'heap di generazione 1, e quindi il sawtooth continua, da un indirizzo iniziale inferiore.

Figura1 Visualizzazione linea temporale del profiler CLR

Si noti che più grande è l'oggetto (E maggiore di D maggiore di C), più velocemente si riempie l'heap di generazione 0 e il ciclo GC più frequente.

Cast e controlli dei tipi di istanza

La base fondamentale del codice gestito sicuro, sicuro e verificabile è la sicurezza dei tipi. Se fosse possibile eseguire il cast di un oggetto a un tipo che non lo è, sarebbe semplice compromettere l'integrità di CLR e quindi farlo alla mercé di codice non attendibile.

Tabella 5 Cast e isinst Times (ns)

Avg Min Primitiva Avg Min Primitiva
0,4 0,4 cast up 1 0,8 0,8 isinst up 1
0,3 0,3 cast down 0 0,8 0,8 isinst giù 0
8.9 8.8 cast down 1 6.3 6.3 isinst down 1
9.8 9.7 cast (su 2) giù 1 10,7 10.6 isinst (fino 2) giù 1
8.9 8.8 cast down 2 6.4 6.4 isinst giù 2
8.7 8,6 cast down 3 6.1 6.1 isinst down 3

La tabella 5 mostra il sovraccarico di questi controlli di tipo obbligatori. Un cast da un tipo derivato a un tipo di base è sempre sicuro e gratuito; mentre un cast da un tipo di base a un tipo derivato deve essere controllato dal tipo.

Un cast (selezionato) converte il riferimento all'oggetto nel tipo di destinazione o genera un'eccezione InvalidCastException.

Al contrario, l'istruzione isinst CIL viene usata per implementare la parola chiave C# as :

bac = ac as B;

Se ac non B è o derivata da B, il risultato è null, non un'eccezione.

L'elenco 2 illustra uno dei cicli di temporizzazione del cast e Disassembly 9 mostra il codice generato per un cast down a un tipo derivato. Per eseguire il cast, il compilatore genera una chiamata diretta a una routine helper.

Listato 2 Ciclo per testare la tempistica del cast

public static void castUp2Down1(int n) {
    A ac = c; B bd = d; C ce = e; D df = f;
    B bac = null; C cbd = null; D dce = null; E edf = null;
    for (n /= 8; --n >= 0; ) {
        bac = (B)ac; cbd = (C)bd; dce = (D)ce; edf = (E)df;
        bac = (B)ac; cbd = (C)bd; dce = (D)ce; edf = (E)df;
    }
}

Disassembly 9 Down cast

               bac = (B)ac;
0000002e 8B D5            mov         edx,ebp 
00000030 B9 40 73 3E 00   mov         ecx,3E7340h 
00000035 E8 32 A7 4E 72   call        724EA76C 

Proprietà

Nel codice gestito, una proprietà è una coppia di metodi, un getter di proprietà e un setter di proprietà che agiscono come un campo di un oggetto. Il metodo get_ recupera la proprietà; il metodo set_ aggiorna la proprietà a un nuovo valore.

A parte questo, le proprietà si comportano e i costi, proprio come i metodi di istanza normali e i metodi virtuali. Se si usa una proprietà per recuperare o archiviare semplicemente un campo di istanza, viene in genere inlined, come con qualsiasi metodo di piccole dimensioni.

La tabella 6 mostra il tempo necessario per recuperare (e aggiungere) e per archiviare un set di campi e proprietà di istanza integer. Il costo di recupero o impostazione di una proprietà è effettivamente identico all'accesso diretto al campo sottostante, a meno che la proprietà non sia dichiarata virtuale, nel qual caso il costo è approssimativamente quello di una chiamata al metodo virtuale. Non c'è sorpresa.

Tabella 6 campi e tempi delle proprietà (ns)

Avg Min Primitiva
1.0 1.0 Campo get
1.2 1.2 get prop
1.2 1.2 campo set
1.2 1.2 set prop
6.4 6.3 get virtual prop
6.4 6.3 set virtual prop

Barriere di scrittura

Il Garbage Collector CLR sfrutta al meglio l'"ipotesi generazionale", ovvero la maggior parte dei nuovi oggetti muore giovane, per ridurre al minimo il sovraccarico della raccolta.

L'heap viene partizionato logicamente in generazioni. Gli oggetti più recenti risiedono nella generazione 0 (generazione 0). Questi oggetti non sono ancora sopravvissuti a una raccolta. Durante una raccolta di generazione 0, GC determina quale, se presente, gli oggetti gen 0 sono raggiungibili dal set radice GC, che include riferimenti agli oggetti nei registri computer, nello stack, nei riferimenti a oggetti campo statici della classe e così via. Gli oggetti raggiungibili in modo transitivo sono "attivi" e promossi (copiati) alla generazione 1.

Poiché le dimensioni totali dell'heap possono essere centinaia di MB, mentre le dimensioni dell'heap di generazione 0 potrebbero essere di soli 256 KB, limitando l'estensione della traccia dell'heap degli oggetti di GC all'heap di generazione 0 è un'ottimizzazione essenziale per ottenere i tempi di sospensione della raccolta molto brevi di CLR.

Tuttavia, è possibile archiviare un riferimento a un oggetto gen 0 in un campo di riferimento oggetto di un oggetto di generazione 1 o gen 2. Poiché non si analizzano oggetti gen 1 o gen 2 durante un insieme di generazione 0, se questo è l'unico riferimento all'oggetto gen 0 specificato, tale oggetto potrebbe essere recuperato erroneamente da GC. Non possiamo lasciare che accada!

Tutti gli archivi invece in tutti i campi di riferimento dell'oggetto nell'heap comportano una barriera di scrittura. Si tratta di codice di contabilità che annota in modo efficiente gli archivi di riferimenti a oggetti di nuova generazione in campi di oggetti di generazione meno recenti. Tali campi di riferimento all'oggetto precedente vengono aggiunti al set radice GC successivo di GC.Such old object reference fields are added to the GC root set of successive GC(s).

L'overhead della barriera di scrittura per oggetto-reference-field-store è paragonabile al costo di una semplice chiamata al metodo (tabella 7). Si tratta di una nuova spesa che non è presente nel codice C/C++ nativo, ma in genere è un piccolo prezzo da pagare per l'allocazione di oggetti super veloce e GC e i numerosi vantaggi di produttività della gestione automatica della memoria.

Tabella 7 Tempo barriera di scrittura (ns)

Avg Min Primitiva
6.4 6.4 barriera di scrittura

Le barriere di scrittura possono essere costose nei cicli interni stretti. Ma negli anni a venire possiamo guardare avanti alle tecniche di compilazione avanzate che riducono il numero di barriere di scrittura prese e il costo totale ammortizzato.

È possibile pensare che le barriere di scrittura siano necessarie solo negli archivi per i campi di riferimento degli oggetti dei tipi di riferimento. Tuttavia, all'interno di un metodo di tipo valore, archivia i campi di riferimento dell'oggetto (se presenti) sono protetti anche da barriere di scrittura. Ciò è necessario perché il tipo di valore stesso può talvolta essere incorporato all'interno di un tipo di riferimento che risiede nell'heap.

Accesso all'elemento array

Per diagnosticare e impedire errori e danneggiamenti di array out-of-bounds e per proteggere l'integrità del CLR stesso, i carichi di elementi di matrice e gli archivi sono controllati, assicurando che l'indice si trovi all'interno dell'intervallo [0,array. Lunghezza-1] inclusiva o generata IndexOutOfRangeException.

I test misurano il tempo per caricare o archiviare elementi di una matrice e di una int[]A[] matrice. (Tabella 8).

Tabella 8 Tempi di accesso alla matrice (ns)

Avg Min Primitiva
1,9 1,9 load int array elem
1,9 1,9 store int array elem
2,5 2,5 load obj array elem
16,0 16,0 store obj array elem

Il controllo dei limiti richiede il confronto dell'indice della matrice alla matrice implicita. Campo Lunghezza. Come mostra Disassembly 10, in solo due istruzioni si verifica che l'indice non sia minore di 0 né maggiore o uguale a matrice. Lunghezza: se è, si ramiamo in una sequenza fuori riga che genera l'eccezione. Lo stesso vale per i carichi di elementi della matrice di oggetti e per gli archivi in matrici di ints e altri tipi di valori semplici. (Load obj array elem time è (in modo insignificante) più lento a causa di una leggera differenza nel ciclo interno.

Elemento array di disassembly 10 Load int

                          ; i in ecx, a in edx, sum in edi
               sum += a[i];
00000024 3B 4A 04         cmp         ecx,dword ptr [edx+4] ; compare i and array.Length
00000027 73 19            jae         00000042 
00000029 03 7C 8A 08      add         edi,dword ptr [edx+ecx*4+8] 
…                         ; throw IndexOutOfRangeException
00000042 33 C9            xor         ecx,ecx 
00000044 E8 52 78 52 72   call        7252789B 

Grazie alle ottimizzazioni di qualità del codice, il compilatore JIT spesso elimina i controlli dei limiti ridondanti.

Richiamando le sezioni precedenti, è possibile prevedere che gli archivi di elementi della matrice di oggetti siano notevolmente più costosi. Per archiviare un riferimento a un oggetto in una matrice di riferimenti a oggetti, il runtime deve:

  1. check array index is in bounds;check array index is in bounds;
  2. l'oggetto check è un'istanza del tipo di elemento matrice;
  3. eseguire una barriera di scrittura (notando alcun riferimento all'oggetto intergenerazionale dalla matrice all'oggetto).

Questa sequenza di codice è piuttosto lunga. Anziché generarla in ogni sito dell'archivio matrice di oggetti, il compilatore genera una chiamata a una funzione helper condivisa, come illustrato in Disassembly 11. Questa chiamata, più questi tre account azioni per il tempo aggiuntivo necessario in questo caso.

Elemento array di oggetti Store 11 Disassembly

                          ; objarray in edi
                          ; obj      in ebx
               objarray[1] = obj;
00000027 53               push        ebx  
00000028 8B CF            mov         ecx,edi 
0000002a BA 01 00 00 00   mov         edx,1 
0000002f E8 A3 A0 4A 72   call        724AA0D7   ; store object array element helper

Boxing e unboxing

Una partnership tra compilatori .NET e CLR consente ai tipi di valore, inclusi i tipi primitivi come int (System.Int32), di partecipare come se fossero tipi di riferimento, da risolvere come riferimenti agli oggetti. Questa offerta, questo zucchero sintattico, consente di passare i tipi di valore ai metodi come oggetti, archiviati nelle raccolte come oggetti e così via.

Per "box" un tipo di valore consiste nel creare un oggetto tipo di riferimento che contiene una copia del relativo tipo di valore. Si tratta concettualmente dello stesso tipo di creazione di una classe con un campo di istanza senza nome dello stesso tipo del valore.

Per "unbox" un tipo di valore casellato consiste nel copiare il valore, dall'oggetto, in una nuova istanza del tipo di valore.

Come mostra la tabella 9 (rispetto alla tabella 4), il tempo ammortizzato necessario per la casella di un int e successivamente per la garbage collection, è paragonabile al tempo necessario per creare un'istanza di una classe piccola con un campo int.

Tabella 9 Box e Unbox int Times (ns)

Avg Min Primitiva
29.0 21.6 box int
3,0 3,0 unbox int

Per annullare la casella di posta in un oggetto int, è necessario un cast esplicito int. In questo modo viene compilato un confronto tra il tipo dell'oggetto (rappresentato dall'indirizzo della tabella del metodo) e l'indirizzo della tabella del metodo int in box. Se sono uguali, il valore viene copiato dall'oggetto. In caso contrario, viene generata un'eccezione. Vedere Disassembly 12.

Disassembly 12 Box e unbox int

box               object o = 0;
0000001a B9 08 07 B9 79   mov         ecx,79B90708h 
0000001f E8 E4 A5 6C F9   call        F96CA608 
00000024 8B D0            mov         edx,eax 
00000026 C7 42 04 00 00 00 00 mov         dword ptr [edx+4],0 

unbox               sum += (int)o;
00000041 81 3E 08 07 B9 79 cmp         dword ptr [esi],79B90708h ; "type == typeof(int)"?
00000047 74 0C            je          00000055 
00000049 8B D6            mov         edx,esi 
0000004b B9 08 07 B9 79   mov         ecx,79B90708h 
00000050 E8 A9 BB 4E 72   call        724EBBFE                   ; no, throw exception
00000055 8D 46 04         lea         eax,[esi+4]
00000058 3B 08            cmp         ecx,dword ptr [eax] 
0000005a 03 38            add         edi,dword ptr [eax]        ; yes, fetch int field

Delegati

In C, un puntatore alla funzione è un tipo di dati primitivo che archivia letteralmente l'indirizzo della funzione.

C++ aggiunge puntatori alle funzioni membro. Un puntatore alla funzione membro (PMF) rappresenta una chiamata di funzione membro posticipata. L'indirizzo di una funzione membro non virtuale può essere un semplice indirizzo di codice, ma l'indirizzo di una funzione membro virtuale deve rappresentare una determinata chiamata di funzione membro virtuale: la dereferenza di tale PMF è una chiamata di funzione virtuale.

Per dereferenziare un PMF C++, è necessario specificare un'istanza:

    A* pa = new A;
    void (A::*pmf)() = &A::af;
    (pa->*pmf)();

Anni fa, nel team di sviluppo del compilatore Visual C++ è stato usato per chiedere a noi stessi, quale tipo di bestia è l'espressione pa->*pmf naked (operatore di chiamata di funzione sans)? È stato chiamato un puntatore associato alla funzione membro , ma la chiamata alla funzione membro latente è proprio come apt.

Tornare alla terra del codice gestito, un oggetto delegato è solo questo, una chiamata di metodo latente. Un oggetto delegato rappresenta sia il metodo da chiamare che l'istanza da chiamare oppure per un delegato a un metodo statico, solo il metodo statico da chiamare.

(Come indicato nella documentazione: una dichiarazione delegato definisce un tipo di riferimento che può essere usato per incapsulare un metodo con una firma specifica. Un'istanza delegato incapsula un metodo statico o di un'istanza. I delegati sono approssimativamente simili ai puntatori alle funzioni in C++; tuttavia, i delegati sono sicuri e protetti dal tipo.

I tipi di delegato in C# sono tipi derivati di MulticastDelegate. Questo tipo fornisce una semantica avanzata, inclusa la possibilità di compilare un elenco di chiamate di coppie (oggetto,metodo) da richiamare quando si richiama il delegato.

I delegati forniscono anche una struttura per la chiamata al metodo asincrono. Dopo aver definito un tipo delegato e crearne un'istanza, inizializzata con una chiamata di metodo latente, è possibile richiamarla in modo sincrono (sintassi della chiamata al metodo) o in modo asincrono, tramite BeginInvoke. Se BeginInvoke viene chiamato, il runtime accoda la chiamata e restituisce immediatamente al chiamante. Il metodo di destinazione viene chiamato più avanti in un thread del pool di thread.

Tutte queste semantiche ricche non sono economiche. Confronto tra tabella 10 e Tabella 3, si noti che il delegato invoke è ** circa otto volte più lento di una chiamata al metodo. Si prevede che migliorare nel tempo.

Tabella 10 Delegato invoke time (ns)

Avg Min Primitiva
41.1 40.9 richiamare delegato

Errori di cache, errori di pagina e architettura computer

Nel 1983, i processori erano lenti (~.5 milioni di istruzioni/s) e relativamente parlando, la RAM era abbastanza veloce, ma piccolo (circa 300 ns tempi di accesso su 256 KB di DRAM), e i dischi erano lenti e grandi (~25 ms access times su dischi da 10 MB). I microprocessori PC erano CISC scalari, la maggior parte del punto mobile era nel software e non c'erano cache.

Dopo venti anni di Legge di Moore, circa 2003, i processori sono veloci (eseguono fino a tre operazioni per ciclo a 3 GHz), la RAM è relativamente lenta (~100 ns access time su 512 MB di DRAM) e i dischi sono glacialmente lenti e enormi (~10 ms access time su 100 GB di dischi). I microprocessori PC sono ora out-of-order dataflow superscalar hyperthreading trace-cache (esecuzione di istruzioni CISC decodificate) e ci sono diversi livelli di cache, ad esempio un determinato microprocessore orientato al server ha 32 KB level 1 data cache (forse 2 cicli di latenza), 512 KB L2 data cache e 2 MB L3 data cache (forse una dozzina di cicli di latenza), tutto su chip.

Nei vecchi giorni, è possibile, e a volte ha fatto, contare i byte di codice scritti e contare il numero di cicli necessari per l'esecuzione del codice. Un carico o un archivio ha impiegato circa lo stesso numero di cicli di un componente aggiuntivo. Il processore moderno usa la stima, la speculazione e l'esecuzione out-of-order (flusso di dati) in più unità di funzione per trovare il parallelismo a livello di istruzione e quindi apportare progressi su diversi fronti contemporaneamente.

Ora i PC più veloci possono eseguire fino a ~9000 operazioni per microsecondo, ma in quel microsecondo, caricare o archiviare solo le righe della cache DRAM ~10. Nei cerchi dell'architettura del computer questo è noto come colpire il muro di memoria. Le cache nascondono la latenza di memoria, ma solo a un punto. Se il codice o i dati non rientrano nella cache e/o presentano scarse località di riferimento, il jet supersonico per microsecondo operazione viene degenerato a 10 tricycle di carico per microsecondo.

E (non lasciare che ciò accada a voi) se il working set di un programma supera la RAM fisica disponibile, e il programma inizia a prendere errori di pagina rigida, quindi in ogni servizio di errore di pagina di 10.000 microsecondi (accesso al disco), abbiamo perso l'opportunità di portare l'utente fino a 90 milioni di operazioni più vicino alla loro risposta. Questo è solo così orribile che mi fidatevi che da questo giorno in avanti prendersi cura di misurare il set di lavoro (vadump) e usare strumenti come CLR Profiler per eliminare allocazioni non necessarie e conservazione inavvertita dei grafici a oggetti.

Ma cosa c'è da fare con conoscere il costo delle primitive del codice gestito?Tutto*.*

Richiamando la tabella 1, l'elenco omnibus dei tempi primitivi del codice gestito, misurato su un P-III da 1,1 GHz, osserva che ogni volta, anche il costo ammortizzato dell'allocazione, dell'inizializzazione e del recupero di un oggetto campo con cinque livelli di chiamate esplicite al costruttore, è più veloce di un singolo accesso DRAM. Un solo carico che perde tutti i livelli di cache su chip può richiedere più tempo per il servizio rispetto a quasi tutte le singole operazioni di codice gestito.

Pertanto, se si è appassionati della velocità del codice, è fondamentale prendere in considerazione e misurare la gerarchia di cache/memoria durante la progettazione e l'implementazione di algoritmi e strutture di dati.

Tempo per una semplice dimostrazione: è più veloce sommare una matrice di int o sommare un elenco collegato equivalente di int? Quale, quanto, e perché?

Pensaci per un minuto. Per elementi di piccole dimensioni, ad esempio int, il footprint di memoria per ogni elemento della matrice è un quarto di quello dell'elenco collegato. Ogni nodo elenco collegato ha due parole di overhead dell'oggetto e due parole di campi (collegamento successivo e elemento int). Questo farà male all'utilizzo della cache. Assegnare un punteggio a uno per l'approccio alla matrice.

Tuttavia, l'attraversamento della matrice potrebbe comportare un controllo dei limiti di matrice per ogni elemento. Si è appena visto che il controllo dei limiti richiede un po 'di tempo. Forse i suggerimenti per le scale a favore dell'elenco collegato?

Disassembly 13 Sum int array versus sum int linked list

sum int array:            sum += a[i];
00000024 3B 4A 04         cmp         ecx,dword ptr [edx+4]       ; bounds check
00000027 73 19            jae         00000042 
00000029 03 7C 8A 08      add         edi,dword ptr [edx+ecx*4+8] ; load array elem
               for (int i = 0; i < m; i++)
0000002d 41               inc         ecx  
0000002e 3B CE            cmp         ecx,esi 
00000030 7C F2            jl          00000024 


sum int linked list:         sum += l.item; l = l.next;
0000002a 03 70 08         add         esi,dword ptr [eax+8] 
0000002d 8B 40 04         mov         eax,dword ptr [eax+4] 
               sum += l.item; l = l.next;
00000030 03 70 08         add         esi,dword ptr [eax+8] 
00000033 8B 40 04         mov         eax,dword ptr [eax+4] 
               sum += l.item; l = l.next;
00000036 03 70 08         add         esi,dword ptr [eax+8] 
00000039 8B 40 04         mov         eax,dword ptr [eax+4] 
               sum += l.item; l = l.next;
0000003c 03 70 08         add         esi,dword ptr [eax+8] 
0000003f 8B 40 04         mov         eax,dword ptr [eax+4] 
               for (m /= 4; --m >= 0; ) {
00000042 49               dec         ecx  
00000043 85 C9            test        ecx,ecx 
00000045 79 E3            jns         0000002A 

Facendo riferimento a Disassembly 13, ho impilati il mazzo a favore dell'attraversamento dell'elenco collegato, annullando la registrazione quattro volte, anche rimuovendo il consueto controllo di fine elenco puntatore Null. Ogni elemento nel ciclo matrice richiede sei istruzioni, mentre ogni elemento del ciclo elenco collegato richiede solo istruzioni 11/4 = 2,75. Ora che si supponga che sia più veloce?

Condizioni di test: creare prima una matrice di un milione di int e un semplice elenco collegato tradizionale di un milione di int (1 nodi elenco M). Quindi il tempo necessario per ogni elemento per aggiungere i primi 1.000, 10.000, 100.000, 100.000 e 1.000.000 elementi. Ripetere ogni ciclo più volte per misurare il comportamento della cache più flat per ogni caso.

Quale è più veloce? Dopo aver indovinato, fare riferimento alle risposte: le ultime otto voci nella tabella 1.

Interessante! I tempi diventano notevolmente più lenti man mano che i dati a cui si fa riferimento aumentano di dimensioni superiori a quelle successive della cache. La versione della matrice è sempre più veloce rispetto alla versione dell'elenco collegato, anche se viene eseguita il doppio del numero di istruzioni; per 100.000 elementi, la versione della matrice è sette volte più veloce.

Perché è così? Prima di tutto, un minor numero di elementi dell'elenco collegato rientra in un determinato livello di cache. Tutte le intestazioni degli oggetti e collega lo spazio sprecato. In secondo luogo, il processore di flussi di dati out-of-order moderno può eseguire lo zoom avanti e fare progressi su diversi elementi nella matrice contemporaneamente. Al contrario, con l'elenco collegato, fino a quando il nodo elenco corrente non è nella cache, il processore non può iniziare a recuperare il collegamento successivo al nodo dopo.

Nel caso di 100.000 elementi, il processore sta spendendo (in media) approssimativamente (22-3,5)/22 = 84% del tempo di twidding dei pollici in attesa che la riga della cache di un nodo elenco venga letta da DRAM. Sembra male, ma le cose potrebbero essere molto peggiori. Poiché le voci dell'elenco collegato sono piccole, molte di esse si adattano a una riga della cache. Poiché si attraversa l'elenco nell'ordine di allocazione e poiché Garbage Collector mantiene l'ordine di allocazione anche quando compatta gli oggetti non recapitabili dall'heap, è probabile che, dopo aver recuperato un nodo in una riga della cache, anche i diversi nodi successivi si trovino nella cache. Se i nodi erano più grandi o se i nodi dell'elenco erano in un ordine di indirizzi casuali, ogni nodo visitato potrebbe essere un mancato riscontro nella cache completa. L'aggiunta di 16 byte a ogni nodo di elenco raddoppia il tempo di attraversamento per ogni elemento a 43 ns; +32 byte, 67 ns/item; e aggiungendo 64 byte lo raddoppia di nuovo, a 146 ns/item, probabilmente la latenza media di DRAM nel computer di test.

Allora, qual è la lezione di takeaway qui? Evitare elenchi collegati di 100.000 nodi? No. La lezione è che gli effetti della cache possono dominare qualsiasi considerazione dell'efficienza di basso livello del codice gestito rispetto al codice nativo. Se si scrive codice gestito critico per le prestazioni, in particolare il codice che gestisce strutture di dati di grandi dimensioni, tenere presente gli effetti della cache, considerare i modelli di accesso alla struttura dei dati e cercare footprint di dati più piccoli e una buona località di riferimento.

A proposito, la tendenza è che il muro di memoria, il rapporto tra il tempo di accesso di DRAM diviso per il tempo di operazione della CPU, continuerà a crescere peggio nel tempo.

Ecco alcune regole di progettazione "consapevole della cache":

  • Sperimentare e misurare gli scenari perché è difficile stimare gli effetti del secondo ordine e perché le regole di identificazione non valgono la carta su cui vengono stampate.
  • Alcune strutture di dati, esemplificate da matrici, usano l'adiacenza implicita per rappresentare una relazione tra i dati. Altri, esemplificati da elenchi collegati, usano puntatori espliciti (riferimenti) per rappresentare la relazione. L'adiacenza implicita è generalmente preferibile: "implicitità" consente di risparmiare spazio rispetto ai puntatori; e l'adiacenza fornisce una posizione stabile di riferimento e può consentire al processore di iniziare più lavoro prima di inseguire il puntatore successivo.
  • Alcuni modelli di utilizzo favoriscono le strutture ibride, ovvero elenchi di matrici di piccole dimensioni, matrici di matrici o alberi B.
  • Ad esempio, gli algoritmi di pianificazione sensibili all'accesso ai dischi, progettati di nuovo quando l'accesso al disco costa solo 50.000 istruzioni sulla CPU, dovrebbero essere riciclati ora che gli accessi DRAM possono richiedere migliaia di operazioni della CPU.
  • Poiché clr mark-and-compact Garbage Collector mantiene l'ordine relativo degli oggetti, gli oggetti allocati insieme nel tempo (e nello stesso thread) tendono a rimanere insieme nello spazio. È possibile usare questo fenomeno per collocare in modo ponderato i dati dell'interfaccia della riga di comando su righe di cache comuni.
  • È possibile partizionare i dati in parti sensibili che vengono spesso attraversate e che devono essere inserite nella cache e parti fredde che vengono usate raramente e che possono essere "memorizzate nella cache".

Esperimenti time-it-it-yourself

Per le misurazioni temporali in questo documento, ho usato il contatore QueryPerformanceCounter delle prestazioni win32 ad alta risoluzione (e QueryPerformanceFrequency).

Sono facilmente chiamati tramite P/Invoke:

    [System.Runtime.InteropServices.DllImport("KERNEL32")]
    private static extern bool QueryPerformanceCounter(
        ref long lpPerformanceCount);

    [System.Runtime.InteropServices.DllImport("KERNEL32")]
    private static extern bool QueryPerformanceFrequency(
        ref long lpFrequency);

Chiamare QueryPerformanceCounter subito prima e subito dopo il ciclo di temporizzazione, sottrarre i conteggi, moltiplicare per 1,0e9, dividere per frequenza, dividere per numero di iterazioni e questo è il tempo approssimativo per iterazione in ns.

A causa di restrizioni relative allo spazio e al tempo, non è stato illustrato il blocco, la gestione delle eccezioni o il sistema di sicurezza dell'accesso al codice. Si consideri un esercizio per il lettore.

A proposito, ho prodotto le disassemblies in questo articolo usando la finestra disassembly in VS.NET 2003. C'è però un trucco. Se si esegue l'applicazione nel debugger VS.NET, anche se un eseguibile ottimizzato compilato in modalità di rilascio, verrà eseguito in modalità "debug" in cui le ottimizzazioni, ad esempio l'inlining, sono disabilitate. L'unico modo in cui è stato trovato per visualizzare il codice nativo ottimizzato generato dal compilatore JIT era avviare l'applicazione di test all'esterno del debugger e quindi collegarla usando Debug.Processes.Attach.

Un modello di costo dello spazio?

Ironicamente, le considerazioni sullo spazio impediscono una discussione approfondita dello spazio. Alcuni brevi paragrafi, poi.

Considerazioni di basso livello (diverse sono C# (TypeAttributes.SequentialLayout predefinite) e specifiche x86:

  • La dimensione di un tipo valore è in genere la dimensione totale dei relativi campi, con campi a 4 byte o più piccoli allineati ai limiti naturali.
  • È possibile usare [StructLayout(LayoutKind.Explicit)] gli attributi e [FieldOffset(n)] per implementare le unioni.
  • La dimensione di un tipo riferimento è di 8 byte più la dimensione totale dei relativi campi, arrotondata fino al limite di 4 byte successivo e con campi a 4 byte o più piccoli allineati ai limiti naturali.
  • In C#, le dichiarazioni di enumerazione possono specificare un tipo di base integrale arbitrario (ad eccezione di char), quindi è possibile definire enumerazioni a 8 bit, 16 bit, 32 bit e enumerazioni a 64 bit.
  • Come in C/C++, spesso è possibile rasare alcuni decine di spazio da un oggetto più grande ridimensionando i campi integrali in modo appropriato.
  • È possibile esaminare le dimensioni di un tipo riferimento allocato con il profiler CLR.
  • Gli oggetti di grandi dimensioni (molte decine di KB o più) vengono gestiti in un heap di oggetti di grandi dimensioni separato, per impedire la copia costosa.
  • Gli oggetti finalizzabili accettano una generazione GC aggiuntiva per il recupero, usarli con moderazione e prendere in considerazione l'uso del criterio Dispose.

Considerazioni sul quadro generale:

  • Ogni AppDomain comporta attualmente un notevole sovraccarico di spazio. Molte strutture di runtime e framework non sono condivise tra AppDomains.
  • All'interno di un processo, il codice jitted non viene in genere condiviso tra AppDomains. Se il runtime è ospitato in modo specifico, è possibile eseguire l'override di questo comportamento. Vedere la documentazione per CorBindToRuntimeEx e il STARTUP_LOADER_OPTIMIZATION_MULTI_DOMAIN flag .
  • In qualsiasi caso, il codice jitted non viene condiviso tra i processi. Se si dispone di un componente che verrà caricato in molti processi, è consigliabile precompilare con NGEN per condividere il codice nativo.

Reflection

Si è detto che "se si deve chiedere quali costi reflection, non si può permettersi". Se hai letto fino a questo punto sai quanto è importante chiedere quali sono i costi e misurare tali costi.

La reflection è utile e potente, ma rispetto al codice nativo jitted, non è né veloce né piccola. Sei stato avvisato. Misurarla per te stesso.

Conclusione

A questo punto si sa (più o meno) quali costi del codice gestito al livello più basso. Ora si ha la conoscenza di base necessaria per rendere più intelligenti i compromessi di implementazione e scrivere codice gestito più veloce.

Si è visto che il codice gestito jitted può essere "pedale per il metallo" come codice nativo. La sfida consiste nel codificare in modo saggio e scegliere con saggezza tra le numerose funzionalità avanzate e facili da usare nel framework

Esistono impostazioni in cui le prestazioni non sono importanti e le impostazioni in cui è la funzionalità più importante di un prodotto. L'ottimizzazione prematura è la radice di tutto il male. Ma così è l'inattenzione senza attenzione all'efficienza. Sei un professionista, un artista, un artigiano. Quindi assicurati di conoscere il costo delle cose. Se non si conosce o anche se si pensa di farlo, misurarlo regolarmente.

Per quanto riguarda il team CLR, continuiamo a lavorare per fornire una piattaforma che è sostanzialmente più produttiva rispetto al codice nativo , ma è più veloce del codice nativo. Aspettatevi che le cose migliorino e migliori. Tornare a visitare la pagina per altre informazioni.

Ricorda la tua promessa.

Risorse