Rust in Everything
基于 tenferro-runtime 0.8.0

用 tenferro 做潜在语义分析

主线位置:直接计算主线的一个真实用例。TypedTensor::from_vec_col_major 装进一个列主序矩阵 → CpuEngine::new(pool) → a.svd(&mut engine) 或 g.eigh(&mut engine) → tenferro-linalg 的 CPU provider 调 tlinalg(native 构建走 t4a-faer,blas 构建走 LAPACK)→ as_slice 读回。没有自动微分,没有 traced 程序(只有一处拿 traced 程序做对照)。

flowchart TB
  files["705 个 index.md"] --> tok["分词 计数 TF-IDF"]
  tok --> rows["单位长度稀疏行"]
  rows --> dense["稠密 A 705 x 52120"]
  rows --> gram["倒排表算 G = A AT"]
  dense --> svd["svd 薄分解"]
  gram --> eigh["eigh 705 x 705"]
  svd --> lat["U_k S_k"]
  eigh --> lat
  lat --> cos["余弦 前 5"]
  cos --> cmp["和 TF-IDF BERT 比较"]

本站(yuxuetr/rustineverything)不用 tenferro。burn 卷的 用 burn 算相关文章 用 TF-IDF 给每个文档页挑了 5 篇相关文章,结论是推荐合理,但 88.4% 留在同一卷里;candle 卷的 用 candle 算语义相关文章 换成 bert-base-chinese 的句向量,跨卷推荐多了,代价是一个 392.5 MiB 的模型和分钟级的计算。两者之间还有一个经典做法:潜在语义分析(latent semantic analysis,LSA),对 TF-IDF 矩阵做截断 SVD,在低维空间里算相似度。它不需要模型,只需要线性代数,正好是 tenferro-linalg 的本行。这一页回答三个问题:tenferro 怎么算这个截断 SVD、结果和前两种比如何、本站该用哪一种。

数据和前两页完全相同。 私有内容仓库在提交 dd09e82 时的 705 个 index.md,按 candle 实验留下的 705 个路径过滤。内容仓库的工作区在那之后改过其中三个页面,所以实验不读工作区,而是用一个 git cat-file --batch 进程按 dd09e82:docs/<路径>/index.md 读出这 705 个文件(只读,不复制内容树)。实验 crate exp-tenferro8/site-lsa 原样复制了前两个实验的 text.rs(取页、分词、计数、定词表)和 plain.rs(稀疏 TF-IDF),在同一进程里重算基线,词表、非零元和统计都和 burn 页一位不差:

pages=705 vocab=52120 nnz=656206 dense f64 = 280.3 MiB (load+tokenize+tfidf 556ms)
tfidf: same volume 3115/3525 (88.4%), same part 1150/3525 (32.6%), no cross-volume hit 530/705; cross hits in same kind of part 300/410, pitfall->other-volume pitfall 41/850

BERT 的结果直接读 candle 实验写下的向量文件(prose 用 emb-prose-metal.bin,chunks 用 emb-chunks-fast.bin),在本实验里重新取前 5,同卷比例 63.9% 和 75.9% 与 candle 页相同。全部 release 构建,平台 M2 Max(8 个性能核 + 4 个能效核)、macOS 27、rustc 1.99.0,tenferro 0.8.0 默认的 native provider;同机有别的写手在跑程序,每组计时给负载范围,取多次里的最小值。页面上只给标题和统计。

做法:LSA 是什么

TF-IDF 的每一页是 52 120 维词表上的一个稀疏向量,按行归一化到单位长度后排成矩阵 A(705 × 52 120),两页的余弦相似度就是对应两行的点积。LSA 对 A 做奇异值分解 A = U Σ Vᵀ,只留最大的 k 个奇异值:

  • U 的前 k 列乘上对应的奇异值(U_k Σ_k,705 × k),每一行是一页在 k 维「潜在空间」里的坐标;
  • V 的前 k 列(52 120 × k)是这 k 个方向在词表上的权重,算页面相似度用不到。

在 U_k Σ_k 的行之间算余弦,取前 5(不含自己)。期待的效果是:经常一起出现的词被并进同一个方向,两页即使没有共同的词,只要用词的「方向」相近也能相似。这正是 burn 页说 TF-IDF 给不了的东西。

本页取 k = 32、64、128。加权、归一化与 burn 页的 plain.rs 逐项相同,只是改用 f64。

截断 SVD:tenferro 能给什么

