国产卡真的不行吗?我把 910C 从 14 毫秒榨到 5 毫秒,聊聊背后的真相

先聊点题外话。
这两年圈子里有个特别拧巴的现象:一边是国产算力呼声震天响,一边是真实业务里没人敢真拿国产卡上在线服务。问就是"慢",再问就"跟 H20 差距太大"。但你真去问那些说"慢"的人,慢在哪、瓶颈是什么、占了多大比例,十个里有九个答不上来,只会甩一句"反正就是慢"。
我一开始也是这个心态。直到有一天被逼着在一张 910C 上做在线推理优化,把时间线一层层剖开,我才发现一个让人哭笑不得的真相:
国产卡不是算力不行,是"指挥系统"太笨。
这句话听着玄,我给你打个比方就明白了。
想象一家顶级餐厅,后厨有个厨艺绝伦的大厨,刀工火候样样顶尖。按理说生意应该火爆,可这家店上菜特别慢,客人骂声一片。老板一查,问题不在大厨,在传菜员。这位传菜员有个毛病:一次只肯端一道菜,端完还得先站在原地等大厨确认"这道菜做好了吧",然后才去端下一道。大厨大部分时间不是在做菜,是在等传菜员慢慢磨蹭。
昇腾 910C 就有点像这位大厨。它的计算核心(AI Core)其实很强,尤其是处理大块、计算密集的任务时,效率高得惊人。但一旦遇到在线推理这种低并发的场景,一次就一两个人提问,任务全都碎成芝麻大的小算子,每个算子算几微秒就算完了。真正让芯片"闲等"的,是 host 侧(CPU)一遍遍地发指令、启动 kernel、等同步、等通信。
举个具体数字。一个叫 slot mapping 的小 kernel,它干的事特别简单,就是把每个 token 的逻辑位置映射到物理显存槽位,纯整数运算,计算本身一微秒都用不了。但每次启动这个 kernel 的固定开销是 131 微秒。你想想,为了做一件一微秒的活,要先花 131 微秒去"把它叫起来",这不就是传菜员每次端菜前都要站在原地等大厨点头确认半天吗?
更扎心的是,910C 的 prefill 阶段(就是首 token 那一段,要处理一大段长上下文,计算密集)表现其实相当好,首 token 延迟甚至比 H20 还快。真正的短板全在 decode 阶段,就是那一个个往外吐 token 的部分。在线体验好不好,拼的就是这个 decode 快不快,也就是我们常说的 TPOT,单 token 解码延迟。
所以整个优化的主线一下就清晰了:低并发场景下,瓶颈根本不在芯片算力,而在 host 侧的调度开销、内存拷贝、通信同步这些"指挥"层面的浪费。 把这条流水线理顺了,性能能翻一倍都不止。
这篇我把自己走完的整条路从头到尾捋一遍,从零成本的参数调优,到侵入框架源码,到手写昇腾原生算子,再到怎么把 GLM-5.3 的上下文从 200k 撑到 300k,最后聊聊如果你拿 GLM-5.3 专门写代码该怎么调。全程不藏私,哪步有坑、哪个参数实测反而变慢,我都给你标出来。
第一步:先别急着改代码,把地上散落的钱捡起来
做任何优化,第一铁律都是:先捡便宜。改源码成本高、风险大,永远放在最后。昇腾这套环境里,散落着不少"零成本"的优化机会,不用改一行代码,换个版本、加个环境变量就能拿到稳定收益。
版本升级是最大的隐藏红利
你绝对想不到,整个优化过程里,单次收益最大、最省事的一步,居然是升级版本。
我们把 vLLM-Ascend 从 0.22.0rc1 升到 0.23.0rc1,别的参数一个没动,单 token 延迟直接从 14.2ms 掉到 12.0ms,降了 15.4%。就这一步,顶得上后面十几个精细化优化的一半。
为什么?因为 vLLM-Ascend 社区迭代太快了,每一版都在修通信路径的 bug、修图编译的逻辑、修调度器的毛病。这些 bug 平时感觉不到,但在 decode 这种几千个算子密集跑的场景里,每一个小 bug 都在悄悄吃掉你的性能。
这里有个血泪教训,必须单独拎出来说:
所有源码优化都是绑定版本的。 你在 0.22 上辛辛苦苦打的补丁,升到 0.23 基本全废,得重写一遍。所以正确的顺序是:先把基线锁在最新稳定版上,再开始做源码优化。千万别反过来,先写一堆补丁再升级版本,那等于把自己写的东西再推翻重来,纯纯的自我惩罚。
CANN 也一样。昇腾的软件栈分两层,上层是 vLLM-Ascend,底层是 CANN。从 CANN 9.0.0 换到 9.1.0,单 token 延迟又从 8.8ms 降到 8.5ms。而且 9.1.0 还是后面一系列更狠的编译优化(SuperKernel 这些)的前置依赖,你不升它,后面的招都用不了。
通信挪个地方,白捡 6 个点
昇腾的集合通信(主要是 AllReduce,就是多卡之间把计算结果汇总的那步)默认跑在一个叫 AICPU 的核上。这个核怎么说呢,算力和带宽都远不如主计算核(AI Core),而且它和主计算流是串行排队的,一步做完了才轮到下一步,特别浪费。
解决办法特别简单,一个环境变量:
export HCCL_OP_EXPANSION_MODE=AIV
把通信"展开"到 AIV 向量核上跑,通信就能和计算重叠起来。就这么一行,单 token 延迟降 6.6%,在另一个模型上验证也是稳定的 6.5%。这是整个优化过程里最稳、最省事的一个开关,几乎是白捡的。
静态 kernel,把"每次都要现算"变成"提前算好"
vLLM-Ascend 默认用的动态 shape kernel,有个特别烦人的毛病:哪怕你已经被 CUDAGraph 捕获了固定 batch 大小的图,每次真正执行的时候,host 侧还是要走一遍"这个算子参数什么样、该分配多大内存、workspace 怎么算"的动态推导逻辑。这些推导明明每次结果都一样,却偏偏要重复算。
静态 kernel 的思路就是:在服务启动的预热阶段,把这些推导全部固化进编译产物,之后执行直接走预编译的静态路径,不再现算。
export ASCEND_RT_VISIBLE_DEVICES=0,1
export LOCAL_WORLD_SIZE=2 # 多卡必须设置
vllm serve /root/workspace/models/Qwen3-32B \
--tensor-parallel-size 2 \
--compilation-config '{"cudagraph_mode": "FULL_DECODE_ONLY"}' \
--additional-config '{"ascend_compilation_config": {"enable_static_kernel": true}}'
这个收益和模型结构强相关。在 Qwen3-32B 这种稠密模型上只降 1.9%,但在 Qwen3.5-35B-A3B 这种激活参数小的 MoE 模型上,收益高达 18.7%。原因前面说了,激活参数小,decode 里算子又多又碎,host 侧动态推导的开销占比就特别高,静态 kernel 正好打中要害。
内存这块,也有大学问
昇腾 HBM 的分配器,支持一组通过环境变量控制的调优项。针对在线低并发,建议开虚拟内存映射、大页、后台 pin memory:
export PYTORCH_NPU_ALLOC_CONF=expandable_segments:True,base_addr_aligned_kb:16,page_size:1g,pinned_use_background_threads:True,pin_memory_expandable_segments:True
听起来复杂,其实逻辑很朴素。虚拟可扩展段解决 HBM 碎片问题,大张量分配不再要求物理连续;1GB 大页减少页表项数量,降低映射开销;锁页内存走后台线程,避免阻塞推理主流程。这套组合叠加前面优化,单 token 延迟从 9.1ms 降到 8.8ms。
host 侧的内存分配也要管。vLLM 的 Python/host 侧每步会产生大量小对象分配,而 glibc 自带的 malloc 在多线程高频小对象场景下有严重的锁竞争问题。解法是一个环境变量,把分配器换成 jemalloc:
export LD_PRELOAD=/usr/lib64/libjemalloc.so.2
这个收益随并发增大而增大,因为并发越高,线程间分配竞争越激烈,jemalloc 的 thread cache 结构优势就越明显。这是一项零风险的通用优化,建议默认就开。
权重预取,得先破一道"门"
MoE 模型有个特点,decode 时专家权重要从 HBM 里读,权重体量远大于激活数据,带宽受限。weight prefetch 机制就是提前把下一层权重搬进高速缓存,用当前层的计算时间掩盖权重加载的延迟。
但社区原生的实现里有个阈值:
MOE_PREFETCH_TOKEN_THRESHOLD = 96 # 原值,token 少于 96 不做预取
这个 96 是为高吞吐批量场景设计的保守值。可我们的在线场景偏偏是 c=1 到 8 的极小 batch,decode 时 token 数等于并发数,永远到不了 96。解决办法就一行:
MOE_PREFETCH_TOKEN_THRESHOLD = 1 # 改成 1,c=1 也能触发预取
改完之后,配合 pa_shape_list: [1,4,8] 显式声明 decode 的 batch 形状,低并发的预取就能跑起来了。
避坑清单,比参数更重要
这一层最大的收获其实是"哪些参数千万别用"。昇腾环境变量几百个,很多和 SoC 型号、CANN 版本、模型结构强耦合,文档上的"推荐值"绝对不能当真。我们实测踩过的坑就有:
- 某些激进绑核参数(CPU_AFFINITY_CONF=2)实测是负优化,降性能;
- 一些网传的 CANN 开关,在这个特定 910C 版本上直接报错或者变慢;
- 同一优化在不同模型上结论可能完全相反。
所以记住这句话:每模型必实测,建立单变量 AB 压测的流程,比记住任何具体参数都重要。
第二步:参数见顶了,就钻进源码里抠微秒
环境变量能捡的便宜捡完了,天花板到了。这时候性能瓶颈全藏在框架的流程里,只能动源码。核心思路其实就四句话:消灭没必要的同步,消灭没必要的 H2D 拷贝,消灭多余的 kernel 启动,用多流把串行任务并行起来。
MoE 共享专家,别让它排队干等
MoE 层的标准执行顺序是:路由专家计算 → 共享专家计算 → AllReduce 通信,三者串行。
但你仔细看数据依赖会发现,共享专家和路由专家之间根本没有依赖,完全可以并行。于是改造思路就是:把共享专家放到一条独立的 stream 上,和主计算流上的路由专家并行跑,连共享专家的 AllReduce 也留在那条侧流上,最后主流等侧流完成。
overlap_enabled = self.multistream_overlap_shared_expert and not get_forward_context().capturing
with npu_stream_switch(shared_experts_calculation_stream(), enabled=overlap_enabled):
shared_out = ... # shared expert 计算
if need_all_reduce:
with torch.npu.stream(shared_experts_calculation_stream()):
tensor_model_parallel_all_reduce(shared_out)
torch.npu.current_stream().wait_stream(shared_experts_calculation_stream())
这里还有个细节。并行化之后,原来用于同步的 dispatch/combine 事件就多余了,继续保留会在每个 MoE 层产生额外开销,于是加了个标志,在 overlap 路径进去时置位、退出时复位,把那些多余的事件全去掉。
顺带还修了一个上游的 bug:原本 FUSED_MC2 和多流是互斥的,强制开 FUSED_MC2 就把多流关掉。分析清楚发现这俩根本不冲突,一个管计算流并行,一个管通信算子融合,于是解开互斥,让两个优化叠加生效。这个修复的价值在于,有时候你发现某个优化"该有效却无效",未必是优化本身不行,可能是某个隐藏的互斥锁把它按住了。
Decode 阶段的"懒执行",不变就不做
原生 vLLM 每一步解码,不管 batch 结构变没变,都无脑执行一大串操作:block table 提交、各种数组清零、内存拷贝。这些操作虽标着 non_blocking,实际上仍会插入多余的 host 到 device 的 DMA,拉长异步流水。
改造的思路是引入"上一轮状态"跟踪,结构没变就跳过:
- 请求数量、token 总数、block 表校验和没变化,就跳过 commit_block_table;
- 尾部清零只在请求数收缩时才做;
- 各种 copy_to_gpu 分别按"确实有变化"才执行。
这个优化特别契合在线低并发,因为 c=1 到 8 的 decode,连续很多步 batch 结构几乎完全不变,那些每步都在做的无用拷贝全都被跳过了。
把 kernel 启动省到极致
前面说的 slot mapping,就是那个每次启动要花 131 微秒的小 kernel,改造方式是加一个 CPU 快路径:当 token 数小于 80 时,直接挪到 host 侧用 numpy 算好,再一次非阻塞 H2D 写回。
req_idx = np.repeat(np.arange(num_reqs, dtype=np.int32),
query_start_loc_cpu[1:] - query_start_loc_cpu[:-1])
pos_arr = positions_cpu[:num_tokens].astype(np.int64)
pos_block = pos_arr // self.block_size
pos_offset = pos_arr % self.block_size
block_ids = self.block_table.np[req_idx, pos_block].astype(np.int64)
slots = (block_ids * self.block_size + pos_offset).astype(np.int32)
self.slot_mapping.np[:num_tokens] = slots
self.slot_mapping.copy_to_gpu(num_tokens) # 单次非阻塞 H2D
c=1 到 8 的 decode,token 数恒小于 80,所以这个快路径在目标场景 100% 命中,每步省下约 131 微秒的 kernel 启动和相关同步。
GDN 线性注意力,专治"launch 爆炸"
如果你用的是带 GDN(Gated DeltaNet)线性注意力层的模型,比如 Qwen3.5 这种混合架构,decode 时间线上算子最密集的区域就是它。每层包含 causal_conv1d、mixed_qkv 拆分、l2norm、recurrent kernel,一堆小算子堆叠,host 开销和 HBM 往返都极其严重。
三个方向的改造:
第一,把 L2Norm 直接融合进 recurrent kernel 的 AscendC 算子内部,让 q/k/v 在片上寄存器完成归一化,直接参与状态更新,避免多次 HBM 往返和 kernel launch。
第二,host 侧小开销:conv 权重转置结果按 data_ptr 缓存,省掉每层每步的 transpose 加 contiguous,约 129 微秒/step;mixed_qkv 的 concat 改成 reshape 返回视图,省掉每层的 Concat 操作,约 123 微秒/step。
第三,把 mamba state 拷贝挪到独立流,和别的计算重叠,用预分配的 Event 做同步,避免每 step 创建销毁事件的开销。
全文收益最大的一刀:拒绝采样的掩码缓存
这一项是整个源码优化里收益最大的单点,把单 token 延迟从 7.02ms 直接压到 5.12ms。
问题出在投机解码的拒绝采样路径上。原生 MinTokensLogitsProcessor 在每个 decode step 都在 host 侧用 numpy 重建掩码,然后两次 torch.from_numpy(...).to(device) 拷到设备。关键坑在于:源内存是分页内存(非锁页),这类 H2D 拷贝实际是同步的,host 必须等设备排空并完成拷贝,异步调度流水被硬生生打断。实测光这一项就占单步时间约 30%。
解决的关键洞察是:掩码在稳态期是常量。只要每个请求剩余的最小生成长度不小于投机 token 数,掩码内容就不变。于是把掩码预计算成 device 端的索引张量,按 batch 结构和请求结构缓存,只在结构变化时重建。每步应用就只执行一次纯设备端的索引赋值,没有 numpy 重建、没有 H2D 拷贝、没有同步。
另外一个关键细节:昇腾上 masked_select 会编译成 NonZero 算子,触发 host-device 同步,所以所有掩码应用都改成纯整数 advanced indexing,彻底绕开同步。
词表并行 argmax,通信量降 3 万倍
贪婪采样的时候,我们根本不需要完整的 logits,只需要最大值对应的 token id。但原生的 compute_logits 会对全词表 logits 做 TP all-gather,通信量是 O(batch × vocab_size),天文数字。
改造思路是:先在每个 TP 分片内做局部 argmax,只聚合 (max_value, max_index) 对。按 15 万词表、TP=2 估算,通信量直接下降约 31000 倍。
logits = lm_head.quant_method.apply(lm_head, hidden_states, bias=None) # 只算本分片
logits[..., -num_pad:] = -float("inf") # mask 掉 padding
local_max_vals, local_max_indices = logits.max(dim=-1)
global_indices = local_max_indices + vocab_start # 分片索引 -> 全局索引
local_pair = torch.stack([local_max_vals.float(), global_indices.float()], dim=-1)
gathered = tensor_model_parallel_all_gather(local_pair, dim=-1) # 只 gather 值+索引对
max_rank_idx = gathered[:, :, 0].argmax(dim=-1, keepdim=True) # 按值选最大分片
top_tokens = gathered[:, :, 1].gather(dim=-1, index=max_rank_idx) # 取对应 token id
为什么正确?因为 soft_cap(tanh)和 scale 都是单调变换,不影响 argmax;padding 位置已置负无穷;"分片内 argmax + 跨分片比大小"和"全词表 argmax"严格等价。
顺带还有一个小优化:bf16 的 logits 做 argmax 不需要转 fp32。因为 bf16 的指数位比 fp16 更宽,任意 bf16 值都能被 fp32 精确表示,argmax(bf16) 和 argmax(bf16.to(fp32)) 逐元素比较顺序完全一样。省掉一次全词表(约 24.8 万元素/步)的 cast kernel。
第三步:改框架不够,就手写算子
源码能抠的微秒抠完了,还剩最后一块硬骨头:手写昇腾原生的 AscendC 算子,把一串连续算子打包成一个大算子。
FUSED_MC2 与 SuperKernel
FUSED_MC2 是昇腾针对 MoE dispatch/combine 通信的融合机制,把 AllToAll 通信和 GroupedMatmul 计算在算子内部交织,减少通信和计算的往返。
export VLLM_ASCEND_ENABLE_FUSED_MC2=1 # 启用计算通信融合算子
SuperKernel 则是把多个连续的小 kernel 在编译期合并为一个大的 SuperKernel,同步 launch,减少 kernel 加载次数,同时天然形成算子间的流水重叠。启用方式在 vllm-ascend 侧通过编译选项:
options["static_kernel_compile"] = True
options["super_kernel_optimize"] = True
options["_vllm_aclnn_static_kernel_sym_range"] = \
_compute_decode_cudagraph_batch_sizes(vllm_config)
其中 sym_range 把静态编译限定在 CUDAGraph 捕获的 batch 尺寸上,避免编译时间爆炸。SuperKernel 开启后,未开投机解码的 c=1 单 token 延迟从 8.5ms 降到 8.2ms,开了 MTP 后收益进一步放大。
自研融合算子:把一整条链路压进一个算子
最狠的一招,是针对 910C 双 die TP=2 的硬件拓扑,自研一个融合算子。它把 decoder 层里最常见、最重的一条序列:o_proj(矩阵乘)+ AllReduce(通信)+ residual add(残差加)+ RMSNorm(归一化),压进单个算子。
设计上采用 AIC(Cube 核)和 AIV(Vector 核)双核协同。AIC 侧用细粒度 matmul,每算完一个 tile 就在全局内存置位就绪 flag;AIV 侧轮询 flag,tile 就绪后立即启动跨 rank 的细粒度归约,两个 die 通过 HCCS 直接读写对方的内存,完成 reduce-add 后再做 RMSNorm,全程不回到 host。这就形成了 matmul 和通信的细粒度流水,AIC 算得越往后,AIV 的归约和归一化越能和它重叠。
这里有个工程上的重要设计:双层回退。Python 侧先做第一道守卫,条件不满足就返回 None 走未融合路径;算子内部还有第二道防线,不支持的形状通过 Dispatcher 回退到标准算子组合(matmul + all_reduce + add_rms_norm)。这保证了正确性永远不依赖融合算子的形状覆盖度。这个设计思路特别值得学习:任何性能优化,都要给自己留一条保证正确的后路,别让性能优化变成正确性的赌注。
接入后,单 token 延迟从 5.2ms 压到 5.1ms,而且因为 prefill 阶段也走这个融合算子,首 token 延迟从 213ms 降到 179ms,降了 16%。
一条必须踩的坑:CUDAGraph 捕获尺寸
这是整个优化过程中最容易翻车、也最反直觉的一个点。
开启投机解码 MTP 时,出现了严重的性能劣化:c=4 的单 token 延迟从 10.4ms 飙到 42.2ms,c=8 从 13.5ms 飙到 42.9ms。怎么排查都找不到原因,最后发现根因是:开启投机后每个 decode step 实际执行的 token 数变了。
原本 c=4、TP=2 对应的 token 数是 4 个,但投机后变成 并发数/DP × (1 + 投机长度),再加上可能的 bonus,比如 c=4、TP=2、投机 4 个,实际 token 数变成 10 到 20 个。而默认的捕获尺寸 [1,2,4,5,8] 完全覆盖不到这些形状,导致投机步骤落不进图里,每次都走 eager 路径,性能直接崩盘。
修复办法是按公式重新设置捕获尺寸:
--compilation-config '{"cudagraph_mode": "FULL_DECODE_ONLY",
"cudagraph_capture_sizes": [5, 20, 40]}'
修复后 c=4 从 42.2ms 回到 10.4ms,c=8 从 42.9ms 回到 13.5ms。
这个坑的教训非常深:任何开启投机解码的优化,第一步都要重新算清楚每一步真实执行的 token 数,然后让 CUDAGraph 捕获尺寸覆盖这些形状。 否则你前面优化的所有收益,都会被这一个隐藏的形状失配全部吐回去。
第四步:把 GLM-5.3 的上下文从 200k 撑到 300k
聊完通用的推理优化,我们切换到模型本身。GLM-5.3 原生支持 200k 上下文,想撑到 300k+,第一步得先破除一个巨大的误区:
框架能塞进 300k token,不等于模型能正确理解 300k 上下文。
很多人改个参数强行塞 300k,显存是够了,但模型在 200k 之后的位置语义错乱,找不到前文。这俩完全是两码事。
这里有个和 Qwen、Llama 天差地别的关键点。GLM 家族用的是自研的 ALiBi 变体位置编码,不是标准 RoPE。这意味着网上所有给 Llama/Qwen 用的 RoPE 缩放脚本(NTK、YARN),一个都不能直接套用,架构不匹配,硬套直接坏模型。
ALiBi 的特点,我再用餐厅比方讲一遍。RoPE 那种位置编码,就像给每个座位标了固定的角度,坐太远角度就转飞了,直接崩坏。而 ALiBi 不一样,它像在一个大厅里,每个位置的"存在感"都带个衰减系数:离主讲台越远,你能听清的声音越小,但不是听不见,是渐进式变弱。所以 ALiBi 理论上支持无限外推,但距离越远,模型召回前文的能力越弱,这就是它长上下文的根本特性。
搞懂这个本质,外推 300k 就有四条路可选。
方案一:纯推理侧改造,零微调,成本最低
第一步,改 config.json 里的 max_position_embeddings,从 200000 改成 327680 或者更大,绕开框架的长度校验。
{
"max_position_embeddings": 327680
}
注意,因为 ALiBi 的衰减斜率是预训练阶段固定死的,单纯改这个参数不会提升长距离能力,它只是允许你输入更长序列。真正的功夫在显存。
300k 上下文的 KV Cache 显存压力巨大,在线推理几乎跑不起来。工程要点:
- 开 PagedAttention,这是 vLLM 默认能力,必开;
- 开 KV Cache 量化,优先 FP8 而不是 INT4。因为长距离的符号绑定、语义依赖细粒度数值差异,INT4 量化损失太大,容易出现信息丢失;
- 开 Chunked Prefill,长序列一次性 prefill 峰值显存极高,分块逐步处理压峰值;
- 框架层面放开
--max-model-len 320000。
长序列下昇腾的 CUDAGraph 支持有限,prefill 阶段基本要走 eager,decode 小步可以保留图捕获,这是标准搭配。
方案二:位置编码重校准,轻量微调
如果要求 300k 内长距离召回不掉太多,只靠原生外推不够。因为 GLM 的位置信息全部由 ALiBi 的衰减斜率 m 控制,那就冻结主体模型,只微调这个斜率,在 200k 到 320k 的长文本上训练,抬高远距离位置的注意力权重。
参数量极小,LoRA 就能搞定,算力需求远低于全参数微调。这是 GLM ALiBi 架构下性价比最高的微调路线。
方案三:全量持续预训练,效果好但烧钱
用大量 200k 到 320k 的长文本持续预训练基座,让模型原生见过 300k 位置分布,长距离检索、摘要、复杂逻辑都稳定。但成本极高,需要大量高质量长文本和大算力集群,普通业务基本不会选。
方案四:外挂 RAG,最稳的折中
如果你本质需求只是"能读取 300k 文档内容",而不是让模型原生注意力覆盖全部 300k,那优先 RAG:把超长文档切分、向量化检索,只把相关片段塞给模型 200k 以内的窗口。
完全不改模型,长距离召回可控,没有外推衰减问题。唯一的代价是依赖检索质量,跨片段关联、需要全局统筹的任务有局限。
四条路怎么选?我的建议是:先评估业务需求,是不是真的需要模型注意力看到全部 300k。如果只是读长文档,优先 RAG,零风险。如果必须原生 300k,先走方案一纯推理外推,做长召回评测看掉点能不能接受,掉点严重再上方案二做轻量校准。
第五步:当 GLM-5.3 专门用来写代码
聊到这里,如果你和我一样,拿 GLM-5.3 主要做 AI Coding,那前面所有东西都要换一副眼镜看。因为 AI Coding 的负载特征特别独特,和普通在线问答完全不是一回事。
写代码这个场景,有几个鲜明的特点:
- Prefill 是长上下文,仓库代码、报错日志、PRD、历史对话、diff,经常冲到 100k 甚至 200k 以上,这正是你要扩 300k 的原因;
- Decode 是逐 token 吐代码、注释、shell、修复方案,单 token 延迟直接决定写代码卡不卡、Agent 循环顺不顺;
- 负载是大量小算子加频繁的 host 校验,加上 MTP 投机解码和大量 argmax 采样;
- 任务是多轮 Agent 闭环:读文件 → 改代码 → 执行 → 看报错 → 再改,对稳定性、低毛刺的要求远高于普通聊天。
所以调优优先级也和通用场景不同。
零成本层:KV 选 FP8,prefix cache 必开
KV Cache 量化建议用 FP8 而不是 INT4,因为代码语义、变量名、函数签名依赖细粒度数值差异,INT4 在超长代码上下文里容易出现变量混淆、函数签名认错、漏看约束。
--enable-prefix-caching 一定要开。这是编码场景独有的巨大红利:AI Coding 多轮对话有大量重复的上下文前缀(同一个仓库、同一组文件),prefix cache 可以跳过重复代码的 prefill。普通聊天场景收益有限,但编码场景命中率极高,Agent 每次只新增少量文件、少量 diff。
MTP 投机解码:收益最大,坑也最多
代码是高可预测文本,MTP 投机在编码场景收益远高于闲聊。但注意:
- 投机 token 数不要开太大,3 个就够。开太大形状组合爆炸,CUDAGraph 捕获集合急剧膨胀,毛刺巨大;
- 压测一定要模拟真实 Agent 行为,连续多轮 decode,而不是单条独立请求。单请求压测好看,Aider 连续循环就崩,是编码场景常态。
源码层:按编码场景排优先级
采样这块,写代码大部分时候是贪婪或低温度采样,不需要完整 logits,那套词表并行 argmax 优化在编码场景非常适配。
源码补丁按收益排优先级:MTP 掩码设备侧缓存排第一,Agent 连续写代码、连续 decode,稳态掩码固定,消除每一轮 CPU 重建加 H2D 同步拷贝,体感改善最强;decode 懒执行排第二,Agent 连续多轮 decode 时很多步 batch 完全没变化,懒校验刚好命中;MoE 多流和词表并行 argmax 紧随其后。
Agent 层:瓶颈往往不在 vLLM,在策略
最后必须强调,很多时候单 token 延迟已经压得很低,但写代码体验还是差,瓶颈不在 vLLM,在 Agent 策略。要主动做上下文裁剪,把确认正确的旧日志、旧 diff 摘要掉,不要无限堆 token 冲到 300k;大重构拆成小任务,单任务尽量落在 120k 以内,这是 GLM-5.3 ALiBi 衰减比较温和的区间,稳定性最高。
System Prompt 也要针对 ALiBi 长距离遗忘加约束,让它引用前文函数、结构体、接口时主动复述签名,找不到定义就明说,不要编造接口,连续三次失败就停止自动迭代,列出不同思路交给人决策。
最后,把这一切串起来
现在回头看,整条路其实是一套可以反复套用的方法论,跟具体的芯片、具体的模型无关。
第一,先 profile,再动手。一切的起点都是把时间线抓出来,定位瓶颈到底是 host 调度、H2D 同步、通信、HBM 带宽还是 kernel launch。不定位就调参,是瞎猫碰死耗子。
第二,捡便宜优先。版本升级、环境变量、内存配置,这些零成本手段先上,往往能拿到一半以上的收益。
第三,改源码要按场景取舍。低并发在线推理,核心是消灭 host 侧的同步、拷贝、kernel launch,用多流并行;高并发离线 batch,瓶颈又不一样,不能照搬。
第四,收益和风险永远要平衡。每一个源码优化、每一个融合算子,都要有正确的兜底。CUDAGraph 捕获尺寸那个坑就是最好的例子,一个隐藏的形状失配能把所有收益全部吐回去。
第五,用真实负载验证。投机解码的收益和接受率强相关,接受率受数据分布影响,公开数据集收益会虚高,必须拿真实业务数据评估。
国产算力推理优化的本质,从来不是拼芯片,是拼谁更懂怎么指挥芯片。910C 这个例子最有力的地方在于:它证明了一张被评价为"慢"的国产卡,在正确的工程方法下,能把 decode 延迟从 14ms 压到 5ms,能和 H20 正面掰手腕。这套方法论,今天能用在 910C 上,明天换一张新卡、换一个新模型,照样能走一遍。
记住,优化不是一次性的,它是跟着业务和模型持续演化的。今天你学会了怎么把一张卡榨干,明天新的挑战来了,你手里已经有了全套的招数。这,才是比任何单一优化都值钱的东西。
更多推荐




所有评论(0)