Kestrel_VLLM - 18 · 投机解码位级对拍收口:batch 与 M=1 一致,注意力路径与 CPU 逐字节对齐

18 · 投机解码位级对拍收口:batch 与 M=1 一致,注意力路径与 CPU 逐字节对齐

一句话 :把「M>1 batch verify 与 M=1 decode」的对拍从 top-1 下沉到完整 logits 逐字节 ,纠正一处 64-token 长序列的误读,并把 CPU↔GPU 的分歧一路消到 layer 0 的 MoE FFN 为止------层 0 整条注意力路径(RMSNorm → QKV GEMM → QK-norm → RoPE → KV 量化 → attention → O 投影+残差)已与 CPU 逐位全等。 前置 :建议先读 第 16 篇(batch verify 的 top-1 位级一致)。 环境 :x86-64 + NVIDIA RTX 5090(CUDA 后端)· Qwen3-30B-A3B q4local · 开关 VLLM_CUDA_ATTN_BITEXACT=1 / VLLM_CUDA_L2EXACT=1(位级档,opt-in)。

一、问题与结论

  1. batch verify 与 M=1 位级一致成立 :ISODIFF 探针(完整 logits 按 uint32 逐字节比)全列 ndiff=0,覆盖 pos 33--38。
  2. 「token 58 翻转」是误读 :纯 CPU 参考 token58=101128,与 GPU 输出一致;此前当作「参考」的 81167 其实是 token21。
  3. 唯一真实分歧在 t=27 (GPU=1773 vs CPU=3837),非 spec 引入 ------纯 GPU greedy 同样 1773;隔离对照定位到 CUDA attention。
  4. 该分歧已彻底消除 :把 attention 的算子顺序对齐 CPU + 位级复刻 UCRT 的 expf 后,CPU↔CUDA 逐 token 位级一致(64/64)。位级路径 opt-in(VLLM_CUDA_ATTN_BITEXACT=1),代价 2.25×。
  5. L2 完整模型 harness(逐层 hidden + 完整 logits + KV 行哈希)已可用 :CPU 与 CUDA 两侧同一批 key 的 FNV-1a 64 哈希(VLLM_L2_DUMP),2120 key 一次跑完,diff 即得首个差异点。
  6. 全 GPU decode 的层 0 注意力路径已逐位全等 :在 VLLM_CUDA_L2EXACT=1 档下,_nrm / _kraw / _knorm / _k / _ks / _v / _vs / _qrope / _attn / _ores 十个探针全部与纯 CPU 参考逐字节一致(含 RoPE 的 cosf/sinf 与 attention 的 expf,均已位级复刻 UCRT)。唯一剩项是 layer 0 的 MoE FFN 输出 _x (见第五节,已在 第 22 篇 收口)。

状态:注意力路径 ✅ 位级全等(opt-in 档);MoE FFN 在本篇仍 ❌ 未对齐(已登记,下一篇收口)。

二、背景:为什么 top-1 对拍不够

第 16 篇 只对拍了 top-1([SPECTOP1]/[ISOTOP1])。top-1 相同不构成位级证明:仓库方法论的「位级门禁」原则说得直白------两个 logits 可能在 top-1 之外有 ULP 差,只在边界 token 处翻 top-1。于是本篇加 [ISODIFF] 探针:batch 列 j 的完整 logits 与「同 token 顺序 M=1 decode」逐 uint32 字节比,报第一个非零 ULP 差的位置。

ini 复制代码
[SPECTOP1] pos=33 col=0 top1=99692   [ISOTOP1] col=0 99692   [ISODIFF] col=0 ndiff=0
[SPECTOP1] pos=33 col=1 top1=99371   [ISOTOP1] col=1 99371   [ISODIFF] col=1 ndiff=0
...(pos 33--38,col 0--4 全 ndiff=0)

→ batch 列与顺序 M=1 的完整 logits 逐字节相同,位级一致成立。

三、核心机制

3.1 误读纠正:token 58 不是翻转

实测:

位置 纯 CPU 参考(cpuref64) GPU(spec) 判定
token21 81167 81167 ✓
token58 101128 101128 ✓

「参考 81167」是 token21 的值。token58 两边一致,不存在翻转。

3.2 唯一真实分歧:t=27,源在 CUDA attention

实测:

位置 纯 CPU 参考 GPU(spec / 纯 greedy)
t=26 33071 33071
t=27 3837 1773
t=28 87267 87267

隔离对照(MoE/lm_head 全留 CPU,实测):

配置 t=27 说明
纯 CPU 3837 基线
VLLM_CUDA_ATTN=1(仅 attention 上 GPU) 1773 复现分歧
VLLM_CUDA_INFER=1(仅 GEMM 上 GPU) ---(t=24 已先发散) 另一条路径

→ 分歧源是 CUDA attention,不是 MoE GEMM,也不是 batch verify。

对拍口径上的判定:

  • batch verify vs M=1(同为 CUDA) :ndiff=0,位级一致------投机正确性红线不破。
  • CPU↔GPU :本篇之前是 greedy 级(跨硬件 ULP 差)。本篇把它提升到位级(opt-in)。

3.3 消除 t=27:逆向 UCRT 的 expf

第一步:算子顺序对齐(不够)。 既有 k_attn_decode_f32q 是两遍 softmax (全局 max → exp → 并行归约求和),归约树求和序与 CPU 的 online softmax 不同。新增 k_attn_decode_online,逐字复刻 CPU 的 flash_attn_single_q_q8_neon:int8 query 量化 + int32 QK 点积 + 逐 token online softmax + 顺序 PV,每 head 一 block、thread 0 串行扫整条 KV。

用 device expf 时反而更差(实测,t=24 就翻):

配置 首次翻 top-1 token
两遍 softmax(默认 fast) t=27 1773(CPU 3837)
online 镜像 + device expf t=24 106708(CPU 9370)

探针:量化 expf 差异 (写对拍探针,400002 组 [-100,0] 含随机/密集近 0,实测):

对比 差异率
CUDA device expf vs UCRT expf(引擎经 api-ms-win-crt-math 走 UCRT) 28.3%(max 2 ULP)
CUDA device exp()(double) vs UCRT exp()(double) 0 / 400002(逐位一致)
(float)exp(double) vs 正确舍入(float) 100%
UCRT expf vs 正确舍入(float) 99.993%(28 例低 1 ULP)

结论:CUDA 的 expf 与 UCRT 差很多;(float)exp(double)(= 正确舍入)能把差异从 28.3% 压到 0.007%(换成它后 64 token 一致数从 26 提到 56,分歧从 t=24 推到 t=57)。但 UCRT 的 expf 本身不是正确舍入 (28 例差 1 ULP),且「到舍入边界的距离」在 bad/ok 之间不可分离------它不是「double 结果加噪声」,而是独立的 float 例程。

第二步:反汇编 ucrtbase.dll,位级复刻。 用 objdump + dumpbin 定位 ucrtbase.dll 的 expf(RVA 0xA84F0,SSE2 路径),读出算法与常量:

ini 复制代码
t = x*K; n = rint(t); j = n&63; m = n>>6;
r = fma(-n, C2, x);  z = r*r;
p = fma(fma(r,C0,C1), z, r);
res = 2^m * fma(T[j], p, T[j]);  return (float)res;
K = 64/ln2, C2 = ln2/64, C0 = 1/6, C1 = 1/2, T[j] = 2^(j/64)(64 项表)

即「3 项泰勒 + 64 项表」,内层全 double + 显式 FMA------所以它的相对误差 ~r⁴/24 ≈ 3.6e-11,这也解释了它为何「几乎正确舍入但差 28 例」。复刻要点(device):__double2int_rn(对应 vcvtpd2dq)、__fma_rn(对应 vfmadd213sd/vfnmadd231sd;-fmad=false 只禁自动融合,显式 FMA 不受影响)、__longlong_as_double 造 2^m,64 项表按位型内嵌。特殊值仅在 x<-104/x>88.72 退化。

仓库里落地后的 device 复刻(src/npu/cuda/vllm_cuda_kernels.cu,64 项 EXPF_T 表略):

c 复制代码
/* 文件名:src/npu/cuda/vllm_cuda_kernels.cu */
/* UCRT expf 的位级复刻。逆向自 C:\Windows\System32\ucrtbase.dll 的 expf
 * (RVA 0xA84F0,SSE2 路径)。host 复刻对 400002 组 [-100,0] 样本与 UCRT
 * expf 逐位一致(0 差异)。 */
__device__ __forceinline__ float expf_ucrt(float xf) {
    if (xf > 88.7228394f) return (float)exp((double)xf);   /* 上溢 -> +inf */
    if (xf < -104.0f) return 0.0f;                         /* 深度下溢 */
    const double K  = 9.23324826168936567683e+01;   /* 64/ln2 */
    const double C2 = 1.08304246962491450973e-02;   /* ln2/64 */
    const double C0 = 1.66666666666666657415e-01;   /* 1/6 */
    const double C1 = 5.00000000000000000000e-01;   /* 1/2 */
    double x  = (double)xf;
    int    n  = __double2int_rn(x * K);            /* 对应 vcvtpd2dq(就近偶数) */
    double dn = (double)n;
    double r  = __fma_rn(-dn, C2, x);              /* 对应 vfnmadd231sd */
    int    j  = n & 63;
    int    m  = n >> 6;
    double z  = r * r;
    double t1 = __fma_rn(r, C0, C1);
    double p  = __fma_rn(t1, z, r);
    double Tj = __longlong_as_double((long long)EXPF_T[j]);
    double q  = __fma_rn(Tj, p, Tj);
    double two_m = __longlong_as_double((long long)(unsigned long long)(m + 0x3ff) << 52);
    return (float)(two_m * q);
}

结果(实测):

配置 与 CPU 逐 token 一致 唯一分歧
默认 fast(两遍 softmax) 63/64 t=27
VLLM_CUDA_ATTN_BITEXACT=1(online + expf_ucrt) 64/64 无

解码文本也与 CPU 完全相同。代价 :ctx=1024 下 89 → 200 ms/tok(2.25× ,double exp + 每 head 串行扫描)。按仓库方法论的默认化门禁(需「位级等价 且 性能领先」),位级路径性能不领先,故不默认化,保留为 opt-in:默认仍走原两遍 softmax(greedy 级,与改动前逐 token 一致)。

3.4 L2 完整模型对拍 harness

前面几节把 attention 做到位级,但那不是 L2。按仓库方法论的 L2 口径,L2 = 逐层 hidden + 完整 logits + KV 行哈希全等。

harness(两侧同 key、同常数) :VLLM_L2_DUMP=<path>(默认关):对缓冲求 FNV-1a 64,按 <tag> <hash16> 追加写;CPU 与 CUDA 两侧使用同一批 key ,diff 文本即得「第一个差异落在哪层哪步」。CPU 侧在 st_qwen_model_forward 直接算;GPU 侧在 vc_decode_body 用单线程 FNV 内核写到设备暂存、token 末一次性 D2H(避免逐层同步)。

两侧同常数的哈希实现(src/model/vllm_safetensors.c 与 src/npu/cuda/vllm_cuda_kernels.cu):

c 复制代码
/* 文件名:src/model/vllm_safetensors.c */
static uint64_t l2_hash64(const void *p, size_t len) {
    const uint8_t *b = (const uint8_t *)p;
    uint64_t h = 1469598103934665603ull;
    for (size_t i = 0; i < len; i++) { h ^= b[i]; h *= 1099511628211ull; }
    return h;
}
void st_l2_dump(const char *tag, const void *p, size_t bytes) {
    const char *path = getenv("VLLM_L2_DUMP");
    if (!path || !path[0] || !p || !bytes) return;
    FILE *f = fopen(path, "a");
    if (!f) return;
    fprintf(f, "%s %016llx\n", tag, (unsigned long long)l2_hash64(p, bytes));
    fclose(f);
}
c 复制代码
/* 文件名:src/npu/cuda/vllm_cuda_kernels.cu,设备侧同常数 FNV-1a 64 */
#define L2_FNV_OFF 1469598103934665603ull
#define L2_FNV_PRM 1099511628211ull
__global__ void k_l2_hash(const unsigned char *__restrict p, int nbytes,
                          unsigned long long *__restrict out) {
    if (threadIdx.x != 0 || blockIdx.x != 0) return;
    unsigned long long h = L2_FNV_OFF;
    for (int i = 0; i < nbytes; i++) { h ^= (unsigned long long)p[i]; h *= L2_FNV_PRM; }
    *out = h;
}

每个 decode token 的 key 集合(48 层 → 每 token 530 key):

key 含义
t<pos>_l<L>_nrm attn RMSNorm 输出(QKV GEMM 输入)
t<pos>_l<L>_kraw / _knorm K 段投影输出 / K 经 QK-norm(RoPE 前)
t<pos>_l<L>_qrope Q 经 RoPE(attention 的 Q 输入)
t<pos>_l<L>_k / _v 本 token 写入的 int8 K / V 行
t<pos>_l<L>_ks / _vs per-head K / V scale
t<pos>_l<L>_attn attention 输出(O 投影前)
t<pos>_l<L>_ores O 投影 + 残差后(FFN 前)的 hidden
t<pos>_l<L>_x 该层 residual 后的 hidden(FFN 之后)
t<pos>_final_normed 最终 RMSNorm 输出(lm_head 输入)
t<pos>_logits 完整 logits(vocab 个 float)

3.5 前提:对拍配置本身必须干净(VLLM_CUDA_FORCE_CPU=1)

这是本轮踩过的最大的坑。 全 GPU decode 需要 VLLM_CUDA_INFER=1(把权重预载驻留),但该开关同时也会让 prefill 的 GEMM 走 GPU offload ------于是 prefill 写入 KV cache 的 K/V 行与 scale 就已经和纯 CPU 参考差 ~1 ULP。此时无论 decode 侧多干净,L2 都必然不等,且首个差异会伪装成 t32_l0_ks(看起来像 RoPE/attention 的问题),把定位带偏。

正解:加 VLLM_CUDA_FORCE_CPU=1------设备仍初始化、权重仍预载,但所有 host 侧 offload 回退 CPU (prefill 因此纯 CPU);而 decode 走的是设备内的 vc_decode_body,不受该开关影响,仍全 GPU。位级对拍的正确命令:

ini 复制代码
# 参考:纯 CPU
VLLM_L2_DUMP=ref.txt  vllm_kestrel_x64 --model <m> --stream-test --stream-n 4
# 被测:全 GPU decode + 位级档(prefill 留 CPU)
VLLM_L2_DUMP=gpu.txt VLLM_CUDA_DECODE=1 VLLM_CUDA_INFER=1 \
VLLM_CUDA_FORCE_CPU=1 VLLM_CUDA_L2EXACT=1 \
vllm_kestrel_x64 --cuda --model <m> --stream-test --stream-n 4

3.6 逐项消差(VLLM_CUDA_L2EXACT=1 档)

按 harness 钉出的顺序一个个改,每改一次复跑、看首个差异是否后移:

# 环节 原差异 处置
1 attn RMSNorm / QK-norm GPU 用 rsqrtf(差 34.9%)+ 树形归约 新增 k_rms_norm_cpu/k_qk_norm_cpu:1.0f/sqrtf(与 UCRT 0/400001 逐位一致 )+ 逐字复刻 CPU 的 8 元素分块 hsum8 树 / 逐元素升序归约
2 QKV 投影 GEMM _kraw 起就不同 CPU q4_dense4_x86 在每个 32 块内按 (e, e+16) 配对 累加(s += w[e]·x[e] + w[e+16]·x[e+16]),GPU 是严格 0..31。给 k_gw_gemm_f32act 加 pairwise 归约,并强制 nsplit=1(z 分区的块和归约会重排相加序)
3 RoPE 的 cosf/sinf _qrope 差 反汇编 ucrtbase.dll 位级复刻(见 3.6.1)
4 decode attention _attn 差 接上已有的 k_attn_decode_online(online softmax + 顺序 PV + expf_ucrt),并修正可见 token 数口径(dyn[1]+1,CPU 侧为自增后的 cache_len[l])

仓库里这两个「同序」内核(src/npu/cuda/vllm_cuda_kernels.cu):

c 复制代码
/* 文件名:src/npu/cuda/vllm_cuda_kernels.cu,k_rms_norm_cpu / k_qk_norm_cpu */
__global__ void k_rms_norm_cpu(float *__restrict out, const float *__restrict x,
                               const float *__restrict w, int d, float eps) {
    __shared__ float rd[1];
    const int i = threadIdx.x;
    if (i == 0) {
        float ss = 0.0f;
        const int d8 = d & ~7;
        for (int b = 0; b < d8; b += 8) {           /* CPU 的 8 元素分块 hsum8 树 */
            float lo[4], hi[4];
            for (int k = 0; k < 4; k++) { lo[k] = x[b+k]*x[b+k]; hi[k] = x[b+4+k]*x[b+4+k]; }
            for (int k = 0; k < 4; k++) lo[k] += hi[k];
            const float s01 = lo[0] + lo[1], s23 = lo[2] + lo[3];
            ss += s01 + s23;
        }
        for (int j = d8; j < d; j++) ss += x[j] * x[j];
        rd[0] = 1.0f / sqrtf(ss / (float)d + eps);  /* 与 UCRT 逐位一致;GPU rsqrtf 差 34.9% */
    }
    __syncthreads();
    const float rms = rd[0];
    for (int j = i; j < d; j += DC_BLK) out[j] = x[j] * rms * w[j];
}
__global__ void k_qk_norm_cpu(float *__restrict x, const float *__restrict w,
                              int hd, int nheads) {
    const int h = blockIdx.x;
    if (h >= nheads) return;
    float *xh = x + (size_t)h * hd;
    __shared__ float rd[1];
    const int i = threadIdx.x;
    if (i == 0) {
        float ss = 0.0f;
        for (int j = 0; j < hd; j++) ss += xh[j] * xh[j];   /* 逐元素升序 ss += x[i]^2 */
        rd[0] = 1.0f / sqrtf(ss / (float)hd + 1e-6f);
    }
    __syncthreads();
    const float rms = rd[0];
    for (int j = i; j < hd; j += DC_BLK) xh[j] = xh[j] * rms * w[j];
}

结果:层 0 内每加一个探针就把范围缩小一次 ------_nrm ok 后从 _kraw 继续,_kraw ok 后再看 _qrope,_qrope ok 后再看 _attn,最后只剩 _x。

3.6.1 UCRT cosf/sinf 的反汇编复刻

与 3.3 的 expf 同一套方法。GetProcAddress(ucrtbase) 定 RVA:cosf = 0xa7d20、sinf = 0xac110(expf = 0xa84f0,与 3.3 一致,验证方法自洽)。读出的算法:

scss 复制代码
| x | <= pi/4 : 直接双精度多项式
    |x| < 2^-13 : cosf -> 1.0f ;sinf -> x(恒等)
    |x| < 2^-7  : cosf -> (float)fma(-(x*0.5), x, 1.0) ;
                  sinf -> (float)fma(-(x*x*x), 1/6, x)
    否则        : z=x*x;cos 多项式 p = A3 + z(A5 + z(A7 + z*A9)),结果 fma(p, z*z, 1 - 0.5*z)
                  sin 多项式 p = B3 + z(B5 + z(B7 + z*B9)),结果 fma(p, x*z, x)
