CHAPTER 30 / 架构、性能与并行

可信测量、原始样本与性能模型

一次最快的耗时能代表改进吗,如何把瓶颈假设变成可复查的实验?

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

这一章要弄清楚

  • 界定计时区间并保留全部样本
  • 区分 warmup、分布和异常值
  • 用 Amdahl 与 roofline 估算上限

先备知识:测试与调试:独立定位失败 / CPU cache、缓存一致性与 SIMD

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

先定义一次测量包含什么

假设你把 reduction 的耗时从 10 毫秒变成 6 毫秒。若旧版本包含分配和数据生成,新版本只计求和,就无法说明算法快了多少。计时边界是实验定义的一部分:端到端时间包含用户真正等待的准备、计算、传输和同步;kernel 时间只描述其中一段。两者都有价值,但不能混用分母。

选择单调时钟 steady_clock 测量经过时间,避免系统校时导致倒退。记录输入规模、数据类型、编译器、优化选项、线程数、设备和电源条件。测试先验证输出,再讨论速度。不能让编译器把没有可观察结果的循环删除,因此结果需进入校验或输出;对非常小的代码仍应检查汇编,必要时采用专门的 benchmark 屏障工具。

warmup 要提前定义

第一次执行可能包含页面首次触碰、缓存预热、动态加载或工具链初始化。先做明确次数的预热可以研究稳态表现,但如果真实用户每次只执行一次,那么首次延迟也必须单独报告。不要看见慢样本后临时决定“这次算预热”,那会把统计变成挑数据。第一个例子用标为教学输入的固定耗时序列练习统计:90 是预先指定的 warmup,之后的 10、11、9、100、10 全部保留。

五个正式样本的中位数是 10,平均值是 28。慢的 100 可能来自调度,也可能是真实尾延迟;未经诊断不能直接丢弃。中位数描述典型样本,均值受长尾影响,p95 在只有五次测量时解释力很有限。应保留原始顺序与样本数,报告离散程度,并在任务需要时增加重复。只报告最小值容易描绘一个用户很少遇到的世界。

可运行测量的诚实边界

iota先填好已有元素,之后才计时(补充,另估15分钟)

26-b的元素类型先读SYS-00 S1固定宽度整数;这里再读输入生成接口。

<numeric>中的std::iota(first,last,value)向已经存在的半开范围逐项赋值,每写一项就把内部value加1;它不返回求和结果,不创建或扩容vector。长度3、初值1时,依次写位置0=1、位置1=2、位置2=3,得到[1,2,3]。空范围不写任何元素。初值自身也会递增,须让当前类型下的递增和目标赋值都符合范围合同,不能只检查最后一个保存值。iota合同

26-b先用数量构造建立100000个元素,再从1ULL填到100000,之后才计求和。ULL是unsigned long long字面量后缀,让本调用的递增值使用该无符号类型;这里的数值与一次后续递增均可表示。这组有界整数的总和100000×100001÷2为5000050000。把长度3的容器改成只reserve(3)而不创建元素,得到的仍是空范围,不会神奇地出现1、2、3。

按已有时间点合同读纳秒样本(补充,另估5分钟)

先复用NET N6的now、时间点差值、duration_cast与count,再读26-b和Reduction Lab的完整计时链。std::chrono::nanoseconds以十亿分之一秒为单位;给定差值是2微秒,转换为纳秒再count得到2000。这个数字只是单位换算,不是本次程序的耗时。输出单位为纳秒也不保证时钟拥有1纳秒分辨率或测量准确度;空输入仍有调用与计时开销,不能预设耗时为0。

第二例实际计时五次 CPU 求和,默认仅输出稳定的样本数和结果校验;传入 --csv 会把真实纳秒样本输出为 CSV。保存方式例如编译后运行 ./26-b --csv > samples.csv。这些样本每次不同,不在教材中伪造固定耗时。这里的输入规模很小,示例用于学习计时与归档机制,不能作为跨机器排名或性能宣传。

把seed、引擎和分布分开记录(补充,另估20分钟)

包含<random>后,std::mt19937 rng(7);建立一个用整数种子7初始化的伪随机引擎。rng保存状态;给同一状态和操作序列,会继续生成确定的结果,不是每次调用都重新从7开始。std::uniform_int_distribution<int> value(-2,2);指定输出int的闭区间[-2,2],两端都可能出现;value(rng)请求一个样本,并消耗、推进引擎状态。一个样本可能消耗不止一个底层引擎输出,不把两者次数硬绑。引擎整数分布

先建立5个已有int元素,再对每个元素执行一次x=value(rng),记录得到的五项。重建另一个种子7的引擎与同样分布,在相同工具链/标准库环境下重做,五项应逐项相同且每项都在[-2,2];继续用原rng生成下一组则不会自动回到第一组。这里不预写五个跨平台固定答案。一个可直接预测的边界是把分布两端都设成2:五项均为2;空容器执行零次取样,不推进引擎。下限不能大于上限。

