开始学 AI Infra 前,我重新补了一遍 C++ · 0xWay1 Blog

开始学 AI Infra 前,我重新补了一遍 C++

“我原以为学 AI Infra,应该先去啃 CUDA。真正开始读代码以后才发现,最先把我卡住的反而是 C++:一个指针到底指向什么?对象什么时候已经死了?std::move 到底移动了什么?为什么 Tensor 总在强调连续内存?为什么 Runtime 里到处都是锁、线程和各种看不懂的 CMake?于是我重新补了一遍 C++,只整理那些真正会在 AI Infra 代码里反复遇到的东西。”

— 次阅读

1. 对象与生命周期
目录

最近开始认真补 AI Infra 的底层基础,我又回头重新看了一遍 C++。

这篇笔记不打算覆盖完整的 C++,只整理后面看 Runtime、Tensor、CUDA 和算子代码时会反复遇到的一组基础。我自己也还在学习,未来学习到新东西也还会补充更新文章,如果哪里写得不准确,欢迎交流指正。

我们往 AI Infra 继续走,很快就会遇到这种代码:

// T 是类型参数,实例化时可替换为 float、int 等具体类型
template <typename T>
class Tensor {
public:
    T* data();              // 返回这段数据开头的地址
    const T* data() const;  // 返回数据开头的地址,但不能通过它修改数据

private:
    std::unique_ptr<T[]> storage_;  // 独占管理一段 T 数组
};

底层函数接口也常写成:

// input / weight 为输入,output 保存结果
void launch_rmsnorm(
    const half* input,
    const half* weight,
    half* output,
    int hidden_size
);

1. 对象与生命周期

int x = 10;

对这行代码,最直接的描述是“定义了一个整型变量 x,值是 10”。这个说法没错,但继续往底层看,还需要关注更多信息:x 的类型、占用空间、地址、生命周期,以及是否能通过地址访问它。再往底层走,还要考虑编译器优化是否会改变它实际存在的形式。

Object(对象)

变量在 C 里已经很常见。到了 C++,还会经常使用一个范围更宽的概念:Object(对象)。变量 x、y 都可以看成对象。

Object(对象)指程序运行过程中,占据存储并具有类型与生命周期的实体。

例如:

int x = 10;
float y = 3.14f;

x 和 y 是两个不同类型的对象。

类型不只决定“它叫什么”,还会影响它需要多少存储、这些二进制位该怎样解释、允许哪些运算,以及对象怎样创建和销毁。

sizeof

对象会占用存储空间,sizeof 用来取得类型或对象表示所占的字节数。

#include <iostream>

int main() {
    std::cout << sizeof(int) << '\n';
    std::cout << sizeof(float) << '\n';
    std::cout << sizeof(double) << '\n';
}

例如,在常见桌面平台上可能看到 sizeof(int) == 4。

但这并不意味着 C++ 规定 int 永远必须是 4 字节。

C++ 语言并不要求所有实现上的 int 都固定为 4 Bytes;基本类型的具体大小具有实现相关性。因此这篇笔记涉及底层数据宽度时,会尽量写明确的 FP32 / INT8 / 32-bit 等表述,而不把某台机器上的 sizeof(int) 当作普遍定律。

在 AI Infra 里,更常直接写明确的数据格式:FP32 是 32 bit,FP16 和 BF16 是 16 bit,INT8 是 8 bit。

地址

对象除了大小,还需要地址来确定它放在哪里。

#include <iostream>

int main() {
    int x = 10;

    std::cout << &x << '\n';
}

&x 用来取得 x 的地址,输出可能类似:

0x7ffd12345678

对应的简化内存示意如下:

内存地址
0x1000    [  x = 10  ]
0x1004    [  ...     ]
0x1008    [  ...     ]

地址用于确定一次读写操作对应的具体存储位置。

Scope(作用域)

地址描述对象位于哪里;Scope(作用域)描述名字在源代码中的可见范围。

int main() {
    int a = 1;

    {
        int b = 2;

        // 这里可以使用 a 和 b
    }

    // 这里可以使用 a
    // 但名字 b 已经不在这个作用域中
}

Scope 主要描述一个名字可以在源代码的哪些位置被使用。

Lifetime(生命周期)

Scope 与 Lifetime(生命周期)容易混淆:名字不可见不等于对象一定不存在,反过来也不成立。Lifetime 描述的是对象在运行时是否仍然存在。

下面的例子展示 Scope 与 Lifetime 分离后可能出现的问题:

// p 先不指向任何变量
int* p = nullptr;

{
    int x = 10;
    p = &x;
}

// x 的生命周期已经结束
// p 还保存着旧地址

这时候 p 这个指针变量本身还存在,但它原来指向的 x 已经不存在了。

此后再解引用 p:

std::cout << *p;

就是未定义行为。

Scope vs Lifetime

  • Scope:名字能不能在代码里写出来;
  • Lifetime:对象在运行时是不是仍然存在。

栈与堆

有了生命周期概念,栈和堆就不应再被当作判断对象存亡的唯一依据。它们描述的是常见的存储组织方式,而生命周期描述对象何时开始和结束。

常见的进程地址空间示意如下:

进程地址空间
├── Code
├── Global / Static
├── Heap
└── Stack

这个图有帮助,但它只是一个简化模型。

例如:

int main() {
    int x = 10;
}

入门阶段通常把它描述为“局部变量 x 在栈上”。

在真实优化编译后,编译器可能:

  • 让它只存在于寄存器;
  • 把它完全消掉;
  • 不真的给它分配一个传统意义上的栈槽。

