プログラミングにおけるマシンレベル表現 - 定数、変数、演算

📚 注意事項

  • 本ブログの内容は学習目的にのみ使用してください
  • 考え方を理解した後、独自で実装することをお勧めします
  • ご意見・ご質問をお待ちしています

タスク : 定数、変数、演算 32ビット定数のロード

int f() { return 0x123; /* 291 */ }
int g() { return -1; }
int h() { return 0x1234; /* 4660 */ }
int i() { return 0xbb8; /* 3000 */ }

rv32gcc -O2 -c a.c
rvobjdump -M no-aliases -d a.o

lui  r,(imm[31:12]+imm[11])  # 結果が0の場合、省略可能
addi r,zero,imm[11:0]

luiのimm[11]の理解:大ステップ(lui) + 小ステップ(addi)

________________________________________________
    |  |   |         |      |     |
   -1  |  291      3000     |    4660
       0                   4096

addiの意味に基づき、imm[11:0]は符号拡張が必要です これはlui結果を±2048の範囲内で調整するものとみなされます(境界条件を慎重に検討してください) luiは、immから±2048以内の数値をまずロードする必要があります もしimm[11:0]が負であれば、luiはまず4096で右シフトし、その後addiで左シフトします

64ビット定数のロード(RV64)

long long j() { return 0x1234567800000000; }
long long k() { return 0x1234567887654321; }

rv64gcc -O2 -S a.c

追加のシフト命令slliがあります より複雑な場合、gccは定数を読み込み専用データセグメントに配置し、ldでメモリを読み込むことがあります メモリからのデータアクセスの遅延を取って、より短い命令列に交換します

命令が多いことは良くありません:オーダー実行の高性能CPUでは、命令の取得コストはデータの取得コストより高いです パイプラインにおいて、命令はデータの上流です メモリからデータを取得する場合、データ取得ロジックを待つだけで、他の無関係な命令を先に実行できます メモリから命令を取得する場合、パイプライン全体が待たなければなりません

疑似命令の定義については《RISC-Vアセンブリ言語プログラミングマニュアル》を参照してください

long long j() { return 0x1234567800000000; }
long long k() { return 0x1234567887654321; }

rv32gcc -O2 -S a.c

2つの32ビットレジスタを使用して64ビットデータを格納します

このバージョンでは、clangが生成するコードの方がgccよりも良いです 😂

clang --target=riscv32 -O2 -S a.c

変数のサイズとアライメント

RISC-Vの2つの整数ABI

I = Integer, L = Long, P = Pointer

ILP32 ABI (RV32)

サイズ アライメント
bool/_Bool 1 1
char 1 1
short 2 2
int 4 4
long 4 4
long long 8 8
void * 4 4
float 4 4
double 8 8

LP64 ABI (RV64)

サイズ アライメント
bool/_Bool 1 1
char 1 1
short 2 2
int 4 4
long 8 8
long long 8 8
void * 8 8
float 4 4
double 8 8

変数の割当

{v1,v2,…}→{r1,r2,…,M}

実際の回路において

Rは高速(現在のサイクルで読み出せる)ですが、容量は限られています(RISC-Vでは32個) Mは遅く(何百サイクルかかる)、しかし容量はほぼ無限(現代のメモリチップは8〜32GB) DDRパッケージ内のストレージセルはトランジスタ+コンデンサで構成されています コンデンサは充放電が必要で、遅延はトランジスタより大きいため、通常Mのアクセスは遅いです

Cプログラムの変数はレジスタよりも多く、どうやって割当するのか?

直感的な割当戦略:よく使われる変数をRに、あまり使われない変数をMに割当する。しかしコンパイラはどの変数がより頻繁に使われるかを分析するのは難しい

実際の割当戦略:すべての変数をMに最初に割当て、必要に応じてRに読み込む

プログラムのメモリレイアウト

変数に関連する3つのメモリ領域:静的データ領域(data), ヒープ領域(heap), スタック領域(stack) 静的 = 動的に成長したり変化しない、コンパイル時に決定される

4つのC変数の割当てが必要となるケース グローバル変数 -> data領域 静的ローカル変数 -> data領域 非静的ローカル変数 -> stack領域 動的変数 -> heap領域

  +----------+
  |          |
  +----------+
  |   stack  |
  +----------+
  |    |     |
  |    v     |
  |          |
  |    ^     |
  |    |     |
  +----------+
  |   heap   |
  +----------+
  |   data   |
  +----------+
  |   text   |
  +----------+
  |          |
0 +----------+

#include <stdio.h>
#include <stdlib.h>
int g;
void f(int n) {
  static int sl;
  int l, *h = malloc(4);
  printf("n = %2d, &g = %p, &sl = %p, &l = %p, h = %p\n", n, &g, &sl, &l, h);
  if (n < 5) f(n + 1);
}
int main() { f(1); printf("===\n"); f(1); return 0; }

変数のアクセス

#define def(type, name) \
  volatile type name ## _a; \
  volatile type name ## _b; \
  void f_##name () { name ## _a = name ## _b; }

