Skip to content

Commit 0d285b1

Browse files
authored
Merge pull request #276 from petli/memory-mapped-hit-counts
Writing hit counts to a shared memory mapped area instead of to a file.
2 parents 15a4e62 + 9b001ad commit 0d285b1

File tree

10 files changed

+331
-239
lines changed

10 files changed

+331
-239
lines changed

coverlet.sln

Lines changed: 134 additions & 134 deletions
Large diffs are not rendered by default.

src/coverlet.core/Coverage.cs

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System;
22
using System.Collections.Generic;
33
using System.IO;
4+
using System.IO.MemoryMappedFiles;
45
using System.Linq;
56

67
using Coverlet.Core.Enums;
@@ -26,11 +27,15 @@ public class Coverage
2627
private bool _useSourceLink;
2728
private List<InstrumenterResult> _results;
2829

30+
private readonly Dictionary<string, MemoryMappedFile> _resultMemoryMaps = new Dictionary<string, MemoryMappedFile>();
31+
2932
public string Identifier
3033
{
3134
get { return _identifier; }
3235
}
3336

37+
internal IEnumerable<InstrumenterResult> Results => _results;
38+
3439
public Coverage(string module, string[] includeFilters, string[] includeDirectories, string[] excludeFilters, string[] excludedSourceFiles, string[] excludeAttributes, string mergeWith, bool useSourceLink)
3540
{
3641
_module = module;
@@ -77,6 +82,26 @@ public void PrepareModules()
7782
}
7883
}
7984
}
85+
86+
foreach (var result in _results)
87+
{
88+
var size = (result.HitCandidates.Count + ModuleTrackerTemplate.HitsResultHeaderSize) * sizeof(int);
89+
90+
MemoryMappedFile mmap;
91+
92+
try
93+
{
94+
// Try using a named memory map not backed by a file (currently only supported on Windows)
95+
mmap = MemoryMappedFile.CreateNew(result.HitsResultGuid, size);
96+
}
97+
catch (PlatformNotSupportedException)
98+
{
99+
// Fall back on a file-backed memory map
100+
mmap = MemoryMappedFile.CreateFromFile(result.HitsFilePath, FileMode.CreateNew, null, size);
101+
}
102+
103+
_resultMemoryMaps.Add(result.HitsResultGuid, mmap);
104+
}
80105
}
81106

82107
public CoverageResult GetCoverageResult()
@@ -183,12 +208,6 @@ private void CalculateCoverage()
183208
{
184209
foreach (var result in _results)
185210
{
186-
if (!File.Exists(result.HitsFilePath))
187-
{
188-
// File not instrumented, or nothing in it called. Warn about this?
189-
continue;
190-
}
191-
192211
List<Document> documents = result.Documents.Values.ToList();
193212
if (_useSourceLink && result.SourceLink != null)
194213
{
@@ -200,20 +219,26 @@ private void CalculateCoverage()
200219
}
201220
}
202221

203-
using (var fs = new FileStream(result.HitsFilePath, FileMode.Open))
204-
using (var br = new BinaryReader(fs))
222+
// Read hit counts from the memory mapped area, disposing it when done
223+
using (var mmapFile = _resultMemoryMaps[result.HitsResultGuid])
205224
{
206-
int hitCandidatesCount = br.ReadInt32();
225+
var mmapAccessor = mmapFile.CreateViewAccessor();
226+
227+
var unloadStarted = mmapAccessor.ReadInt32(ModuleTrackerTemplate.HitsResultUnloadStarted * sizeof(int));
228+
var unloadFinished = mmapAccessor.ReadInt32(ModuleTrackerTemplate.HitsResultUnloadFinished * sizeof(int));
207229

208-
// TODO: hitCandidatesCount should be verified against result.HitCandidates.Count
230+
if (unloadFinished < unloadStarted)
231+
{
232+
throw new Exception($"Hit counts only partially reported for {result.Module}");
233+
}
209234

210235
var documentsList = result.Documents.Values.ToList();
211236

212-
for (int i = 0; i < hitCandidatesCount; ++i)
237+
for (int i = 0; i < result.HitCandidates.Count; ++i)
213238
{
214239
var hitLocation = result.HitCandidates[i];
215240
var document = documentsList[hitLocation.docIndex];
216-
int hits = br.ReadInt32();
241+
var hits = mmapAccessor.ReadInt32((i + ModuleTrackerTemplate.HitsResultHeaderSize) * sizeof(int));
217242

218243
if (hitLocation.isBranch)
219244
{
@@ -256,6 +281,7 @@ private void CalculateCoverage()
256281
}
257282
}
258283

284+
// There's only a hits file on Linux, but if the file doesn't exist this is just a no-op
259285
InstrumentationHelper.DeleteHitsFile(result.HitsFilePath);
260286
}
261287
}

src/coverlet.core/Instrumentation/Instrumenter.cs

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
using System.Diagnostics.CodeAnalysis;
55
using System.IO;
66
using System.Linq;
7-
using System.Reflection;
87

98
using Coverlet.Core.Attributes;
109
using Coverlet.Core.Helpers;
@@ -25,8 +24,9 @@ internal class Instrumenter
2524
private readonly string[] _excludedFiles;
2625
private readonly string[] _excludedAttributes;
2726
private InstrumenterResult _result;
28-
private FieldDefinition _customTrackerHitsArray;
2927
private FieldDefinition _customTrackerHitsFilePath;
28+
private FieldDefinition _customTrackerHitsArray;
29+
private FieldDefinition _customTrackerHitsMemoryMapName;
3030
private ILProcessor _customTrackerClassConstructorIl;
3131
private TypeDefinition _customTrackerTypeDef;
3232
private MethodReference _customTrackerRegisterUnloadEventsMethod;
@@ -55,6 +55,7 @@ public InstrumenterResult Instrument()
5555
{
5656
Module = Path.GetFileNameWithoutExtension(_module),
5757
HitsFilePath = hitsFilePath,
58+
HitsResultGuid = Guid.NewGuid().ToString(),
5859
ModulePath = _module
5960
};
6061

@@ -118,6 +119,8 @@ private void InstrumentModule()
118119
_customTrackerClassConstructorIl.InsertBefore(lastInstr, Instruction.Create(OpCodes.Stsfld, _customTrackerHitsArray));
119120
_customTrackerClassConstructorIl.InsertBefore(lastInstr, Instruction.Create(OpCodes.Ldstr, _result.HitsFilePath));
120121
_customTrackerClassConstructorIl.InsertBefore(lastInstr, Instruction.Create(OpCodes.Stsfld, _customTrackerHitsFilePath));
122+
_customTrackerClassConstructorIl.InsertBefore(lastInstr, Instruction.Create(OpCodes.Ldstr, _result.HitsResultGuid));
123+
_customTrackerClassConstructorIl.InsertBefore(lastInstr, Instruction.Create(OpCodes.Stsfld, _customTrackerHitsMemoryMapName));
121124

122125
if (containsAppContext)
123126
{
@@ -163,10 +166,12 @@ private void AddCustomModuleTrackerToModule(ModuleDefinition module)
163166

164167
_customTrackerTypeDef.Fields.Add(fieldClone);
165168

166-
if (fieldClone.Name == "HitsArray")
167-
_customTrackerHitsArray = fieldClone;
168-
else if (fieldClone.Name == "HitsFilePath")
169+
if (fieldClone.Name == nameof(ModuleTrackerTemplate.HitsFilePath))
169170
_customTrackerHitsFilePath = fieldClone;
171+
else if (fieldClone.Name == nameof(ModuleTrackerTemplate.HitsMemoryMapName))
172+
_customTrackerHitsMemoryMapName = fieldClone;
173+
else if (fieldClone.Name == nameof(ModuleTrackerTemplate.HitsArray))
174+
_customTrackerHitsArray = fieldClone;
170175
}
171176

172177
foreach (MethodDefinition methodDef in moduleTrackerTemplate.Methods)

src/coverlet.core/Instrumentation/InstrumenterResult.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ public InstrumenterResult()
4242

4343
public string Module;
4444
public string HitsFilePath;
45+
public string HitsResultGuid;
4546
public string ModulePath;
4647
public string SourceLink;
4748
public Dictionary<string, Document> Documents { get; private set; }

src/coverlet.core/coverlet.core.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<Project Sdk="Microsoft.NET.Sdk">
1+
<Project Sdk="Microsoft.NET.Sdk">
22