| x | > pi/4 : Cody-Waite 归约
    n = (int)trunc( fma(|x|, 2/pi, 0.5) ) ;  r = fma(-n, PIO2_HI, |x|) - n*PIO2_LO
    cosf: n 奇 -> sin 多项式(参数 r),偶 -> cos 多项式;符号位 ^= ((n&3)+1)>>1
    sinf: n 奇 -> cos 多项式(参数 r),偶 -> sin 多项式;符号位 ^= (n&3>=2) ^ sign(x)
    最终一次 (float) 舍入

常量(.rdata 按位型读出):2/pi = 0x3fe45f306dc9c883、PIO2_HI = 0x3ff921fb54400000、PIO2_LO = 0x3dd0b4611a626331、A3..A9 = 1/24, -1/720, 1/40320, -1/3628800、B3..B9 = -1/6, 1/120, -1/5040, 1/362880。

复刻要点:多项式系数必须按「213 形式」写 ------vfmadd213sd xmm1, xmm3, mem 是 xmm1 = xmm3*xmm1 + mem,不是 xmm3*mem + xmm1;写反会让整个多项式量级错(本次第一版就把 cosf 做成 98% 不一致,定位到这条即修好)。|x| ≥ 0x4f490fdb(cosf)/ 0x4b800456(sinf)走 Payne-Hanek 慢路,RoPE 不可达(angle ≤ max_seq),退化为 device 库函数。

仓库里落地后的 device 复刻(src/npu/cuda/vllm_cuda_kernels.cu,sinf_ucrt 同法):

c 复制代码
/* 文件名:src/npu/cuda/vllm_cuda_kernels.cu,cosf_ucrt() */
/* host 侧对照真实 UCRT 对拍 600002 组样本 → cosf/sinf 各 0 差异。 */
static __device__ __forceinline__ double vc_ucd(unsigned long long b) {
    return __longlong_as_double((long long)b);
}
__device__ __forceinline__ float cosf_ucrt(float xf) {
    const double INV_PIO2 = vc_ucd(0x3fe45f306dc9c883ull);
    const double PIO2_HI  = vc_ucd(0x3ff921fb54400000ull);
    const double PIO2_LO  = vc_ucd(0x3dd0b4611a626331ull);
    const double A3 = vc_ucd(0x3fa5555555555555ull);   /* 1/24 */
    const double A5 = vc_ucd(0xbf56c16c16c16c16ull);
    const double A7 = vc_ucd(0x3efa01a01a01a019ull);
    const double A9 = vc_ucd(0xbe927e4fb7789f5cull);
    ...
    const unsigned au = __float_as_uint(xf) & 0x7fffffffu;
    if (au >= 0x7f800000u) return cosf(xf);
    const double xd = (double)xf;
    if (au <= 0x3f490fdbu) {                       /* |x| <= pi/4 */
        if (au < 0x39000000u) return 1.0f;         /* |x| < 2^-13 */
        ...
        const double z = xd * xd;
        double p = __fma_rn(z, A9, A7);
        p = __fma_rn(z, p, A5);
        p = __fma_rn(z, p, A3);
        return (float)__fma_rn(p, z * z, 1.0 - 0.5 * z);
    }
    ...
}

验证(实测) :host 复刻对 UCRT 对拍 600002 组样本([-10000,10000] 均匀 + [-35,35] 密集 + 随机)→ cosf/sinf 各 0 差异 ;随后移植为 device cosf_ucrt/sinf_ucrt。RoPE 内核里按档位选择(src/npu/cuda/vllm_cuda_kernels.cu):

c 复制代码
/* 文件名:src/npu/cuda/vllm_cuda_kernels.cu,k_rope(..., bitexact) */
const float ca = bitexact ? cosf_ucrt(angle) : cosf(angle);
const float sa = bitexact ? sinf_ucrt(angle) : sinf(angle);

四、实测数据

实测(--stream-n 4,48 层 → 每 token 530 key,4 token 共 2120):

