游戏引擎-帧快照优化实践:从数据交接到高效流水线

文章目录

实时图形系统遇到 CPU 瓶颈时,最容易想到的办法是增加工作线程:对象筛选放一组任务,实例复制放一组任务,绘制命令再放一组任务。

但线程增加之后,帧时间不一定下降。数据还是那些数据,复制还是那些复制,只是多出了任务提交、临时分配、结果归并和等待。有时还会出现更难解释的问题:偶发的画面闪动、材质修改污染旧帧、场景切换后的迟到回调,以及只在某个线程数量下复现的崩溃。

这些现象提醒我们:在讨论“谁来算”之前,应该先回答“到底交出去的是什么”。

本文将一条真实的数据链演进抽象为独立的工程实践:从借用场景对象,到建立拥有数据的只读帧快照,再到选择性提取、不可变版本共享、连续内存布局和受控并行。重点不是介绍一个结构体,而是解释这条边界为什么能改变后续优化的难度。

文中的实现示意均为伪代码。实验数字来自历史局部微基准,不代表完整帧率,也不代表所有设备;跨帧重叠等尚未由这些实验验证的方案,会单独作为后续演进讨论。

一、瓶颈不只是复制,而是没有明确的交接时刻

先看一种很常见的接口:

1
2
3
4
5
6
7
8
9
function updateAndDraw(scene):
lights = collectPointers(scene.lights)
meshes = collectPointers(scene.meshes)

draw({
camera: referenceTo(scene.camera),
lights: lights,
meshes: meshes
})

这段代码能够工作,通常依靠一个没有写在类型里的约定:在绘制函数返回之前,场景不能继续变化。

这里传递的不是“这一帧的灯光和网格”,而是“去哪里读取灯光和网格”。两者差别很大。

如果下游稍晚一点读取相机,它可能拿到下一次更新后的姿态;如果资源在中途替换,下游可能同时看到旧的几何信息与新的材质状态。即使所有指针都标成只读,也只能限制当前访问路径,不能阻止其他持有者修改同一对象。

还有更隐蔽的问题:所谓的读取函数,可能会更新缓存。

1
2
3
4
5
function getWorldMatrix():
if dirty:
cachedMatrix = parent.getWorldMatrix() × localMatrix
dirty = false
return cachedMatrix

它看起来在“获取矩阵”,实际上可能沿着父子层级执行一串写操作。直接把调用它的循环分给多个线程,并不会因为接口名里有 get 就变得安全。

实际演进也未必从“完全没有快照”开始。系统内部往往已经复制过矩阵、筛选过对象,甚至已有骨骼共享和上传合并,只是这些工作藏在绘制入口内部。相机、设置、调试数据等其他输入,仍然借用外部对象。

因此,第一步不是推倒已有优化,而是把局部快照前移并补全,形成真正的交接边界:

交接之前,生产者可以修改;交接之后,消费者不再追着生产者的可变状态读取。

这条边界可以先在同一个线程里成立。它解决的是数据和生命周期问题,不要求同时新增渲染线程。

二、不可变快照:冻结的是依赖关系,不只是顶层结构

1. 把“最后一次修改”与“只读提取”分开

一个可控的准备流程可以写成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function prepareSnapshot(scene, frameContext):
require runningOnCoordinator()

finishAuthoritativeUpdates(scene)
finalizePresentationState(scene)
resolveLazyCaches(scene)

beginStableReadWindow(scene)
try:
builder = newPrivateBuilder(frameContext.identity)
builder.views = freezeViews(scene)
builder.visibility = buildVisibilityPlan(builder.views)

selection = selectRequiredObjects(scene, builder.visibility)
builder.geometry = extractGeometry(selection)

joinAllAcceptedPreparationTasks()
builder.resources = retainRequiredResourceVersions(builder)
verifyFrameStillEligible(frameContext)
return sealAndRemoveWritableAliases(builder)
finally:
drainAnyUnjoinedPreparationTasksBeforeStorageRelease()
endStableReadWindow(scene)

