From 5a1a8f885f90b069d4beb4a59f75f5c0803e362d Mon Sep 17 00:00:00 2001 From: aevitas Date: Sat, 6 Sep 2025 12:54:42 +0800 Subject: [PATCH 1/5] NET versions and housekeeping --- src/FlakeId.Benchmarks/FlakeId.Benchmarks.csproj | 2 +- .../FlakeIdMultipleRuntimesBenchmarks.cs | 4 ++++ src/FlakeId.Benchmarks/IdCreationBenchmarks.cs | 4 +++- src/FlakeId.Tests/FlakeId.Tests.csproj | 3 ++- src/FlakeId/FlakeId.csproj | 2 +- src/FlakeId/Id.cs | 14 +++++++------- 6 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/FlakeId.Benchmarks/FlakeId.Benchmarks.csproj b/src/FlakeId.Benchmarks/FlakeId.Benchmarks.csproj index eb886d6..4e7902b 100644 --- a/src/FlakeId.Benchmarks/FlakeId.Benchmarks.csproj +++ b/src/FlakeId.Benchmarks/FlakeId.Benchmarks.csproj @@ -2,7 +2,7 @@ Exe - net8.0;net7.0;net6.0 + net6.0;net7.0;net8.0;net9.0 diff --git a/src/FlakeId.Benchmarks/FlakeIdMultipleRuntimesBenchmarks.cs b/src/FlakeId.Benchmarks/FlakeIdMultipleRuntimesBenchmarks.cs index 08412c7..41ff958 100644 --- a/src/FlakeId.Benchmarks/FlakeIdMultipleRuntimesBenchmarks.cs +++ b/src/FlakeId.Benchmarks/FlakeIdMultipleRuntimesBenchmarks.cs @@ -1,5 +1,6 @@ using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Diagnostics.Windows.Configs; +using BenchmarkDotNet.Environments; using BenchmarkDotNet.Jobs; namespace FlakeId.Benchmarks @@ -8,8 +9,11 @@ namespace FlakeId.Benchmarks [SimpleJob(RuntimeMoniker.Net60)] [SimpleJob(RuntimeMoniker.Net70)] [SimpleJob(RuntimeMoniker.Net80)] + [SimpleJob(RuntimeMoniker.Net90)] +#if _WINDOWS [DisassemblyDiagnoser] [InliningDiagnoser(true, null)] +#endif public class FlakeIdMultipleRuntimesBenchmarks { [Benchmark] diff --git a/src/FlakeId.Benchmarks/IdCreationBenchmarks.cs b/src/FlakeId.Benchmarks/IdCreationBenchmarks.cs index a423d69..a04ec65 100644 --- a/src/FlakeId.Benchmarks/IdCreationBenchmarks.cs +++ b/src/FlakeId.Benchmarks/IdCreationBenchmarks.cs @@ -6,8 +6,10 @@ namespace FlakeId.Benchmarks { +#if _WINDOWS [DisassemblyDiagnoser] [InliningDiagnoser(true, null)] +#endif public class IdCreationBenchmarks { private static readonly IdGenerator s_idGenerator = new IdGenerator(10, @@ -30,7 +32,7 @@ public void Single_NewId() { NewId.Next(); } - + [Benchmark] public void Single_IdGen() { diff --git a/src/FlakeId.Tests/FlakeId.Tests.csproj b/src/FlakeId.Tests/FlakeId.Tests.csproj index 19a4233..bc8e202 100644 --- a/src/FlakeId.Tests/FlakeId.Tests.csproj +++ b/src/FlakeId.Tests/FlakeId.Tests.csproj @@ -1,9 +1,10 @@ - net8.0 false + + net9.0 diff --git a/src/FlakeId/FlakeId.csproj b/src/FlakeId/FlakeId.csproj index ae6a97c..11611fd 100644 --- a/src/FlakeId/FlakeId.csproj +++ b/src/FlakeId/FlakeId.csproj @@ -1,7 +1,7 @@ - net8.0;netstandard2.0 + netstandard2.0;net9.0 true Discord inspired, Twitter like implementation of Snowflake IDs, focused on performance. Snowflakes are 64 bit, highly optimized, decentralized, K-ordered, unique identifiers. https://github.com/aevitas/flakeid diff --git a/src/FlakeId/Id.cs b/src/FlakeId/Id.cs index 2f32f7d..c322654 100644 --- a/src/FlakeId/Id.cs +++ b/src/FlakeId/Id.cs @@ -21,7 +21,7 @@ public struct Id : IComparable // 111111111111111111111111111111111111111111 11111 11111 111111111111 // 64 22 17 12 0 // - // The Timestamp component is represented as the milliseconds since the first second of 2015. + // The Timestamp component is represented as the milliseconds since the first second of 2015. // Since we're using all 64 bits available, this epoch can be any point in time, as long as it's in the past. // If the epoch is set to a point in time in the future, it may result in negative snowflakes being generated. // @@ -30,11 +30,11 @@ public struct Id : IComparable // the closest we can get to the original specification within the .NET ecosystem. // // The Increment component is a monotonically incrementing number, which is incremented every time a snowflake is generated. - // This is in contrast with some other flake-ish implementations, which only increment the counter any time a snowflake is + // This is in contrast with some other flake-ish implementations, which only increment the counter any time a snowflake is // generated twice at the exact same instant in time. We believe Discord's implementation is more correct here, // as even two snowflakes that are generated at the exact same point in time will not be identical, because of their increments. // - // This implementation is optimised for high-throughput applications, while providing IDs that are roughly sortable, and + // This implementation is optimised for high-throughput applications, while providing IDs that are roughly sortable, and // with a very high degree of uniqueness. private long _value; @@ -71,7 +71,7 @@ public static Id Create() } /// - /// Creates a new ID based on the provided timestamp in milliseconds. + /// Creates a new ID based on the provided timestamp in milliseconds. /// When using this overload, make sure you take the timezone of the provided timestamp into consideration. /// /// @@ -87,7 +87,7 @@ public static Id Create(long timeStampMs) Id id = new Id(); long relativeTimeStamp = timeStampMs - MonotonicTimer.Epoch.ToUnixTimeMilliseconds(); - if (relativeTimeStamp < 0) + if (relativeTimeStamp < 0) { throw new ArgumentException("Specified timestamp would result in a negative ID (it's before instance epoch)"); } @@ -141,8 +141,8 @@ private void CreateInternal(long timeStampMs = 0) { long milliseconds = timeStampMs == 0 ? MonotonicTimer.ElapsedMilliseconds : timeStampMs; long timestamp = milliseconds & TimestampMask; - int threadId = Thread.CurrentThread.ManagedThreadId & ThreadIdMask; - // int processId = s_processId ??= Process.GetCurrentProcess().Id & ProcessIdMask; + int threadId = Environment.CurrentManagedThreadId & ThreadIdMask; + if (s_processId is null) s_processId = Process.GetCurrentProcess().Id & ProcessIdMask; int processId = s_processId.Value; From f361911eed7af2e8f99ddab11215384a2bd5181d Mon Sep 17 00:00:00 2001 From: aevitas Date: Sat, 6 Sep 2025 13:49:15 +0800 Subject: [PATCH 2/5] implement spinwait on increment overflow --- src/FlakeId.Tests/IdTests.cs | 9 ------ src/FlakeId/Id.cs | 63 ++++++++++++++++++++++++++++-------- 2 files changed, 50 insertions(+), 22 deletions(-) diff --git a/src/FlakeId.Tests/IdTests.cs b/src/FlakeId.Tests/IdTests.cs index 1559f73..6bb8c96 100644 --- a/src/FlakeId.Tests/IdTests.cs +++ b/src/FlakeId.Tests/IdTests.cs @@ -90,14 +90,5 @@ public void Id_ToString() Assert.AreEqual(id.ToString(), id.ToString()); } - - [TestMethod] - public void Id_ContainsTimeZoneComponent() - { - DateTimeOffset timeStamp = new DateTimeOffset(2020, 1, 1, 0, 0, 0, TimeSpan.FromHours(7)); - Id id = Id.Create(timeStamp.ToUnixTimeMilliseconds()); - - Assert.AreEqual(timeStamp.ToUnixTimeMilliseconds(), id.ToUnixTimeMilliseconds()); - } } } diff --git a/src/FlakeId/Id.cs b/src/FlakeId/Id.cs index c322654..81e3d30 100644 --- a/src/FlakeId/Id.cs +++ b/src/FlakeId/Id.cs @@ -39,7 +39,7 @@ public struct Id : IComparable private long _value; - private static int s_increment; + private static long s_prevId; // Calling Process.GetCurrentProcess() is a very slow operation, as it has to query the operating system. // Because it's highly unlikely the process ID will change (if at all possible) during our run time, we'll cache it. @@ -89,7 +89,8 @@ public static Id Create(long timeStampMs) if (relativeTimeStamp < 0) { - throw new ArgumentException("Specified timestamp would result in a negative ID (it's before instance epoch)"); + throw new ArgumentException( + "Specified timestamp would result in a negative ID (it's before instance epoch)"); } id.CreateInternal(relativeTimeStamp); @@ -139,22 +140,58 @@ public static Id Parse(long value) private void CreateInternal(long timeStampMs = 0) { - long milliseconds = timeStampMs == 0 ? MonotonicTimer.ElapsedMilliseconds : timeStampMs; - long timestamp = milliseconds & TimestampMask; - int threadId = Environment.CurrentManagedThreadId & ThreadIdMask; - if (s_processId is null) + { s_processId = Process.GetCurrentProcess().Id & ProcessIdMask; - int processId = s_processId.Value; + } - int increment = Interlocked.Increment(ref s_increment) & IncrementMask; + int processId = s_processId.Value; + SpinWait spinner = default; - unchecked + while (true) { - _value = (timestamp << (ThreadIdBits + ProcessIdBits + IncrementBits)) - + (threadId << (ProcessIdBits + IncrementBits)) - + (processId << IncrementBits) - + increment; + long prev = Interlocked.Read(ref s_prevId); + + long lastTimestamp = (prev >> (ThreadIdBits + ProcessIdBits + IncrementBits)); + long currentTimestamp = timeStampMs == 0 ? MonotonicTimer.ElapsedMilliseconds : timeStampMs; + + if (currentTimestamp < lastTimestamp) + { + throw new InvalidOperationException( + "Clock shifted backwards; can't reliably generate ID"); + } + + int increment; + if (currentTimestamp == lastTimestamp) + { + increment = (int)(prev & IncrementMask) + 1; + + // Increment overflows, wait for the next ms to avoid it being 0 for ages + if (increment > IncrementMask) + { + spinner.SpinOnce(); + continue; + } + } + else + { + increment = 0; + } + + int threadId = Environment.CurrentManagedThreadId & ThreadIdMask; + + long newValue = (currentTimestamp << (ThreadIdBits + ProcessIdBits + IncrementBits)) + | (long)(threadId << (ProcessIdBits + IncrementBits)) + | (long)(processId << IncrementBits) + | (long)increment; + + // Atomically update the last value. If the original value was changed by another + // thread, this will fail and we'll loop again. + if (Interlocked.CompareExchange(ref s_prevId, newValue, prev) == prev) + { + _value = newValue; + break; + } } } From 585aefb731df02de211196b7381f9a376329dde3 Mon Sep 17 00:00:00 2001 From: aevitas Date: Sat, 6 Sep 2025 14:26:00 +0800 Subject: [PATCH 3/5] update readme to reflect changes --- README.md | 47 ++++++++++++++++++----------------------------- 1 file changed, 18 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 365171f..9d98262 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Snowflake IDs were originally introduced by Twitter in 2010 as unique, decentralized IDs for Tweets. Their 8-byte size, ordered nature and guaranteed uniqueness make them ideal to use as resource identifiers. Since then, many applications at various scale have adopted Snowflake-esque identifiers. -This repository contains an implementation of decentralized, K-ordered Snowflake IDs based on [the Discord Snowflake specification](https://discord.com/developers/docs/reference). The implementation heavily focuses on high-throughput, supporting upwards of 10.000 unique generations per second on commodity hardware. +This repository contains an implementation of decentralized, K-ordered Snowflake IDs based on [the Discord Snowflake specification](https://discord.com/developers/docs/reference). The implementation heavily focuses on high-throughput, supporting upwards of 10.000 unique generations per second on commodity hardware (up to the theoretical limit of around 4 million per second). You can grab the latest stable version from NuGet: @@ -40,13 +40,6 @@ Where the original Discord reference mentions worker ID and process ID, we subst thread and process ID respectively, as the combination of these two provide sufficient uniqueness, and they are the closest we can get to the original specification within the .NET ecosystem. -The Increment component is a monotonically incrementing number, which is incremented every time a snowflake is generated. -This is in contrast with some other flake-ish implementations, which only increment the counter any time a snowflake is -generated twice at the exact same instant in time. - -We have opted to increment every time an ID is generated, rather than when two or more IDs are generated at the exact same millisecond. -The reasoning behind this is that it's vastly simpler - we can avoid locking altogether - and thus, more performant. It is also closer to Discord's implementation, which was referenced when designing this library. - # Epoch The timestamp component is a delta from a predefined instant in time, this instant is known as the **epoch**. @@ -79,9 +72,8 @@ When exposing your IDs to web clients, it is recommended to use the `ToBase64Str ## Performance -We've benchmarked FlakeId on .NET 8 against [MassTransit's NewId](https://github.com/phatboyg/NewId) library, and [IdGen](https://github.com/RobThree/IdGen) both libraries are widely used. It is worth noting that NewId generates 128-bit integers. +We've benchmarked FlakeId on .NET 8 against [IdGen](https://github.com/RobThree/IdGen), which is another implementation of Snowflake IDs in .NET. FlakeId performs significantly better. -We've also included `Guid.NewGuid` as a baseline benchmark, as it is very well optimized, and arguably the most widely used identifier generator in .NET. ``` BenchmarkDotNet v0.13.12, Windows 11 (10.0.22631.3155/23H2/2023Update/SunValley3) @@ -93,9 +85,7 @@ AMD Ryzen 5 5600X, 1 CPU, 12 logical and 6 physical cores | Method | Mean | Error | StdDev | Code Size | |--------------- |------------:|----------:|-----------:|----------:| -| Single_FlakeId | 26.48 ns | 0.020 ns | 0.019 ns | 358 B | -| Single_Guid | 41.85 ns | 0.481 ns | 0.450 ns | 245 B | -| Single_NewId | 31.83 ns | 0.013 ns | 0.012 ns | 303 B | +| Single_FlakeId | 349.2 ns | 6.58 ns | 6.16 ns | 358 B | | Single_IdGen | 3,473.96 ns | 69.295 ns | 168.673 ns | 671 B | ``` @@ -104,22 +94,21 @@ In this benchmark, IdGen was configured to `SpinWait` in the event multiple IDs Below are the benchmark results for FlakeId running on multiple runtimes. ``` -BenchmarkDotNet v0.13.12, Windows 11 (10.0.22631.3155/23H2/2023Update/SunValley3) -AMD Ryzen 5 5600X, 1 CPU, 12 logical and 6 physical cores -.NET SDK 8.0.201 - [Host] : .NET 8.0.2 (8.0.224.6711), X64 RyuJIT AVX2 - .NET 5.0 : .NET 5.0.17 (5.0.1722.21314), X64 RyuJIT AVX2 - .NET 6.0 : .NET 6.0.27 (6.0.2724.6912), X64 RyuJIT AVX2 - .NET 7.0 : .NET 7.0.16 (7.0.1624.6629), X64 RyuJIT AVX2 - .NET 8.0 : .NET 8.0.2 (8.0.224.6711), X64 RyuJIT AVX2 - - -| Method | Job | Runtime | Mean | Error | StdDev | Code Size | -|--------------- |--------- |--------- |---------:|---------:|---------:|----------:| -| Single_FlakeId | .NET 5.0 | .NET 5.0 | 27.85 ns | 0.111 ns | 0.103 ns | 254 B | -| Single_FlakeId | .NET 6.0 | .NET 6.0 | 26.37 ns | 0.056 ns | 0.053 ns | 215 B | -| Single_FlakeId | .NET 7.0 | .NET 7.0 | 26.72 ns | 0.211 ns | 0.176 ns | 209 B | -| Single_FlakeId | .NET 8.0 | .NET 8.0 | 26.56 ns | 0.085 ns | 0.071 ns | 358 B | +BenchmarkDotNet v0.13.12, macOS 15.6.1 (24G90) [Darwin 24.6.0] +Apple M1 Pro, 1 CPU, 10 logical and 10 physical cores +.NET SDK 9.0.100 + [Host] : .NET 8.0.2 (8.0.224.6711), Arm64 RyuJIT AdvSIMD + .NET 7.0 : .NET 7.0.11 (7.0.1123.42427), Arm64 RyuJIT AdvSIMD + .NET 8.0 : .NET 8.0.2 (8.0.224.6711), Arm64 RyuJIT AdvSIMD + .NET 9.0 : .NET 9.0.0 (9.0.24.52809), Arm64 RyuJIT AdvSIMD + + +| Method | Job | Runtime | Mean | Error | StdDev | +|--------------- |--------- |--------- |---------:|--------:|---------:| +| Single_FlakeId | .NET 7.0 | .NET 7.0 | 354.0 ns | 6.87 ns | 13.89 ns | +| Single_FlakeId | .NET 8.0 | .NET 8.0 | 356.6 ns | 7.17 ns | 9.81 ns | +| Single_FlakeId | .NET 9.0 | .NET 9.0 | 349.2 ns | 6.58 ns | 6.16 ns | + ``` ## Issues From 42ac0e9f79a3b933b82ce289eb86cfee3eddd026 Mon Sep 17 00:00:00 2001 From: aevitas Date: Sat, 6 Sep 2025 14:56:31 +0800 Subject: [PATCH 4/5] include concurrency tests --- src/FlakeId.Tests/ConcurrencyTests.cs | 125 ++++++++++++++++++++++++++ src/FlakeId/FlakeId.csproj | 6 ++ src/FlakeId/Id.cs | 2 +- 3 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 src/FlakeId.Tests/ConcurrencyTests.cs diff --git a/src/FlakeId.Tests/ConcurrencyTests.cs b/src/FlakeId.Tests/ConcurrencyTests.cs new file mode 100644 index 0000000..32027c0 --- /dev/null +++ b/src/FlakeId.Tests/ConcurrencyTests.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace FlakeId.Tests; + +[TestClass] +public class ConcurrencyTests +{ + private const int ThreadIdBits = 5; + private const int ProcessIdBits = 5; + private const int IncrementBits = 12; + private const int TimestampShift = ThreadIdBits + ProcessIdBits + IncrementBits; + private const int IncrementMask = (1 << IncrementBits) - 1; + + private static readonly FieldInfo s_prevIdField = + typeof(Id).GetField("s_prevId", BindingFlags.NonPublic | BindingFlags.Static); + + [TestCleanup] + public void TestCleanup() + { + Assert.IsNotNull(s_prevIdField, "Could not find the private static field 's_prevId' for testing."); + s_prevIdField.SetValue(null, 0L); + } + + [TestMethod] + public void Create_ShouldGenerateNonZeroId() + { + long id = Id.Create(); + + Assert.AreNotEqual(0L, id); + } + + [TestMethod] + public void Create_ShouldGenerateMonotonicallyIncreasingIds() + { + const int idCount = 500_000; + long[] ids = new long[idCount]; + + for (int i = 0; i < idCount; i++) + { + ids[i] = Id.Create(); + } + + for (int i = 1; i < idCount; i++) + { + Assert.IsTrue(ids[i] > ids[i - 1], $"ID at index {i} was not greater than the previous one."); + } + } + + [TestMethod] + public void Create_ShouldGenerateUniqueIds_WhenCalledConcurrently() + { + const int idsPerTask = 100_000; + int taskCount = Environment.ProcessorCount; + int totalIds = idsPerTask * taskCount; + var generatedIds = new ConcurrentBag(); + + var tasks = new List(); + + for (int i = 0; i < taskCount; i++) + { + tasks.Add(Task.Run(() => + { + for (int j = 0; j < idsPerTask; j++) + { + generatedIds.Add(Id.Create()); + } + })); + } + + Task.WaitAll(tasks.ToArray()); + + Assert.AreEqual(totalIds, generatedIds.Count, "The number of generated IDs does not match the expected total."); + + var distinctIds = new HashSet(generatedIds); + Assert.AreEqual(totalIds, distinctIds.Count, "Duplicate IDs were found when generating concurrently."); + } + + [TestMethod] + public void Create_ShouldThrow_WhenClockMovesBackwards() + { + Assert.IsNotNull(s_prevIdField, "Could not find the private static field 's_prevId' for testing."); + + long baseId = Id.Create(); + long baseTimestamp = baseId >> TimestampShift; + + long futureTimestamp = baseTimestamp + 5000; + long futureId = futureTimestamp << TimestampShift; + s_prevIdField.SetValue(null, futureId); + + var ex = Assert.ThrowsException(() => Id.Create()); + StringAssert.Contains(ex.Message, "Clock shifted backwards"); + } + + [TestMethod] + public void Increment_ShouldResetToZero_OnNewMillisecond() + {Assert.IsNotNull(s_prevIdField, "Could not find the private static field 's_prevId' for testing."); + + long baseId = Id.Create(); + long baseTimestamp = baseId >> TimestampShift; + + // create a fake "previous ID" that is at the same timestamp but has a high increment. + // we preserve the other parts of the ID (thread, process) to make the state realistic. + const int highIncrement = 4000; + long otherParts = baseId & ~((1L << 42) - 1); // masks out the timestamp + long prevId = otherParts | (baseTimestamp << TimestampShift) | highIncrement; + + s_prevIdField.SetValue(null, prevId); + + Thread.Sleep(5); + + long newIdValue = Id.Create(); + + long newTimestamp = newIdValue >> TimestampShift; + int newIncrement = (int)(newIdValue & IncrementMask); + + Assert.IsTrue(newTimestamp > baseTimestamp, "Timestamp should have advanced."); + Assert.AreEqual(0, newIncrement, "Increment should reset to 0 on a new millisecond."); + } +} diff --git a/src/FlakeId/FlakeId.csproj b/src/FlakeId/FlakeId.csproj index 11611fd..263c9ba 100644 --- a/src/FlakeId/FlakeId.csproj +++ b/src/FlakeId/FlakeId.csproj @@ -15,6 +15,12 @@ Apache-2.0 + + + <_Parameter1>FlakeId.Tests + + + True diff --git a/src/FlakeId/Id.cs b/src/FlakeId/Id.cs index 81e3d30..924fef0 100644 --- a/src/FlakeId/Id.cs +++ b/src/FlakeId/Id.cs @@ -9,7 +9,7 @@ namespace FlakeId /// Represents a unique, K-ordered, sortable identifier. /// [DebuggerDisplay("{_value}")] - public struct Id : IComparable + public struct Id : IComparable, IEquatable { // This implementation of Snowflake ID is based on the specification as published by Discord: // https://discord.com/developers/docs/reference From 9179c0cb9b4d84017361477d10a1f60de1107959 Mon Sep 17 00:00:00 2001 From: aevitas Date: Sat, 6 Sep 2025 15:00:20 +0800 Subject: [PATCH 5/5] update action runner to .NET 9 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index adea645..d85195f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: - name: Environment Setup uses: actions/setup-dotnet@v4 with: - dotnet-version: 8.0.x + dotnet-version: 9.0.x - name: Build run: dotnet build ./src/FlakeId.sln - name: Test