悬空指针

这种“指针还在,但对象已经没了”的情况非常危险,它有一个常见名字:Dangling Pointer(悬空指针)。

// p 先不指向任何变量
int* p = nullptr;

void f() {
    int x = 42;
    p = &x;
}

int main() {
    f();
    std::cout << *p << '\n';
}

这段代码的问题就在生命周期上:p 是全局变量,所以它会存在很久。

关键在于它指向的 x。

x 是 f() 里的局部自动对象,它的生命周期会在离开对应作用域时结束。

f() 返回后,局部变量 x 的生命周期结束。

于是 p 虽然还保存着原来的地址,但该地址已经不再对应一个存活的 x。此时 p 成为 Dangling Pointer(悬空指针),解引用它属于未定义行为。

下面的程序可以同时打印变量的大小和地址:

#include <iostream>

int main() {
    int x = 10;
    double y = 3.14;

    std::cout << "sizeof(x) = " << sizeof(x) << '\n';
    std::cout << "address x = " << &x << '\n';
    std::cout << "address y = " << &y << '\n';
}

此处不需要分析两个地址之间的具体差值,重点是区分变量的值、类型、地址和生命周期。

2. 指针与引用

对象有地址,指针用于保存地址,并通过这个地址访问原来的对象。

Pointer(指针)

int x = 10;
int* p = &x;  // p 保存 x 的地址

指针与对象的关系可以画成:

p
┌────────────┐
│ 0x1000     │
└─────┬──────┘
      │
      ▼
0x1000
┌────────────┐
│ x = 10     │
└────────────┘

这组关系可以写成:x 是一个 int;&x 是 x 的地址;p 保存这个地址;*p 则通过该地址访问原来的 int 对象。

Dereference(解引用)

保存地址之后,还需要通过地址访问原对象。C++ 使用 *p 完成这件事,称为 Dereference(解引用)。

int x = 10;
int* p = &x;  // p 保存 x 的地址

*p = 99;  // 通过 p 找到 x,并把 x 改成 99

std::cout << x << '\n';

输出是 99。

原因在于 *p 并没有创建新变量,而是通过 p 保存的地址访问原来的对象。

nullptr(空指针)

指针并不一定始终指向有效对象。需要明确表示“当前没有目标对象”时,可以使用 nullptr(空指针)。

// p 先不指向任何变量
int* p = nullptr;

这表示 p 当前没有指向可供正常解引用的 int 对象。

此时不能直接解引用空指针:

*p = 42;

这是未定义行为。

数组与指针

指针最常见的使用场景之一就是数组。因为数组本来就是一串连续放在一起的数据,所以“数组从哪里开始”这个地址非常重要。

// 数组名 a 在这里可以理解成“数组第一个元素的位置”
int a[4] = {10, 20, 30, 40};

在很多表达式中,数组会转换为指向首元素的指针。

int* p = a;

因此既可以用下标访问:

p[0] == 10
p[1] == 20

也可以使用等价的指针算术:

*(p + 2) == a[2]

指针算术

指针算术用于从当前元素位置移动到数组中的其他元素位置。

对于 int* p,表达式

p + 1

表示移动到下一个 int 元素的位置。

如果当前平台上 sizeof(int) == 4,那么 p + 1 对应的底层地址通常会向后移动 4 个字节。

这就是以后 Tensor 线性索引的基础:

// 第 row 行、第 col 列,对应一维数组中的这个位置
data[row * cols + col]

指针 + 长度

只知道数组开头的位置还不够:函数还得知道数组什么时候结束。于是底层接口里经常会把“地址”和“长度”一起传进去。

// data 指向数组开头,n 表示元素个数
void scale(float* data, int n, float alpha) {
    for (int i = 0; i < n; ++i) {
        data[i] *= alpha;
    }
}

两个参数分别描述内存位置和元素数量:data 指向数组起始位置,n 表示元素个数。

工程代码中表示长度时经常使用 std::size_t。示例暂时使用 int 展示“地址 + 长度”的接口形式,后面的 vector 和 MiniTensor 会改用 std::size_t。

CUDA Kernel 也会大量使用这种设计:

kernel(const float* x, float* y, int n);

Reference(引用)

指针需要显式处理 nullptr 和解引用。如果接口语义要求对象必须存在,并且需要直接操作原对象,可以使用 Reference(引用)。

// r 是 x 的引用,也就是别名
int x = 10;  // 创建一个 int 变量
int& r = x;  // r 是 x 的引用(别名)

r = 20;  // 实际修改的是 x

std::cout << x << '\n';

Reference(引用)最简单的直觉就是:给一个已经存在的对象再起一个名字。

Pointer vs Reference

指针和引用都能让函数接触到原来的对象,但表达的意思不同。

问题T*T&
能表达“没有对象”可以用 nullptr通常不用于表达空值
能重新指向别处可以绑定后不重新绑定
访问*p直接像对象一样
常见用途数组、底层内存、可选对象必须存在的函数参数

接口设计可以按下面的语义区分:

如果参数必须对应一个已存在对象,引用 T& 往往更直接;如果允许“没有对象”,指针 T* 可以用 nullptr 表达。裸指针本身不说明资源由谁释放,这属于下一节的所有权问题。

const

指针和引用决定如何访问原对象,const 则进一步限制访问权限,把“只能读、不能改”写进类型。

指向常量

float x = 1.0f;
const float* p = &x;

这里不能通过 p 修改其指向的 float。

常量指针

float x = 1.0f;
float* const p = &x;