tenferro-linalg 在 TypedTensor 上提供的分解(TypedTensorLinalgExt)里,和这件事有关的是四个:svd(薄分解)、svdvals(只要奇异值)、svd_full(方阵的 U 和 V)、eigh(对称矩阵的特征分解)。没有截断 SVD,也没有随机化或 Lanczos 一类只求前 k 个的方法,v0.8.0 的 crates/ 下搜不到。上游例子 dynamic_shape_truncated_svd 里的「截断」是在 traced 程序里先做完整的 svd,再用 dynamic_truncate 按阈值切掉尾部,省不了分解本身。所以只能先做一次完整的分解,再取前 k 个。各分解的 AD 规则见 线性代数;native 和 BLAS 两种构建各把它交给谁,见 provider:native 与 BLAS。

完整分解有三种摆法:

  1. 对 A(705 × 52 120)做 svd。 tenferro-linalg 的 svd_impl 从 engine 的缓冲池取 U、S、Vt 三个输出和一块工作区,交给 tlinalg 的 svd_into。工作区里有什么,由 tlinalg 的 svd_req 决定:V(n × k)、k 个奇异值、faer 自己的工作区;min(m, n) ≥ 128 时还要再加 U·diag(S)(m × k)和一个 m × n 的残差矩阵。每个矩阵由 svd_item 算:

        // The divide-and-conquer path of faer's default parameters can return inaccurate factors
        // without an error. A decomposition that can take it runs with the defaults first and is
        // repeated with the QR algorithm when faer reports an error or the factors do not reproduce
        // the input.
        let accurate = match scratch.check.as_mut() {
            None => false,
            Some(check) => {
                faer::linalg::svd::svd(
                    // ... 默认参数,U 写进调用方的输出,V 写进工作区
                )
                .is_ok()
                    && reproduces(
                        // ... ‖A - U diag(S) Vᴴ‖ 不超过界
                    )
            }
        };
        // ... 不准确时改用 QR 算法重做一遍
        for col in 0..n {
            for row in 0..v_cols {
                // `V` is transposed into `Vᴴ`, so the complex scalars conjugate here. The real ones are
                // their own conjugate, which is why the two implementations share this call.
                vt.push(T::from_entity_conj(scratch.v[(col, row)]));
            }
        }
    

    这里 k = 705,n = 52 120。faer 先在工作区里填一个 52 120 × 705 的 V(280 MiB),tlinalg 再把它逐元素转置写进 705 × 52 120 的 Vt 输出(又 280 MiB)。faer 对这种扁长矩阵还会先做一次 QR:cargo 缓存里 t4a-faer 0.27.0 的 src/linalg/svd/mod.rs 在长宽比超过 qr_ratio_threshold(默认 11/6,这里是 73.9)时,把矩阵复制进工作区做 QR,再对 705 × 705 的 R 做 SVD,又是一份 280 MiB。705 ≥ 128,所以还有 reproduces 用的 705 × 52 120 残差矩阵,第四份。加上输入 A 本身,同时活着的是五份 280 MiB(下面实测 1.6 GiB)。

  2. 对 Aᵀ(52 120 × 705)做 svd。 输出变成 U 是 52 120 × 705、Vt 是 705 × 705,页面向量从 Vt 里取。大矩阵只是换了一个位置,内存一样(下面的表)。

  3. G = A Aᵀ(705 × 705),对 G 做 eigh。 G 的特征值是 σ²,特征向量就是 U,52 120 维的那一侧根本不出现。而且 A 的行是单位长度,G 恰好就是 burn 页用倒排表算的那个 TF-IDF 余弦矩阵:只在两页共有某个词时才乘,不用摊成稠密矩阵。

残差检查是 tlinalg 的规矩,不是 tenferro 的。tlinalg 的模块注释说明了原因:faer 的默认参数在较小的一维达到 128 时改用分治的双对角 SVD,「in faer 0.24.4 that path can return inaccurate factors without an error」,所以带向量的分解先用默认参数算,再算 A - U diag(S) Vᴴ 的 Frobenius 范数,超过 ‖A‖ 的 24 · sqrt(max(m, n)) · ε 倍就用 QR 算法重做。只求奇异值时没有因子可查,svd_values_item 一律用 QR 算法。本页的矩阵通过了检查,没有重做:实验里用 faer 直接把这一步再算一遍,相对残差 8.15 × 10⁻¹⁵,界是 1.22 × 10⁻¹²。

第三种的代价是数值上的:G 的条件数是 A 的平方,很小的奇异值会丢精度。这里只要前 128 个,σ₁ = 6.316、σ₁₂₈ = 1.060,相差不到 6 倍,平方之后也只有 36 倍,影响不到前 5。实测各种摆法(以及直接调 faer)在 705 页上给出完全相同的前 5,前 128 个奇异值最多差 6.2 × 10⁻¹⁴(engine 8 个线程):

tf-eigh: 100ms, max |sigma diff| (top 128) = 0.00e0; k=32 same top-5 set 705/705; k=64 same top-5 set 705/705; k=128 same top-5 set 705/705
faer-svd-u: 569ms, max |sigma diff| (top 128) = 4.04e-14; k=32 same top-5 set 705/705; k=64 same top-5 set 705/705; k=128 same top-5 set 705/705
faer-svd-uv: 978ms, max |sigma diff| (top 128) = 4.04e-14; k=32 same top-5 set 705/705; k=64 same top-5 set 705/705; k=128 same top-5 set 705/705
tf-svd shapes: u=[705, 705] s=[705] vt=[705, 52120]
tf-svd: 1308ms, max |sigma diff| (top 128) = 4.04e-14; k=32 same top-5 set 705/705; k=64 same top-5 set 705/705; k=128 same top-5 set 705/705
tf-svd-t shapes: u=[52120, 705] s=[705] vt=[705, 705]
tf-svd-t: 1166ms, max |sigma diff| (top 128) = 4.04e-14; k=32 same top-5 set 705/705; k=64 same top-5 set 705/705; k=128 same top-5 set 705/705
tf-traced-us: output shape [705, 705], prepare 1.9ms
tf-traced-us: 1237ms, max |sigma diff| (top 128) = 6.22e-14; k=32 same top-5 set 705/705; k=64 same top-5 set 705/705; k=128 same top-5 set 705/705

(基准是 faer 直接做的 eigh;tf- 开头的是 tenferro。)所以本页的 LSA 结果都用第三种算。用 tenferro 写只有几行:

  let pool = tenferro_cpu::Pool::builder().threads(8).build()?;
  let mut engine = CpuEngine::new(pool);
  let g = TypedTensor::<f64>::from_vec_col_major(vec![n, n], gram(unit, v))?;
  let (values, vectors) = g.eigh(&mut engine)?;

eigh 返回升序的特征值,取最后 k 个、开平方就是 σ,对应的列就是 U 的列。G 是对称的,列主序和行主序是同一块数据,这一处不会踩到 列主序 的坑;稠密 A 则必须按列主序摆(第 i 页、第 t 个词在 i + t * n)。engine 怎么建、为什么要 &mut,见 engine 与 threadteam 池。

📝NOTE
冷知识:svd_full 是一个单独的运算,不是 SvdOptions 的一个开关

full-matrices SVD 来自 issue #1446 的需求,由 PR #1448 里的提交 5eca7111(「Add full-matrices SVD and least-squares solve to tenferro-linalg」,首次发布于 v0.3.0)加入。之前只有薄分解;这个提交给 CPU 的 SVD 加了 full 参数,true 时用 ComputeSvdVectors::Full。提交说明解释了为什么做成新运算 LinalgOp::SvdFull:「It stays a sibling op rather than an SvdOptions flag so the thin SVD's gauge pass, output-pruning fusion, and AD rules are untouched.」到 0.8.0,这个分支搬进了 tlinalg 的 mode:full 时 U、V 的列数是 m 和 n,否则都是 min(m, n)。放到本页的矩阵上,svd_full 的 V 是 52 120 × 52 120 的 f64,20.2 GiB,这也是上面只考虑薄分解的原因。

traced 程序能省掉 Vt 吗

提交说明里的「output-pruning fusion」是这样的:traced 程序编译时,扩展算子可以按哪些输出真正被用到改写自己。线性代数的 prune_outputs 只认三种情形:Svd 只用到 S 时改成 SvdVals,Eigh 和 Eig 只用到特征值时改成只求值的版本。只用 U 和 S、丢掉 Vt,不在其中。实测:traced 程序以 U * S(S 沿行广播)为唯一输出时,峰值内存和直接调用 svd 一样;只输出 S 时被改写成 SvdVals,峰值降到四成多(计时包括 runtime.prepare 和 runtime.run,8 线程 engine,每个做法一个进程、连做 3 次):

tf-traced-us: output shape [705, 705], prepare 0.2ms
...
tf-traced-us: reps=3 first=1312ms min=1211ms all=[1312, 1243, 1211]
          1683554304  maximum resident set size
tf-traced-s: output shape [705], prepare 0.1ms
...
tf-traced-s: reps=3 first=632ms min=571ms all=[632, 655, 571]
           717946880  maximum resident set size

编译和运行的其余部分见 编译 和 执行。

代价:时间、内存、tenferro 的开销

每种做法一个进程,site-lsa time <方法> <次数>:先读文件、分词、算 TF-IDF(约 0.4 s,不计入),再把分解连做 3 次(eigh 10 次),每次重新从稀疏行建输入;计时只包分解(eigh 一行包括用倒排表算 G)。tenferro 的 engine 在计时之外建好;「8 线程」是 CpuEngine::new 一个 8 线程的 threadteam 池,直接调 faer 时借给它同样宽度的一个 team(Par::Team);「串行」是 CpuEngine::serial() 和 Par::Seq。整个进程套在 /usr/bin/time -l 下取「maximum resident set size」。三轮交替运行,取三轮里的最小值;峰值 RSS 是最快那一轮的,三轮之间相差不到 1%。8 线程那组开始时的负载在 4.3 到 7.8 之间,串行那组在 4.0 到 6.4 之间:

做法分解8 线程峰值 RSS串行峰值 RSS
只读数据、算 TF-IDF——69.9 MiB—69.9 MiB
再建稠密 A(f64)——350.2 MiB—350.6 MiB
faer 直接 svd,只要 UA 的薄 SVD534 ms725.2 MiB1531 ms669.8 MiB
faer 直接 svd,U 和 V同上922 ms1024.8 MiB3770 ms958.6 MiB
同上,再做 tlinalg 的残差检查同上1127 ms1039.4 MiB4894 ms962.3 MiB
tenferro svd(A)同上1213 ms1601.8 MiB4971 ms1525.7 MiB
tenferro svd(Aᵀ)Aᵀ 的薄 SVD1078 ms1606.4 MiB4800 ms1524.9 MiB
tenferro traced,输出 U * S同 A 的薄 SVD1211 ms1605.6 MiB4945 ms1527.2 MiB
tenferro svdvals只求 σ550 ms681.2 MiB1523 ms652.8 MiB
faer 直接 eigh(含算 G)G 的特征分解68 ms136.4 MiB113 ms104.8 MiB
tenferro eigh(含算 G)同上68 ms143.1 MiB113 ms108.2 MiB

(「直接调 faer」用的是 tenferro-cpu 以 tenferro_cpu::faer 重新导出的那份 faer,即 t4a-faer 0.27.0,和 tenferro 内部是同一份代码。「残差检查」一行是在 faer 的分解之后照 tlinalg 的做法算一遍 A - U·diag(S)·Vᵀ 的范数,残差矩阵在分解结束之后才分配。)

  • 选 Gram 加 eigh。 比 tenferro 的 SVD 快约 18 倍,峰值内存不到它的十分之一,还不用建稠密矩阵。eigh 一行里,算 G 约 46 ms,分解 705 × 705 在 8 线程下约 22 ms、串行约 70 ms;G 本身只有 3.8 MiB。8 线程时进程比串行多 30 MiB 左右,直接调 faer 也一样。
  • tenferro 的开销集中在薄 SVD 上,来自两处。 同样的分解,tenferro 比直接调 faer(也要 U 和 V)慢 32%(8 线程 1213 对 922 ms,串行 4971 对 3770 ms),多 577 MiB。其中残差检查约 0.2 s(串行约 1.1 s)和一份 280 MiB 的残差矩阵;其余是把 V 转置复制成 Vt 的那个循环和那份 280 MiB 的输出,faer 的 V 在复制期间还活着。eigh 上看不出差别:两边都是 68 ms 和 113 ms。直接调 faer 还可以只要 U(v 传 None),比 tenferro 能给的最好结果(要 U 就必须连 V 一起要)再省约 0.7 s 和 880 MiB。
  • 大矩阵放哪一边差不多。 A 和 Aᵀ 两种摆法在 8 线程下差一成左右、串行下差 4%,内存相同,因为 faer 先把扁长矩阵转成「高」的再做 QR,两边做的是同一件事。
  • 只求奇异值不走分治。 svdvals 和只输出 S 的 traced 程序都在 550 到 571 ms,和直接调 faer 只要 U 差不多;它们一律用 QR 算法,没有残差检查。
  • 对照本站容器的 768 MiB 上限(见 burn 页),任何一种稠密 SVD 都不合适:只要 U 的 faer 版本也到了 725 MiB。eigh 一路只在 TF-IDF 之外多 35 到 75 MiB。

换成 Accelerate

exp-tenferro8/site-lsa-blas 是同一份源码,tenferro-cpu 和 tenferro-linalg 改成 default-features = false, features = ["blas-accelerate"],线代走 tlinalg-blas(SVD 用 ?gesdd,eigh 用 ?syevd),链接 Apple Accelerate。engine 仍是 8 线程,但 LAPACK 调用自己管线程,不用 engine 的池。同样三轮(开始时负载 5.2 到 12.3),8 线程 native 的数字放在旁边:

做法Accelerate峰值 RSSnative 8 线程
tenferro svd(A)3249 ms1243.2 MiB1213 ms
tenferro svd(Aᵀ)1426 ms1242.5 MiB1078 ms
tenferro svdvals1720 ms928.0 MiB550 ms
tenferro eigh(含算 G)85 ms99.5 MiB68 ms

Accelerate 给出的前 5 也和 faer 完全相同(eigh 的前 128 个奇异值差 1.33 × 10⁻¹⁵)。它没有残差检查,SVD 的峰值比 native 少 360 MiB;但这台机器上每一项都更慢,扁长的 A 比高的 Aᵀ 慢一倍多,svdvals 慢 3 倍。和 provider:native 与 BLAS 里 512 × 512 SVD 的结论一致:在这台机器上 Accelerate 的强项是大 matmul,不是分解。本站的服务器是 Linux,用不上 Accelerate;这一组只说明换 BLAS 救不了稠密 SVD。

结果:和 TF-IDF、BERT 比

统计口径和前两页相同:「同卷」「同部分」是 3525 条推荐里落在同一卷、同一部分的比例;「没有跨卷推荐的页」是前 5 全在本卷的页数;「重合」是两边前 5 的交集占 3525 的比例。三个 k 的原始输出:

decomposition 79ms, sum sigma^2 = 705.000, sigma_1..3 = [6.316, 3.170, 2.925]
== lsa k=32: sigma^2 kept 23.7%
   same volume 3193/3525 (90.6%), same part 1056/3525 (30.0%), no cross-volume hit 591/705; cross hits in same kind of part 243/332, pitfall->other-volume pitfall 36/850
   vs tfidf       2013/3525 (57.1%), same #1 273
   vs bert prose  950/3525 (27.0%), same #1 99
   vs bert chunks 1466/3525 (41.6%), same #1 211
== lsa k=64: sigma^2 kept 31.4%
   same volume 3131/3525 (88.8%), same part 1174/3525 (33.3%), no cross-volume hit 571/705; cross hits in same kind of part 308/394, pitfall->other-volume pitfall 39/850
   vs tfidf       2382/3525 (67.6%), same #1 384
   vs bert prose  1022/3525 (29.0%), same #1 112
   vs bert chunks 1558/3525 (44.2%), same #1 268
== lsa k=128: sigma^2 kept 43.0%
   same volume 3123/3525 (88.6%), same part 1274/3525 (36.1%), no cross-volume hit 543/705; cross hits in same kind of part 316/402, pitfall->other-volume pitfall 41/850
   vs tfidf       2670/3525 (75.7%), same #1 494
   vs bert prose  1034/3525 (29.3%), same #1 137
   vs bert chunks 1584/3525 (44.9%), same #1 288
lsa k=32 vs k=64: 2433/3525 (69.0%), same #1 404; k=64 vs k=128: 2704/3525 (76.7%), same #1 490
❗IMPORTANT
动脑筋:上面第一行的 sum sigma^2,也就是 G 的全部特征值之和,为什么正好是 705.000?换成不做行归一化的 TF-IDF,它还会是一个整数吗?
做法同卷同部分没有跨卷推荐的页与 TF-IDF 重合与 BERT prose 重合与 BERT chunks 重合
TF-IDF88.4%32.6%530—28.5%48.3%
BERT prose(candle 页)63.9%28.1%20228.5%——
BERT chunks(candle 页)75.9%21.0%32748.3%——
LSA k = 3290.6%30.0%59157.1%27.0%41.6%
LSA k = 6488.8%33.3%57167.6%29.0%44.2%
LSA k = 12888.6%36.1%54375.7%29.3%44.9%
  • 跨卷推荐没有变多。 k = 32 时同卷比例反而升到 90.6%,没有跨卷推荐的页从 530 增加到 591;k 越大越接近 TF-IDF,k = 128 时前 5 有四分之三和 TF-IDF 相同。LSA 是 TF-IDF 的一个低秩近似,k 越小离它越远,但远离的方向不是「跨卷」。
  • 和 BERT 的距离几乎不变。 三个 k 与 BERT prose 都只重合 27%–29%,和 TF-IDF 与它的 28.5% 一样;与 chunks 的重合(42%–45%)还略低于 TF-IDF(48.3%)。LSA 没有朝语义向量的方向挪。
  • k 本身不稳定。 k = 32 和 64 的前 5 只重合 69.0%,64 和 128 重合 76.7%。在 32 到 128 之间选哪个 k,前 5 每换一次就有四分之一换人,而这个选择没有客观标准。
  • 只用 U_k 不乘 Σ_k(k = 64)时同卷 84.8%,没有跨卷推荐的页 531,和 TF-IDF 几乎一样;不改变结论。

这些统计只取决于语料和分解结果:同样的输入,换 tenferro 版本重跑,除了计时逐行相同。

四个页面,k = 64(T 表示也在 TF-IDF 前 5 里,B 表示也在 BERT chunks 前 5 里;分数是潜在空间里的余弦):

[sqlx/04-pool/acquire] 获取连接  (k=64; T = in TF-IDF top-5, B = in BERT chunks top-5)
  TB 0.973  归还、Drop 与维护任务  (sqlx/04-pool/release-and-maintenance)
  TB 0.943  PoolOptions 与默认值  (sqlx/04-pool/pool-options)
  T  0.894  连接池  (sqlx/04-pool)
     0.873  connect_lazy 设了 min_connections,连接数却一直是 0  (sqlx/08-pitfalls/connect-lazy-min-connections)
     0.860  sorry, too many clients already  (sqlx/08-pitfalls/too-many-clients)
[serde/07-pitfalls/missing-field] missing field `x`  (k=64; T = in TF-IDF top-5, B = in BERT chunks top-5)
     0.865  unknown field `kind`  (serde/07-pitfalls/flatten-deny-unknown)
     0.850  flatten  (serde/04-enums/flatten)
  TB 0.835  错误  (serde/02-core/errors)
  TB 0.828  生成的 Deserialize  (serde/03-derive/generated-deserialize)
  T  0.824  'invalid type: string "1", expected u32'  (serde/07-pitfalls/invalid-type)
[tokio/03-task/poll-and-wake] poll 与唤醒  (k=64; T = in TF-IDF top-5, B = in BERT chunks top-5)
  TB 0.951  OwnedTasks 与关闭  (tokio/03-task/owned-tasks)
  TB 0.951  JoinHandle、取消与 JoinError  (tokio/03-task/join-handle)
  T  0.945  一个 usize 的状态机  (tokio/03-task/state)
  T  0.888  任务的内存布局  (tokio/03-task/layout)
     0.878  任务  (tokio/03-task)
[axum/02-handler/state] 状态:State 与 FromRef  (k=64; T = in TF-IDF top-5, B = in BERT chunks top-5)
  TB 0.932  一个请求穿过 axum  (axum/01-overview/request-through-axum)
  TB 0.909  expected `Router`, found `Router<AppState>`  (axum/10-pitfalls/router-state-mismatch)
  T  0.868  Router 的内部结构  (axum/04-routing/router-internals)
  T  0.863  取舍汇总  (axum/09-design/tradeoffs)
     0.853  从零写一个 mini-axum  (axum/12-hands-on/from-scratch)

推荐都说得过去,也都和 TF-IDF 是同一类:连接池那页多了两个连接数相关的坑,missing field 多了「unknown field」和 flatten。但 candle 页里 chunks 给出的那几条跨卷关系(axum 的 State 到 tauri 的「状态」、tokio 的 poll 到 mio 的 waker 坑)一条也没有出现。

LSA 也给出了一些 TF-IDF 没有的跨卷推荐。k = 64 时这样的推荐有 120 条,其中 65 条是两端都是卷首页或部分目录页(「导读」对「导读」、「设计取舍」对「设计取舍」、「动手」对「动手」)。其余的大多落在相邻的两卷之间:

k=64: cross-volume hits not in TF-IDF top-5: 120, both pages volume/part index pages: 65; TF-IDF cross hits dropped: 136
  ...
  [sea-orm/06-connection/transactions] 事务 -> 0.869 事务没 commit:函数返回 Ok,写入全部消失 (sqlx/08-pitfalls/forgot-commit)
  ...
  [sea-orm/09-pitfalls/migration-partial] 迁移中途失败:留下一半的结构,seaql_migrations 没有记录 -> 0.910 migration 1 was previously applied but has been modified (sqlx/08-pitfalls/migration-modified)
  ...
  [sqlx/08-pitfalls/pool-timed-out] pool timed out while waiting for an open connection -> 0.767 connect_timeout 不起作用:连不上库时等的是 acquire_timeout (sea-orm/09-pitfalls/connect-timeout-ignored)
  ...

sea-orm 和 sqlx 的迁移、事务、连接超时这几对,正是「用词不同、讲的是一回事」,LSA 本来该干的事。但它同时丢掉了 TF-IDF 原有的 136 条跨卷推荐,加加减减,跨卷的总数没有变多。

📝NOTE
没有蠢问题

问: LSA 不就是为了发现「不同的词说同一件事」吗?为什么在这里几乎不跨卷?

答: LSA 只能合并在某些页面里一起出现过的词。本站每卷的标识符(Router、PoolOptions、Dispatch)几乎只在本卷出现,它们之间没有共现可以利用;sea-orm 和 sqlx 是例外:sea-orm 建在 sqlx 之上,那时 sea-orm 卷的 66 页里有 60 页提到 sqlx,两卷共用很多词,所以 LSA 的跨卷新推荐集中在这两卷之间。另一方面,截断保留的是 σ² 最大的方向,而这里的谱很平:k = 128 也只保留了 43.0% 的 σ²(见上面的动脑筋)。被丢掉的是每一页独有的细节,留下的是许多页共有的用词模式,而在本站,许多页共有的用词主要来自同一卷的同一套类型名和术语,所以低维空间里挨得近的页仍然是同卷的页。要跨卷,需要知道「连接池」和 pool 在 sqlx 和 hyper 里是同一个概念的外部知识,这只有在大语料上训练过的模型(例如 BERT)才有。

体积和编译

同一个 crate,一次带 tenferro(tenferro-runtime、tenferro-cpu 和 tenferro-linalg,默认的 native),一次 --no-default-features 只依赖 t4a-faer(tenferro 用的那份 faer,开 std、rayon、threadteam),各用一个全新的 target 目录 cargo build --offline --release:

== tf: cargo build --offline --release  (load: { 4.48 5.72 5.23 })
    Finished `release` profile [optimized] target(s) in 1m 21s
       81.47 real       645.06 user        15.26 sys
== faer: cargo build --offline --release --no-default-features (load: { 24.07 12.72 8.02 })
    Finished `release` profile [optimized] target(s) in 52.05s
       52.07 real       115.75 user         5.44 sys
配置编译的包user + systarget 目录二进制strip 后
纯 Rust TF-IDF(burn 页)12.0 s1.1 MB0.53 MiB0.42 MiB
只依赖 t4a-faer58121 s197 MB1.82 MiB1.42 MiB
tenferro(native)92660 s479 MB39.4 MiB26.6 MiB

(「编译的包」数的是 cargo 输出里的 Compiling 行,包括本 crate。只依赖 faer 那次开始时同机在编译别的东西,负载 24,墙钟时间偏大,看 CPU 时间。)tenferro 版的二进制里也有 faer,多出来的 37 MiB 是 tenferro 和它下面那套 CPU 栈(tcontract、cpueinsum、strided-rs 的内核、threadteam)。按 nm -n 相邻符号地址之差粗略归类,22 MiB 代码里 strided_basic 名下约 7 MiB、threadteam 约 2 MiB,各个 tenferro_* 加起来约 3 MiB。这和 tenferro 解决什么问题 里只做一次 matmul 的二进制是同一回事:内核按 dtype、按布局展开,不管这个程序用不用。本页只用了一个 f64 的 eigh。

在 Linux 服务器上(只读了源码)

服务器是 x86_64 Linux、没有 GPU,镜像只装了 ca-certificates 和 tini(burn 页)。tenferro 默认的 native 构建是纯 Rust(tcontract、cpueinsum、tlinalg 和 t4a-faer),不需要系统里的 BLAS / LAPACK,镜像不用多装东西。t4a-faer 和它的 GEMM 在运行时检测 CPU 特性选内核;AVX-512 的内核要 t4a-faer 的 nightly(即 x86-v4)feature,而依赖它的 tenferro-cpu、tenferro-linalg、tlinalg、tcontract 都只开了 std、rayon、threadteam。线程数由调用方建池时自己定(Pool::builder().threads(n)),tenferro 不再读 CPU 亲和集合;常驻服务里还要留意 team 成员在运算之后会自旋一段时间,见 engine 与 threadteam 池。绝对时间取决于服务器的核数和内存带宽,没有测;但 SVD 那一路「五份 280 MiB」、Gram 那一路「705 × 705」只取决于数据的形状。

结论:三种做法放在一起

TF-IDF(burn 页)BERT(candle 页)LSA(本页)
依赖无(或站点已有的 tantivy)candle + tokenizers + 392.5 MiB 模型只要 eigh:faer 即可(58 个包);用 tenferro 是 92 个包
全站一次约 0.3 sCPU 几分钟到半小时级别TF-IDF 之外再加约 0.1 s
内存61.6 MiB800 MiB 起105 到 143 MiB(稠密 SVD 670 MiB 到 1.6 GiB)
二进制0.53 MiB6.5 MiB1.82 MiB(faer)/ 39.4 MiB(tenferro)
同卷 / 无跨卷的页88.4% / 53063.9% / 202(prose)、75.9% / 327(chunks)88.6%–90.6% / 543–591
结果的性质共有的少见词跨卷关系,prose 有文体误配TF-IDF 的近似,k 一变四分之一换人

