不用PyTorch,不用CUDA,我用C++手写了一个能跑GPT和Whisper的推理库

一、先说说这玩意儿是干嘛的

现在想跑个大模型,大多数人的第一反应是装PyTorch、下HuggingFace权重、调pipeline()。这一套确实方便,但代价是:你的电脑里突然多了几个G的依赖,GPU驱动要配,CUDA版本要对,连跑个Demo都得等半天环境搭建。

这个项目干的事,就是把这些全部抛掉。只用C++标准库 ,从零实现一个能在CPU上跑Transformer推理的引擎。它支持GPT(文本生成)和Whisper(语音识别),而且权重用FP16(半精度浮点数)存储,配合Intel CPU的AVX指令集,推理速度能比纯标量实现快4倍

最狠的是,它真的零外部依赖。不链接PyTorch、不调用OpenBLAS、不依赖任何第三方库。就一个头文件+一个cpp文件,加上模型权重,就能让GPT在你的笔记本上吐文字。

二、具体含义:它到底包含哪些东西?

模块 干啥的 为什么重要
Tensor系统 自己管理多维数组,支持FP16/FP32/Int32 所有运算的底座
FP16转换器 手写半精度↔单精度的位运算转换 省一半内存,Cache能多装一倍数据
SIMD向量化 AVX指令一次算8个float 矩阵乘法的核心加速
Activation Cache 缓存中间结果,增量推理 生成第N个token时不用重算前N-1个
Embedding层 Token查表+位置编码 输入接口
LayerNorm 均值为0、方差为1的归一化 训练稳定性,推理必备
Linear层 矩阵乘法+偏置 模型的"肌肉",占绝大部分计算量
GELU激活 非线性变换 让模型能拟合复杂函数
多头注意力 Self-Attention + Causal Mask Transformer的核心
残差连接 跨层跳跃,防止梯度消失 深网络能训练的关键
采样器 Top-k + Temperature 控制输出随机性
性能分析器 每个模块单独计时 知道瓶颈在哪

三、代码实现原理:拆开来看

1. Tensor系统:shared_ptr做数据共享

这个项目的Tensor设计非常精简。它用std::shared_ptr管理底层数据缓冲区,这意味着多个Tensor可以共享同一块内存而不拷贝

cpp 复制代码
class Tensor {
    std::shared_ptr<std::byte> data_;  // 共享所有权
    int32_t shape_[3]{0, 0, 0};        // 最多3维
    Dtype dtype_{Dtype::Float32};     // FP16/FP32/Int32
    int32_t numel_{0};                 // 元素个数
    size_t storage_capacity_{0};     // 分配的内存容量
};

关键设计是storage_capacity_:Tensor创建时按最大可能尺寸分配内存,之后resize()只改shape和numel,不重新分配。这在推理时特别重要------因为每层的输出尺寸在变(token数在增长),但内存只需要申请一次。

cpp 复制代码
// 创建时按最大尺寸分配
Tensor acv_{Tensor({max_ctx, d_embed}, kFloat16)};

// 推理时只resize,不malloc
acv_.resize({n_ctx, d_embed});  // n_ctx <= max_ctx
2. FP16推理:省内存,但计算时转FP32

模型权重全部存成FP16(每个数2字节),比FP32(4字节)省一半内存。GPT Medium有3.45亿参数,FP32要1.38GB,FP16只要0.69GB。

但CPU做FP16运算很慢,所以项目里计算时先把FP16转成FP32,算完再转回去

cpp 复制代码
// FP16 -> FP32 的位运算转换(不用硬件指令,兼容所有CPU)
inline Float32 fp16_to_fp32(Float16 h) noexcept {
    const uint32_t w = (uint32_t) h << 16;
    const uint32_t sign = w & UINT32_C(0x80000000);
    // ... 一系列位运算提取指数和尾数
    return fp32_from_bits(result);
}

// FP32 -> FP16
inline Float16 fp32_to_fp16(Float32 f) noexcept {
    const float scale_to_inf = fp32_from_bits(UINT32_C(0x77800000));
    // ... 位运算压缩到16位
    return (sign >> 16) | nonsign;
}

这些转换函数全是inline的,编译器会把它直接嵌入到调用处,没有函数调用开销。

3. SIMD加速:一次算8个数

矩阵乘法是推理的瓶颈。这个项目手写了一套AVX向量化封装,核心就三个函数:

cpp 复制代码
Vec_f32x8 vec_f32x8_load(const Float16* ptr);     // 加载8个FP16,转FP32
Vec_f32x8 vec_f32x8_fma(Vec_f32x8 a, Vec_f32x8 b, Vec_f32x8 c);  // 乘加融合
void vec_f32x8_store(Vec_f32x8 val, Float16* ptr);  // 存回FP16
float vec_f32x8_sum(Vec_f32x8 val);                // 8个数求和

Linear层(全连接层)的核心计算就是这样:

cpp 复制代码
for (int ctx_idx = ctx_start_idx; ctx_idx < n_ctx; ctx_idx++) {
    for (int out_idx = 0; out_idx < d_out; out_idx++) {
        Vec_f32x8 dot_accum = vec_f32x8_setzero();
        // 每次处理8个维度
        for (int i = 0; i < d_embed; i += 8) {
            Vec_f32x8 x = vec_f32x8_load(inp_data + (ctx_idx * d_embed + i));
            Vec_f32x8 w = vec_f32x8_load(weight_data + (out_idx * d_embed + i));
            dot_accum = vec_f32x8_fma(x, w, dot_accum);  // dot_accum += x * w
        }
        float bias = fp16_to_fp32(bias_data[out_idx]);
        float res = vec_f32x8_sum(dot_accum) + bias;
        acv_data[...] = fp32_to_fp16(res);
    }
}

AVX寄存器一次能装8个float,所以内层循环步长是8。原本要循环768次,现在只要96次,理论加速比就是8倍。实际测下来大约快4倍(因为还有数据加载、转换等开销)。

4. Activation Cache:增量推理的精髓

这是让生成速度不随长度爆炸的关键。假设你已经生成了10个token,现在要预测第11个。

没有Cache时:你把全部10个token塞进模型,12层Transformer每层都算10个位置,输出10个结果,但你只关心最后一个。

有Cache时 :第一次处理prompt时,所有层的输出都存到acv_里。之后每来一个新token,每层只算最后一行

代码里每个模块都有这么一段逻辑:

cpp 复制代码
Tensor LayerNorm::forward_impl(const Tensor &inp) {
    acv_.resize({inp.size(0), inp.size(1)});
    
    if (acv_cached_) {
        // 只算新来的那个token对应的一行
        const int ctx_offset = (n_ctx - 1) * d_embed;
        // ... 对这一行做mean、std、normalize
    } else {
        acv_cached_ = true;
        // 第一次:全部算
        for (int ctx_i = 0; ctx_i < n_ctx; ctx_i++) {
            // ... 对每一行做normalize
        }
    }
    return acv_;
}

EmbeddingLinearGELUResidual都是同样的套路。MultiHeadSelfAttn稍微复杂一点,它的Cache分两部分:

  • Q/K/V的Linear投影:只算新token的投影(因为输入只多了一个token)
  • Attention计算:Q只和新token交互,但K和V要能看到全部历史
cpp 复制代码
Tensor MultiHeadSelfAttn::masked_qkv_attn(const Tensor &q, const Tensor &k, const Tensor &v) {
    if (qkv_cached_) {
        // 只算最后一行query跟所有key的分数
        qk_attn_matmul(q, k, qk_acv_, n_heads_, /*masked=*/true, /*ctx_offset=*/n_ctx-1);
        qk_softmax(qk_acv_, n_heads_, /*ctx_offset=*/n_ctx-1);
        // 只算最后一行的加权求和
        qkv_attn_matmul(qk_acv_, v, qkv_acv_, n_heads_, /*ctx_offset=*/n_ctx-1);
    } else {
        qkv_cached_ = true;
        // 全算
        qk_attn_matmul(q, k, qk_acv_, n_heads_, /*masked=*/true);
        qk_softmax(qk_acv_, n_heads_);
        qkv_attn_matmul(qk_acv_, v, qkv_acv_, n_heads_);
    }
}

