读见

不盲信 LLM,删除 1.9 万行无用编译器代码|The Cloudlet

原题Deleting 19k Lines of Dead Compiler Code Without Trusting the LLM | The Cloudlet

约 9 分钟约 2,248 字原文英文已翻译

读前速览

5 个要点

本文介绍了一套系统化方法,通过静态分析和 AI 辅助分类,安全删除约 1.9 万行已弃用的编译器代码,同时保留其余代码库的功能。

  1. 删除代码的重要性

    作者反对「能用就别动」的心态,指出不断积累的无用代码会导致代码库混乱、维护成本上升。他还强调,AI 能高效识别依赖关系,从而降低清理成本。

  2. 明确范围

    第一步是明确弃用标志(--XYZ)影响了代码库的哪些部分。团队借助静态分析工具追踪调用图,从而集中审查相关代码。

  3. 基于风险分类

    删除不同代码的风险并不相同。作者提出了一套分类体系(机械性、跨团队所有权、副作用、精细手术),用于确定审查优先级,并为每个节点选择合适的处理方式。

  4. 可靠的测试

    为确保删除安全,流程要求针对每个类别编写专门测试。删除测试用于确认代码确实已被移除,功能测试则确保剩余代码的行为与变更前一致。

  5. 删除后的清理

    核心代码删除后,最后一步是清理为已移除功能设计的遗留接口和数据结构,包括合并枚举、删除错误处理路径并简化 API。

完整摘要

作者介绍了清理大型代码库的多步骤流程:移除一项已停止维护的功能(flow X)。整个策略包括明确受影响代码的范围、按风险等级对节点分类、搭建可靠的测试框架、执行实际删除,最后清理遗留接口。最终,代码规模和二进制文件体积都显著缩小,且没有报告回归问题。

目录 · 10 节

登录后请求翻译正文尚未译完,可继续翻译
查看原文
October 16, 2026 [software-engineering] #cpp #refactor #mlir #compiler #ai #refactor-with-ai On this page
  1. 为什么会跳过删除操作
  2. 问题
  3. 步骤 1:界定范围
  4. 步骤 2:对每个节点进行分类
  5. 步骤 3:搭建框架
  6. 步骤 4:真正的删除与外科手术般的精简
  7. 第 5 步:术后清理
  8. 结果
  9. 要点
  10. 下一步

这就是我如何在大约一周内删掉约 1.9 万行代码的。一个月后:没有回滚,也没有补救提交。This is how I deleted ~19k lines of code in about a week. A month later: no reverts, no fix-up commits.

为什么会跳过删除操作Why deletion gets skipped

代码不断堆积,却没人清理。在我之前的一份工作中,一位资深工程师建议我别碰那些耦合在一起的意大利面式代码,原因有二。第一,清理代码不算工作:没人看得见,也没人会为此给你奖励。第二,根据他的经验,这类重构最终都会酿成灾难,最后总有人把代码还原到最初的状态。Code accumulates, and nobody removes it. A staff engineer at a previous job advised me to leave coupled spaghetti alone, for two reasons. First, cleanup didn't count as work: nobody saw it, and nobody rewarded it. Second, in his experience these refactors ended in disaster, and someone eventually reverted the code to where it started.

如果它能正常工作,就别动它。

If it works, don't touch it.

我不同意。软件遵循热力学第二定律:放任不管,它就会逐渐走向混乱;除非有人清理维护,否则最终会变得难以维护。在那家公司,我曾花了 4 天,才弄清楚两个静态链表在一个充满副作用的成员函数里究竟做了什么。一位高级工程师给它们起名叫 list1 和 list2。I disagree. Software follows the second law of thermodynamics: left alone, it drifts toward disorder, and unless someone cleans it up, it becomes unmaintainable. At that same company, I once spent 4 days working out what two static linked lists did inside a member function full of side effects. A senior engineer had named them list1 and list2.

