Những gì tối ưu hóa GHC có thể được dự kiến ​​sẽ thực hiện đáng tin cậy?


183

GHC có rất nhiều tối ưu hóa mà nó có thể thực hiện, nhưng tôi không biết tất cả chúng là gì, cũng như khả năng chúng được thực hiện như thế nào và trong hoàn cảnh nào.

Câu hỏi của tôi là: những biến đổi nào tôi có thể mong đợi nó được áp dụng mỗi lần, hoặc gần như vậy? Nếu tôi nhìn vào một đoạn mã sẽ được thực thi (đánh giá) thường xuyên và suy nghĩ đầu tiên của tôi là "hmm, có lẽ tôi nên tối ưu hóa nó", trong trường hợp đó, suy nghĩ thứ hai của tôi sẽ là "thậm chí không nghĩ về nó," GHC có được điều này "?

Tôi đang đọc bài viết Stream Fusion: Từ danh sách đến luồng không có gì cả , và kỹ thuật họ sử dụng để viết lại xử lý danh sách thành một hình thức khác mà tối ưu hóa bình thường của GHC sẽ tối ưu hóa thành các vòng lặp đơn giản đối với tôi. Làm thế nào tôi có thể biết khi nào các chương trình của riêng tôi đủ điều kiện cho loại tối ưu hóa đó?

một số thông tin trong hướng dẫn sử dụng GHC, nhưng nó chỉ là một phần của cách trả lời câu hỏi.

EDIT: Tôi đang bắt đầu một tiền thưởng. Những gì tôi muốn là một danh sách các phép biến đổi mức thấp hơn như lambda / let / case-float, type / constructor / function, phân tích mức độ nghiêm ngặt và unboxing, worker / Wrapper và bất cứ điều gì quan trọng khác mà GHC bỏ qua , cùng với các giải thích và ví dụ về mã đầu vào và đầu ra, và minh họa lý tưởng về các tình huống khi tổng hiệu ứng lớn hơn tổng các phần của nó. Và lý tưởng nhất là một số đề cập đến khi biến đổi sẽ khôngxảy ra Tôi không mong đợi những lời giải thích dài dòng về mọi biến đổi, một vài câu và ví dụ mã một dòng nội tuyến có thể là đủ (hoặc một liên kết, nếu nó không đến hai mươi trang giấy khoa học), miễn là bức tranh lớn rõ ràng vào cuối của nó. Tôi muốn có thể xem xét một đoạn mã và có thể dự đoán tốt về việc liệu nó sẽ biên dịch thành một vòng lặp chặt chẽ hay tại sao không, hoặc tôi sẽ phải thay đổi điều gì để tạo ra nó. (Tôi không quan tâm lắm đến các khung tối ưu hóa lớn như hợp nhất luồng (tôi chỉ đọc một bài báo về điều đó); nhiều hơn về loại kiến ​​thức mà những người viết các khung này có.)


10
Đây là một câu hỏi xứng đáng nhất. Viết một câu trả lời xứng đáng là ... khó khăn.
Toán học hoặc

1
Một điểm khởi đầu thực sự tốt là đây: aosabook.org/en/ghc.html
Gabriel Gonzalez

7
Trong bất kỳ ngôn ngữ nào, nếu suy nghĩ đầu tiên của bạn là "có lẽ tôi nên tối ưu hóa điều đó", thì suy nghĩ thứ hai của bạn sẽ là "Tôi sẽ hồ sơ trước".
John L

4
Mặc dù loại kiến ​​thức bạn theo đuổi là hữu ích, và vì vậy đây vẫn là một câu hỏi hay, tôi nghĩ bạn thực sự được phục vụ tốt hơn bằng cách cố gắng thực hiện càng ít tối ưu hóa càng tốt. Viết những gì bạn có nghĩa là, và chỉ khi nó trở nên rõ ràng rằng bạn cần phải sau đó suy nghĩ về việc mã ít đơn giản vì lợi ích của hiệu suất. Thay vì nhìn vào mã và nghĩ rằng "điều đó sẽ được thực thi thường xuyên, có lẽ tôi nên tối ưu hóa nó", chỉ nên khi bạn quan sát mã chạy quá chậm, bạn nghĩ rằng "Tôi nên tìm hiểu những gì được thực thi thường xuyên và tối ưu hóa điều đó" .
Ben

14
Tôi hoàn toàn dự đoán rằng một phần sẽ kêu gọi những người hô hào "hồ sơ nó!" :). Nhưng tôi đoán mặt khác của đồng tiền là, nếu tôi cấu hình nó và nó chậm, có thể tôi có thể viết lại hoặc chỉ chỉnh nó thành một hình thức vẫn ở mức cao nhưng GHC có thể tối ưu hóa tốt hơn, thay vì tự tối ưu hóa nó? Mà đòi hỏi cùng loại kiến ​​thức. Và nếu tôi có kiến ​​thức đó ngay từ đầu, tôi có thể tự cứu mình một chu kỳ chỉnh sửa hồ sơ.
glaebhoerl

Câu trả lời:


110

Trang GHC Trac này cũng giải thích các đường chuyền khá tốt. Trang này giải thích thứ tự tối ưu hóa, mặc dù, giống như phần lớn Trac Wiki, nó đã lỗi thời.

