c++ - ¿Por qué std:: fill(0) es más lento que std:: fill(1)?
performance x86 (2)
Compartiré mis hallazgos preliminares , con la esperanza de alentar respuestas más detalladas . Simplemente sentí que esto sería demasiado como parte de la pregunta misma.
El compilador
optimiza el
fill(0)
a un
memset
interno.
No puede hacer lo mismo para
fill(1)
, ya que
memset
solo funciona en bytes.
Específicamente, ambos glibcs
__memset_avx2
y
__intel_avx_rep_memset
se implementan con una sola instrucción
__intel_avx_rep_memset
:
rep stos %al,%es:(%rdi)
Donde el bucle manual se compila en una instrucción real de 128 bits:
add $0x1,%rax
add $0x10,%rdx
movaps %xmm0,-0x10(%rdx)
cmp %rax,%r8
ja 400f41
Curiosamente, aunque hay una optimización de plantilla / encabezado para implementar
std::fill
través de
memset
para los tipos de bytes, pero en este caso es una optimización del compilador para transformar el bucle real.
Curiosamente, para un
std::vector<char>
, gcc comienza a optimizar también
fill(1)
.
El compilador de Intel no lo hace, a pesar de la
memset
plantilla de
memset
.
Dado que esto sucede solo cuando el código realmente funciona en la memoria en lugar de en la memoria caché, parece que la arquitectura Haswell-EP no logra consolidar eficientemente las escrituras de un solo byte.
Agradecería cualquier otra idea
sobre el problema y los detalles relacionados con la microarquitectura.
En particular, no me queda claro por qué esto se comporta de manera tan diferente para cuatro o más subprocesos y por qué
memset
es mucho más rápido en caché.
Actualizar:
Aquí hay un resultado en comparación con
-
fill (1) que usa
-march=native
(avx2vmovdq %ymm0
): funciona mejor en L1, pero es similar a la versiónmovaps %xmm0
para otros niveles de memoria. - Variantes de almacenes no temporales de 32, 128 y 256 bits. Se desempeñan de manera consistente con el mismo rendimiento, independientemente del tamaño de los datos. Todos superan a las otras variantes en la memoria, especialmente para pequeños números de subprocesos. 128 bits y 256 bits tienen un rendimiento exactamente similar, para un bajo número de subprocesos, 32 bits tiene un rendimiento significativamente peor.
Para <= 6 subprocesos,
vmovnt
tiene una ventaja de 2x sobre
rep stos
cuando opera en la memoria.
Ancho de banda de un solo hilo:
Ancho de banda agregado en memoria:
Aquí está el código utilizado para las pruebas adicionales con sus respectivos hot-loops:
void __attribute__ ((noinline)) fill1(vector& v) {
std::fill(v.begin(), v.end(), 1);
}
┌─→add $0x1,%rax
│ vmovdq %ymm0,(%rdx)
│ add $0x20,%rdx
│ cmp %rdi,%rax
└──jb e0
void __attribute__ ((noinline)) fill1_nt_si32(vector& v) {
for (auto& elem : v) {
_mm_stream_si32(&elem, 1);
}
}
┌─→movnti %ecx,(%rax)
│ add $0x4,%rax
│ cmp %rdx,%rax
└──jne 18
void __attribute__ ((noinline)) fill1_nt_si128(vector& v) {
assert((long)v.data() % 32 == 0); // alignment
const __m128i buf = _mm_set1_epi32(1);
size_t i;
int* data;
int* end4 = &v[v.size() - (v.size() % 4)];
int* end = &v[v.size()];
for (data = v.data(); data < end4; data += 4) {
_mm_stream_si128((__m128i*)data, buf);
}
for (; data < end; data++) {
*data = 1;
}
}
┌─→vmovnt %xmm0,(%rdx)
│ add $0x10,%rdx
│ cmp %rcx,%rdx
└──jb 40
void __attribute__ ((noinline)) fill1_nt_si256(vector& v) {
assert((long)v.data() % 32 == 0); // alignment
const __m256i buf = _mm256_set1_epi32(1);
size_t i;
int* data;
int* end8 = &v[v.size() - (v.size() % 8)];
int* end = &v[v.size()];
for (data = v.data(); data < end8; data += 8) {
_mm256_stream_si256((__m256i*)data, buf);
}
for (; data < end; data++) {
*data = 1;
}
}
┌─→vmovnt %ymm0,(%rdx)
│ add $0x20,%rdx
│ cmp %rcx,%rdx
└──jb 40
Nota: tuve que hacer un cálculo manual del puntero para que los bucles fueran tan compactos. De lo contrario, haría una indexación vectorial dentro del bucle, probablemente debido a la intrínseca confusión del optimizador.
Observé en un sistema que
std::fill
en un
std::vector<int>
grande era significativamente más lento cuando establecía un valor constante
0
comparación con un valor constante
1
o un valor dinámico:
5.8 GiB / s vs 7.5 GiB / s
Sin embargo, los resultados son diferentes para tamaños de datos más pequeños, donde el
fill(0)
es más rápido:
Con más de una hebra, con un tamaño de datos de 4 GiB, el
fill(1)
muestra una pendiente más alta, pero alcanza un pico mucho más bajo que el
fill(0)
(51 GiB / s frente a 90 GiB / s):
Esto plantea la pregunta secundaria, por qué el ancho de banda máximo del
fill(1)
es mucho menor.
El sistema de prueba para esto fue una CPU Intel Xeon E5-2680 v3 de doble zócalo establecida a 2.5 GHz (a través de
/sys/cpufreq
) con 8x16 GiB DDR4-2133.
Probé con GCC 6.1.0 (-O3) y el compilador Intel 17.0.1 (-fast), ambos obtuvieron resultados idénticos.
GOMP_CPU_AFFINITY=0,12,1,13,2,14,3,15,4,16,5,17,6,18,7,19,8,20,9,21,10,22,11,23
fue conjunto.
Strem / add / 24 hilos obtiene 85 GiB / s en el sistema.
Pude reproducir este efecto en un sistema de servidor de doble socket Haswell diferente, pero no en ninguna otra arquitectura.
Por ejemplo, en Sandy Bridge EP, el rendimiento de la memoria es idéntico, mientras que en el
fill(0)
caché
fill(0)
es mucho más rápido.
Aquí está el código para reproducir:
#include <algorithm>
#include <cstdlib>
#include <iostream>
#include <omp.h>
#include <vector>
using value = int;
using vector = std::vector<value>;
constexpr size_t write_size = 8ll * 1024 * 1024 * 1024;
constexpr size_t max_data_size = 4ll * 1024 * 1024 * 1024;
void __attribute__((noinline)) fill0(vector& v) {
std::fill(v.begin(), v.end(), 0);
}
void __attribute__((noinline)) fill1(vector& v) {
std::fill(v.begin(), v.end(), 1);
}
void bench(size_t data_size, int nthreads) {
#pragma omp parallel num_threads(nthreads)
{
vector v(data_size / (sizeof(value) * nthreads));
auto repeat = write_size / data_size;
#pragma omp barrier
auto t0 = omp_get_wtime();
for (auto r = 0; r < repeat; r++)
fill0(v);
#pragma omp barrier
auto t1 = omp_get_wtime();
for (auto r = 0; r < repeat; r++)
fill1(v);
#pragma omp barrier
auto t2 = omp_get_wtime();
#pragma omp master
std::cout << data_size << ", " << nthreads << ", " << write_size / (t1 - t0) << ", "
<< write_size / (t2 - t1) << "/n";
}
}
int main(int argc, const char* argv[]) {
std::cout << "size,nthreads,fill0,fill1/n";
for (size_t bytes = 1024; bytes <= max_data_size; bytes *= 2) {
bench(bytes, 1);
}
for (size_t bytes = 1024; bytes <= max_data_size; bytes *= 2) {
bench(bytes, omp_get_max_threads());
}
for (int nthreads = 1; nthreads <= omp_get_max_threads(); nthreads++) {
bench(max_data_size, nthreads);
}
}
Resultados presentados compilados con
g++ fillbench.cpp -O3 -o fillbench_gcc -fopenmp
.
De su pregunta + el asm generado por el compilador de su respuesta:
-
fill(0)
es un stosb derep stosb
que utilizará 256b de tiendas en un bucle microcodificado optimizado. (Funciona mejor si el búfer está alineado, probablemente al menos a 32B o quizás a 64B). -
fill(1)
es un simple bucle de tienda de vectores demovaps
128 bits. Solo se puede ejecutar una tienda por ciclo de reloj principal, independientemente del ancho, hasta 256b AVX. Entonces, las tiendas de 128b solo pueden llenar la mitad del ancho de banda de escritura de caché L1D de Haswell. Es por eso quefill(0)
es aproximadamente 2 veces más rápido para buffers de hasta ~ 32 kB. Compile con-march=haswell
o-march=native
para arreglar eso .Haswell apenas puede mantenerse al día con el ciclo de arriba, pero aún puede ejecutar 1 tienda por reloj a pesar de que no está desenrollado en absoluto. Pero con 4 uops de dominio fusionado por reloj, eso es una gran cantidad de relleno que ocupa espacio en la ventana fuera de servicio. Un poco de desenvolvimiento podría permitir que las fallas de TLB comiencen a resolverse más allá de dónde están ocurriendo las tiendas, ya que hay más rendimiento para las direcciones de la tienda que para los datos de la tienda. Desenrollar podría ayudar a compensar el resto de la diferencia entre ERMSB y este bucle vectorial para buffers que se ajustan en L1D. (Un comentario sobre la pregunta dice que
-march=native
solo ayudó afill(1)
para L1).
Tenga en cuenta que
rep movsd
(que podría usarse para implementar
fill(1)
para elementos
int
) probablemente realizará lo mismo que
rep stosb
en Haswell.
Aunque solo la documentación oficial solo garantiza que ERMSB proporciona una respuesta rápida (pero no una
rep stosd
),
las CPU reales que admiten ERMSB utilizan un microcódigo igualmente eficiente para la
rep stosd
.
Hay algunas dudas sobre IvyBridge, donde tal vez solo
b
es rápido.
Consulte la excelente
respuesta
ERMSB de @ BeeOnRope para obtener actualizaciones sobre esto.
gcc tiene algunas opciones de ajuste x86 para operaciones de cadena (
como
-mstringop-strategy=
alg
y
-mmemset-strategy=strategy
), pero IDK si alguno de ellos logrará que realmente emita
rep movsd
for
fill(1)
.
Probablemente no, ya que supongo que el código comienza como un bucle, en lugar de un
memset
.
Con más de una hebra, con un tamaño de datos de 4 GiB, el relleno (1) muestra una pendiente más alta, pero alcanza un pico mucho más bajo que el relleno (0) (51 GiB / s frente a 90 GiB / s):
Un almacén de
movaps
normal en una línea de caché en frío desencadena una
lectura para propiedad (RFO)
.
Se gasta una gran cantidad de ancho de banda de DRAM real en la lectura de líneas de caché de la memoria cuando
movaps
escribe los primeros 16 bytes.
Las tiendas ERMSB utilizan un protocolo sin RFO para sus tiendas, por lo que los controladores de memoria solo escriben.
(Excepto para lecturas misceláneas, como tablas de páginas si faltan algunos recorridos de página incluso en el caché L3, y tal vez algunos errores de carga en controladores de interrupciones o lo que sea).
@BeeOnRope explica en los comentarios que la diferencia entre las tiendas de RFO regulares y el protocolo de evitación de RFO utilizado por ERMSB tiene inconvenientes para algunos rangos de tamaños de búfer en las CPU del servidor donde hay una alta latencia en la memoria caché de núcleo / L3. Consulte también la respuesta ERMSB vinculada para obtener más información sobre RFO frente a no RFO, y la alta latencia de uncore (L3 / memoria) en las CPU Intel de muchos núcleos es un problema para el ancho de banda de un solo núcleo.
movntps
(
_mm_stream_ps()
)
están ordenadas débilmente, por lo que pueden omitir el caché e ir directamente a la memoria una línea de caché completa a la vez sin tener que leer la línea de caché en L1D.
movntps
evita las
movntps
, como lo hace
rep stos
.
(las tiendas
rep stos
pueden reordenarse entre sí, pero no fuera de los límites de la instrucción).
Sus resultados
movntps
en su respuesta actualizada son sorprendentes.
Para un solo hilo con grandes memorias intermedias, sus resultados son
movnt
>> RFO regular> ERMSB
.
Así que es realmente extraño que los dos métodos que no son RFO estén en lados opuestos de las tiendas antiguas y que ERMSB está lejos de ser óptimo.
Actualmente no tengo una explicación para eso.
(ediciones bienvenidas con una explicación + buena evidencia).
Como esperábamos,
movnt
permite que varios subprocesos logren un ancho de banda de almacenamiento agregado alto, como ERMSB.
movnt
siempre va directamente a los búferes de relleno de línea y luego a la memoria, por lo que es mucho más lento para los tamaños de búfer que caben en la memoria caché.
Un vector de 128b por reloj es suficiente para saturar fácilmente el ancho de banda sin RFO de un solo núcleo para DRAM.
Probablemente
vmovntps ymm
(256b) es solo una ventaja medible sobre
vmovntps xmm
(128b) al almacenar los resultados de un cómputo vectorizado AVX 256b vinculado a la CPU (es decir, solo cuando ahorra la molestia de desempaquetar a 128b).
movnti
ancho de banda de
movnti
es bajo porque el almacenamiento en trozos 4B de cuellos de botella en 1 tienda uop por reloj agrega datos a los buffers de llenado de línea, no al enviar esos buffers de línea completa a DRAM (hasta que tenga suficientes hilos para saturar el ancho de banda de memoria).
@osgx publicó algunos enlaces interesantes en los comentarios :
- Guía de optimización de asm de Agner Fog, tablas de instrucciones y guía de microarquitectura: http://agner.org/optimize/
-
Guía de optimización de Intel: http://www.intel.com/content/dam/www/public/us/en/documents/manuals/64-ia-32-architectures-optimization-manual.pdf .
-
Snooping de NUMA: http://frankdenneman.nl/2016/07/11/numa-deep-dive-part-3-cache-coherency/
- https://software.intel.com/en-us/articles/intelr-memory-latency-checker
- Protocolo de coherencia de caché y rendimiento de memoria de la arquitectura Intel Haswell-EP
Vea también otras cosas en el wiki de etiquetas x86 .