JVM áp dụng những hạn chế nào đối với tối ưu hóa cuộc gọi đuôi


36

Clojure không tự thực hiện tối ưu hóa cuộc gọi đuôi: khi bạn có chức năng đệ quy đuôi và bạn muốn tối ưu hóa nó, bạn phải sử dụng biểu mẫu đặc biệt recur. Tương tự, nếu bạn có hai hàm đệ quy lẫn nhau, bạn chỉ có thể tối ưu hóa chúng bằng cách sử dụng trampoline.

Trình biên dịch Scala có thể thực hiện TCO cho một hàm đệ quy, nhưng không phải cho hai hàm đệ quy lẫn nhau.

Bất cứ khi nào tôi đọc về những hạn chế này, chúng luôn được gán cho một số giới hạn nội tại đối với mô hình JVM. Tôi không biết gì nhiều về trình biên dịch, nhưng điều này đánh đố tôi một chút. Hãy để tôi lấy ví dụ từ Programming Scala. Đây là chức năng

def approximate(guess: Double): Double =
  if (isGoodEnough(guess)) guess
  else approximate(improve(guess))

được dịch sang

0: aload_0
1: astore_3
2: aload_0
3: dload_1
4: invokevirtual #24; //Method isGoodEnough:(D)Z
7: ifeq
10: dload_1
11: dreturn
12: aload_0
13: dload_1
14: invokevirtual #27; //Method improve:(D)D
17: dstore_1
18: goto 2

Vì vậy, ở cấp độ mã byte, người ta chỉ cần goto. Trong trường hợp này, trên thực tế, công việc khó khăn được thực hiện bởi trình biên dịch.

Cơ sở nào của máy ảo cơ bản sẽ cho phép trình biên dịch xử lý TCO dễ dàng hơn?

Là một lưu ý phụ, tôi không mong đợi các máy thực tế sẽ thông minh hơn JVM. Tuy nhiên, nhiều ngôn ngữ biên dịch thành mã gốc, chẳng hạn như Haskell, dường như không có vấn đề gì với việc tối ưu hóa các cuộc gọi đuôi (tốt, đôi khi Haskell có thể có do sự lười biếng, nhưng đó là một vấn đề khác).

Câu trả lời:


25

Bây giờ, tôi không biết nhiều về Clojure và một chút về Scala, nhưng tôi sẽ thử.

Trước hết, chúng ta cần phân biệt giữa đuôi-GỌI và đuôi-RECURSION. Đệ quy đuôi thực sự khá dễ dàng để chuyển thành một vòng lặp. Với các cuộc gọi đuôi, khó hơn nhiều trong trường hợp chung. Bạn cần biết những gì đang được gọi, nhưng với chức năng đa hình và / hoặc hạng nhất, bạn hiếm khi biết điều đó, vì vậy trình biên dịch không thể biết cách thay thế cuộc gọi. Chỉ trong thời gian chạy, bạn mới biết mã đích và có thể nhảy tới đó mà không cần phân bổ khung stack khác. Chẳng hạn, đoạn sau có lệnh gọi đuôi và không cần bất kỳ không gian ngăn xếp nào khi được tối ưu hóa đúng cách (bao gồm cả TCO), nhưng nó không thể bị loại bỏ khi biên dịch cho JVM:

function forward(obj: Callable<int, int>, arg: int) =
    let arg1 <- arg + 1 in obj.call(arg1)

Mặc dù ở đây chỉ là một cách không hiệu quả, có toàn bộ các kiểu lập trình (chẳng hạn như Kiểu liên tục hoặc CPS) có hàng tấn các cuộc gọi đuôi và hiếm khi quay trở lại. Làm điều đó mà không có TCO đầy đủ có nghĩa là bạn chỉ có thể chạy các bit mã nhỏ trước khi hết dung lượng ngăn xếp.

Cơ sở nào của máy ảo cơ bản sẽ cho phép trình biên dịch xử lý TCO dễ dàng hơn?

Một lệnh gọi đuôi, chẳng hạn như trong Lua 5.1 VM. Ví dụ của bạn không đơn giản hơn nhiều. Của tôi trở thành một cái gì đó như thế này:

push arg
push 1
add
load obj
tailcall Callable.call
// implicit return; stack frame was recycled

Là một sidenote, tôi sẽ không mong đợi các máy thực tế sẽ thông minh hơn JVM.