这里的“稳定读取窗口”代表调用约定或访问控制机制,不一定是一把全局锁。关键是:从计数到填充结束,源对象的数量、布局和相关状态不能变化。

伪代码中的选择与提取子过程,在返回可消费结果之前会收口自己的任务;异常退出也要先排空仍被接受的任务,再释放它们引用的存储、结束稳定窗口。后文会进一步解释这种失败处理为什么不能省略。

变换回写、灯光位置更新、目标创建、资源准备等工作,仍可能需要在协调线程上执行。真正可以交给工作线程的,是其中已经证明只读、输出互不冲突的子任务。不能把整个准备函数包成一个后台任务,就宣称完成了并行化。

“最后一次修改”还要区分模拟状态与表现状态。例如固定步长模拟中的前后两个姿态,可以生成供本帧使用的插值姿态,但不能为了绘制平滑,把插值结果写回模拟的权威状态。

快照应该保存“本次展示决定使用的值”,而不是顺便改变下一次模拟的起点。

同理,对象设置的细节等级策略属于可编辑状态,按相机计算出的本帧选级结果属于快照。提取结果不应覆盖对象原本的策略。

2. 不同数据,使用不同的冻结方式

快照并不意味着深拷贝整个场景。合理的方案通常是混合所有权:

数据类别 交接方式 原因
相机参数、灯光值、变换、实例标记 复制本帧所需值 小而易变,需要与源对象隔离
材质参数、已求值骨骼姿态 共享不可变版本,按需打包 大量引用相同内容,重复复制浪费明显
网格、纹理等长期资源 持有对应版本的强引用 不必每帧复制资源本体,但必须保证版本和存活期
绘制目标、回读目标 独立绑定,并记录身份与尺寸约束 它们属于消费端执行环境,不是场景值本身
统计、拾取等异步结果 带身份返回独立反馈通道 不能反向修改已经发布的快照

容易遗漏的是二级引用。复制了一份调试绘制命令,并不意味着复制了命令指向的网格记录;复制了一份后处理请求,也不意味着它引用的参数对象已经稳定。

检查快照时,应该沿着引用关系继续追问:

从这个字段出发,消费者最终会读到的每一块存储,是否由快照拥有,或者由明确的不可变版本契约保护?

因此,私有构建器和公开只读句柄很有价值。构建器负责分配与写入,封存之后不再暴露可写别名;消费者需要临时修改调试信息时,使用自己的工作区,而不是修改已发布数据。

封存是所有权规则,不是内存屏障。同线程消费可以依靠程序顺序;跨线程交接还需要具有正确同步语义的队列或锁,让封存之前的写入对消费者可见。仅仅交换一个裸指针,并不构成安全发布。

公开句柄也不必暴露场景容器、调度器或图形后端类型。不过,公共接口轻量,不等于内部资源模型已经完全后端无关;拥有数据,也不等于这些数据可以直接序列化到磁盘。这是几项不同的能力。

三、发布不是函数返回,完成也不是一个布尔值

1. 先准备全部输入,再启动有副作用的消费者

假设同一帧需要交给画面、声音和界面三个消费者。如果声音已经开始播放,才发现绘制目标在准备后被重建,那么这一帧就只执行了一半。

可以用一个统一发布点降低这种风险:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
function publishFrame(stagedPayloads, ticket):
require publicationState == Preparing

try:
finishAllDeferredPreparation()
releaseTemporarySceneReaders()

for payload in stagedPayloads:
if not ticket.isEligible() or not preflight(payload):
return finishWithoutReplay(Cancelled)

if not claimSinglePublication(ticket):
return finishWithoutReplay(Cancelled)

publicationState = Consuming
for consumer in stableConsumerOrder:
if not ticket.isEligible():
return finishWithoutReplay(Cancelled)
consumer.applyItsPayload()