33
<PropertyGroup>
44
<OutputType>Library</OutputType>

src/coverlet.template/ModuleTrackerTemplate.cs

Lines changed: 58 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using System.Collections.Generic;
33
using System.Diagnostics.CodeAnalysis;
44
using System.IO;
5+
using System.IO.MemoryMappedFiles;
56
using System.Threading;
67

78
namespace Coverlet.Core.Instrumentation
@@ -11,13 +12,18 @@ namespace Coverlet.Core.Instrumentation
1112
/// to a single location.
1213
/// </summary>
1314
/// <remarks>
14-
/// As this type is going to be customized for each instrumeted module it doesn't follow typical practices
15+
/// As this type is going to be customized for each instrumented module it doesn't follow typical practices
1516
/// regarding visibility of members, etc.
1617
/// </remarks>
1718
[ExcludeFromCodeCoverage]
1819
public static class ModuleTrackerTemplate
1920
{
21+
public const int HitsResultHeaderSize = 2;
22+
public const int HitsResultUnloadStarted = 0;
23+
public const int HitsResultUnloadFinished = 1;
24+
2025
public static string HitsFilePath;
26+
public static string HitsMemoryMapName;
2127
public static int[] HitsArray;
2228

2329
static ModuleTrackerTemplate()
@@ -53,56 +59,72 @@ public static void UnloadModule(object sender, EventArgs e)
5359

5460
// The same module can be unloaded multiple times in the same process via different app domains.
5561
// Use a global mutex to ensure no concurrent access.
56-
using (var mutex = new Mutex(true, Path.GetFileNameWithoutExtension(HitsFilePath) + "_Mutex", out bool createdNew))
62+
using (var mutex = new Mutex(true, HitsMemoryMapName + "_Mutex", out bool createdNew))
5763
{
5864
if (!createdNew)
5965
mutex.WaitOne();
6066

61-
bool failedToCreateNewHitsFile = false;
67+
MemoryMappedFile memoryMap = null;
68+
6269
try
6370
{
64-
using (var fs = new FileStream(HitsFilePath, FileMode.CreateNew))
65-
using (var bw = new BinaryWriter(fs))
71+
try
6672
{
67-
bw.Write(hitsArray.Length);
68-
foreach (int hitCount in hitsArray)
69-
{
70-
bw.Write(hitCount);
71-
}
73+
memoryMap = MemoryMappedFile.OpenExisting(HitsMemoryMapName);
74+
}
75+
catch (PlatformNotSupportedException)
76+
{
77+
memoryMap = MemoryMappedFile.CreateFromFile(HitsFilePath, FileMode.Open, null, (HitsArray.Length + HitsResultHeaderSize) * sizeof(int));
7278
}
73-
}
74-
catch
75-
{
76-
failedToCreateNewHitsFile = true;
77-
}
7879

79-
if (failedToCreateNewHitsFile)
80-
{
81-
// Update the number of hits by adding value on disk with the ones on memory.
82-
// This path should be triggered only in the case of multiple AppDomain unloads.
83-
using (var fs = new FileStream(HitsFilePath, FileMode.Open, FileAccess.ReadWrite, FileShare.None))
84-
using (var br = new BinaryReader(fs))
85-
using (var bw = new BinaryWriter(fs))
80+
// Tally hit counts from all threads in memory mapped area
81+
var accessor = memoryMap.CreateViewAccessor();
82+
using (var buffer = accessor.SafeMemoryMappedViewHandle)
8683
{
87-
int hitsLength = br.ReadInt32();
88-
if (hitsLength != hitsArray.Length)
84+
unsafe
8985
{
90-
throw new InvalidOperationException(
91-
$"{HitsFilePath} has {hitsLength} entries but on memory {nameof(HitsArray)} has {hitsArray.Length}");
92-
}
86+
byte* pointer = null;
87+
buffer.AcquirePointer(ref pointer);
88+
try
89+
{
90+
var intPointer = (int*) pointer;
9391

94-
for (int i = 0; i < hitsLength; ++i)
95-
{
96-
int oldHitCount = br.ReadInt32();
97-
bw.Seek(-sizeof(int), SeekOrigin.Current);
98-
bw.Write(hitsArray[i] + oldHitCount);
92+
// Signal back to coverage analysis that we've started transferring hit counts.
93+
// Use interlocked here to ensure a memory barrier before the Coverage class reads
94+
// the shared data.
95+
Interlocked.Increment(ref *(intPointer + HitsResultUnloadStarted));
96+
97+
for (var i = 0; i < hitsArray.Length; i++)
98+
{
99+
var count = hitsArray[i];
100+
101+
// By only modifying the memory map pages where there have been hits
102+
// unnecessary allocation of all-zero pages is avoided.
103+
if (count > 0)
104+
{
105+
var hitLocationArrayOffset = intPointer + i + HitsResultHeaderSize;
106+
107+
// No need to use Interlocked here since the mutex ensures only one thread updates
108+
// the shared memory map.
109+
*hitLocationArrayOffset += count;
110+
}
111+
}
112+
113+
// Signal back to coverage analysis that all hit counts were successfully tallied.
114+
Interlocked.Increment(ref *(intPointer + HitsResultUnloadFinished));
115+
}
116+
finally
117+
{
118+
buffer.ReleasePointer();
119+
}
99120
}
100121
}
101122
}
102-
103-
// On purpose this is not under a try-finally: it is better to have an exception if there was any error writing the hits file
104-
// this case is relevant when instrumenting corelib since multiple processes can be running against the same instrumented dll.
105-
mutex.ReleaseMutex();
123+
finally
124+
{
125+
mutex.ReleaseMutex();
126+
memoryMap?.Dispose();
127+
}
106128
}
107129
}
108130
}
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
<Project Sdk="Microsoft.NET.Sdk">
1+
<Project Sdk="Microsoft.NET.Sdk">
22

