From 73f23734583a747c8309fa80bf1efc0f72bad31d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Fri, 9 Oct 2026 22:17:13 +0300 Subject: [PATCH 01/16] Fix benchmark index wrap and buffer aliasing GetNextIndex only alternated between the first two entries, and all buffers were shared, so most compared pairs were identical. --- src/Ulid.Benchmarks/Program.cs | 213 ++++++++++++++++++++------------- 1 file changed, 127 insertions(+), 86 deletions(-) diff --git a/src/Ulid.Benchmarks/Program.cs b/src/Ulid.Benchmarks/Program.cs index d0960cc..6578ee3 100644 --- a/src/Ulid.Benchmarks/Program.cs +++ b/src/Ulid.Benchmarks/Program.cs @@ -312,99 +312,140 @@ public class GetHashCode : BenchmarkBase public abstract class BenchmarkBase { protected const int _iterationSize = 1024; - private int _idx; + private int _idx; - protected ByteAether.Ulid.Ulid[] _byteAether0 = null!; - protected ByteAether.Ulid.Ulid[] _byteAether1 = null!; + protected ByteAether.Ulid.Ulid[] _byteAether0 = null!; + protected ByteAether.Ulid.Ulid[] _byteAether1 = null!; - protected NetUlid.Ulid[] _netUlid0 = null!; - protected NetUlid.Ulid[] _netUlid1 = null!; + protected NetUlid.Ulid[] _netUlid0 = null!; + protected NetUlid.Ulid[] _netUlid1 = null!; - protected System.Ulid[] _cysharp0 = null!; - protected System.Ulid[] _cysharp1 = null!; + protected System.Ulid[] _cysharp0 = null!; + protected System.Ulid[] _cysharp1 = null!; - protected NUlid.Ulid[] _nulid0 = null!; - protected NUlid.Ulid[] _nulid1 = null!; + protected NUlid.Ulid[] _nulid0 = null!; + protected NUlid.Ulid[] _nulid1 = null!; - protected System.Guid[] _guid0 = null!; - protected System.Guid[] _guid1 = null!; + protected System.Guid[] _guid0 = null!; + protected System.Guid[] _guid1 = null!; - protected string[] _base32 = null!; - protected string[] _guidHex = null!; - protected byte[][] _bytes = null!; + protected string[] _base32 = null!; + protected string[] _guidHex = null!; + protected byte[][] _bytes = null!; - /// - /// Fetches the current index and increments/wraps it without branching. - /// Inlining guarantees zero benchmark method call overhead. - /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - protected int GetNextIndex() - { - var current = _idx; - _idx = (current + 1) & (_idx - 1); - return current; - } - - [GlobalSetup] - public void GlobalSetup() - { - _idx = 0; - - _byteAether0 = new ByteAether.Ulid.Ulid[_iterationSize]; - _byteAether1 = new ByteAether.Ulid.Ulid[_iterationSize]; - - _netUlid0 = new NetUlid.Ulid[_iterationSize]; - _netUlid1 = new NetUlid.Ulid[_iterationSize]; - - _cysharp0 = new System.Ulid[_iterationSize]; - _cysharp1 = new System.Ulid[_iterationSize]; - - _nulid0 = new NUlid.Ulid[_iterationSize]; - _nulid1 = new NUlid.Ulid[_iterationSize]; - - _guid0 = new System.Guid[_iterationSize]; - _guid1 = new System.Guid[_iterationSize]; - - _base32 = new string[_iterationSize]; - _guidHex = new string[_iterationSize]; - _bytes = new byte[_iterationSize][]; - - var rand = new System.Random(42); - var leftBuffer = new byte[16]; - var rightBuffer = new byte[16]; - - for (var i = 0; i < _iterationSize; i++) - { - rand.NextBytes(leftBuffer); - rand.NextBytes(rightBuffer); - - // Scenarios - switch (i % 4) - { - case 0: break; // Left and Right are completely random - case 1: rightBuffer = leftBuffer; break; // Same values - case 2: System.Array.Copy(leftBuffer, 0, rightBuffer, 0, 6); break; // The first 6 bytes are the same (same timestamp) - case 3: System.Array.Copy(leftBuffer, 0, rightBuffer, 0, 15); break; // The first 15 bytes are the same (+1 increment) - } - - _byteAether0[i] = ByteAether.Ulid.Ulid.New(leftBuffer); - _byteAether1[i] = ByteAether.Ulid.Ulid.New(rightBuffer); - - _netUlid0[i] = new(leftBuffer); - _netUlid1[i] = new(rightBuffer); - - _cysharp0[i] = new(leftBuffer); - _cysharp1[i] = new(rightBuffer); - - _nulid0[i] = new(leftBuffer); - _nulid1[i] = new(rightBuffer); + /// + /// Fetches the current index and increments/wraps it without branching. + /// Inlining guarantees zero benchmark method call overhead. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + protected int GetNextIndex() + { + var current = _idx; + _idx = (current + 1) & (_iterationSize - 1); + return current; + } - _guid0[i] = new(leftBuffer); - _guid1[i] = new(rightBuffer); + [GlobalSetup] + public void GlobalSetup() + { + _idx = 0; + + _byteAether0 = new ByteAether.Ulid.Ulid[_iterationSize]; + _byteAether1 = new ByteAether.Ulid.Ulid[_iterationSize]; + + _netUlid0 = new NetUlid.Ulid[_iterationSize]; + _netUlid1 = new NetUlid.Ulid[_iterationSize]; + + _cysharp0 = new System.Ulid[_iterationSize]; + _cysharp1 = new System.Ulid[_iterationSize]; + + _nulid0 = new NUlid.Ulid[_iterationSize]; + _nulid1 = new NUlid.Ulid[_iterationSize]; + + _guid0 = new System.Guid[_iterationSize]; + _guid1 = new System.Guid[_iterationSize]; + + _base32 = new string[_iterationSize]; + _guidHex = new string[_iterationSize]; + _bytes = new byte[_iterationSize][]; + + var rand = new System.Random(42); + + // Not a limitation of the ULID specification itself (it allows timestamps up to 2^48-1 ms, + // i.e. up to roughly year 10889) - this is .NET's DateTime/DateTimeOffset max (year 9999) + // leaking through. NUlid eagerly materializes a DateTimeOffset while parsing/constructing, + // so a fully random timestamp has a ~10% chance of making it throw, even though every other + // library here can store/round-trip such a ULID fine as long as it isn't asked to project it + // into a DateTime/DateTimeOffset. Capping the generated timestamp keeps the corpus usable + // across all libraries being compared. + var maxTimestampMs = (ulong)System.DateTimeOffset.MaxValue.ToUnixTimeMilliseconds(); + + for (var i = 0; i < _iterationSize; i++) + { + var leftBuffer = new byte[16]; + var rightBuffer = new byte[16]; + rand.NextBytes(leftBuffer); + rand.NextBytes(rightBuffer); + + CapTimestamp(leftBuffer, maxTimestampMs); + CapTimestamp(rightBuffer, maxTimestampMs); + + // Scenarios + switch (i % 4) + { + case 0: + break; // Left and Right are completely random + case 1: + System.Array.Copy(leftBuffer, rightBuffer, 16); + break; // Same values + case 2: + System.Array.Copy(leftBuffer, 0, rightBuffer, 0, 6); + break; // The first 6 bytes are the same (same timestamp) + case 3: + System.Array.Copy(leftBuffer, 0, rightBuffer, 0, 15); + break; // The first 15 bytes are the same (+1 increment) + } + + _byteAether0[i] = ByteAether.Ulid.Ulid.New(leftBuffer); + _byteAether1[i] = ByteAether.Ulid.Ulid.New(rightBuffer); + + _netUlid0[i] = new(leftBuffer); + _netUlid1[i] = new(rightBuffer); + + _cysharp0[i] = new(leftBuffer); + _cysharp1[i] = new(rightBuffer); + + _nulid0[i] = new(leftBuffer); + _nulid1[i] = new(rightBuffer); + + _guid0[i] = new(leftBuffer); + _guid1[i] = new(rightBuffer); + + _base32[i] = _byteAether0[i].ToString(); + _guidHex[i] = _guid0[i].ToString(); + _bytes[i] = leftBuffer; + } + } - _base32[i] = _byteAether0[i].ToString(); - _guidHex[i] = _guid0[i].ToString(); - _bytes[i] = leftBuffer; - } - } + // The ULID's 48-bit timestamp occupies the first 6 bytes, big-endian. + private static void CapTimestamp(byte[] ulidBytes, ulong maxTimestampMs) + { + ulong timestampMs = 0; + for (var i = 0; i < 6; i++) + { + timestampMs = (timestampMs << 8) | ulidBytes[i]; + } + + if (timestampMs <= maxTimestampMs) + { + return; + } + + timestampMs = maxTimestampMs; + for (var i = 5; i >= 0; i--) + { + ulidBytes[i] = (byte)timestampMs; + timestampMs >>= 8; + } + } } \ No newline at end of file From f63326401893cb180c0e300b3bf67bbf8c89deca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Fri, 9 Oct 2026 22:41:41 +0300 Subject: [PATCH 02/16] Fix GC holes in AsByteSpan and lock scope Spans created from untracked pointers could outlive a GC move of the underlying object. netstandard2.0 AsByteSpan now copies. --- src/Compatibility/MemoryMarshal.cs | 2 +- src/Ulid.Tests/Ulid.Tests.cs | 22 +++++ src/Ulid/LowLatencyLock.cs | 111 +++++++------------------ src/Ulid/Ulid.New.GenerationOptions.cs | 31 +++++++ src/Ulid/Ulid.New.cs | 2 +- src/Ulid/Ulid.Util.cs | 5 +- src/Ulid/Ulid.cs | 27 +++++- 7 files changed, 110 insertions(+), 90 deletions(-) diff --git a/src/Compatibility/MemoryMarshal.cs b/src/Compatibility/MemoryMarshal.cs index e82eba2..843fdfc 100644 --- a/src/Compatibility/MemoryMarshal.cs +++ b/src/Compatibility/MemoryMarshal.cs @@ -1,4 +1,4 @@ -#if NETSTANDARD +#if NETSTANDARD2_0 using System.Runtime.CompilerServices; // ReSharper disable All diff --git a/src/Ulid.Tests/Ulid.Tests.cs b/src/Ulid.Tests/Ulid.Tests.cs index c383ba1..996b7ec 100644 --- a/src/Ulid.Tests/Ulid.Tests.cs +++ b/src/Ulid.Tests/Ulid.Tests.cs @@ -71,6 +71,28 @@ public void AsByteSpan_ShouldReturnAll16Bytes() Assert.Equal(_sampleUlidBytes, byteSpan.ToArray()); } + [Fact] + public void AsByteSpan_HeapInstance_ShouldSurviveCompactingGc() + { + // Arrange + for (var i = 0; i < 64; i++) + { + // Garbage in front of the array, so that compaction moves it + _ = new byte[1024]; + } + + var ulids = new[] { CreateUlid(_sampleUlidBytes) }; + + // Act + var byteSpan = ulids[0].AsByteSpan(); + var random = ulids[0].Random; + GC.Collect(2, GCCollectionMode.Forced, blocking: true, compacting: true); + + // Assert + Assert.Equal(_sampleUlidBytes, byteSpan.ToArray()); + Assert.Equal(_sampleUlidBytes[6..], random.ToArray()); + } + [Fact] public void ToByteArray_ShouldReturnAll16Bytes() { diff --git a/src/Ulid/LowLatencyLock.cs b/src/Ulid/LowLatencyLock.cs index e3a950c..01eea70 100644 --- a/src/Ulid/LowLatencyLock.cs +++ b/src/Ulid/LowLatencyLock.cs @@ -1,73 +1,27 @@ using System.Runtime.CompilerServices; -#if !NET7_0_OR_GREATER -using System.Runtime.InteropServices; -#endif namespace ByteAether.Ulid; internal struct LowLatencyLock { - internal int LockState; - - internal readonly ref struct Scope - { -#if NET7_0_OR_GREATER - private readonly ref int _lockState; - - [SkipLocalsInit] - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] - internal Scope(ref LowLatencyLock @lock) - { - _lockState = ref @lock.LockState; - } - - [SkipLocalsInit] - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] - public void Dispose() - { - Volatile.Write(ref _lockState, 0); - } -#else - // This is fully supported in .NET Standard 2.0 / .NET 5.0. - private readonly Span _lockState; + internal int LockState; #if NET5_0_OR_GREATER - [SkipLocalsInit] -#endif -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] -#else - [MethodImpl(MethodImplOptions.AggressiveInlining)] -#endif - internal Scope(ref LowLatencyLock @lock) - { - _lockState = -#if NETCOREAPP - MemoryMarshal.CreateSpan(ref @lock.LockState, 1); -#else - Compatibility.MemoryMarshal.CreateSpan(ref @lock.LockState, 1); -#endif - } - -#if NET5_0_OR_GREATER - [SkipLocalsInit] + [SkipLocalsInit] #endif #if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] #else - [MethodImpl(MethodImplOptions.AggressiveInlining)] -#endif - public void Dispose() - { - Volatile.Write(ref MemoryMarshal.GetReference(_lockState), 0); - } + [MethodImpl(MethodImplOptions.AggressiveInlining)] #endif + internal void Enter() + { + if (Interlocked.CompareExchange(ref LockState, 1, 0) != 0) + { + ContendedEnter(ref LockState); + } + } - } -} - -internal static class LowLatencyLockExtensions -{ #if NET5_0_OR_GREATER [SkipLocalsInit] #endif @@ -76,39 +30,32 @@ internal static class LowLatencyLockExtensions #else [MethodImpl(MethodImplOptions.AggressiveInlining)] #endif - public static LowLatencyLock.Scope Enter(ref this LowLatencyLock @lock) - { - if (Interlocked.CompareExchange(ref @lock.LockState, 1, 0) != 0) - { - ContendedEnter(ref @lock); - } - - return new(ref @lock); - } + internal void Exit() + => Volatile.Write(ref LockState, 0); #if NET5_0_OR_GREATER - [SkipLocalsInit] + [SkipLocalsInit] #endif #if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.AggressiveOptimization)] + [MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.AggressiveOptimization)] #else [MethodImpl(MethodImplOptions.NoInlining)] #endif - private static void ContendedEnter(ref LowLatencyLock @lock) - { - var spinner = new SpinWait(); - while (true) - { - if (Interlocked.CompareExchange(ref @lock.LockState, 1, 0) == 0) - { - return; - } + private static void ContendedEnter(ref int lockState) + { + var spinner = new SpinWait(); + while (true) + { + if (Interlocked.CompareExchange(ref lockState, 1, 0) == 0) + { + return; + } #if NET5_0_OR_GREATER - spinner.SpinOnce(-1); + spinner.SpinOnce(-1); #else - spinner.SpinOnce(); + spinner.SpinOnce(); #endif - } - } -} \ No newline at end of file + } + } +} diff --git a/src/Ulid/Ulid.New.GenerationOptions.cs b/src/Ulid/Ulid.New.GenerationOptions.cs index e3c7c23..8fbde19 100644 --- a/src/Ulid/Ulid.New.GenerationOptions.cs +++ b/src/Ulid/Ulid.New.GenerationOptions.cs @@ -158,6 +158,37 @@ internal class State [FieldOffset(16+128+8)] public ulong LastUlidPart1; #pragma warning restore CS0649 // Field is never assigned to, and will always have its default value +#if NETCOREAPP3_0_OR_GREATER + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] +#else + [MethodImpl(MethodImplOptions.AggressiveInlining)] +#endif + internal Scope EnterLock() + { + Lock.Enter(); + return new(this); + } + + // Holds the owning object rather than a reference into it, so the GC can move the state while the lock is held. + internal readonly ref struct Scope + { + private readonly State _state; + +#if NETCOREAPP3_0_OR_GREATER + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] +#else + [MethodImpl(MethodImplOptions.AggressiveInlining)] +#endif + internal Scope(State state) => _state = state; + +#if NETCOREAPP3_0_OR_GREATER + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] +#else + [MethodImpl(MethodImplOptions.AggressiveInlining)] +#endif + public void Dispose() => _state.Lock.Exit(); + } + #if NET5_0_OR_GREATER [SkipLocalsInit] #endif diff --git a/src/Ulid/Ulid.New.cs b/src/Ulid/Ulid.New.cs index 96d3c64..7afef31 100644 --- a/src/Ulid/Ulid.New.cs +++ b/src/Ulid/Ulid.New.cs @@ -209,7 +209,7 @@ GenerationOptions.MonotonicityOptions monotonicity var state = options.CurrentState; - using (state.Lock.Enter()) + using (state.EnterLock()) { // Read the last timestamp (from bytes 0-7 of "last ULID") // Shift it to get 48 bits. diff --git a/src/Ulid/Ulid.Util.cs b/src/Ulid/Ulid.Util.cs index 6606b7a..91604bc 100644 --- a/src/Ulid/Ulid.Util.cs +++ b/src/Ulid/Ulid.Util.cs @@ -1,6 +1,6 @@ using System.Buffers.Binary; using System.Runtime.CompilerServices; -#if NETCOREAPP +#if !NETSTANDARD2_0 using System.Runtime.InteropServices; #endif @@ -14,9 +14,10 @@ private static ulong ReverseOnLittleEndian(ulong value) ? BinaryPrimitives.ReverseEndianness(value) : value; + // On .NET Standard 2.0 the span is not tracked by the GC, so it may only point to stack memory. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static Span CreateSpan(ref byte reference, int length) -#if NETCOREAPP +#if !NETSTANDARD2_0 => MemoryMarshal.CreateSpan(ref reference, length); #else => Compatibility.MemoryMarshal.CreateSpan(ref reference, length); diff --git a/src/Ulid/Ulid.cs b/src/Ulid/Ulid.cs index 6745c32..4a7e470 100644 --- a/src/Ulid/Ulid.cs +++ b/src/Ulid/Ulid.cs @@ -49,7 +49,8 @@ public readonly partial struct Ulid /// /// /// The random component consists of the last 10 bytes of the ULID and is generated randomly to ensure uniqueness.
- /// This component does not encode any timestamp or other structured information. + /// This component does not encode any timestamp or other structured information.
+ /// On .NET Standard 2.0 the returned span is backed by a new copy of the bytes, which allocates (see ). ///
/// /// A byte array containing 10 random bytes that represent the random portion of the ULID. @@ -69,7 +70,8 @@ public ReadOnlySpan Random /// Gets the time component of the ULID as a byte array. /// /// - /// The time component consists of the first 6 bytes of the ULID and is generated during ULID creation. + /// The time component consists of the first 6 bytes of the ULID and is generated during ULID creation.
+ /// On .NET Standard 2.0 the returned span is backed by a new copy of the bytes, which allocates (see ). ///
/// /// A byte array containing 6 time bytes that represent the time portion of the ULID. @@ -124,12 +126,25 @@ public DateTimeOffset Time } } +#if NETSTANDARD2_0 + /// + /// Creates a read-only span of bytes representing the current instance of the struct. + /// + /// + /// On .NET Standard 2.0 there is no GC-safe way to create a span over the instance itself. + /// The returned span is backed by a new copy of the bytes (see ), which allocates. + /// + /// + /// A over a copy of the raw byte representation of the current struct. + /// +#else /// /// Creates a read-only span of bytes representing the current instance of the struct. /// /// /// A that points to the raw byte representation of the current struct. /// +#endif #if NET5_0_OR_GREATER [SkipLocalsInit] #endif @@ -138,8 +153,12 @@ public DateTimeOffset Time #else [MethodImpl(MethodImplOptions.AggressiveInlining)] #endif - public unsafe ReadOnlySpan AsByteSpan() - => new(Unsafe.AsPointer(ref Unsafe.AsRef(in this)), _ulidSize); + public ReadOnlySpan AsByteSpan() +#if NETSTANDARD2_0 + => ToByteArray(); +#else + => MemoryMarshal.CreateReadOnlySpan(ref Unsafe.As(ref Unsafe.AsRef(in this)), _ulidSize); +#endif /// /// Converts the ULID to a byte array. From a2091ea0334e51ba10c30e7f624085b40f9b1e27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Fri, 9 Oct 2026 22:41:51 +0300 Subject: [PATCH 03/16] Validate random span length in New(timestamp, random) Shorter spans read past their end. Take ReadOnlySpan as documented. --- src/Ulid.Tests/Ulid.New.Tests.cs | 27 +++++++++++++++++++++++++++ src/Ulid/Ulid.New.cs | 20 +++++++++++++++++--- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/Ulid.Tests/Ulid.New.Tests.cs b/src/Ulid.Tests/Ulid.New.Tests.cs index d87c230..1e5bd07 100644 --- a/src/Ulid.Tests/Ulid.New.Tests.cs +++ b/src/Ulid.Tests/Ulid.New.Tests.cs @@ -78,6 +78,33 @@ public void New_WithUnixTimestampMillisecondsAndRandom_ShouldGenerateCorrectTime Assert.Equal(random, ulid.Random.ToArray()); } + [Fact] + public void New_WithLongerRandom_ShouldUseFirst10Bytes() + { + // Arrange + var random = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 }; + + // Act + var ulid = Ulid.New(0, random); + + // Assert + Assert.Equal(random[..10], ulid.Random.ToArray()); + } + + [Theory] + [InlineData(0)] + [InlineData(4)] + [InlineData(9)] + public void New_WithTooShortRandom_ShouldThrowArgumentException(int length) + { + // Arrange + var random = new byte[length]; + + // Act & Assert + Assert.Throws("random", () => Ulid.New(0, random)); + Assert.Throws("random", () => Ulid.New(DateTimeOffset.UtcNow, random)); + } + [Fact] public void New_NonMonotonic_CanProduceSmallerUlids() { diff --git a/src/Ulid/Ulid.New.cs b/src/Ulid/Ulid.New.cs index 7afef31..a27bd1d 100644 --- a/src/Ulid/Ulid.New.cs +++ b/src/Ulid/Ulid.New.cs @@ -1,4 +1,5 @@ using System.Buffers.Binary; +using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; @@ -77,12 +78,13 @@ public static Ulid New(DateTimeOffset dateTimeOffset, GenerationOptions? options /// Must be at least 10 bytes long to populate the random component of the Ulid. /// /// A new instance. + /// Thrown if is shorter than 10 bytes. #if NETCOREAPP3_0_OR_GREATER [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] #else [MethodImpl(MethodImplOptions.AggressiveInlining)] #endif - public static Ulid New(DateTimeOffset dateTimeOffset, Span random) + public static Ulid New(DateTimeOffset dateTimeOffset, ReadOnlySpan random) => New(dateTimeOffset.ToUnixTimeMilliseconds(), random); /// @@ -127,6 +129,7 @@ public static Ulid New(long timestamp, GenerationOptions? options = null) /// /// A new instance composed of the given timestamp and random byte sequence. /// + /// Thrown if is shorter than 10 bytes. #if NET5_0_OR_GREATER [SkipLocalsInit] #endif @@ -135,8 +138,13 @@ public static Ulid New(long timestamp, GenerationOptions? options = null) #else [MethodImpl(MethodImplOptions.AggressiveInlining)] #endif - public static Ulid New(long timestamp, Span random) + public static Ulid New(long timestamp, ReadOnlySpan random) { + if (random.Length < _ulidSizeRandom) + { + ThrowRandomTooShort(); + } + Unsafe.SkipInit(out Ulid ulid); ref var ulidRef = ref Unsafe.As(ref ulid); @@ -147,13 +155,19 @@ public static Ulid New(long timestamp, Span random) // Fill random Unsafe.CopyBlockUnaligned( ref Unsafe.Add(ref ulidRef, _ulidSizeTime), - ref random.GetPinnableReference(), + ref MemoryMarshal.GetReference(random), _ulidSizeRandom ); return ulid; } + [DoesNotReturn] + [MethodImpl(MethodImplOptions.NoInlining)] + [SuppressMessage("ReSharper", "NotResolvedInText")] + private static void ThrowRandomTooShort() + => throw new ArgumentException($"The random component must be at least {_ulidSizeRandom} bytes long.", "random"); + #if NET5_0_OR_GREATER [SkipLocalsInit] #endif From 574cb70830c97fea1ae2b07f8b634266534d911a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Fri, 9 Oct 2026 22:41:51 +0300 Subject: [PATCH 04/16] Validate characters when parsing; make TryParse non-throwing Parse accepted invalid, non-ASCII and overflowing input. TryParse no longer relies on exceptions. Characters are decoded and accumulated one by one, which keeps validated parsing close to the previous cost. --- README.md | 8 + src/Ulid.Tests/Ulid.String.Tests.cs | 82 +++++ src/Ulid/PACKAGE.md | 8 + src/Ulid/Ulid.IsValid.cs | 10 + src/Ulid/Ulid.String.cs | 445 +++++++++++++++++++++------- 5 files changed, 439 insertions(+), 114 deletions(-) diff --git a/README.md b/README.md index 4775886..8766b56 100644 --- a/README.md +++ b/README.md @@ -235,6 +235,8 @@ The `Ulid` implementation provides the following properties and methods: - `Ulid.IsValid(ReadOnlySpan ulidBytes)`\ Validates whether the specified byte array represents a valid ULID. +A valid ULID string is exactly 26 characters of [Crockford's Base32](https://www.crockford.com/base32.html) alphabet, read case-insensitively, with `I` and `L` accepted as `1`, and `O` as `0`. The first character must be between `0` and `7`, as larger values do not fit into 128 bits. + ### Parsing - `Ulid.Parse(ReadOnlySpan chars, IFormatProvider? provider = null)`\ @@ -245,6 +247,12 @@ The `Ulid` implementation provides the following properties and methods: Parses a ULID from a string in canonical format. The `IFormatProvider` is ignored. - `Ulid.TryParse(string? s, IFormatProvider? provider, out Ulid result)`\ Tries to parse a ULID from a string in canonical format. Returns `true` if successful. +- `Ulid.Parse(ReadOnlySpan bytes, IFormatProvider? provider = null)`\ + Parses a ULID from a UTF-8 encoded byte span in canonical format. The `IFormatProvider` is ignored. +- `Ulid.TryParse(ReadOnlySpan s, IFormatProvider? provider, out Ulid result)`\ + Tries to parse a ULID from a UTF-8 encoded byte span in canonical format. Returns `true` if successful. + +Parsing applies the same rules as validation (see Checking Validity above). `Parse` throws a `FormatException` for input that `IsValid` rejects, while `TryParse` returns `false` without throwing. ### Properties diff --git a/src/Ulid.Tests/Ulid.String.Tests.cs b/src/Ulid.Tests/Ulid.String.Tests.cs index 615cdb3..f026f3b 100644 --- a/src/Ulid.Tests/Ulid.String.Tests.cs +++ b/src/Ulid.Tests/Ulid.String.Tests.cs @@ -218,6 +218,88 @@ public void TryParse_ReadOnlySpanByte_InvalidInput_ShouldReturnFalse(string inpu Assert.Equal(default, ulid); } + public static TheoryData InvalidCharacters() + { + // Characters outside the alphabet, and non-ASCII characters whose low byte is a valid character + var invalidChars = new[] { 'U', 'u', '!', '@', '[', '`', '{', ' ', '\0', '\u00FF', '\u0130', '\u0141', '\uFF10' }; + + var data = new TheoryData(); + for (var position = 0; position < Ulid.UlidStringLength; position++) + { + foreach (var invalidChar in invalidChars) + { + data.Add(position, invalidChar); + } + } + + return data; + } + + [Theory] + [MemberData(nameof(InvalidCharacters))] + public void TryParse_InvalidCharacter_ShouldReturnFalse(int position, char invalidChar) + { + // Arrange + var chars = _goodUlidString.ToCharArray(); + chars[position] = invalidChar; + var inputString = new string(chars); + + // Act & Assert + Assert.False(Ulid.TryParse(inputString, null, out var ulid)); + Assert.Equal(default, ulid); + Assert.False(Ulid.TryParse(inputString.AsSpan(), null, out _)); + Assert.Throws(() => Ulid.Parse(inputString)); + Assert.Throws(() => Ulid.Parse(inputString.AsSpan())); + Assert.False(Ulid.IsValid(inputString)); + + if (invalidChar <= 0xFF) + { + // Same input, one byte per character + var bytes = chars.Select(x => (byte)x).ToArray(); + Assert.False(Ulid.TryParse(bytes, null, out _)); + Assert.Throws(() => Ulid.Parse(bytes)); + } + } + + [Theory] + [InlineData('8')] + [InlineData('9')] + [InlineData('A')] + [InlineData('Z')] + [InlineData('z')] + public void TryParse_ValueLargerThan128Bits_ShouldReturnFalse(char firstChar) + { + // Arrange + var inputString = firstChar + _goodUlidString[1..]; + + // Act & Assert + Assert.False(Ulid.TryParse(inputString, null, out _)); + Assert.False(Ulid.TryParse(Encoding.UTF8.GetBytes(inputString), null, out _)); + Assert.Throws(() => Ulid.Parse(inputString)); + } + + [Fact] + public void Parse_MaxValue_ShouldSucceed() + { + // Act + var ulid = Ulid.Parse("7ZZZZZZZZZZZZZZZZZZZZZZZZZ"); + + // Assert + Assert.Equal(Ulid.MaxValue, ulid); + } + + [Fact] + public void Parse_LowerCase_ShouldEqualUpperCase() + { + // Act + var lower = Ulid.Parse(_goodUlidString.ToLowerInvariant()); + var lowerUtf8 = Ulid.Parse(Encoding.UTF8.GetBytes(_goodUlidString.ToLowerInvariant())); + + // Assert + Assert.Equal(_goodUlidString, lower.ToString()); + Assert.Equal(_goodUlidString, lowerUtf8.ToString()); + } + [Fact] public void ToString_WrongLetters_ShouldReplaceWithCorrect() { diff --git a/src/Ulid/PACKAGE.md b/src/Ulid/PACKAGE.md index 4e404dc..247217c 100644 --- a/src/Ulid/PACKAGE.md +++ b/src/Ulid/PACKAGE.md @@ -115,6 +115,8 @@ The `Ulid` implementation provides the following properties and methods: - `Ulid.IsValid(ReadOnlySpan ulidBytes)`\ Validates whether the specified byte array represents a valid ULID. +A valid ULID string is exactly 26 characters of [Crockford's Base32](https://www.crockford.com/base32.html) alphabet, read case-insensitively, with `I` and `L` accepted as `1`, and `O` as `0`. The first character must be between `0` and `7`, as larger values do not fit into 128 bits. + ### Parsing - `Ulid.Parse(ReadOnlySpan chars, IFormatProvider? provider = null)`\ @@ -125,6 +127,12 @@ The `Ulid` implementation provides the following properties and methods: Parses a ULID from a string in canonical format. The `IFormatProvider` is ignored. - `Ulid.TryParse(string? s, IFormatProvider? provider, out Ulid result)`\ Tries to parse a ULID from a string in canonical format. Returns `true` if successful. +- `Ulid.Parse(ReadOnlySpan bytes, IFormatProvider? provider = null)`\ + Parses a ULID from a UTF-8 encoded byte span in canonical format. The `IFormatProvider` is ignored. +- `Ulid.TryParse(ReadOnlySpan s, IFormatProvider? provider, out Ulid result)`\ + Tries to parse a ULID from a UTF-8 encoded byte span in canonical format. Returns `true` if successful. + +Parsing applies the same rules as validation (see Checking Validity above). `Parse` throws a `FormatException` for input that `IsValid` rejects, while `TryParse` returns `false` without throwing. ### Properties diff --git a/src/Ulid/Ulid.IsValid.cs b/src/Ulid/Ulid.IsValid.cs index 449e0f0..5a8be8d 100644 --- a/src/Ulid/Ulid.IsValid.cs +++ b/src/Ulid/Ulid.IsValid.cs @@ -12,6 +12,11 @@ public readonly partial struct Ulid /// /// true if the string is a valid ULID, false otherwise. /// + /// + /// The input must be exactly 26 characters of Crockford's Base32 alphabet. It is read case-insensitively, with + /// I and L accepted as 1, and O as 0. The first character must be between + /// 0 and 7, otherwise the value would not fit into 128 bits. + /// #if NETCOREAPP3_0_OR_GREATER [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] #else @@ -26,6 +31,11 @@ public readonly partial struct Ulid /// /// true if the character span is a valid ULID, false otherwise. /// + /// + /// The input must be exactly 26 characters of Crockford's Base32 alphabet. It is read case-insensitively, with + /// I and L accepted as 1, and O as 0. The first character must be between + /// 0 and 7, otherwise the value would not fit into 128 bits. + /// #if NET5_0_OR_GREATER [SkipLocalsInit] #endif diff --git a/src/Ulid/Ulid.String.cs b/src/Ulid/Ulid.String.cs index 401f730..9e60f8a 100644 --- a/src/Ulid/Ulid.String.cs +++ b/src/Ulid/Ulid.String.cs @@ -3,6 +3,9 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Text; +#if NET7_0_OR_GREATER +using System.Runtime.Intrinsics; +#endif namespace ByteAether.Ulid; @@ -123,7 +126,12 @@ public override string ToString() /// The span of characters containing Crockford's Base32 representation of the ULID. /// Ignored. The ULID is always formatted in its canonical Crockford's Base32 format. /// A parsed instance of . - /// Thrown if the input span does not meet the ULID format requirements. + /// + /// Applies the same rules as : the input must be exactly 26 characters of + /// Crockford's Base32 alphabet, read case-insensitively, with I and L accepted as 1, and + /// O as 0. The first character must be between 0 and 7. + /// + /// Thrown if the input span is not a valid ULID string representation. #if NET5_0_OR_GREATER [SkipLocalsInit] #endif @@ -133,7 +141,15 @@ public override string ToString() [MethodImpl(MethodImplOptions.AggressiveInlining)] #endif public static Ulid Parse(ReadOnlySpan chars, IFormatProvider? provider = null) - => ParseCore(chars); + { + var ulid = ParseCore(chars, out var isValid); + if (!isValid) + { + ThrowInvalidFormat(); + } + + return ulid; + } /// /// Parses a ULID from a read-only span of bytes and returns the corresponding ULID value. @@ -141,6 +157,11 @@ public static Ulid Parse(ReadOnlySpan chars, IFormatProvider? provider = n /// The read-only span of bytes containing the ULID string representation in Crockford's Base32 format. /// Ignored. The ULID is always formatted in its canonical Crockford's Base32 format. /// The ULID parsed from the specified byte span. + /// + /// Applies the same rules as : the input must be exactly 26 characters of + /// Crockford's Base32 alphabet, read case-insensitively, with I and L accepted as 1, and + /// O as 0. The first character must be between 0 and 7. + /// /// Thrown if the input byte span does not contain a valid ULID string representation. #if NET5_0_OR_GREATER [SkipLocalsInit] @@ -151,116 +172,312 @@ public static Ulid Parse(ReadOnlySpan chars, IFormatProvider? provider = n [MethodImpl(MethodImplOptions.AggressiveInlining)] #endif public static Ulid Parse(ReadOnlySpan bytes, IFormatProvider? provider = null) - => ParseCore(bytes); + { + var ulid = ParseCore(bytes, out var isValid); + if (!isValid) + { + ThrowInvalidFormat(); + } + + return ulid; + } #if NET5_0_OR_GREATER - [SkipLocalsInit] + [SkipLocalsInit] #endif #if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveOptimization)] + [MethodImpl(MethodImplOptions.AggressiveOptimization)] #endif - private static unsafe Ulid ParseCore(ReadOnlySpan input) - where T : unmanaged + private static Ulid ParseCore(ReadOnlySpan input, out bool isValid) { - // Every T element is read as a byte. Other bits are ignored. - // We create 2 blocks of big-endian ulong values then reverse the endianness - // Creating a big-endian ulong and then reversing it is faster than creating directly a little-endian ulong - - if (input.Length != UlidStringLength) - { - throw new FormatException("The input sequence is not a valid ULID string representation."); - } - - var stepSize = sizeof(T); // We read the span as bytes and iterate by the size of an element - Ulid result = default; - - fixed (T* pSrc = &MemoryMarshal.GetReference(input)) - { - var pBytes = (byte*)pSrc; - ref var tableRef = ref _inverseBase32[0]; - ref var ulidRef = ref Unsafe.As(ref result); - - ulong t00 = Unsafe.Add(ref tableRef, pBytes[00 * stepSize]); - ulong t01 = Unsafe.Add(ref tableRef, pBytes[01 * stepSize]); - ulong t02 = Unsafe.Add(ref tableRef, pBytes[02 * stepSize]); - ulong t03 = Unsafe.Add(ref tableRef, pBytes[03 * stepSize]); - ulong t04 = Unsafe.Add(ref tableRef, pBytes[04 * stepSize]); - ulong t05 = Unsafe.Add(ref tableRef, pBytes[05 * stepSize]); - ulong t06 = Unsafe.Add(ref tableRef, pBytes[06 * stepSize]); - ulong t07 = Unsafe.Add(ref tableRef, pBytes[07 * stepSize]); - ulong t08 = Unsafe.Add(ref tableRef, pBytes[08 * stepSize]); - ulong t09 = Unsafe.Add(ref tableRef, pBytes[09 * stepSize]); - ulong r00 = Unsafe.Add(ref tableRef, pBytes[10 * stepSize]); - ulong r01 = Unsafe.Add(ref tableRef, pBytes[11 * stepSize]); - ulong r02 = Unsafe.Add(ref tableRef, pBytes[12 * stepSize]); - ulong r03 = Unsafe.Add(ref tableRef, pBytes[13 * stepSize]); - - var block1 = - (t00 << 61) - | (t01 << 56) - | (t02 << 51) - | (t03 << 46) - | (t04 << 41) - | (t05 << 36) - | (t06 << 31) - | (t07 << 26) - | (t08 << 21) - | (t09 << 16) - | (r00 << 11) - | (r01 << 6) - | (r02 << 1) - | (r03 >> 4); - - Unsafe.WriteUnaligned(ref Unsafe.Add(ref ulidRef, 0), ReverseOnLittleEndian(block1)); - - // Second block - ulong 64 bits - ulong r04 = Unsafe.Add(ref tableRef, pBytes[14 * stepSize]); - ulong r05 = Unsafe.Add(ref tableRef, pBytes[15 * stepSize]); - ulong r06 = Unsafe.Add(ref tableRef, pBytes[16 * stepSize]); - ulong r07 = Unsafe.Add(ref tableRef, pBytes[17 * stepSize]); - ulong r08 = Unsafe.Add(ref tableRef, pBytes[18 * stepSize]); - ulong r09 = Unsafe.Add(ref tableRef, pBytes[19 * stepSize]); - ulong r10 = Unsafe.Add(ref tableRef, pBytes[20 * stepSize]); - ulong r11 = Unsafe.Add(ref tableRef, pBytes[21 * stepSize]); - ulong r12 = Unsafe.Add(ref tableRef, pBytes[22 * stepSize]); - ulong r13 = Unsafe.Add(ref tableRef, pBytes[23 * stepSize]); - ulong r14 = Unsafe.Add(ref tableRef, pBytes[24 * stepSize]); - ulong r15 = Unsafe.Add(ref tableRef, pBytes[25 * stepSize]); - - var block2 = - (r03 << 60) - | (r04 << 55) - | (r05 << 50) - | (r06 << 45) - | (r07 << 40) - | (r08 << 35) - | (r09 << 30) - | (r10 << 25) - | (r11 << 20) - | (r12 << 15) - | (r13 << 10) - | (r14 << 5) - | r15; - - Unsafe.WriteUnaligned(ref Unsafe.Add(ref ulidRef, 8), ReverseOnLittleEndian(block2)); - } - - return result; + if (input.Length != UlidStringLength) + { + isValid = false; + return default; + } + + // Every char must be ASCII, as only its lowest byte is decoded below +#if NET7_0_OR_GREATER + ref var units = ref Unsafe.As(ref MemoryMarshal.GetReference(input)); + var nonAscii = ( + Vector128.LoadUnsafe(ref units) + | Vector128.LoadUnsafe(ref units, 8) + | Vector128.LoadUnsafe(ref units, 10) + | Vector128.LoadUnsafe(ref units, 18) + ) & Vector128.Create((ushort)0xFF80); + + if (nonAscii != Vector128.Zero) +#else + // The 26 chars (52 bytes) are checked as 6 ulongs and 1 uint, independent of the endianness + ref var bytes = ref Unsafe.As(ref MemoryMarshal.GetReference(input)); + var units = + Unsafe.ReadUnaligned(ref bytes) + | Unsafe.ReadUnaligned(ref Unsafe.Add(ref bytes, 8)) + | Unsafe.ReadUnaligned(ref Unsafe.Add(ref bytes, 16)) + | Unsafe.ReadUnaligned(ref Unsafe.Add(ref bytes, 24)) + | Unsafe.ReadUnaligned(ref Unsafe.Add(ref bytes, 32)) + | Unsafe.ReadUnaligned(ref Unsafe.Add(ref bytes, 40)) + | Unsafe.ReadUnaligned(ref Unsafe.Add(ref bytes, 48)); + + if ((units & 0xFF80_FF80_FF80_FF80) != 0) +#endif + { + isValid = false; + return default; + } + + ref var src = ref Unsafe.As(ref MemoryMarshal.GetReference(input)); + ref var tableRef = ref _inverseBase32[0]; + + // The 128-bit value is built as 2 big-endian 64-bit blocks (a | b and c | d), then the endianness is reversed. + // Every character is decoded and accumulated right away, which keeps the register pressure low, + // and 2 accumulators per block keep the dependency chains short. + ulong a, b, c, d, invalidA, invalidB, value; + + value = Unsafe.Add(ref tableRef, ReadLowByte(ref src, 0)); + invalidA = value << 2; // The first character must be 0..7, otherwise the value overflows 128 bits + a = value << 61; + value = Unsafe.Add(ref tableRef, ReadLowByte(ref src, 1)); + invalidA |= value; + a |= value << 56; + value = Unsafe.Add(ref tableRef, ReadLowByte(ref src, 2)); + invalidA |= value; + a |= value << 51; + value = Unsafe.Add(ref tableRef, ReadLowByte(ref src, 3)); + invalidA |= value; + a |= value << 46; + value = Unsafe.Add(ref tableRef, ReadLowByte(ref src, 4)); + invalidA |= value; + a |= value << 41; + value = Unsafe.Add(ref tableRef, ReadLowByte(ref src, 5)); + invalidA |= value; + a |= value << 36; + value = Unsafe.Add(ref tableRef, ReadLowByte(ref src, 6)); + invalidA |= value; + a |= value << 31; + value = Unsafe.Add(ref tableRef, ReadLowByte(ref src, 7)); + invalidB = value; + b = value << 26; + value = Unsafe.Add(ref tableRef, ReadLowByte(ref src, 8)); + invalidB |= value; + b |= value << 21; + value = Unsafe.Add(ref tableRef, ReadLowByte(ref src, 9)); + invalidB |= value; + b |= value << 16; + value = Unsafe.Add(ref tableRef, ReadLowByte(ref src, 10)); + invalidB |= value; + b |= value << 11; + value = Unsafe.Add(ref tableRef, ReadLowByte(ref src, 11)); + invalidB |= value; + b |= value << 6; + value = Unsafe.Add(ref tableRef, ReadLowByte(ref src, 12)); + invalidB |= value; + b |= value << 1; + value = Unsafe.Add(ref tableRef, ReadLowByte(ref src, 13)); + invalidB |= value; + b |= value >> 4; + c = value << 60; + value = Unsafe.Add(ref tableRef, ReadLowByte(ref src, 14)); + invalidA |= value; + c |= value << 55; + value = Unsafe.Add(ref tableRef, ReadLowByte(ref src, 15)); + invalidA |= value; + c |= value << 50; + value = Unsafe.Add(ref tableRef, ReadLowByte(ref src, 16)); + invalidA |= value; + c |= value << 45; + value = Unsafe.Add(ref tableRef, ReadLowByte(ref src, 17)); + invalidA |= value; + c |= value << 40; + value = Unsafe.Add(ref tableRef, ReadLowByte(ref src, 18)); + invalidA |= value; + c |= value << 35; + value = Unsafe.Add(ref tableRef, ReadLowByte(ref src, 19)); + invalidA |= value; + c |= value << 30; + value = Unsafe.Add(ref tableRef, ReadLowByte(ref src, 20)); + invalidB |= value; + d = value << 25; + value = Unsafe.Add(ref tableRef, ReadLowByte(ref src, 21)); + invalidB |= value; + d |= value << 20; + value = Unsafe.Add(ref tableRef, ReadLowByte(ref src, 22)); + invalidB |= value; + d |= value << 15; + value = Unsafe.Add(ref tableRef, ReadLowByte(ref src, 23)); + invalidB |= value; + d |= value << 10; + value = Unsafe.Add(ref tableRef, ReadLowByte(ref src, 24)); + invalidB |= value; + d |= value << 5; + value = Unsafe.Add(ref tableRef, ReadLowByte(ref src, 25)); + invalidB |= value; + d |= value << 0; + + // Every table value is either 0..31 or 255 (invalid character) + if (((invalidA | invalidB) & 0xE0) != 0) + { + isValid = false; + return default; + } + + Unsafe.SkipInit(out Ulid ulid); + ref var ulidRef = ref Unsafe.As(ref ulid); + Unsafe.WriteUnaligned(ref ulidRef, ReverseOnLittleEndian(a | b)); + Unsafe.WriteUnaligned(ref Unsafe.Add(ref ulidRef, sizeof(ulong)), ReverseOnLittleEndian(c | d)); + + isValid = true; + return ulid; } + // Reads the lowest byte of the char at index, independent of the endianness + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static byte ReadLowByte(ref byte src, int index) + => (byte)Unsafe.ReadUnaligned(ref Unsafe.Add(ref src, index * sizeof(char))); + +#if NET5_0_OR_GREATER + [SkipLocalsInit] +#endif +#if NETCOREAPP3_0_OR_GREATER + [MethodImpl(MethodImplOptions.AggressiveOptimization)] +#endif + private static Ulid ParseCore(ReadOnlySpan input, out bool isValid) + { + if (input.Length != UlidStringLength) + { + isValid = false; + return default; + } + + // Non-ASCII bytes (0x80 and above) are mapped to an invalid value in the table + ref var src = ref MemoryMarshal.GetReference(input); + ref var tableRef = ref _inverseBase32[0]; + + // The 128-bit value is built as 2 big-endian 64-bit blocks (a | b and c | d), then the endianness is reversed. + // Every character is decoded and accumulated right away, which keeps the register pressure low, + // and 2 accumulators per block keep the dependency chains short. + ulong a, b, c, d, invalidA, invalidB, value; + + value = Unsafe.Add(ref tableRef, Unsafe.Add(ref src, 0)); + invalidA = value << 2; // The first character must be 0..7, otherwise the value overflows 128 bits + a = value << 61; + value = Unsafe.Add(ref tableRef, Unsafe.Add(ref src, 1)); + invalidA |= value; + a |= value << 56; + value = Unsafe.Add(ref tableRef, Unsafe.Add(ref src, 2)); + invalidA |= value; + a |= value << 51; + value = Unsafe.Add(ref tableRef, Unsafe.Add(ref src, 3)); + invalidA |= value; + a |= value << 46; + value = Unsafe.Add(ref tableRef, Unsafe.Add(ref src, 4)); + invalidA |= value; + a |= value << 41; + value = Unsafe.Add(ref tableRef, Unsafe.Add(ref src, 5)); + invalidA |= value; + a |= value << 36; + value = Unsafe.Add(ref tableRef, Unsafe.Add(ref src, 6)); + invalidA |= value; + a |= value << 31; + value = Unsafe.Add(ref tableRef, Unsafe.Add(ref src, 7)); + invalidB = value; + b = value << 26; + value = Unsafe.Add(ref tableRef, Unsafe.Add(ref src, 8)); + invalidB |= value; + b |= value << 21; + value = Unsafe.Add(ref tableRef, Unsafe.Add(ref src, 9)); + invalidB |= value; + b |= value << 16; + value = Unsafe.Add(ref tableRef, Unsafe.Add(ref src, 10)); + invalidB |= value; + b |= value << 11; + value = Unsafe.Add(ref tableRef, Unsafe.Add(ref src, 11)); + invalidB |= value; + b |= value << 6; + value = Unsafe.Add(ref tableRef, Unsafe.Add(ref src, 12)); + invalidB |= value; + b |= value << 1; + value = Unsafe.Add(ref tableRef, Unsafe.Add(ref src, 13)); + invalidB |= value; + b |= value >> 4; + c = value << 60; + value = Unsafe.Add(ref tableRef, Unsafe.Add(ref src, 14)); + invalidA |= value; + c |= value << 55; + value = Unsafe.Add(ref tableRef, Unsafe.Add(ref src, 15)); + invalidA |= value; + c |= value << 50; + value = Unsafe.Add(ref tableRef, Unsafe.Add(ref src, 16)); + invalidA |= value; + c |= value << 45; + value = Unsafe.Add(ref tableRef, Unsafe.Add(ref src, 17)); + invalidA |= value; + c |= value << 40; + value = Unsafe.Add(ref tableRef, Unsafe.Add(ref src, 18)); + invalidA |= value; + c |= value << 35; + value = Unsafe.Add(ref tableRef, Unsafe.Add(ref src, 19)); + invalidA |= value; + c |= value << 30; + value = Unsafe.Add(ref tableRef, Unsafe.Add(ref src, 20)); + invalidB |= value; + d = value << 25; + value = Unsafe.Add(ref tableRef, Unsafe.Add(ref src, 21)); + invalidB |= value; + d |= value << 20; + value = Unsafe.Add(ref tableRef, Unsafe.Add(ref src, 22)); + invalidB |= value; + d |= value << 15; + value = Unsafe.Add(ref tableRef, Unsafe.Add(ref src, 23)); + invalidB |= value; + d |= value << 10; + value = Unsafe.Add(ref tableRef, Unsafe.Add(ref src, 24)); + invalidB |= value; + d |= value << 5; + value = Unsafe.Add(ref tableRef, Unsafe.Add(ref src, 25)); + invalidB |= value; + d |= value << 0; + + // Every table value is either 0..31 or 255 (invalid character) + if (((invalidA | invalidB) & 0xE0) != 0) + { + isValid = false; + return default; + } + + Unsafe.SkipInit(out Ulid ulid); + ref var ulidRef = ref Unsafe.As(ref ulid); + Unsafe.WriteUnaligned(ref ulidRef, ReverseOnLittleEndian(a | b)); + Unsafe.WriteUnaligned(ref Unsafe.Add(ref ulidRef, sizeof(ulong)), ReverseOnLittleEndian(c | d)); + + isValid = true; + return ulid; + } + + [DoesNotReturn] + [MethodImpl(MethodImplOptions.NoInlining)] + private static void ThrowInvalidFormat() + => throw new FormatException("The input sequence is not a valid ULID string representation."); + /// /// Parses a string representation of a ULID and returns the corresponding ULID instance. /// /// The string representation of the ULID to parse. /// Ignored. The ULID is always formatted in its canonical Crockford's Base32 format. /// A new instance parsed from the specified string. + /// + /// Applies the same rules as : the input must be exactly 26 characters of + /// Crockford's Base32 alphabet, read case-insensitively, with I and L accepted as 1, and + /// O as 0. The first character must be between 0 and 7. + /// + /// Thrown if the input string is not a valid ULID string representation. #if NETCOREAPP3_0_OR_GREATER [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] #else [MethodImpl(MethodImplOptions.AggressiveInlining)] #endif public static Ulid Parse(string s, IFormatProvider? provider = null) - => ParseCore(s.AsSpan()); + => Parse(s.AsSpan(), provider); /// /// Attempts to parse a string representation of a ULID into a instance. @@ -269,6 +486,11 @@ public static Ulid Parse(string s, IFormatProvider? provider = null) /// Ignored. The ULID is always formatted in its canonical Crockford's Base32 format. /// When this method returns, contains the parsed value if the parse was successful; otherwise, the default value of . /// true if the parsing was successful; otherwise, false. + /// + /// Applies the same rules as : the input must be exactly 26 characters of + /// Crockford's Base32 alphabet, read case-insensitively, with I and L accepted as 1, and + /// O as 0. The first character must be between 0 and 7. + /// #if NETCOREAPP3_0_OR_GREATER [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] #else @@ -284,6 +506,11 @@ public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? prov /// Ignored. The ULID is always formatted in its canonical Crockford's Base32 format. /// When the method returns, contains the parsed ULID if the operation succeeds, or the default value if it fails. /// true if the parsing operation succeeded; otherwise, false. + /// + /// Applies the same rules as : the input must be exactly 26 characters of + /// Crockford's Base32 alphabet, read case-insensitively, with I and L accepted as 1, and + /// O as 0. The first character must be between 0 and 7. + /// #if NETCOREAPP3_0_OR_GREATER [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] #else @@ -291,16 +518,8 @@ public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? prov #endif public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, out Ulid result) { - try - { - result = ParseCore(s); - return true; - } - catch - { - result = default; - return false; - } + result = ParseCore(s, out var isValid); + return isValid; } /// @@ -310,6 +529,11 @@ public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, out /// Ignored. The ULID is always formatted in its canonical Crockford's Base32 format. /// When the method returns, contains the parsed ULID if parsing was successful; otherwise, the default value for ULID. /// true if parsing was successful; otherwise, false. + /// + /// Applies the same rules as : the input must be exactly 26 characters of + /// Crockford's Base32 alphabet, read case-insensitively, with I and L accepted as 1, and + /// O as 0. The first character must be between 0 and 7. + /// #if NETCOREAPP3_0_OR_GREATER [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] #else @@ -317,16 +541,8 @@ public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, out #endif public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, out Ulid result) { - try - { - result = ParseCore(s); - return true; - } - catch - { - result = default; - return false; - } + result = ParseCore(s, out var isValid); + return isValid; } /// @@ -446,8 +662,9 @@ private void Fill(Span span, T[] map) where T: unmanaged /// /// Allows implicit conversion of to . /// - /// - /// + /// The ULID string representation. See . + /// The parsed . + /// Thrown if is not a valid ULID string representation. #if NETCOREAPP3_0_OR_GREATER [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] #else From c8592e22510672a4c59bdebde042e1e6350e6b35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Fri, 9 Oct 2026 22:41:51 +0300 Subject: [PATCH 05/16] Remove allocation from scalar GUID shuffle Also define the SIMD shuffle mask once. --- src/Ulid/Ulid.Guid.cs | 35 ++++++++++++++++------------------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/src/Ulid/Ulid.Guid.cs b/src/Ulid/Ulid.Guid.cs index c51e3a1..04b08ee 100644 --- a/src/Ulid/Ulid.Guid.cs +++ b/src/Ulid/Ulid.Guid.cs @@ -1,6 +1,5 @@ using System.Buffers.Binary; using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; #if NETCOREAPP using System.Runtime.Intrinsics; #if !NET7_0_OR_GREATER @@ -10,12 +9,6 @@ namespace ByteAether.Ulid; -#if NET8_0_OR_GREATER -// We need to target netstandard2.1, so keep using ref for MemoryMarshal.Write -// CS9191: The 'ref' modifier for argument 2 corresponding to the 'in' parameter is equivalent to 'in'. Consider using 'in' instead. -#pragma warning disable CS9191 -#endif - public readonly partial struct Ulid { /// @@ -77,8 +70,12 @@ public Guid ToGuid() public static implicit operator Ulid(Guid guid) => New(guid); #if NETCOREAPP - private static readonly Vector128 _shuffleMask - = Vector128.Create((byte)3, 2, 1, 0, 5, 4, 7, 6, 8, 9, 10, 11, 12, 13, 14, 15); + // Created inline from constants, so the JIT emits it as a constant vector in every tier + private static Vector128 _shuffleMask + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => Vector128.Create((byte)3, 2, 1, 0, 5, 4, 7, 6, 8, 9, 10, 11, 12, 13, 14, 15); + } #endif // HACK: We assume the layout of a Guid is the following: @@ -115,20 +112,20 @@ private static TOut Shuffle(ref TIn bytes) // |D|C|B|A|... // ...|F|E|H|G|... // ...|I|J|K|L|M|N|O|P| - Span result = new byte[_ulidSize]; + ref var src = ref Unsafe.As(ref bytes); - ref var ptr = ref Unsafe.As(ref bytes); - var lower = BinaryPrimitives.ReverseEndianness(ptr); + var lower = BinaryPrimitives.ReverseEndianness(Unsafe.ReadUnaligned(ref src)); - ptr = ref Unsafe.Add(ref ptr, 1); - var upper = ((ptr & 0x00_FF_00_FF) << 8) | ((ptr & 0xFF_00_FF_00) >> 8); + var upper = Unsafe.ReadUnaligned(ref Unsafe.Add(ref src, 4)); + upper = ((upper & 0x00_FF_00_FF) << 8) | ((upper & 0xFF_00_FF_00) >> 8); - ref var upperBytes = ref Unsafe.As(ref Unsafe.Add(ref ptr, 1)); + Unsafe.SkipInit(out TOut result); + ref var dst = ref Unsafe.As(ref result); - MemoryMarshal.Write(result, ref lower); - MemoryMarshal.Write(result[4..], ref upper); - MemoryMarshal.Write(result[8..], ref upperBytes); + Unsafe.WriteUnaligned(ref dst, lower); + Unsafe.WriteUnaligned(ref Unsafe.Add(ref dst, 4), upper); + Unsafe.WriteUnaligned(ref Unsafe.Add(ref dst, 8), Unsafe.ReadUnaligned(ref Unsafe.Add(ref src, 8))); - return Unsafe.As(ref result.GetPinnableReference()); + return result; } } \ No newline at end of file From 71f16ea8a7ffec53951cfef6661e7c2f515461e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Fri, 9 Oct 2026 22:41:51 +0300 Subject: [PATCH 06/16] Support escaped JSON strings in UlidJsonConverter --- src/.editorconfig | 26 ++++---- src/Ulid.Tests/UlidJsonConverterTests.cs | 68 +++++++++++++++++++- src/Ulid/UlidJsonConverter.cs | 79 ++++++++++++++++++------ 3 files changed, 139 insertions(+), 34 deletions(-) diff --git a/src/.editorconfig b/src/.editorconfig index 70f5dcb..d5a5c2f 100644 --- a/src/.editorconfig +++ b/src/.editorconfig @@ -101,7 +101,7 @@ csharp_style_var_when_type_is_apparent = true:suggestion # Expression-bodied members csharp_style_expression_bodied_accessors = true:suggestion -csharp_style_expression_bodied_constructors = false:warning +csharp_style_expression_bodied_constructors = false:none csharp_style_expression_bodied_indexers = true:suggestion csharp_style_expression_bodied_lambdas = when_on_single_line:suggestion csharp_style_expression_bodied_local_functions = false:silent @@ -236,19 +236,19 @@ dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case dotnet_naming_symbols.interface.applicable_kinds = interface dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.interface.required_modifiers = +dotnet_naming_symbols.interface.required_modifiers = dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.types.required_modifiers = +dotnet_naming_symbols.types.required_modifiers = dotnet_naming_symbols.non_field_members.applicable_kinds = property, method, event dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.non_field_members.required_modifiers = +dotnet_naming_symbols.non_field_members.required_modifiers = dotnet_naming_symbols.private_property_field.applicable_kinds = property, field dotnet_naming_symbols.private_property_field.applicable_accessibilities = private, protected, protected_internal, private_protected -dotnet_naming_symbols.private_property_field.required_modifiers = +dotnet_naming_symbols.private_property_field.required_modifiers = dotnet_naming_symbols.private_static_property_field.applicable_kinds = property, field dotnet_naming_symbols.private_static_property_field.applicable_accessibilities = private, protected, protected_internal, private_protected @@ -256,19 +256,19 @@ dotnet_naming_symbols.private_static_property_field.required_modifiers = static # Naming styles -dotnet_naming_style.pascal_case.required_prefix = -dotnet_naming_style.pascal_case.required_suffix = -dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = dotnet_naming_style.pascal_case.capitalization = pascal_case dotnet_naming_style.begins_with_i.required_prefix = I -dotnet_naming_style.begins_with_i.required_suffix = -dotnet_naming_style.begins_with_i.word_separator = +dotnet_naming_style.begins_with_i.required_suffix = +dotnet_naming_style.begins_with_i.word_separator = dotnet_naming_style.begins_with_i.capitalization = pascal_case dotnet_naming_style.begins_with__.required_prefix = _ -dotnet_naming_style.begins_with__.required_suffix = -dotnet_naming_style.begins_with__.word_separator = +dotnet_naming_style.begins_with__.required_suffix = +dotnet_naming_style.begins_with__.word_separator = dotnet_naming_style.begins_with__.capitalization = camel_case [*.{cs,vb}] @@ -308,4 +308,4 @@ dotnet_style_qualification_for_property = false:warning dotnet_style_qualification_for_method = false:warning dotnet_style_qualification_for_event = false:warning dotnet_diagnostic.CA1040.severity = none -dotnet_diagnostic.CA1034.severity = none \ No newline at end of file +dotnet_diagnostic.CA1034.severity = none diff --git a/src/Ulid.Tests/UlidJsonConverterTests.cs b/src/Ulid.Tests/UlidJsonConverterTests.cs index 49be272..053175f 100644 --- a/src/Ulid.Tests/UlidJsonConverterTests.cs +++ b/src/Ulid.Tests/UlidJsonConverterTests.cs @@ -7,7 +7,7 @@ public class UlidJsonConverterTests { private class TestDto { - public Ulid UlidProperty { get; set; } + public Ulid UlidProperty { get; init; } } private static JsonSerializerOptions _jsonOptions => new() @@ -62,6 +62,72 @@ public void Deserialize_BadUlidString_ThrowsJsonException(bool useCustomOptions) Assert.Throws(() => JsonSerializer.Deserialize(invalidJsonString, options)); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Deserialize_EscapedJsonStringToUlid(bool useCustomOptions) + { + // Arrange + var ulid = Ulid.New(); + var ulidString = ulid.ToString(); + var escapedJsonString = $"\"\\u{(int)ulidString[0]:X4}{ulidString[1..^1]}\\u{(int)ulidString[^1]:x4}\""; + var options = useCustomOptions ? _jsonOptions : null; + + // Act + var deserializedUlid = JsonSerializer.Deserialize(escapedJsonString, options); + + // Assert + Assert.Equal(ulid, deserializedUlid); + } + + [Theory] + [InlineData("\"\\u0030\"")] + [InlineData("\"01AN4Z07BY79KA1307SR9X4MV\\u0021\"")] + [InlineData("\"\\u0030\\u0030\\u0030\\u0030\\u0030\\u0030\\u0030\\u0030\\u0030\\u0030\\u0030\\u0030\\u0030\\u0030\\u0030\\u0030\\u0030\\u0030\\u0030\\u0030\\u0030\\u0030\\u0030\\u0030\\u0030\\u0030\\u0030\"")] + public void Deserialize_BadEscapedJsonString_ThrowsJsonException(string invalidJsonString) + { + // Act & Assert + Assert.Throws(() => JsonSerializer.Deserialize(invalidJsonString, _jsonOptions)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Read_SegmentedJsonString_ShouldReturnUlid(bool escaped) + { + // Arrange + var ulid = Ulid.New(); + var ulidString = ulid.ToString(); + var json = escaped + ? $"\"\\u{(int)ulidString[0]:X4}{ulidString[1..]}\"" + : $"\"{ulidString}\""; + var bytes = System.Text.Encoding.UTF8.GetBytes(json); + + // Split the value into two segments to exercise the ValueSequence path + var first = new Segment(bytes.AsMemory(0, 10)); + var last = first.Append(bytes.AsMemory(10)); + var reader = new Utf8JsonReader(new System.Buffers.ReadOnlySequence(first, 0, last, last.Memory.Length)); + reader.Read(); + + // Act + var result = new UlidJsonConverter().Read(ref reader, typeof(Ulid), _jsonOptions); + + // Assert + Assert.Equal(ulid, result); + } + + private sealed class Segment : System.Buffers.ReadOnlySequenceSegment + { + public Segment(ReadOnlyMemory memory) => Memory = memory; + + public Segment Append(ReadOnlyMemory memory) + { + var next = new Segment(memory) { RunningIndex = RunningIndex + Memory.Length }; + Next = next; + return next; + } + } + [Theory] [InlineData(true)] [InlineData(false)] diff --git a/src/Ulid/UlidJsonConverter.cs b/src/Ulid/UlidJsonConverter.cs index 852ef4d..4c6627d 100644 --- a/src/Ulid/UlidJsonConverter.cs +++ b/src/Ulid/UlidJsonConverter.cs @@ -10,38 +10,77 @@ namespace ByteAether.Ulid; /// public class UlidJsonConverter : JsonConverter { + // Every character of an escaped string is at most 6 bytes long ("\uXXXX") + private const int _maxEscapedLength = Ulid.UlidStringLength * 6; + /// public override Ulid Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - try + if (reader.TokenType is not JsonTokenType.String and not JsonTokenType.PropertyName) { - if (reader.TokenType is not JsonTokenType.String and not JsonTokenType.PropertyName) - { - throw new JsonException("Expected string or property name"); - } + throw new JsonException("Expected string or property name"); + } - if (reader.HasValueSequence) - { - var byteSequence = reader.ValueSequence; - if (byteSequence.Length != Ulid.UlidStringLength) - { - throw new JsonException($"Ulid invalid: length must be {Ulid.UlidStringLength}"); - } + if (!TryRead(ref reader, out var ulid)) + { + throw new JsonException($"Ulid invalid: expected a {Ulid.UlidStringLength} character Crockford's Base32 string"); + } + + return ulid; + } + + private static bool TryRead(ref Utf8JsonReader reader, out Ulid ulid) + { +#if NET7_0_OR_GREATER + if (reader.ValueIsEscaped) + { + return TryReadEscaped(ref reader, out ulid); + } +#endif + if (reader.HasValueSequence) + { + var byteSequence = reader.ValueSequence; + if (byteSequence.Length == Ulid.UlidStringLength) + { Span byteSpan = stackalloc byte[Ulid.UlidStringLength]; byteSequence.CopyTo(byteSpan); - return Ulid.Parse(byteSpan); - } - else - { - var byteSpan = reader.ValueSpan; - return Ulid.Parse(byteSpan); + if (Ulid.TryParse(byteSpan, null, out ulid)) + { + return true; + } } } - catch (FormatException ex) + else if (Ulid.TryParse(reader.ValueSpan, null, out ulid)) { - throw new JsonException($"Ulid invalid: length must be {Ulid.UlidStringLength}", ex); + return true; } + +#if NET7_0_OR_GREATER + ulid = default; + return false; +#else + // The escaped flag is not available, so unescape only after the raw value failed to parse + return TryReadEscaped(ref reader, out ulid); +#endif + } + + private static bool TryReadEscaped(ref Utf8JsonReader reader, out Ulid ulid) + { + var length = reader.HasValueSequence ? reader.ValueSequence.Length : reader.ValueSpan.Length; + if (length > _maxEscapedLength) + { + ulid = default; + return false; + } + +#if NET7_0_OR_GREATER + Span chars = stackalloc char[_maxEscapedLength]; + var charsWritten = reader.CopyString(chars); + return Ulid.TryParse(chars[..charsWritten], null, out ulid); +#else + return Ulid.TryParse(reader.GetString(), null, out ulid); +#endif } /// From 1a430ebe313f25bd22743e4ecc4d7b2cd396c0b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Fri, 9 Oct 2026 22:41:51 +0300 Subject: [PATCH 07/16] Return rented arrays in netstandard2.0 RNG shims --- src/Compatibility/RandomNumberGeneratorExtensions.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Compatibility/RandomNumberGeneratorExtensions.cs b/src/Compatibility/RandomNumberGeneratorExtensions.cs index 2447aef..faf5ee5 100644 --- a/src/Compatibility/RandomNumberGeneratorExtensions.cs +++ b/src/Compatibility/RandomNumberGeneratorExtensions.cs @@ -15,6 +15,8 @@ public static void GetBytes(this RandomNumberGenerator rng, Span buffer) var rndInc = ArrayPool.Shared.Rent(buffer.Length); rng.GetBytes(rndInc, 0, buffer.Length); new ReadOnlySpan(rndInc, 0, buffer.Length).CopyTo(buffer); + // Clear, so that the generated secure random bytes do not linger in the shared pool + ArrayPool.Shared.Return(rndInc, clearArray: true); } // In NetStandard 2.0, Random.NextBytes() does not support Span overloads. @@ -24,6 +26,7 @@ public static void NextBytes(this Random rng, Span buffer) var rndInc = ArrayPool.Shared.Rent(buffer.Length); rng.NextBytes(rndInc); new ReadOnlySpan(rndInc, 0, buffer.Length).CopyTo(buffer); + ArrayPool.Shared.Return(rndInc); } } #endif \ No newline at end of file From 0e11f213b3d730fb89e5d057b84d0c737e3835b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Fri, 9 Oct 2026 22:42:46 +0300 Subject: [PATCH 08/16] Drop AggressiveOptimization; use module-level SkipLocalsInit Lets tiered PGO optimize hot paths and removes per-TFM #if blocks. --- .../CryptographicallySecureRandomProvider.cs | 4 - src/Ulid/LowLatencyLock.cs | 21 ------ src/Ulid/PseudoRandomProvider.cs | 8 -- src/Ulid/Ulid.Comparable.cs | 27 ------- src/Ulid/Ulid.Equatable.cs | 33 -------- src/Ulid/Ulid.Guid.cs | 29 ------- src/Ulid/Ulid.IsValid.cs | 14 ---- src/Ulid/Ulid.New.GenerationOptions.cs | 21 +----- src/Ulid/Ulid.New.cs | 50 ------------- src/Ulid/Ulid.String.cs | 75 ------------------- src/Ulid/Ulid.cs | 33 +------- 11 files changed, 5 insertions(+), 310 deletions(-) diff --git a/src/Ulid/CryptographicallySecureRandomProvider.cs b/src/Ulid/CryptographicallySecureRandomProvider.cs index 0138b03..fdd606d 100644 --- a/src/Ulid/CryptographicallySecureRandomProvider.cs +++ b/src/Ulid/CryptographicallySecureRandomProvider.cs @@ -15,11 +15,7 @@ namespace ByteAether.Ulid; #endif /// -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] -#else [MethodImpl(MethodImplOptions.AggressiveInlining)] -#endif public void GetBytes(Span buffer) { #if NETCOREAPP3_0_OR_GREATER diff --git a/src/Ulid/LowLatencyLock.cs b/src/Ulid/LowLatencyLock.cs index 01eea70..15f237c 100644 --- a/src/Ulid/LowLatencyLock.cs +++ b/src/Ulid/LowLatencyLock.cs @@ -6,14 +6,7 @@ internal struct LowLatencyLock { internal int LockState; -#if NET5_0_OR_GREATER - [SkipLocalsInit] -#endif -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] -#else [MethodImpl(MethodImplOptions.AggressiveInlining)] -#endif internal void Enter() { if (Interlocked.CompareExchange(ref LockState, 1, 0) != 0) @@ -22,25 +15,11 @@ internal void Enter() } } -#if NET5_0_OR_GREATER - [SkipLocalsInit] -#endif -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] -#else [MethodImpl(MethodImplOptions.AggressiveInlining)] -#endif internal void Exit() => Volatile.Write(ref LockState, 0); -#if NET5_0_OR_GREATER - [SkipLocalsInit] -#endif -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.AggressiveOptimization)] -#else [MethodImpl(MethodImplOptions.NoInlining)] -#endif private static void ContendedEnter(ref int lockState) { var spinner = new SpinWait(); diff --git a/src/Ulid/PseudoRandomProvider.cs b/src/Ulid/PseudoRandomProvider.cs index bacb9b9..2625143 100644 --- a/src/Ulid/PseudoRandomProvider.cs +++ b/src/Ulid/PseudoRandomProvider.cs @@ -12,11 +12,7 @@ namespace ByteAether.Ulid; #if NET6_0_OR_GREATER private static Random _rng { -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] -#else [MethodImpl(MethodImplOptions.AggressiveInlining)] -#endif get => Random.Shared; } #else @@ -24,10 +20,6 @@ private static Random _rng #endif /// -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] -#else [MethodImpl(MethodImplOptions.AggressiveInlining)] -#endif public void GetBytes(Span buffer) => _rng.NextBytes(buffer); } \ No newline at end of file diff --git a/src/Ulid/Ulid.Comparable.cs b/src/Ulid/Ulid.Comparable.cs index 12d32cd..797b65e 100644 --- a/src/Ulid/Ulid.Comparable.cs +++ b/src/Ulid/Ulid.Comparable.cs @@ -15,11 +15,7 @@ namespace ByteAether.Ulid; /// The first ULID to compare. /// The second ULID to compare. /// True if the value of the left ULID is less than the value of the right ULID; otherwise, false. -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] -#else [MethodImpl(MethodImplOptions.AggressiveInlining)] -#endif public static bool operator <(Ulid left, Ulid right) => left.CompareTo(right) < 0; @@ -29,11 +25,7 @@ namespace ByteAether.Ulid; /// The first ULID to compare. /// The second ULID to compare. /// True if the value of the left ULID is less than or equal to the value of the right ULID; otherwise, false. -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] -#else [MethodImpl(MethodImplOptions.AggressiveInlining)] -#endif public static bool operator <=(Ulid left, Ulid right) => left.CompareTo(right) <= 0; @@ -43,11 +35,7 @@ namespace ByteAether.Ulid; /// The first ULID to compare. /// The second ULID to compare. /// True if the value of the left ULID is greater than the value of the right ULID; otherwise, false. -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] -#else [MethodImpl(MethodImplOptions.AggressiveInlining)] -#endif public static bool operator >(Ulid left, Ulid right) => left.CompareTo(right) > 0; @@ -57,20 +45,12 @@ namespace ByteAether.Ulid; /// The first ULID to compare. /// The second ULID to compare. /// True if the value of the left ULID is greater than or equal to the value of the right ULID; otherwise, false. -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] -#else [MethodImpl(MethodImplOptions.AggressiveInlining)] -#endif public static bool operator >=(Ulid left, Ulid right) => left.CompareTo(right) >= 0; /// -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] -#else [MethodImpl(MethodImplOptions.AggressiveInlining)] -#endif public int CompareTo(object? obj) { if (obj == null) @@ -87,17 +67,10 @@ public int CompareTo(object? obj) } /// -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] -#else [MethodImpl(MethodImplOptions.AggressiveInlining)] -#endif public int CompareTo(Ulid other) => CompareToCore(this, other); -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveOptimization)] -#endif private static int CompareToCore(in Ulid left, in Ulid right) { ref var rA = ref Unsafe.As(ref Unsafe.AsRef(in left)); diff --git a/src/Ulid/Ulid.Equatable.cs b/src/Ulid/Ulid.Equatable.cs index 3c9483f..9c3c4b4 100644 --- a/src/Ulid/Ulid.Equatable.cs +++ b/src/Ulid/Ulid.Equatable.cs @@ -16,30 +16,15 @@ namespace ByteAether.Ulid; #endif { /// -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] -#else [MethodImpl(MethodImplOptions.AggressiveInlining)] -#endif public int GetHashCode(Ulid ulid) => ulid.GetHashCode(); /// -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] -#else [MethodImpl(MethodImplOptions.AggressiveInlining)] -#endif public bool Equals(Ulid x, Ulid y) => EqualsCore(x, y); /// -#if NET5_0_OR_GREATER - [SkipLocalsInit] -#endif -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] -#else [MethodImpl(MethodImplOptions.AggressiveInlining)] -#endif public override int GetHashCode() { ref var rA = ref Unsafe.As(ref Unsafe.AsRef(in this)); @@ -47,20 +32,12 @@ public override int GetHashCode() } /// -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] -#else [MethodImpl(MethodImplOptions.AggressiveInlining)] -#endif public bool Equals(Ulid other) => EqualsCore(this, other); /// -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] -#else [MethodImpl(MethodImplOptions.AggressiveInlining)] -#endif public override bool Equals([NotNullWhen(true)] object? obj) => obj is Ulid ulid && EqualsCore(this, ulid); @@ -71,11 +48,7 @@ public override bool Equals([NotNullWhen(true)] object? obj) /// The second ULID to compare. /// True if the value of the left ULID is equal to the value of the right ULID; otherwise, false. -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] -#else [MethodImpl(MethodImplOptions.AggressiveInlining)] -#endif public static bool operator ==(Ulid left, Ulid right) => EqualsCore(left, right); @@ -88,12 +61,6 @@ public override bool Equals([NotNullWhen(true)] object? obj) public static bool operator !=(Ulid left, Ulid right) => !EqualsCore(left, right); -#if NET5_0_OR_GREATER - [SkipLocalsInit] -#endif -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveOptimization)] -#endif private static bool EqualsCore(in Ulid left, in Ulid right) { #if NET7_0_OR_GREATER diff --git a/src/Ulid/Ulid.Guid.cs b/src/Ulid/Ulid.Guid.cs index 04b08ee..0bcbb8e 100644 --- a/src/Ulid/Ulid.Guid.cs +++ b/src/Ulid/Ulid.Guid.cs @@ -15,14 +15,7 @@ public readonly partial struct Ulid /// Creates a new ULID using the specified GUID. /// /// The GUID to initialize the ULID with. -#if NET5_0_OR_GREATER - [SkipLocalsInit] -#endif -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] -#else [MethodImpl(MethodImplOptions.AggressiveInlining)] -#endif public static Ulid New(Guid guid) => BitConverter.IsLittleEndian ? Shuffle(ref guid) @@ -32,14 +25,7 @@ public static Ulid New(Guid guid) /// Converts the ULID to a GUID. /// /// A GUID representing the ULID. -#if NET5_0_OR_GREATER - [SkipLocalsInit] -#endif -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] -#else [MethodImpl(MethodImplOptions.AggressiveInlining)] -#endif public Guid ToGuid() => BitConverter.IsLittleEndian ? Shuffle(ref Unsafe.AsRef(in this)) @@ -50,11 +36,7 @@ public Guid ToGuid() /// /// The ULID to convert. /// A GUID representing the ULID. -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] -#else [MethodImpl(MethodImplOptions.AggressiveInlining)] -#endif public static implicit operator Guid(Ulid ulid) => ulid.ToGuid(); /// @@ -62,11 +44,7 @@ public Guid ToGuid() /// /// The GUID to convert. /// A ULID representing the GUID. -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] -#else [MethodImpl(MethodImplOptions.AggressiveInlining)] -#endif public static implicit operator Ulid(Guid guid) => New(guid); #if NETCOREAPP @@ -82,14 +60,7 @@ private static Vector128 _shuffleMask // Int32, Int16, Int16, Int8, Int8, Int8, Int8, Int8, Int8, Int8, Int8 // Source: https://github.com/dotnet/runtime/blob/5c4686f831d34c2c127e943d0f0d144793eeb0ad/src/libraries/System.Private.CoreLib/src/System/Guid.cs // More info: https://stackoverflow.com/questions/10190817/guid-byte-order-in-net/10191075#10191075 -#if NET5_0_OR_GREATER - [SkipLocalsInit] -#endif -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] -#else [MethodImpl(MethodImplOptions.AggressiveInlining)] -#endif private static TOut Shuffle(ref TIn bytes) { #if NET7_0_OR_GREATER diff --git a/src/Ulid/Ulid.IsValid.cs b/src/Ulid/Ulid.IsValid.cs index 5a8be8d..69f8196 100644 --- a/src/Ulid/Ulid.IsValid.cs +++ b/src/Ulid/Ulid.IsValid.cs @@ -17,11 +17,7 @@ public readonly partial struct Ulid /// I and L accepted as 1, and O as 0. The first character must be between /// 0 and 7, otherwise the value would not fit into 128 bits. /// -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] -#else [MethodImpl(MethodImplOptions.AggressiveInlining)] -#endif public static bool IsValid(string ulidString) => IsValid(ulidString.AsSpan()); /// @@ -36,12 +32,6 @@ public readonly partial struct Ulid /// I and L accepted as 1, and O as 0. The first character must be between /// 0 and 7, otherwise the value would not fit into 128 bits. /// -#if NET5_0_OR_GREATER - [SkipLocalsInit] -#endif -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveOptimization)] -#endif public static unsafe bool IsValid(ReadOnlySpan ulidString) { if (ulidString.Length != UlidStringLength) // 26 @@ -129,10 +119,6 @@ public static unsafe bool IsValid(ReadOnlySpan ulidString) /// /// true if the byte array is a valid ULID, false otherwise. /// -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] -#else [MethodImpl(MethodImplOptions.AggressiveInlining)] -#endif public static bool IsValid(ReadOnlySpan ulidBytes) => ulidBytes.Length == _ulidSize; } \ No newline at end of file diff --git a/src/Ulid/Ulid.New.GenerationOptions.cs b/src/Ulid/Ulid.New.GenerationOptions.cs index 8fbde19..bf23095 100644 --- a/src/Ulid/Ulid.New.GenerationOptions.cs +++ b/src/Ulid/Ulid.New.GenerationOptions.cs @@ -158,11 +158,7 @@ internal class State [FieldOffset(16+128+8)] public ulong LastUlidPart1; #pragma warning restore CS0649 // Field is never assigned to, and will always have its default value -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] -#else [MethodImpl(MethodImplOptions.AggressiveInlining)] -#endif internal Scope EnterLock() { Lock.Enter(); @@ -174,29 +170,14 @@ internal readonly ref struct Scope { private readonly State _state; -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] -#else [MethodImpl(MethodImplOptions.AggressiveInlining)] -#endif internal Scope(State state) => _state = state; -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] -#else [MethodImpl(MethodImplOptions.AggressiveInlining)] -#endif public void Dispose() => _state.Lock.Exit(); } -#if NET5_0_OR_GREATER - [SkipLocalsInit] -#endif -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] -#else - [MethodImpl(MethodImplOptions.AggressiveInlining)] -#endif + [MethodImpl(MethodImplOptions.AggressiveInlining)] internal void Increment(uint addition) { var increment = (ulong)addition + 1; // carry = 1 is built-in diff --git a/src/Ulid/Ulid.New.cs b/src/Ulid/Ulid.New.cs index a27bd1d..e395db5 100644 --- a/src/Ulid/Ulid.New.cs +++ b/src/Ulid/Ulid.New.cs @@ -26,11 +26,7 @@ public readonly partial struct Ulid /// /// The byte array to initialize the with. /// Given bytes as an instance. -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] -#else [MethodImpl(MethodImplOptions.AggressiveInlining)] -#endif public static Ulid New(ReadOnlySpan bytes) => MemoryMarshal.Read(bytes); @@ -42,11 +38,7 @@ public static Ulid New(ReadOnlySpan bytes) /// Otherwise, uses the specified to control the ULID generation behavior. /// /// A new instance. -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] -#else [MethodImpl(MethodImplOptions.AggressiveInlining)] -#endif public static Ulid New(GenerationOptions? options = null) // We can avoid Offset-related allocations by using DateTime over DateTimeOffset // For public API, DateTimeOffset is an official recommendation @@ -61,11 +53,7 @@ public static Ulid New(GenerationOptions? options = null) /// Otherwise, uses the specified to control the ULID generation behavior. /// /// A new instance. -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] -#else [MethodImpl(MethodImplOptions.AggressiveInlining)] -#endif public static Ulid New(DateTimeOffset dateTimeOffset, GenerationOptions? options = null) => New(dateTimeOffset.ToUnixTimeMilliseconds(), options); @@ -79,11 +67,7 @@ public static Ulid New(DateTimeOffset dateTimeOffset, GenerationOptions? options /// /// A new instance. /// Thrown if is shorter than 10 bytes. -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] -#else [MethodImpl(MethodImplOptions.AggressiveInlining)] -#endif public static Ulid New(DateTimeOffset dateTimeOffset, ReadOnlySpan random) => New(dateTimeOffset.ToUnixTimeMilliseconds(), random); @@ -96,14 +80,7 @@ public static Ulid New(DateTimeOffset dateTimeOffset, ReadOnlySpan random) /// Otherwise, uses the specified to control the ULID generation behavior. /// /// A new instance. -#if NET5_0_OR_GREATER - [SkipLocalsInit] -#endif -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] -#else [MethodImpl(MethodImplOptions.AggressiveInlining)] -#endif public static Ulid New(long timestamp, GenerationOptions? options = null) { Unsafe.SkipInit(out Ulid ulid); @@ -130,14 +107,7 @@ public static Ulid New(long timestamp, GenerationOptions? options = null) /// A new instance composed of the given timestamp and random byte sequence. /// /// Thrown if is shorter than 10 bytes. -#if NET5_0_OR_GREATER - [SkipLocalsInit] -#endif -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] -#else [MethodImpl(MethodImplOptions.AggressiveInlining)] -#endif public static Ulid New(long timestamp, ReadOnlySpan random) { if (random.Length < _ulidSizeRandom) @@ -168,14 +138,7 @@ ref MemoryMarshal.GetReference(random), private static void ThrowRandomTooShort() => throw new ArgumentException($"The random component must be at least {_ulidSizeRandom} bytes long.", "random"); -#if NET5_0_OR_GREATER - [SkipLocalsInit] -#endif -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] -#else [MethodImpl(MethodImplOptions.AggressiveInlining)] -#endif private static void FillTimestamp(ref byte ulidBytesRef, long timestamp) { var ts = (ulong)timestamp << 16; @@ -183,14 +146,7 @@ private static void FillTimestamp(ref byte ulidBytesRef, long timestamp) Unsafe.WriteUnaligned(ref ulidBytesRef, ts); } -#if NET5_0_OR_GREATER - [SkipLocalsInit] -#endif -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] -#else [MethodImpl(MethodImplOptions.AggressiveInlining)] -#endif private static void Fill(ref byte ulidBytesRef, long timestamp, GenerationOptions options) { // Calculate offset to a random part @@ -206,12 +162,6 @@ private static void Fill(ref byte ulidBytesRef, long timestamp, GenerationOption FillMonotonic(ref ulidBytesRef, timestamp, options, monotonicity); } -#if NET5_0_OR_GREATER - [SkipLocalsInit] -#endif -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveOptimization)] -#endif private static void FillMonotonic( ref byte ulidBytesRef, long timestamp, diff --git a/src/Ulid/Ulid.String.cs b/src/Ulid/Ulid.String.cs index 9e60f8a..b23cd5a 100644 --- a/src/Ulid/Ulid.String.cs +++ b/src/Ulid/Ulid.String.cs @@ -87,28 +87,14 @@ public readonly partial struct Ulid ]; /// -#if NET5_0_OR_GREATER - [SkipLocalsInit] -#endif -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] -#else [MethodImpl(MethodImplOptions.AggressiveInlining)] -#endif public string ToString(string? format, IFormatProvider? formatProvider) => ToString(); /// /// Returns a string representation of the current instance of in its canonical Crockford's Base32 format.' /// /// Crockford's Base32 representation of the ULID -#if NET5_0_OR_GREATER - [SkipLocalsInit] -#endif -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] -#else [MethodImpl(MethodImplOptions.AggressiveInlining)] -#endif public override string ToString() { #if NETSTANDARD2_1_OR_GREATER || NETCOREAPP @@ -132,14 +118,7 @@ public override string ToString() /// O as 0. The first character must be between 0 and 7. /// /// Thrown if the input span is not a valid ULID string representation. -#if NET5_0_OR_GREATER - [SkipLocalsInit] -#endif -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] -#else [MethodImpl(MethodImplOptions.AggressiveInlining)] -#endif public static Ulid Parse(ReadOnlySpan chars, IFormatProvider? provider = null) { var ulid = ParseCore(chars, out var isValid); @@ -163,14 +142,7 @@ public static Ulid Parse(ReadOnlySpan chars, IFormatProvider? provider = n /// O as 0. The first character must be between 0 and 7. /// /// Thrown if the input byte span does not contain a valid ULID string representation. -#if NET5_0_OR_GREATER - [SkipLocalsInit] -#endif -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] -#else [MethodImpl(MethodImplOptions.AggressiveInlining)] -#endif public static Ulid Parse(ReadOnlySpan bytes, IFormatProvider? provider = null) { var ulid = ParseCore(bytes, out var isValid); @@ -182,12 +154,6 @@ public static Ulid Parse(ReadOnlySpan bytes, IFormatProvider? provider = n return ulid; } -#if NET5_0_OR_GREATER - [SkipLocalsInit] -#endif -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveOptimization)] -#endif private static Ulid ParseCore(ReadOnlySpan input, out bool isValid) { if (input.Length != UlidStringLength) @@ -335,12 +301,6 @@ private static Ulid ParseCore(ReadOnlySpan input, out bool isValid) private static byte ReadLowByte(ref byte src, int index) => (byte)Unsafe.ReadUnaligned(ref Unsafe.Add(ref src, index * sizeof(char))); -#if NET5_0_OR_GREATER - [SkipLocalsInit] -#endif -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveOptimization)] -#endif private static Ulid ParseCore(ReadOnlySpan input, out bool isValid) { if (input.Length != UlidStringLength) @@ -471,11 +431,7 @@ private static void ThrowInvalidFormat() /// O as 0. The first character must be between 0 and 7. /// /// Thrown if the input string is not a valid ULID string representation. -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] -#else [MethodImpl(MethodImplOptions.AggressiveInlining)] -#endif public static Ulid Parse(string s, IFormatProvider? provider = null) => Parse(s.AsSpan(), provider); @@ -491,11 +447,7 @@ public static Ulid Parse(string s, IFormatProvider? provider = null) /// Crockford's Base32 alphabet, read case-insensitively, with I and L accepted as 1, and /// O as 0. The first character must be between 0 and 7. /// -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] -#else [MethodImpl(MethodImplOptions.AggressiveInlining)] -#endif public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, out Ulid result) => TryParse(s.AsSpan(), provider, out result); @@ -511,11 +463,7 @@ public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? prov /// Crockford's Base32 alphabet, read case-insensitively, with I and L accepted as 1, and /// O as 0. The first character must be between 0 and 7. /// -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] -#else [MethodImpl(MethodImplOptions.AggressiveInlining)] -#endif public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, out Ulid result) { result = ParseCore(s, out var isValid); @@ -534,11 +482,7 @@ public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, out /// Crockford's Base32 alphabet, read case-insensitively, with I and L accepted as 1, and /// O as 0. The first character must be between 0 and 7. /// -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] -#else [MethodImpl(MethodImplOptions.AggressiveInlining)] -#endif public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, out Ulid result) { result = ParseCore(s, out var isValid); @@ -555,11 +499,7 @@ public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, out /// /// true if the formatting is successful and the destination span is large enough to contain the formatted data; otherwise, false. /// -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] -#else [MethodImpl(MethodImplOptions.AggressiveInlining)] -#endif public bool TryFormat( Span destination, out int charsWritten, @@ -588,11 +528,7 @@ public bool TryFormat( /// /// true if the formatting was successful; false if the destination span was too short to contain the formatted value. /// -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] -#else [MethodImpl(MethodImplOptions.AggressiveInlining)] -#endif public bool TryFormat( Span destination, out int bytesWritten, @@ -611,9 +547,6 @@ public bool TryFormat( return true; } -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveOptimization)] -#endif private void Fill(Span span, T[] map) where T: unmanaged { // Encode randomness @@ -652,11 +585,7 @@ private void Fill(Span span, T[] map) where T: unmanaged /// /// /// -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] -#else [MethodImpl(MethodImplOptions.AggressiveInlining)] -#endif public static implicit operator string(Ulid ulid) => ulid.ToString(); /// @@ -665,10 +594,6 @@ private void Fill(Span span, T[] map) where T: unmanaged /// The ULID string representation. See . /// The parsed . /// Thrown if is not a valid ULID string representation. -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] -#else [MethodImpl(MethodImplOptions.AggressiveInlining)] -#endif public static implicit operator Ulid(string str) => Parse(str); } \ No newline at end of file diff --git a/src/Ulid/Ulid.cs b/src/Ulid/Ulid.cs index 4a7e470..0c99c52 100644 --- a/src/Ulid/Ulid.cs +++ b/src/Ulid/Ulid.cs @@ -6,6 +6,10 @@ using System.Text.Json.Serialization; #endif +#if NET5_0_OR_GREATER +[module: SkipLocalsInit] +#endif + namespace ByteAether.Ulid; /// @@ -58,11 +62,7 @@ public readonly partial struct Ulid [IgnoreDataMember] public ReadOnlySpan Random { -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] -#else [MethodImpl(MethodImplOptions.AggressiveInlining)] -#endif get => AsByteSpan()[_ulidSizeTime..]; } @@ -79,11 +79,7 @@ public ReadOnlySpan Random [IgnoreDataMember] public ReadOnlySpan TimeBytes { -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] -#else [MethodImpl(MethodImplOptions.AggressiveInlining)] -#endif get => AsByteSpan()[.._ulidSizeTime]; } @@ -102,14 +98,7 @@ public ReadOnlySpan TimeBytes [IgnoreDataMember] public DateTimeOffset Time { -#if NET5_0_OR_GREATER - [SkipLocalsInit] -#endif -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] -#else [MethodImpl(MethodImplOptions.AggressiveInlining)] -#endif get { // Combine the 6 bytes into a 48-bit timestamp (big-endian order) @@ -145,14 +134,7 @@ public DateTimeOffset Time /// A that points to the raw byte representation of the current struct. /// #endif -#if NET5_0_OR_GREATER - [SkipLocalsInit] -#endif -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] -#else [MethodImpl(MethodImplOptions.AggressiveInlining)] -#endif public ReadOnlySpan AsByteSpan() #if NETSTANDARD2_0 => ToByteArray(); @@ -164,14 +146,7 @@ public ReadOnlySpan AsByteSpan() /// Converts the ULID to a byte array. /// /// A byte array representing the ULID. -#if NET5_0_OR_GREATER - [SkipLocalsInit] -#endif -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] -#else [MethodImpl(MethodImplOptions.AggressiveInlining)] -#endif public byte[] ToByteArray() { var bytes = new byte[_ulidSize]; From 70e01fce21b026f03b803552e005b0007f05fcb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Fri, 9 Oct 2026 22:44:59 +0300 Subject: [PATCH 09/16] Speed up timestamp arithmetic Compute the current timestamp with unsigned division, and read a ULID's timestamp with a single 64-bit load instead of 6 byte loads. --- src/Ulid/Ulid.New.cs | 3 ++- src/Ulid/Ulid.cs | 13 +++---------- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/src/Ulid/Ulid.New.cs b/src/Ulid/Ulid.New.cs index e395db5..a457087 100644 --- a/src/Ulid/Ulid.New.cs +++ b/src/Ulid/Ulid.New.cs @@ -42,7 +42,8 @@ public static Ulid New(ReadOnlySpan bytes) public static Ulid New(GenerationOptions? options = null) // We can avoid Offset-related allocations by using DateTime over DateTimeOffset // For public API, DateTimeOffset is an official recommendation - => New((DateTime.UtcNow.Ticks - _unixEpochTicks) / TimeSpan.TicksPerMillisecond, options); + // Unsigned division is cheaper, the current time is always after the Unix epoch + => New((long)((ulong)(DateTime.UtcNow.Ticks - _unixEpochTicks) / TimeSpan.TicksPerMillisecond), options); /// /// Creates a new with the specified timestamp. diff --git a/src/Ulid/Ulid.cs b/src/Ulid/Ulid.cs index 0c99c52..6fad517 100644 --- a/src/Ulid/Ulid.cs +++ b/src/Ulid/Ulid.cs @@ -101,17 +101,10 @@ public DateTimeOffset Time [MethodImpl(MethodImplOptions.AggressiveInlining)] get { - // Combine the 6 bytes into a 48-bit timestamp (big-endian order) - var time = - ((long)_t0 << 40) | - ((long)_t1 << 32) | - ((long)_t2 << 24) | - ((long)_t3 << 16) | - ((long)_t4 << 8) | - _t5 - ; + // The first 8 bytes in big-endian order hold the 48-bit timestamp in their upper bits + var time = ReverseOnLittleEndian(Unsafe.As(ref Unsafe.AsRef(in this))) >> 16; - return DateTimeOffset.FromUnixTimeMilliseconds(time); + return DateTimeOffset.FromUnixTimeMilliseconds((long)time); } } From e20c7590212ba8569e9177cd550be2b70236430d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Fri, 9 Oct 2026 22:43:34 +0300 Subject: [PATCH 10/16] Use TTAS spinning and pad generator state Spinners no longer steal the lock's cache line with CAS, and the state's cache lines are padded against neighboring objects. --- src/Ulid/LowLatencyLock.cs | 4 +++- src/Ulid/Ulid.New.GenerationOptions.cs | 17 +++++++++++------ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/Ulid/LowLatencyLock.cs b/src/Ulid/LowLatencyLock.cs index 15f237c..26dddc3 100644 --- a/src/Ulid/LowLatencyLock.cs +++ b/src/Ulid/LowLatencyLock.cs @@ -22,10 +22,12 @@ internal void Exit() [MethodImpl(MethodImplOptions.NoInlining)] private static void ContendedEnter(ref int lockState) { + // Test-and-test-and-set: spin on a plain read, which keeps the cache line shared, + // and only attempt the exclusive compare-exchange once the lock looks free. var spinner = new SpinWait(); while (true) { - if (Interlocked.CompareExchange(ref lockState, 1, 0) == 0) + if (Volatile.Read(ref lockState) == 0 && Interlocked.CompareExchange(ref lockState, 1, 0) == 0) { return; } diff --git a/src/Ulid/Ulid.New.GenerationOptions.cs b/src/Ulid/Ulid.New.GenerationOptions.cs index bf23095..61e1838 100644 --- a/src/Ulid/Ulid.New.GenerationOptions.cs +++ b/src/Ulid/Ulid.New.GenerationOptions.cs @@ -1,4 +1,4 @@ -using System.Runtime.CompilerServices; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; namespace ByteAether.Ulid; @@ -146,16 +146,21 @@ public MonotonicityOptions Monotonicity // Separates Lock and LastUlid into different cache lines to prevent "false sharing" // x64 has 64-byte cache lines; ARM64 (e.g., Apple Silicon) has 128-byte cache lines + // The leading and trailing padding keep neighboring heap objects off those cache lines. [StructLayout(LayoutKind.Explicit)] internal class State { - // Cache Line 1 - [FieldOffset(16)] public LowLatencyLock Lock; + private const int _cacheLineSize = 128; + + [FieldOffset(_cacheLineSize)] public LowLatencyLock Lock; - // Cache Line 2(ARM64)/3(x64) #pragma warning disable CS0649 // Field is never assigned to, and will always have its default value - [FieldOffset(16+128)] public ulong LastUlidPart0; - [FieldOffset(16+128+8)] public ulong LastUlidPart1; + [FieldOffset(_cacheLineSize * 2)] public ulong LastUlidPart0; + [FieldOffset(_cacheLineSize * 2 + 8)] public ulong LastUlidPart1; + +#pragma warning disable CS0169 // Padding field is never used + [FieldOffset(_cacheLineSize * 3 + 8)] private readonly ulong _trailingPadding; +#pragma warning restore CS0169 #pragma warning restore CS0649 // Field is never assigned to, and will always have its default value [MethodImpl(MethodImplOptions.AggressiveInlining)] From 9ae7a0a401744666b0b29e90dfd276af70a605a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Fri, 9 Oct 2026 22:44:00 +0300 Subject: [PATCH 11/16] Rework GenerationOptions copying and monotonicity handling with-expressions previously shared the lock and last ULID; copies now get their own state. Monotonicity is validated with a range check instead of reflection, tested against every defined option, and the random increment mask is precomputed when it is set. --- README.md | 1 + src/Ulid.Tests/GenerationOptionsTests.cs | 75 ++++++++++++++++++++++++ src/Ulid/Ulid.New.GenerationOptions.cs | 41 ++++++++++++- src/Ulid/Ulid.New.cs | 6 +- 4 files changed, 115 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 8766b56..b3965ef 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,7 @@ The monotonicity state (last generated timestamp and 80-bit random payload) is b * **Instance Reuse (Recommended for Sequences):** Reusing a single `GenerationOptions` instance across calls guarantees strict, cross-thread monotonic ordering via lock-free atomic compare-and-exchange (CAS) operations. * **Instance Isolation:** Passing a new `GenerationOptions` instance on each call isolates state, disabling monotonic sequence tracking between calls and eliminating CAS contention. +* **Copies:** A copy made with a `with` expression (e.g. `options with { Monotonicity = ... }`) gets its own, fresh state. It never continues or shares the sequence of the instance it was copied from. ```csharp using System; diff --git a/src/Ulid.Tests/GenerationOptionsTests.cs b/src/Ulid.Tests/GenerationOptionsTests.cs index 875a75f..c867cbc 100644 --- a/src/Ulid.Tests/GenerationOptionsTests.cs +++ b/src/Ulid.Tests/GenerationOptionsTests.cs @@ -37,4 +37,79 @@ public void Monotonicity_SetInvalidOption_ShouldThrowArgumentOutOfRangeException var ex = Assert.Throws(() => new GenerationOptions { Monotonicity = invalidMonotonicity }); Assert.Contains("Invalid monotonicity option.", ex.Message); } + + [Fact] + public void Monotonicity_EveryDefinedOption_ShouldBeAccepted() + { + // Fails when an option is added to the enum without updating the validation + foreach (var monotonicity in Enum.GetValues()) + { + // Act + var options = new GenerationOptions { Monotonicity = monotonicity }; + + // Assert + Assert.Equal(monotonicity, options.Monotonicity); + } + } + + [Fact] + public void Monotonicity_ValuesNextToDefinedOptions_ShouldThrow() + { + // Arrange + var definedValues = Enum.GetValues().Select(x => (int)x).ToArray(); + var belowMinimum = (GenerationOptions.MonotonicityOptions)(definedValues.Min() - 1); + var aboveMaximum = (GenerationOptions.MonotonicityOptions)(definedValues.Max() + 1); + + // Act & Assert + Assert.Throws(() => new GenerationOptions { Monotonicity = belowMinimum }); + Assert.Throws(() => new GenerationOptions { Monotonicity = aboveMaximum }); + } + + [Fact] + public void With_ShouldCopyConfiguration() + { + // Arrange + var initialRandomSource = new PseudoRandomProvider(); + var incrementRandomSource = new CryptographicallySecureRandomProvider(); + var options = new GenerationOptions + { + Monotonicity = GenerationOptions.MonotonicityOptions.MonotonicRandom3Byte, + InitialRandomSource = initialRandomSource, + IncrementRandomSource = incrementRandomSource, + }; + + // Act + var copy = options with { }; + + // Assert + Assert.Equal(options.Monotonicity, copy.Monotonicity); + Assert.Same(options.InitialRandomSource, copy.InitialRandomSource); + Assert.Same(options.IncrementRandomSource, copy.IncrementRandomSource); + } + + [Fact] + public void With_ShouldNotShareMonotonicityState() + { + // Arrange + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + var options = new GenerationOptions + { + InitialRandomSource = new FixedRandomProvider(), + }; + var copy = options with { }; + + // Act + var first = New(timestamp, options); + var fromCopy = New(timestamp, copy); + var second = New(timestamp, options); + + // Assert + Assert.Equal(first, fromCopy); // The copy starts its own sequence + Assert.True(second > first); + } + + private sealed class FixedRandomProvider : IRandomProvider + { + public void GetBytes(Span buffer) => buffer.Fill(0x42); + } } \ No newline at end of file diff --git a/src/Ulid/Ulid.New.GenerationOptions.cs b/src/Ulid/Ulid.New.GenerationOptions.cs index 61e1838..d894c69 100644 --- a/src/Ulid/Ulid.New.GenerationOptions.cs +++ b/src/Ulid/Ulid.New.GenerationOptions.cs @@ -107,11 +107,21 @@ public enum MonotonicityOptions public MonotonicityOptions Monotonicity { get; - init => field = Enum.IsDefined(typeof(MonotonicityOptions), value) - ? value - : throw new ArgumentOutOfRangeException(nameof(Monotonicity), value, "Invalid monotonicity option."); + init + { + field = value is >= MonotonicityOptions.NonMonotonic and <= MonotonicityOptions.MonotonicRandom4Byte + ? value + : throw new ArgumentOutOfRangeException(nameof(Monotonicity), value, "Invalid monotonicity option."); + + // Keeps the lowest N bytes of a random increment for the MonotonicRandomNByte options + IncrementMask = value > MonotonicityOptions.MonotonicIncrement + ? (uint)((1UL << ((int)value * 8)) - 1) + : 0; + } } = MonotonicityOptions.MonotonicIncrement; + internal readonly uint IncrementMask; + /// /// Initial random source used for ULID generation. /// @@ -144,6 +154,31 @@ public MonotonicityOptions Monotonicity internal readonly State CurrentState = new(); + /// + /// Initializes a new instance of with default values and its own monotonicity state. + /// + public GenerationOptions() + { + } + + /// + /// Initializes a copy of with its own monotonicity state. + /// + /// + /// This constructor is invoked by with expressions. The copy never shares the monotonicity state + /// (last generated ULID) with , so the two instances generate independent sequences. + /// + /// The instance to copy the configuration from. + protected GenerationOptions(GenerationOptions original) + { + Monotonicity = original.Monotonicity; + InitialRandomSource = original.InitialRandomSource; + IncrementRandomSource = original.IncrementRandomSource; + + // Field initializers do not run for record copy constructors + CurrentState = new(); + } + // Separates Lock and LastUlid into different cache lines to prevent "false sharing" // x64 has 64-byte cache lines; ARM64 (e.g., Apple Silicon) has 128-byte cache lines // The leading and trailing padding keep neighboring heap objects off those cache lines. diff --git a/src/Ulid/Ulid.New.cs b/src/Ulid/Ulid.New.cs index a457087..3363ad0 100644 --- a/src/Ulid/Ulid.New.cs +++ b/src/Ulid/Ulid.New.cs @@ -206,12 +206,8 @@ GenerationOptions.MonotonicityOptions monotonicity // We can use the random bytes of incomplete ULID for the random increment span var tempSpan = CreateSpan(ref ulidBytesRandomRef, sizeof(uint)); options.IncrementRandomSource.GetBytes(tempSpan[..(int)monotonicity]); - var increment = BinaryPrimitives.ReadUInt32LittleEndian(tempSpan); - // The tempSpan may contain garbage, so mask that out - var totalBitsToKeep = (int)monotonicity * 8; - var mask = (uint)((1UL << totalBitsToKeep) - 1); - increment &= mask; + var increment = BinaryPrimitives.ReadUInt32LittleEndian(tempSpan) & options.IncrementMask; state.Increment(increment); } From 10c7c053735b93ab334c1c4c91982999cf2c7e5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Fri, 9 Oct 2026 22:44:35 +0300 Subject: [PATCH 12/16] Keep generator state in numeric order Removes byte swaps from the same-millisecond increment path. --- src/Ulid/Ulid.New.GenerationOptions.cs | 10 +++++----- src/Ulid/Ulid.New.cs | 18 ++++++++---------- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/src/Ulid/Ulid.New.GenerationOptions.cs b/src/Ulid/Ulid.New.GenerationOptions.cs index d894c69..8fb7856 100644 --- a/src/Ulid/Ulid.New.GenerationOptions.cs +++ b/src/Ulid/Ulid.New.GenerationOptions.cs @@ -189,6 +189,7 @@ internal class State [FieldOffset(_cacheLineSize)] public LowLatencyLock Lock; + // The last generated ULID as two numeric (not byte order) halves, so that increments need no byte swaps #pragma warning disable CS0649 // Field is never assigned to, and will always have its default value [FieldOffset(_cacheLineSize * 2)] public ulong LastUlidPart0; [FieldOffset(_cacheLineSize * 2 + 8)] public ulong LastUlidPart1; @@ -221,18 +222,17 @@ internal readonly ref struct Scope internal void Increment(uint addition) { var increment = (ulong)addition + 1; // carry = 1 is built-in - var part1 = ReverseOnLittleEndian(LastUlidPart1); + var part1 = LastUlidPart1; var newPart1 = part1 + increment; - LastUlidPart1 = ReverseOnLittleEndian(newPart1); + LastUlidPart1 = newPart1; if (newPart1 >= part1) { return; } - var part0 = ReverseOnLittleEndian(LastUlidPart0); - part0++; - LastUlidPart0 = ReverseOnLittleEndian(part0); + var part0 = LastUlidPart0 + 1; + LastUlidPart0 = part0; if (part0 == 0) { throw new OverflowException("Addition resulted in a ULID value larger than the absolute maximum ULID value."); diff --git a/src/Ulid/Ulid.New.cs b/src/Ulid/Ulid.New.cs index 3363ad0..30af56a 100644 --- a/src/Ulid/Ulid.New.cs +++ b/src/Ulid/Ulid.New.cs @@ -176,10 +176,8 @@ GenerationOptions.MonotonicityOptions monotonicity using (state.EnterLock()) { - // Read the last timestamp (from bytes 0-7 of "last ULID") - // Shift it to get 48 bits. - var lastTime = ReverseOnLittleEndian(state.LastUlidPart0); - lastTime >>= 16; + // The last timestamp is in the upper 48 bits of the first half of "last ULID" + var lastTime = state.LastUlidPart0 >> 16; // If the timestamp is bigger than the last one, generate a new ULID if (timestamp > (long)lastTime) @@ -190,9 +188,9 @@ GenerationOptions.MonotonicityOptions monotonicity // Generate a new random to the generated ULID options.InitialRandomSource.GetBytes(CreateSpan(ref ulidBytesRandomRef, _ulidSizeRandom)); - // Store the generated ULID as two native-width words in the state. - state.LastUlidPart0 = Unsafe.ReadUnaligned(ref ulidBytesRef); - state.LastUlidPart1 = Unsafe.ReadUnaligned(ref Unsafe.Add(ref ulidBytesRef, sizeof(ulong))); + // Store the generated ULID in the state as two numeric halves. + state.LastUlidPart0 = ReverseOnLittleEndian(Unsafe.ReadUnaligned(ref ulidBytesRef)); + state.LastUlidPart1 = ReverseOnLittleEndian(Unsafe.ReadUnaligned(ref Unsafe.Add(ref ulidBytesRef, sizeof(ulong)))); } else // Otherwise, increment the last ULID { @@ -212,9 +210,9 @@ GenerationOptions.MonotonicityOptions monotonicity state.Increment(increment); } - // Copy the state back as two native-width words. - Unsafe.WriteUnaligned(ref ulidBytesRef, state.LastUlidPart0); - Unsafe.WriteUnaligned(ref Unsafe.Add(ref ulidBytesRef, sizeof(ulong)), state.LastUlidPart1); + // Copy the state back in big-endian byte order. + Unsafe.WriteUnaligned(ref ulidBytesRef, ReverseOnLittleEndian(state.LastUlidPart0)); + Unsafe.WriteUnaligned(ref Unsafe.Add(ref ulidBytesRef, sizeof(ulong)), ReverseOnLittleEndian(state.LastUlidPart1)); } } } From 30e403d052b1e2d5fb4d81ebdeb3caa815120760 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Fri, 9 Oct 2026 22:45:09 +0300 Subject: [PATCH 13/16] Build MinAt/MaxAt/MaxValue without static byte arrays --- src/Ulid/Ulid.Boundaries.cs | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/src/Ulid/Ulid.Boundaries.cs b/src/Ulid/Ulid.Boundaries.cs index d477c06..d8d9080 100644 --- a/src/Ulid/Ulid.Boundaries.cs +++ b/src/Ulid/Ulid.Boundaries.cs @@ -1,3 +1,4 @@ +using System.Runtime.CompilerServices; #if NET7_0_OR_GREATER using System.Numerics; #endif @@ -9,9 +10,6 @@ public readonly partial struct Ulid : IMinMaxValue #endif { - private static readonly byte[] _randomMin = Enumerable.Repeat((byte)0x00, _ulidSizeRandom).ToArray(); - private static readonly byte[] _randomMax = Enumerable.Repeat((byte)0xFF, _ulidSizeRandom).ToArray(); - /// Gets the minimum value of the ULID type. /// /// The field is a ULID with all components set to zero. @@ -26,7 +24,7 @@ public readonly partial struct Ulid /// The field is a ULID where all byte components are set to their highest possible value (0xFF). /// It can be used as a sentinel or boundary value in comparison operations or range validations. /// - public static Ulid MaxValue { get; } = New(Enumerable.Repeat((byte)0xFF, _ulidSize).ToArray()); + public static Ulid MaxValue { get; } = FromParts(ulong.MaxValue, ulong.MaxValue); /// /// Represents an empty ULID value. @@ -43,26 +41,39 @@ public readonly partial struct Ulid /// /// The timestamp used to create the minimum value. /// The minimum value for the given timestamp. - public static Ulid MinAt(long timestamp) => New(timestamp, _randomMin); + public static Ulid MinAt(long timestamp) => FromParts((ulong)timestamp << 16, 0); /// /// Creates the minimum possible value for the specified timestamp. /// /// The timestamp used to create the minimum value. /// The minimum value for the given timestamp. - public static Ulid MinAt(DateTimeOffset datetime) => New(datetime, _randomMin); + public static Ulid MinAt(DateTimeOffset datetime) => MinAt(datetime.ToUnixTimeMilliseconds()); /// /// Creates the maximum possible value for the specified timestamp. /// /// The timestamp used to create the maximum value. /// The maximum value for the given timestamp. - public static Ulid MaxAt(long timestamp) => New(timestamp, _randomMax); + public static Ulid MaxAt(long timestamp) => FromParts(((ulong)timestamp << 16) | 0xFFFF, ulong.MaxValue); /// /// Creates the maximum possible value for the specified timestamp. /// /// The timestamp used to create the maximum value. /// The maximum value for the given timestamp. - public static Ulid MaxAt(DateTimeOffset datetime) => New(datetime, _randomMax); + public static Ulid MaxAt(DateTimeOffset datetime) => MaxAt(datetime.ToUnixTimeMilliseconds()); + + // Creates a ULID from its two numeric 64-bit halves + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static Ulid FromParts(ulong part0, ulong part1) + { + Unsafe.SkipInit(out Ulid ulid); + + ref var ulidRef = ref Unsafe.As(ref ulid); + Unsafe.WriteUnaligned(ref ulidRef, ReverseOnLittleEndian(part0)); + Unsafe.WriteUnaligned(ref Unsafe.Add(ref ulidRef, sizeof(ulong)), ReverseOnLittleEndian(part1)); + + return ulid; + } } \ No newline at end of file From 6bf874d4af0a0b1cb603d4afc67156d6c3f7c55d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Fri, 9 Oct 2026 23:57:24 +0300 Subject: [PATCH 14/16] Speed up Base32 encoding and decoding table access Encode from two 64-bit loads instead of 16 byte loads, and store the inverse Base32 table as RVA data. --- src/Ulid.Tests/Ulid.String.Tests.cs | 46 +++++++++++++++++++ src/Ulid/Ulid.IsValid.cs | 2 +- src/Ulid/Ulid.String.cs | 68 +++++++++++++++-------------- 3 files changed, 83 insertions(+), 33 deletions(-) diff --git a/src/Ulid.Tests/Ulid.String.Tests.cs b/src/Ulid.Tests/Ulid.String.Tests.cs index f026f3b..df5df7a 100644 --- a/src/Ulid.Tests/Ulid.String.Tests.cs +++ b/src/Ulid.Tests/Ulid.String.Tests.cs @@ -300,6 +300,52 @@ public void Parse_LowerCase_ShouldEqualUpperCase() Assert.Equal(_goodUlidString, lowerUtf8.ToString()); } + [Fact] + public void ToString_RandomValues_ShouldRoundTrip() + { + // Arrange + var random = new Random(42); + var bytes = new byte[16]; + Span utf8 = stackalloc byte[Ulid.UlidStringLength]; + + for (var i = 0; i < 10_000; i++) + { + random.NextBytes(bytes); + var ulid = Ulid.New(bytes); + + // Act + var str = ulid.ToString(); + ulid.TryFormat(utf8, out _, []); + + // Assert + Assert.Equal(str, Encoding.ASCII.GetString(utf8.ToArray())); + Assert.Equal(ulid, Ulid.Parse(str)); + Assert.Equal(ulid, Ulid.Parse(utf8)); + } + } + + [Fact] + public void TryParse_ShouldNotAllocate() + { + // Arrange + var bytes = Encoding.UTF8.GetBytes(_goodUlidString); + Ulid.TryParse(_goodUlidString.AsSpan(), null, out _); + Ulid.TryParse(bytes, null, out _); + + // Act + var before = GC.GetAllocatedBytesForCurrentThread(); + for (var i = 0; i < 100; i++) + { + Ulid.TryParse(_goodUlidString.AsSpan(), null, out _); + Ulid.TryParse(bytes, null, out _); + Ulid.TryParse("not a ulid".AsSpan(), null, out _); + } + var allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + // Assert + Assert.Equal(0, allocated); + } + [Fact] public void ToString_WrongLetters_ShouldReplaceWithCorrect() { diff --git a/src/Ulid/Ulid.IsValid.cs b/src/Ulid/Ulid.IsValid.cs index 69f8196..5ed5ed4 100644 --- a/src/Ulid/Ulid.IsValid.cs +++ b/src/Ulid/Ulid.IsValid.cs @@ -43,7 +43,7 @@ public static unsafe bool IsValid(ReadOnlySpan ulidString) { // 1. Fast check for the first character (prevent 128-bit overflow) uint c0 = src[0]; - if (c0 > 255 || _inverseBase32[c0] > 7) + if (c0 > 255 || _inverseBase32[(int)c0] > 7) { return false; } diff --git a/src/Ulid/Ulid.String.cs b/src/Ulid/Ulid.String.cs index b23cd5a..151188b 100644 --- a/src/Ulid/Ulid.String.cs +++ b/src/Ulid/Ulid.String.cs @@ -34,7 +34,8 @@ public readonly partial struct Ulid private static readonly char[] _base32Chars = "0123456789ABCDEFGHJKMNPQRSTVWXYZ".ToCharArray(); private static readonly byte[] _base32Bytes = Encoding.UTF8.GetBytes(_base32Chars); - private static readonly byte[] _inverseBase32 = + // Stored as RVA static data: no static field load, no initialization check and a constant length + private static ReadOnlySpan _inverseBase32 => [ 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, // controls 255, // space @@ -193,7 +194,7 @@ private static Ulid ParseCore(ReadOnlySpan input, out bool isValid) } ref var src = ref Unsafe.As(ref MemoryMarshal.GetReference(input)); - ref var tableRef = ref _inverseBase32[0]; + ref var tableRef = ref MemoryMarshal.GetReference(_inverseBase32); // The 128-bit value is built as 2 big-endian 64-bit blocks (a | b and c | d), then the endianness is reversed. // Every character is decoded and accumulated right away, which keeps the register pressure low, @@ -311,7 +312,7 @@ private static Ulid ParseCore(ReadOnlySpan input, out bool isValid) // Non-ASCII bytes (0x80 and above) are mapped to an invalid value in the table ref var src = ref MemoryMarshal.GetReference(input); - ref var tableRef = ref _inverseBase32[0]; + ref var tableRef = ref MemoryMarshal.GetReference(_inverseBase32); // The 128-bit value is built as 2 big-endian 64-bit blocks (a | b and c | d), then the endianness is reversed. // Every character is decoded and accumulated right away, which keeps the register pressure low, @@ -549,35 +550,38 @@ public bool TryFormat( private void Fill(Span span, T[] map) where T: unmanaged { - // Encode randomness - span[25] = map[_r9 & 0x1F]; // [11111111][11111111][11111111][11111111][11111111][11111111][11111111][11111111][11111111][111|11111|] - span[24] = map[((_r8 & 0x3) << 3) | (_r9 >> 5)]; // [11111111][11111111][11111111][11111111][11111111][11111111][11111111][11111111][111111|11][111|11111] - span[23] = map[(_r8 >> 2) & 0x1F]; // [11111111][11111111][11111111][11111111][11111111][11111111][11111111][11111111][1|11111|11][11111111] - span[22] = map[((_r7 & 0xF) << 1) | (_r8 >> 7)]; // [11111111][11111111][11111111][11111111][11111111][11111111][11111111][1111|1111][1|1111111][11111111] - span[21] = map[((_r6 & 0x1) << 4) | (_r7 >> 4)]; // [11111111][11111111][11111111][11111111][11111111][11111111][1111111|1][1111|1111][11111111][11111111] - span[20] = map[(_r6 >> 1) & 0x1F]; // [11111111][11111111][11111111][11111111][11111111][11111111][11|11111|1][11111111][11111111][11111111] - span[19] = map[((_r5 & 0x7) << 2) | (_r6 >> 6)]; // [11111111][11111111][11111111][11111111][11111111][11111|111][11|111111][11111111][11111111][11111111] - span[18] = map[(_r5 >> 3) & 0x1F]; // [11111111][11111111][11111111][11111111][11111111][|11111|111][11111111][11111111][11111111][11111111] - span[17] = map[_r4 & 0x1F]; // [11111111][11111111][11111111][11111111][111|11111|][11111111][11111111][11111111][11111111][11111111] - span[16] = map[((_r3 & 0x3) << 3) | (_r4 >> 5)]; // [11111111][11111111][11111111][11111111][111111|11][111|11111][11111111][11111111][11111111][11111111] - span[15] = map[(_r3 >> 2) & 0x1F]; // [11111111][11111111][11111111][11111111][11111111][11111111][11111111][11111111][11111111][11111111] - span[14] = map[((_r2 & 0xF) << 1) | (_r3 >> 7)]; // [11111111][11111111][11111111][11111111][11111111][11111111][11111111][11111111][11111111][11111111] - span[13] = map[((_r1 & 0x1) << 4) | (_r2 >> 4)]; // [11111111][11111111][11111111][11111111][11111111][11111111][11111111][11111111][11111111][11111111] - span[12] = map[(_r1 >> 1) & 0x1F]; // [11111111][11111111][11111111][11111111][11111111][11111111][11111111][11111111][11111111][11111111] - span[11] = map[((_r0 & 0x7) << 2) | (_r1 >> 6)]; // [11111111][11111111][11111111][11111111][11111111][11111111][11111111][11111111][11111111][11111111] - span[10] = map[(_r0 >> 3) & 0x1F]; // [|11111|111][11111111][11111111][11111111][11111111][11111111][11111111][11111111][11111111][11111111] - - // Encode timestamp - span[9] = map[_t5 & 0x1F]; // 00[11111111][11111111][11111111][11111111][11111111][111|11111|] - span[8] = map[((_t4 & 0x3) << 3) | (_t5 >> 5)]; // 00[11111111][11111111][11111111][11111111][111111|11][111|11111] - span[7] = map[(_t4 >> 2) & 0x1F]; // 00[11111111][11111111][11111111][11111111][1|11111|11][11111111] - span[6] = map[((_t3 & 0xF) << 1) | (_t4 >> 7)]; // 00[11111111][11111111][11111111][1111|1111][1|1111111][11111111] - span[5] = map[((_t2 & 0x1) << 4) | (_t3 >> 4)]; // 00[11111111][11111111][1111111|1][1111|1111][11111111][11111111] - span[4] = map[(_t2 >> 1) & 0x1F]; // 00[11111111][11111111][11|11111|1][11111111][11111111][11111111] - span[3] = map[((_t1 & 0x7) << 2) | (_t2 >> 6)]; // 00[11111111][11111|111][11|111111][11111111][11111111][11111111] - span[2] = map[_t1 >> 3]; // 00[11111111][|11111|111][11111111][11111111][11111111][11111111] - span[1] = map[_t0 & 0x1F]; // 00[111|11111|][11111111][11111111][11111111][11111111][11111111] - span[0] = map[_t0 >> 5]; // |00[111|11111][11111111][11111111][11111111][11111111][11111111] + // Load the ULID as two big-endian 64-bit halves and extract the 5-bit groups with shifts + ref var ulidRef = ref Unsafe.As(ref Unsafe.AsRef(in this)); + var hi = ReverseOnLittleEndian(Unsafe.ReadUnaligned(ref ulidRef)); + var lo = ReverseOnLittleEndian(Unsafe.ReadUnaligned(ref Unsafe.Add(ref ulidRef, sizeof(ulong)))); + + // Writing the last element first removes the bounds checks of the other elements + span[25] = map[(int)lo & 0x1F]; + span[24] = map[(int)(lo >> 5) & 0x1F]; + span[23] = map[(int)(lo >> 10) & 0x1F]; + span[22] = map[(int)(lo >> 15) & 0x1F]; + span[21] = map[(int)(lo >> 20) & 0x1F]; + span[20] = map[(int)(lo >> 25) & 0x1F]; + span[19] = map[(int)(lo >> 30) & 0x1F]; + span[18] = map[(int)(lo >> 35) & 0x1F]; + span[17] = map[(int)(lo >> 40) & 0x1F]; + span[16] = map[(int)(lo >> 45) & 0x1F]; + span[15] = map[(int)(lo >> 50) & 0x1F]; + span[14] = map[(int)(lo >> 55) & 0x1F]; + span[13] = map[(int)((hi << 4) | (lo >> 60)) & 0x1F]; // Lowest bit of hi, highest 4 bits of lo + span[12] = map[(int)(hi >> 1) & 0x1F]; + span[11] = map[(int)(hi >> 6) & 0x1F]; + span[10] = map[(int)(hi >> 11) & 0x1F]; + span[9] = map[(int)(hi >> 16) & 0x1F]; + span[8] = map[(int)(hi >> 21) & 0x1F]; + span[7] = map[(int)(hi >> 26) & 0x1F]; + span[6] = map[(int)(hi >> 31) & 0x1F]; + span[5] = map[(int)(hi >> 36) & 0x1F]; + span[4] = map[(int)(hi >> 41) & 0x1F]; + span[3] = map[(int)(hi >> 46) & 0x1F]; + span[2] = map[(int)(hi >> 51) & 0x1F]; + span[1] = map[(int)(hi >> 56) & 0x1F]; + span[0] = map[(int)(hi >> 61)]; } /// From 7ea3c8a89be9952d41799589b9dc647cd1bf02af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Fri, 9 Oct 2026 23:58:04 +0300 Subject: [PATCH 15/16] Validate UTF-8 text in IsValid(ReadOnlySpan) It only checked for a 16-byte length, unlike Parse/TryParse on the same type. Document the validation rules on IsValid. --- README.md | 4 +-- src/Ulid.Tests/Ulid.IsValid.cs | 46 ++++++++++++++++-------- src/Ulid.Tests/Ulid.String.Tests.cs | 1 + src/Ulid/PACKAGE.md | 4 +-- src/Ulid/Ulid.IsValid.cs | 54 ++++++++++++++++++++++++++--- 5 files changed, 86 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index b3965ef..cf42d15 100644 --- a/README.md +++ b/README.md @@ -233,8 +233,8 @@ The `Ulid` implementation provides the following properties and methods: Validates whether the specified string represents a valid ULID. - `Ulid.IsValid(ReadOnlySpan ulidString)`\ Validates whether the specified span of characters represents a valid ULID. -- `Ulid.IsValid(ReadOnlySpan ulidBytes)`\ - Validates whether the specified byte array represents a valid ULID. +- `Ulid.IsValid(ReadOnlySpan utf8Text)`\ + Validates whether the specified UTF-8 encoded byte span represents a valid ULID string. A valid ULID string is exactly 26 characters of [Crockford's Base32](https://www.crockford.com/base32.html) alphabet, read case-insensitively, with `I` and `L` accepted as `1`, and `O` as `0`. The first character must be between `0` and `7`, as larger values do not fit into 128 bits. diff --git a/src/Ulid.Tests/Ulid.IsValid.cs b/src/Ulid.Tests/Ulid.IsValid.cs index ee2e612..eb8fc5f 100644 --- a/src/Ulid.Tests/Ulid.IsValid.cs +++ b/src/Ulid.Tests/Ulid.IsValid.cs @@ -1,4 +1,6 @@ -namespace ByteAether.Ulid.Tests; +using System.Text; + +namespace ByteAether.Ulid.Tests; public class UlidIsValidTests { @@ -64,30 +66,46 @@ public void IsValid_BadStringSpan(string badString) Assert.False(isValid); } - [Fact] - public void IsValid_GoodByteArray() + [Theory] + [InlineData("01AN4Z07BY79KA1307SR9X4MV3")] + [InlineData("00000000000000000000000000")] + [InlineData("oooooooooooooooooooooooooo")] + [InlineData("7ZZZZZZZZZZZZZZZZZZZZZZZZZ")] + public void IsValid_GoodUtf8(string goodString) { - // Arrange - var validUlidBytes = new byte[16]; - // Act - var result = Ulid.IsValid(new ReadOnlySpan(validUlidBytes)); + var isValid = Ulid.IsValid(Encoding.UTF8.GetBytes(goodString).AsSpan()); // Assert - Assert.True(result); + Assert.True(isValid); } [Theory] - [InlineData(0)] - [InlineData(15)] - [InlineData(17)] - public void IsValid_BadByteArray(int length) + [InlineData("80000000000000000000000000")] + [InlineData("ZZZZZZZZZZZZZZZZZZZZZZZZZZ")] + [InlineData("")] + [InlineData("01AN4Z07BY79KA1307SR9X4MV")] + [InlineData("01AN4Z07BY79KA1307SR9X4MV3A")] + [InlineData("01AN4Z07BY79KA1307SR9X4MVU")] + [InlineData("01AN4Z07BY79KA1307SR9X4MV@")] + [InlineData("01AN4Z07BY79KA1307SR9X4M\u00C0")] + public void IsValid_BadUtf8(string badString) + { + // Act + var isValid = Ulid.IsValid(Encoding.UTF8.GetBytes(badString).AsSpan()); + + // Assert + Assert.False(isValid); + } + + [Fact] + public void IsValid_BinaryUlidBytes_ShouldReturnFalse() { // Arrange - var validUlidBytes = new byte[length]; + var binaryUlid = Ulid.New().ToByteArray(); // Act - var result = Ulid.IsValid(new ReadOnlySpan(validUlidBytes)); + var result = Ulid.IsValid(new ReadOnlySpan(binaryUlid)); // Assert Assert.False(result); diff --git a/src/Ulid.Tests/Ulid.String.Tests.cs b/src/Ulid.Tests/Ulid.String.Tests.cs index df5df7a..075ca1c 100644 --- a/src/Ulid.Tests/Ulid.String.Tests.cs +++ b/src/Ulid.Tests/Ulid.String.Tests.cs @@ -258,6 +258,7 @@ public void TryParse_InvalidCharacter_ShouldReturnFalse(int position, char inval var bytes = chars.Select(x => (byte)x).ToArray(); Assert.False(Ulid.TryParse(bytes, null, out _)); Assert.Throws(() => Ulid.Parse(bytes)); + Assert.False(Ulid.IsValid(bytes)); } } diff --git a/src/Ulid/PACKAGE.md b/src/Ulid/PACKAGE.md index 247217c..1b0f8bd 100644 --- a/src/Ulid/PACKAGE.md +++ b/src/Ulid/PACKAGE.md @@ -112,8 +112,8 @@ The `Ulid` implementation provides the following properties and methods: Validates whether the specified string represents a valid ULID. - `Ulid.IsValid(ReadOnlySpan ulidString)`\ Validates whether the specified span of characters represents a valid ULID. -- `Ulid.IsValid(ReadOnlySpan ulidBytes)`\ - Validates whether the specified byte array represents a valid ULID. +- `Ulid.IsValid(ReadOnlySpan utf8Text)`\ + Validates whether the specified UTF-8 encoded byte span represents a valid ULID string. A valid ULID string is exactly 26 characters of [Crockford's Base32](https://www.crockford.com/base32.html) alphabet, read case-insensitively, with `I` and `L` accepted as `1`, and `O` as `0`. The first character must be between `0` and `7`, as larger values do not fit into 128 bits. diff --git a/src/Ulid/Ulid.IsValid.cs b/src/Ulid/Ulid.IsValid.cs index 5ed5ed4..e5efc48 100644 --- a/src/Ulid/Ulid.IsValid.cs +++ b/src/Ulid/Ulid.IsValid.cs @@ -113,12 +113,56 @@ public static unsafe bool IsValid(ReadOnlySpan ulidString) } /// - /// Validates if the given byte array represents a valid ULID. + /// Validates if the given UTF-8 encoded span of bytes is a valid ULID string. /// - /// The byte array to validate. + /// The UTF-8 encoded ULID string to validate. /// - /// true if the byte array is a valid ULID, false otherwise. + /// true if the byte span contains a valid ULID string, false otherwise. /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static bool IsValid(ReadOnlySpan ulidBytes) => ulidBytes.Length == _ulidSize; + /// + /// The input must be exactly 26 characters of Crockford's Base32 alphabet. It is read case-insensitively, with + /// I and L accepted as 1, and O as 0. The first character must be between + /// 0 and 7, otherwise the value would not fit into 128 bits. + /// + public static unsafe bool IsValid(ReadOnlySpan utf8Text) + { + if (utf8Text.Length != UlidStringLength) // 26 + { + return false; + } + + // Non-ASCII bytes (0x80 and above) are mapped to 255 in the table + fixed (byte* src = &MemoryMarshal.GetReference(utf8Text)) + fixed (byte* table = _inverseBase32) + { + // Fast check for the first character (prevent 128-bit overflow) + if (table[src[0]] > 7) + { + return false; + } + + if ( + table[src[1]] == 255 || table[src[2]] == 255 || table[src[3]] == 255 + || table[src[4]] == 255 || table[src[5]] == 255 || table[src[6]] == 255 + || table[src[7]] == 255 || table[src[8]] == 255 || table[src[9]] == 255 + || table[src[10]] == 255 || table[src[11]] == 255 || table[src[12]] == 255 + ) + { + return false; + } + + if ( + table[src[13]] == 255 || table[src[14]] == 255 || table[src[15]] == 255 + || table[src[16]] == 255 || table[src[17]] == 255 || table[src[18]] == 255 + || table[src[19]] == 255 || table[src[20]] == 255 || table[src[21]] == 255 + || table[src[22]] == 255 || table[src[23]] == 255 || table[src[24]] == 255 + || table[src[25]] == 255 + ) + { + return false; + } + } + + return true; + } } \ No newline at end of file From ed5370ad654687771fbcb0f4f5e983610eff6402 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Sat, 10 Oct 2026 15:21:48 +0300 Subject: [PATCH 16/16] Fresh benchmark run, updated README.md. --- README.md | 132 +++++++++++++++++++++++++++--------------------------- 1 file changed, 66 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index cf42d15..2b8cf13 100644 --- a/README.md +++ b/README.md @@ -674,72 +674,72 @@ Job=DefaultJob | Type | Method | Mean | Error | Gen0 | Allocated | |---------------- |------------------- |------------:|----------:|-------:|----------:| -| Generate | ByteAetherUlid | 38.4429 ns | 0.1005 ns | - | - | -| Generate | ByteAetherUlidR1Bp | 46.7557 ns | 0.1100 ns | - | - | -| Generate | ByteAetherUlidR4Bp | 48.3786 ns | 0.1072 ns | - | - | -| Generate | ByteAetherUlidR1Bc | 80.0063 ns | 0.2238 ns | - | - | -| Generate | ByteAetherUlidR4Bc | 87.7467 ns | 0.2226 ns | - | - | -| Generate | NetUlid *(1) | 159.7123 ns | 0.4390 ns | 0.0095 | 80 B | -| Generate | NUlid *(2) | 49.1889 ns | 0.0990 ns | - | - | - -| GenerateNonMono | ByteAetherUlid | 88.3031 ns | 0.2193 ns | - | - | -| GenerateNonMono | ByteAetherUlidP | 41.1218 ns | 0.1085 ns | - | - | -| GenerateNonMono | Ulid *(3,4) | 38.0705 ns | 0.0841 ns | - | - | -| GenerateNonMono | NUlid | 93.6811 ns | 0.1897 ns | - | - | -| GenerateNonMono | Guid *(5) | 48.6814 ns | 0.1394 ns | - | - | -| GenerateNonMono | GuidV7 *(3,5) | 79.1241 ns | 0.2201 ns | - | - | - -| FromByteArray | ByteAetherUlid | 0.7968 ns | 0.0067 ns | - | - | -| FromByteArray | NetUlid | 1.7080 ns | 0.0080 ns | - | - | -| FromByteArray | Ulid | 1.1822 ns | 0.0105 ns | - | - | -| FromByteArray | NUlid | 1.1613 ns | 0.0085 ns | - | - | -| FromByteArray | Guid | 1.0453 ns | 0.0065 ns | - | - | - -| FromGuid | ByteAetherUlid | 0.7992 ns | 0.0089 ns | - | - | -| FromGuid | NetUlid | 1.9640 ns | 0.0159 ns | - | - | -| FromGuid | Ulid | 1.9993 ns | 0.0364 ns | - | - | -| FromGuid | NUlid | 1.0107 ns | 0.0081 ns | - | - | - -| FromString | ByteAetherUlid | 14.5914 ns | 0.0334 ns | - | - | -| FromString | NetUlid | 27.2466 ns | 0.1181 ns | - | - | -| FromString | Ulid | 17.1977 ns | 0.0523 ns | - | - | -| FromString | NUlid | 50.1281 ns | 0.3708 ns | 0.0086 | 72 B | -| FromString | Guid | 21.7563 ns | 0.0550 ns | - | - | - -| ToByteArray | ByteAetherUlid | 4.5509 ns | 0.1130 ns | 0.0048 | 40 B | -| ToByteArray | AsByteSpan *(6) | 0.7737 ns | 0.0084 ns | - | - | -| ToByteArray | NetUlid | 9.8672 ns | 0.1395 ns | 0.0048 | 40 B | -| ToByteArray | Ulid | 4.2313 ns | 0.0324 ns | 0.0048 | 40 B | -| ToByteArray | NUlid | 8.5998 ns | 0.1060 ns | 0.0048 | 40 B | - -| ToGuid | ByteAetherUlid | 0.7951 ns | 0.0088 ns | - | - | -| ToGuid | NetUlid | 8.9098 ns | 0.0496 ns | - | - | -| ToGuid | Ulid | 1.2389 ns | 0.0132 ns | - | - | -| ToGuid | NUlid | 0.7842 ns | 0.0059 ns | - | - | - -| ToString | ByteAetherUlid | 19.1704 ns | 0.2129 ns | 0.0095 | 80 B | -| ToString | NetUlid | 23.5829 ns | 0.2413 ns | 0.0095 | 80 B | -| ToString | Ulid | 19.5133 ns | 0.1625 ns | 0.0095 | 80 B | -| ToString | NUlid | 30.6928 ns | 0.1726 ns | 0.0095 | 80 B | -| ToString | Guid | 8.5533 ns | 0.2321 ns | 0.0115 | 96 B | - -| CompareTo | ByteAetherUlid | 2.0684 ns | 0.0243 ns | - | - | -| CompareTo | NetUlid | 4.1654 ns | 0.0136 ns | - | - | -| CompareTo | Ulid | 6.6872 ns | 0.0348 ns | - | - | -| CompareTo | NUlid | 9.1807 ns | 0.0463 ns | - | - | -| CompareTo | Guid | 4.8127 ns | 0.0138 ns | - | - | - -| Equals | ByteAetherUlid | 1.1008 ns | 0.0114 ns | - | - | -| Equals | NetUlid | 2.0444 ns | 0.0402 ns | - | - | -| Equals | Ulid | 1.0745 ns | 0.0106 ns | - | - | -| Equals | NUlid | 1.0961 ns | 0.0072 ns | - | - | -| Equals | Guid | 1.0809 ns | 0.0070 ns | - | - | - -| GetHashCode | ByteAetherUlid | 0.9083 ns | 0.0083 ns | - | - | -| GetHashCode | NetUlid | 8.9188 ns | 0.0272 ns | - | - | -| GetHashCode | Ulid | 0.9349 ns | 0.0095 ns | - | - | -| GetHashCode | NUlid | 6.7673 ns | 0.0399 ns | - | - | -| GetHashCode | Guid | 0.9332 ns | 0.0105 ns | - | - | +| Generate | ByteAetherUlid | 38.1463 ns | 0.0679 ns | - | - | +| Generate | ByteAetherUlidR1Bp | 40.3767 ns | 0.1023 ns | - | - | +| Generate | ByteAetherUlidR4Bp | 45.8267 ns | 0.1110 ns | - | - | +| Generate | ByteAetherUlidR1Bc | 84.1466 ns | 0.4368 ns | - | - | +| Generate | ByteAetherUlidR4Bc | 87.0893 ns | 0.3336 ns | - | - | +| Generate | NetUlid *(1) | 159.8722 ns | 0.8177 ns | 0.0095 | 80 B | +| Generate | NUlid *(2) | 49.0189 ns | 0.1703 ns | - | - | + +| GenerateNonMono | ByteAetherUlid | 86.2648 ns | 0.4680 ns | - | - | +| GenerateNonMono | ByteAetherUlidP | 37.8072 ns | 0.1707 ns | - | - | +| GenerateNonMono | Ulid *(3,4) | 39.4395 ns | 0.2104 ns | - | - | +| GenerateNonMono | NUlid | 93.3513 ns | 0.4815 ns | - | - | +| GenerateNonMono | Guid *(5) | 48.3969 ns | 0.2306 ns | - | - | +| GenerateNonMono | GuidV7 *(3,5) | 79.1905 ns | 0.2090 ns | - | - | + +| FromByteArray | ByteAetherUlid | 0.6981 ns | 0.0093 ns | - | - | +| FromByteArray | NetUlid | 1.7206 ns | 0.0174 ns | - | - | +| FromByteArray | Ulid | 1.2139 ns | 0.0160 ns | - | - | +| FromByteArray | NUlid | 1.1370 ns | 0.0118 ns | - | - | +| FromByteArray | Guid | 1.0113 ns | 0.0072 ns | - | - | + +| FromGuid | ByteAetherUlid | 0.6360 ns | 0.0117 ns | - | - | +| FromGuid | NetUlid | 1.7273 ns | 0.0226 ns | - | - | +| FromGuid | Ulid | 1.8704 ns | 0.0426 ns | - | - | +| FromGuid | NUlid | 0.8229 ns | 0.0090 ns | - | - | + +| FromString | ByteAetherUlid | 15.7206 ns | 0.1081 ns | - | - | +| FromString | NetUlid | 27.4088 ns | 0.0853 ns | - | - | +| FromString | Ulid | 17.2080 ns | 0.0880 ns | - | - | +| FromString | NUlid | 49.9503 ns | 0.2598 ns | 0.0086 | 72 B | +| FromString | Guid | 21.7319 ns | 0.1089 ns | - | - | + +| ToByteArray | ByteAetherUlid | 4.2862 ns | 0.1354 ns | 0.0048 | 40 B | +| ToByteArray | AsByteSpan *(6) | 0.6701 ns | 0.0074 ns | - | - | +| ToByteArray | NetUlid | 9.5231 ns | 0.1255 ns | 0.0048 | 40 B | +| ToByteArray | Ulid | 4.4401 ns | 0.1018 ns | 0.0048 | 40 B | +| ToByteArray | NUlid | 8.5761 ns | 0.0941 ns | 0.0048 | 40 B | + +| ToGuid | ByteAetherUlid | 0.6665 ns | 0.0121 ns | - | - | +| ToGuid | NetUlid | 8.7326 ns | 0.0263 ns | - | - | +| ToGuid | Ulid | 1.2456 ns | 0.0287 ns | - | - | +| ToGuid | NUlid | 0.7064 ns | 0.0096 ns | - | - | + +| ToString | ByteAetherUlid | 14.5372 ns | 0.2029 ns | 0.0095 | 80 B | +| ToString | NetUlid | 22.8099 ns | 0.1848 ns | 0.0095 | 80 B | +| ToString | Ulid | 19.6863 ns | 0.4276 ns | 0.0095 | 80 B | +| ToString | NUlid | 30.7311 ns | 0.2561 ns | 0.0095 | 80 B | +| ToString | Guid | 8.5187 ns | 0.2234 ns | 0.0115 | 96 B | + +| GetHashCode | ByteAetherUlid | 0.8390 ns | 0.0066 ns | - | - | +| GetHashCode | NetUlid | 8.7579 ns | 0.0287 ns | - | - | +| GetHashCode | Ulid | 0.8428 ns | 0.0099 ns | - | - | +| GetHashCode | NUlid | 6.6177 ns | 0.0360 ns | - | - | +| GetHashCode | Guid | 0.7400 ns | 0.0150 ns | - | - | + +| CompareTo | ByteAetherUlid | 1.3273 ns | 0.0215 ns | - | - | +| CompareTo | NetUlid | 4.0820 ns | 0.0202 ns | - | - | +| CompareTo | Ulid | 6.8477 ns | 0.0239 ns | - | - | +| CompareTo | NUlid | 9.5116 ns | 0.0626 ns | - | - | +| CompareTo | Guid | 4.9821 ns | 0.0270 ns | - | - | + +| Equals | ByteAetherUlid | 0.9571 ns | 0.0100 ns | - | - | +| Equals | NetUlid | 1.9630 ns | 0.0140 ns | - | - | +| Equals | Ulid | 0.9663 ns | 0.0121 ns | - | - | +| Equals | NUlid | 0.9564 ns | 0.0077 ns | - | - | +| Equals | Guid | 0.9610 ns | 0.0105 ns | - | - | ``` Alternative .NET ecosystem solutions exhibit design constraints or spec deviations under heavy production loads: