我目前正在尝试提高F#程序的性能,使其与C#等效。该程序确实将滤镜数组应用于像素缓冲区。对内存的访问始终使用指针来完成。

这是应用于图像每个像素的C#代码:

unsafe private static byte getPixelValue(byte* buffer, double* filter, int filterLength, double filterSum)
{
    double sum = 0.0;
    for (int i = 0; i < filterLength; ++i)
    {
        sum += (*buffer) * (*filter);
        ++buffer;
        ++filter;
    }

    sum = sum / filterSum;

    if (sum > 255) return 255;
    if (sum < 0) return 0;
    return (byte) sum;
}

F#代码如下所示,所需时间是C#程序的三倍:
let getPixelValue (buffer:nativeptr<byte>) (filterData:nativeptr<float>) filterLength filterSum : byte =

    let rec accumulatePixel (acc:float) (buffer:nativeptr<byte>) (filter:nativeptr<float>) i =
        if i > 0 then
            let newAcc = acc + (float (NativePtr.read buffer) * (NativePtr.read filter))
            accumulatePixel newAcc (NativePtr.add buffer 1) (NativePtr.add filter 1) (i-1)
        else
            acc

    let acc = (accumulatePixel 0.0 buffer filterData filterLength) / filterSum

    match acc with
        | _ when acc > 255.0 -> 255uy
        | _ when acc < 0.0 -> 0uy
        | _ -> byte acc

在F#中使用可变变量和for循环的结果与使用递归的速度相同。将所有项目配置为在“代码优化”处于打开状态的 Release模式下运行。

如何提高F#版本的性能?

编辑:

瓶颈似乎在(NativePtr.get buffer offset)中。如果我将此代码替换为固定值,并且还将C#版本中的相应代码替换为固定值,则两个程序的速度大致相同。实际上,在C#中,速度根本没有改变,但是在F#中,速度却有很大的不同。

可以改变这种行为,还是可以将其深深 Root 于F#的体系结构中?

编辑2:

我再次重构了代码以使用for循环。执行速度保持不变:
let mutable acc <- 0.0
let mutable f <- filterData
let mutable b <- tBuffer
for i in 1 .. filter.FilterLength do
    acc <- acc + (float (NativePtr.read b)) * (NativePtr.read f)
    f <- NativePtr.add f 1
    b <- NativePtr.add b 1

如果我比较使用(NativePtr.read b)的版本和另一个版本相同的IL代码,不同之处在于它使用固定值111uy而不是从指针读取它,则IL代码中仅以下几行发生了变化:
111uy具有IL-code ldc.i4.s 0x6f(0.3秒)
(NativePtr.read b)具有IL代码行ldloc.s bldobj uint8(1.4秒)

为了进行比较:C#在0.4秒内进行了过滤。

从图像缓冲区读取数据时,读取过滤器不会影响性能,这一事实在某种程度上令人困惑。在过滤图像的一行之前,我先将该行复制到具有一行长度的缓冲区中。这就是为什么读取操作不会散布在整个图像上,而是在此缓冲区内的原因,该缓冲区的大小约为800字节。

最佳答案

如果我们看一下内部循环的实际IL代码,该循环遍历C#编译器生成的并行两个缓冲区(相关部分):

L_0017: ldarg.0
L_0018: ldc.i4.1
L_0019: conv.i
L_001a: add
L_001b: starg.s buffer
L_001d: ldarg.1
L_001e: ldc.i4.8
L_001f: conv.i
L_0020: add

和F#编译器:
L_0017: ldc.i4.1
L_0018: conv.i
L_0019: sizeof uint8
L_001f: mul
L_0020: add
L_0021: ldarg.2
L_0022: ldc.i4.1
L_0023: conv.i
L_0024: sizeof float64
L_002a: mul
L_002b: add

我们将注意到,尽管C#代码仅使用add运算符,而F#同时需要muladd。但是显然,在每一步中,我们只需要增加指针(分别增加“sizeof byte”和“sizeof float”的值),而无需计算地址(addrBase +(sizeof byte))F#mul是不必要的(它总是乘以1)。

原因是C#为指针定义了++运算符,而F#仅提供了add : nativeptr<'T> -> int -> nativeptr<'T>运算符:
[<NoDynamicInvocation>]
let inline add (x : nativeptr<'a>) (n:int) : nativeptr<'a> = to_nativeint x + nativeint n * (# "sizeof !0" type('a) : nativeint #) |> of_nativeint

因此,它并不是在F#中“ Root ”的,只是module NativePtr缺少incdec函数。

顺便说一句,我怀疑如果将参数作为数组而不是原始指针传递,则上述示例可能会以更简洁的方式编写。

更新:

以下代码的速度是否也只有1%的提高(它似乎与C#IL生成的速度非常相似):
let getPixelValue (buffer:nativeptr<byte>) (filterData:nativeptr<float>) filterLength filterSum : byte =

    let rec accumulatePixel (acc:float) (buffer:nativeptr<byte>) (filter:nativeptr<float>) i =
        if i > 0 then
            let newAcc = acc + (float (NativePtr.read buffer) * (NativePtr.read filter))
            accumulatePixel newAcc (NativePtr.ofNativeInt <| (NativePtr.toNativeInt buffer) + (nativeint 1)) (NativePtr.ofNativeInt <| (NativePtr.toNativeInt filter) + (nativeint 8)) (i-1)
        else
            acc

    let acc = (accumulatePixel 0.0 buffer filterData filterLength) / filterSum

    match acc with
        | _ when acc > 255.0 -> 255uy
        | _ when acc < 0.0 -> 0uy
        | _ -> byte acc

另一个想法:它可能还取决于您的测试对getPixelValue的调用次数(F#将此函数分为两个方法,而C#则将其分为一个)。

您是否可以在此处发布测试代码?

关于数组-我希望代码至少更加简洁(而不是unsafe)。

更新2:

看起来这里的实际瓶颈是byte->float转换。

C#:
L_0003: ldarg.1
L_0004: ldind.u1
L_0005: conv.r8

F#:
L_000c: ldarg.1
L_000d: ldobj uint8
L_0012: conv.r.un
L_0013: conv.r8

由于某些原因,F#使用以下路径:byte->float32->float64而C#仅执行byte->float64。不确定为什么会这样,但是通过以下hack,我的F#版本在gradbot测试示例上以与C#相同的速度运行(顺便说一句,感谢 gradbot 进行的测试!):
let inline preadConvert (p : nativeptr<byte>) = (# "conv.r8" (# "ldobj !0" type (byte) p : byte #) : float #)
let inline pinc (x : nativeptr<'a>) : nativeptr<'a> = NativePtr.toNativeInt x + (# "sizeof !0" type('a) : nativeint #) |> NativePtr.ofNativeInt

let rec accumulatePixel_ed (acc, buffer, filter,  i) =
        if i > 0 then
            accumulatePixel_ed
                (acc + (preadConvert buffer) * (NativePtr.read filter),
                (pinc buffer),
                (pinc filter),
                (i-1))
        else
            acc

结果:
    adrian 6374985677.162810 1408.870900 ms
   gradbot 6374985677.162810 1218.908200 ms
        C# 6374985677.162810 227.832800 ms
 C# Offset 6374985677.162810 224.921000 ms
   mutable 6374985677.162810 1254.337300 ms
     ed'ka 6374985677.162810 227.543100 ms

最新更新
事实证明,即使没有任何骇客,我们也可以达到相同的速度:
let rec accumulatePixel_ed_last (acc, buffer, filter,  i) =
        if i > 0 then
            accumulatePixel_ed_last
                (acc + (float << int16 <| NativePtr.read buffer) * (NativePtr.read filter),
                (NativePtr.add buffer 1),
                (NativePtr.add filter 1),
                (i-1))
        else
            acc

我们需要做的就是将byte转换为int16,然后转换为float。这样,将避免使用“昂贵”的conv.r.un指令。

PS来自“prim-types.fs”的相关转换代码:
let inline float (x: ^a) =
    (^a : (static member ToDouble : ^a -> float) (x))
     when ^a : float     = (# "" x  : float #)
     when ^a : float32   = (# "conv.r8" x  : float #)
     // [skipped]
     when ^a : int16     = (# "conv.r8" x  : float #)
     // [skipped]
     when ^a : byte       = (# "conv.r.un conv.r8" x  : float #)
     when ^a : decimal    = (System.Convert.ToDouble((# "" x : decimal #)))

10-08 11:59