[C코드 최적화] 변수 타입 잘 활용하기

3 minute read

개요

해당 내용은 ‘임베디드 프로그래밍 C코드 최적화’ 책의 story 12에내용을 정리하 였습니다. 변수 타입을 어떻게 활용해야 프로그램을 최적화할 수 있는지에 대해 알아보려 합니다.

변수 데이터 타입의 사용 규칙

가능하면 네이티브 데이터 타입을 사용하기

각 프로세서는 가장 잘 다룰 수 있는 데이터 타입을 가지고 있으며, 이를 네이티브 데이터 타입이라고 한다. 네이티브 데이터 타입의 크기는 해당 마이크로프로세서의 한 워드 크기 와 같다. 한 워드의 크기는 레지스터 크기와 같고, 데이터 버스의 폭과 같다. 이러한 이 유 때문에 어셈블리 레벨에서는 네이티브 데이터 타입을 사용했을 때 가장 적은 인스 트럭션을 사용한다. 이는 그만큼 코드 크기가 줄어들 뿐 아니라, 속도 또한 빨라지는 것을 의미한다.

32비트 프로세서의 경우 데이터 버스의 폭이 4바이트(32bit) 이다. 만약 double 형의 데이터 타입을 가져오려면 두번의 메모리 엑세스가 필요하다. 4바이트보다 작은 데이터 타입을 읽을때도 효율성이 떨어진다. 데이터 버스보다 작은 크기의 데이터를 읽어올 때도 버스의 폭만큼 읽어오고, 여기서 유효한 데이터만 추려내는데 추가 연산이 필요하기 때문이다. ARM 프로세서에서 char형 데이터를 메모리로부터 읽어온다면, 메모리 액세스는 한번이지만, 읽어온 4바이트 중에서 1바이트의 데이터를 추려내는 마스킹 연산이 추가된다.

따라서 최적의 성능을 내기 위해서는 네이티브 데이터 타입을 사용하는 것이 좋다.

변수의 범위 안에서 포함되는 가장 작은 데이터 타입을 사용하기

위에서 언급한것처럼 네이티브 데이터 타입을 사용하는것이 처리 성능면에서는 제일 좋다. 하지만 작은 데이임에도 불구하고 큰 데이터 타입을 사용한다면 메모리 낭비가 된다. 처리성능을 최적화하기 위해서는 네이티브 데이터 타입을 사용하는 것이 좋지만, 처리성능이 중요하지 않은 부분이라면, 작은 데이터타입을 사용하여 메모리를 적약하는 것이 바람직하다.

부동소수점을 피하자

부동 소수점은 최악의 경우 정수 연산에 비해 수백 배까지 성능을 떨어뜨릴 수 있다. 그래서 고속 부동 소수점 연산을 지원하는 FPU(Floating Point Unit)를 사용하기도 하는데 프로세서에 따라서 이 유닛을 지원할 수도 있고 지원하지 않을 수도 있다. 만약 FPU가 지원되지 않는 프로세서라면 고정 소수점을 사용하는 것이 좋으며, 되도록 float이나 dlouble과 같은 부동 소수형을 사용하지 않도록 한다.

전역변수 최적화

전역 변수는 레지스터에 할당할 수 없으므로 함수나 루프에서 사용하지 않는 것이 좋다. 함수나 루프에서 변수를 사용할 때마다 외부에서 읽어와야 하기 때문이다. 함수나 루프에서 전역 변수를 써야 한다면, 지역 변수에 복사하여 사용하는 것이 좋다.

  • sample code
int global var;

void f1 () {
  int i;
  for(i = 0; i < 100; i++){
    if(f2())
      global_var ++;
  }
}
  • sample code 전역변수 최적화
int global var;

void f1 () {
  int i, local_var = global_var;
  for(i = 0; i < 100; i++){
    if(f2())
      local_var ++;
  }
  global_var = local_var;
}

소스코드만 보았을때는 최적화 전의 코드가 더 짧고 효율적으로 보인다. 하지만 루프 안에서 전역변수를 사용하므로 외부 메모리 엑세스가 반복적으로 일어나기 때문에 컴퓨팅 시간이 오래걸린다. 반면 최적화된 코드는 전역변수의 값을 지역변수로 복사하여 연산하기 때문에 레지스터를 사용하므로 속도를 높일 수 있다.

지역변수 최적화

지역 변수는 스택이나 레지스터에 저장된다. 함수의 인자는 마이크로프로세서에서 지정한 개수만큼 레지스터에 저장되거나 스택에 저장된다. ARM 프로세서는 지역 변수를 4개(1 바이트 이하의 경우)까지 레지스터에 저장하고 나머지는 스택에 저장한다. 그러므로 지역 변수나 함수 인자의 데이터 타입은 4바이트보다 작은 데이터를 사용한 다고 하더라도 속도를 높여야 할 경우에는 char, short보다는 int형으로 하는 것이 바람직하다. 레지스터와 크기가 맞지 않으면 데이터 처리 중에 자동으로 형 변환이 발생하여 쉬프트나 마스킹 연산이 발생하기 때문이다.

  • sample code
char f1 (char a) {
  return a + 1;
}

short f2 (short b) {
  return b + 1;
}

int f3 (int c) {
  return c + 1;
}

void main() {
  char a;
  short b;
  int c;

  a = f1(1);
  b = f2(2);
  c = f3(3);
}
  • 각 함수에 대한 disassamble (gdb 이용)
    (gdb) disassemble f1
    Dump of assembler code for function f1:
     0x000103d0 <+0>:     add     r0, r0, #1
     0x000103d4 <+4>:     uxtb    r0, r0
     0x000103d8 <+8>:     bx      lr
    End of assembler dump.
    (gdb) disassemble f2
    Dump of assembler code for function f2:
     0x000103dc <+0>:     add     r0, r0, #1
     0x000103e0 <+4>:     sxth    r0, r0
     0x000103e4 <+8>:     bx      lr
    End of assembler dump.
    (gdb) disassemble f3
    Dump of assembler code for function f3:
     0x000103e8 <+0>:     add     r0, r0, #1
     0x000103ec <+4>:     bx      lr
    End of assembler dump.
    

위의 테스트는 라즈베리파이4에서 테스트하였습니다. uxtb 는 8비트 값을 32비트로 확장하는 인스트럭션이며, sxth는 16비트를 32비트로 확장하는 인스트럭션이다. f1함수와 f2함수 호출시에는 레지스터 크기에 맞게 32비트로 형변환 작업이 이뤄지는 것을 확인할 수 있다.

Leave a comment