diff --git a/src/BenchmarkDotNet/Attributes/Mutators/ConsumeTasksSynchronouslyAttribute.cs b/src/BenchmarkDotNet/Attributes/Mutators/ConsumeTasksSynchronouslyAttribute.cs new file mode 100644 index 0000000000..7e95882608 --- /dev/null +++ b/src/BenchmarkDotNet/Attributes/Mutators/ConsumeTasksSynchronouslyAttribute.cs @@ -0,0 +1,8 @@ +using BenchmarkDotNet.Jobs; + +namespace BenchmarkDotNet.Attributes; + +/// +public class ConsumeTasksSynchronouslyAttribute(bool value) : JobMutatorConfigBaseAttribute(Job.Default.WithConsumeTasksSynchronously(value)) +{ +} diff --git a/src/BenchmarkDotNet/Code/CodeGenerator.cs b/src/BenchmarkDotNet/Code/CodeGenerator.cs index 418fcfd637..8cf9f6c784 100644 --- a/src/BenchmarkDotNet/Code/CodeGenerator.cs +++ b/src/BenchmarkDotNet/Code/CodeGenerator.cs @@ -115,6 +115,11 @@ private static DeclarationsProvider GetDeclarationsProvider(BenchmarkCase benchm if (method.ReturnType.IsAwaitable(out var awaitableInfo)) { + if (benchmark.Job.ResolveValue(AccuracyMode.ConsumeTasksSynchronouslyCharacteristic, EnvironmentResolver.Instance) + && AwaitHelper.IsBuiltInTaskType(method.ReturnType)) + { + return new SyncTaskDeclarationsProvider(benchmark); + } return new AsyncDeclarationsProvider(benchmark, awaitableInfo.ResultType); } diff --git a/src/BenchmarkDotNet/Code/DeclarationsProvider.cs b/src/BenchmarkDotNet/Code/DeclarationsProvider.cs index ba52e0ac4f..f7b1fe750a 100644 --- a/src/BenchmarkDotNet/Code/DeclarationsProvider.cs +++ b/src/BenchmarkDotNet/Code/DeclarationsProvider.cs @@ -2,6 +2,7 @@ using BenchmarkDotNet.Engines; using BenchmarkDotNet.Environments; using BenchmarkDotNet.Extensions; +using BenchmarkDotNet.Helpers; using BenchmarkDotNet.Jobs; using BenchmarkDotNet.Running; using Perfolizer.Horology; @@ -89,6 +90,24 @@ private static string GetMethodPrefix(MethodInfo method) protected string GetWorkloadMethodCall(string passArguments) => $"{GetMethodPrefix(Descriptor.WorkloadMethod)}.{Descriptor.WorkloadMethod.Name}({passArguments});"; + protected string GetLoadArguments() + => string.Join( + Environment.NewLine, + Descriptor.WorkloadMethod.GetParameters() + .Select((parameter, index) => + { + var refModifier = parameter.ParameterType.IsByRef ? "ref" : string.Empty; + return $"{refModifier} {parameter.ParameterType.GetCorrectCSharpTypeName()} arg{index} = {refModifier} this.__fieldsContainer.argField{index};"; + }) + ); + + protected string GetPassArguments() + => string.Join( + ", ", + Descriptor.WorkloadMethod.GetParameters() + .Select((parameter, index) => $"{CodeGenerator.GetParameterModifier(parameter)} arg{index}") + ); + protected string GetPassArgumentsDirect() => string.Join( ", ", @@ -167,24 +186,108 @@ protected override SmartStringBuilder ReplaceCore(SmartStringBuilder smartString return smartStringBuilder .Replace("$CoreImpl$", coreImpl); } + } - private string GetLoadArguments() - => string.Join( - Environment.NewLine, - Descriptor.WorkloadMethod.GetParameters() - .Select((parameter, index) => + // Used when Job.Accuracy.ConsumeTasksSynchronously is enabled for (Value)Task()-returning workloads. + // Generates a synchronous loop that blocks on the returned task via AwaitHelper.GetResult, matching the + // pre-async-refactor behavior so historical results stay comparable. + internal sealed class SyncTaskDeclarationsProvider(BenchmarkCase benchmark) : DeclarationsProvider(benchmark) + { + public override string[] GetExtraFields() => []; + + protected override SmartStringBuilder ReplaceCore(SmartStringBuilder smartStringBuilder) + { + // Suppress the SynchronizationContext to avoid deadlocks when async awaits are configured to + // continue on the captured context (which is the default Task behavior), and restore it after. + string synchronizationContextTypeName = typeof(SynchronizationContext).GetCorrectCSharpTypeName(); + string loadArguments = GetLoadArguments(); + string passArguments = GetPassArguments(); + string workloadMethodCall = $"global::{typeof(AwaitHelper).FullName}.{nameof(AwaitHelper.GetResult)}({GetWorkloadMethodCall(passArguments).TrimEnd(';')});"; + string coreImpl = $$""" + private {{CoreReturnType}} OverheadActionUnroll({{CoreParameters}}) { - var refModifier = parameter.ParameterType.IsByRef ? "ref" : string.Empty; - return $"{refModifier} {parameter.ParameterType.GetCorrectCSharpTypeName()} arg{index} = {refModifier} this.__fieldsContainer.argField{index};"; - }) - ); + {{synchronizationContextTypeName}} originalContext = {{synchronizationContextTypeName}}.Current; + {{synchronizationContextTypeName}}.SetSynchronizationContext(null); + try + { + {{loadArguments}} + {{StartClockSyncCode}} + while (--invokeCount >= 0) + { + this.__Overhead({{passArguments}});@Unroll@ + } + {{ReturnSyncCode}} + } + finally + { + {{synchronizationContextTypeName}}.SetSynchronizationContext(originalContext); + } + } - private string GetPassArguments() - => string.Join( - ", ", - Descriptor.WorkloadMethod.GetParameters() - .Select((parameter, index) => $"{CodeGenerator.GetParameterModifier(parameter)} arg{index}") - ); + private {{CoreReturnType}} OverheadActionNoUnroll({{CoreParameters}}) + { + {{synchronizationContextTypeName}} originalContext = {{synchronizationContextTypeName}}.Current; + {{synchronizationContextTypeName}}.SetSynchronizationContext(null); + try + { + {{loadArguments}} + {{StartClockSyncCode}} + while (--invokeCount >= 0) + { + this.__Overhead({{passArguments}}); + } + {{ReturnSyncCode}} + } + finally + { + {{synchronizationContextTypeName}}.SetSynchronizationContext(originalContext); + } + } + + private {{CoreReturnType}} WorkloadActionUnroll({{CoreParameters}}) + { + {{synchronizationContextTypeName}} originalContext = {{synchronizationContextTypeName}}.Current; + {{synchronizationContextTypeName}}.SetSynchronizationContext(null); + try + { + {{loadArguments}} + {{StartClockSyncCode}} + while (--invokeCount >= 0) + { + {{workloadMethodCall}}@Unroll@ + } + {{ReturnSyncCode}} + } + finally + { + {{synchronizationContextTypeName}}.SetSynchronizationContext(originalContext); + } + } + + private {{CoreReturnType}} WorkloadActionNoUnroll({{CoreParameters}}) + { + {{synchronizationContextTypeName}} originalContext = {{synchronizationContextTypeName}}.Current; + {{synchronizationContextTypeName}}.SetSynchronizationContext(null); + try + { + {{loadArguments}} + {{StartClockSyncCode}} + while (--invokeCount >= 0) + { + {{workloadMethodCall}} + } + {{ReturnSyncCode}} + } + finally + { + {{synchronizationContextTypeName}}.SetSynchronizationContext(originalContext); + } + } + """; + + return smartStringBuilder + .Replace("$CoreImpl$", coreImpl); + } } internal abstract class AsyncDeclarationsProviderBase(BenchmarkCase benchmark) : DeclarationsProvider(benchmark) diff --git a/src/BenchmarkDotNet/ConsoleArguments/CommandLineOptions.cs b/src/BenchmarkDotNet/ConsoleArguments/CommandLineOptions.cs index 543e1306ac..89279e5faa 100644 --- a/src/BenchmarkDotNet/ConsoleArguments/CommandLineOptions.cs +++ b/src/BenchmarkDotNet/ConsoleArguments/CommandLineOptions.cs @@ -228,6 +228,9 @@ public bool UseDisassemblyDiagnoser [Option("evaluateOverhead", Required = false, HelpText = "Specifies whether to run and evaluate overhead iterations.")] public bool? EvaluateOverhead { get; set; } + [Option("consumeTasksSynchronously", Required = false, Default = false, HelpText = "Specifies whether to consume (Value)Task-returning benchmarks synchronously.")] + public bool ConsumeTasksSynchronously { get; set; } + [Option("resume", Required = false, Default = false, HelpText = "Continue the execution if the last run was stopped.")] public bool Resume { get; set; } diff --git a/src/BenchmarkDotNet/Environments/EnvironmentResolver.cs b/src/BenchmarkDotNet/Environments/EnvironmentResolver.cs index 675a1f8df8..48b96df4a1 100644 --- a/src/BenchmarkDotNet/Environments/EnvironmentResolver.cs +++ b/src/BenchmarkDotNet/Environments/EnvironmentResolver.cs @@ -23,6 +23,7 @@ private EnvironmentResolver() // TODO: find a better place Register(AccuracyMode.AnalyzeLaunchVarianceCharacteristic, () => false); + Register(AccuracyMode.ConsumeTasksSynchronouslyCharacteristic, () => false); Register(RunMode.UnrollFactorCharacteristic, job => { // TODO: move it to another place and use the main resolver diff --git a/src/BenchmarkDotNet/Helpers/AwaitHelper.cs b/src/BenchmarkDotNet/Helpers/AwaitHelper.cs new file mode 100644 index 0000000000..f7f0f00299 --- /dev/null +++ b/src/BenchmarkDotNet/Helpers/AwaitHelper.cs @@ -0,0 +1,119 @@ +using JetBrains.Annotations; +using System.ComponentModel; +using System.Reflection; +using System.Runtime.CompilerServices; + +namespace BenchmarkDotNet.Helpers; + +[UsedImplicitly] +[EditorBrowsable(EditorBrowsableState.Never)] +public static class AwaitHelper +{ + private class ValueTaskWaiter + { + // We use thread static field so that each thread uses its own individual callback and reset event. + [ThreadStatic] + private static ValueTaskWaiter? ts_current; + internal static ValueTaskWaiter Current => ts_current ??= new ValueTaskWaiter(); + + // We cache the callback to prevent allocations for memory diagnoser. + private readonly Action awaiterCallback; + private readonly ManualResetEventSlim resetEvent; + + private ValueTaskWaiter() + { + resetEvent = new(); + awaiterCallback = resetEvent.Set; + } + + internal void Wait(TAwaiter awaiter) where TAwaiter : ICriticalNotifyCompletion + { + resetEvent.Reset(); + awaiter.UnsafeOnCompleted(awaiterCallback); + + // The fastest way to wait for completion is to spin a bit before waiting on the event. This is the same logic that Task.GetAwaiter().GetResult() uses. + var spinner = new SpinWait(); + while (!resetEvent.IsSet) + { + if (spinner.NextSpinWillYield) + { + resetEvent.Wait(); + return; + } + spinner.SpinOnce(); + } + } + } + + // we use GetAwaiter().GetResult() because it's fastest way to obtain the result in blocking way, + // and will eventually throw actual exception, not aggregated one + public static void GetResult(this Task task) => task.GetAwaiter().GetResult(); + + public static T GetResult(this Task task) => task.GetAwaiter().GetResult(); + + // ValueTask can be backed by an IValueTaskSource that only supports asynchronous awaits, + // so we have to hook up a callback instead of calling .GetAwaiter().GetResult() like we do for Task. + // The alternative is to convert it to Task using .AsTask(), but that causes allocations which we must avoid for memory diagnoser. + public static void GetResult(this ValueTask task) + { + // Don't continue on the captured context, as that may result in a deadlock if the user runs this in-process. + var awaiter = task.ConfigureAwait(false).GetAwaiter(); + if (!awaiter.IsCompleted) + { + ValueTaskWaiter.Current.Wait(awaiter); + } + awaiter.GetResult(); + } + + public static T GetResult(this ValueTask task) + { + // Don't continue on the captured context, as that may result in a deadlock if the user runs this in-process. + var awaiter = task.ConfigureAwait(false).GetAwaiter(); + if (!awaiter.IsCompleted) + { + ValueTaskWaiter.Current.Wait(awaiter); + } + return awaiter.GetResult(); + } + + internal static MethodInfo? GetGetResultMethod(Type taskType) + { + if (!taskType.IsGenericType) + { + return typeof(AwaitHelper).GetMethod(nameof(GetResult), BindingFlags.Public | BindingFlags.Static, null, [taskType], null)!; + } + var genericTypeDefinition = taskType.GetGenericTypeDefinition(); + Type? compareType = genericTypeDefinition == typeof(ValueTask<>) ? typeof(ValueTask<>) + : genericTypeDefinition == typeof(Task<>) ? typeof(Task<>) + : null; + if (compareType == null) + { + return null; + } + var resultType = taskType + .GetMethod(nameof(Task.GetAwaiter), BindingFlags.Public | BindingFlags.Instance)! + .ReturnType + .GetMethod(nameof(TaskAwaiter.GetResult), BindingFlags.Public | BindingFlags.Instance)! + .ReturnType; + return typeof(AwaitHelper).GetMethods(BindingFlags.Public | BindingFlags.Static) + .First(m => + { + if (m.Name != nameof(GetResult)) + return false; + Type paramType = m.GetParameters().First().ParameterType; + return paramType.IsGenericType && paramType.GetGenericTypeDefinition() == compareType; + }) + .MakeGenericMethod([resultType]); + } + + internal static bool IsBuiltInTaskType(Type type) + { + if (!type.IsGenericType) + { + return type == typeof(ValueTask) || type == typeof(Task); + } + var genericTypeDefinition = type.GetGenericTypeDefinition(); + return genericTypeDefinition == typeof(ValueTask<>) + || genericTypeDefinition == typeof(Task<>); + } +} \ No newline at end of file diff --git a/src/BenchmarkDotNet/Jobs/AccuracyMode.cs b/src/BenchmarkDotNet/Jobs/AccuracyMode.cs index 97b794dbd0..3b11150d15 100644 --- a/src/BenchmarkDotNet/Jobs/AccuracyMode.cs +++ b/src/BenchmarkDotNet/Jobs/AccuracyMode.cs @@ -13,6 +13,7 @@ public sealed class AccuracyMode : JobMode public static readonly Characteristic MinIterationTimeCharacteristic = CreateCharacteristic(nameof(MinIterationTime)); public static readonly Characteristic MinInvokeCountCharacteristic = CreateCharacteristic(nameof(MinInvokeCount)); public static readonly Characteristic EvaluateOverheadCharacteristic = CreateCharacteristic(nameof(EvaluateOverhead)); + public static readonly Characteristic ConsumeTasksSynchronouslyCharacteristic = CreateCharacteristic(nameof(ConsumeTasksSynchronously)); public static readonly Characteristic OutlierModeCharacteristic = CreateCharacteristic(nameof(OutlierMode)); public static readonly Characteristic AnalyzeLaunchVarianceCharacteristic = CreateCharacteristic(nameof(AnalyzeLaunchVariance)); @@ -59,8 +60,8 @@ public int MinInvokeCount } /// - /// Specifies if the overhead should be evaluated (Idle runs) and it's average value subtracted from every result. - /// True by default, very important for nano-benchmarks. + /// Specifies if the overhead should be evaluated (Idle runs) and its average value subtracted from every result. + /// False by default. /// public bool EvaluateOverhead { @@ -68,6 +69,19 @@ public bool EvaluateOverhead set => EvaluateOverheadCharacteristic[this] = value; } + /// + /// Specifies whether (Value)Task-returning benchmarks should be consumed synchronously. + /// False by default. + /// + /// + /// Intended to make async benchmark results comparable to historical results obtained from older BenchmarkDotNet versions. Recommended to leave false for new benchmarks. + /// + public bool ConsumeTasksSynchronously + { + get => ConsumeTasksSynchronouslyCharacteristic[this]; + set => ConsumeTasksSynchronouslyCharacteristic[this] = value; + } + /// /// Specifies which outliers should be removed from the distribution. /// diff --git a/src/BenchmarkDotNet/Jobs/JobExtensions.cs b/src/BenchmarkDotNet/Jobs/JobExtensions.cs index 2842c8d694..9099e15ed5 100644 --- a/src/BenchmarkDotNet/Jobs/JobExtensions.cs +++ b/src/BenchmarkDotNet/Jobs/JobExtensions.cs @@ -299,6 +299,9 @@ public static Job WithMsBuildArguments(this Job job, params string[] msBuildArgu /// public static Job WithEvaluateOverhead(this Job job, bool value) => job.WithCore(j => j.Accuracy.EvaluateOverhead = value); + /// + public static Job WithConsumeTasksSynchronously(this Job job, bool value) => job.WithCore(j => j.Accuracy.ConsumeTasksSynchronously = value); + /// /// Specifies which outliers should be removed from the distribution /// diff --git a/src/BenchmarkDotNet/Toolchains/InProcess/Emit/Implementation/Emitters/AsyncStateMachineEmitter.cs b/src/BenchmarkDotNet/Toolchains/InProcess/Emit/Implementation/Emitters/AsyncStateMachineEmitter.cs index bd0528d122..81519664d6 100644 --- a/src/BenchmarkDotNet/Toolchains/InProcess/Emit/Implementation/Emitters/AsyncStateMachineEmitter.cs +++ b/src/BenchmarkDotNet/Toolchains/InProcess/Emit/Implementation/Emitters/AsyncStateMachineEmitter.cs @@ -12,7 +12,7 @@ partial class RunnableEmitter // Roslyn generates ordinals in declaration order of every member. // We don't necessarily emit members in the same order (or at all in the case of Runnable_#.Run), so we map it to the expected Roslyn ordinal. // This doesn't really matter for the runtime, but it helps with the NaiveRunnableEmitDiff tests. - private readonly Dictionary s_asyncMethodToOrdinalMap = new() + protected virtual IReadOnlyDictionary AsyncMethodToOrdinalMap { get; } = new Dictionary { { GlobalSetupMethodName, 4 }, { GlobalCleanupMethodName, 5 }, @@ -134,7 +134,7 @@ private AsyncStateMachineBuilderInfo BeginAsyncStateMachineTypeBuilder(string ca [CompilerGenerated] private struct <__GlobalSetup>d__4 : IAsyncStateMachine */ - int ordinal = s_asyncMethodToOrdinalMap[callerMethodName]; + int ordinal = AsyncMethodToOrdinalMap[callerMethodName]; var asyncStateMachineTypeBuilder = runnableBuilder.DefineNestedType( $"<{callerMethodName}>d__{ordinal}", TypeAttributes.NestedPrivate | TypeAttributes.AutoLayout | TypeAttributes.Sealed | TypeAttributes.BeforeFieldInit, diff --git a/src/BenchmarkDotNet/Toolchains/InProcess/Emit/Implementation/Emitters/RunnableEmitter.cs b/src/BenchmarkDotNet/Toolchains/InProcess/Emit/Implementation/Emitters/RunnableEmitter.cs index 4c15ba95e6..b2fcd89257 100644 --- a/src/BenchmarkDotNet/Toolchains/InProcess/Emit/Implementation/Emitters/RunnableEmitter.cs +++ b/src/BenchmarkDotNet/Toolchains/InProcess/Emit/Implementation/Emitters/RunnableEmitter.cs @@ -1,5 +1,6 @@ using BenchmarkDotNet.Configs; using BenchmarkDotNet.Extensions; +using BenchmarkDotNet.Helpers; using BenchmarkDotNet.Helpers.Reflection.Emit; using BenchmarkDotNet.Jobs; using BenchmarkDotNet.Loggers; @@ -72,7 +73,17 @@ public static Assembly EmitPartitionAssembly(GenerateResult generateResult, Buil var returnType = benchmark.BenchmarkCase.Descriptor.WorkloadMethod.ReturnType; RunnableEmitter runnableEmitter; if (returnType.IsAwaitable(out var awaitableInfo)) - runnableEmitter = new AsyncCoreEmitter(buildPartition, moduleBuilder, benchmark, awaitableInfo); + { + if (benchmark.BenchmarkCase.Job.ResolveValue(AccuracyMode.ConsumeTasksSynchronouslyCharacteristic, buildPartition.Resolver) + && AwaitHelper.IsBuiltInTaskType(returnType)) + { + runnableEmitter = new SyncTaskCoreEmitter(buildPartition, moduleBuilder, benchmark); + } + else + { + runnableEmitter = new AsyncCoreEmitter(buildPartition, moduleBuilder, benchmark, awaitableInfo); + } + } else if (returnType.IsAsyncEnumerable(out var asyncEnumerableInfo)) runnableEmitter = new AsyncEnumerableCoreEmitter(buildPartition, moduleBuilder, benchmark, asyncEnumerableInfo); else diff --git a/src/BenchmarkDotNet/Toolchains/InProcess/Emit/Implementation/Emitters/SyncTaskCoreEmitter.cs b/src/BenchmarkDotNet/Toolchains/InProcess/Emit/Implementation/Emitters/SyncTaskCoreEmitter.cs new file mode 100644 index 0000000000..9f4175ce0c --- /dev/null +++ b/src/BenchmarkDotNet/Toolchains/InProcess/Emit/Implementation/Emitters/SyncTaskCoreEmitter.cs @@ -0,0 +1,173 @@ +using BenchmarkDotNet.Extensions; +using BenchmarkDotNet.Helpers; +using BenchmarkDotNet.Helpers.Reflection.Emit; +using BenchmarkDotNet.Running; +using Perfolizer.Horology; +using System.Reflection; +using System.Reflection.Emit; +using static BenchmarkDotNet.Toolchains.InProcess.Emit.Implementation.RunnableConstants; + +namespace BenchmarkDotNet.Toolchains.InProcess.Emit.Implementation; + +partial class RunnableEmitter +{ + // Used when Job.Accuracy.ConsumeTasksSynchronously is enabled for (Value)Task()-returning workloads. + // Emits the same shape as SyncCoreEmitter but routes the workload return value through AwaitHelper.GetResult + // so the iteration loop stays synchronous, matching the pre-async-refactor behavior so historical results stay comparable. + private sealed class SyncTaskCoreEmitter(BuildPartition buildPartition, ModuleBuilder moduleBuilder, BenchmarkBuildInfo benchmark) : RunnableEmitter(buildPartition, moduleBuilder, benchmark) + { + private static readonly MethodInfo SynchronizationContextGetCurrent = + typeof(SynchronizationContext).GetProperty(nameof(SynchronizationContext.Current), BindingFlags.Public | BindingFlags.Static)!.GetMethod!; + private static readonly MethodInfo SynchronizationContextSetCurrent = + typeof(SynchronizationContext).GetMethod(nameof(SynchronizationContext.SetSynchronizationContext), BindingFlags.Public | BindingFlags.Static, null, [typeof(SynchronizationContext)], null)!; + + // The workload is consumed synchronously, so the only async state machines are the setup/cleanup + // methods. Without arguments there is no fields container declared before them, so their Roslyn + // ordinals are two lower than in the async path (which always declares the fields container). + // With arguments the fields container shifts them back to the async ordinals. + protected override IReadOnlyDictionary AsyncMethodToOrdinalMap + => argFields.Count > 0 + ? base.AsyncMethodToOrdinalMap + : new Dictionary + { + { GlobalSetupMethodName, 2 }, + { GlobalCleanupMethodName, 3 }, + { IterationSetupMethodName, 4 }, + { IterationCleanupMethodName, 5 }, + }; + + protected override void EmitExtraGlobalCleanup(ILGenerator ilBuilder, LocalBuilder? thisLocal) { } + + protected override void EmitCoreImpl() + { + EmitAction(OverheadActionUnrollMethodName, overheadImplementationMethod, jobUnrollFactor, isWorkload: false); + EmitAction(OverheadActionNoUnrollMethodName, overheadImplementationMethod, 1, isWorkload: false); + EmitAction(WorkloadActionUnrollMethodName, Descriptor.WorkloadMethod, jobUnrollFactor, isWorkload: true); + EmitAction(WorkloadActionNoUnrollMethodName, Descriptor.WorkloadMethod, 1, isWorkload: true); + } + + private MethodBuilder EmitAction(string methodName, MethodInfo methodToCall, int unrollFactor, bool isWorkload) + { + MethodInfo? getResultMethod = null; + if (isWorkload) + { + getResultMethod = AwaitHelper.GetGetResultMethod(methodToCall.ReturnType) + ?? throw new InvalidOperationException( + $"AwaitHelper.GetResult is not available for workload return type {methodToCall.ReturnType.GetDisplayName()}. ConsumeTasksSynchronously only supports (Value)Task()."); + } + + var invokeCountArg = new EmitParameterInfo(0, InvokeCountParamName, typeof(long)); + var actionMethodBuilder = runnableBuilder + .DefineNonVirtualInstanceMethod( + methodName, + MethodAttributes.Private, + EmitParameterInfo.CreateReturnParameter(typeof(ValueTask)), + [ + invokeCountArg, + new EmitParameterInfo(1, ClockParamName, typeof(IClock)) + ] + ) + .SetAggressiveOptimizationImplementationFlag(); + invokeCountArg.SetMember(actionMethodBuilder); + + var ilBuilder = actionMethodBuilder.GetILGenerator(); + + var originalContextLocal = ilBuilder.DeclareLocal(typeof(SynchronizationContext)); + var argLocals = argFields.Select(a => ilBuilder.DeclareLocal(a.ArgLocalsType)).ToList(); + var startedClockLocal = ilBuilder.DeclareLocal(typeof(StartedClock)); + var resultLocal = ilBuilder.DeclareLocal(typeof(ValueTask)); + var endLabel = ilBuilder.DefineLabel(); + + // Suppress the SynchronizationContext to avoid deadlocks when async awaits are configured to + // continue on the captured context (which is the default Task behavior), and restore it after. + // SynchronizationContext originalContext = SynchronizationContext.Current; + ilBuilder.Emit(OpCodes.Call, SynchronizationContextGetCurrent); + ilBuilder.EmitStloc(originalContextLocal); + // SynchronizationContext.SetSynchronizationContext(null); + ilBuilder.Emit(OpCodes.Ldnull); + ilBuilder.Emit(OpCodes.Call, SynchronizationContextSetCurrent); + + ilBuilder.BeginExceptionBlock(); + { + // load fields + EmitLoadArgFieldsToLocals(ilBuilder, argLocals); + + // StartedClock startedClock = ClockExtensions.Start(clock); + ilBuilder.Emit(OpCodes.Ldarg_2); + ilBuilder.Emit(OpCodes.Call, GetStartClockMethod()); + ilBuilder.EmitStloc(startedClockLocal); + + // loop + ilBuilder.EmitLoopBeginFromArgToZero(out var loopStartLabel, out var loopHeadLabel); + { + for (int u = 0; u < unrollFactor; u++) + { + if (!methodToCall.IsStatic) + { + ilBuilder.Emit(OpCodes.Ldarg_0); + } + ilBuilder.EmitLdLocals(argLocals); + ilBuilder.Emit(OpCodes.Call, methodToCall); + + if (getResultMethod is not null) + { + // global::BenchmarkDotNet.Helpers.AwaitHelper.GetResult(); + ilBuilder.Emit(OpCodes.Call, getResultMethod); + // Generic Task/ValueTask overloads return T — discard it, mirroring the pre-refactor + // void ExecuteBlocking() => AwaitHelper.GetResult(callback()) implementation. + if (getResultMethod.ReturnType != typeof(void)) + { + ilBuilder.Emit(OpCodes.Pop); + } + } + else if (methodToCall.ReturnType != typeof(void)) + { + ilBuilder.Emit(OpCodes.Pop); + } + } + } + ilBuilder.EmitLoopEndFromArgToZero(loopStartLabel, loopHeadLabel, invokeCountArg); + + // resultLocal = new ValueTask(startedClock.GetElapsed()); + ilBuilder.EmitLdloca(startedClockLocal); + ilBuilder.Emit(OpCodes.Call, typeof(StartedClock).GetMethod(nameof(StartedClock.GetElapsed), BindingFlags.Public | BindingFlags.Instance)!); + ilBuilder.Emit(OpCodes.Newobj, typeof(ValueTask).GetConstructor([typeof(ClockSpan)])!); + ilBuilder.EmitStloc(resultLocal); + ilBuilder.Emit(OpCodes.Leave, endLabel); + } + ilBuilder.BeginFinallyBlock(); + { + // SynchronizationContext.Current = originalContext; + ilBuilder.EmitLdloc(originalContextLocal); + ilBuilder.Emit(OpCodes.Call, SynchronizationContextSetCurrent); + } + ilBuilder.EndExceptionBlock(); + + ilBuilder.MarkLabel(endLabel); + ilBuilder.EmitLdloc(resultLocal); + ilBuilder.Emit(OpCodes.Ret); + + return actionMethodBuilder; + } + + private void EmitLoadArgFieldsToLocals(ILGenerator ilBuilder, List argLocals) + { + for (int i = 0; i < argFields.Count; i++) + { + ilBuilder.Emit(OpCodes.Ldarg_0); + ilBuilder.Emit(OpCodes.Ldflda, fieldsContainerField); + + var argFieldInfo = argFields[i]; + if (argFieldInfo.ArgLocalsType.IsByRef) + ilBuilder.Emit(OpCodes.Ldflda, argFieldInfo.Field); + else + ilBuilder.Emit(OpCodes.Ldfld, argFieldInfo.Field); + + if (argFieldInfo.OpImplicitMethod != null) + ilBuilder.Emit(OpCodes.Call, argFieldInfo.OpImplicitMethod); + + ilBuilder.EmitStloc(argLocals[i]); + } + } + } +} diff --git a/src/BenchmarkDotNet/Toolchains/InProcess/InProcessValidator.cs b/src/BenchmarkDotNet/Toolchains/InProcess/InProcessValidator.cs index a06f11894d..ba650ceeb6 100644 --- a/src/BenchmarkDotNet/Toolchains/InProcess/InProcessValidator.cs +++ b/src/BenchmarkDotNet/Toolchains/InProcess/InProcessValidator.cs @@ -44,6 +44,7 @@ public class InProcessValidator : IValidator { RunMode.UnrollFactorCharacteristic, DontValidate }, { AccuracyMode.AnalyzeLaunchVarianceCharacteristic, DontValidate }, { AccuracyMode.EvaluateOverheadCharacteristic, DontValidate }, + { AccuracyMode.ConsumeTasksSynchronouslyCharacteristic, DontValidate }, { AccuracyMode.MaxRelativeErrorCharacteristic, DontValidate }, { AccuracyMode.MaxAbsoluteErrorCharacteristic, DontValidate }, { AccuracyMode.MinInvokeCountCharacteristic, DontValidate }, diff --git a/src/BenchmarkDotNet/Toolchains/InProcess/NoEmit/BenchmarkActionFactory.cs b/src/BenchmarkDotNet/Toolchains/InProcess/NoEmit/BenchmarkActionFactory.cs index b152fc4c6f..4f7fa22609 100644 --- a/src/BenchmarkDotNet/Toolchains/InProcess/NoEmit/BenchmarkActionFactory.cs +++ b/src/BenchmarkDotNet/Toolchains/InProcess/NoEmit/BenchmarkActionFactory.cs @@ -7,7 +7,7 @@ namespace BenchmarkDotNet.Toolchains.InProcess.NoEmit; internal static class BenchmarkActionFactory { - private static IBenchmarkAction CreateCore(IBenchmarkActionFactory? factory, object instance, MethodInfo targetMethod, int unrollFactor) + private static IBenchmarkAction CreateCore(IBenchmarkActionFactory? factory, object instance, MethodInfo targetMethod, int unrollFactor, bool consumeTasksSynchronously = false) { if (factory?.TryCreate(instance, targetMethod, unrollFactor, out var benchmarkAction) == true) { @@ -41,10 +41,14 @@ private static IBenchmarkAction CreateCore(IBenchmarkActionFactory? factory, obj } if (resultType == typeof(Task)) - return new BenchmarkActionTask(resultInstance, targetMethod, unrollFactor); + return consumeTasksSynchronously + ? new BenchmarkActionBlockingTask(resultInstance, targetMethod, unrollFactor) + : new BenchmarkActionTask(resultInstance, targetMethod, unrollFactor); if (resultType == typeof(ValueTask)) - return new BenchmarkActionValueTask(resultInstance, targetMethod, unrollFactor); + return consumeTasksSynchronously + ? new BenchmarkActionBlockingValueTask(resultInstance, targetMethod, unrollFactor) + : new BenchmarkActionValueTask(resultInstance, targetMethod, unrollFactor); if (resultType.GetTypeInfo().IsGenericType) { @@ -52,14 +56,18 @@ private static IBenchmarkAction CreateCore(IBenchmarkActionFactory? factory, obj var argType = resultType.GenericTypeArguments[0]; if (typeof(Task<>) == genericType) return Create( - typeof(BenchmarkActionTask<>).MakeGenericType(argType), + consumeTasksSynchronously + ? typeof(BenchmarkActionBlockingTask<>).MakeGenericType(argType) + : typeof(BenchmarkActionTask<>).MakeGenericType(argType), resultInstance, targetMethod, unrollFactor); - if (typeof(ValueTask<>).IsAssignableFrom(genericType)) + if (typeof(ValueTask<>) == genericType) return Create( - typeof(BenchmarkActionValueTask<>).MakeGenericType(argType), + consumeTasksSynchronously + ? typeof(BenchmarkActionBlockingValueTask<>).MakeGenericType(argType) + : typeof(BenchmarkActionValueTask<>).MakeGenericType(argType), resultInstance, targetMethod, unrollFactor); @@ -125,8 +133,8 @@ private static BenchmarkActionBase Create(Type actionType, object? instance, Met private static readonly MethodInfo FallbackSignature = new Action(BenchmarkActionBase.OverheadStatic).GetMethodInfo(); - public static IBenchmarkAction CreateWorkload(IBenchmarkActionFactory? factory, Descriptor descriptor, object instance, int unrollFactor) => - CreateCore(factory, instance, descriptor.WorkloadMethod, unrollFactor); + public static IBenchmarkAction CreateWorkload(IBenchmarkActionFactory? factory, Descriptor descriptor, object instance, int unrollFactor, bool consumeTasksSynchronously = false) => + CreateCore(factory, instance, descriptor.WorkloadMethod, unrollFactor, consumeTasksSynchronously); public static IBenchmarkAction CreateOverhead(IBenchmarkActionFactory? factory, Descriptor descriptor, object instance, int unrollFactor) => CreateCore(factory, instance, FallbackSignature, unrollFactor); diff --git a/src/BenchmarkDotNet/Toolchains/InProcess/NoEmit/BenchmarkActionImpl.cs b/src/BenchmarkDotNet/Toolchains/InProcess/NoEmit/BenchmarkActionImpl.cs index 51148cb31e..316e65f2ba 100644 --- a/src/BenchmarkDotNet/Toolchains/InProcess/NoEmit/BenchmarkActionImpl.cs +++ b/src/BenchmarkDotNet/Toolchains/InProcess/NoEmit/BenchmarkActionImpl.cs @@ -1,5 +1,6 @@ using BenchmarkDotNet.Attributes.CompilerServices; using BenchmarkDotNet.Engines; +using BenchmarkDotNet.Helpers; using Perfolizer.Horology; using System.Diagnostics.CodeAnalysis; using System.Reflection; @@ -702,3 +703,327 @@ private async Task WorkloadCore() public override void Cleanup() => workloadValueTaskSource.Complete(); } + +// Blocking variants used when Job.Accuracy.ConsumeTasksSynchronously is enabled. They drive the workload +// through AwaitHelper.GetResult so the iteration loop stays synchronous, matching the pre-async-refactor behavior +// so historical results stay comparable. + +[AggressivelyOptimizeMethods] +public sealed class BenchmarkActionBlockingTask : BenchmarkActionBase +{ + private readonly Func callback; + private readonly Action blockingCallback; + private readonly Action unrolledBlockingCallback; + + [SetsRequiredMembers] + public BenchmarkActionBlockingTask(object? instance, MethodInfo method, int unrollFactor) + { + callback = CreateWorkload>(instance, method); + blockingCallback = InvokeBlocking; + unrolledBlockingCallback = Unroll(blockingCallback, unrollFactor); + InvokeSingle = InvokeOnce; + InvokeUnroll = WorkloadActionUnroll; + InvokeNoUnroll = WorkloadActionNoUnroll; + } + + private void InvokeBlocking() => AwaitHelper.GetResult(callback()); + + private ValueTask InvokeOnce() + { + // Setup/cleanup calls go through InvokeSingle → InvokeOnce, so apply the same SynchronizationContext + // suppression the workload loop uses to avoid deadlocking on user awaits that capture the context. + SynchronizationContext? originalContext = SynchronizationContext.Current; + SynchronizationContext.SetSynchronizationContext(null); + try + { + blockingCallback(); + return new(); + } + finally + { + SynchronizationContext.SetSynchronizationContext(originalContext); + } + } + + private ValueTask WorkloadActionUnroll(long invokeCount, IClock clock) + { + // Suppress the SynchronizationContext to avoid deadlocks when async awaits are configured to + // continue on the captured context (which is the default Task behavior), and restore it after. + SynchronizationContext? originalContext = SynchronizationContext.Current; + SynchronizationContext.SetSynchronizationContext(null); + try + { + var startedClock = clock.Start(); + while (--invokeCount >= 0) + { + unrolledBlockingCallback(); + } + return new ValueTask(startedClock.GetElapsed()); + } + finally + { + SynchronizationContext.SetSynchronizationContext(originalContext); + } + } + + private ValueTask WorkloadActionNoUnroll(long invokeCount, IClock clock) + { + // Suppress the SynchronizationContext to avoid deadlocks when async awaits are configured to + // continue on the captured context (which is the default Task behavior), and restore it after. + SynchronizationContext? originalContext = SynchronizationContext.Current; + SynchronizationContext.SetSynchronizationContext(null); + try + { + var startedClock = clock.Start(); + while (--invokeCount >= 0) + { + blockingCallback(); + } + return new ValueTask(startedClock.GetElapsed()); + } + finally + { + SynchronizationContext.SetSynchronizationContext(originalContext); + } + } +} + +[AggressivelyOptimizeMethods] +public sealed class BenchmarkActionBlockingTask : BenchmarkActionBase +{ + private readonly Func> callback; + private readonly Action blockingCallback; + private readonly Action unrolledBlockingCallback; + + [SetsRequiredMembers] + public BenchmarkActionBlockingTask(object? instance, MethodInfo method, int unrollFactor) + { + callback = CreateWorkload>>(instance, method); + blockingCallback = InvokeBlocking; + unrolledBlockingCallback = Unroll(blockingCallback, unrollFactor); + InvokeSingle = InvokeOnce; + InvokeUnroll = WorkloadActionUnroll; + InvokeNoUnroll = WorkloadActionNoUnroll; + } + + private void InvokeBlocking() => AwaitHelper.GetResult(callback()); + + private ValueTask InvokeOnce() + { + // Setup/cleanup calls go through InvokeSingle → InvokeOnce, so apply the same SynchronizationContext + // suppression the workload loop uses to avoid deadlocking on user awaits that capture the context. + SynchronizationContext? originalContext = SynchronizationContext.Current; + SynchronizationContext.SetSynchronizationContext(null); + try + { + blockingCallback(); + return new(); + } + finally + { + SynchronizationContext.SetSynchronizationContext(originalContext); + } + } + + private ValueTask WorkloadActionUnroll(long invokeCount, IClock clock) + { + // Suppress the SynchronizationContext to avoid deadlocks when async awaits are configured to + // continue on the captured context (which is the default Task behavior), and restore it after. + SynchronizationContext? originalContext = SynchronizationContext.Current; + SynchronizationContext.SetSynchronizationContext(null); + try + { + var startedClock = clock.Start(); + while (--invokeCount >= 0) + { + unrolledBlockingCallback(); + } + return new ValueTask(startedClock.GetElapsed()); + } + finally + { + SynchronizationContext.SetSynchronizationContext(originalContext); + } + } + + private ValueTask WorkloadActionNoUnroll(long invokeCount, IClock clock) + { + // Suppress the SynchronizationContext to avoid deadlocks when async awaits are configured to + // continue on the captured context (which is the default Task behavior), and restore it after. + SynchronizationContext? originalContext = SynchronizationContext.Current; + SynchronizationContext.SetSynchronizationContext(null); + try + { + var startedClock = clock.Start(); + while (--invokeCount >= 0) + { + blockingCallback(); + } + return new ValueTask(startedClock.GetElapsed()); + } + finally + { + SynchronizationContext.SetSynchronizationContext(originalContext); + } + } +} + +[AggressivelyOptimizeMethods] +public sealed class BenchmarkActionBlockingValueTask : BenchmarkActionBase +{ + private readonly Func callback; + private readonly Action blockingCallback; + private readonly Action unrolledBlockingCallback; + + [SetsRequiredMembers] + public BenchmarkActionBlockingValueTask(object? instance, MethodInfo method, int unrollFactor) + { + callback = CreateWorkload>(instance, method); + blockingCallback = InvokeBlocking; + unrolledBlockingCallback = Unroll(blockingCallback, unrollFactor); + InvokeSingle = InvokeOnce; + InvokeUnroll = WorkloadActionUnroll; + InvokeNoUnroll = WorkloadActionNoUnroll; + } + + private void InvokeBlocking() => AwaitHelper.GetResult(callback()); + + private ValueTask InvokeOnce() + { + // Setup/cleanup calls go through InvokeSingle → InvokeOnce, so apply the same SynchronizationContext + // suppression the workload loop uses to avoid deadlocking on user awaits that capture the context. + SynchronizationContext? originalContext = SynchronizationContext.Current; + SynchronizationContext.SetSynchronizationContext(null); + try + { + blockingCallback(); + return new(); + } + finally + { + SynchronizationContext.SetSynchronizationContext(originalContext); + } + } + + private ValueTask WorkloadActionUnroll(long invokeCount, IClock clock) + { + // Suppress the SynchronizationContext to avoid deadlocks when async awaits are configured to + // continue on the captured context (which is the default Task behavior), and restore it after. + SynchronizationContext? originalContext = SynchronizationContext.Current; + SynchronizationContext.SetSynchronizationContext(null); + try + { + var startedClock = clock.Start(); + while (--invokeCount >= 0) + { + unrolledBlockingCallback(); + } + return new ValueTask(startedClock.GetElapsed()); + } + finally + { + SynchronizationContext.SetSynchronizationContext(originalContext); + } + } + + private ValueTask WorkloadActionNoUnroll(long invokeCount, IClock clock) + { + // Suppress the SynchronizationContext to avoid deadlocks when async awaits are configured to + // continue on the captured context (which is the default Task behavior), and restore it after. + SynchronizationContext? originalContext = SynchronizationContext.Current; + SynchronizationContext.SetSynchronizationContext(null); + try + { + var startedClock = clock.Start(); + while (--invokeCount >= 0) + { + blockingCallback(); + } + return new ValueTask(startedClock.GetElapsed()); + } + finally + { + SynchronizationContext.SetSynchronizationContext(originalContext); + } + } +} + +[AggressivelyOptimizeMethods] +public sealed class BenchmarkActionBlockingValueTask : BenchmarkActionBase +{ + private readonly Func> callback; + private readonly Action blockingCallback; + private readonly Action unrolledBlockingCallback; + + [SetsRequiredMembers] + public BenchmarkActionBlockingValueTask(object? instance, MethodInfo method, int unrollFactor) + { + callback = CreateWorkload>>(instance, method); + blockingCallback = InvokeBlocking; + unrolledBlockingCallback = Unroll(blockingCallback, unrollFactor); + InvokeSingle = InvokeOnce; + InvokeUnroll = WorkloadActionUnroll; + InvokeNoUnroll = WorkloadActionNoUnroll; + } + + private void InvokeBlocking() => AwaitHelper.GetResult(callback()); + + private ValueTask InvokeOnce() + { + // Setup/cleanup calls go through InvokeSingle → InvokeOnce, so apply the same SynchronizationContext + // suppression the workload loop uses to avoid deadlocking on user awaits that capture the context. + SynchronizationContext? originalContext = SynchronizationContext.Current; + SynchronizationContext.SetSynchronizationContext(null); + try + { + blockingCallback(); + return new(); + } + finally + { + SynchronizationContext.SetSynchronizationContext(originalContext); + } + } + + private ValueTask WorkloadActionUnroll(long invokeCount, IClock clock) + { + // Suppress the SynchronizationContext to avoid deadlocks when async awaits are configured to + // continue on the captured context (which is the default Task behavior), and restore it after. + SynchronizationContext? originalContext = SynchronizationContext.Current; + SynchronizationContext.SetSynchronizationContext(null); + try + { + var startedClock = clock.Start(); + while (--invokeCount >= 0) + { + unrolledBlockingCallback(); + } + return new ValueTask(startedClock.GetElapsed()); + } + finally + { + SynchronizationContext.SetSynchronizationContext(originalContext); + } + } + + private ValueTask WorkloadActionNoUnroll(long invokeCount, IClock clock) + { + // Suppress the SynchronizationContext to avoid deadlocks when async awaits are configured to + // continue on the captured context (which is the default Task behavior), and restore it after. + SynchronizationContext? originalContext = SynchronizationContext.Current; + SynchronizationContext.SetSynchronizationContext(null); + try + { + var startedClock = clock.Start(); + while (--invokeCount >= 0) + { + blockingCallback(); + } + return new ValueTask(startedClock.GetElapsed()); + } + finally + { + SynchronizationContext.SetSynchronizationContext(originalContext); + } + } +} diff --git a/src/BenchmarkDotNet/Toolchains/InProcess/NoEmit/InProcessNoEmitRunner.cs b/src/BenchmarkDotNet/Toolchains/InProcess/NoEmit/InProcessNoEmitRunner.cs index 28dc146f36..d3af6d0942 100644 --- a/src/BenchmarkDotNet/Toolchains/InProcess/NoEmit/InProcessNoEmitRunner.cs +++ b/src/BenchmarkDotNet/Toolchains/InProcess/NoEmit/InProcessNoEmitRunner.cs @@ -137,10 +137,11 @@ public static async ValueTask RunCore(IHost host, ExecuteParameters parameters, var target = benchmarkCase.Descriptor; var job = new Job().Apply(benchmarkCase.Job).Freeze(); int unrollFactor = benchmarkCase.Job.ResolveValue(RunMode.UnrollFactorCharacteristic, EnvironmentResolver.Instance); + bool consumeTasksSynchronously = benchmarkCase.Job.ResolveValue(AccuracyMode.ConsumeTasksSynchronouslyCharacteristic, EnvironmentResolver.Instance); // DONTTOUCH: these should be allocated together var instance = Activator.CreateInstance(benchmarkCase.Descriptor.Type)!; - var workloadAction = BenchmarkActionFactory.CreateWorkload(benchmarkActionFactory, target, instance, unrollFactor); + var workloadAction = BenchmarkActionFactory.CreateWorkload(benchmarkActionFactory, target, instance, unrollFactor, consumeTasksSynchronously); var overheadAction = BenchmarkActionFactory.CreateOverhead(benchmarkActionFactory, target, instance, unrollFactor); var globalSetupAction = BenchmarkActionFactory.CreateGlobalSetup(benchmarkActionFactory, target, instance); var globalCleanupAction = BenchmarkActionFactory.CreateGlobalCleanup(benchmarkActionFactory, target, instance); diff --git a/tests/BenchmarkDotNet.IntegrationTests/AsyncBenchmarksTests.cs b/tests/BenchmarkDotNet.IntegrationTests/AsyncBenchmarksTests.cs index 03a0fd09b6..566c155c03 100644 --- a/tests/BenchmarkDotNet.IntegrationTests/AsyncBenchmarksTests.cs +++ b/tests/BenchmarkDotNet.IntegrationTests/AsyncBenchmarksTests.cs @@ -43,10 +43,32 @@ public class AsyncBenchmarksTests : BenchmarkTestExecutor { public AsyncBenchmarksTests(ITestOutputHelper output) : base(output) { } - [Fact] - public void TaskReturningMethodsAreAwaited() + public static IEnumerable GetToolchains() => + [ + new InProcessEmitToolchain(new() { ExecuteOnSeparateThread = false }), + new InProcessEmitToolchain(new() { ExecuteOnSeparateThread = true }), + new InProcessNoEmitToolchain(new() { ExecuteOnSeparateThread = false }), + new InProcessNoEmitToolchain(new() { ExecuteOnSeparateThread = true }), + Job.Default.GetToolchain() + ]; + + public static TheoryData GetToolchainsWithConsumeTasksSynchronously() { - var summary = CanExecute(); + var data = new TheoryData(); + foreach (var toolchain in GetToolchains()) + { + data.Add(toolchain, false); + data.Add(toolchain, true); + } + return data; + } + + [Theory] + [MemberData(nameof(GetToolchainsWithConsumeTasksSynchronously), DisableDiscoveryEnumeration = true)] + public void TaskReturningMethodsAreAwaited(IToolchain toolchain, bool consumeTasksSynchronously) + { + var summary = CanExecute(CreateSimpleConfig( + job: Job.Dry.WithToolchain(toolchain).WithConsumeTasksSynchronously(consumeTasksSynchronously))); foreach (var report in summary.Reports) foreach (var measurement in report.AllMeasurements) @@ -58,29 +80,24 @@ public void TaskReturningMethodsAreAwaited() } } - [Fact] - public void TaskReturningMethodsAreAwaited_AlreadyComplete() => CanExecute(); - - public static TheoryData GetToolchains() => - [ - new InProcessEmitToolchain(new() { ExecuteOnSeparateThread = false }), - new InProcessEmitToolchain(new() { ExecuteOnSeparateThread = true }), - new InProcessNoEmitToolchain(new() { ExecuteOnSeparateThread = false }), - new InProcessNoEmitToolchain(new() { ExecuteOnSeparateThread = true }), - Job.Default.GetToolchain() - ]; + [Theory] + [MemberData(nameof(GetToolchainsWithConsumeTasksSynchronously), DisableDiscoveryEnumeration = true)] + public void TaskReturningMethodsAreAwaited_AlreadyComplete(IToolchain toolchain, bool consumeTasksSynchronously) + => CanExecute(CreateSimpleConfig( + job: Job.Dry.WithToolchain(toolchain).WithConsumeTasksSynchronously(consumeTasksSynchronously))); [Theory] - [MemberData(nameof(GetToolchains), DisableDiscoveryEnumeration = true)] - public void TaskYieldWithNullSyncContext(IToolchain toolchain) - => CanExecute(CreateSimpleConfig(job: Job.Dry.WithToolchain(toolchain))); + [MemberData(nameof(GetToolchainsWithConsumeTasksSynchronously), DisableDiscoveryEnumeration = true)] + public void TaskYieldWithNullSyncContext(IToolchain toolchain, bool consumeTasksSynchronously) + => CanExecute(CreateSimpleConfig( + job: Job.Dry.WithToolchain(toolchain).WithConsumeTasksSynchronously(consumeTasksSynchronously))); // #3103 [Theory] - [MemberData(nameof(GetToolchains), DisableDiscoveryEnumeration = true)] - public void AsyncWorkloadRestartsAfterMemoryRandomization(IToolchain toolchain) + [MemberData(nameof(GetToolchainsWithConsumeTasksSynchronously), DisableDiscoveryEnumeration = true)] + public void AsyncWorkloadRestartsAfterMemoryRandomization(IToolchain toolchain, bool consumeTasksSynchronously) => CanExecute(CreateSimpleConfig( - job: Job.Dry.WithToolchain(toolchain).WithIterationCount(3).WithMemoryRandomization(true))); + job: Job.Dry.WithToolchain(toolchain).WithIterationCount(3).WithMemoryRandomization(true).WithConsumeTasksSynchronously(consumeTasksSynchronously))); public class RandomMemoryAsyncBenchmarks { diff --git a/tests/BenchmarkDotNet.IntegrationTests/InProcess.EmitTests.T4/RunnableTaskCaseBenchmark.cs b/tests/BenchmarkDotNet.IntegrationTests/InProcess.EmitTests.T4/RunnableTaskCaseBenchmark.cs index ec85224c36..4f1a3a1283 100644 --- a/tests/BenchmarkDotNet.IntegrationTests/InProcess.EmitTests.T4/RunnableTaskCaseBenchmark.cs +++ b/tests/BenchmarkDotNet.IntegrationTests/InProcess.EmitTests.T4/RunnableTaskCaseBenchmark.cs @@ -19,6 +19,18 @@ namespace BenchmarkDotNet.IntegrationTests.InProcess.EmitTests /// public class RunnableTaskCaseBenchmark { + [GlobalSetup] + public async ValueTask GlobalSetup() => await Task.Yield(); + + [GlobalCleanup] + public async Task GlobalCleanup() => await Task.Yield(); + + [IterationSetup] + public async ValueTask IterationSetup() => await Task.Yield(); + + [IterationCleanup] + public async Task IterationCleanup() => await Task.Yield(); + // ---- Begin TaskCase(Task) ---- [Benchmark] diff --git a/tests/BenchmarkDotNet.IntegrationTests/InProcess.EmitTests.T4/RunnableTaskCaseBenchmark.tt b/tests/BenchmarkDotNet.IntegrationTests/InProcess.EmitTests.T4/RunnableTaskCaseBenchmark.tt index 761aab422b..26cfc6b2bc 100644 --- a/tests/BenchmarkDotNet.IntegrationTests/InProcess.EmitTests.T4/RunnableTaskCaseBenchmark.tt +++ b/tests/BenchmarkDotNet.IntegrationTests/InProcess.EmitTests.T4/RunnableTaskCaseBenchmark.tt @@ -22,6 +22,18 @@ namespace BenchmarkDotNet.IntegrationTests.InProcess.EmitTests /// public class RunnableTaskCaseBenchmark { + [GlobalSetup] + public async ValueTask GlobalSetup() => await Task.Yield(); + + [GlobalCleanup] + public async Task GlobalCleanup() => await Task.Yield(); + + [IterationSetup] + public async ValueTask IterationSetup() => await Task.Yield(); + + [IterationCleanup] + public async Task IterationCleanup() => await Task.Yield(); + <# int counter = 1; EmitTaskCaseBenchmark(ref counter, "Task", "Task.CompletedTask"); diff --git a/tests/BenchmarkDotNet.IntegrationTests/InProcessEmitTest.cs b/tests/BenchmarkDotNet.IntegrationTests/InProcessEmitTest.cs index 1f0e342233..7c6b7bdfb8 100644 --- a/tests/BenchmarkDotNet.IntegrationTests/InProcessEmitTest.cs +++ b/tests/BenchmarkDotNet.IntegrationTests/InProcessEmitTest.cs @@ -32,7 +32,7 @@ private IConfig CreateInProcessConfig(OutputLogger? logger) .AddColumnProvider(DefaultColumnProviders.Instance); } - private IConfig CreateInProcessAndRoslynConfig(OutputLogger logger) + private IConfig CreateInProcessAndRoslynConfig(OutputLogger logger, bool consumeTasksSynchronously = false) { var config = new ManualConfig() .AddColumnProvider(DefaultConfig.Instance.GetColumnProviders().ToArray()) @@ -44,12 +44,14 @@ private IConfig CreateInProcessAndRoslynConfig(OutputLogger logger) Job.Dry .WithToolchain(InProcessEmitToolchain.Default) .WithInvocationCount(4) - .WithUnrollFactor(4)) + .WithUnrollFactor(4) + .WithConsumeTasksSynchronously(consumeTasksSynchronously)) .AddJob( Job.Dry .WithToolchain(new RoslynToolchain()) .WithInvocationCount(4) - .WithUnrollFactor(4)) + .WithUnrollFactor(4) + .WithConsumeTasksSynchronously(consumeTasksSynchronously)) .WithOptions(ConfigOptions.KeepBenchmarkFiles) .AddLogger(logger ?? (Output != null ? new OutputLogger(Output) : ConsoleLogger.Default)); @@ -103,20 +105,21 @@ public void InProcessBenchmarkSimpleCasesReflectionEmitSupported() } [TheoryEnvSpecific("We can't use Roslyn toolchain for .NET Core because we don't know which assemblies to reference and .NET Core does not support dynamic assembly saving", EnvRequirement.FullFrameworkOnly)] - [InlineData(typeof(SampleBenchmark))] - [InlineData(typeof(RunnableVoidCaseBenchmark))] - [InlineData(typeof(RunnableRefStructCaseBenchmark))] - [InlineData(typeof(RunnableStructCaseBenchmark))] - [InlineData(typeof(RunnableClassCaseBenchmark))] - [InlineData(typeof(RunnableManyArgsCaseBenchmark))] - [InlineData(typeof(RunnableTaskCaseBenchmark))] - [InlineData(typeof(AsyncEnumerableBenchmarksTests.AsyncEnumerableBenchmarks))] - [InlineData(typeof(AsyncEnumerableBenchmarksTests.AsyncEnumerableCallerOverride))] - [InlineData(typeof(AsyncEnumerableBenchmarksTests.CustomAsyncEnumerableBenchmarks))] - public void InProcessBenchmarkEmitsSameIL(Type benchmarkType) + [InlineData(typeof(SampleBenchmark), false)] + [InlineData(typeof(RunnableVoidCaseBenchmark), false)] + [InlineData(typeof(RunnableRefStructCaseBenchmark), false)] + [InlineData(typeof(RunnableStructCaseBenchmark), false)] + [InlineData(typeof(RunnableClassCaseBenchmark), false)] + [InlineData(typeof(RunnableManyArgsCaseBenchmark), false)] + [InlineData(typeof(RunnableTaskCaseBenchmark), false)] + [InlineData(typeof(RunnableTaskCaseBenchmark), true)] + [InlineData(typeof(AsyncEnumerableBenchmarksTests.AsyncEnumerableBenchmarks), false)] + [InlineData(typeof(AsyncEnumerableBenchmarksTests.AsyncEnumerableCallerOverride), false)] + [InlineData(typeof(AsyncEnumerableBenchmarksTests.CustomAsyncEnumerableBenchmarks), false)] + public void InProcessBenchmarkEmitsSameIL(Type benchmarkType, bool consumeTasksSynchronously) { var logger = new OutputLogger(Output); - var config = CreateInProcessAndRoslynConfig(logger); + var config = CreateInProcessAndRoslynConfig(logger, consumeTasksSynchronously); var summary = CanExecute(benchmarkType, config); diff --git a/tests/BenchmarkDotNet.Tests/ConfigParserTests.cs b/tests/BenchmarkDotNet.Tests/ConfigParserTests.cs index 8a8313c4fc..20e353cdce 100644 --- a/tests/BenchmarkDotNet.Tests/ConfigParserTests.cs +++ b/tests/BenchmarkDotNet.Tests/ConfigParserTests.cs @@ -748,6 +748,19 @@ public void UsersCanSpecifyEvaluateOverhead() } } + [Fact] + public void UsersCanSpecifyConsumeTasksSynchronously() + { + var parsedConfiguration = ConfigParser.Parse(["--consumeTasksSynchronously", "true"], new OutputLogger(Output)); + Assert.NotNull(parsedConfiguration.config); + Assert.True(parsedConfiguration.isSuccess); + + foreach (var job in parsedConfiguration.config.GetJobs()) + { + Assert.True(job.Accuracy.ConsumeTasksSynchronously); + } + } + [Fact(Skip = "This should be handled somehow at CommandLineParser level. See https://github.com/commandlineparser/commandline/pull/892")] public void UserCanSpecifyWasmArgs() {