부동소수점의 성질과 계산, 그리고 FPU

 

 

부동 소수점의 성질과 연산, 그리고 FPU

이 글에서는 컴퓨터에서의 부동소수점을 저장하고 연산하는 법과 그 성질에 대해서 다루어볼 것이다. 또한 부동소수점을 처리하는 하드웨어인 FPU에 대해서 가볍게 소개하며, 어떻게 실수형 연산을 처리하는지를 알아볼 것이다.

 

각 목차의 내용은 다음과 같다.

  1. 부동소수점의 저장을 다룬다. 이 과정에서 수의 정규화와 비정규화에 대해 다룬다.
  2. 부동소수점의 성질을 다룬다. 이 과정에서 머신 엡실론과 실수의 분포를 다룬다.
  3. 부동소수점의 연산을 다룬다.
  4. FPU에 대해서 다룬다. 이 과정에서 Sparc 어셈블리어를 이용해서 어떻게 FPU를 활용하여 연산을 처리하는 지 알아본다.

💻 컴퓨터의 수의 표현과 부동 소수점

부동소수점의 표현

컴퓨터에서 숫자 체계는 크게 두 가지로 나뉜다. 정수(Integer)와 실수(Float).

 

이진 컴퓨터의 한계로, 실수를 정확하게 표현하는 것은 불가능하다. 그래서 실수를 표현하기 위해 고정 소수점과 부동 소수점 방식을 사용한다.

  • 고정 소수점 방식은 제한된 범위에서, 정수처럼 정확한 숫자의 표현이 가능하나, 표현 범위가 좁기에 특수한 경우에만 사용된다.
  • 부동 소수점 방식은 정확한 숫자의 표현이 불가능하나 넓은 범위를 효과적으로 커버할 수 있기에, 일반적으로 사용되는 방식이다.

컴퓨터가 어떻게 부동소수점을 저장하는 지 먼저 알아보자. 부동소수점의 표현과 연산에 대한 표준은 1985년 ANSI IEEE 754 표준으로 지정되었다. 단일정밀도(Single precision, Float), 2배정밀도(Double Precision), 4배정밀도(Quadruple Precision)에 대한 표준이 존재하는 데, 일반적으로 단일, 2배 정밀도까지는 하드웨어 적으로 처리되고, 4배 정밀도부터는 소프트웨어 연산을 통해 처리된다.

 

실수의 표현을 알기 위해서, 실수의 정규화(Normalization)에 대해서 알아야 한다.

 

컴퓨터 과학에서 정규화된 수(Normalized Number)란, $3.14 * 10 ^ 7$, $1.0 * 10 ^{-18}$과 같은 형태의 수를 의미한다. 이때 가수부에는 선행하는 0이 없어야 한다(즉 $0.1 * 10 ^{-1}$의 경우 $1.0 * 10 ^ {-2}$으로 표기해야 한다).

 

이때 중요한 것은 유효숫자의 표현이다. 이는 저장공간과 연산 능력에 한계가 있는 컴퓨터과학이나, 그 외 물리학 등 현실에 접목되는 학문에서 매우 중요한 문제이다. 현실에서는 무한한 수의 표현을 할 수 없기에, 적당한 유효숫자를 제한한다. 예를 들어 실수부의 유효 숫자가 세 자리라면, $1.xxx * 10^{n}$ 꼴로 표현되어야 한다($1.000 *10^2$ 등).

 

이때 xxx 부분을 가수부(Fractional Part, Mantissa), n 부분을 진수부(Exponent)라고 한다. 10의 경우는 base이다.

 

다만 컴퓨터는 이진(Binary) 체계이므로, Base로 10 대신 2를 사용한다.

 

또한 10진수와 달리 2진수에서는 정규화 시 가수부의 맨 처음은 항상 1이 된다. 따라서 1을 굳이 저장하지 않아도 되므로, 저장공간을 1비트 절약할 수 있다.

32비트 실수(Float)의 구조
32비트 실수(Float)의 구조, 사진출처: https://www.geeksforgeeks.org/ieee-standard-754-floating-point-numbers/



32비트 Float 부동 소수점을 기준으로 컴퓨터가 어떻게 실수를 저장하는 지 알아보자.

 

크게 세 가지 부분으로 나뉜다.

  • 부호 비트(Sign bit): 맨 처음 비트는 부호(+, -)를 표현하는 부호 비트(s)로, 0이면 양(+), 1이면 음(-)을 의미한다.
  • 지수부(Exponent): 8비트로 지수(e)를 저장한다. 이때 e는 unsigned bit로 저장되며, 부호를 표시하기 위해 별도의 Sign 비트를 사용하는 것이 아니라, Bias를 더해 계산한다(후술).
  • 가수부(Mantissa): 23비트로 가수(f)를 저장한다.