模块关系需要定期重新梳理:将纠缠的代码重新整理成深模块,并保持 SICP 中的抽象屏障完好。屏障限定了移除某一层所影响的范围。Module relationships need periodic redrawing: tangled code turned back into deep modules, SICP's abstraction barriers kept intact. A barrier bounds what removing a layer affects.

AI 提高了死代码的代价。纠缠在一起的旧代码和已废弃的代码会占满上下文窗口,模型可能将已废弃的模式误读为有效示例,并在新代码中照搬。AI 也降低了清理成本:过去,删减代码前要先弄清每项依赖,这往往占去大部分精力;如今,这正是 AI 擅长的部分。AI raises the cost of dead code. Tangled and retired code fills the context window, and a model reads a retired pattern as a valid example and reproduces it in new code. AI also lowers the cost of cleanup: understanding every dependency before cutting, which used to take most of the effort, is now the part AI does well.

我的经理同意了。后继流程上线并证明稳定后,他安排我清理它遗留下来的技术债务。My manager agreed. Once the successor flow had shipped and proved stable, he assigned me to clean up the tech debt it left behind.

问题The problem

已停用的流程(称为流程 X)由编译器选项 --XYZ 控制。流程 X 已不再出现在任何默认路径中,但其代码与仍在生产环境中使用的代码交错在一起。The retired flow, call it flow X, was controlled by a compiler option --XYZ. Flow X was no longer on any default path, but its code was interleaved with code still in production.

这项任务有两个目标:The task had two goals:

  1. 已彻底移除。与流程 X 或 --XYZ 相关的代码、测试、文档或脚本均已删除。
    Complete removal. No code, test, doc, or script for flow X or --XYZ remains.
  2. 行为没有变化。每个保留下来的流程都与移除前产生相同的输出。
    No behavior change. Every surviving flow produces the same output as it did before the removal.

目标很明确,但技术债务太多,我一个人难以审查。我刚接触这个代码库,要是靠手动阅读每个依赖来清理它,所需时间会远远超出我手头的时间。The goals were clear, but the tech debt was too large for me to review alone. I was new to this codebase, and cleaning it up by reading every dependency by hand would have taken far longer than the time I had.

步骤 1:定义范围Step 1: Defining the Scope

所以,首先要问的不是如何删除代码,而是我究竟需要阅读哪些代码。代码库分成两部分:依赖于 --XYZ 的代码,我必须审查;其余代码则可以放心忽略。关键是要准确划清这条界线。So the first question was not how to delete the code, but which code I had to read at all. The codebase splits into two piles: code that depends on --XYZ, which I must review, and everything else, which I can safely ignore. The work is to draw that line precisely.

起点是解析 --XYZ 并选择流程 X 的位置。从那里开始,基于 compile_commands.json 构建的静态分析工具会追踪每一个调用方和被调用方。The starting point is where --XYZ is parsed and where it selects flow X. From there, static analysis tools built on compile_commands.json trace every caller and callee.

然后,LLM 会用两种颜色之一的注释标记它访问到的每个函数:The LLM then marks each function it reaches with a comment, in one of two colors:

  • 红色:行为取决于标志的值
    RED: behavior depends on the flag's value
  • 蓝色:准备或转发标志的数据
    BLUE: prepares or forwards the flag's data
// (unmarked) Produces options for every flag. Marking stops here.
Options parseCommandLine(int argc, char **argv);

// BLUE: forwards the flag's value into the pipeline config.
PipelineConfig buildConfig(const Options &opts) {
  PipelineConfig cfg;
  cfg.useFlowX = opts.xyz;
  return cfg;
}

// RED: the starting point. Behavior depends on the flag.
void buildPipeline(PassManager &pm, const PipelineConfig &cfg) {
  if (cfg.useFlowX)
    addFlowXPasses(pm);
  else
    addDefaultPasses(pm);
}

// RED: reached only through the flag's branch.
void addFlowXPasses(PassManager &pm);

// (unmarked) Belongs to the surviving path. Not affected.
void addDefaultPasses(PassManager &pm);