Đối với các chi tiết cụ thể, điều tốt nhất để làm có lẽ là xem xét cách thức một chương trình cụ thể được biên dịch. Cách tốt nhất để xem tối ưu hóa nào đang được thực hiện là biên dịch chương trình bằng lời nói, sử dụng -vcờ. Lấy ví dụ là mảnh Haskell đầu tiên tôi có thể tìm thấy trên máy tính của mình:

Glasgow Haskell Compiler, Version 7.4.2, stage 2 booted by GHC version 7.4.1
Using binary package database: /usr/lib/ghc-7.4.2/package.conf.d/package.cache
wired-in package ghc-prim mapped to ghc-prim-0.2.0.0-7d3c2c69a5e8257a04b2c679c40e2fa7
wired-in package integer-gmp mapped to integer-gmp-0.4.0.0-af3a28fdc4138858e0c7c5ecc2a64f43
wired-in package base mapped to base-4.5.1.0-6e4c9bdc36eeb9121f27ccbbcb62e3f3
wired-in package rts mapped to builtin_rts
wired-in package template-haskell mapped to template-haskell-2.7.0.0-2bd128e15c2d50997ec26a1eaf8b23bf
wired-in package dph-seq not found.
wired-in package dph-par not found.
Hsc static flags: -static
*** Chasing dependencies:
Chasing modules from: *SleepSort.hs
Stable obj: [Main]
Stable BCO: []
Ready for upsweep
  [NONREC
      ModSummary {
         ms_hs_date = Tue Oct 18 22:22:11 CDT 2011
         ms_mod = main:Main,
         ms_textual_imps = [import (implicit) Prelude, import Control.Monad,
                            import Control.Concurrent, import System.Environment]
         ms_srcimps = []
      }]
*** Deleting temp files:
Deleting: 
compile: input file SleepSort.hs
Created temporary directory: /tmp/ghc4784_0
*** Checking old interface for main:Main:
[1 of 1] Compiling Main             ( SleepSort.hs, SleepSort.o )
*** Parser:
*** Renamer/typechecker:
*** Desugar:
Result size of Desugar (after optimization) = 79
*** Simplifier:
Result size of Simplifier iteration=1 = 87
Result size of Simplifier iteration=2 = 93
Result size of Simplifier iteration=3 = 83
Result size of Simplifier = 83
*** Specialise:
Result size of Specialise = 83
*** Float out(FOS {Lam = Just 0, Consts = True, PAPs = False}):
Result size of Float out(FOS {Lam = Just 0,
                              Consts = True,
                              PAPs = False}) = 95
*** Float inwards:
Result size of Float inwards = 95
*** Simplifier:
Result size of Simplifier iteration=1 = 253
Result size of Simplifier iteration=2 = 229
Result size of Simplifier = 229
*** Simplifier:
Result size of Simplifier iteration=1 = 218
Result size of Simplifier = 218
*** Simplifier:
Result size of Simplifier iteration=1 = 283
Result size of Simplifier iteration=2 = 226
Result size of Simplifier iteration=3 = 202
Result size of Simplifier = 202
*** Demand analysis:
Result size of Demand analysis = 202
*** Worker Wrapper binds:
Result size of Worker Wrapper binds = 202
*** Simplifier:
Result size of Simplifier = 202
*** Float out(FOS {Lam = Just 0, Consts = True, PAPs = True}):
Result size of Float out(FOS {Lam = Just 0,
                              Consts = True,
                              PAPs = True}) = 210
*** Common sub-expression:
Result size of Common sub-expression = 210
*** Float inwards:
Result size of Float inwards = 210
*** Liberate case:
Result size of Liberate case = 210
*** Simplifier:
Result size of Simplifier iteration=1 = 206
Result size of Simplifier = 206
*** SpecConstr:
Result size of SpecConstr = 206
*** Simplifier:
Result size of Simplifier = 206
*** Tidy Core:
Result size of Tidy Core = 206
writeBinIface: 4 Names
writeBinIface: 28 dict entries
*** CorePrep:
Result size of CorePrep = 224
*** Stg2Stg:
*** CodeGen:
*** CodeOutput:
*** Assembler:
'/usr/bin/gcc' '-fno-stack-protector' '-Wl,--hash-size=31' '-Wl,--reduce-memory-overheads' '-I.' '-c' '/tmp/ghc4784_0/ghc4784_0.s' '-o' 'SleepSort.o'
Upsweep completely successful.
*** Deleting temp files:
Deleting: /tmp/ghc4784_0/ghc4784_0.c /tmp/ghc4784_0/ghc4784_0.s
Warning: deleting non-existent /tmp/ghc4784_0/ghc4784_0.c
link: linkables are ...
LinkableM (Sat Sep 29 20:21:02 CDT 2012) main:Main
   [DotO SleepSort.o]
Linking SleepSort ...
*** C Compiler:
'/usr/bin/gcc' '-fno-stack-protector' '-Wl,--hash-size=31' '-Wl,--reduce-memory-overheads' '-c' '/tmp/ghc4784_0/ghc4784_0.c' '-o' '/tmp/ghc4784_0/ghc4784_0.o' '-DTABLES_NEXT_TO_CODE' '-I/usr/lib/ghc-7.4.2/include'
*** C Compiler:
'/usr/bin/gcc' '-fno-stack-protector' '-Wl,--hash-size=31' '-Wl,--reduce-memory-overheads' '-c' '/tmp/ghc4784_0/ghc4784_0.s' '-o' '/tmp/ghc4784_0/ghc4784_1.o' '-DTABLES_NEXT_TO_CODE' '-I/usr/lib/ghc-7.4.2/include'
*** Linker:
'/usr/bin/gcc' '-fno-stack-protector' '-Wl,--hash-size=31' '-Wl,--reduce-memory-overheads' '-o' 'SleepSort' 'SleepSort.o' '-L/usr/lib/ghc-7.4.2/base-4.5.1.0' '-L/usr/lib/ghc-7.4.2/integer-gmp-0.4.0.0' '-L/usr/lib/ghc-7.4.2/ghc-prim-0.2.0.0' '-L/usr/lib/ghc-7.4.2' '/tmp/ghc4784_0/ghc4784_0.o' '/tmp/ghc4784_0/ghc4784_1.o' '-lHSbase-4.5.1.0' '-lHSinteger-gmp-0.4.0.0' '-lgmp' '-lHSghc-prim-0.2.0.0' '-lHSrts' '-lm' '-lrt' '-ldl' '-u' 'ghczmprim_GHCziTypes_Izh_static_info' '-u' 'ghczmprim_GHCziTypes_Czh_static_info' '-u' 'ghczmprim_GHCziTypes_Fzh_static_info' '-u' 'ghczmprim_GHCziTypes_Dzh_static_info' '-u' 'base_GHCziPtr_Ptr_static_info' '-u' 'base_GHCziWord_Wzh_static_info' '-u' 'base_GHCziInt_I8zh_static_info' '-u' 'base_GHCziInt_I16zh_static_info' '-u' 'base_GHCziInt_I32zh_static_info' '-u' 'base_GHCziInt_I64zh_static_info' '-u' 'base_GHCziWord_W8zh_static_info' '-u' 'base_GHCziWord_W16zh_static_info' '-u' 'base_GHCziWord_W32zh_static_info' '-u' 'base_GHCziWord_W64zh_static_info' '-u' 'base_GHCziStable_StablePtr_static_info' '-u' 'ghczmprim_GHCziTypes_Izh_con_info' '-u' 'ghczmprim_GHCziTypes_Czh_con_info' '-u' 'ghczmprim_GHCziTypes_Fzh_con_info' '-u' 'ghczmprim_GHCziTypes_Dzh_con_info' '-u' 'base_GHCziPtr_Ptr_con_info' '-u' 'base_GHCziPtr_FunPtr_con_info' '-u' 'base_GHCziStable_StablePtr_con_info' '-u' 'ghczmprim_GHCziTypes_False_closure' '-u' 'ghczmprim_GHCziTypes_True_closure' '-u' 'base_GHCziPack_unpackCString_closure' '-u' 'base_GHCziIOziException_stackOverflow_closure' '-u' 'base_GHCziIOziException_heapOverflow_closure' '-u' 'base_ControlziExceptionziBase_nonTermination_closure' '-u' 'base_GHCziIOziException_blockedIndefinitelyOnMVar_closure' '-u' 'base_GHCziIOziException_blockedIndefinitelyOnSTM_closure' '-u' 'base_ControlziExceptionziBase_nestedAtomically_closure' '-u' 'base_GHCziWeak_runFinalizzerBatch_closure' '-u' 'base_GHCziTopHandler_flushStdHandles_closure' '-u' 'base_GHCziTopHandler_runIO_closure' '-u' 'base_GHCziTopHandler_runNonIO_closure' '-u' 'base_GHCziConcziIO_ensureIOManagerIsRunning_closure' '-u' 'base_GHCziConcziSync_runSparks_closure' '-u' 'base_GHCziConcziSignal_runHandlers_closure'
link: done
*** Deleting temp files:
Deleting: /tmp/ghc4784_0/ghc4784_1.o /tmp/ghc4784_0/ghc4784_0.s /tmp/ghc4784_0/ghc4784_0.o /tmp/ghc4784_0/ghc4784_0.c
*** Deleting temp dirs:
Deleting: /tmp/ghc4784_0

Nhìn từ đầu *** Simplifier:đến cuối, trong đó tất cả các giai đoạn tối ưu hóa xảy ra, chúng ta thấy khá nhiều.

Trước hết, Simoder chạy giữa hầu hết tất cả các pha. Điều này làm cho việc viết nhiều vượt qua dễ dàng hơn nhiều. Ví dụ, khi thực hiện nhiều tối ưu hóa, họ chỉ cần tạo các quy tắc viết lại để truyền bá các thay đổi thay vì phải thực hiện thủ công. Bộ mô phỏng bao gồm một số tối ưu hóa đơn giản, bao gồm cả nội tuyến và hợp nhất. Hạn chế chính của điều này mà tôi biết là GHC từ chối các hàm đệ quy nội tuyến và mọi thứ phải được đặt tên chính xác để phản ứng tổng hợp hoạt động.

