<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>第 18 章 GPU 与异构计算 on Go 语言原本</title><link>https://golang.design/under-the-hood/zh-cn/part6hetero/ch18gpu/</link><description>Recent content in 第 18 章 GPU 与异构计算 on Go 语言原本</description><generator>Hugo</generator><language>zh-cn</language><atom:link href="https://golang.design/under-the-hood/zh-cn/part6hetero/ch18gpu/index.xml" rel="self" type="application/rss+xml"/><item><title>18.1 跨越 FFI 边界</title><link>https://golang.design/under-the-hood/zh-cn/part6hetero/ch18gpu/boundary/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://golang.design/under-the-hood/zh-cn/part6hetero/ch18gpu/boundary/</guid><description>&lt;h1 id="181-跨越-ffi-边界"&gt;18.1 跨越 FFI 边界&lt;/h1&gt;
&lt;p&gt;&lt;a href="../../../part5toolchain/ch15compile/cgo"&gt;15.6&lt;/a&gt;已经把 cgo 这座桥拆开看过了：一次从 Go 到 C 的
调用要切到 &lt;code&gt;g0&lt;/code&gt; 系统栈、按 C 的 ABI 重摆参数、&lt;code&gt;entersyscall&lt;/code&gt; 让出 P、调用、再 &lt;code&gt;exitsyscall&lt;/code&gt;
抢回一个 P，整套下来比一次 Go 调用贵上一两个数量级。那一节给出的结论很干脆：cgo 适合&lt;strong&gt;少量、
粗粒度&lt;/strong&gt;的调用，最忌讳放进热点循环里反复跨界。&lt;/p&gt;
&lt;p&gt;把这条结论摆到 GPU 面前，矛盾立刻就尖锐了。GPU 编程的本质，恰恰是&lt;strong&gt;频繁地跨界&lt;/strong&gt;。一次最普通的
推理或渲染，CPU 这侧要做的事无非三类：把数据从主机内存拷到显存、启动一个又一个 kernel、再把
结果拷回来。每一类都是一次离开 Go、进入驱动的边界穿越。一个稍有规模的神经网络有成百上千个算子，
逐个朴素地启动，就是成百上千次 cgo 调用串在一条关键路径上。15.6 说「别在紧循环里跨界」，
而 GPU 的工作负载天生就长在这样一个紧循环里。这一节要回答的就是：当跨界无法避免、且必须高频时，
这道边界该怎么设计才不至于被通行费压垮。&lt;/p&gt;
&lt;h2 id="1811-边界的另一端一个异步的命令世界"&gt;18.1.1 边界的另一端：一个异步的命令世界&lt;/h2&gt;
&lt;p&gt;先看清桥的对岸站着谁。从 Go 调一个 C 库函数，对岸是一段同步执行的 C 代码，调用返回时活儿就干完了。
GPU 不是这样。CPU 这侧调用的并不是「计算本身」，而是&lt;strong&gt;向设备下达一条命令&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;以 CUDA 为例，软件栈分了两层。底层是&lt;strong&gt;驱动 API&lt;/strong&gt;（driver API，&lt;code&gt;libcuda&lt;/code&gt;，前缀 &lt;code&gt;cu&lt;/code&gt;），直接对应
内核态驱动暴露的能力；上层是&lt;strong&gt;运行时 API&lt;/strong&gt;（runtime API，&lt;code&gt;libcudart&lt;/code&gt;，前缀 &lt;code&gt;cuda&lt;/code&gt;），把驱动
API 包装得更易用，自动管理上下文与模块。无论走哪一层，CPU 侧的一次 &lt;code&gt;cudaLaunchKernel&lt;/code&gt; 或
&lt;code&gt;cudaMemcpyAsync&lt;/code&gt; 都只是把一条命令塞进一个叫&lt;strong&gt;流&lt;/strong&gt;（stream）的队列，然后&lt;strong&gt;立即返回&lt;/strong&gt;。真正的
计算由 GPU 在自己的时间线上异步地完成。CPU 下令，GPU 干活，两者在不同的时钟上跑。&lt;/p&gt;</description></item><item><title>18.2 调度器与阻塞的外部调用</title><link>https://golang.design/under-the-hood/zh-cn/part6hetero/ch18gpu/sched/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://golang.design/under-the-hood/zh-cn/part6hetero/ch18gpu/sched/</guid><description>&lt;h1 id="182-调度器与阻塞的外部调用"&gt;18.2 调度器与阻塞的外部调用&lt;/h1&gt;
&lt;p&gt;&lt;a href=".././boundary"&gt;18.1&lt;/a&gt; 给出的药方是「异步、少同步」：把命令压进流，立即返回，只在末尾等一次。
可那「末尾的一次」终归要等。&lt;code&gt;cudaStreamSynchronize&lt;/code&gt; 会一直阻塞，直到 GPU 把整条流排空；一次
同步的 &lt;code&gt;cudaMemcpy&lt;/code&gt;,一次没走异步路径的驱动调用，都会让 Go 这侧的线程实打实地停在 C 里。
本节要问的就是：当一次跨界&lt;strong&gt;真的会长时间阻塞&lt;/strong&gt;时，&lt;a href="https://golang.design/under-the-hood/zh-cn/part3concurrency/ch09sched/"&gt;第 9 章&lt;/a&gt;
那套调度机器会怎样反应？它会不会被一个卡在 GPU 上的调用拖垮？&lt;/p&gt;
&lt;p&gt;答案要从两个方向看。Go 阻塞在 C 里是一个方向，C 反过来回调 Go 是另一个方向，调度器在这两个
方向上各有一套应对。&lt;/p&gt;
&lt;h2 id="1821-一次阻塞的跨界调度器看见的是什么"&gt;18.2.1 一次阻塞的跨界，调度器看见的是什么&lt;/h2&gt;
&lt;p&gt;先回忆第 9 章的图景：调度器在 M（系统线程）、P（逻辑处理器，数量受 &lt;code&gt;GOMAXPROCS&lt;/code&gt; 限制）、
G（goroutine）三者上编排并发，一个 M 必须先绑定一个 P 才能运行 Go 代码。&lt;/p&gt;
&lt;p&gt;15.6 已经讲过，&lt;code&gt;cgocall&lt;/code&gt; 在跨界前会调用 &lt;code&gt;entersyscall&lt;/code&gt;。这一步的意义此刻变得关键：
&lt;strong&gt;在调度器的记账里，一次 cgo 调用和一次系统调用是同一回事&lt;/strong&gt;。M 被标记为「正处于系统调用中」，
它承载的 goroutine 转入 &lt;code&gt;_Gsyscall&lt;/code&gt; 状态。这里有一个随版本演进的实现细节值得一提：早先的
运行时还专门给 P 设过一个 &lt;code&gt;_Psyscall&lt;/code&gt; 状态来表示「这个 P 正陷在系统调用里」，但 &lt;strong&gt;Go 1.26 起
这个 P 状态已退役&lt;/strong&gt;（源码里留作 &lt;code&gt;_Psyscall_unused&lt;/code&gt;），改为直接看 goroutine 的状态来判断一个 P
是否在系统调用中。这次精简并非无关紧要，它顺带把每次 cgo 跨界的固定开销削减了约三成,正是
&lt;a href=".././boundary"&gt;18.1.2&lt;/a&gt; 那「第一笔成本」被运行时自己磨薄的一例。从这一刻起到 C 调用返回，
有一条铁律：&lt;/p&gt;</description></item><item><title>18.3 显存与垃圾回收的分界</title><link>https://golang.design/under-the-hood/zh-cn/part6hetero/ch18gpu/memory/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://golang.design/under-the-hood/zh-cn/part6hetero/ch18gpu/memory/</guid><description>&lt;h1 id="183-显存与垃圾回收的分界"&gt;18.3 显存与垃圾回收的分界&lt;/h1&gt;
&lt;p&gt;&lt;a href="../../../part5toolchain/ch15compile/cgo"&gt;15.6&lt;/a&gt; 讲过 cgo 的指针规则：Go 的对象不归 C 管，
GC 随时可能搬走或回收它，所以 C 不得在调用返回后还持有一个未钉住的 Go 指针。那是从「Go 与 C
两块内存」的二元世界推出来的。GPU 把这张地图复杂化了：现在至少有&lt;strong&gt;四种&lt;/strong&gt;内存，分属不同的管辖，
遵守不同的规矩。这一节要先把这张地图画清楚，再看垃圾回收器与它们各自的分界划在哪里，
以及哪一条分界最容易在异步传输里被踩穿。&lt;/p&gt;
&lt;h2 id="1831-一张内存地图"&gt;18.3.1 一张内存地图&lt;/h2&gt;
&lt;p&gt;一个用 Go 驱动 GPU 的程序，运行时面对的内存大致分四块：&lt;/p&gt;

