GAME104第二十节

代码执行并不像看上去那么简单

代码是在特定的硬件和操作系统上执行的

如果我们想写一个高性能的程序,硬件和操作系统是必须考虑的。

并行编程基础

游戏引擎对算力的要求非常高。

线程会共享很多的空间。

任务模型

抢占式:

  • 当前正在执行的任务可以在调度器决定的时间中断
  • 调度员确定下一步要执行的任务应用于大多数操作系统

非抢占式:

  • 必须对任务进行明确的编程以实现控制
  • 任务必须配合才能使调度方案发挥作用
  • 目前很多实时操作系统(RTOS)也支持这种调度方式

并行化开发的问题

线程之间的切换时非常贵的。

问题1:并行任务之间通信的依赖性或需求很少或根本不存在。并行任务之间需要沟通。

问题2:同一个数据,当一个线程在读,另一个线程在改

阻断时的并行编程:可能会死锁。

lock-free编程

至少有一个线程能够持续取得进展,但可能有些线程会因为其他线程的操作而暂时无法取得进展。

  • Lock-free算法不使用传统的锁机制(如互斥锁或信号量)来控制对共享数据的访问,而是通过原子操作(如CAS,即Compare-And-Swap)来保证数据的一致性

wait-free编程

每个线程都能在有限步骤内完成其操作,无论其他线程如何操作。

  • 保证无论操作系统如何调度线程,每个线程都能在有限的步骤内完成其操作。
  • 即使在最坏情况下,每个线程都能保证在一定步骤后取得进展,而不会被无限期地阻塞。
  • Wait-free算法通常比lock-free算法更难实现,因为它们需要确保在任何情况下,所有线程都能在有限步骤内完成操作。

image-20241113122214482

内存重排优化

这是并行编程的难点。

现代CPU的原理:要从内存中反复读取各种指令,CPU速度很快,总是会饥饿的等待数据。所以CPU有的时候不会一条条的等着你的执行,他把一大段函数随意切成好几块,然后同时执行,相当于指令和数据同时运行。

游戏中,debug版和release版不同。

游戏引擎并行架构

Fixed Multi-thread

Fixed Multi-thread 固定多线程

image-20241113122436637

  • 每项任务分到一个线程。
  • 有些thread很快,有些thread很慢,木桶效应。

Thread Fork-join

image-20241113122521685

  • thread会分成很多的小的fork join。
  • 使用线程池来防止频繁的线程创建/破坏
  • 实际上还是有很多的工作产生很多的空隙。

Task graph

image-20241113122723304

  • 将所有的任务创建成树的结构。
  • task树的构建不透明。动态增加节点时,非常复杂。
  • 实际任务中,任务工作是有顺序的。早期的版本,没有实现做到一半等其他东西昨晚继续走的东西。

任务系统

Job System 任务系统

Coroutine

协例程序是一种轻量级的执行上下文

线程是调取硬件的中断,成本非常高。协程是程序自己定义,一般在一个thread里面,可以好几个协程来回切换。

Stackful Coroutine 有状态的协程:

  • 功能更强大,支持从嵌套堆栈框架中输出
  • 需要更多的内存来为每个协例预留堆栈
  • 协例上下文切换需要更多时间

Stackless Coroutine 无状态的协程:

  • 无法从子例程中出价
  • 如果没有堆栈来保留数据,就更难使用
  • 协例栈不需要额外的内存
  • 更快的上下文切换

Fiber-based Job System

架构一个非常高速的进程管道,放很多高速的task,task里可以进行很多自由的coroutine的切换。

image-20241113123120186

  • Fiber和Coroutine类似,由Job Scheduler管理。
  • thread 线程是执行单元,fiber是上下文
  • 每个处理器核心有一个线程,以最大限度地减少上下文切换开销
  • job是在fiber环境中执行的

同时注意,尽可能一个thread对应一个core(一般是逻辑盒)。

  • 单个核心的多个工作线程仍然存在上下文切换问题。
  • 每个核心都有一个工作线程可以消除上下文切换
  • image-20241113123232696

Job Scheduler

Job Scheduler是一个系统,负责管理和调度 Job(工作单元)

Global Job:Job Scheduler中的一个组成部分,专门处理那些可以全局调度的任务。

LIFO和FIFO

  • 一般来讲用LIFO。
  • (很多时候这个job执行的时候发现会需要另一个job,而另一个job也可能会需要那个job。)

Job Dependency:

  • 确保任务按照正确的顺序执行,维护数据的一致性和完整性。

Job Stealing:

  • 通过动态重新分配任务来提高多线程程序的效率和处理器资源的利用率。

优缺点

优点:

  • 易于实施的任务计划
  • 易于处理任务依赖关系
  • 作业堆栈是孤立的
  • 避免频率上下文切换

缺点:

  • C++原生不支持
  • 操作系统之间的实现是不同的
  • 有一些限制(thread local无效)

编程范式

POP 面向过程编程

遵循循序渐进的方法,通过一系列指令将任务分解为变量和例程(或子例程)的集合。

