Skip to content

Commit

Permalink
Added memory diagnoser option to measure survived memory from the fir…
Browse files Browse the repository at this point in the history
…st benchmark run.
  • Loading branch information
timcassell committed Nov 8, 2022
1 parent 36f9e73 commit a51fe70
Show file tree
Hide file tree
Showing 19 changed files with 342 additions and 32 deletions.
5 changes: 3 additions & 2 deletions src/BenchmarkDotNet/Attributes/MemoryDiagnoserAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ public class MemoryDiagnoserAttribute : Attribute, IConfigSource
public IConfig Config { get; }

/// <param name="displayGenColumns">Display Garbage Collections per Generation columns (Gen 0, Gen 1, Gen 2). True by default.</param>
public MemoryDiagnoserAttribute(bool displayGenColumns = true)
/// <param name="includeSurvived">If true, monitoring will be enabled and survived memory will be measured on the first benchmark run.</param>
public MemoryDiagnoserAttribute(bool displayGenColumns = true, bool includeSurvived = false)
{
Config = ManualConfig.CreateEmpty().AddDiagnoser(new MemoryDiagnoser(new MemoryDiagnoserConfig(displayGenColumns)));
Config = ManualConfig.CreateEmpty().AddDiagnoser(new MemoryDiagnoser(new MemoryDiagnoserConfig(displayGenColumns, includeSurvived)));
}
}
}
1 change: 1 addition & 0 deletions src/BenchmarkDotNet/Code/CodeGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ internal static string Generate(BuildPartition buildPartition)
.Replace("$PassArguments$", passArguments)
.Replace("$EngineFactoryType$", GetEngineFactoryTypeName(benchmark))
.Replace("$MeasureExtraStats$", buildInfo.Config.HasExtraStatsDiagnoser() ? "true" : "false")
.Replace("$MeasureSurvivedMemory$", buildInfo.Config.HasSurvivedMemoryDiagnoser() ? "true" : "false")
.Replace("$DisassemblerEntryMethodName$", DisassemblerConstants.DisassemblerEntryMethodName)
.Replace("$WorkloadMethodCall$", provider.GetWorkloadMethodCall(passArguments))
.RemoveRedundantIfDefines(compilationId);
Expand Down
2 changes: 2 additions & 0 deletions src/BenchmarkDotNet/Configs/ImmutableConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ internal ImmutableConfig(

public bool HasMemoryDiagnoser() => diagnosers.OfType<MemoryDiagnoser>().Any();

public bool HasSurvivedMemoryDiagnoser() => diagnosers.Any(diagnoser => diagnoser is MemoryDiagnoser md && md.Config.IncludeSurvived);

public bool HasThreadingDiagnoser() => diagnosers.Contains(ThreadingDiagnoser.Default);

public bool HasExceptionDiagnoser() => diagnosers.Contains(ExceptionDiagnoser.Default);
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 @@ -62,6 +62,9 @@ public bool UseDisassemblyDiagnoser
[Option('a', "artifacts", Required = false, HelpText = "Valid path to accessible directory")]
public DirectoryInfo ArtifactsDirectory { get; set; }

[Option("memorySurvived", Required = false, Default = false, HelpText = "Measures survived memory.")]
public bool UseSurvivedMemoryDiagnoser { get; set; }

[Option("outliers", Required = false, Default = OutlierMode.RemoveUpper, HelpText = "DontRemove/RemoveUpper/RemoveLower/RemoveAll")]
public OutlierMode Outliers { get; set; }

Expand Down
5 changes: 4 additions & 1 deletion src/BenchmarkDotNet/ConsoleArguments/ConfigParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -210,8 +210,11 @@ private static IConfig CreateConfig(CommandLineOptions options, IConfig globalCo
.Select(counterName => (HardwareCounter)Enum.Parse(typeof(HardwareCounter), counterName, ignoreCase: true))
.ToArray());

if (options.UseMemoryDiagnoser)
if (options.UseSurvivedMemoryDiagnoser)
config.AddDiagnoser(new MemoryDiagnoser(new MemoryDiagnoserConfig(includeSurvived: true)));
else if (options.UseMemoryDiagnoser)
config.AddDiagnoser(MemoryDiagnoser.Default);

if (options.UseThreadingDiagnoser)
config.AddDiagnoser(ThreadingDiagnoser.Default);
if (options.UseExceptionDiagnoser)
Expand Down
19 changes: 19 additions & 0 deletions src/BenchmarkDotNet/Diagnosers/MemoryDiagnoser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,25 @@ public IEnumerable<Metric> ProcessResults(DiagnoserResults diagnoserResults)
yield return new Metric(GarbageCollectionsMetricDescriptor.Gen2, diagnoserResults.GcStats.Gen2Collections / (double)diagnoserResults.GcStats.TotalOperations * 1000);

yield return new Metric(AllocatedMemoryMetricDescriptor.Instance, diagnoserResults.GcStats.GetBytesAllocatedPerOperation(diagnoserResults.BenchmarkCase));

if (Config.IncludeSurvived)
{
yield return new Metric(SurvivedMemoryMetricDescriptor.Instance, diagnoserResults.GcStats.SurvivedBytes);
}
}

private class SurvivedMemoryMetricDescriptor : IMetricDescriptor
{
internal static readonly IMetricDescriptor Instance = new SurvivedMemoryMetricDescriptor();

public string Id => "Survived Memory";
public string DisplayName => "Survived";
public string Legend => "Memory survived after the first operation (managed only, inclusive, 1KB = 1024B)";
public string NumberFormat => "N0";
public UnitType UnitType => UnitType.Size;
public string Unit => SizeUnit.B.Name;
public bool TheGreaterTheBetter => false;
public int PriorityInCategory { get; } = AllocatedMemoryMetricDescriptor.Instance.PriorityInCategory + 1;
}

private class GarbageCollectionsMetricDescriptor : IMetricDescriptor
Expand Down
5 changes: 4 additions & 1 deletion src/BenchmarkDotNet/Diagnosers/MemoryDiagnoserConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@ namespace BenchmarkDotNet.Diagnosers
public class MemoryDiagnoserConfig
{
/// <param name="displayGenColumns">Display Garbage Collections per Generation columns (Gen 0, Gen 1, Gen 2). True by default.</param>
/// <param name="includeSurvived">If true, monitoring will be enabled and survived memory will be measured on the first benchmark run.</param>
[PublicAPI]
public MemoryDiagnoserConfig(bool displayGenColumns = true)
public MemoryDiagnoserConfig(bool displayGenColumns = true, bool includeSurvived = false)
{
DisplayGenColumns = displayGenColumns;
IncludeSurvived = includeSurvived;
}

public bool DisplayGenColumns { get; }
public bool IncludeSurvived { get; }
}
}
8 changes: 8 additions & 0 deletions src/BenchmarkDotNet/Engines/Consumer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ private static readonly HashSet<Type> SupportedTypes
private IntPtr ptrHolder;
private UIntPtr uptrHolder;

[MethodImpl(MethodImplOptions.AggressiveInlining)]
[PublicAPI]
public void Clear()
{
Volatile.Write(ref stringHolder, null);
Volatile.Write(ref objectHolder, null);
}

[MethodImpl(MethodImplOptions.AggressiveInlining)]
[PublicAPI]
public void Consume(byte byteValue) => byteHolder = byteValue;
Expand Down
112 changes: 102 additions & 10 deletions src/BenchmarkDotNet/Engines/Engine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public class Engine : IEngine

[PublicAPI] public IHost Host { get; }
[PublicAPI] public Action<long> WorkloadAction { get; }
[PublicAPI] public Action<long> WorkloadActionNoUnroll { get; }
[PublicAPI] public Action Dummy1Action { get; }
[PublicAPI] public Action Dummy2Action { get; }
[PublicAPI] public Action Dummy3Action { get; }
Expand All @@ -45,19 +46,23 @@ public class Engine : IEngine
private readonly EnginePilotStage pilotStage;
private readonly EngineWarmupStage warmupStage;
private readonly EngineActualStage actualStage;
private readonly bool includeExtraStats;
private readonly Random random;
private readonly bool includeExtraStats, includeSurvivedMemory;

private long survivedBytes;
private bool survivedBytesMeasured;
private static Func<long> GetTotalBytes { get; set; }

