Perché l’accesso non allineato alla memoria di mmap a volte segfault su AMD64?

Ho questo pezzo di codice che segfaults quando si esegue su Ubuntu 14.04 su una CPU compatibile AMD64:

#include  #include  #include  int main() { uint32_t sum = 0; uint8_t *buffer = mmap(NULL, 1<<18, PROT_READ, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0); uint16_t *p = (buffer + 1); int i; for (i=0;i<14;++i) { //printf("%d\n", i); sum += p[i]; } return sum; } 

Questo solo segfaults se la memoria è allocata usando mmap . Se uso malloc , un buffer nello stack o una variabile globale non segfault.

Se diminuisco il numero di iterazioni del loop fino a un valore inferiore a 14, non è più segfault. E se stampo l’indice dell’array da dentro il loop, non è più segfault.

Perché un accesso non allineato alla memoria segfault su una CPU che è in grado di accedere agli indirizzi non allineati e perché solo in tali circostanze specifiche?

gcc4.8 crea un prologo che tenta di raggiungere un limite di allineamento, ma presuppone che uint16_t *p sia allineato a 2 byte , ovvero che un certo numero di iterazioni scalari renderà il puntatore allineato a 16 byte.

Non credo che gcc abbia mai avuto intenzione di supportare i puntatori disallineati su x86, è successo solo a funzionare per tipi non atomici senza auto-vettorializzazione. È decisamente un comportamento indefinito in ISO C usare un puntatore a uint16_t con meno alignof(uint16_t)=2 allineamento. GCC non avvisa quando può vederti infrangere la regola al momento della compilazione, e in realtà capita di creare codice funzionante (per malloc dove conosce l’allineamento minimo del valore di ritorno), ma questo è presumibilmente solo un incidente degli interni di gcc , e non dovrebbe essere preso come un’indicazione di “supporto”.


Prova con -O3 -fno-tree-vectorize o -O2 . Se la mia spiegazione è corretta, non segfault, perché userà solo i carichi scalari (che come dici su x86 non hanno requisiti di allineamento).


gcc sa che malloc restituisce una memoria allineata di 16 byte su questo target (x86-64 Linux, dove maxalign_t è largo 16 byte perché il long double ha un padding fino a 16 byte nel sistema V ABI x86-64). Vede ciò che stai facendo e usa movdqu .

Ma gcc non considera mmap come un builtin, quindi non sa che restituisce la memoria allineata alla pagina e applica la sua usuale strategia di auto-vettorializzazione che a quanto pare presuppone che uint16_t *p sia allineato a 2 byte, quindi può usare movdqa dopo aver maneggiato il disallineamento. Il puntatore non è allineato e viola questa ipotesi.

(Mi chiedo se i più recenti header di glibc usino __attribute__((alloc_align(4096))) per marcare il valore di ritorno di mmap come allineato, sarebbe una buona idea e probabilmente ti avrebbe dato lo stesso codice di gen di malloc )


su una CPU che è in grado di accedere non allineato

SSE2 movdqa segfaults su non allineato e gli stessi elementi non sono allineati in modo tale da avere la situazione insolita in cui nessun elemento di matrice inizia a un limite di 16 byte.

SSE2 è la linea di base per x86-64, quindi gcc lo usa.


Ubuntu 14.04LTS utilizza gcc4.8.2 (Off topic: che è vecchio e obsoleto, peggio code-gen in molti casi rispetto a gcc5.4 o gcc6.4 specialmente quando si auto-vettorializza. Non riconosce nemmeno -march=haswell .)

14 è la soglia minima per l’euristica di gcc per decidere di auto-vettorizzare il loop in questa funzione , con -O3 e no -march o -mtune .

Ho messo il tuo codice su Godbolt , e questa è la parte rilevante di main :

  call mmap # lea rdi, [rax+1] # p, mov rdx, rax # buffer, mov rax, rdi # D.2507, p and eax, 15 # D.2507, shr rax ##### rax>>=1 discards the low byte, assuming it's zero neg rax # D.2507 mov esi, eax # prolog_loop_niters.7, D.2507 and esi, 7 # prolog_loop_niters.7, je .L2 # .L2 leads directly to a MOVDQA xmm2, [rdx+1] 

