神经网络变得简单(第 95 部分):降低变换器模型中的内存消耗(基础篇)
◍ 给变换器模型腾内存的实在办法
在 MT5 里跑变换器类神经网络,显存和内存占用经常成为瓶颈。Dmitriy Gizlyk 在 2025 年 3 月发布的这套思路,核心是把注意力矩阵的中间激活从常驻显存改成按需重算,实测能把同规模模型的峰值内存压到原来的大约 60%。 外汇与贵金属行情的高波动特性,会让序列长度在回测时放大数倍,内存消耗可能因此非线性上涨,属于典型高风险场景,动手前先在模拟盘验证。 具体落地时,优先改掉那种一次性把整段序列的 QK^T 存下来的写法,改成分块计算再局部聚合。这样你开 MT5 把同一套 EURUSD 的 M15 样本丢进去,能直接看到任务管理器里的内存曲线变平。
从 MQA 到 MLKV:KV 缓存压缩的演进路径
- 年变换器架构落地后,LLM 的自回归解码器在每个时间步都要加载并保留 Key/Value 缓存(KV 缓存)。该缓存随模型规模、批大小与上下文长度线性增长,实测中可能比模型权重本身还占内存,这对本地化推理和边缘部署是硬约束。
- 年提出的多查询关注(MQA)让同一层所有注意力头共用一组 K/V 投影,理论上把 KV 缓存压到原来的 1/头数,但模型质量与稳定性会出现可见退化。2023 年的分组查询关注(GQA)折中处理:把多个 KV 头划成若干组,当分组数等于层数时获得对应压缩效率,在头数合理时测评接近原模型,但压缩上限仍卡在 1/头数。
MLKV 把共享跨到层间:一个 KV 头可同时服务同层分组与后续层分组,极端时全模型共用一个 KV 头。论文实测即便 KV 头总数少于层数,也能在性能与省内存间取得权衡;把内存压到原始 KV 缓存的 2/层数 时,模型品质未明显下滑。对外汇/贵金属量化中跑本地推理的交易者,这意味着小显存机器也有可能加载更大上下文做实时 AIGC 辅助,但需自测延迟与输出漂移,相关实验高风险且结果非确定。
「跨层共享 KV 头能再砍缓存」
MLKV 算是 MQA 和 GQA 的进一步延伸:不再只在单个自关注层内分组共享 KV 头,而是让不同层的关注头也共用同一组 Key/Value。直观动机来自一个观察——连续层算的东西有相似性,低层抓表面形态、高层抓语义细节,所以把 KV 实体跨层复用,理论上不丢太多信息。 实现上,MLKV 在层间做多级键交换,总 KV 头数被压下去,KV 缓存自然比同配置 GQA/MQA 更小。作者实验给出一条清晰权衡线:内存换精度,设计师自己选砍哪头。 若 KV 头数 ≥ 层数,直接用 GQA/MQA 更划算,因为跨层共享的优势起不来;MLKV 的真正用武之地是 KV 头数 < 层数的内存吃紧场景。 作者实测,当关注头减到不足层数一半时,MLKV 表现已很接近 MQA;若要缓存做到 MQA 的一半,它是相对省事的方案。再往下,用比层数少 6 倍的 KV 头,质量也不会骤崩,但低于这条线就都不好使了。外汇/贵金属相关的端侧推理若跑大模型盯盘,这类压缩直接关系显存占用与延迟,高风险环境里先本地回测再上。
◍ 在 MT5 里把 MLKV 注意力模块跑起来
MQL5 实现 MLKV 的核心思路是复用已有的多层序列模块,以 CNeuronMLMHAttentionOCL 为父类派生新类 CNeuronMLMHAttentionMLKV,避免重写神经层间数据交换逻辑。OpenCL 端只需在原有交叉关注度内核上加一个 KV 头数参数(代码中红色高亮),内核体内用「当前注意力头索引 % KV 头总数」定位 KV 头,并调整键值张量缓冲区偏移,反向传播内核 MH2AttentionInsideGradients 做同样改动即可。 新类引入 iHeadsKV(KV 头数)与 iLayersToOneKV(每隔几层重建 KV 张量)两个变量,配套 KV_Tensors、KV_Weights 集合及 Temp 梯度中间缓冲。所有嵌套对象声明为静态,构造/析构留空,初始化全在 Init 方法:先调基类 CNeuronBaseOCL,再算缓冲区尺寸,循环建 Query 张量与每 iLayersToOneKV 层才建的 KV 张量,并依变换器算法建依赖系数、多头注意力、压缩表示及 FeedForward 缓冲。 前向 feedForward 中,KV 张量按「层索引 % iLayersToOneKV == 0」条件生成(首层索引 0 也满足),其余层复用;反向 calcInputGradients 须对多个内层共享的 KV 误差梯度求和,首次直写、后续经辅助缓冲累加,避免覆盖。updateInputWeights 同样按该节奏更新 Query 与 KV 生成参数,并调压缩层与 FeedForward 参数。 MQA 与 GQA 只是 MLKV 特例:Init 时传 layers_to_one_kv=1,heads_kv 等于 Query 头数即 vanilla 变换器,小于则为 GQA,heads_kv=1 即 MQA。模型架构上,扮演者/评论者各加一个 MLKV 交叉注意力层——实测用 Query 8 头、KV 2 头、9 嵌套层且每 3 层出新 KV 张量,优化器走 Adam,输出接 FreDF 频域协调。外汇/贵金属 RL 训练高风险,参数照搬也可能过拟合。 要验证,开 MT5 用附件代码建 CNeuronMLMHAttentionMLKV,Init 填 iHeadsKV=2、iLayersToOneKV=3,对比 heads_kv=1 的 MQA 模式显存占用差异。
多头注意力里的多KV共享实现
在 MT5 的 OpenCL 神经网络扩展里,标准多头注意力每个头配一套 K/V,显存和计算都吃紧。MH2AttentionOut 这个 kernel 用 heads_kv 参数把多个查询头映射到同一组 K/V 头,shift_k 与 shift_v 按 2*dimension*(k + h_kv) 和 2*dimension*(k + heads_kv + h_kv) 做偏移寻址,等于把 KV 张量在内部复用。 CNeuronMLMHAttentionMLKV 类继承了 CNeuronMLMHAttentionOCL,新增 iLayersToOneKV 与 iHeadsKV 两个字段控制共享粒度,KV_Tensors 和 KV_Weights 用 CCollection 单独管理,Temp 缓冲承接中间结果。AttentionOut 与 AttentionInsideGradients 都被重写,调用前必须给 kernel 设 def_k_mh2ao_heads_kv 和 def_k_mh2aig_heads_kv 参数,否则 printf 报错并返回 false。 实盘跑这类模型要清楚:外汇和贵金属杠杆高、滑点大,GPU 加速只解决算力瓶颈,信号失效风险不因此降低。开 MT5 把 heads_kv 设成小于总头数(例如 8 头配 2 个 KV 头),能直接观察显存占用的下降幅度。
__kernel class="type">void MH2AttentionOut(__global class="type">float *q, class=class="str">"cmt">///<[in] Matrix of Querys __global class="type">float *kv, class=class="str">"cmt">///<[in] Matrix of Keys __global class="type">float *score, class=class="str">"cmt">///<[out] Matrix of Scores __global class="type">float *out, class=class="str">"cmt">///<[out] Matrix of attention class="type">int dimension, class=class="str">"cmt">///< Dimension of Key class="type">int heads_kv ) const class="type">int h_kv = h % heads_kv; const class="type">int shift_k = class="num">2 * dimension * (k + h_kv); const class="type">int shift_v = class="num">2 * dimension * (k + heads_kv + h_kv); if(!OpenCL.SetArgument(def_k_MH2AttentionOut, def_k_mh2ao_heads_kv, (class="type">int)iHeads)) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); class="kw">return false; } if(!OpenCL.SetArgument(def_k_MH2AttentionInsideGradients, def_k_mh2aig_heads_kv, (class="type">int)iHeads)) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); class="kw">return false; } class CNeuronMLMHAttentionMLKV : class="kw">public CNeuronMLMHAttentionOCL { class="kw">protected: class="type">uint iLayersToOneKV; class="type">uint iHeadsKV; CCollection KV_Tensors; CCollection KV_Weights; CBufferFloat Temp; class=class="str">"cmt">//--- class="kw">virtual class="type">bool feedForward(CNeuronBaseOCL *NeuronOCL); class="kw">virtual class="type">bool AttentionOut(CBufferFloat *q, CBufferFloat *kv, CBufferFloat *scores, CBufferFloat *out); class="kw">virtual class="type">bool AttentionInsideGradients(CBufferFloat *q, CBufferFloat *q_g, CBufferFloat *kv, CBufferFloat *kv_g, CBufferFloat *scores, CBufferFloat *gradient); class=class="str">"cmt">//--- class="kw">virtual class="type">bool calcInputGradients(CNeuronBaseOCL *prevLayer); class="kw">virtual class="type">bool updateInputWeights(CNeuronBaseOCL *NeuronOCL); class="kw">public: CNeuronMLMHAttentionMLKV(class="type">void) {}; ~CNeuronMLMHAttentionMLKV(class="type">void) {}; class="kw">virtual class="type">bool Init(class="type">uint numOutputs, class="type">uint myIndex, COpenCLMy *open_cl,
「多头注意力层的张量尺寸拆解」
CNeuronMLMHAttentionMLKV 的 Init 里,先用 fmax 把所有传入维度兜底到至少 1,避免窗口或头数为 0 时直接崩初始化。这一步很实在:iWindow、iWindowKey、iUnits、iHeads、iLayers、iHeadsKV、iLayersToOneKV 全部做了下限保护。 真正值得盯的是后面那一串尺寸推导。以默认观感配置(window=window_key=units=heads=heads_kv=layers=1)为例:Q 张量长 num_q = 1*1*1 = 1,KV 张量 num_kv = 2*1*1*1 = 2,而 ff_1 权重矩阵 = 4*(1+1)*1 = 8。这些数直接决定显存占用和 OpenCL 缓冲分配,调大 window 到 10 会让 q_weights 从 2 跳到 (10+1)*1*1 = 11。 循环里按 iLayers 逐层挂 CBufferFloat 临时缓冲,说明多层堆叠是同一个 Init 里一次性铺开的。外汇与贵金属行情用这类结构做序列建模时,杠杆与滑点会带来高风险,任何维度放大都可能让回测与实盘偏差拉大,建议先在 MT5 策略测试器里用最小维度跑通再逐步加压。
class="type">uint window, class="type">uint window_key, class="type">uint heads, class="type">uint heads_kv, class="type">uint units_count, class="type">uint layers, class="type">uint layers_to_one_kv, ENUM_OPTIMIZATION optimization_type, class="type">uint batch); class=class="str">"cmt">//--- class="kw">virtual class="type">int Type(class="type">void) const { class="kw">return defNeuronMLMHAttentionMLKV; } class=class="str">"cmt">//--- class="kw">virtual class="type">bool Save(class="type">int const file_handle); class="kw">virtual class="type">bool Load(class="type">int const file_handle); class="kw">virtual class="type">bool WeightsUpdate(CNeuronBaseOCL *source, class="type">float tau); class="kw">virtual class="type">void SetOpenCL(COpenCLMy *obj); }; class="type">bool CNeuronMLMHAttentionMLKV::Init(class="type">uint numOutputs, class="type">uint myIndex, COpenCLMy *open_cl, class="type">uint window, class="type">uint window_key, class="type">uint heads, class="type">uint heads_kv, class="type">uint units_count, class="type">uint layers, class="type">uint layers_to_one_kv, ENUM_OPTIMIZATION optimization_type, class="type">uint batch) { if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, window * units_count, optimization_type, batch)) class="kw">return false; iWindow = fmax(window, class="num">1); iWindowKey = fmax(window_key, class="num">1); iUnits = fmax(units_count, class="num">1); iHeads = fmax(heads, class="num">1); iLayers = fmax(layers, class="num">1); iHeadsKV = fmax(heads_kv, class="num">1); iLayersToOneKV = fmax(layers_to_one_kv, class="num">1); class="type">uint num_q = iWindowKey * iHeads * iUnits; class=class="str">"cmt">//Size of Q tensor class="type">uint num_kv = class="num">2 * iWindowKey * iHeadsKV * iUnits; class=class="str">"cmt">//Size of KV tensor class="type">uint q_weights = (iWindow + class="num">1) * iWindowKey * iHeads; class=class="str">"cmt">//Size of weights&class="macro">#x27; matrix of Q tenzor class="type">uint kv_weights = class="num">2 * (iWindow + class="num">1) * iWindowKey * iHeadsKV; class=class="str">"cmt">//Size of weights&class="macro">#x27; matrix of KV tenzor class="type">uint scores = iUnits * iUnits * iHeads; class=class="str">"cmt">//Size of Score tensor class="type">uint mh_out = iWindowKey * iHeads * iUnits; class=class="str">"cmt">//Size of multi-heads self-attention class="type">uint out = iWindow * iUnits; class=class="str">"cmt">//Size of out tensore class="type">uint w0 = (iWindowKey + class="num">1) * iHeads * iWindow; class=class="str">"cmt">//Size W0 tensor class="type">uint ff_1 = class="num">4 * (iWindow + class="num">1) * iWindow; class=class="str">"cmt">//Size of weights&class="macro">#x27; matrix class="num">1-st feed forward layer class="type">uint ff_2 = (class="num">4 * iWindow + class="num">1) * iWindow; class=class="str">"cmt">//Size of weights&class="macro">#x27; matrix class="num">2-nd feed forward layer for(class="type">uint i = class="num">0; i < iLayers; i++) { CBufferFloat *temp = NULL;