(正向)管道操作员是否可以阻止尾调用优化?

时间:2016-03-01 12:09:32

标签: f# stack-overflow tail-recursion cil tail-call-optimization

对于工作中的参数优化问题,我写了一个遗传算法来找到一些好的设置,因为蛮力解决方案是不可行的。不幸的是,当我早上回来时,大部分时间我都会看到StackOverflowException

我已经使用F#已经有一段时间了所以我知道TCO和需要带累加器参数的函数,并且通常使用该形式。

经过大量搜索后,我认为我能够找到触发异常的代码:

breedPopulation alive |> simulate (generation + 1) lastTime ewma

breedPopulation从当前alive个人中生成新一代。然后通过调用simulate开始下一轮/生成。当我看到反汇编(总noob)时,我发现了一些pop和一个ret,所以它看起来不像是对我的常规(非尾部)调用。

mov         rcx,qword ptr [rbp+10h]  
mov         rcx,qword ptr [rcx+8]  
mov         rdx,qword ptr [rbp-40h]  
cmp         dword ptr [rcx],ecx  
call        00007FFA3E4905C0  
mov         qword ptr [rbp-0F0h],rax  
mov         r8,qword ptr [rbp-0F0h]  
mov         qword ptr [rbp-80h],r8  
mov         r8,qword ptr [rbp-78h]  
mov         qword ptr [rsp+20h],r8  
mov         r8d,dword ptr [rbp+18h]  
inc         r8d  
mov         rdx,qword ptr [rbp+10h]  
mov         r9,qword ptr [rbp-20h]  
mov         rcx,7FFA3E525960h  
call        00007FFA3E4A5040  
mov         qword ptr [rbp-0F8h],rax  
mov         rcx,qword ptr [rbp-0F8h]  
mov         rdx,qword ptr [rbp-80h]  
mov         rax,qword ptr [rbp-0F8h]  
mov         rax,qword ptr [rax]  
mov         rax,qword ptr [rax+40h]  
call        qword ptr [rax+20h]  
mov         qword ptr [rbp-100h],rax  
mov         rax,qword ptr [rbp-100h]  
lea         rsp,[rbp-10h]  
pop         rsi  
pop         rdi  
pop         rbp  
ret

抛弃管道操作员并将繁殖置于正常参数位置后,拆卸是不同的。

//    simulate (generation + 1) lastTime ewma (breedPopulation alive)
mov         ecx,dword ptr [rbp+18h]  
inc         ecx  
mov         dword ptr [rbp-30h],ecx  
mov         rcx,qword ptr [rbp-20h]  
mov         qword ptr [rbp-38h],rcx  
mov         rcx,qword ptr [rbp-80h]  
mov         qword ptr [rbp-0F0h],rcx  
mov         rcx,qword ptr [rbp+10h]  
mov         rcx,qword ptr [rcx+8]  
mov         rdx,qword ptr [rbp-48h]  
cmp         dword ptr [rcx],ecx  
call        00007FFA3E4605C0  
mov         qword ptr [rbp-0F8h],rax  
mov         rax,qword ptr [rbp-0F8h]  
mov         qword ptr [rbp+30h],rax  
mov         rax,qword ptr [rbp-0F0h]  
mov         qword ptr [rbp+28h],rax  
mov         rax,qword ptr [rbp-38h]  
mov         qword ptr [rbp+20h],rax  
mov         eax,dword ptr [rbp-30h]  
mov         dword ptr [rbp+18h],eax  
nop  
jmp         00007FFA3E47585B

这绝对是短暂的,最终的jmp甚至比尾部召唤更好。

因此,我想了解是否以及为什么 |>似乎是问题,而且它确实有所作为 - 毕竟,这是多年来它第一次咬我。在什么情况下会发生什么?我们需要注意什么?

更新Guy指出我的列表不是IL而是汇编,我首先重写了这个问题。这是我发现的ILSpy

使用|>操作

查看反编译的C#,代码似乎在

之间来回跳转
internal static FSharpFunc<Types.Genome[], System.Tuple<System.Tuple<float, float>, LbpArea[]>[]> simulate@265-1(Universe x, System.Threading.ManualResetEvent pleaseStop, int generation, System.DateTime lastTime, FSharpOption<double> ewma)
{
    return new $Universe.simulate@267-2(x, pleaseStop, generation, lastTime, ewma);
}

// internal class simulate@267-2
public override System.Tuple<System.Tuple<float, float>, LbpArea[]>[] Invoke(Types.Genome[] population)
{
    LbpArea[][] array = ArrayModule.Parallel.Map<Types.Genome, LbpArea[]>(this.x.genomeToArray, population);
    FSharpFunc<System.Tuple<System.Tuple<float, float>, LbpArea[]>, float> accessFitness = this.x.accessFitness;
    System.Tuple<System.Tuple<float, float>, LbpArea[]>[] array2 = ArrayModule.Filter<System.Tuple<System.Tuple<float, float>, LbpArea[]>>(new $Universe.alive@274(accessFitness), ArrayModule.Parallel.Map<LbpArea[], System.Tuple<System.Tuple<float, float>, LbpArea[]>>(new $Universe.alive@273-1(this.x), array));
    if (array2 == null)
    {
        throw new System.ArgumentNullException("array");
    }
    System.Tuple<System.Tuple<float, float>, LbpArea[]>[] array3 = ArrayModule.SortWith<System.Tuple<System.Tuple<float, float>, LbpArea[]>>(new $Universe.alive@275-2(), array2);
    this.x.Population = array3;
    System.Tuple<System.DateTime, FSharpOption<double>> tuple = this.x.printProgress<float, LbpArea[]>(this.lastTime, this.ewma, this.generation, array3);
    System.DateTime item = tuple.Item1;
    FSharpOption<double> item2 = tuple.Item2;
    if (this.pleaseStop.WaitOne(0))
    {
        return array3;
    }
    Types.Genome[] func = this.x.breedPopulation(array3);
    return $Universe.simulate@265-1(this.x, this.pleaseStop, this.generation + 1, item, item2).Invoke(func);
}

new来电的IL中,找不到tail.操作。另一方面,Invoke读取的最后一行的IL

IL_00d3: call class [FSharp.Core]Microsoft.FSharp.Core.FSharpFunc`2<class BioID.GeneticLbp.Types/Genome[], class [mscorlib]System.Tuple`2<class [mscorlib]System.Tuple`2<float32, float32>, valuetype [BioID.Operations.Biometrics]BioID.Operations.Biometrics.LbpArea[]>[]> '<StartupCode$BioID-GeneticLbp>.$Universe'::'simulate@265-1'(class BioID.GeneticLbp.Universe, class [mscorlib]System.Threading.ManualResetEvent, int32, valuetype [mscorlib]System.DateTime, class [FSharp.Core]Microsoft.FSharp.Core.FSharpOption`1<float64>)
IL_00d8: ldloc.s 7
IL_00da: tail.
IL_00dc: callvirt instance !1 class [FSharp.Core]Microsoft.FSharp.Core.FSharpFunc`2<class BioID.GeneticLbp.Types/Genome[], class [mscorlib]System.Tuple`2<class [mscorlib]System.Tuple`2<float32, float32>, valuetype [BioID.Operations.Biometrics]BioID.Operations.Biometrics.LbpArea[]>[]>::Invoke(!0)
IL_00e1: ret

我不知道该怎么做。

没有|&gt;操作

另一个版本确实非常不同。从

开始
internal static System.Tuple<System.Tuple<float, float>, LbpArea[]>[] simulate@264(Universe x, System.Threading.ManualResetEvent pleaseStop, Unit unitVar0)
{
    FSharpFunc<int, FSharpFunc<System.DateTime, FSharpFunc<FSharpOption<double>, FSharpFunc<Types.Genome[], System.Tuple<System.Tuple<float, float>, LbpArea[]>[]>>>> fSharpFunc = new $Universe.simulate@265-2(x, pleaseStop);
    (($Universe.simulate@265-2)fSharpFunc).x = x;
    (($Universe.simulate@265-2)fSharpFunc).pleaseStop = pleaseStop;
    System.Tuple<System.Tuple<float, float>, LbpArea[]>[] population = x.Population;
    Types.Genome[] func;
    if (population != null && population.Length == 0)
    {
        func = x.lengthRandomlyIncreasing(x.laws@53.PopulationSize@);
        return FSharpFunc<int, System.DateTime>.InvokeFast<FSharpOption<double>, FSharpFunc<Types.Genome[], System.Tuple<System.Tuple<float, float>, LbpArea[]>[]>>(fSharpFunc, 0, System.DateTime.Now, null).Invoke(func);
    }
    FSharpFunc<LbpArea[], Types.Genome> arrayToGenome = x.arrayToGenome;
    func = ArrayModule.Parallel.Map<System.Tuple<System.Tuple<float, float>, LbpArea[]>, Types.Genome>(new $Universe.simulate@296-3(arrayToGenome), population);
    return FSharpFunc<int, System.DateTime>.InvokeFast<FSharpOption<double>, FSharpFunc<Types.Genome[], System.Tuple<System.Tuple<float, float>, LbpArea[]>[]>>(fSharpFunc, 0, System.DateTime.Now, null).Invoke(func);
}

它转到

// internal class simulate@265-2
public override System.Tuple<System.Tuple<float, float>, LbpArea[]>[] Invoke(int generation, System.DateTime lastTime, FSharpOption<double> ewma, Types.Genome[] population)
{
    return $Universe.simulate@265-1(this.x, this.pleaseStop, generation, lastTime, ewma, population);
}

最后

internal static System.Tuple<System.Tuple<float, float>, LbpArea[]>[] simulate@265-1(Universe x, System.Threading.ManualResetEvent pleaseStop, int generation, System.DateTime lastTime, FSharpOption<double> ewma, Types.Genome[] population)
{
    while (true)
    {
        // Playing evolution...
        if (pleaseStop.WaitOne(0))
        {
            return array3;
        }
        // Setting up parameters for next loop...
    }
    throw new System.ArgumentNullException("array");
}

TL;博士

所以当然,管道操作员的使用彻底改变了程序流程。我的猜测是两个函数之间的来回是最终导致异常的原因。

我已经阅读Tail Calls in F#但我不认为它适用于这种情况,因为我没有使用第一类函数返回单位作为值(在我的F#代码中)。

所以问题仍然存在:为什么管道操作员在这里会产生这种破坏性影响?我怎么能事先知道/我需要注意什么?

更新2:

您可以在GitHub找到该示例的简化版本。请亲自看看inline运算符|>会更改产生的IL,这不是我期望的。

在减少示例的同时,运气不错,我能够找到异常的真正来源。您可以查看branch以获取更多最小变体。毕竟,它与管道没有任何关系,但我仍然没有得到它,因为恕我直言尾递归。

但我原来的问题仍然存在。我只是添加一个更多。 :)

1 个答案:

答案 0 :(得分:8)

根据提供的最小情况,如果代码在64位的发布模式下运行,则会因堆栈溢出而失败。如果代码在32位模式下以释放模式运行,则会成功。

注意:选择32位和64位之间的选项是Prefer 32-bit,如下图所示。

增加堆栈大小将导致代码在64位的发布模式下成功。这是通过使用Thread constructor

完成的
[<EntryPoint>]
let main _ =

    let test () =
        let r = KissRandom()
        let n = r.Normal()
        Seq.item 20000 n |> printfn "%f"

    /// The greatest maximum-stack-size that should be used
    /// with the 'runWithStackFrame' function.
    let STACK_LIMIT = 16777216

    /// Run a function with a custom maximum stack size.
    /// This is necessary for some functions to execute
    /// without raising a StackOverflowException.
    let runWithCustomStackSize maxStackSize fn =
        // Preconditions
        if maxStackSize < 1048576 then
            invalidArg "stackSize" "Functions should not be executed with a \
                maximum stack size of less than 1048576 bytes (1MB)."
        elif maxStackSize > STACK_LIMIT then
            invalidArg "stackSize" "The maximum size of the stack frame should \
                not exceed 16777216 bytes (16MB)."

        /// Holds the return value of the function.
        let result = ref Unchecked.defaultof<'T>

        // Create a thread with the specified maximum stack size,
        // then immediately execute the function on it.
        let thread = System.Threading.Thread ((fun () -> result := fn()), maxStackSize)
        thread.Start ()

        // Wait for the function/thread to finish and return the result.
        thread.Join ()
        !result

    /// Runs a function within a thread which has an enlarged maximum-stack-size.
    let inline runWithEnlargedStack fn =
        runWithCustomStackSize STACK_LIMIT fn


//    test ()       // Fails with stack overflow in 64-bit mode, Release
                    // Runs successfully in 32-bit mode, Release

    runWithEnlargedStack test

    printf "Press any key to exit: "
    System.Console.ReadKey() |> ignore
    printfn ""

    0

此代码来自FSharp-logic-examples,特别是Anh-Dung Phan

虽然我没有检查根本原因,但我怀疑是因为64位项目的大小比32位项目的大小大,即使是放入的项目数量也是如此。堆栈和堆栈大小对于两个版本保持不变,项目大小增加会将堆栈所需的内存超过1兆字节限制。

TL; DR

这是一个有趣且具有启发性的问题。我很高兴被问到。

最初问题似乎与使用|>和TCO有关,因为这仍然有价值,我将其留在答案中。我还要感谢OP的回应和帮助,很高兴能帮助那些与你合作而不是反对你的人。

在下面的代码中,它是递归的并且在Visual Studio中以调试模式运行|>会导致StackOverflow。

如果它是从bin\release目录的命令行启动的,则不会导致StackOverflow。

使用Visual Studio 15社区

[<EntryPoint>]
let main argv = 

    let largeList = 
        printfn "Creating large list"
        [
            for i in 1 .. 100000000 do
                yield i
        ]

    // causes StackOverflow in Debug
    // No StackOverflow in Release
    let sum4 l =
        printfn "testing sum4"
        let rec sumInner4 l acc =
            match l with
            | h::t -> 
                let acc = acc + h
                acc |> sumInner4 t
            | [] -> acc
        sumInner4 l 0

    let result4 = sum4 largeList
    printfn "result4: %A" result4

在Visual Studio工具栏中设置Release或Debug

enter image description here

并且调试模式下项目的选项是

enter image description here

并且在发布模式下项目的选项是

enter image description here

tldr;

在测试过程中,我创建了16个不同的测试,并在调试和发布模式下构建它们,并验证它们是否运行完成或引发堆栈溢出。 16个被分解成一组4个,每个4个案例。情况1,5,9,13是否定的并产生堆栈溢出以确保可以创建堆栈溢出。情况2,6,10,14是正的,表明尾调用正在工作并且不会导致堆栈溢出。情况3,7,11,15显示尾部调用,其操作在与尾调用相同的语句中完成,并且使用|>远离测试用例的一个分解;这些工作符合预期。案例4,8,12,16使用|>并显示它何时发生并且在调试模式下不起作用,这对许多人来说可能是一个惊喜。案例1-4和9-12使用f x y形式的函数,案例8-11使用f x形式的函数,案例12-16使用{{1}形式的函数}。我最初做了前8个测试用例,但是在Keith的评论之后又做了4个不使用列表但仍使用from f x y z的函数并显示意外结果然后做了4个更多使用f x y形式的函数。

要运行测试,您必须注释掉除了计划运行的一个测试以及在调试模式下构建一次的所有测试,然后可以在Visual Studio中运行,然后再次在发布模式下构建它运行。我从命令行运行它以确保我正在运行发布版本。

f x y z