C#中的缓存友好性
Posted
技术标签:
【中文标题】C#中的缓存友好性【英文标题】:Cache friendliness in C# 【发布时间】:2014-04-03 23:51:53 【问题描述】:我刚刚在 2014 年 Build Conference 上看到 Herb Sutter 的一个非常有趣的演讲,名为“Modern C++: What You Need to Know”。这是演讲视频的链接:http://channel9.msdn.com/Events/Build/2014/2-661
演讲的主题之一是std::vector
是如何对缓存非常友好的,主要是因为它确保了它的元素在堆中是相邻的,这对空间局部性有很大的影响,或者至少我是这样认为的想我已经明白了;即使是插入和移除项目也是如此。巧妙地使用std::vector
可以通过利用缓存带来显着的性能提升。
我想尝试使用 C#/.Net 之类的东西,但是如何确保我的集合中的对象在内存中都是相邻的?
任何其他指向 C# 和 .Net 上的缓存友好性资源的指针也值得赞赏。 :)
【问题讨论】:
我认为使用安全的 C# 意味着我们依赖于处理这些类型的实现细节,并希望框架在缓存性能方面充分利用内存。我在规范中看不到任何保证集合在内存中连续存储元素的内容,但我可能错过了一些东西。您可以随时打开内存查看器,将元素拖入并自行查找(只要您手边有一个十六进制计算器)。一个简单的例子是:byte[] asciiBytes = Encoding.ASCII.GetBytes( "tester" );
,拖入内存查看器你会看到元素是连续的。
【参考方案1】:
对于 GC 管理的语言,您往往会失去对每个对象在内存中的存储位置的显式控制。您可以控制内存访问模式,但如果您无法控制正在访问的内存地址,那将变得有点徒劳。最重要的是,每个对象都会不可避免地带来间接开销和类似于虚拟指针(在后台)的东西,以允许动态调度、反射等。这类似于必须将指针(引用)存储到对象,并且必须与它们间接工作,每个对象实例还存储类比指针以允许运行时类型信息,如反射和虚拟调度所需的内容。
因此,即使您连续存储对象引用数组,也只会使指向对象的类比指针对缓存友好,以便按顺序访问。每个对象的内容仍然可能分散在内存中,当您将内存区域加载到缓存行中时,会导致大量缓存未命中,只是为了在数据被驱逐之前使用一个对象的数据价值。
这实际上是像 C++ 这样的语言对我的最大吸引力:它允许您仍然使用 OOP,同时控制所有内容将在内存中分配的位置以及所有内容的确切使用量,并且它允许您选择退出 (实际上默认情况下)来自与虚拟调度,RTTI等相关的开销,同时仍然使用对象和泛型等等。同时,对我个人而言,像 C# 和 Java 这样的语言对我个人最大的吸引力是你可以从每个对象中获得什么,比如反射,这会带来每个对象的开销,但如果你的代码有足够的用处,这是一个合理的成本。
使用普通的旧数据类型(C# 中包含struct
):
也就是说,我已经看到用 C# 和 Java 编写的非常高效的代码与 C 和 C++ 相当,但关键的区别在于它们避免了对象 用于真正性能的一小部分代码-危急。例如,我看到一个交互式 Java 光线追踪器使用单路径追踪,考虑到它所做的蛮力性质,速度非常惊人。然而,关键的区别在于,虽然大多数光线追踪器是使用面向对象的代码编写的,但对于性能关键部分(BVH、网格表示和存储在叶子中的三角形),它避免了对象,只使用了 @ 的大数组987654322@ 和float[]
。该性能关键代码非常“原始”,甚至可能比同等优化的 C++ 代码更“原始”(它看起来更接近 C 或 Fortran),但它仅对光线追踪器的几个关键区域是必需的。
当您将普通旧数据类型的数组用于性能关键区域时,您可以获得对内存的足够控制以产生所有差异,因为如果数组是 GC 管理的并可能偶尔发生,这并不重要在 GC 循环后从一个内存位置移动到另一个内存位置(例如:在第一个 GC 循环后超出 Eden 空间)。没关系,因为数组是作为一个整体移动的。结果,索引 1 处的元素仍然紧挨着元素 0 和元素 2。这对于数组的缓存友好顺序处理来说最重要的是数组中的每个元素在内存中都紧挨着另一个元素,甚至在 Java 和 C# 中,只要您使用的是 POD 数组(包括我上次检查时 C# 中的 structs
),您就拥有该级别的控制权。
因此,当您编写对性能至关重要的代码时,请确保您的设计留有足够的喘息空间来改变事物存储方式的表示,并且如果将来设计成为瓶颈,则可能远离对象。作为一个基本示例,对于Image
对象(实际上是像素的集合),您可能会避免将像素存储为单独的对象,并且您绝对不想公开抽象的Pixel
对象以供客户直接使用。相反,您可以将像素集合表示为普通旧整数数组或浮动在Image
接口后面,单个图像可能表示一百万像素。这将允许对图像进行缓存友好的顺序处理。
避免将new
用于一堆小事。
简单地说,不要过度使用new
。为性能关键区域批量分配:一个 new
用于代表图像中一百万像素的一百万个整数的整个数组,例如,不是一百万次调用 new
一次将一个像素分配到内存中的位置在你的控制之外。除了缓存友好性之外,如果在 C# 中将每个像素分配为单独的对象,则存储用于动态调度和反射等的类比指针所需的内存开销通常会大于整个像素本身,从而使内存使用量增加一倍或三倍单个像素。
在性能关键领域进行批量、均匀处理的设计。
如果您正在编写一个围绕 OOP 和继承而不是 ECS 和鸭子类型的视频游戏,那么经典的继承示例通常过于细化:Dog
继承 Mammal
,Cat
继承 Mammal
。相反,如果您要在每帧的游戏循环中处理大量哺乳动物,我建议改为让 Cats
继承 Mammals
、Dogs
继承 Mammals
。 Mammals
变成了一个抽象的容器,而不是一次只代表一个哺乳动物的东西。当您尝试处理抽象 Dogs
继承抽象 Mammals
当您尝试间接执行某些操作狗通过Mammals
接口实现多态性。
无论您使用的是 C、C++、Java、C# 还是其他任何东西,这条建议实际上都适用。要编写缓存友好的代码,您必须从留出足够喘息空间的设计开始,以便在未来根据需要优化其数据表示和访问模式,并且最好使用分析器。最坏的情况是最终得到一个设计,它积累了许多瓶颈,比如对Pixel
对象或IPixel
接口的许多依赖,这太细化而无法进一步优化无需重写和重新设计整个软件。因此,要避免大规模依赖过于精细的设计,因为这些设计没有进一步优化的空间。将依赖关系从类比 Pixel
重定向到类比 Image
,从类比 Mammal
到类比 Mammals
,您将能够根据自己的内心进行优化,而无需进行昂贵的重新设计。
【讨论】:
你提到了鸭子打字。除了使用会降低性能的动态类型之外,还有其他在 C# 中使用鸭子类型的方法吗? @VladRadu 如果您有任何会产生高昂运行时成本的东西,那么看看您是否可以将它变成一个集合,每个集合只产生一次该成本。很快,成本变得微不足道,例如每 64 个元素执行一次,将其降低到原始成本的 1/64。这是一个非常通用的答案,但希望它会起作用,因为我不是 C# 专家。但是 OOP 想要对小型标量类型的事物进行建模,对于性能关键的事物,我建议将事物建模为大型集合类型的事物。 @VladRadu 一个非常基本的例子是图像。假设您要将图像过滤器应用于图像。如果您以某种方式检查每个像素,这会产生像像素格式这样的运行时成本,那么它将付出沉重的运行时代价。但是,如果您一次检查整个图像,就没有问题。经典的鸭子打字案例对于热路径来说太细了——“如果它有翅膀,它就会飞。”改为批量检查:“如果他们有翅膀,他们可以飞。”【参考方案2】:似乎在 C# 中实现这一点的唯一方法是使用值类型,这意味着使用结构而不是类。然后使用 List 将您的对象存储在连续的内存中。您可以在此处阅读有关结构与类的更多信息:Choosing Between Class and Struct
【讨论】:
【参考方案3】:您可以使用 List,它连续存储项目(如 std::vector)。 请参阅this answer 了解更多信息。
【讨论】:
List
连续存储引用类型的引用,而不是对象本身。我想知道的是是否可以强制这些对象在内存中相邻。以上是关于C#中的缓存友好性的主要内容,如果未能解决你的问题,请参考以下文章