ctx_offset参数告诉矩阵乘法:从哪一行开始算。之前的结果已经在acv_里了,不用动。

5. 多头注意力:三个步骤清清楚楚

注意力计算被拆成了三个独立的函数,比包成一个黑盒好懂多了:

Step 1: Q @ K^T(算分数)

cpp 复制代码
static void qk_attn_matmul(const Tensor& q, const Tensor& k, Tensor& qk_out,
                           const int n_head, const bool mask_output, int ctx_offset=0) {
    const int d_head = d_embed / n_head;
    const Float32 scale_factor = 1.0f / sqrtf(d_head);
    
    for (int head = 0; head < n_head; head++) {
        for (int q_row = ctx_offset; q_row < q_ctx; q_row++) {
            // Causal Mask:只看前面的token
            const int k_max = mask_output ? q_row + 1 : k_ctx;
            for (int k_col = 0; k_col < k_max; k_col++) {
                // 算这一行query和这一列key的点积
                Vec_f32x8 dot_accum = vec_f32x8_setzero();
                for (int i = 0; i < d_head; i += 8) {
                    Vec_f32x8 qw = vec_f32x8_load(q_data + head*d_head + q_row*d_embed + i);
                    Vec_f32x8 kw = vec_f32x8_load(k_data + head*d_head + k_col*d_embed + i);
                    dot_accum = vec_f32x8_fma(qw, kw, dot_accum);
                }
                qk_data[...] = fp32_to_fp16(vec_f32x8_sum(dot_accum) * scale_factor);
            }
        }
    }
    
    // Causal Mask:把后面的位置填 -INFINITY
    if (mask_output) {
        for (...) {
            for (int k_col = q_row + 1; k_col < k_ctx; k_col++) {
                qk_data[...] = fp32_to_fp16(-INFINITY);
            }
        }
    }
}

Step 2: Softmax(归一化)

cpp 复制代码
static void qk_softmax(Tensor& qk_acv, int n_heads, int ctx_offset=0) {
    for (int head = 0; head < n_heads; head++) {
        for (int q_row = ctx_offset; q_row < q_ctx; q_row++) {
            // 找最大值(数值稳定性)
            Float32 max = -INFINITY;
            for (int i = 0; i < k_ctx; i++) {
                if (qk_data[i] > max) max = qk_data[i];
            }
            // 算exp并求和
            Float32 sum_exp = 0;
            for (int i = 0; i < k_ctx; i++) {
                Float32 exp_val = expf(qk_data[i] - max);
                qk_data[i] = fp32_to_fp16(exp_val);
                sum_exp += exp_val;
            }
            // 归一化
            for (int i = 0; i < k_ctx; i++) {
                qk_data[i] = fp32_to_fp16(fp16_to_fp32(qk_data[i]) / sum_exp);
            }
        }
    }
}

Step 3: Attn @ V(加权求和)

cpp 复制代码
static void qkv_attn_matmul(const Tensor& qk, const Tensor& v, Tensor& qkv_acv, 
                            int n_head, int ctx_offset=0) {
    for (int head = 0; head < n_head; head++) {
        for (int qk_row = ctx_offset; qk_row < q_ctx; qk_row++) {
            for (int v_row = 0; v_row < d_head; v_row++) {
                // 先向量化算大部分
                Vec_f32x8 dot_prod_accum = vec_f32x8_setzero();
                for (int i = 0; i < vec_n_ctx; i += 8) {
                    Vec_f32x8 qkw = vec_f32x8_load(qk_data + ...);
                    Vec_f32x8 vw = vec_f32x8_load(v_data + ...);
                    dot_prod_accum = vec_f32x8_fma(qkw, vw, dot_prod_accum);
                }
                // 尾部用标量处理(长度不是8的倍数时)
                float dot_prod = vec_f32x8_sum(dot_prod_accum);
                for (int i = vec_n_ctx; i < kv_ctx; i++) {
                    dot_prod += fp16_to_fp32(qk_data[i]) * fp16_to_fp32(v_data[i]);
                }
                qkv_data[...] = fp32_to_fp16(dot_prod);
            }
        }
    }
}

三个函数独立、可测、好懂。向量化部分处理8的倍数,尾部用标量收尾,避免越界。

