¿Las architectures x86 actuales admiten cargas no temporales (de la memoria “normal”)?

Soy consciente de múltiples preguntas sobre este tema, sin embargo, no he visto ninguna respuesta clara ni ninguna medida de referencia. Así que creé un progtwig simple que trabaja con dos matrices de enteros. La primera matriz a es muy grande (64 MB) y la segunda matriz b es pequeña para que quepa en el caché L1. El progtwig itera sobre a y agrega sus elementos a los elementos correspondientes de b en un sentido modular (cuando se alcanza el final de b , el progtwig comienza desde el principio nuevamente). Los números medidos de la falta de memoria caché L1 para diferentes tamaños de b son los siguientes:

introduzca la descripción de la imagen aquí

Las mediciones se realizaron en una CPU Xeon E5 2680v3 tipo Haswell con caché de datos L1 de 32 kiB. Por lo tanto, en todos los casos, b encaja en el caché L1. Sin embargo, el número de fallas aumentó considerablemente en alrededor de 16 kiB de huella de memoria b . Esto podría esperarse ya que las cargas de a y b causan la invalidación de las líneas de caché desde el principio de b en este punto.

No hay absolutamente ninguna razón para mantener los elementos de a caché en la memoria caché, se usan solo una vez. Por lo tanto, ejecuto una variante de progtwig con cargas no temporales de datos, pero el número de fallas no cambió. También ejecuto una variante con captación previa no temporal de datos, pero aún con los mismos resultados.

Mi código de referencia es el siguiente (se muestra la variante sin búsqueda previa no temporal):

 int main(int argc, char* argv[]) { uint64_t* a; const uint64_t a_bytes = 64 * 1024 * 1024; const uint64_t a_count = a_bytes / sizeof(uint64_t); posix_memalign((void**)(&a), 64, a_bytes); uint64_t* b; const uint64_t b_bytes = atol(argv[1]) * 1024; const uint64_t b_count = b_bytes / sizeof(uint64_t); posix_memalign((void**)(&b), 64, b_bytes); __m256i ones = _mm256_set1_epi64x(1UL); for (long i = 0; i < a_count; i += 4) _mm256_stream_si256((__m256i*)(a + i), ones); // load b into L1 cache for (long i = 0; i < b_count; i++) b[i] = 0; int papi_events[1] = { PAPI_L1_DCM }; long long papi_values[1]; PAPI_start_counters(papi_events, 1); uint64_t* a_ptr = a; const uint64_t* a_ptr_end = a + a_count; uint64_t* b_ptr = b; const uint64_t* b_ptr_end = b + b_count; while (a_ptr = b_ptr_end) b_ptr = b; } PAPI_stop_counters(papi_values, 1); std::cout << "L1 cache misses: " << papi_values[0] << std::endl; free(a); free(b); } 

Lo que me pregunto es si los proveedores de CPU admiten o van a admitir cargas / captaciones no temporales o de alguna otra forma cómo etiquetar algunos datos como no-ser-retenidos en el caché (por ejemplo, etiquetarlos como LRU). Hay situaciones, por ejemplo, en HPC, donde escenarios similares son comunes en la práctica. Por ejemplo, en dispersores lineales iterativos dispersos / eigensolvers, los datos de la matriz suelen ser muy grandes (más grandes que las capacidades de la memoria caché), pero los vectores a veces son lo suficientemente pequeños como para caber en la memoria caché L3 o incluso L2. Entonces, nos gustaría mantenerlos allí a toda costa. Desafortunadamente, la carga de los datos de la matriz puede invalidar las líneas de la memoria caché del vector x, especialmente aunque en cada iteración del solucionador, los elementos de la matriz se usan solo una vez y no hay razón para mantenerlos en la memoria caché después de que se hayan procesado.

ACTUALIZAR

Acabo de hacer un experimento similar en un Intel Xeon Phi KNC, mientras medía el tiempo de ejecución en lugar del L1 (no he encontrado la forma de medirlos de manera confiable; PAPI y VTune dieron métricas extrañas). Los resultados están aquí:

introduzca la descripción de la imagen aquí

La curva naranja representa las cargas ordinarias y tiene la forma esperada. La curva azul representa cargas con la sugerencia de evicción de llamada (EH) establecida en el prefijo de instrucción y la curva gris representa un caso en el que cada línea de caché de a se desalojó manualmente; estos dos trucos habilitados por KNC obviamente funcionaron como queríamos para más de 16 kiB. El código del bucle medido es el siguiente:

 while (a_ptr = b_ptr_end) b_ptr = b; } 

