크래프톤 정글 (컴퓨터 시스템: CSAPP)/3장 프로그램의 기계수준 표현

컴퓨터 시스템 : CSAPP 3장 정리 - 3.11 부동소수점 코드

고웅 2025. 4. 8. 07:52

📘 3.11 Floating-Point Code

부동소수점 연산은 일반적인 정수 연산과는 달리 복잡한 구조와 특별한 규칙을 따릅니다. 이 절의 도입부에서는 다음과 같은 핵심 요소들을 설명한다:


1. 부동소수점 아키텍처란?

부동소수점 아키텍처는 다음 네 가지 측면에서 프로그램이 부동소수점 데이터를 어떻게 다루는지를 규정한다:

  1. 저장 및 접근 방법: 대부분 레지스터를 사용해 저장하고 접근함.
  2. 작동하는 명령어 세트: 특정 명령어들이 부동소수점 데이터를 처리함.
  3. 함수 호출 시 인자 및 반환값 처리 규칙: 예를 들어, 부동소수점 인자는 %xmm0~%xmm7에 저장됨.
  4. 레지스터 저장 규칙: 어떤 레지스터가 caller-saved 또는 callee-saved인지 규정.

2. 역사적 배경

x86-64에서 부동소수점 연산은 SIMD (Single Instruction, Multiple Data) 구조에서 발전해왔다.

  • MMX → SSE → AVX 순으로 확장됨
  • 각각의 확장마다 레지스터 구조가 강화되었음:
    • MM: 64비트
    • XMM: 128비트
    • YMM: 256비트

이러한 구조는 벡터 연산과 같은 고속 연산을 가능하게 했고, 현재의 AVX2 기반 부동소수점 연산 방식으로 이어졌다​.


3. AVX2 기반 설명

이 책에서 설명하는 구조는 AVX2 (2013년 Core i7 Haswell부터 도입)를 기준으로 한다. 주요 특징은 다음과 같다:

  • 레지스터 %xmm0 ~ %xmm15 및 %ymm0 ~ %ymm15 사용
  • 부동소수점 스칼라 연산은 XMM의 하위 4바이트 (float) 또는 8바이트 (double)를 사용
  • gcc는 -mavx2 옵션으로 AVX2 코드를 생성

4. 어셈블리 언어의 형식

  • 이 책에서는 AT&T 포맷을 사용한다.
  • 이는 인텔의 표준 포맷과 다르며, 피연산자의 순서가 반대이다.
    • 예: addl %eax, %ebx → ebx = ebx + eax

3.11.1 부동소수점 이동 및 변환 연산

1. 이동(Move) 명령어

주요 명령어 요약 (그림 3.46 기준)

명령어 원본 <-> 대상 설명
vmovss M32 ↔ X 단정도(float) 이동
vmovsd M64 ↔ X 배정도(double) 이동
vmovaps X → X 단정도, 정렬된 레지스터 간 이동
vmovapd X → X 배정도, 정렬된 레지스터 간 이동
  • M32/M64: 각각 32비트 또는 64비트 메모리 주소
  • X: xmm 레지스터

이동 연산은 데이터 변환 없이 복사만 수행합니다. 메모리 ↔ 레지스터 간 이동은 스칼라 연산으로, 단일 데이터에만 작동한다.

⚠️ 정렬과 관련된 주의

  • vmovss, vmovsd는 정렬에 민감하지 않음.
  • vmovaps, vmovapd는 16바이트 정렬이 맞지 않으면 예외 발생.

2. 변환(Conversion) 명령어

2-1. 부동소수점 → 정수 변환 (그림 3.47 기준)

명령어 원본 - 대상 설명
vcvttss2si X/M32 → R32 단정도 → 정수, 내림(truncation)
vcvttsd2si X/M64 → R32 배정도 → 정수
vcvttss2siq X/M32 → R64 단정도 → 정수(64비트)
vcvttsd2siq X/M64 → R64 배정도 → 정수(64비트)

이들 명령은 내림(round toward zero) 방식으로 변환한다. C 언어의 (int)x 캐스팅과 유사하다.


2-2. 정수 → 부동소수점 변환 (그림 3.48 기준)

이 명령어는 3-피연산자 형태를 취합니다:

명령어 소스1 소스2 대상 설명
vcvtsi2ss M32/R32 X X 정수 → 단정도 변환
vcvtsi2sd M32/R32 X X 정수 → 배정도 변환
vcvtsi2ssq M64/R64 X X 64비트 정수 → 단정도 변환
vcvtsi2sdq M64/R64 X X 64비트 정수 → 배정도 변환
  • 소스 2는 상위 바이트를 유지하는 용도이며, 일반적으로 소스 2와 대상이 동일하다.
  • 예: vcvtsi2sdq %rax, %xmm1, %xmm1은 %rax의 정수를 double로 바꿔 %xmm1에 저장.

예제: float float_mov(float v1, float *src, float *dst)

float float_mov(float v1, float *src, float *dst) {
    float v2 = *src;
    *dst = v1;
    return v2;
}

컴파일 결과:

1 float_mov:
2   vmovaps %xmm0, %xmm1    ; v1을 %xmm1로 복사
3   vmovss (%rdi), %xmm0    ; *src → %xmm0
4   vmovss %xmm1, (%rsi)    ; %xmm1 → *dst
5   ret                     ; v2 반환 (%xmm0)
  • %xmm0은 함수 인자 v1 및 반환값으로 사용됨.
  • vmovss는 단정도 이동이므로 4바이트 이동.
  • %rdi는 src, %rsi는 dst 포인터를 담고 있음.

3.11.2 프로시저에서 부동소수점 코드

1. 함수 인자 전달 및 반환 규칙

부동소수점 인자는 레지스터 %xmm0부터 %xmm7까지 총 8개를 사용하여 전달된다:

  • 인자의 순서에 따라 %xmm0부터 차례대로 할당
  • 인자가 8개를 초과하면 스택을 사용하여 전달
  • 반환값은 항상 %xmm0에 저장되어 반환됨

예시:

double f(double a, double b, double c, double d, double e, double f, double g, double h, double i);

이 경우 a - h 까지는 %xmm0 - %xmm7에 저장되고, i는 스택에 저장된다.


2. Caller-saved 규칙

  • 모든 %xmm 레지스터는 caller-saved
    • 즉, 함수가 호출되면 호출한 쪽에서 레지스터를 백업해야 하며, 호출된 함수는 마음대로 덮어써도 된다.

3. 다양한 인자 조합 처리 방식

함수가 포인터, 정수, 부동소수점을 혼합하여 인자로 받을 경우, 다음 규칙을 따른다:

  • 포인터, 정수: 일반 범용 레지스터 (예: %rdi, %rsi, %rdx, ...)
  • 부동소수점: %xmm0부터 차례대로

예제 1:

double f1(int x, double y, long z);
  • x → %edi (정수)
  • y → %xmm0 (double)
  • z → %rsi (long)

예제 2:

double f2(double y, int x, long z);

→ 순서가 달라도 레지스터 배정은 타입 기준으로 결정되므로 동일함

예제 3:

double f3(float x, double *y, long *z);
  • x → %xmm0 (float)
  • y → %rdi (포인터)
  • z → %rsi (포인터)

3.11.3 부동 소수점 산술연산

1. AVX2 부동소수점 산술 명령어

그림 3.49는 단일 명령으로 부동소수점 연산을 수행하는 스칼라 AVX2 명령어를 보여준다. 이들 명령은 다음과 같은 특징을 가진다:

  • 피연산자: S1, S2 (소스), D (목적지)
    • S1: 레지스터 또는 메모리
    • S2, D: 반드시 XMM 레지스터
  • 연산은 단정도(float) 또는 배정도(double)로 나뉨
  • 결과는 목적지 레지스터 D에 저장
단정도 명령어 배정도 명령어 의미
vaddss vaddsd 덧셈 D ← S2 + S1
vsubss vsubsd 뺄셈 D ← S2 − S1
vmulss vmulsd 곱셈 D ← S2 × S1
vdivss vdivsd 나눗셈 D ← S2 / S1
vmaxss vmaxsd 최대값 D ← max(S2, S1)
vminss vminsd 최소값 D ← min(S2, S1)
sqrtss sqrtsd 제곱근 D ← √S1

2. 예제 분석: double funct(double a, float x, double b, int i)

double funct(double a, float x, double b, int i) {
    return a * x - b / i;
}

레지스터 할당:

  • a: %xmm0
  • x: %xmm1
  • b: %xmm2
  • i: %edi

어셈블리:

1 funct:
2   vunpcklps %xmm1, %xmm1, %xmm1
3   vcvtps2pd %xmm1, %xmm1          ; x를 double로 변환
4   vmulsd %xmm0, %xmm1, %xmm0      ; a * x
5   vcvtsi2sd %edi, %xmm1, %xmm1    ; i를 double로 변환
6   vdivsd %xmm1, %xmm2, %xmm2      ; b / i
7   vsubsd %xmm2, %xmm0, %xmm0      ; (a*x) - (b/i)
8   ret

