목록에서 마지막이지만 두 번째 요소를 찾을 때 왜 '마지막'을 사용 하는가?


10

리스트에서 마지막이지만 두 번째 요소를 찾는 3 가지 함수가 아래에 있습니다. 사용하는 last . init것이 나머지 것보다 훨씬 빠릅니다. 이유를 알 수없는 것 같습니다.

테스트를 위해 입력 목록 [1..100000000](1 억)을 사용했습니다. 마지막 것은 거의 즉시 실행되는 반면 다른 것은 몇 초가 걸립니다.

-- slow
myButLast :: [a] -> a
myButLast [x, y] = x
myButLast (x : xs) = myButLast xs
myButLast _ = error "List too short"

-- decent
myButLast' :: [a] -> a
myButLast' = (!! 1) . reverse

-- fast
myButLast'' :: [a] -> a
myButLast'' = last . init

5
init목록을 여러 번 "포장 풀기"하지 않도록 최적화되었습니다.
Willem Van Onsem

1
@WillemVanOnsem 그러나 왜 myButLast훨씬 느려 집니까 ?. 그것은 어떤리스트도 init
풀지

1
@Ismor : 그것은 것은 [x, y]위한 짧은 (x:(y:[]))제의 꼬리 경우는 외부 양론, 제 양론 및 검사를 언팩 있도록 cons이다 []. 또한 두 번째 절은에서 목록을 다시 압축 해제합니다 (x:xs). 포장 풀기는 상당히 효율적이지만 물론 자주 발생하는 경우 프로세스 속도가 느려집니다.
Willem Van Onsem

1
hackage.haskell.org/package/base-4.12.0.0/docs/src/… 에서 보면 init인수가 싱글 톤 목록인지 빈 목록인지 반복적으로 확인하지 않는 것이 최적화 된 것 같습니다 . 재귀가 시작되면 재귀 호출의 결과에 첫 번째 요소가 고정된다고 가정합니다.
chepner

2
@WillemVanOnsem 나는 포장 풀기가 아마 여기서 문제가되지 않을 것이라고 생각합니다 : GHC는 호출 패턴 전문화를 통해 myButLast자동으로 최적화 된 버전을 제공해야합니다 . 나는 속도 향상을 비난하는 목록 융합 가능성이 더 높다고 생각합니다.
oisdk

답변:


9

속도와 최적화를 연구 할 때 매우 잘못된 결과얻는 것은 매우 쉽습니다 . 특히, 컴파일러 버전과 벤치마킹 설정의 최적화 모드를 언급하지 않고 한 변형이 다른 변형보다 빠르다고 말할 수는 없습니다. 그럼에도 불구하고 최신 프로세서는 신경망 기반 분기 예측 기능을 갖추고있어 모든 종류의 캐시를 언급 할 수 없을 정도로 정교하므로 신중하게 설정하더라도 벤치마킹 결과가 흐려질 수 있습니다.

그 말은 ...

벤치마킹은 우리의 친구입니다.

criterion고급 벤치마킹 도구를 제공하는 패키지입니다. 나는 다음과 같이 신속하게 벤치 마크를 작성했습니다.

module Main where

import Criterion
import Criterion.Main

-- slow
myButLast :: [a] -> a
myButLast [x, y] = x
myButLast (x : xs) = myButLast xs
myButLast _ = error "List too short"

-- decent
myButLast' :: [a] -> a
myButLast' = (!! 1) . reverse

-- fast
myButLast'' :: [a] -> a
myButLast'' = last . init

butLast2 :: [a] -> a
butLast2 (x :     _ : [ ] ) = x
butLast2 (_ : xs@(_ : _ ) ) = butLast2 xs
butLast2 _ = error "List too short"

setupEnv = do
  let xs = [1 .. 10^7] :: [Int]
  return xs

benches xs =
  [ bench "slow?"   $ nf myButLast   xs
  , bench "decent?" $ nf myButLast'  xs
  , bench "fast?"   $ nf myButLast'' xs
  , bench "match2"  $ nf butLast2    xs
  ]

main = defaultMain
    [ env setupEnv $ \ xs -> bgroup "main" $ let bs = benches xs in bs ++ reverse bs ]