p 自身不能改变指向,但仍可以修改 *p。

双重 const

float x = 1.0f;
const float* const p = &x;

Kernel 的 const

在 Kernel 接口中,const 也能直接区分只读输入和可写输出。

// a、b 只负责读,out 用来写结果
void vector_add(
    const float* a,
    const float* b,
    float* c,
    std::size_t n
);

因此只看函数声明就能知道:a 和 b 是只读输入,c 用来写入输出结果。

因此函数签名本身就承担了一部分接口文档的作用。

参数传递

值、指针、引用和 const 组合后,可以得到 C++ 中几种常见的参数传递方式。

值传递

void f(int x) {
    x = 100;
}

int a = 10;
f(a);

a 不会因为函数里的 x = 100 而改变。

函数拿到的是一份自己的参数值。

引用传递

void f(int& x) {
    x = 100;
}

int a = 10;
f(a);

调用结束后,原来的 a 会变成 100,因为参数 x 是 a 的引用,也就是别名。

指针传递

void f(int* x) {
    if (x != nullptr) {
        *x = 100;
    }
}

int a = 10;
f(&a);

const T&

对于 int 这种小数据,复制一份通常没什么。但对象变大以后,复制可能很贵,于是 const T& 会非常常见:既不复制,又不允许函数修改它。

void print_tensor(const Tensor& t);

如果直接:

void print_tensor(Tensor t);

可能触发对象复制。

而:

const Tensor& t

可以:

  • 不复制;
  • 只读访问。

移动语义(Move Semantics)会进一步影响值传递的成本模型;此处只需要知道,大对象的复制可能有明显成本。

3. 内存所有权

指针解决了如何访问内存,但底层代码还必须明确资源由谁负责释放。这就是 Ownership(所有权)要解决的问题。

最直接的做法当然是动态申请:

int* p = new int(42);

使用后:

delete p;

单看这几行确实很简单。

真实项目里还会遇到提前 return、异常、对象复制、多个对象保存同一个地址、线程并发等情况。手动记住“这里要 delete”很快就会变得不可靠。

new / delete

单个对象和数组分别使用不同的释放形式:

int* p = new int(42);
delete p;

int* a = new int[100];
delete[] a;

最基本的配对规则是:new 对应 delete,new[] 对应 delete[]。

Memory Leak(内存泄漏)

手动 new / delete 最大的问题不是语法难,而是很容易漏掉释放。最典型的后果就是 Memory Leak(内存泄漏)。

// 提前 return 后,下面的 delete[] 不会执行
void f(bool early_return) {
    int* p = new int[1000];

    if (early_return) {
        return;
    }

    delete[] p;
}

当 early_return == true 时,函数提前返回,下面的 delete[] p 根本没有机会执行,于是那块动态内存没有被释放。如果程序反复这样申请内存却不释放,没用的内存会越积越多,这就是内存泄漏。

Double Free(重复释放)

忘记释放会出问题,释放两次同样会出问题,这就是 Double Free(重复释放)。

// 同一块内存不能释放两次
int* p = new int(42);

delete p;
delete p;  // 错误

同一资源释放两次会产生严重错误。

Ownership(所有权)

Memory Leak 和 Double Free 指向同一个根本问题:资源的释放责任必须明确,这就是 Ownership。

Ownership 的核心是:谁负责这块资源,谁就负责在合适的时候释放它。

例如:

float* p;

float* p 只保存地址,并没有说明 p 是否拥有这块内存,也没有说明最终由谁释放。因此大型系统必须建立清晰的所有权约定。

构造、析构与复制 / 移动

当资源被放进一个类里,对象本身的生命周期就会直接影响资源生命周期。C++ 中有几类特殊成员函数尤其重要:Constructor(构造函数)负责对象创建时的初始化,Destructor(析构函数)在对象生命周期结束时执行清理;复制构造和复制赋值决定“复制一个对象”意味着什么,移动构造和移动赋值则决定资源能不能从一个对象转交给另一个对象。

后面的资源类里会看到类似声明:

CudaBuffer(const CudaBuffer&) = delete;
CudaBuffer& operator=(const CudaBuffer&) = delete;

这里的 = delete 是在明确告诉编译器:这个类型不允许复制。对于直接管理文件、锁、CPU / GPU 内存等资源的类型,这些操作必须和 Ownership 一起设计。

RAII

明确释放责任仍然不够,资源释放还应尽量与对象生命周期绑定,而不是依赖手动记住每个 delete。现代 C++ 主要通过 RAII 实现这一点。

RAII 全称 Resource Acquisition Is Initialization,中文常译为“资源获取即初始化”。它解决的核心问题是:资源跟着对象一起自动释放。

RAII 的基本结构是:对象创建时获取资源,对象存活期间管理资源,对象销毁时自动释放资源。

Rule of Zero

Rule of Zero 是 RAII 在工程中的进一步应用:标准库类型已经能够管理资源时,就让这些类型承担资源生命周期,而不是重复手写资源管理逻辑。

例如实现一个浮点 Buffer 时会第一次正式用到 class。这里把 class 看成把一组数据与操作这些数据的函数组织在一起的类型;public、private 等访问控制细节暂不展开。

一种直接写法是手动分配:

new float[n]

更适合这里的写法是让标准容器负责资源管理:

class FloatBuffer {
public:
    explicit FloatBuffer(std::size_t n)
        : data_(n) {}

    float* data() {
        return data_.data();
    }

    const float* data() const {
        return data_.data();
    }

    std::size_t size() const {
        return data_.size();
    }

private:
    std::vector<float> data_;
};

