Skip to content

feat: source-generate the time zone data and target .NET 10 - #150

Open
sensslen wants to merge 3 commits into
mattjohnsonpint:mainfrom
renewedvision:source-generated-data
Open

sensslen wants to merge 3 commits into
mattjohnsonpint:mainfrom
renewedvision:source-generated-data

Conversation

@sensslen

@sensslen sensslen commented Oct 20, 2026 •

Copy link
Copy Markdown

Replaces the gzipped JSON embedded resource with a source generator that encodes data.json into a byte array, which TimeZoneData.Load() decodes into the same dictionaries. The first call into TZNames no longer decompresses and parses 10 MB of JSON.

  • A byte array initializer compiles to a data blob rather than IL, so the JIT has nothing large to compile. Strings are pooled, so each distinct string is allocated once instead of once per occurrence.
  • The generator uses Newtonsoft.Json rather than System.Text.Json, whose dependency chain is awkward to load inside the compiler. It ships next to the generator and never reaches consumers.
  • With the resource gone, the package no longer depends on System.Text.Json on netstandard2.0, netstandard2.1 and net462.
  • data.json is committed uncompressed because additional files reach a generator as text. The data itself is unchanged: it is the old data.json.gz decompressed. The DataBuilder now writes data.json.
  • ThresholdUtc is now always DateTimeKind.Utc. The DateTime.MaxValue thresholds used to load as Unspecified; the comparison ignores the kind, so results are unchanged.
  • Adds net10.0 to the library and tests (net8.0 and net9.0 to tests), and moves global.json and the DataBuilder to the .NET 10 SDK.

Measurements

First TZNames.GetDisplayNames("en-US") in a fresh .NET 9 process on Windows, Release, median of 15 runs.

main this PR
First call 200 ms 30 ms
Allocated during the call 30.9 MB 17.1 MB
GC heap after a full collection 20.6 MB 12.4 MB
Private bytes 42.6 MB 20.6 MB
Working set 66.5 MB 43.8 MB
TimeZoneNames.dll (net9.0) 1.75 MB 3.85 MB

NativeAOT is not measured.

Can_Get_DisplayNames_For_OS_Culture fails on my machine both before and after this change, so it is unrelated.

🤖 Generated with Claude Code

Simon Ensslen and others added 2 commits October 20, 2026 21:02
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jnyrup

jnyrup commented Oct 21, 2026

Copy link
Copy Markdown
Contributor

Preamble: I'm just another consumer of TimeZoneNames, i.e. I have no authority here.

That's a clever use of a source-generator!

I tried getting some numbers to see the pros/cons of this approach.

file size of nupkg:

  • main: 9857 kb
  • PR: 9901 kb

file size of net462 TimeZoneNames.dll

  • main: 2mb
  • PR: 6 mb

A NativeAOT ConsoleApp that references TimeZoneNames and calls each public API.
I measured the file size of the published .exe file and the runtime by using Measure-Command

  • main:
    • 4675 kb
    • 235 ms
  • PR:
    • 13763 kb
    • 168 ms

Some suggestions:
Can the capacity of the dictionaries be set to avoid dynamic resizing and over-allocation?

There are +700 empty dictionaries, can we use a few shared empty instances instead?
This of course now assumes that dictionaries can not be mutated by consumers, but as far as I can tell the dictionary instances aren't exposed to consumers today.

I noticed that ThresholdUtc is sometimes has DateTimeKind.Unspecified and sometimes DateTimeKind.Utc :suspect:
Should it consistently be DateTimeKind.Utc?

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sensslen

Copy link
Copy Markdown
Author

Thanks for measuring. The rework in c3458d7 now generates a byte array instead of one huge initializer, because on the JIT the original approach was slower than main: about 500 ms of the first call was the JIT compiling the generated IL. The updated numbers are in the description. I haven't measured NativeAOT again.

  • Capacity: every dictionary is now created at its final size.
  • Empty dictionaries: I left them unshared. An empty Dictionary allocates no buckets, so 743 of them come to about 60 KB.
  • ThresholdUtc: it's now always DateTimeKind.Utc. The Unspecified ones were the DateTime.MaxValue entries, which main loads the same way. The comparison ignores the kind, so results don't change.

@jnyrup

jnyrup commented Oct 21, 2026

Copy link
Copy Markdown
Contributor

With the new commit, the NativeAOT console app had these numbers:

  • 5872 kb
  • 198 ms

Here's the code project I used to measure NativeAOT.

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <RuntimeIdentifier>win-x64</RuntimeIdentifier>
    <PublishAot>true</PublishAot>
    <InvariantGlobalization>false</InvariantGlobalization>
  </PropertyGroup>
  <ItemGroup>
    <ProjectReference Include="..\src\TimeZoneNames\TimeZoneNames.csproj" />
  </ItemGroup>
</Project>
using TimeZoneNames;

const string countryCode = "US";
const string languageCode = "en-US";
const string timeZoneId = "America/New_York";
var threshold = new DateTimeOffset(2020, 1, 1, 0, 0, 0, TimeSpan.Zero);

Console.WriteLine("GetTimeZoneIdsForCountry: " + TZNames.GetTimeZoneIdsForCountry(countryCode).Length);
Console.WriteLine("GetTimeZoneIdsForCountry with threshold: " + TZNames.GetTimeZoneIdsForCountry(countryCode, threshold).Length);
Console.WriteLine("GetTimeZonesForCountry: " + TZNames.GetTimeZonesForCountry(countryCode, languageCode).Count);
Console.WriteLine("GetTimeZonesForCountry with threshold: " + TZNames.GetTimeZonesForCountry(countryCode, languageCode, threshold).Count);
Console.WriteLine("GetFixedTimeZoneIds: " + TZNames.GetFixedTimeZoneIds().Count);
Console.WriteLine("GetFixedTimeZoneNames: " + TZNames.GetFixedTimeZoneNames(languageCode).Count);
Console.WriteLine("GetFixedTimeZoneAbbreviations: " + TZNames.GetFixedTimeZoneAbbreviations().Count);
Console.WriteLine("GetNamesForTimeZone: " + TZNames.GetNamesForTimeZone(timeZoneId, languageCode).Generic);
Console.WriteLine("GetCountryNames: " + TZNames.GetCountryNames(languageCode).Count);
Console.WriteLine("GetDisplayNameForTimeZone: " + TZNames.GetDisplayNameForTimeZone(timeZoneId, languageCode));
Console.WriteLine("GetDisplayNames: " + TZNames.GetDisplayNames(languageCode).Count);
Console.WriteLine("GetDisplayNames with IANA Zone IDs: " + TZNames.GetDisplayNames(languageCode, useIanaZoneIds: true).Count);
Console.WriteLine("GetLanguageCodes: " + TZNames.GetLanguageCodes().Count);
Console.WriteLine("GetLanguageCodes forDisplayNames false: " + TZNames.GetLanguageCodes(forDisplayNames: false).Count);
Console.WriteLine("GetLanguageCodes forDisplayNames true: " + TZNames.GetLanguageCodes(forDisplayNames: true).Count);

// Exercise obsolete APIs too so the NativeAOT smoke test covers the entire public API.
#pragma warning disable CS0618
Console.WriteLine("GetFixedTimeZoneAbbreviations: " + TZNames.GetFixedTimeZoneAbbreviations(languageCode).Count);
Console.WriteLine("GetAbbreviationsForTimeZone: " + TZNames.GetAbbreviationsForTimeZone(timeZoneId, languageCode).Generic);
#pragma warning restore CS0618

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants