news 2026/9/19 9:49:38

从“神秘 NaN“到竞态排查:ik_llama.cpp 回滚 PR 79 的技术复盘

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从“神秘 NaN“到竞态排查:ik_llama.cpp 回滚 PR 79 的技术复盘

从"神秘 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_R4IQ1_M_R4IQ2_K_R4IQ3_K_R4IQ4_K_R4IQ5_K_R4IQ6_K_R4IQ2_XXS_R4IQ2_XS_R4IQ2_S_R4IQ3_XXS_R4IQ3_S_R4等类型都被标注为一次处理 4 行(return 4),而IQ4_XS_R8Q8_K_R8Q8_KV_R8等则处理 8 行,Q8_K_R16BF16_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 竞态的嫌疑核心。从源码结构看,可以推断出至少两个与"复用/跳过量化"直接相关的风险点:

  1. 线程间共享工作缓冲区的写冲突:在 ggml/src/iqk/iqk_mul_mat.cpp 中,thread_local_work_buffer()使用thread_local std::vector<char>作为每个线程的重打包(repack)缓冲区——注意它是按线程隔离的。一旦优化逻辑在"该行已被量化"的判断上跨线程共享状态(例如把"已完成量化"的标记放在非 thread-local 的共享结构中),不同线程就可能同时读取/写入同一标记,产生经典的读-改-写竞态。

  2. 分片边界上的行归属歧义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 的排查清单:

  1. 先复现,再定位:单次运行出现 NaN 时,先确认是否与环境负载有关——作者第一次就发现"并行执行grep -r大文件目录"会触发,而空闲重跑则不触发,这直接锁定了时序敏感性问题;
  2. 放大竞争窗口:在 16 核系统上用 32 线程(超线程/线程过订阅)运行,作者由此把"偶发"变成"稳定复现",这是竞态类问题的关键复现手段;
  3. 区分"数据问题"与"代码问题"IQ1_S_R4量化格式本身有GGML_ASSERT(nrows%4 == 0)等严格约束且经过反量化校验,量化器本身并非 NaN 来源;NaN 出现在推理计算阶段而非量化阶段,指向运行时路径;
  4. 回溯最近的调度/复用类改动:NaN 与"随机位置、随机线程"耦合时,优先怀疑涉及共享状态、跳过计算、结果复用的优化(如 #79 的"避免重复量化"),而非数值算法本身;
  5. 回滚验证:回滚嫌疑改动后,若在同等负载压力下 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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/19 9:47:01

Rust重塑前端工具链:从SWC到Biome的实践指南

1. 内容整体设计与思路拆解1.1 “吃掉整个工具链”这句话到底在说什么我先抛个结论&#xff1a;所谓 Rust 正在吃掉前端工具链&#xff0c;不是指哪天你打开浏览器发现页面变成 Rust 写的 DOM 了&#xff0c;而是指那些在开发阶段扛大梁、承担编译、打包、lint、转译、格式化这…

作者头像 李华
网站建设 2026/9/19 9:45:15

Obsidian 调 DeepSeek Harness,TaoToken 填 Key 与 endpoint 后跑通

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/19 9:41:26

UEFI与GPT分区下的Windows+Ubuntu双系统安装与引导修复实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华