Skip to content

Commit

Permalink
Add benchmarks (#8)
Browse files Browse the repository at this point in the history
This adds the basic infrastructure required to support benchmarks. This is not meant as a comprehensive set of benchmarks.

Resolves #7
  • Loading branch information
idg10 committed Sep 19, 2019
1 parent 0e50c12 commit 20fd24b
Show file tree
Hide file tree
Showing 14 changed files with 1,397 additions and 38 deletions.
1,000 changes: 1,000 additions & 0 deletions ExampleData/ais.kystverket.no/Ais1000Lines.nm4

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions ExampleData/ais.kystverket.no/LICENSE
@@ -0,0 +1,3 @@
This folder contains data under the Norwegian licence for Open Government data (NLOD) distributed by
the Norwegian Costal Administration - https://ais.kystverket.no/
The license can be found at https://data.norge.no/nlod/en/2.0
27 changes: 27 additions & 0 deletions README.md
Expand Up @@ -21,6 +21,33 @@ This library has been developed to support specific applications, so it only sup

See https://gpsd.gitlab.io/gpsd/AIVDM.html for a complete list of message types.

## Performance

The benchmark suite is a work in progress, but it currently measures two basic scenarios: discovering
message types, and extracting position data. Here are the results of executing these benchmarks on a system
with an Intel Core i9-9900K CPU 3.60GHz CPU:

```
| Method | Mean | Error | StdDev | Gen 0 | Gen 1 | Gen 2 | Allocated |
|------------------------------------ |---------:|---------:|---------:|----------:|------:|------:|----------:|
| InspectMessageTypesFromNorwayFile1M | 347.5 ms | 3.960 ms | 3.704 ms | 1000.0000 | - | - | 3.57 KB |
| ReadPositionsFromNorwayFile1M | 403.5 ms | 5.395 ms | 5.046 ms | 1000.0000 | - | - | 3.57 KB |
```

The test processes 1 million messages, so to get the per-message timings, we divide by 1 million (so
`ms` in this table indicate ns per message). This demonstrates a per-message cost of 404ns to extract the
position data, or 348ns just to inspect the message type. Note that these tests read data from a file, so
this includes all the IO overhead involved in getting hold of the data to be processed. (These messages are
in NMEA format by the way.)

Note that the `1000.0000` Gen 0 GCs is slightly misleading. BenchmarkDotNet reports the number of GCs per
1,000 executions of the benchmark. But in this case the benchmarks take long enough that it only executes
them once each per iteration. The number it shows is the number of GCs multiplied by 1,000, then divided by
the number of times it executed the benchmark (i.e. 1), so what this actually shows is that there was a single GC. We seem to see this whether we parse 1,000, 1,000,000, or 10,000,000 messages, so although we're
not quite sure why we get an GC at all, the important point here is that the memory overhead is fixed, no
matter how many messages you process.


## Licenses

[![GitHub license](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](https://raw.githubusercontent.com/ais-dotnet/Ais.Net/master/LICENSE)
Expand Down
28 changes: 28 additions & 0 deletions Solutions/Ais.Net.Benchmarks/Ais.Net.Benchmarks.csproj
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">

<Import Project="..\Common.NetCore_2_2.proj" />

<PropertyGroup>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<Content Include="..\..\ExampleData\ais.kystverket.no\Ais1000Lines.nm4" Link="TestData\Ais1000Lines.nm4">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>

<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.11.5" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Ais.Net\Ais.Net.csproj" />
</ItemGroup>

<ItemGroup>
<Folder Include="TestData\" />
</ItemGroup>

</Project>
76 changes: 76 additions & 0 deletions Solutions/Ais.Net.Benchmarks/AisNetBenchmarks.cs
@@ -0,0 +1,76 @@
// <copyright file="AisNetBenchmarks.cs" company="Endjin Limited">
// Copyright (c) Endjin Limited. All rights reserved.
// </copyright>

namespace Ais.Net.Benchmarks
{
using System.IO;
using System.Threading.Tasks;
using BenchmarkDotNet.Attributes;

/// <summary>
/// Defines all of the benchmarks and global setup/teardown.
/// </summary>
[JsonExporterAttribute.Full]
public class AisNetBenchmarks
{
private const string TestPath1kLines = "TestData/Ais1000Lines.nm4";
private const string TestPath1mLines = "TestData/Ais1000000Lines.nm4";

/// <summary>
/// Invoked by BenchmarkDotNet before running all benchmarks.
/// </summary>
[GlobalSetup]
public void GlobalSetup()
{
// We have 1000 lines of real test data to provide a realistic mix of messages.
// However, this is too small to get a good measurement of the per-message overhead,
// because the per-execution overheads are a significant proportion of the whole at
// that size.
// For example, on an Intel Core i9-9900K CPU 3.60GHz, the InspectMessageType test
// processes a 1000-message file in about 510us, suggesting a per-message cost of
// about 510ns. However, if we run the exact same test against a 1,000,000 message
// file, it takes about 340ms, suggesting a per-message cost of just 340ns. So by
// measuring over 1,000 messages, we get a reading that's 50% higher than we do at
// 1M. (We get similar results at 10M, so 1M seems to be sufficient. 100K might also
// be enough, but it's easier to read the results when we multiple things up three
// orders of magnitude at a time: it means that test times in ms correspond to
// per-message times in ns.)
string[] testFileLines = File.ReadAllLines(TestPath1kLines);
using (var f = new StreamWriter(TestPath1mLines))
{
for (int i = 0; i < 1000; ++i)
{
foreach (string line in testFileLines)
{
f.WriteLine(line);
}
}
}
}

/// <summary>
/// Invoked by BenchmarkDotNet after running all benchmarks.
/// </summary>
[GlobalCleanup]
public void GlobalCleanup()
{
File.Delete(TestPath1mLines);
}

/// <summary>
/// Benchmark: measure the speed at which we can perform the most minimal amount of
/// processing of messages in a file.
/// </summary>
/// <returns>A task that completes when the benchmark has finished.</returns>
[Benchmark]
public Task InspectMessageTypesFromNorwayFile1M() => InspectMessageType.ProcessMessagesFromFile(TestPath1mLines);

/// <summary>
/// Benchmark: measure the speed at which we can read location data from message in a file.
/// </summary>
/// <returns>A task that completes when the benchmark has finished.</returns>
[Benchmark]
public Task ReadPositionsFromNorwayFile1M() => ReadAllPositions.ProcessMessagesFromFile(TestPath1mLines);
}
}
50 changes: 50 additions & 0 deletions Solutions/Ais.Net.Benchmarks/InspectMessageType.cs
@@ -0,0 +1,50 @@
// <copyright file="InspectMessageType.cs" company="Endjin Limited">
// Copyright (c) Endjin Limited. All rights reserved.
// </copyright>

namespace Ais.Net.Benchmarks
{
using System;
using System.Threading.Tasks;

/// <summary>
/// Benchmark that measures how quickly we can read messages from a file and discover their
/// types.
/// </summary>
internal static class InspectMessageType
{
private static readonly TestProcessor Processor = new TestProcessor();

/// <summary>
/// Execute the benchmark.
/// </summary>
/// <param name="path">The file from which to read messages.</param>
/// <returns>A task that completes when the benchmark has finished.</returns>
public static async Task ProcessMessagesFromFile(string path)
{
await NmeaStreamParser.ParseFileAsync(path, Processor).ConfigureAwait(false);
}

private class TestProcessor : INmeaAisMessageStreamProcessor
{
private readonly int[] messageTypeCounts = new int[30];

public void OnCompleted()
{
}

public void OnNext(in NmeaLineParser firstLine, in ReadOnlySpan<byte> asciiPayload, uint padding)
{
int type = NmeaPayloadParser.PeekMessageType(asciiPayload, padding);
if (type < this.messageTypeCounts.Length)
{
this.messageTypeCounts[type] += 1;
}
}

public void Progress(bool done, int totalNmeaLines, int totalAisMessages, int totalTicks, int nmeaLinesSinceLastUpdate, int aisMessagesSinceLastUpdate, int ticksSinceLastUpdate)
{
}
}
}
}
60 changes: 60 additions & 0 deletions Solutions/Ais.Net.Benchmarks/Program.cs
@@ -0,0 +1,60 @@
// <copyright file="Program.cs" company="Endjin Limited">
// Copyright (c) Endjin Limited. All rights reserved.
// </copyright>

namespace Ais.Net.Benchmarks
{
using System.IO;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Diagnosers;
using BenchmarkDotNet.Jobs;
using BenchmarkDotNet.Running;

/// <summary>
/// Program entry point type.
/// </summary>
internal static class Program
{
/// <summary>
/// Program entry point.
/// </summary>
/// <param name="args">Command line arguments.</param>
/// <remarks>
/// <p>
/// When running in an Azure DevOps pipeline, a couple of things can go wrong with BenchmarkDotNet.
/// First, by default it puts its output into a path relative to the current working directory,
/// which will often be some way further up the folder hierarchy than we want. Second, the components
/// under test will have been built with a /p:Version=x.y.z argument, setting the version number to
/// whatever the build process has determined it should be. That can be problematic because
/// BenchmarkDotNet rebuilds various elements for each benchmark, meaning everything would default back
/// to v1.0.0.0. However, that seems to cause problems because the hosting benchmark project will
/// have been build against the correct version number. This shouldn't be a problem because the
/// benchmarks all run isolated, but weirdly, we get an error *after* the benchmarking is complete,
/// causing this hosting program to exit with an error.
/// </p>
/// <p>
/// To fix these problems, this application accepts two command line arguments. If present, they
/// set the path of the folder into which to write results, and the version number to be used when
/// rebuilding things.
/// </p>
/// </remarks>
private static void Main(string[] args)
{
IConfig config = DefaultConfig.Instance.With(MemoryDiagnoser.Default);
if (args.Length > 0)
{
string artifactsPath = args[0];
Directory.CreateDirectory(artifactsPath);
config = config.WithArtifactsPath(artifactsPath);
}

if (args.Length > 1)
{
string version = args[1];
config = config.With(Job.Default.With(new Argument[] { new MsBuildArgument($"/p:Version={version}") }));
}

BenchmarkRunner.Run<AisNetBenchmarks>(config);
}
}
}
85 changes: 85 additions & 0 deletions Solutions/Ais.Net.Benchmarks/ReadAllPositions.cs
@@ -0,0 +1,85 @@
// <copyright file="ReadAllPositions.cs" company="Endjin Limited">
// Copyright (c) Endjin Limited. All rights reserved.
// </copyright>

namespace Ais.Net.Benchmarks
{
using System;
using System.Threading.Tasks;

/// <summary>
/// Benchmark that measures how quickly we can read messages from a file and read out any
/// location data they contain.
/// </summary>
internal static class ReadAllPositions
{
private static readonly StatsScanner Processor = new StatsScanner();

/// <summary>
/// Execute the benchmark.
/// </summary>
/// <param name="path">The file from which to read messages.</param>
/// <returns>A task that completes when the benchmark has finished.</returns>
public static async Task ProcessMessagesFromFile(string path)
{
await NmeaStreamParser.ParseFileAsync(path, Processor).ConfigureAwait(false);
}

private class StatsScanner : INmeaAisMessageStreamProcessor
{
public long SummedLongs { get; private set; } = 0;

public long SummedLats { get; private set; } = 0;

public int PositionsCount { get; private set; } = 0;

/// <inheritdoc/>
public void OnNext(
in NmeaLineParser firstLine,
in ReadOnlySpan<byte> asciiPayload,
uint padding)
{
int messageType = NmeaPayloadParser.PeekMessageType(asciiPayload, padding);
if (messageType >= 1 && messageType <= 3)
{
var parsedPosition = new NmeaAisPositionReportClassAParser(asciiPayload, padding);
AddPosition(parsedPosition.Latitude10000thMins, parsedPosition.Longitude10000thMins);
}
else if (messageType == 18)
{
var parsedPosition = new NmeaAisPositionReportClassBParser(asciiPayload, padding);
AddPosition(parsedPosition.Latitude10000thMins, parsedPosition.Longitude10000thMins);
}
else if (messageType == 19)
{
var parsedPosition = new NmeaAisPositionReportExtendedClassBParser(asciiPayload, padding);
AddPosition(parsedPosition.Latitude10000thMins, parsedPosition.Longitude10000thMins);
}

void AddPosition(int latitude10000thMins, int longitude10000thMins)
{
this.SummedLats += latitude10000thMins;
this.SummedLongs += longitude10000thMins;
this.PositionsCount += 1;
}
}

/// <inheritdoc/>
public void OnCompleted()
{
}

/// <inheritdoc/>
public void Progress(
bool done,
int totalNmeaLines,
int totalAisMessages,
int totalTicks,
int nmeaLinesSinceLastUpdate,
int aisMessagesSinceLastUpdate,
int ticksSinceLastUpdate)
{
}
}
}
}
13 changes: 3 additions & 10 deletions Solutions/Ais.Net.Specs/Ais.Net.Specs.csproj
@@ -1,11 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">

<Import Project="..\Common.NetCore_2_2.proj" />

<PropertyGroup>
<TargetFramework>netcoreapp2.2</TargetFramework>
<RootNamespace />

<IsPackable>false</IsPackable>
<NoWarn>RCS1029;RCS1089;SA1600</NoWarn>
<NoWarn>RCS1029;RCS1089;SA1600;CS1591</NoWarn>
</PropertyGroup>

<ItemGroup>
Expand All @@ -21,13 +21,6 @@
</ItemGroup>

<ItemGroup>
<AdditionalFiles Include="..\stylecop.json" Link="stylecop.json" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.msbuild" Version="2.6.3">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
Expand Down
8 changes: 8 additions & 0 deletions Solutions/Ais.Net.sln
Expand Up @@ -10,12 +10,16 @@ EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{3C5F2C5B-609C-45A3-8D2C-87D74A35EC9E}"
ProjectSection(SolutionItems) = preProject
.editorconfig = .editorconfig
Common.Net.proj = Common.Net.proj
Common.NetCore_2_2.proj = Common.NetCore_2_2.proj
Common.NetStandard_2_0.proj = Common.NetStandard_2_0.proj
Directory.build.props = Directory.build.props
stylecop.json = stylecop.json
StyleCop.ruleset = StyleCop.ruleset
EndProjectSection
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ais.Net.Benchmarks", "Ais.Net.Benchmarks\Ais.Net.Benchmarks.csproj", "{8D30433E-DD03-4011-89B3-0DB0998BDC24}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand All @@ -30,6 +34,10 @@ Global
{A06E81B3-2490-43EF-BCE2-078BF88DD322}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A06E81B3-2490-43EF-BCE2-078BF88DD322}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A06E81B3-2490-43EF-BCE2-078BF88DD322}.Release|Any CPU.Build.0 = Release|Any CPU
{8D30433E-DD03-4011-89B3-0DB0998BDC24}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8D30433E-DD03-4011-89B3-0DB0998BDC24}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8D30433E-DD03-4011-89B3-0DB0998BDC24}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8D30433E-DD03-4011-89B3-0DB0998BDC24}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down

0 comments on commit 20fd24b

Please sign in to comment.