F#图像处理性能问题

Adr*_* H. 9 performance f# image-processing

我目前正在尝试提高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;
}
Run Code Online (Sandbox Code Playgroud)

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
Run Code Online (Sandbox Code Playgroud)

在F#中使用可变变量和for循环确实会产生与使用递归相同的速度.所有项目都配置为在发布模式下运行,并启用代码优化.

如何改进F#版本的性能?

编辑:

瓶颈似乎在于(NativePtr.get buffer offset).如果我用固定值替换此代码并且还用固定值替换C#版本中的相应代码,那么两个程序的速度大致相同.实际上,在C#中,速度根本没有变化,但在F#中它会产生巨大的差异.

这种行为可能会被改变,还是深深植根于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
Run Code Online (Sandbox Code Playgroud)

如果我比较使用的版本的IL代码(NativePtr.read b)和使用固定值111uy而不是从指针读取它的另一个版本相同的另一个版本,只有IL代码中的以下行更改:

111uy有IL-Code ldc.i4.s 0x6f(0.3秒)

(NativePtr.read b)有IL-Code线 ldloc.s bldobj uint8(1.4秒)

为了比较:C#在0.4秒内完成过滤.

从图像缓冲区读取时读取过滤器不影响性能的事实确实令人困惑.在我过滤图像的一行之前,我将该行复制到一个长度为一行的缓冲区中.这就是为什么读操作不是遍布整个图像而是在这个缓冲区内的原因,该缓冲区的大小约为800字节.

Ed'*_*'ka 13

如果我们查看内部循环的实际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 
Run Code Online (Sandbox Code Playgroud)

和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
Run Code Online (Sandbox Code Playgroud)

我们会注意到,虽然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
Run Code Online (Sandbox Code Playgroud)

所以它并没有"深入"F#,只是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
Run Code Online (Sandbox Code Playgroud)

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

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

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

更新#2:

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

C#:

L_0003: ldarg.1 
L_0004: ldind.u1 
L_0005: conv.r8 
Run Code Online (Sandbox Code Playgroud)

F#:

L_000c: ldarg.1 
L_000d: ldobj uint8
L_0012: conv.r.un 
L_0013: conv.r8 
Run Code Online (Sandbox Code Playgroud)

出于某种原因,F#使用以下路径:byte->float32->float64而C#仅执行此操作byte->float64.不知道为什么会这样,但是在下面的黑客中,我的F#版本在gradbot测试样本上以与C#相同的速度运行(BTW,感谢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
Run Code Online (Sandbox Code Playgroud)

结果:

    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
Run Code Online (Sandbox Code Playgroud)

最后更新 事实证明,即使没有任何黑客,我们也可以达到相同的速度:

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
Run Code Online (Sandbox Code Playgroud)

我们所需要做的就是转换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 #))) 
Run Code Online (Sandbox Code Playgroud)