왜 쓸모없는 MOV 명령어를 도입하면 x86_64 어셈블리에서 타이트한 루프가 빨라 집니까?


222

배경:

어셈블리 언어가 내장 된 일부 파스칼 코드를 최적화하는 동안 불필요한 MOV명령을 발견 하고 제거했습니다.

놀랍게도 불필요한 명령을 제거하면 프로그램 속도느려졌습니다 .

임의의 쓸모없는 MOV명령어추가 하면 성능 이 더욱 향상됩니다 .

효과는 불규칙하며 실행 순서에 따라 변경 됩니다. 한 줄로 위나 아래로 같은 정크 명령이 바뀌면 속도가 느려 집니다.

나는 CPU가 모든 종류의 최적화와 능률화를 수행한다는 것을 이해하지만 이것은 흑 마법처럼 보입니다.

자료:

내 코드 버전은 시간 이 걸리는 루프 중간에 세 가지 정크 작업 을 조건부로 컴파일합니다 2**20==1048576. 주변 프로그램은 단지 SHA-256 해시를 계산 합니다.

다소 오래된 시스템 (Intel® Core ™ 2 CPU 6400 @ 2.13 GHz)의 결과 :

avg time (ms) with -dJUNKOPS: 1822.84 ms
avg time (ms) without:        1836.44 ms

프로그램은 루프에서 25 번 실행되었으며 매번 실행 순서가 무작위로 변경되었습니다.

발췌 :

{$asmmode intel}
procedure example_junkop_in_sha256;
  var s1, t2 : uint32;
  begin
    // Here are parts of the SHA-256 algorithm, in Pascal:
    // s0 {r10d} := ror(a, 2) xor ror(a, 13) xor ror(a, 22)
    // s1 {r11d} := ror(e, 6) xor ror(e, 11) xor ror(e, 25)
    // Here is how I translated them (side by side to show symmetry):
  asm
    MOV r8d, a                 ; MOV r9d, e
    ROR r8d, 2                 ; ROR r9d, 6
    MOV r10d, r8d              ; MOV r11d, r9d
    ROR r8d, 11    {13 total}  ; ROR r9d, 5     {11 total}
    XOR r10d, r8d              ; XOR r11d, r9d
    ROR r8d, 9     {22 total}  ; ROR r9d, 14    {25 total}
    XOR r10d, r8d              ; XOR r11d, r9d

    // Here is the extraneous operation that I removed, causing a speedup
    // s1 is the uint32 variable declared at the start of the Pascal code.
    //
    // I had cleaned up the code, so I no longer needed this variable, and 
    // could just leave the value sitting in the r11d register until I needed
    // it again later.
    //
    // Since copying to RAM seemed like a waste, I removed the instruction, 
    // only to discover that the code ran slower without it.
    {$IFDEF JUNKOPS}
    MOV s1,  r11d
    {$ENDIF}

    // The next part of the code just moves on to another part of SHA-256,
    // maj { r12d } := (a and b) xor (a and c) xor (b and c)
    mov r8d,  a
    mov r9d,  b
    mov r13d, r9d // Set aside a copy of b
    and r9d,  r8d

    mov r12d, c
    and r8d, r12d  { a and c }
    xor r9d, r8d

    and r12d, r13d { c and b }
    xor r12d, r9d

    // Copying the calculated value to the same s1 variable is another speedup.
    // As far as I can tell, it doesn't actually matter what register is copied,
    // but moving this line up or down makes a huge difference.
    {$IFDEF JUNKOPS}
    MOV s1,  r9d // after mov r12d, c
    {$ENDIF}

    // And here is where the two calculated values above are actually used:
    // T2 {r12d} := S0 {r10d} + Maj {r12d};
    ADD r12d, r10d
    MOV T2, r12d

  end
end;

직접 해보십시오.

코드 를 직접 사용하려면 GitHub에서 온라인 상태 입니다.

내 질문 :

  • 레지스터의 내용을 쓸모없이 RAM에 복사 하면 성능이 향상 되는 이유는 무엇 입니까?
  • 왜 같은 쓸모없는 명령이 일부 회선의 속도를 높이고 다른 회선의 속도를 줄이겠습니까?
  • 이 동작은 컴파일러가 예측할 수있는 것입니까?

7
실제로 종속 체인을 끊고 물리적 레지스터를 폐기 된 것으로 표시하는 데 사용할 수있는 모든 종류의 '쓸모없는'명령어가 있습니다. 이러한 작업을 악용하려면 마이크로 아키텍처에 대한 지식이 필요합니다 . 귀하의 질문은 사람들을 github로 안내하는 것이 아니라 최소한의 예로서 간단한 지침을 제공해야합니다.
Brett Hale

1
@BrettHale 좋은 지적, 감사합니다. 주석이 달린 코드 발췌를 추가했습니다. 레지스터의 값을 복사하여 레지스터를 램에 표시하여 나중에 값을 사용하더라도 레지스터를 폐기 된 것으로 표시합니까?
tangentstorm

9
이 평균에 표준 편차를 넣을 수 있습니까? 이 게시물에는 실제 차이가 있다는 실제 표시가 없습니다.
17:07에

2
rdtscp 명령어를 사용하여 명령어 타이밍을 시도하고 두 버전의 클럭주기를 확인할 수 있습니까?
jakobbotsch

2
메모리 정렬 때문일 수 있습니까? 나는 수학을 직접하지 않았지만 (lazy : P) 더미 명령어를 추가하면 코드가 메모리 정렬 될 수있다.
Lorenzo Dematté

답변:


144

속도 향상의 가장 큰 원인은 다음과 같습니다.

  • MOV를 삽입하면 후속 명령이 다른 메모리 주소로 이동합니다.
  • 이동 명령 중 하나는 중요한 조건부 분기였습니다
  • 분기 예측 테이블의 앨리어싱으로 인해 해당 분기가 잘못 예측되었습니다
  • 분기를 이동하면 별칭이 제거되고 분기를 올바르게 예측할 수 있습니다.

Core2는 각 조건부 점프에 대해 별도의 기록 레코드를 유지하지 않습니다. 대신 모든 조건부 점프의 공유 기록을 유지합니다. 글로벌 브랜치 예측 의 한 가지 단점은 다른 조건부 점프가 서로 관련이없는 경우 히스토리가 관련이없는 정보로 희석된다는 것입니다.

이 작은 분기 예측 자습서 에서는 분기 예측 버퍼의 작동 방식을 보여줍니다. 캐시 버퍼는 분기 명령 주소의 하위 부분에 의해 색인됩니다. 두 개의 중요한 관련되지 않은 분기가 동일한 하위 비트를 공유하지 않는 한 잘 작동합니다. 이 경우 앨리어싱이 발생하여 잘못 예측 된 분기가 많이 발생합니다 (명령 파이프 라인이 중단되고 프로그램 속도가 느려짐).

지점의 잘못된 예측이 성능에 미치는 영향을 이해하려면 다음과 같은 훌륭한 답변을 살펴보십시오. https://stackoverflow.com/a/11227902/1001643

컴파일러에는 일반적으로 별칭을 지정할 분기와 해당 별칭이 중요한지 여부를 알기에 충분한 정보가 없습니다. 그러나 CachegrindVTune 과 같은 도구를 사용하여 런타임에 해당 정보를 확인할 수 있습니다 .


2
흠. 유망한 소리. 이 sha256 구현에서 유일한 조건부 분기는 FOR 루프의 끝을 확인하는 것입니다. 당시 나는이 개정판을 git의 기묘한 것으로 태그하고 최적화를 계속했습니다. 다음 단계 중 하나는 어셈블리에서 pascal FOR 루프를 직접 작성하는 것이 었습니다.이 시점에서 이러한 추가 명령은 더 이상 긍정적 인 영향을 미치지 않습니다. 아마도 프리 파스칼에서 생성 된 코드는 프로세서가 교체 한 간단한 카운터보다 예측하기가 더 어려웠을 것입니다.
tangentstorm