if not ticket.isEligible():
return finishWithoutReplay(Cancelled)
return finishWithoutReplay(Completed)
catch error:
return finishWithoutReplay(Failed, error)
finally:
releaseRemainingTemporaryReadersAndPreparedHandles()

这里的预检应检查资源版本、目标身份、尺寸和回读容量等条件,不提前播放声音或提交设备工作。

示意中的结束函数会记录不可再次启动的终态;清理准备数据时,消费者已经移交给录制或提交对象的资源保活引用不受影响。

但这不是数据库式的完整原子事务。预检通过之后,消费者仍可能失败;已经播放的声音,也不一定能够撤回。这个发布点提供的是“先统一检查,再至多启动一次”的边界,不是“任何失败都能回滚”。

所以,准备阶段的纯计算失败可以考虑重做,消费阶段的副作用不能自动整帧重放。若某类消费者需要重试,必须另行定义幂等性、事件序号或确认机制。

2. 至少分清五个完成时刻

时刻 能说明什么 不能据此做什么
准备任务全部收口 这些任务不再读源数据、写临时结果 不代表所有输入已经预检通过
发布资格取得 输入已预检,本帧开始至多一次消费 不代表所有消费者都执行成功
CPU 录制及输入读取结束 消费端不再需要对应 CPU 构建数据 不代表设备不再使用上传内存和资源
提交成功 工作已经交给设备队列 不能立即覆盖在途缓冲或销毁资源
设备执行完成 本次提交对相关资源的使用结束 不代表结果仍适用于当前场景对象

这样的区分,决定了内存应该由谁持有。

大块 CPU 快照与设备资源保活集合应当分开。只要所有 CPU 读者结束,且设备要用的数据已经复制进受保护的上传存储,就可以释放不再需要的 CPU 数组;网格、纹理、上传页等仍由本次录制或提交对应的生命周期持有。

1
2
3
4
5
6
7
8
9
10
11
recording.retain(snapshot.deviceResourceLeases)
recording.retain(outputOwners)

copyRequiredBytesToProtectedUploadStorage(snapshot)
recordCommandsFrom(snapshot)
joinAllRecordingReaders()

releaseCpuSnapshotWhenNoCpuReaderRemains()

onDeviceCompletion(recording):
releaseRecordingLeases()

如果选择让设备直接读取快照所在的内存,这块内存就已经承担上传存储的职责,必须保留到设备读完,不能再按普通 CPU 临时数组释放。

未提交的录制可以在安全撤销后释放保活资源;已经提交的工作不能仅凭一个 CPU 取消标志就当作没有发生。等待或提交失败也不能简单转换成“已完成”,应由后端的错误处理与清理协议决定何时可以释放。

3. 身份校验与资源保活不能互相替代

“对象还活着”与“它还是这一帧需要的版本”是两个问题。

一个长期资源可能仍在同一个地址,却已经换了内部存储;一个对象编号也可能在删除后被新对象复用。因此可以按职责使用几组身份:生产者及其序号、场景身份及代次、资源版本,以及异步反馈所指向对象的代次。

强引用解决存活问题,版本校验解决误用问题。更理想的更新方式是发布新版本,让旧帧继续持有旧版本;如果允许原地修改,就必须有额外的排他与失效规则。消费前比对一次版本,不能代替对并发写入的控制。

输出绑定也一样。先检查不依赖目标裸指针解引用的存活标记,再访问目标,可以拒绝已经销毁的绑定;但若另一线程能在检查之后立刻销毁它,竞态仍然存在。因此检查到消费结束之间,还需要持有者或线程约定保证其稳定。

异步反馈则不能简单要求“结果帧号必须等于当前帧号”。设备结果正常就可能晚几帧到达。它应先通过场景代次、对象或视图代次等检查,再由业务决定是否接纳这个时刻的结果。若存在多个未完成请求,还要按请求序号处理乱序,避免较旧结果覆盖较新结果。

四、第一类性能优化:先决定哪些数据值得进入快照

边界成立之后,最直接的优化问题不是如何复制得更快,而是哪些东西根本不必复制。

