Skip to content

and! support in TaskBulder #18451

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 16 commits into from
Apr 17, 2025
Merged

and! support in TaskBulder #18451

merged 16 commits into from
Apr 17, 2025

Conversation

Lanayx
Copy link
Contributor

@Lanayx Lanayx commented Apr 7, 2025

Description

Implementation for a suggestion

Checklist

  • Test cases added

  • Performance benchmarks added in case of performance changes

  • Release notes entry updated:

    Please make sure to add an entry with short succinct description of the change as well as link to this pull request to the respective release notes file, if applicable.

    Release notes files:

    • If anything under src/Compiler has been changed, please make sure to make an entry in docs/release-notes/.FSharp.Compiler.Service/<version>.md, where <version> is usually "highest" one, e.g. 42.8.200
    • If language feature was added (i.e. LanguageFeatures.fsi was changed), please add it to docs/release-notes/.Language/preview.md
    • If a change to FSharp.Core was made, please make sure to edit docs/release-notes/.FSharp.Core/<version>.md where version is "highest" one, e.g. 8.0.200.

    Information about the release notes entries format can be found in the documentation.
    Example:

    If you believe that release notes are not necessary for this PR, please add NO_RELEASE_NOTES label to the pull request.

Comments

This could be a pretty straightforward implementation with just one method, but there is an issue with type inference of Task<> cases. If I remove overloads with Task<> argument, then I face an error in the simple case:

    let test() =
        task {
            let! x = Task.FromResult 1
            and! y = Task.FromResult 2
            return x + y
        }
A unique overload for method 'GetAwaiter' could not be determined based on type information prior to this program point. A type annotation may be needed.

Known return type: ^a

Candidates:
 - Task.GetAwaiter() : TaskAwaiter
 - Task.GetAwaiter() : TaskAwaiter<int>

 Current overloads do help, but this is a hack for 2 arguments cases. For 3 and 4 arguments I need to create all possible permutations of arguments with tasks in different positions, which obviously is not desired. In theory, current PR can be used as is, but without MergeSources3 and MergeSources4 overloads we'll face additional allocations in those cases. I wonder, is it possible to fight type inference error without introducing combinatorial explosion giving up on reducing allocations in 3+ cases?
 
 CC @TheAngryByrd @T-Gro

Copy link
Contributor

github-actions bot commented Apr 7, 2025

❗ Release notes required


✅ Found changes and release notes in following paths:

Change path Release notes path Description
src/FSharp.Core docs/release-notes/.FSharp.Core/9.0.300.md

@Lanayx
Copy link
Contributor Author

Lanayx commented Apr 7, 2025

I checked those extensions in the empty console app, and thought that just inserting them in random place without doing full build locally might work, however it's not the case :) Will try again tomorrow

@Lanayx
Copy link
Contributor Author

Lanayx commented Apr 7, 2025

I've pushed the fix for build, but the behavior I described is much easier to see in the empty console app:

open System
open System.Runtime.CompilerServices
open System.Threading.Tasks