这里真正负责内存的是 std::vector。FloatBuffer 自己不用手写释放逻辑,vector 被销毁时会自动清理它管理的内存。

因此 FloatBuffer 无需自行实现析构函数(destructor)、复制构造、移动构造、复制赋值和移动赋值这些资源管理代码。

这就是 Rule of Zero:能让标准库资源管理类型完成工作,就尽量不要自己手写资源生命周期逻辑。

裸指针的风险

下面这个类说明了所有权为什么必须明确:裸指针只保存地址,不会自动告诉编译器谁负责释放资源。

// 这个写法有风险:复制对象后,两个对象可能指向同一块内存
class BadBuffer {
public:
    explicit BadBuffer(std::size_t n)
        : data_(new float[n]) {}

    ~BadBuffer() {
        delete[] data_;
    }

private:
    float* data_;
};

问题出现在对象复制时:

BadBuffer a(100);
BadBuffer b = a;

默认复制时,最危险的是它只会把 data_ 里保存的地址也复制一份。

可能变成:

a.data_ ─┐
         ├──→ 同一数组
b.data_ ─┘

结果就是:a 销毁时释放一次,b 销毁时又对同一块内存释放一次,最终形成重复释放(Double Free)。

因此,类型如果直接拥有资源,复制和移动语义必须一起设计。

这就是 Rule of Five 背后的原因。

unique_ptr(独占智能指针)

如果一块资源确实只应该有一个负责人,C++ 可以直接用 unique_ptr 把这种关系写进类型里。

auto 让编译器根据初始化表达式推断变量类型,后面的模板部分会再展开。

#include <memory>

auto p = std::make_unique<int>(42);

unique_ptr 表达唯一所有权,因此不能直接复制:

auto q = p;  // 不允许复制

这保证同一资源不会通过复制产生第二个所有者。

std::move

unique_ptr 不允许复制,但允许通过移动(move)把所有权从一个对象转交给另一个对象。

auto p = std::make_unique<int>(42);
auto q = std::move(p);  // 把这块内存的管理权交给 q

移动以后,管理权交给 q,p 不再拥有原来的对象。

std::move 的误区

std::move 的名字容易造成误解:这个函数本身并不会直接搬运一大块数据。

从使用角度看,std::move(x) 表示后续操作可以尝试采用移动语义。

更严格地说,std::move(x) 本身只是把 x 转换成一种可以触发移动语义的形式;真正怎样转移资源,由这个类型的 move constructor(移动构造)和 move assignment(移动赋值)决定。

移动 vector

假设先创建一个很大的 vector:

std::vector<float> a(10'000'000);

直接复制:

std::vector<float> b = a;

可能需要复制大量元素;如果改成移动:

std::vector<float> b = std::move(a);

对于标准 std::vector,移动构造通常可以接管原容器管理的动态存储,而不需要逐元素深拷贝(allocator 等特殊条件会影响某些移动赋值场景)。

移动以后,a 仍然是一个有效对象,但不要假设它还保持移动前的元素内容;标准里常把这种状态称为 valid but unspecified(对象仍然有效,但里面具体剩下什么不保证)。

shared_ptr(共享智能指针)

并不是所有资源都能做到“永远只有一个负责人”。如果多个对象确实需要共同决定一份资源活多久,就会用到 shared_ptr。

// p1 和 p2 一起指向同一个对象
auto p1 = std::make_shared<int>(42);
auto p2 = p1;  // p2 和 p1 指向同一个对象

shared_ptr 表达共享所有权;只有最后一个 Owner 消失后,被管理对象才会销毁。

但它不是万能方案。

代价/风险:

  • 引用计数(记录当前有多少个 shared_ptr 一起负责这份对象);
  • 所有权关系复杂;
  • 循环引用(两个对象互相用 shared_ptr 把对方“留住”);
  • 引用计数本身能安全更新,不代表被指向的对象就能被多个线程随便同时修改。

weak_ptr(弱引用智能指针)

共享所有权也会带来新问题,例如循环引用。weak_ptr 可以观察 shared_ptr 管理的对象,但不加入“谁负责让它继续活着”的计数。

它最常见的用途之一就是打破循环引用。

所有权决策

优先直接作为值成员
├─ 可行 → 直接值对象
└─ 不可行
   ↓
检查是否需要动态生命周期
├─ 不需要 → 不做动态分配
└─ 需要
   ↓
检查是否只有一个 Owner
├─ 是 → unique_ptr
└─ 否
   ↓
确认是否确实需要共享所有权
├─ 是 → shared_ptr
└─ 否 → 重新设计 Ownership

像 T*、T&、std::span、std::string_view 这类类型通常更适合表达非拥有访问:只临时使用资源,不负责释放。

GPU Buffer

GPU Buffer 常见的接口结构如下:

// 这里只看结构:不允许复制,但可以把内部资源交给另一个对象
class CudaBuffer {
public:
    explicit CudaBuffer(std::size_t bytes);
    ~CudaBuffer();

    CudaBuffer(const CudaBuffer&) = delete;
    CudaBuffer& operator=(const CudaBuffer&) = delete;

    CudaBuffer(CudaBuffer&& other) noexcept;
    CudaBuffer& operator=(CudaBuffer&& other) noexcept;

private:
    void* ptr_ = nullptr;
    std::size_t bytes_ = 0;
};

GPU Buffer 这类资源对象常见的设计就是:禁止复制(copy),允许移动(move)。原因很简单——GPU 内存资源通常应该明确只有一个 Owner 负责释放。