이렇게 저장된 값의 실제 수치 N은 다음과 같다.

$$
N = (-1)^s * 2^{e-b} * (1.f)_2
$$

이때 b는 bias로 단일정밀도에서는 127이 된다. 곧

$$
N = (-1)^s * 2^{e-127} * (1.f)_2
$$

 

bias를 두는 이유는 이 글에 잘 설명되어 있으니 이 분 글을 첨부한다.

실습: IEEE 754 단정밀도 변환

설명만 들으면 복잡하니, 직접 계산해보자.

IEEE754 to 10진수

먼저 0xC094000 을 10진수로 변환해 보자.

 

16진수 0xC0940000을 2진수로 변환하면 1100 0000 1001 0100 0000 0000 0000 0000 이다.

 

이때 맨 앞 sign 비트가 1이므로, 이 수는 음수(-) 임을 알 수 있다. s = 1

그 뒤 8비트는 지수부이다. $e = (10000001)_2 = 128 + 1 = 129$.

이때 Bias를 빼야 하므로 지수는 $e - b = 129 - 127 = 2$.

마지막으로 23비트의 가수부는 001 0100 0000 0000 0000 0000 이다. 소수점 뒤 0을 생략하면 $(1.00101)_2$으로 표현 가능하다.

 

곧 $N=(-1) * (1.00101)_{2} * 2^{2} = -(100.101)_{2} = -(4.625)_{10}$


10진수 to IEEE754

반대로 이번에는 10진수 -6.75를 IEEE 754 단정밀도 부동소수점 형식으로 변환해 보자.

 

$-6.75 = -1 *(1.1011)_2 * 2^{2}$ 이다.

s = 1

e = 2 + 127(bias) = 129 = 1000 0001

f = 101 1000 0000 0000 0000 0000

곧 이진수로 표현하면 1100 0000 1101 1000 0000 0000 0000 0000 이고, 이를 16비트로 표현하면 0xC0D80000 이다.

배정밀도의 표현

2배 정밀도

64bit를 사용하는 2배정밀도 표현은 유효숫자가 더 크므로 단정밀도보다 더 정교하게 숫자 표현이 가능하다.

 

사실 거의 대부분의 경우, 실수형 타입을 float보다는 double로 지정하는 것이 일반적이다. 부동 소수점 오차 없이 훨씬 정확하게 계산이 가능하기 때문이다.

64비트 실수(Double)의 구조
64비트 실수(Double)의 구조

 

2배정밀도에서는 진수부는 11비트, 가수부는 52비트를 사용하며, bias = 1023이다.

 

2배정밀도에서 실제 N값의 결과는 다음과 같다.

 

$$
N = (-1)^s * 2^{e-1023} * (1.f)_2
$$

 

한편 배정밀도까지는 하드웨어 레벨에서 계산이 가능하다. 하나의 레지스터는 4바이트 크기(단정밀도 32bit)를 저장할 수 있으므로, 배정밀도 계산 시 2개의 레지스터를 사용하여 계산한다.

4배 정밀도

4배정밀도 구조
4배정밀도 구조

4배정밀도의 경우 128비트를 사용하며, 여기서부터는 대부분의 시스템에서 하드웨어 레벨보다는 소프트웨어 레벨에서 연산을 수행한다.

 

진수부는 15비트, 가수부는 112비트, bias는 16383을 사용한다.

 

$$
N = (-1)^s * 2^{e-16383} * (1.f)_2
$$

특수한 수의 표현과 수의 범위

단정밀도

0(zero), 무한대, NAN(Not A Number)와 같은 특수한 경우를 표현하기 위해 몇 가지 규칙을 지정해 두었다.

 

이때 NAN은 실수 표현이 불가능한 경우로 $\sqrt{-1}$ 등을 의미한다.

e f 의미
255 ≠ 0 NAN
255 0 +-inf
0 ≠0 subnormal
0 =0 0 (zero)
0 < e < 255   비영의 값

e = 255, f = 0은 무한대($\infty$)로, s에 따라 $\pm\infty$로 나뉜다.

 

e = 0, f = 0은 말 그대로 정확한 0(zero)으로 표현된다.

 

그 외 0 < e < 255인 경우는 비영(non-zero)의 정규화된 수를 표현하게 된다.

 

단 $e=0, f\neq0$ 일 때는 Subnormal로 수를 표현한다.

 

