手写LLM推理引擎:词嵌入层的内存布局与性能优化实战
发布时间:2026/10/28 7:40:30 锦皓数字建站

从前面几章一路读过来的朋友应该清楚我们正在一点一点拆掉LLM推理引擎的“黑盒”把它变成自己手中真正可控的零件。第四章选词嵌入这个看似不起眼的环节不是因为它的代码量有多大而是因为它是整个推理链条里第一道真正“把文字变成数字”的关卡。如果这里的数据布局错了后面所有矩阵乘法、注意力计算都会悄悄出偏甚至直接崩溃。我自己初写这段时吃过不少亏所以这一章不只是贴代码更想聊聊为什么词嵌入在推理引擎里值得单独抠出来设计。词嵌入在这里承担的任务其实很简单把每个token的ID映射成一个稠密向量。但简单背后藏着两个关键问题——映射表怎么存、查出来的向量怎么送进后续计算。前者涉及内存布局和精度选择后者涉及与位置编码、批量维度的配合。很多人写玩具模型时随便拿一个nn.Embedding糊过去等真要手写推理引擎就会发现在无框架环境下连一次索引查表都要想清楚缓存友好性和对齐问题。这一章的内容就是让卡在这里的读者少走弯路。我会先讲词嵌入在推理引擎里的整体定位再拆解实现时的数据结构选型然后用完整的代码片段演示一个可用的嵌入层是怎么构建的最后把我在实际调试中遇到的那些坑整理成速查表。无论你是正在自研推理框架还是单纯想把模型内部的张量流动看得更透这章都能给你一些养料。1. 词嵌入在推理引擎中的定位1.1 推理引擎为什么需要单独设计嵌入层很多从PyTorch转过来手写推理引擎的人第一个不习惯的地方就是模型里那些高层封装全都不见了你面对的是最原始的权重文件和张量计算。词嵌入在这种环境下的核心职责就是把已经完成分词的token序列变成后续Transformer层能处理的连续向量。这个过程听起来像是“查表”但一旦推理引擎需要支持动态批量、不同上下文长度甚至多模型切换时嵌入层就不再只是一张查了完事的表格。关键在于推理引擎和训练框架的侧重点完全不同。训练时嵌入层要参与梯度回传权重会持续更新推理时权重冻结我们真正在乎的是查找效率、内存带宽利用率和数值稳定性。一个典型的7B模型词表大小可能达到三万到十万级别嵌入维度通常在三千到六千之间。仅这一层权重就占了几个GB的显存或内存。如果你直接用一个“最简单”的二维数组加for循环去实现那每次生成一个token都要付出巨大的随机访问开销生成速度会被拖到不可接受的地步。我见过不少初学自写推理引擎的人在嵌入这一层不自觉地沿用训练代码的写法比如把嵌入表组织成[vocab_size, hidden_size]的浮点二维数组每次查表都做一次完整的容器查找。这么做在测试时没问题但一旦跑真正的生成任务随着序列变长、吞吐量要求变高访存瓶颈立刻暴露。推理引擎里的嵌入层本质上是一道“内存带宽题”你的设计目标就是让数据以最快的路径从权重存储区流进计算核心。所以我的建议是不要孤立地看待词嵌入。要把它和位置编码、批量拼接、精度转换看作一个整体。这个整体负责把零散的token ID变成一块连续、对齐、可以直接喂给Transformer Block的输入张量。把这条“数据流水线”想清楚你后面写多头注意力时很多维度问题都会提前消掉。这也是我把这一章放在整本手写引擎的中间位置的原因——前后分别是分词器与核心Transformer层夹在中间的就是这个承上启下的嵌入模块。1.2 从Token ID到向量映射的核心流程整个映射过程听起来很直接一个token ID进来一个向量出去。但在推理引擎内部这个流程至少要拆成三步。第一步是从token ID定位嵌入表的行的起始地址这本质是一个指针运算第二步是把这一行的数据从存储介质搬到计算单元这涉及内存拷贝或零拷贝策略第三步是如果需要加位置编码就把位置编码向量和嵌入向量叠加起来形成最终的模型输入。这里我得特别强调第一步。在C或Rust这类的底层实现里你不能把vocab_size和hidden_size当作抽象容器的大小而要把它们当作字节偏移量来算。比如隐藏维度是4096如果以FP32存储那每个token的行跨度就是4096 * 4 16384字节如果是FP16则是8192字节。你只有把行跨度算精确才能通过base_ptr token_id * row_stride的方式高效定位。很多项目为了省事直接用std::vector或者slice去做索引虽然差别不大但在极高温热的推理循环里每毫秒都要抠。完成定位之后第二步的“搬数据”也有讲究。如果你用的推理引擎支持预填充和生成两个阶段那么嵌入查表的调用频率完全不同。预填充阶段一次性处理几百个token生成阶段则每次只处理一个token。单token生成时嵌入查表会变成整个推理过程中最频繁的随机访存操作之一。这时候如果能利用CPU的缓存预取指令或者GPU上的常量内存可以让这个查表动作快一个量级。第三步则是把嵌入向量与位置编码融合。有的模型选择先加后做LayerNorm有的模型则把嵌入输出和位置编码先拼接再投影比如某些T5风格模型。这部分完全取决于模型结构的定义你必须从模型配置里读出来而不能靠猜。把这套流程画成一个流水线你会发现真正属于“嵌入层”的代码其实只有几百行但设计上的决策点特别密集。比如精度选FP32还是FP16位置编码是预计算还是即时计算批量维度的数据布局是[batch, seq]还是[seq, batch]这些都直接影响后续所有模块的接口约定。我在下面几节会逐一展开把每个决策的取舍逻辑说清楚让读者不仅知道怎么做也明白为什么。2. 嵌入表的存储设计与数值表示2.1 内存布局行主序与对齐问题词嵌入表本质是一个大矩阵。最自然的存储方式是行主序也就是所有token的向量按ID顺序连续铺在一段内存里。这样每次查表就是一个线性偏移不需要跳跃式访问。但在手写引擎里只做到行主序还不够你还要考虑内存对齐。现代CPU和GPU对未对齐访存的惩罚非常明显尤其是SIMD向量化操作要求数据地址对齐到16字节或32字节。如果你的嵌入表起始指针没有做对齐后续做矩阵乘法时会白白损失可观的性能。我在自己的引擎里采用的方案是把整张嵌入表分配在一段连续内存上并且让起始地址至少按64字节对齐。这可以通过标准库里的对齐分配接口实现或者干脆在大型static数组上手动做对齐属性声明。实现时你需要额外记录一个row_stride字段表示实际每行占用多少字节。这个字段不一定是hidden_size * element_size因为为了对齐每行的末尾可能还多了几个填充字节。这里我强烈建议不要在图省事时忽略填充字节否则后面写CUDA kernel时会碰得头破血流。在GPU上共享内存和全局内存访问都要求特定对齐你不把行跨度算对kernel直接出错的概率极高。另一种思路是把嵌入表按“维度”存储也就是列主序。列主序在某些场景下能提高矩阵乘法的缓存命中率因为它把同一维度的数据聚在一起。但综合考虑查表和后续Transformer层的权重布局行主序在绝大多数推理引擎中依然是主流。原因是查表操作天然按token行访问行主序就把一次token查找变成一个连续的memcpy而列主序则要跨着大跨度读很多段完全违背空间局部性。所以我的经验是除非你要做的是某种特殊的模型并行策略否则直接定死行主序别在这个问题上过度设计。2.2 精度选择FP32、FP16与量化嵌入词嵌入层在推理时往往被忽略的一点是精度暴击。很多人把Transformer层的权重全部转成FP16或INT8却忘了嵌入表也可以量化。实际上嵌入表在所有参数量中的占比不小如果完全不做压缩它会成为显存和内存的沉重负担。我用FP16存储嵌入表的效果非常理想精度损失微乎其微因为嵌入向量的分布相对集中不像注意力层的权重那样敏感。FP16还有额外好处它的行跨度刚好是FP32的一半无论SIMD还是GPU访存都更高效。如果你的内存还是紧可以考虑INT8量化嵌入表。但这条路踩坑比较多因为嵌入表的数值范围在不同token之间差异可能很大简单做线性量化容易让低频词的向量失真。我见过的一些开源项目采用按行独立缩放的方式每行存一个额外的scale因子恢复时做浮点乘加。这个方案虽然有效但引入的代码复杂度不低。我的建议是在动手做之前先跑一遍验证集看看量化后的困惑度变化。如果变化在0.5%以内就可以接受否则就继续用FP16。另一个容易被忽略的点是精度转换的时机。不要在查表之后再逐个向量做精度转换那样会浪费时间。更好的做法是如果模型权重是FP16嵌入表直接加载成FP16查表取出来的数据本身就是FP16后续计算链条全部保持FP16。只有需要和位置编码相加时才临时把位置编码也做成FP16。整个过程避免一次多余的拷贝和转换。我发现很多引擎性能上不去不是因为计算量大而是这类“隐形的类型转换”拖慢了速度而且还在每一个token生成时重复发生。2.3 特殊Token与未登录词的处理词嵌入表不是永远按字典顺序排列大多数模型都会在词表开头或特定位置预留特殊token。比如[PAD]、[BOS]、[EOS]、[UNK]以及一些用于对齐的空位。在你的推理引擎里这些特殊token的嵌入向量看起来没什么特别但它们可能影响你的边界判断。比如做长度填充时可以把[PAD]的嵌入向量设置为全零这是一个常见的工程技巧省去了在逻辑上忽略填充位置的额外条件判断。当然这必须是模型训练时就这么设计的如果原模型训练时[PAD]是正常的随机向量你在推理时擅自改成全零就会出问题。未登录词的处理则是另一个层面。如果某个token ID超出了你当前词表的大小那说明分词器与嵌入表不匹配多半是模型配置加载出错了。这里我建议在引擎初始化的时候做一次完整的边界校验把所有token ID的合法范围定下来。越早发现这种错误越好否则很可能等到生成到一半时突然出现非法内存访问那时你很难定位是嵌入层的锅还是别的模块的锅。我在调试一个模型时曾经遇到过奇怪的问题几乎每次生成到第100个token左右就崩溃而且崩溃点还在注意力层怎么查都查不出错。最后排查了大半天才发现在分词阶段生成了一个超出词表范围的ID嵌入层也没有做任何校验直接按越界偏移读了一块错误的内存。后来我在嵌入层入口加了两行检查这个幽灵Bug就再也没出现过。这段经历也让我养成一个习惯所有手写引擎的模块入口处宁多勿缺地做一次索引范围检查即使费几个周期也比崩溃后靠gdb找半天强百倍。3. 实操从零实现一个嵌入层3.1 直接索引的朴素实现与局限我们先用最直接的思路写出一个能用的嵌入层。假设有一个Embedding结构体内部保存一个指针weight指向连续内存的起始地址。此外保存vocab_size、hidden_size、element_size和row_stride四个字段。这里的row_stride不是模型超参而是实际内存布局的行跨度它在初始化时用align_up(hidden_size * element_size, alignment)计算得到。实现查表函数时只需要计算weight token_id * row_stride然后按需拷贝到输出缓冲区即可。struct Embedding { void* weight; size_t vocab_size; size_t hidden_size; size_t element_size; size_t row_stride; float* scale; // 可选用于量化嵌入 void lookup(int token_id, void* output) { assert(token_id 0 token_id vocab_size); uint8_t* src static_castuint8_t*(weight) token_id * row_stride; memcpy(output, src, hidden_size * element_size); } };这个朴素实现在单token输入时是没问题的代码也足够短。但一旦把多个token拼成一个批量输入你就会发现直接一个一个地拷贝的代价非常高。首先是函数调用和memcpy本身的开销其次是每个token的lookup都要重新计算地址无法充分利用现代CPU的向量化批量拷贝指令。如果你只是做教学试验这个实现完全够用但如果你想用这个引擎跑出能接受的性能就必须往下再走一步。改进的方向有两个一是把多个token的查表合并成一次批量操作比较大的memcpy能逼近内存带宽的上限二是把拷贝替换成指针引用也就是说查表之后不直接复制而是返回一个指向嵌入表内部数据的只读指针。这样能省掉一次拷贝但前提是后续模块不会修改这个数据。比如你在做位置编码叠加后结果必须写到一个新缓冲区因为叠加操作总是会产生新数据。在只有加法的情况下用一个临时缓冲区总是无法避免的。但如果你做的是最简单的推理不加位置编码、不做后续修改那零拷贝方案也是可行的。这些决策没有绝对的正确只有在你的具体代码里合不合理。3.2 批量查表与数据流设计真正的推理场景里嵌入层必须支持一次处理一段token序列。在预填充阶段一个序列可能有几百甚至上千个token。此时最合理的做法是做一个batch_lookup函数输入是一个token ID数组输出是一个连续的二维缓冲区形状是[num_tokens, hidden_size]。这个函数内部不循环调用单个lookup而是直接用一个优化过的批量拷贝循环一次处理多个token的行数据。实现上你可以用两层循环外层遍历token内层按块拷贝行数据。但为了更好的性能我建议使用双缓冲策略。也就是说在内存中预先分配一块专门用来存放嵌入输出的缓冲区每次调用批次查表前先把它填充好再让后续的Transformer层直接读这块缓冲区。如果同一批token在不同层之间流转你甚至可以交替使用两块缓冲区避免每层输出都重新分配内存。这个小技巧我在自己引擎里使用后生成速度大约提升了10%虽然不算惊艳但在手写引擎的语境里已经很值了。这里还有一个非常容易被忽略的细节批量维度顺序。有些工程把嵌入输出定义为[batch, seq, hidden]有些定义为[seq, batch, hidden]。如果你后续的Transformer实现是基于[seq, batch]布局写的那么嵌入层也要输出同样的布局否则在进入第一层之前就要做一次转置。转置是矩阵操作里缓存最不友好的一种能在源头上避免就不要在中间补救。我的建议是统一采用[batch, seq, hidden]因为绝大多数现代推理框架都采用这种布局未来的代码复用也会更方便。在实际写代码时你需要保持一个思维习惯永远从整条数据流的角度去设计嵌入接口而不是只看嵌入层本身。比如说嵌入层输出的内存块很可能就是第一个Transformer Block的输入——那你就得保证这个输入块不会被意外释放也不会被后续的算子覆盖。管理这块内存的生命周期往往比写嵌入算法本身更复杂。我的做法是引入一个简单的内存池把嵌入输出、位置编码输出、注意力结果都放在预分配的池子里通过简单的轮转计数避免重复申请释放。这个方案在裸C环境里非常好用也不会引入额外的依赖。3.3 位置编码与上下文注入嵌入层完成之后下一个紧挨着的步骤大多数情况下就是加位置编码。这里要分清自己是哪种位置编码类型。最经典的绝对位置编码是直接加在嵌入向量上比如原始的Transformer结构而RoPE采用的是旋转位置编码它不是在嵌入后直接相加而是作用在每次Attention的Q和K向量上。如果你的模型用的是RoPE嵌入层就完全不需要关心位置编码只需把原始嵌入输出传给后续层即可。反之如果是绝对位置编码则嵌入层最好是附带一个可选的add_positional_encoding接口。绝对位置编码的实现有一个天然的优化点位置编码矩阵是只读的且可以预先计算好。在推理时seq_len是动态变化的所以最稳妥的用法是维护一个足够大的预计算表格比如最大长度2048每次只需查表。如果模型支持训练中未见过的更长序列你还需要实现位置插值或外推策略那意味着不能用简单的固定表。这个时候你就要写一个compute_positional_encoding(pos)函数按需计算并把结果缓存起来避免同一位置重复算两次。在把位置编码和嵌入向量相加时还有一个小技巧可以先判断位置编码是否为全零。因为有些模型在prompt开头会填充[PAD]为全零向量对应的位置编码也可能设为全零。如果检测到全零那么跳过整个加法操作直接使用嵌入向量。这个分支判断很便宜但在长序列生成中能省下不少多余的访存操作。我在实践中发现约三分之一的token走的是这种“跳过”路径所以这个优化不是白做的。这里再次强调你只有把嵌入层和位置编码视为一个整体才能在微小的性能细节上挖出这些油水。4. 常见问题与排查技巧实录4.1 向量维度对不上模型直接崩溃这是自写推理引擎里遇到最多的错误。嵌入层输出的维度是hidden_size但下一层输入要求的是hidden_size加某些额外维度比如有的模型需要拼接token类型向量有的需要拼接特殊标记。如果你没有仔细读模型配置文件很容易漏掉这一层。解决方案是在引擎启动时做一次维度一致性检查把你的hidden_size与每一个下游模块的期望输入维度比对一旦不匹配就立刻报错。排查这类问题还有一个笨办法打印出嵌入层输出的shape和第一个Transformer Block输入的实际shape。绝大多数人靠肉眼就能发现差异。不要觉得这一步多余我调试过很多次最后发现都是这个“显而易见”的问题在捣乱。有的模型甚至会在词汇维度之外再加一个“语言ID”维度比如多语言模型你以为只有一个嵌入表实际上有两张表在叠加。这种时候你的嵌入层就必须支持多个表的加权叠加而不是简单查一张表。为了避免这种尴尬我在自己的引擎里专门写了一个模型配置解析模块把所有与维度相关的参数统一抽出来。嵌入层的初始化函数直接读这个参数而不是在代码里硬编码任何数值。这样模型一旦切换维度问题基本都能被拦截在启动阶段。如果你现在写的引擎还没有这一步我的建议是立刻补上它甚至比优化计算性能更重要。维度出错不会让你生成结果变差它直接让你无法推理。4.2 大批量加载时内存溢出或加载过慢当词表很大、隐藏维度很高的时候嵌入表的加载可能成为整个模型加载时间的瓶颈。很多人以为读取大文件慢是磁盘问题其实更常见的是代码里用了多次不必要的拷贝。比如先把文件读进一个临时std::vector再逐个元素拷贝到嵌入表内存这个过程中的浪费是惊人的。正确的做法是直接把文件内容映射到内存地址或者在一次read调用里把整块数据读入目标地址。如果你的嵌入表是FP16但模型文件里存的是FP32那加载时就必须做一次精度转换。转换过程会带来大量CPU计算略微慢一点是可以接受的。但如果你发现加载时间与转换时间完全不成比例那就得检查你的循环是不是没有开启编译器优化。在C里加载大数组时用#pragma omp parallel for往往能极大提速。我试过在16核机器上把嵌入表转换时间从4秒压到0.3秒效果非常显著。如果你连内存都放不下那就得考虑量化嵌入表或者更极端一点把嵌入表放到磁盘上用mmap读取。mmap的好处是访问时才换入内存这可以让你的引擎在内存紧张的设备上也能跑起来但代价是生成速度会慢很多。我个人的经验是不到万不得已不要用mmap嵌入表因为它把频繁的随机访问压力转给了操作系统的缺页中断速度完全不可控。优先还是考虑FP16加INT8的混合方案。4.3 验证嵌入层输出的正确性很多人在写了嵌入层之后随便跑一次推理看到有输出就认为整个模型没问题了。这是特别危险的心态。嵌入层的错误有时候很隐蔽——维度没错、内存也没越界但数值就是偏离了标准实现。比如你可能在加载模型权重时把行序弄反了或者精度转换的舍入方式不同。这些问题只有在和参考实现做数值比对时才能暴露出来。我建议在做完整模型的端到端测试之前先单独为嵌入层写一个单元测试。具体做法是用一个很小的随机模型比如4个token、隐藏维度8手工构造输入并记录下来。然后在参考框架比如PyTorch或Transformers库里跑同样的输入把嵌入输出导出为二进制文件。再去你的引擎里加载同样的权重和输入计算你的输出与参考输出之间的最大绝对误差和平均误差。误差在1e-3以内通常说明你的实现逻辑没问题超过这个范围就要开始排查权重加载顺序和精度转换过程。这类测试看起来不起眼但它的价值在于能帮你把所有底层的数值假设固定住。后面你在优化Token生成速度或改内存布局时随时可以跑一遍这个测试来确认自己没有改坏东西。我在自己的引擎里始终保留这个微型的数值回归测试每次改动嵌入层相关代码都会跑一遍。它曾经在第一时间帮我抓到过一个行跨度计算的小bug那个bug如果漏过去整个模型的输出都会是噪声。所以我真心建议每一位手写引擎的人把这种“微型测试”当作代码的一部分写进工程里而不是当作额外的负担。5. 最后的经验之谈在把这一整章落到自己的引擎里之后我最大的体会是词嵌入层看似简单但它其实是连接离线权重与在线推理之间的一座桥。桥的设计越扎实后面模块的负担就越小。那些在工程实践里反复出现的维度不匹配、内存越界、加载缓慢的问题绝大多数都能在嵌入层这一关被拦截住。反向推导的话你甚至可以在架构层面用一个良好设计的嵌入层来倒逼整个引擎的数据流规范化。我想再多分享一个我在实际项目里用到的小策略永远在嵌入层里保留一个“调试开关”。当开关打开时查表函数会把输入的每个token ID和它所对应的嵌入向量前几个维度打印到日志里。这个开关在正常推理时是关闭的零开销只有在你排查问题时才手动打开。我曾经靠这个简单的输出很快就发现了一个分词器与词表顺序不一致的错误而当时如果靠断点去跟踪估计要花上数倍的时间。别小看日志的力量在底层引擎调试中往往最简单的输出反而是最快的定位方式。下一步你可以继续往Transformer Block推进了。带着嵌入层的设计经验你会对张量的形状、精度和内存生命周期有更敏锐的判断力。这些底层细节的积累最终会让你的推理引擎不仅“跑得起来”还“跑得快”。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。