传播会持续进行,直到没有新的内容被标记为止。Propagation continues until nothing new gets marked.

这个标记可以验证。从同一个起点再运行一个调用图工具。如果它到达的函数集合与该标记着色的函数集合相同,那么对于工具能够看到的每条边,该标记都是完整的——无需相信 AI。The marking is checkable. Run a second call-graph tool from the same starting point. If it reaches the same set of functions the marking colored, the marking is complete for every edge the tool can see — no need to trust the AI.

一个限制是:MLIR 会注册 pass,并通过字符串构建流水线,因此调用图中存在 clangd 无法追踪的动态边。“没有新的内容被标记”指的是工具能够看到的边中没有新增内容。One limit: MLIR registers passes and builds pipelines from strings, so the call graph has dynamic edges clangd can't follow. "Nothing new gets marked" means nothing new among the edges the tools can see.

步骤 2:对每个节点进行分类Step 2: Classify every node

范围界定告诉我哪些地方不必看。但剩下的代码仍然多到无法逐行阅读。标记出的函数或节点,并非都具有相同的风险。因此,每个节点都会被归类,并接受相应的处理。删除整个函数,几秒钟就能审查完。其他节点则需要真正动手术:切除肿瘤,同时不损伤周围的健康组织。有些节点需要搭建旁路,这样在我们修改自己的代码时,血流仍能继续流向由另一支团队负责的代码。The scope told me where not to look. But the remaining code was still too large to read line by line. Not every marked function, or node, carries the same risk. So each node gets a category and a matching treatment. A deleted whole function takes seconds to review. Other nodes need real surgery: remove the tumor without damage to the healthy tissue around it. Some nodes need a bypass, so the blood continues to flow to code another team owns while our code changes.

我定义了四个类别,并让 AI 将每个节点归入其中一个类别:I defined four categories and asked the AI to put each node into one:

  1. 机械式删除。标志移除后,整个函数或代码块都不可达,也没有任何仍在运行的代码依赖它。将其彻底删除。
    Mechanical deletion. The whole function or block is unreachable once the flag is gone, and no live code depends on it. Remove it entirely.
  2. 跨团队归属。另一团队拥有这段代码,或者这段代码与他们拥有的代码交织在一起。此处的更改需要协商,而且另一团队可能会拒绝。我会将这些情况标记出来,单独处理。
    Cross-ownership. Another team owns the code, or the code is interleaved with code they own. Changes here need negotiation, and the other team might refuse them. I mark these for separate handling.
  3. 副作用。代码会改变共享状态。一个典型的编译器示例是,添加一个全局栈作为性能特化。标注工作假设数据通过参数和返回值传递。副作用则通过静态分析无法追踪的渠道传递数据。我会手动处理这些情况。
    Side effects. The code mutates shared state. A typical compiler example is a global stack added as a performance specialization. The marking assumes that data moves through parameters and return values. Side effects move data through channels that static analysis cannot follow. I handle these by hand.
  4. 手术。语义可能会发生变化,代码也和必须保留的逻辑纠缠在一起。我一次处理一部分:切除必须去掉的内容,再把剩余代码重新缝合起来。
    Surgery. The semantics may change, and the code is entangled with logic that stays. I separate it one piece at a time: excise what has to go, then suture the remaining code back together.

分类是一项范围狭窄、重复性很高的判断任务。通用型 LLM 对这项工作来说功能过剩。像 TypeSafe Jev 这样的小型分类模型,很可能只需少得多的 token 就能完成。我使用了公司批准的唯一一个 LLM。Classification is a narrow, repetitive judgment. A general-purpose LLM is more than the job needs. A small classifier model such as TypeSafe Jev would likely do it with far fewer tokens. I used the one LLM that my company approved.

随后,每个节点都携带其颜色和类别:Each node then carries its color and its category:

// RED, MECHANICAL-DELETION: reached only through the flag's branch.
void addFlowXPasses(PassManager &pm);

