Optimalisasi rendering adegan dari kartun Disney "Moana". Bagian 2

gambar

Terinspirasi oleh kemenangan parsing pertama dengan deskripsi adegan pulau dari kartun Moana Disney, saya melangkah lebih jauh ke dalam studi penggunaan memori. Banyak yang masih bisa dilakukan dengan lead time, tetapi saya memutuskan akan bermanfaat untuk menyelidiki dulu situasinya.

Saya memulai penyelidikan runtime dengan statistik pbrt bawaan; pbrt memiliki pengaturan manual untuk alokasi memori yang signifikan untuk melacak penggunaan memori, dan setelah rendering selesai, laporan alokasi memori ditampilkan. Inilah laporan alokasi memori untuk adegan ini awalnya:


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


Adapun runtime, statistik bawaan ternyata singkat dan hanya melaporkan alokasi memori untuk objek yang diketahui berukuran 24 GB. top mengatakan bahwa sebenarnya sekitar 70 GB memori digunakan, yaitu, 45 GB tidak diperhitungkan dalam statistik. Penyimpangan kecil cukup dapat dimengerti: pengalokasi memori dinamis membutuhkan ruang tambahan untuk mendaftarkan penggunaan sumber daya, beberapa hilang karena fragmentasi, dan sebagainya. Tapi 45 GB? Sesuatu yang buruk pasti bersembunyi di sini.

Untuk memahami apa yang kami lewatkan (dan untuk memastikan bahwa kami melacak dengan benar), saya menggunakan massif untuk melacak alokasi aktual dari memori dinamis. Agak lambat, tapi setidaknya itu bekerja dengan baik.

Primitif


Hal pertama yang saya temukan ketika melacak massif adalah dua baris kode yang mengalokasikan instance dari kelas dasar Primitive , yang tidak diperhitungkan dalam statistik, dalam memori. Pengawasan kecil yang cukup mudah untuk diperbaiki . Setelah itu, kita melihat yang berikut:

Primitives 24,67

Ups Jadi apa itu primitif, dan mengapa semua ingatan ini?

pbrt membedakan antara Shape , yaitu geometri murni (bola, segitiga, dll.) dan Primitive , yang merupakan kombinasi dari geometri, material, kadang-kadang fungsi radiasi dan medium yang terlibat di dalam dan di luar permukaan geometri.

Ada beberapa opsi untuk kelas dasar Primitive : GeometricPrimitive , yang merupakan kasus standar: "vanilla" kombinasi geometri, material, dll., Serta TransformedPrimitive , yang merupakan primitif dengan transformasi yang diterapkan padanya, baik sebagai instance dari objek atau untuk memindahkan primitif dengan transformasi yang berubah seiring waktu. Ternyata dalam adegan ini kedua jenis ini adalah pemborosan ruang.

GeometricPrimitive: 50% Ruang Tambahan


Catatan: beberapa asumsi yang salah dibuat dalam analisis ini; mereka direvisi di pos keempat seri .

4,3 GB digunakan pada GeometricPrimitive . Sangat lucu untuk hidup di dunia di mana 4,3 GB RAM yang digunakan bukan masalah terbesar Anda, tetapi mari kita lihat dari mana kami mendapat 4,3 GB dari GeometricPrimitive . Berikut adalah bagian-bagian yang relevan dari definisi kelas:

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

Kami memiliki pointer ke vtable , tiga pointer lagi, dan kemudian MediumInterface berisi dua pointer lagi dengan ukuran total 48 byte. Hanya ada beberapa jerat pemancar cahaya dalam adegan ini, jadi areaLight hampir selalu merupakan pointer nol, dan tidak ada lingkungan yang mempengaruhi adegan, sehingga kedua pointer mediumInterface juga nol. Jadi, jika kita memiliki implementasi khusus dari kelas Primitive , yang dapat digunakan tanpa adanya fungsi radiasi dan medium, kita akan menghemat hampir setengah ruang disk yang ditempati oleh GeometricPrimitive - dalam kasus kami, sekitar 2 GB.

Namun, saya tidak memperbaikinya dan menambahkan implementasi Primitive baru ke pbrt. Kami berusaha untuk meminimalkan perbedaan antara kode sumber pbrt-v3 pada github dan sistem yang dijelaskan dalam buku saya, karena alasan yang sangat sederhana - menjaga mereka dalam sinkronisasi membuatnya mudah untuk membaca buku dan bekerja dengan kode tersebut. Dalam hal ini, saya memutuskan bahwa implementasi Primitive sepenuhnya baru, yang tidak pernah disebutkan dalam buku ini, akan terlalu berbeda. Tetapi perbaikan ini pasti akan muncul di versi baru pbrt.

Sebelum melanjutkan, mari kita lakukan tes render:


Pantai dari pulau dari film "Moana" disajikan oleh pbrt-v3 dengan resolusi 2048x858 dan 256 sampel per piksel. Total waktu render pada instance 12-core / 24-thread dari Google Compute Engine dengan frekuensi 2 GHz dengan versi terbaru dari pbrt-v3 adalah 2 jam 25 menit 43 detik.

TransformedPrimitive: 95% Wasted Space


Memori yang dialokasikan di bawah 4,3 GB GeometricPrimitive adalah hit yang cukup menyakitkan, tetapi bagaimana dengan 17,4 GB di bawah TransformedPrimitive ?

Seperti disebutkan di atas, TransformedPrimitive digunakan baik untuk transformasi dengan perubahan waktu, dan untuk instance objek. Dalam kedua kasus, kita perlu menerapkan transformasi tambahan ke Primitive ada. Hanya ada dua anggota di kelas TransformedPrimitive :

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

Sejauh ini bagus: pointer ke primitif dan transformasi yang berubah seiring waktu. Tetapi apa yang sebenarnya disimpan di 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]; 

Selain pointer ke dua matriks transisi dan waktu yang terkait dengannya, ada juga dekomposisi matriks menjadi komponen pengangkutan, rotasi, dan penskalaan, serta nilai yang telah dihitung sebelumnya yang digunakan untuk membatasi volume yang ditempati oleh kotak-kotak yang bergerak (lihat bagian 2.4.9 dari buku kami. Rendering Berbasis Fisik ). Semua ini menambahkan hingga 456 byte.

Tapi tidak ada yang bergerak dalam adegan ini. Dari sudut pandang transformasi untuk instance objek, kita memerlukan satu pointer ke transformasi, dan nilai untuk dekomposisi dan kotak pembatas bergerak tidak diperlukan. (Yaitu, hanya 8 byte yang diperlukan). Jika Anda membuat implementasi Primitive terpisah untuk instance objek tetap, 17,4 GB dikompresi total menjadi 900 MB (!).

Sedangkan untuk GeometricPrimitive , memperbaikinya adalah perubahan non-sepele dibandingkan dengan apa yang dijelaskan dalam buku ini, jadi kami juga akan menundanya ke versi pbrt berikutnya. Setidaknya kita sekarang mengerti apa yang terjadi dengan kekacauan memori Primitive 24,7 GB.

Masalah dengan Cache Konversi


Blok terbesar berikutnya dari memori yang tidak terhitung yang didefinisikan oleh massif adalah TransformCache , yang menempati sekitar 16 GB. (Berikut ini tautan ke implementasi semula .) Idenya adalah bahwa matriks transformasi yang sama sering digunakan beberapa kali dalam adegan, jadi yang terbaik adalah memiliki satu salinannya dalam memori, sehingga semua elemen yang menggunakannya hanya menyimpan pointer ke hal yang sama. konversi.

TransformCache menggunakan std::map untuk menyimpan cache, dan massif melaporkan bahwa 6 dari 16 GB digunakan untuk simpul pohon hitam-merah di std::map . Ini banyak sekali: 60% dari volume ini digunakan untuk transformasi itu sendiri. Mari kita lihat deklarasi untuk distribusi ini:

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

Di sini, pekerjaan dilakukan dengan sempurna: Transform sepenuhnya digunakan sebagai kunci untuk distribusi. Bahkan yang lebih baik, Transform pbrt menyimpan dua matriks 4x4 (matriks transformasi dan matriks terbaliknya), yang menghasilkan 128 byte yang disimpan di setiap simpul pohon. Semua ini benar-benar tidak perlu untuk nilai yang disimpan untuknya.

Mungkin struktur seperti itu cukup normal di dunia di mana penting bagi kita bahwa matriks transformasi yang sama digunakan dalam ratusan atau ribuan primitif, dan secara umum tidak ada banyak matriks transformasi. Tetapi untuk adegan dengan sekelompok matriks transformasi yang sebagian besar unik, seperti dalam kasus kami, ini hanya pendekatan yang mengerikan.

Selain fakta bahwa ruang tersebut terbuang untuk kunci, pencarian di std::map untuk melintasi pohon merah-hitam melibatkan banyak operasi penunjuk, jadi sepertinya logis untuk mencoba sesuatu yang sama sekali baru. Untungnya, sedikit yang ditulis tentang TransformCache di buku ini, sehingga sepenuhnya dapat diterima untuk menulis ulang sepenuhnya.

Dan terakhir, sebelum kita mulai: setelah memeriksa tanda tangan metode Lookup() , masalah lain menjadi jelas:

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

Ketika fungsi panggilan menyediakan Transform , cache menyimpan dan mengembalikan pointer konversi sama dengan yang diteruskan, tetapi juga melewati matriks invers. Untuk memungkinkan ini, dalam implementasi asli, ketika menambahkan transformasi ke cache, matriks terbalik selalu dihitung dan disimpan sehingga dapat dikembalikan.

Yang bodoh di sini adalah sebagian besar rekan panggilan yang menggunakan cache transformasi tidak meminta atau menggunakan matriks invers. Artinya, berbagai jenis memori terbuang sia-sia pada transformasi terbalik yang tidak dapat diterapkan.

Dalam implementasi baru , peningkatan berikut ditambahkan:

  • Menggunakan tabel hash untuk mempercepat pencarian dan tidak memerlukan penyimpanan apa pun selain array Transform * , yang, pada dasarnya, mengurangi jumlah memori yang digunakan untuk nilai yang benar-benar diperlukan untuk menyimpan semua Transform .
  • Tanda tangan dari metode pencarian sekarang terlihat seperti Transform *Lookup(const Transform
    &t)
    Transform *Lookup(const Transform
    &t)
    Transform *Lookup(const Transform
    &t)
    ; di satu tempat di mana fungsi panggilan ingin mendapatkan matriks terbalik dari cache, itu hanya memanggil Lookup() dua kali.

Untuk hashing, saya menggunakan fungsi hash FNV1a . Setelah implementasi, saya menemukan posting Aras pada fungsi hash ; mungkin saya seharusnya menggunakan xxHash atau CityHash karena kinerjanya lebih baik; mungkin suatu hari nanti rasa maluku akan menang dan aku akan memperbaikinya.

Berkat implementasi TransformCache baru, waktu startup sistem secara keseluruhan telah menurun secara signifikan - hingga 21 menit 42 detik. Artinya, kami menyimpan 5 menit 7 detik, atau dipercepat 1,27 kali. Selain itu, penggunaan memori yang lebih efisien telah mengurangi ruang yang ditempati oleh matriks transformasi dari 16 menjadi 5,7 GB, yang hampir sama dengan jumlah data yang disimpan. Ini memungkinkan kami untuk tidak mencoba mengambil keuntungan dari fakta bahwa mereka sebenarnya tidak proyektif, dan untuk menyimpan matriks 3x4 bukannya 4x4. (Dalam kasus biasa, saya akan skeptis akan pentingnya optimasi semacam ini, tetapi di sini ini akan menyelamatkan kita lebih dari satu gigabyte - banyak memori! Ini jelas layak dilakukan dalam penyaji produksi.)

Optimasi kinerja kecil untuk menyelesaikan


Struktur TransformedPrimitive terlalu digeneralisasi menghabiskan banyak memori dan waktu: profiler mengatakan bahwa sebagian besar waktu saat startup dihabiskan dalam fungsi AnimatedTransform::Decompose() , yang menguraikan transformasi matriks menjadi rotasi, transfer, dan skala skala empat. Karena tidak ada yang bergerak dalam adegan ini, pekerjaan ini tidak perlu, dan pemeriksaan menyeluruh dari implementasi AnimatedTransform telah menunjukkan bahwa tidak ada nilai-nilai ini diakses jika dua matriks transformasi sebenarnya identik.

Menambahkan dua baris ke konstruktor sehingga dekomposisi dari transformasi tidak dilakukan ketika mereka tidak diperlukan, kami menyimpan 1 menit 31 dari waktu mulai: sebagai hasilnya, kami sampai 20 menit 9 detik, yaitu, secara umum, mereka melaju 1,73 kali.

Pada artikel selanjutnya, kita akan serius mengambil parser dan menganalisis apa yang menjadi penting ketika kita mempercepat pekerjaan bagian lain.

Source: https://habr.com/ru/post/id417445/


All Articles