OOP 面向对象编程

  • 问题1:会有很多的二义性(人攻击怪物还是怪物被人攻击)
  • 问题2:实际上是一个非常深的继承树,继承树中的方法继承,很难知道哪个父类有方法实现
  • 问题3:基类做的非常的杂乱
  • 问题4:性能很低,不稳定
  • 问题5:可测试性低(要测试一个小模块,就要把整个游戏创建出来)

硬件知识储备

CPU发展速度很快,把内存的访问速度拉开了很多。

cache机制:

image-20241113103626082

  • 添加缓存以加快数据读取。
  • L1:范围在 256KB 到不超过 1MB之间,但即使这样也足够了
  • L2:通常是几个兆字节,可以达到10兆字节。
  • L3:比L1和L2大,容量从 16MB到64MB不等,在所有内核之间共享。

局部性原则:在短时间内重复访问同一组存储器位置,让所有数据尽可能在同一块地方。

SIMD:硬件加速的方法,一次性的读写4个空间。

LRU:cache一旦满了以后,把最近常用的东西留下,不常用的扔掉。采用随机的扔掉。

cache line 高速缓存行:

  • 数据以固定大小的块(通常为64字节)在内存和缓存之间传输,这些块被称为cache line 缓存行/缓存块。
  • 缓存只能容纳有限的行数,这取决于缓存大小,例如,64千字节的64字节缓存有1024条缓存行。
  • 每次加载任何内存时,都是在加载一个字节的完整缓存行

cache miss:处理器尝试从缓存中获取数据时,但所需数据不在缓存中的情况

DOP 面向数据编程

强调以数据为中心来组织和优化代码,特别是在性能敏感型应用中,如游戏开发和高性能计算。

将代码和数据紧密存储在内存中,保持代码和数据的小型化,并在可能时分批处理

性能敏感编程

cache 逐行读取比逐列快。

DOP中认为,所有世界的表达都是数据,包括代码也是一块数据。

尽可能减少cache miss,包括代码的cache miss,可以大大提高性能。

数据和代码看成一个整体,在内存中可能分开,在cpu中要一起。

程序分支处理

Branch prediction 分支预测:

CPU将提前获取指令和数据,使用分支预测技术来决定预取什么。

分支机构误报:

image-20241113105947708

  • 大于10和小于10不同的代码
  • 一会大于一会小于,doFunc1和doFunc2来回调用,系统性能就会非常的低。

image-20241113110109178

  • 对数据排序
  • 排序的时间不算,整个性能会提高很多

性能敏感数据组织

Array of Structure AOS

image-20241113111638649

Structure of Array SOA

image-20241113111654207

超高速处理基本使用SOA。

ECS架构

基本结构

component-based 用oo的方式去处理,会很慢。整个调度是无序的。

ECS是一种理论框架,一种模式,用于以面向数据的方式构建游戏代码,以实现最高的性能。

image-20241113112107888

Entity 实体:ID指一组组件

Component 组件:系统要处理的数据,完全没有逻辑(和oop的component不一样)

System 系统:逻辑发生的地方,读/写组件数据

unity dots案例

ecs、jobsystem、burst compiler

image-20241113125236302

Unity ECS

Archetype

Archetype 原型

组件的特定组合,实体被分成原型(可以认为是type of go 很多种类型的go)

image-20241113112434121

原型中的相同组件被紧密集成块,以便于缓存块是固定大小的内存块,即16KB

image-20241113112507791

如果是把所有的同一类型的组件(比如transform)放在一起,数据量非常大,实际上也不符合局部性的原则。

archetype实际是想让这一块要处理的数据/代码刚好能塞进system中,每次system处理一边,直接换一块chunk即可。

System

image-20241113112708842

system直接从chunk中拿来做运算。

Job System

原生容器:

一种可在作业内部访问的共享内存类型。

如果没有本地容器,作业无法输出结果

如果整个系统用C#写,那么申请数据结构处理时在硬件上对应什么是一个黑盒(因为脚本语言实际是跑在虚拟机上的)。但现在高性能编程是和硬件1v1对抗,需要的是原生语言的东西。

安全系统:

在job system层,确保所有的需要无用的资源全部释放掉。

Burst Compiler

需要有一个工具,将使用的C#脚本语言编译成底层的语言,并完成必要的检查。

UE Mass案例

image-20241113125253831

Entity

  • FMassEntityHandle是作为ECS实体的纯ID。
  • 索引指示 FMassEntityManager中的实体数组中的索引

Component:

  • 和unity一样,每种类型的实体都有一个原型
  • Fragments and tags 片段和标记 是实体的组件
  • 标签是常量布尔组件,用于筛选不必要的处理

Systems:

  • MassEntity中的ECS系统是源自UMassProcessor的处理器。
  • 两个重要的接口:ConfigureQueries() Execute()

Fragment Query:

  • 当处理器初始化时,InterfaceConfigureQueries()运行。
  • 使用FMassEntity Query过滤满足系统要求的实体类型。
  • FMassEntityQuery 将过滤后的类型缓存起来,以加速未来的执行。
  • image-20241113125403144

Execute

总结

实战性游戏中OOP和DOP共存

image-20241113120249492