mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-28 05:13:27 +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/... 全绿。
359 lines
12 KiB
Go
359 lines
12 KiB
Go
// Package chineseclip 提供 Chinese-CLIP ViT-B/16 的 text+image 向量空间 provider。
|
||
//
|
||
// 为什么是它(而不是 Qwen3-VL-Embedding-2B / jina-v5-omni-nano):
|
||
// - 体积:721MB ONNX、实测稳态约 0.89GB(加载峰值 1.59GB);Qwen 2B 峰值约 9.4GB,本机可用内存只有 5.3GB。
|
||
// - 许可:Apache-2.0,可随发行版分发;jina-v5-omni-nano 是 CC BY-NC(不可商用)。
|
||
// - 中文:原生在 ~2 亿中文图文对上训练。
|
||
//
|
||
// 代价(明确记录):CLIP 是双塔对比学习,text↔image 是强项,但纯文本语义
|
||
// (text↔text)明显弱于 MLLM 型嵌入器。文本检索仍由既有词向量/TF-IDF 路径兜底,
|
||
// 本空间主要用于跨模态召回与相关性裁剪。需要视频或更强文本语义时应切回
|
||
// providers/qwen3vl(内存允许时)。
|
||
//
|
||
// 模态范围:仅 text 与 image。audio / video 返回 embedding.ErrUnsupportedModality,
|
||
// 绝不用别的模型向量冒充。
|
||
package chineseclip
|
||
|
||
import (
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"unicode/utf8"
|
||
"strings"
|
||
"unicode"
|
||
|
||
"golang.org/x/text/unicode/norm"
|
||
)
|
||
|
||
// BERT 的固定特殊 token(与官方 Chinese-CLIP 的 vocab.txt 一致)。
|
||
const (
|
||
tokenCLS = "[CLS]"
|
||
tokenSEP = "[SEP]"
|
||
tokenPAD = "[PAD]"
|
||
tokenUNK = "[UNK]"
|
||
|
||
// maxInputCharsPerWord 与 HF BertTokenizer 一致:超过就整词判 UNK。
|
||
maxInputCharsPerWord = 100
|
||
)
|
||
|
||
// Tokenizer 是 BERT WordPiece 分词器(Chinese-CLIP 官方配置:do_lower_case=true、
|
||
// strip_accents 生效、tokenize_chinese_chars=true)。
|
||
type Tokenizer struct {
|
||
vocab map[string]int32
|
||
maxLength int
|
||
}
|
||
|
||
// LoadTokenizer 从模型目录读取 vocab.txt。目录里那份词表是产物的组成部分,
|
||
// provider 只依赖这个目录,不去猜任何外部路径。
|
||
func LoadTokenizer(dir string, maxLength int) (*Tokenizer, error) {
|
||
path := filepath.Join(dir, "vocab.txt")
|
||
data, err := os.ReadFile(path)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("chineseclip: 读取词表 %s: %w", path, err)
|
||
}
|
||
if maxLength <= 0 {
|
||
return nil, fmt.Errorf("chineseclip: max_length 必须为正,得到 %d", maxLength)
|
||
}
|
||
vocab := make(map[string]int32, 32768)
|
||
for i, line := range strings.Split(string(data), "\n") {
|
||
piece := strings.TrimRight(line, "\r")
|
||
if piece == "" {
|
||
continue
|
||
}
|
||
if _, dup := vocab[piece]; dup {
|
||
// 词表出现重复行说明文件被写坏;不静默用后者覆盖前者。
|
||
return nil, fmt.Errorf("chineseclip: 词表第 %d 行重复: %q", i+1, piece)
|
||
}
|
||
vocab[piece] = int32(len(vocab))
|
||
}
|
||
for _, special := range []string{tokenCLS, tokenSEP, tokenPAD, tokenUNK} {
|
||
if _, ok := vocab[special]; !ok {
|
||
return nil, fmt.Errorf("chineseclip: 词表缺少特殊 token %s", special)
|
||
}
|
||
}
|
||
return &Tokenizer{vocab: vocab, maxLength: maxLength}, nil
|
||
}
|
||
|
||
// MaxLength 返回文本侧的最大 token 数(含特殊 token)。
|
||
func (t *Tokenizer) MaxLength() int { return t.maxLength }
|
||
|
||
// Encode 返回补齐到 maxLength 的 input_ids 与 attention_mask。
|
||
// attention_mask 与官方 tokenizer 的 padding='max_length' 行为一致:真实 token 为 1,
|
||
// padding 为 0。
|
||
func (t *Tokenizer) Encode(text string) ([]int64, []int64) {
|
||
pieces := t.tokenize(text)
|
||
|
||
// 预留 [CLS] 与 [SEP];超长直接截断尾部(官方 truncation=True 的默认方向)。
|
||
if limit := t.maxLength - 2; len(pieces) > limit {
|
||
pieces = pieces[:limit]
|
||
}
|
||
|
||
ids := make([]int64, 0, t.maxLength)
|
||
mask := make([]int64, 0, t.maxLength)
|
||
ids = append(ids, int64(t.vocab[tokenCLS]))
|
||
mask = append(mask, 1)
|
||
for _, p := range pieces {
|
||
ids = append(ids, int64(t.vocab[p]))
|
||
mask = append(mask, 1)
|
||
}
|
||
ids = append(ids, int64(t.vocab[tokenSEP]))
|
||
mask = append(mask, 1)
|
||
|
||
for len(ids) < t.maxLength {
|
||
ids = append(ids, int64(t.vocab[tokenPAD]))
|
||
mask = append(mask, 0)
|
||
}
|
||
return ids, mask
|
||
}
|
||
|
||
// tokenize 复刻 HF BasicTokenizer + WordPieceTokenizer 的完整流水线。
|
||
func (t *Tokenizer) tokenize(text string) []string {
|
||
var pieces []string
|
||
for _, basic := range basicTokenize(text) {
|
||
pieces = append(pieces, t.wordpiece(basic)...)
|
||
}
|
||
return pieces
|
||
}
|
||
|
||
// basicTokenize 实现 BasicTokenizer(空模型版):清洗 → 中文逐字加空格 →
|
||
// 按空白切分 → 删音标 + 转小写 → 按标点再次切分。
|
||
func basicTokenize(text string) []string {
|
||
cleaned := cleanText(text)
|
||
var out []string
|
||
for _, token := range strings.Fields(tokenizeChineseChars(cleaned)) {
|
||
// ★ 用 RuneCountInString 而不是 len([]rune(token)):
|
||
// 后者为了**数一下长度**就把整个 token 转成 rune 切片 ⇒ 每个 token
|
||
// 一次堆分配。而这个循环对每个词都跑,是分词器里最频繁的小动作。
|
||
// 两者语义等价(都按 rune 计数,非法 UTF-8 每字节算一个 rune)。
|
||
if utf8.RuneCountInString(token) > maxInputCharsPerWord {
|
||
// 与 HF 一致:超长基本 token 直接丢弃(后续不会产出 UNK)。
|
||
continue
|
||
}
|
||
stripped := stripAccents(strings.ToLower(token))
|
||
out = append(out, splitOnPunctuation(stripped)...)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// cleanText 与 HF _clean_text 一致:丢弃 NUL/替换符与控制符,空白统一为空格。
|
||
func cleanText(text string) string {
|
||
var b strings.Builder
|
||
b.Grow(len(text))
|
||
for _, r := range text {
|
||
switch {
|
||
case r == 0 || r == 0xFFFD:
|
||
continue
|
||
case isControl(r):
|
||
continue
|
||
case isBERTWhitespace(r):
|
||
b.WriteRune(' ')
|
||
default:
|
||
b.WriteRune(r)
|
||
}
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// tokenizeChineseChars 在 CJK 字符两侧插入空格,使每个汉字成为独立基本 token。
|
||
func tokenizeChineseChars(text string) string {
|
||
var b strings.Builder
|
||
b.Grow(len(text) + 16)
|
||
for _, r := range text {
|
||
if isCJK(r) {
|
||
b.WriteRune(' ')
|
||
b.WriteRune(r)
|
||
b.WriteRune(' ')
|
||
continue
|
||
}
|
||
b.WriteRune(r)
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// stripAccents 与 HF _run_strip_accents 一致:NFD 分解后丢弃 Mn 组合记号
|
||
// ("café" → "cafe")。
|
||
func stripAccents(text string) string {
|
||
if isASCII(text) {
|
||
return text
|
||
}
|
||
var b strings.Builder
|
||
b.Grow(len(text))
|
||
for _, r := range norm.NFD.String(text) {
|
||
if unicode.Is(unicode.Mn, r) {
|
||
continue
|
||
}
|
||
b.WriteRune(r)
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// splitOnPunctuation 与 HF _run_split_on_punc 一致:标点自成一段。
|
||
//
|
||
// 注意 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 {
|
||
var out []string
|
||
var b strings.Builder
|
||
b.Grow(len(text))
|
||
hasBuf := false
|
||
|
||
flush := func() {
|
||
if hasBuf {
|
||
out = append(out, b.String())
|
||
b.Reset()
|
||
hasBuf = false
|
||
}
|
||
}
|
||
|
||
for i := 0; i < len(text); {
|
||
r, size := utf8.DecodeRuneInString(text[i:])
|
||
if isBERTPunctuation(r) {
|
||
flush()
|
||
out = append(out, string(r))
|
||
i += size
|
||
continue
|
||
}
|
||
// 普通字符:直接写原字节(与原实现 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 {
|
||
// 先建立 rune 边界表(单次分配,比每轮 substring 便宜得多)
|
||
if utf8.RuneCountInString(token) > maxInputCharsPerWord {
|
||
return []string{tokenUNK}
|
||
}
|
||
bounds := runeBounds(token)
|
||
nr := len(bounds) - 1 // rune 个数
|
||
var out []string
|
||
start := 0 // rune 下标
|
||
for start < nr {
|
||
end := nr
|
||
found := false
|
||
var cur string
|
||
for end > start {
|
||
// 先查词表(用原串零拷贝切片构造 map key 仍需 string,
|
||
// 但 Go 对 map[string] 的短 key 查找有优化,且这里
|
||
// 只在**命中**时才真正保留;未命中的候选仍需构造 key)。
|
||
word := token[bounds[start]:bounds[end]]
|
||
piece := word
|
||
if start > 0 {
|
||
piece = "##" + word
|
||
}
|
||
if _, ok := t.vocab[piece]; ok {
|
||
cur = piece
|
||
found = true
|
||
break
|
||
}
|
||
end--
|
||
}
|
||
if !found {
|
||
return []string{tokenUNK}
|
||
}
|
||
out = append(out, cur)
|
||
start = end
|
||
}
|
||
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 {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// isBERTWhitespace:HF _is_whitespace = 空格/制表/换行/回车 或 Unicode Zs。
|
||
func isBERTWhitespace(r rune) bool {
|
||
if r == ' ' || r == '\t' || r == '\n' || r == '\r' {
|
||
return true
|
||
}
|
||
return unicode.Is(unicode.Zs, r)
|
||
}
|
||
|
||
// isControl:HF _is_control = Cc/Cf,但制表/换行/回车不算。
|
||
func isControl(r rune) bool {
|
||
if r == '\t' || r == '\n' || r == '\r' {
|
||
return false
|
||
}
|
||
return unicode.Is(unicode.Cc, r) || unicode.Is(unicode.Cf, r)
|
||
}
|
||
|
||
// isBERTPunctuation:ASCII 标点区间 或 Unicode P*。
|
||
func isBERTPunctuation(r rune) bool {
|
||
if (r >= 33 && r <= 47) || (r >= 58 && r <= 64) || (r >= 91 && r <= 96) || (r >= 123 && r <= 126) {
|
||
return true
|
||
}
|
||
return unicode.IsPunct(r)
|
||
}
|
||
|
||
// isCJK:HF _tokenize_chinese_chars 使用的区间表。
|
||
func isCJK(r rune) bool {
|
||
switch {
|
||
case r >= 0x4E00 && r <= 0x9FFF,
|
||
r >= 0x3400 && r <= 0x4DBF,
|
||
r >= 0x20000 && r <= 0x2A6DF,
|
||
r >= 0x2A700 && r <= 0x2B73F,
|
||
r >= 0x2B740 && r <= 0x2B81F,
|
||
r >= 0x2B820 && r <= 0x2CEAF,
|
||
r >= 0xF900 && r <= 0xFAFF,
|
||
r >= 0x2F800 && r <= 0x2FA1F:
|
||
return true
|
||
}
|
||
return false
|
||
}
|