Perché la mia domanda trascorre il 24% della sua vita facendo un controllo nullo?


104

Ho un albero decisionale binario critico per le prestazioni e vorrei concentrare questa domanda su una singola riga di codice. Il codice per l'iteratore dell'albero binario è di seguito con i risultati dell'esecuzione dell'analisi delle prestazioni rispetto ad esso.

        public ScTreeNode GetNodeForState(int rootIndex, float[] inputs)
        {
0.2%        ScTreeNode node = RootNodes[rootIndex].TreeNode;

24.6%       while (node.BranchData != null)
            {
0.2%            BranchNodeData b = node.BranchData;
0.5%            node = b.Child2;
12.8%           if (inputs[b.SplitInputIndex] <= b.SplitValue)
0.8%                node = b.Child1;
            }

0.4%        return node;
        }

BranchData è un campo, non una proprietà. L'ho fatto per evitare il rischio che non fosse inline.

La classe BranchNodeData è la seguente:

public sealed class BranchNodeData
{
    /// <summary>
    /// The index of the data item in the input array on which we need to split
    /// </summary>
    internal int SplitInputIndex = 0;

    /// <summary>
    /// The value that we should split on
    /// </summary>
    internal float SplitValue = 0;

    /// <summary>
    /// The nodes children
    /// </summary>
    internal ScTreeNode Child1;
    internal ScTreeNode Child2;
}

Come puoi vedere, il ciclo while / controllo null è un enorme successo per le prestazioni. L'albero è enorme, quindi mi aspetto che la ricerca di una foglia richieda un po 'di tempo, ma mi piacerebbe capire la quantità sproporzionata di tempo trascorso su quella riga.

Ho provato:

  • Separare il controllo Null dal tempo: è il controllo Null il risultato.
  • Aggiungendo un campo booleano all'oggetto e confrontandolo, non ha fatto alcuna differenza. Non importa cosa viene confrontato, è il confronto il problema.

È un problema di previsione del ramo? In caso affermativo, cosa posso fare al riguardo? Se qualcosa?

Non pretendo di capire il CIL , ma lo pubblicherò per chiunque lo faccia in modo che possa provare a racimolare alcune informazioni da esso.

.method public hidebysig
instance class OptimalTreeSearch.ScTreeNode GetNodeForState (
    int32 rootIndex,
    float32[] inputs
) cil managed
{
    // Method begins at RVA 0x2dc8
    // Code size 67 (0x43)
    .maxstack 2
    .locals init (
        [0] class OptimalTreeSearch.ScTreeNode node,
        [1] class OptimalTreeSearch.BranchNodeData b
    )

    IL_0000: ldarg.0
    IL_0001: ldfld class [mscorlib]System.Collections.Generic.List`1<class OptimalTreeSearch.ScRootNode> OptimalTreeSearch.ScSearchTree::RootNodes
    IL_0006: ldarg.1
    IL_0007: callvirt instance !0 class [mscorlib]System.Collections.Generic.List`1<class OptimalTreeSearch.ScRootNode>::get_Item(int32)
    IL_000c: ldfld class OptimalTreeSearch.ScTreeNode OptimalTreeSearch.ScRootNode::TreeNode
    IL_0011: stloc.0
    IL_0012: br.s IL_0039
    // loop start (head: IL_0039)
        IL_0014: ldloc.0
        IL_0015: ldfld class OptimalTreeSearch.BranchNodeData OptimalTreeSearch.ScTreeNode::BranchData
        IL_001a: stloc.1
        IL_001b: ldloc.1
        IL_001c: ldfld class OptimalTreeSearch.ScTreeNode OptimalTreeSearch.BranchNodeData::Child2
        IL_0021: stloc.0
        IL_0022: ldarg.2
        IL_0023: ldloc.1
        IL_0024: ldfld int32 OptimalTreeSearch.BranchNodeData::SplitInputIndex
        IL_0029: ldelem.r4
        IL_002a: ldloc.1
        IL_002b: ldfld float32 OptimalTreeSearch.BranchNodeData::SplitValue
        IL_0030: bgt.un.s IL_0039

        IL_0032: ldloc.1
        IL_0033: ldfld class OptimalTreeSearch.ScTreeNode OptimalTreeSearch.BranchNodeData::Child1
        IL_0038: stloc.0

        IL_0039: ldloc.0
        IL_003a: ldfld class OptimalTreeSearch.BranchNodeData OptimalTreeSearch.ScTreeNode::BranchData
        IL_003f: brtrue.s IL_0014
    // end loop

    IL_0041: ldloc.0
    IL_0042: ret
} // end of method ScSearchTree::GetNodeForState