type TaskBuilder with

    member inline this.MergeSources(task1: Task<^TResult1>, task2: Task<^TResult2>) =
        this.Run(
            this.Bind(task1, fun (result1: ^TResult1) ->
                this.Bind(task2, fun (result2: ^TResult2) ->
                    this.Return(result1, result2)
                )
            )
        )

    // member inline this.MergeSources3(task1: Task<^TResult1>, task2: Task<^TResult2>, task3: Task<^TResult3>) =
    //     Console.WriteLine("Middle")
    //     this.Run(
    //         this.Bind(task1, fun (result1: ^TResult1) ->
    //             this.Bind(task2, fun (result2: ^TResult2) ->
    //                 this.Bind(task3, fun (result3: ^TResult3) ->
    //                     this.Return(result1, result2, result3)
    //                 )
    //             )
    //         )
    //     )

    //
    // member inline this.MergeSources4(task1: Task<^TResult1>, task2: Task<^TResult2>, task3: Task<^TResult3>, task4: Task<^TResult4>) =
    //     Console.WriteLine("Bottom")
    //     this.Run(
    //         this.Bind(task1, fun (result1: ^TResult1) ->
    //             this.Bind(task2, fun (result2: ^TResult2) ->
    //                 this.Bind(task3, fun (result3: ^TResult3) ->
    //                     this.Bind(task4, fun (result4: ^TResult4) ->
    //                         this.Return(result1, result2, result3, result4)
    //                     )
    //                 )
    //             )
    //         )
    //     )

    // This is required for type inference in mixed cases to work correctly
    member inline this.MergeSources< ^TaskLike2, ^TResult1, ^TResult2, ^Awaiter2
            when ^TaskLike2 : (member GetAwaiter: unit -> ^Awaiter2)
            and ^Awaiter2 :> ICriticalNotifyCompletion
            and ^Awaiter2: (member get_IsCompleted: unit -> bool)
            and ^Awaiter2: (member GetResult: unit -> 'TResult2)>
            (task1: Task<^TResult1>, task2: ^TaskLike2)
            : Task<^TResult1 * ^TResult2> =
        this.Run(
            this.Bind(task1, fun (result1: ^TResult1) ->
                this.Bind(task2, fun (result2: ^TResult2) ->
                    this.Return(result1, result2)
                )
            )
        )

    // This is required for type inference in mixed cases to work correctly
    member inline this.MergeSources< ^TaskLike1, ^TResult1, ^TResult2, ^Awaiter1
            when ^TaskLike1 : (member GetAwaiter: unit -> ^Awaiter1)
            and ^Awaiter1 :> ICriticalNotifyCompletion
            and ^Awaiter1: (member get_IsCompleted: unit -> bool)
            and ^Awaiter1: (member GetResult: unit -> 'TResult1)>
            (task1: ^TaskLike1, task2: Task<^TResult2>)
            : Task<^TResult1 * ^TResult2> =
        this.Run(
            this.Bind(task1, fun (result1: ^TResult1) ->
                this.Bind(task2, fun (result2: ^TResult2) ->
                    this.Return(result1, result2)
                )
            )
        )

    member inline this.MergeSources< ^TaskLike1, ^TaskLike2, ^TResult1, ^TResult2, ^Awaiter1, ^Awaiter2
            when ^TaskLike1: (member GetAwaiter: unit -> ^Awaiter1)
            and ^TaskLike2 : (member GetAwaiter: unit -> ^Awaiter2)
            and ^Awaiter1 :> ICriticalNotifyCompletion
            and ^Awaiter2 :> ICriticalNotifyCompletion
            and ^Awaiter1: (member get_IsCompleted: unit -> bool)
            and ^Awaiter1: (member GetResult: unit -> ^TResult1)
            and ^Awaiter2: (member get_IsCompleted: unit -> bool)
            and ^Awaiter2: (member GetResult: unit -> ^TResult2)>
            (task1: ^TaskLike1, task2: ^TaskLike2)
            : Task<^TResult1 * ^TResult2> =
        this.Run(
            this.Bind(task1, fun (result1: ^TResult1) ->
                this.Bind(task2, fun (result2: ^TResult2) ->
                    this.Return(result1, result2)
                )
            )
        )

let test() =
    task {
        let! x = Task.FromResult 1
        and! y = Task.FromResult 2
        return x + y
    }

test().GetAwaiter().GetResult() |> printfn "%A"

@vzarytovskii
Copy link
Member

This needs ilgen tests as well, to make sure it codegens resumable code as we expect

@Lanayx
Copy link
Contributor Author

Lanayx commented Apr 8, 2025

I'll refrain from any additional work until we have an agreement on the issue with MergeSourcesN (which is the main goal of this draft PR).

Right now adding MergeSources3 with just tasks breaks compilation for the case Task+Task+ValueTask, so all possible combinations of overloads are required. And this is even worse with MergeSources4. Ideally we only have one SRTP-based overload for each number of arguments, but this doesn't work with tasks, and I wonder if there is any clever hack to fix it (so Task.GetAwaiter() always chooses generic version over non-generic in case of contention).

Another option to leave it as is and face additional allocations for 3+ cases.

@TheAngryByrd
Copy link
Contributor

Right now adding MergeSources3 with just tasks breaks compilation for the case Task+Task+ValueTask, so all possible combinations of overloads are required. And this is even worse with MergeSources4. Ideally we only have one SRTP-based overload for each number of arguments, but this doesn't work with tasks, and I wonder if there is any clever hack to fix it (so Task.GetAwaiter() always chooses generic version over non-generic in case of contention).

This would be a bigger task (no pun intended) since it would involve changing SRTP overload semantics.

#8794 #5531

@Lanayx
Copy link
Contributor Author

Lanayx commented Apr 9, 2025

@TheAngryByrd You are absolutely right, it looks like I'm hitting #8794 bug, because this line

and ^Awaiter1: (member GetResult: unit -> ^TResult1)

really should prevent compiler from trying to infer Task.GetAwaiter() : TaskAwaiter, since it's GetResult returns unit. I can't say that it's changing overloading semantics, but rather fixing buggy behaviour

@Lanayx
Copy link
Contributor Author

Lanayx commented Apr 10, 2025

I think I'll leave it as is, only add some some documentation pieces. Once/if the aforementioned bug is fixed we can add MergeSources3 and MergeSources4 overloads

@vzarytovskii I'm not following this comment

This needs ilgen tests as well, to make sure it codegens resumable code as we expect

Can't find any ilgen tests for the core library and tasks, I'm not sure they are required at all.

@vzarytovskii
Copy link
Member

I think I'll leave it as is, only add some some documentation pieces. Once/if the aforementioned bug is fixed we can add MergeSources3 and MergeSources4 overloads

@vzarytovskii I'm not following this comment

This needs ilgen tests as well, to make sure it codegens resumable code as we expect

Can't find any ilgen tests for the core library and tasks, I'm not sure they are required at all.

They should be for the new feature if you want to make sure resumable code is getting generated. You can skip if you don't care of course.

@brianrourkeboll
Copy link
Contributor

@Lanayx

Can't find any ilgen tests for the core library and tasks, I'm not sure they are required at all.

I don't know where any existing ones might be, either, but if there is no existing place, this seems like as good a spot as any. Either that, or you could add a new folder following a similar structure.

@Lanayx
Copy link
Contributor Author

Lanayx commented Apr 10, 2025

They should be for the new feature if you want to make sure resumable code is getting generated. You can skip if you don't care of course.

I don't want them to be more resumable than Bind and ReturnFrom, so since there are no such ilgen tests for them, why should they be added for MergeSources?

@vzarytovskii
Copy link
Member

They should be for the new feature if you want to make sure resumable code is getting generated. You can skip if you don't care of course.

I don't want them to be more resumable than Bind and ReturnFrom, so since there are no such ilgen tests for them, why should they be added for MergeSources?

As I said, if you don't care and team agrees on no codegen tests, no point in writing them.

@Lanayx Lanayx force-pushed the Lanayx-and-tasks branch from 1bb0016 to a0f9631 Compare April 10, 2025 22:55
@Lanayx Lanayx marked this pull request as ready for review April 11, 2025 04:53
@Lanayx Lanayx requested a review from a team as a code owner April 11, 2025 04:53
@T-Gro
Copy link
Member

T-Gro commented Apr 11, 2025

They should be for the new feature if you want to make sure resumable code is getting generated. You can skip if you don't care of course.

I don't want them to be more resumable than Bind and ReturnFrom, so since there are no such ilgen tests for them, why should they be added for MergeSources?

I don't think we need exact IL verification tests.
Regular unit tests as part of unit testing FSharp.Core is fine.

What would be good though would be a one-time evidence of the allocation footprint for the newly created variants (e.g. for 2,3 and 10 sources) - can be just a benchmarking result attached here to this PR, IMO it does not need to keep living in the codebase after that.

@Lanayx Lanayx changed the title and! support in TaskBulder draft and! support in TaskBulder Apr 11, 2025
@Lanayx
Copy link
Contributor Author

Lanayx commented Apr 11, 2025

BenchmarkDotNet v0.14.0, Windows 11 (10.0.26100.3476)
AMD Ryzen 5 5600H with Radeon Graphics, 1 CPU, 12 logical and 6 physical cores
.NET SDK 9.0.202
  [Host]     : .NET 9.0.3 (9.0.325.11113), X64 RyuJIT AVX2 DEBUG
  DefaultJob : .NET 9.0.3 (9.0.325.11113), X64 RyuJIT AVX2


| Method        | Mean      | Error    | StdDev   | Gen0   | Allocated |
|-------------- |----------:|---------:|---------:|-------:|----------:|
| Baseline2     |  11.86 ns | 0.213 ns | 0.188 ns |      - |         - |
| Applicatives2 |  35.83 ns | 0.733 ns | 1.514 ns | 0.0114 |      96 B |
| Baseline8     |  37.10 ns | 0.611 ns | 0.600 ns | 0.0086 |      72 B |
| Applicatives8 | 219.12 ns | 4.412 ns | 8.811 ns | 0.0947 |     792 B |
[<MemoryDiagnoser>]
type Merge() =

    let allTasks = [|
        Task.FromResult(1)
        Task.FromResult(2)
        Task.FromResult(3)
        Task.FromResult(4)
        Task.FromResult(5)
        Task.FromResult(6)
        Task.FromResult(7)
        Task.FromResult(8)
    |]

    [<Benchmark>]
    member this.Baseline2() =
        task {
            let! a = allTasks[0]
            let! b = allTasks[1]
            return a + b
        }
    [<Benchmark>]
    member this.Applicatives2() =
        task {
            let! a = allTasks[0]
            and! b = allTasks[1]
            return a + b
        }

    [<Benchmark>]
    member this.Baseline8() =
        task {
            let! a = allTasks[0]
            let! b = allTasks[1]
            let! c = allTasks[2]
            let! d = allTasks[3]
            let! e = allTasks[4]
            let! f = allTasks[5]
            let! g = allTasks[6]
            let! h = allTasks[7]
            return a + b + c + d + e + f + g + h
        }

    [<Benchmark>]
    member this.Applicatives8() =
        task {
            let! a = allTasks[0]
            and! b = allTasks[1]
            and! c = allTasks[2]
            and! d = allTasks[3]
            and! e = allTasks[4]
            and! f = allTasks[5]
            and! g = allTasks[6]
            and! h = allTasks[7]
            return a + b + c + d + e + f + g + h
        }

@Lanayx
Copy link
Contributor Author

Lanayx commented Apr 11, 2025

CI seems to be failing with error Expected and actual surface area don't match, I don't know how to fix it

@TheAngryByrd
Copy link
Contributor

is there any difference if you use a struct tuple in the Return call?

@Lanayx
Copy link
Contributor Author

Lanayx commented Apr 11, 2025

@TheAngryByrd Wow, I didn't know it was possible! CE docs didn't mention that you can do that. However allocations gets improved just a bit, probably because task is allocated on each .Run call

// with value tuples
| Method        | Mean      | Error    | StdDev   | Gen0   | Allocated |
|-------------- |----------:|---------:|---------:|-------:|----------:|
| Baseline2     |  11.83 ns | 0.106 ns | 0.094 ns |      - |         - |
| Applicatives2 |  30.87 ns | 0.311 ns | 0.291 ns | 0.0086 |      72 B |
| Baseline8     |  36.77 ns | 0.277 ns | 0.259 ns | 0.0086 |      72 B |
| Applicatives8 | 259.41 ns | 3.756 ns | 3.329 ns | 0.0887 |     744 B |

@vzarytovskii
Copy link
Member

CI seems to be failing with error Expected and actual surface area don't match, I don't know how to fix it

This should help: https://github.com/dotnet/fsharp/blob/main/DEVGUIDE.md#updating-fcs-surface-area-baselines

@Lanayx
Copy link
Contributor Author

Lanayx commented Apr 11, 2025

As for reducing allocations, I've found that the best option is to introduce BindN method, for example

member inline this.Bind3(task1: Task<^TResult1>, task2: Task<^TResult2>, task3: Task<^TResult3>, continuation) =
    this.Bind(task1, fun (result1: ^TResult1) ->
        this.Bind(task2, fun (result2: ^TResult2) ->
            this.Bind(task3, fun (result3: ^TResult3) ->
                continuation struct(result1, result2, result3)
            )
        )
    )

But introducing it breaks compilation for three ValueTask, so it's not feasible to write binds for all possible combinations. Again fixing #8794 would resolve this issue, since SRTP overload would work for all.

@Lanayx
Copy link
Contributor Author

Lanayx commented Apr 11, 2025

This should help: https://github.com/dotnet/fsharp/blob/main/DEVGUIDE.md#updating-fcs-surface-area-baselines

I've run this locally, however only one file
\fsharp\tests\FSharp.Core.UnitTests\FSharp.Core.SurfaceArea.netstandard21.debug.bsl got updated in debug mode, and in release mode I got exception

[xUnit.net 00:00:01.63] FSharp.Compiler.Service.Tests: Catastrophic failure: System.TypeInitializationException: The type initializer for '<StartupCode$FSharp-Test-Utilities>.$TestFramework' threw an exception.
 ---> System.Exception: Couldn't find "FSharp.Compiler.Interactive.Settings\Release\netstandard2.0\FSharp.Compiler.Interactive.Settings.dll" on the following paths: "C:\Work\Fsharp\tests\FSharp.Test.Utilities\..\..\artifacts\bin\FSharp.Compiler.Interactive.Settings\Release\netstandard2.0\FSharp.Compiler.Interactive.Settings.dll", "C:\Work\Fsharp\tests\FSharp.Test.Utilities\..\..\artifacts\bin\fsharp.compiler.interactive.settings\release\netstandard2.0\fsharp.compiler.interactive.settings.dll". Running 'build test' once might solve this issue
   at TestFramework.requireFile(String dir, String path) in C:\Work\Fsharp\tests\FSharp.Test.Utilities\TestFramework.fs:line 296
   at [email protected](String path)
   at TestFramework.config(String configurationName, FSharpMap`2 envVars) in C:\Work\Fsharp\tests\FSharp.Test.Utilities\TestFramework.fs:line 337
   at <StartupCode$FSharp-Test-Utilities>.$TestFramework..cctor() in C:\Work\Fsharp\tests\FSharp.Test.Utilities\TestFramework.fs:line 460
   --- End of inner exception stack trace ---
   at TestFramework.get_initialConfig()
   at <StartupCode$FSharp-Test-Utilities>[email protected](IEnumerable`1 testCases, IMessageSink executionMessageSink, ITestFrameworkExecutionOptions executionOptions) in C:\Work\Fsharp\tests\FSharp.Test.Utilities\XunitHelpers.fs:line 162
   at Xunit.Sdk.TestFrameworkExecutor`1.RunTests(IEnumerable`1 testCases, IMessageSink executionMessageSink, ITestFrameworkExecutionOptions executionOptions) in /_/src/xunit.execution/Sdk/Frameworks/TestFrameworkExecutor.cs:line 100
   at Xunit.Xunit2.RunTests(IEnumerable`1 testCases, IMessageSink messageSink, ITestFrameworkExecutionOptions executionOptions) in /_/src/xunit.runner.utility/Frameworks/v2/Xunit2.cs:line 110
   at Xunit.XunitFrontController.RunTests(IEnumerable`1 testMethods, IMessageSink messageSink, ITestFrameworkExecutionOptions executionOptions) in /_/src/xunit.runner.utility/Frameworks/XunitFrontController.cs:line 185
   at TestFrameworkExtensions.RunTests(ITestFrameworkExecutor executor, IEnumerable`1 testCases, IMessageSinkWithTypes executionMessageSink, ITestFrameworkExecutionOptions executionOptions) in /_/src/xunit.runner.utility/Extensions/TestFrameworkExtensions.cs:line 69
   at Xunit.Runner.VisualStudio.VsTestRunner.RunTestsInAssembly(IRunContext runContext, IFrameworkHandle frameworkHandle, LoggerHelper logger, TestPlatformContext testPlatformContext, RunSettings runSettings, IMessageSinkWithTypes reporterMessageHandler, AssemblyRunInfo runInfo) in /_/src/xunit.runner.visualstudio/VsTestRunner.cs:line 555
  FSharp.Compiler.Service.Tests test net9.0 failed with 1 error(s) and 1 warning(s) (3.0s)
    C:\Program Files\dotnet\sdk\9.0.202\Microsoft.TestPlatform.targets(48,5): warning : No test matches the given testcase filter `SurfaceAreaTest` in C:\Work\Fsharp\artifacts\bin\FSharp.Compiler.Service.Tests\Release\net9.0\FSharp.Compiler.Service.Tests.dll
    C:\Program Files\dotnet\sdk\9.0.202\Microsoft.TestPlatform.targets(48,5): error MSB6006: "dotnet.exe" exited with code 1.

@Lanayx
Copy link
Contributor Author

Lanayx commented Apr 11, 2025

Nevermind, updated those files manually, since the suggested script didn't work

@vzarytovskii
Copy link
Member

vzarytovskii commented Apr 11, 2025

Nevermind, updated those file manually, since the suggested script didn't work

Ah, compiler should've been built in both configs probably. Devguide should be updated.

@T-Gro
Copy link
Member

T-Gro commented Apr 14, 2025

@Lanayx : Is this ready for review now?

Given the SRTP limitation, the allocations should be accepted and implementation reworked later once the compiler can do a general mechanism to support any combination of Task and ValueTask for N=3,4.

@TheAngryByrd
Copy link
Contributor

The other option to support BindN without the crazy permutations of parameters is using the Source member with various overloads (TaskLike, Task, Async). You could then at least achieve lower allocations up to whatever N decided to support. But I think this could introduce more to the API surface area and perhaps that would be bad for compatability in the future.

@Lanayx
Copy link
Contributor Author

Lanayx commented Apr 14, 2025

@T-Gro Yes, this is ready for review. One thing to note, when/if SRTP bug is fixed, it will be possible to change current implementation to BindN methods for all cases except Async, since Async doesn't conform with SRTP restrictions, so it will be a breaking change for Async. An option to avoid it is to remove Async support for and! right from the beginning, or to enhance Async by adding getAwaiter method there, so it will start being compatible.

@TheAngryByrd implementing Source is basically rewriting all the work I did from zero :) I think we can keep it only in IcedTasks, so users will have an option to choose for their project. Also, current implementation can be rewritten to Source any time later, so we are not making the final call here.

@T-Gro
Copy link
Member

T-Gro commented Apr 17, 2025

This looks good.

Could anyone please file new examples going into https://learn.microsoft.com/en-us/dotnet/fsharp/language-reference/task-expressions (as NET10 addition) ?
I don't see any further documentation needs beyond that page.

@github-project-automation github-project-automation bot moved this from New to In Progress in F# Compiler and Tooling Apr 17, 2025
@T-Gro T-Gro merged commit 69be2cd into dotnet:main Apr 17, 2025
33 checks passed
T-Gro added a commit that referenced this pull request Apr 24, 2025
* Consolidated two `SynExpr.LetOrUseBang` patterns with `isUse = true` (#18472)

* `and!` support in TaskBulder (#18451)

* Update package Category (#18479)

* Update dependencies from https://github.com/dotnet/source-build-reference-packages build 20250423.3 (#18494)

Microsoft.SourceBuild.Intermediate.source-build-reference-packages
 From Version 9.0.0-alpha.1.25209.1 -> To Version 9.0.0-alpha.1.25223.3

Co-authored-by: dotnet-maestro[bot] <dotnet-maestro[bot]@users.noreply.github.com>

---------

Co-authored-by: Edgar Gonzalez <[email protected]>
Co-authored-by: Vladimir Shchur <[email protected]>
Co-authored-by: Matt Mitchell <[email protected]>
Co-authored-by: dotnet-maestro[bot] <42748379+dotnet-maestro[bot]@users.noreply.github.com>
Co-authored-by: dotnet-maestro[bot] <dotnet-maestro[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
Archived in project
Development

Successfully merging this pull request may close these issues.

6 participants