Mini-Infer 架构深潜 (1): 构建高性能、可扩展的 Tensor 基石

引言:地基的设计哲学

在任何深度学习推理框架中,Tensor (张量) 都是其绝对的核心。它不仅是数据的载体,其设计本身也直接决定了框架的性能、内存效率和可扩展性(例如,CPU 到 GPU 的移植)。

Mini-Infer 项目的开篇,正是从构建这一核心基石开始。一个专业级的 Tensor 设计必须优雅地解决三个核心问题:

  1. 内存管理 (Allocation): Tensor 的数据存在哪里?它如何被安全、高效地申请与释放?
  2. 数据描述 (Description): Tensor 的形状 (Shape) 和数据类型 (DataType) 如何被精确表达?
  3. 资源所有权 (Ownership): Tensor 在C++中如何被传递和管理?它的拷贝、移动语义是怎样的?

本文将深度剖析 Mini-Infer foundational (基础) 层的设计,分析其如何通过 C++ 的现代特性,为这三个问题提供了健壮且高性能的答案。


1. 内存解耦:Allocator 抽象层

Tensor 设计的第一个挑战是避免“硬编码”内存管理。若在 Tensor 构造函数中直接调用 newmalloc,该 Tensor 将被永久焊死在 CPU 上,框架也将失去异构计算的能力。

Mini-Infer 的解决方案是 Allocator 抽象

1.1. Allocator 接口 (Strategy 模式)

allocator.h 中定义了一个纯虚基类 Allocator,它采用了经典的策略模式 (Strategy Pattern),将“分配内存”这一行为抽象为接口。

1
2
3
4
5
6
7
8
9
10
11
// mini_infer/core/allocator.h
namespace mini_infer {
namespace core {

class Allocator {
public:
virtual ~Allocator() = default;
virtual void* allocate(size_t size) = 0;
virtual void deallocate(void* ptr) = 0;
virtual size_t total_allocated() const { return 0; }
};

这个设计的扩展性极强。Tensor 类将依赖于这个抽象接口,而不是任何具体实现。未来实现 CUDAAllocatorVulkanAllocatorMetalAllocator 时,Tensor 的核心代码无需修改。

1.2. CPUAllocator 实现 (Singleton 模式)

CPUAllocator 是该接口的第一个具体实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// mini_infer/core/allocator.h
class CPUAllocator : public Allocator {
public:
void* allocate(size_t size) override;
void deallocate(void* ptr) override;

static CPUAllocator* get_instance(); // <-- Singleton

private:
CPUAllocator() = default;
size_t total_allocated_{0};
};

// mini_infer/core/allocator.cpp
void* CPUAllocator::allocate(size_t size) {
void* ptr = std::malloc(size);
// ...
return ptr;
}

void CPUAllocator::deallocate(void* ptr) {
if (ptr) {
std::free(ptr);
}
}

CPUAllocator* CPUAllocator::get_instance() {
static CPUAllocator instance; // <-- Meyers' Singleton
return &instance;
}
  • Meyers’ Singleton: get_instance() 内部的 static 实例是 C++ 中最简洁、线程安全的单例实现。它确保了全局只有一个 CPUAllocator 实例,用于统一管理所有 CPU 内存。
  • malloc / free: Allocator 分配的是原始字节块 (raw byte buffers),而非 C++ 对象,因此使用 std_mallocstd::free 是最恰当的选择。

2. 数据描述:ShapeDataType

Tensor 拿到了内存,但如何“解释”这块内存?ShapeDataType 类提供了必要的元数据。

2.1. DataType (tensor.h)

DataType 使用 enum class (强类型枚举) 定义,确保了类型安全,避免了 C 风格 enum 的隐式整数转换。

1
2
3
enum class DataType {
FLOAT32, FLOAT16, INT32, INT8, UINT8, BOOL
};

该列表覆盖了推理所需的主流类型,包括 32 位浮点、16 位浮点和 8 位整数量化。

2.2. Shape 的性能权衡 (tensor.h)

Shape 类的设计体现了高性能库的一个关键权衡:运行时性能 vs. 编译期纯粹性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// mini_infer/core/tensor.h
class Shape {
public:
Shape() = default;
explicit Shape(const std::vector<int64_t>& dims); // <-- 关键

int64_t operator[](size_t index) const;
size_t ndim() const { return dims_.size(); }
int64_t numel() const; // <-- 关键
// ...
private:
std::vector<int64_t> dims_;
};

// mini_infer/core/tensor.cpp
int64_t Shape::numel() const {
if (dims_.empty()) return 0;
// 使用 1LL 确保累积从 int64_t 开始
return std::accumulate(dims_.begin(), dims_.end(), 1LL, std::multiplies<int64_t>());
}
  • explicit 构造函数: explicit 关键字至关重要。它禁止了从 std::vectorShape 的隐式类型转换,防止了编译器在开发者意料之外创建临时的 Shape 对象,规避了潜在的 bug 和性能陷阱。
  • 不使用 Pimpl (Pointer to Implementation): dims_ (一个 std::vector) 被直接作为 Shape 的成员。Shape 是一个会被高频访问的“热点”对象,若使用 Pimpl 惯用法,每次访问 ndim()numel() 都会引入一次不必要的堆指针解引用。Mini-Infer 在此做出了正确的性能选择:牺牲编译期的解耦(Pimpl 的好处),换取运行时的极致性能
  • numel() 溢出安全: numel (Number of Elements) 的实现非常严谨。它使用 std::accumulate 并且初始值设为 1LL (64-bit long long)。这能强制整个乘法累积过程在 64 位精度下进行,有效防止了在处理大 Tensor (如 2GB float Tensor 元素数已超 32-bit int 上限) 时的整数溢出。

3. Tensor:联结一切的 RAII 资源管理器

Tensor 类是 Mini-Infer 基础库的“集大成者”。它将 Allocator, Shape, 和 DataType 组装在一起,并提供了完美的 C++ 资源管理。

3.1. 所有权语义:移动而非拷贝

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// mini_infer/core/tensor.h
class Tensor {
public:
// ... 构造与析构 ...
~Tensor() = default;

// Disable copy, allow move
Tensor(const Tensor&) = delete;
Tensor& operator=(const Tensor&) = delete;
Tensor(Tensor&&) noexcept = default;
Tensor& operator=(Tensor&&) noexcept = default;

// ... Accessors ...
private:
Shape shape_;
DataType dtype_{DataType::FLOAT32};
std::shared_ptr<void> data_; // <-- 核心
};

Tensor 的设计严格遵循了 C++ 的“Rule of Five”。

