一、项目核心释义
这是一套完全用纯C语言、从零手写实现的GPT-2风格Transformer模型,核心定位是教学与学习向工程,目的是通过最朴素的实现,拆解Transformer的每一步底层运算,帮助开发者从根本上理解Transformer的前向传播、反向传播、自注意力机制的本质,同时作为逐步引入CUDA优化的基准代码。
整个实现没有依赖任何深度学习框架(没有PyTorch、TensorFlow、ONNX Runtime),仅用标准C库和数学库,完整实现了12层、12头、768维的GPT-2小模型的前向推理与反向训练,支持加载Safetensors格式的预训练权重,可直接在CPU上运行生成文本。
它不是生产级优化实现,恰恰相反——代码故意采用最直白、最朴素的写法,每一步运算都平铺直写,没有黑盒调用,非常适合用来啃透Transformer的底层数学逻辑和工程实现。
二、行业核心技术知识点
在拆解代码之前,先梳理几个核心技术点,帮你建立认知框架。
2.1 GPT-2 Transformer的核心构成
GPT-2是典型的自回归Decoder-only Transformer,核心结构是堆叠的Transformer解码器块,每个块包含两大子模块:
- 多头自注意力模块:让模型看到当前位置之前的所有token,计算每个token和其他token的关联权重,融合上下文信息;
- 前馈神经网络(MLP):对每个token的特征做非线性变换,提升模型的表达能力。
每个模块前后都有层归一化(LayerNorm)和残差连接,保证深层模型可以稳定训练。最后经过最终层归一化和反嵌入层,输出词表上的概率分布。
2.2 自注意力的本质与因果掩码
自注意力的核心是Q、K、V三个向量的运算:
- Q(查询):当前token的特征;
- K(键):所有上下文token的特征;
- V(值):所有上下文token的特征值。
计算Q和所有K的点积,除以维度的平方根做缩放,经过Softmax得到权重,再对V加权求和,就是自注意力的输出。
GPT作为自回归模型,必须加因果掩码:每个位置只能看到自己和之前的位置,不能看到后面的token,保证生成文本时的单向性。
2.3 为什么用纯C实现Transformer?
现在主流的AI实现都依赖Python+框架,黑盒程度很高,底层运算对开发者不可见。纯C实现有三个不可替代的价值:
- 彻底吃透原理:每一步矩阵运算、归一化、激活函数都亲手写,没有任何黑盒,真正理解Transformer怎么跑起来的;
- 端侧/嵌入式部署基础:C语言是所有系统的通用语言,纯C实现是后续往嵌入式设备、端侧芯片移植的基础;
- 性能优化的基准:朴素的C实现是性能基线,后续做CUDA优化、SIMD优化的时候,可以清晰看到每一步优化带来的提升。
2.4 Safetensors模型格式
项目采用Safetensors格式加载预训练权重,这是一种安全、简单的张量存储格式,结构非常简单:开头是JSON格式的元数据,描述每个张量的名称、形状、偏移量,后面跟着原始的二进制张量数据。解析起来不需要任何第三方库,非常适合纯C项目。
三、整体架构设计思路
整个代码采用单层内存、模块化函数、顺序执行的设计思路,没有复杂的面向对象封装,所有逻辑平铺展开,清晰可见。
3.1 整体内存布局
代码最有特点的设计是一次性分配整块内存,所有的结构、权重、激活值、梯度都放在这一块大内存里,通过偏移量划分不同的区域:
- 词表解码器
- 分词数据
- Safetensors元数据
- 原始模型权重
- 结构化模型参数指针
- 前向激活值缓冲区
- 梯度缓冲区
- 反向激活值缓冲区
这样设计的好处是内存布局完全可控,没有碎片化,也方便后续做内存优化和CUDA移植,同时避免了多次malloc/free的开销和错误。
3.2 模块划分
代码按功能划分为几个核心部分:
- 工具函数:内存对齐分配、时间统计、Safetensors张量解析、数值校验;
- 基础算子:逐元素加、全连接层、层归一化,以及对应的反向传播;
- Transformer核心:嵌入层、12层Transformer块(注意力+MLP)、最终归一化、反嵌入层;
- 流程控制:前向传播函数、反向传播函数、主函数(加载数据、模型、推理/训练)。
3.3 完整前向传播流程示意图
四、全套代码实现原理逐模块详解
这部分我们深入代码,从底层算子到完整流程,逐段拆解实现逻辑。
4.1 宏定义与模型参数结构
首先是模型超参数的宏定义,完全对应GPT-2 124M的配置:
#define VOCAB_SIZE 50257 // 词表大小 #define SEQUENCE_LENGTH 1024 // 最大序列长度 #define MODEL_DIM 768 // 模型隐藏维度 #define HEAD_DIM 64 // 每个注意力头的维度 #define NUM_HEADS 12 // 注意力头数量 #define NUM_LAYERS 12 // Transformer层数 #define INV_SQRT_HEAD_DIM 0.125f // 1/sqrt(64) 注意力缩放因子然后是模型参数结构体,用结构体把所有权重按层级组织起来,和GPT-2的结构一一对应:
structModelParameters{struct{float*weight;}tokenEmbedding;// 词嵌入权重struct{float*weight;}positionEmbedding;// 位置嵌入权重struct{struct{float*bias;float*weight;}norm1;// 第一层归一化struct{struct{float*bias;float*weight;}attentionCombined;// QKV三合一投影struct{float*bias;float*weight;}attentionProjection;// 注意力输出投影}attention;struct{float*bias;float*weight;}norm2;// 第二层归一化struct{struct{float*bias;float*weight;}mlpFC;// MLP升维层struct{float*bias;float*weight;}mlpProj;// MLP投影层}mlp;}layers[NUM_LAYERS];// 12层Transformerstruct{float*bias;float*weight;}finalNorm;// 最终层归一化};设计细节:QKV三个投影被合并成了一个
attentionCombined全连接层,输出维度是3*MODEL_DIM,这样可以一次矩阵乘法算出Q、K、V,减少函数调用,这也是GPT-2的标准实现方式。
除了参数结构体,代码还定义了对应的激活值结构体、梯度结构体、反向激活值结构体,形状和参数一一对应,用来存储前向传播的中间结果和反向传播的梯度。
4.2 基础算子:全连接层(朴素实现)
全连接层是Transformer最基础的运算,代码里用最朴素的三重循环实现,没有任何优化,目的就是把矩阵乘法的本质写清楚:
staticvoidfully_connected(conststructFullyConnectedLayer*fc){// 遍历每个样本(每个token)for(size_t sample=0;sample<fc->sample_count;sample++){float*out=(float*)fc->output+sample*fc->out_features;// 先加上偏置memcpy(out,fc->bias,fc->out_features*sizeof(float));// 矩阵乘法:输入 × 权重for(size_t in=0;in<fc->in_features;in++){floatinput_val=fc->input[sample*fc->in_features+in];for(size_t j=0;j<fc->out_features;j++){out[j]+=input_val*fc->weight[in*fc->out_features+j];}}}}原理解析:
- 权重是行优先存储,形状是[in_features, out_features];
- 每个样本(每个token)单独计算,因为Transformer的全连接层是对每个位置独立做的;
- 先把偏置拷贝到输出,再累加输入和权重的乘积,就是标准的Y = XW^T + b运算。
这种三重循环的写法性能不高,但逻辑完全透明,一眼就能看懂全连接层到底在算什么,非常适合学习。代码里同时也实现了对应的反向传播函数fully_connected_backward,用来计算权重、偏置和输入的梯度。
4.3 基础算子:层归一化(LayerNorm)
层归一化是Transformer稳定训练的关键,代码实现了完整的均值、方差、归一化、缩放偏移流程:
staticvoidlayer_norm(conststructLayerNorm*ln){for(size_t sample=0;sample<ln->sample_count;sample++){constfloat*input_ptr=ln->input+sample*ln->in_features;// 1. 计算均值floatsum=0.0f;for(size_t i=0;i<ln->in_features;i++)sum+=input_ptr[i];floatmean=sum/ln->in_features;// 2. 计算方差floatsum_sq_diff=0.0f;for(size_t i=0;i<ln->in_features;i++){floatdiff=input_ptr[i]-mean;sum_sq_diff+=diff*diff;}floatvariance=sum_sq_diff/ln->in_features;floatrStd=1.0f/sqrtf(variance+1e-5f);// 加epsilon防止除零// 3. 归一化 + 缩放gamma + 偏移betafloat*out_ptr=ln->output+sample*ln->in_features;for(size_t i=0;i<ln->in_features;i++){floatnormalized=(input_ptr[i]-mean)*rStd;out_ptr[i]=normalized*ln->gamma[i]+ln->beta[i];}}}原理解析:LayerNorm是对每个样本的特征维度做归一化,把数值拉到均值0方差1,再通过可学习的gamma和beta做缩放偏移,保留模型的表达能力。代码里同时保存了均值和标准差,给反向传播的时候用。
4.4 核心:多头自注意力的朴素实现
自注意力是Transformer的灵魂,这段代码是整个项目最核心的部分,用最直白的方式实现了带因果掩码的多头自注意力:
第一步,前面已经通过attentionCombined全连接层算出了QKV三合一的结果,形状是[序列长度, 3*模型维度]。
第二步,按头拆分,逐个计算每个头的注意力:
// 遍历每个注意力头for(size_t head=0;head<NUM_HEADS;head++){// 遍历每个查询位置qfor(size_t q=0;q<inputSize;q++){float*softmaxOut=activations->layers[layer].attention.softmax.out[head][q];floatmaxScore=-INFINITY;// 计算q和所有k<=q的点积(因果掩码,只看前面的token)for(size_t k=0;k<=q;k++){// 取出当前头的q向量和k向量float*query=(float*)activations->layers[layer].attention.attentionCombined.out+q*3*MODEL_DIM+head*HEAD_DIM;float*key=(float*)activations->layers[layer].attention.attentionCombined.out+k*3*MODEL_DIM+MODEL_DIM+head*HEAD_DIM;// 点积计算floatdot=0.0f;for(size_t d=0;d<HEAD_DIM;d++){dot+=query[d]*key[d];}dot*=INV_SQRT_HEAD_DIM;// 缩放softmaxOut[k]=dot;if(dot>maxScore)maxScore=dot;}// Softmax计算(数值稳定版本:减去最大值)floatsumExp=0.0f;for(size_t k=0;k<=q;k++){floatexpVal=expf(softmaxOut[k]-maxScore);sumExp+=expVal;softmaxOut[k]=expVal;}floatinvSumExp=1.0f/sumExp;for(size_t k=0;k<=q;k++){softmaxOut[k]*=invSumExp;}// 用注意力权重对V向量加权求和for(size_t v=0;v<=q;v++){float*value=(float*)activations->layers[layer].attention.attentionCombined.out+v*3*MODEL_DIM+2*MODEL_DIM+head*HEAD_DIM;float*outSlice=(float*)activations->layers[layer].attention.attentionOutput.out+q*MODEL_DIM+head*HEAD_DIM;floatweightVal=softmaxOut[v];for(size_t d=0;d<HEAD_DIM;d++){outSlice[d]+=weightVal*value[d];}}}}核心细节讲解:
- 因果掩码实现:没有用掩码矩阵乘,直接通过
k <= q的循环边界实现,每个位置只和自己及之前的位置计算注意力,这是最朴素也最直观的因果掩码实现;- 多头拆分:QKV在最后一维按头拆分,每个头单独计算注意力,最后再拼接起来;
- 数值稳定Softmax:先减去最大值再算指数,防止指数溢出,这是工业界的标准实现;
- V加权求和:用Softmax得到的权重,对V向量做加权平均,得到这个位置的注意力输出。
计算完所有头之后,再通过一个attentionProjection全连接层,把多头的结果融合起来,就完成了整个注意力模块。
4.5 MLP模块与GELU激活
注意力之后是MLP模块,GPT的MLP是“升维-激活-投影”的结构:
// 第一步:全连接升维,从768维升到4*768=3072维structFullyConnectedLayerfcMlpFC={...};fully_connected(&fcMlpFC);// 第二步:GELU激活函数constfloat*fcIn=(float*)activations->layers[layer].mlp.mlpFC.out;size_t totalElems=inputSize*4*MODEL_DIM;float*geluOut=(float*)activations->layers[layer].mlp.gelu.out;for(size_t i=0;i<totalElems;i++){floatphi=0.5f*(1.0f+erff(fcIn[i]*M_SQRT1_2));geluOut[i]=fcIn[i]*phi;}// 第三步:全连接投影,从3072维降回768维structFullyConnectedLayerfcMlpProj={...};fully_connected(&fcMlpProj);原理解析:GELU是GPT用的激活函数,公式是
x * Φ(x),其中Φ是标准正态分布的累积分布函数,代码里用erf误差函数来近似计算,比ReLU更平滑,在大模型里效果更好。
4.6 完整的Transformer块流程
每个Transformer块都是固定的“预归一化+注意力+残差+预归一化+MLP+残差”结构:
输入 → LayerNorm → 注意力 → 残差相加 → LayerNorm → MLP → 残差相加 → 输出这就是GPT-2采用的Pre-LN结构,归一化放在每个模块的前面,而不是后面,训练更稳定。
代码里用结构体组合每个算子的参数,依次调用函数,每一步的输入输出都清晰可见。12层就是循环12次这个流程。
4.7 输出层:反嵌入与概率生成
经过12层Transformer之后,做最终的层归一化,然后通过反嵌入层把768维的特征映射回50257维的词表logits:
// 最终层归一化structLayerNormfinalNorm={...};layer_norm(&finalNorm);// 反嵌入:和词嵌入权重相乘,得到每个词的logitsfor(size_t i=inputSize-1;i<inputSize;i++){float*logits=activations->unembedding.out[i];constfloat*sampleVec=activations->finalNorm.out[i];// 和词表中每个词的嵌入做点积for(size_t vocab=0;vocab<VOCAB_SIZE;vocab++){floatdot=0.0f;for(size_t d=0;d<MODEL_DIM;d++){dot+=sampleVec[d]*embedWeights[vocab*MODEL_DIM+d];}logits[vocab]=dot;}// 对logits做Softmax得到概率// ...}推理模式下,取最后一个位置的logits,选概率最大的token作为输出,然后把这个token加到输入序列里,继续生成下一个,这就是自回归生成的过程。
4.8 反向传播与训练支持
这套代码不仅能推理,还完整实现了反向传播:
- 从交叉熵损失开始,反向计算每个模块的梯度;
- 包含了全连接反向、层归一化反向、嵌入层反向;
- 梯度累积在Gradients结构体里,可以用于参数更新。
虽然是朴素实现,但完整覆盖了Transformer训练的反向流程,对于理解深度学习反向传播的本质非常有帮助。
4.9 主函数:整块内存管理与模型加载
主函数的设计非常有工程参考价值,它不是零散malloc各个结构,而是计算好所有内存需求,一次性分配一大块对齐内存,然后用指针把各个区域划分出来:
// 计算总内存大小size_t totalMem=0;size_t decoderOffset=totalMem;totalMem+=ALIGN(sizeof(structTokenDecoder));size_t tokenDataOffset=totalMem;totalMem+=ALIGN(MAX_DATA_SIZE);// ... 所有区域依次计算偏移// 一次性分配256字节对齐的内存char*memoryBlock=my_aligned_alloc(256,totalMem);// 各个指针指向内存块的对应位置structTokenDecoder*decoder=(structTokenDecoder*)(memoryBlock);uint16_t*tokenData=(uint16_t*)(memoryBlock+tokenDataOffset);// ...设计优势:
- 内存连续,缓存友好,性能更好;
- 只需要一次分配和一次释放,不会内存泄漏;
- 方便后续做内存池、显存迁移等优化。
然后是Safetensors模型加载,代码自己解析Safetensors的JSON元数据,根据张量名称找到对应权重的偏移,把指针赋值给ModelParameters结构体里对应的字段,完全自己实现,不需要任何Safetensors库。
五、环境配置与运行测试全教程
If you need the complete source code, please add the WeChat number (c17865354792)
5.1 环境要求
- 编译器:GCC(支持C11标准)
- 系统:Linux/macOS(Windows可通过WSL或MinGW运行)
- 依赖:仅标准C库和数学库(-lm),无其他第三方依赖
- 辅助:Python3(仅用于生成分词数据,运行时不需要)
5.2 准备模型文件
需要下载GPT-2的Safetensors格式预训练权重,放到代码同级目录,命名为model.safetensors。
5.3 准备分词数据与词表
项目用Tiny Shakespeare数据集做演示,需要先生成分词后的数据文件和词表解码文件。
- 安装分词工具:
pip install tiktoken- 下载Tiny Shakespeare数据集,保存为
input.txt。 - 运行Python脚本生成数据文件:
importtiktoken# 加载数据集 withopen("input.txt","r")as f:text=f.read()# 初始化GPT-2分词器 enc=tiktoken.get_encoding("gpt2")tokens=enc.encode(text)# 保存分词后的二进制数据 withopen("data","wb")as f:f.write(bytes(tokens))# 保存词表解码信息 withopen("enc","wb")as f:fortoken,size inenumerate(enc.mergeable_ranks):offset=len(f"{token}\n")f.write(f"{offset} {size}\n".encode("utf-8"))执行完之后,目录下会有data和enc两个文件。
5.4 运行
运行程序:
./gpt- 默认是推理模式,会加载模型和数据,自动生成128个token的文本;
- 代码里可以切换训练模式,会执行前向+反向传播,输出各层的耗时统计,方便做性能对比。
运行后会输出总内存占用、各层的执行时间,以及生成的文本内容。
六、落地用途与优化方向
6.1 核心落地场景
- Transformer原理学习:这是最好的学习素材之一,没有框架黑盒,每一步运算都清晰可见,适合高校教学、工程师入门深度学习底层;
- CUDA优化练手项目:代码本身是朴素CPU实现,非常适合作为基准,一步步把矩阵乘法、注意力改成CUDA内核,学习GPU编程;
- 端侧/嵌入式AI原型:纯C实现可以直接移植到嵌入式设备、边缘终端,作为轻量级端侧语言模型的原型;
- 性能基准测试:可以作为基线,测试不同硬件、不同优化手段的性能提升。
6.2 后续优化路径
项目本身规划了逐步优化的路线,也是学习性能优化的标准路径:
- SIMD优化:用SSE/AVX指令集优化矩阵乘法和向量运算;
- 单线程算子优化:优化内存布局、循环展开,提升缓存命中率;
- CUDA基础优化:把矩阵乘法、Softmax这些基础算子换成CUDA内核;
- CUDA高级优化:融合算子、减少显存读写、Tensor Core加速;
- 量化推理:实现INT8/INT4量化,进一步降低内存和提升速度。
七、总结
这套纯C实现的GPT-2代码,最大的价值不是性能,而是透明——它把Transformer从一个黑盒框架,变成了几百行可以逐行读懂的C代码。从内存管理、基础算子,到自注意力、MLP、前向传播、反向传播,每一个环节都没有任何隐藏,是真正吃透Transformer底层原理的绝佳实践。
它同时也是一个绝佳的起点,你可以在这个基础上一步步做优化,看着性能从“很慢”一步步提升,每一步提升了多少、为什么提升,都清清楚楚。对于想深入AI底层工程、学习CUDA优化、做端侧AI部署的开发者来说,这是非常有价值的学习项目。