Khi nào thì ghi nhớ tự động trong GHC Haskell?


106

Tôi không thể tìm ra lý do tại sao m1 dường như được ghi nhớ trong khi m2 không có trong như sau:

m1      = ((filter odd [1..]) !!)

m2 n    = ((filter odd [1..]) !! n)

m1 10000000 mất khoảng 1,5 giây trong lần gọi đầu tiên và một phần nhỏ trong số đó trong các lần gọi tiếp theo (có lẽ là nó vào bộ nhớ đệm danh sách), trong khi m2 10000000 luôn mất cùng một khoảng thời gian (xây dựng lại danh sách với mỗi lần gọi). Có ai biết cái gì đang xảy ra không? Có bất kỳ quy tắc chung nào về việc GHC sẽ ghi nhớ một hàm không? Cảm ơn.

Câu trả lời:


112

GHC không ghi nhớ các chức năng.

Tuy nhiên, nó tính toán bất kỳ biểu thức nhất định nào trong mã mỗi lần mà biểu thức lambda xung quanh nó được nhập hoặc nhiều nhất một lần nếu nó ở cấp cao nhất. Việc xác định vị trí của các biểu thức lambda có thể hơi phức tạp khi bạn sử dụng cú pháp đường như trong ví dụ của mình, vì vậy hãy chuyển đổi chúng thành cú pháp gỡ bỏ tương đương:

m1' = (!!) (filter odd [1..])              -- NB: See below!
m2' = \n -> (!!) (filter odd [1..]) n

(Lưu ý: Báo cáo Haskell 98 thực sự mô tả phần toán tử bên trái (a %)tương đương với \b -> (%) a b, nhưng GHC gỡ bỏ nó thành (%) a. Các phần này khác nhau về mặt kỹ thuật vì chúng có thể được phân biệt bởi seq. Tôi nghĩ rằng tôi có thể đã gửi một phiếu GHC Trac về điều này.)

Với điều này, bạn có thể thấy rằng trong m1', biểu thức filter odd [1..]không được chứa trong bất kỳ biểu thức lambda nào, vì vậy nó sẽ chỉ được tính một lần cho mỗi lần chạy chương trình của bạn, trong khi ở trong m2', filter odd [1..]sẽ được tính mỗi khi biểu thức lambda được nhập, tức là trên mỗi cuộc gọi của m2'. Điều đó giải thích sự khác biệt về thời gian mà bạn đang thấy.


Trên thực tế, một số phiên bản của GHC, với các tùy chọn tối ưu hóa nhất định, sẽ chia sẻ nhiều giá trị hơn so với mô tả ở trên. Điều này có thể có vấn đề trong một số tình huống. Ví dụ, hãy xem xét chức năng

f = \x -> let y = [1..30000000] in foldl' (+) 0 (y ++ [x])

GHC có thể nhận thấy điều yđó không phụ thuộc vào xvà viết lại hàm thành

f = let y = [1..30000000] in \x -> foldl' (+) 0 (y ++ [x])

Trong trường hợp này, phiên bản mới kém hiệu quả hơn nhiều vì nó sẽ phải đọc khoảng 1 GB từ bộ nhớ yđược lưu trữ, trong khi phiên bản gốc sẽ chạy trong không gian không đổi và nằm gọn trong bộ nhớ đệm của bộ xử lý. Trên thực tế, theo GHC 6.12.1, hàm fnày nhanh hơn gần gấp đôi khi được biên dịch mà không cần tối ưu hóa so với khi được biên dịch cùng -O2.


1
Chi phí để đánh giá (lọc biểu thức lẻ [1 ..]) dù sao cũng gần bằng 0 - xét cho cùng thì đó là danh sách lười biếng, vì vậy chi phí thực nằm trong ứng dụng (x !! 10000000) khi danh sách thực sự được đánh giá. Bên cạnh đó, cả m1 và m2 dường như chỉ được đánh giá một lần với -O2 và -O1 (trên ghc 6.12.3 của tôi) ít nhất trong thử nghiệm sau: (thử nghiệm = m1 10000000 seqm1 10000000). Có một sự khác biệt mặc dù khi không có cờ tối ưu hóa nào được chỉ định. Và cả hai biến thể của "f" của bạn đều có dung lượng tối đa là 5356 byte bất kể tối ưu hóa, bằng cách này (với tổng phân bổ ít hơn khi -O2 được sử dụng).
Ed'ka 17/10/10

1
@ Ed'ka: Hãy thử chương trình thử nghiệm này, với định nghĩa trên của f: main = interact $ unlines . (show . map f . read) . lines; biên dịch có hoặc không -O2; sau đó echo 1 | ./main. Nếu bạn viết một bài kiểm tra như main = print (f 5), sau đó ycó thể được thu thập rác như nó được sử dụng và không có sự khác biệt giữa hai fs.
Reid Barton

Ờ, map (show . f . read)tất nhiên phải vậy. Và bây giờ tôi đã tải xuống GHC 6.12.3, tôi thấy kết quả tương tự như trong GHC 6.12.1. Và vâng, bạn nói đúng về bản gốc m1m2: các phiên bản GHC thực hiện loại nâng này với tính năng tối ưu hóa được bật sẽ chuyển m2thành m1.
Reid Barton

Vâng, bây giờ tôi thấy sự khác biệt (-O2 chắc chắn là chậm hơn). Cảm ơn bạn vì ví dụ này!
Ed'ka

29

m1 chỉ được tính một lần vì nó là Dạng áp dụng không đổi, trong khi m2 không phải là CAF và do đó được tính cho mỗi lần đánh giá.

Xem wiki GHC về CAF: http://www.haskell.org/haskellwiki/Constant_applicative_form


1
Lời giải thích “m1 chỉ được tính một lần vì nó là Dạng áp dụng không đổi” không có ý nghĩa đối với tôi. Bởi vì có lẽ cả m1 và m2 đều là các biến cấp cao nhất, tôi nghĩ rằng các hàm này chỉ được tính một lần, bất kể chúng có phải là CAF hay không. Sự khác biệt là liệu danh sách chỉ [1 ..]được tính một lần trong quá trình thực thi một chương trình hay nó được tính một lần cho mỗi ứng dụng của hàm, nhưng nó có liên quan đến CAF không?
Tsuyoshi Ito

1
Từ trang được liên kết: "CAF ... có thể được biên dịch thành một phần biểu đồ sẽ được chia sẻ bởi tất cả các mục đích sử dụng hoặc một số mã được chia sẻ sẽ tự ghi đè lên một số biểu đồ trong lần đầu tiên nó được đánh giá". Vì m1là CAF, điều thứ hai áp dụng và filter odd [1..](không chỉ [1..]!) Chỉ được tính một lần. GHC cũng có thể lưu ý rằng m2đề cập đến filter odd [1..]và đặt một liên kết đến cùng một thứ được sử dụng trong đó m1, nhưng đó sẽ là một ý tưởng tồi: nó có thể dẫn đến rò rỉ bộ nhớ lớn trong một số tình huống.
Alexey Romanov,

@Alexey: Cảm ơn bạn đã chỉnh sửa về [1..]filter odd [1..]. Phần còn lại, tôi vẫn chưa thuyết phục. Nếu tôi không nhầm, CAF chỉ có liên quan khi chúng ta muốn tranh luận rằng một trình biên dịch có thể thay thế filter odd [1..]in m2bằng một thunk toàn cục (thậm chí có thể giống như thunk được sử dụng trong m1). Nhưng trong tình huống của người hỏi, trình biên dịch đã không thực hiện “tối ưu hóa” đó, và tôi không thể thấy mức độ liên quan của nó với câu hỏi.
Tsuyoshi Ito

2
Nó có liên quan rằng nó có thể thay thế nó trong m1 , và nó có.
Alexey Romanov

13

Có một sự khác biệt quan trọng giữa hai hình thức: giới hạn đơn thức áp dụng cho m1 nhưng không áp dụng cho m2, vì m2 đã cho các đối số rõ ràng. Vì vậy, loại của m2 là chung nhưng của m1 là cụ thể. Các loại chúng được chỉ định là:

m1 :: Int -> Integer
m2 :: (Integral a) => Int -> a

Hầu hết các trình biên dịch và thông dịch Haskell (tất cả đều là những cấu trúc mà tôi biết) không ghi nhớ các cấu trúc đa hình, vì vậy danh sách nội bộ của m2 được tạo lại mỗi khi nó được gọi, trong đó m1 thì không.


1
Chơi với những thứ này trong GHCi có vẻ như nó cũng phụ thuộc vào phép biến đổi thả nổi (một trong những cách tối ưu hóa của GHC không được sử dụng trong GHCi). Và tất nhiên khi biên dịch các hàm đơn giản này, trình tối ưu hóa vẫn có thể làm cho chúng hoạt động giống hệt nhau (theo một số bài kiểm tra tiêu chí mà tôi đã chạy, với các chức năng trong một mô-đun riêng biệt và được đánh dấu bằng NOINLINE pragmas). Có lẽ đó là bởi vì dù sao thì việc tạo danh sách và lập chỉ mục cũng được kết hợp thành một vòng lặp siêu chặt chẽ.
mokus

1

Tôi không chắc lắm, vì bản thân tôi cũng khá mới với Haskell, nhưng có vẻ như đó là do hàm thứ hai được tham số hóa còn hàm thứ nhất thì không. Bản chất của hàm là vậy, kết quả của nó phụ thuộc vào giá trị đầu vào và trong mô hình chức năng đặc biệt nó chỉ phụ thuộc vào đầu vào. Ngụ ý rõ ràng là một hàm không có tham số luôn trả về cùng một giá trị, bất kể điều gì.

Rõ ràng có một cơ chế tối ưu hóa trong trình biên dịch GHC khai thác thực tế này để tính toán giá trị của một hàm như vậy chỉ một lần cho toàn bộ thời gian chạy chương trình. Nó làm điều đó một cách lười biếng, chắc chắn, nhưng nó vẫn làm. Tôi tự nhận thấy điều đó khi viết hàm sau:

primes = filter isPrime [2..]
    where isPrime n = null [factor | factor <- [2..n-1], factor `divides` n]
        where f `divides` n = (n `mod` f) == 0

Sau đó, để kiểm tra nó, tôi bước vào GHCI và viết: primes !! 1000. Phải mất vài giây, nhưng cuối cùng tôi nhận được câu trả lời: 7927. Sau đó tôi gọi primes !! 1001và nhận được câu trả lời ngay lập tức. Tương tự ngay lập tức tôi nhận được kết quả take 1000 primesvì Haskell phải tính toán toàn bộ danh sách nghìn phần tử để trả về phần tử thứ 100 trước đó.

Vì vậy, nếu bạn có thể viết hàm của mình sao cho không cần tham số, bạn có thể muốn nó. ;)

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.