1
@tangentstorm 좋은 요약처럼 들립니다. 분기 예측 테이블은 크지 않으므로 하나의 테이블 항목이 둘 이상의 분기를 참조 할 수 있습니다. 이것은 일부 예측을 쓸모 없게 만들 수 있습니다. 충돌하는 분기 중 하나가 테이블의 다른 부분으로 이동하면 문제가 쉽게 해결됩니다. 거의 모든 작은 변화로 이런 일이 일어날 수 있습니다 :-)
Raymond Hettinger

1
나는 이것이 내가 관찰 한 특정 행동에 대한 가장 합리적인 설명이라고 생각하므로 이것을 대답으로 표시 할 것입니다. 감사. :)
tangentstorm

3
Bochs의 기여자 중 하나가 비슷한 문제에 대해 절대적으로 훌륭한 토론이 있습니다. emulators.com/docs/nx25_nostradamus.htm
leander

3
insn 정렬은 단순한 분기 대상보다 훨씬 중요합니다. 디코딩 병목 현상은 Core2 및 Nehalem의 큰 문제입니다. 종종 실행 단위를 바쁘게 유지하기가 어렵습니다. Sandybridge의 uop 캐시 도입으로 프론트 엔드 처리량이 크게 증가했습니다. 이 문제로 인해 분기 대상 정렬이 수행 되지만 모든 코드에 영향을 미칩니다.
Peter Cordes

80

http://research.google.com/pubs/pub37077.html 을 읽어보십시오.

TL; DR : 프로그램에 nop 명령어를 무작위로 삽입하면 성능을 5 % 이상 쉽게 향상시킬 수 있으며 컴파일러는이를 쉽게 이용할 수 없습니다. 일반적으로 브랜치 예측 변수와 캐시 동작의 조합이지만 예약 스테이션 중단 (예를 들어, 종속 체인이 끊어 지거나 리소스 초과 구독이 명백한 경우에도)이 될 수 있습니다.


1
흥미 롭군 그러나 프로세서 (또는 FPC)는이 경우 램에 쓰는 것이 NOP라는 것을 알기에 충분히 똑똑합니까?
tangentstorm

8
어셈블러가 최적화되지 않았습니다.
Marco van de Voort

5
컴파일러는 반복적으로 빌드하고 프로파일 링 한 다음 시뮬레이션 된 어닐링 또는 유전자 알고리즘으로 컴파일러 출력을 변경하는 등 엄청나게 비싼 최적화를 수행하여이를 활용할 수 있습니다. 그 분야의 연구에 대해 읽었습니다. 그러나 우리는 컴파일을 위해 최소 5-10 분 동안 100 % CPU를 사용하고 있으며, 그 결과 최적화는 아마도 CPU 코어 모델 일 수도 있고 코어 또는 마이크로 코드 개정에 따라 달라질 수도 있습니다.
AdamIerymenko

나는 이것을 랜덤 NOP라고 부르지 않을 것이며, NOP가 왜 성능에 긍정적 영향을 미칠 수 있는지 설명하고 (tl; dr : stackoverflow.com/a/5901856/357198 ) NOP를 무작위로 삽입하면 성능이 저하되었습니다. 이 논문의 흥미로운 점은 GCC에 의한 '전략적'NOP 제거가 전반적인 성능에 영향을 미치지 않았다는 것입니다!
PuercoPop

15

나는 현대 CPU에서 어셈블리 명령을 CPU에 실행 명령을 제공하기 위해 프로그래머에게 마지막으로 보이는 계층이지만 실제로는 CPU에 의한 실제 실행의 여러 계층이라고 생각합니다.

