Rust in Everything
基于 candle-core 0.11.0

用 candle 算语义相关文章

主线位置:推理主线的另一种用法。没有 GGUF、没有 KV 缓存、不取样:BertModel::forward 一次吃进整段(最多 512 个 token),12 层 encoder 各做 4 次 Linear、2 次批量 matmul、一次 softmax、一次 GELU、两次 LayerNorm,返回最后一层的隐藏状态,再由调用方做 mean pooling。落到算子主线上,绝大多数 matmul 在 CPU 上走 gemm 或 Accelerate,在 Metal 上走 MLX gemm 内核。

flowchart TB
  files["读 705 个 index.md"] --> clean["去代码块 小写 去反引号"]
  clean --> tok["tokenizer 分字"]
  tok --> cut["截成 510 个 token"]
  cut --> fwd["BertModel forward"]
  fwd --> pool["mean pooling"]
  pool --> cos["余弦 前 5"]
  tfidf["burn 卷的 TF-IDF"] --> cmp["比较 前 5"]
  cos --> cmp

本站(yuxuetr/rustineverything)不用 candle。burn 卷的 用 burn 算相关文章 给每个文档页按 TF-IDF 余弦相似度挑了前 5 篇,结论是推荐大多合理,但 88.4% 落在同一卷里,705 页里有 530 页一条跨卷的都没有;它把「跨卷推荐」留给了语义向量。这一页就做这件事:用 candle 跑 bert-base-chinese,给同一批页面算句向量,和 TF-IDF 的结果逐页比较,再量代价。站点代码读的仍是提交 8729cfb,只读;站点怎么读文档、怎么部署,burn 那页已经讲过,这里只引用结论。

数据和 burn 那页完全相同。 那页用的是私有内容仓库在提交 dd09e82 时的 705 个 index.md。之后内容仓库只新增了页面(git diff --name-status dd09e82 HEAD -- docs 全是 A),没有改过旧页面,所以这里在原处读当前目录,再用 dd09e82 时的 705 个路径过滤,文字逐字相同。实验 crate exp-candle/site-bert 原样复制了 burn 实验的 text.rs(按 walk_docs 的规则取页、分词、计数)和 plain.rs(稀疏 TF-IDF),在同一进程里重算基线:

tfidf: vocab=52120 locality: same volume 3115/3525 (88.4%), same part 1150/3525 (32.6%), pages with no cross-volume hit 530/705

和 burn 页的数字一位不差。下文所有比较都以这一行为基线。全部是 release 构建,平台 M2 Max、macOS 27、rustc 1.99.0;机器上同时有别的实验在跑,每组计时都给了开始时的 load average。页面上只给标题和统计,不贴正文。

本站的条件

burn 那页读过的部署事实,这里用得上三条:

  • 镜像在 CI 里构建,只出 linux/amd64,运行阶段是 debian:trixie-slim,没有 GPU。
  • 内容仓库在宿主上只读挂进容器(:ro),更新文章只 git pull,不重建镜像。
  • app 容器 mem_limit: 768m。

TF-IDF 没有模型文件;BERT 有,而且不小。这三条决定了下面的代价里哪些是硬约束。

模型和加载

bert-base-chinese 的 model.safetensors 是 411 553 788 字节(392.5 MiB),206 个张量全是 F32;config.json 的 architectures 是 BertForMaskedLM,文件里带着预训练用的两个头。candle 的 BertModel 只读 encoder 部分,没有 pooler,返回 (B, T, 768)。

这个模型不能直接交给 BertModel::load:它的 LayerNorm 参数还叫 gamma / beta,而 candle_nn::layer_norm 要 weight / bias;报出来的错误还指错了地方。原因和修法(VarBuilder 的 rename_f)见 BERT。这里用的就是那个改名:

  // bert-base-chinese still uses the TF names gamma / beta for LayerNorm
  let vb = vb.rename_f(|name: &str| {
    name.replace("LayerNorm.weight", "LayerNorm.gamma").replace("LayerNorm.bias", "LayerNorm.beta")
  });

不改名时的两条报错,本实验也复现了:

load: cannot find tensor embeddings.word_embeddings.weight
load pp(bert): cannot find tensor bert.embeddings.LayerNorm.weight

分词:两处 [UNK]

candle 不管分词,这一步是 tokenizers crate 读模型自带的 tokenizer.json。它的 normalizer 是 BertNormalizer,lowercase: false(tokenizer_config.json 也写着 "do_lower_case": false);而 vocab.txt 的 21 128 个词里,没有一个以大写字母开头。BERT 的 WordPiece 拆不出一个词时,整个词变成 [UNK]。所以按原样用,Router、Option、macOS 这类词全是 [UNK]。另一个大户是行内代码的反引号,它不在词表里。

统计全部 705 页(raw 是 TF-IDF 用的同一份文字;clean 是先转小写、删掉反引号;prose 再删掉围栏代码块,包括 mermaid):

raw: chars=8956836 tokens=4496787 unk=509587 (11.33%) per page median=6008 p10=670 p90=12196 max=26491 pages<=510: 45 chunks(510)=9175
raw+clean: chars=8956836 tokens=4451226 unk=928 (0.02%) per page median=5945 p10=646 p90=12171 max=26425 pages<=510: 47 chunks(510)=9081
prose+clean: chars=4282250 tokens=2460176 unk=586 (0.02%) per page median=3433 p10=496 p90=6361 max=11963 pages<=510: 73 chunks(510)=5174
most common [UNK] sources (prose, as shipped): [("`", 253392), ("T", 1167), ("None", 977), ("macOS", 959), ("PR", 800), ("Max", 796), ("Vec", 795), ("M2", 792), ("HTTP", 776), ("Option", 721), ("Router", 680), ...]

