## 어셈블리에서 한 글자를 바꾸면 나누기 연산이 3배 빨라진다: 기술적 분석
어셈블리 명령어에서 단 하나의 문자를 바꾸는 것만으로 나누기 연산 시간이 66% 단축됩니다. 현대 CPU에서의 실제 벤치마크 결과는 잘못된 레지스터 의존성이 아웃오브오더 실행의 잠재력을 병목으로 만드는 과정을 보여줍니다. x86-64 아키텍처의 성능에 영향을 미치는 세부 사항을 분석해 보겠습니다.
x86-64 레지스터의 아키텍처적 특징
현대 x86-64 프로세서는 상속 구조를 가진 레지스터 계층을 사용합니다. rax 레지스터(64비트)는 다음을 포함합니다:
┌───────────────────────────────────────────────────────────────┐
│ rax (64 bit) │
├───────────────────────────────┬───────────────────────────────┤
│ (verkhnie 32) │ eax (32 bit) │
│ ├───────────────┬───────────────┤
│ │ │ ax (16 bit) │
│ │ ├───────┬───────┤
│ │ │ah (8) │al (8) │
└───────────────────────────────┴───────────────┴───────┴───────┘
핵심: 레지스터의 하위 부분(ax, al)에 쓰기를 하면 상위 비트에 영향을 주지 않지만, 32비트 레지스터(eax) 연산은 상위 32비트를 완전히 덮어씁니다. 이는 파이프라이닝에 치명적인 숨겨진 의존성을 만듭니다.
div/idiv 나누기 명령어는 레지스터 쌍을 사용합니다:
┌──────────┬────────────────┬──────────────┬─────────────────────┐
│ Size │ Delimoe │ Chastnoe in │ Balance in │
├──────────┼────────────────┼──────────────┼─────────────────────┤
│ 16-bit │ dx:ax │ ax │ dx │
├──────────┼────────────────┼──────────────┼─────────────────────┤
│ 32-bit │ edx:eax │ eax │ edx │
├──────────┼────────────────┼──────────────┼─────────────────────┤
│ 64-bit │ rdx:rax │ rax │ rdx │
└──────────┴────────────────┴──────────────┴─────────────────────┘
나누기 전에 쌍의 상위 부분을 0으로 초기화해야 합니다. 표준 패턴:
mov edx, 0
mov eax, 536700
div dword [denominator]
실험 데이터: 3.7배 차이
테스트 루프는 2,073,600회 반복(1920×1080)을 처리하며 픽셀 렌더링을 시뮬레이션했습니다. 반복당 사이클 수(루프 오버헤드 차감 후):
┌────────┬──────────────┬──────────────┐
│ │ idiv (clean.) │ div (clean.) │
├────────┼──────────────┼──────────────┤
│ 16-bit │ 22.2 │ 22.2 │
├────────┼──────────────┼──────────────┤
│ 32-bit │ 6.0 │ 6.0 │
├────────┼──────────────┼──────────────┤
│ 64-bit │ 26.2 │ 24.1 │
└────────┴──────────────┴──────────────┘
놀라운 점: 동일한 입력 데이터에서 32비트 연산이 16비트보다 3.7배 빠릅니다. 이유는 나누기 알고리즘 복잡도가 아니라 마이크로아키텍처 실행 세부 사항입니다.
잘못된 의존성 메커니즘
프로세서는 논리적 레지스터를 물리적 레지스터에 매핑하기 위해 Register Alias Table(RAT)을 사용합니다. 주목할 점:
- 32비트 레지스터에 쓰기(
mov edx, ...)는 값을 완전히 덮어쓰며 이전 연산과의 의존성을 끊습니다 - 16비트 레지스터에 쓰기(
mov dx, ...)는 상위 비트와 새 데이터를 병합해야 하므로 잘못된 의존성을 만듭니다
16비트 변형에서 의존성 체인은 다음과 같습니다:
div word → merge → mov dx → merge → div word → ...
각 반복은 레지스터 병합 때문에 이전 반복을 기다려야 합니다. 32비트 변형에서는 이 체인이 끊어져 프로세서가 실행을 파이프라인화할 수 있습니다.
주요 성능 지표
- Latency: 16비트
div의 21–22 사이클(결과가 준비될 때까지의 시간) - Throughput: 16비트
div의 6 사이클(연산 간 디스패치 간격)
연산이 서로 의존하면 레이턴시가 병목이 됩니다. 독립적이면 처리량이 됩니다. 22/6 ≈ 3.7 비율이 속도 향상을 설명합니다.
가설 검증: 한 글자 바꾸기
실험이 이론을 확인했습니다. 16비트 컨텍스트에서 16비트 명령어를 32비트로 교체:
; Before
mov dx, 0x0008
mov ax, 0x2B7C
; After
mov edx, 8
mov eax, 0x2B7C
극적인 결과:
┌───────────────────────┬──────────────┬──────────────┐
│ │ idiv (clean.) │ div (clean.) │
├───────────────────────┼──────────────┼──────────────┤
│ 16-bit (mov dx) │ 22.2 │ 22.2 │
├───────────────────────┼──────────────┼──────────────┤
│ 16-bit (mov edx) │ 7.0 │ 7.0 │
└───────────────────────┴──────────────┴──────────────┘
명령어의 한 글자 e가 잘못된 의존성을 제거해 실행 시간을 22.2사이클에서 7.0사이클로 줄였습니다—나눗셈기의 처리량 수준으로.
중요한 점
- 레지스터 크기 선택이 핵심: 16비트 나누기를 위한 준비에서 32비트 연산을 사용하면 불필요한 의존성을 제거합니다
- Latency vs Throughput: 의존성은 성능을 레이턴시 중심으로 만듭니다
- 아키텍처 세부 사항: RAT와 부분 레지스터 병합 이해가 저수준 최적화에 필수입니다
- 나눗셈기가 나머지를 더 빨리 생성: x86은 몫과 나머지를 병렬로 계산해 edx를 rax보다 일찍 사용할 수 있게 합니다
- 테스트 필수: 마이크로아키텍처 특성으로 이론 추정치가 실제 측정과 다를 수 있습니다
개발자를 위한 실전 추천
- 나누기 시 상위 레지스터 부분을 0으로 초기화할 때 항상 32비트 연산을 사용하세요. 16비트 데이터라도요
- 루프에서 레지스터 크기를 섞지 마세요
- 성능이 중요한 코드에서는 컴파일러의 어셈블리 출력을 확인하세요
- 나눗셈기 처리량이 레이턴시를 초과하지만 의존성 없어야 합니다
- 현대 CPU(Ice Lake 이후)에서는 uops.info 명세를 확인하세요
이 실험은 깊은 마이크로아키텍처 지식이 비직관적 최적화를 드러낸다는 것을 보여줍니다. 명령어에서 무의미해 보이는 한 글자를 바꾸는 것만으로 인위적 파이프라인 제한을 제거해 3배 속도 향상을 얻습니다. 시스템 프로그래머에게는 저수준 어셈블리에서도 하드웨어 이해 속에 최적화 기회가 숨어 있다는提醒입니다.
— Editorial Team
아직 댓글이 없습니다.