c++ - gcc-O0 supera a-O3 en tamaños de matriz que son potencias de 2(transposiciones de matriz)
linear-algebra compiler-optimization (4)
Comentario con el código: En el caso de -O3, con
#include <cstdlib>
extern void transpose(const size_t n, double* a)
{
for (size_t i = 0; i < n; ++i) {
for (size_t j = i + 1; j < n; ++j) {
std::swap(a[i * n + j], a[j * n + i]); // or your expanded version.
}
}
}
compilando con
$ g++ --version
g++ (Ubuntu/Linaro 4.8.1-10ubuntu9) 4.8.1
...
$ g++ -g1 -std=c++11 -Wall -o test.S -S test.cpp -O3
yo obtengo
_Z9transposemPd:
.LFB68:
.cfi_startproc
.LBB2:
testq %rdi, %rdi
je .L1
leaq 8(,%rdi,8), %r10
xorl %r8d, %r8d
.LBB3:
addq $1, %r8
leaq -8(%r10), %rcx
cmpq %rdi, %r8
leaq (%rsi,%rcx), %r9
je .L1
.p2align 4,,10
.p2align 3
.L10:
movq %r9, %rdx
movq %r8, %rax
.p2align 4,,10
.p2align 3
.L5:
.LBB4:
movsd (%rdx), %xmm1
movsd (%rsi,%rax,8), %xmm0
movsd %xmm1, (%rsi,%rax,8)
.LBE4:
addq $1, %rax
.LBB5:
movsd %xmm0, (%rdx)
addq %rcx, %rdx
.LBE5:
cmpq %rdi, %rax
jne .L5
addq $1, %r8
addq %r10, %r9
addq %rcx, %rsi
cmpq %rdi, %r8
jne .L10
.L1:
rep ret
.LBE3:
.LBE2:
.cfi_endproc
Y algo bastante diferente si agrego -m32.
(Nota: no hace ninguna diferencia en el ensamblaje si uso std :: swap o su variante)
Sin embargo, para comprender qué está causando los picos, es probable que desee visualizar las operaciones de memoria que se están realizando.
(Para propósitos de prueba) He escrito un Método simple para calcular la transposición de una Matriz nxn
void transpose(const size_t _n, double* _A) {
for(uint i=0; i < _n; ++i) {
for(uint j=i+1; j < _n; ++j) {
double tmp = _A[i*_n+j];
_A[i*_n+j] = _A[j*_n+i];
_A[j*_n+i] = tmp;
}
}
}
Cuando utilizaba niveles de optimización O3 o Ofast, esperaba que el compilador desarrollara algunos bucles, lo que conduciría a un mayor rendimiento, especialmente cuando el tamaño de la matriz es un múltiplo de 2 (es decir, el cuerpo del doble bucle se puede realizar en cada iteración) o similar. En cambio, lo que medí fue exactamente lo contrario. Los poderes de 2 en realidad muestran un aumento significativo en el tiempo de ejecución.
Estos picos también están en intervalos regulares de 64, más pronunciados en intervalos de 128 y así sucesivamente. Cada punta se extiende a los tamaños de matriz adyacentes como en la siguiente tabla
size n time(us)
1020 2649
1021 2815
1022 3100
1023 5428
1024 15791
1025 6778
1026 3106
1027 2847
1028 2660
1029 3038
1030 2613
Compilé con una versión de gcc 4.8.2, pero lo mismo ocurre con un clang 3.5, ¿entonces esto podría ser algo genérico?
Entonces mi pregunta básicamente es: ¿Por qué hay este aumento periódico en el tiempo de ejecución? ¿Es algo genérico que viene con alguna de las opciones de optimización (como sucede con clang y gcc por igual)? Si es así, ¿qué opción de optimización está causando esto?
¿Y cómo puede ser esto tan significativo que incluso la versión O0 del programa supera a la versión 03 en múltiplos de 512?
EDITAR: tenga en cuenta la magnitud de los picos en esta gráfica (logarítmica). Transponer una matriz de 1024x1024 con optimización en realidad toma tanto tiempo como transponer una matriz de 1300x1300 sin optimización. Si este es un problema de falla de caché / falla de página, entonces alguien tiene que explicarme por qué el diseño de la memoria es tan diferente para la versión optimizada del programa, que falla por dos poderes, solo para recuperar el alto rendimiento de Matrices ligeramente más grandes. ¿No deberían las fallas de caché crear más de un patrón escalonado? ¿Por qué los tiempos de ejecución vuelven a bajar? (¿Y por qué debería la optimización crear fallas de caché que no existían antes?)
EDIT: los siguientes deben ser los códigos de ensamblador que gcc produjo
sin optimización (O0):
_Z9transposemRPd:
.LFB0:
.cfi_startproc
push rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
mov rbp, rsp
.cfi_def_cfa_register 6
mov QWORD PTR [rbp-24], rdi
mov QWORD PTR [rbp-32], rsi
mov DWORD PTR [rbp-4], 0
jmp .L2
.L5:
mov eax, DWORD PTR [rbp-4]
add eax, 1
mov DWORD PTR [rbp-8], eax
jmp .L3
.L4:
mov rax, QWORD PTR [rbp-32]
mov rdx, QWORD PTR [rax]
mov eax, DWORD PTR [rbp-4]
imul rax, QWORD PTR [rbp-24]
mov rcx, rax
mov eax, DWORD PTR [rbp-8]
add rax, rcx
sal rax, 3
add rax, rdx
mov rax, QWORD PTR [rax]
mov QWORD PTR [rbp-16], rax
mov rax, QWORD PTR [rbp-32]
mov rdx, QWORD PTR [rax]
mov eax, DWORD PTR [rbp-4]
imul rax, QWORD PTR [rbp-24]
mov rcx, rax
mov eax, DWORD PTR [rbp-8]
add rax, rcx
sal rax, 3
add rdx, rax
mov rax, QWORD PTR [rbp-32]
mov rcx, QWORD PTR [rax]
mov eax, DWORD PTR [rbp-8]
imul rax, QWORD PTR [rbp-24]
mov rsi, rax
mov eax, DWORD PTR [rbp-4]
add rax, rsi
sal rax, 3
add rax, rcx
mov rax, QWORD PTR [rax]
mov QWORD PTR [rdx], rax
mov rax, QWORD PTR [rbp-32]
mov rdx, QWORD PTR [rax]
mov eax, DWORD PTR [rbp-8]
imul rax, QWORD PTR [rbp-24]
mov rcx, rax
mov eax, DWORD PTR [rbp-4]
add rax, rcx
sal rax, 3
add rdx, rax
mov rax, QWORD PTR [rbp-16]
mov QWORD PTR [rdx], rax
add DWORD PTR [rbp-8], 1
.L3:
mov eax, DWORD PTR [rbp-8]
cmp rax, QWORD PTR [rbp-24]
jb .L4
add DWORD PTR [rbp-4], 1
.L2:
mov eax, DWORD PTR [rbp-4]
cmp rax, QWORD PTR [rbp-24]
jb .L5
pop rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size _Z9transposemRPd, .-_Z9transposemRPd
.ident "GCC: (Debian 4.8.2-15) 4.8.2"
.section .note.GNU-stack,"",@progbits
con optimización (O3)
_Z9transposemRPd:
.LFB0:
.cfi_startproc
push rbx
.cfi_def_cfa_offset 16
.cfi_offset 3, -16
xor r11d, r11d
xor ebx, ebx
.L2:
cmp r11, rdi
mov r9, r11
jae .L10
.p2align 4,,10
.p2align 3
.L7:
add ebx, 1
mov r11d, ebx
cmp rdi, r11
mov rax, r11
jbe .L2
mov r10, r9
mov r8, QWORD PTR [rsi]
mov edx, ebx
imul r10, rdi
.p2align 4,,10
.p2align 3
.L6:
lea rcx, [rax+r10]
add edx, 1
imul rax, rdi
lea rcx, [r8+rcx*8]
movsd xmm0, QWORD PTR [rcx]
add rax, r9
lea rax, [r8+rax*8]
movsd xmm1, QWORD PTR [rax]
movsd QWORD PTR [rcx], xmm1
movsd QWORD PTR [rax], xmm0
mov eax, edx
cmp rdi, rax
ja .L6
cmp r11, rdi
mov r9, r11
jb .L7
.L10:
pop rbx
.cfi_def_cfa_offset 8
ret
.cfi_endproc
.LFE0:
.size _Z9transposemRPd, .-_Z9transposemRPd
.ident "GCC: (Debian 4.8.2-15) 4.8.2"
.section .note.GNU-stack,"",@progbits
El aumento periódico del tiempo de ejecución se debe a que la memoria caché solo es asociativa en N, en lugar de totalmente asociativa. Estás presenciando una colisión de hash relacionada con el algoritmo de selección de línea de caché
El caché L1 más rápido tiene un número menor de líneas de caché que el siguiente nivel L2. En cada nivel, cada línea de caché se puede rellenar solo desde un conjunto limitado de fuentes.
Las implementaciones típicas de HW de los algoritmos de selección de línea de caché solo utilizarán algunos bits de la dirección de memoria para determinar en qué ranura de caché se deben escribir los datos. Los cambios de bits de HW son gratuitos.
Esto provoca una competencia entre rangos de memoria, por ejemplo, entre las direcciones 0x300010 y 0x341010. En un algoritmo completamente secuencial, esto no importa: N es lo suficientemente grande para prácticamente todos los algoritmos de la forma:
for (i=0;i<1000;i++) a[i] += b[i] * c[i] + d[i];
Pero cuando el número de las entradas (o salidas) aumenta, lo que ocurre internamente cuando el algoritmo está optimizado, tener una entrada en la memoria caché obliga a otra entrada a salir de la memoria caché.
// one possible method of optimization with 2 outputs and 6 inputs
// with two unrelated execution paths -- should be faster, but maybe it isn''t
for (i=0;i<500;i++) {
a[i] += b[i] * c[i] + d[i];
a[i+500] += b[i+500] * c[i+500] + d[i+500];
}
Un gráfico en el Ejemplo 5: La asociación de caché ilustra el desplazamiento de 512 bytes entre las líneas de la matriz y es la dimensión del peor caso global para el sistema en particular. Cuando se sabe esto, una mitigación de trabajo es sobre-asignar la matriz horizontalmente a alguna otra char matrix[512][512 + 64]
dimensión char matrix[512][512 + 64]
.
La mejora en el rendimiento probablemente esté relacionada con el almacenamiento en caché de la CPU / RAM.
Cuando los datos no tienen una potencia de 2, una carga de línea de caché (como 16, 32 o 64 palabras) transfiere más que los datos que se requieren para atar el bus, por inútil que resulte. Para un conjunto de datos que es una potencia de 2, se utilizan todos los datos precargados.
Apuesto a que si deshabilitases el almacenamiento en caché L1 y L2, el rendimiento sería completamente suave y predecible. Pero sería mucho más lento. El almacenamiento en caché realmente ayuda al rendimiento!
Para agregar a otros: g++ -std=c++11 -march=core2 -O3 -c -S
- gcc versión 4.8.2 (MacPorts gcc48 4.8.2_0) - x86_64-apple-darwin13.0.0:
__Z9transposemPd:
LFB0:
testq %rdi, %rdi
je L1
leaq 8(,%rdi,8), %r10
xorl %r8d, %r8d
leaq -8(%r10), %rcx
addq $1, %r8
leaq (%rsi,%rcx), %r9
cmpq %rdi, %r8
je L1
.align 4,0x90
L10:
movq %r9, %rdx
movq %r8, %rax
.align 4,0x90
L5:
movsd (%rdx), %xmm0
movsd (%rsi,%rax,8), %xmm1
movsd %xmm0, (%rsi,%rax,8)
addq $1, %rax
movsd %xmm1, (%rdx)
addq %rcx, %rdx
cmpq %rdi, %rax
jne L5
addq $1, %r8
addq %r10, %r9
addq %rcx, %rsi
cmpq %rdi, %r8
jne L10
L1:
rep; ret
Básicamente lo mismo que el código de @ksfone, para:
#include <cstddef>
void transpose(const size_t _n, double* _A) {
for(size_t i=0; i < _n; ++i) {
for(size_t j=i+1; j < _n; ++j) {
double tmp = _A[i*_n+j];
_A[i*_n+j] = _A[j*_n+i];
_A[j*_n+i] = tmp;
}
}
}
Aparte de las diferencias de Mach-O (como subrayado adicional, alineación y ubicaciones DWARF), es lo mismo. Pero muy diferente de la salida de montaje del OP. Un bucle interno mucho más "apretado".