即将从学术界进入工业界,在参与 AI Infra 相关开发时,能体会到核心难点从算法设计转移到了系统工程。在 LLM 推理与训练场景下,C++ 的不可替代性体现在对异构硬件资源的精确管理,以及海量 Tensor 数据在算子间的零拷贝传输。通过具体代码实现,探讨 Modern C++ 在 CUDA 资源 RAII 封装与对象所有权转移中的实践。
异构资源管理:从 void 到 RAII 封装*
在 Python 层的 PyTorch 中,用户只需要轻松调用 .cuda(),底层复杂的显存分配、流同步与异常处理都被封装了。但在 C++ 层开发自定义算子或推理引擎时,要直接面对 cudaMalloc 和 cudaFree。
直接裸写 cudaMalloc 在现代 C++ 工程中是 Code Review 的红线。最典型的风险场景是:在申请显存后,后续的业务逻辑(如 Kernel Launch 或文件 IO)抛出了异常。此时,Stack Unwinding 会跳过尾部的 cudaFree,导致昂贵的显存永久泄漏。在长期运行的推理服务中,这会导致 OOM 。
利用 C++11 引入的智能指针,配合 Custom Deleter,将 GPU 显存的生命周期与 CPU 端的栈对象强绑定。
定义一个无状态的 Functor 来处理资源的释放逻辑。相比函数指针,结构体 Functor 能被编译器内联优化,消除运行时开销。
cpp
#include <memory>
#include <cuda_runtime.h>
#include <stdexcept>
struct CudaDeleter {
void operator()(void* ptr) const {
if (ptr) {
cudaFree(ptr);
}
}
};
利用 using 定义智能指针别名,明确资源的所有权语义。这比直接到处写 std::unique_ptr 要清晰得多。
cpp
using CudaUniquePtr = std::unique_ptr<void, CudaDeleter>;
在实际的 Buffer 封装类中,构造函数负责资源的申请。确保如果 cudaMalloc 失败,异常能被安全抛出,且不会产生悬垂指针。
cpp
CudaUniquePtr make_cuda_buffer(size_t size) {
void* ptr = nullptr;
if (cudaMalloc(&ptr, size) != cudaSuccess) {
throw std::bad_alloc();
}
return CudaUniquePtr(ptr);
}
此时,我们完全不需要关心内存释放,无论函数是正常返回,还是中间发生了异常,buffer 变量离开作用域时都会自动触发 CudaDeleter。
cpp
void inference_pipeline() {
auto buffer = make_cuda_buffer(1024 * 1024 * 1024);
// kernel_launch(buffer.get());
if (false) {
throw std::runtime_error("Error");
}
}
零拷贝与所有权转移:移动语义
在 LLM 推理服务中,QPS 的瓶颈往往不在计算,而在数据搬运,即所谓的 Memory Wall 问题。模型权重、KV Cache 动辄占用数十 GB 内存。如果使用传统的 C++98 写法,在函数传参或将对象压入队列时,std::vector 会触发隐式的 Deep Copy。这不仅导致 CPU 占用飙升,还会引发严重的 Latency Jitter。现代C++ 的核心优势在于 Move Semantics。它允许通过窃取底层指针的方式,将资源的所有权从一个对象转移到另一个对象,整个过程仅涉及几个指针的赋值,时间复杂度为 O(1)。
在实现一个 Tensor 类时,首先要做的是显式禁用拷贝构造函数和拷贝赋值运算符。这一步至关重要,它在编译期杜绝了低效的复制行为。
cpp
class SimpleTensor {
public:
float* host_data = nullptr;
size_t size = 0;
explicit SimpleTensor(size_t s) : size(s) {
host_data = new float[size];
}
~SimpleTensor() {
if (host_data) {
delete[] host_data;
}
}
SimpleTensor(const SimpleTensor&) = delete;
SimpleTensor& operator=(const SimpleTensor&) = delete;
接下来是实现移动构造函数。关键逻辑在于将被移动对象(Source)的指针置空。如果不置空,当 Source 对象析构时会释放内存,导致 Target 对象持有的指针变成 Dangling Pointer,引发 Double Free 错误。
cpp
SimpleTensor(SimpleTensor&& other) noexcept
: host_data(other.host_data), size(other.size) {
other.host_data = nullptr;
other.size = 0;
}
移动赋值运算符的逻辑稍微复杂一些。需处理自赋值的情况(尽管很少见),并且在接管新资源之前,必须先释放当前对象持有的资源,防止内存泄漏。
cpp
SimpleTensor& operator=(SimpleTensor&& other) noexcept {
if (this != &other) {
if (host_data) delete[] host_data;
host_data = other.host_data;
size = other.size;
other.host_data = nullptr;
other.size = 0;
}
return *this;
}
};
在实际的 Pipeline 中,可以利用 std::move 来显式转交所有权。
cpp
#include <vector>
void process_queue() {
std::vector<SimpleTensor> queue;
SimpleTensor t(1000000);
queue.push_back(std::move(t));
}
在上述代码中,t 在被 push_back 之后就变成了一个空壳(Empty State),不再持有任何堆内存。这种所有权的流转非常清晰,完全符合高性能系统对零拷贝的苛刻要求。靠 Type System 而非人工约定,才是底层系统稳定性的根源。