首页
/ CodeGraph 原生提取内核设计:从「每节点一次 JS↔WASM 编组」到「每文件一次边界穿越」

CodeGraph 原生提取内核设计:从「每节点一次 JS↔WASM 编组」到「每文件一次边界穿越」

2026-09-06 12:37:30作者:温玫谨Lighthearted

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)

在设计之前,团队实测排除了两个「看起来更简单」的杠杆:

  1. 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
  2. 重写 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 = truecodegen-units = 1strip = "symbols"。入口函数在 codegraph-kernel/src/lib.rs 中:

#[napi]
pub fn extract_file(file_path: String, content: String, language: String) -> Result<ExtractBuffers>

它在一个 match 中按语言分派到各 walker 模块(java::extractpython::extractgo::extractccpp::extracttsjs::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:metanodesedgesrefsarena。所有行定宽小端;字符串一律是 arena 中的 (offset, len) 对(UTF-8 arena),offset == 0xFFFFFFFFNONE)表示「字段缺失」。布局在 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.tsdecodeExtractBuffers() 中完成,把平铺行还原成与 wasm 提取路径完全同构的 ExtractionResultnodes/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()):

  1. CODEGRAPH_KERNEL_PATH — 显式 .node 路径(开发/测试覆盖);
  2. <package根>/kernel/codegraph-kernel.node — 发布 bundle 布局;
  3. <package根>/codegraph-kernel/prebuilds/<platform>-<arch>/codegraph-kernel.node — 源码构建与测试(staged by scripts/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-languageLanguageFn::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.rspython.rsgo.rsccpp/csharp.rsruby.rsphp.rsswift.rskotlin.rsr-langlua.rsscala.rsdart.rs 等,见 lib.rs 的模块清单),行为对标对应的 TS 提取器,用 parity 工具验证。

而文档说的 TS pre/post pass 也真实存在,分三段:

  • preParse 提前提取(hoist)src/extraction/kernel/index.tspreParsedSource() 在调用内核之前应用语言提取器的 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。这是文档「.scm expressiveness ceilings → 每语言 escape-hatch 回调」这条风险在实现层面的对应物:先降级、后补逻辑,而不是宣布语言 blocked

六、路由、回退与降级:三层「宁可慢,不可错」

6.1 语言级路由策略

src/extraction/kernel/index.tsDEFAULT_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、macOS pthread_get_stackaddr_np、Windows GetCurrentThreadStackLimits),每线程计算一次;拿不到时退回固定下降预算;热路径只有一次线程局部量加载加一次比较;
  • run_guarded() 包裹整个提取:闩锁被触发则丢弃结果、返回 defer: 错误——而 defer: 恰是 TS 侧既有的「此文件走 wasm 路径」信号。wasm 侧 walker 自己逐文件捕获 JS RangeError,文件最终落成带记录解析错误的部分结果,而不是进程死亡。

测试覆盖在 tests/kernel-deep-nesting.test.tsstack.rs 内置用例中:30,000 层嵌套的 C/C++/Rust/TS/Python 样本在 1MiB 小栈线程上必须返回 defer: 而非崩溃,浅层文件不受影响,闩锁在两次运行间正确复位。

八、等价性门禁(Equivalence Gate):文档三条标准 + 工具链落地

文档明确:与手写提取器的字节级一致不被期待(定制逻辑只是近似移植)。门禁是三条:

  1. 计数门禁:node/edge/ref 计数在 3 个真实仓库(小/中/大)上 ±0.5% 以内,且每个 diff 类别都要人工过目;
  2. 检索不变量:explore-flow 能端到端连通该语言的 canonical flows(方法学见 dynamic-dispatch-coverage-playbook.md),agent A/B 按标准方法学无回退;
  3. 性能不变量:该语言仓库的全量索引 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.tskernel-ccpp-parity.test.tskernel-kotlin-parity.test.tskernel-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.rslayout.ts)、一套处处可选、逐级降级的加载/路由策略(per-platform .node → wasm fallback → per-file defer)、以及一条以计数等价 + 检索不变量 + 全索引 dump 逐字节 diff 为准的 per-language 门禁,让 20 种语言按证据逐个 default-on。对任何「Node + wasm 解析器」的索引类项目,这套「先证地板、再压边界、以降级保正确、以 parity 门禁管节奏」的做法都可直接参照。

登录后查看全文
热门项目推荐
相关项目推荐