news 2026/9/17 2:03:32

.NET CoreCLR JIT 分析框架深度解析:从 2009 年架构计划看 SSA、值编号与约束传播的落地实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
.NET CoreCLR JIT 分析框架深度解析:从 2009 年架构计划看 SSA、值编号与约束传播的落地实现

.NET CoreCLR JIT 分析框架深度解析:从 2009 年架构计划看 SSA、值编号与约束传播的落地实现

【免费下载链接】runtime.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps.项目地址: https://gitcode.com/GitHub_Trending/runtime6/runtime

本篇技术指南以仓库中 docs/design/coreclr/jit/Jit Architecture Plan 2009.md 为骨架,逐段解析 CLR JIT 团队在 2009 年提出的优化分析框架——静态单赋值形式(SSA)、值编号(Value Numbering)与约束传播——并结合当前仓库中 src/coreclr/jit 的实际实现(SsaBuilderValueNumStorefgValueNumber等),说明这份十余年前的"架构蓝图"如何在今天的 RyuJIT 中落地。读完本文,你将理解 JIT 编译器如何以"不可变值"为推理单位做公共子表达式消除、循环不变量提升与冗余分支优化,并能从源码层面定位每一处理论对应的实现。

一、文档背景:一份"历史档案"式的架构计划

该文档由 CLR Jit Team 撰写于 2009 年 9 月,仓库在收录时明确标注:"this is an excerpt from a document written a number of years ago outlining a plan for Jit development... There is no guarantee that what is described here has been implemented, or was implemented as described."即这是一份历史规划文档的节选,描述的是计划而非必然的现状

这一点决定了本文的阅读姿势:我们既要把文档中的理论讲透,也要回到当前仓库源码(src/coreclr/jit目录)去核实其中哪些设想真正落地、以何种形态落地。从代码结构看,文档第 3.3 节提出的核心设想——SSA 形式、值编号、基于值编号的约束传播——已在 RyuJIT 的优化流水线中基本实现,但堆内存的"字段图"建模与 egraph 同余闭包等高级设想,则以折中形态(如ValueNumStore中的 map select 机制)存在。

二、分析框架的总纲:把优化问题归结为"值的问题"

文档开篇提出的方法论基石是:

