Optimalisasi rendering adegan dari kartun Disney "Moana". Bagian 4 dan 5

gambar

Saya memiliki cabang pbrt, yang saya gunakan untuk menguji ide-ide baru, mengimplementasikan ide-ide menarik dari artikel ilmiah, dan secara umum untuk mempelajari semua yang biasanya menghasilkan edisi baru dari buku Rendering Berbasis Fisik . Tidak seperti pbrt-v3 , yang kami berusaha sedekat mungkin dengan sistem yang dijelaskan dalam buku ini, di utas ini kami dapat mengubah apa pun. Hari ini kita akan melihat bagaimana perubahan yang lebih radikal dalam sistem akan secara signifikan mengurangi penggunaan memori dalam adegan dengan pulau dari kartun Disney "Moana" .

Catatan tentang metodologi: dalam tiga posting sebelumnya, semua statistik diukur untuk versi WIP (Work In Progress) dari adegan yang saya gunakan sebelum rilis. Pada artikel ini, kita akan beralih ke versi final, yang sedikit lebih rumit.

Saat merender adegan pulau terakhir dari Moana , 81 GB RAM digunakan untuk menyimpan deskripsi adegan untuk pbrt-v3. Saat ini, pbrt-berikutnya menggunakan 41 GB - sekitar setengahnya. Untuk mendapatkan hasil ini, cukup membuat perubahan kecil yang meluas ke beberapa ratus baris kode.

Mengurangi Primitif


Mari kita ingat bahwa dalam pbrt, Primitive adalah kombinasi dari geometri, materialnya, fungsi radiasi (jika itu adalah sumber cahaya) dan catatan tentang lingkungan di dalam dan di luar permukaan. Di pbrt-v3, GeometricPrimitive menyimpan yang berikut:

  std::shared_ptr<Shape> shape; std::shared_ptr<Material> material; std::shared_ptr<AreaLight> areaLight; MediumInterface mediumInterface; 

Seperti yang dinyatakan sebelumnya , sebagian besar areaLight waktu adalah nullptr , dan MediumInterface berisi sepasang nullptr . Jadi di pbrt-next saya menambahkan opsi Primitive bernama SimplePrimitive , yang hanya menyimpan pointer ke geometri dan material. Jika memungkinkan, ini digunakan GeometricPrimitive mungkin alih-alih GeometricPrimitive :

 class SimplePrimitive : public Primitive { // ... std::shared_ptr<Shape> shape; std::shared_ptr<Material> material; }; 

Untuk instance objek non-animasi, kami sekarang memiliki TransformedPrimitive , yang menyimpan hanya pointer ke primitif dan transformasi, yang menyelamatkan kami sekitar 500 byte ruang yang terbuang yang ditambahkan oleh instance AnimatedTransform ke dalam renderer TransformedPrimitive pbrt-v3.

 class TransformedPrimitive : public Primitive { // ... std::shared_ptr<Primitive> primitive; std::shared_ptr<Transform> PrimitiveToWorld; }; 

(Ada AnimatedPrimitive jika Anda memerlukan konversi animasi ke pbrt-next.)

Setelah semua perubahan ini, statistik melaporkan bahwa hanya 7,8 GB yang digunakan di bawah Primitive , bukannya 28,9 GB yang digunakan di pbrt-v3. Meskipun hebat bahwa kami menghemat 21 GB, itu tidak sebanyak penurunan yang bisa kami harapkan dari perkiraan sebelumnya; kami akan kembali ke perbedaan ini di akhir bagian ini.

Mengurangi geometri


Selain itu, pbrt-next secara signifikan mengurangi jumlah memori yang ditempati oleh geometri: ruang yang digunakan untuk segitiga mesh menurun dari 19,4 GB menjadi 9,9 GB, dan ruang penyimpanan untuk kurva dari 1,4 menjadi 1,1 GB. Lebih dari setengah penghematan ini berasal dari penyederhanaan kelas Shape dasar.

Di pbrt-v3, Shape membawa serta beberapa anggota yang membawa ke semua implementasi Shape - ini adalah beberapa aspek yang nyaman untuk diakses dalam implementasi Shape .

 class Shape { // .... const Transform *ObjectToWorld, *WorldToObject; const bool reverseOrientation; const bool transformSwapsHandedness; }; 

Untuk memahami mengapa variabel anggota ini menyebabkan masalah, akan sangat membantu untuk memahami bagaimana jerat segitiga direpresentasikan dalam pbrt. Pertama, ada kelas TriangleMesh , yang menyimpan simpul dan buffer indeks untuk seluruh jala:

 struct TriangleMesh { int nTriangles, nVertices; std::vector<int> vertexIndices; std::unique_ptr<Point3f[]> p; std::unique_ptr<Normal3f[]> n; // ... }; 

Setiap segitiga di jala diwakili oleh kelas Triangle , yang mewarisi dari Shape . Idenya adalah untuk menjaga Triangle sekecil mungkin: mereka hanya menyimpan pointer ke jala di mana mereka menjadi bagian, dan pointer ke offset di buffer indeks di mana indeks dari simpulnya dimulai:

 class Triangle : public Shape { // ... std::shared_ptr<TriangleMesh> mesh; const int *v; }; 

Ketika implementasi Triangle perlu menemukan posisi verteks mereka, ia melakukan pengindeksan yang sesuai untuk mendapatkannya dari TriangleMesh .

Masalah dengan Shape pbrt-v3 adalah bahwa nilai yang disimpan di dalamnya sama untuk semua segitiga mesh, jadi lebih baik untuk menyimpannya dari setiap seluruh mesh di TriangleMesh , dan kemudian memberikan akses Triangle ke satu salinan dari nilai-nilai umum.

Masalah ini diperbaiki di pbrt-next: kelas Shape dasar di pbrt-next tidak mengandung anggota tersebut, dan oleh karena itu setiap Triangle kurang dari 24 byte. Curve Geometri menggunakan strategi yang sama dan juga manfaat dari Shape lebih kompak.

Buffer Segitiga Bersama


Terlepas dari kenyataan bahwa pemandangan pulau Moana membuat penggunaan instantiasi objek secara luas untuk pengulangan geometri, saya ingin tahu seberapa sering menggunakan kembali buffer indeks, buffer koordinat tekstur, dan sebagainya digunakan untuk berbagai jerat segitiga.

Saya menulis sebuah kelas kecil yang mem-hash buffer ini setelah diterima dan menyimpannya di cache, dan memodifikasi TriangleMesh sehingga memeriksa cache dan menggunakan versi yang sudah disimpan dari buffer redundan yang dibutuhkannya. Keuntungannya sangat baik: saya berhasil menyingkirkan 4,7 GB volume berlebih, yang jauh lebih dari yang saya harapkan.

Hancurkan dengan std :: shared_ptr


Setelah semua perubahan ini, statistik melaporkan sekitar 36 GB dari memori yang dialokasikan diketahui, dan pada awal rendering, top menunjukkan penggunaan 53 GB. Urusan.

Saya takut akan rangkaian massif lambat lainnya untuk mengetahui memori yang dialokasikan tidak ada dalam statistik, tetapi kemudian surat dari Arseny Kapulkin muncul di kotak masuk saya. Arseny menjelaskan kepada saya bahwa perkiraan saya sebelumnya tentang penggunaan memori GeometricPrimitive sangat salah. Saya harus mencari tahu untuk waktu yang lama, tetapi kemudian saya menyadari; banyak terima kasih kepada Arseny karena menunjukkan kesalahan dan penjelasan rinci.

Sebelum menulis ke Arseny, saya membayangkan implementasi std::shared_ptr sebagai berikut: di baris ini ada deskriptor umum yang menyimpan jumlah referensi dan pointer ke objek yang ditempatkan itu sendiri:

 template <typename T> class shared_ptr_info { std::atomic<int> refCount; T *ptr; }; 

Lalu saya menyarankan bahwa instance shared_ptr hanya menunjuk ke sana dan menggunakannya:

 template <typename T> class shared_ptr { // ... T *operator->() { return info->ptr; } shared_ptr_info<T> *info; }; 

Singkatnya, saya berasumsi bahwa sizeof(shared_ptr<>) sama dengan ukuran pointer, dan bahwa 16 byte ruang ekstra terbuang untuk setiap pointer yang dibagikan.

Tapi ini tidak benar.

Dalam implementasi sistem saya, deskriptor yang umum adalah 32 byte dalam ukuran dan 16 byte dalam sizeof(shared_ptr<>) . Oleh karena itu, GeometricPrimitive , yang terutama terdiri dari std::shared_ptr , sekitar dua kali lebih besar dari perkiraan saya. Jika Anda bertanya-tanya mengapa ini terjadi, maka kedua pos Stack Overflow ini menjelaskan alasannya dengan sangat terperinci: 1 dan 2 .

Di hampir semua kasus menggunakan std::shared_ptr di pbrt-next, mereka tidak harus dibagi pointer. Saat melakukan peretasan gila, saya mengganti semua yang saya bisa dengan std::unique_ptr , yang sebenarnya memiliki ukuran yang sama dengan pointer biasa. Sebagai contoh, inilah SimplePrimitive sekarang:

 class SimplePrimitive : public Primitive { // ... std::unique_ptr<Shape> shape; const Material *material; }; 

Hadiahnya ternyata lebih besar dari yang saya perkirakan: penggunaan memori pada awal rendering menurun dari 53 GB menjadi 41 GB - penghematan 12 GB, benar-benar tidak terduga beberapa hari yang lalu, dan total volume hampir setengah dari yang digunakan oleh pbrt-v3. Hebat!

Pada bagian selanjutnya, kita akhirnya akan menyelesaikan seri artikel ini - memeriksa kecepatan rendering di pbrt-next dan mendiskusikan ide untuk cara lain untuk mengurangi jumlah memori yang diperlukan untuk adegan ini.

Bagian 5


Untuk meringkas seri artikel ini, kita akan mulai dengan mengeksplorasi kecepatan rendering adegan pulau dari kartun Disney "Moana" di pbrt-next - cabang pbrt yang saya gunakan untuk menguji ide-ide baru. Kami akan membuat lebih banyak perubahan radikal daripada yang dimungkinkan dalam pbrt-v3, yang harus mematuhi sistem yang dijelaskan dalam buku kami. Kami menyimpulkan dengan diskusi tentang area untuk perbaikan lebih lanjut, dari yang paling sederhana hingga yang sedikit ekstrim.

Waktu render


Pbrt-next membuat banyak perubahan pada algoritma transfer cahaya, termasuk perubahan pada pengambilan sampel BSDF dan peningkatan pada algoritma roulette Rusia. Akibatnya, ia melacak lebih banyak sinar daripada pbrt-v3 untuk membuat adegan ini, jadi tidak mungkin untuk secara langsung membandingkan waktu eksekusi kedua renderer ini. Kecepatan umumnya dekat, dengan satu pengecualian penting: ketika merender adegan pulau dari Moana , ditunjukkan di bawah, pbrt-v3 menghabiskan 14,5% dari waktu eksekusi untuk mencari tekstur ptex . Ini dulunya tampak cukup normal bagi saya, tetapi pbrt-next hanya menghabiskan 2,2% dari waktu eksekusi. Semua ini sangat menarik.

Setelah mempelajari statistik, kita mendapatkan 1 :

pbrt-v3:
Ptex 20828624
Ptex 712324767

pbrt-next:
Ptex 3378524
Ptex 825826507


Seperti yang kita lihat di pbrt-v3, tekstur ptex dibaca dari disk rata-rata setiap 34 pencarian tekstur. Dalam pbrt-next, ia dibacakan hanya setelah setiap 244 pencarian - yaitu, disk I / O telah menurun sekitar 7 kali. Saya menyarankan ini terjadi karena pbrt-selanjutnya menghitung perbedaan ray untuk sinar tidak langsung, dan ini mengarah ke akses ke tingkat tekstur MIP yang lebih tinggi, yang pada gilirannya menciptakan serangkaian akses yang lebih terintegrasi ke cache tekstur ptex, mengurangi jumlah kesalahan cache, dan karenanya jumlah operasi I / O 2 . Pemeriksaan singkat mengkonfirmasi dugaan saya: ketika perbedaan balok dimatikan, kecepatan ptex menjadi jauh lebih buruk.

Peningkatan kecepatan ptex tidak hanya memengaruhi biaya komputasi dan I / O. Dalam sistem 32-CPU, pbrt-v3 hanya dipercepat 14,9 kali setelah menguraikan deskripsi adegan. pbrt biasanya menunjukkan skala linear paralel, sehingga cukup mengecewakan saya. Karena jumlah konflik yang jauh lebih kecil selama penguncian dalam ptex, versi pbrt-next adalah 29,2 kali lebih cepat dalam sistem dengan 32 CPU dan 94,9 kali lebih cepat dalam sistem dengan 96 CPU - kita kembali ke indikator yang sesuai dengan kita.


Akar dari adegan pulau Moana dibuat oleh pbrt dengan resolusi 2048x858 pada 256 sampel per piksel. Total waktu rendering pada mesin Google Compute Engine dengan 96 CPU virtual dengan frekuensi 2 GHz di pbrt-next adalah 41 menit 22 detik. Akselerasi karena mulithreading selama rendering adalah 94,9 kali. (Saya tidak mengerti apa yang terjadi dengan pemetaan benjolan.)

Bekerja untuk masa depan


Mengurangi jumlah memori yang digunakan dalam adegan yang rumit adalah pengalaman yang menyenangkan: menyimpan beberapa gigabyte dengan perubahan kecil jauh lebih menyenangkan daripada puluhan megabita yang disimpan dalam adegan yang lebih sederhana. Saya memiliki daftar yang baik tentang apa yang saya harap dapat pelajari di masa depan, jika waktu mengizinkan. Berikut ini adalah ikhtisar singkat.

Lebih lanjut mengurangi memori buffer segitiga


Bahkan dengan penggunaan berulang buffer yang menyimpan nilai yang sama untuk beberapa jerat segitiga, banyak memori masih digunakan di bawah buffer segitiga. Berikut ini adalah rincian penggunaan memori untuk berbagai jenis buffer segitiga di tempat kejadian:

JenisMemori
Item baris2,5 GB
Normal2,5 GB
UV98 MB
Indeks252 MB

Saya mengerti bahwa tidak ada yang bisa dilakukan dengan posisi vertex yang ditransmisikan, tetapi untuk data lain ada penghematan. Ada banyak jenis representasi vektor normal dalam bentuk yang efisien-memori yang menyediakan berbagai pertukaran antara ukuran memori / jumlah perhitungan. Menggunakan salah satu dari representasi 24-bit atau 32-bit akan mengurangi ruang yang ditempati oleh normals menjadi 663 MB dan 864 MB, yang akan menghemat kita lebih dari 1,5 GB RAM.

Dalam adegan ini, jumlah memori yang digunakan untuk menyimpan koordinat tekstur dan buffer indeks sangat kecil. Saya kira ini terjadi karena keberadaan banyak tanaman yang dihasilkan secara prosedural di tempat kejadian dan karena semua variasi dari jenis tanaman yang sama memiliki topologi yang sama (dan karenanya penyangga indeks) dengan parameterisasi (dan karenanya koordinat UV). Pada gilirannya, menggunakan kembali buffer yang cocok cukup efisien.

Untuk adegan lain, pengambilan sampel koordinat UV 16-bit tekstur atau menggunakan nilai float setengah presisi, tergantung pada kisaran nilainya, mungkin sangat cocok. Tampaknya dalam adegan ini, semua nilai koordinat tekstur adalah nol atau satu, yang berarti bahwa mereka dapat diwakili oleh satu bit - yaitu, adalah mungkin untuk mengurangi memori yang ditempati hingga 32 kali. Keadaan ini mungkin muncul karena penggunaan format ptex untuk texturing, yang menghilangkan kebutuhan untuk atlas UV. Mengingat jumlah kecil yang saat ini ditempati oleh koordinat tekstur, implementasi optimasi ini tidak terlalu diperlukan.

pbrt selalu menggunakan bilangan bulat 32-bit untuk buffer indeks. Untuk jerat kecil kurang dari 256 simpul, hanya 8 bit per indeks yang memadai, dan untuk jerat kurang dari 65.536 simpul, 16 bit dapat digunakan. Mengubah pbrt untuk menyesuaikannya dengan format ini tidak akan terlalu sulit. Jika kami ingin mengoptimalkan secara maksimal, kami dapat memilih bit yang tepat sebanyak yang diperlukan untuk mewakili kisaran yang diperlukan dalam indeks, sementara harga akan meningkatkan kompleksitas menemukan nilai-nilai mereka. Terlepas dari kenyataan bahwa sekarang hanya seperempat gigabyte memori digunakan untuk indeks titik, tugas ini tidak terlihat sangat menarik dibandingkan dengan yang lain.

Puncak BVH membangun penggunaan memori


Sebelumnya, kami tidak membahas detail lain dari penggunaan memori: segera sebelum rendering, puncak jangka pendek 10 GB memori tambahan yang digunakan terjadi. Ini terjadi ketika BVH (besar) dari seluruh adegan dibangun. Kode untuk membangun BVH dari renderer pbrt ditulis untuk dieksekusi dalam dua fase: pertama, itu membuat BVH dengan representasi tradisional : dua pointer anak ke setiap node. Setelah membangun pohon, itu dikonversi menjadi skema memori efisien di mana anak pertama dari node terletak tepat di belakangnya di memori, dan offset ke anak kedua disimpan sebagai integer.

Pemisahan seperti itu diperlukan dari sudut pandang mengajar siswa - itu jauh lebih mudah untuk memahami algoritma untuk membangun BVH tanpa kekacauan yang terkait dengan kebutuhan untuk mengubah pohon menjadi bentuk yang kompak selama proses konstruksi. Namun, hasilnya adalah puncak ini dalam penggunaan memori; dengan mempertimbangkan pengaruhnya di tempat kejadian, penghapusan masalah ini tampaknya menarik.

Konversikan pointer ke integer


Dalam berbagai struktur data, ada banyak pointer 64-bit yang dapat direpresentasikan sebagai integer 32-bit. Sebagai contoh, setiap SimplePrimitive berisi pointer ke Material . Sebagian besar contoh Material adalah umum bagi banyak primitif di tempat kejadian dan tidak pernah ada lebih dari beberapa ribu; oleh karena itu, kita dapat menyimpan satu vektor vector global vector semua bahan:

 std::vector<Material *> allMaterials; 

dan hanya menyimpan offset integer 32-bit untuk vektor ini di SimplePrimitive , yang menghemat 4 byte. Trik yang sama dapat digunakan dengan pointer ke TriangleMesh di setiap Triangle , serta di banyak tempat lainnya.

Setelah perubahan seperti itu, akan ada sedikit redundansi dalam mengakses tanda-tanda itu sendiri, dan sistem akan menjadi sedikit kurang dimengerti bagi siswa yang mencoba memahami pekerjaannya; selain itu, ini mungkin terjadi ketika, dalam konteks pbrt, lebih baik untuk menjaga implementasi sedikit lebih dimengerti, meskipun dengan biaya optimalisasi penggunaan memori yang tidak lengkap.

Akomodasi berdasarkan arena (area)


Untuk setiap Triangle individu dan primitif, panggilan terpisah dibuat untuk yang new (sebenarnya make_unique , tetapi ini sama). Alokasi memori tersebut mengarah pada penggunaan akuntansi sumber daya tambahan, menempati sekitar lima gigabytes memori, tidak terhitung dalam statistik. Karena umur semua penempatan seperti itu adalah sama - hingga perenderan selesai - kita dapat menyingkirkan penghitungan tambahan ini dengan memilihnya dari arena memori .

Khaki vtable


Gagasan terakhir saya mengerikan, dan saya minta maaf untuk itu, tetapi dia menggugah saya.

Setiap segitiga dalam adegan memiliki beban tambahan setidaknya dua pointer vtable: satu untuk Triangle , dan satu lagi untuk SimplePrimitive . Ini adalah 16 byte. Adegan pulau Moana memiliki total 146 162 124 segitiga unik, yang menambahkan hampir 2,2 GB pointer tabel berlebihan.

Bagaimana jika kita tidak memiliki kelas dasar abstrak untuk Shape dan setiap implementasi geometri tidak mewarisi dari apa pun? Ini akan menghemat ruang kita pada pointer vtable, tetapi, tentu saja, ketika melewati pointer ke geometri, kita tidak akan tahu seperti apa geometri itu, yaitu, itu akan sia-sia.

Ternyata pada CPU x86 modern , hanya 48 bit pointer 64-bit yang benar-benar digunakan . Oleh karena itu, ada 16 bit tambahan yang dapat kita pinjam untuk menyimpan beberapa informasi ... misalnya, seperti geometri yang kita tunjuk. Pada gilirannya, dengan menambahkan sedikit pekerjaan, kita dapat membuat jalan kembali ke kemungkinan membuat analog panggilan ke fungsi virtual.

Begini caranya: pertama kita mendefinisikan struktur ShapeMethods yang berisi pointer ke fungsi, seperti 3 :

 struct ShapeMethods { Bounds3f (*WorldBound)(void *); // Intersect, etc. ... }; 

Setiap implementasi geometri akan mengimplementasikan fungsi kendala, fungsi persimpangan, dan seterusnya, menerima analog dari pointer this sebagai argumen pertama:

 Bounds3f TriangleWorldBound(void *t) { //       Triangle. Triangle *tri = (Triangle *)t; // ... 

Kita akan memiliki tabel global struktur ShapeMethods di mana elemen ke - n adalah untuk tipe geometri dengan indeks n :

 ShapeMethods shapeMethods[] = { { TriangleWorldBound, /*...*/ }, { CurveWorldBound, /*...*/ }; // ... }; 

Saat membuat geometri, kami menyandikan tipenya ke beberapa bit yang tidak digunakan dari pointer kembali. Kemudian, dengan mempertimbangkan penunjuk ke geometri yang panggilan spesifiknya ingin kita lakukan, kita akan mengekstrak indeks tipe ini dari penunjuk dan menggunakannya sebagai indeks dalam shapeMethods untuk menemukan penunjuk fungsi yang sesuai. Pada dasarnya, kami akan mengimplementasikan vtable secara manual, memproses pengiriman sendiri. Jika kita melakukan ini baik untuk geometri dan untuk primitif, maka kita akan menghemat 16 byte per Triangle , tetapi pada saat yang sama kami membuat cara yang agak sulit.

Saya kira peretasan seperti itu untuk mengimplementasikan manajemen fungsi virtual bukanlah hal baru, tetapi saya tidak dapat menemukan tautan ke sana di Internet. Berikut adalah halaman Wikipedia tentang pointer yang ditandai , tetapi ia memang melihat hal-hal seperti jumlah tautan. Jika Anda tahu tautan yang lebih baik, kirimkan saya surat.

Dengan berbagi hack yang canggung ini, saya dapat menyelesaikan serangkaian posting. Sekali lagi, banyak terima kasih kepada Disney untuk menerbitkan adegan ini. Sangat menyenangkan untuk bekerja dengan; roda gigi di kepalaku terus berputar.

Catatan


  1. Pada akhirnya, pbrt-next melacak lebih banyak sinar dalam adegan ini daripada pbrt-v3, yang mungkin menjelaskan peningkatan jumlah operasi pencarian.
  2. Perbedaan ray untuk sinar tidak langsung pada pbrt-next dihitung menggunakan retasan yang sama dengan yang digunakan dalam ekstensi cache tekstur untuk pbrt-v3. , , .
  3. Rayshade . , C . Rayshade .

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


All Articles