Search by

iliaal / fast_uuid

iliaa

Fast RFC 9562 UUID generation (v1/v2/v3/v4/v5/v6/v7/v8 + nil/max) as a PHP C extension, with a ramsey/uuid-shaped object API and procedural fast-path functions.

Package info

github.com/iliaal/fast_uuid

Type:php-ext

Ext name:ext-fast_uuid

pkg:composer/iliaal/fast_uuid

Statistics

Installs: 22

Dependents: 0

Suggesters: 0

Stars: 23

Open Issues: 0

0.8.1 2026-10-13 10:15 UTC

README

Tests Windows Build Version License: BSD-3-Clause Follow @iliaa

fast_uuid: RFC 9562 UUIDs for PHP in pure C, 11x to 57x faster than ramsey/uuid

A PHP C extension for RFC 9562 / RFC 4122 UUID generation. Recorded benchmarks measured 11x to 57x faster than ramsey/uuid on v1/v4/v7 generation and 7x to 11x faster on parsing; see the version caveat in Benchmarks. It produces versions 1, 2 (DCE Security), 3, 4, 5, 6, 7, 8, plus nil and max. The engine is pure C (no C++/libstdc++). The object API follows the shape of ramsey/uuid under the FastUuid namespace, and procedural functions skip object allocation for the hottest call sites.

Full API reference with runnable examples: docs/index.html. Benchmarks: BENCHMARKS.md.

⚡ Why it's fast

  • Userspace CSPRNG: an 8 KB per-thread buffer is refilled by an AES-256-CTR generator with fast key erasure, running on the CPU's AES instructions (x86/x86-64 AES-NI, ARMv8 Crypto Extensions) and reseeded from the OS CSPRNG every 64 KiB, so one syscall covers ~4,000 v4s. CPUs without AES instructions refill the buffer from the OS CSPRNG (getrandom() on Linux) directly, one syscall per ~500 v4s. ramsey's per-call random_bytes() is the usual bottleneck.
  • No property table: the object is 16 inline bytes plus a lazily-cached canonical string. No HashTable, no declared properties, custom create/free/clone/compare/cast handlers.
  • SIMD hex formatter: x86-64 uses a runtime-dispatched SSSE3 pshufb-LUT path, and ARM64 uses a NEON table-lookup path. Both turn 16 bytes into 32 hex in a handful of vector ops, with a scalar LUT fallback for other architectures.
  • Procedural path: uuid_v4() and friends return a zend_string with no object allocation, for ORM inserts and cache keys. On PHP 8.4+ direct calls compile to frameless calls that skip the call frame and argument parser (up to ~20% faster per call; see BENCHMARKS.md).

Requirements

  • PHP 8.1 through 8.6, NTS or ZTS. PHP 8.1/8.2/8.3 build via small #if PHP_VERSION_ID polyfills.
  • x86-64 and ARM64 get the SIMD formatter automatically; other architectures fall back to the scalar path. No build flags needed either way.
  • No external libraries. v1 and v6 use an internal RFC-compliant generator with a random node (multicast bit set, per RFC 9562 §5.1). v3 and v5 use PHP's bundled MD5/SHA1.

📦 Install

The quickest path is PIE, which resolves a prebuilt binary for your platform (Windows x86/x64 NTS/TS, Linux glibc x86_64/arm64, macOS arm64) and falls back to a source build otherwise:

pie install iliaal/fast_uuid

Then enable it with extension=fast_uuid in your php.ini. Load it with extension= rather than dl(): a dl()-loaded copy runs without frameless calls (PHP never unregisters them), and on musl an unloaded copy leaves a dangling fork handler.

🛠️ Build from source

phpize
./configure --enable-fast-uuid
make
make test
php -d extension="$(pwd)/modules/fast_uuid.so" -r 'echo \FastUuid\Uuid::uuid4(), "\n"; echo uuid_v7(), "\n";'

The SIMD hex formatter and the AES-256-CTR CSPRNG backend are chosen at runtime from the CPU's feature flags. To build without them, add a define to CFLAGS before running ./configure:

  • -DFU_DISABLE_AES removes only the AES backend; the buffer is refilled from the OS CSPRNG.
  • -DFU_DISABLE_SSSE3 (x86) or -DFU_DISABLE_NEON (ARM64) removes the SIMD formatter and, with it, the AES backend for that architecture.

php --ri fast_uuid shows the active CSPRNG backend and, after a fallback, the reason.