Reduction Lab将这组配置换成seed=20260905、闭区间[-1000000,1000000],读法相同。复现需记录引擎名称、seed、分布参数和标准库环境,并保存实际输入;只给相同seed不保证不同标准库的分布映射产生完全相同的序列。随机用例还要与手写空输入、单元素及范围边界分开保存,iota产生的递增序列也不是随机数据。分布算法的实现范围

下面的完整文件把两组engine和distribution分别命名为first/again、first_value/again_value,都从seed=7和区间[-2,2]开始;两套状态各自推进,逐项检查两组输出相同且没有越界。

#include <cstddef>
#include <iostream>
#include <random>
#include <vector>

int main() {
    std::mt19937 first{7};
    std::mt19937 again{7};
    std::uniform_int_distribution<int> first_value{-2, 2};
    std::uniform_int_distribution<int> again_value{-2, 2};
    std::vector<int> values(5);
    std::vector<int> repeated(5);
    bool in_range{true};
    bool same{true};
    for (std::size_t i{0}; i < values.size(); ++i) {
        values[i] = first_value(first);
        repeated[i] = again_value(again);
        if (values[i] < -2 || values[i] > 2 || repeated[i] < -2 || repeated[i] > 2)
            in_range = false;
        if (values[i] != repeated[i]) same = false;
    }
    std::cout << "first:";
    for (int value : values) std::cout << ' ' << value;
    std::cout << "\nagain:";
    for (int value : repeated) std::cout << ' ' << value;
    std::cout << "\ncount=" << values.size() << " repeat=" << same << " in-range=" << in_range << '\n';
    return values.size() == 5 && repeated.size() == 5 && same && in_range ? 0 : 1;
}

下载这份同源seeded-input.cpp。程序输出三行:第一行是first:及五个整数,第二行是again:及五个整数,第三行应为count=5 repeat=1 in-range=1。前两行的五项在本次同实现内逐项相等;本页不固定跨标准库的五个数。最终返回0还要求两组长度都为5、重复与范围检查均通过,不能只看打印了三行就宣布正确。

正式实验要增加规模与重复,报告计时器开销,避免把 sanitizer 运行与优化 release 运行相比较。温度、频率、后台程序与线程亲和性都可能改变结果。改动前后交替测量可以减弱时间漂移影响;固定随机种子保证数据可复现,但也要测试多种分布。

Amdahl 先估算最大收益

若总时间的 80% 可以加速四倍,剩下 20% 不变,新时间比例是 0.2 + 0.8/4 = 0.4,整体加速为 2.5 倍。即使那 80% 无限快,上限也只有 5 倍。这个模型假定问题规模固定、加速比例可信,并没有自动包含线程创建、通信或竞争新增的代价。把这些开销加回总时间,才是实际方案。

Roofline 则比较计算能力与数据搬运能力。算术强度 I 是每搬运一字节执行多少运算,性能上限可写成 min(峰值算力, 带宽 × I)。使用哪层带宽、如何计 FLOP、读写多少字节都必须说明。reduction 通常数据流量大而每元素运算少,因此优化数据移动常比减少一次整数索引更值得优先验证。模型提出上限和假设,真正测量检验这些假设,两者不能互相冒充。

观察 · 推演

把预热与正式样本分开

warmup90正式样本尚无01 / 03 · TIMELINEwarmup90正式样本尚无01 / 03 · TIMELINE
预定预热

首次 90 被明确标成预热,未混入正式统计。

1 / 3
阅读完整推演文字
  1. 预定预热

    warmup:90;正式样本:尚无

    首次 90 被明确标成预热,未混入正式统计。

  2. 采集五次

    raw:10 11 9 100 10;样本数:5;长尾:100

    慢样本 100 仍保留,不能事后删除。

  3. 计算统计

    median:10;mean:28;原始记录:完整保留

    排序只用于求中位数,原始顺序另存。

跟着例子,走完一遍

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

固定样本练习统计与 Amdahl

预热 90,正式样本 10、11、9、100、10;可优化部分占 80%,加速 4 倍。

  1. 预热样本按预定规则排除。
  2. 保留全部正式样本,计算中位数与均值。
  3. 用 1/(0.2+0.8/4) 求整体上限。
26-a.cpp
下载
#include <algorithm>
#include <cmath>
#include <iostream>
#include <numeric>
#include <stdexcept>
#include <vector>
int main() {
    const std::vector<double> raw{90,10,11,9,100,10};
    std::vector<double> samples(raw.begin()+1,raw.end());
    const double mean=std::accumulate(samples.begin(),samples.end(),0.0)/static_cast<double>(samples.size());
    std::sort(samples.begin(),samples.end());const double median=samples[samples.size()/2];
    const double speedup=1.0/(0.2+0.8/4.0);
    if(median!=10 || mean!=28 || std::abs(speedup-2.5)>1e-12) throw std::runtime_error("statistics");
    std::cout<<"median="<<median<<" mean="<<mean<<" speedup="<<speedup<<'\n';
}

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

结果与解释

median=10 mean=28 speedup=2.5

输入是教学数字,不是本机性能数据;中位数与均值回答不同问题。

在本机运行这个例子

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

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

预期标准输出:

median=10 mean=28 speedup=2.5
例题 02C++20 · 本机可运行