// RED, SURGERY: keep the default branch, excise the flow X branch.
void buildPipeline(PassManager &pm, const PipelineConfig &cfg);

手动检查单个节点上的一个标签,远比审查整个代码库轻松得多。AI 只给少数几个节点贴错了标签,而且每个错误都很容易发现。这些错误全都是删除整个文件,而这些文件后来发现会注册一个 pass。没有 C++ 代码调用这些文件,因此调用图中看不到任何仍在使用的内容。但文件会以字符串名称注册一个 pass,流水线字符串或命令行仍然可以通过名称调用该 pass。删除文件会改变接口,因此这并非机械性的操作。修正方法始终相同:将节点移到风险更高的类别中。我发现的每个标签错误,都会把节点归入需要更谨慎处理的类别。Manually checking one label on one node is a much smaller job than reviewing the whole codebase. The AI mislabeled only a few nodes and each mistake was easy to spot. The mistakes were all whole-file deletions that turned out to register a pass. No C++ code called the file, so the call graph showed nothing alive. But the file registered a pass under a string name, and a pipeline string or a command line could still invoke that pass by name. Deleting the file changed an interface, so it was not mechanical. The fix was always the same: move the node to a riskier category. Every mislabel I found sent a node to riskier handling.

步骤 3:搭建框架Step 3: Scaffolding

每个目标都需要一个在未达成目标时会失败的测试。Each goal needs a test that fails when the goal is not met.

目标 1:彻底移除,是最难的一项。根据我的经验,要求 LLM“移除这个标志”时,它会漏掉一些地方。测试套件发现不了这些遗漏,因为残留的死代码不会导致任何问题。它只会让下一位读者——无论是人还是模型——感到困惑。因此,必须有人补上缺失的检查。Goal 1, complete removal, is the hard one. In my experience, an LLM told to "remove this flag" misses sites. The test suite does not catch the misses, because leftover dead code breaks nothing. It only confuses the next reader, human or model. So someone has to write the missing check.

目标 2(不改变行为)正是现有回归测试所保护的内容。手术式改动对这一目标的风险最大,因此需要接受最严格的测试。Goal 2, no behavior change, is what the existing regression tests already protect. Surgery puts this goal at the most risk, so surgery gets the strictest test.

每个类别都会进行与其风险相匹配的测试:Each category gets a test that matches its risk:

Category Handling Test
Mechanical Remove entirely Deletion test: fails before removal, passes after
Cross-ownership Adapter Pattern at the boundary Interface tests on the adapter
Side effects Handled by hand Reviewed and tested one by one
Surgery Excise and suture Surgical functional test: behavior identical to before

删除测试用于检查目标 1。它断言被移除的内容确实不复存在。已弃用的选项会被拒绝,旧产物也不再生成。我会先编写这个测试。删除之前测试会失败,删除之后则会通过。被遗忘的站点会让它的测试一直保持红色。The deletion test checks goal 1. It asserts that the removed thing is gone. The retired option is rejected, and the old artifacts are no longer produced. I write this test first. It fails before the deletion and passes after. A forgotten site keeps its test red.

手术式功能测试用于检查目标 2。对于切除操作触及的每个位置,变更后的可观察行为都必须与变更前一致。如果单元测试无法隔离该位置,就用真实输入进行回归测试来覆盖它,并确保测试数量足以执行该位置的每条路径。The surgical functional test checks goal 2. For every site that the excision touched, the observable behavior after the change must match the behavior before it. Where a unit test cannot isolate the site, regression tests on real inputs cover it. There are enough of them to exercise each path through the site.

这些测试大多只是脚手架,用来检查诸如某个内部符号是否不存在之类的内部细节。工作完成后,这些细节也就不复存在了。就像建筑上的脚手架一样,工作完成后,这些测试也会被移除。如果它们留在代码仓库中,就会测试已经不存在的代码。Most of these tests are scaffolding. They check internal details, such as the absence of an internal symbol. Those details stop existing once the work is done. Like scaffolding on a building, these tests are removed when the work is finished. If they stayed in the repo, they would test code that no longer exists.