Modifica: ho deciso di fare un test di previsione del ramo, ne ho aggiunto uno identico se nel frattempo, quindi abbiamo

while (node.BranchData != null)

e

if (node.BranchData != null)

dentro quello. Quindi ho eseguito l'analisi delle prestazioni rispetto a quella e ci sono voluti sei volte più tempo per eseguire il primo confronto come ha fatto per eseguire il secondo confronto che ha sempre restituito vero. Quindi sembra che sia davvero un problema di previsione del ramo - e immagino che non ci sia niente che io possa fare al riguardo ?!

Un'altra modifica

Il risultato di cui sopra si verificherebbe anche se node.BranchData dovesse essere caricato dalla RAM per il while check - verrebbe quindi memorizzato nella cache per l'istruzione if.


Questa è la mia terza domanda su un argomento simile. Questa volta mi sto concentrando su una singola riga di codice. Le mie altre domande su questo argomento sono:


3
Si prega di mostrare l'implementazione della BranchNodeproprietà. Prova a sostituire node.BranchData != null ReferenceEquals(node.BranchData, null). Fa qualche differenza?
Daniel Hilgarth

4
Sei sicuro che il 24% non sia per l'istruzione while e non l'espressione della condizione quella parte
dell'istruzione

2
Un altro test: provare a re-scrivere il ciclo while in questo modo: while(true) { /* current body */ if(node.BranchData == null) return node; }. Cambia qualcosa?
Daniel Hilgarth

2
Una piccola ottimizzazione sarebbe la seguente: while(true) { BranchNodeData b = node.BranchData; if(ReferenceEquals(b, null)) return node; node = b.Child2; if (inputs[b.SplitInputIndex] <= b.SplitValue) node = b.Child1; }questo recupererebbe node. BranchDatasolo una volta.
Daniel Hilgarth

2
Si prega di aggiungere il numero di volte in cui le due righe con il maggior consumo di tempo vengono eseguite in totale.
Daniel Hilgarth

Risposte:


180

L'albero è enorme

La cosa di gran lunga più costosa che un processore fa mai è non eseguire istruzioni, è accedere alla memoria. Il cuore di esecuzione di una moderna CPU è molte volte più veloce del bus di memoria. Un problema relativo alla distanza , più lontano deve viaggiare un segnale elettrico, più difficile diventa ottenere quel segnale consegnato all'altra estremità del filo senza che venga danneggiato. L'unica cura per quel problema è renderlo più lento. Un grosso problema con i fili che collegano la CPU alla RAM nella tua macchina, puoi aprire il case e vedere i fili.

I processori hanno una contromisura per questo problema, usano cache , buffer che memorizzano una copia dei byte nella RAM. Un altro importante è la cache L1 , tipicamente 16 kilobyte per i dati e 16 kilobyte per le istruzioni. Piccolo, che gli consente di essere vicino al motore di esecuzione. La lettura dei byte dalla cache L1 richiede in genere 2 o 3 cicli della CPU. Il prossimo è la cache L2, più grande e più lenta. I processori di alto livello hanno anche una cache L3, ancora più grande e più lenta. Man mano che la tecnologia di processo migliora, quei buffer occupano meno spazio e diventano automaticamente più veloci man mano che si avvicinano al core, una grande ragione per cui i processori più recenti sono migliori e come riescono a utilizzare un numero sempre crescente di transistor.

Quelle cache non sono tuttavia una soluzione perfetta. Il processore si bloccherà ancora durante un accesso alla memoria se i dati non sono disponibili in una delle cache. Non può continuare fino a quando il bus di memoria molto lento non ha fornito i dati. È possibile perdere un centinaio di cicli di CPU con una singola istruzione.