ACTUALIZACIÓN 2

En Xeon Phi, icpc generó para la icpc de variante de carga normal (curva naranja) para a_ptr :

 400e93: 62 d1 78 08 18 4c 24 vprefetch0 [r12+0x80] 

Cuando manualmente (mediante la edición hexadecimal del ejecutable) modifiqué esto para:

 400e93: 62 d1 78 08 18 44 24 vprefetchnta [r12+0x80] 

Obtuve los resultados deseados, incluso mejores que las curvas azul / gris. Sin embargo, no pude forzar al comstackdor a generar prefetchnig no temporal para mí, ni siquiera utilizando #pragma prefetch a_ptr:_MM_HINT_NTA antes del bucle 🙁

Para responder específicamente a la pregunta del titular:

, las 1 CPU Intel recientes más recientes admiten cargas no temporales en la memoria normal 2 , pero solo “indirectamente” a través de instrucciones de movntdqa no temporal, en lugar de utilizar directamente instrucciones de carga no temporal como movntdqa . Esto contrasta con los almacenes no temporales, en los que puede utilizar directamente las instrucciones correspondientes del almacenamiento no temporal 3 .

La idea básica es que emita un prefetchnta a la línea de caché antes de cualquier carga normal, y luego emita las cargas de manera normal. Si la línea no estaba ya en el caché, se cargará de manera no temporal. El significado exacto de la moda no temporal depende de la architecture, pero el patrón general es que la línea se carga al menos en la L1 y quizás en algunos niveles de caché más altos. De hecho, para que una captura previa sea de algún uso, debe hacer que la línea se cargue, al menos, en algún nivel de caché para su consumo por una carga posterior. La línea también se puede tratar especialmente en el caché, por ejemplo, marcándola como de alta prioridad para el desalojo o restringiendo las formas en que se puede colocar.

El resultado de todo esto es que, si bien las cargas no temporales son compatibles en cierto sentido, en realidad solo son en parte no temporales, a diferencia de las tiendas en las que realmente no se deja rastro de la línea en ninguno de los niveles de caché. Las cargas no temporales causarán cierta contaminación de caché, pero generalmente menos que las cargas regulares. Los detalles exactos son específicos de la architecture, y he incluido algunos detalles a continuación para la Intel moderna ( en esta respuesta puede encontrar un informe un poco más extenso).

Cliente Skylake

Según las pruebas de esta respuesta , parece que el comportamiento de prefetchnta Skylake es ir a buscar normalmente al caché L1, a omitir el L2 por completo, y se recupera de forma limitada en el caché L3 (probablemente en 1 o 2 formas solo para que La cantidad total de L3 disponible para nta prefetches es limitada).

Esto se probó en el cliente de Skylake , pero creo que este comportamiento básico probablemente se extienda hacia atrás probablemente hasta Sandy Bridge y versiones anteriores (según la redacción de la guía de optimización de Intel), y también remita a Kaby Lake y architectures posteriores basadas en el cliente de Skylake. Entonces, a menos que esté utilizando una pieza Skylake-SP o Skylake-X, o una CPU extremadamente antigua, este es probablemente el comportamiento que puede esperar de prefetchnta .

Servidor skylake

El único chip Intel reciente que se sabe que tiene un comportamiento diferente es el servidor Skylake (utilizado en Skylake-X, Skylake-SP y algunas otras líneas). Esto tiene una architecture L2 y L3 considerablemente modificada, y el L3 ya no incluye el L2 mucho más grande. Para este chip, parece que prefetchnta omite las memorias caché L2 y L3, por lo que en esta architecture la contaminación de la memoria caché se limita a la L1.

Este comportamiento fue reportado por el usuario Mysticial en un comentario . El inconveniente, como se señaló en esos comentarios, es que esto hace que prefetchnta sea ​​mucho más frágil: si la distancia de captación previa es incorrecta (especialmente fácil cuando se trata de un subproceso y el núcleo hermano está activo), y los datos se desalojan de L1 antes. Si utiliza, regresará a la memoria principal en lugar de a la L3 en architectures anteriores.


1 Lo reciente aquí significa algo en la última década, pero no quiero decir que el hardware anterior no sea compatible con la captación previa no temporal: es posible que la asistencia se remita a la introducción de prefetchnta pero no lo hago. tenga el hardware para verificar eso y no puede encontrar una fuente de información confiable existente en él.

2 Normal aquí solo significa memoria WB (writeback), que es la memoria que se ocupa en el nivel de la aplicación la gran mayoría de las veces.

3 Específicamente, las instrucciones de la tienda NT son movnti para registros de propósito general y las movntd* y movntp* para registros SIMD.

Respondo a mi propia pregunta desde que encontré la siguiente publicación de Intel Developer Forum, lo cual tiene sentido para mí. Fue escrito por John McCalpin:

Los resultados para los procesadores principales no son sorprendentes: en ausencia de una verdadera memoria “scratchpad”, no está claro que sea posible diseñar una implementación de comportamiento “no temporal” que no esté sujeta a sorpresas desagradables. Dos enfoques que se han utilizado en el pasado son (1) cargar la línea de caché, pero marcarla como LRU en lugar de MRU, y (2) cargar la línea de caché en un “conjunto” específico del conjunto de caché asociativa. En cualquier caso, es relativamente fácil generar situaciones en las que el caché descarta los datos antes de que el procesador finalice su lectura.

Ambos enfoques corren el riesgo de una degradación del rendimiento en los casos que operan en más de una pequeña cantidad de arreglos, y se hacen mucho más difíciles de implementar sin “errores” cuando se considera HyperThreading.

En otros contextos, he defendido la implementación de instrucciones de “carga múltiple” que garantizarían que todo el contenido de una línea de caché se copiaría en registros de forma atómica. Mi razonamiento es que el hardware garantiza absolutamente que la línea de caché se mueve atómicamente y que el tiempo requerido para copiar el rest de la línea de caché en los registros fue tan pequeño (1 a 3 ciclos adicionales, dependiendo de la generación del procesador) que podría Ser implementado de forma segura como una operación atómica.

A partir de Haswell, el núcleo puede leer 64 bytes en un solo ciclo (2 lecturas AVX alineadas de 256 bits), por lo que la exposición a efectos secundarios no deseados se vuelve aún más baja.

A partir de KNL, las cargas de línea de caché completa (alineadas) deben ser atómicas “naturalmente”, ya que las transferencias de la caché de datos L1 al núcleo son líneas de caché completas y todos los datos se colocan en el registro AVX-512 de destino. (¡Esto no significa que Intel garantice la atomicidad en la implementación! No tenemos visibilidad de los horribles casos de esquina que los diseñadores deben tener en cuenta, pero es razonable concluir que la mayoría del tiempo se producirán cargas alineadas de 512 bits atómicamente.) Con esta atomicidad “natural” de 64 bytes, algunos de los trucos utilizados en el pasado para reducir la contaminación de la memoria caché debido a cargas “no temporales” pueden merecer otra mirada …


La instrucción MOVNTDQA está destinada principalmente para leer desde rangos de direcciones que se asignan como “Combinación de escritura” (WC), y no para leer desde la memoria normal del sistema que se asigna “Write-Back” (WB). La descripción en el Volumen 2 del SWDM dice que una implementación “puede” hacer algo especial con MOVNTDQA para las regiones de WB, pero el énfasis está en el comportamiento del tipo de memoria de WC.

El tipo de memoria “Combinación de escritura” casi nunca se usa para la memoria “real”, se usa casi exclusivamente para las regiones de E / S asignadas a la memoria.

Consulte aquí la publicación completa: https://software.intel.com/en-us/forums/intel-isa-extensions/topic/597075