项目仓库
Gitee 主仓:https://gitee.com/pei-xiaoguang/kestrel-llm
GitHub 镜像:https://github.com/m13253246268-ship-it/kestrel-llm
相关文档
术语与数据口径 |
性能与基准 |
构建与复现 |
快速上手 |
0. 一句话结论
同一份源码、同一份输入,T23_NT=4 与 T23_NT=8 跑出来的密文在位级上不同。
根因不是浮点误差,而是随机性的来源不对 :随机数状态是线程局部的、且用固定常量播种------于是"哪个线程抽到哪几个随机数"取决于调度器,而不是取决于数学。
修复方案早就定了(按自同构指数 k 播种)。但按本项目"不漂亮的结论照实写"的纪律,必须说清楚:
截至本文写作,这个修复还没有落地。
源码里
ckks_rng_state仍然由固定常量初始化。因此"不同线程数结果位级不同"这条限制当前仍然成立。
1. 现象:它是被"比字节"发现的,不是被"看误差"发现的
这个问题的发现方式本身就是重点。
如果我们只看 max|err|,一切正常------4 线程和 8 线程的结果都在容差内 ,verify_layer 都会打 PASS。它们只是"不完全相同",而 max|err| 从来不是用来衡量"相同"的。
发现它靠的是逐字节比对:
u0_{t}_{h}.ct × 8 → BIT_IDENTICAL: 8 / 8 ← 同一线程数、同一输入
换成另一个线程数 → 逐字节不同
(顺带说明:我们的工具链里本来就有这个能力------verify_layer -BitA u5 -BitB u5b -L 5 就是专门做位级比对的。)
教训:如果你从不比字节,你就永远不会知道自己的"可复现"只到"数值接近"这一层。
2. 根因:三行代码
全部问题就在这几行(vllm_ckks.c):
c
/* RNG(xorshift64*,原型级) */
static _Thread_local uint64_t ckks_rng_state = 0x243F6A8885A308D3ull; /* ← 固定常量 */
static uint64_t ckks_rng_u64(void) {
...
uint64_t x = ckks_rng_state;
x ^= x >> 12; x ^= x << 25; x ^= x >> 27;
ckks_rng_state = x; /* ← 只有线程自己推进 */
return x * 0x2545F4914F6CDD1Dull;
}
两个要素叠加成灾:
| 要素 | 后果 |
|---|---|
static _Thread_local |
每个线程有自己独立的随机数序列,互不干扰 |
| 初值是固定常量 | 所有线程从同一个状态起步 |
于是:每个线程都会从同一个种子开始,生成"同样的"随机数流;但谁生成了几个、用在哪个对象上,完全由调度决定。
举个具体样子:假设某次加密需要 100 个随机系数,4 个线程平分------线程 0 拿前 25 个、线程 1 拿 26--50 个......8 个线程平分则变成每线程 12~13 个。同一个逻辑对象拿到的随机数,在两种线程数下是不同的。 噪声因此不同,密文自然不同。
而"噪声不同"在同态加密里是完全合法 的------噪声本来就是随机的,只要在界内。这就是为什么没有报错。
3. 为什么 max|err| 永远看不见它
max|err| 衡量的是"解密后与明文参考差多少"。两种线程数的结果都 是合法的密文,都 在噪声界内,所以误差都在容差里。
这引出一个更普遍的判断:
"数值接近"与"结果相同"是两个不同的性质,而前者无法推出后者。
在浮点计算里我们习惯了"误差是连续的";但在整数模运算 + 噪声的世界里,差异可以是非连续的------两个结果可能一模一样,也可能差得毫无规律,而它们的"误差"看起来都在正常范围。
4. 为什么修复方案是"按 k 播种"
修复思路很直接:既然随机性不可消除,那就让它变成输入的函数。
具体做法是按自同构指数 k 来播种 ------而这条路之所以顺,是因为代码里本来就有按 k 组织的入口(本系列第 6 篇):
c
int ckks_gk_gen_k(ckks_gk_t *gk, const ckks_sk_t *sk, const ckks_ctx_t *ctx, uint64_t k);
int ckks_rotate_k(ckks_ct_t *ct, ..., uint64_t k); /* 一般 Galois σ_k(k 任意奇数) */
也就是说:结构上早就是"按 k 索引"的,只是随机数播种没跟上。 修复的难度不在改架构,而在改播种点------并且要确保每一个使用随机性的地方都覆盖到(密钥生成、加密、以及一切需要采噪声的操作)。
为什么这样能修好 :一旦播种只依赖 k(以及其他确定性输入),那么"线程 5 抽到哪几个随机数"就不再由调度决定------每个逻辑对象自己有一个确定的随机数流,与谁在执行、有几个执行者无关。
5. 当前状态与我们的诚实口径
源码现状(本文写作时):
c
static _Thread_local uint64_t ckks_rng_state = 0x243F6A8885A308D3ull; /* 固定常量,推理路径 */
文件里唯一一处"时间相关"的播种在自测函数里:
c
int ckks_self_test(void) {
...
ckks_rng_state = (uint64_t)time(NULL) ^ 0xDEADBEEFCAFEBABEull;
------它不在推理路径上。 所以对 lay / boot 的实际计算没有任何影响(这一点我们核对过,避免把"自测里的随机"误当成"推理里的随机")。
因此我们对外一律这么讲:
| 说法 | 是否成立 |
|---|---|
| "同一源码、同一线程数,在 x86-64 与 aarch64 上逐系数位级一致" | ✅ 成立(层 0--4 已验证) |
| "同一源码,不同线程数,结果位级一致" | ❌ 不成立 |
| "结果在容差内可复现" | ✅ 成立(与线程数无关) |
所有可复现性声明都必须绑定 T23_NT。 这句话我们写进了交付文档,也写在了本系列第 1 篇与第 14 篇里。
6. 同一类的第二个案例:懒初始化的并发首次触发
在自举驱动里有一条修复记录:
c
tab_prepare(n); /* M1c 修复:coeff_to_slot/slot_to_coeff 的 omp 区并发首次触发 ... */
原来某些预计算表是在并行区里首次访问时懒初始化 的。多个线程同时"第一次"进入 → 并发触发初始化 → 结果取决于哪个线程先到。
这和 RNG 是同一类问题:
| RNG 播种 | 懒初始化 | |
|---|---|---|
| 不确定性来源 | 线程局部状态 + 固定初值 | 初始化时机与线程到达顺序 |
| 表现 | 位级不同 | 数据竞争,偶发错误 |
| 会不会报错 | ❌ 不会 | ❌ 通常不会 |
| 修法 | 让随机性成为输入的函数 | 在并行区之前完成初始化 |
共同点:都是"执行顺序被当成了隐式输入"。
7. 一份可复用的排查清单
如果你在做任何"并行 + 随机化 + 结果比对"的流水线,下面这些模式值得逐一排查:
- 线程局部随机状态 (
_Thread_local/thread_local/ TLS RNG),尤其是初值为常量的; - 懒初始化:在并行区内首次访问触发的初始化;
- 浮点归约顺序:并行求和的分组方式不同 → 结果位级不同(对我们是整数模运算,但同样的陷阱在别处存在);
- 非确定的分块策略 :动态调度(
schedule(dynamic))、工作窃取; - 哈希/字典遍历顺序:某些语言的字典迭代顺序与插入/内存布局相关;
- 时间或地址参与运算 :把
time()、指针值当作种子或输入。
判断标准很简单:问一句"这个结果只依赖输入吗?" 如果答案里出现了"取决于调度/时序/内存布局",那它就不是确定的。
8. 安全边界(务请读完)
本文所述参数为机制验证级,远低于 HE 参数标准的 128-bit 水平,不得用于保护真实数据。
本文主张的是:一个真实的可复现性缺陷的定位与结论。
本文不主张:安全强度、性能优越性(4 线程与 8 线程的性能差异不在本文讨论范围)。
另一处必须说明的 :本文引用的 RNG 是原型级的 xorshift (源码注释即写"原型级")。它不是为了密码学安全设计的,不应被当作安全随机源使用。这与"本项目不主张安全强度"的边界一致。
9. 这一篇的未解问题
- 补丁还没落地 。方案清楚、入口现成,但做 和说是两件事。在它落地之前,我们的任何"位级复现"结论都带着一个星号。
- 修复的覆盖面需要审计 。不只是"播种点",还要确认所有消耗随机性的路径都被覆盖------漏掉一处,问题就以更低概率复现(更难查)。
- 没有一条自动检查来防回归。理想情况下,CI 里应该有一个"同输入、不同线程数,比对字节"的测试,一旦不一致就报警。我们目前靠人工比对。
T23_NT之外还有哪些隐式输入,没有系统梳理 。比如g_fold/g_chk这些阶段参数、预计算表的构建顺序,都值得过一遍 §7 的清单。
下一篇给出账本:一跳 1.8 小时,钱到底花在哪------以及为什么"再优化乘法"基本是白费力气。