Skip to content

Speed up ULID parsing, generation, and encoding; fix related correctness bugs - #104

Merged
Seramis merged 16 commits into
ByteAether:mainfrom
Seramis:performance_optimizations
Oct 10, 2026
Merged

Seramis merged 16 commits into
ByteAether:mainfrom
Seramis:performance_optimizations

Conversation

@Seramis

@Seramis Seramis commented Oct 10, 2026 •

Copy link
Copy Markdown
Member

Description

Performance-focused pass over the core Ulid type's hot paths (parsing, string encoding/decoding, generation, timestamp arithmetic, locking), bundled with several correctness fixes surfaced along the way. README.md updated with latest benchmark results.

Performance:

  • Base32 encoding/decoding now uses two 64-bit loads instead of 16 byte loads, with the inverse table stored as RVA data.
  • MinAt/MaxAt/MaxValue are built without static byte arrays.
  • Generator state is kept in numeric order, removing byte swaps from the same-millisecond increment path.
  • Timestamp arithmetic uses unsigned division for "now", and a single 64-bit load (instead of 6 byte loads) to read a ULID's timestamp.
  • Lock contention: switched to TTAS spinning (spinners no longer steal the lock's cache line via CAS) and padded generator state against neighboring objects.
  • Scalar GUID shuffle no longer allocates.
  • Dropped AggressiveOptimization letting tiered PGO optimize hot paths and removing per-TFM #if blocks. Moved SkipLocalsInit to module-level.
  • GenerationOptions copies now get independent lock/state instead of sharing it with the source. Monotonicity is validated via range check (More AOT friendly), and the random increment mask is precomputed when set.

Correctness fixes:

  • Parse accepted invalid, non-ASCII, and overflowing input, and TryParse relied on exceptions internally. Both are fixed, with characters decoded and validated one by one.
  • IsValid(ReadOnlySpan<byte>) only checked length (16 bytes) and didn't validate UTF-8 content like Parse/TryParse do. Validation rules are now documented.
  • New(timestamp, random) didn't validate the random span length, allowing reads past the end for shorter spans.
  • Fixed GC holes in AsByteSpan and a lock scope where spans from untracked pointers could outlive a GC move. netstandard2.0's AsByteSpan now copies.
  • UlidJsonConverter didn't support escaped JSON strings.
  • netstandard2.0 RNG shims didn't return rented arrays.
  • Fixed a benchmark bug where GetNextIndex only alternated between the first two entries and all buffers were shared, so most compared pairs were identical.

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update
  • Other (please specify): Performance optimizations, with accompanying correctness fixes

Checklist

  • The PR is submitted to the correct branch (main).
  • My code follows the project's coding style. (.editorconfig)
  • I have commented my code, particularly in hard-to-understand areas and public interfaces.
  • I have added or updated tests for the changes I made.
  • All new and existing tests passed.
  • I have updated the documentation where applicable.

Additional Notes

No public API breaking changes.
This is primarily an internal hot-path rewrite plus validation fixes that tighten previously-loose input handling (Parse, TryParse, IsValid, New(timestamp, random)).

@Seramis Seramis self-assigned this Oct 10, 2026
@Seramis Seramis added enhancement New feature or request pkg:Ulid ByteAether.Ulid labels Oct 10, 2026
GetNextIndex only alternated between the first two entries, and all
buffers were shared, so most compared pairs were identical.
Spans created from untracked pointers could outlive a GC move of
the underlying object. netstandard2.0 AsByteSpan now copies.
@Seramis
Seramis force-pushed the performance_optimizations branch from 8739214 to 8ecf7ef Compare October 10, 2026 12:21
@Seramis Seramis added the documentation Improvements or additions to documentation label Oct 10, 2026
@Seramis
Seramis marked this pull request as ready for review October 10, 2026 12:23
Shorter spans read past their end. Take ReadOnlySpan<byte> as documented.
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.
Also define the SIMD shuffle mask once.
Lets tiered PGO optimize hot paths and removes per-TFM #if blocks.
Compute the current timestamp with unsigned division, and read a
ULID's timestamp with a single 64-bit load instead of 6 byte loads.
Spinners no longer steal the lock's cache line with CAS, and the
state's cache lines are padded against neighboring objects.
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.
Removes byte swaps from the same-millisecond increment path.
Encode from two 64-bit loads instead of 16 byte loads, and store the
inverse Base32 table as RVA data.
It only checked for a 16-byte length, unlike Parse/TryParse on the
same type. Document the validation rules on IsValid.
@Seramis
Seramis force-pushed the performance_optimizations branch from 8ecf7ef to ed5370a Compare October 10, 2026 12:38
@Seramis
Seramis merged commit 31120c4 into ByteAether:main Oct 10, 2026
5 checks passed
@Seramis
Seramis deleted the performance_optimizations branch October 10, 2026 12:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation enhancement New feature or request pkg:Ulid ByteAether.Ulid

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant