Mah*_* GB 1 c# performance caching
假设我们希望由于某些原因和约束而有效地编程.我们应该放弃OOP吗?让我们用一个例子来说明
public class CPlayer{
Vector3 m_position;
Quaternion m_rotation;
// other fields
}
public class CPlayerController{
CPlayer[] players;
public CPlayerController(int _count){
players=new CPlayer[_count];
}
public void ComputeClosestPlayer(CPlayer _player){
for(int i=0;i<players.Length;i++){
// find the closest player to _player
}
}
}
Run Code Online (Sandbox Code Playgroud)
如果我们将类转换为结构,我们可以利用缓存内存中的播放器数组并获得更好的性能.当我们需要在ComputeClosestPlayer函数中迭代数组时,我们知道玩家结构是连续存储的,因此在读取数组的第一个元素时可以进入高速缓存.
public struct CPlayerController{
CPlayer[] players;
public CPlayerController(int _count){
players=new CPlayer[_count];
}
public void ComputeClosestPlayer(CPlayer _player){
for(int i=0;i<players.Length;i++){
// find the closest player to _player
}
}
}
Run Code Online (Sandbox Code Playgroud)
如果我们想要获得更高的性能,我们可以将位置字段与类分开:
public Vector3[] m_positions;
Run Code Online (Sandbox Code Playgroud)
所以现在,当我们调用函数时,只有位置(每个位置12个字节)缓存在缓存中,而在以前的方法中,我们必须缓存占用更多内存的对象.
最后我不知道这是一种标准的方法,或者你避免将它从一个类中分离出来以获得更好的性能并分享你的方法以获得战略游戏中最多的表现,你有很多物品和士兵
抛开OO设计模式以在战略游戏中实现更好的表现?
我倾向于支持这种在中央架构中放置OOP用于视觉效果的广泛方法,特别是实体组件系统,如下所示:
...蓝色的组件只是数据(structs没有自己的功能).如果组件内部存在任何功能,那么它就是纯数据结构功能(就像您std::vector在C++或ArrayListC#中找到的功能一样).
它确实可以让更有效的事情更容易完成,但对我来说主要的好处不是效率.这是灵活性和可维护性.当我需要全新的行为时,我可以在广泛的依赖关系流向数据而不是抽象时,对系统进行小的本地修改或添加新组件或添加新系统.自从我接受这种方法以来,面对级联设计变化的需求已经成为过去.
"没有中央设计"
每一个新的设计理念,无论多么疯狂,往往都很容易扩展并添加到系统中而不会破坏任何中心设计,因为除了ECS之外,首先没有中心抽象设计(或者包含功能的设计)数据库本身.除了ECS数据库的依赖关系和每个系统中的少数组件(原始数据)之外,系统是非常分离的.
它使得每个系统都很容易从线程安全到什么副作用在何时何地进行推理.每个系统都执行一个定义明确的角色,可以非常直接地映射到业务需求.当你有这样的设计和责任时,更难以推断中型和小型物体之间的沟通/互动:
...以上图表不是依赖关系图.在耦合方面,每个对象之间可能存在抽象,以这种方式将它们分离(例如:建模对象取决于IMesh,而不是具体网格),但它们仍然相互通信,并且所有这些交互和通信都可能使其难以关于发生了什么以及提出最有效的循环代码的原因.
同时,第一个系统使每个独立的系统以平面管道式方式处理来自中央数据库的数据,这使得很容易弄清楚正在发生的事情以及非常有效地实现循环的关键执行路径.它还可以使您在不知道其他系统正在做什么的情况下坐下来处理系统:实现物理系统所需要做的就是从数据库中读取运动组件等内容并正确转换数据.除了一些组件类型以及如何从ECS"数据库"中获取它们之外,您不需要了解太多来实现和维护物理系统.
这也使得在团队中工作变得更容易,并且雇用新的开发人员,他们可以快速加速,而不用花费2年的时间来教他们整个系统中的中心抽象是如何工作的,以便他们能够完成自己的工作.他们可以开始做大胆的工作,并对软件的设计产生集中影响,因为在几周内将全新的物理引擎或渲染引擎引入系统.
效率
如果我们将类转换为结构,我们可以利用缓存内存中的播放器数组并获得更好的性能.
这只是在对象开始妨碍性能的粒度级别.例如,如果您尝试使用封装和隐藏其数据的抽象Pixel对象或IPixel接口来表示图像的单个像素,那么即使不考虑动态分派的成本,它也很容易成为性能障碍.这样一个粒状对象只会强迫你在一个像素的粒度级别上工作,同时用它的公共接口捏造,所以当我们有这个时,像处理GPU上的图像或CPU上的SIMD处理这样的优化就会出来我们与像素数据之间的障碍.
除此之外,您通常无法对接口进行编码,并期望在此级别(一个像素)的高度解决方案.我们不能隐藏像像素格式这样的具体细节并将它们抽象出来,并期望编写高效的视频滤波器,每帧循环数百万像素,例如,在足够低的水平,我们必须开始编写代码来反对具体的效率以获得合理的效率.当您进行高级操作时,抽象和编码到界面是有帮助的.
但是,如果你Pixel变成只存储在原型中的原始数据,这自然不适用Image.有一个图像对象,实际上是一个通常数百万像素的容器,不是实现非常有效的解决方案的实际障碍.我们不必放弃OOP来编写非常有效的循环.我们可能需要为那些几乎不存储任何自己数据的最小,最精细的对象做这件事.
因此,一种替代策略是仅在较粗糙的水平上对对象进行建模.您不必设计Human课程.您可以设计一个Humans继承自的类Creatures.现在,实现Humans可能包括循环多线程SIMD代码,该代码一次处理数千个人类的数据(例如:存储在并行数组中的SoA字段).
OOP的替代方案
我对在最广泛的设计层面放弃OOP的呼吁(我仍然使用OOP来实现每个子系统)支持中央层面的价值聚合,就像ECS中的组件一样,主要是灵活性我将数据保持开放并通过中央"数据库"访问.它可以更轻松地实现您需要的系统并有效地访问数据,而无需跳过箍和层层抽象层,同时与您设计的抽象作斗争.
当然还有缺点,比如你的数据现在必须非常稳定(我们不能继续改变它),否则你的系统就会崩溃.而且你还必须有一个像样的系统组织才能使它在最少的地方访问和修改你的数据,以便你有效地维护不变量,副作用的原因等等.但我发现ECS做到了这一点非常自然地,因为它很容易分辨哪些系统访问哪些组件.
在我的例子中,我发现使用ECS进行大规模设计非常合适,然后当您放大特定系统(如物理或渲染系统)的实现时,他们使用OOP来帮助实现辅助数据结构和介质大小的对象,使系统的实现更容易理解和类似的事情.我发现OOP在中等复杂程度上非常有用,但有时很难以最大规模维护和优化.
热/冷场分裂和价值汇总
最后我不知道这是一种标准的方法,或者你避免将它从一个类中分离出来以获得更好的性能并分享你的方法以获得战略游戏中最多的表现,你有很多物品和士兵
这有点C#特定,我更像是一个C++和C程序员,但我相信我已经读过C#structs,只要它们没有盒装,就可以连续存储在一个数组中.您获得的这种连续性可以在减少缓存未命中方面创造一个与众不同的世界.
特别是在GC语言中,通常可以使用顺序分配器(Java中的"Eden"空间,我相信C#做类似的事情虽然我没有阅读任何关于C#的实现细节的文章)来快速完成初始的对象分配集,但效率非常高GC实施.但是在第一个GC循环之后,可以对存储器进行混洗以允许在单个对象的基础上回收它.如果你需要做非常有效的顺序循环,那么空间局部性的损失可能真的会损害性能.因此,在游戏的某些关键循环区域中存储一组structs或原始数据类型,int或者float可能是有用的优化.
至于分离字段的方法,这对SIMD处理和热/冷字段分割很有用.热/冷场分裂是将经常访问的数据字段与其他非经常访问的数据字段分开.例如,粒子系统可能花费大部分时间移动粒子并检测它们是否发生碰撞.在关键路径中,它对粒子的颜色完全没有兴趣.
因此,在这种情况下有效的优化可能是避免将颜色直接存储在粒子内,而是将其提升并将其存储在自己独立的并行阵列中.通过这种方式,可以将不断访问的热数据加载到64字节的高速缓存行中,而不会像颜色那样无关地加载到其中,并且通过使得必须通过更多不相关的数据来减轻关键路径的速度来减慢关键路径的速度.相关数据.
所有重要的优化都倾向于归结为交易所,这些交易会将性能偏向于常见情况,而成本则低于极少数情况.为了达成一个很好的交易并找到一个好的交易,你想要使常见的情况更快,即使它使罕见的情况稍微慢一点.除了明显的低效率之外,你通常无法为所有事情做出快速的事情,尽管如果你优化常见案例,关键路径,那么你可以实现看起来那样的东西并且对于用户来说似乎超级快.
内存访问和表示
如果我们想要获得更高的性能,我们可以将位置字段与类分开:
Run Code Online (Sandbox Code Playgroud)public Vector3[] m_positions;
这将致力于SoA(阵列结构)方法,并且如果大部分关键循环花时间position以顺序或随机访问模式访问播放器,但是没有,那么这是有意义的rotation.如果两个rotation并position以随机访问模式经常一起访问一个struct存储都使用AOS(结构数组)的方法也许最有意义.如果它们都主要以顺序访问模式访问而没有随机访问,那么SoA可能表现更好,不是因为它减少了缓存未命中(它将接近与AoS相同),而是因为它可以允许更有效的指令选择例如,当您可以将8个SPFP位置字段一次加载到YMM寄存器中而没有旋转字段与处理循环中更均匀的垂直算法交错时,由优化器完成.
全面的SoA方法甚至可以分离您的位置组件.代替:
xyzxyzxyzxyz...
Run Code Online (Sandbox Code Playgroud)
如果关键路径的访问模式都是顺序的并处理大量数据,那么它可能会有所帮助:
xxxxxxxx...
yyyyyyyy...
zzzzzzzz...
Run Code Online (Sandbox Code Playgroud)
像这样:
float[] m_x;
float[] m_y;
float[] m_z;
Run Code Online (Sandbox Code Playgroud)
对于所有类型的SIMD指令而言,这往往是最友好的内存布局(允许您或优化器以与标量代码相同的方式使用SIMD,只有它一次应用于4个以上的字段),尽管您通常想要这种布局的顺序访问模式.如果它是随机访问,则最终可能会导致缓存未命中数的三倍.
至于你选择什么,首先你必须弄清楚你将如何访问最关键循环中的数据,以了解如何设计和非常有效地表示它.而且你通常必须进行一些交流,这将减缓罕见的情况,有利于常见的情况,因为如果你采用SoA设计,你可能仍然在系统中有一些地方可以从AoS中受益更多,所以你'如果关键路径是连续的,则使用SoA加速常见情况,同时如果非关键路径使用随机访问模式,则减慢极少数情况.要想提出最有效的解决方案,需要做很多事情并做出妥协,自然它有助于衡量,而且还要提前有效地设计事物,您还必须考虑内存访问模式.一种访问模式的良好内存布局不一定对另一种访问模式有好处.
如果有疑问,我会支持AoS,直到你遇到一个热点,因为它通常比并行阵列更容易维护.然后您可以在后见之明选择性地应用SoA优化.关键是找到可以做到这一点的喘息空间,如果你设计的更粗糙的物体,你可以找到它Image,不Pixel喜欢Humans,不Human喜欢ParticleSystem,不喜欢Particle.如果你设计的小物件在任何地方都可以使用,你可能会发现自己被困在一个次优的表现中,你无法在不破坏一切的情况下改变它.
最后我不知道这是一种标准方法,或者你避免将它从一个类中分离出来以获得更好的性能[...]
实际上它是非常常见的,至少在计算机图形和游戏等领域相当广泛地讨论(至少它不是太深奥的知识)当使用语言时,你可以非常明确地控制像C++这样的内存布局.但是,这些技术甚至适用于使用GC的语言,因为这些语言仍然struct能够以极其重要的方式为您提供足够的内存布局控制,前提是它们至少可以为您提供类似于可以在数组中连续存储的内容.
关于高效内存访问模式的所有这些内容主要涉及连续性和空间局部性,因为我们处理循环,理想情况下,当我们将一些数据加载到缓存行时,它不仅涵盖了我们感兴趣的那个元素的数据,也是下一个,下一个,等等.我们希望尽可能多的相关数据存在,尽可能少的无关数据.如果没有任何连续存储的话,大多数都变得毫无意义(除了时间局部性),因为我们每次加载一个元素时都会在整个地方加载不相关的数据,但是几乎每种语言都提供了一些存储数据的方法.一种纯粹连续的方式,即使你不能使用它.
我实际上已经看到用Java编写的一个小型交互式路径跟踪器,它可以与用C或C++编写的任何同样小的交互式跟踪器的速度相媲美.最主要的是,它避免了对象涉及BVH遍历和光线/三角形交叉的关键部分.它使用了浮点数和整数的大数组,但其他一切都使用了OOP.如果您谨慎地在这些语言中应用这些类型的优化,那么从性能角度来看,它们可以开始给出非常令人印象深刻的结果,而不会因维护问题而陷入困境.
| 归档时间: |
|
| 查看次数: |
393 次 |
| 最近记录: |