33
<PropertyGroup>
44
<TargetFramework>netstandard2.0</TargetFramework>
5+
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
56
</PropertyGroup>
67

78
</Project>

test/coverlet.core.tests/CoverageTests.cs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,13 @@
66

77
using Coverlet.Core;
88
using System.Collections.Generic;
9+
using System.Linq;
10+
using Coverlet.Core.Instrumentation;
11+
using Coverlet.Core.Tests.Instrumentation;
912

1013
namespace Coverlet.Core.Tests
1114
{
15+
[Collection(nameof(ModuleTrackerTemplate))]
1216
public class CoverageTests
1317
{
1418
[Fact]
@@ -22,14 +26,21 @@ public void TestCoverage()
2226
File.Copy(module, Path.Combine(directory.FullName, Path.GetFileName(module)), true);
2327
File.Copy(pdb, Path.Combine(directory.FullName, Path.GetFileName(pdb)), true);
2428

25-
// TODO: Find a way to mimick hits
29+
// TODO: Mimic hits by calling ModuleTrackerTemplate.RecordHit before Unload
2630

2731
// Since Coverage only instruments dependancies, we need a fake module here
2832
var testModule = Path.Combine(directory.FullName, "test.module.dll");
2933

3034
var coverage = new Coverage(testModule, Array.Empty<string>(), Array.Empty<string>(), Array.Empty<string>(), Array.Empty<string>(), Array.Empty<string>(), string.Empty, false);
3135
coverage.PrepareModules();
3236

37+
// The module hit tracker must signal to Coverage that it has done its job, so call it manually
38+
var instrumenterResult = coverage.Results.Single();
39+
ModuleTrackerTemplate.HitsArray = new int[instrumenterResult.HitCandidates.Count + ModuleTrackerTemplate.HitsResultHeaderSize];
40+
ModuleTrackerTemplate.HitsFilePath = instrumenterResult.HitsFilePath;
41+
ModuleTrackerTemplate.HitsMemoryMapName = instrumenterResult.HitsResultGuid;
42+
ModuleTrackerTemplate.UnloadModule(null, null);
43+
3344
var result = coverage.GetCoverageResult();
3445

3546
Assert.NotEmpty(result.Modules);

test/coverlet.core.tests/Helpers/InstrumentationHelperTests.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,6 @@ public void TestDeleteHitsFile()
6363
Assert.False(File.Exists(tempFile));
6464
}
6565

66-
6766
public static IEnumerable<object[]> GetExcludedFilesReturnsEmptyArgs =>
6867
new[]
6968
{

0 commit comments

Comments
 (0)