这就是 C++ 所有权知识直接连接 CUDA。

4. 连续内存与数据布局

所有权明确以后,性能问题开始集中到数据布局。对于数组、Tensor 和后面的 GPU Kernel,连续内存非常重要。

裸数组的问题

裸数组仍然可以这样分配:

float* p = new float[n];

但裸数组会把释放、长度、复制、扩容和异常安全等责任重新交给调用代码。标准库已经提供了更成熟的资源管理工具。

std::vector

vector 的本质

std::vector 是会自动管理内存的动态数组。它和 C 数组最大的联系,就是元素仍然连续地排在内存里。

std:: 表示名字来自 C++ 标准库,例如 std::vector 就是标准库中的 vector。虽然可以通过 using 省略部分前缀,但本文保留 std::,便于区分标准库类型与项目自定义类型。

std::vector<float> v = {1.0f, 2.0f, 3.0f};

它在内存中的关系可以简化成:

v.data()
   │
   ▼
[1.0][2.0][3.0]

vector 也适合用来比较值传递、引用传递与 const 引用的差异。

vector 传参

三种常见写法如下:

void f1(std::vector<int> v);
void f2(std::vector<int>& v);
void f3(const std::vector<int>& v);

f1:

void f1(std::vector<int> v);

参数是一个新的 vector 对象。

如果传进来的是一个已经存在的 vector,这种写法通常会复制它的元素。

f2:

void f2(std::vector<int>& v);

这里不会复制,并且可以修改调用者的 vector。

f3:

void f3(const std::vector<int>& v);

这里不会复制,并且不能通过 v 修改对象。

因此,只读访问较大的 vector 时常写成:

const std::vector<int>&

是非常常见的接口。

连续内存与性能

vector 的连续性最直接的体现,就是可以拿到第一个元素的地址:

float* p = v.data();

可以把数据交给底层 C/C++ API。

在 CPU 代码里,vector.data() 得到的首地址可以交给底层数值函数;到了 CUDA 里,也会用类似的“设备指针(device pointer)”把数据位置交给 GPU Kernel。

更重要的是:连续数据通常更容易利用 Cache 与 SIMD。

size vs capacity

vector 的自动增长依赖容量管理。理解重新分配之前,需要先区分 size 和 capacity。

std::vector<int> v;

std::cout << v.size();
std::cout << v.capacity();

size 表示当前实际有多少个元素;capacity 表示在不重新分配内存的前提下,当前存储最多可以容纳多少个元素。

reserve

reserve 只调整可用容量,不会创建对应数量的元素。

std::vector<int> v;
v.reserve(1000);  // 预留容量,不新增元素

reserve(1000) 的意思是把容量提升到至少能容纳 1000 个元素,但它不会创建这些元素。上面的 vector 原本为空,因此调用后 size() 仍然是 0。

resize

resize 才会真正改变当前元素数量。

v.resize(1000);  // 把元素数量调整为 1000

因此 reserve 和 resize 完全不是一回事:前者主要改容量,后者会真的改元素数量。

扩容与失效

当扩容触发重新分配时,vector 可能搬到新的内存位置,此前保存的元素地址、指针或迭代器也可能随之失效。

例如先保存一个元素地址:

std::vector<int> v;
v.push_back(1);

int* p = &v[0];

随后继续调用 push_back:

v.push_back(...);

当 capacity 不足时,vector 可能执行重新分配:

申请更大内存
↓
搬元素
↓
释放旧内存

重新分配后,p 仍保存旧内存地址,因此可能成为悬空指针。

Iterator(迭代器)

除了下标和指针,C++ 标准库还大量使用 Iterator(迭代器)表示容器中的当前位置。它是一种比裸指针更通用的位置抽象。

for (auto it = v.begin(); it != v.end(); ++it) {
    std::cout << *it << '\n';
}

最常见的遍历写法之一是:

for (int x : v) {
    std::cout << x << '\n';
}

<algorithm>

有了迭代器以后,排序这类算法就不必和某一种容器绑死。标准库的 <algorithm> 大量使用这种设计。

#include <algorithm>

std::sort(v.begin(), v.end());

C++ 标准库大量采用:

Container
   ↓
Iterator
   ↓
Algorithm

而不是每个容器都重新实现同样的算法 API。

复杂度与硬件

常见操作的复杂度可以先记成下面这样:

操作复杂度直觉
vector[i]O(1)
push_back摊销 O(1)
vector 中间插入O(n)
sortO(n log n)
unordered_map 平均查找平均 O(1)
map 查找O(log n)

性能分析还要区分算法复杂度与硬件行为:复杂度相同,真实硬件性能也可能完全不同。

例如,同样都是 O(n),连续扫描一个 vector 往往比在很多零散指针之间随机跳来跳去更符合 CPU Cache 的工作方式。

reserve 的效果

对比两种写法:

std::vector<int> v;

for (int i = 0; i < 1'000'000; ++i) {
    v.push_back(i);
}

另一种写法:

std::vector<int> v;
v.reserve(1'000'000);

for (int i = 0; i < 1'000'000; ++i) {
    v.push_back(i);
}

这两种写法在不同机器上的速度差距可能并不明显。

reserve 的主要作用是减少增长过程中重复 reallocation(重新分配)的次数。

Alignment(对齐)与 Padding(填充)

学到底层以后,仅仅知道“有几个元素”还不够,还要知道这些数据在内存里具体怎么排。这里会遇到 Alignment(对齐)和 Padding(填充)。

例如下面这个结构体:

struct A {
    char c;
    int x;
};

