1 GPU 编程模型要回答什么
把 GPU 插进服务器,并不会让原来的程序自动并行。设想 1 台 2U 服务器:CPU 负责运行操作系统,128 GB 主存保存程序与待处理数据,NVMe SSD 提供持久存储,100 Gb/s 网卡接收外部数据,1 张带 24 GB 设备内存的 GPU 板卡通过 PCIe 4.0 x16 接入。现在要对 1600 万个浮点数做变换。数据可能已经到达主存,却还没有进入 GPU 可直接高效访问的设备内存。谁申请空间、谁搬运数据、谁发起计算、谁确认结束,都需要程序明确安排。
GPU 编程模型就是程序员与执行系统之间的约定。它说明如何表达大量相似工作,如何标识每份工作处理的数据,哪些存储可被哪些执行者访问,以及不同工作之间何时可以交换结果。硬件规格回答“设备具有什么资源”,编程模型回答“程序怎样正确使用这些资源”。只知道 GPU 有很多计算单元,还不足以写出结果可靠、速度稳定的程序。
这里把 CPU 及其控制程序称为主机端,把 GPU 及其执行环境称为设备端。这样的区分描述职责,不等于机箱内外:它们可以位于同 1 台服务器,也可以集成在同 1 个系统中。带独立设备内存的板卡适合展示显式搬运;共享物理内存的系统可能省去部分复制,但仍然需要管理访问顺序、可见性和资源占用。不能把某种机器上的便利条件当成所有机器都成立的规则。
本课始终围绕 1 条路径:CPU 从主存取出输入,将输入准备给 GPU,提交核函数,等待结果可用,再由 CPU 检查或继续处理。我们先用向量变换理解这条路径,再观察为什么很短的核函数会被提交和同步开销淹没。这里不展开计算单元内部调度、指令执行细节与设备互连协议;入门阶段更重要的是把数据位置、工作划分和完成条件说清楚。

