¿Por qué el acceso no alineado a la memoria con mapas mm a veces tiene un error de segmentación en AMD64?

Resuelto kasperd asked hace 7 años • 1 respuestas

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?

kasperd avatar Nov 27 '17 19:11 kasperd
Aceptado

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 *pestá 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_tcon menos alignof(uint16_t)=2alineació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 malloccuando 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-vectorizeo -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 mallocdevuelve una memoria alineada de 16 bytes en este destino (x86-64 Linux, donde maxalign_ttiene 16 bytes de ancho porque long doubletiene 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 mmapcomo 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 *pestá alineada en 2 bytes, por lo que puede usarla movdqadespué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 mmapel 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 movdqatiene 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 -O3y sin -marchopciones -mtuneu.

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 pes 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 movzxcargas 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 movqcarga, 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 pmaddwdcon un multiplicador de 1para 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.

memcpyAsí 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. memcpySe 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 memcpyse inserta de manera simple sin llamada a función, pero, por supuesto, tmpno 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].

Peter Cordes avatar Nov 27 '2017 13:11 Peter Cordes