def(_Bool, _Bool)
def(char, char)
def(signed char, signed_char)
def(short, short)
def(unsigned short, unsigned_short)
def(int, int)
def(unsigned int, unsigned_int)
def(long, long)
def(long long, long_long)
def(void *, void_)
def(float, float)
def(double, double)

rv32gcc -O2 -S a.c
rv64gcc -O2 -S a.c

例外:unsigned intはRV64でlwではなくlwuを使用する 詳細はRISC-V命令セットマニュアルとRISC-V ABIマニュアルを参照してください

変数のアクセス(2)

ILP32 ABI (RV32)

サイズ 命令
bool/_Bool 1 lbu/sb
char 1 lbu/sb
signed char 1 lb/sb
short 2 lh/sh
unsigned short 2 lhu/sh
int 4 lw/sw
unsigned int 4 lw/sw
long 4 lw/sw
long long 8 lw+lw/sw+sw
void * 4 lw/sw
float 4 flw/fsw
double 8 fld/fsd

LP64 ABI (RV64)

サイズ 命令
bool/_Bool 1 lbu/sb
char 1 lbu/sb
signed char 1 lb/sb
short 2 lh/sh
unsigned short 2 lhu/sh
int 4 lw/sw
unsigned int 4 lw/sw
long 8 ld/sd
long long 8 ld/sd
void * 8 ld/sd
float 4 flw/fsw
double 8 fld/fsd

アライメントが正しくない場合、アクセス効率が低下します

アライメントが正しくないとはaddr(n) % align(n) != 0、例えばint変数がアドレス0x13に割当てられている場合

ハードウェアがアライメントされていないアクセスをサポート: 回路が複雑になり、2サイクル以上かかる
ソフトウェアがアライメントされていないアクセスをサポート: 例外を投げ、効率が非常に悪い

x86上でアライメントされていないアクセスの性能を実測

#include <stdlib.h>
#define LOOP 2000000
#define SIZE 10000
char buf[SIZE + 64] __attribute((aligned(64))); // 64 = キャッシュブロックのサイズ(バイト)
int main(int argc, char *argv[]) {
  int offset = atoi(argv[1]);
  for (int n = LOOP; n != 0; n --) {
    for (char *p = buf + offset; p < buf + SIZE; p += 64) { *(long *)p = 1; }
  }
  return 0;
}

gcc -O2 a.c && for i in `seq 1 64`; do TIME="$i: %E" /usr/bin/time ./a.out $i; done

x86では、アライメントされていないデータが64バイト境界を越える場合、約2倍の性能低下が観測されます

演算と命令

C演算子 RISC-V命令
+, -, \*, /, % add, sub, mul, div, rem
= mv, メモリアクセス命令
&, |, ^ and, or, xor
~ xori r, r, -1
<<, >> sll, srl, sra
! sltiu r, r, 1
<, > slt
long f1(long a, long b) { return a + b; }
long f2(long a, long b) { return a - b; }
long f3(long a, long b) { return a * b; }
long f4(long a, long b) { return a / b; }
// ...

-O1でアセンブラファイルにコンパイルすることで、これらの関係を理解できます

コンパイル最適化 - 計算をできるだけレジスタ内で行う

int sum = 0;
void f() {
  int i;
  for (i = 1; i <= 100; i ++) {
    sum += i;
  }
}

-O0 - 毎回計算前にメモリから変数を読み出し、毎回計算後に即座にメモリに書き戻す -O1 -計算開始前にメモリから変数を読み出し、計算過程はレジスタ内で行い、すべての計算終了後にメモリに書き戻す -O2 -コンパイラが直接結果を計算します

RV32で64ビット加算

long long f1(long long a, long long b) { return a + b; }

rv32gcc -O2 -S a.c

(a1, a0) <= (a1, a0) + (a3, a2)

           mv
    a1 a0 ===> a5
    a3 a2      |
               v
   carry <--- sltu
   +           ^
 ---------     |
    a1 a0 -----+

RV64で128ビット加算も同様のプロセス

__int128 f1(__int128 a, __int128 b) { return a + b; }

符号付き数と符号なし数

#include <stdint.h>
 int32_t add1( int32_t a,  int32_t b) { return a + b; }
uint32_t add2(uint32_t a, uint32_t b) { return a + b; }
 int32_t cmp1( int32_t a,  int32_t b) { return a < b; }
 int32_t cmp2(uint32_t a, uint32_t b) { return a < b; }
 int32_t shr1( int32_t a,  int32_t b) { return a >> b; }
uint32_t shr2(uint32_t a,  int32_t b) { return a >> b; }
 int64_t zext1( int32_t a) { return a; }
uint64_t zext2(uint32_t a) { return a; }

add1とadd2のコードは完全に同じです 結論:RISC-Vハードウェアにとっては、符号付き加算と符号なし加算の動作は完全に一致しています 同じ加算器モジュールを使用して計算できます

比較 - slt/sltu 右シフト - sra/srl 64ビットに拡張 - 符号拡張/ゼロ拡張 乗算、除算、剰余にはそれぞれ符号付きと符号なしの命令があります

タグ: RISC-V アセンブリ言語 コンパイラ最適化 メモリアライメント 命令セット

6月4日 22:58 投稿