Skip to content

Commit 8162a98

Browse files
committed
Added AggressiveOptimization to methods involved in measuring allocations.
Warm up allocation measurement before taking actual measurement. Isolated allocation measurement. Changed some `RuntimeInformation` properties to static readonly fields. Removed enable monitoring in Engine (GcStats handles it). Removed `GC.Collect()` from allocation measurement. Sleep thread to account for tiered jit in Core runtimes 3.0 to 6.0. Updated MemoryDiagnoserTests. Block finalizer thread during memory tests. Disabled EventSource for integration tests.
1 parent ca5dfdf commit 8162a98

File tree

7 files changed

+229
-99
lines changed

7 files changed

+229
-99
lines changed

src/BenchmarkDotNet/Engines/Engine.cs

+89-21
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using System.Globalization;
44
using System.Linq;
55
using System.Runtime.CompilerServices;
6+
using System.Threading;
67
using BenchmarkDotNet.Characteristics;
78
using BenchmarkDotNet.Jobs;
89
using BenchmarkDotNet.Portability;
@@ -214,31 +215,56 @@ private ClockSpan Measure(Action<long> action, long invokeCount)
214215

215216
private (GcStats, ThreadingStats, double) GetExtraStats(IterationData data)
216217
{
217-
// we enable monitoring after main target run, for this single iteration which is executed at the end
218-
// so even if we enable AppDomain monitoring in separate process
219-
// it does not matter, because we have already obtained the results!
220-
EnableMonitoring();
218+
// Warm up the measurement functions before starting the actual measurement.
219+
DeadCodeEliminationHelper.KeepAliveWithoutBoxing(GcStats.ReadInitial());
220+
DeadCodeEliminationHelper.KeepAliveWithoutBoxing(GcStats.ReadFinal());
221221

222222
IterationSetupAction(); // we run iteration setup first, so even if it allocates, it is not included in the results
223223

224224
var initialThreadingStats = ThreadingStats.ReadInitial(); // this method might allocate
225225
var exceptionsStats = new ExceptionsStats(); // allocates
226226
exceptionsStats.StartListening(); // this method might allocate
227-
var initialGcStats = GcStats.ReadInitial();
228227

229-
WorkloadAction(data.InvokeCount / data.UnrollFactor);
228+
#if !NET7_0_OR_GREATER
229+
if (RuntimeInformation.IsNetCore && Environment.Version.Major is >= 3 and <= 6 && RuntimeInformation.IsTieredJitEnabled)
230+
{
231+
// #1542
232+
// We put the current thread to sleep so tiered jit can kick in, compile its stuff,
233+
// and NOT allocate anything on the background thread when we are measuring allocations.
234+
// This is only an issue on netcoreapp3.0 to net6.0. Tiered jit allocations were "fixed" in net7.0
235+
// (maybe not completely eliminated forever, but at least reduced to a point where measurements are much more stable),
236+
// and netcoreapp2.X uses only GetAllocatedBytesForCurrentThread which doesn't capture the tiered jit allocations.
237+
Thread.Sleep(TimeSpan.FromMilliseconds(500));
238+
}
239+
#endif
230240

231-
exceptionsStats.Stop();
232-
var finalGcStats = GcStats.ReadFinal();
241+
// GC collect before measuring allocations.
242+
ForceGcCollect();
243+
GcStats gcStats;
244+
using (FinalizerBlocker.MaybeStart())
245+
{
246+
gcStats = MeasureWithGc(data.InvokeCount / data.UnrollFactor);
247+
}
248+
249+
exceptionsStats.Stop(); // this method might (de)allocate
233250
var finalThreadingStats = ThreadingStats.ReadFinal();
234251

235252
IterationCleanupAction(); // we run iteration cleanup after collecting GC stats
236253

237254
var totalOperationsCount = data.InvokeCount * OperationsPerInvoke;
238-
GcStats gcStats = (finalGcStats - initialGcStats).WithTotalOperations(totalOperationsCount);
239-
ThreadingStats threadingStats = (finalThreadingStats - initialThreadingStats).WithTotalOperations(data.InvokeCount * OperationsPerInvoke);
255+
return (gcStats.WithTotalOperations(totalOperationsCount),
256+
(finalThreadingStats - initialThreadingStats).WithTotalOperations(totalOperationsCount),
257+
exceptionsStats.ExceptionsCount / (double)totalOperationsCount);
258+
}
240259

