Thuộc tính nào của khuyết điểm cho phép loại bỏ các khuyết điểm modulo đệ quy đuôi?


14

Tôi quen thuộc với ý tưởng loại bỏ đệ quy đuôi cơ bản , trong đó các hàm trả về kết quả trực tiếp của một cuộc gọi đến chính chúng có thể được viết lại dưới dạng các vòng lặp.

foo(...):
    # ...
    return foo(...)

Tôi cũng hiểu rằng, như một trường hợp đặc biệt, hàm vẫn có thể được viết lại nếu cuộc gọi đệ quy được gói trong một cuộc gọi đến cons.

foo(...):
    # ...
    return (..., foo(...))

Tài sản nào conscho phép điều này? Những chức năng nào khác ngoài việc conscó thể gói một cuộc gọi đuôi đệ quy mà không phá hủy khả năng của chúng ta để viết lại nó lặp đi lặp lại?

GCC (chứ không phải Clang) có thể tối ưu hóa ví dụ này về " phép nhân modulo đệ quy đuôi " , nhưng không rõ cơ chế nào cho phép nó khám phá ra điều này hoặc cách nó thực hiện các phép biến đổi của nó.

pow(x, n):
    if n == 0: return 1
    else if n == 1: return x
    else: return x * pow(x, n-1)

1
Trong liên kết trình thám hiểm trình biên dịch Godbolt của bạn, hàm của bạn có if(n==0) return 0;(không trả về 1 như trong câu hỏi của bạn). x^0 = 1Vì vậy, đó là một lỗi. Tuy nhiên, điều đó không quan trọng đối với phần còn lại của câu hỏi; asm lặp đi lặp lại kiểm tra trường hợp đặc biệt đó trước tiên. Nhưng kỳ lạ thay, việc thực hiện lặp đi lặp lại giới thiệu nhiều bội số 1 * xkhông có trong nguồn, ngay cả khi chúng tôi tạo một floatphiên bản. gcc.godbolt.org/z/eqwine (và gcc chỉ thành công với -ffast-math.)
Peter Cordes

@PeterCordes Bắt tốt. Các return 0đã được sửa. Phép nhân với 1 là thú vị. Tôi không biết phải làm gì với nó.
Tối đa

Tôi nghĩ đó là tác dụng phụ của cách GCC biến đổi khi biến nó thành một vòng lặp. Rõ ràng gcc có một số tối ưu hóa bị bỏ lỡ ở đây, ví dụ như thiếu nó floatmà không có -ffast-math, mặc dù nó có cùng giá trị được nhân lên mỗi lần. (Ngoại trừ 1.0f` có thể là điểm gắn bó?)
Peter Cordes

Câu trả lời:


12

Mặc dù GCC có thể sử dụng các quy tắc đặc biệt, bạn có thể rút ra chúng theo cách sau. Tôi sẽ sử dụng powđể minh họa vì bạn foorất mơ hồ được xác định. Ngoài ra, footốt nhất có thể được hiểu là một ví dụ của tối ưu hóa cuộc gọi cuối liên quan đến các biến gán đơn như ngôn ngữ mà Oz có và như được thảo luận trong các khái niệm, Kỹ thuật và Mô hình lập trình máy tính . Lợi ích của việc sử dụng các biến gán đơn là nó cho phép duy trì trong mô hình lập trình khai báo. Về cơ bản, bạn có thể có mỗi trường của footrả về cấu trúc được biểu diễn bằng các biến gán đơn sau đó được chuyển đến foodưới dạng đối số bổ sung. foosau đó trở thành một đệ quy đuôivoidHàm trả về. Không có sự thông minh đặc biệt là cần thiết cho việc này.

Trở lại pow, đầu tiên, chuyển đổi thành phong cách tiếp tục đi qua . powtrở thành:

pow(x, n):
    return pow2(x, n, x => x)

pow2(x, n, k):
    if n == 0: return k(1)
    else if n == 1: return k(x)
    else: return pow2(x, n-1, y => k(x*y))

Tất cả các cuộc gọi là cuộc gọi đuôi bây giờ. Tuy nhiên, ngăn xếp điều khiển đã được di chuyển vào các môi trường bị bắt trong các bao đóng đại diện cho các phần tiếp theo.

Tiếp theo, khử chức năng tiếp tục. Vì chỉ có một cuộc gọi đệ quy, nên cấu trúc dữ liệu kết quả đại diện cho các phần tiếp theo bị hủy là một danh sách. Chúng tôi nhận được:

pow(x, n):
    return pow2(x, n, Nil)

pow2(x, n, k):
    if n == 0: return applyPow(k, 1)
    else if n == 1: return applyPow(k, x)
    else: return pow2(x, n-1, Cons(x, k))

applyPow(k, acc):
    match k with:
        case Nil: return acc
        case Cons(x, k):
            return applyPow(k, x*acc)

Những gì applyPow(k, acc)không có là một danh sách, tức là monoid miễn phí, thích k=Cons(x, Cons(x, Cons(x, Nil)))và làm cho nó vào x*(x*(x*acc)). Nhưng vì *là liên kết và thường tạo thành một đơn vị với đơn vị 1, chúng ta có thể liên kết lại điều này thành ((x*x)*x)*acc, và để đơn giản, giải 1quyết bắt đầu, sản xuất (((1*x)*x)*x)*acc. Điều quan trọng là chúng ta thực sự có thể tính toán một phần kết quả ngay cả trước khi chúng ta có acc. Điều đó có nghĩa là thay vì chuyển qua knhư một danh sách mà về cơ bản là một "cú pháp" chưa hoàn chỉnh mà chúng ta sẽ "diễn giải" ở cuối, chúng ta có thể "diễn giải" nó khi chúng ta đi. Kết quả cuối cùng là chúng ta có thể thay thế Nilbằng đơn vị của monoid, 1trong trường hợp này và Consvới hoạt động của monoid *, và bây giờ kđại diện cho "sản phẩm đang chạy".applyPow(k, acc)sau đó trở thành k*accthứ mà chúng ta có thể nội tuyến trở lại pow2và đơn giản hóa việc sản xuất:

pow(x, n):
    return pow2(x, n, 1)

pow2(x, n, k):
    if n == 0: return k
    else if n == 1: return k*x
    else: return pow2(x, n-1, k*x)

Một phiên bản theo phong cách đệ quy đuôi, tích lũy qua bản gốc pow.

Tất nhiên, tôi không nói GCC thực hiện tất cả lý do này vào thời gian biên dịch. Tôi không biết GCC sử dụng logic gì. Quan điểm của tôi chỉ đơn giản là đã thực hiện lý do này một lần, việc nhận ra mẫu tương đối dễ dàng và ngay lập tức dịch mã nguồn gốc sang dạng cuối cùng này. Tuy nhiên, biến đổi CPS và biến đổi khử cực là hoàn toàn chung và cơ học. Từ đó, kỹ thuật tổng hợp, phá rừng hoặc siêu biên dịch có thể được sử dụng để cố gắng loại bỏ các phần tiếp theo thống nhất. Các phép biến đổi đầu cơ có thể bị loại bỏ nếu không thể loại bỏ tất cả sự phân bổ của các phần tiếp theo thống nhất. Mặc dù vậy, tôi nghi ngờ rằng điều đó sẽ quá tốn kém để làm mọi lúc, trong toàn bộ tính tổng quát, do đó có nhiều cách tiếp cận đặc biệt hơn.

Nếu bạn muốn nhận được sự lố bịch, bạn có thể kiểm tra Tiếp tục tái chế giấy cũng sử dụng CPS và các biểu diễn của các phần tiếp theo làm dữ liệu, nhưng thực hiện một số thứ tương tự nhưng khác với đệ quy đuôi-modulo. Điều này mô tả cách bạn có thể tạo ra các thuật toán đảo ngược con trỏ bằng cách chuyển đổi.

Mô hình chuyển đổi và khử nhiễu CPS này là một công cụ khá mạnh để hiểu và được sử dụng để có hiệu quả tốt trong một loạt các bài báo tôi liệt kê ở đây .


Kỹ thuật mà GCC sử dụng thay cho Phong cách chuyển tiếp liên tục mà bạn thể hiện ở đây là, tôi tin rằng, Biểu mẫu chuyển nhượng đơn tĩnh.
Davislor

@Davislor Mặc dù liên quan đến CPS, SSA không ảnh hưởng đến luồng điều khiển của thủ tục cũng như không điều chỉnh ngăn xếp (hoặc nói cách khác là giới thiệu các cấu trúc dữ liệu cần được phân bổ động). Liên quan đến SSA, CPS "làm quá nhiều", đó là lý do tại sao Mẫu thông thường hành chính (ANF) phù hợp hơn với SSA. Vì vậy, GCC sử dụng SSA, nhưng SSA không dẫn đến ngăn điều khiển có thể xem được dưới dạng cấu trúc dữ liệu có thể thao tác.
Derek Elkins rời SE

Đúng. Tôi đã trả lời, tôi không nói GCC thực hiện tất cả lý do này vào thời gian biên dịch. Tôi không biết GCC sử dụng logic gì. Tương tự, câu trả lời của tôi, tương tự, đã cho thấy rằng phép biến đổi là hợp lý về mặt lý thuyết, không nói rằng đó là phương thức thực hiện mà bất kỳ trình biên dịch đã cho nào sử dụng. (Mặc dù, như bạn đã biết, nhiều trình biên dịch thực hiện chuyển đổi một chương trình thành CPS trong quá trình tối ưu hóa.)
Davislor 30/12/17

8

Tôi sẽ đi xung quanh bụi cây một lúc, nhưng có một điểm.

Nhóm bán kết

Câu trả lời là, thuộc tính kết hợp của hoạt động giảm nhị phân .

Đó là khá trừu tượng, nhưng phép nhân là một ví dụ tốt. Nếu x , yz là một số số tự nhiên (hoặc số nguyên, hoặc số hữu tỉ, hoặc số thực, hoặc số phức, hoặc N × N ma trận, hoặc bất kỳ của toàn bộ một loạt những thứ khác), sau đó x × y là cùng loại của số là cả xy . Chúng tôi đã bắt đầu với hai số, vì vậy, đó là một hoạt động nhị phân và có một số, vì vậy chúng tôi đã giảm số lượng chúng tôi có một, làm cho điều này trở thành một hoạt động giảm. Và ( x × y ) × z luôn giống với x × ( y ×z ), đó là tài sản liên kết.

(Nếu bạn đã biết tất cả những điều này, bạn có thể bỏ qua phần tiếp theo.)

Một vài điều nữa bạn thường thấy trong khoa học máy tính hoạt động theo cùng một cách:

  • thêm bất kỳ loại số nào thay vì nhân
  • chuỗi nối ( "a"+"b"+"c""abc"cho dù bạn bắt đầu bằng "ab"+"c"hoặc "a"+"bc")
  • Nối hai danh sách với nhau. [a]++[b]++[c]tương tự [a,b,c]hoặc từ sau ra trước hoặc trước ra sau.
  • constrên đầu và đuôi, nếu bạn nghĩ về đầu như một danh sách đơn. Đó chỉ là kết hợp hai danh sách.
  • lấy liên minh hoặc giao điểm của bộ
  • Boolean và, Boolean hoặc
  • bitwise &, |^
  • Thành phần của các hàm: ( fg ) ∘ h x = f ( gh ) x = f ( g ( h ( x )))
  • tối đa và tối thiểu
  • bổ sung modulo p

Một số điều không nên:

  • phép trừ, vì 1- (1-2) (1-1) -2
  • xy = tan ( x + y ), vì tan (π / 4 + π / 4) không xác định
  • phép nhân trên các số âm, vì -1 × -1 không phải là số âm
  • phân chia số nguyên, có tất cả ba vấn đề!
  • logic không, bởi vì nó chỉ có một toán hạng, không phải hai
  • int print2(int x, int y) { return printf( "%d %d\n", x, y ); }, như print2( print2(x,y), z );print2( x, print2(y,z) );có đầu ra khác nhau.

Đó là một khái niệm đủ hữu ích mà chúng tôi đặt tên cho nó. Một tập hợp với một hoạt động có các thuộc tính này là một nửa nhóm . Vì vậy, các số thực dưới cấp số nhân là một nửa nhóm. Và câu hỏi của bạn hóa ra là một trong những cách loại trừu tượng này trở nên hữu ích trong thế giới thực. Tất cả các hoạt động của Semigroup đều có thể được tối ưu hóa theo cách bạn yêu cầu.

Thử cái này ở nhà

Theo như tôi biết, kỹ thuật này được mô tả lần đầu tiên vào năm 1974, trong bài viết của Daniel Friedman và David Wise, Hồi phục được cách điệu hóa thành Iterations , mặc dù họ cho rằng một số thuộc tính nhiều hơn mức cần thiết.

Haskell là một ngôn ngữ tuyệt vời để minh họa điều này, bởi vì nó có kiểu chữ Semigrouptrong thư viện tiêu chuẩn của nó. Nó gọi hoạt động của một Semigrouptoán tử chung <>. Vì các danh sách và chuỗi là các thể hiện của Semigroup, nên các thể hiện của chúng được định nghĩa <>là toán tử nối ++, chẳng hạn. Và với nhập khẩu đúng, [a] <> [b]là một bí danh cho [a] ++ [b], đó là [a,b].

Nhưng, những con số thì sao? Chúng tôi chỉ thấy rằng các loại số là nửa nhóm dưới dạng cộng hoặc nhân! Vì vậy, cái nào sẽ được <>cho một Double? Vâng, một trong hai! Haskell định nghĩa các loại Product Double, where (<>) = (*)(đó là định nghĩa thực tế trong Haskell), và cũng Sum Double, where (<>) = (+).

Một nếp nhăn là bạn đã sử dụng thực tế rằng 1 là danh tính nhân. Một nửa nhóm có một danh tính được gọi là một monoid và được định nghĩa trong gói Haskell Data.Monoid, gọi phần tử nhận dạng chung của một kiểu chữ mempty. Sum, ProductVà danh sách từng có một yếu tố bản sắc (0, 1 và [], tương ứng), vì vậy họ là trường hợp của Monoidcũng như Semigroup. (Đừng nhầm lẫn với một đơn nguyên , vì vậy hãy quên tôi thậm chí đã đưa những người đó lên.)

Đó là đủ thông tin để dịch thuật toán của bạn thành hàm Haskell bằng cách sử dụng các đơn sắc:

module StylizedRec (pow) where

import Data.Monoid as DM

pow :: Monoid a => a -> Word -> a
{- Applies the monoidal operation of the type of x, whatever that is, by
 - itself n times.  This is already in Haskell as Data.Monoid.mtimes, but
 - let’s write it out as an example.
 -}
pow _ 0 = mempty -- Special case: Return the nullary product.
pow x 1 = x      -- The base case.
pow x n = x <> (pow x (n-1)) -- The recursive case.

Điều quan trọng, lưu ý rằng đây là modulo đệ quy đuôi: mỗi trường hợp là một giá trị, một cuộc gọi đệ quy đuôi hoặc sản phẩm semigroup của cả hai. Ngoài ra, ví dụ này tình cờ sử dụng memptycho một trong các trường hợp, nhưng nếu chúng tôi không cần điều đó, chúng tôi có thể thực hiện nó với kiểu chữ chung hơn Semigroup.

Hãy tải chương trình này lên trong GHCI và xem nó hoạt động như thế nào:

*StylizedRec> getProduct $ pow 2 4
16
*StylizedRec> getProduct $ pow 7 2
49

Hãy nhớ làm thế nào chúng ta tuyên bố powcho một chung chung Monoid, loại mà chúng ta đã gọi a? Chúng tôi đã GHCI đủ thông tin để suy ra rằng các loại aở đây là Product Integer, đó là một instancesố Monoidngười có <>hoạt động là nguyên nhân. Vì vậy, pow 2 4mở rộng đệ quy đến 2<>2<>2<>2, đó là 2*2*2*2hoặc 16. Càng xa càng tốt.

Nhưng chức năng của chúng tôi chỉ sử dụng các hoạt động monoid chung. Trước đây, tôi đã nói rằng có một thể hiện của Monoidgọi Sum, mà <>hoạt động là +. Chúng ta có thể thử nó không?

*StylizedRec> getSum $ pow 2 4
8
*StylizedRec> getSum $ pow 7 2
14

Việc mở rộng tương tự bây giờ cho chúng ta 2+2+2+2thay vì 2*2*2*2. Phép nhân là phép cộng như phép nhân là phép nhân!

Nhưng tôi đã đưa ra một ví dụ khác về một monoid Haskell: danh sách, có hoạt động là nối.

*StylizedRec> pow [2] 4
[2,2,2,2]
*StylizedRec> pow [7] 2
[7,7]

Viết [2]cho trình biên dịch rằng đây là một danh sách, <>trong danh sách là ++, vì vậy [2]++[2]++[2]++[2][2,2,2,2].

Cuối cùng, một thuật toán (Hai, trong thực tế)

Bằng cách đơn giản thay thế xbằng [x], bạn chuyển đổi thuật toán chung sử dụng modulo đệ quy một nửa nhóm thành một nhóm tạo ra một danh sách. Danh sách nào? Danh sách các yếu tố thuật toán áp dụng <>cho. Bởi vì chúng tôi chỉ sử dụng các hoạt động semigroup mà danh sách cũng có, danh sách kết quả sẽ là đẳng cấu với tính toán ban đầu. Và vì hoạt động ban đầu là kết hợp, chúng ta có thể đánh giá tốt các yếu tố từ sau ra trước hoặc từ trước ra sau.

Nếu thuật toán của bạn từng đạt đến trường hợp cơ sở và chấm dứt, danh sách sẽ không trống. Vì trường hợp đầu cuối trả về một cái gì đó, đó sẽ là phần tử cuối cùng của danh sách, vì vậy nó sẽ có ít nhất một phần tử.

Làm thế nào để bạn áp dụng một hoạt động giảm nhị phân cho mọi yếu tố của danh sách theo thứ tự? Đúng vậy, một nếp gấp. Vì vậy, bạn có thể thay thế [x]cho x, có được một danh sách các yếu tố để giảm <>, và sau đó, hoặc phải gấp hoặc trái gấp danh sách:

*StylizedRec> getProduct $ foldr1 (<>) $ pow [Product 2] 4
16
*StylizedRec> import Data.List
*StylizedRec Data.List> getProduct $ foldl1' (<>) $ pow [Product 2] 4
16

Phiên bản foldr1thực sự tồn tại trong thư viện tiêu chuẩn, như sconcatcho Semigroupmconcatcho Monoid. Nó không lười biếng gấp trong danh sách. Đó là, nó mở rộng [Product 2,Product 2,Product 2,Product 2]đến 2<>(2<>(2<>(2))).

Điều này không hiệu quả trong trường hợp này vì bạn không thể làm bất cứ điều gì với các điều khoản riêng lẻ cho đến khi bạn tạo ra tất cả chúng. (Tại một thời điểm tôi đã có một cuộc thảo luận ở đây về việc khi nào nên sử dụng nếp gấp bên phải và khi nào nên sử dụng nếp gấp bên trái nghiêm ngặt, nhưng nó đã đi quá xa.)

Phiên bản với foldl1'một nếp gấp bên trái được đánh giá nghiêm ngặt. Đó là để nói, một chức năng đệ quy đuôi với một bộ tích lũy nghiêm ngặt. Điều này đánh giá (((2)<>2)<>2)<>2, tính toán ngay lập tức và không muộn hơn khi cần thiết. (Ít nhất, không có sự chậm trễ trong bản thân gấp:. Danh sách được gấp lại được tạo ra ở đây bởi một chức năng có thể chứa đánh giá lười biếng) Vì vậy, sẽ tính toán lần (4<>2)<>2, sau đó ngay lập tức tính toán của 8<>2, sau đó 16. Đây là lý do tại sao chúng tôi cần hoạt động để được kết hợp: chúng tôi chỉ thay đổi nhóm các dấu ngoặc đơn!

Nếp gấp bên trái nghiêm ngặt tương đương với những gì GCC đang làm. Số ngoài cùng bên trái trong ví dụ trước là bộ tích lũy, trong trường hợp này là một sản phẩm đang chạy. Ở mỗi bước, nó nhân với số tiếp theo trong danh sách. Một cách khác để diễn đạt nó là: bạn lặp lại các giá trị sẽ được nhân, giữ sản phẩm đang chạy trong bộ tích lũy và trên mỗi lần lặp, bạn nhân số tích lũy với giá trị tiếp theo. Đó là, đó là một whilevòng lặp ngụy trang.

Nó đôi khi có thể được thực hiện như là hiệu quả. Trình biên dịch có thể tối ưu hóa cấu trúc dữ liệu danh sách trong bộ nhớ. Về lý thuyết, nó có đủ thông tin tại thời điểm biên dịch để tìm ra nó nên làm như vậy ở đây: [x]là một singleton, [x]<>xscũng giống như vậy cons x xs. Mỗi lần lặp của hàm có thể có thể sử dụng lại cùng khung stack và cập nhật các tham số tại chỗ.

Một nếp gấp bên phải hoặc nếp gấp bên trái nghiêm ngặt có thể phù hợp hơn, trong một trường hợp cụ thể, vì vậy hãy biết bạn muốn cái nào. Ngoài ra còn có một số điều chỉ có một nếp gấp đúng có thể làm (chẳng hạn như tạo đầu ra tương tác mà không cần chờ tất cả đầu vào và hoạt động trên một danh sách vô hạn). Mặc dù vậy, ở đây, chúng tôi đang giảm một chuỗi các hoạt động xuống một giá trị đơn giản, do đó, một nếp gấp nghiêm ngặt là điều chúng tôi muốn.

Vì vậy, như bạn có thể thấy, có thể tự động tối ưu hóa modulo đệ quy đuôi bất kỳ nửa nhóm nào (một ví dụ trong số đó là bất kỳ kiểu số thông thường nào trong phép nhân) thành nếp gấp bên phải lười biếng hoặc nếp gấp trái nghiêm ngặt, trong một dòng Haskell.

Tổng quát hóa hơn nữa

Hai đối số của hoạt động nhị phân không phải cùng loại, miễn là giá trị ban đầu là cùng loại với kết quả của bạn. (Tất nhiên, bạn luôn có thể lật các đối số để khớp với thứ tự của kiểu gấp mà bạn đang thực hiện, trái hoặc phải.) Vì vậy, bạn có thể liên tục thêm các bản vá vào một tệp để nhận tệp cập nhật hoặc bắt đầu với giá trị ban đầu là 1.0, chia cho số nguyên để tích lũy kết quả dấu phẩy động. Hoặc thêm các yếu tố vào danh sách trống để có được danh sách.

Một kiểu khái quát hóa khác là áp dụng các nếp gấp không cho các danh sách mà cho các Foldablecấu trúc dữ liệu khác . Thông thường, một danh sách liên kết tuyến tính bất biến không phải là cấu trúc dữ liệu bạn muốn cho một thuật toán nhất định. Một vấn đề tôi không gặp phải ở trên là việc thêm các yếu tố vào phía trước danh sách sẽ hiệu quả hơn rất nhiều và khi hoạt động không giao hoán, áp dụng xở bên trái và bên phải của hoạt động giống nhau. Vì vậy, bạn sẽ cần sử dụng một cấu trúc khác, chẳng hạn như một cặp danh sách hoặc cây nhị phân, để biểu diễn một thuật toán có thể áp dụng xở bên phải <>cũng như bên trái.

Cũng lưu ý rằng thuộc tính kết hợp cho phép bạn tập hợp lại các hoạt động theo các cách hữu ích khác, chẳng hạn như phân chia và chinh phục:

times :: Monoid a => a -> Word -> a
times _ 0 = mempty
times x 1 = x
times x n | even n    = y <> y
          | otherwise = x <> y <> y
  where y = times x (n `quot` 2)

Hoặc tự động song song, trong đó mỗi luồng giảm một phân nhóm thành một giá trị sau đó được kết hợp với các giá trị khác.


1
Chúng tôi có thể thực hiện một thử nghiệm để kiểm tra tính kết hợp đó là chìa khóa cho khả năng tối ưu hóa của GCC: pow(float x, unsigned n)phiên bản gcc.godbolt.org/z/eqwine chỉ tối ưu hóa với -ffast-math, (ngụ ý -fassociative-math. Điểm nổi nghiêm ngặt tất nhiên không liên quan vì các thời gian khác nhau = làm tròn khác nhau). Phần giới thiệu 1.0f * xkhông có trong máy trừu tượng C (nhưng sẽ luôn cho kết quả giống hệt nhau). Sau đó, phép nhân n-1 giống do{res*=x;}while(--n!=1)như đệ quy, vì vậy đây là một tối ưu hóa bị bỏ lỡ.
Peter Cordes
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.