Calcola (con questo blocco di codice) quante iterazioni scalari fare prima di raggiungere MOVDQA, ma nessuno dei percorsi di codice porta ad un loop MOVDQU. per esempio gcc non ha un percorso di codice per gestire il caso in cui p è dispari.


Ma il code-gen per malloc assomiglia a questo:

  call malloc # movzx edx, WORD PTR [rax+17] # D.2497, MEM[(uint16_t *)buffer_5 + 17B] movzx ecx, WORD PTR [rax+27] # D.2497, MEM[(uint16_t *)buffer_5 + 27B] movdqu xmm2, XMMWORD PTR [rax+1] # tmp91, MEM[(uint16_t *)buffer_5 + 1B] 

Nota l’uso di movdqu . Ci sono altri carichi di movzx scalari mescolati in: 8 delle 14 iterazioni totali sono fatte con SIMD, e le restanti 6 con scalare. Questa è una ottimizzazione mancata: potrebbe facilmente fare un altro 4 con un carico movq , specialmente perché riempie un vettore XMM dopo aver decompresso con zero per ottenere gli elementi uint32_t prima di aggiungere.

(Ci sono varie altre ottimizzazioni mancate, come forse usando pmaddwd con un moltiplicatore di 1 per aggiungere coppie orizzontali di parole in elementi dword.)


Codice sicuro con puntatori non allineati:

Se si desidera scrivere codice che utilizza puntatori non allineati, è ansible farlo correttamente in ISO C utilizzando memcpy . Sugli obiettivi con supporto di carico non allineato efficiente (come x86), i compilatori moderni useranno ancora un semplice carico scalare in un registro, esattamente come il dereferenziamento del puntatore. Ma quando si auto-vettorializza, gcc non assumerà che un puntatore allineato si allinea con i limiti degli elementi e userà carichi non allineati.

memcpy è come si esprime un carico / archivio non allineato in ISO C / C ++.

 #include  int sum(int *p) { int sum=0; for (int i=0 ; i<10001 ; i++) { // sum += p[i]; int tmp; #ifdef USE_ALIGNED tmp = p[i]; // normal dereference #else memcpy(&tmp, &p[i], sizeof(tmp)); // unaligned load #endif sum += tmp; } return sum; } 

Con gcc7.2 -O3 -DUSE_ALIGNED , otteniamo il solito scalare fino a un limite di allineamento, quindi a un loop vettoriale: ( Godbolt compiler explorer )

 .L4: # gcc7.2 normal dereference add eax, 1 paddd xmm0, XMMWORD PTR [rdx] add rdx, 16 cmp ecx, eax ja .L4 

Ma con memcpy , otteniamo auto-vettorizzazione con un carico non allineato (senza intro / outro per gestire l'allineamento), a differenza della normale preferenza di gcc:

 .L2: # gcc7.2 memcpy for an unaligned pointer movdqu xmm2, XMMWORD PTR [rdi] add rdi, 16 cmp rax, rdi # end_pointer != pointer paddd xmm0, xmm2 jne .L2 # -mtune=generic still doesn't optimize for macro-fusion of cmp/jcc :( # hsum into EAX, then the final odd scalar element: add eax, DWORD PTR [rdi+40000] # this is how memcpy compiles for normal scalar code, too. 

Nel caso dell'OP, la semplice scelta di allineare i puntatori è una scelta migliore. Evita le suddivisioni della linea di cache per il codice scalare (o per vettorializzare il modo in cui gcc lo fa). Non costa molto spazio o memoria extra e il layout dei dati in memoria non è fisso.

Ma a volte non è un'opzione. memcpy ottimizza abbastanza in modo affidabile completamente con il moderno gcc / clang quando copi tutti i byte di un tipo primitivo. vale a dire solo un carico o un negozio, nessuna chiamata di funzione e nessun rimbalzo in una posizione di memoria extra. Anche a -O0 , questa semplice memcpy con nessuna chiamata di funzione, ma ovviamente tmp non si ottimizza.

Ad ogni modo, controlla l'asm generato dal compilatore se sei preoccupato che non si possa ottimizzare in un caso più complicato o con compilatori diversi. Ad esempio, ICC18 non auto-vettorializza la versione utilizzando memcpy.

uint64_t tmp=0; e quindi memcpy sui 3 byte bassi compila una copia effettiva in memoria e ricarica, quindi non è un buon modo per esprimere l'estensione zero di tipi di dimensioni dispari, per esempio.