Bạn nói đúng, họ không. Trên thực tế, chúng kém thông minh hơn và do đó thậm chí không biết (nhiều) về những thứ như khung stack. Đó chính xác là lý do tại sao người ta có thể kéo các thủ thuật như sử dụng lại không gian ngăn xếp và chuyển sang mã mà không cần đẩy địa chỉ trả về.


Tôi hiểu rồi. Tôi đã không nhận ra rằng việc kém thông minh hơn có thể cho phép tối ưu hóa sẽ bị cấm.
Andrea

7
+1, tailcallhướng dẫn cho JVM đã được đề xuất từ ​​đầu năm 2007: Blog trên sun.com thông qua máy quay ngược . Sau khi tiếp quản Oracle, liên kết này 404. Tôi đoán nó đã không được đưa vào danh sách ưu tiên của JVM 7.
K.Steff

1
Một tailcalllệnh sẽ chỉ đánh dấu một cuộc gọi đuôi là một cuộc gọi đuôi. Liệu JVM sau đó có thực sự được tối ưu hóa cho cuộc gọi đuôi hay không là một câu hỏi hoàn toàn khác. CLI CIL có .tailtiền tố hướng dẫn, nhưng CLR 64 bit của Microsoft trong một thời gian dài đã không tối ưu hóa nó. OTOH, IBM J9 JVM không phát hiện các cuộc gọi đuôi và tối ưu hóa chúng, mà không cần một hướng dẫn đặc biệt để cho nó biết cuộc gọi nào là cuộc gọi đuôi. Chú thích các cuộc gọi đuôi và tối ưu hóa các cuộc gọi đuôi là thực sự trực giao. (Ngoài thực tế là tĩnh suy luận mà gọi là một cuộc gọi đuôi có thể hoặc không thể được undecidable Dunno..)
Jörg W Mittag

@ JörgWMittag Bạn thực hiện một điểm tốt, một JVM có thể dễ dàng phát hiện mẫu call something; oreturn. Công việc chính của bản cập nhật đặc tả JVM sẽ không phải là giới thiệu một lệnh gọi đuôi rõ ràng mà là bắt buộc rằng một lệnh đó được tối ưu hóa. Một lệnh như vậy chỉ làm cho công việc của người viết trình biên dịch trở nên dễ dàng hơn: Tác giả JVM không phải đảm bảo nhận ra chuỗi lệnh đó trước khi nó bị sai lệch ngoài sự công nhận và trình biên dịch mã X-> có thể yên tâm rằng mã byte của họ không hợp lệ hoặc thực sự tối ưu hóa, không bao giờ chính xác-nhưng-chồng-tràn.

@delnan: Chuỗi call something; return;sẽ chỉ tương đương với một cuộc gọi đuôi nếu thứ được gọi không bao giờ yêu cầu theo dõi ngăn xếp; nếu phương thức trong câu hỏi là ảo hoặc gọi một phương thức ảo, JVM sẽ không có cách nào để biết liệu nó có thể hỏi về ngăn xếp hay không.
supercat

12

Clojure có thể thực hiện tự động tối ưu hóa đệ quy đuôi thành các vòng: chắc chắn có thể thực hiện điều này trên JVM như Scala chứng minh.

Đó thực sự là một quyết định thiết kế không làm điều này - bạn phải sử dụng rõ ràng recurhình thức đặc biệt nếu bạn muốn tính năng này. Xem chủ đề thư Re: Tại sao không tối ưu hóa cuộc gọi đuôi trên nhóm google Clojure.

Trên JVM hiện tại, điều duy nhất không thể thực hiện là tối ưu hóa cuộc gọi đuôi giữa các chức năng khác nhau (đệ quy lẫn nhau). Điều này không đặc biệt phức tạp để thực hiện (các ngôn ngữ khác như Scheme đã có tính năng này ngay từ đầu) nhưng nó sẽ yêu cầu thay đổi đối với thông số JVM. Ví dụ: bạn phải thay đổi các quy tắc về bảo toàn ngăn xếp cuộc gọi hàm hoàn chỉnh.

Một lần lặp lại trong tương lai của JVM có khả năng có được khả năng này, mặc dù có thể là một tùy chọn để hành vi tương thích ngược với mã cũ được duy trì. Nói, Tính năng xem trước tại Geeknizer liệt kê điều này cho Java 9:

Thêm các cuộc gọi đuôi và tiếp tục ...

Tất nhiên, lộ trình trong tương lai luôn có thể thay đổi.

Hóa ra, dù sao nó cũng không phải là vấn đề lớn. Trong hơn 2 năm mã hóa Clojure, tôi chưa bao giờ gặp phải tình huống thiếu TCO. Những lý do chính cho điều này là:

  • Bạn đã có thể nhận được đệ quy đuôi nhanh cho 99% các trường hợp phổ biến có recurhoặc một vòng lặp. Trường hợp đệ quy đuôi lẫn nhau là khá hiếm trong mã thông thường
  • Ngay cả khi bạn cần đệ quy lẫn nhau, thường thì độ sâu đệ quy đủ nông để bạn có thể thực hiện nó trên ngăn xếp mà không cần TCO. TCO chỉ là một "tối ưu hóa" sau tất cả ....
  • Trong những trường hợp rất hiếm khi bạn cần một số hình thức đệ quy lẫn nhau không tiêu thụ, có rất nhiều lựa chọn khác có thể đạt được cùng một mục tiêu: trình tự lười biếng, trampolines, v.v.

"Lặp lại trong tương lai" - Tính năng Xem trước tại Geeknizer nói cho Java 9: Thêm các cuộc gọi đuôi và tiếp tục - có phải vậy không?
gnat

1
Đúng - đó là nó. Tất nhiên, các lộ trình trong tương lai luôn có thể thay đổi ....
mikera

5

Là một sidenote, tôi sẽ không mong đợi các máy thực tế sẽ thông minh hơn JVM.

Đó không phải là thông minh hơn, mà là về sự khác biệt. Cho đến gần đây, JVM được thiết kế và tối ưu hóa dành riêng cho một ngôn ngữ (rõ ràng là Java), có các mô hình gọi và bộ nhớ rất nghiêm ngặt.

Không chỉ không có bất kỳ gotohoặc con trỏ, thậm chí còn không có cách nào để gọi hàm 'trần' (một phương thức không phải là một phương thức được định nghĩa trong một lớp).

Về mặt khái niệm, khi nhắm mục tiêu JVM, một người viết trình biên dịch phải hỏi "làm thế nào tôi có thể diễn đạt khái niệm này bằng thuật ngữ Java?". Và rõ ràng, không có cách nào để diễn đạt TCO bằng Java.

Lưu ý rằng những điều này không được coi là thất bại của JVM, vì chúng không cần thiết cho Java. Ngay khi Java cần một số tính năng như thế này, nó đã được thêm vào JVM.

Chỉ gần đây, các nhà chức trách Java mới bắt đầu coi JVM là nền tảng cho các ngôn ngữ không phải Java, vì vậy nó đã có được một số hỗ trợ cho các tính năng không có Java tương đương. Được biết đến nhiều nhất là kiểu gõ động, đã có trong JVM nhưng không có trong Java.


3

Vì vậy, ở cấp độ mã byte, người ta chỉ cần goto. Trong trường hợp này, trên thực tế, công việc khó khăn được thực hiện bởi trình biên dịch.

Bạn có nhận thấy rằng địa chỉ phương thức bắt đầu bằng 0 không? Đó là tất cả các phương pháp của bắt đầu bằng 0? JVM không cho phép một người nhảy ra ngoài một phương thức.

Tôi không biết điều gì sẽ xảy ra với một nhánh có bù ngoài phương thức được tải bởi java - có thể nó sẽ bị trình xác minh bytecode bắt, có thể nó sẽ tạo ra một ngoại lệ và có thể nó sẽ thực sự nhảy ra ngoài phương thức.

Tất nhiên, vấn đề là bạn không thể thực sự đảm bảo các phương thức khác của cùng một lớp sẽ ít hơn các phương thức khác của các lớp khác. Tôi nghi ngờ JVM đưa ra bất kỳ đảm bảo nào về nơi nó sẽ tải các phương thức, mặc dù tôi rất vui khi được sửa chữa.


Điểm tốt. Nhưng để gọi đuôi tối ưu hóa chức năng tự đệ quy, tất cả những gì bạn cần là một GOTO trong cùng một phương thức . Vì vậy, giới hạn này không loại trừ TCO của các phương pháp tự đệ quy.
Alex D
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.