步骤 4:真正的删除与手术搭建Step 4: True deletion and surgery

AI 会分几轮按类别执行编辑。每一轮先由子代理审核,然后再由我审核。本阶段只有一个目标:移除流程 X,并确保所有保留下来的行为都正确。The AI performs the edits, category by category, over several rounds. A subagent reviews each round first. Then I review it. This phase has one goal: remove flow X and keep every surviving behavior correct.

代码质量不在此处的讨论范围内。冗余设计和浅层模块暂时保持原样,下一阶段再修复它们。Code quality is out of scope here. Redundant design and shallow modules stay as they are for now. The next phase fixes them.

步骤 5:术后清理Step 5: Post-op cleanup

第 1–4 步留下了围绕已不复存在的模式构建的代码。接口仍然包裹着如今只有一个调用方的逻辑。类型仍在编码一个已无人做出的选择。因此,这一步要问每个剩余部分一个问题:它现在仍是一个深模块吗?Steps 1–4 left code shaped around modes that no longer exist. Interfaces still wrap logic that now has one caller. Types still encode a choice that nobody makes. So this step asks one question of every remaining piece: is this still a deep module?

有一条硬性规则限定了工作范围:我只修改步骤 1–4 已经涉及的代码。这是对手术区域的清理,而不是重写。步骤 1 确定的范围同样适用于这一步。One hard rule bounds the work: I only touch code that steps 1–4 already touched. This is cleanup of the surgical site, not a rewrite. The scope from step 1 bounds this step too.

典型发现:Typical findings:

  • 共享状态分散。三个函数都读写同一小组变量。我将它们打包成一个传入的结构体和一个返回的结构体。
    Scattered shared state. Three functions all read and write the same handful of variables. I bundle them into one struct passed in and one struct returned.
  • 只剩一种情况的类型。std::variant 现在只有一个备选类型,或者 std::expected 的错误路径只有已废弃的模式才会用到。确认错误路径确实不可达后,我会将它们简化为普通类型。
    Types with only one case left. A std::variant now has a single alternative, or a std::expected has an error path that only the retired mode used. I collapse them to plain types, after I confirm that the error path really is unreachable.
  • 只剩一条路由时,调度逻辑原本用于在不同模式间进行选择。只剩一种模式时,它就简化为直接调用。
    Routing with one route left. Dispatch logic used to choose between modes. With one mode left, it reduces to a direct call.

第 3 步中的测试会继续为这一步提供保障。The tests from step 3 continue to guard this step.

例如,在步骤 4 移除流程 X 后,管道代码仍然如下所示:For example, after step 4 removed flow X, the pipeline code still looked like this:

// After step 4: correct, but still shaped for two modes.
enum class PipelineMode { Default };  // FlowX was removed in step 4.

struct PipelineConfig {
  PipelineMode mode = PipelineMode::Default;
};

std::expected<void, std::string>
buildPipeline(PassManager &pm, const PipelineConfig &cfg) {
  switch (cfg.mode) {
  case PipelineMode::Default:
    addDefaultPasses(pm);
    return {};
  }
  return std::unexpected("unknown pipeline mode");  // Unreachable now.
}

这个枚举只有一个值,switch 只有一个分支,错误处理路径也不可能再触发。每个调用方仍然会检查一个不可能失败的结果。步骤 5 之后:The enum has one value, the switch has one case, and the error path can no longer fire. Every caller still checks a result that cannot fail. After step 5:

// After step 5: one mode, one path, no choice left to encode.
void buildPipeline(PassManager &pm) { addDefaultPasses(pm); }

枚举、配置字段、switch 语句,以及每个调用点的错误处理都不复存在。现在,接口本身就说明了函数的作用。The enum, the config field, the switch, and the error handling at every call site are gone. The interface now says what the function does.

结果Results

大约一周内,编译器减少了约 1.9 万行代码,二进制体积也缩小了 50 多 MB。一个月后,这项改动既没有被回滚,也没有补丁提交来修正。只有约 12% 的删除代码经过了仔细审查。分类结果告诉我是哪 12%。In about a week, the compiler lost ~19k lines of code and over 50 MB of binary size. A month later, there were no reverts and no fix-up commits for this change. Careful review covered only about 12% of the deleted lines. The classification told me which 12%.

What Value
Compiler binary size -50+ MB
Code lines deleted / added ~19k / ~130
Test lines deleted / added ~1.5k / <10
Doc lines deleted / added ~1.9k / <10
Whole code files deleted ~30 (~17k lines)
Code files modified ~35 (~2.3k lines)
Flag-name occurrences outside C++ 13
Reverts or fix-up commits since 0

分类结果决定了我的审查时间花在哪里。大部分被删除的代码都在整份文件中,我根据文件列表审查了这些文件。在删除整份文件之前,我会搜索其中是否有 pass 注册项和流水线名称。只要命中任何一项,我就会将该文件移出机械性删除的类别。我逐行审查了约 35 个修改过的文件。这些文件包含了约 12% 的删除内容。The classification decided where my review time went. Most of the deleted code was in whole files, and I reviewed those files by their file list. Before I deleted a whole file, I grepped it for pass registrations and pipeline names. Any hit moved the file out of the mechanical category. I read the ~35 modified files line by line. These files hold about 12% of what was deleted.

提交历史按步骤排列。下面是一个示例版本,按时间从早到晚排列:The commit history follows the steps. An illustrative version, oldest first:

a1c3e09 Add REFACTOR(retire-xyz) annotations: scope and categories
4f7b2d1 Add deletion tests for --XYZ (expected to fail)
9e0a6c3 Add surgical functional tests for buildPipeline
c28d5f4 Remove flow X pass files (mechanical)
7b19e8a Remove flow X helpers in Conversion/ (mechanical)
e5402bb Remove --XYZ from lit RUN lines and scripts (mechanical)
3d8f1a7 Excise flow X branch from buildPipeline (surgery)
b60c4e2 Move global pass stack out of flow X path (side effects)
0f9a7d5 Reject retired option --XYZ with a clear error
8a2e3c6 Collapse single-mode PipelineConfig into direct call (cleanup)
d47b9f0 Remove std::expected from buildPipeline (cleanup)
61e0c8b Remove scaffolding tests and REFACTOR annotations

每个提交都属于一个类别,且规模足够小,可以一次审阅完。整文件删除则按文件列表进行了审阅。Each commit belongs to one category and is small enough to review in one sitting. Whole-file deletions were reviewed by file list.

要点Takeaway

大部分工作都是 AI 做的,但我对它做的任何事都不信任。这两点并不矛盾。每个修改点在任何人动手之前,都先配好了一个验证器:一种颜色、一个类别和一项测试。我的工作是检查每个验证器,而不是把工作重做一遍。The AI did most of the work, and I trusted none of it. These two facts do not conflict. Every change site got a verifier before anyone touched it: a color, a category, and a test. My job was to check each verifier, not to do the work again.

下一步Next

这篇文章讲的是删除。下一篇将讲替换:重写一个过时的工具。这项工作没有一张可以绘制的完整路线图,而是一连串小步骤。每一步都足够小,出了问题也很容易发现。This post covered deletion. The next post covers replacement: the rewrite of an outdated tool. That job had no single map to draw. It was a chain of small steps. Each step was small enough that a problem was easy to see.


> share on twitter / bluesky / mastodon / facebook / reddit / telegram / email

> 相关文章:> related articles:


值得记下

软件遵循热力学第二定律:如果任其发展,就会逐渐走向混乱;若无人清理,最终便会变得难以维护。
大部分工作是 AI 完成的,而我对它的成果毫不盲信。这两点并不矛盾。任何人动手修改之前,每个变更点都必须有验证机制:颜色、类别和测试。
提交历史记录了整个步骤。以下是一个示例版本,按时间从早到晚排列:[提交列表]