看图时应区分“容量够用”和“路径够快”。24 GB 设备内存能装下某批输入,不代表从 SSD 读取、经过主存再送到设备的时间可以忽略。PCIe 4.0 x16 的单向理论带宽约为 31.5 GB/s,真实复制还受平台连接、缓冲区类型及竞争影响。100 Gb/s 网卡的线速折合约 12.5 GB/s,也不代表应用能持续得到相同的有效吞吐。程序最终受整条路径约束。
2 主机端与设备端的职责边界
主机端负责组织任务。它选择设备、检查可用内存、申请和释放缓冲区,安排输入来源,提交复制与核函数,并处理失败。操作系统文件读取、网络协议处理和大部分业务控制通常仍在 CPU 上。设备端负责执行适合并行的数据计算,例如让大量线程分别读取不同元素并写入对应输出。把控制任务留在 CPU,不意味着 CPU 无关紧要:若输入准备跟不上,GPU 即使空闲也无法产生结果。
以 1600 万个 32 位浮点数为例,每个数组占 64 MB,这里 MB 按 10 的 6 次方字节计。计算 y[i] = a × x[i] + b,需要输入数组与输出数组,共约 128 MB;标量 a、b 的空间可以忽略,但临时区和运行环境占用不能凭空消失。主存中的指针与设备端可使用的地址之间由接口建立关系,不能把任意主机地址直接交给设备访问。即使接口提供统一地址形式,也不表示数据已经位于理想位置。
最容易理解的方式是显式申请设备空间,再把 x 从主存复制过去,最后把 y 复制回来。这样每段时间的责任很清楚:复制之前,主机保证输入完整;设备计算期间,程序不能提前改写仍被使用的输入;结果复制完成之后,主机才可读取输出。异步接口允许主机先返回去做其他工作,却不会取消缓冲区的生命周期要求。调用已经返回与数据已经安全可用,是不同的事件。
某些接口提供自动迁移或共享访问,让程序看起来像在使用普通数组。它们能降低入门成本,但运行时可能在首次访问时搬运页面,或者在主机与设备交替访问时来回迁移。使用者仍应知道“谁在什么时候访问”。在 24 GB 设备内存上请求远大于可驻留容量的数据,即使接口允许继续运行,也可能出现频繁迁移、延迟波动或申请失败;行为取决于平台与接口,不能靠猜测。
服务器形态也会改变边界条件。双路 CPU 服务器上的 GPU、网卡和 NVMe 可能连接到不同的处理器侧,主存分配位置会影响实际路径。小型工作站可能同时用 GPU 显示桌面,计算会与显示任务共享资源。机架服务器还受板卡供电、散热和槽位宽度限制。先记录实际连接与资源共享关系,再讨论软件参数,才能避免把硬件路径造成的波动误认为算法问题。
3 核函数、网格与同步
核函数是提交给设备执行的一段计算入口。程序通常提交 1 次启动,由许多逻辑线程运行同一段代码,每个线程依据自己的索引选择数据。逻辑线程是编程模型提供的工作单位,不表示所有线程在同一时刻都占有独立硬件。设备会把工作分批安排到有限资源上。入门时应先保证每个线程处理什么、是否越界、是否依赖其他线程,再考虑硬件如何细分执行。
常见层次是线程组成线程块,线程块组成网格。假设每块放 256 个线程,处理 1600 万个元素,需要向上取整得到 62500 个块。线程计算 i = 块编号 × 每块线程数 + 块内线程编号,只在 i < N 时处理元素。即使当前 N 恰好能整除 256,也应保留边界判断,让相同程序能处理 1000 或 16000003 个元素。漏掉判断可能让末尾线程写出数组边界,破坏其他数据。
块划分还决定可使用的协作范围。同 1 个块内的线程通常可以通过块内共享存储交换中间结果,并使用块内屏障等待彼此到达。屏障必须遵守接口的参与规则:若部分线程因为分支提前退出,而其余线程等待它们,就可能发生错误或无法继续。不能把块内屏障当作整个网格的屏障,也不能假设块编号较小的块总会先完成。
最直接的全局分阶段方法是拆成 2 次核函数启动。第 1 次产生所有中间结果,第 2 次消费这些结果;通过同一有序执行队列或显式事件依赖保证顺序。不同队列是否有依赖,必须由接口规则与程序安排决定。把任务放进不同队列只是创造了调度机会,既不自动保证正确顺序,也不保证复制和计算一定重叠。
下面使用通用伪代码,函数名称表示动作,不对应某个厂商接口。它故意把等待放在输出复制之后,表示主机要等完整路径结束才能检查结果。
kernel transform(x, y, N, a, b):
i = block_id * block_size + thread_id
if i < N:
y[i] = a * x[i] + b
queue.copy_to_device(device_x, host_x)
queue.launch(transform, blocks=ceil_div(N, 256), threads=256)
queue.copy_to_host(host_y, device_y)
queue.wait()
check(host_y)
这个例子中的各项操作必须位于具有所需顺序保证的队列中,且复制接口必须支持所用主存缓冲区。真正的程序还要把设备地址和参数传入启动,检查各步返回状态,并释放资源。学习重点是建立依赖:输入准备完成之后计算,计算完成之后复制输出,复制完成之后检查。省略任何依赖,都可能让偶尔正确的结果变成难以复现的故障。
4 动手:追踪一次内存拷贝与启动路径
动手追踪时不必先追求复杂算子。使用前面的向量变换,固定 N、数值类型和板卡,令 a = 2、b = 1,输入采用可预测模式,例如 x[i] = i 除以 1024 后的余数。这样能在 CPU 上生成参考结果,也能检查数组末尾。对浮点结果采用合理误差阈值;若把表达式换成更长的运算链,还要考虑不同执行方式可能改变舍入结果。
先建立基线:申请主存与设备空间,填充输入,执行若干预热轮次,再测量稳定轮次。预热用于区分首次上下文建立、代码加载、内存映射等一次性工作与持续运行成本,不保证温度或频率已经完全稳定。记录预热次数、测量次数、输入规模和当前共享负载,不把第一次运行与后续运行混在同 1 个平均值里解释。
至少保留 2 种时间。主机端计时器覆盖“提交输入复制到等待输出完成”,得到端到端延迟;设备端事件在队列上包围核函数,得到相应设备时间区间。设备事件的区间如何定义,要以接口说明为准。主机端仅包住 launch 调用,通常只能测到提交调用花了多久,不能代表 GPU 真正完成计算用了多久。给数值加上明确名称,比单独写“耗时”更有用。

