Skip to content

Commit 0e02b24

Browse files
timcassellclaude
andauthored
Point in-process disassembly at the benchmark's own runnable type. (#3269)
Run host-side disasm in a threadpool thread. Remove unnecessary single-method branch. The in-process host passed a default BenchmarkId to diagnosers, so every benchmark of a run was disassembled as Runnable_0. The disassembler also resolved the runnable by name across all loaded modules, and earlier in-process runs leave same-named runnables loaded (never unloaded when saved to disk), so it could disassemble a previous run's code. The host now passes the real BenchmarkId and the emitted runnable type, and the disassembler resolves that type by its method table. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent c13dacc commit 0e02b24

9 files changed

Lines changed: 97 additions & 16 deletions

File tree

‎src/BenchmarkDotNet/Diagnosers/DiagnoserActionParameters.cs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ public DiagnoserActionParameters(Process? process, BenchmarkCase benchmarkCase,
2121

2222
public BenchmarkId BenchmarkId { get; }
2323

24+
/// <summary>The runnable type an in-process toolchain emitted for this benchmark, which only it can name unambiguously.</summary>
25+
internal Type? InProcessRunnableType { get; init; }
26+
2427
public ImmutableConfig Config => BenchmarkCase.Config;
2528
}
2629
}

‎src/BenchmarkDotNet/Disassemblers/ClrMdArgs.cs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ internal struct ClrMdArgs(int processId, string typeName, string methodName, boo
1111
[JsonIgnore]
1212
internal string TypeName = typeName ?? "";
1313

14+
// Identifies the type exactly when the disassembler runs in the process that loaded it. Several loaded modules can
15+
// define a type of the same name: an in-process toolchain emits the same runnable type names on every run.
16+
[JsonIgnore]
17+
internal IntPtr TypeHandle;
18+
1419
[JsonInclude]
1520
internal string MethodName = methodName;
1621

‎src/BenchmarkDotNet/Disassemblers/ClrMdDisassembler.cs‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,11 @@ internal DisassemblyResult AttachAndDisassemble(ClrMdArgs args)
8686
}
8787
else
8888
{
89-
var typeWithBenchmark = state.Runtime.EnumerateModules().Select(module => module.GetTypeByName(args.TypeName)).WhereNotNull().First();
89+
// A type handle is the type's method table, so it cannot resolve to a same-named type of another module.
90+
var typeWithBenchmark = args.TypeHandle != IntPtr.Zero
91+
? state.Runtime.GetTypeByMethodTable((ulong)args.TypeHandle.ToInt64())
92+
?? throw new InvalidOperationException($"No type has the method table {args.TypeHandle.ToInt64():X} ({args.TypeName}).")
93+
: state.Runtime.EnumerateModules().Select(module => module.GetTypeByName(args.TypeName)).WhereNotNull().First();
9094

9195
// The whole signature, because the name alone is not enough: ClrType.Methods also surfaces inherited methods
9296
// on the desktop CLR, and ClrMethod.Type reports the type being enumerated rather than the declaring one, so
@@ -104,9 +108,7 @@ internal DisassemblyResult AttachAndDisassemble(ClrMdArgs args)
104108
// we don't want to export the disassembler entry point method which is just an artificial method added to get generic types working.
105109
// Identified by its native code address rather than its name: a signature does not always carry the declaring
106110
// type (see AddressToNameMapping below), so a benchmark method of the same name could match it.
107-
var filteredMethods = disassembledMethods.Length == 1
108-
? disassembledMethods // if there is only one method we want to return it (most probably benchmark got inlined)
109-
: disassembledMethods.Where(method => method.NativeCode != entryPoint?.NativeCode).ToArray();
111+
var filteredMethods = disassembledMethods.Where(method => method.NativeCode != entryPoint?.NativeCode).ToArray();
110112

111113
return new DisassemblyResult
112114
{

‎src/BenchmarkDotNet/Disassemblers/DisassemblyDiagnoser.cs‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,15 @@ public async ValueTask HandleAsync(HostSignal signal, DiagnoserActionParameters
9595
switch (signal)
9696
{
9797
case HostSignal.AfterAll when (Config.RunInHost || isInProcess) && ShouldUseClrMdDisassembler(benchmark):
98-
results.Add(benchmark, ClrMdDisassembler.AttachAndDisassemble(
99-
BuildClrMdArgs(parameters.BenchmarkCase, $"{RunnableConstants.EmittedTypePrefix}{parameters.BenchmarkId.Value}", parameters.ProcessId))
100-
);
98+
await ThreadPoolHelper.Switch(cancellationToken);
99+
100+
var clrMdArgs = BuildClrMdArgs(parameters.BenchmarkCase, $"{RunnableConstants.EmittedTypePrefix}{parameters.BenchmarkId.Value}", parameters.ProcessId);
101+
if (parameters.InProcessRunnableType is { } runnableType)
102+
{
103+
clrMdArgs.TypeName = runnableType.FullName!;
104+
clrMdArgs.TypeHandle = runnableType.TypeHandle.Value;
105+
}
106+
results.Add(benchmark, ClrMdDisassembler.AttachAndDisassemble(clrMdArgs));
101107
break;
102108
case HostSignal.SeparateLogic when ShouldUseMonoDisassembler(benchmark):
103109
var result = await monoDisassembler.Disassemble(benchmark, cancellationToken).ConfigureAwait(false);
@@ -270,6 +276,7 @@ ValueTask IInProcessDiagnoserHandler.HandleAsync(BenchmarkSignal signal, InProce
270276
var clrMdArgs = _clrMdArgs;
271277
clrMdArgs.ProcessId = Process.GetCurrentProcess().Id;
272278
clrMdArgs.TypeName = args.BenchmarkInstance.GetType().FullName!;
279+
clrMdArgs.TypeHandle = args.BenchmarkInstance.GetType().TypeHandle.Value;
273280
_result = DisassemblyDiagnoser.GetClrMdDisassembler().AttachAndDisassemble(clrMdArgs);
274281
return new();
275282
}

‎src/BenchmarkDotNet/Toolchains/InProcess/Emit/InProcessEmitExecutor.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ internal class InProcessEmitExecutor(bool executeOnSeparateThread) : IExecutor
1515
{
1616
public async ValueTask<ExecuteResult> ExecuteAsync(ExecuteParameters executeParameters, CancellationToken cancellationToken)
1717
{
18-
var host = new InProcessHost(executeParameters.BenchmarkCase, executeParameters.Logger, executeParameters.Diagnoser, cancellationToken);
18+
var host = new InProcessHost(executeParameters.BenchmarkCase, executeParameters.BenchmarkId, InProcessEmitRunner.GetRunnableType(executeParameters), executeParameters.Logger, executeParameters.Diagnoser, cancellationToken);
1919

2020
int exitCode = -1;
2121
if (executeOnSeparateThread)

‎src/BenchmarkDotNet/Toolchains/InProcess/Emit/InProcessEmitRunner.cs‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,7 @@ public static async ValueTask<int> Run(IHost host, ExecuteParameters parameters)
2222

2323
try
2424
{
25-
var runnableType = ((InProcessEmitArtifactsPath)parameters.BuildResult.ArtifactsPaths)
26-
.GeneratedAssembly
27-
.GetType(EmittedTypePrefix + parameters.BenchmarkId)!;
28-
29-
await RunCore(runnableType, host, parameters).ConfigureAwait();
25+
await RunCore(GetRunnableType(parameters), host, parameters).ConfigureAwait();
3026

3127
return 0;
3228
}
@@ -54,6 +50,11 @@ public static async ValueTask<int> Run(IHost host, ExecuteParameters parameters)
5450
}
5551
}
5652

53+
internal static Type GetRunnableType(ExecuteParameters parameters)
54+
=> ((InProcessEmitArtifactsPath)parameters.BuildResult.ArtifactsPaths)
55+
.GeneratedAssembly
56+
.GetType(EmittedTypePrefix + parameters.BenchmarkId)!;
57+
5758
private static async ValueTask RunCore(Type runnableType, IHost host, ExecuteParameters parameters)
5859
{
5960
var benchmarkCase = parameters.BenchmarkCase;

‎src/BenchmarkDotNet/Toolchains/InProcess/InProcessHost.cs‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ internal sealed class InProcessHost : IHost
1919
private readonly List<string> inProcessDiagnoserLines = [];
2020
private readonly List<string> prefixedLines = [];
2121

22-
public InProcessHost(BenchmarkCase benchmarkCase, ILogger logger, IDiagnoser diagnoser, CancellationToken cancellationToken)
22+
public InProcessHost(BenchmarkCase benchmarkCase, BenchmarkId benchmarkId, Type? runnableType, ILogger logger, IDiagnoser diagnoser, CancellationToken cancellationToken)
2323
{
2424
this.logger = logger;
2525
this.diagnoser = diagnoser;
@@ -30,7 +30,10 @@ public InProcessHost(BenchmarkCase benchmarkCase, ILogger logger, IDiagnoser dia
3030
diagnoserActionParameters = new DiagnoserActionParameters(
3131
Process.GetCurrentProcess(),
3232
benchmarkCase,
33-
default);
33+
benchmarkId)
34+
{
35+
InProcessRunnableType = runnableType
36+
};
3437
}
3538

3639
public RunResults RunResults { get; private set; }

‎src/BenchmarkDotNet/Toolchains/InProcess/NoEmit/InProcessNoEmitExecutor.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ internal class InProcessNoEmitExecutor(bool executeOnSeparateThread, IBenchmarkA
1515
{
1616
public async ValueTask<ExecuteResult> ExecuteAsync(ExecuteParameters executeParameters, CancellationToken cancellationToken)
1717
{
18-
var host = new InProcessHost(executeParameters.BenchmarkCase, executeParameters.Logger, executeParameters.Diagnoser, cancellationToken);
18+
var host = new InProcessHost(executeParameters.BenchmarkCase, executeParameters.BenchmarkId, null, executeParameters.Logger, executeParameters.Diagnoser, cancellationToken);
1919

2020
int exitCode = -1;
2121
if (executeOnSeparateThread)

‎tests/BenchmarkDotNet.IntegrationTests/DisassemblyDiagnoserTests.cs‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,66 @@ public void CanDisassembleWhenBenchmarkDeclaresGeneratedMemberNames(Jit jit, Pla
210210
AssertDisassemblyResult(result, $"{nameof(WithGeneratedMemberNames.Called)}()");
211211
}
212212

213+
public class WithTwoBenchmarks
214+
{
215+
[Benchmark] public void First() => FirstCallee();
216+
217+
[Benchmark] public void Second() => SecondCallee();
218+
219+
[MethodImpl(MethodImplOptions.NoInlining)] public void FirstCallee() { }
220+
221+
[MethodImpl(MethodImplOptions.NoInlining)] public void SecondCallee() { }
222+
}
223+
224+
// Every benchmark of an in-process run is emitted into the same assembly, one runnable type each, so the disassembler
225+
// has to be pointed at the runnable of the benchmark it is handling rather than the first one.
226+
[Fact]
227+
public void InProcessDisassemblyTargetsTheRunnableOfEachBenchmark()
228+
{
229+
var disassemblyDiagnoser = new DisassemblyDiagnoser(
230+
new DisassemblyDiagnoserConfig(printSource: true, maxDepth: 3));
231+
232+
CanExecute<WithTwoBenchmarks>(CreateInProcessConfig(disassemblyDiagnoser));
233+
234+
Assert.Equal(2, disassemblyDiagnoser.Results.Count);
235+
foreach (var pair in disassemblyDiagnoser.Results)
236+
{
237+
var benchmarkCase = pair.Key;
238+
var result = pair.Value;
239+
Assert.Empty(result.Errors);
240+
string called = benchmarkCase.Descriptor.WorkloadMethod.Name == nameof(WithTwoBenchmarks.First)
241+
? nameof(WithTwoBenchmarks.FirstCallee)
242+
: nameof(WithTwoBenchmarks.SecondCallee);
243+
AssertDisassemblyResult(result, $"{benchmarkCase.Descriptor.WorkloadMethod.Name}()");
244+
AssertDisassemblyResult(result, $"{called}()");
245+
}
246+
}
247+
248+
// Each in-process run emits its runnables under the same type names, and the assemblies of earlier runs can still be
249+
// loaded (never collected when saved to disk, which KeepBenchmarkFiles does on .NET Framework).
250+
[Fact]
251+
public void InProcessDisassemblyIgnoresRunnablesOfEarlierRuns()
252+
{
253+
CanExecute<WithCalls>(ManualConfig.CreateEmpty()
254+
.AddJob(Job.Dry.WithToolchain(InProcessEmitToolchain.Default))
255+
.AddLogger(new OutputLogger(Output))
256+
.WithOptions(ConfigOptions.KeepBenchmarkFiles));
257+
258+
var disassemblyDiagnoser = new DisassemblyDiagnoser(
259+
new DisassemblyDiagnoserConfig(printSource: true, maxDepth: 3));
260+
261+
CanExecute<WithGeneratedMemberNames>(CreateInProcessConfig(disassemblyDiagnoser));
262+
263+
DisassemblyResult result = disassemblyDiagnoser.Results.Single().Value;
264+
265+
Assert.Empty(result.Errors);
266+
AssertDisassemblyResult(result, $"{nameof(WithGeneratedMemberNames.Benchmark)}()");
267+
AssertDisassemblyResult(result, $"{nameof(WithGeneratedMemberNames.Called)}()");
268+
}
269+
270+
private IConfig CreateInProcessConfig(IDiagnoser disassemblyDiagnoser)
271+
=> CreateConfig(JitInfo.GetCurrentJit(), RuntimeInformation.GetCurrentPlatform(), InProcessEmitToolchain.Default, disassemblyDiagnoser, RunStrategy.ColdStart);
272+
213273
private IConfig CreateConfig(Jit jit, Platform platform, IToolchain toolchain, IDiagnoser disassemblyDiagnoser, RunStrategy runStrategy)
214274
=> ManualConfig.CreateEmpty()
215275
.AddJob(Job.Dry.WithJit(jit)

0 commit comments

Comments
 (0)