Skip to content

Commit 3fcc08d

Browse files
committed
feature "use_counter" to increase randomness
1 parent 2f0926f commit 3fcc08d

5 files changed

Lines changed: 151 additions & 77 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "fast-uuid-v7"
3-
version = "0.1.1"
3+
version = "0.1.2"
44
edition = "2021"
55
description = "A high-performance Rust library for generating UUID v7 compatible identifiers."
66
repository = "https://github.com/marcomq/fast-uuid-v7"
@@ -9,6 +9,11 @@ license = "MIT"
99
[dependencies]
1010
rand = "0.9"
1111

12+
[features]
13+
# Enables the 18-bit monotonic counter (reduces randomness to 56 bits).
14+
# If disabled (default), uses 74 bits of randomness.
15+
use_counter = []
16+
1217
[[bench]]
1318
name = "benchmark"
1419
harness = false

README.md

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,36 +8,51 @@ This implementation focuses on speed. It uses thread-local storage and a seeded
88

99
* **UUID v7**: Time-ordered, 128-bit unique identifiers.
1010
* **Fast**: Minimal overhead using thread-local state.
11-
* **Monotonic**: IDs generated on the same thread increase monotonically. It supports up to ~262k IDs per millisecond before incrementing the timestamp to preserve order.
11+
* **Configurable**: Choose between maximum randomness (default) or per-thread monotonicity.
1212

1313
## Why?
1414

1515
I was testing the performance of network streams and used the original `uuid` v7 to generate messages
1616
with unique IDs. I wondered why there was a maximum of 400,000 messages per
1717
second, even though the rest of the code looked like it could handle millions.
1818
I figured out that 400,000 is mostly the limit of the standard `uuid` crate when
19-
using v7. Even v4 was not significantly faster. This happened due to the strong
19+
using v7 without any additional feature flags. This happened due to the strong
2020
random number generator used in `uuid`. While a strong RNG is correct and avoids potential issues
2121
in security or crypto contexts, it is otherwise just very slow.
22+
I found out about the `fast-rng` feature flag of `uuid` after creating this crate. But even with `fast-rng` feature flag, `fast-uuid-ui` is much faster than `uuid`.
2223

2324
## Comparison to `uuid` crate
2425

2526
Compared to the standard `uuid` crate (which may take up to ~1.4µs / 1440ns per ID):
2627
* **`fast-uuid-v7` can be up to ~130x faster** (11ns vs 1440ns).
28+
* When using feature `fast-rng` on the original `uuid` crate, `fast-uuid-v7` can still be up to
29+
8 times faster for `uint128` (11ns vs 90ns) and 6 times faster for `&str` generation (26ns vs 170ns).
2730

28-
As the potential throughput is much higher, the internal counter was increased
29-
from 12 bits to 18 bits, and the random part was reduced from 64 bits to 56 bits.
31+
## Configuration
32+
33+
### Default (74 bits randomness)
34+
By default, `fast-uuid-v7` uses all available 74 bits for randomness. This matches the standard UUID v7 randomness layout.
35+
* **Pros**: Extremely low collision risk across distributed systems.
36+
* **Cons**: IDs generated within the same millisecond on the same thread are not guaranteed to be monotonic (they will be random).
37+
38+
### Feature `use_counter`
39+
If you enable the `use_counter` feature in `Cargo.toml`, the library uses an 18-bit counter and 56 bits of randomness.
40+
* **Pros**: Guarantees monotonicity per thread (up to ~262k IDs/ms).
41+
* **Cons**: Reduced randomness (56 bits) increases collision risk in massive distributed systems (approx. 50% chance after 4.5 billion IDs/ms globally).
3042

3143
## Bit Layout
3244

3345
The 128-bit ID is fully compatible with UUID v7. It is composed of:
3446

3547
* **48 bits**: Unix timestamp in milliseconds.
3648
* **4 bits**: Version (7).
37-
* **12 bits**: Counter (High 12 bits).
49+
* **12 bits**: Random Data (or Counter High 12 bits with `use_counter`).
3850
* **2 bits**: Variant (10xx).
39-
* **6 bits**: Counter (Low 6 bits).
40-
* **56 bits**: Random data.
51+
* **62 bits**: Random Data (or Counter Low 6 bits + 56 bits random with `use_counter`).
52+
53+
**Total Randomness:**
54+
* Default: **74 bits**
55+
* With `use_counter`: **56 bits**
4156

