Haskell có tối ưu hóa đệ quy đuôi không?


89

Tôi đã phát hiện ra lệnh "time" trong unix ngày hôm nay và nghĩ rằng tôi sẽ sử dụng nó để kiểm tra sự khác biệt trong thời gian chạy giữa các hàm đệ quy đuôi và đệ quy bình thường trong Haskell.

Tôi đã viết các chức năng sau:

--tail recursive
fac :: (Integral a) => a -> a
fac x = fac' x 1 where
    fac' 1 y = y
    fac' x y = fac' (x-1) (x*y) 

--normal recursive
facSlow :: (Integral a) => a -> a
facSlow 1 = 1
facSlow x = x * facSlow (x-1)

Đây là những điều hợp lệ cần lưu ý rằng chúng chỉ được sử dụng cho dự án này, vì vậy tôi không bận tâm đến việc kiểm tra số 0 hay số âm.

Tuy nhiên, khi viết một phương thức chính cho mỗi phương thức, biên dịch chúng và chạy chúng bằng lệnh "time", cả hai đều có thời gian chạy tương tự với hàm đệ quy bình thường loại bỏ hàm đệ quy đuôi. Điều này trái ngược với những gì tôi đã nghe liên quan đến tối ưu hóa đệ quy đuôi trong ngọng. Lý do cho điều này là gì?


8
Tôi tin rằng TCO là một sự tối ưu hóa để tiết kiệm một số ngăn xếp cuộc gọi, nó không có nghĩa là bạn sẽ tiết kiệm một chút thời gian CPU. Sửa cho tôi nếu sai.
Jerome

3
Chưa thử nghiệm nó với lisp, nhưng hướng dẫn tôi đọc ngụ ý rằng việc thiết lập ngăn xếp tự phát sinh nhiều chi phí xử lý hơn, trong khi giải pháp đệ quy đuôi được biên dịch thành lặp đi lặp lại không tiêu tốn bất kỳ năng lượng (thời gian) nào để làm điều này và do đó hiệu quả hơn.
haskell rascal 24/10/12

1
@Jerome tốt nó phụ thuộc vào rất nhiều thứ, nhưng thường Caches cũng đi vào chơi, vì vậy TCO thường sẽ sản xuất một chương trình nhanh hơn cũng ..
Kristopher Micinski

Lý do cho điều này là gì? Nói một cách ngắn gọn: sự lười biếng.
Dan Burton

Điều thú vị faclà cách ghc tính toán của bạn ít nhiều product [n,n-1..1]bằng cách sử dụng một chức năng phụ trợ prod, nhưng tất nhiên product [1..n]sẽ đơn giản hơn. Tôi chỉ có thể giả định rằng họ đã không làm cho nó nghiêm ngặt trong lập luận thứ hai của nó với lý do rằng đây là loại điều mà ghc rất tự tin rằng nó có thể biên dịch xuống một bộ tích lũy đơn giản.
AndrewC

Câu trả lời:


168

Haskell sử dụng tính năng đánh giá lười biếng để triển khai đệ quy, vì vậy coi bất kỳ thứ gì như một lời hứa cung cấp một giá trị khi cần thiết (điều này được gọi là thunk). Thunks chỉ được giảm khi cần thiết để tiếp tục, không nhiều hơn. Điều này giống với cách bạn đơn giản hóa một biểu thức về mặt toán học, vì vậy sẽ rất hữu ích khi nghĩ về nó theo cách đó. Thực tế là thứ tự đánh giá không được chỉ định bởi mã của bạn cho phép trình biên dịch thực hiện nhiều tối ưu hóa thậm chí còn thông minh hơn là chỉ loại bỏ cuộc gọi đuôi mà bạn thường làm. Biên dịch với -O2nếu bạn muốn tối ưu hóa!

Hãy xem cách chúng tôi đánh giá facSlow 5như một nghiên cứu điển hình:

facSlow 5
5 * facSlow 4            -- Note that the `5-1` only got evaluated to 4
5 * (4 * facSlow 3)       -- because it has to be checked against 1 to see
5 * (4 * (3 * facSlow 2))  -- which definition of `facSlow` to apply.
5 * (4 * (3 * (2 * facSlow 1)))
5 * (4 * (3 * (2 * 1)))
5 * (4 * (3 * 2))
5 * (4 * 6)
5 * 24
120

Vì vậy, đúng như bạn lo lắng, chúng tôi đã tích hợp các con số trước khi bất kỳ phép tính nào xảy ra, nhưng không giống như bạn lo lắng, không có chồng facSlowlệnh gọi hàm nào đang chờ kết thúc - mỗi lần giảm được áp dụng và biến mất, để lại một khung ngăn xếp trong đó thức (đó là bởi vì (*)nó nghiêm ngặt và do đó kích hoạt đánh giá của đối số thứ hai của nó).

Các hàm đệ quy của Haskell không được đánh giá theo cách rất đệ quy! Ngăn xếp cuộc gọi duy nhất quanh quẩn là chính các phép nhân. Nếu (*)được xem như một phương thức khởi tạo dữ liệu nghiêm ngặt, thì đây được gọi là đệ quy được bảo vệ (mặc dù nó thường được gọi như vậy với các hàm tạo dữ liệu không hạn chế, trong đó những gì còn lại sau nó là các hàm tạo dữ liệu - khi bị buộc bởi truy cập thêm).

Bây giờ chúng ta hãy xem xét đệ quy đuôi fac 5:

fac 5
fac' 5 1
fac' 4 {5*1}       -- Note that the `5-1` only got evaluated to 4
fac' 3 {4*{5*1}}    -- because it has to be checked against 1 to see
fac' 2 {3*{4*{5*1}}} -- which definition of `fac'` to apply.
fac' 1 {2*{3*{4*{5*1}}}}
{2*{3*{4*{5*1}}}}        -- the thunk "{...}" 
(2*{3*{4*{5*1}}})        -- is retraced 
(2*(3*{4*{5*1}}))        -- to create
(2*(3*(4*{5*1})))        -- the computation
(2*(3*(4*(5*1))))        -- on the stack
(2*(3*(4*5)))
(2*(3*20))
(2*60)
120

Vì vậy, bạn có thể thấy cách đệ quy đuôi tự nó đã không tiết kiệm cho bạn bất kỳ thời gian hoặc không gian nào. Nó không chỉ thực hiện nhiều bước hơn về tổng thể facSlow 5, nó còn xây dựng một thunk lồng nhau (được hiển thị ở đây là {...}) - cần thêm một không gian cho nó - mô tả việc tính toán trong tương lai, các phép nhân lồng nhau sẽ được thực hiện.

Cú đánh này sau đó được làm sáng tỏ bằng cách di chuyển xuống dưới cùng, tạo lại tính toán trên ngăn xếp. Ở đây cũng có một nguy cơ gây ra tràn ngăn xếp với các tính toán rất dài, cho cả hai phiên bản.

Nếu chúng ta muốn tối ưu hóa thủ công điều này, tất cả những gì chúng ta cần làm là làm cho nó nghiêm ngặt. Bạn có thể sử dụng toán tử ứng dụng nghiêm ngặt $!để xác định

facSlim :: (Integral a) => a -> a
facSlim x = facS' x 1 where
    facS' 1 y = y
    facS' x y = facS' (x-1) $! (x*y) 

Điều này buộc facS'phải nghiêm ngặt trong lập luận thứ hai của nó. (Nó đã nghiêm ngặt trong lập luận đầu tiên của nó vì điều đó phải được đánh giá để quyết định facS'áp dụng định nghĩa nào.)

Đôi khi sự nghiêm khắc có thể giúp ích rất nhiều, đôi khi đó lại là một sai lầm lớn vì lười biếng sẽ hiệu quả hơn. Đây là một ý kiến ​​hay:

facSlim 5
facS' 5 1
facS' 4 5 
facS' 3 20
facS' 2 60
facS' 1 120
120

Tôi nghĩ đó là những gì bạn muốn đạt được.

Tóm lược

  • Nếu bạn muốn tối ưu hóa mã của mình, bước một là biên dịch với -O2
  • Đệ quy đuôi chỉ tốt khi không có sự tích tụ, và việc thêm thắt chặt chẽ thường giúp ngăn chặn nó, nếu và khi thích hợp. Điều này xảy ra khi bạn đang xây dựng một kết quả cần thiết sau này cùng một lúc.
  • Đôi khi đệ quy đuôi là một kế hoạch tồi và đệ quy có bảo vệ là phù hợp hơn, tức là khi kết quả bạn đang xây dựng sẽ cần từng chút một, theo từng phần. Hãy xem câu hỏi này về foldrfoldlví dụ, và kiểm tra chúng với nhau.

Hãy thử hai điều sau:

length $ foldl1 (++) $ replicate 1000 
    "The size of intermediate expressions is more important than tail recursion."
length $ foldr1 (++) $ replicate 1000 
    "The number of reductions performed is more important than tail recursion!!!"

foldl1là đệ quy đuôi, trong khi foldr1thực hiện đệ quy được bảo vệ để mục đầu tiên được trình bày ngay lập tức để xử lý / truy cập tiếp theo. (Dấu ngoặc đơn đầu tiên "ngoặc" sang bên trái ngay lập tức, (...((s+s)+s)+...)+sbuộc danh sách đầu vào của nó đầy đủ đến cuối và xây dựng một loạt các phép tính trong tương lai sớm hơn nhiều so với kết quả đầy đủ của nó; dấu ngoặc đơn thứ hai dần dần sang phải s+(s+(...+(s+s)...)), tiêu tốn đầu vào liệt kê từng chút một, vì vậy toàn bộ điều có thể hoạt động trong không gian không đổi, với sự tối ưu hóa).

Bạn có thể cần điều chỉnh số lượng số không tùy thuộc vào phần cứng bạn đang sử dụng.


1
@WillNess Thật xuất sắc, cảm ơn. không cần rút lại. Tôi nghĩ rằng đó là một câu trả lời tốt hơn cho hậu thế bây giờ.
AndrewC

4
Điều này thật tuyệt, nhưng tôi có thể đề nghị gật đầu với phân tích mức độ nghiêm ngặt không? Tôi nghĩ rằng điều đó gần như chắc chắn sẽ thực hiện công việc cho giai thừa đệ quy đuôi trong bất kỳ phiên bản hợp lý nào gần đây của GHC.
dfeuer

16

Cần lưu ý rằng fachàm không phải là một ứng cử viên tốt cho đệ quy được bảo vệ. Đệ quy đuôi là cách để đi đến đây. Do sự lười biếng, bạn không nhận được tác dụng của TCO trong fac'chức năng của mình vì các đối số tích lũy tiếp tục tạo ra các phần thu lớn, khi được đánh giá sẽ yêu cầu một ngăn xếp lớn. Để ngăn chặn điều này và có được hiệu quả mong muốn của TCO, bạn cần phải làm cho các đối số tích lũy này chặt chẽ.

{-# LANGUAGE BangPatterns #-}

fac :: (Integral a) => a -> a
fac x = fac' x 1 where
  fac' 1  y = y
  fac' x !y = fac' (x-1) (x*y)

Nếu bạn biên dịch bằng -O2(hoặc chỉ -O) GHC có thể sẽ tự thực hiện việc này trong giai đoạn phân tích mức độ nghiêm ngặt .


4
Tôi nghĩ nó rõ ràng $!hơn với với BangPatterns, nhưng đây là một câu trả lời tốt. Đặc biệt là việc đề cập đến phân tích tính chặt chẽ.
singpolyma

7

Bạn nên xem bài viết wiki về đệ quy đuôi trong Haskell . Đặc biệt, vì đánh giá biểu thức, loại đệ quy bạn muốn là đệ quy được bảo vệ . Nếu bạn tìm hiểu chi tiết về những gì đang diễn ra (trong máy trừu tượng cho Haskell), bạn sẽ nhận được điều tương tự như với đệ quy đuôi trong các ngôn ngữ nghiêm ngặt. Cùng với đó, bạn có một cú pháp thống nhất cho các hàm lười biếng (đệ quy đuôi sẽ buộc bạn phải đánh giá nghiêm ngặt, trong khi đệ quy được bảo vệ hoạt động tự nhiên hơn).

(Và khi học Haskell, phần còn lại của các trang wiki đó cũng tuyệt vời!)


0

Nếu tôi nhớ lại chính xác, GHC sẽ tự động tối ưu hóa các hàm đệ quy thuần túy thành các hàm được tối ưu hóa đệ quy đuôi.

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.