- Jika AST tidak disimpan sebagai graf objek yang terhubung dengan pointer, melainkan direpresentasikan dengan satu array dan indeks, tata letak memori dan cara pengelolaan struktur data ala kompiler menjadi lebih sederhana
- Contoh Rust mengimplementasikan AST yang diratakan hanya dengan perubahan kecil: mengganti
Box<Expr> menjadi ExprRef dan menambahkan add/get ke ExprPool
- Dalam microbenchmark yang membuat sekitar 100 juta node AST lalu langsung menginterpretasikannya, implementasi biasa memakan 3,1 detik, sedangkan implementasi yang diratakan 1,3 detik, sehingga 2,4× lebih cepat
- Perbedaan performa berasal dari lokalitas karena tata letak memori yang berurutan, referensi yang lebih kecil karena memakai indeks 32-bit alih-alih pointer 64-bit, alokasi yang murah, dan dealokasi per pool
- Jika memanfaatkan sifat bahwa dalam array anak berada lebih dulu daripada induk, traversal pohon rekursif dapat berubah menjadi eksekusi linear, yang bentuknya mendekati interpreter bytecode
Ide dasar perataan
- Arena atau region digunakan luas dalam implementasi bahasa modern, dan pendekatan yang dibahas di sini adalah perataan struktur data yang memakai arena berisi satu tipe saja seperti array sederhana
- Ini adalah teknik mengganti pointer dalam struktur data yang penuh pointer menjadi indeks array; contoh utamanya AST, tetapi bisa diterapkan juga ke struktur data kompiler lain
- Kode contohnya ada di repositori flatcalc, dan perbedaan antara implementasi biasa dan implementasi yang diratakan bisa dilihat pada perbandingan branch
- Perubahan kodenya kecil, tetapi pada microbenchmark terlihat peningkatan kecepatan 2,4×, dan selain performa juga ada kelebihan dari sisi kemudahan penggunaan kode
Representasi AST yang umum
- Bahasa contoh adalah bahasa ekspresi aritmetika yang sangat sederhana dan hanya mendukung literal bilangan bulat serta empat operator aritmetika biner
- Contoh program yang mungkin adalah
42, 0 + 14 * 3, (100 - 16) / 2
- Representasi Rust terdiri dari enum
BinOp dan Expr
Expr::Binary(BinOp, Box<Expr>, Box<Expr>)
Expr::Literal(i64)
Box<Expr> di Rust setara dengan pointer yang menunjuk ke Expr, dan berperan mirip dengan Expr* di C
- Parser, formatter output, dan interpreter memiliki struktur yang khas, dan interpreter ditulis sebagai metode rekursif pada
Expr
- Semantik aritmetikanya disusun agar semua ekspresi pada akhirnya dievaluasi menjadi
i64
- Penjumlahan, pengurangan, dan perkalian memakai operasi wrapping
- Pembagian dengan 0 diproses dengan
checked_div agar mengembalikan 0
- Generator program acak dengan seed PRNG tetap dipakai untuk mengukur performa manipulasi AST tanpa biaya parsing dan formatting output
Mengubah AST menjadi array dan indeks
- Perataan terdiri dari dua perubahan
- Objek
Expr tidak lagi dialokasikan satu per satu di heap, melainkan disimpan dalam satu array berurutan
- Node anak direferensikan dengan indeks di dalam array, bukan pointer
- Dalam contoh Rust,
ExprPool didefinisikan sebagai newtype dari Vec<Expr>
struct ExprPool(Vec<Expr>);
- Peran pointer sebelumnya digantikan oleh
ExprRef berbasis bilangan bulat 32-bit
struct ExprRef(u32);
- Perubahan tipe inti adalah mengganti field anak pada
Binary dari Box<Expr> menjadi ExprRef
enum Expr {
Binary(BinOp, ExprRef, ExprRef),
Literal(i64),
}
ExprPool diberi utilitas add untuk memasukkan Expr baru dan get untuk mencari Expr dari ExprRef
- Parser tidak lagi mengembalikan
Expr secara langsung, melainkan menambahkan node ke ExprPool lalu mengembalikan ExprRef
- Interpreter juga menjadi metode pada
ExprPool, bukan metode Expr, dan sebelum pattern matching ia melakukan dereferensi referensi dengan self.get(expr)
- Secara keseluruhan, perubahan yang dibutuhkan kecil: cukup mengganti
Box<Expr> dengan ExprRef dan menambahkan add serta get di titik yang perlu
Keunggulan performa
- Kelebihan utama AST yang diratakan adalah lokalitas memori
Expr biasa yang berbasis pointer berisiko mengalami fragmentasi memori
Expr yang diratakan tersusun rapat dalam area memori berurutan sehingga cache data dan prefetcher dapat bekerja lebih baik
- Allocator memori yang cukup pintar mungkin bisa memberi efek serupa, tetapi memakai array padat mengurangi ketidakpastian
- Ukuran referensi juga mengecil
- Pointer biasa pada arsitektur modern berukuran 64-bit
- Jika node AST yang dibutuhkan tidak lebih dari 4.294.967.295, referensi 32-bit sudah cukup
- Ada penghematan ruang 50% per referensi, dan pada AST yang kaya pointer ini bisa menurunkan penggunaan memori total
- Jika struktur datanya lebih kecil, referensi 16-bit atau 8-bit juga memungkinkan
- Biaya alokasi menjadi lebih rendah
- Tidak perlu memanggil
malloc untuk setiap node
- Jika memori yang cukup sudah dicadangkan sebelumnya, ruang untuk
Expr baru bisa dibuat dengan bump allocation yang hanya menaikkan tail pointer
- Dealokasi bisa diproses per pool
- Ini berasumsi bahwa
Expr individual tidak dibebaskan satu per satu
- Dalam banyak implementasi bahasa, AST cenderung dibuat bersama dan hilang bersama
- AST biasa harus membebaskan setiap node dengan mengikuti pointer, sedangkan AST yang diratakan cukup membebaskan seluruh
ExprPool sekaligus
- Dalam pengantar arena allocation, dealokasi murah sering ditekankan sebagai alasan utama, tetapi dalam konteks kompiler AST bisa hidup sampai kompilasi selesai sehingga dealokasi justru mungkin alasan yang paling tidak penting
Keunggulan dari sisi kemudahan penggunaan kode
- Perataan menyederhanakan pengelolaan lifetime
- AST dengan n node bisa dipandang memiliki satu lifetime AST, bukan n lifetime yang berbeda
- Di Rust, penyederhanaan ini juga langsung memengaruhi cara lifetime diekspresikan dalam kode
- Alih-alih mengelola lifetime dari
&Expr, kita bisa meneruskan ExprRef yang berupa u32 dan bergantung pada lifetime ExprPool
- Kesederhanaan yang sama juga bisa diterapkan di bahasa dengan pengelolaan memori manual seperti C++
- Array yang diratakan memudahkan implementasi deduplikasi
- Kita bisa memakai
hash consing atau cara yang lebih sederhana untuk menghindari pembuatan ekspresi yang identik
- Misalnya, ekspresi
Literal yang sering dipakai dari 0 sampai 127 bisa dipesan pada 128 slot pertama ExprPool
- Saat membutuhkan literal bilangan bulat
42, kita tidak perlu membuat Expr baru dan cukup mengembalikan ExprRef(42)
- Pada representasi berbasis pointer, hal serupa juga mungkin dilakukan, tetapi kemungkinan memerlukan struktur data tambahan
Hasil microbenchmark
- Benchmark membuat program acak dengan sekitar 100 juta node AST lalu langsung memasukkannya ke interpreter
- Parser dan formatter output tidak disertakan
- Karena satu program dibuat lalu langsung dijalankan, ini bukan benchmark yang realistis
- Ada beberapa keterbatasan dalam kondisi eksperimen
- Ruang yang cukup untuk seluruh program dipesan lebih dulu pada
Vec<Expr>
- Di lingkungan nyata, perkiraan ukuran arena akan lebih dibutuhkan
- Hampir tidak ada pekerjaan selain pembuatan dan eksekusi, sehingga keunggulan alokasi/dealokasi murah bisa terlihat berlebihan
- Programnya sangat besar sehingga proporsi yang muat di cache CPU kecil, sehingga efek lokalitas bisa jadi justru terukur lebih rendah dari seharusnya
- Rata-rata 10 kali eksekusi di laptop dibandingkan dengan Hyperfine
- Lingkungannya adalah M1 Max 10-core 3.2GHz, memori 32GB, macOS 13.3.1, Rust 1.69.0
- Implementasi biasa memakan 3,1 detik, sedangkan implementasi yang diratakan 1,3 detik, menunjukkan peningkatan kecepatan 2,4×
- Untuk melihat biaya dealokasi secara terpisah, dibuat versi yang melewati deallocation pada kedua implementasi
- Pada implementasi yang diratakan, waktu antara versi no-free dan versi standar hampir sama, sehingga waktu dealokasi tidak besar
- Pada implementasi biasa, waktunya turun dari 3,1 detik menjadi 1,9 detik, artinya sekitar 38% waktu dipakai untuk membebaskan memori
- Bahkan saat membandingkan sesama versi no-free, implementasi yang diratakan tetap 1,5× lebih cepat daripada implementasi biasa
Interpreter yang langsung memanfaatkan representasi rata
- Pada awalnya, perataan dipakai seperti perubahan implementasi internal untuk menggantikan alokasi biasa dan pointer, tetapi sifat representasi array ini juga bisa dimanfaatkan secara langsung
- Jika
Expr bersifat immutable, node anak harus dibuat lebih dulu dan node induk dibuat belakangan
- Saat membuat
a * b, a dan b berada lebih dulu di ExprPool daripada * yang merujuk ke keduanya
- Panah referensi dalam array selalu mengarah dari belakang ke depan, dan aliran data bergerak maju
- Dengan memanfaatkan invariant ini, kita bisa membuat interpreter yang memindai
ExprPool dari awal sampai akhir alih-alih turun secara rekursif dari root
- Traversal selalu mengunjungi anak lebih dulu daripada induk
- Hasil setiap ekspresi disimpan di vektor
state
- Untuk ekspresi biner, nilai diambil dengan melihat
state memakai indeks ExprRef anak
- Pada akhirnya, hasil yang sesuai dengan
root yang diminta dikembalikan
- Interpreter “extra-flat” ini tidak memiliki overhead pengelolaan stack dari pemanggilan rekursif dan dapat menelusuri
ExprPool secara linear
- Sebaliknya, ia harus melakukan akses acak ke vektor
state yang besar sehingga bisa kurang baik untuk lokalitas
- Hasilnya, interpreter extra-flat mencatat 1,2 detik, sedangkan interpreter rekursif berbasis perataan 1,3 detik, atau peningkatan 8,2%
Keterkaitan dengan interpreter bytecode
- Komentar Bob Nystrom di Reddit melihat bahwa pendekatan ini pada dasarnya menciptakan ulang gagasan interpreter bytecode
- Struktur
Expr bertindak seperti instruksi bytecode, dan referensi variabel dimasukkan seperti referensi yang dikodekan dalam u32
- Jika tabel
state yang sederhana diubah menjadi struktur seperti stack, hasilnya nyaris tidak berbeda dari interpreter bytecode yang memang dirancang sejak awal
- Hanya dengan mengubah struktur data AST, pendekatan ini berpindah secara alami dari traversal pohon ke gaya bytecode
Materi terkait
- Materi terkait perataan dalam konteks kompiler
- Gagasan serupa juga muncul di bidang yang berorientasi performa di luar implementasi bahasa
- Tulisan tentang bahasa kalkulator mainan Rust oleh Inanna Malick juga menerapkan teknik yang sama, sekaligus memakai gagasan recursion scheme dari dunia Haskell
1 komentar
Komentar Hacker News
Blender (perangkat lunak pemodelan 3D) adalah contoh menarik dari pendekatan ini. Untuk membuat pemuatan/penyimpanan file cepat dan lossless, Blender menggunakan representasi di disk dan representasi di memori yang sama
Artinya, semua hal berada di dalam arena, dan simpan/muat kurang lebih setara dengan melakukan
memcpypada seluruh arena. Jika mempertimbangkan potensi kompleksitas proyek Blender serta masalah serialisasi/deserialisasi, ini terlihat seperti desain yang sangat bagusKekurangannya adalah karena file versi lama juga harus bisa dibuka, desain struktur data jadi mudah membeku
Transformasi semacam ini hanya dijalankan saat memuat data dari versi aplikasi lama, lalu setelah itu langsung disimpan ke disk dalam versi yang sudah diperbarui sehingga biaya yang sama tidak perlu dibayar lagi
https://www.inkandswitch.com/cambria/ yang dibuat dalam konteks CRDT adalah contoh implementasi konsep ini, dan meskipun tidak bisa diterapkan langsung, tetap bisa menjadi inspirasi yang bagus
Suka atau tidak suka pada framework I/O tersebut, karena struktur langsung diserialisasi apa adanya, kami selalu harus menyiapkan struktur data untuk masa depan sambil tahu bahwa kami akan terikat pada struktur itu selamanya. Itu masa yang suram
Karena itu, menulis konverter menjadi masalah besar, dan saya ingat orang harus menguraikan struktur data internal Word yang tidak terdokumentasi
Jika seseorang bisa mengubah file di disk, itu berarti mereka sudah menguasai perangkatnya. Hasilnya, loading menjadi sangat cepat, dan setelah semuanya dibaca ke memori, kami hanya menambal lokasi pointer ke alamat muat yang sebenarnya
Saya menyukai abstract syntax tree yang diratakan. Saya terutama menyukai pendekatan yang dipakai dalam pemrosesan markup inline di pulldown-cmark. Penjelasan sederhananya ada di https://fullyfaithful.eu/pulldown-cmark/
Input mentah dipecah menjadi urutan node, dan hal seperti
*bisa menjadi emphasis atau tetap menjadi teks jika tidak punya pasangan, sehingga dibuat sebagai nodeMaybeEmphasisPada tahap berikutnya, sebuah stack digunakan untuk menelusuri node secara berurutan sambil mencari pasangan yang mungkin. Jika pasangan ditemukan, node
MaybeEmphasisdiubah menjadi node emphasis yang sesuai, dan seluruh urutan node di antara node pembuka dan penutup dipotong menjadi subtree dari node baruTransformasi tree ini cukup tidak biasa sehingga implementasi naif bisa dengan mudah menjadi O(n), tetapi dalam representasi AST yang diratakan, ini bisa diproses dalam O(1) terlepas dari jumlah node atau kedalaman stack
Representasi tree yang lebih rinci ada di https://github.com/raphlinus/pulldown-cmark/blob/b7e709c0bd6..., yang pada dasarnya menyimpan indeks
childdannextbersama isi node. Kode operasi tree saat pencocokan emphasis ada di https://github.com/raphlinus/pulldown-cmark/blob/b7e709c0bd6...Performanya sangat bagus. pulldown-cmark mungkin bukan parser CommonMark tunggal tercepat, tetapi cukup kompetitif, dan misalnya jauh lebih cepat daripada pendekatan yang melakukan alokasi per node
Jadi teringat presentasi GDC yang memuji Rust. Isinya bahwa Rust, karena borrow checker-nya, bisa membuat orang frustrasi, atau justru mendorong mereka menata kode dengan sistem entity-component
Menariknya, dalam kasus nyata dengan masalah lifetime yang rumit, nilai borrow checker pada akhirnya terletak pada bagaimana ia membuat orang mencari cara untuk memasukkan semuanya ke dalam array dan mereferensikannya dengan indeks agar sebisa mungkin menghindari borrow checker
Karena objek-objek tidak lagi saling mereferensikan secara langsung, kegunaan sistem tipe berkurang drastis, dan kode menjadi sangat sulit ditelusuri. Saya percaya ini bisa berguna pada sistem yang sangat besar, tetapi pada program kecil yang saya tulis, rasanya lebih seperti penghalang
Ini mencakup game, basis data, embedded, pekerjaan batch bergaya komputasi performa tinggi, bahkan compiler
Tentu saja pembatasan aliasing tetap ada, jadi race condition data tidak akan muncul, tetapi bug yang pada dasarnya sama dengan bug dari pointer mentah tetap bisa terjadi
Dalam proses itu, pihak yang meminjam selalu hidup lebih singkat daripada pemiliknya, dan selama masa kepemilikan hanya bisa mengakses memori yang memang dialokasikan. Saat pemilik mati, memorinya dibebaskan, dan setelah itu tidak seorang pun bisa memakainya atau membebaskannya lagi, sehingga use-after-free, double free, dan akses ke memori yang tidak dialokasikan bisa dihindari
Bahkan ada klaim aneh di C bahwa arena menyelesaikan masalah keamanan memori, padahal tergantung programnya justru tetap sama-sama bisa memicu dangling pointer atau use-after-free dan semacamnya
Masalah yang sama muncul di C/C++ dan Rust dengan cara yang sedikit berbeda
Komentar saya pada tulisan ini dua bulan lalu: https://old.reddit.com/r/ProgrammingLanguages/comments/1350d...
Singkatnya, kelebihannya sangat nyata, tetapi kekurangannya juga harus dibicarakan. Arena menunda keamanan memori dan kepemilikannya bisa rumit, perubahan serta penambahan list/vector juga menjadi lebih kompleks, dan representasi pointer lebih ramah untuk debugger
Di bagian bawah tulisan ini ada tautan ke halaman wiki saya, dan saya senang melihat ada kode nyata dan hasil pengukuran: https://github.com/oilshell/oil/wiki/Compact-AST-Representat...
Term Prolog juga direpresentasikan di heap dengan cara seperti ini dalam Warren Abstract Machine (WAM). Jika term Prolog
+(*(a,b), c)ditulis dalam notasi operator seperti pada contoh di tulisan, hasilnya menjadiexpr(E) :- E = a*b + c.Lalu representasi yang di-flatten dibuat di stack global mesin virtual. Di Scryer Prolog, instruksi WAM dapat dilihat dengan
?- wam_instructions(expr/1, Is), maplist(portray_clause, Is).Hasilnya akan terlihat seperti
put_structure(*,2,x(3)).,set_constant(a).,set_constant(b).,put_structure(+,2,x(2)).,set_value(x(3)).,set_constant(c).,execute(=,2).Kedua term majemuk dilinearkan, sehingga di heap setiap functor diikuti argumennya, dan masing-masing tepat menempati satu sel memori WAM. Argumen-argumennya bisa menunjuk ke sel memori lain
Heap adalah array dari sel-sel seperti ini, dan semua sel memiliki tipe konkret yang sama. Misalnya, Scryer Prolog memakai 8 byte per sel sehingga akses dan modifikasi sel sangat efisien di arsitektur 64-bit
“Daripada mengalokasikan objek
Exprsecara berlebihan di heap, simpan semuanya dalam satu array kontigu, lalu biarkan anak mereferensikan indeks array itu alih-alih pointer” lebih dekat ke representasi heap alternatif daripada flattening. Bentuk AST-nya sendiri tidak berubahIni sudah lama dilakukan di banyak bahasa, misalnya seperti keluarga Lisp yang menaruh cons cell dan objek lain di dalam array, lalu memakai bump allocation dan pointer berbasis indeks
Ketika objek berada di dalam array, garbage collector lebih mudah menelusurinya pada tahap sweep setelah marking selesai. Tahap marking berjalan di atas graf untuk menemukan objek yang masih dapat dijangkau, dan tahap sweep melewati array datar untuk membersihkan bit GC serta menandai objek yang tak terjangkau untuk didaur ulang
Akan sulit menemukan implementasi Lisp yang cukup serius yang memanggil
mallocterpisah untuk setiap cons cell. Jika begitu, akan dibutuhkan linked list global untuk tahap sweep GC, atau array global yang hanya menyimpan pointerSaya setidaknya sudah dua kali melihat proyek Lisp mainan buatan akhir pekan yang melakukan
mallocuntuk cons cell, membiarkan kebocoran memori, dan meninggalkan GC sebagai TODO raksasaBahkan jika sel berasal dari heap array terkompresi, array global pada akhirnya tetap bisa muncul. Misalnya, saat mengimplementasikan garbage collection generasional pada allocator non-copying, objek muda bisa dikumpulkan ke array bantu agar bisa disapu pada siklus GC cepat, dan array itu merepresentasikan nursery
Tulisan yang bagus, tetapi ada dua jebakan
Tagged union juga bisa dipakai, tetapi penggunaan memorinya harus ditangani dengan cerdas
Teknik “virtual memory” memungkinkan wilayah memori fisik yang terfragmentasi disambung menjadi wilayah memori virtual yang kontigu. Jika ruang alamat virtual tersegmentasi, dari awal ia tidak akan terfragmentasi, dan setiap wilayah memori bisa selalu membesar di tempat tanpa bertabrakan dengan wilayah lain
Kalau begitu, implementasi
realloc()bisa menghilangkan jalurmemcpy(), sayang sekaliBagian dalamnya mungkin tidak aman, tetapi antarmuka aman yang hanya-baca tampaknya memungkinkan. Pembebasan memori akan O(n), tetapi tetap jauh lebih cepat daripada tree
Saya terkejut masih ada dua pointer (“referensi”) yang tersisa di node, padahal sekarang kita tahu pointer pertama selalu menunjuk tepat ke node berikutnya
Saya pernah melihat https://github.com/rswier/c4 memakai pendekatan seperti itu. Tentu saja keterbacaan kodenya tidak bagus, tetapi lebih kecil dan lebih cepat
Dalam manajemen memori, ketika mendengar arena, yang terbayang bagi saya biasanya allocator arena biasa, bukan “flattening”
Saat harus mengalokasikan banyak item dengan masa hidup yang sama, pendekatan ini mengalokasikannya dengan lebih efisien dari satu atau beberapa blok memori besar, lalu ketika masa hidup bersama itu berakhir, yang dibebaskan adalah blok besarnya, bukan item satu per satu
Karena kita hanya perlu memakai ruang dalam blok induk secara berurutan, tidak perlu free list seperti pada heap allocator serbaguna, sehingga alokasinya juga bisa lebih efisien
Dalam konteks ini, “flattening” yang memakai indeks alih-alih pointer juga bisa dilihat sebagai penggunaan pointer relatif terhadap blok induk, yaitu offset
Saat mengimplementasikan V7(https://github.com/cesanta/v7), parser dan interpreter JavaScript untuk lingkungan embedded dengan memori yang sangat terbatas, saya memakai struktur AST ringkas seperti ini
Belakangan saya mengubahnya menjadi tahap kompilasi dari AST ke bytecode, tetapi untuk sementara waktu saya langsung menelusuri AST implisit saat interpretasi berlangsung
Saya juga melakukan sesuatu yang mirip di compiler Yaml to Sql saya di https://yaml2sql.netlify.app
Proses flattening-nya agak aneh, tetapi menyenangkan, dan pada akhirnya sepadan dengan usaha yang dikeluarkan
Misalnya, flattening ekspresi boolean adalah latihan yang bagus bagi siapa pun yang ingin mencobanya: https://github.com/revskill10/yaml2sql/blob/main/app/query.r...