4257
## Usage
4358

@@ -63,8 +78,8 @@ fn main() {
6378

6479
On a modern machine (e.g., Apple M1 or recent x86_64), you can expect:
6580

66-
* **`gen_id`**: ~11-50 ns
67-
* **`gen_id_str`**: ~23-60 ns (zero-allocation)
81+
* **`gen_id`**: ~10-50 ns
82+
* **`gen_id_str`**: ~25-60 ns (zero-allocation)
6883
* **`gen_id_string`**: ~90-130 ns (includes heap allocation)
6984

7085
Generating 10 million IDs takes approximately **120ms** on a single core.
@@ -80,7 +95,7 @@ Generating 10 million IDs takes approximately **120ms** on a single core.
8095
### Limitations
8196

8297
* **Not Cryptographically Secure**: The randomness is optimized for speed, not unpredictability. Do not use for session tokens or secrets. If you don't need speed, use the original `uuid` crate.
83-
* **Per-Thread Monotonicity**: IDs are monotonic within a single thread. Across threads, they are only roughly ordered by timestamp (1ms precision).
98+
* **Monotonicity**: Only guaranteed per-thread if `use_counter` is enabled. Otherwise, IDs within the same millisecond are random.
8499
* **Clock Drift Risk**: The batched timestamp check assumes the CPU counter frequency is stable. While we include safety checks, extreme edge cases (e.g., VM migration) might cause a 1ms timestamp lag.
85100
* **Still needs SystemTime::now()**: The speed of 11ns is not constant and can only be achieved if we can skip calling `SystemTime::now()`. We still need to call `SystemTime::now()` from time to time, for example if the previous call was 1ms ago. In that case, we still need to call `SystemTime::now()` and the performance drops to about 50ns. This is still much faster than the original `uuid` crate.
86101

benches/benchmark.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,16 @@ fn benchmark_uuid_now_v7(c: &mut Criterion) {
1818
c.bench_function("uuid_now_v7", |b| b.iter(|| Uuid::now_v7()));
1919
}
2020

21-
criterion_group!(benches, benchmark_gen_id_u128, benchmark_gen_id_string, benchmark_gen_id_str, benchmark_uuid_now_v7);
21+
fn benchmark_uuid_now_v7_str(c: &mut Criterion) {
22+
c.bench_function("uuid_now_v7_str", |b| b.iter(|| Uuid::now_v7().to_string()));
23+
}
24+
25+
criterion_group!(
26+
benches,
27+
benchmark_gen_id_u128,
28+
benchmark_gen_id_string,
29+
benchmark_gen_id_str,
30+
benchmark_uuid_now_v7,
31+
benchmark_uuid_now_v7_str
32+
);
2233
criterion_main!(benches);

src/lib.rs

Lines changed: 107 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -21,20 +21,27 @@ thread_local! {
2121
/// The identifier is a `u128` value composed of:
2222
/// - 48 bits: Current timestamp in milliseconds.
2323
/// - 4 bits: Version (7).
24-
/// - 12 bits: Counter (High 12 bits of 18).
24+
/// - 12 bits: Random data (or Counter High 12 bits if `use_counter` enabled).
2525
/// - 2 bits: Variant (10..).
26-
/// - 6 bits: Counter (Low 6 bits of 18).
27-
/// - 56 bits: Random number.
26+
/// - 62 bits: Random data (or Counter Low 6 bits + 56 bits random if `use_counter` enabled).
2827
///
29-
/// This layout effectively provides an 18-bit counter (supporting ~262k IDs/ms)
30-
/// by utilizing the standard `rand_a` field and the upper bits of `rand_b`
31-
/// (compliant with RFC 9562 Method 1).
28+
/// **Default Behavior (74 bits randomness):**
29+
/// By default, this function uses 74 bits of randomness. This provides extremely low
30+
/// collision probability across distributed systems but does not guarantee monotonicity
31+
/// for IDs generated within the same millisecond on the same thread.
3232
///
33-
/// **Note on Sorting:**
34-
/// Since the counter is thread-local and resets every millisecond, IDs generated
35-
/// concurrently by multiple threads within the same millisecond are not guaranteed
36-
/// to be globally monotonic.
37-
/// This is not random enough for cryptography!
33+
/// **With `use_counter` feature:**
34+
/// If the `use_counter` feature is enabled, it uses an 18-bit counter (supporting ~262k IDs/ms)
35+
/// combined with 56 bits of randomness. This guarantees per-thread monotonicity but
36+
/// increases collision risk across different nodes if the random part is exhausted.
37+
///
38+
/// **Randomness Comparison:**
39+
/// - Default: 74 bits (Same as standard `uuid` v7).
40+
/// - With `use_counter`: 56 bits.
41+
/// - Note: This library uses a seeded `SmallRng` (fast) vs `uuid`'s CSPRNG (secure).
42+
///
43+
/// fast-uuid-v7 is is not random enough for cryptography!
44+
#[inline]
3845
pub fn gen_id_u128() -> u128 {
3946
// Optimization: Check time only every 32 calls to amortize syscall overhead.
4047
const TIME_CHECK_MASK: u32 = 0x1F;
@@ -71,7 +78,13 @@ pub fn gen_id_u128() -> u128 {
7178
LAST_TSC.with(|t| t.set(current_tsc));
7279
(c & TIME_CHECK_MASK) == 0 || current_tsc.wrapping_sub(last_tsc) > TSC_THRESHOLD
7380
}
74-
#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
81+
#[cfg(target_arch = "wasm32")]
82+
{
83+
// WASM lacks a cheap cycle counter (like rdtsc) to detect thread sleeps.
84+
// We must check the time on every call to prevent timestamp drift.
85+
true
86+
}
87+
#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64", target_arch = "wasm32")))]
7588
{
7689
true
7790
}
@@ -106,22 +119,45 @@ pub fn gen_id_u128() -> u128 {
106119
})
107120
});
108121

109-
// Use 18 bits for counter: 12 in rand_a, 6 in rand_b high.
110-
// This allows ~262k IDs per millisecond per thread.
111-
let rand_a = (counter >> 6) & 0xFFF;
112-
let rand_b_high = counter & 0x3F;
113-
114-
let rand_nr = RNG.with(|random_nr| random_nr.borrow_mut().next_u64());
115-
116122
let timestamp_part = (timestamp as u128) << 80;
117123
let version_part = 7u128 << 76; // Version 7 (0111)
118-
let counter_part = (rand_a as u128) << 64; // 12 bits of counter
119124
let variant_part = 2u128 << 62; // Variant 1 (10..), RFC 4122
120-
// 56 bits of randomness + 6 bits of counter
121-
let rand_b_low = rand_nr & 0x00FF_FFFF_FFFF_FFFF;
122-
let random_part = ((rand_b_high as u128) << 56) | (rand_b_low as u128);
123125

124-
timestamp_part | version_part | counter_part | variant_part | random_part
126+
#[cfg(feature = "use_counter")]
127+
{
128+
// Use 18 bits for counter: 12 in rand_a, 6 in rand_b high.
129+
// This allows ~262k IDs per millisecond per thread.
130+
let rand_a = (counter >> 6) & 0xFFF;
131+
let rand_b_high = counter & 0x3F;
132+
133+
let rand_nr = RNG.with(|random_nr| random_nr.borrow_mut().next_u64());
134+
135+
let counter_part = (rand_a as u128) << 64; // 12 bits of counter
136+
// 56 bits of randomness + 6 bits of counter
137+
let rand_b_low = rand_nr & 0x00FF_FFFF_FFFF_FFFF;
138+
let random_part = ((rand_b_high as u128) << 56) | (rand_b_low as u128);
139+
140+
timestamp_part | version_part | counter_part | variant_part | random_part
141+
}
142+
143+
#[cfg(not(feature = "use_counter"))]
144+
{
145+
// Suppress unused variable warning for counter
146+
let _ = counter;
147+
148+
// We need 74 bits of randomness. SmallRng generates 64 bits per call.
149+
let (r1, r2) = RNG.with(|random_nr| {
150+
let mut rng = random_nr.borrow_mut();
151+
(rng.next_u64(), rng.next_u64())
152+
});
153+
154+
// rand_a: 12 bits (from r1)
155+
let rand_a = (r1 & 0xFFF) as u128;
156+
// rand_b: 62 bits (from r2)
157+
let rand_b = (r2 & 0x3FFFFFFFFFFFFFFF) as u128;
158+
159+
timestamp_part | version_part | (rand_a << 64) | variant_part | rand_b
160+
}
125161
}
126162

