在 9985WX 上运行原版游戏逻辑时,一个显著特点是在很多并行阶段结束后都会出现一段暗红色的区域。这部分开销被我们标记为“程序调度”,主要用于安排、分配任务,并提供线程间协同所需的数据。
对于使用一般消费级平台的玩家来说,这部分开销本应几乎不可见,在14400F上以16线程运行时每次并行阶段中此开销平均不到0.001ms,而在9985wx上,随着线程数的增加,这部分开销会爆炸性地增长。
原因在于我们原先采用了常见的“工作窃取”策略:线程完成自身任务后,会找到剩余任务最多的目标线程,并取走其一半任务。为了避免多线程同时竞争同一目标,我们设计为同一时间只允许一个线程执行窃取操作,其他线程必须等待该线程更新剩余任务数据后才能继续。
这套逻辑在线程数量不高时表现良好,但当线程数增长到一定程度时问题便随之而来。为方便大家理解,这边举一个贴近游戏的例子,我们这套系统可以类比为这样一个情景:在某处有个冒险者公会,每当有冒险者完成自己的任务时,就可以回到公会去任务栏中寻找下一个自己认为最有价值的任务。为了防止两个冒险者争抢同一个任务,公会采用摇号的形式,每次只随机在大厅中找一人去挑任务,一个人挑完后才能随机下一个人。在公会规模比较小时,大多数人的大部分时间都在外执行任务,公会里基本最多同时只有一人,极少数情况下可能同时有两三人。
但当规模扩大,任务和人数增多,挑选任务的时间变长,大厅等候的人也增多。处理器跨 NUMA 或跨 CCD 的架构类似公会不得不开设分会,但所有分会仍共享同一任务榜,且一次仍只允许一人领取任务。一个分会在开始或结束领取时,还需电话通知其他分会同步状态。这导致领任务的时间占比急剧上升,甚至出现 A 领完任务回来等候时,B 仍未被摇到号的情况。若 B 运气不佳,等待时间便会大幅延长,于是就出现了线程数从 16 增至 128(8 倍),而最大等待时间从 0.001ms 增至 5ms(5000 倍)的极端现象。
从整体来看,当线程完成初始平均分配的任务并开始“返回公会”寻找窃取目标后,在 16 线程环境下,平均仍有约 15.9 个线程处于工作状态;而当线程数升至 128 时,同一时段内可能平均只有三五个线程真正在工作,其余线程都在“等待摇号”,效率反而下降。
为解决这一问题,我们重构了任务动态分配系统。我们允许小概率下出现两个线程同时窃取同一目标的情况,并规定前者取走一半,后者再取走剩余的一半。以“窃取目标的剩余任务未必最多”为代价,换来的是多个线程可同时挑选各自认为最有价值的任务。
同时,我们将线程以 32 个为一组,窃取仅限组内发生。这既限制了单个线程挑选目标的时间,也减少了线程数过多时出现“前面有n个线程时仅获得预期的0.5^n份任务”的极端情况。在开启线程亲和性绑定的情况下(游戏默认设置即启用),这还大幅降低了数据跨 NUMA/CCD 同步的概率。
沿用“冒险者公会”的比喻,现在允许多名冒险者同时挑选任务;如果两人选中同一任务,则严格按先后顺序,后者只能领取前一人剩余任务量的一半。原先所有分会共享同一任务栏,领取时需相互通知;现在则改为最多 3~4 个相邻分会共享任务栏,从而大幅降低了通信成本。
最终优化效果可以直接从新图中看出——原本明显的红色调度开销区域现已基本消失。
https://www.bilibili.com/opus/1160311090351964177
戴森球组的某几个开发已经完全沉浸在 hpc 的艺术中了😇