Khi tìm phần tử cuối cùng nhưng thứ hai của danh sách, tại sao lại sử dụng `last` nhanh nhất trong số này?


10

Có 3 hàm được đưa ra dưới đây để tìm phần tử cuối cùng nhưng thứ hai trong danh sách. Người sử dụng last . initdường như nhanh hơn nhiều so với phần còn lại. Tôi dường như không thể hiểu tại sao.

Để thử nghiệm, tôi đã sử dụng một danh sách đầu vào là [1..100000000](100 triệu). Cái cuối cùng chạy gần như ngay lập tức trong khi những cái khác mất vài giây.

-- 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đã được tối ưu hóa để tránh "giải nén" danh sách nhiều lần.
Willem Van Onsem

1
@WillemVanOnsem nhưng tại sao myButLastchậm hơn nhiều?. Có vẻ như không giải nén bất kỳ danh sách nào, mà chỉ duyệt qua nó như initchức năng ...
lsmor

1
@Ismor: nó là [x, y]viết tắt của từ này (x:(y:[])), vì vậy nó giải nén các khuyết điểm bên ngoài, một khuyết điểm thứ hai và kiểm tra xem đuôi của giây conscó phải không []. Hơn nữa, mệnh đề thứ hai sẽ giải nén lại danh sách (x:xs). Vâng, giải nén là hiệu quả hợp lý, nhưng tất nhiên nếu nó xảy ra rất thường xuyên, điều đó sẽ làm chậm quá trình.
Willem Van Onsem

1
Tìm kiếm trong hackage.haskell.org/package/base-4.12.0.0/docs/src/ , sự tối ưu hóa dường như initkhông liên tục kiểm tra xem đối số của nó là danh sách đơn hay danh sách trống. Khi đệ quy bắt đầu, nó chỉ giả định rằng phần tử đầu tiên sẽ được xử lý theo kết quả của lệnh gọi đệ quy.
chepner

2
@WillemVanOnsem Tôi nghĩ việc giải nén có lẽ không phải là vấn đề ở đây: GHC thực hiện chuyên môn hóa mô hình cuộc gọi sẽ cung cấp cho bạn phiên bản tối ưu hóa myButLasttự động. Tôi nghĩ rằng nó có nhiều khả năng hợp nhất danh sách đó là đổ lỗi cho việc tăng tốc.
oonomk

Câu trả lời:


9

Khi nghiên cứu tốc độ và tối ưu hóa, rất dễ dàng để có được kết quả cực kỳ sai . Cụ thể, bạn thực sự không thể nói rằng một biến thể nhanh hơn biến thể khác mà không đề cập đến phiên bản trình biên dịch và chế độ tối ưu hóa của thiết lập điểm chuẩn của bạn. Ngay cả khi đó, các bộ xử lý hiện đại tinh vi đến mức có tính năng dự đoán nhánh dựa trên mạng thần kinh, chưa kể tất cả các loại bộ nhớ cache, do đó, ngay cả khi thiết lập cẩn thận, kết quả đo điểm chuẩn sẽ bị mờ.

Điều đó đang được nói ...

Điểm chuẩn là bạn của chúng tôi.

criterionlà một gói cung cấp các công cụ đo điểm chuẩn tiên tiến. Tôi nhanh chóng phác thảo một điểm chuẩn như thế này:

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 ]

Như bạn thấy, tôi đã thêm biến thể khớp rõ ràng vào hai yếu tố cùng một lúc, nhưng nếu không thì nó là cùng một mã nguyên văn. Tôi cũng chạy các điểm chuẩn theo chiều ngược lại, để nhận thức được sự sai lệch do bộ nhớ đệm. Vì vậy, chúng ta hãy chạy và xem!

% 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)

Có vẻ như phiên bản "chậm" của chúng tôi không hề chậm chút nào! Và sự phức tạp của khớp mẫu không thêm gì cả. (Tăng tốc một chút chúng ta thấy giữa hai lần chạy liên tiếp của match2tôi quy cho các hiệu ứng của bộ nhớ đệm.)

Có một cách để có được dữ liệu "khoa học" hơn : chúng ta có thể -ddump-simplvà xem cách trình biên dịch nhìn thấy mã của chúng ta.

Kiểm tra các cấu trúc trung gian là bạn của chúng tôi.

"Lõi" là ngôn ngữ nội bộ của GHC. Mỗi tệp nguồn Haskell được đơn giản hóa thành Core trước khi được chuyển thành biểu đồ chức năng cuối cùng để hệ thống thời gian chạy thực thi. Nếu chúng ta nhìn vào giai đoạn trung gian này, nó sẽ cho chúng ta biết điều đó myButLastbutLast2tương đương. Nó thực sự tìm kiếm, vì, ở giai đoạn đổi tên, tất cả các định danh đẹp của chúng tôi được thu thập ngẫu nhiên.

% 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)

Có vẻ như A1A4là giống nhau nhất. Kiểm tra kỹ lưỡng sẽ cho thấy rằng thực sự các cấu trúc mã trong A1A4giống hệt nhau. Điều đó A2A3giống nhau cũng hợp lý vì cả hai đều được định nghĩa là một thành phần của hai chức năng.

Nếu bạn định kiểm tra coređầu ra rộng rãi, thì cũng nên cung cấp các cờ như -dsuppress-module-prefixes-dsuppress-uniques. Họ làm cho nó dễ đọc hơn nhiều.

Một danh sách ngắn của kẻ thù của chúng tôi, quá.

Vì vậy, những gì có thể đi sai với điểm chuẩn và tối ưu hóa?

  • ghci, được thiết kế để chơi tương tác và lặp lại nhanh, biên dịch nguồn Haskell thành một hương vị nhất định của mã byte, thay vì thực thi cuối cùng, và tránh tối ưu hóa đắt tiền để ủng hộ tải lại nhanh hơn.
  • Profiling có vẻ như là một công cụ tốt để xem xét hiệu suất của các bit và phần riêng lẻ của một chương trình phức tạp, nhưng nó có thể phá hỏng tối ưu hóa trình biên dịch rất tệ, kết quả sẽ là các đơn đặt hàng lớn.
    • Bảo vệ của bạn là cấu hình từng bit mã nhỏ như một tệp thực thi riêng biệt, với trình chạy chuẩn của chính nó.
  • Thu gom rác được điều chỉnh. Chỉ hôm nay một tính năng chính mới đã được phát hành. Sự chậm trễ cho việc thu gom rác sẽ ảnh hưởng đến hiệu suất theo những cách không dễ dự đoán.
  • Như tôi đã đề cập, các phiên bản trình biên dịch khác nhau sẽ xây dựng mã khác nhau với hiệu suất khác nhau, vì vậy bạn phải biết phiên bản nào mà người dùng mã của bạn sẽ sử dụng để xây dựng mã đó và điểm chuẩn với điều đó, trước khi bạn thực hiện bất kỳ lời hứa nào.

Điều này có thể trông buồn. Nhưng nó thực sự không phải là điều mà một lập trình viên Haskell quan tâm, hầu hết thời gian. Câu chuyện có thật: Tôi có một người bạn vừa mới bắt đầu học Haskell. Họ đã viết một chương trình tích hợp số, và nó rất chậm. Vì vậy, chúng tôi ngồi xuống với nhau và đã viết một categorial mô tả của thuật toán, với sơ đồ và các công cụ. Khi họ viết lại mã để phù hợp với mô tả trừu tượng, nó trở nên kỳ diệu, giống như, cheetah nhanh và mỏng trên bộ nhớ. Chúng tôi tính toán π trong thời gian không. Đạo đức của câu chuyện? Cấu trúc trừu tượng hoàn hảo, và mã của bạn sẽ tự tối ưu hóa.


Rất nhiều thông tin, và cũng có một chút áp đảo cho tôi trong giai đoạn này. Trong trường hợp này, tất cả "điểm chuẩn" tôi đã làm là chạy tất cả các chức năng cho danh sách 100 triệu mục và lưu ý rằng cái này mất nhiều thời gian hơn cái kia. Điểm chuẩn với tiêu chí có vẻ khá hữu ích. Ngoài ra, ghcidường như cho kết quả khác nhau (về tốc độ) so với làm exe trước, như bạn đã nói.
bão125
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.