241-
return (gcStats, threadingStats, exceptionsStats.ExceptionsCount / (double)totalOperationsCount);
260+
// Isolate the allocation measurement and skip tier0 jit to make sure we don't get any unexpected allocations.
261+
[MethodImpl(MethodImplOptions.NoInlining | CodeGenHelper.AggressiveOptimizationOption)]
262+
private GcStats MeasureWithGc(long invokeCount)
263+
{
264+
var initialGcStats = GcStats.ReadInitial();
265+
WorkloadAction(invokeCount);
266+
var finalGcStats = GcStats.ReadFinal();
267+
return finalGcStats - initialGcStats;
242268
}
243269

244270
private void RandomizeManagedHeapMemory()
@@ -267,7 +293,7 @@ private void GcCollect()
267293
ForceGcCollect();
268294
}
269295

270-
private static void ForceGcCollect()
296+
internal static void ForceGcCollect()
271297
{
272298
GC.Collect();
273299
GC.WaitForPendingFinalizers();
@@ -278,15 +304,6 @@ private static void ForceGcCollect()
278304

279305
public void WriteLine() => Host.WriteLine();
280306

281-
private static void EnableMonitoring()
282-
{
283-
if (RuntimeInformation.IsOldMono) // Monitoring is not available in Mono, see http://stackoverflow.com/questions/40234948/how-to-get-the-number-of-allocated-bytes-in-mono
284-
return;
285-
286-
if (RuntimeInformation.IsFullFramework)
287-
AppDomain.MonitoringIsEnabled = true;
288-
}
289-
290307
[UsedImplicitly]
291308
public static class Signals
292309
{
@@ -309,5 +326,56 @@ private static readonly Dictionary<string, HostSignal> MessagesToSignals
309326
public static bool TryGetSignal(string message, out HostSignal signal)
310327
=> MessagesToSignals.TryGetValue(message, out signal);
311328
}
329+
330+
// Very long key and value so this shouldn't be used outside of unit tests.
331+
internal const string UnitTestBlockFinalizerEnvKey = "BENCHMARKDOTNET_UNITTEST_BLOCK_FINALIZER_FOR_MEMORYDIAGNOSER";
332+
internal const string UnitTestBlockFinalizerEnvValue = UnitTestBlockFinalizerEnvKey + "_ACTIVE";
333+
334+
// To prevent finalizers interfering with allocation measurements for unit tests,
335+
// we block the finalizer thread until we've completed the measurement.
336+
// https://github.com/dotnet/runtime/issues/101536#issuecomment-2077647417
337+
private readonly struct FinalizerBlocker : IDisposable
338+
{
339+
private readonly ManualResetEventSlim hangEvent;
340+
341+
private FinalizerBlocker(ManualResetEventSlim hangEvent) => this.hangEvent = hangEvent;
342+
343+
private sealed class Impl
344+
{
345+
private readonly ManualResetEventSlim hangEvent = new (false);
346+
private readonly ManualResetEventSlim enteredFinalizerEvent = new (false);
347+
348+
~Impl()
349+
{
350+
enteredFinalizerEvent.Set();
351+
hangEvent.Wait();
352+
}
353+
354+
[MethodImpl(MethodImplOptions.NoInlining)]
355+
internal static (ManualResetEventSlim hangEvent, ManualResetEventSlim enteredFinalizerEvent) CreateWeakly()
356+
{
357+
var impl = new Impl();
358+
return (impl.hangEvent, impl.enteredFinalizerEvent);
359+
}
360+
}
361+
362+
internal static FinalizerBlocker MaybeStart()
363+
{
364+
if (Environment.GetEnvironmentVariable(UnitTestBlockFinalizerEnvKey) != UnitTestBlockFinalizerEnvValue)
365+
{
366+
return default;
367+
}
368+
var (hangEvent, enteredFinalizerEvent) = Impl.CreateWeakly();
369+
do
370+
{
371+
GC.Collect();
372+
// Do NOT call GC.WaitForPendingFinalizers.
373+
}
374+
while (!enteredFinalizerEvent.IsSet);
375+
return new FinalizerBlocker(hangEvent);
376+
}
377+
378+
public void Dispose() => hangEvent?.Set();
379+
}
312380
}
313381
}

src/BenchmarkDotNet/Engines/GcStats.cs

+10-12
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System;
22
using System.Reflection;
3+
using System.Runtime.CompilerServices;
34
using BenchmarkDotNet.Columns;
45
using BenchmarkDotNet.Jobs;
56
using BenchmarkDotNet.Portability;
@@ -106,9 +107,10 @@ public int GetCollectionsCount(int generation)
106107
return AllocatedBytes <= AllocationQuantum ? 0L : AllocatedBytes;
107108
}
108109

