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/... 全绿。
This commit is contained in:
JianFeeeee
2026-09-26 11:44:49 +08:00
parent 4ead049a52
commit 15e5e87fbf
3 changed files with 550 additions and 16 deletions

View File

@ -0,0 +1,177 @@
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)
}
})
}
}