假设场景有十万个对象,本帧相关的只有几千个。先复制十万个对象再剔除,与先筛选再复制,最终绘制数量可能完全相同,但 CPU 访问量、分配量和内存峰值明显不同。

不过,“相关”不能只等于主相机看得到。

1. 按所有消费者的需求取并集

画面之外的物体,仍可能向画面内投射阴影。其他相机也可能需要主相机看不到的物体。若某种间接光照算法依赖更大范围的几何,直接沿用相机剔除结果,就会产生画面错误。

1
2
3
4
5
6
7
8
9
function requiredByFrame(object, plan):
if plan.indirectLightingNeedsWholeScene:
return true

if intersectsAny(object.bounds, plan.cameraVolumes):
return true

return object.canCastShadow
and intersectsAny(object.bounds, plan.shadowVolumes)

这是粗粒度保留判定。进入快照的对象,仍可在后续按具体视图和绘制用途继续筛选。

只参与相机绘制的贴花,不必无条件继承阴影保留规则。不同类别应该使用自己的需求契约,而不是套用同一个最宽松的集合。

当某个消费者尚未给出可靠的空间需求范围时,保守保留是一条合理的正确性退路。优化不能以“多数时候看不出来”为删除数据的依据。

可见性计划最好由同一份冻结的相机和设置生成,并在本帧复用。若还用它提前减少动画求值,必须保证动画前的包围体足够保守,否则角色的一部分运动到原包围体之外时,可能被错误跳过。

2. 静态对象与动态对象分开处理

动态对象需要随运动更新;大量静态对象则不必每帧重建变换和索引。

一种实用结构是:动态对象直接筛选,静态对象通过长期维护的空间索引查询,最后按稳定对象顺序归并。稳定帧复用索引,相关内容变化时使其失效并重建。

这个方案也需要异常路径:特别大的对象可能不适合普通网格单元,无法可靠建立索引的对象则应进入受控回退检查,不能因为插入失败就静默消失。

还要诚实区分两种优化:脏时重建减少了稳定帧的重复工作,但不等于已经实现逐对象增量维护。若编辑或场景变更频繁,局部更新才可能成为下一步值得测量的方向。

五、第二类性能优化:让重复数据在生产时就共享

快照的早期版本通常偏向直接复制。这容易建立正确性,却也容易把场景对象里的可编辑容器一并搬进每一帧。

接下来的演进,是把“可编辑实例”和“只读绘制状态”拆开。

1. 材质参数:把重复准备改成按变更付费

考虑几百个对象共享同一种材质的默认参数。若每个实例都拥有自己的属性数组和纹理字典,每帧又各复制一次,系统实际上在重复处理同一份状态。

可以让实例只引用一个不可变参数版本,修改时再发布替代版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
function setParameter(instance, name, value):
oldVersion = instance.parameters

if oldVersion.valueOf(name) == value:
return

replacement = copyForEditing(oldVersion)
replacement.set(name, value)
validateNamesTypesAndLayout(replacement)
replacement.packedBytes = packParameters(replacement)
replacement.textureBindings = buildSortedBindings(replacement)

instance.parameters = freeze(replacement)

旧帧继续引用旧版本;修改后的实例使用新版本;其他没有修改的实例不受影响。同值写入直接返回,避免制造没有意义的新版本。

这样,属性打包和纹理绑定整理可以在参数变化时完成,而不是在每个稳定帧里重新构建。注意,这减少的是准备成本;采用逐帧上传页时,不变参数仍可能每帧上传一次,并不自动成为跨帧设备缓存。

帧内再以参数版本身份建立上传索引:

1
2
3
4
5
6
7
function internParameters(version):
if parameterTable.contains(version.identity):
return parameterTable[version.identity]

location = appendToCompatibleLayoutGroup(version.packedBytes)
parameterTable[version.identity] = location
return location