단정밀도에서 수의 표현 범위는

 

$$
\text{0x 0080 0000} \le N \le \text{0x 7f7f ffff}
$$

$$
10 * 2 ^{-126} \le N \le 2.0 * 2^{127}
$$

$$
1.17549435 * 10 ^{-38} \le N \le 3.40282347 * 10 ^{38}
$$

이다.

2배정밀도

두 배 정밀도에서는 e가 255가 아닌, 2047일 때 특수한 의미를 갖는다.

e f 의미
2047 ≠ 0 NAN
2047 0 +-inf
0 ≠0 subnormal
0 =0 0 (zero)
0 < e < 2047   비영의 값

2배정밀도에서 수의 범위는

$$
2.225073858507201 * 10 ^{-308} \le N \le 1.7976931348623157 * 10 ^ {308}
$$

이다.

비정규화(Subnormal)

글에서 중점으로 다루지는 않지만, 정규화된 수(Normalized Number) 외에 비정규화(Subnormal) 수 역시 IEEE 754에서 중요한 표준이다.

 

0.0에 매우 가까운 범위에서의 수를 표현하기 위해 사용된다.

 

위에서 수의 범위를 보면, 단정밀도를 기준으로 $0 \sim \pm 1.17549435 * 10 ^{-38}$ 사이 수치가 표현되지 않는다.

0.0에 근접하는 더 작은 수들을 표현하기 위해 subnormal을 사용한다.

 

Subnormal을 사용 시 가장 작은 양수의 수치 표현은 0x00000001이 되며, $1.40129846 * 10 ^{-45}$ 까지의 수 표현이 가능하다.

 

Subnormal 덕분에 정규화된 수보다 더 촘촘하게 0에 가까운 수를 표현할 수 있다. Subnormal에서 N의 값은 아래와 같다.

 

$$
N = (-1)^s * 2^{-126} * (0.f)_{2}
$$

그러나 이 Subnormal의 존재 때문에 후술할 컴퓨터에서 실수의 고르지 않게 분산된(Non-evenly Distributed) 문제를 야기하기도 한다.

📊 머신 앱실론과 실수의 분포

컴퓨터에서 실수를 다루는 것은 주의를 요한다. 실수의 연산 과정에서 생각지도 못한 결과, 치명적인 오류를 발생할 수 있다. 그렇기 때문에 실수의 연산을 알아보기 전에, 머신 앱실론(machine epsilon)과 실수의 분포를 알아야 한다.

 

먼저 수학의 세계와는 다르게, 컴퓨터의 수 체계에서 실수는 불연속적(discontinuous)이고, 고르게 분포되어 있지 않다(non-evenly disritubted).

 

이는 수치해석 및 확률과 통계에서 매우 중요하게 다루어지는 문제이다.

머신 엡실론과 실수의 불연속성

머신 엡실론(Machine Epsilon, $\varepsilon$)은 부동소수점 수의 표현 한계로 인해 나타나는 오차의 상한을 의미한다. 이 오차로 인해 수는 불연속적으로 분포되어 있으며, 이 차이를 머신 엡실론이라 한다.

 

머신 엡실론 $\varepsilon$은 다음과 같이 계산한다.

$$
\varepsilon = b^{-(p-1)}
$$

이때 b는 base(여기서는 2), p는 정밀도이다.

 

단정밀도 float은 p = 24로 $\varepsilon = 2^{-23}$, 배정밀도 double은 p = 53으로 $\varepsilon = 2^{-54}$ 이다.

 

컴퓨터에서 $\varepsilon$보다 작은 간격은 표현되지 않는다. $1$과 $1+\varepsilon$ 사이의 실수는 존재하지 않는다.

 

곧 컴퓨터는 어떤 두 실수의 차이가 $\varepsilon$보다 작으면, 두 수는 같다고 판단한다($|a - b| < \varepsilon \implies a = b$)

실수의 분포

위에서 소개한 Subnormal의 존재와, 실수의 계산 표현 그 자체의 한계로, 실수는 불연속적으로 분포한다.

실수의 분포
실수의 분포, 사진 출처: https://courses.engr.illinois.edu/cs357/fa2019/references/ref-1-fp/

0.0 부근에서 subnormal의 존재로 수는 더 촘촘히 분포한다.

 

0과 Subnormal이 표현할 수 있는 최소하한보다 작은 수 사이는 어떤 실수도 존재하지 않는 공간으로, 이 영역에 접근하려 하면 underflow가 발생한다.

 

