diff --git a/cmd/homed-upgrade/main_test.go b/cmd/homed-upgrade/main_test.go index b604315a..b835837d 100644 --- a/cmd/homed-upgrade/main_test.go +++ b/cmd/homed-upgrade/main_test.go @@ -113,5 +113,3 @@ func TestFirstSemverLike(t *testing.T) { t.Errorf("日期不应被识别为版本号,实际 %q", got) } } - - diff --git a/providers/chineseclip/tokenizer.go b/providers/chineseclip/tokenizer.go index a59140ca..f1b1d8b8 100644 --- a/providers/chineseclip/tokenizer.go +++ b/providers/chineseclip/tokenizer.go @@ -18,9 +18,9 @@ import ( "fmt" "os" "path/filepath" - "unicode/utf8" "strings" "unicode" + "unicode/utf8" "golang.org/x/text/unicode/norm" ) @@ -193,20 +193,23 @@ func stripAccents(text string) string { // 不是 Unicode P*,但它们也是标点(HF 用的是 ASCII 码点区间)。 // // ★ 性能改动:把「累积 rune slice + 每次 string(cur)」换成 -// 一趟扫描,段以**字节区间**表示,最后一次 substring。 -// pprof 实测(mid_zh)本函数占 alloc_objects 的 33.5%。 +// +// 一趟扫描,段以**字节区间**表示,最后一次 substring。 +// pprof 实测(mid_zh)本函数占 alloc_objects 的 33.5%。 // // ★★ 但**非法 UTF-8 必须与旧实现逐值一致**:旧实现走 `[]rune(text)`, -// 会把每个非法字节归一成 U+FFFD(`�`,3 字节);而纯字节切片会 -// **原样保留坏字节**。差分测试当场抓到这一分歧: -// "\xbc\xef=..." → 旧 ["��" ...] vs 新 ["\xbc\xef" ...] -// 这是**真实缺陷**而非测量噪声:下游把 piece 当分词输入、也可能进日志, -// 保留坏字节会让它进入本不该到达的地方(且 hash/去重会与旧行为不一致)。 +// +// 会把每个非法字节归一成 U+FFFD(`�`,3 字节);而纯字节切片会 +// **原样保留坏字节**。差分测试当场抓到这一分歧: +// "\xbc\xef=..." → 旧 ["��" ...] vs 新 ["\xbc\xef" ...] +// 这是**真实缺陷**而非测量噪声:下游把 piece 当分词输入、也可能进日志, +// 保留坏字节会让它进入本不该到达的地方(且 hash/去重会与旧行为不一致)。 // // ⇒ 正确做法:**逐 rune 扫描**(utf8.DecodeRuneInString 对非法序列返回 -// (RuneError, 1),与 []rune 同语义),但**不预先把整串转成 rune slice**; -// 对非 ASCII/非法字节的段,用 strings.Builder 写回 RuneError 的 UTF-8, -// 从而与旧实现完全一致,同时省掉「整串 rune slice」那一块分配。 +// +// (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 @@ -247,15 +250,16 @@ func splitOnPunctuation(text string) []string { // 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]。 +// +// 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 { diff --git a/providers/chineseclip/tokenizer_bench_test.go b/providers/chineseclip/tokenizer_bench_test.go index 00c7af59..4f25d832 100644 --- a/providers/chineseclip/tokenizer_bench_test.go +++ b/providers/chineseclip/tokenizer_bench_test.go @@ -57,12 +57,12 @@ func benchTok(b *testing.B) *Tokenizer { // 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), + "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), } @@ -83,8 +83,9 @@ func BenchmarkTokenizerEncode(b *testing.B) { // BenchmarkTokenizerStages 分解流水线各段,用于定位真正的热点。 // // ★ 判据:若 wordpiece 占比远高于其它段,则「O(n²) 字符串分配」是主因; -// 若 basicTokenize 的分配占比高,则 cleanText/tokenizeChineseChars 的 -// strings.Builder 往返是主因。两者处方完全不同,不能凭直觉断言。 +// +// 若 basicTokenize 的分配占比高,则 cleanText/tokenizeChineseChars 的 +// strings.Builder 往返是主因。两者处方完全不同,不能凭直觉断言。 func BenchmarkTokenizerStages(b *testing.B) { tok := benchTok(b) for _, name := range []string{"mid_zh", "mid_en"} { diff --git a/providers/chineseclip/tokenizer_diff_test.go b/providers/chineseclip/tokenizer_diff_test.go index bb4e5546..8d1f27cd 100644 --- a/providers/chineseclip/tokenizer_diff_test.go +++ b/providers/chineseclip/tokenizer_diff_test.go @@ -121,13 +121,13 @@ var diffInputs = []string{ "混合Mixed中英English文本text。", "標點測試:;、()《》「」", "emoji 😀 与中文混合", - "combin\u0301ing", // 组合音标 - "café naïve résumé", // 预组合 - "a" + strings.Repeat("b", 300), // 超长 ASCII 词(> maxInputCharsPerWord) + "combin\u0301ing", // 组合音标 + "café naïve résumé", // 预组合 + "a" + strings.Repeat("b", 300), // 超长 ASCII 词(> maxInputCharsPerWord) "字" + strings.Repeat("长", 300), " 多个 空格\t制表\n换行 ", "$+=^`|~ 符号", - "#全角#ABC", // 全角 + "#全角#ABC", // 全角 "1234567890", "UPPER lower MiXeD", "无标点长句onetwothreefour", @@ -221,9 +221,10 @@ func TestTokenizerDiff_Encode(t *testing.T) { // // ★ 为什么必须测这个:字节层面扫描(新实现)与 rune 层面扫描(旧实现)在 // **非法 UTF-8** 上的行为最容易不同 —— -// · []rune(s) 把非法字节变成 U+FFFD(每个坏字节一个) -// · utf8.DecodeRuneInString 返回 (RuneError, 1) 并前进 1 字节 -// 两者语义应当一致,但「应当」不是证据。 +// +// · []rune(s) 把非法字节变成 U+FFFD(每个坏字节一个) +// · utf8.DecodeRuneInString 返回 (RuneError, 1) 并前进 1 字节 +// 两者语义应当一致,但「应当」不是证据。 func TestTokenizerDiff_RandomBytes(t *testing.T) { rng := newSeededRand(20260926) alphabet := []byte("ab ,.!?中文。,!?$+=^`|~\xff\xfe\x80\xc3\xe4\t\n") diff --git a/providers/qwen3vl/tokenizer_test.go b/providers/qwen3vl/tokenizer_test.go index b3d2a8be..99368042 100644 --- a/providers/qwen3vl/tokenizer_test.go +++ b/providers/qwen3vl/tokenizer_test.go @@ -10,7 +10,7 @@ import ( // modelDir 是本地千问模型目录。不存在则跳过——参考数据已固化在 testdata, // 但分词器本身要从 tokenizer.json 加载词表与 merges(11MB,不入库)。 -const modelDir = "/opt/models/qwen3-vl-embedding-2b" // 环境变量 QWEN3VL_MODEL_DIR 可覆盖 +const modelDir = "/opt/models/qwen3-vl-embedding-2b" // 环境变量 QWEN3VL_MODEL_DIR 可覆盖 type tokenizerRef struct { VocabSize int `json:"vocab_size"`