CHAPTER 33 / 架构、性能与并行

Host/device、传输与异步生命周期

launch 返回后能否立即读结果,为什么数据留在设备上可能比优化一条指令更有效?

阅读与推演约 60 分钟练习时间另计

这一章要弄清楚

  • 画出准备、传输、计算、回传的依赖
  • 用完成事件约束缓冲区生命周期
  • 区分可重叠条件与理论流水线模型

先备知识:真实资源与RAII:独占拥有 / 原子操作与 happens-before / Reduction、scan 与数值正确性

C++20 / macOS 与 Linux;本章的硬件模型只推演逻辑,不代表设备性能。

两个执行环境,一份计算目标

Host 通常是负责组织任务的 CPU 端,device 是执行加速计算的设备。对离散 GPU,host 内存和 device 内存一般有明确的访问与传输规则;某些平台提供统一地址空间、托管内存或共享物理内存,但不能因此假设任何指针在任意端都可解引用。指针值、地址可达性、数据驻留位置和访问完成时间,是四个不同问题。

从输入 1、2、3 算出两倍值,最清晰的逻辑路径是准备 host 输入、建立设备存储、H2D 传入、kernel 计算、D2H 回传、CPU 验证、释放资源。第一例用两个普通 vector 代表两端,用显式状态检查说明依赖。它实际只运行 CPU 代码,没有实现 GPU runtime、DMA 或设备模拟器;模型通过只表示这一流程逻辑自洽。

提交完成不是执行完成

异步 launch 往往表示命令已提交,host 可以继续做其他工作,不代表 kernel 已结束。要读取结果或重用存储,需要等待能覆盖相关操作的完成事件。错误也有两个时间点:提交时可以发现配置等问题,执行时才可能发现非法访问。只检查 launch 返回值而不观察完成,容易把“成功入队”误当作“正确计算”。

Host 到 device 的异步拷贝也有生命周期合同:如果 API 仍可能读取源缓冲,调用者不能提前修改、释放或使其地址失效。设备输出未完成回传前,host 不能读取未完成的目标。用 RAII 包装资源有帮助,但异步任务可能跨越局部作用域,因此析构策略必须等待、延迟释放或由更长寿命的所有者管理;单纯在函数末尾 free 不够。

流与事件建立局部顺序

一个 stream 可理解为有顺序关系的命令队列。同一流中相关操作按 API 合同建立顺序;不同流之间不能靠源代码先后自动推断依赖,通常需要事件或其他显式同步。默认流行为还有具体平台规则,初学时使用显式流更容易审查。全设备同步很容易获得正确结果,却可能无谓阻塞无关工作;逐步验证后再缩小等待范围。

尽量把中间结果留在 device 上。若一个流水线依次做归一化、矩阵乘、归约,每一步都回 CPU 再传回设备,会增加固定延迟与字节量。端到端测量要包括这些传输,不能只挑最快 kernel。一份峰值算力参数并不说明小任务能加速,启动和搬运可能支配总时间。

流水线的收益带有前提

第二例设 H2D=2、计算=5、D2H=2 个抽象时间单位,三个块串行需要27。若三类阶段能独立执行、使用两组缓冲,并按完成依赖复用,模型完工时间为19。这里的数字是手算参数,不是毫秒,也不是任何设备的实测。真实重叠还依赖 copy engine、互联、内存带宽、数据依赖和 host 内存类型,不能只创建两个 stream 就保证实现。

Pinned host memory、双缓冲和较大批次可能有助于异步传输,但锁页内存也是有限资源。选块大小需要权衡固定开销、可重叠程度和内存占用。面试时画出每个 buffer 从准备到可复用的时间线,标明哪条事件保护它,比笼统说“异步更快”更有说服力。

动手改变 · 观察因果

传输、计算和缓冲区何时可以重叠

下一阶段等的是数据、执行资源,还是缓冲区?

每类阶段占一条独立资源,按批次 FIFO,无启动开销。时长全为假设单位,不是真实设备性能;双缓冲默认复现 27 → 19。 对应 例题 29-b;图中的代码行是步骤提示,完整可编译源码见例题。

