루프가 재귀보다 빠른 이유는 무엇입니까?


18

실제로 나는 모든 재귀가 루프로 작성 될 수 있고 그 반대의 경우도 마찬가지라는 것을 이해하고 실제 컴퓨터로 측정하면 동일한 문제에 대해 루프가 재귀보다 빠릅니다. 그러나이 차이를 만드는 이론이 있습니까? 아니면 주로 실용적입니까?


9
모양이 제대로 구현되지 않은 언어에서는 재귀보다 모양이 더 빠릅니다. 적절한 Tail Recursion을 사용하는 언어에서 재귀 프로그램은 장면 뒤의 루프로 변환 될 수 있으며,이 경우 동일하므로 차이가 없습니다.
jmite

3
예, 지원하는 언어를 사용하면 성능에 부정적인 영향을 미치지 않고 (꼬리) 재귀를 사용할 수 있습니다.
jmite

1
@jmite, 실제로 루프에 최적화 할 수있는 꼬리 재귀는 생각보다 훨씬 드물며 매우 드 rare니다. 특히 참조 계수 변수와 같은 유형을 관리하는 언어의 경우.
Johan-Monica 복원

1
태그 시간 복잡성을 포함했기 때문에 루프가있는 알고리즘은 재귀가있는 알고리즘과 시간 복잡성이 동일하지만 후자는 알고리즘에 따라 시간이 다소 일정하다는 점을 추가해야한다고 생각합니다. 재귀에 대한 오버 헤드 양.
Lieuwe Vinkhuijzen

2
이봐, 당신은 거의 모든 가능성을 다 써 버린 많은 좋은 답변을 현상금에 추가했기 때문에, 당신이 필요로하거나 더 명확 해져야 할 것 같은 것이 있습니까? 추가 할 것이 많지 않고 일부 답변을 수정하거나 의견을 남길 수 있으므로 일반적인 (개인적이 아닌) 질문입니다.
Evil

답변:


17

루프가 재귀보다 빠르다는 이유는 쉽습니다.
어셈블리에서 루프는 이와 같습니다.

mov loopcounter,i
dowork:/do work
dec loopcounter
jmp_if_not_zero dowork

루프 카운터에 대한 단일 조건부 점프 및 일부 부기.

재귀 (컴파일러가 최적화하지 않거나 최적화 할 수없는 경우)는 다음과 같습니다.

start_subroutine:
pop parameter1
pop parameter2
dowork://dowork
test something
jmp_if_true done
push parameter1
push parameter2
call start_subroutine
done:ret

그것은 훨씬 더 복잡하고 적어도 3 번의 점프를 얻습니다 (한 번의 테스트, 한 번의 호출 및 한 번의 리턴 확인).
또한 재귀에서 매개 변수를 설정하고 가져와야합니다.
모든 매개 변수가 이미 설정되어 있으므로 루프에서이 항목이 필요하지 않습니다.

이론적으로 매개 변수는 재귀와 함께 유지 될 수 있지만 실제로 알고있는 컴파일러는 최적화에 그다지 도움이되지 않습니다.

콜과 jmp
차이점 콜-리턴 페어는 jmp보다 훨씬 비싸지 않습니다. 페어는 2 사이클이 걸리고 jmp는 1이 걸립니다. 거의 눈에 띄지 않습니다.
레지스터 매개 변수를 지원하는 호출 규칙에서 매개 변수에 대한 오버 헤드는 최소이지만 CPU의 버퍼가 오버 플로우되지 않는 한 스택 매개 변수도 저렴 합니다 .
호출 규칙 및 사용중인 매개 변수 처리에 따라 재귀 속도가 느려지는 것은 호출 설정의 오버 헤드입니다.
이것은 구현에 크게 의존합니다.

