perf(embedding,memory): 批上限 4→8 削掉请求放大,审查预算 20s→90s 覆盖实测链长
实测(2026-10-13,本机空闲,活库 cyrene/user 分区 201 条、均长 34.7 汉字): - 一条入库审查召回链 = 51 个「4 条」请求 = 23.070 / 21.860 s;旧审查预算 20s 比链长还短 ⇒ 每一条候选满额的审查都超时,且超时后 **一行留痕都不写** (7 天里 92.1% 的审查 recall_mode 为空、2274 条超时降级的根因) - 同一条链改成「8 条」= 26 个请求 = 21.435 / 21.597 s:请求数减半,墙钟几乎不变 —— 链路耗时由「条数 × 每条成本」主导,批次只改请求数 - 「16 条」在**真实代码路径**上 3 次里 2 次让单请求越过 5s 单次超时(1.818 / 5.016 超时 / 5.001 超时)⇒ 批上限不越过 8;隔离测量里 12 条 0.996~1.017 s 仍在界内 改动: - embedding: perItemBudget 500ms→300ms(实测摊销 70~107ms/条的约 3 倍) ⇒ DefaultBatchLimit 由 (5s/2−100ms)/300ms = **8**,同一套推演、两维都成立 (条数 100ms+8×300ms=2.5s;字符 100ms+480×5ms=2.5s,均在超时的一半内) - embedding: 新增 maxSafeBatchLimit=12(实测外沿)+ 编译期断言(越界即 uint 溢出、 编译失败),与 tidy_config.go 那条同手法 - memory: defaultReviewTimeout 20s→**90s**(> 实测最坏链:空闲 21.6s⇒4.2 倍余量、 与整理链并发 56.7s + T1 8s ⇒ 1.4 倍;不再大的理由=单 worker 有界队列 256 的 最坏积压 256×90s≈6.4h、服务真挂死时第一片 5s 就降级用不到这条预算) 测试(不留过时断言):budget_test.go 加「钉值 8/480 + 两维不等式 + ≤12 外沿 + 16 条必须仍判越界」四层;api_url_test.go 的 reviewBudgetRef 20s→90s; preinsert_recall_test.go 新增跨包断言「预算 > 实测最坏链 + defaultT1Timeout」。 变异自证:perItemBudget 回 500ms ⇒ 钉值断言红;reviewTimeout 回 20s ⇒ 跨包断言红; perItemBudget 150ms(⇒16)⇒ 编译失败(constant -4 overflows uint)。三者均已逐字还原。 遗留(如实登记,未修):① 整理轮每轮重嵌整个分区(user 一轮仍 250 个请求), 解法方向是复用已入库的 vec_items 或给整理轮一个 embedding 请求预算;② 链路对单点 慢分片没有韧性 —— 一个分片失败即整条 Embed 失败、丢掉其余分片的工作量,且实测 任何批次形状都会偶发 >5s 尖峰(4 条 7.139s / 8 条 9.711s,与批次无关),方向是 重试/分片级容错,调常量解决不了。
This commit is contained in:
1 parent
20051f0168
commit
f3fb097630
8 files changed
+280
-53
No files matched your search
@@ -20,28 +20,72 @@ import "time"
|
||||
// 时刻**,而且只发生一次(见 warmupTimeout 的说明)。批形状与超时仍然同源:
|
||||
// 无论第一个请求的截止多宽,它带的条数/字符数永远按稳态超时算出来。
|
||||
//
|
||||
// ## 实测依据(2026-10-13,本机 = AMD RX 6600 XT + llama.cpp b11371 +
|
||||
// Qwen3-Embedding-0.6B Q8_0,端点 127.0.0.1:11434,**服务器空闲**,正文取自活库
|
||||
// data/cyrene.db:短正文约 33 汉字、长正文约 168 汉字;每档 5~6 次取最坏)
|
||||
// ## 实测依据(2026-10-13,本机 = AMD RX 6600 XT + llama.cpp(PID 318144,起于
|
||||
// 10-07,**排除冷启动**)+ Qwen3-Embedding-0.6B Q8_0,端点 127.0.0.1:11434,
|
||||
// **服务器空闲**,正文取自活库 data/cyrene.db 的 cyrene/user 分区:本次样本
|
||||
// 201 条、均长 34.7 汉字、最长 75 汉字)
|
||||
//
|
||||
// n=1 短 0.066~0.185s 长 0.740s
|
||||
// n=8 短 0.86~1.11s
|
||||
// n=12 短 1.27s 长 5.47s
|
||||
// n=16 短 2.20~3.31s
|
||||
// n=64 短 7.49~8.49s(改前的批次上限)
|
||||
// ① 单次请求的**形状曲线**(隔离测量 = 一条请求发完算完,同档重复 2~3 次):
|
||||
//
|
||||
// 拟合:单次请求耗时 ≈ 固定开销 + 字符总量 × 每字符成本;固定开销实测约 50ms,
|
||||
// 每字符成本实测 2.7~3.9ms。**同一批 n≥32 起出现过数倍放大**(8.97s / 26.6s),
|
||||
// 因此大批次的耗时不可预测 —— 这也是「余量取一半」而不是「卡着边界」的依据。
|
||||
// n=1 15 字 0.069 / 0.076 / 0.072 s
|
||||
// n=4 78 字 0.243 / 0.224 / 0.221 s
|
||||
// n=8 184 字 0.550 / 0.570 / 0.569 s
|
||||
// n=12 331 字 0.996 / 1.017 s
|
||||
// n=16 464 字 1.385 / 1.394 / 1.353 s
|
||||
// n=32 991 字 2.884 s
|
||||
// n=64 1997 字 6.872 s
|
||||
//
|
||||
// 拟合:耗时 ≈ 固定开销 + 条数 × 每条成本;固定开销实测约 50ms、每条成本
|
||||
// 70~110ms。64 条 6.9s **已越过单次超时 5s**(旧批次上限正落在这里)。
|
||||
//
|
||||
// ② **真实链路的墙钟与请求数**:把一条入库审查的召回链(201 条 = 新条 1 +
|
||||
// ScanLimit 200,preinsert_recall.go 的 embedReviewTexts)按批次形状整条发出:
|
||||
//
|
||||
// 形状 请求数 墙钟(s) 单请求均值(s) 单请求最大(s)
|
||||
// 4 条/片(改前) 51 23.070 / 21.860 0.452 / 0.429 1.449 / 1.052
|
||||
// 8 条/片(本次) 26 21.435 / 21.597 0.824 / 0.831 1.782 / 1.732
|
||||
// 16 条/片 / 480 字 16 21.477 → 49.433 1.342 → 3.089 2.119 → **11.043**
|
||||
// 16 条/片 / 不限字 13 21.012 / 21.904 1.616 / 1.685 2.313 / 2.582
|
||||
//
|
||||
// 三条结论 —— 本次修复的判断全部落在这里:
|
||||
//
|
||||
// - **链路墙钟由「条数 × 每条成本」主导,几乎与批次形状无关**:同样 201 条,
|
||||
// 4 条/片与 8 条/片的墙钟都是 ~21s。抬高批次上限买到的是**请求数减半**
|
||||
// (51 → 26)与其带来的服务端排队压力下降,**不是**链路变快。要让链路落回
|
||||
// 调用方预算,只有把预算抬到链长之上(审查侧 defaultReviewTimeout)。
|
||||
// - **8 条/片不越界**:空闲窗口里三轮最大的单请求 1.782s,远在 5s 单次超时之内。
|
||||
// - **16 条/片会越界**:同一批正文、同一次会话里复现出单请求 **11.043s**
|
||||
// (那一轮整条链 49.4s)。批上限取 8 而不是 12/16,依据是**实测到的最坏单请求
|
||||
// 本身就出界**,不需要归因。(隔离测量里 16 条三次稳定在 1.35~1.39s ⇒ 这个
|
||||
// 形态方差极大,不能只看隔离值。)
|
||||
//
|
||||
// ③ **真实代码路径**(不是直连端点:跑 APIEmbedder.Embed(201 条) 本身,用注入的
|
||||
// RoundTripper 数真实 HTTP 请求数):
|
||||
//
|
||||
// 8 条/片 26 个请求 / 201 个向量,墙钟 21.489s、28.220s(两轮,均未触发单请求超时)
|
||||
// 16 条/片 直接 embedChunk:1.818s、**5.016s 超时**、**5.001s 超时**(3 次里 2 次越界)
|
||||
//
|
||||
// ④ **必须登记的残余(实测,不是推测)**:任何形状下都偶发「单请求越过 5s」,
|
||||
// 与批次**无关** —— 交错对比 40 次 4 条 + 40 次 8 条(同一批正文):
|
||||
//
|
||||
// 4 条/片 p50 0.456s p90 1.008s 最大 **7.139s**(>5s 计 1/40)
|
||||
// 8 条/片 p50 0.848s p90 1.861s 最大 **9.711s**(>5s 计 2/40)
|
||||
//
|
||||
// 该窗口机器上确有别的负载(`tasklist` 可见多个 go/link 进程 = 塔里其它 worker 在
|
||||
// 编译;同窗后端也在跑群聊回合)。归因**未定**,但两件事可以确定:① 4 条/片同样尖峰
|
||||
// (7.139s)⇒ 尖峰**不是**本次放大批次的后果;② 因为「一个分片失败 ⇒ 整条 Embed
|
||||
// 失败」,一次尖峰就丢掉其余分片已算完的工作量 —— 链路对单点慢分片的**韧性**是遗留
|
||||
// 问题,**调常量解决不了**(修法是重试/分片级容错,不在本切片内)。
|
||||
//
|
||||
// 复跑命令(读活库正文、只连本机 embedding 服务、不写任何库):
|
||||
//
|
||||
// python - <<'PY'
|
||||
// import json,sqlite3,time,urllib.request
|
||||
// URL="http://127.0.0.1:11434/v1/embeddings"; MODEL="<model id>"
|
||||
// t=[r[0] for r in sqlite3.connect("data/cyrene.db").execute(
|
||||
// "select content from memory_items order by id limit 64")]
|
||||
// for b in (t[:1],t[:8],t[:12],t[:16],t[:64]):
|
||||
// t=[r[0] for r in sqlite3.connect("file:data/cyrene.db?mode=ro",uri=True).execute(
|
||||
// "select content from memory_items where status='active' and persona_id='cyrene'"
|
||||
// " and scope='user' order by created_at desc, rowid desc limit 201")]
|
||||
// for b in (t[:1],t[:4],t[:8],t[:16],t[:64]):
|
||||
// body=json.dumps({"model":MODEL,"input":b}).encode()
|
||||
// r=urllib.request.Request(URL,data=body,headers={"Content-Type":"application/json"})
|
||||
// s=time.perf_counter(); urllib.request.urlopen(r,timeout=300).read()
|
||||
@@ -54,43 +98,75 @@ const (
|
||||
// perCharBudget 是**每字符**的墙钟预算上界(实测 2.7~3.9 ms/字符,取 5ms)。
|
||||
perCharBudget = 5 * time.Millisecond
|
||||
|
||||
// perItemBudget 是**每条文本**的墙钟预算上界:按活库里最长的正文(168 汉字,
|
||||
// 最坏摊销 0.456 s/条)向上取整到 500ms。
|
||||
perItemBudget = 500 * time.Millisecond
|
||||
// perItemBudget 是**每条文本**的墙钟预算上界:它守住「条数」那一维。
|
||||
//
|
||||
// 2026-10-13 由 500ms 降到 300ms —— 旧值是把条数上限压到 **4** 的那个约束项,
|
||||
// 而 4 条/片会把一次召回放大成 ⌈N/4⌉ 个 HTTP 请求(201 条的审查链 = 51 个请求,
|
||||
// 见文件头)。依据不是「拍一个更小的数」,而是**实测摊薄**:整条链里每条的平均
|
||||
// 成本 70~103ms(8 条/片轮次 0.824s ÷ 8 ≈ 103ms;64 条档最坏 6.872s ÷ 64 ≈
|
||||
// 107ms),300ms 仍是实测的约 **3 倍**。按不变式反解,它把条数上限放到 8:
|
||||
// (5s/2 − 100ms) / 300ms = 8 —— 恰好用满「超时的一半」,另一半=抖动余量。
|
||||
//
|
||||
// 为什么不再大:条数上限 12 需要 perItemBudget ≤ 200ms(只剩实测的 1.9 倍),
|
||||
// 16 需要 ≤ 150ms(1.4 倍)—— 而 16 条/片本次已复现出**单请求 11.043s**
|
||||
// (> 单次超时 5s,见文件头)。批次越靠近那条界,单请求越可能「客户端先放弃、
|
||||
// 服务端还在算」(级联堆积的起点)。取 8:**请求数减半,单请求仍留 3 倍余量**。
|
||||
perItemBudget = 300 * time.Millisecond
|
||||
|
||||
// callBudgetShare 表示一次请求最多用掉超时的 1/2:另一半是抖动余量
|
||||
// (见上面 n≥32 的数倍放大)。调大它 = 余量变小,必须连带复核实测。
|
||||
// (见文件头 16 条/片那轮的数倍放大)。调大它 = 余量变小,必须连带复核实测。
|
||||
callBudgetShare = 2
|
||||
|
||||
// DefaultBatchLimit 是单次请求的**条数**上限,由上面三个常量推出来(唯一推演):
|
||||
// (5s/2 − 100ms) / 500ms = 4。
|
||||
// (5s/2 − 100ms) / 300ms = 8。
|
||||
DefaultBatchLimit = (int(DefaultRequestTimeout/callBudgetShare) - int(requestOverhead)) / int(perItemBudget)
|
||||
|
||||
// DefaultBatchChars 是单次请求的**字符总量**上限,同一个不等式换成每字符预算:
|
||||
// (5s/2 − 100ms) / 5ms = 480。它防的是「条数很少但每条很长」:
|
||||
// 4 条 × 168 汉字 = 672 字符已越过它,会被再切一刀。
|
||||
// (5s/2 − 100ms) / 5ms = 480。它防的是「条数很少但每条很长」。
|
||||
//
|
||||
// 本次**不动**它(每字符成本实测 2.7~3.9ms,5ms 仍是上界),因此两道界的分工
|
||||
// 是:8 条 × 均长 35 字(280 字)走**条数界**;8 条 × 长正文(如 168 字 = 1344 字)
|
||||
// 被它再切一刀,落到 2 条/片。两维都必须成立,缺哪一维都会有请求越界。
|
||||
DefaultBatchChars = (int(DefaultRequestTimeout/callBudgetShare) - int(requestOverhead)) / int(perCharBudget)
|
||||
|
||||
// maxSafeBatchLimit 是条数上限的**实测外沿**(12),它守的是与上面那条不等式
|
||||
// **不同**的一层:不等式管的是**建模成本**(requestOverhead + 批次 × perItemBudget
|
||||
// ≤ 超时的一半),它管的是**实测形状** —— 文件头里 16 条/片复现出 11.043s 的单
|
||||
// 请求(> 5s 超时),8 条/片三轮最大 1.782s,隔离测量 12 条 0.996~1.017s 仍在界内。
|
||||
// 两层必须同时成立:把批次放到 12 需要**同时**把 perItemBudget 压到 ≤ 200ms
|
||||
// (只剩实测摊销的 1.9 倍,且算式上 100ms + 12×200ms = 2.5s 已顶到边界);
|
||||
// 越过 12 则已进入「实测已知会出界」的区间 —— 由下面那条编译期断言拦住。
|
||||
maxSafeBatchLimit = 12
|
||||
|
||||
// warmupTimeout 是**一次性的**冷启动放宽:本 embedder 生命周期里的**第一个**
|
||||
// 请求可以拿到这个截止(模型加载 / GPU 缓冲分配还没发生过),此后**每一个**
|
||||
// 请求(含刚失败的那个之后的下一个)一律回到 DefaultRequestTimeout。
|
||||
//
|
||||
// 🔴 它**不是**「只要还没成功过就一直放宽」—— 那个口径会在服务挂死时把
|
||||
// fail-fast 变成 fail-slow(每次都等 warmupTimeout),并把审查的 20s 预算、
|
||||
// fail-fast 变成 fail-slow(每次都等 warmupTimeout),并把审查单条预算、
|
||||
// 索引写入的 5s 预算整段吃掉。一次性 + 8s 两条一起才是有界的:
|
||||
// - 一次性:放宽最多花掉**一个**请求的时长,最坏情形之后立刻回到 5s;
|
||||
// - 8s:与常态 5s **同一量级**(1.6×),且严格小于审查单条总预算 20s
|
||||
// (memory.defaultReviewTimeout)⇒ 一次冷启动不可能吃光整轮审查,
|
||||
// - 8s:与常态 5s **同一量级**(1.6×),且严格小于审查单条总预算
|
||||
// (memory.defaultReviewTimeout = 90s)⇒ 一次冷启动不可能吃光整轮审查,
|
||||
// 后面的 T1 灰区判定(8s)仍有预算。这条不等式由测试守住
|
||||
// (api_url_test.go 的 TestDefaultRequestTimeout)。
|
||||
//
|
||||
// 依据:2026-10-13 部署后第一分钟的生产日志里有一条
|
||||
// `vector embed for index failed ... embed 1 texts ... context deadline exceeded`
|
||||
// —— 模型还没热时 5s 不够;而稳态实测单条只要 0.03~0.19s(见上面的曲线),
|
||||
// 所以放宽只需要覆盖「进程的第一个请求」这一刻。
|
||||
// —— 模型还没热时 5s 不够;而本次实测稳态单条只要 0.069~0.076s(见文件头的
|
||||
// 形状曲线),所以放宽只需要覆盖「进程的第一个请求」这一刻。
|
||||
warmupTimeout = 8 * time.Second
|
||||
)
|
||||
|
||||
// 编译期不变式(2026-10-13):条数上限一旦越过**实测划出来的外沿**
|
||||
// (maxSafeBatchLimit)—— 例如把 perItemBudget 压到 150ms(⇒ 16)、或把
|
||||
// callBudgetShare / requestOverhead 往「放宽」的方向调 —— 下面这行常量表达式
|
||||
// 会变成负数、uint 溢出 ⇒ **编译失败**。与 tidy_config.go 里那条「整理侧批上限
|
||||
// 不许超过 embedding 推出来的值」同一手法:把实测承诺交给编译器守,而不是只写在
|
||||
// 注释里。反方向的漂移(perItemBudget 调大 ⇒ 批上限变小 ⇒ 报错变多)由
|
||||
// budget_test.go 的钉值断言抓。
|
||||
const _ = uint(maxSafeBatchLimit - DefaultBatchLimit)
|
||||
|
||||
// callBudget 返回在 timeout 这个单次超时下,一次请求允许用掉的墙钟预算。
|
||||
func callBudget(timeout time.Duration) time.Duration {
|
||||
if timeout <= 0 {
|
||||
|
||||
Reference in new issue
Block a user