用 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。
完整分解有三种摆法:
对 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)。对 Aᵀ(52 120 × 705)做
svd。 输出变成 U 是 52 120 × 705、Vt是 705 × 705,页面向量从Vt里取。大矩阵只是换了一个位置,内存一样(下面的表)。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 池。
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,只要 U | A 的薄 SVD | 534 ms | 725.2 MiB | 1531 ms | 669.8 MiB |
faer 直接 svd,U 和 V | 同上 | 922 ms | 1024.8 MiB | 3770 ms | 958.6 MiB |
| 同上,再做 tlinalg 的残差检查 | 同上 | 1127 ms | 1039.4 MiB | 4894 ms | 962.3 MiB |
tenferro svd(A) | 同上 | 1213 ms | 1601.8 MiB | 4971 ms | 1525.7 MiB |
tenferro svd(Aᵀ) | Aᵀ 的薄 SVD | 1078 ms | 1606.4 MiB | 4800 ms | 1524.9 MiB |
tenferro traced,输出 U * S | 同 A 的薄 SVD | 1211 ms | 1605.6 MiB | 4945 ms | 1527.2 MiB |
tenferro svdvals | 只求 σ | 550 ms | 681.2 MiB | 1523 ms | 652.8 MiB |
faer 直接 eigh(含算 G) | G 的特征分解 | 68 ms | 136.4 MiB | 113 ms | 104.8 MiB |
tenferro eigh(含算 G) | 同上 | 68 ms | 143.1 MiB | 113 ms | 108.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 | 峰值 RSS | native 8 线程 |
|---|---|---|---|
tenferro svd(A) | 3249 ms | 1243.2 MiB | 1213 ms |
tenferro svd(Aᵀ) | 1426 ms | 1242.5 MiB | 1078 ms |
tenferro svdvals | 1720 ms | 928.0 MiB | 550 ms |
tenferro eigh(含算 G) | 85 ms | 99.5 MiB | 68 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
sum sigma^2,也就是 G 的全部特征值之和,为什么正好是 705.000?换成不做行归一化的 TF-IDF,它还会是一个整数吗?| 做法 | 同卷 | 同部分 | 没有跨卷推荐的页 | 与 TF-IDF 重合 | 与 BERT prose 重合 | 与 BERT chunks 重合 |
|---|---|---|---|---|---|---|
| TF-IDF | 88.4% | 32.6% | 530 | — | 28.5% | 48.3% |
| BERT prose(candle 页) | 63.9% | 28.1% | 202 | 28.5% | — | — |
| BERT chunks(candle 页) | 75.9% | 21.0% | 327 | 48.3% | — | — |
| LSA k = 32 | 90.6% | 30.0% | 591 | 57.1% | 27.0% | 41.6% |
| LSA k = 64 | 88.8% | 33.3% | 571 | 67.6% | 29.0% | 44.2% |
| LSA k = 128 | 88.6% | 36.1% | 543 | 75.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 条跨卷推荐,加加减减,跨卷的总数没有变多。
问: 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 + sys | target 目录 | 二进制 | strip 后 |
|---|---|---|---|---|---|
| 纯 Rust TF-IDF(burn 页) | 1 | 2.0 s | 1.1 MB | 0.53 MiB | 0.42 MiB |
| 只依赖 t4a-faer | 58 | 121 s | 197 MB | 1.82 MiB | 1.42 MiB |
tenferro(native) | 92 | 660 s | 479 MB | 39.4 MiB | 26.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 s | CPU 几分钟到半小时级别 | TF-IDF 之外再加约 0.1 s |
| 内存 | 61.6 MiB | 800 MiB 起 | 105 到 143 MiB(稠密 SVD 670 MiB 到 1.6 GiB) |
| 二进制 | 0.53 MiB | 6.5 MiB | 1.82 MiB(faer)/ 39.4 MiB(tenferro) |
| 同卷 / 无跨卷的页 | 88.4% / 530 | 63.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;跨卷推荐只能靠离线算的语义向量。