再用时间线观察工具查看主机调用、输入复制、核函数、输出复制与等待的位置。先确认工具显示的是实际执行还是提交记录,再观察各段之间有没有空隙。输入复制前的长空白可能是 CPU 填充数组,核函数之间的空白可能来自主机提交不足,输出复制后的等待则可能只是主机尚未观察到完成。时间线上的空隙是线索,需要结合主机活动判断原因。
进行 2 组对照。第 1 组每轮都复制输入和输出,回答“独立请求多久完成”;第 2 组把数据预先留在设备上,连续执行多次变换,只在最后取回结果,回答“驻留数据上的持续计算能力如何”。第 2 组中必须交替使用输入输出或明确覆盖规则,避免实际计算与想象不符。它们测量的服务边界不同,不能把更小的设备时间直接当作完整应用提速。
最后人为制造 1 个小故障:在测试版本中把块数误写为向下取整,并使用无法整除 256 的 N。末尾元素没有被处理,程序可能不报越界却输出错误。恢复向上取整与边界判断后再次检查。这说明性能追踪必须以正确性为前提;运行结束且没有异常,只能证明某些检查没有发现问题,不能证明所有元素都被正确计算。
5 易用抽象与可控性能取舍
编程模型可以通过不同层次呈现。数组库让开发者写出接近数学表达式的代码;算子库封装矩阵乘法、归约等常见任务;自定义核函数提供线程索引和局部存储的控制。层次越高,通常越容易组合与维护,但实际启动次数、临时数组和数据搬运可能藏在表达式后面。层次越低,需要承担的边界检查、同步和兼容性工作通常越多。
例如 y = a × x + b 看起来只有 1 行,某个实现可能先生成乘法临时数组,再启动加法;另一个实现可能把它融合成 1 个核函数。前者可能多写入并读取临时数组,也多承担启动成本。不能根据代码行数判断硬件做了多少事。应观察实际启动和内存访问,再决定是否值得改变写法。对已经把表达式融合的库,手写核函数也不一定更快。
用总时间表达取舍会更清楚:总时间约等于主机准备、必要搬运、提交与等待、设备执行等成本的组合。有重叠时不能把各段简单相加,应依据时间线上的关键路径计算。在没有重叠的简化情形中,64 MB 输入与64 MB 输出,若各自有效带宽均为 20 GB/s,仅搬运约需 6.4 ms;即使把设备执行从 0.4 ms 降到 0.2 ms,端到端收益仍很有限。
交互里固定总数据量,让每次启动处理的元素数发生变化,观察启动次数怎样下降。它使用显式假设,帮助理解趋势,不能据此推断任何板卡的实测成绩。实际扩大每批工作量,可能增加暂存需求、排队时间与单次响应延迟。吞吐改善与请求延迟并非同 1 个目标,必须按应用需求选择测量边界。
选择抽象时,先用成熟接口建立正确基线,检查是否能维持数据驻留、合并相邻操作、复用缓冲区。只有在证据表明特定部分限制整体性能,而且改写能维护清晰的正确性条件时,再降低抽象层次。方案还应说明代码维护者是否具备调试设备端错误的能力,以及更换设备或编译环境后如何重新验证。易用性与可控性应放在完整维护成本中比较。
6 常见坑:隐式同步掩盖真正瓶颈
异步执行中,最容易误判的是“哪里等待,哪里就最慢”。程序连续提交多个核函数后,读取结果中的 1 个标量,接口为了给 CPU 返回可靠数值,必须等待相关设备工作完成。主机计时会显示读取标量花了很久,但这段时间可能主要来自之前排队的计算。把那行读取代码改得更短,并不会消除真正的计算成本。
输出打印、某些结果转换、特定内存管理调用以及队列之间的默认依赖,都可能引入同步,具体取决于接口与运行环境。调试时插入的频繁检查也可能改变执行节奏,让原本可重叠的复制与计算串行化。正确做法是先列出已知等待点,再结合设备事件与时间线核对;不要见到“异步”字样就假设整段程序完全没有等待。

