CodeGraph 原生提取内核设计:从「每节点一次 JS↔WASM 编组」到「每文件一次边界穿越」
CodeGraph 在 2026-07 的一次性能弧(arc)之后,把「全量索引解析阶段」的最后一道结构杠杆锁定为原生提取内核(native extraction kernel):用一个 napi-rs 编写的 Rust crate 在原生侧完成 tree-sitter 的 parse + AST 遍历,每个文件只穿越一次 JS 边界,替代原先「每个 AST 节点都要过一次 JS↔WASM 编组」的 wasm 提取路径。本文以设计文档 native-extraction-kernel.md 的骨架为主线——为什么做、spike 验证数据、架构决策、等价性门禁、非目标与风险——并结合仓库中已落地的 codegraph-kernel crate、TS 侧加载/解码/路由层(src/extraction/kernel/)与 parity 工具链,完整还原「设计如何变成实现」。读完本文,你将掌握:该内核的字节级线上契约(wire contract)、每语言回退/路由策略、defer: 降级信号与深嵌套栈护栏机制,以及用于验证内核与 wasm 行为等价的 parity 门禁的完整方法。
一、背景动机:解析阶段是 CPU 瓶颈,而地板是「逐节点编组」
设计文档给出的出发点是 dubbo 仓库(4,402 个 Java 文件)全量索引的 profile 数据:
| 阶段 | 耗时 |
|---|---|
| parse-loop | ~4.7s |
| resolution | ~5.5s(persist-bound) |
| synthesis | ~0.9s |
| 合计 | ~11.1s(对比 codebase-memory-mcp v0.9.0 为 7.1s) |
在设计之前,团队实测排除了两个「看起来更简单」的杠杆:
- RAM-backed DB(ramdisk 上的数据库):ramdisk 上 parse-loop 6.9s,SSD 上 4.6–4.8s(n=2 交错测量)——因为 fast-init 已用
synchronous=OFF以 page-cache 速度写入,解析阶段是 CPU-bound,不是 IO-bound; - 重写 TreeCursor(更早的 arc):web-tree-sitter 的遍历本身不是成本所在,真正的地板是每节点一次的 JS↔WASM marshaling——每个
node.kind、.childForFieldName、.text访问都要跨越一次边界。
由此得出唯一剩下的解析杠杆:把遍历挪到原生侧,每文件只穿越一次边界,而不是每节点一次。这也是整个内核设计的核心约束,后文所有架构决策(平铺 buffer、字节级契约、单点回退)都服务于这一条。
二、Spike 验证(2026-07-16):原生单线程击败 7 worker wasm 池
设计文档记录了一个最小 Rust spike 二进制:tree-sitter 0.25 + tree-sitter-java,TreeCursor 遍历触摸每个节点的 kind/range 加 name 字段文本,输出平铺的 (kind_id, start, end, name_len) 行——即真实提取路径的访问模式。测试对象是 dubbo 的 4,048 个 .java 文件(17MB,3.59M 个 AST 节点,Apple M3 Pro):
| 方案 | wall time |
|---|---|
| 现流水线 parse-loop(7 个 wasm worker,含提取 + store 调度) | 4,700ms |
| Rust parse+walk,rayon 并行 | 202ms |
| Rust parse+walk,单线程 | 1,067ms |
两个关键结论:
- 一个原生线程就比整个 7-worker wasm 池快 4.4×;同等并行度下遍历快约 14×;
- 即使把「内核还必须执行的提取逻辑」计算在内,parse-loop 从 4.7s 降到 ~1.0–1.5s 是现实的,dubbo 总耗时可降到 ≈7.5–8s,与对比基线持平。
Spike 在 2026-07-16 验证通过、项目获批(文档标注 "spike validated, project approved")。
三、总体架构:crate + napi-rs + 平铺类型化 buffer
文档的架构四要素,以及仓库中对应的落地形态:
3.1 Crate 与调用契约
文档规定:crate 名为 codegraph-kernel,基于 napi-rs,原生链接 tree-sitter 的 C 库和 vendored grammars;输入 (filePath, content, language),输出平铺类型化 buffer(nodes、edges、unresolved refs),每文件一次边界穿越。
仓库中 codegraph-kernel/Cargo.toml 确认了这条路线:crate-type = ["cdylib"],依赖 napi 3、tree-sitter 0.25,release profile 开启 lto = true、codegen-units = 1、strip = "symbols"。入口函数在 codegraph-kernel/src/lib.rs 中:
#[napi]
pub fn extract_file(file_path: String, content: String, language: String) -> Result<ExtractBuffers>
它在一个 match 中按语言分派到各 walker 模块(java::extract、python::extract、go::extract、ccpp::extract、tsjs::extract 等 20 种语言,见 codegraph-kernel/src/langs.rs 中的 LANGUAGES 表),返回 ExtractBuffers { meta, nodes, edges, refs, arena } 五个 Buffer。
注意 lib.rs 头部注释对并行模型的明确约束:调用是同步的,刻意不在 Rust 侧重建线程池——现有 ParseWorkerPool 的 worker 已经按文件并行,每个 worker 线程自己驱动一次内核调用。这正对应文档「Risks」一节中 "napi-rs threading vs the parse-pool" 的决策:内核只替换 wasm worker 的 parse+extract 部分,池编排(文件顺序提交、重试、回收)留在 TS 侧。
3.2 五个平铺 buffer 的字节布局
「每文件一次边界穿越」能否成立,取决于返回物是否足够紧凑。内核返回 5 个 buffer:meta、nodes、edges、refs、arena。所有行定宽小端;字符串一律是 arena 中的 (offset, len) 对(UTF-8 arena),offset == 0xFFFFFFFF(NONE)表示「字段缺失」。布局在 Rust 侧 codegraph-kernel/src/buffers.rs 与 TS 侧 src/extraction/kernel/layout.ts 中逐字节镜像(两侧文件头都注明 "MUST MATCH BYTE FOR BYTE"):
| buffer | 大小 | 内容 |
|---|---|---|
| meta | 36 字节 | ABI 版本(u8)、node/edge/ref 计数、arena 长度、errors-JSON 在 arena 中的 (offset,len)、kernel 侧耗时 f64 |
| node 行 | 96 字节 | kind 索引(u8)、visibility(u8)、bool 标志位对(u16:isExported/isAsync/isStatic/isAbstract 的 (present,value) 位对)、起止行/列、name、qualifiedName、id(内核自算,形如 kind:hash32)、docstring、signature、decorators(NUL 连接)、typeParameters、returnType、extraJson(逃生舱:任意附加 Node 属性的 JSON)、metrics 预留槽 |
| edge 行 | 44 字节 | source/target 行索引(NONE 时退回字符串 id)、kind 索引、provenance(0 absent / 1 tree-sitter / 2 scip / 3 heuristic)、line/column、metadataJson |
| ref 行 | 40 字节 | fromIdx、kind(EDGE_KINDS 索引,或 200 = 内部专用的 function_ref)、flags(v2 起 bit0 = 该 ref 携带提取文件自身路径,ruby/php 的 mixin/trait implements ref 用)、line/column、referenceName、candidates、fromIdStr |
| arena | 变长 | 所有字符串原文,不 intern |
其中 kind 字段是 NODE_KINDS(23 个,file/module/class/…/union)与 EDGE_KINDS(12 个:contains、calls、imports、exports、extends、implements、references、type_of、returns、instantiates、overrides、decorates)的数组下标——两侧数组顺序就是线上契约的一部分,「可追加、不可重排」。TS 侧解码在 src/extraction/kernel/decode.ts 的 decodeExtractBuffers() 中完成,把平铺行还原成与 wasm 提取路径完全同构的 ExtractionResult(nodes/edges/unresolvedReferences/errors),下游 store 无感。
3.3 版本与契约校验:过时的 .node 只会「降级」,不会「错解」
TS 加载器 src/extraction/kernel/loader.ts 的关键设计是内核处处可选:找不到本平台二进制、dlopen 失败、ABI/kind 表不匹配,一切故障模式都解析为 null,提取路径静默继续使用 wasm 流水线——「缺失或过期的内核永远不能弄坏索引,只能跳过加速」。加载成功后 verifyContract() 会比对三样东西:abiVersion 与 TS 侧 KERNEL_ABI_VERSION(当前为 2)一致、nodeKinds/edgeKinds 表与 src/types.ts 中的 NODE_KINDS/EDGE_KINDS 逐字节相同、必需导出 extractFile/contractInfo 存在。
搜索顺序(candidatePaths()):
CODEGRAPH_KERNEL_PATH— 显式.node路径(开发/测试覆盖);<package根>/kernel/codegraph-kernel.node— 发布 bundle 布局;<package根>/codegraph-kernel/prebuilds/<platform>-<arch>/codegraph-kernel.node— 源码构建与测试(staged byscripts/build-kernel.sh)。
这正是文档「Distribution」一节的落地:预构建的 per-platform .node 走既有 release-bundle 管线(与 Node runtime 相同的 per-platform packages)。
四、最大风险之一被正面处理:native grammar 与 wasm grammar 的 ABI 漂移
文档「Risks」第一条:vendored 原生 grammars 与 wasm fallback grammars 之间的 ABI 漂移(对策:两者必须从同一 grammar source rev 构建,CI 断言)。仓库中这个对策落实为两层机制:
1. Cargo 依赖精确钉死 + 逐条注释。 codegraph-kernel/Cargo.toml 中每个 grammar 依赖旁都写着与 wasm 侧的对应关系,例如 tree-sitter-c = "=0.24.2"(vendored wasm 由该 tag 的 checked-in parser.c 构建、sha 匹配)、tree-sitter-php = "=0.24.2"(walker 调用 LANGUAGE_PHP 完整 HTML 交叠变体,永远不用 LANGUAGE_PHP_ONLY,否则遇到前置 HTML 会报错)、tree-sitter-swift = "=0.7.3"(parser.c 约 20MB 生成代码,预期编译缓慢)。[lib.rs] 的 grammar_info() 导出则把每个 native grammar 的 abi_version、node-kind 表、field 表暴露给 TS 侧,供 parity 测试逐表比对。
2. grammar parity 测试。 tests/kernel-grammar-parity.test.ts 在测试时断言每个语言条目的 native grammar 与 vendored wasm grammar 的 node-kind/field 表相等——Cargo.toml 顶部注释说得直接:"kernel-grammar-parity test asserts node-kind-table equality at test time; bump these together with the wasm side or that gate fails"。此外还有四个 crates.io 无对应版本的语言(kotlin、lua、scala、dart)走 vendored C + build.rs 编译的路径,见 codegraph-kernel/grammars/ 下各语言的 parser.c/scanner.c,并在 codegraph-kernel/src/langs.rs 里用 tree-sitter-language 的 LanguageFn::from_raw 接入。
五、每语言提取逻辑:从 .scm 查询计划到「按语言 walker + TS pre/post pass」
文档的原方案是:把提取器迁移到 tree-sitter 查询文件(.scm),由通用 Rust emitter 执行;查询表达不了的定制 TS 逻辑(macro salvage、dialect sniffing、content-gated .h 检测)留在 TS 侧作为对返回 buffer 的 pre/post pass。
实际实现演进了一档,仓库里留下了明确足迹:codegraph-kernel/src/langs.rs 的模块注释写道 "R1 shipped a generic .scm-query emitter here; R2 replaced it with the bespoke per-language walker — see tsjs/ and the migration plan §3a — because extraction parity needs logic queries can't express"。也就是说,spike 后团队确认提取等价性所需的逻辑超出了查询表达能力,最终形态是每语言一个专属 walker 模块(tsjs/、java.rs、python.rs、go.rs、ccpp/、csharp.rs、ruby.rs、php.rs、swift.rs、kotlin.rs、r-lang、lua.rs、scala.rs、dart.rs 等,见 lib.rs 的模块清单),行为对标对应的 TS 提取器,用 parity 工具验证。
而文档说的 TS pre/post pass 也真实存在,分三段:
- preParse 提前提取(hoist):src/extraction/kernel/index.ts 的
preParsedSource()在调用内核之前应用语言提取器的preParse钩子(C/C++ 宏 blanking、csharp#if处理、metal/cuda 方言处理)。所有 blank 都是等长空格替换,行/列/偏移全部保留——这样两条路径(native 与 wasm fallback)解析的是完全相同的 blanked 字节,blanking 逻辑不需要移植 Rust; - post pass 逃生舱:
POST_PASSES表为「查询表达不了的逻辑」保留同步后处理钩子(当前为空表,注释 "none yet — R2+"),并有一条硬规则:有 post pass 的语言不走 bulk 快速路径(见 6.2 节); - per-file 安全阀:解析树里含 ERROR 的文件
defer给 wasm 提取器——UTF-8 与 UTF-16 解析的错误恢复行为不同,wasm 侧的恢复是 canonical。这是文档「.scmexpressiveness ceilings → 每语言 escape-hatch 回调」这条风险在实现层面的对应物:先降级、后补逻辑,而不是宣布语言 blocked。
六、路由、回退与降级:三层「宁可慢,不可错」
6.1 语言级路由策略
src/extraction/kernel/index.ts 中 DEFAULT_ROUTED 集合当前包含 20 种语言(typescript/tsx/javascript/jsx、java、python、go、c、cpp、rust、csharp、ruby、php、swift、kotlin、r、lua、luau、scala、dart),每个条目都注明了通过等价性门禁的证据,例如:
- R3(TS/JS 系):2026-07-16 通过,express/excalidraw/vscode 全索引 dump 逐字节相同,控制仓库无变化;
- R7a(C/C++):redis/git/fmt/protobuf/ALS 共 2,389 文件 0-diff;含错误文件按策略逐文件 defer(macro-heavy C/C++ 在 git 19%、protobuf 26%、fmt 42% 的比例上真实产生解析错误);
- R7b 各批次:rust(ripgrep/tokio/rust-analyzer 2,108 文件)、csharp(serilog/Newtonsoft.Json/jellyfin 3,229 文件)、ruby(sinatra/jekyll/rails 3,763 文件、0 deferral)、php(monolog/laravel/symfony 13,950 文件)、swift(Alamofire/vapor/swift-nio,错误率结构性 9–27% 属双臂 grammar 现实,sweep 用
--max-deferral 0.3)、kotlin、r、lua/luau、scala、dart 等,均附带各自的 deferral 基线——"deferral 率突增才是 bug 信号"。
这与文档「Rollout: per-language, funnel languages first (TS/JS → Java → Python → Go). A language ships only when its equivalence gate passes」完全一致,且回退粒度同样是 per-language:从 DEFAULT_ROUTED 移除即整体回退。
运行时控制开关:
| 环境变量 | 作用 |
|---|---|
CODEGRAPH_KERNEL=0 |
总开关(kill switch),每次调用时检查,一切走 wasm |
CODEGRAPH_KERNEL_LANGS=<langs|all> |
替换默认路由集(逗号分隔) |
CODEGRAPH_KERNEL_PATH |
显式指定 .node 二进制(开发/测试) |
CODEGRAPH_KERNEL_DEBUG=1 |
输出内核为何未加载的 stderr 诊断 |
6.2 两条提取入口:解码路径与 bulk 快速路径
tryKernelExtract():提取后立即在 JS 侧解码(decodeExtractBuffers)并跑 post pass,供需要对象的路径使用(主线程 store、测试);tryKernelExtractRaw():bulk 索引快速路径——不解码,把原始平铺 buffer 随ExtractionResult.kernelBuffers直接驮到 store 边界,在 store worker 里才解码。这样主线程永不物化每节点对象,平铺 buffer 的「零拷贝优势」贯穿整条索引链;materializeKernelResult():把带 buffer 的结果还原成普通ExtractionResult的兜底。
单文件失败(非 defer: 的内核错误)也返回 null 走 wasm——「内核 bug 的代价只是那一个文件的加速,而不是那一个文件的正确性」。此外 deferSlot 单槽 memo 记住最近一次被 defer 的 (file, source, language),避免同一文件在多个 seam 上重复 blank+parse(文档时代没预料到、但实现中真实存在的性能细节:Linux kernel 这类高 deferral 树上 ~79% 文件被 defer,重复工作会主导整个 parse 阶段)。
七、深嵌套防线:原生栈溢出从「SIGSEGV」变成「defer」
设计文档没有覆盖、但实现中必须正面处理的一个问题:tree-sitter 解析器本身是迭代式的,深嵌套文件(如 clang 的 parser_overflow.c 嵌套 16,384 个 {)解析正常,但递归 walker 会溢出自带栈。原生溢出不可捕获——解析 worker 是 codegraph 进程的一条线程,SIGSEGV 会拖死整个 indexer。
codegraph-kernel/src/stack.rs(#1581)给出了优雅解法:
- 每个递归 walker 函数第一行执行
stack_guard!宏(lib.rs 顶部定义):栈指针进入线程栈限额上方 256KiB 的 RED_ZONE 时返回默认值停止下探,并闩锁 per-thread 标志; - 线程栈边界来自 OS(Linux
pthread_getattr_np、macOSpthread_get_stackaddr_np、WindowsGetCurrentThreadStackLimits),每线程计算一次;拿不到时退回固定下降预算;热路径只有一次线程局部量加载加一次比较; run_guarded()包裹整个提取:闩锁被触发则丢弃结果、返回defer:错误——而defer:恰是 TS 侧既有的「此文件走 wasm 路径」信号。wasm 侧 walker 自己逐文件捕获 JSRangeError,文件最终落成带记录解析错误的部分结果,而不是进程死亡。
测试覆盖在 tests/kernel-deep-nesting.test.ts 与 stack.rs 内置用例中:30,000 层嵌套的 C/C++/Rust/TS/Python 样本在 1MiB 小栈线程上必须返回 defer: 而非崩溃,浅层文件不受影响,闩锁在两次运行间正确复位。
八、等价性门禁(Equivalence Gate):文档三条标准 + 工具链落地
文档明确:与手写提取器的字节级一致不被期待(定制逻辑只是近似移植)。门禁是三条:
- 计数门禁:node/edge/ref 计数在 3 个真实仓库(小/中/大)上 ±0.5% 以内,且每个 diff 类别都要人工过目;
- 检索不变量:explore-flow 能端到端连通该语言的 canonical flows(方法学见 dynamic-dispatch-coverage-playbook.md),agent A/B 按标准方法学无回退;
- 性能不变量:该语言仓库的全量索引 wall time 改善,且一个未迁移语言的控制仓库上无回退。
仓库中的执行工具:
-
快内环:scripts/kernel-parity.mjs —— 对同一批文件跑两条提取路径,把每文件
ExtractionResult规范化后按集合 diff,行为缺口显示为分类 diff。用法:node scripts/kernel-parity.mjs <file-or-dir>... [--lang typescript,tsx] \ [--max-samples N] [--list-files] [--max-deferral 0.1] # 退出码:0 = parity,1 = 有 diff,2 = setup error前置条件是
npm run build(dist/)与 staged 内核(npm run build:kernel)。--max-deferral是关键校准参数:默认 0.1 是按 TS/Java/Python/Go 0–0.4% 的解析错误率标定的;C/C++ 要传 0.5(git 19%、protobuf 26%、fmt 42% 的真实错误率下按策略 defer 是正常行为),swift/scala 前沿代码用 0.3——一个坏掉的 walker 仍会在 0.5 上被抓住(它 defer 掉几乎一切)。 -
门禁外环:每语言全仓库 dump-diff 逐字节比对(
DEFAULT_ROUTED各条目的注释就是记录在案的通过证据),加上每语言一套 parity 测试(tests/kernel-tsjs-parity.test.ts、kernel-ccpp-parity.test.ts、kernel-kotlin-parity.test.ts、kernel-swift-parity.test.ts 等十余个文件)与 grammar parity 测试。迁移过程本身还有 rust-kernel-migration-plan.md 与每语言 port checklist(docs/design/ 下的*-kernel-port-checklist.md)做跟踪。
九、非目标与风险控制:文档的克制,实现的兑现
文档「Non-goals」画出了明确边界,仓库实现严格遵守:
- 不迁移 resolution、synthesis、frameworks、MCP、installer——它们是 pool-parallel 的、非 marshaling-bound,实测原生优势仅 ~1.4× CPU,不值得用正确性护城河去换(2,444 个测试、字节级确定性、多年累积的不变量)。实现层面,lib.rs 的模块头注释直接写死:"Everything downstream (resolution, synthesis, frameworks, MCP) is untouched and consumes the decoded result exactly as before";
- 不做单一静态二进制(分发层面打磨,与速度正交)。
文档三条风险的处理状态总结:
| 风险 | 文档对策 | 仓库中的兑现 |
|---|---|---|
| native/wasm grammar ABI 漂移 | 同一 source rev 构建,CI 断言 | Cargo.toml 精确钉版本 + 逐条 sha 注释;grammar_info() + kernel-grammar-parity.test.ts 表级断言;wasm 侧仍保留为 universal fallback(同一 crate 可编译到 wasm,保持单实现) |
.scm 表达力天花板 |
每语言 escape-hatch 回调 | 演进为每语言专属 walker + POST_PASSES 后处理钩子 + 解析错误文件的 per-file defer 到 wasm |
| napi-rs 线程与 parse-pool 关系 | 池编排留 TS,逐文件同步驱动 | tryKernelExtract() 同步调用;ParseWorkerPool 并行度不变;另补了栈护栏(#1581)与 deferSlot 去重 |
十、小结:一条可复制的「边界穿越」优化范式
CodeGraph 原生提取内核的完整叙事是:先用 profile 定位到真正的地板(逐节点 JS↔WASM marshaling,而非 IO、也非遍历本身),再用一个最小 spike 拿到 4.4×/14× 的硬数据,然后把「每文件一次边界穿越」落成一份字节级双镜像契约(buffers.rs ↔ layout.ts)、一套处处可选、逐级降级的加载/路由策略(per-platform .node → wasm fallback → per-file defer)、以及一条以计数等价 + 检索不变量 + 全索引 dump 逐字节 diff 为准的 per-language 门禁,让 20 种语言按证据逐个 default-on。对任何「Node + wasm 解析器」的索引类项目,这套「先证地板、再压边界、以降级保正确、以 parity 门禁管节奏」的做法都可直接参照。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00