F# vs OCaml: Stack overflow

16,740

Solution 1

Executive summary:

  • I wrote a simple implementation of an algorithm... that wasn't tail-recursive.
  • I compiled it with OCaml under Linux.
  • It worked fine, and finished in 0.14 seconds.

It was then time to port to F#.

  • I translated the code (direct translation) to F#.
  • I compiled under Windows, and run it - I got a stack overflow.
  • I took the binary under Linux, and run it under Mono.
  • It worked, but run very slowly (84 seconds).

I then posted to Stack Overflow - but some people decided to close the question (sigh).

  • I tried compiling with --optimize+ --checked-
  • The binary still stack overflowed under Windows...
  • ...but run fine (and finished in 0.5 seconds) under Linux/Mono.

It was time to check the stack size: Under Windows, another SO post pointed out that it is set by default to 1MB. Under Linux, "uname -s" and a compilation of a test program clearly showed that it is 8MB.

This explained why the program worked under Linux and not under Windows (the program used more than 1MB of stack). It didn't explain why the optimized version run so much better under Mono than the non-optimized one: 0.5 seconds vs 84 seconds (even though the --optimize+ appears to be set by default, see comment by Keith with "Expert F#" extract). Probably has to do with the garbage collector of Mono, which was somehow driven to extremes by the 1st version.

The difference between Linux/OCaml and Linux/Mono/F# execution times (0.14 vs 0.5) is because of the simple way I measured it: "time ./binary ..." measures the startup time as well, which is significant for Mono/.NET (well, significant for this simple little problem).

Anyway, to solve this once and for all, I wrote a tail-recursive version - where the recursive call at the end of the function is transformed into a loop (and hence, no stack usage is necessary - at least in theory).

The new version run fine under Windows as well, and finished in 0.5 seconds.

So, moral of the story:

  • Beware of your stack usage, especially if you use lots of it and run under Windows. Use EDITBIN with the /STACK option to set your binaries to larger stack sizes, or better yet, write your code in a manner that doesn't depend on using too much stack.
  • OCaml may be better at tail-recursion elimination than F# - or it's garbage collector is doing a better job at this particular problem.
  • Don't despair about ...rude people closing your Stack Overflow questions, good people will counteract them in the end - if the questions are really good :-)

P.S. Some additional input from Dr. Jon Harrop:

...you were just lucky that OCaml didn't overflow as well. You already identified that actual stack sizes vary between platforms. Another facet of the same issue is that different language implementations eat stack space at different rates and have different performance characteristics in the presence of deep stacks. OCaml, Mono and .NET all use different data representations and GC algorithms that impact these results... (a) OCaml uses tagged integers to distinguish pointers, giving compact stack frames, and will traverse everything on the stack looking for pointers. The tagging essentially conveys just enough information for the OCaml run time to be able to traverse the heap (b) Mono treats words on the stack conservatively as pointers: if, as a pointer, a word would point into a heap-allocated block then that block is considered to be reachable. (c) I do not know .NET's algorithm but I wouldn't be surprised if it ate stack space faster and still traversed every word on the stack (it certainly suffers pathological performance from the GC if an unrelated thread has a deep stack!)... Moreover, your use of heap-allocated tuples means you'll be filling the nursery generation (e.g. gen0) quickly and, therefore, causing the GC to traverse those deep stacks often...

Solution 2

Let me try to summarize the answer.

There are 3 points to be made:

  • problem: stack overflow happens on a recursive function
  • it happens only under windows: on linux, for the problem size examined, it works
  • same (or similar) code in OCaml works
  • optimize+ compiler flag, for the problem size examined, works

It is very common that a Stack Overflow exception is the result of a recursive vall. If the call is in tail position, the compiler may recognize it and apply tail call optimization, therefore the recursive call(s) will not take up stack space. Tail call optimization may happen in F#, in the CRL, or in both:

CLR tail optimization1

F# recursion (more general) 2

F# tail calls 3

The correct explanation for "fails on windows, not in linux" is, as other said, the default reserved stack space on the two OS. Or better, the reserved stack space used by the compilers under the two OSes. By default, VC++ reserves only 1MB of stack space. The CLR is (likely) compiled with VC++, so it has this limitation. Reserved stack space can be increased at compile time, but I'm not sure if it can be modified on compiled executables.