&lt;pre class="mermaid"&gt;flowchart TB
 subgraph host[&amp;#34;主机内存（CPU 可寻址）&amp;#34;]
 go[&amp;#34;Go 堆&amp;lt;br/&amp;gt;GC 管理：可移动、可回收、被扫描&amp;#34;]
 c[&amp;#34;C 堆 / malloc&amp;lt;br/&amp;gt;手动管理：GC 不可见&amp;#34;]
 pinned[&amp;#34;页锁定内存 cudaHostAlloc&amp;lt;br/&amp;gt;不可换出，供 DMA 高速传输&amp;#34;]
 end
 subgraph device[&amp;#34;设备内存（GPU 上，CPU 不可寻址）&amp;#34;]
 dev[&amp;#34;显存 cudaMalloc&amp;lt;br/&amp;gt;GC 完全看不见，手动 cudaFree&amp;#34;]
 end
 go -. &amp;#34;cudaMemcpy&amp;#34; .-&amp;gt; dev
 pinned -. &amp;#34;cudaMemcpyAsync（快）&amp;#34; .-&amp;gt; dev&lt;/pre&gt;
&lt;p&gt;四块里，只有第一块 &lt;strong&gt;Go 堆&lt;/strong&gt;归 GC 管，第 12、13 章那套分配与回收、那套可达性扫描，作用范围就到
这里为止。后三块对 GC 而言是「境外」：C 堆是手动 &lt;code&gt;malloc&lt;/code&gt;/&lt;code&gt;free&lt;/code&gt; 的，页锁定内存由 CUDA 分配，
而&lt;strong&gt;显存根本不在 CPU 的地址空间里&lt;/strong&gt;,CPU 连解引用它都做不到。理解这一节的全部诀窍，
就是时刻分清一个指针到底落在哪一块。&lt;/p&gt;</description></item><item><title>18.4 异步编程模型</title><link>https://golang.design/under-the-hood/zh-cn/part6hetero/ch18gpu/model/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://golang.design/under-the-hood/zh-cn/part6hetero/ch18gpu/model/</guid><description>&lt;h1 id="184-异步编程模型"&gt;18.4 异步编程模型&lt;/h1&gt;
&lt;p&gt;前三节都在讲 FFI 边界上的「成本」：过桥要快（&lt;a href=".././boundary"&gt;18.1&lt;/a&gt;）、过桥会占住线程
（&lt;a href=".././sched"&gt;18.2&lt;/a&gt;）、桥上的内存归谁管（&lt;a href=".././memory"&gt;18.3&lt;/a&gt;）。这一节换一个角度，
回到&lt;strong&gt;并发模型&lt;/strong&gt;本身。Go 的并发是 goroutine 与通道，GPU 的并发是另一套东西，CPU 自己还藏着
第三套。把这三套并行摆清楚、看明白它们怎么对接，是这一章的收尾，也是理解「何时该把活儿推过边界、
何时压根不必」的关键。&lt;/p&gt;
&lt;h2 id="1841-三种并行别混为一谈"&gt;18.4.1 三种并行，别混为一谈&lt;/h2&gt;
&lt;p&gt;「并发」（concurrency）与「并行」（parallelism）的区分，第 9 章借 Rob Pike 的话讲过：并发是
&lt;strong&gt;把程序拆成可独立推进的部分&lt;/strong&gt;的结构，并行是&lt;strong&gt;同时执行&lt;/strong&gt;的事实。带着这把尺子，眼前这三套各占
什么位置就清楚了。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;goroutine 并发。&lt;/strong&gt; Go 的看家本领，M:N 的&lt;strong&gt;任务级&lt;/strong&gt;并发。每个 goroutine 是一段独立的控制流，
廉价、可阻塞、靠通道通信。它回答的是「如何把程序组织成许多并发的任务」。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;SIMT（GPU）。&lt;/strong&gt; Single Instruction, Multiple Thread。一次 kernel 启动铺开一张由成千上万个
线程组成的网格，它们&lt;strong&gt;跑同一段程序、各自处理一个数据元素&lt;/strong&gt;，硬件以 warp（NVIDIA 上 32 个线程
一组）为单位近乎锁步地推进。它回答的是「如何让海量数据元素被同一段计算并行碾过」。
编程模型是：为单个元素写好 kernel，然后启动一整张网格。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;SIMD（CPU）。&lt;/strong&gt; Single Instruction, Multiple Data。在&lt;strong&gt;一个&lt;/strong&gt; CPU 核内，一条指令同时作用于
一个向量寄存器里的多个数据通道（4、8、16 路）。它是 CPU 自带的&lt;strong&gt;数据级&lt;/strong&gt;并行，不需要 GPU、
不跨任何边界。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这三者是&lt;strong&gt;正交的轴&lt;/strong&gt;，可以叠加：一个 Go 程序完全可以用 goroutine 并发地处理多个请求，
在每个请求的 CPU 内循环里用 SIMD 向量化，再把最重的矩阵乘用 SIMT 推给 GPU。把它们混为一谈
（比如「一个 GPU 线程就像一个 goroutine」）会从一开始就把设计带偏:goroutine 是为&lt;strong&gt;可阻塞的
任务&lt;/strong&gt;生的，GPU 线程是为&lt;strong&gt;无分支的密集算术&lt;/strong&gt;生的，两者的设计假设南辕北辙。&lt;/p&gt;</description></item></channel></rss>