Posts

Showing posts with the label risc-v

C코드 최적화 - 전역변수 대신 지역변수 사용

반복문 등에서 반복적으로 사용되는 변수 등은 전역변수 말고 지연변수를 사용하는 게 빠르다. 지역변수는 CPU 레지스터에서 바로 사용되나, 전역변수는 RAM에서 레지스터로 읽어와서 결과를 쓰기 때문이다. 『 레지스터 32bit CPU인 경우에, CPU에는 여러 연산(더하기, 빼기 등)을 처리할 수 있는 연산기가 있고, 내부에는 32bit 레지스터(register)가 여러 개 있다. 레지스터는 CPU가 사용하는 조그마한 저장 공간이다. CPU는 레지스터 저장공간을 사용해서 연산을 한다. 예컨대, c = a + b; 라면 CPU는 a, b를 각각 레지스터0, 레지스터1에 올려 놓고 더하기 연산을 해서 레지스터2에 더하기 결과를 넣을 수 있다. 』 int global_sum = 0; int calculate_sum_by_global_variable(void) { for (int i = 0; i < 10; i++) { global_sum += get_some_value(i); } } void calculate_sum_by_local_variable(void) { int local_sum = 0; for (int i = 0; i < 10; i++) { local_sum += get_some_value(i); } global_sum = local_sum; } 위의 C코드 예제를 보면 지연변수를 사용하는 게 더 느릴 거라고 생각할 수도 있다. C코드상으로는,  전역변수 사용 시에는 전역변수에 바로 합계를 계산하는 반면에,  지역변수를 사용 시에는 지역변수를 선언하고 합계를 계산한 후에 전역변수에 대입하기 때문이다. 하지만 CPU가 동작할 때는, 지역변수 사용 시에는 [①레지스터에 할당된 지역변수에 더하기]를 10번 한 후에 [②전역 변수(RAM)에 대입]하는 반면에, 전역변수 사용 시에는 [①전역변수를 RAM에서 읽어서 레지스터에 넣고 ②더하기를 한 후에...

__builtin_expect (likely, unlikey) 설명 / 성능 차이

GCC의 __builtin_expect keyword는 분기문이 어디로 탈지 컴파일러에게 hint를 줘서, 컴파일러가 분기문 최적화를 하도록 한다. __builtin_expect가 뭔지 알아보고 __builtin_expect가 분기문 최적화를 어떻게 하고 성능 차이는 어떻게 되는지 어셈블리 코드로 알아보자. * 확인 방법 : C 코드를 컴파일하여 생성된 RISC-V 어셈블리(assembly) 코드로 확인 포스팅 ▶ 테스트 조건 및 성능 / 메모리 사용량 비교 방법 포스팅 ▶ RISC-V 어셈블리 명령어 설명 __builtin_expect 관련 사항 __builtin_expect __builtin_expect는 GCC 내장함수이다. prototype : long __builtin_expect (long exp, long c); if (exp) foo; 로 변환되며, 컴파일러는 exp == 0 으로 예상해서(foo() 함수를 타지 않을 것이라 예상) 분기문 최적화를 한다. likely, unlikely linux kernel에서는 아래와 같이 define 하여 사용한다. #define likely(x)       __builtin_expect(!!(x), 1) #define unlikely(x)     __builtin_expect(!!(x), 0) [[likely]], [[unlikely]] C++20에서 likely, unlikely attributes가 추가되었으며, syntax는 [[likely]], [[unlikely]]이다. 사용법은 GCC _butin_expect와 조금 다르나 마찬가지로 분기문이 어디로 탈지 컴파일러에게 알려주어, 컴파일러가 분기문 최적화를 한다. 분기 예측 __builtin_expect를 사용하면 컴파일러는 CPU의 정적 분기 예측에 맞춰서 최적화를 한다. 따라서 분기 예측을 간단히 알아보자. 현대 CPU는 성능을 위해 대부분 파이프라인(pipeline)을 사용하...

C언어 volatile keyword

C언어에서 volatile을 변수에 적용하면 컴파일러가 최적화를 하지 않고 항상 해당 메모리에 접근한다. volatile의 사전적인 의미는 "변할 수 있는" 정도의 뜻이며, 변할 수 있는 값이기 때문에 컴파일러가 최적화를 할 수 없다. volatile은 HW 접근 시에 보통 사용한다. 아래의 예제를 보자. * target CPU는 RISC-V이며 컴파일 조건은 아래와 같다. 포스팅 ▶ 테스트 조건 및 성능 / 메모리 사용량 비교 방법 * RISC-V 명령어 설명은 아래를 참조하자. 포스팅 ▶ RISC-V 어셈블리 명령어 설명 <C 코드> #define HW_QUEUE_POP (*(int*)0x20000000) int first_value; int second_value; void get_two_values(void) { first_value = HW_QUEUE_POP; second_value = HW_QUEUE_POP; } <어셈블리 코드> 00000000 <get_two_values>: 0: 200007b7 lui a5,0x20000 4: 0007a783 lw a5,0(a5) # a5 = 20000000번지의 값 8: 10000737 lui a4,0x10000 c: 00f72223 sw a5,4(a4) # 10000004번지(first_value) = a5 10: 10000737 lui a4,0x10000 14: 00f72023 sw a5,0(a4) # 10000000번지(second_vaule) = a5 18: 00008067 ret Disassembly of section .sbss: 10000000 <second_value>: 10000000: 0000 unimp ... 10000...

if vs switch, 성능 / 메모리 사용량 비교

사실 실제 코드에서 if와 switch가 중에 뭐가 더 빠른지를 알기는 매우 어렵다. if와 switch를 사용했을 때, 아래 사항을 고려해야 하기 때문이다. if나 switch를 사용했을 때 CPU 명령어가 각각 어떻게 구성되는지 예를 들어 if나 switch가 5군데로 분기한다면 어디로 분기하는 경우가 많은지 CPU마다 분기 예측 방법 / 성능이 다른데 if나 switch를 사용했을 때 분기 예측 성공 / 실패가 어떻게 되는지 void test_if(int param) { if(param == 0) func1(); else if(param == 1) func2(); else if(param == 2) func3(); else if(param == 3) func4(); else if(param == 4) func5(); } void test_switch(int param) // 위 if 예제에 대응하는 switch의 CPU 명령어 동작을 표현 { (jump table address) + (param * 4) 에 있는 주소에 있는 값으로 jump } ex. jump table CPU 명령어 주소 CPU 명령어 10000000: 00000028 (28번지에 func1 호출 CPU 명령어가 있음) 10000004: 0000002c (2c번지에 func2 호출 CPU 명령어가 있음) 10000008: 00000030 (30번지에 func3 호출 CPU 명령어가 있음) 1000000c: 00000020 (20번지에 func4 호출 CPU 명령어가 있음) 10000010: 00000024 (24번지에 func5 호출 CPU 명령어가 있음) 먼저 if와 switch의 동작이 어떻게 다른지 보자. if의 CPU 동작은 c언어 표현과 같다. 하지만 switch는 jump table을 만들어서 해당 table의 주소로 jump 하는 방법을...

분기 예측(branch prediction) - BHT, BTB, RAS

Image
분기 예측(branch prediction)이 왜 필요하고 무엇인지 알아보고, RISC-V CPU인 SiFive E31이 분기 예측 방법인 BHT, BTB, RAS를 어떻게 사용하는지 알아보자. 이 포스팅에 어셈블리로 설명하는 부분이 있는데, 어셈블리 명령어가 궁금하면 아래 포스팅을 참고하자. 포스팅 ▶ RISC-V 어셈블리 명령어 설명 파이프라인 관련 내용을 알고 싶다면 아래 링크를 참고하자. Link ▶ 효율적인 명령어 처리, 명령어 파이프라인(instruction Pipeline) Link ▶ 파이프라인 해저드(Pipeline Hazard) Link ▶ 파이프라인 분기예측과 예측실행 제어 해저드(control hazard) 현대 CPU는 성능을 위해 대부분 파이프라인(pipeline)을 사용한다. 파이프라인을 사용하면 구조적 해저드(structural hazard), 데이터 해저드(data hazard), 제어 해저드(control hazard)가 발생하는데, 분기 예측은 제어 해저드를 줄이기 위한 방법이다. 제어 해저드가 뭔지 알아보자. 아래 예제의 파이프라인 단계별 동작은 아래와 같다. IF(instruction fetch) : 명령어를 fetch 한다. ID(instruction decode) : fetch한 명령어를 decoding 한다. EX(execute) : decoding한 명령어를 수행한다. MEM : data memory에 접근한다. WB : 수행 결과를 register에 write-back 한다. 해저드가 없는 경우에는 명령어가 차례대로 파이프라인에 들어간다. 반면에 분기 명령어가 있는 경우에는 어느 번지로 분기할지 EX 단계에서 결정되기 때문에 그 다음 명령어가 2 cycle stall 된다. 이렇게 분기문으로 인해 제어해저드가 발생한다. 분기 예측(branch prediction) 분기 예측은 제어 해저드를 줄이기 위한 방법으로써 분기 예측을 해서 예측 실행(speculative execution)을 한다. beq 명령어는 EX 단...

char vs int, 성능 / 메모리 사용량 비교

char와 int의 속도는 같을까? 다를까? char와 int의 성능과 메모리 사용량을 비교해보자. * 성능 / 메모리 사용량 비교 방법 : C 코드를 컴파일하여 생성된 RISC-V 어셈블리(assembly) 코드로 비교 포스팅 ▶ 테스트 조건 및 성능 / 메모리 사용량 비교 방법 포스팅 ▶ RISC-V 어셈블리 명령어 설명 1 대입 코드 비교 각 자료형에 1을 대입하는 코드를 비교하자. <C 코드> char char1; int int1; void assign_one_to_char(void) { char1 = 1; } void assign_one_to_int(void) { int1 = 1; } <symbol table> location                  size     name 10000000 g     O .sbss 00000004 int1 10000004 g     O .sbss 00000001 char1 <어셈블리 코드> 00000000 <assign_one_to_char>: 0: 100007b7 lui a5,0x10000 4: 00100713 li a4,1 8: 00e78223 sb a4,4(a5) # 10000004 <char1> c: 00008067 ret 00000010 <assign_one_to_int>: 10: 100007b7 lui a5,0x10000 14: 00100713 li a4,1 18: 00e7a023 sw a4,0(a5) # 10000000 <int1> 1c: 00008067 ret 성...

C언어 전역변수 / 지역변수 저장(메모리) 위치

Image
C 언어의 전역변수는 메모리(memory)의 정적위치에 저장되고 지역변수는 CPU 레지스터(register)나 스택(stack)에 저장된다. 어셈블리 코드로 각각 어떻게 동작하는지 살펴보자. * 확인 방법 : C 코드를 컴파일하여 생성된 RISC-V 어셈블리(assembly) 코드로 확인 포스팅 ▶ 테스트 조건 및 성능 / 메모리 사용량 비교 방법 포스팅 ▶ RISC-V 어셈블리 명령어 설명 전역변수 전역변수에 1을 대입하는 코드를 살펴보자. <C 코드> int global_int; void global_variable(void) { global_int = 1; } <symbol table> location                  size     name 10000000 g     O .sbss 00000004 global_int global_int 전역변수가 컴파일 시에 정해진 정적 위치인 0x10000000 번지에 위치한다. <어셈블리 코드> 00000000 <global_variable>: 0: 100007b7 lui a5,0x10000 4: 00100713 li a4,1 8: 00e7a023 sw a4,0(a5) # 10000000 <global_int> c: 00008067 ret global_int 전역변수의 메모리 위치인 0x10000000 번지에 1을 store 한다. 지역변수 지역변수가 register에 할당되는 경우 <C 코드> volatile int volatile_int; int analyze_local_variable1(void) { int local_int1 = volatile_int; int local_i...

RISC-V 어셈블리 명령어 설명

어셈블리 명령어 동작을 C 언어 비슷한 문법 등으로 설명한다. RISC-V는 base instruction set 외에도 여러 optional instruction set이 있으나, 기본이 되는 RV32I base instruction set만 다룬다. Download Link ▶ RISC-V Spec  : ISA Specification의 Volume 1, Unprivileged Spec에 RV32I instruction set Spec이 포함되어 있다. RISC-V integer register * reference : RISC-V Volume 1, Unprivileged Spec의 "Table 25.1: Assembler mnemonics for RISC-V integer and floating-point registers, and their role in the first standard calling convention."의 integer register 부분만 그대로 copy Register ABI Name Description Saver x0 zero Hard-wired zero — x1 ra Return address Caller x2 sp Stack pointer Callee x3 gp Global pointer — x4 tp Thread pointer — x5 t0 Temporary/alternate link register Caller x6–7 t1–2 Temporaries Caller x8 s0/fp Saved re...