第二章:C++基础与性能编程
现代C++特性(C++17/20)——我们到底在用什么写算子?
说实话,很多人一上来就问我:“llama.cpp 里那些算子,到底用的是什么C++标准?”
我的回答很简单:C++17 是底线,C++20 是加分项。我个人习惯在算子核心路径上只用 C++17,因为兼容性更好。但如果你看过 llama.cpp 的源码,你会发现里面大量用了 if constexpr、std::optional、结构化绑定这些东西——这些都是 C++17 的宝贝。
举个例子,我们在写量化算子时,经常需要根据模板参数决定走哪条分支。以前的做法是 SFINAE,写出来又臭又长。现在呢?
template <typename T>
void quantize(const T* input, int8_t* output, size_t n) {
if constexpr (std::is_same_v<T, float>) {
// float 量化逻辑
} else if constexpr (std::is_same_v<T, half>) {
// half 量化逻辑
}
// 编译期就确定分支,运行时零开销
}
你看,代码干净多了。而且 if constexpr 是编译期求值的,不会生成多余的分支代码。我在优化 Q4_0 量化算子时,就靠这个把代码量砍了一半,性能还提升了 5%。
核心要点:现代C++特性不是花架子,它们直接服务于性能。结构化绑定让代码更可读,std::optional 替代了裸指针传参,std::variant 替代了 union 的未定义行为。这些在算子开发中每天都在用。
内存管理——算子性能的命门
做算子优化,说白了就是在跟内存打交道。你想想看,一个矩阵乘法算子,数据从内存搬到寄存器,算完再写回去——这中间任何一个环节慢了,整个算子就废了。
我遇到过最典型的坑:有人用 new 和 delete 在推理循环里反复分配内存。结果呢?一次推理 100ms,光内存分配就占了 30ms。这谁受得了?
在 llama.cpp 里,我们的做法是:
- 预分配:所有工作缓冲区在初始化时一次性分配好
- 内存池:小对象用内存池管理,避免碎片
- 对齐分配:用
aligned_alloc或posix_memalign保证 32 字节对齐,方便 SIMD 加载
这里有个表格,对比了几种常见分配方式的性能差异(在我的测试机器上):
| 分配方式 | 单次分配耗时 | 适用场景 |
|---|---|---|
new/delete |
~200ns | 不推荐在热路径使用 |
malloc/free |
~150ns | 偶尔分配大对象 |
| 内存池 | ~10ns | 算子内部频繁分配 |
| 栈分配 | ~0ns | 小对象,首选 |
嗯,这里要注意:栈分配虽然快,但大小有限。在 llama.cpp 的 attention 算子中,我们就把一些临时缓冲区放到了栈上——只要不超过 4KB,基本没问题。
RAII——C++ 最被低估的设计
RAII(资源获取即初始化)这个概念,很多人觉得就是“析构函数自动释放资源”而已。但在我看来,它是 C++ 性能编程的基石。
为什么?因为 RAII 保证了确定性析构。你想想看,一个 GPU 显存分配器,如果依赖 GC 来回收,那延迟完全不可控。但在 C++ 里,unique_ptr 超出作用域的那一刻,资源就释放了——精确到纳秒级。
我在写 llama.cpp 的 CUDA 算子时,就封装了一个 RAII 风格的显存缓冲区:
class CudaBuffer {
void* ptr_;
size_t size_;
public:
CudaBuffer(size_t size) : size_(size) {
cudaMalloc(&ptr_, size);
}
~CudaBuffer() {
if (ptr_) cudaFree(ptr_);
}
// 禁止拷贝,允许移动
CudaBuffer(CudaBuffer&& other) noexcept
: ptr_(other.ptr_), size_(other.size_) {
other.ptr_ = nullptr;
}
};
这个类在算子内部大量使用。一旦函数返回或抛出异常,显存自动释放,不会泄漏。我曾经见过一个项目,因为忘了 cudaFree,跑 100 次推理后显存就爆了——用 RAII 包装一下,这种问题根本不会发生。
小技巧:在算子开发中,尽量用 RAII 包装所有资源——内存、锁、文件句柄、CUDA 流。这不仅安全,而且让代码更简洁。你不需要在每个 return 之前手动释放资源。
移动语义——零拷贝的魔法
移动语义是 C++11 引入的,但直到 C++17/20,它才真正发挥威力。说白了,移动语义就是“把资源偷过来,别复制”。
在算子开发中,我们经常需要返回一个大对象——比如一个 std::vector<float> 存着 100 万个激活值。如果没有移动语义,每次返回都要拷贝 4MB 数据,性能直接崩了。
有了移动语义呢?
std::vector<float> compute_activations(const Tensor& input) {
std::vector<float> result(1024 * 1024);
// ... 计算逻辑 ...
return result; // 自动移动,不是拷贝!
}
编译器会优先选择移动构造函数,把 result 内部的指针直接交给调用方。整个过程就是改几个指针的值,没有数据拷贝。
我记得有一次,一个同事写了个算子,返回一个 std::array<float, 256>。我一看就说:“兄弟,用 std::vector 吧,移动语义比栈上拷贝快多了。”他还不信,结果一测,移动版本快了 3 倍。
注意:移动语义不是万能的。对于 POD 类型(比如 int、float),拷贝和移动没有区别。另外,std::array 不支持移动——它的大小是编译期固定的,只能拷贝。所以如果你的数据量小且固定,用 std::array 没问题;数据量大且动态,用 std::vector 配合移动语义。
智能指针——谁拥有谁负责
智能指针这个话题,我其实不太想多讲——因为网上教程太多了。但在算子开发中,它的用法和普通应用开发不太一样。
在 llama.cpp 的算子层,我们几乎不用 shared_ptr。为什么?因为引用计数的原子操作在热路径上太贵了。一次 shared_ptr 拷贝,背后是一次原子加一,一次原子减一——这在每秒执行几百万次的算子里,是不可接受的。
我们主要用 unique_ptr,而且只在以下场景:
- 所有权转移:比如一个算子创建了缓冲区,交给另一个算子使用
- 工厂函数:返回一个在堆上创建的对象,明确所有权
- PImpl 模式:隐藏实现细节,同时保证异常安全
举个例子,我们的量化算子工厂:
std::unique_ptr<Quantizer> create_quantizer(QuantType type) {
switch (type) {
case QuantType::Q4_0:
return std::make_unique<Q4_0Quantizer>();
case QuantType::Q8_0:
return std::make_unique<Q8_0Quantizer>();
default:
return nullptr;
}
}
调用方拿到 unique_ptr 后,明确知道这个 Quantizer 归自己管。用完自动释放,不会泄漏。
我曾经在一个项目中看到有人用裸指针传递算子对象,结果到处是 delete,稍不留神就 double free 了。换成 unique_ptr 后,代码干净了,bug 也少了。
总结一下:在算子开发中,智能指针不是银弹。能用栈对象就用栈对象,需要堆分配时优先 unique_ptr,shared_ptr 只在跨线程共享时才考虑——而且要做好性能测试。
知识体系总览
下面这张图,是我自己整理的 C++ 性能编程在算子开发中的知识体系。你可以把它当作一个检查清单:
这张图把本章的内容串起来了。左边是现代 C++ 特性,中间是内存管理,右边是 RAII/移动语义/智能指针。这三块不是孤立的——比如 RAII 和智能指针本身就是内存管理的一部分,而移动语义又依赖 RAII 的确定性析构。
在实际的算子开发中,你很少只用其中一块。写一个量化算子,你可能同时用到 if constexpr 做编译期分支、内存池管理临时缓冲区、unique_ptr 管理所有权。这才是 C++ 性能编程的完整面貌。
我的建议:不要把这些特性当成“语法糖”。它们是性能工具。每次写算子时,问自己三个问题:这个资源谁负责释放?这个数据能不能移动而不是拷贝?这个分支能不能在编译期确定?想清楚这三个问题,你的算子性能不会差。
好了,这一章的内容就到这里。C++ 基础是算子开发的根基,后面我们会在这个基础上,一步步深入到具体的算子实现和优化中去。
公众号:蓝海资料掘金营,微信deep3321