改输入后从第一步重新推演。Tab 选择控件,Enter/空格操作按钮;图内方向键平移,手机可横向滑动。

正在准备默认算例。下方例题包含完整源码与逐步解释。

第 1 步

先预测,再前进一步

下一阶段等的是数据、执行资源,还是缓冲区?

输入、边界、状态变化

完整文字推演与当前数据
    静态推演与完整文字(便于对照、打印)
    观察 · 推演

    输入与输出的可用时刻

    host1 2 3device输出不可读01 / 03 · PIPELINEhost1 2 3device输出不可读01 / 03 · PIPELINE
    准备输入

    host输入存在,逻辑设备存储还未上传。

    1 / 3
    阅读完整推演文字
    1. 准备输入

      host:1 2 3;device:空;输出:不可读

      host输入存在,逻辑设备存储还未上传。

    2. 完成上传

      host:1 2 3;device:1 2 3;状态:uploaded

      device有自己的输入副本,随后才能计算。

    3. 计算与回传完成

      device:2 4 6;host结果:2 4 6;状态:complete

      device先算成2、4、6,回传完成后host结果可读。

    跟着例子,走完一遍

    例题 01C++20 · 本机可运行

    显式执行状态防止过早读结果

    host=[1,2,3],CPU模型的设备任务为每元素乘二。

    1. 未上传时运行被拒绝。
    2. 上传复制到单独存储,运行完成后才能回传。
    3. 验证结果[2,4,6],并确认原输入未改变。
    29-a.cpp
    下载
    #include <iostream>
    #include <stdexcept>
    #include <vector>
    class DeviceModel {
        enum class State {empty,uploaded,complete};State state=State::empty;
        std::vector<int> memory;
    public:
        void upload(const std::vector<int>& input) {memory=input;state=State::uploaded;}
        void run() {
            if(state!=State::uploaded) throw std::logic_error("input not ready");
            for(auto& x:memory) x*=2;state=State::complete;
        }
        std::vector<int> download() const {
            if(state!=State::complete) throw std::logic_error("output not ready");return memory;
        }
    };
    int main() {
        DeviceModel device;bool rejected=false;
        try {
            const auto unexpected=device.download();
            std::cout<<"unexpected early result size="<<unexpected.size()<<'\n';
            return 1;
        } catch(const std::logic_error&){rejected=true;}
        const std::vector<int> input{1,2,3};device.upload(input);device.run();const auto output=device.download();
        if(!rejected || output!=std::vector<int>{2,4,6} || input!=std::vector<int>{1,2,3}) throw std::runtime_error("model");
        device.upload({});device.run();if(!device.download().empty()) throw std::runtime_error("empty");
        std::cout<<"result=2,4,6 early_read_rejected=1\n";
    }

    如何编译和运行下载的 .cpp 文件 →

    结果与解释

    result=2,4,6 early_read_rejected=1

    两个vector只用于教学数据所有权;不模拟设备指令或性能。

    在本机运行这个例子

    下载后,在文件所在目录执行。需要支持 C++20 的编译器;POSIX 示例还需要章节说明中的系统条件。

    clang++ -std=c++20 -Wall -Wextra -Wpedantic -Werror -pthread 29-a.cpp -o example && ./example

    预期标准输出:

    result=2,4,6 early_read_rejected=1
    
    例题 02C++20 · 本机可运行

    双缓冲流水线的依赖模型

    三个块,三个独立阶段耗时2、5、2,缓冲区数量2。

    1. H2D必须等对应缓冲区前一次D2H完成。
    2. 计算等待本块H2D及前块计算。
    3. D2H等待本块计算及前次D2H。
    29-b.cpp
    下载
    #include <algorithm>
    #include <array>
    #include <iostream>
    #include <stdexcept>
    int main() {
        std::array<int,2> buffer_free{0,0};int upload=0,compute=0,download=0;
        std::array<int,3> finished{};
        for(std::size_t chunk=0;chunk<3;++chunk) {
            const auto slot=chunk%2;
            upload=std::max(upload,buffer_free[slot])+2;
            compute=std::max(compute,upload)+5;
            download=std::max(download,compute)+2;
            buffer_free[slot]=download;finished[chunk]=download;
        }
        if(finished!=std::array<int,3>{9,14,19}) throw std::runtime_error("dependency schedule");
        std::cout<<"serial_units="<<3*(2+5+2)<<" pipeline_units="<<download<<'\n';
    }
    结果与解释

    serial_units=27 pipeline_units=19

    这是理想化调度算式,无真实设备或延迟测量。

    在本机运行这个例子

    下载后,在文件所在目录执行。需要支持 C++20 的编译器;POSIX 示例还需要章节说明中的系统条件。

    clang++ -std=c++20 -Wall -Wextra -Wpedantic -Werror -pthread 29-b.cpp -o example && ./example

    预期标准输出:

    serial_units=27 pipeline_units=19
    
    常见错误

    异步调用返回就释放源缓冲

    H2D尚未完成便释放host输入,或kernel尚在写时复用device输出。

    修正思路:让缓冲区寿命覆盖所有未完成访问;用适当事件确认相关操作完成后再读取、修改或释放。

    轮到你动手

    先写预测或代码,再按需打开提示。完整答案用于对照自己的推理。

    练习 1

    一个异步上传引用 vector.data(),调用后立刻 push_back 导致扩容。问题在哪里?

    给我一点提示
    1. 上传什么时候不再读取源地址?
    2. vector扩容会使旧地址失效。
    查看答案与推理

    未完成上传可能访问已释放的旧存储,属于生命周期错误。上传完成前保持容量与内容稳定;或使用专门拥有且被完成事件保护的传输缓冲。

    练习 2

    理想三阶段改成2、2、2,三个块且足够缓冲,串行与完工时间各是多少?

    给我一点提示
    1. 第一块需走完三阶段。
    2. 之后每2单位能完成一块。
    查看答案与推理

    串行为18,理想流水为首块6加后两块4,共10。结果需要独立阶段资源和正确依赖;共享带宽或无法重叠会使实际时间更大。

    把理解说出来

    先用中文讲清因果,再用英文回答。问题依据技能主题编写,并非公司内部题库。

    解释

    host 与 device 指针数值一样,能直接互相解引用吗?

    参考回答 / English answer

    不能只凭数值判断,需要知道内存分配类型、平台可达性、驻留与同步合同。

    A pointer value alone does not establish accessibility. The allocation type, platform, and synchronization contract determine valid access.
    找错

    launch 返回成功是否证明计算成功?

    参考回答 / English answer

    只代表提交阶段通过了相关检查,异步执行错误还要在同步或事件完成处观察,并验证输出。

    Successful submission is not successful completion. I check completion errors and validate the produced data.
    追问

    不同 stream 的命令如何建立依赖?

    参考回答 / English answer

    使用明确事件或API提供的同步关系,不能只凭host源码顺序假设不同流互相等待。

    I use explicit events or documented synchronization. Host submission order alone does not necessarily order different streams.
    解释

    为什么尽量保留中间结果在 device?

    参考回答 / English answer

    减少反复H2D/D2H及固定传输开销,尤其对多个连续kernel;同时需要管理设备内存容量。

    Keeping intermediates on the device avoids repeated transfers. The tradeoff is device-memory capacity and lifetime management.
    预测

    本章流水线19个单位是不是某GPU的测量?

    参考回答 / English answer

    不是,来自假设独立阶段的调度模型,单位为抽象时间;真实重叠需要设备验证。

    It is an analytical schedule with assumed durations. It is not a measured GPU result.
    追问

    为什么两个 stream 未必比一个更快?

    参考回答 / English answer

    依赖、copy engine、带宽、锁页内存和任务粒度可能限制重叠;必须端到端计时。

    Streams expose concurrency but do not create extra bandwidth or engines. Dependencies and resource limits can eliminate the expected overlap.

    继续查证

    公开资料用于查证;本章图解和例题是独立教学内容。CPU 逻辑模型不能证明设备性能。

    接着看已有的图解

    这些资料按主题补充本章内容。