Mengapa .NET / C # tidak mengoptimalkan rekursi tail-call?

111

Saya menemukan pertanyaan ini tentang bahasa mana yang mengoptimalkan rekursi ekor. Mengapa C # tidak mengoptimalkan rekursi ekor, jika memungkinkan?

Untuk kasus konkret, mengapa metode ini tidak dioptimalkan menjadi satu loop ( Visual Studio 2008 32-bit, jika itu penting) ?:

private static void Foo(int i)
{
    if (i == 1000000)
        return;

    if (i % 100 == 0)
        Console.WriteLine(i);

    Foo(i+1);
}
ripper234
sumber
Saya membaca buku tentang Struktur Data hari ini yang membagi fungsi rekursif menjadi dua yaitu preemptive(misalnya algoritma faktorial) dan Non-preemptive(misalnya fungsi ackermann). Penulis hanya memberikan dua contoh yang telah saya sebutkan tanpa memberikan alasan yang tepat di balik percabangan ini. Apakah percabangan ini sama dengan fungsi rekursif ekor dan non-ekor?
NSP
5
Percakapan berguna tentang hal itu oleh Jon skeet dan Scott Hanselman pada 2016 youtu.be/H2KkiRbDZyc?t=3302
Daniel B
@RBT: Saya pikir itu berbeda. Ini mengacu pada jumlah panggilan rekursif. Panggilan tail adalah tentang panggilan yang muncul di posisi ekor, yaitu hal terakhir yang dilakukan suatu fungsi sehingga mengembalikan hasil dari panggilan secara langsung.
JD

Jawaban:

84

Kompilasi JIT adalah tindakan penyeimbangan yang rumit antara tidak menghabiskan terlalu banyak waktu melakukan fase kompilasi (sehingga memperlambat aplikasi yang berumur pendek) vs. tidak melakukan analisis yang cukup untuk menjaga aplikasi tetap kompetitif dalam jangka panjang dengan kompilasi standar sebelumnya. .

Menariknya, langkah kompilasi NGen tidak ditargetkan untuk lebih agresif dalam pengoptimalannya. Saya menduga ini karena mereka tidak ingin memiliki bug di mana perilakunya bergantung pada apakah JIT atau NGen bertanggung jawab atas kode mesin.

The CLR itu sendiri tidak mendukung ekor optimasi panggilan, tetapi compiler bahasa-spesifik harus tahu bagaimana untuk menghasilkan relevan opcode dan JIT harus bersedia untuk menghormatinya. F #'s fsc akan menghasilkan opcodes yang relevan (meskipun untuk rekursi sederhana itu mungkin hanya mengubah semuanya menjadi whileloop secara langsung). Csc C # tidak.

Lihat posting blog ini untuk beberapa detail (sangat mungkin sekarang sudah ketinggalan zaman mengingat perubahan JIT baru-baru ini). Perhatikan bahwa CLR berubah untuk 4.0 , x86, x64 dan ia64 akan menghormati itu .

ShuggyCoUk
sumber
2
Lihat juga posting ini: social.msdn.microsoft.com/Forums/en-US/netfxtoolsdev/thread/… dimana saya menemukan bahwa tail lebih lambat dari panggilan biasa. Eep!
alas
77

Ini Pengiriman umpan balik Microsoft Connect harus menjawab pertanyaan Anda. Ini berisi tanggapan resmi dari Microsoft, jadi saya akan merekomendasikan untuk melakukannya.

Terima kasih untuk sarannya. Kami telah mempertimbangkan untuk mengeluarkan instruksi panggilan ekor pada sejumlah titik dalam pengembangan kompilator C #. Namun, ada beberapa masalah halus yang telah mendorong kami untuk menghindari hal ini sejauh ini: 1) Sebenarnya ada biaya overhead yang tidak sepele untuk menggunakan instruksi .tail di CLR (ini bukan hanya instruksi lompat karena panggilan tail akhirnya menjadi di banyak lingkungan yang tidak terlalu ketat seperti lingkungan runtime bahasa fungsional di mana panggilan tail sangat dioptimalkan). 2) Ada beberapa metode C # nyata di mana akan legal untuk mengeluarkan panggilan ekor (bahasa lain mendorong pola pengkodean yang memiliki lebih banyak rekursi ekor, dan banyak yang sangat bergantung pada pengoptimalan panggilan ekor benar-benar melakukan penulisan ulang global (seperti transformasi penerusan lanjutan) untuk meningkatkan jumlah rekursi ekor). 3) Sebagian karena 2), kasus di mana metode C # stack overflow karena rekursi dalam yang seharusnya berhasil cukup jarang terjadi.

