fsharp icon indicating copy to clipboard operation
fsharp copied to clipboard

Request for debug team - always stepping in to a call

Open dsyme opened this issue 3 years ago • 6 comments

I've been looking at debugging issues for computation expressions as part of #13339 . It's ultimately a request for the .NET debugger team but I'll write it up here first. cc @gregg-miskelly who the .NET team suggested as a contact in the Visual Studio debugger.

I believe we should look into a new attribute DebuggerStepIntoAttribute (or something similar) to partner DebuggerStepThroughAttribute, where putting the new attribute on a method has the effect of changing a F10 "Step Over" into a "Step Into".

This would have use in debugging for F# computation expressions, for expressing the "calling the continuation that gives the rest of the computation" part of a bind operation. It would also have many other uses in F# code, where "the rest of the method" is sometimes compiled as a separate method (this is an optimization F# sometimes performs for large methods).

We actually also need this to somehow apply to an indirect call. For example, the typical situation is that

let f() =
    cancellable {
        print "hello"
        let! res1 = SomeOtherCancellable()   <--- F10 Step Over here
        print "world"
    }

From the user's point of view the sequence points are:

let f() =
    cancellable {
        // Sequence Point 1
        print "hello"
        // Sequence Point 2                         <--- F10 Step Over here
        let! res1 = SomeOtherCancellable()  
        // Sequence Point 3
        print "world"
    }

And "Step Over" should go through these three sequence points. However the code gets compiled as follows - each chunk becomes a function that gets called, and these are then stitched together.

let f() =
    cancellable.Delay(fun () ->
        print "hello"
        cancellable.Bind( SomeOtherCancellable(), fun() ->
            print "world"
        )
    )

After inlining and some flattening of cancellable.Bind becomes

let f() =
    Cancellable(fun ct ->
        // Sequence Point 1
        print "hello"
        // Sequence Point 2
        let computation1 = SomeOtherCancellable()
        let res1 = computation1.Invoke(ct)
        let computation2 =
            Cancellable(fun ct ->
                // Sequence Point 3
                print "world")
        computation2.Invoke(ct)  <--- need to step into here to reach Sequence Point 3
    )

The effect we want is that a Step Over at Sequence Point 2 would step over al the invocations and then actially Step Into computation2.Invoke(ct). Exactly how we achieve that I'm not sure. Somehow we need to mark that call as "always step into".

dsyme avatar Jun 21 '22 22:06 dsyme

I am assuming 'Cancellable' is some sort of runtime helper method? Is the print "world" statement going to execute synchronously on the same thread as the primary method with 'Cancellable' on the stack? Is this true for the "other uses" that you are thinking of? If not, how could a debugger understand the causal connection between the original method and the 'outlined' method?

gregg-miskelly avatar Jun 21 '22 23:06 gregg-miskelly

@gregg-miskelly

I am assuming 'Cancellable' is some sort of runtime helper method? Is the print "world" statement going to execute synchronously on the same thread as the primary method with 'Cancellable' on the stack? Is this true for the "other uses" that you are thinking of? If not, how could a debugger understand the causal connection between the original method and the 'outlined' method?

In the last example, Cancellable is just a constructor/function which accepts a function (so it later can be executed).

Note cancellable {} construct is a computation expression, where pretty much everything inside of it (inside the {}) we, expand into (sometimes chained, continuation-style) method calls or a state machine in compile-time.

Technically, it depends on the actual implementation of Cancellable, where will certain things be executed. I looked at the code, and in this particular case, I don't think we explicitly do the switching to new thread. @dsyme knows more about the implementation.

vzarytovskii avatar Jun 22 '22 12:06 vzarytovskii

@vzarytovskii How did you input that image ? I tried typing > Note as suggested in the "Quote reply" but it does not display like that.

Note

Edit: Thanks @baronfel for replying through Discord that > **Note** and > **Warning** now have new formats. https://github.com/orgs/github-community/discussions/16925

Note

Warning

Question resolved.

Happypig375 avatar Jun 22 '22 12:06 Happypig375

Yes, this is synchronous code. If it helps think of Cancellable as a delegate, e.g., see this gist

Another example where this would be helpful is an optimization the F# compiler performs. Consider this:

let SomeFunction(input) =
    match input with
    | SomeCase1 ->
        ...SomeVeryLargeAmountOfCode1...      <-- A
    | SomeCase2 ->
        ...SomeVeryLargeAmountOfCode2...      <-- B

For this kind of very large function, the F# compiler can optionally decide to put each branch of the match in its own target function, e.g.

let InvisibleHelper1() =
        ...SomeVeryLargeAmountOfCode1...

let InvisibleHelper2() =
        ...SomeVeryLargeAmountOfCode1...

let SomeFunction(input) =
    match input with
    | SomeCase1 ->
        InvisibleHelper1()                    <-- location A
    | SomeCase2 ->
        InvisibleHelper2()                     <-- location B

In this case, a "F10 step" at A or B should step into the destination helper function, rather than step over it.

dsyme avatar Jun 22 '22 13:06 dsyme

An example of Cancellable is here: https://gist.github.com/dsyme/5557d61a4393577c4fa0f299ca4220f2

Here a "step over" on "let! v1 = f1()" does not proceed to the next line, but rather completes the enclosing function.

The execution is at the (inlined) code:

                 computation2.Invoke(ct)

which represents running the rest of the function. However stepping over this skips the Invoke when what we want is to step into it.

Note that extensive F# compiler optimization work could in theory get rid of the Invoke and flatten the "cancellable" code completely. In the example some of this is done using inlining. However we generally only do the maximum of these kinds of optimizations in release code. So in debug code we still get some of these "combine these pieces of code" artefacts.

dsyme avatar Jun 22 '22 14:06 dsyme

Let me check my understanding of what was said about the cancellable example: My understanding is that this is an example of using the computation expression feature, and we aren't trying to solve cancelable in particular but any of these cases were the compiler emits a call to some sort of Invoke method that will call back into a delegate (or at least the cases where this happens synchronously).

Is that right?

For the match example: I am assuming this is a more straightforward case where the 'real' method is directly calling the 'outlined' method?

gregg-miskelly avatar Jun 22 '22 16:06 gregg-miskelly