<?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>/zh-cn/part6hetero/ch18gpu/</link>
    <description>Recent content in 第 18 章 GPU 与异构计算 on Go 语言原本</description>
    <generator>Hugo</generator>
    <language>zh-cn</language>
    <atom:link href="/zh-cn/part6hetero/ch18gpu/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>18.1 跨越 FFI 边界</title>
      <link>/zh-cn/part6hetero/ch18gpu/boundary/</link>
      <pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate>
      <guid>/zh-cn/part6hetero/ch18gpu/boundary/</guid>
      <description>&lt;h1 id=&#34;181-跨越-ffi-边界&#34;&gt;18.1 跨越 FFI 边界&lt;/h1&gt;&#xA;&lt;p&gt;&lt;a href=&#34;../../../part5toolchain/ch15compile/cgo&#34;&gt;15.6&lt;/a&gt;已经把 cgo 这座桥拆开看过了：一次从 Go 到 C 的&#xA;调用要切到 &lt;code&gt;g0&lt;/code&gt; 系统栈、按 C 的 ABI 重摆参数、&lt;code&gt;entersyscall&lt;/code&gt; 让出 P、调用、再 &lt;code&gt;exitsyscall&lt;/code&gt;&#xA;抢回一个 P，整套下来比一次 Go 调用贵上一两个数量级。那一节给出的结论很干脆：cgo 适合&lt;strong&gt;少量、&#xA;粗粒度&lt;/strong&gt;的调用，最忌讳放进热点循环里反复跨界。&lt;/p&gt;&#xA;&lt;p&gt;把这条结论摆到 GPU 面前，矛盾立刻就尖锐了。GPU 编程的本质，恰恰是&lt;strong&gt;频繁地跨界&lt;/strong&gt;。一次最普通的&#xA;推理或渲染，CPU 这侧要做的事无非三类：把数据从主机内存拷到显存、启动一个又一个 kernel、再把&#xA;结果拷回来。每一类都是一次离开 Go、进入驱动的边界穿越。一个稍有规模的神经网络有成百上千个算子，&#xA;逐个朴素地启动，就是成百上千次 cgo 调用串在一条关键路径上。15.6 说「别在紧循环里跨界」，&#xA;而 GPU 的工作负载天生就长在这样一个紧循环里。这一节要回答的就是：当跨界无法避免、且必须高频时，&#xA;这道边界该怎么设计才不至于被通行费压垮。&lt;/p&gt;&#xA;&lt;h2 id=&#34;1811-边界的另一端一个异步的命令世界&#34;&gt;18.1.1 边界的另一端：一个异步的命令世界&lt;/h2&gt;&#xA;&lt;p&gt;先看清桥的对岸站着谁。从 Go 调一个 C 库函数，对岸是一段同步执行的 C 代码，调用返回时活儿就干完了。&#xA;GPU 不是这样。CPU 这侧调用的并不是「计算本身」，而是&lt;strong&gt;向设备下达一条命令&lt;/strong&gt;。&lt;/p&gt;&#xA;&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;），直接对应&#xA;内核态驱动暴露的能力；上层是&lt;strong&gt;运行时 API&lt;/strong&gt;（runtime API，&lt;code&gt;libcudart&lt;/code&gt;，前缀 &lt;code&gt;cuda&lt;/code&gt;），把驱动&#xA;API 包装得更易用，自动管理上下文与模块。无论走哪一层，CPU 侧的一次 &lt;code&gt;cudaLaunchKernel&lt;/code&gt; 或&#xA;&lt;code&gt;cudaMemcpyAsync&lt;/code&gt; 都只是把一条命令塞进一个叫&lt;strong&gt;流&lt;/strong&gt;（stream）的队列，然后&lt;strong&gt;立即返回&lt;/strong&gt;。真正的&#xA;计算由 GPU 在自己的时间线上异步地完成。CPU 下令，GPU 干活，两者在不同的时钟上跑。&lt;/p&gt;</description>
    </item>
    <item>
      <title>18.2 调度器与阻塞的外部调用</title>
      <link>/zh-cn/part6hetero/ch18gpu/sched/</link>
      <pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate>
      <guid>/zh-cn/part6hetero/ch18gpu/sched/</guid>
      <description>&lt;h1 id=&#34;182-调度器与阻塞的外部调用&#34;&gt;18.2 调度器与阻塞的外部调用&lt;/h1&gt;&#xA;&lt;p&gt;&lt;a href=&#34;.././boundary&#34;&gt;18.1&lt;/a&gt; 给出的药方是「异步、少同步」：把命令压进流，立即返回，只在末尾等一次。&#xA;可那「末尾的一次」终归要等。&lt;code&gt;cudaStreamSynchronize&lt;/code&gt; 会一直阻塞，直到 GPU 把整条流排空；一次&#xA;同步的 &lt;code&gt;cudaMemcpy&lt;/code&gt;,一次没走异步路径的驱动调用，都会让 Go 这侧的线程实打实地停在 C 里。&#xA;本节要问的就是：当一次跨界&lt;strong&gt;真的会长时间阻塞&lt;/strong&gt;时，&lt;a href=&#34;/zh-cn/part3concurrency/ch09sched/&#34;&gt;第 9 章&lt;/a&gt;&#xA;那套调度机器会怎样反应？它会不会被一个卡在 GPU 上的调用拖垮？&lt;/p&gt;&#xA;&lt;p&gt;答案要从两个方向看。Go 阻塞在 C 里是一个方向，C 反过来回调 Go 是另一个方向，调度器在这两个&#xA;方向上各有一套应对。&lt;/p&gt;&#xA;&lt;h2 id=&#34;1821-一次阻塞的跨界调度器看见的是什么&#34;&gt;18.2.1 一次阻塞的跨界，调度器看见的是什么&lt;/h2&gt;&#xA;&lt;p&gt;先回忆第 9 章的图景：调度器在 M（系统线程）、P（逻辑处理器，数量受 &lt;code&gt;GOMAXPROCS&lt;/code&gt; 限制）、&#xA;G（goroutine）三者上编排并发，一个 M 必须先绑定一个 P 才能运行 Go 代码。&lt;/p&gt;&#xA;&lt;p&gt;15.6 已经讲过，&lt;code&gt;cgocall&lt;/code&gt; 在跨界前会调用 &lt;code&gt;entersyscall&lt;/code&gt;。这一步的意义此刻变得关键：&#xA;&lt;strong&gt;在调度器的记账里，一次 cgo 调用和一次系统调用是同一回事&lt;/strong&gt;。M 被标记为「正处于系统调用中」，&#xA;它承载的 goroutine 转入 &lt;code&gt;_Gsyscall&lt;/code&gt; 状态。这里有一个随版本演进的实现细节值得一提：早先的&#xA;运行时还专门给 P 设过一个 &lt;code&gt;_Psyscall&lt;/code&gt; 状态来表示「这个 P 正陷在系统调用里」，但 &lt;strong&gt;Go 1.26 起&#xA;这个 P 状态已退役&lt;/strong&gt;（源码里留作 &lt;code&gt;_Psyscall_unused&lt;/code&gt;），改为直接看 goroutine 的状态来判断一个 P&#xA;是否在系统调用中。这次精简并非无关紧要，它顺带把每次 cgo 跨界的固定开销削减了约三成,正是&#xA;&lt;a href=&#34;.././boundary&#34;&gt;18.1.2&lt;/a&gt; 那「第一笔成本」被运行时自己磨薄的一例。从这一刻起到 C 调用返回，&#xA;有一条铁律：&lt;/p&gt;</description>
    </item>
    <item>
      <title>18.3 显存与垃圾回收的分界</title>
      <link>/zh-cn/part6hetero/ch18gpu/memory/</link>
      <pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate>
      <guid>/zh-cn/part6hetero/ch18gpu/memory/</guid>
      <description>&lt;h1 id=&#34;183-显存与垃圾回收的分界&#34;&gt;18.3 显存与垃圾回收的分界&lt;/h1&gt;&#xA;&lt;p&gt;&lt;a href=&#34;../../../part5toolchain/ch15compile/cgo&#34;&gt;15.6&lt;/a&gt; 讲过 cgo 的指针规则：Go 的对象不归 C 管，&#xA;GC 随时可能搬走或回收它，所以 C 不得在调用返回后还持有一个未钉住的 Go 指针。那是从「Go 与 C&#xA;两块内存」的二元世界推出来的。GPU 把这张地图复杂化了：现在至少有&lt;strong&gt;四种&lt;/strong&gt;内存，分属不同的管辖，&#xA;遵守不同的规矩。这一节要先把这张地图画清楚，再看垃圾回收器与它们各自的分界划在哪里，&#xA;以及哪一条分界最容易在异步传输里被踩穿。&lt;/p&gt;&#xA;&lt;h2 id=&#34;1831-一张内存地图&#34;&gt;18.3.1 一张内存地图&lt;/h2&gt;&#xA;&lt;p&gt;一个用 Go 驱动 GPU 的程序，运行时面对的内存大致分四块：&lt;/p&gt;&#xA;&#xA;&lt;pre class=&#34;mermaid&#34;&gt;flowchart TB&#xA;    subgraph host[&amp;#34;主机内存（CPU 可寻址）&amp;#34;]&#xA;        go[&amp;#34;Go 堆&amp;lt;br/&amp;gt;GC 管理：可移动、可回收、被扫描&amp;#34;]&#xA;        c[&amp;#34;C 堆 / malloc&amp;lt;br/&amp;gt;手动管理：GC 不可见&amp;#34;]&#xA;        pinned[&amp;#34;页锁定内存 cudaHostAlloc&amp;lt;br/&amp;gt;不可换出，供 DMA 高速传输&amp;#34;]&#xA;    end&#xA;    subgraph device[&amp;#34;设备内存（GPU 上，CPU 不可寻址）&amp;#34;]&#xA;        dev[&amp;#34;显存 cudaMalloc&amp;lt;br/&amp;gt;GC 完全看不见，手动 cudaFree&amp;#34;]&#xA;    end&#xA;    go -. &amp;#34;cudaMemcpy&amp;#34; .-&amp;gt; dev&#xA;    pinned -. &amp;#34;cudaMemcpyAsync（快）&amp;#34; .-&amp;gt; dev&lt;/pre&gt;&#xA;&lt;p&gt;四块里，只有第一块 &lt;strong&gt;Go 堆&lt;/strong&gt;归 GC 管，第 12、13 章那套分配与回收、那套可达性扫描，作用范围就到&#xA;这里为止。后三块对 GC 而言是「境外」：C 堆是手动 &lt;code&gt;malloc&lt;/code&gt;/&lt;code&gt;free&lt;/code&gt; 的，页锁定内存由 CUDA 分配，&#xA;而&lt;strong&gt;显存根本不在 CPU 的地址空间里&lt;/strong&gt;,CPU 连解引用它都做不到。理解这一节的全部诀窍，&#xA;就是时刻分清一个指针到底落在哪一块。&lt;/p&gt;</description>
    </item>
    <item>
      <title>18.4 异步编程模型</title>
      <link>/zh-cn/part6hetero/ch18gpu/model/</link>
      <pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate>
      <guid>/zh-cn/part6hetero/ch18gpu/model/</guid>
      <description>&lt;h1 id=&#34;184-异步编程模型&#34;&gt;18.4 异步编程模型&lt;/h1&gt;&#xA;&lt;p&gt;前三节都在讲 FFI 边界上的「成本」：过桥要快（&lt;a href=&#34;.././boundary&#34;&gt;18.1&lt;/a&gt;）、过桥会占住线程&#xA;（&lt;a href=&#34;.././sched&#34;&gt;18.2&lt;/a&gt;）、桥上的内存归谁管（&lt;a href=&#34;.././memory&#34;&gt;18.3&lt;/a&gt;）。这一节换一个角度，&#xA;回到&lt;strong&gt;并发模型&lt;/strong&gt;本身。Go 的并发是 goroutine 与通道，GPU 的并发是另一套东西，CPU 自己还藏着&#xA;第三套。把这三套并行摆清楚、看明白它们怎么对接，是这一章的收尾，也是理解「何时该把活儿推过边界、&#xA;何时压根不必」的关键。&lt;/p&gt;&#xA;&lt;h2 id=&#34;1841-三种并行别混为一谈&#34;&gt;18.4.1 三种并行，别混为一谈&lt;/h2&gt;&#xA;&lt;p&gt;「并发」（concurrency）与「并行」（parallelism）的区分，第 9 章借 Rob Pike 的话讲过：并发是&#xA;&lt;strong&gt;把程序拆成可独立推进的部分&lt;/strong&gt;的结构，并行是&lt;strong&gt;同时执行&lt;/strong&gt;的事实。带着这把尺子，眼前这三套各占&#xA;什么位置就清楚了。&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&#xA;&lt;p&gt;&lt;strong&gt;goroutine 并发。&lt;/strong&gt; Go 的看家本领，M:N 的&lt;strong&gt;任务级&lt;/strong&gt;并发。每个 goroutine 是一段独立的控制流，&#xA;廉价、可阻塞、靠通道通信。它回答的是「如何把程序组织成许多并发的任务」。&lt;/p&gt;&#xA;&lt;/li&gt;&#xA;&lt;li&gt;&#xA;&lt;p&gt;&lt;strong&gt;SIMT（GPU）。&lt;/strong&gt; Single Instruction, Multiple Thread。一次 kernel 启动铺开一张由成千上万个&#xA;线程组成的网格，它们&lt;strong&gt;跑同一段程序、各自处理一个数据元素&lt;/strong&gt;，硬件以 warp（NVIDIA 上 32 个线程&#xA;一组）为单位近乎锁步地推进。它回答的是「如何让海量数据元素被同一段计算并行碾过」。&#xA;编程模型是：为单个元素写好 kernel，然后启动一整张网格。&lt;/p&gt;&#xA;&lt;/li&gt;&#xA;&lt;li&gt;&#xA;&lt;p&gt;&lt;strong&gt;SIMD（CPU）。&lt;/strong&gt; Single Instruction, Multiple Data。在&lt;strong&gt;一个&lt;/strong&gt; CPU 核内，一条指令同时作用于&#xA;一个向量寄存器里的多个数据通道（4、8、16 路）。它是 CPU 自带的&lt;strong&gt;数据级&lt;/strong&gt;并行，不需要 GPU、&#xA;不跨任何边界。&lt;/p&gt;&#xA;&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;这三者是&lt;strong&gt;正交的轴&lt;/strong&gt;，可以叠加：一个 Go 程序完全可以用 goroutine 并发地处理多个请求，&#xA;在每个请求的 CPU 内循环里用 SIMD 向量化，再把最重的矩阵乘用 SIMT 推给 GPU。把它们混为一谈&#xA;（比如「一个 GPU 线程就像一个 goroutine」）会从一开始就把设计带偏:goroutine 是为&lt;strong&gt;可阻塞的&#xA;任务&lt;/strong&gt;生的，GPU 线程是为&lt;strong&gt;无分支的密集算术&lt;/strong&gt;生的，两者的设计假设南辕北辙。&lt;/p&gt;</description>
    </item>
  </channel>
</rss>