是否适用某个优化,本质上是在回答——"能否证明性质 P 对表达式 E 的值恒成立?"(can we prove that property P always holds of the value of expression E?

因此分析框架应围绕表达式的值来回答问题,而不是围绕语法结构做模式识别。这一论断直接决定了后续三件工具的选择顺序:先做 SSA 形式,再做值编号(含流不敏感约束),最后做约束传播。

在今天的 src/coreclr/jit/compiler.cpp 中,这一思路体现为优化流水线中严格有序的若干阶段:

DoPhase(this, PHASE_BUILD_SSA, &Compiler::fgSsaBuild); // 构建 SSA DoPhase(this, PHASE_EARLY_PROP, &Compiler::optEarlyProp); // 数组长度传播等 DoPhase(this, PHASE_VALUE_NUMBER, &Compiler::fgValueNumber); // 值编号 DoPhase(this, PHASE_HOIST_LOOP_CODE, &Compiler::optHoistLoopCode); // 循环不变量提升 DoPhase(this, PHASE_VN_COPY_PROP, &Compiler::optVnCopyProp); // 基于 VN 的复制传播 DoPhase(this, PHASE_OPTIMIZE_BRANCHES,&Compiler::optRedundantBranches); // 冗余分支优化

SSA 与值编号被设计为后续所有"基于值"优化的公共基础设施,这与文档"先建框架、再谈具体优化"的论述顺序完全一致。

三、SSA 形式:把可变变量的生命周期切分成"不可变的值段"

3.1 SSA 的核心思想与 phi 函数

SSA(Static Single-Assignment form,静态单赋值形式)把局部变量的生命周期切分为多个片段,每个片段内变量持有不同的值,并赋予不同的名字;每个名字只对应唯一一处定义。因此在静态分析中,每个 SSA 名字代表一个"在其名字生命周期内不可变"的值。

这为 CSE、循环不变量代码移动等优化提供了直接答案:两个相同表达式是否产生相同值,取决于两次求值之间自由变量是否被修改。在非 SSA 形式下需要做"路径上是否有修改"的保守分析;在 SSA 形式下,若自由变量在路径上被修改,它们会被赋予不同的 SSA 名字,两次出现自然就不再是同一个表达式了。文档同时指出这种处理是保守的——它不区分"被修改成新值但保持了所在表达式不变"的情况,而后续的值编号可以弥补这一不精确性。

SSA 的成立条件(每个使用的 SSA 变量都有唯一的支配性定义)需要在合并点插入新定义。文档给出的经典示例:基本块B3有前驱B1B2,二者各自定义了局部变量v,而B3使用v。SSA 转换把B1B2中的定义改名为v1v2(并同步改写被其支配的所有使用),并在B3头部插入新定义v3,其值由一个phi 函数v1v2中选出。

文档对 phi 函数给出一个很精到的"可执行视角":把 phi 看成额外携带一个整数"选择子"参数,指示控制流来自哪个前驱——每个前驱传入对应的整数常量,phi 依据选择子返回剩余参数之一。从这个视角看,phi 函数是良定义的数学函数,完全保持执行语义;而从静态分析的保守视角看,phi 则是在输入之间"非确定性选择"。文档特意指出,这种"可执行视角"对 phi 成立,但对某些用于给 SSA 名字附加约束的所谓pi 函数却不一定非平凡地成立(后文约束传播将涉及)。

3.2 剪枝 SSA 与需求驱动分析

剪枝 SSA(pruned SSA)在变量已死的位置避免插入无意义的 phi 定义,代价是需要局部变量活性分析。需求驱动(demand-driven)是 SSA 的另一大优点:传统前向数据流分析要传播所有抽象值直到不动点,不知道哪些值最终对优化有用;而 SSA 支持沿 use-def 链反向遍历——从某个使用出发,立刻找到对应定义、定义里的表达式、表达式用到的 SSA 变量,如此反向回溯,得到影响该使用点值的"程序切片"(program slice)。数组边界检查移除正是典型场景:只需关注数组引用表达式与下标表达式的性质,用 use-def 链独立分析即可,不必扫描整个方法。

3.3 源码实现:SsaBuilder

当前仓库中,SSA 构建由 src/coreclr/jit/ssabuilder.h 中的SsaBuilder类完成,其设计几乎逐条对应文档描述:

  • Build()要求语句节点已按求值顺序排列,分析流图以确定哪些块需要 phi 节点,并将 phi 节点以GT_PHI树节点的形式插入到每个块的起始处;
  • 每个GT_LCL_VAR通过节点的GetSsaNum()字段获得 SSA 编号;
  • 每个GT_PHI节点位于一个STORE_LCL_VAR节点之下、作为该 store 的值操作数;
  • phi 的输入表示为GT_PHI_ARG节点的链表;
  • 变量的所有定义(def)记录在局部变量描述符的 "per SSA data" 中,供 use-def 链查询。

构建过程分为两阶段(见 ssabuilder.h 中注释):先在按拓扑序排列的postOrder块上为需要 phi 的块插入GT_PHI节点,再对方法内所有定义和使用执行重命名(Rename系列函数)。值得注意的细节是 EH 异常处理:AddDefToEHSuccessorPhis系列函数专门处理"定义所在块处于一个或多个 EH 后继块内"的情形——若局部变量在对应后继块入口处存活,则把该 SSA 编号加入相应处理器起始块 phi 的参数列表。这说明真实 JIT 的 SSA 必须应对异常流图这一文档未展开的复杂性。

四、值编号:追踪"等价"而不只是"名字"

4.1 为什么 SSA 还不够:复制与同余

SSA 名字是值的不可变名字,但它不表达值之间的等价关系。等价关系可能来自:

  • 复制(copy)x = y;之后xy持有相同值,但 SSA 给它们不同名字;
  • 同余(congruence)(a7 + b4)两次出现——既然a7b4是 SSA 变量且两次出现同名,说明中间语句S既没定义a也没定义b,两次+应用是"作用于等价参数的同函数应用",即公共子表达式。但"检测表达式等价"超出 SSA 的能力范围。

值编号正是负责追踪复制与同余引发的等价关系的机制,它还能编码函数语义带来的进一步等价(例如加法交换律,使b4 + a7a7 + b4被发现等价)。

文档给出了值编号的精确定义:值编号发现等价表达式类。由于找出程序中所有表达式等价关系不可判定,具体系统只能发现全部等价的某个子集。每个等价类被赋予一个整数value number(值编号),每个表达式被标记为其所属等价类的编号(必要时创建单元素类)。于是:具有相同值编号的两个不同表达式,求值结果相同(技术上要求:在从一次求值到另一次求值的控制流路径上,没有插入任何会影响表达式值的 phi 求值)。反过来,值编号不同并不保证值一定不同。

4.2 值编号的基本赋值算法

文档给出的赋值流程非常工程化:

  1. 为所有输入参数分配 primitive 值编号(入参是方法开始执行前就存在的值);
  2. 维护"字面量 → 值编号"映射表(常量映射);
  3. 按前向流分析遍历程序,跟踪变量中保存的值编号(把globMem视为隐式程序变量);
  4. 维护一张"(算子,参数值编号元组)→ 结果值编号"的表格。遇到内建算子(算术、逻辑等)表达式时,若关心其等价性,就在表中查找该元组是否已有值编号:命中则复用,未命中则新建值编号、记录其定义,并把元组映射存入表。若不关心,则直接分配新编号、不存表。

今天ValueNumStore(src/coreclr/jit/valuenum.h)的接口与此一一对应:VNForIntCon/VNForLongCon/VNForDoubleCon等为各类常量分配编号;VNForFunc(有 0 到 4 元五个重载)为"算子应用"分配编号——这正是文档中"算子/参数元组表"的具象化;VNF_ARR_LENGTHVNF_ADDVNF_MULVNF_DIVVNF_ANDVNF_LT_UN等枚举(见 src/coreclr/jit/inductionvariableopts.cpp 与 src/coreclr/jit/gentree.cpp 的调用)则是文档所说"内建算子"的现代形态。ValueNumStore还提供VNForNull()VNForVoid()VNForEmptyExcSet()等特殊编号,用于表达空指针、空值与空异常集合,这是文档未涉及、实现中必需的扩展。

文档还定义了值编号的三分类,可直接对应实现中对值的查询能力:

  • 常量值编号:表示字面常量;
  • 原始(primitive)值编号:表示方法开始执行前就存在的值(入参、初始堆状态);
  • 其余值编号:带有定义,定义是其他值编号的某个(纯)函数——其中一类就是后文要讲的值编号 phi 函数

4.3 堆内存建模:字段图、globMem 与 havoc

文档最前瞻的部分是对托管堆内存的统一建模,其动机是:托管语言大多使用类型化指针,可以采用比 C 语言通用指针分析更受限的别名处理。

建模方案:若对象类型A有字段f,则把a.f建模为对"字段映射"A$f以引用值a为索引的取值。字段写a.f = v建模为函数式更新A$f ¬ A$f[a := v]——生成一张新映射,内容与原映射相同,仅在索引a处值为v。由此:

  • 对不同字段的写不会影响A$f
  • 可以准确地对涉及堆引用的表达式做 CSE(如o.f + o.f),文档直言"据我所知,我们今天不做这件事";
  • 更进一步,还能追踪A$f相邻两个值之间的关系,使编译器得知"通过已知与a不同的引用做 store 不影响a.f的值"。

调用点问题:调用方法时通常必须对堆影响持高度保守态度,需要"havoc"(搅乱)操作——为所有字段映射分配新值编号。为此把字段映射看作全局内存状态的函数:全局内存状态是"字段名 → 字段映射值编号"的映射。于是a.f实际上是globMem[A$f][a]a.f = v实际更新globMem使其A$f映射在引用值a处为v。一次调用只需把全局内存状态的值编号设为新值(对它一无所知),即可廉价地使所有堆知识失效。在此视图下,globMem是所有方法的隐式输入,在没有反证的情况下被假定被所有方法修改。

这一模型在源码中的痕迹清晰可见:ValueNumStore注释中提到VNForMapSelect[Work]方法"代表了选择基础设施的核心"(见 src/coreclr/jit/valuenum.h 注释区),专门处理"在字段映射上按对象引用做选择"这类取值编号问题,与文档"a.fglobMem[A$f][a]"的表述对应;编译器阶段fgValueNumberFieldLoad/fgValueNumberFieldStore/fgValueNumberByrefExposedLoad(见 src/coreclr/jit/compiler.h)分别处理字段读、字段写与 byref 暴露下的堆读。

文档还设想了方法的纯度建模:方法调用结果(及 ref 参数被赋的值)可视为"参数(含globMem)与调用目标对象"的方法特有函数;若 CLR 支持可验证的[Pure]属性,则纯方法既不观察也不修改全局内存,可建模为不接收globMem参数的函数,其调用也不修改globMem。需要说明:这是一个 2009 年的展望性描述,截至当前仓库源码并未提供该可验证[Pure]属性的通用实现。

五、值编号 phi:合并点与循环的处理

5.1 合并点的 VN phi

流分析到达控制流合并点时遇到难题:若变量v沿不同入边持有不同值,显然应新建一个值编号,定义为入边值的VN phi 函数。但问题是如何知道何时需要——不能在每个合并点为每个变量都新建值编号(很多情况下变量在菱形控制流前并未被修改,所有路径上值相同)。

文档给出的工程策略:

  1. 尽量延迟分析块,直到其所有前驱都被完整分析;
  2. 存在循环时这不可能做到,但SSA 转换已经算出了哪里必须放 SSA phi 节点,这可以非常近似地指示 VN phi 节点的必要性;
  3. 这是一个保守近似:若某合并点没有v的 SSA phi,则所有入边v值相同(不需要 VN phi);但可能存在"需要 SSA phi 却不需要 VN phi"的情况——例如变量在所有入边都被赋值,但各边赋的是同一个值;
  4. 于是算法是:先对应 SSA phi 引入 VN phi 定义,再做流分析,当能证明所有入参等价时消除 VN phi(及其结果值编号)。这个过程只会发现新等价,绝不会使已声称的等价失效。

消除方式可以沿用标准流分析范式:若最初为v在合并点分配了n2 = vnphi(n0, n1),后来发现两条分支流入的都是n0,就把n0赋给v并继续流分析、把该块标记为"已变化",这种变化传播可能连带消除更多 VN phi。

5.2 循环示例:k = k + 1 – 1

文档用如下循环展示了 VN phi 在不动点处消解等价的能力:

k = … while (P) { k = k + 1; … use k …; k = k – 1; }

SSA 形式为:

k_0 = … loop: k_1 = phi(k_0, k_3); // n1 = vnphi(n0, ^) if (!P) goto exit; k_2 = k_1 + 1; … use k …; k_3 = k_2 – 1; goto loop; exit:

流分析给k_0分配值编号n0并流入循环;循环头是汇合点、有k的 SSA 定义,于是为k引入值编号n1,定义为k_0k_3值编号的 VN phi。初始时k_3的值未知,记为 bottom 元素。分析循环体时假设k_1持有n1;若值编号基础设施内建足够的算术知识,能推出n1 + 1 – 1 == n1,则循环结束时确定k_3持有n1。此时n1的定义形如n1 = vnphi(n0, n1)。由于 phi 的"非确定性选择"语义,无论 VN phi 选哪个输入该方程都必须成立——唯一解是n1 == n0。于是到达不动点时即可判定:k在每次循环迭代开始时始终持有同一个值,其值在循环内不变(因为定义在循环外)。

这正是文档"值编号超越 SSA 保守性"的经典例证:SSA 形式因循环内语法上的赋值而不得不创建 phi,而值编号能够证明变量实际持有的值不变。

该设想在今天的实现中以更工程化的形态存在:Compiler::optVNIsLoopInvariant(ValueNum vn, FlowGraphNaturalLoop* loop, VNSet* recordedVNs)(见 src/coreclr/jit/compiler.h)用于判定某个值编号是否对给定循环不变,其注释明确指出:"VNPhi 把 VN 连接到 SSA 定义,因此我们可以知道该 SSA 定义是否出现在循环中"——常量与初始值总是循环不变的,而 VNPhi 提供了"SSA 定义在循环内但值实际不变"的判定通道。这正是文档中"若 SSA 的循环不变测试失败,则检查值编号图"的落地:若变量的值编号递归地由常量、primitive 值编号或位于循环外的 VN phi 定义,则表达式值循环不变。该判定由循环不变量提升阶段(optHoistLoopCode,见 src/coreclr/jit/compiler.cpp)消费。

5.3 等价类合并:union-find 与 egraph

文档提出了两种处理"发现新等价后收回旧值编号"的机制:

  • 流分析传播(前述"把块标记为已变化"):简单但可能低效;
  • 等价类代表映射 + union-find 并查集:维护"值编号 → 值编号"映射,把每个值编号映射到其等价类代表。使用任何值编号前先翻译成类代表。查询时沿"指针链"行进并做路径压缩,使链上所有元素直接指向最终代表;合并两个等价类只需让一方的代表指向另一方。若再配合对函数定义映射中不同表达式做统一(如vn4 = +(vn1, vn3)vn5 = +(vn2, vn3),统一vn1vn2后希望发现vn4vn5同余),则可引入定理证明领域的数据结构egraph(Nelson, 1981)高效表示等价类并自动发现同余(即同余闭包,congruence closure),从而有可能在对 SSA 转换后程序的单次线性遍历中完成值编号。

文档自评:使用 egraph 会增加复杂度。从当前源码看,RyuJIT 采用的前者路径——即流分析传播加 VN phi 消除——是主要机制,egraph 属于文档层面的前瞻设想,并未以 Nelson 原始形态出现于 src/coreclr/jit 中。

六、约束传播:值编号上的流敏感事实

文档预告了约束传播(constraint propagation,即流敏感事实)作为分析框架的第三块拼图,其设计要点如下:

  • 不变量事实(invariant facts):由定义表达式产生的值保证成立的事实,在不可变值的整个生命周期内保持,可直接记录为值编号的属性。例如new Foo()的结果非空、且精确类型为Foo
  • 流敏感事实(flow-sensitive facts):由程序控制流谓词衍生的事实。例如循环测试可用以推断(递增的)循环迭代变量的上界,但仅限循环内。
  • 需求驱动:不必为所有表达式做完整数据流分析,而是"给定某位置某表达式的出现,查询其值编号、以及在该位置适用于该值编号的约束",仅在 SSA use-def 图的有关部分做局部化数据流分析。
  • 以值编号而非变量名表达约束:若两个变量持有等价值,对其中一个的约束自动是另一个的约束,无需跟踪依赖变量、也无需在变量更新时"杀死"断言。

这一节在文档中属于预告("we will discuss a treatment of flow-sensitive facts in the next section"),但今天仓库中有对应实现:optRedundantBranchesPHASE_OPTIMIZE_BRANCHES,src/coreclr/jit/compiler.cpp)即基于 VN 做冗余分支优化,src/coreclr/jit/redundantbranchopts.cpp 中大量使用VNForFunc构造关系表达式(如VNF_AND)来推理分支条件;src/coreclr/jit/assertionprop.cpp 中则用VNF_ARR_LENGTH构造数组长度值编号参与断言传播。这些都属于"在值编号上附加流敏感事实"的现代形态。