保存真实 CPU 求和样本

数组为 1..100000;预热一次,正式运行五次,每次把首元素增加 trial。

  1. 计时只覆盖 accumulate,初始化在区间外。
  2. 每次核验结果,保存纳秒样本而非最快值。
  3. 默认输出确定性校验;--csv 导出实际样本。
26-b.cpp
下载
#include <chrono>
#include <cstdint>
#include <iostream>
#include <numeric>
#include <stdexcept>
#include <string_view>
#include <vector>
int main(int argc,char** argv) {
    if(argc>2 || (argc==2 && std::string_view(argv[1])!="--csv")) throw std::invalid_argument("use --csv");
    std::vector<std::uint64_t> values(100000);std::iota(values.begin(),values.end(),1ULL);
    constexpr std::uint64_t baseline=5000050000ULL;
    if(std::accumulate(values.begin(),values.end(),0ULL)!=baseline) throw std::runtime_error("warmup");
    std::vector<long long> samples;std::uint64_t checksum=0;
    for(std::uint64_t trial=0;trial<5;++trial) {
        values[0]=1+trial;
        const auto start=std::chrono::steady_clock::now();
        const auto sum=std::accumulate(values.begin(),values.end(),0ULL);
        const auto stop=std::chrono::steady_clock::now();
        if(sum!=baseline+trial) throw std::runtime_error("sum");
        samples.push_back(std::chrono::duration_cast<std::chrono::nanoseconds>(stop-start).count());
        checksum+=sum;
    }
    if(checksum!=25000250010ULL) throw std::runtime_error("checksum");
    if(argc==2) {
        std::cout<<"trial,elapsed_ns\n";
        for(std::size_t i=0;i<samples.size();++i) std::cout<<i<<','<<samples[i]<<'\n';
    } else std::cout<<"samples="<<samples.size()<<" checksum="<<checksum<<'\n';
}
结果与解释

samples=5 checksum=25000250010

运行确实读取单调时钟;样本不被写死,真实数据仅在 --csv 模式输出。

在本机运行这个例子

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

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

预期标准输出:

samples=5 checksum=25000250010
常见错误

比较不同计时边界并只保留最快一次

旧版计入分配,新版不计;丢掉全部慢样本后声称稳定提速。

修正思路:固定比较范围,预先定义预热,保存全部样本及环境;同时报告端到端与核心计算时间。

轮到你动手

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

练习 1

90% 的工作加速到 8 倍,剩下 10% 不变,整体加速是多少?若并行新增原时间的 5% 开销呢?

给我一点提示
  1. 新时间比是串行部分加缩短后的并行部分。
  2. 新增开销加在分母。
查看答案与推理

无新增开销为 1/(0.1+0.9/8)=约4.706;再加0.05为1/0.2625=约3.810。说明只优化局部不能直接把8倍写成整个程序收益。

练习 2

一个算法每元素读 4B、写 4B、做 2 FLOP。带宽 100 GB/s,峰值 2 TFLOP/s,简化 roofline 上限是多少?

给我一点提示
  1. 强度单位是 FLOP/byte。
  2. 统一十进制单位。
查看答案与推理

强度为2/8=0.25 FLOP/B,带宽上限100×0.25=25 GFLOP/s,小于2000 GFLOP/s。该模型忽略额外流量与缓存复用,需明确这8B是所研究层级的流量。

把理解说出来

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

解释

为什么用 steady_clock?

参考回答 / English answer

测经过时间需要单调时钟,不能被墙上时间调整影响。精度和开销仍需考察。

Elapsed-time measurement needs a monotonic clock. I still check its resolution and measurement overhead.
找错

最小耗时能代表用户体验吗?

参考回答 / English answer

通常不能,最小值偏向最佳条件,不能描述中位数和尾部;报告分布与样本数。

The minimum describes a best observed case. It does not characterize typical or tail latency.
追问

为什么预热次数必须提前决定?

参考回答 / English answer

事后把慢样本归为预热会选择性过滤数据;冷启动与稳态应分别定义并报告。

A predefined warmup policy prevents selective removal of slow samples. Cold-start and steady-state behavior answer different questions.
预测

80% 部分无限加速,整体能到多少?

参考回答 / English answer

固定规模且其余20%不变时上限5倍;新增并行开销会进一步降低实际收益。

With twenty percent unchanged, the ideal limit is five times. Additional parallel overhead reduces the achievable gain.
解释

roofline 的算术强度如何确定?

参考回答 / English answer

以所研究内存层级的数据流量为分母,运算数为分子;缓存复用会改变不同层级的强度。

Arithmetic intensity is work divided by data movement at a specified memory level. Reuse can change that ratio across the hierarchy.
找错

输出正确就能保证计时循环没有被优化掉吗?

参考回答 / English answer

仍不完全保证,编译器可能折叠已知计算或搬移工作;保持运行时输入、检查汇编并使用合适 benchmark 工具。

An observable result helps but is not a complete optimizer barrier. I use runtime inputs and inspect the generated code for microbenchmarks.

继续查证

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

接着看已有的图解

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