另 1 类误判来自失败的报告位置。核函数启动时可能通过了参数检查,但运行过程中出现越界访问,错误到后续同步或复制时才暴露。于是日志显示“复制失败”,真正原因却在更早的核函数。调试版本可以在可疑启动后加入明确等待,把故障范围缩小;定位后重新评估这些等待对吞吐的影响,不把调试配置直接用于性能结论。
还要留意缓冲区过早复用。主机提交异步输入复制后立刻把原数组填成下一批数据,如果复制尚未完成,设备可能收到混杂内容。类似地,设备还在写输出时就释放其存储,也可能产生难以复现的错误。每个缓冲区都应有明确的可复用事件:复制完成、最后的读取完成或最后的写入完成,而不是仅以函数返回作为标志。
排查顺序应从结果和依赖出发。先在小输入上与 CPU 参考结果比较,再检查索引、容量与生命周期;随后确认错误在哪个同步边界出现;最后才判断复制、启动、计算与外部供给中哪项限制吞吐。若 GPU 长期空闲而 CPU 忙于解码输入,优化设备核函数不会解决供给不足。若设备申请失败,先核对实际占用和并发任务,也不要直接归因于总线速度。
7 案例:小核函数启动开销吞吞吐
考虑服务器对传感器片段做相同归一化处理。总量仍为 1600 万个浮点数,每片包含 1000 个元素,应用对每片分别启动核函数,共启动 16000 次。数据已经驻留在设备端,因此先把传输排除在本次比较之外。观察到 GPU 时间线上出现大量很短的核函数,间隙占据显著比例,CPU 则持续进行参数准备和提交。
为了理解数量级,采用简化估算:把每次串行关键路径上的固定成本暂记为 8 μs,把全部有效计算时间暂记为 4 ms。16000 次的固定成本约为 128 ms,合计约 132 ms。8 μs 不是通用硬件常数,也不能把主机提交时间和设备调度时间随意重复相加;真实系统可能排队、流水执行或受其他资源约束。这组数只说明:当固定成本远大于每片有效工作时,计算单元再快也难以提高整体吞吐。
第 1 个改动是把多个片段放进 1 次启动。若每次处理 100000 个元素,启动次数降为 160;按同样简化假设,固定部分约为 1.28 ms,加上 4 ms 计算,总计约 5.28 ms。核函数仍然需要根据片段边界选择参数,不能为了凑大批次破坏每片的独立语义。如果片段长度不一致,还需要偏移表与边界检查,其额外成本应进入实测。
第 2 个改动是融合连续的小操作。例如原先分别做减去均值、乘缩放值、截断范围,若它们没有必须对外可见的中间结果,可以在同 1 个核函数中完成,减少启动和中间数组读写。融合后仍要验证数值误差与边界行为,也要观察资源使用是否导致实际变慢。不能把“启动越少越快”作为无条件结论。