6. Whisper支持:Encoder-Decoder架构

除了GPT,这个项目还支持Whisper(OpenAI的语音识别模型)。Whisper比GPT复杂,因为它有两个部分:

AudioEncoder:把音频转成特征向量

  • 两层Conv1d(降采样)
  • N个Transformer Block(没有Causal Mask,因为音频是完整的)

Decoder:把特征向量转成文字

  • Self-Attention(有Causal Mask,自回归生成)
  • Cross-Attention(看Encoder的输出,类似翻译模型的注意力)
  • MLP

Cross-Attention的实现很有意思:Key和Value来自Encoder,只算一次就缓存起来;Query来自Decoder,每步更新。

cpp 复制代码
Tensor MultiHeadCrossAttn::forward(const Tensor& x, const Tensor& xa) {
    Tensor q = query.forward(x);
    if (!kv_cached_) {
        k_cache = key.forward(xa);      // Encoder输出,只算一次
        v_cache = value.forward(xa);    // 同上
        kv_cached_ = true;
    }
    const Tensor qkv = qkv_attn(q, k_cache, v_cache);
    return qkv_proj.forward(qkv);
}

Whisper的Decoder还有一套Timestamp Rules:强制模型按"时间戳+文本+时间戳"的格式输出,这样转录结果能跟音频时间对齐。

四、相关领域知识点

知识点 在这项目里的体现
FP16推理 权重存FP16,计算转FP32,省内存、保精度
SIMD向量化 AVX 256位寄存器一次处理8个float
FMA融合乘加 a*b+c一条指令完成,减少延迟
Activation Cache 增量推理的核心,避免重复计算
Causal Mask 自回归生成的核心约束,用-INFINITY实现
LayerNorm 减均值、除标准差、乘权重、加偏置
GELU激活 0.5*x*(1+tanh(sqrt(2/π)*(x+0.044715*x^3)))
Shared Pointer C++的引用计数,多个Tensor共享同一块内存
Im2Col 把1D卷积转成矩阵乘法,复用Linear的优化
Temperature采样 除temperature后再softmax,控制随机性

五、设计思路:为什么这样设计?

设计选择 为什么这么做 生产框架会怎么做
纯C++17,零依赖 任何人都能编译,不用配环境 链接PyTorch、CUDA、cuBLAS
单头文件+单cpp 代码最短,结构最清晰 几百个文件,复杂的构建系统
FP16权重 省一半内存,Cache命中率更高 量化到INT8/INT4,更复杂
手动SIMD封装 不依赖编译器自动向量化,可控 调OpenBLAS/MKL,黑盒
Activation Cache 推理速度不随长度增长 更复杂的KV Cache管理
每个模块独立Timer 一眼看出瓶颈在哪 TensorBoard、Chrome Trace
3维Tensor上限 推理场景够用了,代码简单 支持任意维度,更通用但更复杂

六、代码实现用途

  1. 学习Transformer推理的完整链路:从Embedding到采样,每一层都是手写的
  2. 理解FP16和SIMD优化:看手写代码比看编译器生成的汇编直观多了
  3. 作为嵌入式/边缘设备的起点:纯C++、无依赖,容易移植到各种平台
  4. 性能分析教学:每个模块的耗时统计,是理解推理瓶颈的好教材
  5. Whisper理解:Encoder-Decoder+Cross-Attention的完整实现

七、流程原理图

图1:整体架构

If you need the complete source code, please add the WeChat number (c17865354792)

图2:Activation Cache 增量计算
图3:FP16 + SIMD 加速

八、怎么跑起来?一步步来

1. 跑 GPT
bash 复制代码
cd gpt/

# 基础编译(任何CPU都能跑)
g++ -std=c++17 -O3 -I../ -ffast-math gpt.cpp -o gpt

# 如果你的Intel CPU支持AVX和F16C(2013年之后的CPU基本都支持),加这两个flag快4倍
g++ -std=c++17 -O3 -I../ -ffast-math -mavx -mf16c gpt.cpp -o gpt

# 运行:单条prompt
./gpt -p "Once upon a time"

# 运行:聊天模式(交互式)
./gpt

