mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-28 21:33:05 +00:00
嵌入式模型推理(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/... 全绿。
178 lines
5.3 KiB
Go
178 lines
5.3 KiB
Go
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)
|
||
}
|
||
})
|
||
}
|
||
}
|