Why does Backtrace.Exn not work very well with monadic let bindings?

I am using the custom let bindings introduced in Ocaml 4.08 to implement a custom monad. Here is a snippet of how this is implemented:

type 'a t = state -> (state * 'a)

let return a = fun s -> (s, a)

module Syntax = struct
  (* For ppx_let *)
  module Let_syntax = struct 
    let bind m ~f = fun sin ->
      let sout, res = m sin in
      f res sout

    let return = return

    let map m ~f = fun sin ->
      let sout, res = m sin in
      (sout, f res)
      
    let both m1 m2 = fun sin ->
      let s1, res1 = m1 sin in
      let s2, res2 = m2 s1 in
      (s2, (res1, res2))
  end
    
  open Let_syntax
  
  let (let+) (m: state -> state * 'a) (f: 'a -> 'b) : (state -> state * 'b) = map m ~f
  let (and+) = both
  let (let* ) (m: state -> state * 'a) (f: 'a -> state -> state * 'b) : (state -> state * 'b) = bind m ~f
  let (and* ) = both
  
end

These bindings make my code much more readable and streamlined. The issue is that when exceptions are raised, using Backtrace.Exn.most_recent () to print the backtrace does not yield useful backtraces. Instead of the actual functions being called, the backtrace is full of references to Rewriter.Let_syntax.map and Rewriter.Let_syntax.bind, for example:

Raised at Util__Error.fail in file "lib/util/error.ml", line 10, characters 24-51
Called from Ast__Rewriter.resolve_and_find in file "lib/ast/rewriter.ml", line 811, characters 52-95
Called from Ast__Rewriter.Syntax.Let_syntax.map in file "lib/ast/rewriter.ml", line 26, characters 13-18
Called from Ast__Rewriter.Syntax.Let_syntax.map in file "lib/ast/rewriter.ml", line 25, characters 22-27
Called from Ast__Rewriter.Syntax.Let_syntax.map in file "lib/ast/rewriter.ml", line 25, characters 22-27
Called from Ast__Rewriter.Syntax.Let_syntax.bind in file "lib/ast/rewriter.ml", line 19, characters 22-27

This makes the backtrace essentially useless. In the definitions of bind and map, if an exception is raised during the computation of m, the corresponding functions are not appearing in the backtrace. Why might that be the case?

What can I do to ensure that the information about these functions is preserved in the backtrace? For example, would it be possible to modify the bind and map functions to add more information to the backtrace when exceptions occur? I don’t understand the backtrace enough to figure out how to do something like that.

1 Like

Not sure it applies to you because I’m not sure what raising/catching you are doing, but in Lwt we improved the backtraces as describe in this other thread.

I’m not doing any raising/catching AFAIK. That makes it even stranger that the functions called inside the monadic bindings don’t get reported.

The cause of the missing frames is likely tail call optimisation.
Typically:

(* User code *)
let my_function x =
  let* y = foo x in
  let* z = bar y in
  baz z

(* As the compiler sees it *)
let my_function x =
  (let*) (foo x) (fun y ->
    (let*) (bar y) (fun z -> baz z))

Here, my_function contains a single call to (let*) in tail position, so it will immediately replace its stack frame with that of (let*), and no backtrace will ever contain a frame for my_function.

You can try to force the call not to be tail by inserting dummy bindings:

let my_function x =
  let r =
    let* y = ... in
    ...
  in
  let () = Sys.opaque_identity () in
  r

The Sys.opaque_identity line is there to prevent the compiler from optimising this code back into the tailcall version.

1 Like

Ah, it seems like you’re right! Here’s a small example that I was able to create:

open Base

module Syntax = struct
  let bind m ~f = (1, f m)
  let (let*) m f = bind m ~f
end

let fail_fn a = raise (Failure "fail")

let my_function x =
	let open Syntax in
  let* y = x in
  let* z = y in
	fail_fn z

let () = 
	try
		let open Syntax in
		Stdio.print_endline "Hello, World!"; let _d = (let* c = my_function 1 in ()) in ()
	with
	| Failure s ->
		Stdio.print_endline @@  s ^ "   " ^ Backtrace.to_string @@ Backtrace.Exn.most_recent ();
		Stdlib.exit 1

Running this prints the following backtrace:

