240 개 이상의 요소가있는 어레이를 반복 할 때 성능에 큰 영향을 미치는 이유는 무엇입니까?


230

Rust에서 배열에 대해 합 루프를 실행할 때 CAPACITY> = 240 일 때 성능이 크게 저하되는 것을 발견했습니다 . CAPACITY= 239가 약 80 배 빠릅니다.

Rust가 "짧은"배열에 대해 수행하는 특수 컴파일 최적화가 있습니까?

로 컴파일되었습니다 rustc -C opt-level=3.

use std::time::Instant;

const CAPACITY: usize = 240;
const IN_LOOPS: usize = 500000;

fn main() {
    let mut arr = [0; CAPACITY];
    for i in 0..CAPACITY {
        arr[i] = i;
    }
    let mut sum = 0;
    let now = Instant::now();
    for _ in 0..IN_LOOPS {
        let mut s = 0;
        for i in 0..arr.len() {
            s += arr[i];
        }
        sum += s;
    }
    println!("sum:{} time:{:?}", sum, now.elapsed());
}


4
어쩌면 240으로 CPU 캐시 라인이 오버플로되고 있습니까? 이 경우 결과는 CPU마다 매우 다릅니다.
rodrigo

11
여기서 재현 했습니다 . 이제 루프 언 롤링과 관련이 있다고 생각합니다.
rodrigo

답변:


355

요약 : 240 이하에서 LLVM은 내부 루프를 완전히 풀고 반복 루프를 최적화하여 벤치 마크를 깨뜨릴 수 있음을 알 수 있습니다.



LLVM이 특정 최적화 수행을 중지하는 마법 임계 값을 찾았습니다 . 임계 값은 8 바이트 * 240 = 1920 바이트입니다 (배열은 usizes 의 배열 이므로 길이는 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
}

이 간단한 코드는 대략적으로 어셈블리를 생성합니다 : 요소를 추가하는 루프. 변경할 경우, 240239, 조립 방출은 꽤 많이 다릅니다. 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 레지스터 ( xmm0xmm1)가 병렬로 사용되므로 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()배열의 모든 요소를 ​​요약하는 더 좋은 방법입니다. 그리고 두 번째 예에서이를 변경해도 방출 된 어셈블리에서 눈에 띄는 차이가 발생하지 않습니다. 성능이 저하된다고 측정하지 않는 한 짧고 관용적 인 버전을 사용해야합니다.


2
@ lukas-kalbertodt 큰 답변 감사합니다! 이제 sum로컬 s이 아닌 곳에서 직접 업데이트 된 원래 코드가 훨씬 느리게 실행되는 이유를 이해 합니다. for i in 0..arr.len() { sum += arr[i]; }
Guy Korland '08

4
@LukasKalbertodt LLVM에서 다른 일이 AVX2를 켜도 큰 차이는 없습니다. 너무 녹에 Repro'd
Mgetz

4
@Mgetz 재미있는! 그러나 사용 가능한 SIMD 명령에 따라 임계 값을 결정하기에는 너무 미치게 들리지 않습니다. 궁극적으로 완전히 풀린 루프의 명령 수를 결정합니다. 그러나 불행히도, 나는 확실히 말할 수 없습니다. LLVM 개발자가 이것을 대답하는 것이 좋을 것입니다.
Lukas Kalbertodt

7
컴파일러 또는 LLVM이 컴파일 타임에 전체 계산을 수행 할 수 있다는 것을 왜 인식하지 못합니까? 루프 결과를 하드 코딩해야합니다. 아니면 Instant그것을 방지하는 사용입니까?
비 창조적 이름

4
@JosephGarvin : 완전히 풀면 롤링이 나중에 최적화 과정에서 볼 수 있기 때문에 발생한다고 가정합니다. 컴파일러 최적화는 여전히 빠른 컴파일과 효율적인 asm 생성에 관심이 있으므로 복잡한 분석을 통해 최악의 경우 복잡성을 제한해야하므로 복잡한 루프로 불쾌한 소스 코드를 컴파일하는 데 몇 시간 / 일이 걸리지 않습니다. . 그러나 그렇습니다. 이것은 크기가 240보다 큰 경우에 대한 누락 된 최적화입니다. 루프 내부의 루프를 최적화하지 않는 것이 단순한 벤치 마크를 위반하지 않도록 의도적 인 것인지 궁금합니다. 아마 그렇지 않을 수도 있습니다.
Peter Cordes

30

Lukas의 답변 외에도 반복자를 사용하려면 다음을 시도하십시오.

const CAPACITY: usize = 240;
const IN_LOOPS: usize = 500000;

pub fn bar() -> usize {
    (0..CAPACITY).sum::<usize>() * IN_LOOPS
}

범위 패턴에 대한 제안에 대해 @Chris Morgan에게 감사드립니다.

조립 최적화는 꽤 좋은 :

example::bar:
        movabs  rax, 14340000000
        ret

3
또는 더 나은 방법으로 (0..CAPACITY).sum::<usize>() * IN_LOOPS동일한 결과를 얻을 수 있습니다.
Chris Morgan

11
실제로 어셈블리가 실제로 계산을 수행하지 않는다고 설명하지만 LLVM 은이 경우 답변을 사전 계산했습니다.
Josep

rustc이 강도 감소를 할 수있는 기회가 없다는 것에 놀랐습니다 . 그러나이 특정 상황에서 이는 타이밍 루프 인 것으로 보이며 의도적으로 최적화하지 않기를 원합니다. 요점은 계산을 처음부터 반복하고 반복 횟수로 나누는 것입니다. C에서 (비공식) 관용구는 루프 카운터를 volatile예를 들어 Linux 커널의 BogoMIPS 카운터 로 선언하는 것 입니다. Rust에서 이것을 달성 할 수있는 방법이 있습니까? 있을지 모르지만 모르겠습니다. 외부에 전화하면 fn도움이 될 수 있습니다.
Davislor

1
@Davislor : volatile해당 메모리를 강제로 동기화합니다. 루프 카운터에 적용하면 루프 카운터 값의 실제 재로드 / 저장 만 강제 실행됩니다. 루프 본문에 직접 영향을 미치지 않습니다. 그렇기 때문에 더 나은 방법은 일반적으로 실제 중요한 결과를 volatile int sink루프 후 (루프 수행 종속성이있는 경우) 또는 매 반복마다 할당 하여 컴파일러가 루프 카운터를 최적화하도록하지만 원하는대로 강제 실행하는 것입니다. 레지스터에 원하는 결과 를 구체화 하여 저장할 수 있습니다.
Peter Cordes

1
@Davislor : Rust에 GNU C와 같은 인라인 asm 구문이 있다고 생각합니다. 인라인 asm을 사용하면 컴파일러가 값 을 저장하지 않고 레지스터에 값을 구체화 할 수 있습니다. 각 루프 반복의 결과에서이를 사용하면 최적화가 중단되는 것을 막을 수 있습니다. (주의하지 않으면 자동 벡터화도 가능합니다). 예를 들어 MSVC에서 "Escape"와 "Clobber"에 해당하는 것은 2 개의 매크로를 설명하고 (실제로는 불가능한 MSVC로 포팅하는 방법을 묻는) Chandler Carruth의 대화에서 링크가 사용되는 곳을 보여줍니다.
Peter Cordes
당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.