internal Engine(
IHost host,
IResolver resolver,
Action dummy1Action, Action dummy2Action, Action dummy3Action, Action<long> overheadAction, Action<long> workloadAction, Job targetJob,
Action dummy1Action, Action dummy2Action, Action dummy3Action, Action<long> overheadAction, Action<long> workloadAction, Action<long> workloadActionNoUnroll, Job targetJob,
Action globalSetupAction, Action globalCleanupAction, Action iterationSetupAction, Action iterationCleanupAction, long operationsPerInvoke,
bool includeExtraStats, string benchmarkName)
bool includeExtraStats, bool includeSurvivedMemory, string benchmarkName)
{

Host = host;
OverheadAction = overheadAction;
WorkloadActionNoUnroll = workloadActionNoUnroll;
Dummy1Action = dummy1Action;
Dummy2Action = dummy2Action;
Dummy3Action = dummy3Action;
Expand All @@ -70,6 +75,7 @@ internal Engine(
OperationsPerInvoke = operationsPerInvoke;
this.includeExtraStats = includeExtraStats;
BenchmarkName = benchmarkName;
this.includeSurvivedMemory = includeSurvivedMemory;

Resolver = resolver;

Expand All @@ -85,6 +91,55 @@ internal Engine(
actualStage = new EngineActualStage(this);

random = new Random(12345); // we are using constant seed to try to get repeatable results

if (includeSurvivedMemory && GetTotalBytes is null)
{
// CreateGetTotalBytesFunc enables monitoring, so we only call it if we need to measure survived memory.
GetTotalBytes = CreateGetTotalBytesFunc();

// Necessary for CORE runtimes.
// Measure bytes to allow GC monitor to make its allocations.
GetTotalBytes();
// Run the clock once to allow it to make its allocations.
MeasureAction(_ => { }, 0);
GetTotalBytes();
}
}

private static Func<long> CreateGetTotalBytesFunc()
{
// Don't try to measure in Mono, Monitoring is not available, and GC.GetTotalMemory is very inaccurate.
if (RuntimeInformation.IsMono)
return () => 0;
try
{
// Docs say this should be available in .NET Core 2.1, but it throws an exception.
// Just try this on all non-Mono runtimes, fallback to GC.GetTotalMemory.
AppDomain.MonitoringIsEnabled = true;
return () =>
{
// Enforce GC.Collect here to make sure we get accurate results.
ForceGcCollect();
return AppDomain.CurrentDomain.MonitoringSurvivedMemorySize;
};
}
catch
{
return () =>
{
// Enforce GC.Collect here to make sure we get accurate results.
ForceGcCollect();
return GC.GetTotalMemory(true);
};
}
}

internal Engine WithInitialData(Engine other)
{
// Copy the survived bytes from the other engine so we only measure it once.
survivedBytes = other.survivedBytes;
survivedBytesMeasured = other.survivedBytesMeasured;
return this;
}

public void Dispose()
Expand Down Expand Up @@ -160,7 +215,9 @@ public Measurement RunIteration(IterationData data)
var action = isOverhead ? OverheadAction : WorkloadAction;

if (!isOverhead)
{
IterationSetupAction();
}

GcCollect();

Expand All @@ -169,10 +226,36 @@ public Measurement RunIteration(IterationData data)

Span<byte> stackMemory = randomizeMemory ? stackalloc byte[random.Next(32)] : Span<byte>.Empty;

// Measure
var clock = Clock.Start();
action(invokeCount / unrollFactor);
var clockSpan = clock.GetElapsed();
bool needsSurvivedMeasurement = includeSurvivedMemory && !isOverhead && !survivedBytesMeasured;
double nanoseconds;
if (needsSurvivedMeasurement)
{
// Measure survived bytes for only the first invocation.
survivedBytesMeasured = true;
if (totalOperations == 1)
{
// Measure normal invocation for both survived memory and time.
long beforeBytes = GetTotalBytes();
nanoseconds = MeasureAction(action, invokeCount / unrollFactor);
long afterBytes = GetTotalBytes();
survivedBytes = afterBytes - beforeBytes;
}
else
{
// Measure a single invocation for survived memory, plus normal invocations for time.
++totalOperations;
long beforeBytes = GetTotalBytes();
nanoseconds = MeasureAction(WorkloadActionNoUnroll, 1);
long afterBytes = GetTotalBytes();
survivedBytes = afterBytes - beforeBytes;
nanoseconds += MeasureAction(action, invokeCount / unrollFactor);
}
}
else
{
// Measure time normally.
nanoseconds = MeasureAction(action, invokeCount / unrollFactor);
}

if (EngineEventSource.Log.IsEnabled())
EngineEventSource.Log.IterationStop(data.IterationMode, data.IterationStage, totalOperations);
Expand All @@ -186,7 +269,7 @@ public Measurement RunIteration(IterationData data)
GcCollect();

// Results
var measurement = new Measurement(0, data.IterationMode, data.IterationStage, data.Index, totalOperations, clockSpan.GetNanoseconds());
var measurement = new Measurement(0, data.IterationMode, data.IterationStage, data.Index, totalOperations, nanoseconds);
WriteLine(measurement.ToString());
if (measurement.IterationStage == IterationStage.Jitting)
jittingMeasurements.Add(measurement);
Expand All @@ -196,6 +279,15 @@ public Measurement RunIteration(IterationData data)
return measurement;
}

// This is necessary for the CORE runtime to clean up the memory from the clock.
[MethodImpl(MethodImplOptions.NoInlining)]
private double MeasureAction(Action<long> action, long arg)
{
var clock = Clock.Start();
action(arg);
return clock.GetElapsed().GetNanoseconds();
}

private (GcStats, ThreadingStats, double) GetExtraStats(IterationData data)
{
// we enable monitoring after main target run, for this single iteration which is executed at the end
Expand All @@ -219,7 +311,7 @@ public Measurement RunIteration(IterationData data)
IterationCleanupAction(); // we run iteration cleanup after collecting GC stats

var totalOperationsCount = data.InvokeCount * OperationsPerInvoke;
GcStats gcStats = (finalGcStats - initialGcStats).WithTotalOperations(totalOperationsCount);
GcStats gcStats = (finalGcStats - initialGcStats).WithTotalOperationsAndSurvivedBytes(data.InvokeCount * OperationsPerInvoke, survivedBytes);
ThreadingStats threadingStats = (finalThreadingStats - initialThreadingStats).WithTotalOperations(data.InvokeCount * OperationsPerInvoke);

return (gcStats, threadingStats, exceptionsStats.ExceptionsCount / (double)totalOperationsCount);
Expand Down
8 changes: 6 additions & 2 deletions src/BenchmarkDotNet/Engines/EngineFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,12 @@ public IEngine CreateReadyToRun(EngineParameters engineParameters)
.WithMinInvokeCount(2) // the minimum is 2 (not the default 4 which can be too much and not 1 which we already know is not enough)
.WithEvaluateOverhead(false); // it's something very time consuming, it overhead is too small compared to total time

return CreateEngine(engineParameters, needsPilot, engineParameters.OverheadActionNoUnroll, engineParameters.WorkloadActionNoUnroll);
return CreateEngine(engineParameters, needsPilot, engineParameters.OverheadActionNoUnroll, engineParameters.WorkloadActionNoUnroll)
.WithInitialData(singleActionEngine);
}

var multiActionEngine = CreateMultiActionEngine(engineParameters);
var multiActionEngine = CreateMultiActionEngine(engineParameters)
.WithInitialData(singleActionEngine);

DeadCodeEliminationHelper.KeepAliveWithoutBoxing(Jit(multiActionEngine, ++jitIndex, invokeCount: defaultUnrollFactor, unrollFactor: defaultUnrollFactor));

Expand Down Expand Up @@ -118,13 +120,15 @@ private static Engine CreateEngine(EngineParameters engineParameters, Job job, A
engineParameters.Dummy3Action,
idle,
main,
engineParameters.WorkloadActionNoUnroll,
job,
engineParameters.GlobalSetupAction,
engineParameters.GlobalCleanupAction,
engineParameters.IterationSetupAction,
engineParameters.IterationCleanupAction,
engineParameters.OperationsPerInvoke,
engineParameters.MeasureExtraStats,
engineParameters.MeasureSurvivedMemory,
engineParameters.BenchmarkName);
}
}
2 changes: 2 additions & 0 deletions src/BenchmarkDotNet/Engines/EngineParameters.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ public class EngineParameters
public Action IterationCleanupAction { get; set; }
public bool MeasureExtraStats { get; set; }

public bool MeasureSurvivedMemory { get; set; }

[PublicAPI] public string BenchmarkName { get; set; }

public bool NeedsJitting => TargetJob.ResolveValue(RunMode.RunStrategyCharacteristic, DefaultResolver).NeedsJitting();
Expand Down
Loading

0 comments on commit a51fe70

Please sign in to comment.