재귀 처리 불량의 예 예를 들어, 참조 카운트 된 (예를 들어, const가 아닌 유형의 관리되는) 파라미터가 전달되면 레퍼런스 카운트의 잠금 조정을 수행하는 100 사이클을 추가하여 성능과 루프를 완전히 죽입니다.
재귀를 위해 조정 된 언어에서는이 나쁜 동작이 발생하지 않습니다.

CPU 최적화
재귀가 느린 다른 이유는 CPU의 최적화 메커니즘에 대해 작동하기 때문입니다.
행에 너무 많지 않은 경우에만 반환을 정확하게 예측할 수 있습니다. CPU에는 몇 개의 항목이있는 리턴 스택 버퍼가 있습니다. 그 결과가 다 떨어지면 모든 추가 수익이 잘못 예측되어 큰 지연이 발생합니다.
버퍼 리턴 크기를 초과하는 스택 리턴 버퍼 호출 기반 재귀를 사용하는 CPU에서는 피하는 것이 가장 좋습니다.

재귀
를 사용하는 사소한 코드 예제 정보 피보나치 수 생성과 같은 사소한 재귀 예제를 사용하는 경우 재귀에 대해 '알고있는'컴파일러는 소금의 가치가있는 프로그래머와 마찬가지로 루프로 변환하기 때문에 이러한 효과가 발생하지 않습니다. 할 것이다.
호출 스택보다 제대로 최적화되지 않은 환경에서 이러한 간단한 예제를 실행하면 (필요하지 않게) 범위를 벗어나게됩니다.

꼬리 재귀 정보
때때로 컴파일러는 꼬리 재귀를 루프로 변경하여 꼬리 재귀를 최적화합니다. 이와 관련하여 잘 알려진 기록이있는 언어에서만이 동작을 사용하는 것이 가장 좋습니다.
많은 언어가 최종 반환 전에 숨겨진 정리 코드를 삽입하여 꼬리 재귀의 최적화를 방지합니다.

실제 재귀와 의사 재귀의 혼동
프로그래밍 환경에서 재귀 소스 코드를 루프로 바꾸면 실행되는 실제 재귀가 아닐 수 있습니다.
실제 재귀에는 브레드 크럼이 저장되어야하므로 재귀 루틴은 종료 후 단계를 추적 할 수 있습니다.
루프를 사용하는 것보다 재귀를 느리게 만드는 것이이 트레일의 처리입니다. 이 효과는 위에서 설명한 현재 CPU 구현에 의해 확대됩니다.

프로그래밍 환경의 영향
언어가 재귀 최적화를 위해 조정 된 경우 반드시 모든 기회에 재귀를 사용하십시오. 대부분의 경우 언어는 재귀를 일종의 루프로 바꿉니다.
그것이 불가능한 경우에도 프로그래머는 압박을 받게 될 것입니다. 프로그래밍 언어가 재귀에 맞게 조정되지 않은 경우 도메인이 재귀에 적합하지 않으면 피해야합니다.
불행히도 많은 언어는 재귀를 잘 처리하지 못합니다.

재귀 오용 재귀
를 사용하여 피보나치 수열을 계산할 필요는 없습니다. 실제로 병리학적인 예입니다.
재귀는 명시 적으로 지원하는 언어 또는 트리에 저장된 데이터 처리와 같이 재귀가 빛나는 도메인에서 가장 잘 사용됩니다.

모든 재귀를 루프로 작성할 수 있음을 이해합니다.

네, 말보다 카트를 기꺼이 싣고 싶다면
모든 재귀 인스턴스를 루프로 작성할 수 있으며, 이러한 인스턴스 중 일부는 스토리지와 같은 명시 적 스택을 사용해야합니다.
재귀 코드를 루프로 변환하기 위해 자체 스택을 롤링 해야하는 경우 일반 재귀를 사용할 수도 있습니다.
물론 트리 구조에서 열거자를 사용하는 것과 같은 특별한 요구가 있고 적절한 언어 지원이 없습니다.


16

이 다른 답변은 다소 오도됩니다. 본인은 이러한 불일치를 설명 할 수있는 구현 세부 사항을 진술하지만 동의합니다. jmite가 올바르게 제안한 것처럼, 함수 호출 / 재귀의 깨진 구현을 향한 구현 지향적 입니다. 많은 언어가 재귀를 통해 루프를 구현하므로 해당 언어에서 루프가 더 빠르지는 않습니다. 이론적으로 재귀는 루핑보다 효율적이지 않습니다 (둘 다 적용 가능한 경우). 가이 스틸 ( Ge Steele)의 1977 년 논문 에서 "비용이 많이 드는 절차 호출"신화 또는 절차 구현이 유해한 것으로 간주되거나 Lambda : Ultimate GOTO에 대한 요약을 인용하겠습니다 .

Folklore는 GOTO 문이 "저렴한"반면 프로 시저 호출은 "고가"라고 말합니다. 이 신화는 주로 제대로 구현되지 않은 언어 구현의 결과입니다. 이 신화의 역사적 성장이 고려됩니다. 이 신화에 대한 이론적 아이디어와 기존의 구현에 대해 논의합니다. 프로 시저 호출의 무제한 사용은 큰 문체 자유를 허용합니다. 특히, 플로우 차트는 추가 변수를 도입하지 않고 "구조화 된"프로그램으로 작성할 수 있습니다. GOTO 문과 프로 시저 호출의 어려움은 추상적 인 프로그래밍 개념과 구체적인 언어 구성 사이의 충돌로 특징 지워집니다.

은 "추상적 인 프로그래밍 개념과 구체적인 언어 구조 사이의 충돌은"대부분의 이론적 모델은, 예를 들어, 형식화되지 않은 사실에서 볼 수있는 람다 계산법은 , 스택이 없습니다 . 물론, 위의 논문이 설명하고 Haskell과 같은 재귀 이외의 반복 메커니즘이없는 언어에서도이 충돌이 필요하지 않습니다.

fixfix f x = f (fix f) x(λ엑스.미디엄)미디엄[/엑스][/엑스]엑스미디엄

이제 예를 들어 보겠습니다. 정의 fact

fact = fix (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) 1

다음 fact 3은 압축을 g위해 fix (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)),에 대한 동의어로 사용 하는 평가입니다 fact = g 1. 이것은 내 주장에 영향을 미치지 않습니다.

fact 3 
~> g 1 3
~> fix (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) 1 3 
~> (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) g 1 3
~> (λa.λn.if n == 0 then a else g (a*n) (n-1)) 1 3
~> (λn.if n == 0 then 1 else g (1*n) (n-1)) 3
~> if 3 == 0 then 1 else g (1*3) (3-1)
~> g (1*3) (3-1)
~> g 3 2
~> fix (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) 3 2
~> (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) g 3 2
~> (λa.λn.if n == 0 then a else g (a*n) (n-1)) 3 2
~> (λn.if n == 0 then 3 else g (3*n) (n-1)) 2
~> if 2 == 0 then 3 else g (3*2) (2-1)
~> g (3*2) (2-1)
~> g 6 1
~> fix (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) 6 1
~> (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) g 6 1
~> (λa.λn.if n == 0 then a else g (a*n) (n-1)) 6 1
~> (λn.if n == 0 then 6 else g (6*n) (n-1)) 1
~> if 1 == 0 then 6 else g (6*1) (1-1)
~> g (6*1) (1-1)
~> g 6 0
~> fix (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) 6 0
~> (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) g 6 0
~> (λa.λn.if n == 0 then a else g (a*n) (n-1)) 6 0
~> (λn.if n == 0 then 6 else g (6*n) (n-1)) 0
~> if 0 == 0 then 6 else g (6*0) (0-1)
~> 6