直觉上可能会觉得 1 + 4 = 5 bytes。

实际打印:

std::cout << sizeof(A);

但在常见平台上,sizeof(A) 很可能得到 8。

这是因为 int 通常需要满足特定的 alignment(对齐)要求。

于是编译器可能布局:

offset 0      char c
offset 1~3    padding
offset 4~7    int x

Alignment 与 GPU

往后做性能优化时,会继续遇到 SIMD 对齐加载、向量化读写、float4、Tensor 对齐和 GPU 内存访问等概念。它们背后都和“数据怎样排在内存里”有关。

因此,底层性能分析不仅要知道元素数量,还要知道数据的具体排布方式。

TensorView

假设现在有:

std::vector<float> storage(1024);

例如,只需要表示从 storage[100] 开始、长度为 200 的一段数据时,没有必要复制底层存储。

可以做一个非拥有 View:

// TensorView 只记住数据地址和长度,不负责删除这块数据
struct TensorView {
    float* data = nullptr;
    std::size_t size = 0;
};

Buffer / vector 是数据的 Owner,而 TensorView 只保存地址和长度等信息,是不负责释放数据的 View(视图)。

现代 Tensor 框架大量依赖类似思想。

std::span(C++20)

// span 只表示“一段数据在哪里、有多长”,不负责删除它
#include <span>

void scale(std::span<float> x, float alpha) {
    for (float& v : x) {
        v *= alpha;
    }
}

std::span 表达的是一段连续 T 的非拥有视图。

std::span 从 C++20 开始进入标准库。如果项目使用 C++17,先掌握最朴素的“指针 + 长度(pointer + size)”就够了。

5. 模板与编译期

AI Infra 里的同一套逻辑经常要支持 FP32、FP16、BF16、INT8 等多种类型,因此需要把“类型”本身也变成参数。

为什么需要模板

例如已有:

float add(float a, float b);

接下来可能还想支持 double、int、half、bfloat16 等类型。

如果为每种类型分别复制一份代码,会产生大量重复。模板解决的正是“逻辑相同、类型不同”的问题。

Function Template(函数模板)

函数模板把类型本身参数化:先写一份通用实现,实例化时再由编译器确定 T 对应 int、float 等具体类型。

template <typename T>
T add(T a, T b) {
    return a + b;
}

实例化时可以直接写:

int a = add(1, 2);
float b = add(1.5f, 2.5f);

模板到底做了什么

模板的核心是把类型参数化,让编译器为具体类型生成对应实现。数值库经常需要同一套逻辑支持 FP32、FP16、BF16、INT8 等数据类型,因此模板非常常见。

Class Template(类模板)

同样的想法也能放到类上。这样一个 Tensor 类就不用为 float、int 分别复制一整份代码。

template <typename T>
class Tensor {
public:
    T* data() {
        return data_.data();
    }

private:
    std::vector<T> data_;
};

因此可以分别得到:

Tensor<float> a;
Tensor<int> b;

auto

auto x = 10;      // int
auto y = 3.14f;   // float

auto 比较适合下面这些场景:

  • 类型本身已经很明显;
  • 迭代器等类型写出来很长;
  • 模板代码中具体类型依赖上下文。

但不要为了省几个字符让接口难读。

constexpr

constexpr int kBlockSize = 256;

表示它可以作为常量表达式参与编译期计算(满足相关规则时)。

以后 CUDA 模板代码中常见:

template <int BLOCK_SIZE>

就是把某些参数提前到编译期。

6. 多线程与并发

多线程让 CPU 可以同时处理更多工作,但并发的核心难点在于多个线程如何安全地访问共享状态。

std::thread

#include <iostream>
#include <thread>

void worker(int id) {
    std::cout << "worker " << id << '\n';
}

int main() {
    std::thread t1(worker, 1);  // 启动线程 1
    std::thread t2(worker, 2);  // 启动线程 2

    t1.join();  // 等待线程 1 结束
    t2.join();  // 等待线程 2 结束
}

join() 会等待目标线程执行结束。两个线程谁先输出并不保证,运行顺序由调度决定。

Lambda(匿名函数)

后面的线程、条件变量和 Benchmark 会多次出现 Lambda。它可以先理解成“临时写在当前位置的小函数”:

auto f = [] {
    std::cout << "hello\n";
};

f();

方括号 [] 叫 Capture List(捕获列表),决定 Lambda 能不能使用外部变量。最常见的三种写法是:

  • []:不捕获外部变量;
  • [=]:默认按值捕获,用到的外部值会复制进 Lambda;
  • [&]:默认按引用捕获,直接访问外部对象。

在并发代码里使用按引用捕获时,还要额外考虑对象生命周期和线程同步,不能只看语法是否能编译。

共享状态

创建线程本身并不复杂,真正需要设计的是多个线程共享并修改同一份状态时的同步关系。

当 Thread A 和 Thread B 同时访问可修改的共享数据时,就必须考虑访问之间是否会发生冲突。

Data Race(数据竞争)

如果多个线程同时访问同一个变量,并且至少有一个线程在修改,而程序又没有做好同步,就可能形成 Data Race(数据竞争)。

// 如果多个线程同时执行 ++counter,结果可能出问题
int counter = 0;

void work() {
    for (int i = 0; i < 100000; ++i) {
        ++counter;
    }
}

多个线程同时执行时,counter 的冲突访问没有同步,这会形成 Data Race(数据竞争)。在 C++ 中,Data Race 会导致未定义行为,因此它并不是“偶尔少加几个”的普通逻辑误差。

++counter 的问题