127163
/// Alias for `gen_id_u128`.
@@ -144,48 +180,14 @@ pub fn gen_id_string() -> String {
144180
gen_id_str().to_string()
145181
}
146182

147-
/// A stack-allocated string representation of a UUID (36 bytes).
148-
///
149-
/// This type implements `Deref<Target=str>`, so it can be used like a `&str`.
150-
/// It avoids heap allocation, making it faster than `gen_id_string`.
151-
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
152-
pub struct UuidString([u8; 36]);
153-
154-
impl std::ops::Deref for UuidString {
155-
type Target = str;
156-
fn deref(&self) -> &str {
157-
// SAFETY: The buffer is always filled with valid ASCII (hex + dashes)
158-
unsafe { std::str::from_utf8_unchecked(&self.0) }
159-
}
160-
}
161-
162-
impl AsRef<str> for UuidString {
163-
fn as_ref(&self) -> &str {
164-
self
165-
}
166-
}
167-
168-
impl PartialEq<str> for UuidString {
169-
fn eq(&self, other: &str) -> bool {
170-
&**self == other
171-
}
172-
}
173-
174-
impl PartialEq<&str> for UuidString {
175-
fn eq(&self, other: &&str) -> bool {
176-
&**self == *other
177-
}
178-
}
179-
180-
impl std::fmt::Display for UuidString {
181-
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182-
f.write_str(self)
183-
}
184-
}
185-
186183
/// Generates a UUID v7 string on the stack, avoiding heap allocation.
187184
pub fn gen_id_str() -> UuidString {
188-
let id = gen_id_u128();
185+
format_uuid(gen_id_u128())
186+
}
187+
188+
/// Formats a u128 UUID into a stack-allocated string representation.
189+
/// `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`
190+
pub fn format_uuid(id: u128) -> UuidString {
189191
let mut out = UuidString([0; 36]);
190192
let bytes = id.to_be_bytes();
191193
const HEX: &[u8; 16] = b"0123456789abcdef";
@@ -242,9 +244,49 @@ pub fn gen_id_str() -> UuidString {
242244
out
243245
}
244246

247+
/// A stack-allocated string representation of a UUID (36 bytes).
248+
///
249+
/// This type implements `Deref<Target=str>`, so it can be used like a `&str`.
250+
/// It avoids heap allocation, making it faster than `gen_id_string`.
251+
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
252+
pub struct UuidString([u8; 36]);
253+
254+
impl std::ops::Deref for UuidString {
255+
type Target = str;
256+
fn deref(&self) -> &str {
257+
// SAFETY: The buffer is always filled with valid ASCII (hex + dashes)
258+
unsafe { std::str::from_utf8_unchecked(&self.0) }
259+
}
260+
}
261+
262+
impl AsRef<str> for UuidString {
263+
fn as_ref(&self) -> &str {
264+
self
265+
}
266+
}
267+
268+
impl PartialEq<str> for UuidString {
269+
fn eq(&self, other: &str) -> bool {
270+
&**self == other
271+
}
272+
}
273+
274+
impl PartialEq<&str> for UuidString {
275+
fn eq(&self, other: &&str) -> bool {
276+
&**self == *other
277+
}
278+
}
279+
280+
impl std::fmt::Display for UuidString {
281+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
282+
f.write_str(self)
283+
}
284+
}
285+
245286
/// Returns the current time in milliseconds since the Unix epoch.
246287
///
247288
/// It returns `0` if the system clock hasn't started yet.
289+
#[inline]
248290
fn fast_time_ms() -> u64 {
249291
SystemTime::now()
250292
.duration_since(UNIX_EPOCH)
@@ -276,6 +318,7 @@ mod tests {
276318
}
277319

278320
#[test]
321+
#[cfg(feature = "use_counter")]
279322
/// IDs are sorted correctly per thread.
280323
/// Capacity is ~262k IDs per ms (18 bits).
281324
fn test_next_id_ordering() {

0 commit comments

Comments
 (0)