Skip to content

Speed up cron next-fire-time computation with a bitmask fast path#3126

Merged
lahma merged 1 commit into
3.xfrom
cronos-perf-compare
Jun 23, 2026
Merged

Speed up cron next-fire-time computation with a bitmask fast path#3126
lahma merged 1 commit into
3.xfrom
cronos-perf-compare

Conversation

@lahma

@lahma lahma commented Jun 23, 2026

Copy link
Copy Markdown
Member

Summary

Inspired by the Cronos library, which represents each cron field as an integer bitmask and finds the next matching value with a trailing-zero bit scan. CronExpression.GetTimeAfter (run once per trigger fire on the scheduler hot path) previously located the next allowed value for each field via SortedSet<int> lookups whose slow path allocates a GetViewBetween view and walks a red-black tree.

This PR adds a non-breaking bitmask fast path:

  • New Quartz.Util.BitUtilTrailingZeroCount via System.Numerics.BitOperations on net8+, with a De Bruijn lookup-table fallback for net462/net472/netstandard2.0.
  • CronExpression keeps its protected SortedSet<int> fields and GetSet/AddToSet exactly as-is (no public/protected API or serialization break), and additionally builds [NonSerialized] bitmask fields from those sets after parsing (BuildBitmasks, called from BuildExpression, so it also runs on deserialize and for subclasses).
  • GetTimeAfter routes its seconds/minutes/hours/months/days lookups through an O(1) bit scan. The years field stays on the set path (open-ended range).

Results

BenchmarkDotNet, AMD Ryzen 9 5950X, .NET 10, ShortRun. Next-occurrence (GetNextValidTimeAfter):

Expression Before After
0 0/5 * * * ? (×100) 48.1 µs / 22,816 B 32.5 µs / 0 B
0/15 * * * * ? (×100) 40.7 µs / 18,600 B 26.5 µs / 0 B
0 0,10,…,50 * * * ? (×100) 44.0 µs / 20,832 B 32.4 µs / 0 B
single call, typical 400–880 ns 6–35% faster, 0 B

The hot path is now allocation-free; expressions with ranges/steps/lists (which hit the old GetViewBetween slow path) see the largest gains. Parsing is intentionally unchanged on this branch (the layered approach keeps the sets); the bitmask-parse win is on the 4.x PR.

External yardstick (CronComparisonBenchmark vs Cronos 0.13.0, equivalent 6-field expressions): Quartz GetNext is now zero-allocation like Cronos; absolute time is still ~8–20× Cronos because the loop constructs many DateTimeOffset values per iteration (a deeper rewrite, out of scope here).

Validation

  • All existing cron tests pass on net10.0 and net472 (the net472 run exercises the De Bruijn fallback): CronExpressionTest, CronExpressionHashTest, CronTriggerTest, CronScheduleBuilderTest — 296 cron cases; full unit suite 1384 green.
  • New CronExpressionDifferentialTest — seeded-random expressions vs an independent brute-force oracle, plus a BitUtil fuzz test, run on both TFMs.
  • Benchmarks restructured (separate parse / next-occurrence / 100-call chain) and a Cronos head-to-head added.

The companion 4.x change is #3127 (puts the bitmask inside CronField, also speeding parse).

🤖 Generated with Claude Code

https://claude.ai/code/session_01WEQMdnqSWJkTN7KDemLtre

CronExpression.GetTimeAfter located the next allowed value for each field
via SortedSet<int> lookups whose slow path allocates a GetViewBetween view
and walks a red-black tree - on the scheduler hot path, once per trigger fire.

Add a parallel bitmask representation of the parsed fields (seconds, minutes,
hours, days-of-month, months, days-of-week), built from the existing SortedSet
fields after parsing, and route the hot lookups through an O(1) trailing-zero
bit scan. The protected SortedSet fields and GetSet/AddToSet stay unchanged,
so there is no public/protected API or serialization break; the years field
stays on the set path because its range is open-ended.

BitUtil provides TrailingZeroCount via System.Numerics.BitOperations on net8+
and a De Bruijn lookup-table fallback for net462/net472/netstandard2.0.

Next-occurrence is now allocation-free (was up to ~23 KB per 100 chained
calls) and 6-35% faster. Adds a randomized differential test and restructures
the cron benchmarks (separate parse / next-occurrence, plus a head-to-head
against the Cronos library).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01WEQMdnqSWJkTN7KDemLtre
@lahma
lahma merged commit ffc5338 into 3.x Jun 23, 2026
16 checks passed
@lahma
lahma deleted the cronos-perf-compare branch June 23, 2026 16:26
@sonarqubecloud

Copy link
Copy Markdown

lahma added a commit that referenced this pull request Jun 25, 2026
Port of the main-branch optimization (#3128) to 3.x. GetTimeAfter rebuilt a
DateTimeOffset after every field even when the field already matched - and since
each d.Year / d.Month / d.Day / ... access re-decomposes the tick count, an
unchanged rebuild cost several decompositions plus a recomposition. In the common
steady state most fields are already satisfied, so this was pure waste.

- The second / minute / hour / day / month / year sections now skip rebuilding
  the date when the field did not change. The value they used to rebuild is
  provably identical to the existing one (same components, milliseconds already
  stripped at loop entry, same offset), so behavior is unchanged.
- BitUtil.TrailingZeroCount / PopCount / TryGetMinValueStartingFrom are marked
  AggressiveInlining; the dead "start outside [0,63]" guard on the hottest method
  becomes a Debug.Assert (callers always pass an in-range value).

Next-occurrence computation is 27-36% faster (largest on day-of-week, L and
year-constrained expressions, which iterate the loop more). All 1401 unit tests
pass on net10.0 and net472 (the net472 run exercises the De Bruijn fallback),
including the randomized differential test added in #3126.

Note: 3.x's L/W day-of-month handling is already allocation-free (it computes the
day inline, with no per-month SortedSet), so the companion L/W zero-allocation
change in #3128 has no 3.x counterpart.


Claude-Session: https://claude.ai/code/session_01WEQMdnqSWJkTN7KDemLtre

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
This was referenced Jun 27, 2026
This was referenced Jun 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant