Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using BenchmarkDotNet.Jobs;

namespace BenchmarkDotNet.Attributes;

/// <inheritdoc cref="AccuracyMode.ConsumeTasksSynchronously"/>
public class ConsumeTasksSynchronouslyAttribute(bool value) : JobMutatorConfigBaseAttribute(Job.Default.WithConsumeTasksSynchronously(value))
{
}
5 changes: 5 additions & 0 deletions src/BenchmarkDotNet/Code/CodeGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
133 changes: 118 additions & 15 deletions src/BenchmarkDotNet/Code/DeclarationsProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
", ",
Expand Down Expand Up @@ -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(<T>)-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)
Expand Down
3 changes: 3 additions & 0 deletions src/BenchmarkDotNet/ConsoleArguments/CommandLineOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }

Expand Down
1 change: 1 addition & 0 deletions src/BenchmarkDotNet/Environments/EnvironmentResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
119 changes: 119 additions & 0 deletions src/BenchmarkDotNet/Helpers/AwaitHelper.cs
Original file line number Diff line number Diff line change
@@ -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>(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<T>(this Task<T> 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<T>(this ValueTask<T> 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<>);
}
}
18 changes: 16 additions & 2 deletions src/BenchmarkDotNet/Jobs/AccuracyMode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public sealed class AccuracyMode : JobMode<AccuracyMode>
public static readonly Characteristic<TimeInterval> MinIterationTimeCharacteristic = CreateCharacteristic<TimeInterval>(nameof(MinIterationTime));
public static readonly Characteristic<int> MinInvokeCountCharacteristic = CreateCharacteristic<int>(nameof(MinInvokeCount));
public static readonly Characteristic<bool> EvaluateOverheadCharacteristic = CreateCharacteristic<bool>(nameof(EvaluateOverhead));
public static readonly Characteristic<bool> ConsumeTasksSynchronouslyCharacteristic = CreateCharacteristic<bool>(nameof(ConsumeTasksSynchronously));
public static readonly Characteristic<OutlierMode> OutlierModeCharacteristic = CreateCharacteristic<OutlierMode>(nameof(OutlierMode));
public static readonly Characteristic<bool> AnalyzeLaunchVarianceCharacteristic = CreateCharacteristic<bool>(nameof(AnalyzeLaunchVariance));

Expand Down Expand Up @@ -59,15 +60,28 @@ public int MinInvokeCount
}

/// <summary>
/// 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.
/// </summary>
public bool EvaluateOverhead
{
get => EvaluateOverheadCharacteristic[this];
set => EvaluateOverheadCharacteristic[this] = value;
}

/// <summary>
/// Specifies whether (Value)Task-returning benchmarks should be consumed synchronously.
/// False by default.
/// </summary>
/// <remarks>
/// Intended to make async benchmark results comparable to historical results obtained from older BenchmarkDotNet versions. Recommended to leave false for new benchmarks.
/// </remarks>
public bool ConsumeTasksSynchronously
{
get => ConsumeTasksSynchronouslyCharacteristic[this];
set => ConsumeTasksSynchronouslyCharacteristic[this] = value;
}

/// <summary>
/// Specifies which outliers should be removed from the distribution.
/// </summary>
Expand Down
3 changes: 3 additions & 0 deletions src/BenchmarkDotNet/Jobs/JobExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,9 @@ public static Job WithMsBuildArguments(this Job job, params string[] msBuildArgu
/// </summary>
public static Job WithEvaluateOverhead(this Job job, bool value) => job.WithCore(j => j.Accuracy.EvaluateOverhead = value);

/// <inheritdoc cref="AccuracyMode.ConsumeTasksSynchronously"/>
public static Job WithConsumeTasksSynchronously(this Job job, bool value) => job.WithCore(j => j.Accuracy.ConsumeTasksSynchronously = value);

/// <summary>
/// Specifies which outliers should be removed from the distribution
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, int> s_asyncMethodToOrdinalMap = new()
protected virtual IReadOnlyDictionary<string, int> AsyncMethodToOrdinalMap { get; } = new Dictionary<string, int>
{
{ GlobalSetupMethodName, 4 },
{ GlobalCleanupMethodName, 5 },
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading