计算机组成与设计
参考资料
- 计算机组成与设计: 硬件 / 软件接口 (RISC-V)
计算机抽象及相关技术
- 计算机体系结构中的 7 个伟大思想
- 使用抽象简化设计
- 加速经常性事件
- 通过并行提高性能
- 通过流水线提高性能
- 通过预测提高性能
- 存储层次
- 通过冗余提高可靠性
- 对性能建模
- 用户 CPU 时间 (CPU 性能), 用户程序运行所需的时间
- 系统 CPU 时间 (系统性能), 操作系统运行用户程序的相关程序所需的时间
- CPI, 指令平均时钟周期数
- CPU 性能 = (指令数 * CPI) / 时钟频率
- 功耗墙
- 动态功耗: 与 CMOS 开关活动相关的功耗
- 静态功耗: 与晶体管泄漏电流相关的功耗
- 功耗 = 0.5 * 电容 * 电压 * 电压 * 频率 + 静态功耗
- 增加性能就是增加能效
- 低利用率的计算机不一定具有更低功耗
指令: 计算机的语言
- 字是 \(32\) 位 (\(4\) 字节)
- 双字是 \(64\) 位 (\(8\) 字节)
- 大多有 \(32\) 个寄存器 (
x0-x31)- \(2^30\) 字节内存寻址空间
- 由于常数 \(0\) 经常用 (比如求相反数),
x0硬连线为 \(0\) - 针对有无符号的整数有不同的指令, 原因是补码的拓展方式不同
// 算术运算
add x3, x1, x2 // x3 = x1 + x2
sub x3, x1, x2 // x3 = x1 - x2
// 常数会在程序加载时放入内存
lw x1, constant_address // x1 = Memory[constant_address]
add x2, x2, x1 // x2 = x2 + constant
addi x1, x2, 100 // x1 = x2 + 100 立即数
// 因为是补码, 所以可以实现减法
// 数据传输
lw x1, 0(x2) // x1 = Memory[x2 + 0] 取字
lwu x1, 0(x2) // x1 = ZeroExtend(Memory[x2 + 0]) 取无符号字
sw x1, 0(x2) // Memory[x2 + 0] = x1 存字
// 半字
lh x1, 0(x2) // x1 = SignExtend(Memory[x2 + 0]) 取半字
lhu x1, 0(x2) // x1 = ZeroExtend(Memory[x2 + 0]) 取无符号半字
sh x1, 0(x2) // Memory[x2 + 0] = LowHalf(x1) 存半字
// 字节
lb x1, 0(x2) // x1 = SignExtend(Memory[x2 + 0]) 取字节
lbu x1, 0(x2) // x1 = ZeroExtend(Memory[x2 + 0]) 取无符号字节
sb x1, 0(x2) // Memory[x2 + 0] = LowByte(x1) 存字节
// CAS
lr.d x2, (x1) // x2 = Memory[x1] 加载并监控是否被修改
sc.d x3, x2, (x1) // if (没被修改) Memory[x1] = x2, x3 = 0; else x3 = 非零数
// 取立即数高位
lui x1, 0x12345 // x1 = 0x12345000 把高 20 位装入寄存器, 低 12 位清零, 原因是一条指令只能装下 20 位立即数
// 逻辑运算
and x3, x1, x2 // x3 = x1 & x2
or x3, x1, x2 // x3 = x1 | x2
xor x3, x1, x2 // x3 = x1 ^ x2
andi x3, x1, 0xFF // x3 = x1 & 0xFF 立即数
ori x3, x1, 0xFF // x3 = x1 | 0xFF 立即数
xori x3, x1, 0xFF // x3 = x1 ^ 0xFF 立即数
// 移位操作
sll x3, x1, x2 // x3 = x1 << (x2 & 0x1F) 逻辑左移
srl x3, x1, x2 // x3 = x1 >> (x2 & 0x1F) 逻辑右移
sra x3, x1, x2 // x3 = x1 >> (x2 & 0x1F) 算术右移
slli x3, x1, 4 // x3 = x1 << 4 逻辑左移 立即数
srli x3, x1, 4 // x3 = x1 >> 4 逻辑右移 立即数
srai x3, x1, 4 // x3 = x1 >> 4 算术右移 立即数
// 条件分支
beq x1, x2, 100 // if (x1 == x2) goto PC + 100 * 2
bne x1, x2, 100 // if (x1 != x2) goto PC + 100 * 2
blt x1, x2, 100 // if (x1 < x2) goto PC + 100 * 2
bge x1, x2, 100 // if (x1 >= x2) goto PC + 100 * 2
bltu x1, x2, 100 // if (x1 < x2) goto PC + 100 * 2 (无符号)
bgeu x1, x2, 100 // if (x1 >= x2) goto PC + 100 * 2 (无符号)
// 无条件跳转
jal x2, 100 // x2 = PC + 4; goto PC + 100 * 2 过程调用
jalr x1, 0(x2) // x1 = PC + 4; goto x2 + 0 * 2 过程返回
指令格式
- RISC-V 指令都是 \(32\) 位长, 分多类型
- RISC-V 保证了所有立即数的符号位都在第 \(31\) 位
- R 型指令 (寄存器 - 寄存器运算)
- \(7\) 位操作码
- \(5\) 位目的操作数寄存器
- \(3\) 位操作码
- \(5\) 位源操作数寄存器
- \(5\) 位源操作数寄存器
- \(7\) 位操作码
- I 型指令 (立即数运算, 载入, 条件分支)
- \(7\) 位操作码
- \(5\) 位目的操作数寄存器
- \(3\) 位操作码
- \(5\) 位源操作数寄存器
- \(12\) 位立即数
- S 型指令 (存储, 立即数表示偏移量)
- \(7\) 位操作码
- \(5\) 位, 低位立即数
- \(3\) 位操作码
- \(5\) 位源操作数寄存器
- \(5\) 位源操作数寄存器
- \(7\) 位, 高位立即数
- B 型指令 (条件分支, 长的跟 S 一样, 立即数乘二表示偏移量)
- U 型指令 (大立即数)
- \(7\) 位操作码
- \(5\) 位目的操作数寄存器
- \(20\) 位立即数
- J 型指令 (无条件跳转, 长的跟 U 一样, 立即数乘二表示偏移量)
- 移位指令通常使用 I 型格式, 但只使用立即数字段的低位
- RISC-V 通过与全 \(1\) 的数进行异或来实现按位取反
- 利用无符号比较指令
bgeu可以用一条指令同时检查 \(x < 0\) 和 \(x \ge y\) (要么是真的越界了, 要么它是一个负数)
过程
- 六个步骤
- 传递参数
- 转交控制
- 获取过程所需的存储资源
- 执行所需的任务
- 将结果值放在调用程序可以访问到的位置
- 将控制返回到调用点
x10-x17用于传递参数和返回值x1用于存放返回地址- 调用
jal x1, target保存返回地址到x1并且跳转 - 返回时使用
jalr x0, 0(x1)跳转回x1中的地址并且不保存返回地址
- 调用
x5-x7,x28-x31用作临时x8-x9,x18-x27用作被调用者保存x8也是帧指针, 指向当前栈帧的起始位置- 服务于调试 / 溢出检测 / 动态分配
x2用作栈指针x3用作全局指针- 指向 data 段中间, 用于快速访问高频数据
- 编译器会进行相应优化
x4用作线程指针- 指向每个线程的本地存储区
- 用于多线程程序
程序的生命周期
- 编译 -> 链接 -> 加载 -> 执行
- 加载器
- 读取可执行文件首部以确定正文段和数据段的大小
- 为正文和数据创建足够大的地址空间
- 将可执行文件中的指令和数据复制到内存中
- 将主程序的参数复制到栈
- 初始化处理器寄存器并将栈指针指向第一个空闲位置
- 跳转到启动例程, 将参数复制到参数寄存器中并调用程序的主例程
- 当主例程返回时, 启动例程通过
exit系统调用终止程序
- 动态链接优化为延迟过程链接
- 只有在第一次调用时才链接过程
- 后续调用直接跳转到已链接的过程 (直接改写全局偏移表 GOT)
增补
auipc x1, 0x12345 // x1 = PC + 0x12345000 把高 20 位装入寄存器, 低 12 位清零, 原因是一条指令只能装下 20 位立即数
slt x3, x1, x2 // if (x1 < x2) x3 = 1 else x3 = 0 有符号比较
sltu x3, x1, x2 // if (x1 < x2) x3 = 1 else x3 = 0 无符号比较
slti x3, x1, 100 // if (x1 < 100) x3 = 1 else x3 = 0 有符号比较 立即数
sltiu x3, x1, 100 // if (x1 < 100) x3 = 1 else x3 = 0 无符号比较 立即数
- RISC-V 支持拓展
- A - 原子指令拓展, 本质上就之前说的那两个指令
- M - 整数乘除法拓展
- F - 单精度浮点数拓展
- D - 双精度浮点数拓展
- C - 压缩指令拓展, 16 位指令
计算机的算术运算
- 加法的速度取决于向高位进位的计算速度
- 实现乘法
- 模拟竖式乘法
- 流水线竖式
- 分治乘法 + 流水线
- 实现除法
- 模拟竖式除法 (计算机猜不出来每次能除多少, 只能一点点减)
- 流水线竖式 + 共用硬件寄存器空间
- SRT 除法技术试图根据被除数和余数的高位来查找表, 以猜测每步的多个商的位数
- RISC-V 除法指令忽略溢出并不检查除零错误, 由软件处理这些情况
- 浮点数加法
- 比较指数, 对齐
- 尾数相加
- 规格化结果
- 异常 / 中断
- 舍入结果
- 如果舍入后不规格化, 则重复规格化和舍入步骤
- 浮点数乘法
- 指数相加并减去偏置
- 尾数相乘
- 规格化结果
- 异常 / 中断
- 舍入结果
- 如果舍入后不规格化, 则重复规格化和舍入步骤
- 设置符号
- IEEE 754 在中间计算时, 总是在右边保留额外的位 (保护位, 舍入位) 以提高精度
- 在一个宽字内部进行的并行操作称为子字并行 (数据级并行)
- SIMD 指令集
- 适用于整型数据类型的并行执行策略不一定适用于浮点数据类型
处理器
- 有效表示信号为逻辑高, 无效表示信号为逻辑低
- 如果状态单元在每个有效时钟边沿都进行写入, 则可忽略写控制信号
- 写控制信号可以解决
- 输入一个周期准备不好
- 条件写入
- 留住当前状态
- 降低功耗
- 数据存储单元需要一个读信号
- 因为存在无效地址
- 边沿触发的时钟同步方法
- 支持状态单元在同一个时钟周期内读和写
- 在一个时钟周期内不可能出现反馈
单周期处理器
- 数据链路
- 取指
- 译码, 包括立即数生成单元的工作 (符号拓展)
- 执行, 同时输出零标识
- 访存
- 写回
- 多路选择器
- ALU 输入源的第二个操作数可能是寄存器或立即数
- 写回寄存器的数据可能来自 ALU 结果或数据存储器
- 下一条指令地址可能是 PC+4 或分支目标地址
- 主控制单元: 根据指令的 Opcode 生成所有控制信号
- RegWrite: 是否写寄存器堆
- ALUSrc: ALU 第二个输入源 (\(0=Reg\), \(1=Imm\))
- MemRead: 读数据存储器
- MemWrite: 写数据存储器
- MemtoReg (仅 RegWrite为 \(1\) 是有效): 写回寄存器的数据源 (\(0=ALU\), \(1=Mem\))
- Branch: 是否是分支指令
- ALUOp: 传递给 ALU 控制单元的意图
- ALU 控制单元: 根据 ALUOp 和 funct 字段生成实际的 ALU 控制信号
- ALU 控制信号决定 ALU 执行的具体操作
00: 加法 (用于算偏移量)01: 减法 (用于比较)10: 由 funct 字段决定具体操作 (R 型指令)
funct3 & funct7决定具体操作- 输出 4 位 ALU 控制信号, 驱动 ALU 硬件
- ALU 控制信号决定 ALU 执行的具体操作
- 多周期实现允许每个指令多次使用同一个功能单元, 有助于减少所需的硬件数量
流水线处理器
- RISC-V 指令集为何适合流水线
- 指令长度固定, 在分析指令之前就能取下一条指令
- 指令格式规整, 在分析指令的同时就能读寄存器
- 无内存运算指令, EX 阶段只做计算, MEM 阶段只做访存
- 流水线寄存器数据链路
- PC: 存放取指地址
- IF: 取指
- IF/ID: 指令
- ID: 译码 & 读寄存器
- ID/EX: 寄存器值, 立即数, 控制信号 (目标寄存器号等)
- EX: 执行计算
- EX/MEM: 执行和访存之间的寄存器
- MEM: 访问内存
- MEM/WB: 访存和写回之间的寄存器
- WB: 写回寄存器
- 这些寄存器必须足够大, 能存下所有需要传递到后续阶段的信息 (指令, 数据值, 控制信号等)
- 控制信号还是在 ID 阶段生成, 但不能立刻发送出去
- 按生效阶段分组 (EX, MEM, WB)
- EX:
ALUSrc,ALUOp - MEM:
MemRead,MemWrite,Branch - WB:
RegWrite,MemtoReg
- EX:
- 把控制信号也放进流水线寄存器里, 随数据一起传递
- 按生效阶段分组 (EX, MEM, WB)
流水线冒险
- 结构冒险
- 多条指令想同时用同一个硬件
- 比如取指和访存冲突
- 采用哈佛架构思想, 指令 Cache 和数据 Cache 分开设计
- 数据冒险
- 同 CSAPP
- ALU 的输入端加一个巨大的多路选择器
- 前递单元负责监控寄存器号, 决定多路选择器选谁
- 控制冒险
- 同 CSAPP
- MIPS 常用延迟分支, 编译器负责往分支指令后面的一条指令填一条有用的指令
- 优化一: 缩短分支延迟
- 在 ID 阶段增加额外的比较器与前递单元
- 这样就能在 ID 阶段判断分支是否成立
- 在 ID 阶段计算目标地址
- 优化二: 分支预测
- 基础方案同 CSAPP
- 但是 IF 阶段还不知道是不是分支指令, 只能猜不跳转 / 到 ID 阶段再猜测
- 进阶方案: 动态分支预测
- 选上次的该位置的跳转结果作为本次预测 (以指令地址为索引的小表), 这样就可以在 IF 阶段预测
- 优化为两位预测器, 连续错两次才改变预测结果
例外
- 例外指意外的控制流变化 (通常同步不可屏蔽)
- 若由处理器外部事件引起, 则称为中断 (通常异步可屏蔽)
- 如系统重启, 操作系统调用, IO设备请求, 非法指令
- 其中只有 IO 设备请求是中断
- 当例外发生时
- 在系统例外程序计数器中保存发生例外的指令的地址
- 在系统例外原因寄存器中保存例外的原因 (其它体系结构常用向量式中断, 即根据不同的例外类型决定向量表上偏移量)
- 将控制权转交给操作系统
- 操作系统自由处理例外
- 使用系统例外程序计数器, 返回到被中断的程序 / 终止程序
- 在系统例外程序计数器中保存发生例外的指令的地址
- 流水线中的例外处理
- 类似于分支预测错误
- 例外会改变处理器状态
- 可能还需要跳回来重新执行那条出事的指令
- 将其后续的指令清除
- 将 RegWrite 信号置 0, 防止写回
- 保存现场信息
- 修改 PC, 跳转到例外处理程序
- 类似于分支预测错误
- RISC-V 保证精确例外
- 如果指令引发例外, 那么其之前的所有指令都必须完全执行
- 之后的所有指令都必须完全被丢弃
- SEPC 必须精准指向引发例外的指令
- 同时规定多例外同时发生时, 只处理最早发生的那个例外
指令级并行
- 流水线本就是一种指令级并行
- 另一种是多发射, 在每个时钟周期内发射多条指令
- 如果指令发射与否的判断是在编译时完成的, 则称为静态多发射
- 如果指令发射与否的判断是在运行时由硬件完成的, 则称为动态多发射
- 多发射流水线的主要任务
- 将指令打包并放入发射槽
- 处理数据和控制冒险
- 将分支预测的思想推广到其他指令, 称为推测
- 预测结果正确性的检查机制
- 预测出错后的恢复机制
- 消除推测式执行带来的副作用的机制
静态多发射
- 编译器负责打包指令与减少或消除所有的冒险
- 有的体系结构使用超长指令字 (VLIW)
- 每条指令包含多条并行执行的操作
- 硬件只负责执行, 不负责冒险检测
- 需要非常复杂的编译器
- 双发射处理器
- 嵌入式处理器常用
- 拓展数据通路以支持每个周期发射两条指令
- 限制: 每对指令中第一条必须是 ALU/Branch 指令, 第二条必须是 Load/Store 指令 (否则填
nop)
- 编译器会用循环展开 + 寄存器重命名来增加指令间的独立性配合多发射
- 名字相关 / 反相关指指令之间因为使用了相同的寄存器名而产生的虚假依赖
- 静态多发射的缺点
- 编译器复杂度高
- 不能适应动态变化的运行时行为
动态多发射 (超标量处理器)
- 动态多发射是与静态多发射相配合使用的, 优势是
- 不可预测的停顿
- 分支预测的不确定性
- 二进制兼容性
- 动态调度处理器的流水线
- 取指和发射单元
- 多功能部件与保留站
- 提交单元
- 取指和发射单元
- 严格按程序顺序取指
- 将指令发送到保留站
- 如果数据已经在寄存器里, 直接拿走
- 如果数据还没算出来, 记下我在等谁
- 多功能部件与保留站
- 保留站: 每个功能单元前的小缓冲区
- 当所有操作数都准备好了且功能单元空闲, 指令就开始执行 (乱序)
- 指令执行完后, 结果广播到公共数据总线, 供其他保留站抓取
- 指令结果先放在重排序缓冲里, 等待按序提交
- 重排序缓冲也起到了寄存器重命名的作用, 消除名字相关
- 提交单元
- 按程序顺序把 ROB 里的结果写回寄存器或内存
- 确保发生异常时处理器状态是精确的
- 名字相关
- 指令之间因为使用了相同的寄存器名而产生的虚假依赖
- 通过寄存器重命名消除名字相关 (写入 ROB 临时位置而不是物理寄存器)
- 内存队列
- 所有指令进读 / 写队列, 先计算地址, 再计算依赖
- 读内存指令必须等到依赖的写内存指令完成后才能执行
存储器层次结构
- 同 CSAPP
- Cache 失效时停顿不会全停 (因为有乱序执行)
- 组相联的 LRU 一般使用近似 LRU 算法实现, 以减少硬件复杂度
可靠的存储器层次
- 可靠性: 平均无故障时间 / 年度失效率
- 可用性: 平均无故障时间 / (平均无故障时间 + 平均修复时间)
- 设计一种编码, 其中最近码汉明距离为 \(d\)
- 能够检测到 \(d-1\) 位错误
- 能够纠正 \(\lfloor (d-1)/2 \rfloor\) 位错误
- 所以它在正常编码中增加了奇偶校验位
- 在检测 \(1\) 位错误时非常有效
- 汉明纠错码
- 增加多个校验位, 每个位检查数据位的不同组合
- 能够纠正单个位错误, 检测双位错误
虚拟机
- 隔离, 简化配置, 易于管理, 屏蔽异构性, 增强安全性
- 允许虚拟机直接在硬件上执行的体系结构被冠以可虚拟化的名称
虚拟存储
- 将主存看作是辅助存储的 Cache
- 早期是为了扩展内存容量
- 现在是为了简化内存管理和保护
- 地址映射机制
- 虚拟地址 (虚拟页号 + 页内偏移) -> 物理地址 (物理页号 + 页内偏移)
- 页表存储在主存中, 每个进程有自己的页表
- 虚拟存储是全相联映射的
- TLB 是页表的 Cache
- 在 CPU 内部的内存管理单元实现, 速度非常快
- 对其访问是纯硬件的, 操作系统配置一下页大小 (必须是 \(2\) 的幂次方) 和页表位置即可
- TLB 命中时直接拿到物理地址
- TLB 失效时查页表, 再加载到 TLB
- 页表设计
- 多级页表
- 每一项都是 \(8\) 字节
- 包括物理页号, 占 \(40\) 位
- 有效位, 缺不缺页
- 读写位, 只能限制用户态
- 特权位
- 禁止执行位
- 脏位, 是否被写过, 决定是否需要写回磁盘
- 先于缓存的脏位, 写回磁盘前会先从缓存写回主存
- 缓存位, 告诉 CPU 不用看 Cache 直接读写内存
- 虚拟寻址缓存
- 传统方案是 CPU 发出虚拟地址 -> 查 TLB -> 拿物理地址 -> 查 Cache 太慢
- 所以我们把 Cache 一行的索引部分用虚拟地址, 标签部分用物理地址
- 注意到页内偏移量不变 (因为映射的是页)
- 只要页内偏移量包含了组索引 + 块偏移量, 那么虚拟地址和物理地址的索引位置是一模一样的
- 这限制了 L1 Cache 的最大尺寸 = 页大小 × 相联度
- 然后并行查 TLB 获得物理页号, 比较标签
- 还有一个方案是不比较标签
- 会出现同名异义, 读到之前的进程的数据 (切换进程时要清空 Cache)
- 也会出现异名同义, 两个不同线程共享的物理地址的虚拟地址映射到同一个物理地址 (写一个读另一个会出问题)
- 我们还是想突破最大尺寸限制
- 会出现同名异义的问题, 因为组索引位数变多了
- 委托操作系统解决同名异义问题, 分配虚拟地址时保证组索引位不变
- 或者做大页
存储层次结构的一般框架
- 块放在哪
- 就是组相联变体
- 平衡相连带来的常数时间开销和低失效率
- 如何找到块
- 全相联 + 查找表肯定最好
- 否则平衡相连带来的常数时间开销和低失效率
- 哪个块被替换
- LRU 通常最好, 但相联度高时实现复杂, 可能还不如随机
- 但实现复杂, 近似 LRU 是折中方案
- 写操作
- 写回减少主存写操作次数
Cache 一致性
- 在自己的缓存中写导致多个处理器看到的同一个内存地址的值不一致
- 一致性
- 写后读: 如果我自己写了 X, 然后我自己读 X, 中间没人插手, 那我必须读到我刚才写的那个值
- 别人写后读: 如果 A 写了 X, 过了一段时间后 B 读 X, B 必须读到 A 写的新值
- 写串行化: 大家看到的变化顺序必须是一样的
- 解决方案: 监听协议
- 所有的 Cache 都挂在一根共享的总线
- 广播 + 监听
- A 写 X 前先广播, 让其他 Cache 都把 X 标记为失效
- A 读失效的 X 时广播请求最新值, 最新值所有者给它 / 写回内存
- 优化: 目录协议
- 不广播了, 专门有个目录记录谁有哪个数据的副本
- 要改数据时, 只点对点通知有副本的那几个核
- 适合大型多核系统
- 假共享
- 变量 DataA 和 DataB 在同一个块
- CPU A 修改 DataA, CPU B 修改 DataB 导致螺旋式失效
- 性能剧烈下降, 总线带宽被占满
- 编程时要注意数据对齐, 把频繁修改的共享变量隔开, 让它们处于不同的块
并行处理器: 从客户端到云
- 强比例缩放: 保持问题规模不变测量的加速比 (绝对性能)
- 弱比例缩放: 随着处理器数量增加, 问题规模与处理器的数显成比例增长时所测批的加速比 (拓展能力)
并行硬件
- 分类
- SISD: 单指令单数据流 不是并行
- SIMD: 单指令多数据流 数据级并行
- MISD: 多指令单数据流 不存在 (某种意义上流水线就是)
- MIMD: 多指令多数据流 任务级并行
- SIMD
- 不仅节省了控制开销
- 还能降低流水线停顿
- 消除循环的控制冒险
- 天然约定了数据独立
- 使用 MIMD 的经典风格是 SPMD (单程序多数据流)
- 所有处理器运行同一个程序, 但在不同的数据集上运行
- 通过条件语句实现不同处理器的不同任务
硬件多线程
- 每个处理器核心同时运行多个线程 (通过增加寄存器堆 / PC 等资源)
- 调度
- 细粒度多线程: 每条指令后切换线程
- 流水线永远不会空转
- 但是减慢单个线程的执行速度
- 粗粒度多线程: 仅在高开销停顿时切换线程
- 切换开销低
- 保护单线程性能
- 但是对短停顿无能为力
- 流水线启动开销 (因为切换时要清空/冻结流水线)
- 同时多线程: 利用多发射和动态调度
- 利用 TLP 补充 ILP 的不足
- 在同一个时钟周期内发射来自多个独立线程的指令
- 利用寄存器重命名和动态调度解决不同线程指令之间的依赖问题
- 细粒度多线程: 每条指令后切换线程
共享内存多处理器
- 所有处理器共享一个统一的物理地址空间
- 硬件必须提供 Cache 一致性机制
- 每个处理器仍可在自己的虚拟地址空间中运行独立的程序
- 统一内存访问
- 例如家用多核 CPU
- 对于任何一个处理器, 访问内存中的任何位置, 延迟 (时间) 都是相同的
- 编程最简单, 但很难扩展到非常多的核心数 (总线或网络会拥堵)
- 非统一内存访问
- 例如服务器多路 CPU
- 访问内存的速度取决于数据在哪里 (直接连接在该处理器同一侧的内存 vs 连接在其他处理器或芯片上的内存)
- 编程复杂, 但可以扩展到更多的处理器核心数
- 为了高性能, 程序员必须尽量让处理器只处理存储在它“附近”的数据, 减少远程访问
- 在共享内存环境中, 多个处理器可能同时操作同一个数据
- 加锁
- OpenMP 能简化共享内存多处理器编程
GPU
- 没 Cache, 你去读内存, 我还有的是线程执行呢
- 重视带宽
- 由许多 SIMD 处理器组成的 MIMD 机器
- 每个 SIMD 处理器
- 有若干个线程, 它们同时执行同一条指令
- 每个 SM 拥有海量的寄存器堆 (可以驻留非常多线程, 隐藏内存延迟)
- 每个线程能同时处理多条数据 (这里才是所谓的 CUDA 线程)
- 存储结构
- 每个 CUDA 线程独有私有寄存器
- SM 内部的局部寄存器由同一个线程块内的线程共享 (需要显式管理)
- 术语翻译对照
- CUDA 线程 = SIMD 通道
- 线程束 = SIMD 线程
- 线程块 = 一组并行执行的线程束 (必须在同一个流多处理器, 经常是在处理一个大向量)
- 流多处理器 = 多线程 SIMD 处理器
- 共享内存 = 局部内存