На які оптимізації можна очікувати надійної роботи GHC?


183

У GHC є багато оптимізацій, які він може виконати, але я не знаю, що вони є, ні наскільки вони можуть бути виконані та за яких обставин.

Моє запитання: яке перетворення я можу очікувати, що воно буде застосовуватися кожного разу, або майже так? Якщо я дивлюся на фрагмент коду, який буде виконуватися (оцінюватися) часто, і моя перша думка - "хм, можливо, я мушу оптимізувати це", в яких випадках має бути моя друга думка, "навіть не думай про це" GHC отримав це "?

Я читав статтю Fusion Fusion: From Lists to Streams to Nothing at наогул , і техніка, яку вони використовували для переписування обробки списків у іншу форму, яку звичайні оптимізації GHC потім надійно оптимізували б у прості петлі, була для мене новаторською. Як я можу визначити, коли власні програми мають право на оптимізацію?

У посібнику з GHC є деяка інформація , але вона лише проходить частину шляху до відповіді на питання.

EDIT: Я починаю баунті. Що мені хотілося б - це перелік нижчих рівнів перетворень, таких як лямбда / хай / плаваючий на регістр, спеціалізація аргументів типу / конструктора / функцій, аналіз суворості та розпакування, працівник / обгортка та все інше, що є важливим для GHC, що я залишив разом із поясненнями та прикладами вхідного та вихідного коду та в ідеалі - ілюстрацій ситуацій, коли загальний ефект перевищує суму його частин. І в ідеалі деякі згадки про те, коли трансформації не будутьтрапляються. Я не сподіваюсь на нове пояснення кожного перетворення, пару речень та вбудованих однолінійних кодів прикладів може бути достатньо (або посилання, якщо це не двадцять сторінок наукового документу), поки велика картина ясно до кінця. Я хочу мати змогу подивитися на фрагмент коду і вміти добре здогадуватися про те, чи буде він складатись до жорсткого циклу, чи чому ні, або що мені доведеться змінити, щоб це зробити. (Мене тут не так цікавлять такі великі оптимізаційні рамки, як потоковий синтез (я просто прочитав статтю про це); більше в тому знанні, яке мають люди, які пишуть ці рамки.)


10
Це найдостойніше питання. Написати гідну відповідь ... хитро.
MathematicalOrchid

1
Справді вдалий вихідний пункт: aosabook.org/en/ghc.html
Габріель Гонсалес

7
Будь-якою мовою, якщо ваша перша думка - "можливо, я повинен це оптимізувати", ваша друга думка повинна бути "Я перегляну її першою".
Джон Л

4
Хоча знання, яке ви шукаєте, є корисним, і тому це все-таки хороше питання, я думаю, що вам справді краще служити, намагаючись зробити якомога менше оптимізації. Напишіть , що ви маєте в виду, і тільки тоді , коли стає очевидним , що вам потрібно , то думати про те , щоб код менш простим для виконання. Замість того, щоб дивитись на код і думати: "це буде виконуватися часто, можливо, я мушу його оптимізувати", це має бути тільки тоді, коли ви спостерігаєте, як код працює занадто повільно, ви думаєте, "я повинен з’ясувати, що виконується часто, і оптимізувати це" .
Бен

14
Я повністю сподівався, що ця частина закликає заклики "профайлювати це!" :). Але я здогадуюсь, що інша сторона монети є, якщо я профайлюю її, і вона повільна, можливо, я можу переписати або просто змінити її у форму, яка все ще на високому рівні, але GHC може краще оптимізувати, замість того, щоб власноруч оптимізувати її? Для чого потрібні такі самі знання. І якби у мене були ці знання в першу чергу, я міг би врятувати собі цикл редагування профілю.
glaebhoerl

Відповіді:


110

Ця сторінка GHC Trac також досить добре пояснює пропуски. На цій сторінці пояснюється замовлення на оптимізацію, хоча, як і більшість Trac Wiki, воно застаріло.

Для конкретики найкраще зробити, мабуть, подивитися, як складається конкретна програма. Найкращий спосіб побачити, які оптимізації проводяться, - це складання програми докладно, використовуючи -vпрапор. Взявши за приклад першу частину Haskell, яку я міг знайти на своєму комп’ютері:

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

Дивлячись з першого *** Simplifier:на останній, де відбуваються всі етапи оптимізації, ми бачимо досить багато.

Перш за все, Simplifier працює між майже всіма фазами. Це значно спрощує написання багатьох пропусків. Наприклад, застосовуючи багато оптимізацій, вони просто створюють правила перезапису для розповсюдження змін, а не робити це вручну. Спрощувач включає низку простих оптимізацій, включаючи вбудовування та синтез. Основне обмеження цього, що мені відомо, полягає в тому, що GHC відмовляється вбудовувати рекурсивні функції, і що речі повинні бути названі правильно, щоб синтез працював.

Далі ми бачимо повний перелік усіх проведених оптимізацій:

  • Спеціалізуйтеся

    Основна ідея спеціалізації полягає у усуненні поліморфізму та перевантаження шляхом визначення місць, де викликається функція, та створення версій функції, які не є поліморфними - вони характерні для типів, з якими вони викликаються. Ви також можете сказати компілятору зробити це з SPECIALISEпрагмою. Як приклад, візьміть функціональну функцію:

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

    Оскільки компілятор не знає жодних властивостей множення, яке слід використовувати, він не може оптимізувати це взагалі. Якщо, однак, він бачить, що він використовується на Int, тепер може створити нову версію, що відрізняється лише типом:

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

    Далі, правила, згадані нижче, можуть запустити, і ви закінчите щось, що працює над unboxed Ints, що набагато швидше, ніж оригінал. Інший спосіб розглянути спеціалізацію - часткове застосування у словниках класів типів та змінних типів.

    Джерело тут має навантаження нот в ньому.

  • Попливте

    EDIT: Я, мабуть, неправильно зрозумів це раніше. Моє пояснення повністю змінилося.

    Основна ідея цього полягає в переміщенні обчислень, які не повинні повторюватися поза функціями. Наприклад, припустимо, у нас це було:

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

    У вищевказаній лямбда щоразу, коли функція викликається, yперераховується. Кращою функцією, яка випливає, є

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

    Для полегшення процесу можуть застосовуватися інші перетворення. Наприклад, це відбувається:

     \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

    Знову повторне обчислення зберігається.

    Джерело дуже читається в цьому випадку.

    На даний момент пов'язки між двома сусідніми лямбдами не плавають. Наприклад, цього не відбувається:

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

    збираюся

     \x -> let t = x+x in \y -> ...
  • Попливте всередину

    Цитуючи вихідний код,

    Основна мета - floatInwardsце плавання у гілки справи, щоб ми не виділяли речі, зберігали їх у стеку, а потім виявляли, що вони не потрібні у вибраній гілці.

    Наприклад, припустимо, у нас був такий вираз:

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

    Якщо vоцінювати False, то, виділяючи x, що, мабуть, є великим загрозою, ми втратили час і простір. Плаваючий всередину виправляє це, створюючи це:

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

    , який згодом замінюється на спрощувач на

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

    Цей документ , хоча висвітлює інші теми, дає досить чітке вступ. Зауважте, що незважаючи на їхні назви, плаваючий та випливаючий не потрапляють у нескінченний цикл з двох причин:

    1. Потоп у плавках дає змогу caseвисловлюватись, тоді як плаваючий - має справу з функціями.
    2. Існує фіксований порядок пропусків, тому вони не повинні чергуватися нескінченно.

  • Аналіз попиту

    Аналіз попиту або аналіз суворості - це не менша трансформація, а більше, як випливає з назви, перепуску для збору інформації. Компілятор знаходить функції, які завжди оцінюють їхні аргументи (або, принаймні, деякі з них), і передає ці аргументи, використовуючи call-by-value, а не call-by-need. Оскільки ви ухиляєтесь від накладних гронів, це часто набагато швидше. Багато проблем з продуктивністю в Haskell виникають через те, що цей пропуск не працює, або код просто не є досить суворим. Простим прикладом є різниця між використанням foldr, foldlіfoldl'підсумувати список цілих чисел - перше викликає переповнення стека, друге викликає переповнення купи, а останнє працює нормально, через строгість. Це, мабуть, найпростіший для розуміння і найкраще задокументований з усіх цих. Я вважаю, що поліморфізм та код CPS часто перемагають це.

  • Робоча обгортка зв’язує

    Основна ідея перетворення робітника / обгортки полягає в тому, щоб зробити тугий цикл на простій структурі, перетворюючи на та з цієї структури на кінцях. Наприклад, візьмемо цю функцію, яка обчислює факторіал числа.

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

    Використовуючи визначення IntGHC, ми маємо

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

    Зауважте, як висвітлюється код у I#s? Ми можемо їх видалити, зробивши це:

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

    Хоча цей конкретний приклад міг бути зроблений і SpecConstr, перетворення працівника / обгортки є дуже загальним у тому, що він може зробити.

  • Поширений підвираз

    Це ще одна дійсно проста оптимізація, яка є дуже ефективною, як аналіз строгості. Основна ідея полягає в тому, що якщо у вас є два однакових вирази, вони матимуть однакове значення. Наприклад, якщо fibце калькулятор чисел Фібоначчі, CSE перетвориться

    fib x + fib x

    в

    let fib_x = fib x in fib_x + fib_x

    що розрізає обчислення навпіл. На жаль, це може періодично заважати іншим оптимізаціям. Інша проблема полягає в тому, що два вирази повинні бути в одному місці і що вони повинні бути синтаксично однаковими, а не однаковими за значенням. Наприклад, CSE не запустить наступний код без купки вбудовування:

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

    Однак якщо ви компілюєте через llvm, ви можете отримати частину цього в поєднанні, завдяки пропуску про глобальну нумерацію цінностей.

  • Визволити справу

    Це здається жахливо задокументованим перетворенням, окрім того, що це може спричинити вибух коду. Ось переформатована (і трохи перероблена) версія маленької документації, яку я знайшов:

    Цей модуль переходить Coreі шукає caseвільні змінні. Критерій: якщо caseна маршруті до рекурсивного виклику є вільна змінна, то рекурсивний виклик замінюється на розгортається. Наприклад, в

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

    внутрішня fзаміна. робити

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

    Зверніть увагу на необхідність затінення. Спрощуючи, ми отримуємо

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

    Це кращий код, тому що aвін вільний всередині letrec, а не потребує проекції з v. Зауважимо, що це стосується вільних змінних , на відміну від SpecConstr, який стосується аргументів відомої форми.

    Дивіться нижче для отримання додаткової інформації про SpecConstr.

  • SpecConstr - це перетворює програми типу

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

    в

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

    В якості розширеного прикладу візьміть таке визначення last:

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

    Ми спочатку перетворюємо його на

    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

    Далі працює спрощувач, і ми маємо

    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

    Зауважте, що програма зараз швидша, оскільки ми не раз боксуємо і розблоковуємо передню частину списку. Також зауважте, що вкладення є надзвичайно важливим, оскільки дозволяє фактично використовувати нові, більш ефективні визначення, а також покращує рекурсивні визначення.

    SpecConstr контролюється рядом евристики. Згадані в роботі такі:

    1. Лямбди явні, а аріть є a.
    2. Права сторона "достатньо мала", щось контролюється прапором.
    3. Функція є рекурсивною, а спеціалізований виклик використовується в правій частині.
    4. Усі аргументи функції присутні.
    5. Принаймні один з аргументів - це програма конструктора.
    6. Цей аргумент аналізується на прикладі десь у функції.

    Однак евристика майже напевно змінилася. Насправді у статті згадується альтернативний шостий евристичний:

    Спеціалізується на аргументі , xтільки якщо xбуде тільки уважно вивчено case, і не передаються звичайної функції, або повертаються як частина результату.

Це був дуже маленький файл (12 рядків) і, можливо, не викликав таких оптимізацій (хоча, я думаю, це все зробив) Це також не говорить вам про те, чому він обрав ці проходи і чому він їх в такому порядку.


Тепер ми кудись потрапляємо! Коментарі: Ви, здається, маєте відсічне речення у частині про Спеціалізація. Я не бачу сенсу відпливу: для чого це? Як він вирішує, плавати в або виходити (чому він не потрапляє в цикл)? У мене склалося враження , що звідки - то GHC не робив CSE взагалі , але , по- видимому , що помилилася. Я відчуваю, що заблукаю в деталях, а не бачу великої картини ... тема ще складніша, ніж я думав. Можливо, моє запитання неможливе, і просто немає способу здобути цю інтуїцію, окрім тонни досвіду чи самої роботи над GHC?
glaebhoerl

Ну, я не знаю, але я ніколи не працював над GHC, так що ви повинні мати можливість зрозуміти деяку інтуїцію.
gereeter

Я виправив згадані вами проблеми.
gereeter

1
Крім того, щодо великої картини, на мою думку, насправді не існує. Коли я хочу здогадатися, які оптимізації будуть виконані, я спускаюсь із контрольного списку. Потім я роблю це ще раз, щоб побачити, як кожен прохід змінить речі. І знову. По суті, я граю компілятор. Єдина відома мені схема оптимізації, яка насправді має "велику картину", - це суперкомпіляція.
gereeter

1
Що ви маєте на увазі під "правильні назви, щоб синтез працював" точно?
Вінсент Беффара

65

Лінь

Це не "оптимізація компілятора", але це щось гарантоване специфікацією мови, тому ви завжди можете розраховувати на те, що це станеться. По суті це означає, що робота не виконується, поки ви не зробите щось із результатом. (Якщо ви не зробите одну з кількох речей, щоб навмисно вимкнути лінь.)

Це, очевидно, сама по собі тема, і ТАК вже багато питань і відповідей на неї.

За моїм обмеженим досвідом, занадто лінивий або занадто суворий код має значно більші штрафи за продуктивність (у часі та просторі), ніж будь-який інший матеріал, про який я говорю ...

Аналіз строгості

Лінь - це уникнути роботи, якщо це не потрібно. Якщо компілятор може визначити, що певний результат буде "завжди" потрібен, він не буде турбуватися зберігати обчислення і виконувати його пізніше; він буде виконувати це безпосередньо, тому що це більш ефективно. Це так званий "аналіз строгості".

Очевидно, що компілятор не завжди може виявити, коли щось можна зробити суворим. Іноді потрібно давати компілятору невеликі підказки. (Мені невідомий будь-який простий спосіб визначити, чи зробив аналіз суворості те, що, на вашу думку, має, окрім того, як пробиратися через основні результати).

Вкладиш

Якщо ви викликаєте функцію, і компілятор може сказати, яку функцію ви викликаєте, вона може спробувати "вбудувати" цю функцію - тобто замінити виклик функції на копію самої функції. Накладні витрати функціонального виклику, як правило, досить невеликі, але вбудовування часто дає можливість здійснити інші оптимізації, які б не сталися інакше, тому вбудовування може бути великим виграшем.

Функції вкладені лише в тому випадку, якщо вони "достатньо малі" (або якщо ви додасте прагму, яка спеціально просить вбудувати). Також функції можна вказувати лише тоді, коли компілятор може сказати, яку функцію ви викликаєте. Є два основні способи, через які компілятор не міг сказати:

  • Якщо функція, яку ви телефонуєте, передається з іншого місця. Наприклад, коли filterкомпілюється функція, ви не можете вбудувати присудок фільтра, оскільки це аргумент, що надається користувачем.

  • Якщо функція, яку ви викликаєте, це метод класу, і компілятор не знає, для якого типу йдеться. Наприклад, коли sumкомпілюється функція, компілятор не може вбудувати +функцію, оскільки sumпрацює з декількома типами чисел, кожен з яких має іншу +функцію.

В останньому випадку ви можете використовувати {-# SPECIALIZE #-}прагму для створення версій функції, які жорстко закодовані для певного типу. Наприклад, {-# SPECIALIZE sum :: [Int] -> Int #-}складе версію з sumжорстким кодом для Intтипу, тобто значення, яке +може бути окреслено в цій версії.

Зауважте, що наша нова спеціальна sumфункція буде викликана лише тоді, коли компілятор може сказати, що ми працюємо Int. Інакше sumназивається оригінальний, поліморфний . Знову ж таки, фактичний накладний виклик функції досить невеликий. Це додаткові оптимізації, які вкладення може дати, які вигідні.

Поширене усунення субдекспресії

Якщо певний блок коду обчислює одне і те ж значення вдвічі, компілятор може замінити його на один екземпляр одного і того ж обчислення. Наприклад, якщо ви робите

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

тоді компілятор може оптимізувати це до

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

Ви можете очікувати, що компілятор завжди це зробить. Однак, мабуть, у деяких ситуаціях це може призвести до погіршення продуктивності, а не до кращого, тому GHC не завжди робить це. Чесно кажучи, я не дуже розумію деталей цього. Але суть полягає в тому, що якщо ця трансформація важлива для вас, це не важко зробити це вручну. (А якщо це не важливо, чому ви переживаєте про це?)

Виразні вирази

Розглянемо наступне:

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

Перші три рівняння перевіряють, чи список не порожній (серед іншого). Але тричі перевіряти те саме, що марно. На щастя, компілятору дуже легко оптимізувати це в декілька вкладених виразів регістру. У цьому випадку щось подібне

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

Це досить менш інтуїтивно, але більш ефективно. Оскільки компілятор може легко здійснити це перетворення, вам не доведеться турбуватися про це. Просто запишіть відповідність шаблону найбільш інтуїтивно зрозумілим способом; компілятор дуже добре переупорядковує та переставляє це, щоб зробити це якомога швидше.

Злиття

Стандартна ідіома Haskell для обробки списку полягає в з’єднанні функцій, які беруть один список і створюють новий список. Канонічний приклад буття

map g . map f

На жаль, хоча лінь гарантує пропускання непотрібної роботи, всі розподіли та розстановки для проміжного списку сап-роботи. "Злиття" або "вирубка лісів" - це коли компілятор намагається усунути ці проміжні кроки.

Проблема в тому, що більшість цих функцій є рекурсивними. Без рекурсії було б елементарною вправою складати всі функції в один великий код коду, запустити спрощувач над ним і створити дійсно оптимальний код без проміжних списків. Але через рекурсію це не вийде.

Ви можете використовувати {-# RULE #-}прагми, щоб виправити щось із цього. Наприклад,

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

Тепер, кожного разу, коли GHC бачить mapзастосований до mapнього, він стискає його в один прохід над списком, виключаючи проміжний список.

Проблема полягає в тому, що це працює лише за mapцим map. Існує багато інших можливостей - mapслідуючи за ними filter, filterслідуючи і mapт. Д. Замість того, щоб вручну кодувати рішення для кожного з них, було винайдено так зване "потокове синтез". Це більш складна хитрість, яку я тут не опишу.

Довгий і короткий: Це все спеціальні прийоми оптимізації, написані програмістом . Сам GHC нічого не знає про синтез; це все в бібліотеках списків та інших бібліотеках контейнерів. Тож, які оптимізації відбудуться, залежить від того, як записані ваші бібліотеки контейнерів (або, що більш реально, які бібліотеки ви вирішили використовувати).

Наприклад, якщо ви працюєте з масивами Haskell '98, не чекайте будь-якого злиття. Але я розумію, що vectorбібліотека має широкі можливості синтезу. Це все про бібліотеки; компілятор просто надає RULESпрагму. (До речі, це надзвичайно потужно. Як автор бібліотеки, ви можете використовувати його для перезапис коду клієнта!)


Мета:

  • Я погоджуюсь з тим, що люди говорять "код по-перше, профіль другий, оптимізуйте третє".

  • Я також погоджуюся з людьми, які говорять: "корисно мати ментальну модель, скільки коштує дане дизайнерське рішення".

Баланс у всьому і всьому тому ...


9
it's something guaranteed by the language specification ... work is not performed until you "do something" with the result.- не зовсім. Мовна специфіка обіцяє не сувору семантику ; це нічого не обіцяє про те, чи буде виконуватися зайва робота чи ні.
Ден Бертон

1
@DanBurton Звичайно. Але це не дуже просто пояснити кількома реченнями. Крім того, оскільки GHC є чи не єдиним існуючим впровадженням Haskell, той факт, що GHC лінивий, є достатньо хорошим для більшості людей.
Математична

@MathematicalOrchid: спекулятивні оцінки - це цікавий контрприклад, хоча я погоджуюся, що для початківця це, мабуть, занадто багато.
Бен Мілвуд

5
Про CSE: Моє враження, що це робиться майже ніколи, оскільки він може вводити небажаний обмін, а значить, і пробіли.
Йоахім Брейтнер

2
Вибачте за: (а) не відповів раніше і (б) не прийняв вашу відповідь. Що довге та вражаюче, але не охоплює потрібну мені територію. Мені хотілося б - перелік нижчих рівнів перетворень, таких як лямбда / хай / плаваючий на регістр, спеціалізація аргументів типу / конструктора / функції, аналіз строгості та розпакування (про що ви згадуєте), працівник / обгортка та все, що ще робить GHC з поясненнями та прикладами вхідного та вихідного коду, а в ідеалі - прикладами їх комбінованого ефекту та тими, де перетворень не відбувається. Думаю, я повинен зробити щедрість?
glaebhoerl

8

Якщо нехай зв'язування v = rhs використовується лише в одному місці, ви можете розраховувати на компілятор, щоб вбудувати його, навіть якщо rhs є великим.

Виняток (який майже не є одним із контекстів цього питання) - це лямбди, що ризикують дублюванням роботи. Поміркуйте:

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

там вбудовування v було б небезпечним, оскільки одне (синтаксичне) використання означало б 99 додаткових оцінок резус. Однак у цьому випадку ви навряд чи захочете вбудувати його вручну. Тому по суті ви можете використовувати правило:

Якщо ви вирішите вставити ім'я, яке з’являється лише один раз, компілятор все одно зробить це.

Як щасливий наслідок, використання пускового зв’язку просто для розкладання довгого висловлювання (з надією на отримання ясності) по суті вільне.

Це походить від community.haskell.org/~simonmar/papers/inline.pdf, який включає в себе набагато більше інформації про вбудовування.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.