七、为什么值编号优于裸 SSA:文档的总结性评论

文档在 3.3.3 节给出三点关键评论,每一句都能在今天的优化器中找到对应物:

1. 从"可变变量"到"不可变值"的思维转变。JIT32(x86 全框架 JIT,即旧版 JIT)做 CSE 时,为每个候选表达式计算其依赖的变量集合,两个同文表达式只有当路径间无依赖变量被修改时才"公共";在 SSA/值编号框架中,发生此类修改时两个表达式根本不会被当作 CSE 候选——SSA 下二者使用的 SSA 变量集合不同,值编号下二者获得不同值编号。类似地,若断言传播系统以值编号而非变量表达,就无需跟踪依赖变量、也无需在变量更新时"杀死"断言。今天 RyuJIT 的 CSE 阶段(optPerformCSE,见PHASE_CSE之前Remove common sub-expressions注释,src/coreclr/jit/compiler.cpp)与 VN 复制传播阶段(PHASE_VN_COPY_PROPoptVnCopyProp)正是沿此思路运作。

2. 稀疏 use-def 图上的需求驱动分析。与传统"为整个方法计算分析事实"的全量流分析相比,SSA 与值编号(在文档所述的混合概念下)都允许在稀疏 use-def 图上做需求驱动分析,把分析精力集中到优化真正需要的地方——这对编译时间敏感的场景(JIT 或 NGEN 编译)尤其重要。RyuJIT 作为运行时 JIT 对编译开销高度敏感,正是文档此点的现实背景。

