f# stack-overflow tail-recursion cil tail-call-optimization

f# - ¿Puede/hace el operador de tuberías(hacia adelante) prevenir la optimización de llamadas de cola?



stack-overflow tail-recursion (1)

Para un problema de optimización de parámetros en el trabajo, escribí un algoritmo genético para encontrar algunas configuraciones buenas porque una solución de fuerza bruta no es factible. Desafortunadamente, cuando vuelvo por la mañana, la mayoría de las veces me presentan una StackOverflowException .

He estado usando F # desde hace bastante tiempo, así que estoy al tanto del TCO y la necesidad de funciones con argumentos del acumulador y generalmente uso esa forma.

Después de mucho buscar, creo que pude encontrar el código que desencadenó la excepción:

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

breedPopulation genera una nueva generación a partir de los individuos en la corriente actual. Luego se inicia la siguiente ronda / generación con la llamada a simulate . Cuando miro el desmontaje (noob total) veo un pop y un ret , por lo que no me parece una llamada normal (sin cola).

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

Después de desechar el operador de la tubería y colocar la cría en una posición de parámetro normal, el desmontaje es diferente.

// 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

Eso es definitivamente más corto y con el jmp final aún mejor que una llamada de cola.

Por lo tanto, quiero entender si y por qué el problema parece ser el problema y cuándo marca la diferencia; después de todo, esta es la primera vez que me muerde después de años. ¿En qué circunstancias ocurre y a qué debemos prestar atención?

Actualización: después de que Guy señaló que mis listas no son IL, sino ensamblador, primero reescribí la pregunta. Esto es lo que descubrí con ILSpy :

Con el operador |>

Mirando el C # descompilado, el código parece saltar de un lado a otro entre

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); }

y

// 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); }

En la IL de la new convocatoria no hay tail. op para ser encontrado. Por otro lado, el IL de las últimas líneas del Invoke lee.

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

No sé qué hacer con eso.

Sin |> operador

La otra versión es de hecho muy diferente. Empezando con

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([email protected]@); 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); }

va a

// 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); }

y finalmente

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; dr

Definitivamente, el uso del operador de tuberías cambió drásticamente el flujo del programa. Mi conjetura es que el ir y venir entre las dos funciones es lo que eventualmente causa la excepción.

Ya había leído Tail Calls en F # pero no creo que se aplique a esta situación, ya que no estoy usando una función de primera clase que devuelva una unidad como valor (en mi código F #).

Así que la pregunta sigue siendo: ¿Por qué el operador de tuberías tiene este efecto destructivo aquí? ¿Cómo podría haberlo sabido de antemano / a qué debo tener cuidado?

Actualización 2:

Puedes encontrar una versión reducida del ejemplo en GitHub . Comprueba por ti mismo que el operador en inline |> cambia el IL produce, que no es lo que yo esperaría.

Mientras reducía el ejemplo, con un poco de suerte pude encontrar la fuente real de la excepción. Puede comprobar la branch para la variante mucho más mínima. Después de todo, no tiene nada que ver con la tubería, pero todavía no lo entiendo porque, en mi humilde opinión, hay una recursión de la cola.

Pero mis preguntas originales permanecen. Solo estoy agregando uno más . :)


En función del caso mínimo que se proporciona, si el código se ejecuta en modo de lanzamiento en 64 bits, falla con un desbordamiento de pila. Si el código se ejecuta en modo de liberación en modo de 32 bits, se realiza correctamente.

Nota: La opción para elegir entre 32 y 64 bits es Prefer 32-bit como se ve en las imágenes a continuación.

Al aumentar el tamaño de la pila, el código tendrá éxito en el modo de lanzamiento en 64 bits. Esto se hace mediante el uso del constructor Thread .

