JVM이 테일 콜 최적화에 부과하는 제한 사항


36

Clojure는 자체적으로 테일 콜 최적화를 수행하지 않습니다. 테일 재귀 함수가 있고이를 최적화하려면 특수 양식을 사용해야합니다 recur. 마찬가지로 두 개의 상호 재귀 함수가있는 경우을 사용해서 만 최적화 할 수 있습니다 trampoline.

Scala 컴파일러는 재귀 함수에 대해서는 TCO를 수행 할 수 있지만 두 개의 상호 재귀 함수에는 사용할 수 없습니다.

이러한 제한 사항에 대해 읽을 때마다 항상 JVM 모델에 내재 된 일부 제한 사항이 발생했습니다. 나는 컴파일러에 대해 거의 아무것도 모른다. 그러나 이것은 나를 조금 당혹스럽게한다. 에서 예제를 보도록하겠습니다 Programming Scala. 여기 기능

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

로 번역

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

따라서 바이트 코드 수준에서는 goto. 이 경우 실제로는 힘든 일이 컴파일러에 의해 수행됩니다.

컴파일러가 TCO를보다 쉽게 ​​처리 할 수있는 기본 가상 머신의 기능

참고로, 실제 머신이 JVM보다 훨씬 더 똑똑하지는 않을 것입니다. 그럼에도 불구하고 Haskell과 같은 네이티브 코드로 컴파일되는 많은 언어는 꼬리 호출을 최적화하는 데 문제가없는 것으로 보입니다 (Hassell은 때로는 게으름으로 인해 발생할 수 있지만 또 다른 문제입니다).

답변:


25

클로저와 스칼라에 대해서는 잘 몰라요.

먼저 tail-CALL과 tail-RECURSION을 구분해야합니다. 꼬리 재귀는 실제로 루프로 변환하기가 다소 쉽습니다. 테일 콜을 사용하면 일반적으로 불가능한 것이 훨씬 더 어렵습니다. 호출되는 것을 알아야하지만 다형성 및 / 또는 일류 함수를 사용하면 거의 알지 못하므로 컴파일러는 호출을 대체하는 방법을 알 수 없습니다. 런타임에만 대상 코드를 알고 있으며 다른 스택 프레임을 할당하지 않고 이동할 수 있습니다. 예를 들어, 다음 프래그먼트에는 테일 호출이 있으며 올바르게 최적화 된 경우 (TCO 포함) 스택 공간이 필요하지 않지만 JVM 컴파일시 제거 할 수 없습니다.

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

여기에는 약간 비효율적이지만, 수많은 테일 콜이 있고 거의 반환되지 않는 전체 프로그래밍 스타일 (예 : Continuation Passing Style 또는 CPS)이 있습니다. 전체 TCO없이이를 수행하면 스택 공간이 부족하기 전에 작은 코드 만 실행할 수 있습니다.

컴파일러가 TCO를보다 쉽게 ​​처리 할 수있는 기본 가상 머신의 기능

Lua 5.1 VM과 같은 테일 콜 명령어. 귀하의 예는 훨씬 간단하지 않습니다. 광산은 다음과 같이됩니다.

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

참고로, 실제 머신이 JVM보다 훨씬 똑똑하지는 않을 것입니다.

당신 말이 맞아요. 실제로, 그들은 똑똑해서 스택 프레임과 같은 것들에 대해 (많이) 알지 못합니다. 그것이 바로 스택 주소를 재사용하고 리턴 주소를 푸시하지 않고 코드로 점프하는 것과 같은 트릭을 끌어낼 수있는 이유입니다.


내가 참조. 나는 똑똑한 것이 달리 금지되는 최적화를 허용 할 수 있다는 것을 몰랐다 .
Andrea

7
+1, tailcallJVM에 대한 지침은 2007 년 초에 이미 제안되었습니다 : wayback machine을 통한 sun.com의 블로그 . 오라클 인수 이후이 링크는 404입니다. JVM 7 우선 순위 목록으로 만들지 않았다고 생각합니다.
K.Steff

1
tailcall명령 만 꼬리 호출로 꼬리 호출을 표시 할 것입니다. JVM이 실제로 테일 호출을 최적화했는지 여부는 완전히 다른 질문입니다. CLI CIL에는 .tail명령어 접두사가 있지만 Microsoft 64 비트 CLR은 오랫동안 최적화하지 않았습니다. IBM J9 JVM 인 OTOH 테일 호출을 감지하고 최적화하여 특별한 호출없이 테일 호출을 알려줍니다. 꼬리 통화에 주석을 달고 꼬리 통화를 최적화하는 것은 실제로 직교합니다. (꼬리 전화를 정적으로 추론한다는 사실을 제외하고는 결정하지 못할 수도 있습니다. Dunno.)
Jörg W Mittag

@ JörgWMittag JVM이 패턴을 쉽게 감지 할 수 있다는 점이 좋다 call something; oreturn. JVM을 사양 업데이트의 주요 작업은 명시 적으로 꼬리 호출 명령을 소개하지만,하지 않는 것이 의무화 등의 명령이 최적화되어있다. 이러한 명령어는 컴파일러 작성자의 작업을 더 쉽게 만듭니다. JVM 작성자는 인식을 넘어서 엉망이되기 전에 해당 명령어 시퀀스를 인식 할 필요가 없으며 X-> 바이트 코드 컴파일러는 바이트 코드가 유효하지 않거나 실제로 최적화되었지만 절대로 스택 오버플로가 발생하지 않습니다.