The arginfo header is generated from fast_uuid.stub.php. To regenerate after editing the stub:

php /path/to/php-src/build/gen_stub.php fast_uuid.stub.php

Object API: FastUuid\Uuid

Static factories (all return FastUuid\UuidValueInterface, except isValid()):

uuid1(int|string|null $node = null, ?int $clockSeq = null)
uuid2(int $localDomain, int|string|null $localIdentifier = null, int|string|null $node = null, ?int $clockSeq = null)
uuid3(object|string $ns, string $name)
uuid4()
uuid5(object|string $ns, string $name)
uuid6(int|string|null $node = null, ?int $clockSeq = null)
uuid7(int|DateTimeInterface|null $dateTime = null)   // int = unix milliseconds
uuid8(string $bytes)                       // 16 raw bytes
fromString(string $uuid)                   // canonical, urn:uuid:, {braced}, bare 32-hex, any case
fromBytes(string $bytes)                   // 16 raw bytes
fromInteger(string $integer)               // decimal string
fromHexadecimal(Stringable|string $hex)    // 32 hex chars; Stringable covers ramsey's Type\Hexadecimal
fromDateTime(DateTimeInterface $dt, int|string|null $node = null, ?int $clockSeq = null)
isValid(string $uuid): bool

Instance methods:

toString(): string        __toString(): string      getBytes(): string        getHex(): string
getUrn(): string          getVersion(): ?int        getVariant(): int         getInteger(): string
getDateTime(): DateTimeImmutable                     getFields(): array        equals(mixed): bool
compareTo(mixed): int     jsonSerialize(): string   getTimestampMillis(): int
toBytes(): string         toHexadecimal(): string   toUrn(): string           toInteger(): string
  • toBytes() / toHexadecimal() / toUrn() / toInteger() are aliases of getBytes() / getHex() / getUrn() / getInteger(), matching the get*→to* naming of the newer ramsey/identifier library.
  • getTimestampMillis() returns the embedded timestamp as unix milliseconds for RFC time-based versions (v1, v2, v6, v7) and is much cheaper than getDateTime() since it builds no object; it throws UnsupportedOperationException for non-time-based versions and non-RFC variants.
  • Uuid::uuid7() accepts a unix-millisecond int as well as a DateTimeInterface, which skips the DateTime machinery entirely. The procedural uuid_v7_at(int $unixMillis) is the fastest explicit-timestamp form.
  • Live-clock uuid1() and uuid6() share a timestamp high-water mark. If the clock stalls or moves backward, the next timestamp advances by 100 ns while preserving any supplied node and clock sequence. This prevents repeats within one process, or one thread under ZTS. You must coordinate node and clock-sequence values across processes, forks, threads, and restarts when you supply fixed values. fromDateTime() preserves your explicit timestamp and does not use this monotonic state.
  • Live-clock uuid2() keeps its own high-water mark on the visible 28-bit timestamp portion (time_mid + time_hi; time_low is the local identifier, so the shared v1/v6 mark would be invisible until a bit-32 carry), but only when you supply both node and clockSeq. A defaulted node or clock sequence is freshly random per call, so those callers never repeat and their output is unchanged. When the mark applies and the clock stalls or moves backward, the next visible timestamp advances by one unit, which is 2^32 × 100 ns ≈ 429.5 s of embedded time per call: getDateTime()/getTimestampMillis() read back the advanced time. The 28-bit visible field exhausts after roughly 2.36×10^8 stalled calls from the current epoch, after which uuid2() throws UnsupportedOperationException ("v2 timestamp exhausted") until the worker exits. As with v1/v6, you must coordinate the local identifier, node, and clock sequence across processes, forks, threads, and restarts when you supply fixed values.
  • Live-clock UUIDv7 carries sub-millisecond precision (RFC 9562 §6.2 Method 3): rand_a encodes the sub-ms fraction. When the millisecond/sub-millisecond time key repeats or moves backward, the counter in rand_b advances by a fresh CSPRNG-selected value from 1..2^24 inclusive, carrying into the time key on overflow. Single and batch calls share this monotonic state within a process, or within a thread under ZTS; ties across threads or processes carry no order. Explicit uuid7($dateTime) and uuid7($unixMillis) calls do not use the monotonic state. getDateTime() reads back at millisecond precision, matching ramsey/uuid.
  • getVariant() returns 0 (NCS), 2 (RFC 4122), 6 (Microsoft), 7 (future); getVersion() is null for nil/max and non-RFC variants.
  • getDateTime() works for RFC time-based versions (v1, v2, v6, v7) and throws FastUuid\Exception\UnsupportedOperationException for non-time-based versions and non-RFC variants.
  • getFields() returns an associative array of hex strings (time_low, time_mid, time_hi_and_version, clock_seq_hi_and_reserved, clock_seq_low, node). For the ramsey-shaped FieldsInterface / Type objects, use the compat layer below.
  • equals() and compareTo() accept another UUID object (native, a compat wrapper, or any Stringable whose string form parses as a UUID) or its canonical string.
  • uuid3() and uuid5() accept object|string namespaces. Native UUIDs resolve directly; wrappers with a compatible public, non-static getCore() callable without arguments resolve through its native UUID result. Other Stringable objects resolve through their string form. Foreign getBytes() methods are not used, because those bytes may have GUID or ordered-time storage order. The same resolution applies to native equals() and compareTo(). Ordinary PHP weak string coercion applies to scalar namespace arguments; strict_types=1 rejects scalars other than strings with TypeError.
  • var_dump() shows the value as a virtual uuid property, and var_export() output rebuilds through Uuid::__set_state().