假设 512 个实例共享一个按布局对齐后为 256 字节的参数块,属性载荷可以从 512 × 256 = 128 KiB 降到 256 B。这里比较的是这一类属性载荷,不包括对象引用、索引表和其他帧数据,也不是整个帧内存缩小了 512 倍。

同一材质模板不代表同一参数版本;两个内容暂时相同、但独立创建的版本,也不一定会被这个身份表合并。它利用的是生产者已经确认的共享关系,不是在每帧做全量内容查重。

2. 骨骼姿态:按一次求值结果共享

同一个角色可能由身体、服装和装备等多个独立网格部件组成。它们如果使用同一套已经求值的骨骼矩阵,就没有必要各复制一份。

假设一套姿态有 128 个矩阵,每个矩阵占 64 字节。一份是 8 KiB,四个部件各复制一次就是 32 KiB。若四者共享同一个不可变姿态对象,帧内只需打包 8 KiB,部件保存相同的起始偏移。

共享的前提不只是“看起来姿态一样”,还包括骨骼布局、矩阵含义和使用版本一致。不同角色恰好站成相似姿势,不能因此直接共用矩阵。

帧内身份表还可以保持很简单:连续部件经常引用同一姿态,可以先比较上一次命中的身份;未命中再查紧凑哈希表。容量按唯一姿态数量估算,而不是按所有刚体实例数量估算,避免为了一个很小的骨骼集合分配很大的表。

身份去重依赖对象在这次查表期间保持存活且不可变,不能把已经释放对象的地址跨帧保存为永久键。

这两类优化有一个共同点:

与其让消费者反复判断两份数据是否相同,不如让生产者明确表达它们本来就是同一份数据。

代价也需要承认。如果参数每帧大量变化,新版本分配与引用计数本身可能成为负担;粒度过大的参数块还会放大修改成本。这时应继续测量版本创建量,再考虑拆分热冷字段或更合适的存储策略。

六、第三类性能优化:先确定内存归属,再并行填充

减少了不需要的数据,也合并了重复数据之后,剩下的工作才适合讨论并行。

1. 从临时容器归并,走向计数与固定区间

一个自然的并行版本是让每个任务建立自己的结果数组,最后把数组拼起来。它可以成立,但可能带来多次分配、额外搬运和索引重定位。

对于“每个输入产生数量不等的输出”这类问题,可以采用两遍处理。

先计数,再计算前缀偏移。例如四个输入分别产生 2、0、3、1 个子项,对应偏移就是 0、2、2、5、6。由此,每个输入的输出范围在任务开始前就已经确定。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
counts = array(inputCount)
offsets = array(inputCount + 1)
offsets[0] = 0

for i in inputOrder:
counts[i] = countRequiredParts(inputs[i])
offsets[i + 1] = checkedAdd(offsets[i], counts[i])

output = allocateInitializedArray(offsets[inputCount])

parallelForEachInputRange(range):
for i in range:
cursor = offsets[i]
for part in requiredParts(inputs[i]):
output[cursor] = freezePart(part)
cursor += 1
require cursor == offsets[i + 1]

joinAllRanges()

这里重要的不是用了多少线程,而是每个任务都提前知道自己能写哪一段内存。任务不需要竞争共享的追加位置,最终顺序也不依赖谁先执行完。

注意,预留容量和创建可写元素不是一回事。在需要显式对象生命周期的语言里,只有容量、没有合法元素的数组,不能直接按下标写入;若选择原始未初始化存储,就必须另外保证构造、失败清理和析构规则。

这种布局也有利于稳定引用。若绘制条目保存了指向子项数组的内部引用,数组扩容会使旧引用失效。可以在建立引用之前固定存储,也可以尽量使用索引;不能只冻结最外层对象,就忽略内部数组的重定位。

两遍法并不免费。计数本身也是一次遍历,分配初始化也有成本。若每个输入只产生一个很小的输出,简单的连续串行循环完全可能更快。

2. 不必一次把所有工作并行化

一个实用的落地范围是:先只并行实例几何子项的冻结,变换汇集、骨骼去重、材质归并和资源使用记录仍由协调者处理。