Tiếp theo, chúng tôi thấy một danh sách đầy đủ của tất cả các tối ưu hóa được thực hiện:

  • Chuyên

    Ý tưởng cơ bản của chuyên môn hóa là loại bỏ tính đa hình và quá tải bằng cách xác định các vị trí nơi hàm được gọi và tạo các phiên bản của hàm không đa hình - chúng đặc trưng cho các loại mà chúng được gọi. Bạn cũng có thể yêu cầu trình biên dịch thực hiện điều này với SPECIALISEpragma. Ví dụ, lấy một hàm giai thừa:

    fac :: (Num a, Eq a) => a -> a
    fac 0 = 1
    fac n = n * fac (n - 1)

    Vì trình biên dịch không biết bất kỳ thuộc tính nào của phép nhân sẽ được sử dụng, nên nó không thể tối ưu hóa điều này. Tuy nhiên, nếu nó thấy rằng nó được sử dụng trên một Int, thì bây giờ nó có thể tạo một phiên bản mới, chỉ khác nhau về loại:

    fac_Int :: Int -> Int
    fac_Int 0 = 1
    fac_Int n = n * fac_Int (n - 1)

    Tiếp theo, các quy tắc được đề cập dưới đây có thể kích hoạt và bạn kết thúc với một cái gì đó hoạt động trên hộp Int , nhanh hơn nhiều so với bản gốc. Một cách khác để xem xét chuyên môn hóa là ứng dụng một phần vào từ điển lớp loại và biến kiểu.

    Các nguồn ở đây có một tải công hàm trong đó.

  • Nổi

    EDIT: Tôi dường như đã hiểu nhầm điều này trước đây. Lời giải thích của tôi đã hoàn toàn thay đổi.

    Ý tưởng cơ bản của việc này là di chuyển các tính toán không nên lặp lại ra khỏi các hàm. Ví dụ: giả sử chúng ta có điều này:

    \x -> let y = expensive in x+y

    Trong lambda ở trên, mỗi khi hàm được gọi, yđược tính toán lại. Một chức năng tốt hơn, mà nổi ra sản xuất, là

    let y = expensive in \x -> x+y

    Để tạo thuận lợi cho quá trình, các biến đổi khác có thể được áp dụng. Ví dụ: điều này xảy ra:

     \x -> x + f 2
     \x -> x + let f_2 = f 2 in f_2
     \x -> let f_2 = f 2 in x + f_2
     let f_2 = f 2 in \x -> x + f_2

    Một lần nữa, tính toán lặp đi lặp lại được lưu lại.

    các nguồn là rất có thể đọc được trong trường hợp này.

    Tại thời điểm ràng buộc giữa hai lambdas liền kề không được thả nổi. Ví dụ: điều này không xảy ra:

    \x y -> let t = x+x in ...

    sẽ

     \x -> let t = x+x in \y -> ...
  • Phao vào trong

    Trích dẫn mã nguồn,

    Mục đích chính của việc floatInwardsthả nổi vào các nhánh của một vụ án, để chúng ta không phân bổ mọi thứ, lưu chúng trên ngăn xếp và sau đó khám phá ra rằng chúng không cần thiết trong nhánh được chọn.

    Ví dụ, giả sử chúng ta có biểu thức này:

    let x = big in
        case v of
            True -> x + 1
            False -> 0

    Nếu vđánh giá False, sau đó bằng cách phân bổ x, có lẽ là một số thunk lớn, chúng ta đã lãng phí thời gian và không gian. Nổi bên trong sửa lỗi này, tạo ra điều này:

    case v of
        True -> let x = big in x + 1
        False -> let x = big in 0

    , sau đó được thay thế bằng trình giả lập với

    case v of
        True -> big + 1
        False -> 0

    Bài viết này , mặc dù bao gồm các chủ đề khác, đưa ra một giới thiệu khá rõ ràng. Lưu ý rằng mặc dù tên của chúng, trôi nổi và trôi nổi không đi vào một vòng lặp vô hạn vì hai lý do:

    1. Phao trong phao cho phép vào case báo cáo, trong khi float ra giao dịch với các chức năng.
    2. Có một trật tự cố định, vì vậy chúng không nên xen kẽ nhau.

  • Phần tích nhu cầu

    Phân tích nhu cầu, hoặc phân tích nghiêm ngặt là ít chuyển đổi và nhiều hơn, như tên cho thấy, của một thông qua thu thập thông tin. Trình biên dịch tìm các hàm luôn đánh giá các đối số của chúng (hoặc ít nhất là một số trong số chúng) và chuyển các đối số đó bằng cách sử dụng lệnh gọi theo giá trị, thay vì gọi theo nhu cầu. Vì bạn có thể trốn tránh các chi phí của thunks, nên việc này thường nhanh hơn nhiều. Nhiều vấn đề về hiệu năng trong Haskell phát sinh từ việc vượt qua lỗi này hoặc mã đơn giản là không đủ nghiêm ngặt. Một ví dụ đơn giản là sự khác biệt giữa việc sử dụng foldr,foldlfoldl'để tổng hợp danh sách các số nguyên - nguyên nhân đầu tiên gây ra lỗi tràn stack, lần thứ hai gây ra tràn heap và lần cuối chạy tốt, vì tính nghiêm ngặt. Đây có lẽ là dễ hiểu nhất và tài liệu tốt nhất trong số này. Tôi tin rằng đa hình và mã CPS thường đánh bại điều này.

  • Công nhân Wrapper liên kết

    Ý tưởng cơ bản của phép biến đổi worker / Wrapper là thực hiện một vòng lặp chặt chẽ trên một cấu trúc đơn giản, chuyển đổi sang và từ cấu trúc đó ở cuối. Ví dụ, lấy hàm này, tính toán giai thừa của một số.

    factorial :: Int -> Int
    factorial 0 = 1
    factorial n = n * factorial (n - 1)

    Sử dụng định nghĩa Inttrong GHC, chúng ta có

    factorial :: Int -> Int
    factorial (I# 0#) = I# 1#
    factorial (I# n#) = I# (n# *# case factorial (I# (n# -# 1#)) of
        I# down# -> down#)

    Lưu ý cách mã được bao phủ trong I#s? Chúng ta có thể loại bỏ chúng bằng cách làm điều này:

    factorial :: Int -> Int
    factorial (I# n#) = I# (factorial# n#)
    
    factorial# :: Int# -> Int#
    factorial# 0# = 1#
    factorial# n# = n# *# factorial# (n# -# 1#)

    Mặc dù ví dụ cụ thể này cũng có thể được SpecConstr thực hiện, nhưng phép biến đổi worker / Wrapper rất chung chung trong những điều nó có thể làm.

  • Biểu thức con thường gặp

    Đây là một tối ưu hóa thực sự đơn giản khác rất hiệu quả, như phân tích nghiêm ngặt. Ý tưởng cơ bản là nếu bạn có hai biểu thức giống nhau, chúng sẽ có cùng giá trị. Ví dụ: nếu fiblà một máy tính số Fibonacci, CSE sẽ biến đổi

    fib x + fib x

    vào

    let fib_x = fib x in fib_x + fib_x

    trong đó cắt giảm một nửa tính toán. Thật không may, điều này đôi khi có thể cản trở các tối ưu hóa khác. Một vấn đề khác là hai biểu thức phải ở cùng một vị trí và chúng phải giống nhau về mặt cú pháp , không giống nhau theo giá trị. Ví dụ: CSE sẽ không kích hoạt mã sau mà không có một loạt nội tuyến:

    x = (1 + (2 + 3)) + ((1 + 2) + 3)
    y = f x
    z = g (f x) y

    Tuy nhiên, nếu bạn biên dịch qua llvm, bạn có thể nhận được một số kết hợp này, do vượt qua Đánh số giá trị toàn cầu của nó.

  • Giải phóng vụ án

    Đây dường như là một sự chuyển đổi tài liệu khủng khiếp, bên cạnh thực tế là nó có thể gây ra vụ nổ mã. Đây là một phiên bản được định dạng lại (và viết lại một chút) của tài liệu nhỏ mà tôi tìm thấy:

    Mô-đun này đi qua Corevà tìm kiếm casecác biến miễn phí. Tiêu chí là: nếu có một casebiến miễn phí trên tuyến đến cuộc gọi đệ quy, thì cuộc gọi đệ quy được thay thế bằng một lần mở. Ví dụ: trong

    f = \ t -> case v of V a b -> a : f t

    bên trong fđược thay thế. để làm cho

    f = \ t -> case v of V a b -> a : (letrec f = \ t -> case v of V a b -> a : f t in f) t

    Lưu ý sự cần thiết cho bóng. Đơn giản hóa, chúng tôi nhận được

    f = \ t -> case v of V a b -> a : (letrec f = \ t -> a : f t in f t)

    Đây là mã tốt hơn, vì alà miễn phí bên trong bên trong letrec, thay vì cần chiếu từ v. Lưu ý rằng điều này xử lý các biến miễn phí , không giống như SpecConstr, xử lý các đối số có dạng đã biết.

    Xem bên dưới để biết thêm thông tin về SpecConstr.

  • SpecConstr - điều này biến đổi các chương trình như

    f (Left x) y = somthingComplicated1
    f (Right x) y = somethingComplicated2

    vào

    f_Left x y = somethingComplicated1
    f_Right x y = somethingComplicated2
    
    {-# INLINE f #-}
    f (Left x) = f_Left x
    f (Right x) = f_Right x

    Để làm ví dụ mở rộng, hãy lấy định nghĩa này về last:

    last [] = error "last: empty list"
    last (x:[]) = x
    last (x:x2:xs) = last (x2:xs)

    Trước tiên chúng tôi chuyển đổi nó thành

    last_nil = error "last: empty list"
    last_cons x [] = x
    last_cons x (x2:xs) = last (x2:xs)
    
    {-# INLINE last #-}
    last [] = last_nil
    last (x : xs) = last_cons x xs

    Tiếp theo, trình giả lập chạy và chúng ta có

    last_nil = error "last: empty list"
    last_cons x [] = x
    last_cons x (x2:xs) = last_cons x2 xs
    
    {-# INLINE last #-}
    last [] = last_nil
    last (x : xs) = last_cons x xs

    Lưu ý rằng chương trình bây giờ nhanh hơn, vì chúng tôi không liên tục đấm bốc và mở hộp phía trước danh sách. Cũng lưu ý rằng nội tuyến là rất quan trọng, vì nó cho phép các định nghĩa mới, hiệu quả hơn thực sự được sử dụng, cũng như làm cho các định nghĩa đệ quy tốt hơn.

    SpecConstr được kiểm soát bởi một số phương pháp phỏng đoán. Những người được đề cập trong bài báo là như vậy:

    1. Các lambdas là rõ ràng và arity là a.
    2. Phía bên tay phải là "đủ nhỏ", một cái gì đó được điều khiển bởi một lá cờ.
    3. Hàm này được đệ quy và cuộc gọi đặc biệt được sử dụng ở phía bên tay phải.
    4. Tất cả các đối số cho chức năng là hiện tại.
    5. Ít nhất một trong các đối số là một ứng dụng xây dựng.
    6. Đối số đó được phân tích trường hợp ở đâu đó trong hàm.

    Tuy nhiên, các heuristic gần như chắc chắn đã thay đổi. Trong thực tế, bài báo đề cập đến một heuristic thứ sáu thay thế:

    Chuyên về một cuộc tranh cãi xchỉ khi xđược chỉ xem xét kỹ lưỡng bởi một case, và không được đưa vào một chức năng bình thường, hoặc quay trở lại như là một phần của kết quả.

Đây là một tệp rất nhỏ (12 dòng) và do đó có thể không kích hoạt nhiều tối ưu hóa (mặc dù tôi nghĩ rằng nó đã làm tất cả). Điều này cũng không cho bạn biết lý do tại sao nó chọn những đường chuyền đó và tại sao nó lại đưa chúng theo thứ tự đó.


Bây giờ chúng tôi đang nhận được ở đâu đó! Nhận xét: Bạn dường như có một câu bị cắt trong phần về Chuyên môn. Tôi không thấy điểm nổi: nó dùng để làm gì? Làm thế nào để nó quyết định có nổi vào hay không (tại sao nó không vào vòng lặp)? Tôi có cảm giác từ một nơi nào đó GHC đã không làm CSE ở tất cả , nhưng dường như đó là sai lầm. Tôi cảm thấy như mình bị lạc trong chi tiết thay vì nhìn thấy một bức tranh lớn ... chủ đề thậm chí còn phức tạp hơn tôi nghĩ. Có lẽ câu hỏi của tôi là không thể và không có cách nào để có được trực giác này ngoại trừ một tấn kinh nghiệm hoặc tự mình làm việc trên GHC?
glaebhoerl

Chà, tôi không biết, nhưng tôi chưa bao giờ làm việc trên GHC, vì vậy bạn phải có được một chút trực giác.
gereeter

Tôi đã sửa các vấn đề bạn đề cập.
gereeter

1
Ngoài ra, về bức tranh lớn, theo ý kiến ​​của tôi, thực sự không có ai. Khi tôi muốn đoán những gì tối ưu hóa sẽ được thực hiện, tôi đi xuống một danh sách kiểm tra. Sau đó, tôi làm lại, để xem mỗi lần vượt qua sẽ thay đổi mọi thứ như thế nào. Và một lần nữa. Về cơ bản, tôi chơi trình biên dịch. Kế hoạch tối ưu hóa duy nhất tôi biết rằng thực sự có một "bức tranh lớn" là siêu biên dịch.
gereeter

1
Bạn có ý nghĩa gì bởi "mọi thứ phải được đặt tên chính xác để hợp hạch hoạt động" chính xác?
Vincent Beffara

65

Lười biếng

Nó không phải là "tối ưu hóa trình biên dịch", nhưng nó là thứ được đảm bảo bởi đặc tả ngôn ngữ, vì vậy bạn luôn có thể tin tưởng vào nó đang diễn ra. Về cơ bản, điều này có nghĩa là công việc không được thực hiện cho đến khi bạn "làm điều gì đó" với kết quả. (Trừ khi bạn làm một trong nhiều điều để cố tình tắt sự lười biếng.)

Rõ ràng, đây là toàn bộ chủ đề theo đúng nghĩa của nó và SO đã có rất nhiều câu hỏi và câu trả lời về nó.

Theo kinh nghiệm hạn chế của tôi, làm cho mã của bạn quá lười biếng hoặc quá khắt khe có bao la hình phạt hiệu suất lớn hơn (trong thời gian không gian) hơn bất kỳ những thứ khác tôi sắp nói về ...

Phân tích nghiêm ngặt

Lười biếng là về việc tránh công việc trừ khi nó cần thiết. Nếu trình biên dịch có thể xác định rằng một kết quả nhất định sẽ "luôn luôn" là cần thiết, thì nó sẽ không bận tâm đến việc lưu trữ phép tính và thực hiện nó sau này; nó sẽ chỉ thực hiện nó trực tiếp, bởi vì nó hiệu quả hơn. Điều này được gọi là "phân tích nghiêm ngặt".

Rõ ràng, gotcha là trình biên dịch không thể luôn luôn phát hiện khi một cái gì đó có thể được thực hiện nghiêm ngặt. Đôi khi bạn cần đưa ra gợi ý nhỏ cho trình biên dịch. (Tôi không biết bất kỳ cách dễ dàng nào để xác định xem phân tích nghiêm ngặt có thực hiện được những gì bạn nghĩ hay không, ngoài việc lội qua đầu ra Core.)

Nội tuyến

Nếu bạn gọi một hàm và trình biên dịch có thể cho biết bạn đang gọi hàm nào, nó có thể cố gắng "nội tuyến" hàm đó - nghĩa là, để thay thế lệnh gọi hàm bằng một bản sao của chính hàm đó. Chi phí hoạt động của một lệnh gọi thường khá nhỏ, nhưng nội tuyến thường cho phép các tối ưu hóa khác xảy ra, điều này sẽ không xảy ra nếu không, do đó, nội tuyến có thể là một chiến thắng lớn.

Các hàm chỉ được nội tuyến nếu chúng "đủ nhỏ" (hoặc nếu bạn thêm một pragma đặc biệt yêu cầu nội tuyến). Ngoài ra, các hàm chỉ có thể được nội tuyến nếu trình biên dịch có thể cho biết bạn đang gọi hàm nào. Có hai cách chính mà trình biên dịch không thể nói:

  • Nếu chức năng bạn đang gọi được chuyển đến từ một nơi khác. Ví dụ: khi filterhàm được biên dịch, bạn không thể nội tuyến vị ngữ bộ lọc, bởi vì đó là đối số do người dùng cung cấp.

  • Nếu hàm bạn đang gọi là một phương thức lớp trình biên dịch không biết loại nào có liên quan. Ví dụ, khi sumhàm được biên dịch, trình biên dịch không thể nội tuyến +hàm, bởi vì sumhoạt động với một số loại số khác nhau, mỗi loại có một +chức năng khác nhau .

Trong trường hợp sau, bạn có thể sử dụng {-# SPECIALIZE #-}pragma để tạo các phiên bản của hàm được mã hóa cứng thành một loại cụ thể. Ví dụ, {-# SPECIALIZE sum :: [Int] -> Int #-}sẽ biên dịch một phiên bản summã hóa cứng cho Intloại, có nghĩa là+ có thể được nội tuyến trong phiên bản này.

Tuy nhiên, xin lưu ý rằng sumchức năng đặc biệt mới của chúng tôi sẽ chỉ được gọi khi trình biên dịch có thể cho biết chúng tôi đang làm việc với Int. Nếu không, bản gốc, đa hình sumđược gọi. Một lần nữa, chức năng gọi thực tế là khá nhỏ. Đó là những tối ưu bổ sung mà nội tuyến có thể cho phép có lợi.

Loại bỏ phổ biến phụ

Nếu một khối mã nhất định tính toán cùng một giá trị hai lần, trình biên dịch có thể thay thế nó bằng một thể hiện duy nhất của cùng một tính toán. Ví dụ, nếu bạn làm

(sum xs + 1) / (sum xs + 2)

sau đó trình biên dịch có thể tối ưu hóa điều này thành

let s = sum xs in (s+1)/(s+2)

Bạn có thể mong đợi rằng trình biên dịch sẽ luôn luôn làm điều này. Tuy nhiên, rõ ràng trong một số tình huống, điều này có thể dẫn đến hiệu suất kém hơn, không tốt hơn, vì vậy GHC không phải lúc nào cũng làm điều này. Thành thật mà nói, tôi không thực sự hiểu các chi tiết đằng sau cái này. Nhưng điểm mấu chốt là, nếu sự chuyển đổi này quan trọng với bạn, không khó để thực hiện thủ công. (Và nếu nó không quan trọng, tại sao bạn lại lo lắng về nó?)

Biểu thức tình huống

Hãy xem xét những điều sau đây:

foo (0:_ ) = "zero"
foo (1:_ ) = "one"
foo (_:xs) = foo xs
foo (  []) = "end"

Ba phương trình đầu tiên đều kiểm tra xem danh sách này có trống không (trong số những thứ khác). Nhưng kiểm tra ba lần điều tương tự là lãng phí. May mắn thay, trình biên dịch rất dễ dàng tối ưu hóa điều này thành một số biểu thức trường hợp lồng nhau. Trong trường hợp này, một cái gì đó như

foo xs =
  case xs of
    y:ys ->
      case y of
        0 -> "zero"
        1 -> "one"
        _ -> foo ys
    []   -> "end"

Điều này là khá ít trực quan, nhưng hiệu quả hơn. Bởi vì trình biên dịch có thể dễ dàng thực hiện việc chuyển đổi này, bạn không phải lo lắng về nó. Chỉ cần viết kết hợp mẫu của bạn theo cách trực quan nhất có thể; trình biên dịch rất tốt trong việc sắp xếp lại và sắp xếp lại thứ này để làm cho nó nhanh nhất có thể.

Dung hợp

Thành ngữ Haskell tiêu chuẩn để xử lý danh sách là xâu chuỗi các chức năng lấy một danh sách và tạo ra một danh sách mới. Ví dụ kinh điển là

map g . map f

Thật không may, trong khi sự lười biếng đảm bảo bỏ qua công việc không cần thiết, tất cả các phân bổ và thỏa thuận cho hiệu suất sap danh sách trung gian. "Hợp nhất" hoặc "phá rừng" là nơi trình biên dịch cố gắng loại bỏ các bước trung gian này.

Vấn đề là, hầu hết các chức năng này là đệ quy. Nếu không có đệ quy, nó sẽ là một bài tập cơ bản để sắp xếp tất cả các hàm thành một khối mã lớn, chạy trình giả lập trên nó và tạo ra mã thực sự tối ưu không có danh sách trung gian. Nhưng vì sự đệ quy, điều đó sẽ không hiệu quả.

Bạn có thể sử dụng các {-# RULE #-}pragma để sửa một số điều này. Ví dụ,

{-# RULES "map/map" forall f g xs. map f (map g xs) = map (f.g) xs #-}

Bây giờ mỗi khi GHC thấy mapđược áp dụng map, nó sẽ đưa nó vào một danh sách duy nhất trong danh sách, loại bỏ danh sách trung gian.

Rắc rối là, điều này chỉ hoạt động để maptheo sau map. Có nhiều khả năng khác - maptheo sau filter, filtertiếp theo map, v.v. Thay vì mã hóa bằng tay một giải pháp cho mỗi trong số chúng, cái gọi là "hợp hạch dòng" đã được phát minh. Đây là một thủ thuật phức tạp hơn, mà tôi sẽ không mô tả ở đây.

Cái dài và ngắn của nó là: Đây đều là những thủ thuật tối ưu hóa đặc biệt được viết bởi lập trình viên . Bản thân GHC không biết gì về phản ứng tổng hợp; đó là tất cả trong các thư viện danh sách và các thư viện container khác. Vì vậy, những gì tối ưu hóa xảy ra phụ thuộc vào cách các thư viện container của bạn được viết (hoặc, thực tế hơn, những thư viện bạn chọn sử dụng).

Ví dụ: nếu bạn làm việc với mảng Haskell '98, đừng mong đợi bất kỳ sự hợp nhất nào. Nhưng tôi hiểu rằng vectorthư viện có khả năng hợp nhất rộng rãi. Đó là tất cả về các thư viện; trình biên dịch chỉ cung cấp RULESpragma. (Nhân tiện, nó cực kỳ mạnh mẽ. Là một tác giả thư viện, bạn có thể sử dụng nó để viết lại mã máy khách!)


Meta:

  • Tôi đồng ý với những người nói "mã đầu tiên, hồ sơ thứ hai, tối ưu hóa thứ ba".

  • Tôi cũng đồng ý với những người nói rằng "thật hữu ích khi có một mô hình tinh thần cho một quyết định thiết kế nhất định có chi phí bao nhiêu".

Cân bằng trong mọi thứ, và tất cả những điều đó ...


9
it's something guaranteed by the language specification ... work is not performed until you "do something" with the result.- không chính xác. Các đặc tả ngôn ngữ hứa hẹn ngữ nghĩa không nghiêm ngặt ; nó không hứa hẹn bất cứ điều gì về việc liệu công việc không cần thiết sẽ được thực hiện hay không.
Dan Burton

1
@DanBurton Chắc chắn. Nhưng điều đó không thực sự dễ dàng để giải thích trong một vài câu. Bên cạnh đó, vì GHC gần như là triển khai Haskell duy nhất còn tồn tại, nên thực tế là GHC lười biếng là đủ tốt cho hầu hết mọi người.
Toán học,

@MathologistsOrchid: các đánh giá đầu cơ là một ví dụ thú vị, mặc dù tôi đồng ý rằng nó có thể quá nhiều cho người mới bắt đầu.
Ben Millwood

5
Về CSE: Ấn tượng của tôi là nó được thực hiện gần như không bao giờ, bởi vì nó có thể giới thiệu chia sẻ không mong muốn và do đó là spaceleaks.
Joachim Breitner

2
Xin lỗi vì (a) không trả lời trước đây và (b) không chấp nhận câu trả lời của bạn. Cái này dài và ấn tượng, nhưng không bao gồm lãnh thổ tôi muốn. Những gì tôi muốn là một danh sách các phép biến đổi mức thấp hơn như lambda / let / case-float, chuyên môn hóa đối số kiểu / hàm tạo / hàm, phân tích mức độ nghiêm ngặt và unboxing (mà bạn đề cập), worker / Wrapper, và bất cứ điều gì khác GHC làm, cùng với các giải thích và ví dụ về mã đầu vào và đầu ra, và các ví dụ lý tưởng về hiệu ứng kết hợp của chúng và các biến đổi không xảy ra. Tôi đoán tôi nên làm một tiền thưởng?
glaebhoerl

8

Nếu một ràng buộc cho phép v = rhs chỉ được sử dụng ở một nơi, bạn có thể tin tưởng vào trình biên dịch để nội tuyến nó, ngay cả khi rhs lớn.

Ngoại lệ (gần như không phải là một trong bối cảnh của câu hỏi hiện tại) là lambdas có nguy cơ trùng lặp công việc. Xem xét:

let v = rhs
    l = \x-> v + x
in map l [1..100]

có nội tuyến v sẽ nguy hiểm vì việc sử dụng (cú pháp) sẽ chuyển thành 99 đánh giá thêm về rhs. Tuy nhiên, trong trường hợp này, bạn sẽ rất khó có thể muốn nội tuyến bằng tay. Vì vậy, về cơ bản bạn có thể sử dụng quy tắc:

Nếu bạn xem xét nội tuyến một tên chỉ xuất hiện một lần, trình biên dịch sẽ làm điều đó bằng mọi cách.

Như một hệ quả hạnh phúc, sử dụng một ràng buộc cho phép đơn giản để phân tách một tuyên bố dài (với hy vọng đạt được sự rõ ràng) về cơ bản là miễn phí.

Điều này xuất phát từ Community.haskell.org/~simonmar/ con / line.pdf bao gồm rất nhiều thông tin về nội tuyế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.