[<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 Exception. 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 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

Este código es de FSharp-logic-examples y, en particular, de Anh-Dung Phan.

Si bien no he comprobado la causa raíz, sospecho que es debido a que el tamaño de los elementos para 64 bits es mayor que el tamaño de los elementos para 32 bits y aunque la cantidad de elementos colocados en la pila y la el tamaño de la pila sigue siendo el mismo para ambas versiones, el aumento del tamaño del elemento empuja la memoria necesaria para la pila por encima del límite de 1 megabyte.

TL; DR

Esta ha sido una pregunta divertida e iluminadora para responder. Me alegro que me lo pidieran.

Originalmente, el problema parecía estar relacionado con el uso de |> y TCO y, dado que todavía es de valor, lo dejo en la respuesta. También me gustaría agradecer al OP por ser una respuesta y ayuda, es un placer ayudar a alguien que trabaja con usted y no contra usted.

En el siguiente código, que es recursivo y se ejecuta en modo de depuración dentro de Visual Studio, causa un .

Si se inicia desde la línea de comandos desde el directorio bin/release , NO causa un .

Usando la comunidad de Visual Studio 15

[<EntryPoint>] let main argv = let largeList = printfn "Creating large list" [ for i in 1 .. 100000000 do yield i ] // causes in Debug // No 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

Donde la versión o la depuración se establece en la barra de herramientas de Visual Studio

y las opciones para el proyecto en modo Debug son

y las opciones para el proyecto en modo Release son

tldr;

En el proceso de prueba, creé 16 pruebas diferentes y las construí tanto en el modo de depuración como en el modo de lanzamiento, y verifiqué si se ejecutaron hasta el final o si arrojaron un desbordamiento de pila. Los 16 se dividen en un conjunto de 4 con 4 casos cada uno. Los casos 1,5,9,13 son negativos y producen un desbordamiento de pila para garantizar que se pueda crear un desbordamiento de pila. Los casos 2,6,10,14 son positivos para mostrar que la llamada de cola está funcionando y no está causando un desbordamiento de pila. Los casos 3,7,11,15 muestran una llamada de cola con una operación realizada en la misma declaración que la llamada de cola, y están a una factorización de los casos de prueba usando |> ; estos funcionan como se espera. Los casos 4,8,12,16 usan |> y muestran cuándo funciona y no funciona en modo de depuración, lo que probablemente sea una sorpresa para muchos. Los casos 1-4 y 9-12 usan una función de la forma fxy , los casos 8-11 usan una función de la forma fx y los casos 12-16 usan una función de la forma fxyz . Originalmente hice los primeros 8 casos de prueba, pero después del comentario de Keith, hice 4 más que no usan una lista, pero aún así usan una función de la de fxy y presentan el resultado inesperado y luego hice 4 más que usan una función de la forma fxyz .

Para ejecutar una prueba, tendrá que comentar todas las pruebas excepto la que planea ejecutar y compilarla una vez en el modo de depuración, que luego se puede ejecutar desde Visual Studio, y luego volver a compilarla en el modo de lanzamiento y ejecutarla. Lo ejecuto desde una línea de comandos para asegurarme de que estoy ejecutando la versión de lanzamiento.

[<EntryPoint>] let main argv = let largeList = printfn "Creating large list" [ for i in 1 .. 100000000 do yield i ] // causes in Debug // causes in Release // Negative confirmation // A supposed tail call that DOES cause a in both debug and release mode // options: f x y let sum1 l = printfn "test 01: " let rec sum1Inner l acc = match l with | h::t -> let acc = acc + h 1 + sum1Inner t acc | [] -> acc sum1Inner l 0 // No in Debug // No in Release // Positive confirmation // A tail call that DOES NOT cause a in both debug and release mode // options: f x y let sum2 l = printfn "test 02: " let rec sum2Inner l acc = match l with | h::t -> let acc = acc + h sum2Inner t acc | [] -> acc sum2Inner l 0 // No in Debug // No in Release // A test case // options: f x y and no |> let sum3 l = printfn "test 03: " let rec sum3Inner l acc = match l with | h::t -> sum3Inner t (acc + h) | [] -> acc sum3Inner l 0 // causes in Debug // No in Release // A test case // options: f x y and |> let sum4 l = printfn "test 04: " let rec sum4Inner l acc = match l with | h::t -> let acc = acc + h acc |> sum4Inner t | [] -> acc sum4Inner l 0 // causes in Debug // causes in Release // Negative confirmation // A supposed tail call that DOES cause a in both debug and release mode // options: f x let sum5 () = printfn "test 05: " let rec sum5Inner x = match x with | 10000000 -> x | _ -> let acc = x + 1 1 + sum5Inner acc sum5Inner 0 // No in Debug // No in Release // Positive confirmation // A tail call that DOES NOT cause a in both debug and release mode // options: f x let sum6 () = printfn "test 06: " let rec sum6Inner x = match x with | 10000000 -> x | _ -> let acc = x + 1 sum6Inner acc sum6Inner 0 // No in Debug // No in Release // A test case // options: f x and no |> let sum7 l = printfn "test 07: " let rec sum7Inner x = match x with | 10000000 -> x | _ -> sum7Inner (x + 1) sum7Inner 0 // No in Debug // No in Release // A test case // options: f x and |> let sum8 () = printfn "test 07: " let rec sumInner8 x = match x with | 10000000 -> x | _ -> let acc = x + 1 acc |> sumInner8 sumInner8 0 // causes in Debug // causes in Release // Negative confirmation" // A supposed tail call that DOES cause a in both debug and release mode" // options: f x y" let sum9 () = printfn "test 09: " let rec sum9Inner x y = match y with | 10000000 -> y | _ -> let acc = x + y 1 + sum9Inner x acc sum9Inner 1 0 // No in Debug // No in Release // Positive confirmation // A tail call that DOES NOT cause a in both debug and release mode // options: f x y let sum10 () = printfn "test 10: " let rec sum10Inner x y = match y with | 10000000 -> y | _ -> let acc = x + y sum10Inner x acc sum10Inner 1 0 // No in Debug // No in Release // A test case // options: f x y and no |> let sum11 () = printfn "test 11: " let rec sum11Inner x y = match y with | 10000000 -> y | _ -> sum11Inner x (x + y) sum11Inner 1 0 // causes in Debug // No in Release // A test case // options: f x y and |> let sum12 () = printfn "test 12: " let rec sum12Inner x y = match y with | 10000000 -> y | _ -> let acc = x + y acc |> sum12Inner x sum12Inner 1 0 // causes in Debug // No in Release // A test case" // options: f x y and |>" let sum12 () = printfn "test 12: " let rec sum12Inner x y = match y with | 10000000 -> y | _ -> let acc = x + y acc |> sum12Inner x sum12Inner 1 0 // causes in Debug // causes in Release // Negative confirmation" // A supposed tail call that DOES cause a in both debug and release mode" // options: f x y" let sum13 () = printfn "test 13: " let rec sum13Inner x z y = match y with | 10000000 -> y | _ -> let acc = x + y 1 + sum13Inner x z acc sum13Inner 1 "z" 0 // No in Debug // No in Release // Positive confirmation" // A tail call that DOES NOT cause a in both debug and release mode" // options: f x y" let sum14 () = printfn "test 14: " let rec sum14Inner x z y = match y with | 10000000 -> y | _ -> let acc = x + y sum14Inner x z acc sum14Inner 1 "z" 0 // No in Debug // No in Release // A test case" // options: f x y and no |>" let sum15 () = printfn "test 15: " let rec sum15Inner x z y = match y with | 10000000 -> y | _ -> sum15Inner x z (x + y) sum15Inner 1 "z" 0 // causes in Debug // No in Release // A test case" // options: f x y and |>" let sum16 () = printfn "test 16: " let rec sum16Inner x z y = match y with | 10000000 -> y | _ -> let acc = x + y acc |> sum16Inner x z sum16Inner 1 "z" 0 let result1 = sum1 largeList printfn "result1: %A" result1 let result2 = sum2 largeList printfn "result2: %A" result2 let result3 = sum3 largeList printfn "result3: %A" result3 let result4 = sum4 largeList printfn "result4: %A" result4 let result5 = sum5 () printfn "result5: %A" result5 let result6 = sum6 () printfn "result6: %A" result6 let result7 = sum7 () printfn "result7: %A" result7 let result8 = sum8 () printfn "result8: %A" result8 let result9 = sum9 () printfn "result9: %A" result9 let result10 = sum10 () printfn "result10: %A" result10 let result11 = sum11 () printfn "result11: %A" result11 let result12 = sum12 () printfn "result12: %A" result12 let result13 = sum13 () printfn "result13: %A" result13 let result14 = sum14 () printfn "result14: %A" result14 let result15 = sum15 () printfn "result15: %A" result15 let result16 = sum16 () printfn "result16: %A" result16 printf "Press any key to exit: " System.Console.ReadKey() |> ignore printfn "" 0 // return an integer exit code