这些步骤有的涉及共享索引,有的需要稳定归并,有的会修改服务状态。把它们留在明确的收口位置,比为了“全并行”立刻引入多个共享并发容器更容易验证。

未来若确实测到这些阶段成为瓶颈,再考虑为变换分配固定槽、对唯一姿态先编号、对材质版本建立稳定映射,之后分发互不重叠的填充范围。

输出顺序稳定能消除一类不确定性,却不自动保证整个系统在不同平台上浮点结果逐位相同;它解决的是任务完成次序影响布局的问题。

3. 失败路径必须先排空,再重做

假设六个任务已经提交,前四个被接受,第五个因队列容量不足而被拒绝。此时立即清空输出并执行串行回退,会与仍在运行的任务同时写同一块内存。

安全的批次适配器应该保证:无论成功还是失败,返回时都不再有已接受的任务访问借用数据。

1
2
3
4
5
6
7
8
9
10
11
12
result = runIndexedPreparationBatch(taskCount, function(index):
fillAssignedRange(index)
)
# 上面的调用在所有返回路径上,都会等待已接受任务退出。

if result.succeeded:
usePreparedOutput()
else if result.isCancellationOrShutdown:
discardThisFrame()
else:
discardPartialOutput()
output = runPureExtractionSerially()

串行回退只重做纯提取,不能重新运行模拟、播放事件或其他有副作用的步骤。若帧已经取消或系统正在停止,也不应以“回退”为名继续把工作做完。

批次等待只覆盖本帧接纳的任务,不需要等待所有后台服务空闲。等待放在协调边界,也不意味着所有领域内部的依赖等待都能删除;其他算法仍可能有动态生成任务或特定的推进要求。

在接口层,借用一个按索引调用的函数对象,也可以避免为每个任务重复复制一大份捕获状态。但这种借用成立的条件,正是批次退出前已经彻底收口。生命周期约束既是正确性条件,也给减少包装开销提供了空间。

4. 并行是否划算,要算包含调度的总账

一个简化模型是:

1
2
3
4
5
6
7
串行耗时 ≈ 有效提取工作

并行耗时 ≈ 计数与分配
+ 任务提交
+ 最慢任务的执行时间
+ 等待与归并
+ 缓存和内存带宽竞争

真实执行中部分成本可以重叠,这不是精确预测式,而是提醒我们不要只测任务函数内部的循环。

并行策略因此应当可关闭,并受最小工作量和任务粒度约束。最初可以使用实例数量作为阈值,但一百个复杂角色与一百个简单物件的成本并不相同。后续可以结合子项数量、唯一姿态矩阵数量和待复制字节数估计工作量。

如果瓶颈已经是内存带宽,继续增加线程不一定有用。前面的筛选、共享和紧凑布局,反而可能比扩大线程池更有效。

七、从 CPU 快照到设备上传:连续布局还要接上正确生命周期

CPU 端已经整理好的矩阵、参数和实例数据,如果在消费时又被拆成大量独立小上传,收益仍会被削弱。

一种自然的衔接方式,是把本次绘制的 CPU 生产数据放进一批连续上传存储,通过切片描述各类数据:

1
2
3
4
5
6
7
8
transformSlice = uploadBatch.append(snapshot.transforms, requiredAlignment)
skinSlice = uploadBatch.append(snapshot.uniquePoses, requiredAlignment)
materialSlice = uploadBatch.append(snapshot.materialBytes, requiredAlignment)

publishUploadWritesBeforeConsumers(uploadBatch)
bindSlice(transformSlice)
bindSlice(skinSlice)
bindSlice(materialSlice)

这样可以减少独立分配与碎片化上传命令,并让“这些字节必须先可读”成为明确依赖,而不是依靠函数恰好写在前面。

具体设备路径仍需要分别处理:使用可直接访问的上传内存,或者经过中转复制,都必须满足相应的内存可见性与同步要求。没有设备复制命令,不等于 CPU 没有复制、缓存处理没有成本,也不等于带宽消耗为零。