Hello, World!                       
fail   Raised at Dune__exe__Main.fail_fn in file "bin/main.ml", line 8, characters 16-38
Called from Dune__exe__Main.Syntax.bind in file "bin/main.ml", line 4, characters 22-25
Called from Dune__exe__Main.Syntax.bind in file "bin/main.ml", line 4, characters 22-25
Called from Dune__exe__Main in file "bin/main.ml", line 19, characters 58-71

(no reference to my_function like you expected)

The tailcall optimization makes code harder to debug. I also came across this discussion about this exact issue, and it seems like several other people want help with this issue, but odds of progress seem dim.

Does that mean the only thing I can do to obtain sensible backtraces is to go and insert a dubious function call at the end of all my functions to trick the compiler into disabling tailcall optimizations?

As a user of the monadic let-bindings, I would expect them to have similar behavior wrt to the backtrace as regular let-bindings. Semantically, normal let-bindings can also be rewritten as functions similar to let* bindings, but when running the following code:

open Base

module Syntax = struct
  let bind m ~f = (1, f m)
  let (let*) m f = bind m ~f
end

let fail_fn a = raise (Failure "fail")

let my_function x =
	let open Syntax in
  let y = x in
  let z = y in
	fail_fn z

let () = 
	try
		let open Syntax in
		Stdio.print_endline "Hello, World!"; let _d = (let c = my_function 1 in ()) in ()
	with
	| Failure s ->
		Stdio.print_endline @@  s ^ "   " ^ Backtrace.to_string @@ Backtrace.Exn.most_recent ();
		Stdlib.exit 1

(replaced let* with let everywhere)

I get the following sensible backtrace:

Hello, World!                      
fail   Raised at Dune__exe__Main.fail_fn in file "bin/main.ml", line 8, characters 16-38
Called from Dune__exe__Main.my_function in file "bin/main.ml" (inlined), line 14, characters 1-10
Called from Dune__exe__Main in file "bin/main.ml", line 19, characters 57-70

The only alternative I’m aware of is to rely on the ability of the optimising compiler to preserve backtrace information for inlined function calls, including tail calls. You will need to use a compiler with Flambda configured for inlining to work reliably though.

I installed Ocaml with flambda enabled. However, I cannot find flags which either disable inlined function calls, or preserve backtrace information for inlined function calls. Are you aware of any such flags?

You can control inlining in several ways:

  • Through command-line flags. There are generic optimisation flags like -O2, -O3 and -Oclassic that impact what gets or not inlined, and there are some other flags that are more specific but not necessarily meant for general use, like -inline (takes a number, bigger numbers means more inlining). You can look through ocamlopt -help for all the available flags. If you want to completely disable inlining, -inline 0 should work in most cases.

  • Through annotations in the source. A function annotated with [@inline always] will be inlined whenever possible, while with [@inline never] it should never get inlined. You can also annotate the calls specifically with [@inlined] annotations, in which case the annotation only applies to this specific application.

Some examples of annotations:

let f x = x + 1 [@@inline always] (* Annotate a toplevel function *)
let[@inline always] f' x = x + 1 (* Equivalent, but also works for local bindings [let ... in ...] *)
let[@inline never] g x = x (* Negative version *)

let run x =
   let x = f x in (* No specific annotation, will follow the annotation from the definition *)
  let x = (f'[@inlined never]) x in (* Disable inlining even if the definition has annotations *)
  let x = (g[@inlined always]) x in (* The annotation on [g] is incompatible with this annotation, so you should get a warning *)
  x

Preserving backtrace information around inlining should be done automatically as long as you compile with -g.

Finally, a small warning: the compiler implements a few optimisations that look like inlining but are not properly integrated with the inlining framework. For example, let f x = ... in f arg will remove the function [f] completely, as if it was inlined, but without doing a proper inlining transformation; this particular issue can be worked around using an annotation [@local never] on [f], but there are others that have to be worked around in different ways.

The LWT folks may have done work to make this happen, but in general, you should NOT expect this. The entire point of monads is that you can’t count on the stack being there, and without the stack, you can’t count on stack backtraces. So for instance, all monads that do CPS will have the property that the stack is meaningful from an application viewpoint. Again, that’s part of the point of monads.