Приводит ли использование карри к снижению производительности в F #?

При написании функции, которая может принимать карри, вы можете написать ее как функцию с одним аргументом, которая возвращает функцию. Например,

let add x =
    let inner y = x + y
    inner

Так что вы можете сделать:

add 3 4

или же:

let add3 = add 3
add3 4

Мой вопрос заключается в том, что, поскольку вы возвращаете функцию, вы концептуально вызываете функцию дважды (внешняя функция и внутренняя функция). Это медленнее, чем:

let add x y = x + y

или компилятор оптимизирует вызовы add 3 4 в определении карри?

Ответ 1

let f x   = fun y -> x + y
let g x y = x + y

Анализ этих определений функций в dnSpy для оптимизированной сборки показывает, что они:

public static int f(int x, int y)
{
    return x + y;
}

public static int g(int x, int y)
{
    return x + y;
}

Это не так уж странно, потому что g на самом деле является кратким определением для f что является общим случаем. В языках F # -like функции концептуально всегда принимают одно значение, возвращающее одно значение. Значения могут быть функциями. Это легче увидеть, если один из них является функцией подписи для f и g

val f: int -> int -> int
// Actually is
// val f: int -> (int -> int)
// ie f is a function that takes a single int and returns a function that takes a single int and returns an int.

Чтобы заставить F # выполняться быстрее в .NET, физическое представление f в сборке:

public static int f(int x, int y)

Пока это более естественное представление функции F #.

public static Func<int, int> f(int x)

Будет плохо, хотя.

Обычно F # достаточно умен, чтобы избежать издержек абстракции путем оптимизации, как описано выше, и при вызове. Однако есть ситуации, когда F # не может оптимизировать для вас.

Представьте, что вы реализуете fold

let rec fold f s vs =
  match vs with
  | v::vs -> fold f (f s v) vs
  | []    -> s

Здесь F # не может полностью оптимизировать fsv. Причина в том, что f может иметь более сложную реализацию, чем приведенная выше, которая может возвращать другую функцию в зависимости от s.

Если вы посмотрите в dnSpy то заметите, что F # вызывает функцию, используя InvokeFast но это делает внутренний тест, чтобы определить, можно ли ее быстро вызвать. Затем мы делаем этот тест для каждого значения, даже если это одна и та же функция.

По этой причине иногда можно увидеть fold написанный так:

let fold f s vs =
  let f = OptimizedClosures.FSharpFunc<_, _, _>.Adapt f
  let rec loop s vs =
    match vs with
    | v::vs -> loop (f.Invoke (s, v)) vs
    | []    -> s
  loop s vs

Adapt здесь тесты перед циклом, если f действительно может быть оптимизирован, а затем возвращает эффективный адаптер. В общем случае это все еще может быть немного медленнее, но тогда именно это и задумал вызывающий.

Заметка; это потенциальное снижение производительности не происходит для простых значений функций, таких как 'T → 'U Это всегда может быть эффективно использовано.

Надеюсь это поможет.

Ответ 2

Я проверял это в LINQPad 5.

Когда оптимизация компилятора отключена, компилятор F # будет выдавать разные IL для каждого фрагмента. Другими словами, если происходит какая-либо оптимизация, она оставляется на усмотрение JITter, и вполне может быть медленнее вызывать первую форму.

Однако, когда оптимизация компилятора включена, обе формы выдают одинаковые выходные данные IL в каждом сценарии, о котором я мог подумать, чтобы проверить это. На самом деле, с обеих форм, позвонив:

add 3 4

дает IL-эквивалент жестко запрограммированного 7 с полным оптимизацией вызова функции:

ldc.i4.7

Другими словами, компилятор F # довольно тщательно подходит для оптимизации логически идентичных блоков кода.

Конечно, это не исчерпывающий ответ, и в некоторых случаях компилятор может по-разному относиться к ним.