硬件
不同的独立硬件供应商(IHV)对异构 CPU 拓扑的定义各不相同。Intel 和 Qualcomm 使用 Performance 与 Efficiency,而 AMD 使用 Performance 与 Compact。各家在设计核心拓扑时都会在性能与效率之间进行取舍。 下面的例子说明了其中涉及的一些设计选择:- 不同的缓存大小
- 不同核心可能具有不同的缓存大小。
- 例如,高性能核心可能具有更大的 L1 与 L2 缓存。
- 缓存共享
- 核心之间的缓存共享程度可能不同。
- 例如,高性能核心可能各自拥有私有的 L2 缓存,而节能核心可能共享同一 L2 缓存。
- 对对称多线程(SMT)的支持
- 某些核心可能支持 SMT,而另一些则不支持。
- Intel 将 SMT 称为 Hyper-Threading。
- 核心内部布局
- 各类核心的内部资源可能不同。
- 例如,高性能核心可能包含更多的浮点管线、ALU 管线,或更宽的数据通道。
- 最大频率
- 各类核心的最大频率可能不同。
- 例如,高性能核心可能达到比节能核心更高的频率。
性能数据
不同效率等级的核心性能表现自然不同。一颗核心为性能而设计,另一颗则为节能而设计。问题是它们之间的差异有多大。 以下数据是跨多家 IHV 的综合结果。每家 IHV 由于所选的拓扑不同,各测试的缩放比例也不同。虽然具体数值有差异,但整体规律在所有 IHV 中是相似的。此外,每一代 CPU 会有不同的取舍,从而调整相对数值。构建一份覆盖所有现有 IHV 和世代的数据超出本文范围。合成测试
向量数学是游戏在一帧中所做工作的重要组成部分,可能涵盖物理、AI、渲染等。游戏会通过包含三个或四个 float(可放入一个 128 位 SSE 寄存器)的向量类型来完成这些工作。它们涵盖点积、矩阵乘法、平方根计算和向量变换等广泛的操作。这些操作的性能差异会对整体帧时产生显著影响。 下图展示了执行七种常见数学运算的相对成本。测量使用存储在单个 128 位 SSE 寄存器中的四个 32 位浮点值的向量。矩阵由四个向量组成,总共十六个 32 位浮点值,分布在四个 128 位 SSE 寄存器中。选择这些操作是因为它们在一帧内常被使用。- Dot product:计算向量的点积。
- Transform vector:通过与矩阵相乘来变换向量。
- Vector add:将两个向量相加。
- Cross product:计算两个向量的叉积。
- Vector sine:对向量中每个元素计算其正弦的近似值。
- Vector FMA (fused multiply-add):两个向量相乘并加上第三个向量。
- Inverse square root:计算向量长度的倒数平方根。

真实世界
之前的数据展示的是隔离环境中的特定基准测试,只反映了部分实际情况。以下数据来自一款 AAA 游戏引擎的基准测试,该引擎使用两个主线程(Simulation 与 Render)以及一组任务线程。基准测试采用三种配置。- 两个主线程只在高性能核心上运行。
- 两个主线程只在节能核心上运行。
- 两个主线程可以在系统中所有核心上运行。

各家厂商可以针对自家硬件调整这些调度器启发式规则。例如 AMD 有 Workload Profile Scheduling,Intel 有 Thread Director 技术。每家 IHV 也倾向于根据 SoC 的具体拓扑对启发式规则进行调整。
建议
线程亲和性
对于只有单一效率等级的桌面机器,建议是避免设置硬性线程亲和性,而应让线程在所有核心间自由浮动。主要原因是,你事先并不知道用户机器上还运行着哪些其他进程,一个具有硬亲和性的线程可能被安排到与系统中另一个高优先级线程共享的核心上,结果就是作品线程被“饿死”。如果这种争用发生在帧关键线程上,作品会出现帧时不一致。你希望操作系统始终有地方可以把作品线程迁移,以保证它们能继续运行。 但在异构 CPU 上,这一建议需要改变。主要原因是操作系统不一定知道作品的哪些线程或任务对帧关键。它有一些启发式规则来判断,但仍可能做出错误决定,可能把这些线程或任务迁到性能较低的核心上,从而增加作品出现性能问题的概率。 建议目标是允许作品线程至少在三到四颗核心上运行,越多越好。例如,在低端系统上,如果只有一颗高性能核心和三颗低性能核心,就应该让线程在所有核心间自由浮动;在拥有三颗或更多高性能核心的机器上,你可以将帧关键线程限制在高性能核心上。 在拥有大量核心且支持 SMT 的高端机器上,一些作品可能会试图将线程锁定到物理核心,以避免几个高优先级线程共享同一物理核心资源导致的争用。总体来说,并不建议为此做特殊处理,OS 已经在系统范围内实施了此行为 —— 先将线程分配到不同物理核心,只在必要时才使用更多逻辑核心。 作品只有在系统中拥有足够高性能核心以支持其关键线程时才应考虑此做法。此时,可能希望将帧关键线程锁定到高性能核心。不过,这些线程仍应允许在所有高性能核心之间自由浮动。虽然在某些情况下允许所有线程在所有核心间自由浮动能提升性能,但这一行为在不同 CPU 制造商之间并不一致。 如果作品实现了一个能在所有线程间完全负载均衡的任务窃取(work stealing)系统,则任何任务线程都应允许在所有核心间自由浮动,无论其属于高优先级还是低优先级任务线程。任务窃取算法会自动均衡负载,让所有工作在最短时间内完成。高性能核心上的线程在一帧中执行更多任务。 如果有多个帧关键线程,可以设置它们的亲和性,使得两两不会运行在同一颗核心上。例如,若有两个帧关键线程,则把它们的亲和性设为彼此的异或(XOR),就能避免 OS 让它们相互争用。同样,只有在系统有足够核心的情况下才考虑此做法,同时仍要允许每个帧关键线程至少在三到四颗核心间浮动。线程拓扑
在异构环境中规划如何将工作分配到线程时,主要数据围绕各任务的时长以及它们之间的交互。每个任务的平均耗时是多少、任务之间有哪些依赖关系、各任务的优先级如何等等。 任务线程能在多大程度上吸收关键线程或任务的耗时波动?如果其中一个线程或任务多花 35% 的时间,会对帧时产生多大影响?例如,一个任务运行超时会对本帧的其余工作造成多大延迟,系统是否会停下等它完成? 在关键线程或任务中人为增加额外开销,以此评估异构系统对帧时的影响,通常很有帮助。作为测试,随机让一个任务或工作块多花 25%–35% 的时间。整体帧时的变化程度反映了任务系统吸收执行波动的能力。理想情形是帧时几乎没有变化,说明系统能吸收任意的帧时波动。这些波动既可能来自运行在性能较低的核心上,也可能来自系统中其他进程占用 CPU。线程通信
使用锁原语进行线程通信时需要谨慎考虑。上下文切换的开销与新线程开始运行前接收方核心的 C-State 密切相关。核心必须处于 C0(运行状态)后,新线程才能开始运行。最便宜的情形是核心已经在运行,已在 C0;然而如果核心空闲,它会处于更低的 C-State。C-State 越低,睡眠越深,功耗越低,但唤醒与恢复运行的代价也更高。最常见的低功耗 C 状态是 C1,可能增加 2–7 μs 的额外开销;下一个 C2 可能增加约 40–100 μs。具体数字与 IHV 与 SoC 有关。有很多变量影响核心空闲时处于哪个 C-State,是响应速度与功耗之间的平衡。较低的 C-State 比更高的 C-State 更省电。常见的做法是核心先进入 C1,如果一段时间未运行代码则切换到 C2。 还有另一个问题 —— 提交任务的线程与执行任务的线程分处不同性能特征的核心时会发生什么。技术上讲,任务线程有可能比新任务被创建的速度还快地完成任务。这种情况在任务线程运行在比任务提交线程更快的核心上时更容易发生。 如果一个任务的执行时间比创建它所需的时间还短,作品就可能出现任务线程不断挂起等待新任务的情形。总的效果是任务执行成本上升 —— 上下文切换的开销被加到每个任务的执行时间上。这个开销可能会矛盾地导致在更高性能的核心上运行时帧率反而更低。例如,原本 250 μs 的工作可能突然变成 275 μs,这就是 10% 的性能下降。 针对这种情况,建议不要一边创建一边提交任务,而是批量提交。例如,如果系统的模式是构建一个任务、提交一个任务,再构建一个任务、提交一个任务……,应改为先批量构建,然后一次性全部提交。 如果作品使用了要求 WaitForSingleObject 的对象,应将其放入一个小的自旋循环中。先以超时 0 调用该函数,自旋结束后再使用作品的正常超时。不过,更好的解决办法是使用现代同步 API,例如 Slim Reader/Writer (SRW) 锁、临界区 和 WaitOnAddress。这些同步 API 内部都包含某种形式的自旋,可以避免线程不断挂起。对于持有时间较短的锁尤其有效。 务必避免长时间的自旋锁,例如让线程不断自旋等待工作的模式。如果核心上有其他工作需要执行,调度器最终会挂起自旋线程去执行其他工作。当发生这种抢占时,被挂起线程很可能被挂起较长时间。这是为了防止其他线程被饿死。由于作品对何时被挂起几乎没有控制权,这会导致帧率抖动。任务系统
为获得最佳性能,应围绕任务而非依赖长期存在的帧关键线程来组织引擎。例如,Simulation/Render 线程模型创建了两个持久的帧关键线程。然而,一个耗时较长的任务会让任何任务线程表现得像帧关键线程:如果一个任务耗时 10 ms,那么在这段时间里,执行它的线程就成了帧关键线程,与其原本角色无关。任务窃取(Work Stealing)
首要建议是在整个任务系统中采用任务窃取,并让任何帧关键线程也参与到任务窃取算法中。例如,如果渲染线程正在等待与渲染相关的任务完成,它应该执行其中一部分任务。 许多游戏引擎已经在实践中使用了这种做法。这里之所以特别提及,是因为这种行为对于引擎吸收由异构核心和可能的 OS 抢占引起的帧时波动至关重要。 任务执行时间变长时,帧时波动的风险会上升。这种波动可能来自任务运行在性能较低的核心上,或来自 PC 上的其他工作。建议引擎将任务耗时控制在约帧时的 2% 左右。例如,对于 60 fps 的游戏,大约不超过 333 μs。如有必要,可以将多个较小任务合并为一个较大任务以达到 2% 的目标。这种做法在任务大小、线程通信与吸收波动能力之间提供了良好平衡。 以下示例展示了长耗时的帧关键任务对系统性能的影响。示例中每个任务包含相同数量的操作。示例中的一个任务在慢核上耗时 375 μs,在快核上耗时 250 μs。长耗时任务比其他任务贵 6 倍。 最坏情况是长耗时任务被安排到慢核上,可能对帧时产生严重影响。此情形下总工作时间为 2,250 μs(6 × 375 μs)。 图 3:长耗时任务运行在慢核上的示例。