对比 不一致 key 首个差异
纯 CPU vs VLLM_CUDA_ATTN=1 VLLM_CUDA_ATTN_BITEXACT=1(CPU + 位级 attention) 0 / 1936 无
纯 CPU vs 全 GPU decode,未加 FORCE_CPU 1917 / 1936 t32_l0_ks(伪差异,见 3.5)
纯 CPU vs 全 GPU decode(DECODE+INFER+FORCE_CPU+L2EXACT) 2054 / 2120 t32_l0_x(MoE FFN 输出)

层 0 逐字段(数据流序):

lua 复制代码
ok  _nrm  ok  _kraw  ok  _knorm  ok  _k  ok  _ks  ok  _v  ok  _vs
ok  _qrope  ok  _attn  ok  _ores
DIFF _x        <-- 唯一残项:MoE FFN 输出

结论:全 GPU decode 在 opt-in 档(VLLM_CUDA_L2EXACT=1)下,层 0 的整条注意力路径(RMSNorm → QKV GEMM → QK-norm → RoPE → KV 量化 → attention → O 投影+残差)已与纯 CPU 逐字节一致;沿数据流继续推进,第一个差异点就是 MoE FFN。

五、边界与已知限制

已完成(位级,opt-in)

项 开关 位置
UCRT expf 位级复刻 VLLM_CUDA_ATTN_BITEXACT / L2EXACT expf_ucrt(.cu)
UCRT cosf/sinf 位级复刻 VLLM_CUDA_L2EXACT cosf_ucrt/sinf_ucrt(.cu)
attn RMSNorm / QK-norm 同序 VLLM_CUDA_L2EXACT k_rms_norm_cpu/k_qk_norm_cpu
QKV/O/GU/DOWN/LMHEAD GEMM 块内归约序 + nsplit=1 VLLM_CUDA_L2EXACT k_gw_gemm_f32act(pairwise)
K 段投影 / Q 段 RoPE 对齐 VLLM_CUDA_L2EXACT k_rope(..., bitexact)
decode attention 镜像 VLLM_CUDA_L2EXACT(或 ATTN_BITEXACT) k_attn_decode_online
L2 harness(530 key/token) VLLM_L2_DUMP 两侧 k_l2_hash/st_l2_dump

未完成 / 已知墙

项 现状 结论
MoE FFN(router / gate_up / down / topk) _x 起分歧 本轮唯一残项 。三维天然一致 (激活 s16 / 32-block scale、块内 int32 精确点积、权重 4×4 72B tile 布局);四处结构性不同 :①router GEMM 是 64 路树归约 vs CPU 2048 步串行链(作者注释自认 relaxed bit-exactness);②scale 结合律 (dot*s_w)*s_a vs (s_w*s_a)*dot;③down 把 p 折叠进块项 vs 末尾乘一次;④silu 用 device expf 而非现成 expf_ucrt。要对齐需改写 4 个内核并放弃其现有并行度 ,故仅作 opt-in 档候选,本轮未做 (已在 第 22 篇 收口)。
全 GPU decode 的逐层位级全等(L2 整体) 未达成 被 MoE 阻断;层 0 注意力路径已通
prefill/batch 路径位级 未做 本轮只覆盖 M=1 decode
缺 FORCE_CPU 的生产配置 VLLM_CUDA_DECODE=1 VLLM_CUDA_INFER=1 仍是 greedy 级;位级档须显式开 L2EXACT(+FORCE_CPU 保 prefill 干净)

CPU 对照(迁移前基线)

  • CPU 参考 :kestrel-llm/src/model/vllm_safetensors.c:f32x8_hsum / hsum_neon4 ------ CPU 归约序(8 元素树 / 4 元素树);超越函数直接用 UCRT 库 expf/cosf/sinf。
  • 迁移要点 :跨硬件位级的拦路虎是超越函数------反汇编 ucrtbase.dll 位级复刻 expf_ucrt / cosf_ucrt / sinf_ucrt;RMSNorm/QK-norm 改串行同序(k_rms_norm_cpu / k_qk_norm_cpu);QKV GEMM 加 pairwise+nsplit=1;attention 改 online softmax 镜像。
  • 真机验证 :未独立复现:需 30B + VLLM_L2_DUMP(530 key/token,2120 key 一次跑完)与位级档 A/B。

