Qemu GPU 方向¶
主要贡献者
- 作者:@caspian
背景介绍 & 专业阶段 见 CPU 方向
引入¶
本实验中 GPGPU 是一个挂载在 PCIe 总线上的外部设备。GPGPU 内部分为 互联前端 (gpgpu.c) 和 SIMT 执行后端 (gpgpu_core.c) 两部分。
要如何理解 gpgpu 设备工作的机制呢?根据在 cpu blog 中提到的方法,拆解分析一个复杂系统通常分为
理解一个复杂系统的方式:
- 第一,理解每个子模块各自的功能;
- 第二,理解子模块之间如何交互;
- 第三,从更大的视角理解系统整体“做什么”
从第一步到第三步是 Bottom-Up 视角,在此我觉得 Top-Down 视角会更合理,也就是先看系统整体上做什么,再看子系统之间是如何交互的。
Top-Down 视角分析¶
整体框架¶
首先需要对系统建立一个整体框架,之后再根据这个框架进行拓展。
GPGPU 与 CPU 的协同的整体框架可以用三点概括
- CPU 配置 GPU,并将数据和程序搬入 GPU 中
- GPU 根据写入的配置执行程序(线程并行)
- CPU 把结果读回来
子系统的功能 & 交互¶
一、CPU 配置 GPU,并将数据和程序搬入 GPU 中¶
CPU 配置 GPU¶
要看懂如何配置 GPU,就要理解 GPU 的设备模型
PCIe 总线
│
└── gpgpu
│
├── BAR0: 控制寄存器 (1MB MMIO)
│ ├── 设备信息 (DEV_ID, VERSION)
│ ├── 全局控制/状态 (GLOBAL_CTRL, GLOBAL_STATUS)
│ ├── 中断 (IRQ_ENABLE, IRQ_STATUS, IRQ_ACK)
│ ├── 内核配置 (KERNEL_ADDR, GRID_DIM, BLOCK_DIM, DISPATCH)
│ ├── DMA 引擎 (DMA_SRC, DMA_DST, DMA_SIZE, DMA_CTRL)
│ ├── SIMT 上下文 (THREAD_ID, BLOCK_ID, WARP_ID, LANE_ID)
│ ├── 同步 (BARRIER, THREAD_MASK)
│ ├── MSI-X Table (偏移 0xFE000)
│ └── MSI-X PBA (偏移 0xFF000)
│
├── BAR2: VRAM (64MB, 可配置)
│ ├── kernel 代码 (偏移 0x0000)
│ ├── 输入数据
│ └── 输出结果 (偏移 0x1000)
│
└── BAR4: Doorbell (64KB, stub)
所有的内部状态都存储在 GPGPUState
struct GPGPUState {
PCIDevice parent_obj; // PCI 设备基类(继承)
MemoryRegion ctrl_mmio; // BAR0 对应的 MemoryRegion
MemoryRegion vram; // BAR2 对应的 MemoryRegion
MemoryRegion doorbell_mmio; // BAR4 对应的 MemoryRegion
...
GPGPUSIMTContext simt; // SIMT 执行上下文(当前线程身份)
};
这段设备模型的注册在 gpgpu_realize 中完成:
static void gpgpu_realize(PCIDevice *pdev, Error **errp)
{
GPGPUState *s = GPGPU(pdev);
/* 分配 VRAM 后端存储 */
s->vram_ptr = g_malloc0(s->vram_size); // ← DRAM 物理存储
/* 注册三块 BAR 空间 */
memory_region_init_io(&s->ctrl_mmio, ...); // ← BAR0: 1MB MMIO
pci_register_bar(pdev, 0, ..., &s->ctrl_mmio);
memory_region_init_io(&s->vram, ...); // ← BAR2: VRAM
pci_register_bar(pdev, 2, ..., &s->vram);
memory_region_init_io(&s->doorbell_mmio, ...); // ← BAR4: Doorbell
pci_register_bar(pdev, 4, ..., &s->doorbell_mmio);
/* 注册 MSI-X 中断能力 */
msix_init(pdev, 4, &s->ctrl_mmio, 0, 0xFE000,
&s->ctrl_mmio, 0, 0xFF000, ...);
msi_init(pdev, 0, 1, true, false, ...);
s->global_status = GPGPU_STATUS_READY; // ← 初始状态
}
数据和程序搬入 GPU 中¶
1. 使能设备
qpci_io_writel(bar0, GPGPU_REG_GLOBAL_CTRL, ENABLE)
→ gpgpu_ctrl_write(addr=0x0100, val=ENABLE)
→ s->global_ctrl = ENABLE
2. 上传 kernel 代码到 VRAM
qpci_io_writel(bar2, offset=0x0000, inst[0]) // csrrs
qpci_io_writel(bar2, offset=0x0004, inst[1]) // andi
...
→ gpgpu_vram_write(s, addr, val)
→ memcpy(s->vram_ptr + addr, &val, 4)
3. 初始化输出区域
qpci_io_writel(bar2, offset=0x1000, 0xDEADBEEF)
→ gpgpu_vram_write(...)
4. 配置内核参数
qpci_io_writel(bar0, KERNEL_ADDR_LO, 0x0000) // kernel 在 VRAM 偏移
qpci_io_writel(bar0, GRID_DIM_X, 1) // 1 block in X
qpci_io_writel(bar0, BLOCK_DIM_X, 8) // 8 threads per block
...
→ gpgpu_ctrl_write 将值写入 s->kernel.*
这些 qpci_io_writel 走的是 QEMU qtest 协议,QEMU 端的处理路径:
qtest 协议 ("writel addr val")
→ address_space_write(first_cpu->as, addr, ...) // ← 走 guest CPU 地址空间
→ flatview_write(fv, addr, ...)
→ pci_register_bar → 找到目标设备
→ memory_region_dispatch_write(MemoryRegion, offset, ...)
→ gpgpu_ctrl_write(s, offset, val) // ← 最终调用设备回调
→ s->xxx = val // ← 更新设备状态
二、GPU 根据写入的配置执行程序(线程并行)¶
这一步是 GPU 的核心。CPU 写 DISPATCH 寄存器(偏移 0x0330)触发:
case GPGPU_REG_DISPATCH:
s->global_status = GPGPU_STATUS_BUSY; // 设 BUSY
gpgpu_core_exec_kernel(s); // 执行 kernel
s->global_status = GPGPU_STATUS_READY; // 恢复 READY
if (s->irq_enable & GPGPU_IRQ_KERNEL_DONE) {
s->irq_status |= GPGPU_IRQ_KERNEL_DONE;
msix_notify(PCI_DEVICE(s), VEC_KERNEL); // 发中断
}
break;
SIMT 编程模型¶
GPU 采用 SIMT(Single Instruction, Multiple Threads)编程模型。与 CPU 的标量执行不同,GPU 把计算组织为三级层次:
Grid (三维网格)
│ grid_dim = (X, Y, Z) ← X×Y×Z 个 Block
│
├── Block[0,0,0]
│ │ block_dim = (X, Y, Z) ← X×Y×Z 个线程
│ │
│ ├── Warp 0: 线程 0..31 (锁步执行)
│ │ ├── Lane 0 (线程 0)
│ │ ├── Lane 1 (线程 1)
│ │ └── ...
│ │
│ ├── Warp 1: 线程 32..63
│ └── ...
│
├── Block[0,1,0]
└── ...
每个 Lane 拿到唯一的 mhartid——CPU 和 GPU 的核⼼区别就在这:
// 一个 CPU 程序:同一个代码段在多个 CPU 核上跑同样的数据
for (int i = 0; i < 8; i++) {
C[i] = i; // i 由 外 部循环控制
}
// 一个 GPU kernel:同一个代码段在数千个线程上跑,每个线程的 ID 就是自己的索引
// thread 0: mhartid=0 → C[0] = 0
// thread 1: mhartid=1 → C[1] = 1
// ...
// thread 7: mhartid=7 → C[7] = 7
mhartid 的编码:
mhartid = (block_id_linear << 13) | (warp_id << 5) | lane_id
例子 Grid(1,1,1) × Block(8,1,1):
Block(0,0,0): block_id_linear=0
Warp 0 (warp_id=0):
Lane 0: mhartid = 0 → csrrs → t1=0 → C[0]=0
Lane 1: mhartid = 1 → csrrs → t1=1 → C[1]=1
...
Lane 7: mhartid = 7 → csrrs → t1=7 → C[7]=7
Core 执行¶
SIMT 后端(gpgpu_core.c)实现了从 Grid→Block→Warp→Lane 的三级展开和每一条 RV32I/RV32F 指令的解释执行。
入口是 gpgpu_core_exec_kernel:
int gpgpu_core_exec_kernel(GPGPUState *s)
{
for (int bx = 0; bx < s->kernel.grid_dim[0]; bx++) // ← 第一层:Grid
for (int by = 0; by < s->kernel.grid_dim[1]; by++)
for (int bz = 0; bz < s->kernel.grid_dim[2]; bz++) {
uint32_t block_id[3] = {bx, by, bz};
for (int w = 0; w < num_warps; w++) { // ← 第二层:Block→Warp
int num_threads = MIN(32, threads_per_block - w*32);
gpgpu_core_init_warp // ← 初始化 Warp
gpgpu_core_exec_warp // ← 执行 Warp
}
}
return 0;
}
gpgpu_core_init_warp 设置每个 Lane 的初始状态:
void gpgpu_core_init_warp(GPGPUWarp *warp, uint32_t pc,
uint32_t thread_id_base, const uint32_t block_id[3],
uint32_t num_threads,
uint32_t warp_id, uint32_t block_id_linear)
{
memset(warp, 0, sizeof(*warp));
warp->warp_id = warp_id;
warp->block_id[0] = block_id[0]; ...
for (int i = 0; i < 32; i++) {
warp->lanes[i].active = (i < num_threads); // mask 超出的线程
warp->lanes[i].pc = pc; // 所有线程从同一处开始
warp->lanes[i].mhartid = MHARTID_ENCODE(
block_id_linear, warp_id, i); // 唯一 ID
}
warp->active_mask = (1u << num_threads) - 1;
}
gpgpu_core_exec_warp 是核心执行循环——Warp 内 32 个 Lane 锁步执行:
Warp 执行流
┌──────────────────────────────────────────────┐
│ │
│ for each cycle: │
│ │
│ 取指: inst = vram_readl(s, lanes[0].pc) │
│ ↑ 从 VRAM 的 pc 偏移取指令 │
│ │
│ 译码: opcode = inst & 0x7F │
│ funct3 = (inst >> 12) & 0x7 │
│ rs1 = (inst >> 15) & 0x1F │
│ ... │
│ │
│ 执行 (for i in 0..31 if active): │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Lane 0 │ │ Lane 1 │ │
│ │ gpr[32] │ │ gpr[32] │ │
│ │ fpr[32] │ │ fpr[32] │ │
│ │ mhartid = 0 │ │ mhartid = 1 │ │
│ │ pc = 0x0018 │ │ pc = 0x0018 │ │
│ │ active = 1 │ │ active = 1 │ │
│ └────────┬────────┘ └────────┬────────┘ │
│ │ │ │
│ ┌────────▼───────────────────▼────────┐ │
│ │ 同一条指令 (csrrs t0,mhartid,x0) │ │
│ │ │ │
│ │ Lane 0: csrrs → mhartid=0 → t0=0 │ │
│ │ Lane 1: csrrs → mhartid=1 → t0=1 │ │
│ │ Lane 7: csrrs → mhartid=7 → t0=7 │ │
│ └─────────────────────────────────────┘ │
│ │
│ 访存: for lane in active: │
│ sw rs2, offset(rs1) │
│ → vram_writel(s, rs1+offset, rs2) │
│ → 每个 lane 写到 VRAM 的不同位置 │
│ │
│ PC += 4 │
│ until ebreak │
└──────────────────────────────────────────────┘
每条 Lane 的 gpr[0] 在每周期结束时被清零——确保 RISC-V 的 x0 = 0 规约:
浮点格式转换¶
在实验 9-10 要进行浮点格式转换,需要理解浮点数的表示方法。
同样可以通过 TD 的方式来拆解。从熟悉的科学计数法开始,再逐步深入
根据科学计数法,任意实数都可以写成:
底数换成 2 同样成立
所以科学计数法的核心就是有效数字,底数,指数
在计算机编程中底数默认是 2, 不表示,将有效数字的绝对值和符号位拆开,也就得到了三要素:
符号 (Sign),指数 (Exponent), 有效数字 (Mantissa)。
在 ieee754 中单精度浮点的 bit 表示为
单精度浮点 = 1 sign bit | 8 exponent bits | 23 mantissa bits
S EEEEEEEE MMMMMMMMMMMMMMMMMMMMMMM
bit31 bits30..23 bits22..0
| 部分 | 含义 | float32 |
| (-1)^S | 符号位 | 1 bit |
| E - bias | 无偏指数,bias=127 | 8 bit |
| 1.M | 隐含的前导 1 + 小数位 | 23 bit mantissa |
E 的分类:
一个 8-bit E 有 256 个可能值 (0~255),被分成三段:
E = 0 │ E = 1 ~ 254 │ E = 255
────────────┼────────────────────┼────────────
零 & 次正规 │ 正规数 (含隐含1) │ Inf & NaN
为什么要引入 bias?
用补码的话,负数指数和正数指数的二进制大小比较是反直觉的。加上 bias=127 后:
指数按 unsigned integer 比较就能得到正确的大小顺序——这是硬件加速比较的关键设计。
为什么隐含 1?
因为规范化二进制数的第一位永远是 1:
与其浪费 bit 去存已知的值,不如省下来多存一位尾数。23 位尾数 + 隐含 1 = 24 位有效精度。
为什么需要次正规数?
把 E=0 预留出来是为了填平 0 到最小正规数之间的空缺:
打开次正规(空隙被均匀填满):
0 ──────────────────────┐
── 0.000...001 × 2⁻¹²⁶ (最小次正规 ≈ 2⁻¹⁴⁹)
── 0.000...01 × 2⁻¹²⁶
── 0.000...1 × 2⁻¹²⁶
── ...
── 0.1 × 2⁻¹²⁶
1.0 × 2⁻¹²⁶ ───────────┘
没有次正规数,任何比 2⁻¹²⁶ 小的值直接变成 0。这在数值计算中会导致:
- 两个非常接近的数相减,结果不是精确的微小差值,而是 0
- 累计误差骤增,甚至除零异常
次正规数让下溢到 0 的过程变成了渐变而不是突变。
e4m3, e5m2 也就是在指数和有效数字的位数上以及一些特殊值的表示上有区别,理解这个心智模式之后,编码就水到渠成。