上传页按实际录制与提交生命周期复用。不能只用一个自增帧号取模,就假设某个页已经安全;同一轮命令录制里还可能有多次离屏绘制,它们的数据不能互相覆盖。

这里也能看出为什么 CPU 快照与上传存储不必一开始就做成同一个东西:前者负责独立交接,后者负责设备可访问性、对齐与在途保护。分开能保持边界清楚;是否合并来减少一次复制,应在这些约束都能满足后再测量。

八、验证:先证明没有改变结果,再证明成本下降

1. 用能够破坏错误假设的测试验证边界

仅仅运行一个场景,看起来没有问题,不足以证明快照成立。更有效的是主动制造旧设计最怕的变化:

  • 封存之后修改、清空并销毁源对象,确认旧快照的值不变。
  • 强制源数组扩容,确认二级引用没有指回旧存储。
  • 修改一个共享参数实例,确认其他实例和旧快照仍使用原版本;同值写入不产生新版本。
  • 释放 CPU 快照,同时保留未完成提交,确认资源仍然存活;安全结束后又能释放。
  • 用同尺寸的另一个输出替换原目标,或在相同位置重建对象,确认身份检查能够拒绝误用。
  • 在零个、一个和多个工作线程下比较逻辑输出顺序与内容,并注入部分提交拒绝、任务异常和取消。
  • 让旧场景的设备反馈在场景替换后返回,确认不会写入新场景中编号恰好相同的对象。

对于设备实际访问过的缓冲、输出和完成通知,还需要真实后端测试。只在 CPU 测试中观察引用计数,不能完整证明上传与设备执行之间没有同步错误。

2. 一组有用的反直觉实验

一次历史桌面微基准使用了 4096 个简单、单几何子项实例,配置四个通用工作线程,使用优化构建。每种模式独立运行五次,每次预热五次、采样二十五次。下表给出五次运行各自中位数的中位数,单位为毫秒。

实例复制策略 数据表示优化前 数据表示与分配策略优化后
实例复制串行 0.7628 0.5339
实例复制并行 1.1338 0.7308

这里的“串行”只指实例复制策略,其他筛选阶段仍可能使用任务,并不是“单核对四核”的实验。测量对象是 CPU 帧提取入口,不包含完整的显示链路。

这组数据支持两个有限但有用的结论:优化后的两条提取路径在这组历史样本中都降低了局部耗时;实例复制并行在该负载下仍慢于串行,因此应保持默认关闭,等待产品负载给出不同证据。

它不支持“帧率提高了相同比例”,也不能证明某一项改动独自贡献了全部差值。这一轮同时改变了状态共享与分配方式,实验样本也较短,不能代替持续运行与跨设备对照。

3. 指标要能解释收益来自哪里

只记录“这一帧用了几个任务”很难指导下一步。更值得记录的是:源对象与保留对象数量、唯一参数版本和复用次数、唯一姿态及去重矩阵数、各类载荷字节数、临时存储峰值、任务数量与回退原因。

统计口径必须写清楚。静态索引中的源子项数量可能是缓存容量,包含当前隐藏部分;为了得到一个漂亮的“精确可见源数量”再额外遍历全场景,反而可能让遥测成为热路径。快照里的待上传字节数,也不等于设备测得的外部内存流量。

最后再以相同场景、视角、画质、分辨率和资源就绪状态比较完整帧的 P50、P95、P99、内存高水位以及输入到提交的延迟。设备和 CPU 阶段可以重叠,不能把所有局部耗时直接相加来推算最终帧率。

九、继续演进:下一步由瓶颈决定,而不是由结构图决定

到这里,已经可以形成一条完整的同帧路径:生产者完成必要修改,准备任务收口,发布独立快照,消费者读取,资源按真实完成时刻回收。

这并不意味着下一步一定要跨帧,也不意味着所有方向都值得同时推进。