110+
// Skip tier0 jit to make sure we don't get any unexpected allocations in this method.
111+
[MethodImpl(CodeGenHelper.AggressiveOptimizationOption)]
109112
public static GcStats ReadInitial()
110113
{
111-
// this will force GC.Collect, so we want to do this before collecting collections counts
112114
long? allocatedBytes = GetAllocatedBytes();
113115

114116
return new GcStats(
@@ -119,15 +121,14 @@ public static GcStats ReadInitial()
119121
0);
120122
}
121123

124+
// Skip tier0 jit to make sure we don't get any unexpected allocations in this method.
125+
[MethodImpl(CodeGenHelper.AggressiveOptimizationOption)]
122126
public static GcStats ReadFinal()
123127
{
124128
return new GcStats(
125129
GC.CollectionCount(0),
126130
GC.CollectionCount(1),
127131
GC.CollectionCount(2),
128-
129-
// this will force GC.Collect, so we want to do this after collecting collections counts
130-
// to exclude this single full forced collection from results
131132
GetAllocatedBytes(),
132133
0);
133134
}
@@ -136,17 +137,16 @@ public static GcStats ReadFinal()
136137
public static GcStats FromForced(int forcedFullGarbageCollections)
137138
=> new GcStats(forcedFullGarbageCollections, forcedFullGarbageCollections, forcedFullGarbageCollections, 0, 0);
138139

140+
// Skip tier0 jit to make sure we don't get any unexpected allocations in this method.
141+
[MethodImpl(CodeGenHelper.AggressiveOptimizationOption)]
139142
private static long? GetAllocatedBytes()
140143
{
141144
// we have no tests for WASM and don't want to risk introducing a new bug (https://github.com/dotnet/BenchmarkDotNet/issues/2226)
142145
if (RuntimeInformation.IsWasm)
143146
return null;
144147

145-
// "This instance Int64 property returns the number of bytes that have been allocated by a specific
146-
// AppDomain. The number is accurate as of the last garbage collection." - CLR via C#
147-
// so we enforce GC.Collect here just to make sure we get accurate results
148-
GC.Collect();
149-
148+
// Do NOT call GC.Collect() here, as it causes finalizers to run and possibly allocate. https://github.com/dotnet/runtime/issues/101536#issuecomment-2077533242
149+
// Instead, we call it before we start the measurement in the Engine.
150150
#if NET6_0_OR_GREATER
151151
return GC.GetTotalAllocatedBytes(precise: true);
152152
#else
@@ -218,9 +218,7 @@ private static long CalculateAllocationQuantumSize()
218218
break;
219219
}
220220

221-
GC.Collect();
222-
GC.WaitForPendingFinalizers();
223-
GC.Collect();
221+
Engine.ForceGcCollect();
224222

225223
result = GC.GetTotalMemory(false);
226224
var tmp = new object();

src/BenchmarkDotNet/Portability/RuntimeInformation.cs

+34-23
Original file line numberDiff line numberDiff line change
@@ -29,47 +29,47 @@ internal static class RuntimeInformation
2929
internal const string ReleaseConfigurationName = "RELEASE";
3030
internal const string Unknown = "?";
3131

32+
// Many of these checks allocate and/or are expensive to compute. We store the results in static readonly fields to keep Engine non-allocating.
33+
// Static readonly fields are used instead of properties to avoid an extra getter method call that might not be tier1 jitted.
34+
// This class is internal, so we don't need to expose these as properties.
35+
3236
/// <summary>
3337
/// returns true for both the old (implementation of .NET Framework) and new Mono (.NET 6+ flavour)
3438
/// </summary>
35-
public static bool IsMono { get; } =
36-
Type.GetType("Mono.RuntimeStructs") != null; // it allocates a lot of memory, we need to check it once in order to keep Engine non-allocating!
39+
public static readonly bool IsMono = Type.GetType("Mono.RuntimeStructs") != null;
3740

38-
public static bool IsOldMono { get; } = Type.GetType("Mono.Runtime") != null;
41+
public static readonly bool IsOldMono = Type.GetType("Mono.Runtime") != null;
3942

40-
public static bool IsNewMono { get; } = IsMono && !IsOldMono;
43+
public static readonly bool IsNewMono = IsMono && !IsOldMono;
4144

42-
public static bool IsFullFramework =>
45+
public static readonly bool IsFullFramework =
4346
#if NET6_0_OR_GREATER
47+
// This could be const, but we want to avoid unreachable code warnings.
4448
false;
4549
#else
4650
FrameworkDescription.StartsWith(".NET Framework", StringComparison.OrdinalIgnoreCase);
4751
#endif
4852

