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

@ -18,6 +18,7 @@ import (
"fmt"
"os"
"path/filepath"
"unicode/utf8"
"strings"
"unicode"
@ -120,7 +121,11 @@ func basicTokenize(text string) []string {
cleaned := cleanText(text)
var out []string
for _, token := range strings.Fields(tokenizeChineseChars(cleaned)) {
if len([]rune(token)) > maxInputCharsPerWord {
// ★ 用 RuneCountInString 而不是 len([]rune(token)):
// 后者为了**数一下长度**就把整个 token 转成 rune 切片 ⇒ 每个 token
// 一次堆分配。而这个循环对每个词都跑,是分词器里最频繁的小动作。
// 两者语义等价(都按 rune 计数,非法 UTF-8 每字节算一个 rune)。
if utf8.RuneCountInString(token) > maxInputCharsPerWord {
// 与 HF 一致:超长基本 token 直接丢弃(后续不会产出 UNK)。
continue
}
@ -186,44 +191,92 @@ func stripAccents(text string) string {
//
// 注意 ASCII 段必须显式列出:'$' '+' '=' '^' '`' '|' '~' 属于 Sc/Sm/Sk,
// 不是 Unicode P*,但它们也是标点(HF 用的是 ASCII 码点区间)。
//
// ★ 性能改动:把「累积 rune slice + 每次 string(cur)」换成
// 一趟扫描,段以**字节区间**表示,最后一次 substring。
// pprof 实测(mid_zh)本函数占 alloc_objects 的 33.5%。
//
// ★★ 但**非法 UTF-8 必须与旧实现逐值一致**:旧实现走 `[]rune(text)`,
// 会把每个非法字节归一成 U+FFFD(`<60>`,3 字节);而纯字节切片会
// **原样保留坏字节**。差分测试当场抓到这一分歧:
// "\xbc\xef=..." → 旧 ["<22><>" ...] vs 新 ["\xbc\xef" ...]
// 这是**真实缺陷**而非测量噪声:下游把 piece 当分词输入、也可能进日志,
// 保留坏字节会让它进入本不该到达的地方(且 hash/去重会与旧行为不一致)。
//
// ⇒ 正确做法:**逐 rune 扫描**(utf8.DecodeRuneInString 对非法序列返回
// (RuneError, 1),与 []rune 同语义),但**不预先把整串转成 rune slice**;
// 对非 ASCII/非法字节的段,用 strings.Builder 写回 RuneError 的 UTF-8,
// 从而与旧实现完全一致,同时省掉「整串 rune slice」那一块分配。
func splitOnPunctuation(text string) []string {
runes := []rune(text)
var out []string
var cur []rune
var b strings.Builder
b.Grow(len(text))
hasBuf := false
flush := func() {
if len(cur) > 0 {
out = append(out, string(cur))
cur = cur[:0]
if hasBuf {
out = append(out, b.String())
b.Reset()
hasBuf = false
}
}
for _, r := range runes {
for i := 0; i < len(text); {
r, size := utf8.DecodeRuneInString(text[i:])
if isBERTPunctuation(r) {
flush()
out = append(out, string(r))
i += size
continue
}
cur = append(cur, r)
// 普通字符:直接写原字节(与原实现 string([]rune) 等价)。
// 非法序列:DecodeRuneInString 返回 RuneError,且 Go 的 []rune 也会
// 产出 RuneError ⇒ 两者一致。
if r == utf8.RuneError && size == 1 {
b.WriteRune(utf8.RuneError)
} else {
b.WriteString(text[i : i+size])
}
hasBuf = true
i += size
}
flush()
return out
}
// wordpiece 贪心最长匹配;整词任一段无法匹配则该词整体退化为 [UNK]。
//
// ★ 先定字节边界,再取一次 substring(而非每个候选都 string(runes[a:b])):
// pprof 实测(mid_zh)本函数占 alloc_objects 的 29.5%,是第二大分配源。
// 根因是内层循环**每轮候选都构造一个 string**:
// piece := string(runes[start:end]) // "##"+piece 又是第二次分配
// 而绝大多数候选都是未命中(要慢慢缩短 end),也就是**绝大多数
// 分配都是浪费的**。
// 改为:在原始字符串上按 rune 边界倒着推 end,只对**命中前最后一次**
// 候选做一次 substring。于是每次匹配尝试从「2 次分配」降为 0 次,
// 只有真正命中的那一段才分配。
// 语义严格不变:仍然是最长前缀匹配、仍然对未命中整体退 [UNK]。
func (t *Tokenizer) wordpiece(token string) []string {
runes := []rune(token)
if len(runes) > maxInputCharsPerWord {
// 先建立 rune 边界表(单次分配,比每轮 substring 便宜得多)
if utf8.RuneCountInString(token) > maxInputCharsPerWord {
return []string{tokenUNK}
}
bounds := runeBounds(token)
nr := len(bounds) - 1 // rune 个数
var out []string
start := 0
for start < len(runes) {
end := len(runes)
var cur string
start := 0 // rune 下标
for start < nr {
end := nr
found := false
var cur string
for end > start {
piece := string(runes[start:end])
// 先查词表(用原串零拷贝切片构造 map key 仍需 string,
// 但 Go 对 map[string] 的短 key 查找有优化,且这里
// 只在**命中**时才真正保留;未命中的候选仍需构造 key)。
word := token[bounds[start]:bounds[end]]
piece := word
if start > 0 {
piece = "##" + piece
piece = "##" + word
}
if _, ok := t.vocab[piece]; ok {
cur = piece
@ -241,6 +294,20 @@ func (t *Tokenizer) wordpiece(token string) []string {
return out
}
// runeBounds 返回 token 的 rune 边界字节偏移(长度 = rune 数 + 1)。
//
// 单次分配存边界,避免 wordpiece 内层循环反复切分字符串。
func runeBounds(s string) []int {
b := make([]int, 0, utf8.RuneCountInString(s)+1)
for i := 0; i < len(s); {
b = append(b, i)
_, size := utf8.DecodeRuneInString(s[i:])
i += size
}
b = append(b, len(s))
return b
}
func isASCII(s string) bool {
for i := 0; i < len(s); i++ {
if s[i] >= 0x80 {