概念上可能经历:

load counter
↓
+1
↓
store counter

两个线程:

A load 10
B load 10
A store 11
B store 11

本来应该增加 2,结果只变成 11。

上面的序列只是可能的一种交错示意;语言层真正的问题是未同步的冲突访问构成 Data Race。

Mutex(互斥锁)

Mutex(互斥锁)通过限制同一时刻进入临界区的线程数量,保护共享数据免受并发修改。

// mutex 像一把锁:同一时间只让一个线程修改 counter
#include <mutex>

std::mutex m;
int counter = 0;

void work() {
    for (int i = 0; i < 100000; ++i) {
        std::lock_guard<std::mutex> lock(m);  // 进入时上锁,离开时自动解锁
        ++counter;
    }
}

lock_guard 创建时上锁,离开当前作用域时自动解锁,本质上仍然是在用 RAII 管理锁的生命周期。

Critical Section(临界区)

被锁保护起来的那小段关键代码叫 Critical Section(临界区)。

{
    std::lock_guard<std::mutex> lock(m);  // 进入时上锁,离开时自动解锁

    ++counter;
}

临界区越长,锁被占用的时间越久,其他线程等待的时间也越长。所以先保证正确,再考虑怎样减少锁竞争(contention)。

std::atomic(原子变量)

如果问题只是一个简单计数器,有时不需要给整个代码块加锁,可以使用 std::atomic。它可以让指定操作具有原子性,其他线程不会观察到这个操作只完成了一部分的中间状态。

// 对 counter 的加 1 是原子操作
#include <atomic>

std::atomic<int> counter{0};

void work() {
    for (int i = 0; i < 100000; ++i) {
        counter.fetch_add(1);  // 安全地加 1
    }
}

atomic 能保证针对这个变量的指定操作具有原子性。

但原子操作只保证相关操作的原子性,并不意味着整个程序自动线程安全。多个共享变量之间的关系仍然需要额外的同步设计。

Condition Variable(条件变量)

Mutex 解决互斥访问,Condition Variable(条件变量)则解决等待条件变化的问题:条件不满足时线程可以阻塞,条件改变后再被唤醒。

假设消费者线程现在没有任务可做。

一种低效的忙等写法是:

// 这种写法会一直循环,占着 CPU 不放
while (queue.empty()) {
}

这种循环会持续占用 CPU。更合理的方式是阻塞等待:

没任务
→ 睡眠等待

有任务
→ 生产者通知

生产者-消费者

条件变量最经典的使用场景就是生产者-消费者:一个线程往队列里放任务,另一个线程等着取任务。

下面的等待条件是:队列里有任务,或者程序已经准备结束。

// 没任务时消费者等待;生产者放入任务后再把它叫醒
#include <condition_variable>
#include <mutex>
#include <queue>

std::mutex m;
std::condition_variable cv;
std::queue<int> tasks;
bool finished = false;

void producer() {
    {
        std::lock_guard<std::mutex> lock(m);  // 进入时上锁,离开时自动解锁
        tasks.push(42);  // 放入一个任务
    }

    cv.notify_one();  // 叫醒一个正在等待的消费者
}

void consumer() {
    std::unique_lock<std::mutex> lock(m);  // 等待时会先把锁放开,被叫醒后再拿回来

    cv.wait(lock, [] {
        return !tasks.empty() || finished;
    });

    if (!tasks.empty()) {
        int task = tasks.front();
        tasks.pop();  // 把这个任务从队列里取走

        // 使用 task
        (void)task;
    }
}

这组关系可以简化成:

共享条件
→ 用 mutex 保护

条件不成立
→ wait

状态改变
→ notify

wait 与 Predicate(条件判断)

线程被唤醒并不代表 Predicate(条件判断)一定成立,因此醒来后仍然需要重新检查条件。常见情况包括:

  • 多个线程被唤醒;
  • 条件已经被其他线程改变;
  • 存在 spurious wakeup(伪唤醒,也就是线程可能在没有真正任务到来时被唤醒)。

因此,等待逻辑应以 Predicate 为准,而不能把一次 notify 直接等同于条件已经成立。

Deadlock(死锁)

多把锁之间如果形成循环等待,就会出现 Deadlock(死锁):线程彼此等待对方释放资源,最终都无法继续执行。

最典型的情况是两个线程按相反顺序拿锁:

Thread A:
lock M1
wait M2

Thread B:
lock M2
wait M1

形成:

A 等 B
↑   ↓
└───┘

工程上常见的处理方式有:

  • 统一锁顺序;
  • 少同时持多把锁;
  • 缩短临界区;
  • 用 std::scoped_lock 等组合工具。

线程并非越多越快

如果单个任务只有 1 ms,却为每个任务反复创建和销毁线程,线程管理开销本身就可能超过实际计算时间。

另外还可能有:

  • context switch(上下文切换):CPU 在不同线程之间切换也有成本;
  • Cache Interference(缓存干扰):多个线程可能互相挤占 Cache;
  • lock contention(锁竞争):线程都在等同一把锁;
  • Memory Bandwidth bottleneck(内存带宽瓶颈):数据搬运速度跟不上计算。

Runtime 通常会复用 Worker Thread(工作线程),而不是为每个任务重新创建线程,这就是 Thread Pool(线程池)的基本思路。

Runtime 中的并发

推理引擎中可能有:

Request Queue
     ↓
Scheduler Thread
     ↓
Worker
     ↓
GPU Launch

所以现在学到的互斥锁(mutex)、条件变量(condition variable)、队列(queue)和线程生命周期,后面都会在推理 Runtime 里再次出现。