六、小结

  1. 对拍参考要锁死 token 位置;top-1 对拍会漏 ULP。 跨位置比较会把一个 token 的翻转误判成另一个 token 的翻转;要证位级,必须下沉到完整 logits 逐字节(或逐层 hidden/KV 哈希)。
  2. CPU↔GPU 与 batch↔M=1 是两个正交口径。 前者可经「函数级对齐」做到位级,后者是投机红线。
  3. 跨硬件位级的真正拦路虎是 transcendental 函数。 算子顺序对齐不够------expf/logf/rsqrt/cosf/sinf 的实现在不同运行库间不同。本次若止步于「换 (float)exp(double)」就只能到 56/64;只有把 UCRT 的 expf 位级复刻 (含其自身 0.007% 的非正确舍入行为)才到 64/64。同理 1.0f/sqrtf 与 UCRT 逐位一致(rsqrtf 差 34.9%)、device cosf/sinf 差 ~20%。逆向运行库函数是可行路径:dumpbin /exports 定 RVA → objdump -d 读算法 → 运行时读常量表 → 复刻 → 用大样本探针证 0 差异;比「盲拟合多项式」可靠得多。写 FMA 时要分清 132/213/231 形式 ------把 vfmadd213sd 写成 xmm3*mem + xmm1 会让多项式整体错位(本次 cosf 首版 98% 不一致就是这个)。
  4. L2 harness 要按 key 哈希、可二分。 纯 token 序列只能判「是否」,逐层+KV+scale 的哈希 key 才能把首个差异钉到具体层与具体量------本次一路钉到 t32_l0_kraw → _qrope → _attn → _x,没有一步靠猜。
  5. 对拍配置本身要干净;构建脚本的管道会骗人。 给被测路径加「预载权重」这类开关时,很容易连带改动参考侧 的上游(本次 VLLM_CUDA_INFER=1 让 prefill 也上了 GPU,使 KV 行整体偏离,伪装成 layer 0 的 scale 问题)。对拍前先确认「参考侧与被测侧的唯一差别就是被测的那一段」。另外,把 powershell -File build.ps1 | Select-String ... 和多条后续语句写在同一段命令里时,出现过 exe 没被重链、却仍显示 rc=0 的情况,导致拿着旧二进制读结论------构建必须重定向到日志文件并单独校验产物时间戳。

相关篇目:第 16 篇、第 17 篇、第 22 篇 源码与配套资源:本仓库 gitee.com/pei-xiaogua...; CPU 推理源码 gitee.com/pei-xiaogua...

相关推荐
韩振方1 小时前
Kubernetes 通过资源 requests 决定 Pending Pod 能否找到节点
后端
Thneonl1 小时前
git reset --hard 之后先别哭:reflog 里存着你刚弄丢的那个分支
运维·后端
弦儿1 小时前
后端 Web 实战:员工管理模块(多表关系 + 多表查询 + 分页查询 + 条件分页)
后端·sql
王中阳Go1 小时前
网页上答对、接口里答错:同一个知识库,差的不是模型是入口
人工智能·后端·架构
郝琳1 小时前
真实 flaky 18.3%,CI 报 0%,让 Agent 修测试,它删掉了被测的变量
后端·ai编程
子兮曰1 小时前
Bun v1.4.4 实测:2,025 个文件,bun fmt 2.6 秒跑完
前端·后端·bun
萧瑟余晖2 小时前
Spring Data JPA高级特性详解
后端·spring
利威尔xu2 小时前
Go IM 源码实战(2):WebSocket 握手 11 步,少 1 个参数连不上
后端·websocket·微服务·面试·golang
轻舟技术A3 小时前
Rust 重写 Pi:会话启动耗时降 91%,就绪内存少 78%
开发语言·后端·rust