原样分词时 11.33% 的 token 是 [UNK];小写加去反引号之后只剩 0.02%。下文的输入一律先做这两步。英文词在这个词表里本来就切得很碎(tensor → te ##ns ##or),模型对标识符的理解有限;区分页面主要靠中文。中文每个字一个 token,所以 token 数和汉字数差不多。

❗IMPORTANT
动脑筋:按 tokenizer.json 原样(不转小写)编码 Tensor tensor Device::new_metal `matmul` ,输出里有几个 [UNK]?

截断:只有 512 个位置

config.json 的 max_position_embeddings 是 512。BertEmbeddings::forward 用 0..seq_len 去位置嵌入表里 index_select,超出就报错,不会替你截断:

len 512: ok [1, 512, 768]
len 513: index-select invalid index 512 with dim size 512

去掉 [CLS]、[SEP],每段最多 510 个 token。而去掉代码块之后,页面的 token 数中位数是 3433,705 页里只有 73 页放得下;取开头 510 个 token,平均只覆盖一页的 29.5%。比较了三种取法:

  • head:TF-IDF 用的那份文字(含代码块)的前 510 个 token。每页开头是标题、「主线位置」,接着就是一张 mermaid 图,图的源码会占掉一部分名额。
  • prose:先删掉围栏代码块,再取前 510 个 token。大致是标题、主线位置和第一两段正文,也就是这一页对自己的概括。
  • chunks:把 prose 全文按 510 个 token 切段(每段前面都拼上标题),每段算一个向量,按 token 数加权平均成整页的向量。705 页切出 5223 段,计算量是 prose 的 7.4 倍。

mean pooling

forward 返回每个位置的隐藏状态,句向量取有效位置的平均:padding 的位置用 mask 乘成 0,再除以有效长度。批里长短不一时 mask 必须传进 forward,原因见 BERT。

  let hidden = model.forward(&ids, &types, Some(&mask))?; // (b, len, 768)
  let m = mask.to_dtype(DType::F32)?.unsqueeze(2)?; // (b, len, 1)
  let sum = hidden.broadcast_mul(&m)?.sum(1)?; // (b, 768)
  let n = m.sum(1)?; // (b, 1)
  Ok(sum.broadcast_div(&n)?)

705 个向量各做 L2 归一化,两两点积就是余弦相似度,每页取前 5(不含自己)。705 × 705 × 768 次乘加,纯 Rust 单线程约 0.3 s,和 TF-IDF 那一步是同一个量级。

结果:和 TF-IDF 比

同一组 705 页、同样的统计口径(「同卷」「同部分」「没有跨卷推荐的页」与 burn 页相同)。「与 TF-IDF 重合」是两边前 5 的交集占 3525 条推荐的比例:

做法同卷同部分没有跨卷推荐的页与 TF-IDF 重合第 1 名相同全部两两余弦平均(最小 – 最大)
TF-IDF(burn 页)88.4%32.6%530———
BERT head59.3%22.9%14625.8%1280.969(0.856 – 0.994)
BERT prose63.9%28.1%20228.5%1310.970(0.852 – 0.993)
BERT chunks75.9%21.0%32748.3%3150.981(0.887 – 0.998)

跨卷推荐确实多了,但「多」本身说明不了好坏。看推荐落到哪里,再加一项统计:跨卷推荐里,有多少落在别卷的同一种部分(坑对坑、「在本站中」对「在本站中」);坑页面(170 页、850 条推荐)有多少条指向别卷的坑页面:

tfidf:   cross-volume hits in the same kind of part: 300/410;  pitfall pages: 41/850 of their hits are other volumes' pitfall pages
prose:   cross-volume hits in the same kind of part: 644/1272; pitfall pages: 202/850 of their hits are other volumes' pitfall pages
chunks:  cross-volume hits in the same kind of part: 340/851;  pitfall pages: 55/850 of their hits are other volumes' pitfall pages

prose 的 1272 条跨卷推荐里,坑页面互相推荐占了一大块:坑页面的标题是一句报错原文,开头一段是「报错是什么、什么时候出现」,BERT 看到的是这种文体。chunks 读了全文,跨卷推荐少了一半,坑对坑也回到和 TF-IDF 差不多的水平。

和 burn 页同样的四页,加上一页 tokio(= 表示 TF-IDF 的前 5 里也有它)。prose:

[burn/03-backend/flex] Flex:CPU 后端
    0.986  Autodiff 装饰器  (burn/04-autodiff/autodiff-backend)
    0.986  张量  (burn/02-tensor)
    0.985  优化器  (burn/07-module/optimizer)
    0.985  Device  (burn/02-tensor/device)
  = 0.985  后端与分派  (burn/03-backend)
[serde/07-pitfalls/missing-field] missing field `x`
    0.989  data did not match any variant of untagged enum Shape  (serde/07-pitfalls/untagged-no-match)
    0.988  unexpected null; try decoding as an `Option`  (sqlx/08-pitfalls/unexpected-null)
    0.988  Missing value for column 'body'  (sea-orm/09-pitfalls/missing-value-for-column)
  = 0.987  错误  (serde/02-core/errors)
    0.986  key must be a string  (serde/07-pitfalls/key-must-be-a-string)
[tokio/03-task/poll-and-wake] poll 与唤醒
  = 0.989  JoinHandle、取消与 JoinError  (tokio/03-task/join-handle)
    0.987  service overloaded  (tower/09-pitfalls/service-overloaded)
    0.986  AlreadyBorrowed / AlreadyBorrowedMut  (dioxus/09-pitfalls/already-borrowed)
    0.985  阻塞线程池  (tokio/04-scheduler/blocking-pool)
    0.985  LoadShed  (tower/04-middleware/load-shed)

chunks:

[axum/02-handler/state] 状态:State 与 FromRef
    0.995  Handler trait  (axum/02-handler/handler-trait)
  = 0.995  一个请求穿过 axum  (axum/01-overview/request-through-axum)
  = 0.994  expected `Router`, found `Router<AppState>`  (axum/10-pitfalls/router-state-mismatch)
    0.994  状态  (tauri/04-app/state)
    0.994  from_fn 与它的兄弟  (axum/05-middleware/from-fn)
[sqlx/04-pool/acquire] 获取连接
  = 0.997  归还、Drop 与维护任务  (sqlx/04-pool/release-and-maintenance)
  = 0.996  pool timed out while waiting for an open connection  (sqlx/08-pitfalls/pool-timed-out)
    0.996  连接池  (hyper/06-client/pool)
  = 0.996  PoolOptions 与默认值  (sqlx/04-pool/pool-options)
    0.995  超时取消 pool.begin():连接带着事务回到池里  (sqlx/08-pitfalls/cancelled-begin)
[tokio/03-task/poll-and-wake] poll 与唤醒
  = 0.997  JoinHandle、取消与 JoinError  (tokio/03-task/join-handle)
    0.995  JoinSet、LocalSet、task_local  (tokio/08-api/joinset-localset)
    0.995  wake() 了,poll 却等到超时  (mio/06-pitfalls/waker-dropped-before-poll)
    0.995  poll(2) 后备  (mio/03-platforms/poll-fallback)
  = 0.995  OwnedTasks 与关闭  (tokio/03-task/owned-tasks)
  • prose 的跨卷推荐有对的,也有错的。missing field 推到 sqlx 的「unexpected null」和 sea-orm 的「Missing value for column」,三者都是「数据里缺了一个值」,这正是 TF-IDF 给不了的关系;但 poll 与唤醒 推到 dioxus 的借用冲突和 tower 的过载,只是同为坑页面或同为「讲机制」的文体相近。Flex 一页五条都在 burn 卷,但「优化器」「Autodiff 装饰器」和 CPU 后端关系较远,TF-IDF 给的 burn-cubecl 后端、Dispatch 更贴近。
  • chunks 的跨卷推荐少,但读起来更像人会给的:axum 的 State 推到 tauri 的「状态」,sqlx 的连接池推到 hyper 的连接池,tokio 的 poll 推到 mio 的「wake() 了,poll 却等到超时」。这些都是 burn 页说「要靠语义向量」的那类关系。代价是它和 TF-IDF 有一半重合,同卷比例回到 75.9%。
  • 换设备、换实现几乎不改变结果:prose 在 Metal 和 CPU(开不开 accelerate)上算出的前 5 在 705 页上完全相同;下文的两处提速(融合 softmax、tanh 近似 GELU)只让 9 页的前 5 换了一篇,第 1 名全部不变。
📝NOTE
没有蠢问题

问: 所有页面两两余弦的平均是 0.970,第 5 名和第 1 名只差 0.001 到 0.005。分数这么挤,前 5 还靠得住吗?

答: 分数挤是 BERT 向量的通病:所有向量共享一个很大的公共方向,余弦都偏高(BERT 里不相关的句子也有 0.63 到 0.72)。排序只看相对大小,挤本身不要紧,要紧的是排序稳不稳。把 705 个向量各减去它们的平均向量再归一化,分数散开到 -0.55 到 0.87,同卷比例几乎不变(prose 63.9% → 64.5%),但前 5 只有 63.8% 和原来相同,第 1 名只有 398 页不变。也就是说,前 5 里有相当一部分是在几乎一样的分数之间排的序,换个合理的后处理就会换人。bert-base-chinese 只做过掩码语言模型预训练(文件里是 MLM 和下一句预测两个头),没有为句子相似度微调过,这一点在分数上看得出来。

代价

时间

prose 一组 705 段、347 939 个 token,批大小 8,按长度排序后分批;计时从第一批开始到最后一批读回,Metal 在结束处 synchronize()。Metal 的库编译缓存是热的(冷缓存时的第一批在本实验的第一次运行里是 10.5 s,机制见 Metal 后端)。

设备与构建原样(candle-transformers 的 BertModel)融合 softmax + tanh GELU开始时 load
Metal70.1 s(99.5 ms/段)42.7 s(60.6 ms/段)13.1 / 9.0
CPU,accelerate268.3 s(380.5 ms/段)88.3 s(125.2 ms/段)6.4 / 32.6
CPU,不开 accelerate(gemm)381.0 s(540.5 ms/段)257.7 s(365.6 ms/段)14.1 / 5.4

chunks 一组 5223 段、2 505 628 个 token,只在 Metal 上用提速版跑了一次:307.7 s(58.9 ms/段)。按 prose 的每段时间推算,同样的工作量 CPU 开 accelerate 约 11 分钟,不开约 32 分钟(推算,没有跑)。作为对照,TF-IDF 全站分词加计算约 0.3 s。

「融合 softmax + tanh GELU」是本实验对 BertModel 的两处改动,各自的效果用一批 8 × 512 的固定输入分开量(每格是第 2 到 4 次的平均,ms/段):

原样只换 softmax只换 GELU两者都换
Metal103.663.0104.162.5
CPU,accelerate420.9364.4213.2144.9
  • GELU:config.json 的 "gelu" 对应精确的 gelu_erf,它在 CPU 上逐元素调 libm::erff、单线程;tanh 近似的 gelu 开了 accelerate 时整段交给 vForce。这一点 BERT 已经量过(CPU 上占一半时间)。在 512 个 token 的长输入上同样如此:CPU 快一倍,Metal 上没有差别。不开 accelerate 时 tanh 近似也是标量循环,所以 gemm 那一行只快 1.5 倍。
  • softmax:BertSelfAttention::forward 用的是拼出来的 ops::softmax,不是融合的 softmax_last_dim。短句子上这一步不显眼;512 个 token 时每层的注意力分数是 (8, 12, 512, 512),2500 万个元素要过五个 op(max_keepdim、broadcast_sub、exp、sum_keepdim、broadcast_div)。单独量这一个张量,Metal 上拼出来的版本 69.6 ms、融合版 4.3 ms;放回整个模型里,Metal 每段从 103.6 ms 降到 63.0 ms。改法是把 bert.rs 复制进实验 crate,只改这一行。融合版不记反向(见常用层),对只做推理的这里没有影响。
📝NOTE
冷知识:softmax_last_dim 出现后,换过去的是 stable-diffusion 和 whisper,不是 BERT

#744(「Add a custom softmax implementation.」,2023-09-05)在 candle-nn 里加了 softmax_last_dim:只对最后一维、CPU 上按行并行的自定义算子。提交说明里写着「Use the slow implementation on cuda.」「Add a todo for the cuda kernel.」,同一个提交就把 stable-diffusion 例子的注意力从 softmax(&xs, D::Minus1) 换了过去;六天后 #810(「Use softmax-last-dim in whisper.」)换了 whisper。两者都在 0.2.2 发布(本地仓库最早的 tag 是 0.5.1,这个版本号是按版本号提交 #822 推断的)。bert.rs 里注意力的 softmax 从 2023 年 7 月写下以后,含 softmax 的改动只有加 tracing 和搬进 candle-transformers 两次,到 0.11.0 一直是拼出来的 ops::softmax。

内存

运行最大 RSSpeak memory footprint
Metal,批 8826 MiB1376 MiB(原样)/ 1029 MiB(提速)
CPU accelerate,批 8,原样 / 提速1416 / 1146 MiB1395 / 1122 MiB
CPU gemm,批 8,原样 / 提速1404 / 1027 MiB1388 / 1012 MiB
CPU accelerate,批 1,提速819 MiB532 MiB

模型本身就是两份:from_mmaped_safetensors 把 392.5 MiB 的文件映射进来,取张量时再复制一份(safetensors 与 mmap)。批 1 时 RSS 819 MiB 里,约 392 MiB 是映射的文件页,「peak memory footprint」不算干净的文件页,是 532 MiB。批 8 时每层的注意力分数就有 8 × 12 × 512 × 512 × 4 字节 = 96 MiB,拼出来的 softmax 还要多几份同样大的中间结果,所以原样版本超过 1.3 GiB。对照容器的 768 MiB 上限:批 1 也只剩两百多 MiB 给站点本身,批 8 直接超限。TF-IDF 版的进程峰值是 61.6 MiB(burn 页)。

体积

构建(release,本实验的二进制)大小strip 后
不开 feature(gemm)6.47 MiB5.05 MiB
accelerate6.26 MiB4.87 MiB
metal7.41 MiB5.91 MiB
metal + accelerate7.20 MiB5.74 MiB

二进制里包括 tokenizers(带 onig 正则库)和 candle 三个 crate。比 burn 只开 flex 的 8.8 MiB 还小一些,比纯 Rust 的 TF-IDF(0.53 MiB)大一个数量级;但真正的大头是模型:392.5 MiB 的权重加 263 KiB 的 tokenizer.json,要跟着二进制一起到服务器上。

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

服务器是 x86_64 Linux、没有 GPU,镜像不开任何 feature 时 candle 的 CPU matmul 走 gemm crate:它在运行时检测 CPU,有 FMA 就用 AVX2 + FMA 的内核,AVX-512 内核要 gemm 的 x86-v4 feature,candle 的工作区依赖没有开。不开 mkl 时,Gelu 和 GeluErf 都没有向量化版本,erf 是 libm。所以上表「不开 accelerate」一行是最接近服务器的配置:同样是 gemm、同样是标量激活。绝对时间取决于服务器的核数和内存带宽,这些没有测;但「全站一次要几分钟、chunks 要半小时级别、模型两份占掉大半个容器」这几条只取决于模型和数据的大小。

结论

在本站的条件下,BERT 不值得替换 TF-IDF。

  • 效果:prose 把同卷比例从 88.4% 降到 63.9%,但多出来的跨卷推荐一部分是按文体(坑对坑)配的对,不是按内容;前 5 和 TF-IDF 只重合 28.5%,又对后处理敏感。chunks 的跨卷推荐质量明显更好,但跨卷的页只比 TF-IDF 多 200 页左右,前 5 有一半和 TF-IDF 相同。bert-base-chinese 不是句向量模型,这是上限所在。
  • 代价:TF-IDF 全站 0.3 s、几十 MiB、没有模型文件;BERT 要 392.5 MiB 的模型,进程 800 MiB 起,CPU 上 prose 几分钟、chunks 半小时级别。站点已有的 tantivy 索引还有 MoreLikeThisQuery 可用(见 burn 页),零新依赖。

如果要用,放在内容仓库一侧离线算。

  • 离线(推荐):在写作用的 Mac 上跑 Metal,chunks 全站约 5 分钟,prose 不到 1 分钟;输出一个「页面路径 → 5 个相关页面路径」的 JSON,本实验写出来是 131 KB,跟着内容一起提交、git pull。服务器上零计算、零模型,只读一个文件;内容是只读挂载,读不受影响。代价是写作流程多一步,忘了跑就过期,新页面在下一次生成前没有推荐。
  • 进程启动时:要把 392.5 MiB 的模型放进镜像或内容仓库,每次启动在 CPU 上算几分钟到半小时,内存逼近或超过 768 MiB。不合适。
  • 每次请求时:同样要常驻模型,而且一页要和全站比,至少要先有全站的向量。不合适。
  • 构建镜像时:镜像里没有内容,做不到(burn 页已说明)。

什么时候重估:换成一个为句子相似度训练过的中文模型(结构仍是 BERT 时,candle 的 BertModel 可以直接加载),并且它在上面同样的统计里,跨卷推荐增加的同时坑对坑的比例不升高。本地没有这样的模型,没有测。

动脑筋答案

as shipped: "Tensor tensor Device::new_metal `matmul`" -> ["[UNK]", "te", "##ns", "##or", "[UNK]", ":", ":", "new", "_", "metal", "[UNK]", "ma", "##tm", "##ul", "[UNK]"]
lowercased, no backticks: -> ["te", "##ns", "##or", "te", "##ns", "##or", "de", "##vice", ":", ":", "new", "_", "metal", "ma", "##tm", "##ul"]

4 个:Tensor、Device 各一个(首字母大写,词表里没有大写的片段,整个词变成 [UNK]),两个反引号各一个。new_metal 没事:预分词先在标点(:、_)处切开,new、metal 都是小写。转小写、去掉反引号之后一个 [UNK] 都没有,Tensor 和 tensor 变成同样的三个片段。

小结

  • 数据与 burn 卷相同:内容仓库 dd09e82 时的 705 页,原样复用它的分词和 TF-IDF,基线 88.4% 同卷、530 页无跨卷推荐,在同一进程里复现。
  • bert-base-chinese 要改名才能加载(见 BERT);原样的 tokenizer 把含大写字母的词和反引号变成 [UNK](11.33%),转小写、去反引号后 0.02%;位置只有 512 个,超出直接报错。
  • 每页平均 3433 个 token,开头 510 个只覆盖 29.5%。只看开头(prose)同卷 63.9%,但多出的跨卷推荐不少是坑对坑的文体匹配;全文分段平均(chunks)同卷 75.9%,跨卷推荐更像人给的。余弦全挤在 0.97 上下,前 5 对后处理敏感。
  • Metal 全站 prose 42.7 s、chunks 308 s;CPU 开 accelerate 88 s,不开 258 s(提速版)。两个瓶颈:bert.rs 的拼接 softmax(Metal 上换成 softmax_last_dim 快 1.6 倍)和标量 gelu_erf(CPU 上换 tanh 近似快一倍);两处改动只让 9 页的前 5 换了一篇。
  • 模型 392.5 MiB、进程 RSS 819 MiB 起,超过容器的 768 MiB;TF-IDF 是 0.3 s、62 MiB。结论:不替换 TF-IDF;真要用,在内容仓库一侧用 Metal 离线算出 131 KB 的 JSON 随内容发布。

💬 关联讨论

发起讨论
加载中...