复测时保留原始实现作为基线,使用相同输入、数据驻留条件和输出检查,分别报告端到端时间、设备计算区间、启动次数与峰值内存。再把输入和输出搬运放回完整路径。若完整请求仍被 SSD 读取或 PCIe 复制限制,就应把下一轮优化投向相应环节。局部加速倍数说明了改动有效,却不能替代应用整体收益。
最后评估等待凑批的代价。离线处理允许积累较大批次,通常更容易减少启动次数;实时系统若等待片段积满,可能损害尾部延迟。可以设置最大元素数与最长等待时间,由先达到的条件触发提交。这样把吞吐目标与响应约束写成明确策略,而不是依赖偶然到达的流量。真正需要选择的是在既定延迟边界内,怎样让每次启动承担足够多的有效工作。
8 把编程模型假设写进实现方案
实现方案应先声明硬件和执行环境。写清设备内存容量、PCIe 代际与链路宽度、CPU 主存容量、数据来源,以及是否与显示或其他任务共享设备。若使用多路服务器,附上设备和主存的连接关系;若尚未拿到目标机器,就把未知项标为待测条件。把“有 GPU”作为配置描述,会掩盖容量、传输和资源竞争带来的差别。
再写清数据契约。每批含多少元素,采用什么数值类型,输入输出各占多少字节,临时数组需要多少空间,允许多少批并发驻留。估算必须覆盖运行环境和其他任务的占用,并留出经实测确定的余量。超过容量时的行为也要明确:减小批次、排队等待、回退 CPU 或返回资源不足,不能让申请失败后程序继续使用无效地址。
执行契约应描述队列与依赖:输入复制完成后哪些核函数可以开始,哪些中间结果由后续核函数消费,输出什么时候交给 CPU,缓冲区何时允许复用。对于独立批次,可以尝试流水安排,但要注明复制引擎、主存类型与接口条件可能限制重叠。设备端逻辑的块大小属于可调参数;跨块执行顺序则不应成为未经保证的隐含假设。
验收契约要同时包含正确性和性能。正确性覆盖空输入、非整块长度、最大批次、异常数值和资源申请失败;性能分别测独立请求与持续吞吐,记录中位延迟及适合场景的尾部指标。写出计时起止点、预热方法和并发负载,让别人能判断数据是否可比。只报告“GPU 利用率很高”,既不能证明结果正确,也不能证明请求处理及时。
最后约定失败如何收敛。启动返回状态与后续异步错误都应被检查;发生设备错误后,按接口规则决定当前上下文能否继续使用,并避免交付未完成输出。日志关联批次标识、输入规模、队列阶段和错误码即可,不必倾倒完整数据。这样的实现方案把编程模型的规则转成团队可以检查的条件,后续优化才有可靠基线。
9 从GPU 编程模型走向大模型预训练(A29)
大模型预训练把这里的向量变换扩展成更长的算子链。训练程序仍由主机组织,设备执行张量计算;参数、梯度、中间激活和优化器状态都占空间。输入从存储经过主存到达设备,计算结果再被后续算子消费。区别主要在规模与依赖复杂度,并不是前面的完成条件突然失效。数据在哪里、由谁读取、何时可覆盖,依然是理解训练系统的起点。
单卡容量不足时,不能只看模型参数文件大小。训练期间还有中间结果和更新状态,实际峰值受批次、数值表示与执行策略影响。设备端算子很快时,输入准备或大量小启动也可能成为瓶颈。理解本课的时间线,就能在后续课程中区分“算力不足”“数据没有及时到达”和“主机未能及时提交工作”,避免只凭 GPU 型号解释所有现象。
当训练扩展到多张设备,还会增加设备之间的结果交换。通信与计算之间同样需要依赖与完成信号,而网卡带宽、设备连接和服务器形态会进入关键路径。这里先不展开并行训练算法与通信实现;进入 A29 时,带着 3 个问题即可:每步训练需要保留哪些数据,哪些计算可以独立推进,哪些结果必须等待其他设备。它们是本课问题在更大系统中的延伸。
放进方案里
在实现方案中,把 GPU 使用方式写成可验证的执行约定。本例采用带 24 GB 设备内存的 PCIe 4.0 x16 板卡,主机具有 128 GB 主存,输入来自 NVMe SSD。每批处理 1600 万个 32 位浮点数,输入与输出各约 64 MB;设备空间还要计入临时区、运行环境和并发批次。上线前测量实际可用容量及有效复制带宽,批次大小以目标机器结果为准。
CPU 负责读取、校验、申请缓冲区与提交任务,GPU 负责元素变换。每批按输入复制、核函数、输出复制建立顺序,CPU 仅在输出完成后读取结果。主机输入在复制完成前不可改写,设备输出在最后使用结束前不可释放。以每块 256 个线程作为初始配置,块数向上取整,核函数保留边界判断;不同块之间不假定执行先后。
优先复用缓冲区并让连续计算的数据留在设备。只有在依赖独立且平台支持时才尝试复制与计算重叠。若启动次数限制吞吐,比较合批与融合方案,同时限制等待凑批的时间,防止吞吐改善损害响应要求。自动迁移或库内部融合都应通过时间线确认,不能仅凭接口名称推断实际执行。
验收同时记录完整请求延迟、持续吞吐、设备计算区间、启动次数和峰值内存,固定预热、输入及共享负载条件。正确性检查覆盖非整块长度、空输入和最大批次,并与 CPU 参考结果比较。申请失败时缩小批次或明确返回资源不足;异步错误在完成边界检查,失败批次不得交付输出。性能改动保留同条件基线,以完整路径的收益决定是否采用,为 A29 的训练数据流与算子链分析留下可复用记录。
10 小结:下一步大模型预训练(A29)
GPU 编程模型把主机控制、设备计算、数据位置与同步规则连接起来。网格表达大量工作,块限定部分协作范围,完成事件决定何时能读取或复用数据。性能判断要覆盖实际交付结果的路径,并区分提交时间、设备时间与等待时间。
进入 A29 前,可以用自己的话检查 3 件事:为什么 launch 返回后不能立即假定结果可读;为什么末尾不足 256 个元素仍需正确处理;为什么缩短核函数不一定显著缩短完整请求。能沿着数据路径解释它们,就具备了继续分析大模型训练计算与搬运关系的基础。