Perché memcpy () e memmove () sono più veloci degli incrementi del puntatore?


92

Sto copiando N byte da pSrca pDest. Questo può essere fatto in un unico ciclo:

for (int i = 0; i < N; i++)
    *pDest++ = *pSrc++

Perché è più lento di memcpyo memmove? Quali trucchi usano per accelerarlo?


2
Il tuo loop copia solo una posizione. Penso che in qualche modo intendessi incrementare i puntatori.
Mysticial

13
Oppure potresti semplicemente aggiustarlo per loro, come ho fatto io. E, BTW, nessun vero programmatore C conta mai da 1a N, è sempre da 0a N-1:-)
paxdiablo

6
@paxdiablo: se stai eseguendo il loop su array, certo. Ma ci sono molti casi in cui il looping da 1 a N va bene. Dipende da cosa stai facendo con i dati: se stai visualizzando un elenco numerato che inizia da 1, ad esempio, per un utente, allora iniziare da 1 probabilmente ha più senso. In ogni caso, ignora il problema più grande che sta usando intcome contatore quando size_tinvece dovrebbe essere usato un tipo senza segno come .
Billy ONeal

2
@paxdiablo È anche possibile contare da N a 1. Su alcuni processori che eliminano un'istruzione di confronto poiché il decremento imposterà il bit appropriato per l'istruzione di salto quando raggiunge lo zero.
onemasse

6
Penso che la premessa della domanda sia falsa. I compilatori moderni lo convertiranno in memcpyo memmove(a seconda che possano dire se i puntatori potrebbero alias).
David Schwartz

Risposte:


120

Poiché memcpy usa puntatori a parole invece di puntatori a byte, anche le implementazioni di memcpy sono spesso scritte con istruzioni SIMD che rendono possibile mescolare 128 bit alla volta.

Le istruzioni SIMD sono istruzioni di assemblaggio che possono eseguire la stessa operazione su ogni elemento in un vettore lungo fino a 16 byte. Ciò include le istruzioni di caricamento e archiviazione.


15
Quando attivi GCC -O3, utilizzerà SIMD per il ciclo, almeno se conosce pDeste pSrcnon alias.
Dietrich Epp

Attualmente sto lavorando su uno Xeon Phi con SIMD da 64 byte (512 bit), quindi questa roba di "fino a 16 byte" mi fa sorridere. Inoltre, devi specificare quale CPU stai mirando per abilitare SIMD, ad esempio con -march = native.
yakoudbz

Forse dovrei rivedere la mia risposta. :)
onemasse

Questo è altamente obsoleto anche al momento della pubblicazione. I vettori AVX su x86 (forniti nel 2011) sono lunghi 32 byte e AVX-512 sono lunghi 64 byte. Ci sono alcune architetture con vettori a 1024 bit o 2048 bit, o anche a larghezza vettoriale variabile come ARM SVE
phuclv

@phuclv anche se le istruzioni potevano essere disponibili allora, hai qualche prova che memcpy le utilizzi? Normalmente le biblioteche impiegano un po 'di tempo a mettersi al passo, e le ultime che riesco a trovare usano SSSE3 e sono molto più recenti del 2011.
Pete Kirkham,

81

Le routine di copia in memoria possono essere molto più complicate e veloci di una semplice copia in memoria tramite puntatori come:

void simple_memory_copy(void* dst, void* src, unsigned int bytes)
{
  unsigned char* b_dst = (unsigned char*)dst;
  unsigned char* b_src = (unsigned char*)src;
  for (int i = 0; i < bytes; ++i)
    *b_dst++ = *b_src++;
}

Miglioramenti

Il primo miglioramento che si può fare è allineare uno dei puntatori su un confine di parola (per parola intendo la dimensione intera nativa, di solito 32 bit / 4 byte, ma può essere 64 bit / 8 byte su architetture più recenti) e utilizzare lo spostamento di dimensioni di parola / copia le istruzioni. Ciò richiede l'utilizzo di una copia da byte a byte fino all'allineamento di un puntatore.

void aligned_memory_copy(void* dst, void* src, unsigned int bytes)
{
  unsigned char* b_dst = (unsigned char*)dst;
  unsigned char* b_src = (unsigned char*)src;

  // Copy bytes to align source pointer
  while ((b_src & 0x3) != 0)
  {
    *b_dst++ = *b_src++;
    bytes--;
  }

  unsigned int* w_dst = (unsigned int*)b_dst;
  unsigned int* w_src = (unsigned int*)b_src;
  while (bytes >= 4)
  {
    *w_dst++ = *w_src++;
    bytes -= 4;
  }

  // Copy trailing bytes
  if (bytes > 0)
  {
    b_dst = (unsigned char*)w_dst;
    b_src = (unsigned char*)w_src;
    while (bytes > 0)
    {
      *b_dst++ = *b_src++;
      bytes--;
    }
  }
}

Architetture diverse funzioneranno in modo diverso a seconda che il puntatore di origine o di destinazione sia allineato in modo appropriato. Ad esempio su un processore XScale ho ottenuto prestazioni migliori allineando il puntatore di destinazione anziché il puntatore di origine.

Per migliorare ulteriormente le prestazioni è possibile eseguire alcuni cicli di srotolamento, in modo che più registri del processore vengano caricati con i dati e ciò significa che le istruzioni di caricamento / memorizzazione possono essere intercalate e la loro latenza è nascosta da istruzioni aggiuntive (come il conteggio dei loop, ecc.). Il vantaggio che ciò comporta varia notevolmente a seconda del processore, poiché le latenze delle istruzioni di caricamento / memorizzazione possono essere molto diverse.

In questa fase il codice finisce per essere scritto in Assembly anziché in C (o C ++) poiché è necessario posizionare manualmente il caricamento e archiviare le istruzioni per ottenere il massimo vantaggio dall'occultamento della latenza e dalla velocità effettiva.

Generalmente un'intera linea di dati della cache dovrebbe essere copiata in un'iterazione del ciclo srotolato.

Il che mi porta al miglioramento successivo, aggiungendo il prelettura. Queste sono istruzioni speciali che indicano al sistema di cache del processore di caricare parti specifiche di memoria nella sua cache. Poiché c'è un ritardo tra l'emissione dell'istruzione e il riempimento della riga della cache, le istruzioni devono essere posizionate in modo tale che i dati siano disponibili proprio come devono essere copiati e non prima / poi.

Ciò significa inserire le istruzioni di precaricamento all'inizio della funzione e all'interno del ciclo di copia principale. Con le istruzioni di prefetch nel mezzo del ciclo di copia, si recuperano i dati che verranno copiati in diverse iterazioni.

Non ricordo, ma può anche essere utile precaricare gli indirizzi di destinazione oltre a quelli di origine.

Fattori

I principali fattori che influenzano la velocità di copia della memoria sono:

  • La latenza tra il processore, le sue cache e la memoria principale.
  • La dimensione e la struttura delle linee della cache del processore.
  • Le istruzioni di spostamento / copia della memoria del processore (latenza, velocità effettiva, dimensione del registro, ecc.).

Quindi, se vuoi scrivere una routine di gestione della memoria efficiente e veloce, avrai bisogno di sapere abbastanza sul processore e sull'architettura per cui stai scrivendo. Basti dire che, a meno che tu non stia scrivendo su qualche piattaforma incorporata, sarebbe molto più facile usare semplicemente le routine di copia della memoria incorporate.


Le moderne CPU rileveranno un modello di accesso alla memoria lineare e inizieranno il precaricamento da sole. Mi aspetto che le istruzioni di precaricamento non farebbero molta differenza per questo motivo.
maxy

@maxy Sulle poche architetture che ho implementato routine di copia della memoria l'aggiunta del prefetch ha aiutato in modo misurabile. Sebbene possa essere vero che l'attuale generazione di chip Intel / AMD esegue il prefetch abbastanza avanti, ci sono molti chip più vecchi e altre architetture che non lo fanno.
Daemin

qualcuno può spiegare "(b_src & 0x3)! = 0"? Non riesco a capirlo, e inoltre - non si compila (genera un errore: operatore non valido a binary &: unsigned char e int);
David Refaeli

"(b_src & 0x3)! = 0" sta controllando se i 2 bit più bassi non sono 0. Quindi, se il puntatore sorgente è allineato a un multiplo di 4 byte o meno. Il tuo errore di compilazione si verifica perché tratta 0x3 come un byte e non come un ingresso, puoi risolverlo usando 0x00000003 o 0x3i (penso).
Daemin

b_src & 0x3non verrà compilato perché non è consentito eseguire operazioni aritmetiche bit per bit sui tipi di puntatore. Devi (u)intptr_tprima
lanciarlo

18

memcpypuò copiare più di un byte contemporaneamente a seconda dell'architettura del computer. La maggior parte dei computer moderni può funzionare con 32 bit o più in una singola istruzione del processore.

Da un'implementazione di esempio :

    00026 * Per una copia veloce, ottimizza il caso comune in cui entrambi i puntatori
    00027 * e la lunghezza è allineata alla parola e copia la parola alla volta
    00028 * di byte alla volta. Altrimenti, copia byte.

8
Su un 386 (per un esempio), che non aveva cache a bordo, questo ha fatto un'enorme differenza. Sulla maggior parte dei processori moderni, le letture e le scritture avverranno una riga di cache alla volta, e il bus alla memoria sarà solitamente il collo di bottiglia, quindi aspettatevi un miglioramento di qualche percentuale, non vicino al quadruplo.
Jerry Coffin

2
Penso che dovresti essere un po 'più esplicito quando dici "dalla fonte". Certo, questa è "la fonte" su alcune architetture, ma certamente non è su, diciamo, una macchina BSD o Windows. (E diavolo, anche tra i sistemi GNU c'è spesso molta differenza in questa funzione)
Billy ONeal

@ Billy ONeal: +1 assolutamente vero ... c'è più di un modo per scuoiare un gatto. Quello era solo un esempio. Fisso! Grazie per il commento costruttivo.
Mark Byers

7

È possibile implementare memcpy()utilizzando una delle seguenti tecniche, alcune dipendono dalla propria architettura per migliorare le prestazioni e saranno tutte molto più veloci del codice:

  1. Usa unità più grandi, come parole a 32 bit invece di byte. Puoi anche (o devi) occuparti dell'allineamento anche qui. Non puoi leggere / scrivere una parola a 32 bit in una posizione di memoria strana, ad esempio su alcune piattaforme, e su altre piattaforme paghi un'enorme penalità per le prestazioni. Per risolvere questo problema, l'indirizzo deve essere un'unità divisibile per 4. Puoi portarlo fino a 64 bit per CPU a 64 bit, o anche superiore utilizzando istruzioni SIMD (istruzione singola, dati multipli) ( MMX , SSE , ecc.)

  2. Puoi usare istruzioni speciali della CPU che il tuo compilatore potrebbe non essere in grado di ottimizzare da C. Ad esempio, su un 80386, puoi usare l'istruzione prefisso "rep" + l'istruzione "movsb" per spostare N byte dettati inserendo N nel conteggio Registrati. I buoni compilatori lo faranno solo per te, ma potresti trovarti su una piattaforma che manca di un buon compilatore. Nota, quell'esempio tende ad essere una cattiva dimostrazione di velocità, ma combinato con l'allineamento + istruzioni di unità più grandi, può essere più veloce di quasi tutto il resto su alcune CPU.

  3. Svolgimento loop : i rami possono essere piuttosto costosi su alcune CPU, quindi lo srotolamento dei loop può ridurre il numero di rami. Questa è anche una buona tecnica per combinare con istruzioni SIMD e unità di dimensioni molto grandi.

Ad esempio, http://www.agner.org/optimize/#asmlib ha memcpyun'implementazione che batte di più (di una quantità molto piccola). Se leggi il codice sorgente, sarà pieno di tonnellate di codice assembly inline che tira fuori tutte le tre tecniche precedenti, scegliendo quale di queste tecniche in base alla CPU su cui stai eseguendo.

Nota, ci sono ottimizzazioni simili che possono essere fatte anche per trovare byte in un buffer. strchr()e gli amici spesso passeranno più velocemente dell'equivalente rotolato della tua mano. Ciò è particolarmente vero per .NET e Java . Ad esempio, in .NET, il built-in String.IndexOf()è molto più veloce anche di una ricerca di stringhe Boyer – Moore , perché utilizza le tecniche di ottimizzazione di cui sopra.


1
Lo stesso Agner Fog a cui ti stai collegando teorizza anche che lo srotolamento del loop è controproducente sulle moderne CPU .

La maggior parte delle CPU oggigiorno ha una buona previsione dei rami, che dovrebbe negare il vantaggio dello srotolamento del loop nei casi tipici. Un buon compilatore di ottimizzazione può ancora usarlo a volte.
thomasrutter

5

Risposta breve:

  • riempimento cache
  • dove possibile i trasferimenti di parole anziché di byte
  • Magia SIMD

4

Non so se sia effettivamente utilizzato in implementazioni del mondo reale di memcpy, ma penso che Duff's Device meriti una menzione qui.

Da Wikipedia :

send(to, from, count)
register short *to, *from;
register count;
{
        register n = (count + 7) / 8;
        switch(count % 8) {
        case 0:      do {     *to = *from++;
        case 7:              *to = *from++;
        case 6:              *to = *from++;
        case 5:              *to = *from++;
        case 4:              *to = *from++;
        case 3:              *to = *from++;
        case 2:              *to = *from++;
        case 1:              *to = *from++;
                } while(--n > 0);
        }
}

Nota che quanto sopra non è a memcpypoiché deliberatamente non incrementa il topuntatore. Implementa un'operazione leggermente diversa: la scrittura in un registro mappato in memoria. Vedere l'articolo di Wikipedia per i dettagli.


Il dispositivo di Duff, o solo il meccanismo di salto iniziale, è un buon uso per copiare i primi 1..3 (o 1..7) byte in modo che i puntatori siano allineati a un confine più gradevole dove possono essere usate istruzioni di spostamento della memoria più grandi.
Daemin

@MarkByers: il codice illustra un'operazione leggermente diversa (si *toriferisce a un registro mappato in memoria e non è deliberatamente incrementato - vedere l'articolo collegato). Come pensavo di aver chiarito, la mia risposta non cerca di fornire un efficiente memcpy, menziona semplicemente una tecnica piuttosto curiosa.
NPE

@Daemin D'accordo, come hai detto puoi saltare il do {} while () e lo switch verrà tradotto in una jump table dal compilatore. Molto utile quando vuoi prenderti cura dei dati rimanenti. Un avvertimento dovrebbe essere menzionato sul dispositivo di Duff, apparentemente su architetture più recenti (più recente x86), la previsione dei rami è così efficiente che il dispositivo di Duff è in realtà più lento di un semplice loop.
onemasse

1
Oh no .. non il dispositivo di Duff. Per favore, non utilizzare il dispositivo di Duff. Per favore. Usa PGO e lascia che il compilatore esegua lo srotolamento del ciclo per te dove ha senso.
Billy ONeal

No, il dispositivo di Duff non è sicuramente utilizzato in nessuna implementazione moderna.
gnasher729

3

Come altri dicono che memcpy copia più grandi di blocchi di 1 byte. La copia in blocchi di dimensioni di una parola è molto più veloce. Tuttavia, la maggior parte delle implementazioni fa un ulteriore passo avanti ed esegue diverse istruzioni MOV (word) prima del ciclo. Il vantaggio di copiare, diciamo, 8 blocchi di parole per loop è che il loop stesso è costoso. Questa tecnica riduce il numero di rami condizionali di un fattore 8, ottimizzando la copia per i blocchi giganti.


1
Non credo che questo sia vero. È possibile svolgere il ciclo, ma non è possibile copiare in una singola istruzione più dati di quanti indirizzabili alla volta sull'architettura di destinazione. Inoltre, c'è anche un sovraccarico per lo svolgimento del loop ...
Billy ONeal

@ Billy ONeal: Non credo che questo sia ciò che intendeva VoidStar. Avendo diverse istruzioni di movimento consecutive, l'overhead del conteggio del numero di unità viene ridotto.
wallyk

@ Billy ONeal: ti stai perdendo il punto. 1 parola alla volta è come MOV, JMP, MOV, JMP, ecc. Dove puoi fare MOV MOV MOV MOV JMP. Ho già scritto mempcy e ho valutato molti modi per farlo;)
VoidStar

@wallyk: forse. Ma dice "copia pezzi ancora più grandi", cosa che non è realmente possibile. Se intende lo svolgimento del ciclo, allora dovrebbe dire "la maggior parte delle implementazioni fa un ulteriore passo avanti e srotola il ciclo". La risposta scritta è nel migliore dei casi fuorviante, nel peggiore dei casi sbagliata.
Billy ONeal

@VoidStar: d'accordo --- ora è meglio. +1.
Billy ONeal

2

Le risposte sono grandi, ma se si vuole ancora implementare un digiuno memcpyda soli, c'è un post sul blog interessante su memcpy veloce, memcpy veloce nel C .

void *memcpy(void* dest, const void* src, size_t count)
{
    char* dst8 = (char*)dest;
    char* src8 = (char*)src;

    if (count & 1) {
        dst8[0] = src8[0];
        dst8 += 1;
        src8 += 1;
    }

    count /= 2;
    while (count--) {
        dst8[0] = src8[0];
        dst8[1] = src8[1];

        dst8 += 2;
        src8 += 2;
    }
    return dest;
}

Può anche essere migliore ottimizzando gli accessi alla memoria.


1

Perché come molte routine di libreria è stato ottimizzato per l'architettura su cui stai eseguendo. Altri hanno pubblicato varie tecniche che possono essere utilizzate.

Data la scelta, usa le routine della libreria piuttosto che tirare le tue. Questa è una variazione di DRY che chiamo DRO (Don't Repeat Others). Inoltre, è meno probabile che le routine della libreria siano sbagliate rispetto alla tua implementazione.

Ho visto i controllori di accesso alla memoria lamentarsi di letture fuori limite sulla memoria o sui buffer di stringa che non erano un multiplo della dimensione della parola. Questo è il risultato dell'ottimizzazione utilizzata.


0

Puoi guardare l'implementazione MacOS di memset, memcpy e memmove.

Al momento dell'avvio, il sistema operativo determina su quale processore è in esecuzione. Ha incorporato un codice specificamente ottimizzato per ogni processore supportato e al momento dell'avvio memorizza un'istruzione jmp nel codice corretto in una posizione fissa di sola lettura.

Le implementazioni C memset, memcpy e memmove sono solo un salto verso quella posizione fissa.

Le implementazioni utilizzano codice diverso a seconda dell'allineamento di origine e destinazione per memcpy e memmove. Ovviamente usano tutte le capacità vettoriali disponibili. Utilizzano anche varianti non memorizzate nella cache quando si copiano grandi quantità di dati e dispongono di istruzioni per ridurre al minimo le attese per le tabelle di pagina. Non è solo codice assembler, è codice assembler scritto da qualcuno con un'ottima conoscenza dell'architettura di ogni processore.

Intel ha anche aggiunto le istruzioni dell'assemblatore che possono rendere più veloci le operazioni sulle stringhe. Ad esempio con un'istruzione per supportare strstr che esegue confronti a 256 byte in un ciclo.


La versione open source di Apple di memset / memcpy / memmove è solo una versione generica che sarà molto più lenta della versione reale che utilizza SIMD
phuclv
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.