
从神秘 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 #192Revert #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避免重复量化激活值关联讨论#185IQ1_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 / NEONIQ4_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 * dptr4 个 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 个通道k0..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::vectorchar作为每个线程的重打包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%40的分组约束有关但 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.cppR4 家族行数约束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),仅供参考