从"神秘 NaN"到竞态排查:ik_llama.cpp 回滚 PR #79 的技术复盘
【免费下载链接】ik_llama.cppllama.cpp fork with additional SOTA quants and improved performance项目地址: https://gitcode.com/GitHub_Trending/ik/ik_llama.cpp
本指南以 ik_llama.cpp 仓库中 PR #192(Revert #79)为脉络,复盘一次在 DeepSeek-Lite +
IQ1_S_R4量化推理中暴露出的疑似多线程竞态问题:作者在排查 Perplexity 计算出现 NaN 的过程中,如何定位到"重复量化激活值"优化引入的不确定性,并最终通过回滚该优化加以规避。读者读完可以掌握:IQ1_S_R4等 R4 量化家族在 ik_llama.cpp 中的实现位置与激活量化路径、多线程分片下iqk_mul_mat的工作方式,以及遇到"偶发 NaN"时应如何从复现手段、线程数、工作负载干扰三个维度进行系统性排查。
一、背景:一次"偶发 NaN"引发的回滚
2025 年 2 月,ik_llama.cpp 作者 ikawrakow 在测试IQ1_S_R4量化的潜在改进时,遇到了一个极具迷惑性的现象:在运行 DeepSeek-Lite 的 Perplexity 计算过程中,偶尔会出现 NaN 的 PPL(困惑度)值。
关键线索在于触发条件非常不稳定:
- 在计算运行的同时,对包含大量大文件的目录执行
grep -r,系统负载升高后突然出现 NaN PPL; - 不并行执行任何其他任务、单独重跑计算时,NaN 不再出现;
- 在 16 核系统上用 32 线程运行,可以稳定复现——在某个随机 chunk 上必然出现 NaN。
这种"负载变化能触发、随机位置出现、多线程下可稳定复现"的特征,直接指向一个结论:存在竞态(race condition)。
作者进一步判断,竞态最可能由 PR #79 引入——该 PR 的改动是"避免重复进行已完成过的激活值量化"(avoid repeating already done quantizations of activations)。PR #192 正是对这一改动的回滚。
| 属性 | 内容 |
|---|---|
| PR 编号 | #192 - Revert #79 |
| 作者 | ikawrakow |
| 状态 | ❌Closed |
| 创建时间 | 2025-02-07 |
| 更新时间 | 2025-02-08 |
| 回滚对象 | #79(避免重复量化激活值) |
| 关联讨论 | #185(IQ1_S_R4量化 DeepSeek-R1 同样出现 NaN) |
回滚之后,无论系统负载如何,DeepSeek-Lite 推理都再无 NaN。作者也希望这一修复能顺带解决 @saood06 在 #185 讨论中报告的IQ1_S_R4量化 DeepSeek-R1 的 NaN 问题。
二、问题载体:IQ1_S_R4量化格式与 R4 家族
2.1 R4 量化家族是什么
IQ1_S_R4属于 ik_llama.cpp 特有的R4 量化家族——这类格式在原始量化类型(如IQ1_S)基础上引入了4 行捆绑(row grouping)的布局:每 4 行共享一组标度信息,从而降低每行的元数据开销、提高极低比特量化下的压缩率。
从源码可以确认 R4 家族的规模与组织方式。在 ggml/src/iqk/iqk_mul_mat.cpp 的num_rows()实现中,IQ1_S_R4、IQ1_M_R4、IQ2_K_R4、IQ3_K_R4、IQ4_K_R4、IQ5_K_R4、IQ6_K_R4、IQ2_XXS_R4、IQ2_XS_R4、IQ2_S_R4、IQ3_XXS_R4、IQ3_S_R4等类型都被标注为一次处理 4 行(return 4),而IQ4_XS_R8、Q8_K_R8、Q8_KV_R8等则处理 8 行,Q8_K_R16、BF16_R16处理 16 行。也就是说,"×4"代表 4 行共享标度,"×8/×16"同理。
对应地,README 中记录了 R4 系列的 CPU 与 CUDA 实现演进:
- CPU 首批实现(Zen4 / AVX2 / NEON):
IQ4_K_R4PR 138、IQ3_K_R4PR 145、IQ2_K_R4PR 146 等; - CUDA 实现:
IQ1_S_R4由 PR 492 引入,IQ1_M_R4由 PR 494 引入,IQ4_KS_R4/IQ5_KS_R4由 PR 493 引入。
2.2IQ1_S_R4的量化与反量化实现
IQ1_S_R4的量化核心位于 ggml/src/iqk/iqk_quantize.cpp 的quantize_iq1_s_r4()。几个值得注意的实现细节:
- 块大小:
kBlockSize = 32,每块 32 个元素,n_per_row必须能被 32 整除(GGML_ASSERT(n_per_row%kBlockSize == 0)); - 4 行分组量化:外层循环
for (int row = 0; row < nrows; row += 4),一次处理 4 行;输出布局为ggml_half * dptr(4 个 fp16 行标度)加上block_iq1_s_r4数组; - 全零块处理:当块能量
sumx2 < 1e-14f时,直接使用网格索引 1029(全零网格项),不进入常规量化路径; - imatrix 支持:若提供了重要性矩阵,权重取
imatrix * sqrt(sigma2 + x*x),其中sigma2 = 1.5*sumx2/kBlockSize;当权重与模型值不匹配(sumwx < 1e-14f)时会自动退化为无 imatrix 路径; - 行标度归一:每行的最终标度为
dptr[k] = fp16(1.0625f*max[k]/15),块内局部标度则量化为 3 比特(ls截断到 0..7),与IQ1S_DELTA移位标志一起打包进qh字段。
反量化dequantize_row_iq1_s_r4()(ggml/src/iqk/iqk_quantize.cpp)则按 4 个通道(k=0..3)分别展开:从qh[k]中解析移位符号(0x8000位)、3 比特局部标度((qh[k] >> 12) & 7),再从iq1s_grid码本中取 8 元素网格向量,最终得到dl*(grid[j] + shift)。
2.3 推理端的乘法内核
在推理侧,ggml/src/iqk/iqk_gemm_1bit.cpp 中的mul_mat_iq1_s_r4_q8_1()实现了IQ1_S_R4激活与Q8_1权重/缓存矩阵的乘法,并通过IQK_SET_MUL_MAT_FUNCTIONS注册到调度表;其头部注释与实现均断言nrc_x%4 == 0——即一次处理的激活行数必须是 4 的倍数,这与 R4 的 4 行捆绑布局严格对应。
三、竞态嫌疑对象:PR #79 的"避免重复量化激活值"优化
PR #79 的改动目的是性能优化:在推理过程中,激活值(activations)需要被量化成定点格式以便与量化权重做矩阵乘。如果多个计算步骤共享同一份激活数据,逐一重复量化会造成浪费;#79 的思路是检测到已量化过的激活,就跳过重复量化,直接复用结果。
这条优化路径正是本 PR 竞态的嫌疑核心。从源码结构看,可以推断出至少两个与"复用/跳过量化"直接相关的风险点:
线程间共享工作缓冲区的写冲突:在 ggml/src/iqk/iqk_mul_mat.cpp 中,
thread_local_work_buffer()使用thread_local std::vector<char>作为每个线程的重打包(repack)缓冲区——注意它是按线程隔离的。一旦优化逻辑在"该行已被量化"的判断上跨线程共享状态(例如把"已完成量化"的标记放在非 thread-local 的共享结构中),不同线程就可能同时读取/写入同一标记,产生经典的读-改-写竞态。分片边界上的行归属歧义:
iqk_mul_mat()(ggml/src/iqk/iqk_mul_mat.cpp)按nth个线程把Nx行切成Nx/nth的分片,并在Nx/nth < 32时退化为按 tile 分发(for (int itile = ith; itile < ntile; itile += nth))。分片边界上的行是否属于"已完成量化"的判断,取决于标记更新的可见性与顺序;在系统负载升高、线程调度被打乱时,这种共享标记更容易出现不一致。
另外需要强调作者本人的判断:他并不完全理解为什么存在竞态,更不理解为什么竞态只会在IQ1_S_R4量化的 DeepSeek-Lite 上出现——毕竟在 #79 合入后的无数次运行中,从未观察到任何异常。这与代码中IQ1_S_R4走独立乘法内核、且nrc_x%4==0的分组约束有关,但 PR 描述本身并未给出最终根因定位,而是以"回滚以规避"的方式处理。
四、多线程分片与激活量化路径的源码佐证
要理解这个竞态为什么"在 16 核上用 32 线程就能稳定复现",需要结合iqk_mul_mat的多线程调度细节:
- 线程数是调用方传入的:
iqk_mul_mat(Nx, Ny, ne00, typeA, A, strideA, typeB, B, strideB, C, stride_C, ith, nth)的签名中ith/nth表示当前线程索引与总线程数(ggml/src/iqk/iqk_mul_mat.cpp)。当 16 核系统上开 32 线程时,每个物理核要轮转 2 个线程,线程调度交错加剧,共享状态的可见性窗口被放大; - 每线程处理行数的计算:
int npt = (Nx + nth - 1)/nth;(第 554 行)决定每线程分到的行数;若npt >= 16且目标类型"反量化更优"(is_dequant_better),则会走重打包路径(第 557-591 行):每线程在thread_local_work_buffer()中做iqk_convert_repack转换,再调用mm.mul_mat_NxM; - 偶数线程数时的减半优化:
if (npt <= 16 && nth%2 == 0 && Ny >= 16 && Ny%2 == 0)(第 605 行)会把nth减半、让每个线程处理双倍行数——这类优化在"激活是否已量化"的共享状态存在时,会进一步改变线程间的工作量分配时机。
从这些细节可以推断:#79 的"避免重复量化"若依赖任何跨线程共享的"已完成"状态,那么线程数、分片大小、负载扰动都会改变状态更新的时序,从而让 NaN 的出现变得"随机但可复现"。
五、回滚方案与最终结果
PR #192 的处理方式非常干脆:直接回滚 #79,放弃"避免重复量化激活值"的优化,恢复为每次都重新量化激活。回滚后验证结果:
- 在系统繁忙(同时执行
grep -r大量文件)的情况下运行 DeepSeek-Lite 推理,不再出现 NaN; - 预期顺带修复 #185 中报告的
IQ1_S_R4量化 DeepSeek-R1 的 NaN 问题。
这一选择反映了实践中常见且有效的工程策略:当竞态根因无法在短时间内精确定位、而优化带来的收益不足以抵消正确性风险时,回滚引入问题的改动、恢复确定性行为,比在不确定的共享状态上继续打补丁更稳妥。PR 本身最终以Closed状态关闭,说明该修复已按回滚方式落地,而非继续在原方向上迭代。
六、排查"偶发 NaN"的可复用方法论
结合本 PR 的完整排查过程,可以提炼出针对推理框架偶发 NaN 的排查清单:
- 先复现,再定位:单次运行出现 NaN 时,先确认是否与环境负载有关——作者第一次就发现"并行执行
grep -r大文件目录"会触发,而空闲重跑则不触发,这直接锁定了时序敏感性问题; - 放大竞争窗口:在 16 核系统上用 32 线程(超线程/线程过订阅)运行,作者由此把"偶发"变成"稳定复现",这是竞态类问题的关键复现手段;
- 区分"数据问题"与"代码问题":
IQ1_S_R4量化格式本身有GGML_ASSERT(nrows%4 == 0)等严格约束且经过反量化校验,量化器本身并非 NaN 来源;NaN 出现在推理计算阶段而非量化阶段,指向运行时路径; - 回溯最近的调度/复用类改动:NaN 与"随机位置、随机线程"耦合时,优先怀疑涉及共享状态、跳过计算、结果复用的优化(如 #79 的"避免重复量化"),而非数值算法本身;
- 回滚验证:回滚嫌疑改动后,若在同等负载压力下 NaN 消失,即可确认因果;若希望保留性能,再基于确定的根因(如将共享标记改为 thread-local、加同步或改为纯函数式复用)重新引入优化。
七、总结
PR #192 是一个典型的"性能优化引入不确定性、回滚恢复确定性"的工程案例。它展示了 ik_llama.cpp 在极低比特量化(IQ1_S_R4)与多线程推理交织下可能出现的一类隐蔽问题:激活量化复用逻辑在共享状态下存在竞态,其触发高度依赖线程数、系统负载与分片边界。对使用IQ1_S_R4/IQ1_M_R4等 R4 量化模型、且遇到偶发 NaN 的开发者,本 PR 的回滚结论与排查路径具有直接的参考价值:先怀疑一切"跳过计算、复用结果"的优化,再用线程过订阅与负载扰动复现,最后以回滚或线程局部化改造收束问题。
相关代码与文档入口:
- 量化实现:ggml/src/iqk/iqk_quantize.cpp
- 多线程乘法调度:ggml/src/iqk/iqk_mul_mat.cpp
- 单比特 GEMM 内核:ggml/src/iqk/iqk_gemm_1bit.cpp
- R4 家族行数约束:ggml/src/iqk/iqk_mul_mat.cpp
- 相关讨论:github-data/discussions/477 - DeepSeek-R1-0528 ik quants_.md、github-data/issues/367 - Bug_ IQ1_S_R4_ IQ1_M_R4 failed on Qwen3-235B-A22B.md
【免费下载链接】ik_llama.cppllama.cpp fork with additional SOTA quants and improved performance项目地址: https://gitcode.com/GitHub_Trending/ik/ik_llama.cpp
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考