49-
[PublicAPI]
50-
public static bool IsNetNative => FrameworkDescription.StartsWith(".NET Native", StringComparison.OrdinalIgnoreCase);
53+
public static readonly bool IsNetNative = FrameworkDescription.StartsWith(".NET Native", StringComparison.OrdinalIgnoreCase);
5154

52-
public static bool IsNetCore
53-
=> ((Environment.Version.Major >= 5) || FrameworkDescription.StartsWith(".NET Core", StringComparison.OrdinalIgnoreCase))
54-
&& !string.IsNullOrEmpty(typeof(object).Assembly.Location);
55+
public static readonly bool IsNetCore =
56+
((Environment.Version.Major >= 5) || FrameworkDescription.StartsWith(".NET Core", StringComparison.OrdinalIgnoreCase))
57+
&& !string.IsNullOrEmpty(typeof(object).Assembly.Location);
5558

56-
public static bool IsNativeAOT
57-
=> Environment.Version.Major >= 5
58-
&& string.IsNullOrEmpty(typeof(object).Assembly.Location) // it's merged to a single .exe and .Location returns null
59-
&& !IsWasm; // Wasm also returns "" for assembly locations
59+
public static readonly bool IsNativeAOT =
60+
Environment.Version.Major >= 5
61+
&& string.IsNullOrEmpty(typeof(object).Assembly.Location) // it's merged to a single .exe and .Location returns null
62+
&& !IsWasm; // Wasm also returns "" for assembly locations
6063

6164
#if NET6_0_OR_GREATER
6265
[System.Runtime.Versioning.SupportedOSPlatformGuard("browser")]
63-
#endif
64-
public static bool IsWasm =>
65-
#if NET6_0_OR_GREATER
66-
OperatingSystem.IsBrowser();
66+
public static readonly bool IsWasm = OperatingSystem.IsBrowser();
6767
#else
68-
IsOSPlatform(OSPlatform.Create("BROWSER"));
68+
public static readonly bool IsWasm = IsOSPlatform(OSPlatform.Create("BROWSER"));
6969
#endif
7070

7171
#if NETSTANDARD2_0
72-
public static bool IsAot { get; } = IsAotMethod(); // This allocates, so we only want to call it once statically.
72+
public static readonly bool IsAot = IsAotMethod();
7373

7474
private static bool IsAotMethod()
7575
{
@@ -87,11 +87,22 @@ private static bool IsAotMethod()
8787
return false;
8888
}
8989
#else
90-
public static bool IsAot => !System.Runtime.CompilerServices.RuntimeFeature.IsDynamicCodeCompiled;
90+
public static readonly bool IsAot = !System.Runtime.CompilerServices.RuntimeFeature.IsDynamicCodeCompiled;
9191
#endif
9292

93-
public static bool IsRunningInContainer => string.Equals(Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER"), "true");
94-
93+
public static readonly bool IsTieredJitEnabled =
94+
IsNetCore
95+
&& (Environment.Version.Major < 3
96+
// Disabled by default in netcoreapp2.X, check if it's enabled.
97+
? Environment.GetEnvironmentVariable("COMPlus_TieredCompilation") == "1"
98+
|| Environment.GetEnvironmentVariable("DOTNET_TieredCompilation") == "1"
99+
|| (AppContext.TryGetSwitch("System.Runtime.TieredCompilation", out bool isEnabled) && isEnabled)
100+
// Enabled by default in netcoreapp3.0+, check if it's disabled.
101+
: Environment.GetEnvironmentVariable("COMPlus_TieredCompilation") != "0"
102+
&& Environment.GetEnvironmentVariable("DOTNET_TieredCompilation") != "0"
103+
&& (!AppContext.TryGetSwitch("System.Runtime.TieredCompilation", out isEnabled) || isEnabled));
104+
105+
public static readonly bool IsRunningInContainer = string.Equals(Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER"), "true");
95106

96107
internal static string GetArchitecture() => GetCurrentPlatform().ToString();
97108

tests/BenchmarkDotNet.IntegrationTests/BenchmarkDotNet.IntegrationTests.csproj

+2
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
<Content Include="xunit.runner.json">
1919
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
2020
</Content>
21+
<!-- Disable EventSource to stabilize MemoryDiagnoserTests. https://github.com/dotnet/BenchmarkDotNet/pull/2562#issuecomment-2081317379 -->
22+
<RuntimeHostConfigurationOption Include="System.Diagnostics.Tracing.EventSource.IsSupported" Value="false" />
2123
</ItemGroup>
2224
<ItemGroup>
2325
<ProjectReference Include="..\BenchmarkDotNet.IntegrationTests.ConfigPerAssembly\BenchmarkDotNet.IntegrationTests.ConfigPerAssembly.csproj" />

0 commit comments

Comments
 (0)