CPS, atau gaya penerusan lanjutan, adalah representasi perantara untuk program, khususnya program fungsional. Ini digunakan dalam kompiler untuk bahasa seperti SML dan Skema.
Dalam CPS, ada dua aturan: pertama, argumen fungsi/operator harus selalu ada remeh; kedua, pemanggilan fungsi tidak kembali. Banyak hal yang keluar dari sini.
Dalam posting ini, kami akan memperkenalkan CPS dengan membangun sebuah sederhana (Plotkin) Transformasi CPS dari bahasa kecil seperti Skema. Kami akan membuat sketsa beberapa optimasi pada IR. Kemudian kita akan melihat beberapa cara umum untuk mengkompilasi IR untuk dieksekusi.
Skema Mini
Kami memiliki bilangan bulat: 5
Kami memiliki beberapa operasi pada bilangan bulat: (+ 1 2)
, (< 3 4)
(mengembalikan 1 atau 0)
Kita dapat mengikat variabel: (let ((x 1)) x)
/ (letrec ...)
?
Kita dapat membuat fungsi parameter tunggal: (lambda (x) (+ x 1))
dan mereka dapat menutup variabel
Kita dapat memanggil fungsi: (f x)
Kita dapat bercabang: (if (< x y) x y)
(di mana kami memutuskan untuk menggunakan 0 dan 1 sebagai boolean)
Bagaimana saya…?
Kita akan mengimplementasikan fungsi rekursif yang disebut cps
secara bertahap, dimulai dengan bentuk bahasa yang mudah dan berlanjut dari sana. Banyak orang suka mengimplementasikan kompiler baik dalam Skema maupun Skema tetapi saya menemukan bahwa semua kuasi-kuotasi membuat segalanya lebih rumit dari yang seharusnya dan mengaburkan pelajaran, jadi kami melakukannya dengan Python.
Ini berarti kami memiliki pemisahan yang jelas antara kode dan data. Kode Python kami adalah kompilernya dan kami akan mengandalkan daftar Python untuk ekspresi S. Berikut ini beberapa contoh program Skema yang mungkin terlihat seperti daftar Python:
5
("+", 1, 2)
("let", (("x", 1)), "x")
("lambda", ("x"), ("+", "x", 1))
Kita cps
fungsi akan mengambil dua argumen. Argumen pertama, exp
adalah ekspresi yang akan dikompilasi. Argumen kedua, k
adalah a kelanjutan. Kita harus melakukannya sesuatu dengan nilai-nilai kita, namun CPS mengharuskan fungsi tersebut tidak pernah kembali. Jadi apa yang kita lakukan? Tentu saja, panggil fungsi lain.
Ini berarti pemanggilan tingkat atas cps
akan melewati beberapa kelanjutan tingkat atas yang berguna seperti print-to-screen
atau write-to-file
. Semua doa anak dari cps
akan diteruskan baik itu kelanjutan, kelanjutan yang diproduksi, atau variabel kelanjutan.
cps(("+", 1, 2), "$print-to-screen")
# ...or...
cps(("+", 1, 2), ("cont", ("v"), ...))
Jadi kelanjutan hanyalah fungsi lain. Agak.
Meskipun Anda benar-benar dapat menghasilkan fungsi kelas satu yang nyata untuk digunakan sebagai kelanjutan, hal ini sering kali berguna partisi IR CPS Anda dengan memisahkannya. Semua fungsi nyata (pengguna) akan mengambil kelanjutan sebagai parameter terakhir—untuk menyerahkan nilai kembaliannya—dan dapat keluar secara sewenang-wenang, sedangkan semua kelanjutan dihasilkan dan dialokasikan/dibebaskan dengan cara seperti tumpukan. (Kami bahkan dapat menerapkannya menggunakan tumpukan asli jika kami mau. Lihat “CPS yang Dipartisi” dan “Memulihkan tumpukan” dari halaman Might.)
Untuk alasan ini kami secara sintaksis membedakan bentuk fungsi IR ("fun", ("x",
dari formulir kelanjutan IR
"k"), ...)("cont", ("x"), ...)
. Demikian pula, kami membedakan pemanggilan fungsi ("f", "x")
dari panggilan lanjutan ("$call-cont",
(Di mana
"k", "x")$call-cont
adalah bentuk khusus yang diketahui oleh kompiler).
Mari kita lihat bagaimana kita mengkompilasi bilangan bulat menjadi CPS:
def cps(exp, k):
match exp:
case int(_):
return ("$call-cont", k, exp)
raise NotImplementedError(type(exp)) # TODO
cps(5, "k") # ("$call-cont", "k", 5)
Bilangan bulat memenuhi remeh persyaratan. Begitu juga dengan semua data konstan (jika kita memiliki string, float, dll), referensi variabel, dan bahkan ekspresi lambda. Tak satu pun dari hal ini memerlukan evaluasi rekursif, yang merupakan inti dari persyaratan sepele. Semua ini mengharuskan AST bersarang kita dilinearisasi menjadi serangkaian operasi kecil.
Variabel juga mudah untuk dikompilasi. Kami membiarkan nama variabel apa adanya untuk saat ini di IR kami, jadi kami tidak perlu menyimpan parameter lingkungan.
def cps(exp, k):
match exp:
case int(_) | str(_):
return ("$call-cont", k, exp)
raise NotImplementedError(type(exp)) # TODO
cps("x", "k") # ("$call-cont", "k", "x")
Sekarang mari kita lihat pemanggilan fungsi. Pemanggilan fungsi adalah tipe ekspresi pertama yang memerlukan evaluasi subekspresi secara rekursif. Untuk mengevaluasi (f
misalnya kita mengevaluasi
x)f
Kemudian x
lalu lakukan pemanggilan fungsi. Urutan evaluasi tidak penting untuk postingan ini; itu adalah properti semantik dari bahasa yang sedang dikompilasi.
Untuk mengonversi ke CPS, kita harus melakukan kompilasi argumen secara rekursif dan juga mensintesis kelanjutan pertama kita!
Untuk mengevaluasi subekspresi, yang bisa jadi sangat rumit, kita harus melakukan panggilan rekursif ke cps
. Tidak seperti kompiler biasa, ini tidak mengembalikan nilai. Sebaliknya, Anda meneruskannya sebagai kelanjutan (apakah kata “panggilan balik” membantu di sini?) untuk melakukan pekerjaan di masa depan ketika nilai tersebut memiliki nama. Untuk menghasilkan nama internal kompiler, kami memiliki a gensym
fungsi yang tidak menarik dan mengembalikan string unik.
Satu-satunya hal yang membedakannya dari, misalnya, panggilan balik JavaScript, adalah bahwa ini bukan fungsi Python melainkan fungsi dalam kode yang dihasilkan.
def cps(exp, k):
match exp:
case (func, arg):
vfunc = gensym()
varg = gensym()
return cps(func, ("cont", (vfunc),
cps(arg, ("cont", (varg),
(vfunc, varg, k)))))
# ...
cps(("f", 1), "k")
# ("$call-cont", ("cont", ("v0"),
# ("$call-cont", ("cont", ("v1"),
# ("v0", "v1", "k")),
# 1)),
# "f")
Perhatikan bahwa panggilan fungsi yang kami hasilkan berasal (f x)
sekarang juga memiliki argumen lanjutan yang sebelumnya tidak ada. hal ini dikarenakan (f x)
tidak kembali
apa pun, melainkan meneruskan nilai ke kelanjutan yang diberikan.
Panggilan ke operator primitif seperti +
adalah kasus menarik kami yang lain. Seperti pemanggilan fungsi, kami mengevaluasi operan secara rekursif dan meneruskan argumen kelanjutan tambahan. Perhatikan bahwa tidak semua implementasi CPS melakukan hal ini untuk operator matematika sederhana; beberapa memilih untuk mengizinkan aritmatika sederhana untuk benar-benar “mengembalikan” nilai.
def gensym(): ...
def cps(exp, k):
match exp:
case (op, x, y) if op in ("+", "-"):
vx = gensym()
vy = gensym()
return cps(x, ("cont", (vx),
cps(y, ("cont", (vy),
(f"${op}", vx, vy, k)))))
# ...
cps(("+", 1, 2), "k")
# ("$call-cont", ("cont", ("v0"),
# ("$call-cont", ("cont", ("v1"),
# ("$+", "v0", "v1", "k")),
# 2)),
# 1)
Kami juga membuat formulir khusus untuk operator di CPS IR kami yang dimulai dengan
$
. Jadi +
akan berubah menjadi $+
dan sebagainya. Ini membantu membedakan pemanggilan operator dari pemanggilan fungsi.
Sekarang mari kita lihat cara membuat fungsi. Ekspresi Lambda seperti (lambda (x)
perlu membuat fungsi saat run-time dan badan fungsi itu berisi beberapa kode. Untuk “kembali”, kami menggunakan
(+ x 1))$call-cont
seperti biasa, tapi kita juga harus ingat untuk membuat yang baru fun
formulir dengan parameter kelanjutan (dan kemudian meneruskannya ke badan fungsi).
def cps(exp, k):
match exp:
case ("lambda", (arg), body):
vk = gensym("k")
return ("$call-cont", k,
("fun", (arg, vk), cps(body, vk)))
# ...
cps(("lambda", ("x"), "x"), "k")
# ("$call-cont", "k",
# ("fun", ("x", "k0"),
# ("$call-cont", "k0", "x")))
Baiklah, yang terakhir dalam bahasa mini ini adalah milik kita if
ekspresi: (if cond iftrue
dimana semuanya
iffalse)cond
, iftrue
Dan iffalse
dapat berupa ekspresi bertumpuk secara sewenang-wenang. Ini berarti kita menelepon cps
secara rekursif sebanyak tiga kali.
Kami juga menambahkan kompiler baru yang disebut ($if cond iftrue iffalse)
yang mengambil satu ekspresi sepele—kondisi—dan memutuskan cabang mana yang akan dieksekusi. Ini kira-kira setara dengan lompatan bersyarat kode mesin.
Penerapan langsungnya berfungsi dengan baik, namun dapatkah Anda melihat apa yang salah?
def cps(exp, k):
match exp:
case ("if", cond, iftrue, iffalse):
vcond = gensym()
return cps(cond, ("cont", (vcond),
("$if", vcond,
cps(iftrue, k),
cps(iffalse, k))))
# ...
cps(("if", 1, 2, 3), "k")
# ("$call-cont", ("cont", ("v0"),
# ("$if", "v0",
# ("$call-cont", "k", 2),
# ("$call-cont", "k", 3))),
# 1)
Masalahnya adalah kelanjutan kita, k
tidak harus berupa variabel lanjutan—bisa berupa ekspresi rumit yang sewenang-wenang. Implementasi kami menyalinnya ke dalam kode yang dikompilasi dua kaliyang dalam kasus terburuk dapat menyebabkan pertumbuhan program secara eksponensial. Sebagai gantinya, mari kita ikat ke sebuah nama lalu gunakan nama itu dua kali.
def cps(exp, k):
match exp:
case ("if", cond, iftrue, iffalse):
vcond = gensym()
vk = gensym("k")
return ("$call-cont", ("cont", (vk),
cps(cond, ("cont", (vcond),
("$if", vcond,
cps(iftrue, vk),
cps(iffalse, vk))))),
k)
# ...
cps(("if", 1, 2, 3), "k")
# ("$call-cont", ("cont", ("k1"),
# ("$call-cont", ("cont", ("v0"),
# ("$if", "v0",
# ("$call-cont", "k1", 2),
# ("$call-cont", "k1", 3))),
# 1)),
# "k")
Terakhir, let
dapat ditangani dengan menggunakan kelanjutan, karena kita telah mengikat variabel sementara pada contoh sebelumnya. Anda juga bisa mengatasinya dengan melakukan desugaring ke dalamnya
((lambda (x) body) value)
tapi itu akan menghasilkan lebih banyak overhead administratif untuk dihilangkan oleh pengoptimal Anda nanti.
def cps(exp, k):
match exp:
case ("let", (name, value), body):
return cps(value, ("cont", (name),
cps(body, k)))
# ...
cps(("let", ("x", 1), ("+", "x", 2)), "k")
# ('$call-cont', ('cont', ('x'),
# ('$call-cont', ('cont', ('v0'),
# ('$call-cont', ('cont', ('v1'),
# ('$+', 'v0', 'v1', 'k')),
# 2)),
# 'x')),
# 1)
Itu dia. Konverter Skema Mini ke CPS yang berfungsi. Implementasi saya adalah ~30 baris kode Python. Ini singkat dan manis tetapi Anda mungkin telah memperhatikan beberapa kekurangan…
Sekarang, Anda mungkin memperhatikan bahwa kami memberi nama pada banyak ekspresi sepele—yang tidak perlu cont
bentuk yang digunakan seperti let
ikatan. Mengapa memberi nama bilangan bulat 3
kalau itu sepele?
Salah satu pendekatan yang dilakukan orang untuk menghindari hal ini adalah meta-lanjutanyang menurut saya banyak orang menyebutnya sebagai “transformasi tingkat tinggi”. Daripada selalu menghasilkan
cont
s, terkadang kita dapat meneruskan fungsi tingkat kompiler (dalam hal ini, Python) sebagai gantinya.
Melihat artikel Matt Might dan apa yang menurut saya berhasil Implementasi ular piton.
Pendekatan ini, meskipun kadang-kadang lebih sulit untuk dipikirkan dan lebih kompleks, mengurangi sejumlah besar kode sebelum kode tersebut dikeluarkan. Untuk kompiler dengan beberapa lintasan, untuk lingkungan dengan sumber daya terbatas, untuk program berukuran besar,… hal ini sangat masuk akal.
Pendekatan lain, yang mungkin lebih mudah untuk dipikirkan, adalah bersandar pada pengoptimal Anda. Anda mungkin tetap menginginkan pengoptimal, jadi sebaiknya Anda menggunakannya untuk mengurangi kode perantara juga.
Pengoptimalan
Sama seperti IR lainnya, pengoptimalan dapat dilakukan dengan melakukan penulisan ulang secara rekursif. Kami tidak akan menerapkan apa pun di sini (untuk saat ini… mungkin saya akan kembali ke sini) tetapi akan membuat sketsa beberapa hal yang umum.
Yang paling sederhana mungkin adalah pelipatan konstan, seperti berputar (+ 3 4)
ke dalam 7
. Persamaan CPS terlihat seperti ini:
("$+", "3", "4", "k") # => ("$call-cont", "k", 7)
("$if", 1, "t", "f") # => t
("$if", 0, "t", "f") # => f
Pengoptimalan yang sangat penting, khususnya jika menggunakan transformasi CPS sederhana yang telah kita gunakan, adalah pengurangan beta. Ini adalah proses mengubah ekspresi seperti ((lambda (x) (+ x 1)) 2)
ke dalam (+ 2 1)
dengan mengganti 2
untuk x
. Misalnya, di CPS:
("$call-cont", ("cont", ("k1"),
("$call-cont", ("cont", ("v0"),
("$if", "v0",
("$call-cont", "k1", 2),
("$call-cont", "k1", 3))),
1)),
"k")
# into
("$call-cont", ("cont", ("v0"),
("$if", "v0",
("$call-cont", "k", 2),
("$call-cont", "k", 3))),
1)
# into
("$if", 1,
("$call-cont", "k", 2),
("$call-cont", "k", 3))
# into (via constant folding)
("$call-cont", "k", 2)
Pergantian harus memperhatikan cakupan, dan oleh karena itu memerlukan threading parameter lingkungan melalui pengoptimal Anda.
Selain itu: bahkan jika Anda melakukan “alphatise” pada ekspresi Anda untuk membuatnya memiliki pengikatan dan nama variabel yang unik, Anda mungkin secara tidak sengaja membuat pengikatan kedua dengan nama yang sama saat melakukan substitusi. Misalnya:
# substitute(haystack, name, replacement) substitute(("+", "x", "x"), "x", ("let", ("x0", 1), "x0"))
Ini akan menciptakan dua ikatan
x0
yang melanggar properti keunikan global.
Anda mungkin tidak selalu ingin melakukan penulisan ulang ini. Untuk menghindari ledakan kode, Anda mungkin hanya ingin mengganti jika nama parameter fungsi atau kelanjutan muncul nol atau satu kali di badan. Atau, jika muncul lebih dari satu kali, substitusikan hanya jika ekspresi yang disubstitusikan adalah bilangan bulat/variabel. Ini adalah heuristik sederhana yang akan menghindari beberapa skenario terburuk namun mungkin tidak memberikan manfaat maksimal—ini adalah optimal lokal.
Hal lain yang harus diperhatikan adalah bahwa pergantian pemain dapat mengubah urutan evaluasi. Jadi meskipun Anda hanya memiliki satu referensi parameter, Anda mungkin tidak ingin menggantinya:
((lambda (f) (begin (g) f))
(do-a-side-effect))
Seperti programnya saat ini, do-a-side-effect
akan dipanggil sebelumnya g
dan hasilnya akan menjadi f
. Jika Anda menggantinya do-a-side-effect
untuk f
di pengoptimal Anda, g
akan dipanggil sebelumnya do-a-side-effect
. Anda bisa lebih agresif jika penganalisis Anda memberi tahu Anda fungsi apa yang bebas efek samping, namun sebaliknya… berhati-hatilah dengan pemanggilan fungsi.
Ada juga optimasi lebih lanjut tetapi lebih dari sekedar pengenalan CPS dan saya tidak merasa cukup percaya diri untuk membuat sketsanya.
Baiklah, kita telah melakukan banyak transformasi CPS→CPS. Sekarang kami ingin mengeksekusi kode yang dioptimalkan. Untuk melakukan itu, kita harus mengubah CPS menjadi sesuatu yang dirancang untuk dieksekusi.
Ke C, mungkin bermimpi
Pada bagian ini kami akan mencantumkan beberapa pendekatan untuk menghasilkan kode yang dapat dieksekusi dari CPS tetapi kami tidak akan menerapkannya.
Anda dapat menghasilkan kode C yang naif langsung dari CPS. Itu fun
Dan cont
formulir menjadi fungsi C tingkat atas. Untuk mendukung penutupan, Anda perlu melakukan analisis variabel bebas dan mengalokasikan struktur penutupan untuk masing-masing. (Lihat juga pendekatan dalam scrapscript di bagian yang disebut “fungsi”.) Kemudian Anda dapat melakukan konvensi pemanggilan yang sangat umum di mana Anda meneruskan penutupan. Sayangnya, hal ini tidak terlalu efisien dan tidak menjamin penghapusan tail-call.
Untuk mendukung penghapusan tail-call, Anda dapat menggunakan trampolin. Hal ini sebagian besar melibatkan pengalokasian penutupan seperti bingkai pada heap pada setiap tail-call. Jika Anda memiliki pemulung, ini tidak terlalu buruk; bingkainya tidak berumur panjang. Faktanya, jika Anda instrumen contoh faktorial dalam postingan blog Eli, Anda dapat melihat bahwa bingkai trampolin hanya aktif hingga bingkai berikutnya dialokasikan.
Pengamatan ini mengarah pada perkembangan Cheney di MTA
algoritma, yang menggunakan tumpukan panggilan C sebagai generasi muda untuk pengumpul sampah. Ini menggunakan setjmp
Dan longjmp
untuk melepaskan tumpukan. Pendekatan ini digunakan dalam Skema AYAM Dan Skema Topan. Melihat
Implementasi Baker tahun 1994.
Jika Anda tidak ingin melakukan hal-hal trampolin ini, Anda juga dapat melakukan pendekatan One Big Switch yang mengisi masing-masing fun
pasir cont
s menjadi a case
secara masif switch
penyataan. Panggilan menjadi goto
S. Anda dapat mengelola akar tumpukan dengan cukup mudah dalam satu larik yang berdekatan. Namun, seperti yang Anda bayangkan, program Skema yang lebih besar mungkin menyebabkan masalah pada beberapa kompiler C.
Terakhir, Anda tidak perlu membuat C. Anda juga dapat menurunkan sendiri dari CPS ke IR tingkat yang lebih rendah dan kemudian ke beberapa jenis bahasa assembly.
Menyelesaikan
Anda telah melihat cara menghasilkan CPS, cara mengoptimalkannya, dan cara menghilangkannya. Masih banyak lagi yang bisa dipelajari, jika Anda tertarik. Kirimkan saya materi jika Anda merasa berguna.
Saya awalnya berencana untuk menulis pengoptimal berbasis CPS dan pembuat kode untuk skrip memo, tetapi saya terjebak pada detail yang lebih baik dalam menyusun pencocokan pola dengan CPS. Mungkin saya akan kembali ke sini di masa depan dengan menjadikannya bersarang
if
s atau sesuatu.
Memeriksa kodenya.
Ucapan Terima Kasih
Berkat Vaibhav Sagar Dan Kartik Agaram untuk memberikan masukan pada postingan ini. Berkat
Olin Menggigil untuk kursus luar biasa tentang kompilasi bahasa pemrograman fungsional.