Skip to content
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
18 changes: 17 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.0.4] - 2025-08-03

### Fixed

- Corrected the package release notes to include the repository URL.
- Restored AddEmbeddedAttributeDefinition Stage

### Changed

- Improved the EquatableImmutableArray instance cache (for most cases)

### Added

- Include Generator name and version in the "auto-generated" source code comment.

## [1.0.3] - 2025-07-28

### Removed
Expand Down Expand Up @@ -75,7 +90,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

---

[Unreleased]: https://github.com/datacute/IncrementalGeneratorExtensions/compare/1.0.3...develop
[Unreleased]: https://github.com/datacute/IncrementalGeneratorExtensions/compare/1.0.4...develop
[1.0.4]: https://github.com/datacute/IncrementalGeneratorExtensions/releases/tag/1.0.4
[1.0.3]: https://github.com/datacute/IncrementalGeneratorExtensions/releases/tag/1.0.3
[1.0.2]: https://github.com/datacute/IncrementalGeneratorExtensions/releases/tag/1.0.2
[1.0.1]: https://github.com/datacute/IncrementalGeneratorExtensions/releases/tag/1.0.1
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,112 +22,14 @@ public sealed class EquatableImmutableArray<T> : IEquatable<EquatableImmutableAr
{
public static EquatableImmutableArray<T> Empty { get; } = new EquatableImmutableArray<T>(ImmutableArray<T>.Empty, 0);

// The source generation pipelines compare these a lot
// so being able to quickly tell when they are different
// is important.
// We will use an instance cache to find when we can reuse
// an existing object, massively speeding up the Equals call.
#region Instance Cache

// The WeakReference allows the GC to collect arrays that are no longer in use.
// Thread-safe cache using dictionary of hash code -> list of arrays with that hash
private static readonly ConcurrentDictionary<int, List<WeakReference<EquatableImmutableArray<T>>>> ValueCache = new ConcurrentDictionary<int, List<WeakReference<EquatableImmutableArray<T>>>>();

// Static factory method with singleton handling
public static EquatableImmutableArray<T> Create(ImmutableArray<T> values, CancellationToken cancellationToken = default)
{
#if !DATACUTE_EXCLUDE_LIGHTWEIGHTTRACE && !DATACUTE_EXCLUDE_GENERATORSTAGE
// Record a histogram of the array sizes we are being asked to create
LightweightTrace.IncrementCount(GeneratorStage.EquatableImmutableArrayLength, values.Length);
#endif
if (values.IsEmpty)
return Empty;

var hash = CalculateHashCode(values);
var list = ValueCache.GetOrAdd(hash, _ => new List<WeakReference<EquatableImmutableArray<T>>>());

lock (list)
{
for (int i = list.Count - 1; i >= 0; i--)
{
#if !DATACUTE_EXCLUDE_LIGHTWEIGHTTRACEEXTENSIONS && !DATACUTE_EXCLUDE_GENERATORSTAGE
cancellationToken.ThrowIfCancellationRequested(0);
#else
cancellationToken.ThrowIfCancellationRequested();
#endif
if (list[i].TryGetTarget(out var existing))
{
if (ValuesEqual(values, existing._values))
{
// Cache Hit
#if !DATACUTE_EXCLUDE_LIGHTWEIGHTTRACE && !DATACUTE_EXCLUDE_GENERATORSTAGE
LightweightTrace.IncrementCount(GeneratorStage.EquatableImmutableArrayCacheHit);
#endif
return existing;
}
}
else
{
#if !DATACUTE_EXCLUDE_LIGHTWEIGHTTRACE && !DATACUTE_EXCLUDE_GENERATORSTAGE
LightweightTrace.IncrementCount(GeneratorStage.EquatableImmutableArrayCacheWeakReferenceRemoved);
#endif
list.RemoveAt(i);
}
}

// Cache Miss: Create a new instance
#if !DATACUTE_EXCLUDE_LIGHTWEIGHTTRACE && !DATACUTE_EXCLUDE_GENERATORSTAGE
LightweightTrace.IncrementCount(GeneratorStage.EquatableImmutableArrayCacheMiss);
#endif
var newResult = new EquatableImmutableArray<T>(values, hash);
list.Add(new WeakReference<EquatableImmutableArray<T>>(newResult));
return newResult;
}
}

private static int CalculateHashCode(ImmutableArray<T> values)
{
var comparer = EqualityComparer<T>.Default;
var hash = 0;
for (var index = 0; index < values.Length; index++)
{
var value = values[index];
hash = HashHelpers_Combine(hash, value == null ? 0 : comparer.GetHashCode(value));
}
return hash;
}

private static int HashHelpers_Combine(int h1, int h2)
{
// RyuJIT optimizes this to use the ROL instruction
// Related GitHub pull request: https://github.com/dotnet/coreclr/pull/1830
uint rol5 = ((uint)h1 << 5) | ((uint)h1 >> 27);
return ((int)rol5 + h1) ^ h2;
}

private static bool ValuesEqual(ImmutableArray<T> a, ImmutableArray<T> b)
{
// Identical arrays reference check
if (a == b) return true;

int length = a.Length;
if (length != b.Length) return false;

var comparer = EqualityComparer<T>.Default;
for (int i = 0; i < length; i++)
{
if (!comparer.Equals(a[i], b[i]))
return false;
}

return true;
}

#endregion
public static EquatableImmutableArray<T> Create(ImmutableArray<T> values, CancellationToken cancellationToken = default)
=> EquatableImmutableArrayInstanceCache<T>.GetOrCreate(values, cancellationToken);

private readonly ImmutableArray<T> _values;
private readonly int _hashCode;
private readonly int _length;

public T this[int index] => _values[index];
public int Count => _length;

Expand All @@ -137,7 +39,7 @@ private static bool ValuesEqual(ImmutableArray<T> a, ImmutableArray<T> b)
public bool IsDefault => _values.IsDefault;
public bool IsDefaultOrEmpty => _values.IsDefaultOrEmpty;

private EquatableImmutableArray(ImmutableArray<T> values, int hashCode)
internal EquatableImmutableArray(ImmutableArray<T> values, int hashCode)
{
_values = values;
_length = values.Length;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
// <auto-generated>
// This file is part of the Datacute.IncrementalGeneratorExtensions package.
// It is included as a source file and should not be modified.
// </auto-generated>

#if !DATACUTE_EXCLUDE_EQUATABLEIMMUTABLEARRAY
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Threading;

namespace Datacute.IncrementalGeneratorExtensions
{
// The source generation pipelines compare these a lot
// so being able to quickly tell when they are different
// is important.
// We will use an instance cache to find when we can reuse
// an existing object, massively speeding up the Equals call.

/// <summary>
/// A cache for instances of <see cref="EquatableImmutableArray{T}"/>.
/// </summary>
/// <typeparam name="T">The type of elements in the array, which must implement <see cref="IEquatable{T}"/>.</typeparam>
public static class EquatableImmutableArrayInstanceCache<T> where T : IEquatable<T>
{
// Two-level cache: length -> first element hash -> list of instances
// Because this is a generic class, there is a separate static cache for each type T
private static readonly ConcurrentDictionary<int, ConcurrentDictionary<int, List<WeakReference<EquatableImmutableArray<T>>>>> Cache =
new ConcurrentDictionary<int, ConcurrentDictionary<int, List<WeakReference<EquatableImmutableArray<T>>>>>();

/// <summary>
/// Gets or creates an instance of <see cref="EquatableImmutableArray{T}"/> from the provided values.
/// </summary>
/// <param name="values">The immutable array of values to create the instance from.</param>
/// <param name="cancellationToken">A cancellation token to observe while waiting for the operation to complete.</param>
/// <returns>An instance of <see cref="EquatableImmutableArray{T}"/> containing the provided values.</returns>
public static EquatableImmutableArray<T> GetOrCreate(ImmutableArray<T> values, CancellationToken cancellationToken = default)
{
if (values.IsEmpty)
return EquatableImmutableArray<T>.Empty;

// If we were to calculate the hash of the entire array first, and find
// matching instances based on that, we would still have to check each element
// for equality. Instead, we will first find a small number of potentially equal arrays,
// and then check each element for equality, since that is required anyway.
// If we find a match, we've saved the time to compute the full hash.

// To quickly narrow down the candidates for equality checks,
// this implementation uses a two-level cache:
// 1. The first level is based on the length of the array.
// 2. The second level is based on the hash of the first element.

// TODO: Optimize cache performance for large arrays with localized differences
// Current approach may be O(n²) when many similar large arrays differ at high indices
// Potential solutions:
// 1. Multi-element hashing (hash first few elements for better bucketing)
// 2. Sparse sampling (hash elements at strategic intervals: start, 1/4, 1/2, 3/4, end)
// 3. Adaptive difference tracking (learn common difference points, create sub-caches)
// 4. Hierarchical bucketing (first element -> second element -> etc.)
// Need real usage data to determine which approach provides best performance

var comparer = EqualityComparer<T>.Default;
var length = values.Length;
var firstElementHash = values[0] == null ? 0 : comparer.GetHashCode(values[0]);

// Get or create the length-based dictionary
var lengthDict = Cache.GetOrAdd(length, _ => new ConcurrentDictionary<int, List<WeakReference<EquatableImmutableArray<T>>>>());

// Get or create the first-element-hash-based list
var candidateList = lengthDict.GetOrAdd(firstElementHash, _ => new List<WeakReference<EquatableImmutableArray<T>>>());

lock (candidateList)
{
// Check for matches (backwards to allow removal of dead references)
for (int i = candidateList.Count - 1; i >= 0; i--)
{
#if !DATACUTE_EXCLUDE_LIGHTWEIGHTTRACEEXTENSIONS && !DATACUTE_EXCLUDE_GENERATORSTAGE
cancellationToken.ThrowIfCancellationRequested(0);
#else
cancellationToken.ThrowIfCancellationRequested();
#endif
if (candidateList[i].TryGetTarget(out var existing))
{
// Check if this candidate matches element by element
bool isMatch = true;

for (int elementIndex = 0; elementIndex < length; elementIndex++)
{
if (!comparer.Equals(values[elementIndex], existing[elementIndex]))
{
isMatch = false;
break;
}
}

if (isMatch)
{
#if !DATACUTE_EXCLUDE_LIGHTWEIGHTTRACE && !DATACUTE_EXCLUDE_GENERATORSTAGE
LightweightTrace.IncrementCount(GeneratorStage.EquatableImmutableArrayCacheHit, values.Length);
#endif
return existing;
}
}
else
{
// Clean up dead references
#if !DATACUTE_EXCLUDE_LIGHTWEIGHTTRACE && !DATACUTE_EXCLUDE_GENERATORSTAGE
LightweightTrace.IncrementCount(GeneratorStage.EquatableImmutableArrayCacheWeakReferenceRemoved, values.Length);
LightweightTrace.DecrementCount(GeneratorStage.EquatableImmutableArrayLength, values.Length);
#endif
candidateList.RemoveAt(i);
}
}

// No match found, calculate hash and create new instance
var hash = CalculateHashCode(values, comparer, firstElementHash, 1);
#if !DATACUTE_EXCLUDE_LIGHTWEIGHTTRACE && !DATACUTE_EXCLUDE_GENERATORSTAGE
// Record a histogram of the array sizes we are being asked to create
LightweightTrace.IncrementCount(GeneratorStage.EquatableImmutableArrayCacheMiss, values.Length);
LightweightTrace.IncrementCount(GeneratorStage.EquatableImmutableArrayLength, values.Length);
#endif
var newResult = new EquatableImmutableArray<T>(values, hash);
candidateList.Add(new WeakReference<EquatableImmutableArray<T>>(newResult));
return newResult;
}
}

private static int CalculateHashCode(ImmutableArray<T> values, EqualityComparer<T> comparer, int currentHash, int hashedValues)
{
int hash = currentHash;
for (var index = hashedValues; index < values.Length; index++)
{
var value = values[index];
hash = HashHelpers_Combine(hash, value == null ? 0 : comparer.GetHashCode(value));
}
return hash;
}

private static int HashHelpers_Combine(int h1, int h2)
{
// RyuJIT optimizes this to use the ROL instruction
// Related GitHub pull request: https://github.com/dotnet/coreclr/pull/1830
uint rol5 = ((uint)h1 << 5) | ((uint)h1 >> 27);
return ((int)rol5 + h1) ^ h2;
}
}
}
#endif
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public enum GeneratorStage
RegisterImplementationSourceOutput = 4,

// PostInitializationContext Methods
PostInitializationContextAddEmbeddedAttributeDefinition = 5,
PostInitializationContextAddSource = 6,

// SourceProductionContext Methods
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,12 @@ public static class GeneratorStageDescriptions
{ (int)GeneratorStage.Cancellation, "Operation Cancelled" },

// Output registration stages
{ (int)GeneratorStage.RegisterPostInitializationOutput, "Register Post Initialization Output" },
{ (int)GeneratorStage.RegisterSourceOutput, "Register Source Output" },
{ (int)GeneratorStage.RegisterImplementationSourceOutput, "Register Implementation Source Output" },
{ (int)GeneratorStage.RegisterPostInitializationOutput, "Register Post Initialization Output - callback called" },
{ (int)GeneratorStage.RegisterSourceOutput, "Register Source Output - action called" },
{ (int)GeneratorStage.RegisterImplementationSourceOutput, "Register Implementation Source Output - action called" },

// PostInitializationContext Methods
{ (int)GeneratorStage.PostInitializationContextAddEmbeddedAttributeDefinition, "Post Initialization Context Add Embedded Attribute Definition" },
{ (int)GeneratorStage.PostInitializationContextAddSource, "Post Initialization Context Add Source" },

// SourceProductionContext Methods
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@ namespace Datacute.IncrementalGeneratorExtensions
public class SourceTextGeneratorBase<T>
where T : IEquatable<T>
{
private static readonly string _assemblyName;
private static readonly string _version;

static SourceTextGeneratorBase()
{
var assemblyName = typeof(SourceTextGeneratorBase<T>).Assembly.GetName();
_assemblyName = assemblyName.Name;
_version = assemblyName.Version != null ? assemblyName.Version.ToString(fieldCount: 3) : "unknown";
}

/// <summary>
/// Initializes a new instance of the <see cref="SourceTextGeneratorBase{T}"/> class with the provided context and cancellation token.
/// </summary>
Expand Down Expand Up @@ -79,12 +89,23 @@ protected SourceTextGeneratorBase(
/// The comment that will be used at the top of the source file to indicate that this is an automatically generated file.
/// </summary>
protected virtual string AutoGeneratedComment => /* language=c# */
@"//------------------------------------------------------------------------------
$@"//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a source code generator.
// This code was generated by {GeneratorName}.
// Version: {Version}
// </auto-generated>
//------------------------------------------------------------------------------";

/// <summary>
/// Gets the name of the source code generator, which will be included in the auto-generated comment.
/// </summary>
protected virtual string GeneratorName => _assemblyName ?? GetType().Namespace ?? "a source code generator";

/// <summary>
/// Gets the version to show in the auto-generated comment.
/// </summary>
protected virtual string Version => _version;

/// <summary>
/// Gets the generated source text as a <see cref="SourceText"/> object.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,11 @@
<Title>Datacute Incremental Generator Extensions</Title>
<Description>Extension methods and helper classes for incremental source generator projects.</Description>
<PackageTags>SourceGenerator source compiletime</PackageTags>
<PackageProjectUrl>https://github.com/datacute/IncrementalGeneratorExtensions</PackageProjectUrl>
<PackageReleaseNotes>See full release notes and changelog: $(PackageProjectUrl)/blob/main/CHANGELOG.md</PackageReleaseNotes>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageRequireLicenseAcceptance>false</PackageRequireLicenseAcceptance>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageProjectUrl>https://github.com/datacute/IncrementalGeneratorExtensions</PackageProjectUrl>
<NoPackageAnalysis>true</NoPackageAnalysis>
<PackageOutputPath>$(MSBuildThisFileDirectory)../artifacts</PackageOutputPath>
</PropertyGroup>
Expand Down
3 changes: 1 addition & 2 deletions global.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
{
"sdk": {
"version": "9.0.0",
"rollForward": "latestMajor",
"allowPrerelease": true
"rollForward": "latestFeature"
}
}
Loading