한편 정규화된 경우 역시 부동소수점 표현 자체의 특성상, 수가 커질수록 그 오차 간격 역시 커진다. 때문에 일반적으로 실수는 0에 가까울수록 더 촘촘하게 분포한다.

 

표현 가능한 최대 상한을 벗어날 경우 overflow가 발생한다.

C언어로 머신 엡실론과 실수의 최대, 최소 구하기

못 믿겠다면 직접 구해보자. 누구나 자신의 컴퓨터에서 머신 엡실론과, 실수의 최대, 최솟값을 구할 수 있다.

#include <stdio.h>

void min_num() {
  float x = 1.0;
  for (;;) {
    printf("f %.30e\n", x / 2.0);
    x = x / 2.0;
    if (x == 0.0)
      break;
  }
}

void emach() {
  float emach = 1.0, emach2, tmp;

  for (;;) {
    emach2 = emach;
    emach /= (float)2.;
    tmp = (float)((float)1. + (float)emach);
    if (tmp == (float)1.0)
      break;
  }
  printf("single Emach = %.30e\n", emach2);
}

void max_num() {
  float x = 1.0;
  int i = 0;
  for (int i = 0; i < 130; i++) {
    printf("%.30e\n", x);
    fflush(stdout);
    x = x * 2.0;
  }
}

int main() {
  min_num();
  emach();
  max_num();

  return 0;
}

위 코드는 각각 실수의 최소, 머신 엡실론, 최대를 구하는 코드이다.

f 2.802596928649634141847459166580e-45
f 1.401298464324817070923729583290e-45
f 7.006492321624085354618647916450e-46
single Emach = 1.192092895507812500000000000000e-07
(생략)
4.253529586511730793292182592897e+37
8.507059173023461586584365185794e+37
1.701411834604692317316873037159e+38
inf
inf
inf

단, 이 결과는 시스템 혹은 컴파일러에 의해서 크게 달라질 수 있다.

➕ 실수의 연산

실수의 연산은 크게 덧셈과 뺄셈, 곱셈과 나눗셈으로 나뉜다.

덧셈과 뺄셈

  1. 유효숫자를 정렬한다.
  2. 유효숫자를 덧셈/뺄셈한다.
  3. 결과를 정규화한다.
  4. (필요시) 반올림 후 다시 정규화한다.

위 단계를 토대로 아래 10진수 실수의 덧셈을 계산해 보자.

 

$$
9.999 × 10 ^{1} + 2.622 × 10 ^{-1}
$$

  1. 유효숫자 정렬: 지수가 작은 쪽 Operand를 Right Shift 한다.
    $$
    2.622 * 10^{-1} \rightarrow 0.026 * 10 ^{1}
    $$
  2. 유효숫자 덧셈/뺄셈
    $$
    9.999 + 0.026 = 10.025
    $$
  3. 결과 정규화 & 오버플로우/언더플로우 검사
    $$
    10.025 × 10^{1} \rightarrow 1.0025 × 10 ^{2}
    $$
  4. (필요시) 반올림 후 재정규화
    $$
    1.0025 × 10 ^{2} \rightarrow 1.003 × 10 ^2
    $$

이제 2진수 실수의 덧셈을 진행해 보자.

$$
-1.110_2 × 2^{-2} + {1.000}_{2} × 2^{-1}
$$

  1. 유효숫자 정렬: 지수가 작은 쪽 Operand를 Right Shift 한다.
    $$
    −1.110_2 × 2^{−2} \rightarrow −0.111_2 × 2^{−1}
    $$
  2. 유효숫자 덧셈/뺄셈
    $$
    −0.111_2 + 1.000_2 → 0.001_2
    $$
  3. 결과 정규화 & 오버플로우/언더플로우 검사
    $$
    0.001_2 × 2^{−1} \rightarrow 1.000 × 2^{−4}
    $$
  4. (필요시) 반올림 후 재정규화
    $$
    1.000 × 2^{-4}
    $$

곱셈과 나눗셈

  1. 지수를 덧셈/뺄셈한다.
  2. 유효숫자를 곱셈/나눗셈한다.
  3. 결과를 정규화한다.
  4. 반올림한다.
  5. Sign 연산으로 부호를 결정한다.

위 단계를 토대로 아래 10진수를 곱셈해 보자.

$$
−1.110 × 10^{10} × 9.200 × 10^{−5}
$$

  1. 지수 덧셈/뺄셈
    $$
    10 + (-5)= 5
    $$
  2. 유효숫자 곱셈/나눗셈
    $$
    1.110 × 9.200 = 10.212 \rightarrow 10.212 × 10^5
    $$
  3. 결과 정규화 & 오버플로우/언더플로우 검사
    $$
    10.212 × 10^5 \rightarrow 1.0212 × 10^6
    $$
  4. 반올림 후 재정규화
    $$
    1.0212 × 10^6 \rightarrow 1.021 × 10^6
    $$
  5. 부호 결정
    $$
    +1.021 × 10^6
    $$

이제 2진수의 곱셈을 계산해 보자.

$$
−1.110_2 × 2^{−2} × 1.000_2 × 2^{−1}
$$

  1. 지수 덧셈/뺄셈
    $$
    -2+(-1)=-3
    $$
  2. 유효숫자 곱셈/나눗셈
    $$
    1.110_2 × 1.000_2 = 1.110_2 \rightarrow 1.110_2 × 10^{−3}
    $$
  3. 결과 정규화 & 오버플로우/언더플로우 검사
    $$
    1.110_2 × 10^{−3}
    $$
  4. 반올림 후 재정규화
    $$
    1.110_2 × 10^{−3}
    $$
  5. 부호 결정
    $$
    -1.110_2 × 10^{−3}
    $$

🤖 FPU

FPU의 개념

FPU와 CPU
FPU와 CPU, 사진 출처: 위키피디아 FPU

 

FPU와 CPU
FPU와 CPU의 연결

실수의 연산은 정수의 연산보다 훨씬 복잡하다. 지수부, 가수부를 별도로 계산해야 하기 때문이다.

 

이를 소프트웨어적으로 처리할 수도 있지만, FPU(Floating Point Unit)이라는 하드웨어를 통해서 하드웨어로 연산을 대신할 수도 있다.

 

FPU가 있는 시스템의 경우 부동소수점 연산을 매우 빠르게 수행할 수 있어, 대규모 병렬 연산 및 슈퍼 컴퓨팅에 강점이 있다.

 

만약 FPU가 없는 시스템의 경우 정수 연산을 위한 ALU(Arithmetic and Logic Unit)에서 실수 연산을 처리한다. 다만 이 경우 FPU를 사용하는 것보다 느리다.

현재의 FPU

원래 초창기 x86 컴퓨터 시스템에서는 CPU와 분리된 별도의 FPU를 갖고 있다. 이때는 ALU의 성능이 워낙 낮았기 때문에 부동소수점 역시 ALU에서 처리하기에는 부담이 있었기 때문이다.

 

하지만 CPU 외부에 존재하는 FPU의 경우, CPU의 레지스터에서 FPU의 레지스터로 데이터가 이동하는 I/O 딜레이가 존재한다는 단점이 있다.

 

더군다나 Sparc 시스템 기준으로 CPU의 레지스터에서 FPU의 레지스터로 직접 전달하는 방법 또한 없다. 무조건 Memory를 거쳐야 한다. CPU의 레지스터에서 FPU의 레지스터까지 CPU → MEM → FPU 순서를 거쳐야 하고, 이 과정에서 매번 RAM에 적재/저장(Load/Store)이라는 비싼 연산을 거쳐야 한다. 이 모든 것이 I/O 딜레이를 유발한다.

 

초기에는 ALU의 성능이 낮아서 I/O 성능이 크게 병목 현상을 일으키지는 않았지만, 점차 ALU의 성능이 발전하면서 결국 I/O 딜레이가 치명적인 병목 현상으로 지적되었다.

 

결국 Intel 486을 기점으로 FPU는 CPU로 통합되게 된다. 486 때만 해도 Single Core 시절이었다.

인텔의 듀얼 코어 CPU와 쿼드 코어 CPU의 구조
인텔의 듀얼 코어 CPU와 쿼드 코어 CPU의 구조. FPU와 ALU가 같이 섞여 있다.

FPU의 형태를 정리하면 아래와 같다.

  • 소프트웨어 처리(FPU Emulator): 부동소수점 라이브러리가 ISA 레벨에서 제공됨
  • 별도의 FPU 하드웨어
  • CPU에 통합된 형태

그리고 이제는 CPU에 통합되었거나 소프트웨어적으로 처리하여, FPU 하드웨어는 역사의 뒤안길로… 사라지게 되었다.

애플 m1
사진 출처: https://www.production-expert.com/production-expert-1/why-are-the-apple-m1-m1-pro-and-m1-max-chips-so-fast

 

사실 FPU만의 운명은 아닌 게, 요새 트렌드 자체가 한 칩에 모든 HW를 통합하는 SoC 구조가 유행이다.

 

최근에 나온 Apple Silicon을 생각해 보자. 채널 사이의 딜레이를 줄이기 위해, DRAM, CPU, GPU 등을 SoC 구조로 한 칩에다가 전부 때려 박는다. AMD Ryzen도 마찬가지고, 많은 시스템에서 점차 채널 사이 딜레이를 줄이려고 노력한다.

 

물론 인공지능에 사용되는 Cuda 연산 등에서는 매우 높은 GPU/TPU 연산 성능이 필요하기에, 아직까지는 별도의 VGA를 두는 게 일반적이기는 하지만… ARM 아키텍처나 그 외 모바일 프로세서 등에 사용되는 퀄컴 프로세서도 SoC를 많이 채택한다.

 

언젠가는 GPU도 완전히 CPU에 통합되게 되는 날이 올까? 지금은 AI 때문에 워낙 GPU 사용률이 높아서, 근미래에는 오지 않을 것 같다. 하지만 먼 미래에는 또 모를지도?

FPU를 이용한 연산의 구현(Sparc)

이 글은 Sparc ISA를 기준으로 작성되었다.

FPU 레지스터 및 명령어 알아보기

Sparc에서 FPU의 레지스터는 %f0부터 %f31까지 32개가 있고, 1개의 레지스터가 32비트를 저장할 수 있다.

 

단정밀도 부동소수점의 경우 하나의 레지스터를 사용하면 되고, 배정밀도 부동소수점(double)의 경우는 두 개를 묶어서 사용한다. 곧 %f4에 double을 저장할 경우 실제로는 %f4%f5의 레지스터를 같이 사용하게 된다.

 

FPU에서 제공하는 대표적인 실수 연산은 덧셈, 뺄셈, 곱셈, 나눗셈, 제곱근 등이 있다. 이를 명령어로 표현하면 fadds, fsubs, fmuls, fdivs, fsqrts 이다. 이때 맨 뒤에 붙는 ‘s’는 단정밀도를 의미하는 single의 s이다. 만약 double을 사용해야 한다면, 맨 뒤에 s 대신 d를 붙이면 된다. faddd, fsubd, fmuld, fdivd, fsqrtd 이런 식으로 말이다.

 

✅ TIP: 구형 Sparc 시스템에서는 정수의 곱셈, 나눗셈 등의 연산이 명령어로 제공되지 않고 함수로 제공되기도 했었다. 그에 반해 FPU에서는 곱셈, 나눗셈, 제곱근 등의 연산이 모두 명령어로 제공된다.

 

double이 온다면 f 레지스터는 반드시 0 또는 짝수가 와야 한다. 즉 faddd %f0, %f2, %f4는 올바른 명령어지만 faddd %f0, %f1, %f4는 올바른 명령어가 아니다. %f0%f1 이 실제로는 하나의 double을 저장하고 있기 때문이다.

 

한편 move 연산, 부호변환, 절댓값은 각각 fmovs, fnegs, fabss 로 표현된다.

 

데이터 타입의 변환 역시 필요하다. float에서 int로의 변환(single to int)는 fstoi, int에서 float으로의 변환(int to single)은 fitos 명령어로 구현되어 있다.

 

표로 정리하면 아래와 같다. double은 맨 뒤에 s를 d로 바꾼다.

   
명령어 동작
fadds fr1, fr2, frd 덧셈
fsubs fr1, fr2, frd 뺄셈
fmuls fr1, fr2, frd 곱셈
fdivs fr1, fr2, frd 나눗셈
fsqrts fr1, fr2, frd 제곱근
fmovs fr1, frd 레지스터 복사
fnegs fr1, frd 부호변환
fabss fr1, frd 절대값
fitos fr1, frd int to single(float)
fstoi fr1, frd single(float) to int

FPU의 비교 명령어와 분기 명령어

이제 분기 시에 사용되는 FPU 비교 명령어를 알아보자. FPU에서 생성되는 조건 코드(Condition Code, 이하 CC)를 FCC라 한다. FCC는 E, L, G, U가 존재한다.

fcc 기호 의미
0 E fr1 = fr2
1 L fr1 < fr2
2 G fr1 > fr2
3 U fr1 ? fr2

Equal, Less, Greater를 의미하는 앞에 세 개는 이해되지만, U가 생소할 수 있다. U는 Unordered의 의미로써, Operand인 fr1, fr2 두 개 중 적어도 하나는 유효하지 않은 실수(NaN)임을 의미한다. 즉 오류의 의미를 담는다.

 

fcmps fr1, fr2와 같은 명령어는, 위 E, L, G, U 네 가지 CC를 생성한다. 예를 들어

fcmps   %f1, %f2
fbe     equal ! equal은 브랜치 이름
nop

처럼 사용할 수 있다.

 

FPU의 분기 명령어와 사용하는 CC는 아래 표로 정리하였다.

명령어 의미 조건코드
fba always 1
fbn never 0
fbo on ordered E or L or G
fbu on unordered U
fbul on unordered or less L or U
fbl on less L
fbule on unordered or less or equal E or L or U
fble on less or equal E or L
fbue on unordered or equal E or U
fbe on equal E
fbne on not equal L or G or U
fblg on less or greater L or G
fbuge on unordered or greater or equal E or G or U
fbge on greater or equal E or G
fbug on unordered or greater G or U
fbg on greater G

대부분 less, greater, equal, unordered의 앞 글자를 그대로 조합해서 명령어를 구성하기에 매우 직관적이다.

 

한편 fblgfbne가 무엇이 다른 지 헷갈릴 수 있다. 둘 다 not equal을 체크하는 건 동일한데, fblg는 L, G만 체크하고 fbne는 L, G에 더해 U(NaN)도 체크한다.

CPU 레지스터에서 FPU 레지스터로의 이동

CPU에서 MEM&#44; FPU로 레지스터를 복사하는 과정
CPU에서 MEM, FPU로 레지스터를 복사하는 과정

선술했듯이, Sparc 시스템에서는 CPU의 레지스터에서 FPU의 레지스터로 직접 전달하는 방법은 없다. %l0에 있는 값을 %f0로 옮기기 위해 mov %l0, %f0와 같은 순진한 방법을 생각했다면, 아직 어셈블리어의 쓴 맛을 깨닫지 못한 상태다.

 

CPU 레지스터의 값을 FPU 레지스터로 복사하기 위해서는 반드시 Memory 영역을 거쳐야 한다.

.global main
main:   
    save %sp, -96, %sp

    mov 3, %o0      ! %o0: 0x00000003
    st %o0, [%fp-4] ! %o0에 있는 값을 메모리에 저장
    ld [%fp-4], %f1 ! %f1: 0x00000003
    fitos %f1, %f2  ! %f2: 0x40400000

위 코드를 살펴보자. mov 3, %o0로 o0 레지스터에 3을 저장한다.

%o0 레지스터에 있는 값을 메모리에 저장한다(st %o0, [%fp - 4]).

메모리에 저장된 값을 %f1으로 적재한다(ld [%fp - 4], %f1).

이때 저장된 3이라는 값은 정수형이므로, 이를 fitos를 이용해서 실수형으로 변경한다.

FPU에서 double 연산

이제 FPU에서 double을 연산해보자. 2배정밀도 double은 레지스터를 두 개를 사용한다. 만약 %f0에 값을 저장했으면, 실제로는 %f0%f1 두 개의 레지스터를 사용한다.

#include <stdio.h>

void main() {
    register double a = 1.5;
    register double b = 2.0;
    register double c = 0.0;

    c = a + b;

    printf("a+b=%f\n", c);
    return;
}

위 C언어 코드를 Sparc로 구현하면, 아래와 같이 구현할 수 있다.

            .data
a:      .double 0r1.5
b:      .double 0r2.0
c:      .double 0r0.0
str:    .asciz “a+b=%f\n”
    .text
    .global main
main: 
    save    %sp, -96, %sp
    set     a, %l0
    set     b, %l1
    set     c, %l2

    ldd     [%l0], %f0 ! stores a’s value, 1.5 to %f0%f1
    ldd     [%l1], %f2 ! stores b’s value 2.0 to %f2%f3

    faddd   %f0, %f2, %f4 ! %f4%f5=%f0%f1+%f2%f3

    std     %f4, [%l2]
    ldd     [%l2], %o2 ! load c’s value to %o2%o3, %o2 an even numbered register
    set     str, %o0
    mov     %o2, %o1 ! printf takes a real number argument as a double precision number
    call    printf
    mov     %o3, %o2 ! %o2%o3 -> %o1%o2

    ret
    restore

std, ldd, faddd 처럼 명령어의 뒤에 ‘d’가 붙음을 주의하라. 이러면 알아서 두 개의 레지스터를 사용하여 double의 계산을 처리한다.

 

ldd [%l0], %f0의 경우 실제로는 %f0%f1 두 레지스터에 a = 1.5 값이 저장된다.

 

그렇기 때문에 다음 b값을 저장하기 위해서는 ldd [%l1], %f2 로 저장해야 한다.

이는 FPU의 레지스터에서 CPU의 레지스터로 이동 시에도 마찬가지이다.

std     %f4, [%l2]
ldd     [%l2], %o2 ! 실제로는 %o2, %o3 레지스터에 저장됨

한편 printf 함수를 호출할 때에는, %o2%o3의 레지스터를 %o1%o2의 레지스터로 옮기는 작업을 하였다. %o0"a+b=%f"라는 첫 번째 문자열 인자값이다.

함수에서 실수 주고 받기

함수에서 실수 타입을 주고 받는 것을 어떻게 처리하는 지 알아보자.

 

만약 일반적인 정수형 인자를 주고 받는다면, 레지스터 윈도우를 사용하는 Sparc 시스템의 경우 %o 레지스터에서 %i 레지스터로 전달이 일어난다.

 

실수형도 마찬가지로 %o에서 %i로 인자를 넘긴다. FPU 레지스터로 옮기기 위해 메모리를 경유하는 과정이 추가될 뿐이다.

 

한편 반환값을 받을 때는 어떤 FPU 레지스터를 사용해도 상관은 없으나, 일반적으로 %f0를 사용하는 게 통용되는 국룰이라고 한다.

 

정수형 레지스터와 달리 FPU 레지스터는 별도의 레지스터 윈도우가 없다. 그래서 FPU의 레지스터를 사용하기 전에, 기존에 있는 값을 어딘가에 저장해두었다가 사용 후 다시 복구시키는 습관은 유용하다.

어딘선가 기존에 있는 값을 이용하고 있을 수도 있기 때문이다. 단, OS의 강제사항은 아니고, 권장되는 규칙이다.

void main() {
  static float a = 3.0, b = 2.5, c = 0.0;
  c = single_add(a, b);
}

float single_add(float a, float b) { 
    return a + b; 
}

위 C언어 코드를 구현해보자.

    .data
a:  .single 0r3.0 ! 0x40400000
b:  .single 0r2.5 ! 0x40200000
c:  .single 0r0.0
    .text
    .global main
main: 
    save %sp, -96, %sp

    set a, %l0
    set b, %l1
    ld  [%l0], %o0 ! argument 1 (0x40400000)
    ld  [%l1], %o1 ! argument 2 (0x40200000)

    st  %f0, [%fp-4] ! 기존에 있는 %f0를 메모리에 저장

    call single_add
    nop

    set c, %l2
    st  %f0, [%l2] ! %f0에 저장된 return 값을 c에 저장

    ld  [%fp-4], %f0 ! 아까 전에 메모리에 저장한 %f0 값을 복구

    mov 1, %g1
    ta  0       ! 프로그램 종료

single_add:
    save    %sp, -96, %sp
    st      %i0,[%fp-4]     ! i 레지스터의 값을 메모리 저장 후 %f 레지스터로 적재
    ld      [%fp-4], %f1    ! %i0 → [%fp-4] → %f1
    st      %i1,[%fp-4]
    ld      [%fp-4], %f2    ! %i1 → [%fp-4] → %f2

    fadds   %f1, %f2, %f0   ! 덧셈 결과를 %f0에 저장

    ret
    restore

두 가지 포인트를 볼 수 있다.

 

먼저 st %f0, [%fp - 4]로 기존에 저장된 %f0 레지스터 값을 메모리에 저장한다. 이후 모든 계산이 끝난 후에 ld [%fp - 4], %f0%f0 레지스터를 복구시킨다.

 

single_add 서브루틴을 살펴보자. 여기서도 마찬가지로 %i0, %i1 등에 들어온 인자값을 메모리를 거쳐서 %f1, %f2 레지스터에 저장한다.

 

메모리에 저장, FPU에 적재 과정만 추가되었을 뿐 정수형 인자를 주고 받는 것과 크게 다르지 않다.

 

fadds %f1, %f2, %f0%f0에 덧셈 결과를 저장한다.

 

만약 double이라면, %o0에 넘긴 인자는 실제로는 %i0, %i1 두 개의 레지스터를 사용한다는 것을 기억하자.

📚 참고 문헌

이 글은 가볍게 소개만 하는 수준으로 오류가 있을 수 있기에, 참고만 하기를 바란다. 더 정확하고 자세한 내용을 알아보려면 참고 문헌을 활용하길 권장한다.

  • E. Ward Cheney and David R. Kincaid. 2007. Numerical Mathematics and Computing (6th. ed.). Brooks/Cole Publishing Co., USA.
  • 박도순, 표창우. 2022. SPARC 프로세서 어셈블리 언어. 대한민국: 시큐어 코딩 테크놀로지.
  • Floating Point Representation, courses.engr.illinois.edu
  • Floating-point unit, WIKIPEDIA