Files
HomeAgent/providers/chineseclip/tokenizer_bench_test.go
JianFeeeee 84d2d7c313 perf(chineseclip): 分词器热路径分配优化 + 差分 oracle 验收(含一处真实语义修复)
嵌入式模型推理(ONNX)本身已是 C++,**可优化的 Go 侧是分词器与预处理**。
本轮先测出成本分布,再改,且**不假设 C 更快**。

## 实测(合成词表,无需 CHINESECLIP_MODEL_DIR)
| 场景 | ns/op | allocs |
|---|---:|---:|
| short_zh | 25681 | 60 |
| short_en | 19100 | 43 |
| mid_en | 117343 | 451 |
| mid_zh | 189260 | 1047 |
| punct_heavy | 283265 | 1388 |
| long_zh | 757746 | 4233 |

## pprof 指出的分配源(alloc_objects,mid_zh)
- splitOnPunctuation **33.5%**(每 token 都做 []rune + string(cur))
- wordpiece **32.3%**(内层每轮候选都 string(runes[a:b]),多数未命中)
- stripAccents/NFD 33.6% cum
- basicTokenize 自身只 1.4%

## 改了三处
1. `basicTokenize`:`len([]rune(token))` → `utf8.RuneCountInString`
   (原为「数个长度」就把整个 token 转 rune 切片)
2. `splitOnPunctuation`:去掉整串 []rune,改逐 rune 扫描 + 一次 flush
3. `wordpiece`:预建 rune 边界表,按字节区间取 substring,
   消除「每轮候选都构造 string」

## ★★ 差分 oracle 抓到一处**真实语义缺陷**(非测量噪声)
本机无模型产物,权威的 TestTokenizerMatchesOfficialReference 会 **SKIP**
⇒ 仅靠现有测试,我的重写**没有被有效验证**。故把改动前的实现原样内联为
oracle 做差分(split/wordpiece/basicTokenize/Encode 四组 + 随机字节 2 万组
+ 随机 rune 5000 组)。

它立刻抓到:`"\xbc\xef=..."` 旧实现得 `["��" ...]`,新实现得 `["\xbc\xef" ...]`。
根因是 `[]rune(s)` 会把**非法字节归一成 U+FFFD**,而纯字节切片原样保留坏字节。
⇒ 真实差异(会进日志/去重/hash),已改为对非法序列写回 RuneError,与旧行为逐值一致。

## 诚实的收益结论:**基本没有**
改动后:mid_zh 189260(改前 186777)、long_zh 757746(改前 786416)、
mid_en 117343(改前 120422)。分配数 mid_en -40%、其余基本持平,
**时间无实质改善**(部分场景还略慢)。

复查原因(不掩盖):重新做 CPU profile 后发现 **~25% 的样本是
runtime 锁/抢占**(unlock2 8.1% + lock2 6.8% + procyieldAsm 6.8% +
asyncPreempt 5.4%),而 utf8/unicode 相关不足 20%。
且 GOMAXPROCS 敏感:1→375365ns、4→209922ns、12→189543ns
⇒ **大量时间花在调度与 GC 而非分词算术**。

⇒ 结论:Go 侧微优化这条路**已到头**。真正的杠杆在别处:
① 提高 GOMAXPROCS/减少 GC 压力 ② 批量分词(降低每条输入的固定开销)
③ 减少送入模型的 token 量。三者都不是 C 能解决的。

改动本身保留(正确性等价、有 oracle 守护),但**不应据此宣称性能收益**。
与 C 化那几刀同一条纪律:没有数据支撑的优化不算优化。

验证:差分 oracle 6 组全过(含非法 UTF-8);providers/... 全绿。
2026-09-26 11:44:49 +08:00

178 lines
5.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package chineseclip
// tokenizer_bench_test.go —— 分词器热路径基准(判定 C 化是否值得)。
//
// ============================ 为什么不依赖真实模型 ============================
// LoadTokenizer 只需要 vocab.txt + maxLength,不需要 ONNX 产物;
// 本基准用**合成词表**(结构与真实词表同形:含 ## 续接前缀与 CJK 字符),
// 于是无需 CHINESECLIP_MODEL_DIR 即可在任意机器复现。
//
// ★ 同时**不假设「C 更快」**,而是先测出成本分布:
// 分词流水线有 5 段(cleanText / tokenizeChineseChars / splitOnPunctuation
// / stripAccents+ToLower / wordpiece),哪一段占大头要**测**出来。
// 三刀教训:C 化的收益判据必须先有数据支撑。
import (
"strings"
"testing"
"golang.org/x/text/unicode/norm"
)
// synthVocab 造一个与 BERT 中文词表同形的词表。
// 结构还原要点:单字 + ## 续接 + 少量多字词 + 4 个特殊 token。
func synthVocab() map[string]int32 {
v := make(map[string]int32, 32768)
add := func(p string) {
if _, ok := v[p]; !ok {
v[p] = int32(len(v))
}
}
for _, s := range []string{tokenCLS, tokenSEP, tokenPAD, tokenUNK} {
add(s)
}
// ASCII 词与 ## 续接
words := []string{"hello", "world", "user", "query", "memory", "agent",
"ing", "er", "ed", "s", "ly", "tion", "##ing", "##er", "##ed"}
for _, w := range words {
add(w)
}
// 常用单字(含中英)
singles := []string{"的", "了", "是", "在", "我", "你", "他", "们", "这", "那",
"a", "b", "c", "x", "y", "z", "0", "1", "2"}
for _, s := range singles {
add(s)
}
// 双字词(让 wordpiece 有机会一次命中)
for i := 0; i < 512; i++ {
add(string(rune('A'+i%26)) + string(rune('a'+i/26)))
}
return v
}
func benchTok(b *testing.B) *Tokenizer {
b.Helper()
return &Tokenizer{vocab: synthVocab(), maxLength: 512}
}
// benchInputs 覆盖真实分布:短查询 / 中文长句 / 英文长文 / 混合 / 超长。
var benchInputs = map[string]string{
"short_zh": "用户询问了系统状态",
"short_en": "what is the system status",
"mid_zh": strings.Repeat("这是一段中文文本,用于测试分词器的吞吐。", 10),
"mid_en": strings.Repeat("the quick brown fox jumps over the lazy dog. ", 10),
"mixed": strings.Repeat("记忆 memory 检索 recall 上下文 context 注入 inject。", 8),
"long_zh": strings.Repeat("长文本。", 200),
"punct_heavy": strings.Repeat("你好,世界!这是一个测试。", 20),
}
// BenchmarkTokenizerEncode 整体分词(Encode 全流程)。
func BenchmarkTokenizerEncode(b *testing.B) {
tok := benchTok(b)
for name, in := range benchInputs {
b.Run(name, func(b *testing.B) {
b.SetBytes(int64(len(in)))
b.ReportAllocs()
for i := 0; i < b.N; i++ {
tok.Encode(in)
}
})
}
}
// BenchmarkTokenizerStages 分解流水线各段,用于定位真正的热点。
//
// ★ 判据:若 wordpiece 占比远高于其它段,则「O(n²) 字符串分配」是主因;
// 若 basicTokenize 的分配占比高,则 cleanText/tokenizeChineseChars 的
// strings.Builder 往返是主因。两者处方完全不同,不能凭直觉断言。
func BenchmarkTokenizerStages(b *testing.B) {
tok := benchTok(b)
for _, name := range []string{"mid_zh", "mid_en"} {
in := benchInputs[name]
b.Run(name+"/basicTokenize", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
basicTokenize(in)
}
})
b.Run(name+"/cleanText", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
cleanText(in)
}
})
b.Run(name+"/tokenizeChineseChars", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
tokenizeChineseChars(in)
}
})
b.Run(name+"/stripAccents", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
stripAccents(strings.ToLower(in))
}
})
b.Run(name+"/wordpiece_all", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
for _, tok2 := range basicTokenize(in) {
tok.wordpiece(tok2)
}
}
})
}
}
// BenchmarkWordpieceSingle 单独压 wordpiece(已知 O(n²) 分配的那个)。
func BenchmarkWordpieceSingle(b *testing.B) {
tok := benchTok(b)
// 长 token 触发更多次回退(end 递减)
cases := map[string]string{
"cjk_1": "学",
"cjk_2": "学习",
"cjk_4": "学习机器学习",
"ascii_8": "unbelievable",
"mixed_6": "机器learning",
}
for name, w := range cases {
b.Run(name, func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
tok.wordpiece(w)
}
})
}
}
// BenchmarkTokenizerSplitPuncAndToLower 补测两段之前没单独量的成本。
func BenchmarkTokenizerSplitPuncAndToLower(b *testing.B) {
for _, name := range []string{"mid_zh", "mid_en", "punct_heavy"} {
in := benchInputs[name]
b.Run(name+"/splitOnPunctuation", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
splitOnPunctuation(in)
}
})
b.Run(name+"/ToLower", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = strings.ToLower(in)
}
})
b.Run(name+"/Fields", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = strings.Fields(in)
}
})
b.Run(name+"/NFD", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = norm.NFD.String(in)
}
})
}
}