Strutture ad albero sono un problema, sono senza cache di amichevole. I loro nodi tendono ad essere sparsi in tutto lo spazio degli indirizzi. Il modo più veloce per accedere alla memoria è leggere da indirizzi sequenziali. L'unità di archiviazione per la cache L1 è 64 byte. O in altre parole, una volta che il processore legge un byte, i successivi 63 sono molto veloci poiché saranno presenti nella cache.

Il che rende un array di gran lunga la struttura dati più efficiente. Anche il motivo per cui la classe .NET List <> non è affatto un elenco, utilizza un array per l'archiviazione. Lo stesso per altri tipi di raccolta, come Dictionary, strutturalmente non remotamente simile a un array, ma implementato internamente con gli array.

Quindi è molto probabile che l'istruzione while () soffra di blocchi della CPU perché dereferenzia un puntatore per accedere al campo BranchData. L'istruzione successiva è molto economica perché l'istruzione while () ha già svolto il compito pesante di recuperare il valore dalla memoria. Assegnare la variabile locale è economico, un processore utilizza un buffer per le scritture.

Non è altrimenti un problema semplice da risolvere, appiattire il tuo albero in array è molto probabile che non sia pratico. Non da ultimo perché in genere non è possibile prevedere in quale ordine verranno visitati i nodi dell'albero. Un albero rosso-nero potrebbe aiutare, non è chiaro dalla domanda. Quindi una semplice conclusione da trarre è che sta già funzionando più velocemente che puoi sperare. E se ne hai bisogno per andare più veloce, allora avrai bisogno di un hardware migliore con un bus di memoria più veloce. DDR4 diventerà mainstream quest'anno.


1
Può essere. È molto probabile che siano già adiacenti in memoria, e quindi nella cache, poiché sono stati allocati uno dopo l'altro. Con l'algoritmo di compattazione dell'heap GC che altrimenti avrebbe un effetto imprevedibile su questo. Meglio non lasciarmi indovinare, misura in modo da conoscere un fatto.
Hans Passant

11
I thread non risolvono questo problema. Ti dà più core, hai ancora un solo bus di memoria.
Hans Passant

2
Forse l'uso di b-tree limiterà l'altezza dell'albero, quindi sarà necessario accedere a meno puntatori, poiché ogni nodo è una singola struttura in modo che possa essere archiviato in modo efficiente nella cache. Vedi anche questa domanda .
MatthieuBizien

4
approfondito esplicativo con un'ampia gamma di informazioni correlate, come al solito. +1
Tigran

1
Se si conosce il modello di accesso all'albero e segue la regola 80/20 (l'80% dell'accesso è sempre sullo stesso 20% dei nodi), anche un albero a regolazione automatica come un albero splay potrebbe rivelarsi più veloce. en.wikipedia.org/wiki/Splay_tree
Jens Timmerman,

10

Per completare l'ottima risposta di Hans sugli effetti della cache di memoria, aggiungo una discussione sulla memoria virtuale alla traduzione della memoria fisica e agli effetti NUMA.

Con il computer con memoria virtuale (tutti i computer correnti), quando si esegue un accesso alla memoria, ogni indirizzo di memoria virtuale deve essere tradotto in un indirizzo di memoria fisica. Questo viene fatto dall'hardware di gestione della memoria utilizzando una tabella di traduzione. Questa tabella è gestita dal sistema operativo per ogni processo ed è essa stessa memorizzata nella RAM. Per ogni pagina della memoria virtuale, c'è una voce in questa tabella di traduzione che associa una pagina virtuale a una fisica. Ricorda la discussione di Hans sugli accessi alla memoria che sono costosi: se ogni traduzione da virtuale a fisica necessita di una ricerca nella memoria, tutto l'accesso alla memoria costerebbe il doppio. La soluzione è avere una cache per la tabella di traduzione chiamata buffer di traduzione lookaside(TLB in breve). I TLB non sono grandi (da 12 a 4096 voci) e le dimensioni tipiche della pagina sull'architettura x86-64 sono solo 4 KB, il che significa che ci sono al massimo 16 MB direttamente accessibili con gli hit TLB (probabilmente è anche inferiore a quella, Sandy Bridge avente una dimensione TLB di 512 elementi .). Per ridurre il numero di TLB mancati, è possibile fare in modo che il sistema operativo e l'applicazione lavorino insieme per utilizzare una dimensione della pagina più grande come 2 MB, portando a uno spazio di memoria molto più grande accessibile con gli hit TLB. Questa pagina spiega come utilizzare pagine di grandi dimensioni con Java che possono velocizzare notevolmente gli accessi alla memoria

