게으름
"컴파일러 최적화"는 아니지만 언어 사양에 의해 보장되는 것이므로 언제든지 발생할 수 있습니다. 기본적으로 이는 결과에 "무언가를"할 때까지 작업이 수행되지 않음을 의미합니다. 게으름을 고의로 끄는 데 여러 가지 중 하나를 수행하지 않는 한.
이것은 분명히 그 자체로 전체 주제이며, 이에 대해 이미 많은 질문과 답변이 있습니다.
제한된 경험으로, 코드를 너무 게 으르거나 너무 엄격 하게 만들면 내가 이야기하려는 다른 것보다 훨씬 큰 성능 페널티 (시간 및 공간)가 있습니다 ...
엄격 성 분석
게으름은 필요하지 않은 한 일을 피하는 것입니다. 컴파일러가 주어진 결과가 "항상 필요하다"고 판단하면 계산을 저장하고 나중에 수행하지 않아도됩니다. 더 효율적이기 때문에 직접 수행합니다. 이것을 소위 "엄격 성 분석"이라고합니다.
분명한 것은 컴파일러가 무언가를 엄격하게 할 수있는시기를 항상 감지 할 수 없다는 것입니다. 때때로 컴파일러에게 작은 힌트를 주어야합니다. (핵심 출력을 넘어가는 것 외에 엄격 성 분석이 생각한 것을 수행했는지 여부를 쉽게 확인할 수있는 방법을 모르겠습니다.)
인라인
함수를 호출하고 컴파일러가 호출중인 함수를 알 수있는 경우 해당 함수를 "인라인"하려고 시도 할 수 있습니다. 즉, 함수 호출을 함수 자체의 복사본으로 대체하려고합니다. 함수 호출의 오버 헤드는 일반적으로 매우 작지만 인라인을 사용하면 그렇지 않은 다른 최적화가 발생할 수 있으므로 인라인이 큰 승리가 될 수 있습니다.
함수는 "충분히 작"거나 인라인을 요구하는 pragma를 추가 한 경우에만 인라인됩니다. 또한 컴파일러가 호출하는 함수를 컴파일러가 알 수있는 경우에만 함수를 인라인 할 수 있습니다. 컴파일러가 알 수없는 두 가지 주요 방법이 있습니다.
호출하는 함수가 다른 곳에서 전달 된 경우. 예를 들어, filter
함수가 컴파일되면 사용자가 제공 한 인수이므로 필터 술어를 인라인 할 수 없습니다.
호출하는 함수가 클래스 메소드 이고 컴파일러가 어떤 유형을 포함하는지 알 수없는 경우 예를 들어, sum
함수가 컴파일 될 때 컴파일러는 +
함수를 인라인 할 수 없습니다 sum
. 각각의 +
함수 가 다른 여러 가지 숫자 유형으로 작동 하기 때문 입니다.
후자의 경우, {-# SPECIALIZE #-}
pragma를 사용하여 특정 유형으로 하드 코딩 된 버전의 함수를 생성 할 수 있습니다 . 예를 들어, 유형 {-# SPECIALIZE sum :: [Int] -> Int #-}
에 맞게 sum
하드 코딩 된 버전을 컴파일하면 이 버전에서 인라인 될 수 있습니다.Int
+
그러나 새로운 특수 sum
함수는 컴파일러가 작업 중임을 알 수있는 경우에만 호출됩니다 Int
. 그렇지 않으면 원래의 다형성 sum
이 호출됩니다. 다시 말하지만 실제 함수 호출 오버 헤드는 상당히 작습니다. 인라인이 도움이 될 수있는 추가적인 최적화입니다.
공통 하위 식 제거
특정 코드 블록이 동일한 값을 두 번 계산하면 컴파일러는이 값을 동일한 계산의 단일 인스턴스로 바꿀 수 있습니다. 예를 들어
(sum xs + 1) / (sum xs + 2)
컴파일러는 이것을 최적화 할 수 있습니다.
let s = sum xs in (s+1)/(s+2)
컴파일러가 항상이 작업을 수행 할 것으로 예상 할 수 있습니다 . 그러나 분명히 어떤 상황에서는 성능이 좋지 않을 수 있지만 GHC가 항상 그렇게 하지는 않습니다 . 솔직히, 나는 이것의 세부 사항을 실제로 이해하지 못합니다. 그러나 결론은이 변환이 중요한 경우 수동으로 수행하는 것이 어렵지 않다는 것입니다. (그리고 중요하지 않다면 왜 걱정하고 있습니까?)
사례 표현
다음을 고려하세요:
foo (0:_ ) = "zero"
foo (1:_ ) = "one"
foo (_:xs) = foo xs
foo ( []) = "end"
처음 세 방정식은 모두 목록이 비어 있지 않은지 여부를 확인합니다 (다른 것들 중에서). 그러나 같은 것을 세 번 확인하는 것은 낭비입니다. 다행스럽게도 컴파일러는이를 여러 개의 중첩 된 케이스 표현식으로 최적화하는 것이 매우 쉽습니다. 이 경우에는
foo xs =
case xs of
y:ys ->
case y of
0 -> "zero"
1 -> "one"
_ -> foo ys
[] -> "end"
이것은 다소 직관적이지 않지만 더 효율적입니다. 컴파일러는이 변환을 쉽게 수행 할 수 있으므로 걱정할 필요가 없습니다. 가능한 가장 직관적 인 방식으로 패턴 일치를 작성하십시오. 컴파일러는 이것을 재정렬하고 재정렬하는 데 매우 능숙하여 가능한 빨리 작성합니다.
퓨전
리스트 처리를위한 표준 Haskell 관용구는 하나의리스트를 가져 와서 새로운리스트를 생성하는 함수들을 서로 연결하는 것입니다. 정식 예는
map g . map f
불행히도 게으름은 불필요한 작업을 건너 뛰는 것을 보장하지만 중간 목록 수액 성능에 대한 모든 할당 및 할당 해제입니다. "Fusion"또는 "Deforestation"은 컴파일러가 이러한 중간 단계를 제거하려고하는 곳입니다.
문제는 이러한 기능의 대부분이 재귀 적이라는 것입니다. 재귀가 없으면 모든 함수를 하나의 큰 코드 블록으로 뭉개고 단순화기를 실행하고 중간 목록없이 실제로 최적의 코드를 생성하는 것이 기본 연습입니다. 그러나 재귀 때문에 작동하지 않습니다.
{-# RULE #-}
pragma를 사용 하여이 중 일부를 수정할 수 있습니다 . 예를 들어
{-# RULES "map/map" forall f g xs. map f (map g xs) = map (f.g) xs #-}
이제 GHC가에 map
적용되는 것을 볼 때마다 map
목록을 한 번에 통과시켜 중간 목록을 제거합니다.
문제는 map
다음에 대해서만 작동합니다 map
. - 다른 많은 가능성이있다 map
다음에 filter
, filter
다음 map
등 오히려 "스트림 융합"소위 그들 각각에 대한 해결책이 발명 한 손으로 코드를보다가. 이것은 더 복잡한 트릭이므로 여기서는 설명하지 않습니다.
그것의 길고 짧은 : 이것은 프로그래머가 작성한 특별한 최적화 트릭입니다 . GHC 자체는 융합에 대해 아무것도 모른다. 모두 목록 라이브러리 및 기타 컨테이너 라이브러리에 있습니다. 따라서 최적화 작업은 컨테이너 라이브러리를 작성하는 방법 (또는보다 현실적으로 사용하려는 라이브러리)에 따라 다릅니다.
예를 들어, Haskell '98 어레이로 작업하는 경우 어떤 종류의 융합도 기대하지 마십시오. 그러나 vector
라이브러리에는 광범위한 융합 기능이 있다는 것을 알고 있습니다. 라이브러리에 관한 모든 것입니다. 컴파일러는 단지 RULES
pragma를 제공합니다 . (어쨌든 매우 강력합니다. 라이브러리 작성자는 클라이언트 코드를 다시 작성하는 데 사용할 수 있습니다!)
메타 :
나는 사람들이 "코드 우선, 프로파일 두 번째, 세 번째 최적화"라는 말에 동의합니다.
또한 사람들에게 "주어진 설계 결정에 얼마의 비용이 드는지에 대한 정신적 모델을 갖는 것이 유용하다"는 의견에 동의합니다.
모든 것의 균형과 그 모든 것 ...