# 指定模型大小
./gpt -sm -p "Hello"      # Small (117M)
./gpt -md -p "Hello"      # Medium (345M,默认)
./gpt -lg -p "Hello"      # Large (762M)

# 调整采样参数
./gpt -md --temp 0.5 --len 100 -p "The future of AI is"

第一次运行会自动下载模型权重(通过Python脚本),下载完后存到本地,下次直接用。

2. 跑 Whisper(语音识别)
bash 复制代码
cd whisper/

# 编译
g++ -std=c++17 -O3 -I../ -ffast-math -mavx -mf16c whisper.cpp -o whisper

# 运行(需要16kHz采样率的wav文件)
./whisper assets/Elon.wav

# 指定模型大小
./whisper -tiny  your_audio.wav
./whisper -base  your_audio.wav
./whisper -small your_audio.wav
3. 看性能报告

GPT运行完后会打印每层耗时:

复制代码
--------------------------------------
LAYER/OP         TIME PER TOKEN   TIME TOTAL
--------------------------------------
Embedding        |   2ms |   400ms
Embedding proj   |   5ms |  1000ms
Pos embedding    |   1ms |   200ms
Linear(qkv+mlp)  |  15ms |  3000ms
Attention        |   8ms |  1600ms
Layer norm       |   1ms |   200ms
Gelu             |   2ms |   400ms
Residual         |   1ms |   200ms
Sampler          |   3ms |   600ms
--------------------------------------
TOTAL            |  38ms |  7600ms
--------------------------------------

一眼就能看出:Linear层(矩阵乘法)和Attention层是大头,优化这两块收益最大。

4. 常见问题
  • 编译报错找不到AVX指令 :去掉-mavx -mf16c,用基础版本
  • 模型下载失败 :检查网络,或者手动运行python model_dl.py下载
  • 内存不够 :用-sm跑小模型(117M参数,约200MB内存)
  • Whisper音频格式不对:必须是16kHz、单声道、16bit的wav文件

九、总结一下

这个项目最大的价值,是让你看到一个"能跑"的Transformer推理引擎可以有多小、多直白

它用纯C++实现了:

  • 完整的Tensor系统(FP16/FP32/Int32)
  • 手写的FP16↔FP32位运算转换
  • AVX SIMD向量化加速
  • Activation Cache增量推理
  • GPT和Whisper两个完整模型

而且全部代码加起来可能就几千行,没有PyTorch的层层封装,没有CUDA的复杂配置,每个vec_f32x8_fma、每个acv_cached_标志都清清楚楚。

如果你想真正理解"CPU推理是怎么优化的",而不是只会调包,这个项目就是最好的起点。把代码通读一遍,亲手改改temperature、层数、采样策略,你会对Transformer推理有一个完全不一样的体感。

Welcome to follow WeChat official account【程序猿编码

相关推荐
MC皮蛋侠客1 小时前
TDengine C++ 系列(1):全景与最小闭环——从时序数据到第一个 C++ 读写
大数据·c++·tdengine
zhangfeng11332 小时前
Windows AMD显卡跑PyTorch
人工智能·pytorch·windows
MC皮蛋侠客2 小时前
TDengine C++ 系列(5):C/C++ 连接器——连接管理与查询 API
c语言·c++·tdengine
MC皮蛋侠客2 小时前
TDengine C++ 系列(8):流式计算与最新值缓存——库内实时处理
c++·缓存·tdengine
AI大佬的小弟2 小时前
大模型名词精讲06:Top-P(核采样)
大模型·top-p·temperature·ai入门·核采样·采样策略·ai调参
*.✧屠苏隐遥(ノ◕ヮ◕)ノ*.✧10 小时前
C++学习:基础知识的掌握
c++·visualstudio
C++ 老炮儿的技术栈16 小时前
从 Qt Designer 属性编辑器的层级可以看到继承链
c语言·数据库·c++·qt·sqlite·visual studio
new_zhou16 小时前
C++ 项目 AI 协作指南(Windows / MSVC 环境)
c++·人工智能·windows
水饺编程18 小时前
AT&T 汇编语言学习笔记开篇语
c语言·汇编·c++