성장이 없으며 각 반복에 동일한 양의 공간이 필요하다는 세부 정보를 보지 않고도 모양에서 볼 수 있습니다. (기술적으로, 수치 결과는 피할 수없고 while루프에 대해서도 마찬가지입니다 .) 나는 무한히 성장하는 "스택"을 여기서 지적하지 않습니다.

람다 미적분학의 전형적인 의미론은 이미 "꼬리 호출 최적화"라는 이름을 가진 것을 이미 수행하는 것 같습니다. 물론 여기서는 "최적화"가 일어나지 않습니다. 여기서는 "통상"호출과 달리 "꼬리"호출에 대한 특별한 규칙이 없습니다. 이러한 이유로 함수 호출 시맨틱의 많은 추상 특성화에서 꼬리 호출 "최적화"를 수행 할 수있는 것은 없기 때문에 꼬리 호출 "최적화"가 수행하는 작업에 대한 "추상적 인"특성을 부여하기가 어렵습니다!

fact많은 언어에서 "스택 오버플로" 의 유사한 정의는 함수 호출 시맨틱을 올바르게 구현하지 못하는 언어에서 실패했습니다. (일부 언어에는 변명의 여지가 있습니다.) 상황은 링크 된 목록으로 배열을 구현 한 언어 구현과 유사합니다. 그런 다음 "배열"로 인덱싱하면 배열 기대 값을 충족하지 않는 O (n) 연산이됩니다. 링크 된 목록 대신 실제 배열을 사용하는 언어를 별도로 구현하면 "배열 액세스 최적화"를 구현했다고 말하지 않고 배열의 깨진 구현을 수정했다고 말할 수 있습니다.

따라서 Veedrac의 답변에 응답하십시오. 스택은 재귀에 "기본적" 이 아닙니다 . 평가 과정에서 "스택 유사"동작이 발생하는 경우, 이는 보조 데이터 구조가없는 루프가 처음에는 적용되지 않는 경우에만 발생할 수 있습니다! 다시 말하면, 정확히 동일한 성능 특성을 가진 재귀로 루프를 구현할 수 있습니다. 실제로 Scheme과 SML은 모두 반복 구조를 포함하지만 둘 다 재귀 측면에서 구조를 정의합니다 (적어도 Scheme에서는 do종종 재귀 호출로 확장되는 매크로로 구현 됩니다). 마찬가지로 Johan의 답변에 대해서는 아무것도 대답하지 않습니다. 컴파일러는 재귀에 대해 설명 된 어셈블리 Johan을 내 보내야합니다. 과연,루프를 사용하든 재귀를 사용하든 정확히 동일한 어셈블리입니다. 컴파일러가 Johan이 설명하는 것과 같은 어셈블리를 방출 해야하는 유일한 시간은 어쨌든 루프로 표현할 수없는 무언가를 할 때입니다. 스틸의 논문에서 설명하고 하스켈, 계획 및 SML 같은 언어의 실제 연습에 의해 입증, 그들이 할 수있는, 꼬리 호출이 "최적화"할 수있다 "대단히 드문"아니다 항상"최적화"하십시오. 특정 재귀 사용이 일정한 공간에서 실행 될지 여부는 작성 방법에 따라 다르지만, 가능한 한 적용해야 할 제한 사항은 문제를 루프 모양에 맞추기 위해 필요한 제한 사항입니다. (실제로 덜 엄격합니다. 인코딩 상태 머신과 같이 보조 변수를 필요로하는 루프와 달리 테일 호출을 통해보다 깨끗하고 효율적으로 처리되는 문제가 있습니다.) 재귀에 더 많은 작업을 수행 해야하는 유일한 시간 은 어쨌든 코드가 루프가 아닌 경우.