@delnan : call something; return;호출되는 것이 스택 추적을 요구하지 않는 경우 시퀀스 는 tail 호출과 동일합니다. 해당 메소드가 가상이거나 가상 메소드를 호출하는 경우 JVM은 스택에 대해 문의 할 수 있는지 여부를 알 수 없습니다.
supercat

12

Clojure 루프로 테일 재귀를 자동으로 최적화 할 수 있습니다. Scala가 증명 한대로 JVM에서이를 수행 할 수 있습니다.

실제로이 작업을 수행하지 않기 위한 디자인 결정 이었습니다 recur.이 기능을 사용하려면 특수 양식 을 명시 적으로 사용해야합니다 . Clojure google 그룹 에서 메일 스레드 Re : 왜 꼬리 호출 최적화가 없는지 확인 하십시오.

현재 JVM에서 수행 할 수없는 유일한 것은 다른 기능 (상호 재귀) 간의 꼬리 호출 최적화입니다. 이것은 구현하기가 특히 복잡하지 않습니다 (Scheme와 같은 다른 언어는 처음 부터이 기능을 가졌습니다) .JVM 사양을 변경해야합니다. 예를 들어, 완전한 함수 호출 스택을 보존하는 규칙을 변경해야합니다.

향후의 JVM 반복에서는이 기능을 사용할 수 있지만 이전 코드에 대한 이전 버전과 호환되는 동작이 유지되도록 옵션으로 제공 될 수 있습니다. 말, 미리보기 기능 자바 9 Geeknizer 목록이에서 :

테일 통화 및 연속 추가 중 ...

물론 향후 로드맵은 항상 변경 될 수 있습니다.

결과적으로, 어쨌든 그렇게 큰 문제는 아닙니다. 2 년이 넘는 Clojure 코딩 과정 에서 TCO가 부족한 상황에 처한 적이 없습니다 . 이에 대한 주요 이유는 다음과 같습니다.

  • 이미 recur있거나 루프가있는 일반적인 경우의 99 %에 대해 빠른 꼬리 재귀를 얻을 수 있습니다 . 상호 꼬리 재귀 사례는 정상적인 코드에서 매우 드 rare니다.
  • 상호 재귀가 필요한 경우에도 재귀 깊이는 TCO없이 스택에서 수행 할 수있을 정도로 얕습니다. TCO는 결국 "최적화"입니다 ....
  • 스택을 사용하지 않는 상호 재귀의 어떤 형태가 필요한 매우 드문 경우에 동일한 목표를 달성 할 수있는 다른 대안이 많이 있습니다 : 게으른 시퀀스, 트램폴린 등

"미래 반복" -Geeknizer의 기능 미리보기 에서 Java 9에 대해 설명합니다. 꼬리 호출 및 연속 추가 - 그게 맞 습니까?
gnat

1
그래-그게 다야. 물론, 미래 로드맵은 항상 변경 될 수 있습니다 ....
mikera

5

참고로, 실제 머신이 JVM보다 훨씬 똑똑하지는 않을 것입니다.

더 똑똑해지는 것이 아니라 다른 것에 관한 것입니다. 최근까지 JVM은 매우 엄격한 메모리 및 호출 모델을 가진 단일 언어 (Java)에 대해 독점적으로 설계 및 최적화되었습니다.

goto포인터 나 포인터 가 없었을뿐만 아니라 '베어 (bare)'함수 (클래스 내에 정의 된 메소드가 아닌 함수)를 호출하는 방법조차 없었습니다.

개념적으로 JVM을 대상으로 할 때 컴파일러 작성자는 "이 개념을 Java 용어로 어떻게 표현할 수 있습니까?"를 물어야합니다. 그리고 분명히 Java로 TCO를 표현할 방법이 없습니다.

Java에는 필요하지 않기 때문에 JVM 장애로 간주되지 않습니다. Java가 이와 같은 기능을 필요로하는 즉시 JVM에 추가됩니다.

최근에야 Java 당국이 JVM을 Java 이외의 언어를위한 플랫폼으로 심각하게 사용하기 시작한 것이기 때문에 Java와 동등한 기능이없는 기능을 이미 일부 지원하고 있습니다. 가장 잘 알려진 것은 동적 타이핑인데, 이미 JVM에는 있지만 Java에는 없습니다.


3

따라서 바이트 코드 수준에서 goto가 필요합니다. 이 경우 실제로는 힘든 일이 컴파일러에 의해 수행됩니다.

메소드 주소가 0으로 시작하는 것을 보셨습니까? 그건 모든 방법의 ofsets은 0으로 시작? JVM은 메소드 외부로 점프하는 것을 허용하지 않습니다.

메소드 외부에 오프셋이있는 브랜치에서 발생하는 일이 java에 의해로드되었는지는 알 수 없습니다. 아마 바이트 바이트 검증기에 의해 잡히거나 예외가 발생할 수 있으며 실제로 메소드 외부로 점프 할 수 있습니다.

물론 문제는 동일한 클래스의 다른 메소드가 어디에 있는지, 다른 클래스의 메소드가 훨씬 적은지를 실제로 보장 할 수 없다는 것입니다. JVM이 메소드를로드 할 위치에 대해 보증하지만 의심 스럽지만 의심의 여지가 없습니다.


좋은 지적. 그러나 자기 호출 기능을 테일 콜 (tail-call)으로 최적화 하려면 동일한 방법 내에서 GOTO 만 있으면 됩니다. 따라서이 제한은 자체 재귀 방법의 TCO를 배제하지 않습니다.
Alex D
당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.