ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

手写LLM推理引擎:词嵌入层的内存布局与性能优化实战

手写LLM推理引擎:词嵌入层的内存布局与性能优化实战

从前面几章一路读过来的朋友应该清楚,我们正在一点一点拆掉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_cast<uint8_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推进了。带着嵌入层的设计经验,你会对张量的形状、精度和内存生命周期有更敏锐的判断力。这些底层细节的积累,最终会让你的推理引擎不仅“跑得起来”,还“跑得快”。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进