本站继续用 TF-IDF,不用 LSA,也不为它引入 tenferro。

  • LSA 没有带来本站想要的东西。 想加它,是为了 TF-IDF 给不了的跨卷推荐;实测跨卷推荐不增反减(k = 32 时 591 页没有跨卷推荐),新增的跨卷推荐一半以上是目录页对目录页,剩下的集中在 sea-orm 和 sqlx 两卷之间。前 5 还随 k 大幅变动。
  • 便宜不是理由。 按 Gram 加 eigh 算,LSA 在 TF-IDF 之外只多 0.1 s、几十 MiB,完全放得进 burn 页说的「进程启动时、内容变化后算一次」;但多算出来的结果并不更好。
  • 即使要做 LSA,也不需要 tenferro。 这里只用到一个 f64 的 eigh:没有自动微分、没有 einsum、没有 traced 程序、服务器上也没有 GPU,tenferro 的长处一个都用不上;代价是比只用 faer 多编 34 个包、编译的 CPU 时间是 5.5 倍、二进制大 37 MiB。直接调 faer 的 self_adjoint_evd 结果一样、速度一样,二进制 1.82 MiB。如果走稠密 SVD,tenferro 的 svd 还会因为残差检查和必须返回 Vt 多占 577 MiB、多花三成时间。
  • 跨卷推荐仍然只有语义向量能给,那条路的结论沿用 candle 页:在内容仓库一侧离线算成 JSON,随内容发布。burn 页的建议也不变:要上线「相关文章」,用纯 Rust 的 TF-IDF,或者站点已有 tantivy 索引的 MoreLikeThisQuery,在启动时或内容变化后算一次。

什么时候重估 LSA:内容里出现大量跨卷共用词汇的卷(像 sea-orm 之于 sqlx 那样),并且按本页的统计,LSA 在某个 k 下的「没有跨卷推荐的页」明显少于 TF-IDF 的 530,同时新增的跨卷推荐不以目录页为主。到那时也先用 faer 直接实现。

动脑筋答案

decomposition 79ms, sum sigma^2 = 705.000, sigma_1..3 = [6.316, 3.170, 2.925]

G = A Aᵀ 的特征值之和等于它的迹,迹是对角线之和,对角线第 i 项是 A 第 i 行和自己的点积,也就是第 i 行长度的平方。TF-IDF 的每一行都归一化成了单位长度,所以迹是 705 × 1 = 705,和词表、权重都无关。不做行归一化时,迹是 705 行长度平方之和,取决于每页的词频和 IDF,一般不是整数。这也是上面「σ² 保留比例」的分母:k = 128 保留 43.0%,意思是 705 份里只留下了约 303 份。

小结

  • 数据、分词、TF-IDF 与 burn、candle 两页完全相同(dd09e82 时的 705 页,经 git cat-file 读出;词表 52 120,基线 88.4% 同卷),BERT 结果读 candle 实验的向量文件,统计口径一致。
  • tenferro-linalg 没有截断或随机化 SVD,只能完整分解再取前 k 个。对 705 × 52 120 的 A 做薄 svd,8 线程 1.21 s、1.6 GiB:输入、faer 的 QR 副本、V、tlinalg 的残差矩阵、转置出的 Vt 各 280 MiB。改用 G = A Aᵀ(就是 TF-IDF 余弦矩阵,用倒排表算)加 eigh,68 ms、143 MiB,前 5 完全相同。
  • tenferro 的开销:薄 SVD 比直接调 faer 慢 32%、多 577 MiB,来自 tlinalg 对 min(m, n) ≥ 128 的分解做的残差检查和 Vt 的转置复制;eigh 上没有差别。traced 程序的输出裁剪只把「只用 S」改写成 SvdVals,只用 U 和 S 时省不掉 Vt。
  • Accelerate 构建没有残差检查、SVD 的峰值少 360 MiB,但 SVD 和 eigh 都比 native 8 线程慢。
  • 结果:k = 32 / 64 / 128 时同卷 90.6% / 88.8% / 88.6%,没有跨卷推荐的页 591 / 571 / 543,都不比 TF-IDF 少;与 TF-IDF 重合 57%–76%,与 BERT 的距离和 TF-IDF 一样。新增的跨卷推荐一半是目录页,其余集中在 sea-orm 与 sqlx 之间。
  • 体积:tenferro(native)版 92 个包、39.4 MiB;只用 faer 58 个包、1.82 MiB。
  • 结论:继续用 TF-IDF(或 tantivy 的 MoreLikeThisQuery),不用 LSA,也不为它引入 tenferro;跨卷推荐只能靠离线算的语义向量。

💬 关联讨论

发起讨论
加载中...