3. 推理单位从"变量格子"到"不可变值"。转向值编号视角,完成了 SSA 开启的那一步:从"变量是可变的存储单元"的视角,转向"推理变量在每个程序点所持有的不可变值的性质"。值编号编码的信息严格多于 SSA 名字(至少捕获了表达式间的一部分等价),因此许多优化只需要关心"变量或表达式持有的值的性质"——查询值编号即可。

八、总结:从 2009 蓝图到今天的 RyuJIT

将文档设想与当前仓库源码对照,可以得出清晰的"落地清单":

2009 年文档设想当前仓库实现落地状态
SSA 形式构建(phi 节点、重命名、剪枝)SsaBuilder(ssabuilder.h、PHASE_BUILD_SSA已实现(含 EH 后继 phi 等增强)
值编号赋值(常量、primitive、算子元组表)ValueNumStore::VNFor*ConVNForFunc(valuenum.h)已实现
堆内存字段图建模(A$fglobMem、havoc)VNForMapSelect[Work]fgValueNumberFieldLoad/Store(compiler.h)以 map select 机制实现
VN phi 消除与循环值不变证明optVNIsLoopInvariant(compiler.h)、fgValueNumberPhiDef已实现
值编号上的约束传播(需求驱动)optRedundantBranches、断言传播中的VNF_ARR_LENGTH使用已实现(分布形态)
egraph 同余闭包单遍值编号未见于 src/coreclr/jit未落地(文档自评"增加复杂度")
可验证[Pure]属性建模未见于当前仓库未落地(文档明确为展望)

这份文档的价值在于:它用一份 2009 年的规划,清晰阐述了 .NET 托管编译器中"以值为中心"的分析哲学——SSA 提供不可变的名字,值编号捕获名字之上的等价,约束传播在等价之上叠加流敏感事实。而仓库中 src/coreclr/jit/compiler.cpp 的优化流水线,正是这一哲学十余年后仍在使用的活证据。对于想深入 RyuJIT 优化器源码的读者,建议按PHASE_BUILD_SSA → PHASE_VALUE_NUMBER → PHASE_HOIST_LOOP_CODE → PHASE_VN_COPY_PROP → PHASE_OPTIMIZE_BRANCHES的顺序跟踪 src/coreclr/jit 中的对应实现,即可把本文的理论逐一映射到可调试的代码路径上。

【免费下载链接】runtime.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps.项目地址: https://gitcode.com/GitHub_Trending/runtime6/runtime

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/17 2:03:19

高校与初创团队的轻量化智能驾驶数据采集方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/17 2:01:17

Agent-harness定时任务调度实战:从手动触发到自动执行

你有没有过这种经历:Agent 开发完了,测试的时候跑得挺顺,可真上了线,每天早上还是得自己手动触发一次,或者半夜爬起来看它到底执行完没有。我最早搭 Agent-harness 框架时就是这样的状态,直到我把定时任务调…

作者头像 李华
网站建设 2026/9/17 1:57:25

AWS S3大文件上传优化:从putObject到多线程分段上传实践

如果你的项目里还有人在用putObject直接传几个 GB 的大文件,我建议你把这篇文章转给他。前阵子我接手一个数据迁移工具,要从内网把几千个大文件搬到 AWS S3,最初版本就是最简单的单请求上传,一个 1.2GB 的备份文件平均要跑 6 分多…

作者头像 李华
网站建设 2026/9/17 1:55:36

VisionMaster授权更新报错“本地LM通讯出错”排查与解决

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华