최신 CPU는 RISC / CISC 하이브리드로 CISC x86 명령어를보다 RISC 동작의 내부 명령어로 변환합니다. 또한 명령을 대량의 동시 작업 ( VLIW / Itanium titanic 과 같은 종류)으로 그룹화하려는 비 순차적 실행 분석기, 분기 예측기, Intel의 "마이크로-옵스 퓨전"이 있습니다. 캐시 경계가 있기 때문에 코드가 커지면 코드를 더 빨리 실행할 수 있습니다 (캐시 컨트롤러가 더 지능적으로 슬롯을 만들거나 더 오래 유지할 수 있습니다).

CISC는 항상 어셈블리-마이크로 코드 변환 계층을 가지고 있었지만 요점은 최신 CPU에서는 일이 훨씬 더 복잡하다는 것입니다. 현대 반도체 제조 공장의 모든 추가 트랜지스터 공간으로 인해 CPU는 여러 가지 최적화 방법을 병렬로 적용한 다음 최상의 속도를 제공하는 방법을 선택할 수 있습니다. 여분의 명령은 CPU가 다른 것보다 더 나은 하나의 최적화 경로를 사용하도록 바이어스 할 수 있습니다.

추가 명령어의 효과는 CPU 모델 / 생성 / 제조업체에 따라 달라질 수 있으며 예측할 수 없습니다. 이 방법으로 어셈블리 언어를 최적화하려면 CPU 별 실행 경로를 사용하여 많은 CPU 아키텍처 세대에 대해 실행해야하며 실제로 중요한 코드 섹션에만 바람직하지만 어셈블리를 수행하는 경우 이미 알고있을 것입니다.


6
당신의 대답은 혼란 스럽습니다. 당신이 말하는 대부분의 말이 맞지만 많은 곳에서 당신이 추측하는 것 같습니다.
alcuadrado

2
어쩌면 내가 분명히해야합니다. 내가 혼란스러워하는 것은 확실성의 부족이다
alcuadrado

3
그것이 합리적이고 좋은 논증으로 추측하는 것은 완전히 유효합니다.
jturolla

7
특수 진단 장비에 대한 액세스 권한을 가진 인텔의 엔지니어가 아니라면 OP가 왜이 이상한 행동을 관찰하고 있는지 확실히 아무도 알 수 없습니다. 다른 사람들이 할 수있는 것은 추측입니다. @cowarldlydragon의 잘못이 아닙니다.
Alex D

2
공감; 당신이 말하는 것 중 어느 것도 OP가보고있는 행동을 설명하지 않습니다. 당신의 대답은 쓸모가 없습니다.
fuz

0

캐시 준비

메모리로 이동 조작은 캐시를 준비하고 후속 이동 조작을 더 빠르게 할 수 있습니다. CPU에는 일반적으로 두 개의로드 장치와 하나의 저장 장치가 있습니다. 로드 장치는 메모리에서 레지스터로 읽을 수 있으며 (사이클 당 한 번 읽음) 저장 장치는 레지스터에서 메모리로 저장됩니다. 레지스터 사이에서 작업을 수행하는 다른 장치도 있습니다. 모든 장치가 병렬로 작동합니다. 따라서 각주기마다 한 번에 여러 작업을 수행 할 수 있지만 두 개의로드, 하나의 저장소 및 여러 개의 레지스터 작업을 수행 할 수 있습니다. 일반적으로 일반 레지스터를 사용한 최대 4 개의 간단한 작업, XMM / YMM 레지스터를 사용한 최대 3 개의 간단한 작업 및 모든 종류의 레지스터를 사용한 1-2 개의 복잡한 작업입니다. 코드에는 레지스터에 대한 많은 연산이 있으므로 하나의 더미 메모리 저장소 연산은 무료입니다 (어쨌든 4 개의 레지스터 연산이 있으므로). 그러나 후속 저장 조작을 위해 메모리 캐시를 준비합니다. 메모리 저장소의 작동 방식을 확인하려면인텔 64 및 IA-32 아키텍처 최적화 참조 설명서 .

거짓 의존성 깨기

이것은 귀하의 경우를 정확하게 참조하지는 않지만 64 비트 프로세서에서 32 비트 mov 작업을 사용하여 상위 비트 (32-63)를 지우고 종속성 체인을 끊는 데 사용되는 경우가 있습니다.

x86-64에서 32 비트 피연산자를 사용하면 64 비트 레지스터의 상위 비트가 지워진다는 것이 잘 알려져 있습니다. 탄원서는 인텔 ® 64 및 IA-32 아키텍처 소프트웨어 개발자 매뉴얼 1 권 의 관련 섹션 (3.4.1.1)을 읽습니다 .

32 비트 피연산자는 대상 범용 레지스터에서 64 비트 결과로 0으로 확장 된 32 비트 결과를 생성합니다.

따라서 첫눈에 쓸모없는 것처럼 보일 수있는 mov 명령은 해당 레지스터의 상위 비트를 지 웁니다. 그것이 우리에게 무엇을 주는가? 종속성 체인을 중단 하고 1995 년 Pentium Pro 이후 CPU에 의해 내부적으로 구현 된 Out-of-Order 알고리즘에 의해 명령이 임의의 순서로 병렬로 실행될 수 있도록합니다 .

로부터 견적 인텔 ® 64 및 수동 IA-32 아키텍처 최적화 참조 절 3.5.1.8 :

부분 레지스터를 수정하는 코드 시퀀스는 종속성 체인에서 약간의 지연을 경험할 수 있지만 종속성 차단 관용구를 사용하여 피할 수 있습니다. Intel Core 마이크로 아키텍처 기반 프로세서에서 소프트웨어가 이러한 명령어를 사용하여 레지스터 내용을 0으로 지우면 여러 명령이 실행 종속성을 지우는 데 도움이됩니다. 부분 레지스터 대신 32 비트 레지스터에서 작동하여 명령어 사이의 레지스터 부분에 대한 종속성을 해제하십시오. 이동의 경우 32 비트 이동 또는 MOVZX를 사용하여 수행 할 수 있습니다.

어셈블리 / 컴파일러 코딩 규칙 37. (M 영향, MH 일반성) : 부분 레지스터 대신 32 비트 레지스터에서 작동하여 명령어 사이의 레지스터 부분에 대한 종속성을 해제합니다. 이동의 경우 32 비트 이동 또는 MOVZX를 사용하여 수행 할 수 있습니다.

x64에 대해 32 비트 피연산자가있는 MOVZX와 MOV는 동일하며 모두 종속 체인을 손상시킵니다.

그렇기 때문에 코드가 더 빠르게 실행됩니다. 종속성이 없으면 CPU는 내부적으로 레지스터의 이름을 바꿀 수 있습니다. 비록 첫눈에 두 번째 명령이 첫 번째 명령에 사용 된 레지스터를 수정하는 것처럼 보일 수 있으며 두 명령은 병렬로 실행할 수 없습니다. 그러나 등록 이름 변경으로 인해 가능합니다.

레지스터 이름 변경 은 CPU 내부에서 사용되는 기술로, 실제 데이터 종속성이없는 연속적인 명령으로 레지스터 재사용으로 인해 발생하는 잘못된 데이터 종속성을 제거합니다.

나는 당신이 지금 그것이 너무 명백한 것을 본다고 생각합니다.


이것은 모두 사실이지만 질문에 제시된 코드와는 아무런 관련이 없습니다.
코디 그레이

@CodyGray-의견 주셔서 감사합니다. 회신을 편집하고 사례에 대한 장을 추가했습니다. 레지스터 작업으로 둘러싸인 mov 메모리로 캐시를 준비하면 저장 장치가 유휴 상태이므로 무료입니다. 따라서 후속 저장 조작이 더 빠릅니다.
Maxim Masiutin

당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.