PS3 的 Cell 架构与 SPU 多线程:从 DMA 到 SPURS 移植
PS3 的 Cell 架构与 SPU 多线程:从 DMA 到 SPURS 移植
这篇是把一个自研引擎项目往 PS3 上做 SPU 多线程移植时整理的笔记,从最底层的 DMA 讲起,一路到 Cell 的处理器架构、SPU 的组成,最后落到 SPURS 运行时怎么把任务派到协处理器上跑。
先搞懂 DMA
要理解 Cell 为什么长成那样,得先从数据怎么在内存和外设之间搬讲起。
最原始:编程 I/O(Programmable I/O)。 只有一个主控——处理器。外设和内存的每一次交互都要过处理器:处理器先把内存数据读进寄存器,再用总线写到外设。问题很明显——每次读写都吃机器周期,数据量一大,效率就塌了。
加入 DMA 控制器。 在内存和外设之间放一个专门搬数据的控制器,内存↔外设的交互交给它,处理器腾出手干别的。代价是系统里现在有两个主控(处理器和 DMA 控制器),两者访问内存和外设都要走系统总线,于是需要协调,避免对同一块地址的读写冲突。
加入仲裁器(Arbitrator)。 两个控制器抢总线,就像多线程抢共享资源,需要调度和同步。调度算法有很多,值得了解的两类是 TDMA(时分多址)和 Round-Robin 轮询仲裁。TDMA 在通信领域用得极广,同族的还有 FDMA(频分多址)、CDMA(码分多址)。
再提效率:总线直连。 上面几种方式里,DMA 控制器和内存、和外设的交互都还占着系统总线。把 DMA 控制器到外设的传输总线单独互联,绕开系统总线,传输效率再上一个台阶。这条「给数据流单独修路」的思路,正是 Cell 内部 EIB 的雏形。
Cell Broadband Engine 总览
PS3 的处理器是 IBM 设计的 64 位 Cell,主频 3.2GHz,架构相当复杂:
- 一个主控处理元件 PPE(PowerPC Processing Element)。
- 八个协处理元件 SPE(Synergistic Processing Element)。其中 6 个开放给开发者编程,一个出厂即禁用(良率冗余),一个专供主机系统(FreeBSD 系 OS)使用。
- 一条内部数据总线 EIB(Element Interconnect Bus)把它们串起来,外加若干对外接口。
这就是典型的异构多核:一个能跑操作系统和调度逻辑的通用核,带一队专攻并行数据的算力核。
EIB:双向环形总线
Cell 内部几个 SPE 之间的数据链路由 EIB 承担。它由 12 个 Ramp 组成双向环形链路,并有一个数据仲裁器(Data Arbiter)管理元件间的调度。仲裁器有个约束:不会把一条数据流安排在超过环上一半路径长度的节点上——绕远路不如反向走近路。单个 Ramp 的吞吐可达 204.8 GB/s。
PPE 内部
PPE 由两部分组成:一个 PPU 单元,和一个缓存子系统 PPSS。
- PPSS 是 PPU 与外部元件连接的桥梁,提供 512KB 的指令/数据二级缓存。
- 指令单元(IU)里有 32KB 的一级缓存,且指令支持多线程分发。
主内存:XDR 与 128 字节对齐
PS3 装了 256MB XDR 内存,主频 400MHz,由 4 个 64MB 的 DRAM 元件组成。处理器对这 4 个元件是成对读写的:PPU 写一笔 64MB 数据时,会把它分发到其中一对元件上。
一个落到代码里的细节:内存接口控制器(MIC)对数据的最小传输粒度是 128 字节。这解释了为什么自研引擎的代码里,很多结构体被声明成 128 字节对齐——大概率就是为了对齐这个传输粒度,提高 SPU 和内存之间的搬运效率。这种「让数据结构去迁就硬件传输单位」的做法,在 SPU 编程里是常态。
SPE 的组成
SPE 是干活的核心,和常见 CPU 一样用 ISA(指令集架构)编程、走精简指令集,但有几点不同:
- 指令是 SPU 专有的,且主要由 SIMD(单指令多数据)指令构成,天生为并行数据服务。
- 指令长度只有 32 位,最多可并行三个操作数的计算。
- MFC(Memory Flow Controller)是 SPU 与外界交互的控制器,内含一个 DMA 控制器和一条协助接口总线:DMA 控制器负责 SPU 与主内存的数据中转,协助接口总线接到 EIB,实现与其他核心的数据交互。
SPU 内部有两条管线:奇管线处理大部分指令;偶管线有两个执行算术和逻辑指令的单元,即 FPU 和 FXU。
PPE 与 SPE 的四种协作模式
同样一批 SPE,组织方式不同,适合的负载也不同:
| 模式 | 组织方式 | 适合 |
|---|---|---|
| PPE 中心 · 串行 | PPE 把任务分阶段串行派给各 SPE,执行完把结果传回 PPE | 有强依赖顺序的流水任务 |
| SPE 并行 | PPE 把一个任务拆成多份并行发给各 SPE,各自算完把结果传回 PPE 组合 | 可切分的大批量同质计算 |
| SPE 服务化 | 每个 SPE 固定负责一类任务(音效、渲染前置计算等),PPE 等结果期间可切去干别的,任务可动态分配 | 职责相对独立、可动态调度的子系统 |
| SPE 中心 | 每个 SPE 独占一个模块/任务集,只跑属于自己的任务,SPE 之间互不干扰,结果直接回传 PPU | 边界清晰、需要隔离的长期模块 |
选哪种,取决于任务之间的依赖关系和数据切分难度。
SPURS 与多线程移植
从 Main 函数开始
游戏的各模块初始化和主循环都从 Main 函数展开。整个 SPU 多线程的初始化,就是在 Main 里由一句类似 SPURS_Start(MAX_SPURS_SPU) 拉起的——包含系统初始化、Task 的创建、线程上下文的创建。
SPURS(Synergistic Processor Unit Runtime System)是协处理器运行时系统,给开发者提供对 SPU 编程的运行时接口。用它跑多线程程序,需要配置一整套相当繁琐的参数。
一个 Task 是怎么派到 SPU 上跑的
以一个「手雷投掷轨迹计算」的函数 grenade_throw_trace_task 为例,它要在 SPU 上跑,就得先在 PPU 端把数据和上下文配好:
// SPU 端要执行的函数(宏声明)
DECLARE_SPURS_TASK( grenade_throw_trace_task );
void grenade_throw_trace_task( SpursTaskContext* taskContext, SpursTask* task );
// ---- PPU 端配置 ----
// 1. 初始化 SPURS 任务
SPURS_TaskInit( &work->spuTask );
// 2. 决定输入/输出缓冲区。SpuHeader 在 PPU 端配置并存放 SPU 要处理的数据,
// 这里把 header 指针和内存区域大小登记进 SpursTask。
// 大小按 16 字节向上取整(迁就 SPU 的传输对齐)。
SPURS_TaskSetBufferInOut(
&work->spuTask,
&work->spuHeader,
sizeof(GRENADE_THROW_TRACE_SPU_HEADER)
+ (16 - sizeof(GRENADE_THROW_TRACE_SPU_HEADER) % 16) );
// 3. 启动 SPU 处理,传入要执行的函数地址
SPURS_TaskStart( &work->spuTask, 0, SPURS_TASK_ADDR( grenade_throw_trace_task ), 0 );
几个关键结构:
- SpursTaskContext:SPU 端执行函数的上下文,就是那个
taskContext参数。 - SPUTaskArgs:存放「被执行的函数 + 函数参数」,由任务集引用。它里面有个无类型指针
eaElf指向要执行的函数;argTask(CellSpursTaskArgument,本质是一组 u32/u64 联合体)用来配置执行上下文;argTaskset是个 64 位整型,把SpursTask*指针转成整型来传输。 - TaskSet:任务集,包含一批要执行的 Task 参数。
typedef union CellSpursTaskArgument {
uint32_t u32[4];
uint64_t u64[2];
} CellSpursTaskArgument;
struct SPUTaskArgs {
void* eaElf; // 指向要执行的函数
CellSpursTaskArgument argTask; // 执行上下文
uint64_t argTaskset; // SpursTask* 转成的整型
};
有了执行函数和它需要的参数,就能把 Task 加进队列执行了。整体流程是:
用线程池替代 SPURS
在往新平台移植时(目标平台只有 3 个核心可用),我们没有直接照搬 SPURS,而是用一个线程池作为替代:池大小设为 3,用一个原子标志 shutdown_taskset 控制关停,用 std::mutex 保护取任务的 id,用一个轻量信号量记待处理任务数:
struct TaskSet {
std::vector<SPUTaskArgs> tasks;
mts_lwsem_t sem_pending_tasks_num; // 待处理任务数
std::mutex get_task_id; // 保护取任务
std::array<std::thread, 3> threads_pool; // 目标平台 3 核
std::atomic<bool> shutdown_taskset;
};
线程池遍历 TaskSet,从中取出 Task(被执行函数 + 参数)并执行。这样把 PS3 上那套「SPURS 派任务给 SPE」的心智模型,平移成了通用平台上「线程池消费任务队列」——接口收敛了,繁琐的 SPURS 参数配置也省掉了。
小结
PS3 编程的核心约束就一句话:算力在一堆各自带本地内存、只能靠 DMA 和主内存/彼此通信的 SPE 上。这逼着你把数据按 128 字节对齐、把任务切成能塞进 SPE 本地内存的块、再用 SPURS 显式地调度它们进出。今天写 GPGPU、写 job system 时那套「拆任务、对齐数据、显式搬运、异步回收结果」的直觉,很多都能在 Cell 这套老架构里找到原型。