Files
HomeAgent/internal/memory/introspect_perf_test.go
JianFeeeee df865518e6 perf(memory): Introspect 507ms→11ms、ListPersons 130ms→3ms
继续清「非常卡」。上次修完 pulse 端点后,`/kernel` 与 `/runtime` 仍各约
600ms(每 15s 一次)。这次**逐项量**而不是猜(临时探针量各子收集器):

    memDB.Introspect      507ms   ← 主凶
    social.ListPersons    129ms
    textMem.FileCount     0.1ms
    docStore.Stats        0.0ms
    knowledge.List/Stats  0.0ms

## ① Introspect 507ms → 11ms(43×)

hotspots 的度数写成**相关标量子查询**:

    COALESCE((SELECT COUNT(*) FROM memory_block_edges e
               WHERE (e.source_id = b.id OR e.target_id = b.id) ...), 0)

EXPLAIN 是 `SCAN b`(3364 块)→ `CORRELATED SCALAR SUBQUERY` → `SCAN e`
(2829 边),即 **3364 × 2829 ≈ 950 万次行扫描**,且 ORDER BY 前必须算完
全部块的度数。

改成边表按端点 UNION ALL + GROUP BY 聚合一次,再 LEFT JOIN 回块表。
边表只扫两遍而不是 3364 遍。**新旧查询输出逐字节一致**(已 diff 验证)。

## ② ListPersons 130ms → 3ms(38×)

它走 `Recall(nil, nil, 1, "")`,而那条 full-recall 路径为了填
`result.Entities` 会加载**全部块(blockColumns 含稠密向量)**:

    Recall 全程 130ms:实体 3266 个、关系 30 条

即为了 30 条关系反序列化了 3266 个块的向量,而 ListPersons
**只看 result.Relations**。

新增 `GraphDB.RecentRelations(limit)`:与 full-recall 分支里的关系子查询
**逐字对齐**(同一 WHERE/ORDER/LIMIT,含 trait 边——过滤掉它会让特质
主体漏出人物表)。实测该查询单独 4ms。

等价性验证:新旧路径的人物列表 + 关系集**逐字节 diff 完全一致**
(56 人、同样的边)。旧路径输出是先 git stash 采样的,不是凭记忆比对。

## 验证

- `TestIntrospect_HotspotsQueryIsNotCorrelated`(源码形状)+
  `TestIntrospect_LargeGraphIsFast`(2000 块/1500 边的耗时门)。
  **两者各自独立可杀变异**(实测):
  - 源码断言:退回相关子查询 → FAIL「必须用一次聚合」
  - 耗时门:同变异 → FAIL「149ms 超过 60ms 门槛」(修复后 11ms)

  为何源码断言要锚在返回值行而非 `Index`:注释里也提到 memory_hotspots,
  用 Index 会截到注释处、取到的片段根本没有查询——第一版就这么写错了,
  自己先红了一次才发现。
- 全仓 `go test ./... -count=1` → 48 包 ok / 0 FAIL
2026-10-08 16:41:03 +08:00

130 lines
4.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package memory
import (
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
)
func readSource(t *testing.T, name string) string {
t.Helper()
b, err := os.ReadFile(name)
if err != nil {
t.Fatal(err)
}
return string(b)
}
// Introspect 曾是内核状态页最大的单点开销(2026-10-08 修)。
//
// 生产实测(3364 块 / 2829 边):**507ms**。
// 根因:hotspots 的度数写成**相关标量子查询**——对每个块执行一次
//
// SELECT COUNT(*) FROM memory_block_edges
// WHERE source_id = b.id OR target_id = b.id
//
// EXPLAIN 是 SCAN b → CORRELATED SCALAR SUBQUERY → SCAN e,
// 即 3364 × 2829 ≈ **950 万次行扫描**,且 ORDER BY 前必须算完全部块的度数。
//
// 修法:边表按端点 UNION ALL + GROUP BY 聚合一次,再 LEFT JOIN 回块表。
// 实测同数据 **507ms → 11ms**,且新旧输出逐字节一致。
//
// 为何用源码断言兜住「形状」而不只靠耗时:小库上两种写法都快,
// 只有大库才暴露;而形状(有没有相关子查询)是**静态可判定**的,
// 回归时立刻红,不必等库长大才发现。
func TestIntrospect_HotspotsQueryIsNotCorrelated(t *testing.T) {
src := readSource(t, "graph.go")
// 定位 hotspots 那条查询:用其返回值行的**最后**一次出现(前面的注释里
// 也提到 memory_hotspots,用 Index 会截到注释处,取到的片段里根本没有查询)。
i := strings.LastIndex(src, `"memory_hotspots": hotspots`)
if i < 0 {
t.Fatal("找不到 memory_hotspots 返回值")
}
seg := src[:i]
q := strings.LastIndex(seg, "g.db.Query(")
if q < 0 {
t.Fatal("找不到 hotspots 查询")
}
// 从查询起点到返回值:这段里就应当只有 hotspots 那一条 SELECT。
query := seg[q:]
if len(query) > 4000 {
t.Fatalf("提取的查询片段异常长(%d),定位可能错了", len(query))
}
// ① 度数必须来自**预聚合**(GROUP BY),不是逐行子查询。
if !strings.Contains(query, "GROUP BY id") {
t.Error("hotspots 度数必须用一次聚合(GROUP BY)算出,不能是相关子查询:\n" +
" 相关子查询 = 每块一次边表扫描 = 3364 × 2829 ≈ 950 万次行访问\n" +
" 实测 Introspect 507ms → 11ms 就是这一处的差别")
}
// ② 明确禁止那个形态:括号里直接嵌 COUNT(*) 子查询。
if strings.Contains(query, "COALESCE((SELECT COUNT(*) FROM memory_block_edges e") {
t.Error("hotspots 又退回相关子查询了(COALESCE((SELECT COUNT(*) ... WHERE (e.source_id = b.id OR ...))")
}
// ③ 聚合形态必须双边都算(source 与 target 都是端点)。
if !strings.Contains(query, "SELECT source_id AS id") ||
!strings.Contains(query, "SELECT target_id AS id") {
t.Error("度数聚合必须同时含 source_id 与 target_id 两个端点,缺一个就会漏度")
}
}
// TestIntrospect_LargeGraphIsFast 用真实规模的库钉住耗时上限。
//
// 源码断言只能守形状;这条守「形状对了但仍慢」的情况(比如聚合子查询里
// 又嵌了别的全表扫)。规模按生产量级造(2000 块 / 1500 边),
// 上限留足余量但足以抓住相关子查询那档(2000×1500 ≈ 300 万次访问)。
func TestIntrospect_LargeGraphIsFast(t *testing.T) {
g := newPerfGraph(t, 2000, 1500)
start := time.Now()
if _, err := g.Introspect(); err != nil {
t.Fatal(err)
}
elapsed := time.Since(start)
// 相关子查询版在这个规模约 250-500ms;聚合版 < 20ms。
// 60ms 的门槛既能抓住退化,又不会因 CI 机器慢而误报。
if elapsed > 60*time.Millisecond {
t.Errorf("Introspect 在 2000 块/1500 边上耗时 %v,超过 60ms 门槛。\n"+
" 相关子查询版本会落到几百毫秒;若本条变红,检查 hotspots 是否退回逐块计数。",
elapsed)
}
}
// newPerfGraph 造一个指定规模的图:nBlocks 个块、nEdges 条边(互相引用)。
func newPerfGraph(t *testing.T, nBlocks, nEdges int) *GraphDB {
t.Helper()
g, err := NewGraphDB(filepath.Join(t.TempDir(), "perf.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { g.Close() })
ids := make([]string, 0, nBlocks)
for i := 0; i < nBlocks; i++ {
id := "blk_" + strconv.Itoa(i)
ids = append(ids, id)
if err := g.PutMemoryBlocks([]MemoryBlock{{
ID: id, Modality: BlockText, Text: "实体 " + strconv.Itoa(i),
Source: "triple", SemanticType: "Concept",
}}); err != nil {
t.Fatal(err)
}
}
for i := 0; i < nEdges; i++ {
a := ids[i%len(ids)]
b := ids[(i*7+3)%len(ids)]
if a == b {
continue
}
if err := g.AddMemoryBlockEdge("block", a, "block", b, "关联"); err != nil {
t.Fatal(err)
}
}
return g
}