  • = delete (拷贝): Tensor 是一个资源管理类,它独占一块内存。拷贝 Tensor(深拷贝)是一个极其昂贵且通常非必要的操作。将其删除,可以从编译器层面防止这种昂贵的意外发生。
  • = default (移动): 启用移动语义 (noexcept 保证) 使得 Tensor 可以被高效地存入 std::vectorstd::map 以及作为函数返回值,其成本仅为几次指针交换。

3.2. 核心设计:shared_ptr<void> 与自定义删除器

Tensor 中最精妙的设计是 data_ 成员:std::shared_ptr<void> data_

这行代码完美地解决了 Allocator 抽象带来的 RAII 挑战。

挑战在于:

  1. Tensor 需要自动管理内存(RAII),因此需要智能指针。
  2. Tensor 分配的内存来自 Allocator::allocate()
  3. 这块内存必须通过 Allocator::deallocate() 释放,绝不能使用 C++ 默认的 delete

解决方案: std::shared_ptr自定义删除器 (Custom Deleter)

Tensor::allocate() 实现中,这一模式被清晰地展现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// mini_infer/core/tensor.cpp
void Tensor::allocate() {
size_t bytes = size_in_bytes();
if (bytes > 0) {
// 1. 从 Allocator 获取原始指针
void* ptr = CPUAllocator::get_instance()->allocate(bytes);

// 2. 封装 shared_ptr,并注入自定义删除器 (Lambda 表达式)
data_ = std::shared_ptr<void>(ptr, [](void* p) {
CPUAllocator::get_instance()->deallocate(p);
});

std::memset(ptr, 0, bytes);
}
}
  • std::shared_ptr<void>:
    • shared_ptr 提供了自动的、基于引用计数的生命周期管理。
    • void 提供了“类型擦除”。shared_ptr 只关心这块内存的“生命周期”,不关心它存储的是 float 还是 int
  • [](void\* p) { ... } (自定义删除器):
    • 这是 shared_ptr 构造函数的第二个参数,一个 Lambda 表达式。
    • Tensor 在此处告诉 shared_ptr:“当你(在未来)决定销毁所管理的 ptr 时,不要调用 delete,而是必须调用我提供的这个 Lambda 函数。”
    • 这个 Lambda 函数忠实地调用了 CPUAllocator::get_instance()->deallocate(p)

这是 RAII 模式与自定义内存管理结合的典范。Tensor 的析构函数 ~Tensor() 甚至不需要编写 (= default),C++ 编译器会自动确保 data_ 成员在 Tensor 销毁时被正确析构,而 data_ 的析构又会触发这个自定义删除器,从而自动、安全地将内存归还给 Allocator

结论

Mini-InferAllocatorTensor 类展示了专业 C++ 框架的扎实基础。其设计充满了深思熟虑的工程权衡:

  • 可扩展性: 通过 Allocator 接口解耦了内存平台。
  • 高性能: 通过在 Shape 上避免 Pimpl,优先保障了运行时效率。
  • 健壮性: 通过 explicit 构造函数和 numel1LL 技巧,防止了隐式转换和整数溢出。
  • 内存安全: 通过 shared_ptr<void> 和自定义删除器,实现了完美的 RAII 内存管理,杜绝了内存泄漏。