내 생각에 Johan은 꼬리 호출 "최적화"를 수행 할시기에 대한 임의의 제한이있는 C 컴파일러를 언급하고있다. Johan은 아마도 "관리되는 유형의 언어"에 대해 이야기 할 때 C ++ 및 Rust와 같은 언어를 언급하고있을 것입니다. C ++ 의 RAII 관용구는 Rust에도 있으며 꼬리 호출이 아닌 표면 호출처럼 보이게합니다 ( "소멸자"는 여전히 호출해야하기 때문에). 꼬리 재귀를 허용하는 약간 다른 의미론을 선택하기 위해 다른 구문을 사용하라는 제안이있었습니다 (즉, 소멸자 호출 하기 전에최종 테일 호출 및 "파괴 된"객체에 대한 액세스를 명백히 금지합니다). 가비지 수집에는 이러한 문제가 없으며 Haskell, SML 및 Scheme은 모두 가비지 수집 언어입니다. 매우 다른 맥락에서 스몰 토크와 같은 일부 언어는 "스택"을 일류 개체로 노출합니다. "스택"은 더 이상 구현 세부 사항이 아니지만 의미가 다른 별도의 호출 유형을 배제하지는 않습니다. (자바는 보안의 일부 측면을 처리하는 방식으로 인해 불가능하다고 말하지만 실제로는 거짓 입니다.)

실제로, 함수 호출의 깨진 구현의 보급은 세 가지 주요 요소에서 비롯됩니다. 첫째, 많은 언어가 구현 언어 (보통 C)에서 깨진 구현을 상속합니다. 둘째, 결정 론적 자원 관리는 훌륭하고 문제를 더욱 복잡하게 만들지 만 소수의 언어 만이이를 제공합니다. 셋째, 내 경험상 대부분의 사람들이 관심을 갖는 이유는 디버깅 목적으로 오류가 발생할 때 스택 추적을 원하기 때문입니다. 두 번째 이유는 이론적으로 동기를 부여 할 수있는 이유입니다.


논리적으로이 방법을 사용해야하는지 여부가 아니라 (기본적으로 두 프로그램이 동일하기 때문에 그렇지 않은 경우) 주장이 사실 인 가장 기본적인 이유를 언급하기 위해 "기본"을 사용했습니다. 그러나 나는 당신의 의견에 전체적으로 동의하지 않습니다. 람다 미적분을 사용한다고해서 스택이 모호하게 제거되지는 않습니다.
Veedrac

귀하의 주장 "요한이 설명하는 것과 같이 어셈블리가 어쨌든 루프를 통해 표현할 수없는 무언가를 수행 할 때 컴파일러가 (어쩌면) 어셈블리를 방출해야하는 유일한 시간은" 또한 매우 이상합니다. 컴파일러는 (일반적으로) 동일한 출력을 생성하는 모든 코드를 생성 할 수 있으므로 주석은 기본적으로 팽팽한 설명입니다. 그러나 실제로 컴파일러는 다른 동등한 프로그램에 대해 다른 코드를 생성하므로 그 이유에 대한 질문이있었습니다.
Veedrac

영형(1)

유추 할 수없는 문자열을 루프에 추가하는 데 2 ​​차적인 시간이 걸리는 이유에 대한 비유를하자면, "필요할 필요는 없다"고 전적으로 합리적이지만 구현이 중단되었다고 주장하는 것은 바람직하지 않습니다.
Veedrac

매우 흥미로운 답변입니다. 비록 그것은 소리처럼 들리지만 :-). 새로운 것을 배웠기 때문에 공감했습니다.
Johan-Monica 복원

2

근본적으로 차이점은 재귀에는 원하지 않는 보조 데이터 구조 인 스택이 포함되지만 루프는 자동으로 그렇지 않다는 것입니다. 드문 경우지만 실제로는 스택이 실제로 필요하지 않다고 추론 할 수있는 일반적인 컴파일러입니다.

할당 된 스택에서 수동으로 작동하는 루프 (예 : 힙 메모리에 대한 포인터를 통해)를 비교하면 일반적으로 하드웨어 스택을 사용하는 것보다 빠르거나 느리지 않습니다.

당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.