Se il tuo computer ha molti socket, probabilmente è un'architettura NUMA . NUMA significa accesso alla memoria non uniforme. In queste architetture, alcuni accessi alla memoria costano più di altri. Ad esempio, con un computer a 2 socket con 32 GB di RAM, ogni socket ha probabilmente 16 GB di RAM. In questo computer di esempio, gli accessi alla memoria locale sono più economici degli accessi alla memoria di un altro socket (l'accesso remoto è più lento dal 20 al 100%, forse anche di più). Se su tale computer, il tuo albero utilizza 20 GB di RAM, almeno 4 GB dei tuoi dati si trovano sull'altro nodo NUMA e se gli accessi sono più lenti del 50% per la memoria remota, gli accessi NUMA rallentano del 10% gli accessi alla memoria. Inoltre, se hai solo memoria libera su un singolo nodo NUMA, tutti i processi che necessitano di memoria sul nodo affamato verranno allocati memoria dall'altro nodo i cui accessi sono più costosi. Ancora peggio, il sistema operativo potrebbe pensare che sia una buona idea scambiare parte della memoria del nodo affamato,che causerebbe accessi alla memoria ancora più costosi . Questo è spiegato più dettagliatamente nel problema di MySQL "swap insanity" e gli effetti dell'architettura NUMA in cui vengono fornite alcune soluzioni per Linux (distribuendo gli accessi alla memoria su tutti i nodi NUMA, stringendo i denti sugli accessi NUMA remoti per evitare lo scambio). Posso anche pensare di allocare più RAM a un socket (24 e 8 GB invece di 16 e 16 GB) e assicurarmi che il tuo programma sia programmato sul nodo NUMA più grande, ma questo richiede l'accesso fisico al computer e un cacciavite ;-) .


4

Questa non è una risposta di per sé, ma piuttosto un'enfasi su ciò che Hans Passant ha scritto sui ritardi nel sistema di memoria.

Il software ad alte prestazioni - come i giochi per computer - non è solo scritto per implementare il gioco stesso, ma è anche adattato in modo tale che il codice e le strutture dei dati sfruttino al massimo la cache e i sistemi di memoria, ovvero li trattino come una risorsa limitata. Quando mi occupo di problemi di cache, in genere presumo che L1 consegnerà in 3 cicli se i dati sono presenti lì. Se non lo è e devo andare a L2 presumo 10 cicli. Per L3 30 cicli e per memoria RAM 100.

C'è un'ulteriore azione relativa alla memoria che, se è necessario utilizzarla, impone una penalità ancora maggiore e questa è un blocco del bus. I blocchi del bus vengono definiti sezioni critiche se si utilizza la funzionalità di Windows NT. Se usi una varietà coltivata in casa potresti chiamarla spinlock. Qualunque sia il nome, si sincronizza con il dispositivo di masterizzazione bus più lento del sistema prima che il blocco sia in posizione. Il dispositivo di bus mastering più lento potrebbe essere una classica scheda PCI a 32 bit collegata a 33 MHz. 33MHz è un centesimo della frequenza di una tipica CPU x86 (@ 3,3 GHz). Presumo non meno di 300 cicli per completare un blocco bus, ma so che possono impiegare molte volte così a lungo, quindi se vedo 3000 cicli non sarò sorpreso.

Gli sviluppatori di software multi-threading alle prime armi useranno blocchi bus ovunque e poi si chiederanno perché il loro codice è lento. Il trucco, come tutto ciò che ha a che fare con la memoria, è risparmiare sugli accessi.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.