보시다시피, 한 번에 두 요소에서 명시 적으로 일치하는 변형을 추가했지만 그렇지 않으면 동일한 코드입니다. 또한 캐싱으로 인한 바이어스를 인식하기 위해 벤치 마크를 반대로 실행합니다. 그래서 우리가 달리고 보자!

% ghc --version
The Glorious Glasgow Haskell Compilation System, version 8.6.5


% ghc -O2 -package criterion A.hs && ./A
benchmarking main/slow?
time                 54.83 ms   (54.75 ms .. 54.90 ms)
                     1.000 R²   (1.000 R² .. 1.000 R²)
mean                 54.86 ms   (54.82 ms .. 54.93 ms)
std dev              94.77 μs   (54.95 μs .. 146.6 μs)

benchmarking main/decent?
time                 794.3 ms   (32.56 ms .. 1.293 s)
                     0.907 R²   (0.689 R² .. 1.000 R²)
mean                 617.2 ms   (422.7 ms .. 744.8 ms)
std dev              201.3 ms   (105.5 ms .. 283.3 ms)
variance introduced by outliers: 73% (severely inflated)

benchmarking main/fast?
time                 84.60 ms   (84.37 ms .. 84.95 ms)
                     1.000 R²   (1.000 R² .. 1.000 R²)
mean                 84.46 ms   (84.25 ms .. 84.77 ms)
std dev              435.1 μs   (239.0 μs .. 681.4 μs)

benchmarking main/match2
time                 54.87 ms   (54.81 ms .. 54.95 ms)
                     1.000 R²   (1.000 R² .. 1.000 R²)
mean                 54.85 ms   (54.81 ms .. 54.92 ms)
std dev              104.9 μs   (57.03 μs .. 178.7 μs)

benchmarking main/match2
time                 50.60 ms   (47.17 ms .. 53.01 ms)
                     0.993 R²   (0.981 R² .. 0.999 R²)
mean                 60.74 ms   (56.57 ms .. 67.03 ms)
std dev              9.362 ms   (6.074 ms .. 10.95 ms)
variance introduced by outliers: 56% (severely inflated)

benchmarking main/fast?
time                 69.38 ms   (56.64 ms .. 78.73 ms)
                     0.948 R²   (0.835 R² .. 0.994 R²)
mean                 108.2 ms   (92.40 ms .. 129.5 ms)
std dev              30.75 ms   (19.08 ms .. 37.64 ms)
variance introduced by outliers: 76% (severely inflated)

benchmarking main/decent?
time                 770.8 ms   (345.9 ms .. 1.004 s)
                     0.967 R²   (0.894 R² .. 1.000 R²)
mean                 593.4 ms   (422.8 ms .. 691.4 ms)
std dev              167.0 ms   (50.32 ms .. 226.1 ms)
variance introduced by outliers: 72% (severely inflated)

benchmarking main/slow?
time                 54.87 ms   (54.77 ms .. 55.00 ms)
                     1.000 R²   (1.000 R² .. 1.000 R²)
mean                 54.95 ms   (54.88 ms .. 55.10 ms)
std dev              185.3 μs   (54.54 μs .. 251.8 μs)

우리처럼 보인다 "느린" 버전은 전혀하지 느리다! 그리고 패턴 매칭의 복잡성은 아무것도 추가하지 않습니다. ( match2캐싱의 영향으로 인해 두 번의 연속 실행 사이에 약간의 속도가 나타납니다 .)

보다 "과학적인" 데이터 를 얻는 -ddump-simpl방법이 있습니다. 컴파일러가 코드를 보는 방식을 살펴볼 수 있습니다 .

중간 구조물의 검사는 우리의 친구입니다.

"핵심" 은 GHC의 내부 언어입니다. 모든 하스켈 소스 파일은 런타임 시스템이 실행하기위한 최종 기능 그래프로 변환되기 전에 코어로 단순화됩니다. 우리는이 중간 단계에서 보면, 그 우리를 말할 것 myButLastbutLast2동일합니다. 이름 바꾸기 단계에서 모든 멋진 식별자가 무작위로 엉망이되기 때문에 살펴볼 필요가 있습니다.

% for i in `seq 1 4`; do echo; cat A$i.hs; ghc -O2 -ddump-simpl A$i.hs > A$i.simpl; done

module A1 where

-- slow
myButLast :: [a] -> a
myButLast [x, y] = x
myButLast (x : xs) = myButLast xs
myButLast _ = error "List too short"

module A2 where

-- decent
myButLast' :: [a] -> a
myButLast' = (!! 1) . reverse

module A3 where

-- fast
myButLast'' :: [a] -> a
myButLast'' = last . init

module A4 where

butLast2 :: [a] -> a
butLast2 (x :     _ : [ ] ) = x
butLast2 (_ : xs@(_ : _ ) ) = butLast2 xs
butLast2 _ = error "List too short"

% ./EditDistance.hs *.simpl
(("A1.simpl","A2.simpl"),3866)
(("A1.simpl","A3.simpl"),3794)
(("A2.simpl","A3.simpl"),663)
(("A1.simpl","A4.simpl"),607)
(("A2.simpl","A4.simpl"),4188)
(("A3.simpl","A4.simpl"),4113)

것으로 보인다 A1A4가장 유사하다. 철저한 검사를 통해 실제로 코드 구조 가 동일 A1하고 A4동일하다는 것을 알 수 있습니다. 둘 다 두 함수의 구성으로 정의되기 때문에 그 A2A3비슷합니다.

core출력을 광범위하게 살펴 보려면 and와 같은 플래그제공 하는 것이 좋습니다 . 읽기가 훨씬 쉽습니다.-dsuppress-module-prefixes-dsuppress-uniques

적들의 짧은 목록도 있습니다.

그렇다면 벤치마킹 및 최적화에 어떤 문제가있을 수 있습니까?

  • ghci대화 형 재생 및 빠른 반복을 위해 설계된 Haskell 소스를 최종 실행 파일이 아닌 특정 바이트 코드로 컴파일하고 빠른 재로드를 위해 비싼 최적화를 피합니다.
  • 프로파일 링은 개별 비트 및 복잡한 프로그램의 성능을 조사하는 데 유용한 도구처럼 보이지만 컴파일러 최적화를 심하게 손상시킬 수 있습니다.
    • 귀하의 안전 장치는 자체 벤치 마크 러너와 함께 모든 작은 코드를 별도의 실행 파일로 프로파일 링하는 것입니다.
  • 가비지 콜렉션을 조정할 수 있습니다. 바로 오늘 새로운 주요 기능이 릴리스되었습니다. 가비지 수집 지연은 예측하기 쉽지 않은 방식으로 성능에 영향을줍니다.
  • 앞에서 언급했듯이 다른 컴파일러 버전은 다른 성능으로 다른 코드 를 작성하므로 약속을하기 전에 코드 사용자 가 코드를 작성하고 벤치마킹 할 때 사용할 버전을 알아야 합니다.

슬퍼 보일 수 있습니다. 그러나 대부분 Haskell 프로그래머와 관련이있는 것은 아닙니다. 실제 이야기 : 최근에 Haskell을 배우기 시작한 친구가 있습니다. 그들은 수치 통합을위한 프로그램을 작성했으며 거북이 느 렸습니다. 그래서 우리는 함께 앉아서 다이어그램과 내용으로 알고리즘에 대한 범주 설명을 썼습니다 . 그들이 추상적 인 설명과 일치하도록 코드를 다시 작성할 때, 그것은 마술처럼 치타처럼 빠르며 기억력이 떨어졌습니다. 우리는 즉시 π를 계산했습니다. 이야기의 교훈? 완벽한 추상 구조이며 코드가 자동으로 최적화됩니다.


이 단계에서 매우 유익하고 약간 압도적입니다. 이 경우 내가 한 모든 "벤치마킹"은 1 억 개의 항목 목록에 대한 모든 기능을 실행했으며 하나가 다른 것보다 오래 걸린다는 것을 알았습니다. 기준이있는 벤치 마크는 다소 유용합니다. 또한, ghci당신이 말한 것처럼 exe를 먼저 만드는 것과 비교하여 다른 속도 (속도로)를 나타내는 것처럼 보입니다.
storm125
당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.