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 {
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.