7. 编译与构建

代码写对了,还不等于项目就能跑。真正的 C++ 项目通常会拆成很多 .h 和 .cpp 文件,最后还要经过编译、链接,才能变成一个可执行程序或库。

构建流程

假设项目里现在只有 main.cpp、tensor.cpp 和 tensor.h 三个文件。

整个过程粗略看起来是:

Source
  ↓
Preprocess
  ↓
Compile
  ↓
Object File
  ↓
Link
  ↓
Executable

Header(头文件)

当代码被拆到多个文件里,其他 .cpp 文件首先要知道“有这样一个函数可以用”。头文件通常用来放这种声明。

tensor.h:

#pragma once

float add(float a, float b);

它的作用是向其他 Translation Unit(编译单元,可以理解成单独参与编译的一份源代码)声明这个函数接口。

Definition(定义)

声明只是告诉编译器“这个函数长什么样”,真正做事情的代码还要有 Definition(定义)。

tensor.cpp:

float add(float a, float b) {
    return a + b;
}

真正的实现定义则放在这里。

undefined reference

例如 main.cpp 已经通过头文件知道了某个函数的声明,但链接器最后找不到真正的实现,那么编译阶段可能没问题,链接阶段却会失败。这时就可能看到很常见的 undefined reference。

静态库与动态库

常见库可以分为两类:

静态库

Linux 下静态库常见的后缀是 .a。

在链接阶段把需要的对象代码整合进结果。

动态库

Linux 下动态库常见的后缀是 .so。

运行时由动态链接机制参与加载/解析。

以后接触 CUDA / PyTorch 时,经常会见到 libcudart.so、libcublas.so、libtorch.so 这样的动态库。

ABI(二进制接口)

到了库这一层,还会遇到一个比源码更底层的兼容问题:编译后的二进制到底怎样彼此调用。这就是 ABI(Application Binary Interface,应用二进制接口)。

它关注编译后的二进制如何彼此协作,例如:

  • calling convention(调用约定):函数参数、返回值怎样传;
  • data layout(数据布局):数据在二进制层面怎样排;
  • symbol naming(符号命名):函数和变量编译后叫什么名字;
  • binary interface(二进制接口):不同编译结果怎样彼此配合。

因此即使源码看起来一样,只要编译器、标准库或二进制接口不兼容,也可能出现链接或运行问题。

CMake

项目文件增多以后,手写编译命令会迅速变得难以维护。CMake 不是编译器,它负责描述项目中的源文件、构建目标和依赖关系。

一个最小的 CMakeLists.txt 可以写成:

cmake_minimum_required(VERSION 3.20)

project(MiniTensor LANGUAGES CXX)

add_executable(app
    src/main.cpp
)

target_compile_features(app
    PRIVATE
        cxx_std_17
)

然后在命令行完成配置和构建:

cmake -S . -B build
cmake --build build

Target

大型 CMake 项目如果只理解成一堆全局 flag,很容易失去结构。更合适的视角是把每个库(library)和可执行程序(executable)都看成一个 Target(构建目标)。

例如:

# 先生成一个库,再生成 demo,最后让 demo 使用这个库
add_library(minitensor
    src/vector_add.cpp
)

target_include_directories(minitensor
    PUBLIC
        ${CMAKE_CURRENT_SOURCE_DIR}/include
)

target_compile_features(minitensor
    PUBLIC
        cxx_std_17
)

add_executable(demo
    src/main.cpp
)

target_link_libraries(demo
    PRIVATE
        minitensor
)

这里的依赖关系可以理解成:

minitensor
     ↑
     │ link
     │
    demo

PRIVATE / PUBLIC / INTERFACE

可以把三种可见性概括为:PRIVATE 只供当前 Target 使用;PUBLIC 同时作用于当前 Target 和使用它的 Target;INTERFACE 只作用于使用当前 Target 的其他 Target。

以后结合:

  • include dirs;
  • compile features;
  • link libraries;

再逐步深化。

参考资料

这篇笔记的术语和工程原则优先参考以下一手/官方资料。需要特别说明:C++ Core Guidelines 是现代 C++ 工程指南,不是 ISO C++ 标准文本本身;涉及语言最终规范时应以对应 ISO C++ 标准为准。这篇笔记用 Guidelines 主要核对资源管理、接口设计与并发工程原则。

C++ Core Guidelines

https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines

其中比较值得看的部分包括资源管理(R)、并发与并行(CP)、函数(F)以及类(C)。

后面遇到具体问题时,尤其值得回来查:

  • RAII;
  • Ownership;
  • unique_ptr;
  • lock RAII;
  • 不返回局部对象引用/指针。

Standard C++ Foundation

https://isocpp.org/

用于查 C++ 标准生态、委员会与官方社区资料入口。

CMake 官方教程

https://cmake.org/cmake/help/latest/guide/tutorial/index.html

建议真正跟着官方教程做一遍 Executable、Library、Usage Requirements、Testing、Installing 和 Dependencies。

CMake Buildsystem Manual

https://cmake.org/cmake/help/latest/manual/cmake-buildsystem.7.html

这份手册更适合进一步理解 Target、Usage Requirements 和 Transitive Properties。

← 返回博客English →
Discussion

评论

欢迎交流想法,也欢迎留下问题。

正在加载评论…

无需登录,昵称和邮箱为必填项。邮箱不会公开,仅用于身份区分与回复通知;服务端会保留必要的请求时间、IP 和设备信息,用于反垃圾、限流、封禁与安全审计。