Tại sao .NET / C # không tối ưu hóa cho đệ quy cuộc gọi đuôi?


111

Tôi đã tìm thấy câu hỏi này về những ngôn ngữ nào tối ưu hóa đệ quy đuôi. Tại sao C # không tối ưu hóa đệ quy đuôi, bất cứ khi nào có thể?

Đối với một trường hợp cụ thể, tại sao phương pháp này không được tối ưu hóa thành một vòng lặp ( Visual Studio 2008 32-bit, nếu điều đó quan trọng) ?:

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

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

    Foo(i+1);
}

Hôm nay tôi đang đọc một cuốn sách về Cấu trúc dữ liệu chia đôi hàm đệ quy thành hai preemptive(ví dụ: thuật toán giai thừa) và Non-preemptive(ví dụ: hàm ackermann). Tác giả chỉ đưa ra hai ví dụ mà tôi đã đề cập mà không đưa ra lý do xác đáng đằng sau sự phân đôi này. Sự phân đôi này có giống với các hàm đệ quy đuôi và không đuôi không?
RBT

5
Cuộc trò chuyện hữu ích về nó của Jon xiên và Scott Hanselman trên youtu.be/H2KkiRbDZyc?t=3302
Daniel B,

@RBT: Tôi nghĩ rằng điều đó là khác nhau. Nó đề cập đến số lượng cuộc gọi đệ quy. Các lệnh gọi đuôi là về các lệnh gọi xuất hiện ở vị trí đuôi, tức là điều cuối cùng mà một hàm thực hiện để nó trả về kết quả trực tiếp từ callee.
JD

Câu trả lời:


84

Biên dịch JIT là một hành động cân bằng khó khăn giữa việc không dành quá nhiều thời gian để thực hiện giai đoạn biên dịch (do đó làm chậm đáng kể các ứng dụng tồn tại trong thời gian ngắn) và không thực hiện đủ phân tích để giữ cho ứng dụng cạnh tranh trong thời gian dài với một biên dịch tiêu chuẩn trước thời hạn .

Điều thú vị là các bước biên dịch NGen không được nhắm mục tiêu để tối ưu hóa tích cực hơn. Tôi nghi ngờ điều này là bởi vì họ chỉ đơn giản là không muốn có lỗi trong đó hành vi phụ thuộc vào việc JIT hoặc NGen có chịu trách nhiệm về mã máy hay không.

Bản thân CLR hỗ trợ tối ưu hóa cuộc gọi đuôi, nhưng trình biên dịch theo ngôn ngữ cụ thể phải biết cách tạo opcode có liên quan và JIT phải sẵn sàng tôn trọng nó. Fsc của F # sẽ tạo ra các mã quang có liên quan (mặc dù đối với một phép đệ quy đơn giản, nó có thể chỉ chuyển đổi toàn bộ thành một whilevòng lặp trực tiếp). C # của csc không.

Xem bài đăng trên blog này để biết một số chi tiết (có thể hiện đã lỗi thời do các thay đổi JIT gần đây). Lưu ý rằng CLR thay đổi cho 4.0 x86, x64 và ia64 sẽ tôn trọng nó .


2
Xem thêm bài đăng này: social.msdn.microsoft.com/Forums/en-US/netfxtoolsdev/thread/… trong đó tôi phát hiện ra rằng đuôi chậm hơn cuộc gọi thông thường. Eep!
plinth

77

Đây Gửi phản hồi Microsoft Connect nên trả lời câu hỏi của bạn. Nó chứa phản hồi chính thức từ Microsoft, vì vậy tôi khuyên bạn nên làm theo cách đó.

Cám ơn vì sự gợi ý. Chúng tôi đã xem xét việc tạo ra các lệnh gọi đuôi ở một số điểm trong quá trình phát triển trình biên dịch C #. Tuy nhiên, có một số vấn đề tế nhị đã khiến chúng tôi phải tránh điều này cho đến nay: 1) Thực sự có một khoản chi phí không nhỏ khi sử dụng lệnh .tail trong CLR (nó không chỉ là một lệnh nhảy như cuối cùng các lệnh gọi đuôi trở thành trong nhiều môi trường ít nghiêm ngặt hơn như môi trường thời gian chạy ngôn ngữ chức năng nơi các lệnh gọi đuôi được tối ưu hóa nhiều). 2) Có rất ít phương thức C # thực sự hợp pháp khi phát ra các lệnh gọi đuôi (các ngôn ngữ khác khuyến khích các mẫu mã hóa có nhiều đệ quy đuôi hơn, và nhiều người phụ thuộc nhiều vào tối ưu hóa lệnh gọi đuôi thực sự thực hiện việc viết lại toàn cục (chẳng hạn như các phép biến đổi Tiếp tục đi qua) để tăng lượng đệ quy đuôi). 3) Một phần là do 2), các trường hợp tràn ngăn xếp các phương thức C # do đệ quy sâu mà lẽ ra đã thành công là khá hiếm.