EDIT: turns out that it can be done (see this blog post http://www.bluebytesoftware.com/blog/2006/07/04/ModifyingStackReserveAndCommitSizesOnExistingBinaries.aspx) I would not recommend it, but in extreme situations at least it is possible.

OCaml version may work because it was run under Linux. However, it would be interesting to test also the OCaml version under Windows. I know that the OCaml compiler is more aggressive at tail-call optimization than F#.. could it even extract a tail recursive function from your original code?

My guess about "--optimize+" is that it will still cause the code to recur, hence it will still fail under Windows, but will mitigate the problem by making the executable run faster.

Finally, the definitive solution is to use tail recursion (by rewriting the code or by relying on aggressive compiler optimization); it is a good way to avoid stack overflow problem with recursive functions.

Share:
16,740
ttsiodras
Author by

ttsiodras

Summary From 2002 to 2012, I was part-owner of a startup ( Semantix S.A. ), in the role of the company's Senior Software Engineer and Technical Lead. The company was acquired by Neuropublic S.A in June 2012. Tech I mostly code in Python and C/C++, targeting Linux, Windows and embedded development. In some of my projects I had to optimize for speed using CUDA and OpenMP/TBB. I can also code in x86/SSE asm if necessary. Scripting: Python mostly ; Perl in the past; daily one-liners with bash/awk/sed. SQL-wise: Oracle, MySQL and PostgreSQL (I've also written native apps over direct APIs: OCI for Oracle, psycopg2 for PostgreSQL). I begun my career 11 years ago, successfully coding Windows device drivers for 7 different FPGA designs that stress-tested Siemens 3G switches. I mostly use VIM these days but have no fear of IDEs (that's where I begun). That being said, I prefer Makefiles (recently, tup) to Eclipse-sized monsters. I value strong type systems and functional-style thinking (OCaml/F#). I am not an extremist in this regard, sometimes mutable state is the way to go (translation: I think Haskell takes it too far). I love ZFS - in general, data checksums in the filesystem. When they are not there, I use my own. I have a soft spot for Lisps. When my work in my startup demanded it, I skimmed over C#, Java and Windows administration. My programming/admin blog (Slashdotted, Reddit-ed, HN-ed, etc) Here ( mirrored on: http://ttsiodras.github.com/ ). My CV is here: https://www.thanassis.space/cv.pdf ( mirrored on: http://ttsiodras.github.io/cv.pdf )

Updated on June 02, 2022

Comments

  • ttsiodras
    ttsiodras almost 2 years

    I recently found a presentation about F# for Python programmers, and after watching it, I decided to implement a solution to the "ant puzzle" on my own.

    There is an ant that can walk around on a planar grid. The ant can move one space at a time left, right, up or down. That is, from the cell (x, y) the ant can go to cells (x+1, y), (x-1, y), (x, y+1), and (x, y-1). Points where the sum of the digits of the x and y coordinates are greater than 25 are inaccessible to the ant. For example, the point (59,79) is inaccessible because 5 + 9 + 7 + 9 = 30, which is greater than 25. The question is: How many points can the ant access if it starts at (1000, 1000), including (1000, 1000) itself?

    I implemented my solution in 30 lines of OCaml first, and tried it out:

    $ ocamlopt -unsafe -rectypes -inline 1000 -o puzzle ant.ml
    $ time ./puzzle
    Points: 148848
    
    real    0m0.143s
    user    0m0.127s
    sys     0m0.013s
    

    Neat, my result is the same as that of leonardo's implementation, in D and C++. Comparing to Leonardo's C++ implementation, the OCaml version runs approx 2 times slower than C++. Which is OK, given that Leonardo used a queue to remove recursion.

    I then translated the code to F# ... and here's what I got:

    Thanassis@HOME /g/Tmp/ant.fsharp
    $ /g/Program\ Files/FSharp-2.0.0.0/bin/fsc.exe ant.fs
    Microsoft (R) F# 2.0 Compiler build 2.0.0.0
    Copyright (c) Microsoft Corporation. All Rights Reserved.
    
    Thanassis@HOME /g/Tmp/ant.fsharp
    $ ./ant.exe
    
    Process is terminated due to StackOverflowException.
    Quit
    
    Thanassis@HOME /g/Tmp/ant.fsharp
    $ /g/Program\ Files/Microsoft\ F#/v4.0/Fsc.exe ant.fs
    Microsoft (R) F# 2.0 Compiler build 4.0.30319.1
    Copyright (c) Microsoft Corporation. All Rights Reserved.
    
    Thanassis@HOME /g/Tmp/ant.fsharp
    $ ./ant.exe
    
    Process is terminated due to StackOverflowException
    

    Stack overflow... with both versions of F# I have in my machine... Out of curiosity, I then took the generated binary (ant.exe) and run it under Arch Linux/Mono:

    $ mono -V | head -1
    Mono JIT compiler version 2.10.5 (tarball Fri Sep  9 06:34:36 UTC 2011)
    
    $ time mono ./ant.exe
    Points: 148848
    
    real    1m24.298s
    user    0m0.567s
    sys     0m0.027s
    

    Surprisingly, it runs under Mono 2.10.5 (i.e. no stack overflow) - but it takes 84 seconds, i.e. 587 times slower than OCaml - oops.

    So this program...

    • runs fine under OCaml
    • doesn't work at all under .NET/F#
    • works, but is very slow, under Mono/F#.

    Why?

    EDIT: Weirdness continues - Using "--optimize+ --checked-" makes the problem disappear, but only under ArchLinux/Mono ; under Windows XP and Windows 7/64bit, even the optimized version of the binary stack overflows.

    Final EDIT: I found out the answer myself - see below.

  • Totti
    Totti about 6 years
    "the OCaml compiler is more aggressive at tail-call optimization than F#". Not really. Both OCaml and F# guarantee that all calls in tail position are eliminated (under appropriate circumstances). However, OCaml is probably using much smaller stack frames than .NET or Mono.