优化了迪士尼动画片“ Moana”中的场景渲染。 第二部分

图片

受到第一次解析胜利的启发,其中描述了迪士尼的Moana动画片中的一个岛屿场景,我进一步研究了内存使用情况。 提前期仍然可以做很多事情,但是我认为首先调查情况将很有用。

我开始使用内置的pbrt统计信息进行运行时调查。 pbrt有一个用于大量内存分配的手动设置,可以跟踪内存使用情况,渲染完成后,将显示内存分配报告。 以下是此场景的内存分配报告:


BVH- 9,01
1,44
MIP- 2,00
11,02


至于运行时,内置统计信息很简短,仅报告了24 GB大小的已知对象的内存分配。 top说实际上使用了大约70 GB的内存,即统计中没有考虑45 GB的内存。 很小的偏差是可以理解的:动态内存分配器需要额外的空间来注册资源使用,有些由于碎片而丢失,依此类推。 但是45 GB? 肯定有什么不好的东西藏在这里。

为了了解缺少的内容(并确保我们正确地进行了跟踪),我使用了massif来跟踪动态内存的实际分配。 它的速度很慢,但至少效果很好。

原语


跟踪massif时,我发现的第一件事是两行代码,它们在内存中分配了基类Primitive实例,而在统计中并未考虑这些实例。 很小的疏忽很容易解决 。 之后,我们看到以下内容:

Primitives 24,67

哎呀 那么什么是原语,为什么要保留所有这些内存呢?

pbrt可以将Shape (纯几何形状(球体,三角形等))和Primitive几何体)区分开来, Primitive是几何形状,材料,有时是辐射函数以及几何体表面内外涉及的介质的组合。

Primitive基类有几个选项GeometricPrimitive ,这是标准情况:几何,材料等的“香草”组合,以及TransformedPrimitive ,这是对其应用了变换的基元,可以作为对象的实例或移动基元随时间变化的转换。 事实证明,在这种情况下,这两种类型都是浪费空间。

GeometricPrimitive:50%的额外空间


注意:在此分析中做出了一些错误的假设; 它们在系列第四篇文章中被修订。

4.3 GB用于GeometricPrimitive 。 生活在一个4.3 GB的已用内存不是您最大的问题的世界中,这很有趣,但是让我们看看从中获得了4.3 GB的GeometricPrimitive 。 这是类定义的相关部分:

 class GeometricPrimitive : public Primitive { std::shared_ptr<Shape> shape; std::shared_ptr<Material> material; std::shared_ptr<AreaLight> areaLight; MediumInterface mediumInterface; }; 

我们有一个指向vtable指针 ,另外三个指针,然后是一个MediumInterface其中包含另外两个指针,总大小为48个字节。 此场景中只有几个发光网格物体,因此areaLight几乎始终是空指针,并且没有影响场景的环境,因此两个mediumInterface指针也mediumInterface空。 因此,如果我们有Primitive类的特殊实现,并且可以在没有辐射函数和中等函数的情况下使用它,则可以节省GeometricPrimitive占用的几乎一半磁盘空间-在我们的示例中约为2 GB。

但是,我没有修复它,而是向pbrt添加了新的Primitive实现。 由于一个非常简单的原因,我们努力使github上的pbrt-v3源代码与我的书中描述的系统之间的差异最小化-使其保持同步可轻松阅读本书并使用代码。 在这种情况下,我认为书中从未提到的Primitive全新实现会有很大的不同。 但是,此修复程序肯定会出现在新版本的pbrt中。

在继续之前,我们先进行测试渲染:


pbrt-v3渲染的电影“ Moana”从岛上出发,分辨率为2048x858,每个像素256个样本。 在最新版本的pbrt-v3的频率为2 GHz的Google Compute Engine的12核/ 24线程实例上,总渲染时间为2小时25分43秒。

变形的原语:95%的浪费空间


在4.3 GB GeometricPrimitive下分配的内存非常痛苦,但是TransformedPrimitive下的17.4 GB呢?

如上所述, TransformedPrimitive既用于随时间变化的转换,又用于对象的实例。 在这两种情况下,我们都需要对现有Primitive进行附加转换。 TransformedPrimitive类中只有两个成员:

  std::shared_ptr<Primitive> primitive; const AnimatedTransform PrimitiveToWorld; 

到目前为止一切顺利:指向原语的指针和随时间变化的转换。 但是,实际上在AnimatedTransform存储了什么?

  const Transform *startTransform, *endTransform; const Float startTime, endTime; const bool actuallyAnimated; Vector3f T[2]; Quaternion R[2]; Matrix4x4 S[2]; bool hasRotation; struct DerivativeTerm { // ... Float kc, kx, ky, kz; }; DerivativeTerm c1[3], c2[3], c3[3], c4[3], c5[3]; 

除了指向两个过渡矩阵以及与之相关的时间的指针外,还将矩阵分解为传输,旋转和缩放分量,以及用于限制移动边界框所占用的体积的预先计算的值(请参见本书的第2.4.9节) 基于物理的渲染 )。 所有这些加起来总计456个字节。

但是,这一幕没有任何动静 。 从对象实例的转换角度来看,我们需要一个指向转换的指针,并且不需要分解值和可移动边界框的值。 (也就是说,仅需要8个字节)。 如果为对象的固定实例创建一个单独的Primitive实现,则17.4 GB的文件总共压缩为900 MB(!)。

至于GeometricPrimitive ,与书中描述的相比,修复它是不平凡的更改,因此我们还将其推迟到pbrt的下一版本。 至少我们现在了解了24.7 GB Primitive内存混乱的情况。

转换缓存出现问题


massif定义的第二大未占用内存块是TransformCache ,它占据了大约16 GB。 (这里是原始实现的链接。)这个想法是,同一变换矩阵经常在场景中使用多次,因此最好在内存中保存一个副本,以便使用它的所有元素都简单地存储指向同一事物的指针转换。

TransformCache使用std::map来存储高速缓存,而massif报告称16GB中有6 GB用于std::map黑红色树节点。 这真是太糟糕了:其中60%用于转换本身。 让我们看一下这个分布的声明:

 std::map<Transform, std::pair<Transform *, Transform *>> cache; 

在这里,工作完美完成: Transform完全用作分发的键。 更好的是,pbrt Transform存储两个4x4矩阵(转换矩阵及其逆矩阵),这导致在树的每个节点中存储128个字节。 对于为他存储的值,这绝对是不必要的。

在一个对我们很重要的世界中,成百上千的基元使用相同的转换矩阵,并且通常没有很多转换矩阵对我们来说很重要,这种结构也许在世界上是很正常的。 但是,对于像我们这样的一堆具有大多数独特的变换矩阵的场景,这只是一个糟糕的方法。

除了在键上浪费空间之外,在std::map搜索遍历红黑树的操作还涉及许多指针操作,因此尝试全新的方法似乎是合乎逻辑的。 幸运的是,本书中关于TransformCache很少,因此完全重写它是完全可以接受的。

最后,在我们开始之前:在检查Lookup()方法的签名之后,另一个问题显而易见:

 void Lookup(const Transform &t, Transform **tCached, Transform **tCachedInverse) 

当调用函数提供Transform ,缓存将保存并返回等于传递的指针的转换指针,但还会传递逆矩阵。 为此,在原始实现中,当向缓存中添加转换时,总是计算并存储逆矩阵,以便可以将其返回。

愚蠢的事情是,大多数使用转换缓存的拨号对等体都不查询或使用逆矩阵。 即,不同类型的存储器浪费在不适用的逆变换上。

新的实现中 ,添加了以下改进:

  • 它使用哈希表来加快搜索速度,并且不需要存储Transform *数组以外的任何内容,从本质上讲,它可以减少用于存储所有Transform所需值的内存量。
  • 搜索方法的签名现在看起来像Transform *Lookup(const Transform
    &t)
    Transform *Lookup(const Transform
    &t)
    Transform *Lookup(const Transform
    &t)
    ; 在调用函数要从缓存中获取逆矩阵的一个地方,它只需调用Lookup()两次。

对于哈希,我使用了哈希函数FNV1a 。 实现之后,我发现了Aras的关于散列函数的文章 ; 也许我应该使用xxHash或CityHash,因为它们的性能更好; 也许有一天,我的耻辱会赢了,我会解决的。

由于采用了新的TransformCache实现,整个系统的启动时间已大大减少-长达21分42秒。 也就是说,我们又节省了5分钟7秒,或加速了1.27倍。 此外,更有效地使用内存已将转换矩阵占用的空间从16 GB减少到5.7 GB,几乎等于存储的数据量。 这使我们不必尝试利用它们实际上不是射影这一事实,而是存储3x4矩阵而不是4x4。 (在通常情况下,我会对这种优化的重要性表示怀疑,但在这里它可以为我们节省超过1 GB的内存-很多内存!在生产渲染器中绝对值得这样做。)

小型性能优化完成


过于笼统的TransformedPrimitive结构浪费了我们的内存和时间:探查器说,启动时的大部分时间都花在AnimatedTransform::Decompose()函数上,该函数将矩阵转换为四元数旋转,传递和缩放。 由于此场景中没有任何动静,因此这项工作是不必要的,并且彻底检查AnimatedTransform的实现情况表明,如果两个转换矩阵实际上相同,则不会访问这些值。

向构造函数添加两行 ,以便在不需要转换时不执行分解,我们从开始时间节省了另外1分31秒:结果是20分9秒,即通常它们加速了1.73倍。

在下一篇文章中,我们将认真研究解析器,并分析在加速其他部分的工作时重要的事情。

Source: https://habr.com/ru/post/zh-CN417445/


All Articles