Tất cả những gì đã nói, chúng tôi tiếp tục xem xét điều này và chúng tôi có thể trong một bản phát hành trình biên dịch trong tương lai sẽ tìm thấy một số mẫu có ý nghĩa khi phát ra các lệnh .tail.

Nhân tiện, như nó đã được chỉ ra, cần lưu ý rằng đệ quy đuôi được tối ưu hóa trên x64.


3
Bạn cũng có thể thấy điều này hữu ích: weblogs.asp.net/podwysocki/archive/2008/07/07/…
Noldorin 29/01/09

Không có vấn đề, rất vui vì bạn thấy nó hữu ích.
Noldorin

17
Cảm ơn bạn đã trích dẫn nó, vì bây giờ nó là 404!
Roman Starkov

3
Liên kết hiện đã được sửa.
luksan

15

C # không tối ưu hóa cho đệ quy cuộc gọi đuôi vì đó là những gì F # dành cho!

Để biết thêm chi tiết về các điều kiện ngăn trình biên dịch C # thực hiện tối ưu hóa lệnh gọi đuôi, hãy xem bài viết này: Các điều kiện lệnh gọi đuôi JIT CLR .

Khả năng tương tác giữa C # và F #

C # và F # tương tác rất tốt và bởi vì .NET Common Language Runtime (CLR) được thiết kế với khả năng tương tác này, mỗi ngôn ngữ được thiết kế với các tối ưu hóa cụ thể cho mục đích và mục đích của nó. Để có ví dụ cho thấy việc gọi mã F # từ mã C # dễ dàng như thế nào, hãy xem Gọi mã F # từ mã C # ; để biết ví dụ về cách gọi các hàm C # từ mã F #, hãy xem Gọi các hàm C # từ F # .

Để biết khả năng tương tác ủy quyền, hãy xem bài viết này: Khả năng tương tác ủy quyền giữa F #, C # và Visual Basic .

Sự khác biệt lý thuyết và thực tế giữa C # và F #

Đây là một bài viết đề cập đến một số khác biệt và giải thích sự khác biệt về thiết kế của đệ quy lệnh gọi đuôi giữa C # và F #: Tạo mã lệnh gọi đuôi trong C # và F # .

Đây là một bài viết với một số ví dụ trong C #, F # và C ++ \ CLI: Adventures in Tail Recursion in C #, F # và C ++ \ CLI

Sự khác biệt lý thuyết chính là C # được thiết kế với các vòng lặp trong khi F # được thiết kế dựa trên các nguyên tắc của phép tính Lambda. Để có một cuốn sách rất hay về các nguyên tắc của phép tính Lambda, hãy xem cuốn sách miễn phí này: Cấu trúc và Giải thích các Chương trình Máy tính, của Abelson, Sussman và Sussman .

Để có bài viết giới thiệu rất hay về lệnh gọi đuôi trong F #, hãy xem bài viết này: Giới thiệu chi tiết về lệnh gọi đuôi trong F # . Cuối cùng, đây là một bài viết đề cập đến sự khác biệt giữa đệ quy không đuôi và đệ quy gọi đuôi (trong F #): Đệ quy đuôi so với đệ quy không đuôi trong F sharp .


8

Gần đây tôi đã được thông báo rằng trình biên dịch C # cho 64 bit không tối ưu hóa đệ quy đuôi.

C # cũng thực hiện điều này. Lý do tại sao nó không phải lúc nào cũng được áp dụng, là các quy tắc được sử dụng để áp dụng đệ quy đuôi rất nghiêm ngặt.


8
Các x64 jitter thực hiện điều này, nhưng biên dịch C # không
Đánh dấu Sowul

cảm ơn vì thông tin. Đây là màu trắng khác với những gì tôi nghĩ trước đây.
Alexandre Brisebois,

3
Chỉ để làm rõ hai nhận xét này, C # không bao giờ phát ra opcode 'đuôi' CIL và tôi tin rằng điều này vẫn đúng vào năm 2017. Tuy nhiên, đối với tất cả các ngôn ngữ, opcode đó luôn chỉ mang tính tư vấn theo nghĩa là các jitters tương ứng (x86, x64 ) sẽ im lặng bỏ qua nó nếu các điều kiện lặt vặt không được đáp ứng (tốt, không có lỗi ngoại trừ khả năng tràn ngăn xếp ). Điều này giải thích tại sao bạn buộc phải theo sau 'tail' với 'ret' - nó dành cho trường hợp này. Trong khi đó, các jitters cũng có thể tự do áp dụng tối ưu hóa khi không có tiền tố 'tail' trong CIL, một lần nữa được cho là phù hợp và bất kể ngôn ngữ .NET là gì.
Glenn Slayden

3

Bạn có thể sử dụng kỹ thuật trampoline cho các hàm đệ quy đuôi trong C # (hoặc Java). Tuy nhiên, giải pháp tốt hơn (nếu bạn chỉ quan tâm đến việc sử dụng ngăn xếp) là sử dụng phương thức trợ giúp nhỏ này để bọc các phần của cùng một hàm đệ quy và làm cho nó lặp đi lặp lại trong khi vẫn giữ cho hàm có thể đọc được.


Trampolines xâm lấn (chúng là một thay đổi toàn cầu đối với quy ước gọi), chậm hơn ~ 10 lần so với loại bỏ lệnh gọi đuôi thích hợp và chúng làm xáo trộn tất cả thông tin theo dõi ngăn xếp khiến việc gỡ lỗi và mã hồ sơ khó hơn nhiều
JD

1

Như các câu trả lời khác đã đề cập, CLR không hỗ trợ tối ưu hóa cuộc gọi đuôi và có vẻ như nó đã được cải tiến trong lịch sử. Nhưng hỗ trợ nó trong C # có một Proposalvấn đề mở trong kho lưu trữ git cho việc thiết kế ngôn ngữ lập trình C # Hỗ trợ đệ quy đuôi # 2544 .

Bạn có thể tìm thấy một số chi tiết và thông tin hữu ích ở đó. Ví dụ @jaykrell được đề cập

Hãy để tôi đưa ra những gì tôi biết.

Đôi khi gọi trước là một hiệu suất đôi bên cùng có lợi. Nó có thể tiết kiệm CPU. jmp rẻ hơn call / ret Nó có thể tiết kiệm ngăn xếp. Chạm vào ít ngăn xếp hơn sẽ tạo ra vị trí tốt hơn.

Đôi khi tailcall làm giảm hiệu suất, stack win. CLR có một cơ chế phức tạp, trong đó truyền nhiều tham số đến callee hơn là người gọi nhận được. Ý tôi là cụ thể hơn không gian ngăn xếp cho các tham số. Điều này là chậm. Nhưng nó bảo tồn ngăn xếp. Nó sẽ chỉ làm điều này với phần đuôi. tiếp đầu ngữ.

Nếu các tham số của người gọi lớn hơn tham số callee, thì đó thường là một biến đổi win-win khá dễ dàng. Có thể có các yếu tố như tham số-vị trí thay đổi từ được quản lý thành số nguyên / float và tạo các Bản đồ StackMap chính xác, v.v.

Bây giờ, có một góc độ khác, đó là các thuật toán yêu cầu loại bỏ cuộc gọi riêng, nhằm mục đích có thể xử lý dữ liệu lớn tùy ý với ngăn xếp cố định / nhỏ. Đây không phải là về hiệu suất, mà là về khả năng chạy.

Ngoài ra, hãy để tôi đề cập đến (như thông tin bổ sung), Khi chúng tôi đang tạo một lambda đã biên dịch bằng cách sử dụng các lớp biểu thức trong System.Linq.Expressionskhông gian tên, có một đối số có tên là 'tailCall' được giải thích trong nhận xét của nó

Một bool cho biết liệu tối ưu hóa cuộc gọi đuôi có được áp dụng khi biên dịch biểu thức đã tạo hay không.

Tôi chưa thử nó và tôi không chắc nó có thể giúp ích như thế nào liên quan đến câu hỏi của bạn, nhưng Có lẽ ai đó có thể thử nó và có thể hữu ích trong một số trường hợp:


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

var myFunc =  myFuncExpression.Compile();
Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.