요약 : 240 이하에서 LLVM은 내부 루프를 완전히 풀고 반복 루프를 최적화하여 벤치 마크를 깨뜨릴 수 있음을 알 수 있습니다.
LLVM이 특정 최적화 수행을 중지하는 마법 임계 값을 찾았습니다 . 임계 값은 8 바이트 * 240 = 1920 바이트입니다 (배열은 usize
s 의 배열 이므로 길이는 x86-64 CPU를 가정 할 때 8 바이트로 곱함). 이 벤치 마크에서, 길이 239에 대해서만 수행되는 하나의 특정 최적화는 큰 속도 차이를 담당합니다. 그러나 천천히 시작합시다.
(이 답변의 모든 코드는로 컴파일됩니다 -C opt-level=3
)
pub fn foo() -> usize {
let arr = [0; 240];
let mut s = 0;
for i in 0..arr.len() {
s += arr[i];
}
s
}
이 간단한 코드는 대략적으로 어셈블리를 생성합니다 : 요소를 추가하는 루프. 변경할 경우, 240
에 239
, 조립 방출은 꽤 많이 다릅니다. Godbolt 컴파일러 탐색기에서 참조하십시오 . 다음은 어셈블리의 작은 부분입니다.
movdqa xmm1, xmmword ptr [rsp + 32]
movdqa xmm0, xmmword ptr [rsp + 48]
paddq xmm1, xmmword ptr [rsp]
paddq xmm0, xmmword ptr [rsp + 16]
paddq xmm1, xmmword ptr [rsp + 64]
; more stuff omitted here ...
paddq xmm0, xmmword ptr [rsp + 1840]
paddq xmm1, xmmword ptr [rsp + 1856]
paddq xmm0, xmmword ptr [rsp + 1872]
paddq xmm0, xmm1
pshufd xmm1, xmm0, 78
paddq xmm1, xmm0
이를 루프 언 롤링 이라고 합니다 . LLVM은 루프 변수를 증가시키고 루프가 종료되었는지 확인하고 루프 시작으로 점프하는 등 모든 "루프 관리 명령"을 실행하지 않아도되도록 루프 바디에 많은 시간을 붙여 넣습니다. .
궁금한 경우 : paddq
및 유사한 명령어는 여러 값을 병렬로 합칠 수있는 SIMD 명령어입니다. 또한 2 개의 16 바이트 SIMD 레지스터 ( xmm0
및 xmm1
)가 병렬로 사용되므로 CPU의 명령 수준 병렬 처리는 기본적으로 두 명령을 동시에 실행할 수 있습니다. 결국, 그들은 서로 독립적입니다. 결국 두 레지스터가 함께 추가 된 다음 스칼라 결과에 수평으로 합산됩니다.
최신 메인 스트림 x86 CPU (저전력 Atom 아님)는 L1d 캐시에 도달 할 때 클럭 당 2 개의 벡터로드를 실제로 수행 할 수 있으며 paddq
처리량은 클럭 당 2 개 이상이며 대부분의 CPU에서 1주기 대기 시간을 갖습니다 . 대기 시간 (도트 제품의 경우 FP FMA)을 숨기고 처리량에 병목 현상을 숨기기 위한 여러 축 압기에 대한 https://agner.org/optimize/ 및 이 Q & A를 참조 하십시오 .
LLVM은 풀다 작은 루프를 수행 일부 그렇지 않은 경우 완전히 줄이기, 여전히 다수의 축전지를 사용합니다. 따라서 일반적으로 프런트 엔드 대역폭 및 백엔드 대기 시간 병목 현상은 전체 풀림없이 LLVM 생성 루프에 큰 문제가되지 않습니다.
그러나 루프 언 롤링은 요소 80의 성능 차이에 대해 책임을지지 않습니다! 적어도 언 롤링 만 반복해서는 안됩니다. 하나의 루프를 다른 하나의 루프 안에 넣는 실제 벤치마킹 코드를 살펴 보겠습니다.
const CAPACITY: usize = 239;
const IN_LOOPS: usize = 500000;
pub fn foo() -> usize {
let mut arr = [0; CAPACITY];
for i in 0..CAPACITY {
arr[i] = i;
}
let mut sum = 0;
for _ in 0..IN_LOOPS {
let mut s = 0;
for i in 0..arr.len() {
s += arr[i];
}
sum += s;
}
sum
}
( Godbolt 컴파일러 탐색기에서 )
어셈블리 CAPACITY = 240
는 정상적으로 보입니다 : 두 개의 중첩 루프. (함수의 시작 부분에는 초기화를위한 코드가 많이 있습니다. 무시할 것입니다.) 그러나 239의 경우에는 매우 다르게 보입니다! 초기화 루프와 내부 루프가 풀렸다는 것을 알았습니다.
중요한 차이점은 239의 경우 LLVM은 내부 루프의 결과가 외부 루프에 의존하지 않는다는 것을 알 수있었습니다! 결과적으로 LLVM은 기본적으로 내부 루프 만 실행하고 (계산 계산) sum
여러 번 더해 외부 루프를 시뮬레이션하는 코드를 생성합니다 !
먼저 위와 거의 동일한 어셈블리 (내부 루프를 나타내는 어셈블리)를 볼 수 있습니다. 그 후 우리는 이것을 봅니다 (나는 집회를 설명하기 위해 논평했습니다; 의견 *
은 특히 중요합니다) :
; at the start of the function, `rbx` was set to 0
movq rax, xmm1 ; result of SIMD summing up stored in `rax`
add rax, 711 ; add up missing terms from loop unrolling
mov ecx, 500000 ; * init loop variable outer loop
.LBB0_1:
add rbx, rax ; * rbx += rax
add rcx, -1 ; * decrement loop variable
jne .LBB0_1 ; * if loop variable != 0 jump to LBB0_1
mov rax, rbx ; move rbx (the sum) back to rax
; two unimportant instructions omitted
ret ; the return value is stored in `rax`
여기에서 볼 수 있듯이 내부 루프의 결과는 외부 루프가 실행 된 후 자주 추가 된 다음 반환됩니다. LLVM은 내부 루프가 외부 루프와 독립적이라는 것을 이해했기 때문에이 최적화 만 수행 할 수 있습니다.
이는 런타임이에서 CAPACITY * IN_LOOPS
로 변경됨을 의미합니다CAPACITY + IN_LOOPS
. 그리고 이것은 큰 성능 차이를 담당합니다.
추가 참고 사항 : 이것에 대해 무엇을 할 수 있습니까? 실제로는 아닙니다. LLVM에는 마법 임계 값이 없어야합니다. LLVM 최적화는 특정 코드에서 완료하는 데 영원히 걸릴 수 있습니다. 그러나이 코드가 매우 인공적이라는 데 동의 할 수도 있습니다. 실제로, 나는 그러한 큰 차이가 발생할 것이라고 의심합니다. 풀 루프 언 롤링으로 인한 차이는 이러한 경우 일반적으로 요소 2도 아닙니다. 따라서 실제 사용 사례에 대해 걱정할 필요가 없습니다.
관용적 Rust 코드에 대한 마지막 참고 사항 : arr.iter().sum()
배열의 모든 요소를 요약하는 더 좋은 방법입니다. 그리고 두 번째 예에서이를 변경해도 방출 된 어셈블리에서 눈에 띄는 차이가 발생하지 않습니다. 성능이 저하된다고 측정하지 않는 한 짧고 관용적 인 버전을 사용해야합니다.