源码深度:时间分片与调度策略
篇章 :06-源码深度-Unity增量式GC | 阅读时间 :约 45 分钟 | 前置知识:增量标记原理
一、引言与前提纠正
增量式 GC 的宣传口径通常是"把一次长暂停摊到多帧"。这句话说起来只有一行,工程上却对应一整套调度系统:每帧给 GC 多少时间、一轮标记怎么被切成若干小步、切不完时怎么办、应用把堆改得太快时怎么兜底。本章就在源码层面把这套系统拆开。
拆之前必须先纠正一个流传很广的错误模型。不少文章把 Unity 的增量 GC 描述成"基于 SGen 的 PID 调度器",甚至画出带 IncrementalMarkContext、反馈控制器结构体的伪代码,看起来精密,实际上并不存在:SGen 从来没有随 Unity 产品发布过,Unity 的增量 GC 就是 Boehm-Demers-Weiser 收集器的 incremental mode,Mono 与 IL2CPP 两个后端皆是如此(第 03-04 章已经划清了这条界线)。如果你在别处看到 GCScheduler、target_budget_ms 这类"Unity 源码",可以默认那是作者的想象。
这个虚构模型的危害不只是"名字不对"。PID 控制器的核心假设是:存在一个可以连续调节的控制量(预算毫秒数),系统根据输出误差(上一片实际耗时与目标的偏差)反向修正它。按这个假设做调优,你会去改"预算系数"、去查"平滑因子",而这些参数在 Unity 里根本不存在。真实的系统连"控制量"都不是同一类东西:它不调节"每帧给几毫秒"的连续变量,而是维护一本工作量账本,账本欠多少就干多少,时间上限只是防止干过头的保险丝。把两类模型混为一谈,调优方向从一开始就偏了。
那么真实的调度是什么样?它由两层协作构成:
- A 层:bdwgc 核心的切片引擎 。
GC_collect_a_little_inner(n)是推进增量标记的唯一入口。它不按"毫秒"硬切,而是按"工作亏空"(work deficit)循环推进,用时间上限做兜底。这一层在开源的 Boehm 源码里,可以逐行验证。 - B 层:引擎与托管侧的泵 。玩家循环每帧调用一次
CollectALittle()(IL2CPP 落到GC_collect_a_little,Mono 经由 boehm-gc.c 的绑定),把 A 层的切片引擎"泵"起来;C# 侧的GarbageCollector.incrementalTimeSliceNanoseconds与System.GC.incrementalTimeSliceNanoseconds则是配置这个切片时间上限的旋钮。
两层的分工可以这样概括:A 层回答"这一小步该干多少活、干到什么时候撒手",B 层回答"什么时候开始干、每次允许干多久"。A 层是开源库的通用机制,脱离 Unity 也成立;B 层是 Unity 的集成方式,它决定了这套机制在游戏帧循环里的具体节奏。
真正"自适应"的东西确实存在,但形态和 PID 控制器无关:GC_rate 决定每一步推进要记多少工作量,GC_deficit 做亏空记账,GC_n_attempts 一族计数器统计切片超时的重试次数。这是一套闭环控制,只是它的执行器是"亏空账本"而不是"预算计算器"。反馈量不是"上一片耗时",而是"已经欠了多少工作、连续超时了几次",控制手段不是"加减预算",而是"多跑几步或直接收摊"。本文接下来九节,就沿着这两层协作把机制讲透,每一条结论都以 Boehm 源码与 Unity 托管绑定里能查到的符号为准。
二、为什么标记必须切片,而清扫不必
分片不是均匀的。Boehm 的一轮收集里,只有标记被切成跨帧的小步,初始标记、最终标记和清扫都不切。原因回到两类工作各自的性质。
标记的工作量与"堆大小 × 存活对象图规模"成正比。一轮完整标记要遍历从根出发能到达的整个对象图,堆涨到上百 MB 时这一步就是几十毫秒的量级(示例量级,实际取决于堆布局与存活率)。更麻烦的是它有状态:标记栈、灰色对象队列、每个块的标记位,这些都必须跨帧保存,任意一帧中断后下一帧能从断点精确续上。03-04 章把这称为"标记状态跨帧保留",本章要补的是:这份状态存在 GC 管理的持久区域里,而推进它的函数就是 GC_collect_a_little_inner。
清扫则完全是另一回事。Boehm 不移动对象,对象没有被搬走,地址不变,所以清扫不需要修补任何引用,也不需要和应用线程协商。它要做的只是走一遍堆块,把本轮没有标记的块挂回空闲链表。这活儿不改对象图、没有跨帧一致性问题、速度也远快于标记,为它引入跨帧状态纯属亏本买卖。所以一轮增量周期的形态永远是:
[短暂 STW 初始标记] [帧 k 切片] [帧 k+1 切片] ... [帧 k+n 切片] [短暂 STW 最终标记] [一次性清扫]
根扫描 └──────── 标记被切片,跨帧推进 ────────┘ 脏区补扫 不切片
顺带说明本章的分工:GC_mark_some 内部如何遍历对象图、如何处理脏页,属于 06-02 章的范围,本文只把它当黑盒调用;本文关心的是它外面那一圈:谁调用它、按什么节奏调、超时了怎么办。
把"切标记、不切清扫"这个不对称记住还有个实际用途:读 Profiler 时你能据此判断一根长条是什么。分散在多帧的小段是增量标记在推进,帧尾那根来自最终标记与清扫的短条是收尾动作,而一根突兀的长条要么是非增量模式的全量收集,要么是第六节说的回退。同样是 GC.Collect,形态不同对应的病根完全不同,开错药方比不开药更糟。清扫保持一次性还意味着一件事:增量 GC 对"峰值暂停"的改善是有上限的,堆里存活块多到清扫本身都很慢时,那部分暂停不会因为开了增量而消失,只是相对标记来说它通常不是大头。
三、bdwgc 的切片引擎:GC_collect_a_little_inner
现在进入 A 层的核心。Boehm 源码 alloc.c 里的 GC_collect_a_little_inner(int n) 是增量标记的推进函数,整个调度策略浓缩在它十几行的亏空循环里。先给出示意代码,再逐段解释:
// bdwgc alloc.c(示意,摘取主干逻辑)
void GC_collect_a_little_inner(int n) {
int i;
for (i = 0; i < n; ++i) {
if (GC_deficit <= 0) // 亏空已补平,本轮切片完成
return;
if (!GC_mark_some((ptr_t)0)) // 推进一步标记
break; // 标记已收尾
GC_deficit -= GC_rate; // 完成一步,冲销一份亏空
}
// 亏空还没补平、但时间用尽:按重试策略处理
if (GC_n_attempts < GC_max_prior_attempts
&& GC_time_limit != GC_TIME_UNLIMITED) {
GC_n_attempts++;
(void)GC_stopped_mark(GC_timeout_stop_func); // 放弃本切片,等下帧再续
} else {
(void)GC_stopped_mark(GC_never_stop_func); // 不再等了,一次做完
GC_finish_collection();
}
}
三个变量撑起了整套机制。
GC_rate:每一步该记多少工作量。 GC_rate 是一个"工作量单价",每成功执行一步 GC_mark_some 就从亏空里冲销一份 GC_rate。它在历史上与分配速率挂钩:分配越多,亏空积累越快,同样的 GC_rate 下就要多跑几步才能补平,标记自动提速去追分配。这就是开篇说的"闭环控制":系统不预测未来,只对已经发生的分配记账,然后让标记进度去消账。
GC_deficit:工作亏空账本。 分配路径持续往这个账本里"记债",切片引擎持续"还债"。用亏空而不是毫秒做推进判据,有一个关键好处:工作量驱动保证进度单调 。同一个对象图,同样跑一步 GC_mark_some,完成的物理工作量是确定的,亏空只会单调下降,标记必然朝收敛方向走。如果反过来按毫秒硬切,每帧固定跑 3ms,那么机器快时切片过早结束、浪费帧预算,机器慢时一步 GC_mark_some 都跑不完还会超帧,进度既不可预测也不保证单调。
时间预算兜底。 毫秒预算并没有被丢掉,它换了个位置:GC_mark_some 的推进过程中挂着一个停止判定(timeout stop func),不断检查是否已经超过 GC_time_limit。超了就中断本步,控制权回到 GC_collect_a_little_inner 的尾部,由重试策略决定是"放弃本切片、下帧再续"还是"强制做完"。所以准确的说法是:工作量决定推进多少,时间上限决定何时撒手。两者职责分明,谁也不越位。
对比一下两种切法在真实机器上的表现,这套分工的用意会更清楚。假设同一帧预算里给 GC 的额度是 2ms。按工作量驱动的现行设计,切片先定好要消多少亏空,跑到时间线了就停在自洽边界,这一帧最多吃掉 2ms,剩下的账下帧再还,帧时间的上界被预算锁死。若改成按毫秒硬切,比如"每片恰好跑满 2ms",问题出在步与步之间没有刚好 2ms 的落点:机器快时一步 GC_mark_some 只花 0.5ms,四步跑完还有 1.5ms 空转,这帧的额度没用满,标记进度白白落后;机器慢时一步要 3ms,超过预算却无法从半步里退出来,帧时间被击穿。工作量驱动没有这两个极端,时间只在"步间"检查,粒度与步长天然匹配。
n 参数则决定这一次调用最多愿意跑几步:循环上限是 n 步,每步冲销 GC_rate。引擎侧每帧的调用传入的就是这个步数因子,实际跑几步取决于亏空有多大、时间是否先到。换句话说,n 是"步数上限"而不是"时间预算",真正限制单帧占用时长的始终是那根挂在 GC_mark_some 上的时间判定。
再看 GC_mark_some((ptr_t)0) 这个调用本身。参数传空指针表示"继续当前这轮标记",函数内部沿着标记栈与灰色队列向前推进一段,返回时会告诉你这轮标记是否已经收尾。循环里对返回值的处理很朴素:标记收尾了就 break,没收尾就继续消账。值得注意的是,这个函数就是 06-02 章深挖的那个遍历引擎,本文不重复它的内部细节,只强调一点接口语义:它是可重入的,任何一次调用返回后,堆的标记状态都处于一个合法的中间态,下一帧的下一次调用从这里接着走。这正是"跨帧状态"在函数签名层面的体现。
时间兜底的位置也值得多说一句。它不是在循环外面掐表,而是在 GC_mark_some 推进过程中反复检查。这样设计的好处是撒手的粒度足够细:时间到的瞬间正在处理的对象不会被拦腰截断,标记总能停在一个自洽的边界上。如果改成"跑完整个 GC_mark_some 再看表",一步遍历本身可能就是好几毫秒,时间预算会被大幅击穿,兜底就失去了意义。
四、时间预算 API 链路
亏空循环里的 GC_time_limit 从哪来?这一节从 bdwgc 的全局变量一路追到 C# 属性,把完整链路画出来。
bdwgc 侧(include/gc.h)提供一族接口:
GC_time_limit:全局时间上限变量;GC_set_time_limit(ms):按毫秒设置;GC_set_time_limit_ns(ns):按纳秒设置,托管层用的就是它;GC_TIME_UNLIMITED:哨兵值,表示不限时,配合重试计数器决定是否允许强制完成。
IL2CPP 侧的 BoehmGC.cpp 在初始化时就接上了这条线:BoehmGC::Initialize 在启用写屏障(IL2CPP_ENABLE_WRITE_BARRIERS,增量模式的前提)的构建里调用 GC_set_time_limit(IL2CPP_INCREMENTAL_TIME_SLICE),给增量模式垫一个默认时间片;运行期 SetMaxTimeSliceNs(ns) 直接转发到 GC_set_time_limit_ns(ns)。Mono 侧走的是 boehm-gc.c 的绑定层,增量的开启靠 GC_enable_incremental(),推进入口则是 mono_gc_start_incremental_collection() 转发到 GC_start_incremental_collection()。
托管层的 GarbageCollector.bindings.cs 暴露三个成员:isIncremental(只读,当前是否真跑在增量模式)、incrementalTimeSliceNanoseconds(读写,每片纳秒预算)、CollectIncremental(ulong nanoseconds = 0)(手动推进)。System.GC.incrementalTimeSliceNanoseconds 是同一旋钮的 System 命名空间入口。三者的关系是:属性管"预算给多大",CollectIncremental 管"现在就推一把",isIncremental 管"确认这套机制到底有没有在跑"。整条链路如下:
C# 业务代码
GarbageCollector.incrementalTimeSliceNanoseconds = 2_000_000; // 2ms
│
▼
引擎托管绑定(GarbageCollector.bindings.cs)
│ IL2CPP:internal calls → il2cpp-api(il2cpp_gc_*)
│ Mono: icall → boehm-gc.c 绑定层
▼
运行时桥接层
IL2CPP: BoehmGC::SetMaxTimeSliceNs(ns) Mono: GC_enable_incremental() 一族
│
▼
bdwgc(include/gc.h)
GC_set_time_limit_ns(ns) → 写入全局 GC_time_limit
│
▼
每帧的 GC_collect_a_little_inner
timeout stop func 每步检查 GC_time_limit,超时即中止本切片
值得注意的是单位的落差:托管 API 用纳秒,bdwgc 老接口用毫秒,桥接层负责换算(SetMaxTimeSliceNs 对应纳秒版接口,正是为了避免毫秒精度不够)。这也解释了为什么文档建议用纳秒表达预算:毫秒粒度对"每帧只给 GC 一两毫秒"的场景太粗。
五、每帧调度时序
B 层的泵在什么时机打下去?先说诚实的边界:玩家循环内部具体在哪个阶段调用 CollectALittle(),属于 UnityPlayer 闭源部分,公开资料没有给出确切位置。可以确定的是行为层面的事实:启用增量后,引擎每帧会调用一次切片推进(对应 il2cpp_gc_collect_a_little 或 Mono 等价路径),把 A 层的亏空循环跑起来;而"这一帧的 GC 时间从哪挤出来"(帧尾空闲、Update 之前的调度缝隙),是引擎基于帧节奏做的安排,外部只能推断。
多帧推进的时序可以画成这样:
帧 k(触发轮):[初始标记 STW][玩家逻辑 Update][渲染提交] + 切片 1
帧 k+1: [切片 2][玩家逻辑 Update][渲染提交]
帧 k+2: [切片 3][玩家逻辑 Update][渲染提交]
...
帧 k+n: [最终标记 STW][清扫][玩家逻辑 Update][渲染提交]
↑ 一轮标记结束,切片停止
(切片在帧内的具体位置为推断示意;可观测的事实是每帧至多一次切片调用,以及 Profiler Timeline 上分散的小段 GC 开销。)
可观察面有三个。第一,Profiler 的 CPU Timeline 上,GC.Collect 相关的段从非增量模式的一根长条变成分散在多帧的小段,这直接验证"每帧一次切片"在工作。第二,GC Allocated In Frame 计数器的形态不变,因为增量只重排暂停、不改变分配量。第三,把 incrementalTimeSliceNanoseconds 临时调小,一轮收集跨的帧数会明显变多,这是从外部反推切片机制还在运转的最直接实验。
关于"帧尾空闲"再补一句衔接 03-04 的话:VSync 或 Application.targetFrameRate 至少要配一个,帧与帧之间才有等待窗口可供切片安放。两者都不设、帧率跑满时,每帧被逻辑和渲染塞满,切片只能和主线程抢时间,一轮标记拖长,更容易滑向第六节的回退路径。
还可以从"调用次数"的角度把两层的节奏对齐。B 层的泵是每帧一发,A 层的引擎是每发最多 n 步。两层相乘决定了标记的名义推进速率,而真正把它拉回现实的是两个衰减因素:其一是时间预算,步子跑到一半就被 timeout stop func 掐断,名义步数跑不满;其二是亏空账本的余额,账本空了引擎提前收工,这一帧本可以多干的活也没了干头。所以"每帧推进多少"从来不是一个配置项,它是预算、账本余额、机器速度三者当场结算出来的结果。这也解释了为什么同样的项目在不同机型上,一轮增量跨越的帧数会差出一截:高端机每片消化的亏空多,轮次自然更短;低端机每片能干的活少,同一本账要还更久,掉进回退的风险也更高。把这层记在心里,第九节调优时"按机型分开验证"就不显得多余了。
顺带说明 Editor 与 Player 的差别:编辑器自身的重绘、菜单、多窗口都会改变帧节奏,切片落在帧内的位置和长度跟真机分布不同。本章所有关于时序的推断,验收时都必须以 Development Build 连真机 Profiler 的数据为准,这一点与 03-04 第 8.3 节的结论一致。
六、超时、重试与回退
切片引擎最微妙的分支在尾部那段重试逻辑。回顾第三节的示意:当亏空还没补平、时间却已用尽时,走哪条路取决于 GC_n_attempts 与 GC_max_prior_attempts 的比较。
先解释为什么会出现"切片总是超时"。增量标记期间,应用线程照常改写引用,Boehm 用脏位(VDB 或写屏障,见 03-04 第 4.3 节)记录被改写的区域,最终标记阶段要补扫这些区域。如果应用改引用的速度太快,本轮窗口内积累的脏区越来越大,补扫工作随切片推进不降反升,每片的物理工作量膨胀,固定的纳秒预算就永远不够用。表现在账本上:GC_deficit 迟迟补不平,每片都以超时告终,GC_n_attempts 一路累加。
超过 GC_max_prior_attempts 之后,引擎放弃等待:走 GC_stopped_mark(GC_never_stop_func) 加 GC_finish_collection(),在一个 STW 里把整轮标记与后续工作强行做完。这就是官方文档说的 "falls back to a full, non-incremental collection"(回退为一次全量非增量收集)的源码落点。整理成状态流:
切片执行 → 时间到但亏空未平
│
▼
GC_n_attempts < max_prior_attempts ? ── 是 ──→ 放弃本切片,下帧续切(增量继续)
│ 否
▼
GC_stopped_mark(GC_never_stop_func)
GC_finish_collection() ← 回退:一个 STW 里做完,等价非增量
回退是安全兜底,不会漏标也不会崩,但经济账最难看:前面若干帧已经付了切片开销和写屏障开销,最后还要挨一记接近非增量水平的长暂停,这一轮的增量收益归零。识别信号在 Profiler 上很典型:大部分帧的 GC 都是毫秒级小段,却周期性冒出一根长条,通常就是回退在发生。
这里可以顺手回答一个常见疑问:既然回退这么亏,为什么不把重试上限设得很大,无限期地等下去?答案藏在"边跑程序边标记"这个前提里。应用不会为了等 GC 而停下来,改引用的速率也不会因为 GC 还没收敛就自动降下来。如果放任重试无限累积,最坏情况是一轮标记拖过几十帧甚至上百帧:这段时间里每帧都付着切片开销与写屏障开销,堆还在持续膨胀,浮动垃圾越积越多,而收敛依然遥遥无期。重试上限的作用就是给这种"越陷越深"的状态画一条止损线,宁可立刻挨一记大暂停,也不允许收集器进入无界等待。换句话说,回退不是调度失误,而是设计者刻意保留的出口,GC_never_stop_func 这个名字本身就说明了它的语义:这一次不再让时间打断我。
治理方向不在调度器,而在堆:降低分配速率、减少大对象图的逐帧重建,脏区生成速度降下来,切片自然能收敛。另有一个相关的开启行为值得知道:GC_enable_incremental() 在堆上已有分配时会先强制做一次全量收集,把增量周期从干净的堆开始,这属于一次性的启动成本,只在特性启用的那一刻发生,不会在运行期反复出现。
七、分配路径的切片泵
一个容易被忽略的事实:切片不只在帧回调里被泵起来。alloc.c 的分配慢路径(约 1564 至 1584 行区间)在时间限制下同样会推进切片。含义是:分配本身会触发标记推进。堆上每分配一批对象,亏空账本就记一笔债,慢路径顺手还一点,标记进度和堆增长被耦合在同一处代码里。
这个设计有两面。好的一面是标记进度天然追着分配速率走:分配越猛,泵打得越勤,标记不会落后太远,这让"高分配负载下标记迟迟无法收敛"比想象中稍难发生。坏的一面也来自同一条耦合:分配增长的速度如果持续快过标记消债的速度,亏空账本越积越厚,一轮标记的总工作量随堆膨胀而膨胀,切片数被拉长,撞上时间预算与重试上限的概率同步上升,最终仍可能落入第六节的回退。官方文档说 "too many changes might overwhelm"(引用变动太多可能压垮增量),在源码层面的机制正是"分配记账追不上、脏区补扫膨胀"这两条线的合流。
对性能分析的推论:看到一轮增量标记拖了很多帧才完成时,先查的不是调度参数,而是这一窗口内的分配速率与引用改写密度。切片引擎只会按既定规则消账,它救不了一个每帧重建整棵对象图的项目。
还可以把这条耦合讲得更具体些。分配慢路径推进切片意味着两件事同时为真。第一,一个"分配大户"应用其实在帮 GC 干活:每次触发慢路径都在给标记还债,标记进度被分配推着走,看起来像是一种良性互动。第二,这种互动有上限:分配带来亏空的同时也带来新的堆块与新的存活候选,债越记越厚、单笔债务越来越大,慢路径里那点顺手归还的量就逐渐变得杯水车薪。当"记债速度"稳定压过"还债速度",亏空账本不再围绕零轴波动而是单调上扬,一轮标记的工作总量就被拖着不断变大,切片数量跟着膨胀,时间预算与重试上限被逐一触碰。用一句更工程化的话说:切片引擎是一个以分配速率为扰动输入的负反馈系统,扰动在系统承受范围内时它自会收敛,扰动持续超过消化能力时它唯一的出路就是第六节那条回退支线。官方文档警告引用变动太多会压垮增量,落在这一节的机制上,就是"扰动超出系统带宽"的另一种说法。
八、手动干预 API
自动泵之外,托管层留了两个手动入口。
GarbageCollector.CollectIncremental(ulong nanoseconds = 0) 直接推进一次增量收集,返回值指示这轮是否已经完成。nanoseconds 参数允许为这一次调用单独指定预算,传 0 则沿用全局 incrementalTimeSliceNanoseconds。Manual 模式下引擎不再自动触发,每帧调用一次 CollectIncremental() 就相当于自己接管了 B 层的泵。
System.GC.Collect() 则是另一条路:无视当前模式,立刻做一次全量阻塞收集,等于把整轮工作一次性付清。它和 CollectIncremental 的关系不是竞争而是分工。典型的协作剧本是场景加载点:
// 加载期协作示意:先付清,再恢复自动增量
void OnSceneLoadStart()
{
// 承受一次可预测的暂停,把堆清到干净状态
System.GC.Collect();
}
void OnSceneLoadDone()
{
// 恢复自动增量;如需微调,可在加载期临时放大预算
GarbageCollector.GCMode = GarbageCollector.Mode.Incremental;
GarbageCollector.incrementalTimeSliceNanoseconds = savedSlice;
}
选择加载点做全量收集的理由很实际:加载期间帧率本就不敏感,一次可预测的长暂停远好过战斗帧里突如其来的回退。反过来说,CollectIncremental 的适用场景是"我想精确控制这一轮何时开始、每片多大"的框架层需求,比如回合制或过场动画这类能预知空闲窗口的节奏点。两个入口都只是把预算喂给第三节那台亏空循环机器,机器本身没有变。
手动干预还有一个容易踩的坑值得点出:CollectIncremental 只推进、不保证完成。它返回一个布尔值告诉你"这一轮是不是已经收尾",返回 false 意味着标记还跨在半空,你得在后续帧继续喂,直到它返回 true。指望一次调用换回一次干净堆是不成立的,真要"现在就把堆清干净",那该用的是 GC.Collect。两个 API 的分工可以记成一句话:CollectIncremental 是踩离合,控制节奏;GC.Collect 是直接挂挡到底,一次付清。混用这两者写出来的加载流程,常见症状就是加载结束时标记还没跑完,进战斗后第一波分配立刻把残余的增量周期顶进回退。
九、调优实践:incrementalTimeSliceNanoseconds 取多少
最后一节是工程旋钮。incrementalTimeSliceNanoseconds 的取值是一场双向权衡:
- 取太小:每片推进少,一轮标记跨的帧数变多,标记周期拉长;增量窗口越长,"边扫边生"的浮动垃圾窗口越长,脏区积累时间也越长,更容易顶到重试上限触发回退。
- 取太大:单片逼近甚至超过一帧预算,增量退化成"分几次做的非增量",峰值暂停重新变大,失去开增量的意义。
一个常用的经验区间是把单片预算定在帧预算的 5% 到 15%。注意这是工程经验估算,不是官方常数,Unity 没有公布任何"推荐毫秒数"。按这个区间换算到常见目标帧率:
| 目标帧率 | 帧预算 | 5% 档(典型估算) | 15% 档(典型估算) | 常用落点(典型估算) |
|---|---|---|---|---|
| 30 FPS | 33.3ms | 1.7ms | 5.0ms | 2 ~ 4ms |
| 60 FPS | 16.7ms | 0.8ms | 2.5ms | 1 ~ 2ms |
| 90 FPS | 11.1ms | 0.6ms | 1.7ms | 0.8 ~ 1.5ms |
| 120 FPS | 8.3ms | 0.4ms | 1.2ms | 0.6 ~ 1ms |
(表中全部为典型估算,用于给出数量级直觉;真实最优值取决于堆规模、分配速率与目标机型,必须用 Development Build 连真机 Profiler 校准。)
调参的实际顺序建议是:先按目标帧率选中间档落点,然后在最重的战斗场景跑 A/B。如果 Timeline 上切片段稳定、长条罕见,说明预算合适;如果周期性出现长条回退,优先查分配速率而不是先调大预算,因为预算调大只是把回退推迟,堆增长问题不会因此消失;如果帧时间本身被切片顶破,说明项目帧预算太紧,先把帧内余量做出来再谈分片。另有一个反直觉的点:低端机上机器慢,同样的纳秒预算能完成的工作量少,等效"每片标记的对象数"下降,这天然是一种按机型的自适应,不需要额外做分档配置,除非低端机上回退频率明显升高,那时再为低端档单独调小预算并同时压分配。
十、本章小结
把全章收拢成几句话。Unity 增量 GC 没有 PID 调度器,有的是两层协作:bdwgc 的 GC_collect_a_little_inner 按工作亏空循环推进标记,GC_rate、GC_deficit、GC_n_attempts 构成闭环记账;引擎侧每帧一次 CollectALittle() 把这台机器泵起来,托管层的 incrementalTimeSliceNanoseconds 与 CollectIncremental 提供配置与手动干预入口。工作量驱动保证进度单调,纳秒预算通过 timeout stop func 兜底,重试上限耗尽后回退为一次全量 STW。标记切片、清扫不切片,是因为前者有跨帧状态而后者没有;分配路径自己也泵切片,所以堆增长和标记进度天然耦合,高分配速率是回退的第一诱因。调优围绕帧预算的 5% 到 15% 起步,最终以真机 Profiler 的分布尾部数据定案。
本章总结
真实机制可以用一条链路概括:C# 的 incrementalTimeSliceNanoseconds 经绑定层落到 bdwgc 的 GC_set_time_limit_ns,写入 GC_time_limit;每帧引擎调用一次 GC_collect_a_little_inner,亏空循环调用 GC_mark_some 推进标记并按 GC_rate 冲销 GC_deficit,时间到则由 timeout stop func 中止切片;反复超时超过重试上限时,GC_stopped_mark(GC_never_stop_func) 强制在一个 STW 里完成整轮,这就是官方文档所述的回退为全量非增量收集。切片只作用于标记,清扫因不移动对象而保持一次性。调优的杠杆是预算与分配速率两头:预算按帧预算的 5% 到 15% 典型估算起步,分配速率则是回退与否的决定因素,任何调度参数都替代不了把堆增长压下来这件事。