1. 分配成为热点时,先复用工作区

计数数组、偏移数组、选择结果和帧内查重表适合优先考虑复用。它们生命周期短,退出条件清楚,比直接池化整个已发布快照更容易控制。

池化快照本体则要求所有 CPU 读者都已退出,内部引用不会悬空;如果同一存储还承担设备上传职责,则要等设备完成。复用依据应是明确的所有权归还协议,而不是“现在已经进入下一帧”。

复用还有内存代价。一次异常大场景可能让数组容量永久停留在高位,因此应有容量上限或受控缩容策略,而不是无限保留峰值。

2. 多视图成为热点时,分开处理共享与画质策略

多视图共享一次提取,可以减少重复复制,但不能把“对象保留并集”与“细节等级选择”混成一件事。

使用固定的参考视图选择细节等级,结果稳定,实现也简单;但如果参考视图离对象很远,另一个视图离对象很近,就可能为后者选得过粗。稳定顺序并不等于对所有视图都保守。

进一步可以取各视图要求中更精细的等级,或者共享不随视图变化的数据,再建立较薄的逐视图绘制记录。前者可能增加几何开销,后者增加数据结构和批次成本,需要结合视图数量与差异测量。

3. 真正需要跨帧时,再加入有界交接

不可变快照为跨帧提供了必要条件,却没有自动解决线程亲和、输出获取、历史状态、背压和退出清理。

尤其要注意帧资格。在严格同帧发布模型中,可以要求待消费帧就是当前生产帧;下一帧开始后,旧帧便不再具备发布资格。如果加入异步队列,这条规则必须重新定义为允许指定的在途帧消费,而不是直接删除过期检查。

一个异步消费者至少需要检查:生产者是否匹配、场景代次是否仍有效、序号是否重复或倒退,以及该帧是否已被显式取消或丢弃。允许旧序号处于队列中,不等于允许旧场景的帧继续提交。

队列容量还要和存储槽数量分开计算。一个在构建、一个在消费、一个在队列等待,已经是三份 CPU 帧载荷,而不是“两份缓冲”。

可以用下面的预算粗略审视内存:

1
2
3
4
峰值内存 ≈ 所有在建、等待、消费中的 CPU 私有载荷
+ 在途上传页
+ 仍被引用的资源版本集合
+ 临时工作区

共享资源应按仍然存活的版本集合计算,不能机械地按帧数重复累加;但队列加深又可能延长旧版本的存活时间。

当等待队列实际积压时,多保留一份帧通常意味着额外等待一个消费周期量级。它可能平滑突发波动,却不会让持续落后的消费者凭空变快。

因此,初始方案应限制生产者领先程度,明确队满时是等待、替换未消费画面还是丢弃。可替换的画面状态,与必须按序执行的播放、保存等事件,不能直接套用同一种丢帧规则。

只有当测量显示生产和消费确实存在可利用的重叠空间,并且内存与交互延迟预算允许时,跨帧才是值得推进的下一步。

结语:先减少必须做的事,再改变谁来做

帧快照的价值不在于多了一个数据容器,而在于它把过去散落在调用顺序里的约定,变成了能够检查的工程边界。

沿着这条边界,优化可以逐步展开:先补全所有权和发布规则,让消费者不再借用活跃场景;再提前筛选、共享不可变版本,减少需要复制的内容;之后用连续布局明确每个任务的写入范围,并把失败排空纳入正常设计;最后才根据测量决定是否开启更多并行,以及是否跨帧重叠。

其中最重要的经验是:少复制一份重复状态,往往比多找一个线程复制它更值得先做。而一次可靠的交接,能让这类优化持续发生,不必每次都重新证明整条数据链是否安全。

本文作者:Berg Zha

本文链接:https://junglemanpro.com/posts/27a74117/

版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明出处!

关于

我是谁
一个热爱底层渲染的人
与我交流
bergzha@gmail.com
ESC 关闭 | 导航 | Enter 打开
输入关键词开始搜索