Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DateTime performance improvements #107

Merged
merged 14 commits into from
Mar 16, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,11 @@ dotnet_diagnostic.S4136.severity = warning
# S1694: An abstract class should have both abstract and concrete methods
dotnet_diagnostic.S1694.severity = none

[OADateTests.cs]
# S6562: Always set the "DateTimeKind" when creating new "DateTime" instances
dotnet_diagnostic.S6562.severity = none


[{*Test,*.Benchmark}/**.cs]
# S927: Parameter names should match base declaration
dotnet_diagnostic.S927.severity = none
Expand Down
30 changes: 15 additions & 15 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -5,41 +5,41 @@
</PropertyGroup>
<ItemGroup>
<GlobalPackageReference Include="ErrorProne.NET.CoreAnalyzers" Version="0.7.0-beta.1" />
<GlobalPackageReference Include="Meziantou.Analyzer" Version="2.0.186" />
<GlobalPackageReference Include="Meziantou.Analyzer" Version="2.0.188" />
<GlobalPackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4" />
<GlobalPackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.12.19" />
<GlobalPackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.13.2" />
<GlobalPackageReference Include="PrimaryConstructorAnalyzer" Version="1.0.6" />
<GlobalPackageReference Include="Roslynator.Analyzers" Version="4.12.11" />
<GlobalPackageReference Include="SonarAnalyzer.CSharp" Version="10.6.0.109712" />
<GlobalPackageReference Include="Roslynator.Analyzers" Version="4.13.1" />
<GlobalPackageReference Include="SonarAnalyzer.CSharp" Version="10.7.0.110445" />
</ItemGroup>
<ItemGroup>
<PackageVersion Include="BenchmarkDotNet" Version="0.14.0" />
<PackageVersion Include="Bogus" Version="35.6.1" />
<PackageVersion Include="Bogus" Version="35.6.2" />
<PackageVersion Include="ClosedXML" Version="0.104.2" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="DocumentFormat.OpenXml" Version="3.2.0" />
<PackageVersion Include="DocumentFormat.OpenXml" Version="3.3.0" />
<PackageVersion Include="EPPlusFree" Version="4.5.3.8" />
<PackageVersion Include="ErrorProne.NET.Structs" Version="0.6.1-beta.1" />
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="8.0.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Analyzer.Testing" Version="1.1.2" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.9.2" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageVersion Include="Polyfill" Version="7.13.0" />
<PackageVersion Include="PublicApiGenerator" Version="11.4.1" />
<PackageVersion Include="System.Collections.Immutable" Version="9.0.1" />
<PackageVersion Include="System.Drawing.Common" Version="9.0.1" />
<PackageVersion Include="System.Formats.Asn1" Version="9.0.1" />
<PackageVersion Include="PublicApiGenerator" Version="11.4.5" />
<PackageVersion Include="System.Collections.Immutable" Version="9.0.3" />
<PackageVersion Include="System.Drawing.Common" Version="9.0.3" />
<PackageVersion Include="System.Formats.Asn1" Version="9.0.3" />
<PackageVersion Include="System.IO.Compression" Version="4.3.0" />
<PackageVersion Include="System.Memory" Version="4.6.0" />
<PackageVersion Include="System.Reflection.Metadata" Version="9.0.1" />
<PackageVersion Include="System.Reflection.Metadata" Version="9.0.3" />
<PackageVersion Include="System.Text.RegularExpressions" Version="4.3.1" />
<PackageVersion Include="TngTech.ArchUnitNET.xUnit" Version="0.11.2" />
<PackageVersion Include="TngTech.ArchUnitNET.xUnit" Version="0.11.3" />
<PackageVersion Include="Verify.SourceGenerators" Version="2.5.0" />
<PackageVersion Include="Verify.Xunit" Version="28.9.0" />
<PackageVersion Include="Verify.Xunit" Version="28.15.0" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.assert" Version="2.9.3" />
<PackageVersion Include="Xunit.Combinatorial" Version="1.6.24" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.0.1" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.0.2" />
</ItemGroup>
</Project>
195 changes: 195 additions & 0 deletions SpreadCheetah.Benchmark/Benchmarks/OADates.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Jobs;
using System.Buffers.Text;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Text;

namespace SpreadCheetah.Benchmark.Benchmarks;

[SimpleJob(RuntimeMoniker.Net90)]
[MemoryDiagnoser]
public class OADates
{
private List<DateTime> _dateTimes = [];

[Params(10000)]
public int Count { get; set; }

[Params(true, false)]
public bool WithFractions { get; set; }

[GlobalSetup]
public void GlobalSetup()
{
var random = new Random(42);
var origin = new DateTime(2025, 1, 1);

Check warning on line 26 in SpreadCheetah.Benchmark/Benchmarks/OADates.cs

View workflow job for this annotation

GitHub Actions / build

Provide the "DateTimeKind" when creating this object. (https://rules.sonarsource.com/csharp/RSPEC-6562)

Check warning on line 26 in SpreadCheetah.Benchmark/Benchmarks/OADates.cs

View workflow job for this annotation

GitHub Actions / build

Provide the "DateTimeKind" when creating this object. (https://rules.sonarsource.com/csharp/RSPEC-6562)

Check warning on line 26 in SpreadCheetah.Benchmark/Benchmarks/OADates.cs

View workflow job for this annotation

GitHub Actions / build

Provide the "DateTimeKind" when creating this object. (https://rules.sonarsource.com/csharp/RSPEC-6562)
_dateTimes = Enumerable.Range(0, Count)
.Select(_ => WithFractions
? origin.AddSeconds(random.Next(-10000000, 10000000))
: origin.AddDays(random.Next(-10000, 10000)))
.ToList();
}

[Benchmark]
public IList<string> DateTime_ToOADate()
{
var result = new List<string>(Count);
Span<byte> destination = stackalloc byte[19];

foreach (var dateTime in _dateTimes)
{
var oaDate = dateTime.ToOADate();
Utf8Formatter.TryFormat(oaDate, destination, out var written);
var stringValue = Encoding.UTF8.GetString(destination.Slice(0, written));
result.Add(stringValue);
}

return result;
}

[Benchmark]
public IList<string> OADate_TryFormat()
{
var result = new List<string>(Count);
Span<byte> destination = stackalloc byte[19];

foreach (var dateTime in _dateTimes)
{
var oaDate = new OADate(dateTime.Ticks);
oaDate.TryFormat(destination, out var written);
var stringValue = Encoding.UTF8.GetString(destination.Slice(0, written));
result.Add(stringValue);
}

return result;
}
}

/// <summary>
/// Copy of SpreadCheetah.Helpers.OADate
/// </summary>
file readonly record struct OADate(long Ticks)
{
// Implementation is based on DateTime.ToOADate(). These constants are taken from there.
private const int DaysPerYear = 365;
private const int DaysPer4Years = DaysPerYear * 4 + 1;
private const int DaysPer100Years = DaysPer4Years * 25 - 1;
private const int DaysPer400Years = DaysPer100Years * 4 + 1;
private const int DaysTo1899 = DaysPer400Years * 4 + DaysPer100Years * 3 - 367;
private const long DoubleDateOffset = DaysTo1899 * TimeSpan.TicksPerDay;
private const long MillisecondsPerDay = TimeSpan.TicksPerDay / TimeSpan.TicksPerMillisecond;
private const long OADateMinAsTicks = (DaysPer100Years - DaysPerYear) * TimeSpan.TicksPerDay;

public bool TryFormat(Span<byte> destination, out int bytesWritten)
{
bytesWritten = 0;

// Days can be up to 7 digits (max = 2958465, min = -657434).
// In this implementation, the fraction part is limited to 11 digits.
if (destination.Length < 19)
return false;

var value = Ticks;
if (value == 0)
{
destination[0] = (byte)'0';
bytesWritten = 1;
return true;
}

if (value < TimeSpan.TicksPerDay)
value += DoubleDateOffset;

// TODO: Check in DataCell constructor and throw if below OADateMinAsTicks

Check warning on line 104 in SpreadCheetah.Benchmark/Benchmarks/OADates.cs

View workflow job for this annotation

GitHub Actions / build

TODO Check in DataCell constructor and throw if below OADateMinAsTicks (https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0026.md)

Check warning on line 104 in SpreadCheetah.Benchmark/Benchmarks/OADates.cs

View workflow job for this annotation

GitHub Actions / build

Complete the task associated to this 'TODO' comment. (https://rules.sonarsource.com/csharp/RSPEC-1135)

Check warning on line 104 in SpreadCheetah.Benchmark/Benchmarks/OADates.cs

View workflow job for this annotation

GitHub Actions / build

TODO Check in DataCell constructor and throw if below OADateMinAsTicks (https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0026.md)

Check warning on line 104 in SpreadCheetah.Benchmark/Benchmarks/OADates.cs

View workflow job for this annotation

GitHub Actions / build

Complete the task associated to this 'TODO' comment. (https://rules.sonarsource.com/csharp/RSPEC-1135)

Check warning on line 104 in SpreadCheetah.Benchmark/Benchmarks/OADates.cs

View workflow job for this annotation

GitHub Actions / build

TODO Check in DataCell constructor and throw if below OADateMinAsTicks (https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0026.md)

Check warning on line 104 in SpreadCheetah.Benchmark/Benchmarks/OADates.cs

View workflow job for this annotation

GitHub Actions / build

Complete the task associated to this 'TODO' comment. (https://rules.sonarsource.com/csharp/RSPEC-1135)

Check warning on line 104 in SpreadCheetah.Benchmark/Benchmarks/OADates.cs

View workflow job for this annotation

GitHub Actions / build

TODO Check in DataCell constructor and throw if below OADateMinAsTicks (https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0026.md)
Debug.Assert(value >= OADateMinAsTicks);

var millis = (value - DoubleDateOffset) / TimeSpan.TicksPerMillisecond;
var days = Math.DivRem(millis, MillisecondsPerDay, out var millisAfterMidnight);

if (millisAfterMidnight == 0)
return TryFormatDays(days, destination, out bytesWritten);

return TryFormatWithFraction(days, millisAfterMidnight, destination, out bytesWritten);
}

[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool TryFormatDays(long days, Span<byte> destination, out int bytesWritten)
{
#if NET8_0_OR_GREATER
return days.TryFormat(destination, out bytesWritten, provider: System.Globalization.NumberFormatInfo.InvariantInfo);
#else
return System.Buffers.Text.Utf8Formatter.TryFormat(days, destination, out bytesWritten);
#endif
}

#pragma warning disable MA0051 // Method is too long
private static bool TryFormatWithFraction(long days, long millisAfterMidnight, Span<byte> destination, out int bytesWritten)
#pragma warning restore MA0051 // Method is too long
{
var fraction = millisAfterMidnight * 1000000 / 864;
if (fraction < 0)
{
days--;
fraction += 100000000000;
}

TryFormatDays(days, destination, out bytesWritten);
destination[bytesWritten] = (byte)'.';
bytesWritten++;

var quotient = Math.DivRem(fraction, 10000000000, out var remainder);
destination[bytesWritten] = (byte)(quotient + '0');
bytesWritten++;
if (remainder == 0) return true;

quotient = Math.DivRem(remainder, 1000000000, out remainder);
destination[bytesWritten] = (byte)(quotient + '0');
bytesWritten++;
if (remainder == 0) return true;

quotient = Math.DivRem(remainder, 100000000, out remainder);
destination[bytesWritten] = (byte)(quotient + '0');
bytesWritten++;
if (remainder == 0) return true;

quotient = Math.DivRem(remainder, 10000000, out remainder);
destination[bytesWritten] = (byte)(quotient + '0');
bytesWritten++;
if (remainder == 0) return true;

quotient = Math.DivRem(remainder, 1000000, out remainder);
destination[bytesWritten] = (byte)(quotient + '0');
bytesWritten++;
if (remainder == 0) return true;

quotient = Math.DivRem(remainder, 100000, out remainder);
destination[bytesWritten] = (byte)(quotient + '0');
bytesWritten++;
if (remainder == 0) return true;

quotient = Math.DivRem(remainder, 10000, out remainder);
destination[bytesWritten] = (byte)(quotient + '0');
bytesWritten++;
if (remainder == 0) return true;

quotient = Math.DivRem(remainder, 1000, out remainder);
destination[bytesWritten] = (byte)(quotient + '0');
bytesWritten++;
if (remainder == 0) return true;

quotient = Math.DivRem(remainder, 100, out remainder);
destination[bytesWritten] = (byte)(quotient + '0');
bytesWritten++;
if (remainder == 0) return true;

quotient = Math.DivRem(remainder, 10, out remainder);
destination[bytesWritten] = (byte)(quotient + '0');
bytesWritten++;
if (remainder == 0) return true;

destination[bytesWritten] = (byte)(remainder + '0');
bytesWritten++;
return true;
}
}
12 changes: 12 additions & 0 deletions SpreadCheetah.Test/Helpers/RandomExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace SpreadCheetah.Test.Helpers;

internal static class RandomExtensions
{
public static DateTime NextDateTime(this Random r, DateTime minValue, DateTime maxValue)
{
var minTicks = minValue.Ticks;
var maxTicks = maxValue.Ticks;
var ticks = r.NextDouble() * (maxTicks - minTicks) + minTicks;
return new DateTime((long)ticks, DateTimeKind.Unspecified);
}
}
4 changes: 4 additions & 0 deletions SpreadCheetah.Test/SpreadCheetah.Test.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@
<PackageReference Include="DocumentFormat.OpenXml" />
<PackageReference Include="EPPlusFree" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Polyfill">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="PublicApiGenerator" />
<PackageReference Include="System.Drawing.Common" />
<PackageReference Include="System.Text.RegularExpressions" />
Expand Down
77 changes: 77 additions & 0 deletions SpreadCheetah.Test/Tests/DataCellTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
namespace SpreadCheetah.Test.Tests;

public static class DataCellTests
{
[Theory, CombinatorialData]
public static void DataCell_DateTime_InvalidOADate(bool nullable)
{
// Arrange
var dateTime = new DateTime(99, 1, 1, 0, 0, 0, DateTimeKind.Unspecified);
string? expectedMessage = null;
try
{
dateTime.ToOADate();
}
catch (OverflowException e)
{
expectedMessage = e.Message;
}

Assert.NotNull(expectedMessage);

// Act
var exception = Record.Exception(() => nullable
? new DataCell((DateTime?)dateTime)
: new DataCell(dateTime));

// Assert
Assert.IsType<OverflowException>(exception);
Assert.Equal(expectedMessage, exception.Message);
}

[Theory, CombinatorialData]
public static void DataCell_DateTime_MinValue(bool nullable)
{
// Arrange
var dateTime = DateTime.MinValue;

// Act
var exception = Record.Exception(() => nullable
? new DataCell((DateTime?)dateTime)
: new DataCell(dateTime));

// Assert
Assert.Null(exception);
}

[Theory, CombinatorialData]
public static void DataCell_DateTime_MinValueWithTimePart(
[CombinatorialValues(86399, 86400)] int seconds,
bool nullable)
{
// Arrange
var dateTime = DateTime.MinValue.AddSeconds(seconds);
var shouldThrow = seconds == 86400;

// Act
var exception = Record.Exception(() => nullable
? new DataCell((DateTime?)dateTime)
: new DataCell(dateTime));

// Assert
Assert.Equal(shouldThrow, exception is not null);
}

[Fact]
public static void DataCell_NullableDateTime_NullValue()
{
// Arrange
DateTime? dateTime = null;

// Act
var exception = Record.Exception(() => new DataCell(dateTime));

// Assert
Assert.Null(exception);
}
}
Loading