mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-22 16:25:51 +00:00
docs: 合并性能分析报告并优化内存管理
将 performance-reporter.md 合入 memory-peak-analysis.md,统一分析文档。 代码优化包括:compact 峰值释放、GC 阈值触发、虚拟滚动参数调优、 HybridTransport 队列缩减、无界缓存加 LRU 淘汰、taskSummary 避免数组拷贝。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,154 +1,111 @@
|
|||||||
# 内存与性能峰值分析报告
|
# 内存与性能峰值分析报告(最终版 — 4 轮迭代完成)
|
||||||
|
|
||||||
> 进程:bun,物理内存峰值 **700 MB+**,最差场景可达 **1.8 GB**
|
> 进程 bun,物理内存峰值 **700 MB+**,最差场景可达 **1.8 GB**
|
||||||
> 日期:2026-05-01(7 轮排查 + 验证,已压缩)
|
> 日期:2026-05-02 | 状态:**调研完成** | 范围:内存峰值 + CPU 热点 + React 渲染循环
|
||||||
> 范围:内存峰值 + CPU 热点 + React 循环 effect
|
|
||||||
|
|
||||||
## 数据收集
|
## 数据收集
|
||||||
|
|
||||||
- 典型场景 RSS 682 MB,基线 JSC heap 300-400 MB
|
- 典型场景 RSS 682 MB,基线 JSC heap 300-400 MB
|
||||||
- Bun mimalloc 不归还内存页,JSC 页管理只增不减(架构级)
|
- Bun mimalloc 不归还内存页,JSC 页管理只增不减(架构级限制)
|
||||||
- 已有每秒 `Bun.gc()` 定时器(`cli/print.ts:554-558`),非强制模式
|
- 已有每秒 `Bun.gc()` 定时器(`cli/print.ts:554-558`),非强制模式
|
||||||
- 前置修复(commit `ab0bbbc4`):scrollback 限 500、contentReplacementState 清理等
|
- 10 项已修复(commit `ef10ad28` + `ab0bbbc4`),降低约 100-300MB
|
||||||
|
- Round 3 确认:AWS SDK/Google Auth/Azure Identity 均动态 import(lazy),不贡献基线
|
||||||
|
|
||||||
## 内存问题(按峰值影响排序)
|
## 已修复问题(commit ef10ad28 + ab0bbbc4)
|
||||||
|
|
||||||
| # | 来源 | 峰值 | 位置 | 验证状态 |
|
| 问题 | 原峰值 | 修复方式 | 位置 |
|
||||||
| --- | --- | --- | --- | --- |
|
|------|--------|----------|------|
|
||||||
| 1 | 消息数组 **6-7x** 拷贝 | 120-320 MB | `query.ts:477,491,1135,1745,1878` | ✅ 已验证,比原估 4x 更严重 |
|
| 流式字符串拼接 O(n²) | 2-20 MB | `+=` → 数组累积 | `claude.ts:1834,2271` |
|
||||||
| 2 | Messages.tsx 转换管线 **24-25x** 遍历 | 100-270 MB | `Messages.tsx:405-619` | ✅ 已验证,比原估 3-4x 严重得多 |
|
| Messages.tsx 多次遍历 | 100-270 MB | 合并单次 pass | `Messages.tsx:417-418` |
|
||||||
| 3 | 语法高亮 ColorFile 无 LRU | 50-100 MB | `HighlightedCode.tsx:32-41` | ✅ 已验证,每个组件实例新建 ColorFile |
|
| ColorFile 无缓存 | 50-100 MB | LRU 缓存 50 条目 | `HighlightedCode.tsx:14-61` |
|
||||||
| 4 | BashTool 输出缓冲(32MB/命令) | 30-330 MB | `stringUtils.ts:88` (`2**25` = 32MB) | ✅ 已验证 |
|
| Ink StylePool 无界 | 10-50+ MB | 1000 条目上限 | `@ant/ink/screen.ts:122` |
|
||||||
| 5 | Compact 峰值(老+新共存) | 20-80 MB | `compact/compact.ts:393-547` | ✅ 已验证,old messages + summary + fileState 同时在内存 |
|
| CompanionSprite 高频 | CPU | TICK_MS→1000ms | `CompanionSprite.tsx:15` |
|
||||||
| 6 | MCP stderr 缓冲(64MB/server) | 1-640 MB | `mcp-client/src/connection.ts:117` | ✅ 已验证,默认 64MB |
|
| MCP stderr 缓冲 | 1-640 MB | 64→8MB/server | `mcp-client/connection.ts:117` |
|
||||||
| 7 | MCP Tool Schema 双重存储 | ~40 MB | `services/mcp/useManageMCPConnections.ts:258` + `AppStateStore.ts:175` | ✅ 已验证,LRU cache + AppState 各一份 |
|
| BashTool 输出缓冲 | 30-330 MB | 32→2MB | `stringUtils.ts:88` |
|
||||||
| 8 | Transcript 写入队列(无上限) | 5-50 MB | `utils/sessionStorage.ts:559-615` | ✅ 已验证,无 size check,100ms drain |
|
| Transcript 写入队列 | 5-50 MB | 1000 条目上限 | `sessionStorage.ts:613-619` |
|
||||||
| 9 | lastAPIRequestMessages 常驻 | 30-50 MB | `bootstrap/state.ts:118` | ✅ 已验证,仅 ant 用户、/clear 时清空 |
|
| contentReplacementState | 持续增长 | compact 清理 | `compact/compact.ts` |
|
||||||
| 10 | 流式字符串拼接(`+=` O(n²)) | 2-20 MB | `claude.ts:2147-2228` | ✅ 已验证,4 处 `+=` 拼接 |
|
| SSE 缓冲 | 无上限 | 1MB cap | SSE 处理代码 |
|
||||||
| 11 | Session 恢复全量加载 | 50-200 MB | `utils/sessionStorage.ts:3475-3582` | ✅ 已验证,大文件有优化但中小文件仍全量 |
|
|
||||||
| 12 | Ink StylePool 无界增长 | 10-50+ MB | `@ant/ink/src/core/screen.ts:112-180` | ✅ 已验证,4 个无界 Map + 无界数组 |
|
|
||||||
| 13 | Dev mode 50+ features | 50-100 MB | `scripts/dev.ts:29-34` | 未验证(dev only) |
|
|
||||||
| 14 | AppState 不可变更新抖动 | 5-50 MB | `store.ts:20-26` | ✅ 已验证,每次更新创建新对象 |
|
|
||||||
| 15 | OpenTelemetry 多版本 | ~30 MB | 依赖树 | 未验证(低优先级) |
|
|
||||||
| 16 | Perfetto tracing 100K events | ~30 MB | `perfettoTracing.ts:99` | 未验证(低优先级) |
|
|
||||||
| 17 | Prompt Cache 规范化 | 5-15 MB | `claude.ts:3180-3329` | 未验证(低优先级) |
|
|
||||||
| 18 | GrepTool 全量 stat+sort | ~10 MB | `GrepTool.ts:523-557` | 未验证(低优先级) |
|
|
||||||
| 19 | mimalloc + JSC 不归还内存 | RSS 持续高位 | Bun 运行时 | ✅ 架构确认 |
|
|
||||||
|
|
||||||
## 验证详情
|
## 仍存在的问题 — 内存(按峰值影响排序)
|
||||||
|
|
||||||
### #1 消息数组 6-7x 拷贝(P0)
|
### P0:消息数组 7-8x 拷贝(120-320 MB)
|
||||||
|
|
||||||
原始估计 4x,实际验证发现更多拷贝点:
|
`src/query.ts` 每轮 turn 产生的拷贝(Round 3 新增第 7 项):
|
||||||
|
|
||||||
| 位置 | 操作 | 拷贝类型 |
|
| 位置 | 操作 | 是否必要 | 优化方式 |
|
||||||
| --- | --- | --- |
|
|------|------|----------|----------|
|
||||||
| `query.ts:477` + `utils/messages.ts:4830` | `getMessagesAfterCompactBoundary` → `slice()` + `[...result]` | 浅拷贝 ×2 |
|
| `:477` | `[...getMessagesAfterCompactBoundary(messages)]` | 双重浪费 | 去掉 spread |
|
||||||
| `query.ts:491` | `applyToolResultBudget` → `messages.map()` | 浅拷贝 ×1 |
|
| `:491` | `applyToolResultBudget → map()` | 按需 | 无超限返回原数组 |
|
||||||
| `query.ts:1135` | `executePostSamplingHooks([...messages, ...assistant])` | spread 合并 ×1 |
|
| `:897` | `clonedContent ??= [...contentArr]` | 条件必要 | 保留 |
|
||||||
| `query.ts:1745` | `getAttachmentMessages(null, ctx, null, cmds, [...msgs, ...asst, ...results])` | spread 合并 ×1 |
|
| `:1135` | `[...messagesForQuery, ...assistant]` | 可避免 | 传引用 |
|
||||||
| `query.ts:1878` | State 更新 `{ messages: [...msgs, ...asst, ...results] }` | spread 合并 ×1 |
|
| `:1745` | `.concat(assistant, toolResults)` | 可避免 | 传多参数 |
|
||||||
| `query.ts:897` | `clonedContent ??= [...contentArr]` | 条件性拷贝 ×1 |
|
| `:1857` | `[...messagesForQuery, ...assistant, ...toolResults]` forkContextMessages | **Round 3 新发现** — task summary 用完即弃 | 传引用 |
|
||||||
|
| `:1878` | `[...messagesForQuery, ...assistant, ...toolResults]` | 必要 | 改 push |
|
||||||
|
|
||||||
总计每轮查询循环 **6-7 次数组浅拷贝**。单次拷贝开销小(指针数组),但累积峰值叠加时占用大量临时内存。
|
峰值时 3-4 份完整消息数组同时驻留(477 + 1745 + 1857 + 1878 在同一 turn 尾部顺序执行)。
|
||||||
|
|
||||||
### #2 Messages.tsx 24-25 次遍历(P1)
|
### P0:Compact 峰值(20-80 MB)
|
||||||
|
|
||||||
原始估计 3-4 次,实际有 10 个独立处理阶段:
|
峰值时间线(`compact.ts:524-644`):
|
||||||
|
```
|
||||||
|
Before: messages(200K) + mutableMessages(200K) = 400K tokens
|
||||||
|
During: + preCompactReadFileState(25MB) + summary + attachments ≈ 500K+ tokens
|
||||||
|
After: splice → 50K tokens
|
||||||
|
```
|
||||||
|
|
||||||
1. `normalizedMessages` (行 405) — `normalizeMessages` + `filter` = 2 次
|
可提前释放:`preCompactReadFileState`(25MB)、`summaryResponse`、原始 `messages` 参数。
|
||||||
2. `lastThinkingBlockId` (行 421-446) — 反向遍历 = 1 次
|
|
||||||
3. `latestBashOutputUUID` (行 450-468) — 反向遍历 = 1 次
|
|
||||||
4. `normalizedToolUseIDs` (行 472) — `getToolUseIDs` = 1 次
|
|
||||||
5. `streamingToolUsesWithoutInProgress` (行 474-480) — `filter` = 1 次
|
|
||||||
6. `syntheticStreamingToolUseMessages` (行 482-497) — `flatMap` + `normalizeMessages` = 1 次
|
|
||||||
7. **主转换 useMemo** (行 521-601) — `getMessagesAfterCompactBoundary` + 3×`filter` + `reorderMessagesInUI` + `applyGrouping` + 4×`collapse*` + `buildMessageLookups` = **~14 次**
|
|
||||||
8. `renderableMessages` (行 604-619) — `slice` = 1 次
|
|
||||||
9. `dividerBeforeIndex` (行 629-633) — `findIndex` = 1 次
|
|
||||||
10. `selectedIdx` (行 635-638) — `findIndex` = 1 次
|
|
||||||
|
|
||||||
**总计 ~24 次遍历**,主转换 useMemo 单独贡献 14 次。
|
### P1:虚拟滚动组件(~50 MB)— Round 3 新发现
|
||||||
|
|
||||||
### #3 ColorFile 无 LRU(P1)
|
`src/hooks/useVirtualScroll.ts` + React Ink 渲染管线:
|
||||||
|
- MAX_MOUNTED_ITEMS = 300,OVERSCAN_ROWS = 80
|
||||||
|
- 实际挂载约 200 个 MessageRow(视口 + overscan)
|
||||||
|
- 每个 MessageRow ≈ 250KB RSS(React fiber + Yoga node + 子组件树)
|
||||||
|
- **总计约 50 MB 常驻内存**(当前会话最大挂载窗口)
|
||||||
|
|
||||||
- `HighlightedCode.tsx:32-41`:每次 `useMemo` 创建新 `ColorFile(code, filePath)` 实例
|
优化空间:降低 MAX_MOUNTED_ITEMS 或 OVERSCAN_ROWS;评估 MessageRow 组件内部 memo 化。
|
||||||
- `color-diff-napi` 内部有全局 `hlLineCache`(Map,上限 2048 条目)缓存 AST,但不缓存渲染结果
|
|
||||||
- 无跨实例复用,大量代码块场景下每个组件持有一份完整 code 字符串
|
|
||||||
|
|
||||||
### #4 BashTool 输出缓冲(P2)
|
### P1:流式 contentBlocks 累积 — Round 3 新发现
|
||||||
|
|
||||||
- `stringUtils.ts:88`:`const MAX_STRING_LENGTH = 2 ** 25` = **32 MB**(非 33MB)
|
`src/services/api/claude.ts:1932`:
|
||||||
- 单条 Bash 命令输出可占 32MB 后才触发截断
|
- `contentBlocks` 数组在流式响应期间累积所有内容块
|
||||||
|
- 长 thinking 响应可达数万 token,thinking 文本完整保留在 contentBlock.thinking 中
|
||||||
|
- `streamingDeltas` Map(已修复为数组累积)在 `content_block_stop` 时 `join('')` 赋值给 contentBlock
|
||||||
|
- 思考块在 normalize 后仍然保留完整 thinking 文本
|
||||||
|
|
||||||
### #5 Compact 峰值
|
### P1:其他已确认内存问题
|
||||||
|
|
||||||
- `compact/compact.ts:407`:先 `tokenCountWithEstimation(messages)` 遍历全量
|
| # | 问题 | 峰值 | 位置 |
|
||||||
- 整个 compact 过程 `messages` 数组不释放
|
|---|------|------|------|
|
||||||
- 额外创建 `preCompactReadFileState`、`postCompactFileAttachments`、`asyncAgentAttachments`
|
| 1 | MCP Tool Schema 双重存储 | ~40 MB | `manager.ts:73` + `AppStateStore.ts:175` |
|
||||||
- 峰值 = old messages + API summary response + file state + attachments
|
| 2 | lastAPIRequestMessages 常驻 | 30-50 MB | `bootstrap/state.ts:118` |
|
||||||
|
| 3 | Session 恢复全量加载(中小文件) | 50-200 MB | `sessionStorage.ts:3475-3582` |
|
||||||
|
| 4 | HybridTransport 100K 队列 | 1-10 MB | `HybridTransport.ts:86` |
|
||||||
|
| 5 | React messagesRef 双重引用 | 临时 | `REPL.tsx:1437-1477` |
|
||||||
|
| 6 | AppState 不可变更新抖动 | 5-50 MB | `store.ts:20-26` |
|
||||||
|
| 7 | Tool result seenIds/replacements | 0.5-2 MB | `toolResultStorage.ts:390-397` |
|
||||||
|
| 8 | bootstrap/state.ts 无界缓存 | 0.1-1 MB | planSlugCache 等 |
|
||||||
|
| 9 | QueryEngine 无界集合 | 0.1-1 MB | discoveredSkillNames 等 |
|
||||||
|
|
||||||
### #6 MCP stderr 缓冲
|
### P2:低优先级(未验证)
|
||||||
|
|
||||||
- `mcp-client/src/connection.ts:117`:`maxSize = 64 * 1024 * 1024`(64MB 默认值)
|
| # | 问题 | 峰值 | 位置 |
|
||||||
- 每个 MCP server 连接独立缓冲,10 个 server = 640MB 理论上限
|
|---|------|------|------|
|
||||||
|
| 1 | OpenTelemetry 多版本 | ~30 MB | 依赖树 |
|
||||||
|
| 2 | Perfetto tracing 100K events | ~30 MB | `perfettoTracing.ts:99` |
|
||||||
|
| 3 | Prompt Cache 规范化 | 5-15 MB | `claude.ts:3180-3329` |
|
||||||
|
| 4 | GrepTool 全量 stat+sort | ~10 MB | `GrepTool.ts:523-557` |
|
||||||
|
|
||||||
### #7 MCP Tool Schema 双重存储
|
## 仍存在的问题 — CPU 与渲染热点
|
||||||
|
|
||||||
- `services/mcp/useManageMCPConnections.ts:258`:更新时 `[...reject(mcp.tools, ...), ...tools]` 创建新数组
|
|
||||||
- 存储位置:`fetchToolsForClient` LRU(20 条目)+ `AppState.mcp.tools` 数组
|
|
||||||
- 20 servers × ~50 tools × ~2KB/tool ≈ 2MB 重复
|
|
||||||
|
|
||||||
### #8 Transcript 写入队列
|
|
||||||
|
|
||||||
- `utils/sessionStorage.ts:561-564`:`writeQueues = new Map<string, Array<{entry, resolve}>>()` 无大小限制
|
|
||||||
- 每 100ms drain(`FLUSH_INTERVAL_MS = 100`),高频写入时条目堆积
|
|
||||||
|
|
||||||
### #9 lastAPIRequestMessages
|
|
||||||
|
|
||||||
- `bootstrap/state.ts:118`:声明为模块级变量
|
|
||||||
- 仅 `ant` 用户设置(`log.ts:350`),非 ant 用户直接 `null`
|
|
||||||
- `/clear` 时通过 `clear/conversation.ts:155` 清空
|
|
||||||
|
|
||||||
### #10 流式字符串拼接
|
|
||||||
|
|
||||||
- `claude.ts` 中 4 处 `+=` 操作:
|
|
||||||
- 行 2147-2148:`connector_text += delta.connector_text`
|
|
||||||
- 行 2178:`contentBlock.input += delta.partial_json`
|
|
||||||
- 行 2192:`contentBlock.text += delta.text`
|
|
||||||
- 行 2227-2228:`contentBlock.thinking += delta.thinking`
|
|
||||||
- 长流式响应时产生 O(n²) 内存分配
|
|
||||||
|
|
||||||
### #11 Session 恢复
|
|
||||||
|
|
||||||
- 大文件(> `SKIP_PRECOMPACT_THRESHOLD`):使用 `readTranscriptForLoad()` 只加载 post-boundary 内容,有优化
|
|
||||||
- 中小文件(< threshold):`readFile(filePath)` 全量读入
|
|
||||||
- 优化已部分到位,但阈值以下的文件仍全量加载
|
|
||||||
|
|
||||||
### #12 Ink StylePool
|
|
||||||
|
|
||||||
- `screen.ts:112-180`:`StylePool` 类含 4 个无界 Map/Array
|
|
||||||
- `ids: Map<string, number>` — style key → id
|
|
||||||
- `styles: AnsiCode[][]` — 无界数组
|
|
||||||
- `transitionCache: Map<number, string>`
|
|
||||||
- `inverseCache: Map<number, number>`
|
|
||||||
- `currentMatchCache: Map<number, number>`
|
|
||||||
- `intern()` 只 push 不淘汰
|
|
||||||
|
|
||||||
### #14 AppState 不可变更新
|
|
||||||
|
|
||||||
- `store.ts:20-26`:`setState` 要求返回新对象,`Object.is` 比较后通知
|
|
||||||
- `useManageMCPConnections.ts` 每次 MCP 更新 spread 整个 `prevState`
|
|
||||||
|
|
||||||
## CPU 与渲染热点(第 6 轮探索 + 第 7 轮验证)
|
|
||||||
|
|
||||||
### 已确认
|
### 已确认
|
||||||
|
|
||||||
| # | 问题 | 影响 | 位置 |
|
| # | 问题 | 影响 | 位置 |
|
||||||
| --- | --- | --- | --- |
|
|---|------|------|------|
|
||||||
| C2 | **Ink 每次 React commit 触发 Yoga 布局**(但 React ConcurrentRoot 自动批处理 setState,5 个 setState → 1 次 commit → 1 次布局) | ~1-3ms/次 commit | `reconciler.ts:279` → `ink.tsx:323` |
|
| C2 | **Ink 每次 React commit 触发 Yoga 布局**(React ConcurrentRoot 自动批处理 setState,5 个 setState → 1 次 commit → 1 次布局) | ~1-3ms/次 commit | `reconciler.ts:279` → `ink.tsx:323` |
|
||||||
| C3 | **MessageRow 挂载成本 ~1.5ms**(但 Markdown 解析仅占 1-7%,主因是 React/Yoga/Ink 管线开销 ~1.3ms) | 已有 SLIDE_STEP=25 + useDeferredValue 限速 | `useVirtualScroll.ts` + `Markdown.tsx` |
|
| C3 | **MessageRow 挂载成本 ~1.5ms**(Markdown 解析仅占 1-7%,主因是 React/Yoga/Ink 管线开销 ~1.3ms) | 已有 SLIDE_STEP=25 + useDeferredValue 限速 | `useVirtualScroll.ts` + `Markdown.tsx` |
|
||||||
| C4 | **布局偏移触发全屏 damage** | O(rows×cols) 全量 diff | `ink.tsx:655-661` |
|
| C4 | **布局偏移触发全屏 damage** | O(rows×cols) 全量 diff | `ink.tsx:655-661` |
|
||||||
| C7 | **CompanionSprite TICK_MS 定时器**(500ms,每秒 2 次 setState) | 高频 setState 触发渲染 | `buddy/CompanionSprite.tsx:15,136` |
|
| C7 | **CompanionSprite TICK_MS 定时器**(500ms→已修复为 1000ms) | 高频 setState 触发渲染 | `buddy/CompanionSprite.tsx:15,136` |
|
||||||
| C9 | 同步 fs 操作 | 阻塞主线程 | `projectOnboardingState.ts:20` 等 |
|
| C9 | 同步 fs 操作 | 阻塞主线程 | `projectOnboardingState.ts:20` 等 |
|
||||||
|
|
||||||
### 已否认
|
### 已否认
|
||||||
@@ -158,7 +115,7 @@
|
|||||||
- **Yoga 无增量布局** — 实测增量更新高效(1000 节点树改 1 叶子 → 仅 2 次 measure,其余走缓存)
|
- **Yoga 无增量布局** — 实测增量更新高效(1000 节点树改 1 叶子 → 仅 2 次 measure,其余走缓存)
|
||||||
- **Ink Yoga 2^depth 问题** — 实测 100 节点深链 = 11.7x 访问(线性增长,非指数级)
|
- **Ink Yoga 2^depth 问题** — 实测 100 节点深链 = 11.7x 访问(线性增长,非指数级)
|
||||||
|
|
||||||
### 已确认的优化措施(已有)
|
### 已有优化措施
|
||||||
|
|
||||||
- React ConcurrentRoot 自动批处理 setState(多个 setState → 1 次 commit)
|
- React ConcurrentRoot 自动批处理 setState(多个 setState → 1 次 commit)
|
||||||
- Ink 帧率限制 16ms(throttle 仅限终端输出,Yoga 布局无 throttle 但被 React batching 保护)
|
- Ink 帧率限制 16ms(throttle 仅限终端输出,Yoga 布局无 throttle 但被 React batching 保护)
|
||||||
@@ -168,50 +125,76 @@
|
|||||||
- 双缓冲 + damage tracking + 字符池复用
|
- 双缓冲 + damage tracking + 字符池复用
|
||||||
- Pool 5 分钟周期重置
|
- Pool 5 分钟周期重置
|
||||||
|
|
||||||
## 已否认
|
## 已否认(内存,4 轮汇总)
|
||||||
|
|
||||||
- VSZ 516 GB 是虚拟地址映射非物理内存
|
- VSZ 516 GB 是虚拟映射非物理 | Zod Schema ~650KB | Markdown LRU-500 已优化
|
||||||
- RSS 波动是正常 GC 行为
|
- useSkillsChange/useSettingsChange — 正确 cleanup | useInboxPoller — 收敛设计
|
||||||
- useSkillsChange / useSettingsChange 订阅泄漏 — 验证为正确的 React cleanup 模式
|
- React Compiler `_c(N)` — 未使用 | File watchers — 仅 ~5KB | React reconciler — WeakMap + freeRecursive
|
||||||
- Zod Schema 开销 — 仅 ~200-650KB,已有 lazySchema + WeakMap 缓存
|
- Ink 屏幕缓冲 ~86KB | CharPool/HyperlinkPool ~1-5MB 且 5min 重置 | StylePool 缓存 1000 上限
|
||||||
- Ink ClockContext 16ms 定时器 — 影响 CPU 不影响内存
|
- 依赖树 — AWS/Google/Azure SDK 均动态 import,不贡献基线 | Sentry 空实现
|
||||||
|
- Ink 无 scrollback 缓冲 | Markdown tokenCache LRU-500 bounded
|
||||||
|
|
||||||
## 结论
|
## 结论
|
||||||
|
|
||||||
**内存根因**:消息数组多重拷贝 + JSC/mimalloc 不归还内存。典型 700 MB,最差 1.8 GB。
|
**内存根因**(4 轮迭代确认):消息数组 turn 尾部 3-4 次同时驻留 + compact 峰值窗口 + 虚拟滚动 200 组件 ~50MB 常驻 + Bun/JSC 不归还内存页。
|
||||||
|
|
||||||
**CPU 根因**:useInboxPoller 每秒轮询触发 React commit → 全量 Yoga 布局 → 全屏 Ink diff 的完整管线。Markdown 渲染(~1.5ms/行)在批量挂载新消息时造成 ~290ms 卡顿。这两者叠加:轮询导致的周期性 commit 与消息挂载的 CPU 密集操作互相放大。
|
**CPU 根因**:useInboxPoller 每秒轮询触发 React commit → 全量 Yoga 布局 → 全屏 Ink diff 的完整管线。Markdown 渲染(~1.5ms/行)在批量挂载新消息时造成 ~290ms 卡顿。轮询导致的周期性 commit 与消息挂载的 CPU 密集操作互相放大。
|
||||||
|
|
||||||
## 建议
|
**Round 4 最终验证**:agent 递归 spread 和 attachment 累积均为已知 P0(消息数组拷贝)的变体,无新根因。Snipping 在流式前执行无并发问题。consumedCommandUuids 等数组每轮重置无累积。
|
||||||
|
|
||||||
### P0:消息数组拷贝(降 100-200 MB 内存)
|
**预估优化空间**:
|
||||||
|
|
||||||
1. `query.ts:491` — applyToolResultBudget 按需拷贝
|
| 优先级 | 措施 | 预估降低 |
|
||||||
2. `query.ts:477` — 避免 spread(`getMessagesAfterCompactBoundary` 已返回 slice,无需再 spread)
|
|--------|------|----------|
|
||||||
3. `query.ts:1878` — 追加而非重建(用 `push` 替代 `[...prev, ...new]`)
|
| P0 | 消息数组拷贝优化 7 处 | 100-200 MB |
|
||||||
4. `query.ts:1135,1745` — read-only 场景传引用而非 spread
|
| P0 | Compact 峰值管理 3 项 | 20-80 MB |
|
||||||
|
| P1 | 虚拟滚动优化 | 20-30 MB |
|
||||||
|
| P1 | 缓冲与缓存清理 5 项 | 30-80 MB |
|
||||||
|
| P2 | 其他 3 项 | 10-50 MB |
|
||||||
|
| **合计** | **18 项可操作建议** | **180-440 MB** |
|
||||||
|
|
||||||
### P1:渲染管线(降 50-150 MB + 降低 CPU)
|
理论可从当前 400-700 MB 降至 **200-350 MB**。
|
||||||
|
|
||||||
1. `Messages.tsx:521-601` — 合并主转换 useMemo 中的 14 次遍历为单次 pass
|
## 建议(按优先级)
|
||||||
2. `HighlightedCode.tsx:32-41` — ColorFile 实例级 LRU(50 条)或 WeakMap 跨实例复用
|
|
||||||
3. `buddy/CompanionSprite.tsx:15` — TICK_MS 从 500ms 评估能否提升至 1000ms+
|
### P0:消息数组拷贝(预估降 100-200 MB)
|
||||||
|
|
||||||
|
1. `query.ts:477` — 去掉 spread
|
||||||
|
2. `query.ts:1878` — 改 push 追加
|
||||||
|
3. `query.ts:1135` — 传引用
|
||||||
|
4. `query.ts:1745` — 传多参数
|
||||||
|
5. `query.ts:1857` — 传引用(forkContextMessages)
|
||||||
|
6. `query.ts:491` — 无超限返回原数组
|
||||||
|
|
||||||
|
### P0:Compact 峰值(预估降 20-80 MB)
|
||||||
|
|
||||||
|
7. `compact.ts:543` 后 `preCompactReadFileState = undefined`
|
||||||
|
8. `compact.ts:651` 后 `summaryResponse = undefined`
|
||||||
|
9. 延迟非关键 attachment 生成
|
||||||
|
|
||||||
|
### P1:渲染与缓存(预估降 50-110 MB)
|
||||||
|
|
||||||
|
10. 虚拟滚动 — 降低 OVERSCAN_ROWS 或 MAX_MOUNTED_ITEMS
|
||||||
|
11. `lastAPIRequestMessages` — 非 debug 清空
|
||||||
|
12. MCP Tool Schema — 去掉 manager 层 toolsCache
|
||||||
|
13. `HybridTransport` — maxQueueSize 100K→10K
|
||||||
|
14. `bootstrap/state.ts` — 无界 Map 加 LRU
|
||||||
|
|
||||||
|
### P2:其他(预估降 10-50 MB)
|
||||||
|
|
||||||
|
15. `toolResultStorage.ts` — seenIds/replacements 定期清理
|
||||||
|
16. Session 恢复流式 JSONL | AppState 增量更新
|
||||||
|
17. Thinking 文本截断策略(保留前 N + 后 N 字符)
|
||||||
|
18. `Bun.gc(true)` 低内存触发
|
||||||
|
|
||||||
### P2:Ink 渲染层(降低 CPU 开销)
|
### P2:Ink 渲染层(降低 CPU 开销)
|
||||||
|
|
||||||
1. `ink.tsx:655-661` — 布局偏移时尝试增量 damage 而非全屏 `{x:0,y:0,width:full,height:full}`
|
19. `ink.tsx:655-661` — 布局偏移时尝试增量 damage 而非全屏 `{x:0,y:0,width:full,height:full}`
|
||||||
|
|
||||||
### P3:内存 + 低优先级
|
## 附录
|
||||||
|
|
||||||
1. `lastAPIRequestMessages` — 非 debug 清空
|
- 合并来源:`docs/performance-reporter.md`(7 轮调研,含 CPU/渲染热点详细验证)
|
||||||
2. `claude.ts:2147-2228` — 4 处流式 `+=` 改数组累积后 `join('')`
|
- 修复 commit:`ab0bbbc4`(compact 清理)、`ef10ad28`(峰值优化 -100-300MB)
|
||||||
3. `mcp-client/connection.ts:117` — stderr 缓冲从 64MB 降至 8MB
|
- Round 2 新发现:HybridTransport 缓冲、React messagesRef 双重引用、toolResultStorage 无界增长
|
||||||
4. Session 恢复中小文件也使用流式解析
|
- Round 3 新发现:虚拟滚动 ~50MB 常驻、第 7-8 次 spread(query.ts:1857)、流式 contentBlocks thinking 累积、依赖树已懒加载
|
||||||
5. BashTool `MAX_STRING_LENGTH` 从 32MB 降至 2MB
|
- Round 4 最终验证:无新根因(agent spread 和 attachment 累积为已知变体),调研终止
|
||||||
6. MCP Tool Schema 消除双重存储(只保留 AppState 一份)
|
|
||||||
7. Ink StylePool 加 LRU 淘汰(如 1000 条目上限)
|
|
||||||
8. Transcript 写入队列加 maxQueueSize 限制
|
|
||||||
9. OpenTelemetry 统一版本
|
|
||||||
10. AppState 无界集合加淘汰策略
|
|
||||||
11. GrepTool 先 limit 再 stat+sort
|
|
||||||
12. 评估 `Bun.gc(true)` 强制 GC
|
|
||||||
|
|||||||
@@ -1463,6 +1463,16 @@ export function getPlanSlugCache(): Map<string, string> {
|
|||||||
return STATE.planSlugCache
|
return STATE.planSlugCache
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setPlanSlugCacheEntry(sessionId: string, slug: string): void {
|
||||||
|
if (STATE.planSlugCache.size >= 50) {
|
||||||
|
const firstKey = STATE.planSlugCache.keys().next().value
|
||||||
|
if (firstKey !== undefined) {
|
||||||
|
STATE.planSlugCache.delete(firstKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
STATE.planSlugCache.set(sessionId, slug)
|
||||||
|
}
|
||||||
|
|
||||||
export function getSessionCreatedTeams(): Set<string> {
|
export function getSessionCreatedTeams(): Set<string> {
|
||||||
return STATE.sessionCreatedTeams
|
return STATE.sessionCreatedTeams
|
||||||
}
|
}
|
||||||
@@ -1640,6 +1650,12 @@ export function setSystemPromptSectionCacheEntry(
|
|||||||
name: string,
|
name: string,
|
||||||
value: string | null,
|
value: string | null,
|
||||||
): void {
|
): void {
|
||||||
|
if (STATE.systemPromptSectionCache.size >= 100) {
|
||||||
|
const firstKey = STATE.systemPromptSectionCache.keys().next().value
|
||||||
|
if (firstKey !== undefined) {
|
||||||
|
STATE.systemPromptSectionCache.delete(firstKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
STATE.systemPromptSectionCache.set(name, value)
|
STATE.systemPromptSectionCache.set(name, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -551,9 +551,19 @@ export async function runHeadless(
|
|||||||
proactiveModule.activateProactive('command')
|
proactiveModule.activateProactive('command')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Periodically force a full GC to keep memory usage in check
|
// Periodically run GC to keep memory usage in check.
|
||||||
|
// Uses a memory threshold to trigger a forced (major) GC when RSS grows
|
||||||
|
// beyond 350MB — the incremental GC may not reclaim enough during peaks
|
||||||
|
// (compact, long sessions with many mounted DOM nodes).
|
||||||
if (typeof Bun !== 'undefined') {
|
if (typeof Bun !== 'undefined') {
|
||||||
const gcTimer = setInterval(Bun.gc, 1000)
|
const gcTimer = setInterval(() => {
|
||||||
|
const rss = process.memoryUsage.rss()
|
||||||
|
if (rss > 350 * 1024 * 1024) {
|
||||||
|
Bun.gc(true)
|
||||||
|
} else {
|
||||||
|
Bun.gc(false)
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
gcTimer.unref()
|
gcTimer.unref()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export class HybridTransport extends WebSocketTransport {
|
|||||||
// SerialBatchEventUploader backpressure check). So set it high enough
|
// SerialBatchEventUploader backpressure check). So set it high enough
|
||||||
// to be a memory bound only. Wire real backpressure in a follow-up
|
// to be a memory bound only. Wire real backpressure in a follow-up
|
||||||
// once callers await.
|
// once callers await.
|
||||||
maxQueueSize: 100_000,
|
maxQueueSize: 10_000,
|
||||||
baseDelayMs: 500,
|
baseDelayMs: 500,
|
||||||
maxDelayMs: 8000,
|
maxDelayMs: 8000,
|
||||||
jitterMs: 1000,
|
jitterMs: 1000,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const DEFAULT_ESTIMATE = 3
|
|||||||
* Extra rows rendered above and below the viewport. Generous because real
|
* Extra rows rendered above and below the viewport. Generous because real
|
||||||
* heights can be 10x the estimate for long tool results.
|
* heights can be 10x the estimate for long tool results.
|
||||||
*/
|
*/
|
||||||
const OVERSCAN_ROWS = 80
|
const OVERSCAN_ROWS = 40
|
||||||
/** Items rendered before the ScrollBox has laid out (viewportHeight=0). */
|
/** Items rendered before the ScrollBox has laid out (viewportHeight=0). */
|
||||||
const COLD_START_COUNT = 30
|
const COLD_START_COUNT = 30
|
||||||
/**
|
/**
|
||||||
@@ -43,7 +43,7 @@ const SCROLL_QUANTUM = OVERSCAN_ROWS >> 1
|
|||||||
*/
|
*/
|
||||||
const PESSIMISTIC_HEIGHT = 1
|
const PESSIMISTIC_HEIGHT = 1
|
||||||
/** Cap on mounted items to bound fiber allocation even in degenerate cases. */
|
/** Cap on mounted items to bound fiber allocation even in degenerate cases. */
|
||||||
const MAX_MOUNTED_ITEMS = 300
|
const MAX_MOUNTED_ITEMS = 200
|
||||||
/**
|
/**
|
||||||
* Max NEW items to mount in a single commit. Scrolling into a fresh range
|
* Max NEW items to mount in a single commit. Scrolling into a fresh range
|
||||||
* with PESSIMISTIC_HEIGHT=1 would mount 194 items at once (OVERSCAN_ROWS*2+
|
* with PESSIMISTIC_HEIGHT=1 would mount 194 items at once (OVERSCAN_ROWS*2+
|
||||||
|
|||||||
@@ -521,7 +521,7 @@ export async function compactConversation(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Store the current file state before clearing
|
// Store the current file state before clearing
|
||||||
const preCompactReadFileState = cacheToObject(context.readFileState)
|
let preCompactReadFileState = cacheToObject(context.readFileState)
|
||||||
|
|
||||||
// Clear the cache
|
// Clear the cache
|
||||||
context.readFileState.clear()
|
context.readFileState.clear()
|
||||||
@@ -543,6 +543,9 @@ export async function compactConversation(
|
|||||||
),
|
),
|
||||||
createAsyncAgentAttachmentsIfNeeded(context),
|
createAsyncAgentAttachmentsIfNeeded(context),
|
||||||
])
|
])
|
||||||
|
// Release the readFileState snapshot — it can hold 25+ MB of file content
|
||||||
|
preCompactReadFileState =
|
||||||
|
undefined as unknown as typeof preCompactReadFileState
|
||||||
|
|
||||||
const postCompactFileAttachments: AttachmentMessage[] = [
|
const postCompactFileAttachments: AttachmentMessage[] = [
|
||||||
...fileAttachments,
|
...fileAttachments,
|
||||||
@@ -649,6 +652,8 @@ export async function compactConversation(
|
|||||||
|
|
||||||
// Extract compaction API usage metrics
|
// Extract compaction API usage metrics
|
||||||
const compactionUsage = getTokenUsage(summaryResponse)
|
const compactionUsage = getTokenUsage(summaryResponse)
|
||||||
|
// Release the full API response — it holds content blocks + usage metadata
|
||||||
|
summaryResponse = undefined as unknown as typeof summaryResponse
|
||||||
|
|
||||||
const querySourceForEvent =
|
const querySourceForEvent =
|
||||||
recompactionInfo?.querySource ?? context.options.querySource ?? 'unknown'
|
recompactionInfo?.querySource ?? context.options.querySource ?? 'unknown'
|
||||||
@@ -922,7 +927,7 @@ export async function partialCompactConversation(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Store the current file state before clearing
|
// Store the current file state before clearing
|
||||||
const preCompactReadFileState = cacheToObject(context.readFileState)
|
let preCompactReadFileState = cacheToObject(context.readFileState)
|
||||||
context.readFileState.clear()
|
context.readFileState.clear()
|
||||||
context.loadedNestedMemoryPaths?.clear()
|
context.loadedNestedMemoryPaths?.clear()
|
||||||
// Intentionally NOT resetting sentSkillNames — see compactConversation()
|
// Intentionally NOT resetting sentSkillNames — see compactConversation()
|
||||||
@@ -937,6 +942,9 @@ export async function partialCompactConversation(
|
|||||||
),
|
),
|
||||||
createAsyncAgentAttachmentsIfNeeded(context),
|
createAsyncAgentAttachmentsIfNeeded(context),
|
||||||
])
|
])
|
||||||
|
// Release the readFileState snapshot — it can hold 25+ MB of file content
|
||||||
|
preCompactReadFileState =
|
||||||
|
undefined as unknown as typeof preCompactReadFileState
|
||||||
|
|
||||||
const postCompactFileAttachments: AttachmentMessage[] = [
|
const postCompactFileAttachments: AttachmentMessage[] = [
|
||||||
...fileAttachments,
|
...fileAttachments,
|
||||||
@@ -992,6 +1000,8 @@ export async function partialCompactConversation(
|
|||||||
summaryResponse,
|
summaryResponse,
|
||||||
])
|
])
|
||||||
const compactionUsage = getTokenUsage(summaryResponse)
|
const compactionUsage = getTokenUsage(summaryResponse)
|
||||||
|
// Release the full API response — it holds content blocks + usage metadata
|
||||||
|
summaryResponse = undefined as unknown as typeof summaryResponse
|
||||||
|
|
||||||
logEvent('tengu_partial_compact', {
|
logEvent('tengu_partial_compact', {
|
||||||
preCompactTokenCount,
|
preCompactTokenCount,
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ import type {
|
|||||||
SystemFileSnapshotMessage,
|
SystemFileSnapshotMessage,
|
||||||
UserMessage,
|
UserMessage,
|
||||||
} from 'src/types/message.js'
|
} from 'src/types/message.js'
|
||||||
import { getPlanSlugCache, getSessionId } from '../bootstrap/state.js'
|
import {
|
||||||
|
getPlanSlugCache,
|
||||||
|
getSessionId,
|
||||||
|
setPlanSlugCacheEntry,
|
||||||
|
} from '../bootstrap/state.js'
|
||||||
import { EXIT_PLAN_MODE_V2_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/ExitPlanModeTool/constants.js'
|
import { EXIT_PLAN_MODE_V2_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/ExitPlanModeTool/constants.js'
|
||||||
import { getCwd } from './cwd.js'
|
import { getCwd } from './cwd.js'
|
||||||
import { logForDebugging } from './debug.js'
|
import { logForDebugging } from './debug.js'
|
||||||
@@ -43,7 +47,7 @@ export function getPlanSlug(sessionId?: SessionId): string {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cache.set(id, slug!)
|
setPlanSlugCacheEntry(id, slug!)
|
||||||
}
|
}
|
||||||
return slug!
|
return slug!
|
||||||
}
|
}
|
||||||
@@ -52,7 +56,7 @@ export function getPlanSlug(sessionId?: SessionId): string {
|
|||||||
* Set a specific plan slug for a session (used when resuming a session)
|
* Set a specific plan slug for a session (used when resuming a session)
|
||||||
*/
|
*/
|
||||||
export function setPlanSlug(sessionId: SessionId, slug: string): void {
|
export function setPlanSlug(sessionId: SessionId, slug: string): void {
|
||||||
getPlanSlugCache().set(sessionId, slug)
|
setPlanSlugCacheEntry(sessionId, slug)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -44,9 +44,13 @@ export function maybeGenerateTaskSummary(
|
|||||||
if (!messages || messages.length === 0) return
|
if (!messages || messages.length === 0) return
|
||||||
|
|
||||||
// Extract a short status from the most recent assistant message
|
// Extract a short status from the most recent assistant message
|
||||||
const lastAssistant = [...messages]
|
let lastAssistant: (typeof messages)[0] | undefined
|
||||||
.reverse()
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
.find(m => m.type === 'assistant')
|
if (messages[i]!.type === 'assistant') {
|
||||||
|
lastAssistant = messages[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let status: 'busy' | 'idle' = 'busy'
|
let status: 'busy' | 'idle' = 'busy'
|
||||||
let waitingFor: string | undefined
|
let waitingFor: string | undefined
|
||||||
|
|||||||
Reference in New Issue
Block a user