Semua yang dikatakan, kami terus melihat ini, dan kami mungkin di rilis mendatang dari compiler menemukan beberapa pola yang masuk akal untuk mengeluarkan instruksi .tail.

By the way, seperti yang telah ditunjukkan, perlu dicatat bahwa rekursi ekor yang dioptimalkan pada x64.

Noldorin
sumber
3
Anda mungkin menemukan ini berguna juga: weblogs.asp.net/podwysocki/archive/2008/07/07/…
Noldorin
Tidak masalah, senang Anda merasa terbantu.
Noldorin
17
Terima kasih telah mengutipnya, karena sekarang menjadi 404!
Roman Starkov
3
Tautan sekarang sudah diperbaiki.
luksan
15

C # tidak mengoptimalkan rekursi panggilan-ekor karena untuk itulah F #!

Untuk mengetahui lebih mendalam tentang kondisi yang mencegah compiler C # melakukan pengoptimalan panggilan-ekor, lihat artikel ini: Kondisi panggilan-ekor JIT CLR .

Interoperabilitas antara C # dan F #

C # dan F # saling beroperasi dengan sangat baik, dan karena .NET Common Language Runtime (CLR) dirancang dengan interoperabilitas ini, setiap bahasa dirancang dengan pengoptimalan yang khusus untuk maksud dan tujuannya. Untuk contoh yang menunjukkan betapa mudahnya memanggil kode F # dari kode C #, lihat Memanggil kode F # dari kode C # ; untuk contoh panggilan fungsi C # dari kode F #, lihat Memanggil fungsi C # dari F # .

Untuk mendelegasikan interoperabilitas, lihat artikel ini: Mendelegasikan interoperabilitas antara F #, C # dan Visual Basic .

Perbedaan teoritis dan praktis antara C # dan F #

Berikut adalah artikel yang membahas beberapa perbedaan dan menjelaskan perbedaan desain rekursi tail-call antara C # dan F #: Menghasilkan Opcode Tail-Call di C # dan F # .

Berikut adalah artikel dengan beberapa contoh di C #, F #, dan C ++ \ CLI: Petualangan di Rekursi Ekor di C #, F #, dan C ++ \ CLI

Perbedaan teoritis utama adalah bahwa C # dirancang dengan loop sedangkan F # dirancang berdasarkan prinsip kalkulus Lambda. Untuk buku yang sangat bagus tentang prinsip kalkulus Lambda, lihat buku gratis ini: Structure and Interpretation of Computer Programs, oleh Abelson, Sussman, dan Sussman .

Untuk artikel pengantar yang sangat bagus tentang tail call di F #, lihat artikel ini: Pengenalan Mendetail tentang Tail Calls di F # . Terakhir, berikut adalah artikel yang membahas perbedaan antara rekursi non-ekor dan rekursi panggilan-ekor (di F #): Rekursi ekor vs. rekursi non-ekor di F tajam .

devinbost
sumber
8

Saya baru-baru ini diberitahu bahwa kompilator C # untuk 64 bit mengoptimalkan rekursi ekor.

C # juga menerapkan ini. Alasan mengapa tidak selalu diterapkan, adalah karena aturan yang digunakan untuk menerapkan rekursi ekor sangat ketat.

Alexandre Brisebois
sumber
8
Jitter x64 melakukan ini, tetapi kompiler C # tidak
Mark Sowul
Terima kasih untuk informasi. Ini putih berbeda dari yang saya pikirkan sebelumnya.
Alexandre Brisebois
3
Hanya untuk mengklarifikasi dua komentar ini, C # tidak pernah mengeluarkan opcode 'ekor' CIL, dan saya yakin ini masih berlaku di tahun 2017. Namun, untuk semua bahasa, opcode tersebut selalu menjadi penasehat hanya dalam arti masing-masing kegugupan (x86, x64 ) akan mengabaikannya secara diam-diam jika berbagai kondisi tidak terpenuhi (yah, tidak ada kesalahan kecuali kemungkinan stack overflow ). Ini menjelaskan mengapa Anda dipaksa untuk mengikuti 'tail' dengan 'ret' - untuk kasus ini. Sementara itu, kegugupan juga bebas menerapkan pengoptimalan saat tidak ada awalan 'tail' di CIL, lagi-lagi jika dianggap sesuai, dan terlepas dari bahasa .NET.
Glenn Slayden
3

Anda dapat menggunakan teknik trampolin untuk fungsi rekursif-ekor di C # (atau Java). Namun, solusi yang lebih baik (jika Anda hanya peduli tentang pemanfaatan tumpukan) adalah dengan menggunakan metode pembantu kecil ini untuk membungkus bagian dari fungsi rekursif yang sama dan membuatnya berulang sambil menjaga agar fungsi tetap dapat dibaca.

naif
sumber
Trampolin bersifat invasif (merupakan perubahan global pada konvensi pemanggilan), ~ 10x lebih lambat dari eliminasi panggilan ekor yang tepat dan mereka mengaburkan semua informasi pelacakan tumpukan sehingga lebih sulit untuk men-debug dan kode profil
JD
1

Seperti jawaban lain yang disebutkan, CLR memang mendukung pengoptimalan panggilan ekor dan tampaknya itu sedang dalam perbaikan progresif secara historis. Tetapi mendukungnya di C # memiliki Proposalmasalah terbuka di repositori git untuk desain bahasa pemrograman C # Mendukung rekursi ekor # 2544 .

Anda dapat menemukan beberapa detail dan info berguna di sana. Misalnya @jaykrell disebutkan

Biarkan saya memberikan apa yang saya tahu.

Terkadang tailcall adalah kinerja win-win. Itu bisa menghemat CPU. jmp lebih murah daripada call / ret. Ini bisa menghemat stack. Menyentuh lebih sedikit tumpukan menghasilkan lokalitas yang lebih baik.

Terkadang tailcall adalah kerugian kinerja, tumpukan menang. CLR memiliki mekanisme yang kompleks untuk meneruskan lebih banyak parameter ke callee daripada yang diterima pemanggil. Maksud saya secara khusus lebih banyak ruang tumpukan untuk parameter. Ini lambat. Tapi itu menghemat tumpukan. Ini hanya akan dilakukan dengan ekor. awalan.

Jika parameter pemanggil lebih besar tumpukan dari parameter callee, biasanya ini merupakan transformasi win-win yang cukup mudah. Mungkin ada faktor-faktor seperti perubahan posisi parameter dari yang dikelola menjadi integer / float, dan menghasilkan StackMaps yang tepat dan semacamnya.

Sekarang, ada sudut lain, yaitu algoritma yang menuntut penghapusan tailcall, untuk tujuan dapat memproses data besar secara sewenang-wenang dengan tumpukan tetap / kecil. Ini bukan tentang kinerja, tetapi tentang kemampuan untuk berlari sama sekali.

Juga izinkan saya menyebutkan (sebagai info tambahan), Ketika kami membuat lambda yang dikompilasi menggunakan kelas ekspresi di System.Linq.Expressionsnamespace, ada argumen bernama 'tailCall' yang seperti yang dijelaskan dalam komentarnya.

Bool yang menunjukkan apakah pengoptimalan panggilan ekor akan diterapkan saat menyusun ekspresi yang dibuat.

Saya belum mencobanya, dan saya tidak yakin bagaimana ini dapat membantu terkait dengan pertanyaan Anda, tetapi Mungkin seseorang dapat mencobanya dan mungkin berguna dalam beberapa skenario:


var myFuncExpression = System.Linq.Expressions.Expression.Lambda<Func<  >>(body:  , tailCall: true, parameters:  );

var myFunc =  myFuncExpression.Compile();
gaya hidup
sumber