💡 주목할 점

  • x는 float이므로 double로 변환 필요 (vcvtps2pd)
  • i는 int이므로 vcvtsi2sd로 double 변환
  • float ↔ double 간 변환, int ↔ float/double 간 변환이 많음

3.11.4 부동 소수점 상수의 정의 및 이용

🔍 핵심 개념

❗ AVX 명령어의 제약

  • AVX 부동소수점 연산 명령어는 정수 연산처럼 즉시 상수 값을 사용할 수 없음
  • 대신, 메모리에 상수를 저장하고 이를 로드하여 사용해야 함

예제: 섭씨를 화씨로 변환

C 코드

double cel2fahr(double temp) {
    return 1.8 * temp + 32.0;
}

어셈블리 코드

1 cel2fahr:
2   vmulsd .LC2(%rip), %xmm0, %xmm0   ; temp × 1.8
3   vaddsd .LC3(%rip), %xmm0, %xmm0   ; + 32.0
4   ret
  • %xmm0에 인자 temp가 들어 있고, 최종 결과도 %xmm0에 저장되어 반환됨
  • .LC2, .LC3는 각각 1.832.0을 저장한 메모리 주소

상수의 메모리 표현 (.long)

5 .LC2:
6   .long 3435973837   ; 하위 4바이트 (1.8)
7   .long 1073532108   ; 상위 4바이트 (1.8)
8 .LC3:
9   .long 0            ; 하위 4바이트 (32.0)
10  .long 1077936128   ; 상위 4바이트 (32.0)
  • AVX는 리틀 엔디안이므로 낮은 주소에 하위 바이트가 먼저 저장됨
  • .LC2:
    • 0xCCCCCCCD (3435973837)
    • 0x3FFCCCC (1073532108)
  • 이 조합은 IEEE 754 표현으로 1.8을 나타냄
  • .LC3의 두 값은 32.0을 나타냄

요점 정리

  • AVX 명령어는 즉시 상수를 직접 사용할 수 없음
  • 상수는 데이터 섹션에 저장되고, .LCx와 같은 레이블을 통해 참조
  • .long으로 표현된 두 개의 32비트 정수는 64비트 double 형식 상수의 하위/상위 바이트
  • 이는 부동소수점 데이터의 이진 표현에 대한 깊은 이해를 필요로 함

3.11.5 부동 소수점 코드에서 비트연산 사용하기

핵심 아이디어

AVX2 명령어 중 일부는 비트 단위 연산 (bitwise operation)을 지원하며, 이들은 부동소수점 값의 특정 비트를 직접 조작할 수 있는 도구가 된다.

📌 주의점

  • 이러한 명령어는 packed data에 작동합니다 (즉, 128비트 전체에 대해 수행).
  • 하지만 우리는 보통 하위 8바이트 (double) 혹은 하위 4바이트 (float)만 사용하므로, 나머지는 무시된다.
  • 이러한 방식은 정교하고 효율적인 방식으로 부동소수점 값을 조작하는 데 사용될 수 있다.

주요 명령어 (그림 3.50)

명령어 의미
vxorps, xorpd 비트 XOR (배타적 논리합)
vandps, andpd 비트 AND (논리곱)
  • xorpd: 두 레지스터의 내용을 XOR하여 새로운 레지스터에 저장
  • andpd: 두 레지스터의 내용을 AND하여 저장

예제: 실용적인 활용

예제 A

vmovsd .LC1(%rip), %xmm1
vandpd %xmm1, %xmm0, %xmm0
  • .LC1은 특정 마스크 비트를 담은 상수 (예: sign bit 제거)
  • vandpd는 이 마스크를 적용하여 특정 비트만 남김 (예: 절댓값 계산)

예제 B

vxorpd %xmm0, %xmm0, %xmm0
  • 자기 자신과 XOR하면 0이 됨
  • xmm0을 0으로 초기화하는 간단하고 빠른 방법

예제 C

vmovsd .LC2(%rip), %xmm1
vxorpd %xmm1, %xmm0, %xmm0
  • .LC2는 sign bit만 1인 값
  • vxorpd는 sign bit만 반전 → 부호 반전 (예: -x)

요점 정리

  • 정수 연산처럼, 부동소수점에서도 비트 연산이 간편한 초기화, 절댓값 계산, 부호 반전 등에 매우 유용하게 사용된다.
  • 이는 함수 호출과 연산보다 훨씬 빠른 저수준 최적화 기술이다.
  • 특히 컴파일러는 이러한 연산을 자동으로 생성하기도 한다.

3.11.6 부동 소수점 비교 연산

핵심 비교 명령어

명령어 설명
ucomiss 단정도(float) 비교
ucomisd 배정도(double) 비교
  • 이 명령어들은 **S2 - S1**을 계산하여 비교 결과에 따라 조건 코드 (ZF, CF, PF)를 설정한다.
  • AT&T 문법에서는 피연산자 순서가 반대입니다: ucomisd S1, S2는 실제로 S2 - S1을 의미한다​.

조건 코드 설정

조건 CF ZF PF
Unordered (NaN 포함) 1 1 1
S2 < S1 1 0 0
S2 = S1 0 1 0
S2 > S1 0 0 0
  • PF (Parity Flag)는 하나라도 NaN이면 1로 설정됨
  • ZF는 두 값이 같을 때, CF는 S2 < S1일 때 설정
  • NaN 검출: jp (jump on parity) 명령어로 감지 가능

예제: 값의 범위 분류 함수

typedef enum {NEG, ZERO, POS, OTHER} range_t;
range_t find_range(float x) {
    int result;
    if (x < 0) result = NEG;
    else if (x == 0) result = ZERO;
    else if (x > 0) result = POS;
    else result = OTHER;
    return result;
}

어셈블리 분석

1  find_range:
2  vxorps %xmm1, %xmm1, %xmm1       ; %xmm1 = 0.0
3  vucomiss %xmm0, %xmm1            ; 비교: 0 - x
4  ja .L5                           ; x < 0 이면 NEG
5  vucomiss %xmm1, %xmm0            ; 비교: x - 0
6  jp .L8                           ; NaN이면 OTHER
7  movl $1, %eax                    ; result = ZERO
8  je .L3                           ; x == 0 이면 종료
9  .L8:                             ; x > 0 혹은 NaN
10 vucomiss .LC0(%rip), %xmm0       ; 0.0과 비교
11 setbe %al                        ; NaN이면 1, 아니면 0
12 movzbl %al, %eax
13 addl $2, %eax                    ; result = POS 또는 OTHER
14 ret
15 .L5: movl $0, %eax               ; result = NEG
16 .L3: rep; ret

흐름 설명

  • x < 0 → NEG
  • x == 0 → ZERO
  • x > 0 → POS
  • x == NaN → OTHER (jp 분기로 감지)

요점 정리

  • ucomiss, ucomisd는 AVX2에서 스칼라 부동소수점 비교에 사용됨
  • 비교 결과는 ZF, CF, PF 플래그로 표현되고, 조건부 분기 명령어 (je, ja, jp 등)와 함께 사용됨
  • NaN 검출에는 PF (parity flag)를 사용하며, 이는 일반 정수 연산에서는 거의 쓰이지 않음

3.11.7 부동 소수점 코드에 대한 관찰

핵심 관찰 요약

유사성: 정수 코드와의 공통점

  • AVX2 부동소수점 연산은 정수 연산처럼 레지스터 기반으로 수행된다.
  • 부동소수점 연산도 XMM 레지스터를 사용하여 데이터를 전달하고, 결과를 반환한다.
  • 함수 인자 전달 및 반환에서의 레지스터 사용 규칙이 명확하게 정의되어 있음

차이점: 다양한 데이터 형식과 명령어

  • 부동소수점은 데이터 형식 변환, 비트 연산, 예외 처리(NaN, Inf 등), 정렬 조건 등을 고려해야 한다.
  • AVX2 기반 부동소수점 명령어는 정수 연산보다 훨씬 다양하고 복잡한 형식을 가진다.
  • 특히 스칼라 vs 벡터 모드, 단정도 vs 배정도, 비트 연산 포함 여부에 따라 명령어가 분기된다​.

병렬화와 SIMD

  • AVX2는 벡터화(parallelization) 기능을 통해 여러 데이터를 동시에 처리할 수 있음
  • 예: YMM 레지스터는 256비트 → 4개의 double 혹은 8개의 float를 동시에 처리 가능
  • 하지만 컴파일러가 자동으로 스칼라 → 벡터 코드 변환하는 데는 한계가 있음
  • GCC는 벡터화 최적화를 위해 C 확장을 제공함 (#pragma, vector types 등)

결론

  • AVX2 기반 부동소수점 연산은 정수 연산과 유사한 점도 많지만, 그 복잡성과 처리 방식에서 큰 차이가 있음
  • 고성능 부동소수점 연산을 위해서는 컴파일러 최적화 외에도 프로그래머가 직접 벡터 연산을 설계하는 경우도 많음
  • 부동소수점 코드의 이해는 C 추상화를 넘어서 기계 수준의 정확한 모델링을 가능케 해 줌