Constants: NIL, MAX, NAMESPACE_DNS, NAMESPACE_URL, NAMESPACE_OID, NAMESPACE_X500, DCE_DOMAIN_PERSON, DCE_DOMAIN_GROUP, DCE_DOMAIN_ORG.

Implements FastUuid\UuidValueInterface, which extends the lightweight FastUuid\UuidInterface namespace marker with the complete native instance contract. Factory return types use UuidValueInterface, while existing userland UuidInterface implementations remain valid namespace arguments.

DCE Security (v2)

$u = \FastUuid\Uuid::uuid2(\FastUuid\Uuid::DCE_DOMAIN_PERSON);   // local id auto-fills from POSIX uid
$u->getVersion();        // 2
$u = \FastUuid\Uuid::uuid2(\FastUuid\Uuid::DCE_DOMAIN_GROUP, 4242);

The local identifier occupies bytes 0 to 3 (big-endian); the local domain is stored in byte 9. With domain PERSON or GROUP and a null identifier, the extension uses the process uid or gid; on Windows, where there is no POSIX uid/gid, an explicit localIdentifier is required.

Exceptions

  • FastUuid\Exception\InvalidArgumentException (extends \InvalidArgumentException): a bad length, node, or integer.
  • FastUuid\Exception\InvalidUuidStringException (extends the above): an unparseable UUID string.
  • FastUuid\Exception\UnsupportedOperationException (extends \LogicException, matching ramsey/uuid 4.x): raised by getDateTime() on a non-time-based version.

All three implement FastUuid\Exception\UuidExceptionInterface (extends \Throwable), so one catch covers everything the extension throws.

Out-of-range factory inputs are rejected, not silently truncated: a v7 timestamp past the 48-bit millisecond field, a fromDateTime instant outside the v1 Gregorian window, a non-canonical or >128-bit decimal string for fromInteger, a node outside 0..2^48-1, a clock sequence outside 0..0x3fff, a DCE domain outside 0..255, a uuid3()/uuid5() name over 16 MiB, or uuid2 without an explicit local identifier for a non-PERSON/GROUP domain all throw InvalidArgumentException. Range is checked after type: a node or localIdentifier of the wrong type raises TypeError from the parameter parser, since both are declared int|string|null.

Procedural API

uuid_v1() uuid_v3($ns, $name) uuid_v4() uuid_v4_fast() uuid_v5($ns, $name) uuid_v6() uuid_v7() uuid_v8($bytes)
uuid_v7_at($unixMillis)  // v7 from a unix-millisecond int (no DateTime)
uuid_v1_bin() uuid_v4_bin() uuid_v6_bin() uuid_v7_bin() uuid_v4_fast_bin()  // raw 16 bytes, no string
uuid_v3_bin($ns, $name) uuid_v5_bin($ns, $name) uuid_v8_bin($bytes) uuid_v7_at_bin($unixMillis)
uuid_v4_batch($n) uuid_v7_batch($n)          // array of $n canonical strings
uuid_v4_bin_batch($n) uuid_v7_bin_batch($n)  // array of $n raw 16-byte values
uuid_to_bin($uuid)   // canonical/parsed string -> 16 raw bytes
uuid_from_bin($bytes)// 16 raw bytes -> canonical string
uuid_is_valid($uuid) // bool
fast_uuid_random_bytes($length) // batched CSPRNG bytes, $length > 0

uuid_v4_fast() uses a non-cryptographic xoshiro256** PRNG. Use it only for non-security IDs. Batch count is capped at 100,000 UUIDs per call, and fast_uuid_random_bytes() is capped at 16 MiB per call. UUIDv7 batches read the clock once per call and advance the same random-increment counter as single calls, so a large batch's timestamps can trail the wall clock by the batch duration.

ramsey/uuid compatibility layer (FastUuid\Compat)

compat/ is a PSR-4 (FastUuid\Compat\) companion package (iliaal/fast-uuid-compat) that provides the cold-path ramsey ergonomics on top of the C engine. It ships in this repo's compat/ directory and is not on Packagist yet; install it as a Composer path repository (composer config repositories.fast-uuid-compat path /path/to/fast_uuid/compat && composer require iliaal/fast-uuid-compat:@dev) or autoload FastUuid\Compat\ to compat/src/. It provides: UuidFactory / UuidFactoryInterface, the per-version Rfc4122\UuidV1…UuidV8 / NilUuid / MaxUuid / Nonstandard\Uuid classes, Rfc4122\UuidV2 with getLocalDomain() / getLocalIdentifier() / getLocalDomainName(), Rfc4122\UuidV6 with fromUuidV1() / toUuidV1(), Rfc4122\Fields and Nonstandard\Fields (FieldsInterface), Type\Hexadecimal / Type\Integer with their TypeInterface / NumberInterface contracts, the codecs (StringCodec, OrderedTimeCodec, TimestampFirstCombCodec, TimestampLastCombCodec, GuidStringCodec), Guid\Guid, the providers (RandomGeneratorInterface, NodeProviderInterface, TimeGeneratorInterface + defaults), and the validators (GenericValidator, NonstandardValidator). Compat UUIDs implement both modern magic serialization and Ramsey's legacy Serializable methods. Node arguments accept Type\Hexadecimal, and UUIDv2 local identifiers accept Type\Integer.

To migrate from ramsey/uuid, swap use Ramsey\Uuid\Uuid for use FastUuid\Compat\Uuid; most code needs nothing else. The compat package depends only on the extension.

Compatibility covers the API shape; generation algorithms and some codec conventions differ. In particular, live-clock v7 uses sub-millisecond time and the random increments described above, and GUID text follows the canonical RFC form described below.

Generation stays on the pure-C fast path. A custom RandomGeneratorInterface, TimeGeneratorInterface, or NodeProviderInterface routes the affected factories off it (ramsey behaviour), so your generators win. Custom node providers feed uuid1(), uuid2(), uuid6(), and fromDateTime() when you pass no explicit node. The compat Uuid::uuid7() facade also accepts a unix-millisecond int, matching the core object API.

Compat uuid3() and uuid5() retain UuidInterface|string namespace parameters. String namespaces identify network-order UUIDs independently of the output codec, including under COMB codecs and on subclasses that inherit fromString(). Overriding getCodec() does not change namespace identity; an explicit fromString() override retains the subclass parsing path. To use codec-formatted text as a namespace, decode it with $factory->fromString($text) first and pass the resulting UUID object.

Decoded UUID objects keep the factory's active codec for strings, bytes, and serialization, and derive identity forms from toString() as ramsey does: getUrn() is 'urn:uuid:' . toString() and getHex() is toString() without the hyphens, while getInteger() always reads the network-order core. equals() takes any value and returns false for non-UUID input instead of throwing. compareTo() accepts UUID objects only, orders by the network-order bytes regardless of codec, and throws InvalidArgumentException otherwise. Under the default codec all of these match the canonical form, so nothing changes unless you set a codec.

GuidStringCodec swaps the first three fields in encodeBinary() / decodeBytes() only. A GUID is mixed-endian in its byte array but its text is the plain RFC string, which is why .NET's Guid.ToString() and Guid.ToByteArray() disagree. Under that codec, pass GUID-ordered bytes to fromBytes() and canonical text to fromString(); Guid\Guid::getBytes() returns the same mixed-endian order. OrderedTimeCodec and the COMB codecs change their documented binary storage order, and the COMB codecs also reshape the text, so their getHex() follows it.

Validation is strict by default: GenericValidator enforces the version nibble (1-8) and the variant nibble (8/9/a/b), with the nil UUID explicitly exempt; NonstandardValidator stays shape-only with no nibble constraints. UuidInterface has no required getCore(), so third-party Ramsey implementations and doubles can satisfy it. Internals use a compatible optional getCore() returning a native UUID when available (AbstractUuid and Guid\Guid expose one), then fall back to the implementation's string form.

📊 Benchmarks

These measurements predate the v1/v6 timestamp guard and the v7 random-increment fix. They are historical results, not measurements of the current generation paths.

Throughput against ramsey/uuid 4.9.2 and the PECL uuid extension 1.3.0 (libuuid-backed). PHP 8.4.22 NTS, non-debug, no sanitizers; SSSE3 hex formatter active (x86-64). Each operation runs 300,000 iterations after a 20,000-iteration warmup; reported figure is the best of 40 runs. Million ops/sec, higher is better:

Operation fast_uuid (obj) fast_uuid (proc) ramsey/uuid PECL uuid
v4 gen→string 12.6 19.5 1.10 0.47
v1 gen→string 12.3 16.5 0.29 8.22
v7 gen→string 12.1 19.8 0.66 n/a
parse→16 bytes 23 36 3.18 5.28

Speedup over ramsey/uuid: v4 11.5x to 17.7x, v1 42x to 57x, v7 18.3x to 30x, parse 7.2x to 11.3x.

Generating many at once amortizes the per-call overhead. The batch functions return an array of 100 per call; procedural binary forms (uuid_v4_bin() etc.) skip canonical formatting and return raw 16-byte strings. Million UUIDs/sec:

Batch operation fast_uuid (proc)
uuid_v4_batch 22.5
uuid_v7_batch 52
uuid_v4_bin_batch 25
uuid_v7_bin_batch 79

The fast_uuid operations are fast enough (~50 ns) that scheduler noise dominates a single run, so read the fast_uuid columns as order-of-magnitude, not three-significant-digit (roughly ±10% run-to-run). ramsey/uuid (~900 ns) and PECL (~2 µs) reproduce to within ~3%. Full table, the ARM64/NEON numbers, the timestamp/DateTime API breakdown, and how to reproduce are in BENCHMARKS.md.

Testing

make test                                  # run-tests.php against the built .so

The suite (tests/*.phpt) covers every version, all parse forms, per-version getDateTime, fields/integer, node/clockSeq, the exception hierarchy, the procedural functions, the SIMD formatter, and the full compat layer. Verified green on PHP 8.1 / 8.2 / 8.3 / 8.4 / 8.4-ZTS / 8.5 / 8.6 (0 compiler warnings) and clean under an ASan/UBSan-instrumented build.

Contributing

Build instructions, the stub-to-arginfo workflow, and the test conventions are in CONTRIBUTING.md. Run the suite against more than one PHP version when you touch C, and add an ASan/UBSan run for any change to a parse, format, or generation path.

Security

Report a vulnerability by email to ilia@ilia.ws. Details and scope are in SECURITY.md.

🔗 Native PHP extensions

Companion native PHP extensions:

  • php_excel: native Excel I/O via LibXL. 7-10× faster than PhpSpreadsheet, full XLS/XLSX with formulas, formatting, and styling.
  • mdparser: native CommonMark + GFM markdown parser via md4c. 15-30× faster than pure-PHP libraries.
  • php_clickhouse: native ClickHouse client speaking the wire protocol directly. Picks up where SeasClick left off.
  • pdo_duckdb: PDO driver for DuckDB, analytical SQL in your PHP stack.
  • fastjson: drop-in faster ext/json, backed by yyjson. 6× encode, 2.7× decode, 5× validate.
  • phpser: decoder-optimized binary serializer for cache workloads. Faster than igbinary on packed numerics and DTO batches.
  • fastchart: native chart-rendering extension. 38 chart types behind one fluent OO API, SVG-canonical with PNG/JPG/WebP and optional PDF output.
  • statgrab: system statistics (CPU, memory, disk, network) via libstatgrab, no parsing /proc by hand.
  • phonetic: native phonetic name matching (Double Metaphone, Beider-Morse, Daitch-Mokotoff, NYSIIS, Match Rating), the encoders PHP core lacks.

License

BSD-3-Clause. See LICENSE.

Follow @iliaa on X • Blog • If this sped up your UUID generation, ⭐ star it!