¿Por qué el acceso no alineado a la memoria con mapas mm a veces tiene un error de segmentación en AMD64?
Tengo este fragmento de código que genera un error de segmentación cuando se ejecuta en Ubuntu 14.04 en una CPU compatible con AMD64:
#include <inttypes.h>
#include <stdlib.h>
#include <sys/mman.h>
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;
}
Esto solo genera un error de segmentación si la memoria se asigna mediante mmap
. Si uso malloc
, un búfer en la pila o una variable global, no se segmenta.
Si reduzco el número de iteraciones del bucle a menos de 14, ya no se producirá un error de segmentación. Y si imprimo el índice de la matriz desde dentro del bucle, ya no tiene errores de segmentación.
¿Por qué el acceso a la memoria no alineada tiene un error de segmentación en una CPU que puede acceder a direcciones no alineadas, y por qué solo en circunstancias tan específicas?
Relacionado: La publicación del blog de Pascal Cuoq muestra un caso en el que GCC asume punteros alineados (que dos int*
no se superponen parcialmente): GCC siempre asume accesos a punteros alineados . También enlaza a una publicación de blog de 2016 ( Una historia de error: alineación de datos en x86 ) que tiene exactamente el mismo error que esta pregunta: vectorización automática con un puntero desalineado -> segfault.
gcc4.8 crea un prólogo de bucle que intenta alcanzar un límite de alineación, pero supone que uint16_t *p
está alineado en 2 bytes , es decir, que un cierto número de iteraciones escalares harán que el puntero esté alineado en 16 bytes.
No creo que gcc alguna vez haya tenido la intención de admitir punteros desalineados en x86, simplemente funcionó para tipos no atómicos sin vectorización automática. Definitivamente es un comportamiento indefinido en ISO C usar un puntero uint16_t
con menos alignof(uint16_t)=2
alineación. GCC no advierte cuando puede ver que infringe la regla en el momento de la compilación y, de hecho, genera código que funciona (para malloc
cuando conoce la alineación mínima del valor de retorno), pero presumiblemente eso es solo un accidente de los componentes internos de gcc , y no debería. No debe tomarse como una indicación de "apoyo".
Pruebe con -O3 -fno-tree-vectorize
o -O2
. Si mi explicación es correcta, eso no generará un error de segmentación, porque solo usará cargas escalares (que, como usted dice, en x86 no tienen ningún requisito de alineación).
gcc sabe que malloc
devuelve una memoria alineada de 16 bytes en este destino (x86-64 Linux, donde maxalign_t
tiene 16 bytes de ancho porque long double
tiene un relleno de 16 bytes en la ABI x86-64 System V). Ve lo que estás haciendo y lo usa movdqu
.
Pero gcc no lo trata mmap
como una función incorporada, por lo que no sabe que devuelve memoria alineada con la página y aplica su estrategia habitual de vectorización automática que aparentemente supone que uint16_t *p
está alineada en 2 bytes, por lo que puede usarla movdqa
después de manejar la desalineación. Su puntero está desalineado y viola esta suposición.
(Me pregunto si los encabezados glibc más nuevos se utilizan __attribute__((assume_aligned(4096)))
para marcar mmap
el valor de retorno como alineado. Eso sería una buena idea y probablemente le habría dado aproximadamente la misma generación de código que para malloc
. Excepto que no funcionaría porque rompería el error -comprobando mmap != (void*)-1
, como señala @Alcaro con un ejemplo en Godbolt: https://gcc.godbolt.org/z/gVrLWT )
en una CPU que puede acceder a archivos no alineados
SSE2 movdqa
tiene un error de segmentación no alineado y sus elementos también están desalineados, por lo que se produce la situación inusual en la que ningún elemento de la matriz comienza en un límite de 16 bytes.
SSE2 es la base para x86-64, por lo que gcc lo usa.
Ubuntu 14.04LTS usa gcc4.8.2 (fuera de tema: que es antiguo y obsoleto, peor generación de código en muchos casos que gcc5.4 o gcc6.4, especialmente cuando se autovectoriza. Ni siquiera reconoce -march=haswell
).
14 es el umbral mínimo para que la heurística de gcc decida vectorizar automáticamente su bucle en esta función , con -O3
y sin -march
opciones -mtune
u.
Puse su código en Godbolt , y esta es la parte relevante de 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]
Calcula (con este bloque de código) cuántas iteraciones escalares hay que hacer antes de llegar a MOVDQA, pero ninguna de las rutas de código conduce a un bucle MOVDQU. es decir, gcc no tiene una ruta de código para manejar el caso en el que p
es impar.
Pero el código generado para malloc se ve así:
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]
Tenga en cuenta el uso de movdqu
. Hay algunas movzx
cargas escalares más mezcladas: 8 de las 14 iteraciones totales se realizan SIMD y las 6 restantes con escalar. Esta es una optimización fallida: fácilmente podría hacer otros 4 con una movq
carga, especialmente porque eso llena un vector XMM después de descomprimirlo con cero para obtener elementos uint32_t antes de agregarlos.
(Hay varias otras optimizaciones perdidas, como tal vez usar pmaddwd
con un multiplicador de 1
para agregar pares horizontales de palabras en elementos dword).
Código seguro con punteros no alineados:
Si desea escribir código que utilice punteros no alineados, puede hacerlo correctamente en ISO C usando memcpy
. En objetivos con soporte eficiente de carga no alineada (como x86), los compiladores modernos seguirán usando una carga escalar simple en un registro, exactamente como desreferenciar el puntero. Pero al realizar la vectorización automática, gcc no asumirá que un puntero alineado se alinea con los límites del elemento y utilizará cargas no alineadas.
memcpy
Así es como se expresa una carga/almacenamiento no alineado en ISO C/C++.
#include <string.h>
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
, obtenemos el escalar habitual hasta un límite de alineación, luego un bucle vectorial: ( explorador del compilador Godbolt )
.L4: # gcc7.2 normal dereference
add eax, 1
paddd xmm0, XMMWORD PTR [rdx]
add rdx, 16
cmp ecx, eax
ja .L4
Pero con memcpy
, obtenemos vectorización automática con una carga no alineada (sin introducción/final para manejar la alineación), a diferencia de la preferencia normal de 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.
En el caso del OP, simplemente hacer arreglos para que los punteros estén alineados es una mejor opción. Evita divisiones de líneas de caché para código escalar (o para vectorizado como lo hace gcc). No cuesta mucha memoria o espacio adicional y el diseño de los datos en la memoria no es fijo.
Pero a veces esa no es una opción. memcpy
Se optimiza de manera bastante confiable por completo con el gcc/clang moderno cuando se copian todos los bytes de un tipo primitivo. es decir, solo una carga o almacenamiento, sin llamada a función y sin rebote a una ubicación de memoria adicional. Incluso en -O0
, esto memcpy
se inserta de manera simple sin llamada a función, pero, por supuesto, tmp
no se optimiza.
De todos modos, verifique el conjunto generado por el compilador si le preocupa que no se optimice en un caso más complicado o con diferentes compiladores. Por ejemplo, ICC18 no vectoriza automáticamente la versión usando memcpy.
uint64_t tmp=0;
y luego memcpy sobre los 3 bytes inferiores se compila en una copia real en la memoria y se recarga, por lo que no es una buena manera de expresar la extensión cero de tipos de tamaño impar, por ejemplo.
GNU C __attribute__((aligned(1)))
ymay_alias
En lugar de memcpy
(que no se alineará en algunas ISA cuando GCC no sabe que el puntero está alineado, es decir, exactamente este caso de uso), también puede usar un typedef con un atributo GCC para crear una versión subalineada de un tipo. .
typedef int __attribute__((aligned(1), may_alias)) unaligned_aliasing_int;
typedef unsigned long __attribute__((may_alias, aligned(1))) unaligned_aliasing_ulong;
relacionado: ¿ Por qué el strlen de glibc debe ser tan complicado para ejecutarse rápidamente? muestra cómo hacer que un bithack C strlen de palabra a la vez sea seguro con esto.
Tenga en cuenta que parece que ICC no respeta __attribute__((may_alias))
, pero gcc/clang sí. Recientemente estuve jugando con eso tratando de escribir una carga SIMD de 4 bytes portátil y segura _mm_loadu_si32
(que falta en GCC). https://godbolt.org/z/ydMLCK tiene varias combinaciones de generación de código segura en todas partes pero ineficiente en algunos compiladores, o insegura en ICC pero buena en todas partes.
aligned(1)
puede ser menos malo que memcpy en ISA como MIPS donde las cargas no alineadas no se pueden realizar en una sola instrucción.
Lo usas como cualquier otro puntero.
unaligned_aliasing_int *p = something;
int tmp = *p++;
int tmp2 = *p++;
Y, por supuesto, puedes indexarlo como de costumbre p[i]
.