Почему выражения последовательности для массивов настолько медленны в F #?
Код:
#time "on"
let newVector = [|
for v in 1..10000000 ->
v |]
let newVector2 =
let a = Array.zeroCreate 10000000
for v in 1..10000000 do
a.[v-1] <- v
a
let newVector3 =
let a = System.Collections.Generic.List() // do not set capacity
for v in 1..10000000 do
a.Add(v)
a.ToArray()
дает время в FSI:
--> Timing now on
>
Real: 00:00:01.121, CPU: 00:00:01.156, GC gen0: 4, gen1: 4, gen2: 4
val newVector : int [] = [|1; 2; 3; 4; ...|]
>
Real: 00:00:00.024, CPU: 00:00:00.015, GC gen0: 0, gen1: 0, gen2: 0
val newVector2 : int32 [] = [|1; 2; 3; 4; ...|]
>
Real: 00:00:00.173, CPU: 00:00:00.156, GC gen0: 2, gen1: 2, gen2: 2
val newVector3 : int32 [] = [|1; 2; 3; 4; ...|]
Не большая разница в автономном приложении, режиме выпуска, без отладки, 5 пробегов в среднем:
- Выражение последовательности: 425
- Предоставить: 13
- Список: 80
Третий метод не знает исходную емкость, но все же почти в 7 раз быстрее. Почему выражения последовательности для массивов настолько медленны в F #?
Update:
let seq = seq { for v in 1..10000000 do yield v }
let seqArr = seq |> Seq.toArray
Real: 00:00:01.060, CPU: 00:00:01.078, GC gen0: 2, gen1: 2, gen2: 2
let newVector4 =
let a = System.Collections.Generic.List() // do not set capacity
for v in seq do
a.Add(v)
a.ToArray()
Real: 00:00:01.119, CPU: 00:00:01.109, GC gen0: 1, gen1: 1, gen2: 1
open System.Linq
let newVector5 = seq.ToArray()
Real: 00:00:00.969, CPU: 00:00:00.968, GC gen0: 0, gen1: 0, gen2: 0
это дает то же время, что и первое, и не зависит от GC. Таким образом, реальный вопрос, почему перечисление 1..10000000
настолько медленнее, что цикл for
во втором и третьем случаях?
Обновление 2
open System
open System.Linq
open System.Collections.Generic
let newVector5 = seq.ToArray()
let ie count =
{ new IEnumerable<int> with
member x.GetEnumerator(): Collections.IEnumerator = x.GetEnumerator() :> Collections.IEnumerator
member x.GetEnumerator(): IEnumerator<int> =
let c = ref 0
{ new IEnumerator<int> with
member y.MoveNext() =
if !c < count then
c := !c + 1
true
else false
member y.Current with get() = !c + 1
member y.Current with get() = !c + 1 :> obj
member y.Dispose() = ()
member y.Reset() = ()
}
}
let newVector6 =
let a = System.Collections.Generic.List() // do not set capacity
for v in ie 10000000 do
a.Add(v)
a.ToArray()
Real: 00:00:00.185, CPU: 00:00:00.187, GC gen0: 1, gen1: 1, gen2: 1
Ручная реализация IEnumerable эквивалентна циклу for
. Интересно, почему расширение lo..hi
должно быть намного медленнее для общего случая. Он может быть реализован путем перегрузки метода, по крайней мере, для наиболее распространенных типов.
Ответы
Ответ 1
В таких случаях я всегда проверяю сгенерированный код, используя один из многих хороших декомпиляторов в .NET.
let explicitArray () =
let a = Array.zeroCreate count
for v in 1..count do
a.[v-1] <- v
a
Это скомпилировано в эквивалентный С#:
public static int[] explicitArray()
{
int[] a = ArrayModule.ZeroCreate<int>(10000000);
for (int v = 1; v < 10000001; v++)
{
a[v - 1] = v;
}
return a;
}
Это примерно так же эффективно.
let arrayExpression () =
[| for v in 1..count -> v |]
Это, с другой стороны, становится:
public static int[] arrayExpression()
{
return SeqModule.ToArray<int>(new [email protected](0, null, 0, 0));
}
Этот эквивалент:
let arrayExpression () =
let e = seq { for v in 1..count -> v }
let a = List() // do not set capacity
for v in e do
a.Add(v)
a.ToArray()
Когда выполняется итерация по seq
(псевдоним для IEnumerable
), сначала вызывается MoveNext
, затем Current
. Это виртуальные вызовы, которые JIT: er редко могут встроить. При проверке кода сборки JIT: ed мы видим следующее:
mov rax,qword ptr [rbp+10h]
cmp byte ptr [rax],0
mov rcx,qword ptr [rbp+10h]
lea r11,[7FFC07830030h]
# virtual call .MoveNext
call qword ptr [7FFC07830030h]
movzx ecx,al
# if .MoveNext returns false then exit
test ecx,ecx
je 00007FFC079408A0
mov rcx,qword ptr [rbp+10h]
lea r11,[7FFC07830038h]
# virtual call .Current
call qword ptr [7FFC07830038h]
mov edx,eax
mov rcx,rdi
# call .Add
call 00007FFC65C8B300
# loop
jmp 00007FFC07940863
Если сравнить это с кодом JIT: ed для кода, который использовал ResizeArray
(List
)
lea edx,[rdi-1]
mov rcx,rbx
# call .Add
call 00007FFC65C8B300
mov edx,edi
mov rcx,rbx
# call .Add
call 00007FFC65C8B300
lea edx,[rdi+1]
mov rcx,rbx
# call .Add
call 00007FFC65C8B300
lea edx,[rdi+2]
mov rcx,rbx
# call .Add
call 00007FFC65C8B300
add edi,4
# loop
cmp edi,989682h
jl 00007FFC07910384
Здесь JIT: er разворачивает цикл 4 раза здесь, и мы имеем только не виртуальные вызовы List.Add
.
Это объясняет, почему выражения массива F # медленнее, чем два других примера.
Чтобы исправить это, нужно было бы исправить оптимизатор в F #, чтобы распознать форму выражений, таких как:
seq { for v in 1..count -> v } |> Seq.toArray
И оптимизируйте их в:
let a = Array.zeroCreate count
for v in 1..count do
a.[v-1] <- v
a
Задача состоит в том, чтобы найти оптимизацию, которая является достаточно общей, чтобы быть полезной, но также не нарушает семантику F #.
Ответ 2
Для усмешек я бросил ваше понимание массива в ILDASM, чтобы посмотреть, что происходит. Я положил let
в main
и получил следующее:
.locals init ([0] int32[] newVector,
[1] class [FSharp.Core]Microsoft.FSharp.Core.FSharpFunc`2<string[],class [FSharp.Core]Microsoft.FSharp.Core.Unit> V_1,
[2] string[] V_2)
IL_0000: nop
IL_0001: ldc.i4.0
IL_0002: ldnull
IL_0003: ldc.i4.0
IL_0004: ldc.i4.0
IL_0005: newobj instance void Program/[email protected]::.ctor(int32,
class [mscorlib]System.Collections.Generic.IEnumerator`1<int32>,
int32,
int32)
IL_000a: call !!0[] [FSharp.Core]Microsoft.FSharp.Collections.SeqModule::ToArray<int32>(class [mscorlib]System.Collections.Generic.IEnumerable`1<!!0>)
IL_000f: stloc.0
Итак, создается экземпляр newVector @11, и этот класс наследуется от GeneratedSequenceBase, который сам реализует IENumerable. Это имеет смысл, потому что рядом есть вызов Seq.ToArray. Рассматривая этот класс, нет способа определить, известна ли длина последовательности, даже если она в этом случае познаваема, из-за характера IEnumerable. Это говорит мне, что это должно быть эквивалентно:
let seqArr = seq { for v in 1..10000000 do yield v } |> Seq.toArray
и выбор времени:
Real: 00:00:01.032, CPU: 00:00:01.060, GC gen0: 4, gen1: 4, gen2: 4
для финального бит удовольствия, я поставил вышеописанное понимание последовательности через ILDASM, а класс-оболочка и метод GenerateNext внутри него - инструкция для команды, идентичная пониманию массива. Поэтому я считаю, что очень безопасно заключить, что любое понимание массива формы:
let arr = [| sequence-expr |]
эквивалентен 100%:
let arr = seq { sequence-expr } |> Seq.toArray
И это намечено в документации массива F # со следующей строкой:
Вы также можете использовать выражения последовательности для создания массивов. Ниже приведен пример, который создает массив квадратов целых чисел от 1 до 10.
который действительно говорит "это последовательность, а не ее собственная вещь".