diff --git a/Cargo.lock b/Cargo.lock index 749ca1477..3dd52008b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -17,6 +26,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + [[package]] name = "annotate-snippets" version = "0.12.16" @@ -178,6 +193,12 @@ dependencies = [ "displaydoc", ] +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cc" version = "1.2.24" @@ -193,6 +214,33 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "clap" version = "4.5.60" @@ -273,6 +321,46 @@ dependencies = [ "libm", ] +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools 0.10.5", + "num-traits", + "once_cell", + "oorandom", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools 0.10.5", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "databake" version = "0.2.1" @@ -337,7 +425,7 @@ dependencies = [ "diplomat_core", "heck", "indenter", - "itertools", + "itertools 0.14.0", "quote", "serde", "syn 3.0.3", @@ -390,6 +478,17 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.16.0" @@ -413,6 +512,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -565,12 +670,32 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -662,6 +787,12 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "percent-encoding" version = "2.3.1" @@ -706,6 +837,35 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "resb" version = "0.1.2" @@ -728,6 +888,15 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "serde" version = "1.0.228" @@ -862,6 +1031,7 @@ version = "0.2.6" dependencies = [ "calendrical_calculations", "core_maths", + "criterion", "iana-time-zone", "icu_calendar", "icu_locale_core", @@ -930,6 +1100,16 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "toml" version = "0.8.22" @@ -1014,6 +1194,16 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasm-bindgen" version = "0.2.100" @@ -1082,6 +1272,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + [[package]] name = "windows-core" version = "0.61.2" @@ -1261,6 +1460,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "zerofrom" version = "0.1.6" diff --git a/crates/temporal/Cargo.toml b/crates/temporal/Cargo.toml index dde03aa05..d2c863b6a 100644 --- a/crates/temporal/Cargo.toml +++ b/crates/temporal/Cargo.toml @@ -37,6 +37,12 @@ calendrical_calculations = "0.2.4" timezone_provider = { workspace = true, features = ["zoneinfo64", "experimental_tzif"] } zoneinfo64 = { workspace = true } resb = "0.1.0" +criterion = { version = "0.5", default-features = false, features = ["cargo_bench_support"] } + +[[bench]] +name = "zoned_date_time" +harness = false +required-features = ["compiled_data"] [features] default = ["sys-local"] diff --git a/crates/temporal/benches/zoned_date_time.rs b/crates/temporal/benches/zoned_date_time.rs new file mode 100644 index 000000000..0736774b9 --- /dev/null +++ b/crates/temporal/benches/zoned_date_time.rs @@ -0,0 +1,228 @@ +//! Benchmarks for `ZonedDateTime` field access. +//! +//! Mirrors the harness in : every +//! field getter resolves the ISO date-time from the epoch nanoseconds, so reading +//! N fields costs N conversions. The `PlainDateTime` rows are the floor those +//! getters should approach; the construction and arithmetic rows guard against +//! moving that cost onto paths that never read a field. + +use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use temporal::options::{Disambiguation, OffsetDisambiguation}; +use temporal::TimeZone; +use temporal::{Duration, PlainDateTime, ZonedDateTime}; + +/// An IXDTF string carrying both an offset and an IANA annotation, which is the +/// shape an engine actually parses. +const SOURCE: &str = "2025-01-01T00:00:00-05:00[America/New_York]"; + +fn parse(source: &str) -> ZonedDateTime { + ZonedDateTime::from_utf8( + source.as_bytes(), + Disambiguation::Compatible, + OffsetDisambiguation::Reject, + ) + .expect("source must parse") +} + +/// The step between fixtures: an uneven duration that crosses DST boundaries as +/// the series walks forward, so the spread is not all one offset. +fn step() -> Duration { + Duration::new(0, 0, 0, 11, 7, 23, 41, 0, 0, 0).expect("valid duration") +} + +/// A spread of zoned date-times, parsed once and then walked forward by adding a +/// duration, so the benchmark is not dominated by a single offset or code path. +fn zoned_date_times() -> Vec { + let step = step(); + let mut current = parse(SOURCE); + let mut out = Vec::with_capacity(64); + for _ in 0..64 { + out.push(current.clone()); + current = current + .add(&step, None) + .expect("addition must stay in range"); + } + out +} + +/// Reading seven fields — the shape a formatter or `toString` takes. +fn seven_getters(c: &mut Criterion) { + let zdts = zoned_date_times(); + let pdts: Vec = zdts.iter().map(|z| z.to_plain_date_time()).collect(); + + let mut group = c.benchmark_group("seven_getters"); + group.bench_function("zoned_date_time", |b| { + b.iter(|| { + for zdt in &zdts { + let zdt = black_box(zdt); + black_box(( + zdt.year(), + zdt.month(), + zdt.day(), + zdt.hour(), + zdt.minute(), + zdt.second(), + zdt.millisecond(), + )); + } + }) + }); + group.bench_function("plain_date_time", |b| { + b.iter(|| { + for pdt in &pdts { + let pdt = black_box(pdt); + black_box(( + pdt.year(), + pdt.month(), + pdt.day(), + pdt.hour(), + pdt.minute(), + pdt.second(), + pdt.millisecond(), + )); + } + }) + }); + group.bench_function("zoned_via_plain_date_time", |b| { + b.iter(|| { + for zdt in &zdts { + let pdt = black_box(zdt).to_plain_date_time(); + black_box(( + pdt.year(), + pdt.month(), + pdt.day(), + pdt.hour(), + pdt.minute(), + pdt.second(), + pdt.millisecond(), + )); + } + }) + }); + group.finish(); +} + +/// A single getter, separating the time fields (pure ISO arithmetic) from the +/// calendar fields (which additionally build an ICU4X date). +fn single_getter(c: &mut Criterion) { + let zdts = zoned_date_times(); + + let mut group = c.benchmark_group("single_getter"); + group.bench_function("hour", |b| { + b.iter(|| { + for zdt in &zdts { + black_box(black_box(zdt).hour()); + } + }) + }); + group.bench_function("year", |b| { + b.iter(|| { + for zdt in &zdts { + black_box(black_box(zdt).year()); + } + }) + }); + group.finish(); +} + +/// Construction, which an eagerly-computed cache makes more expensive. Parsing is +/// how an engine reaches `ZonedDateTime` from user input. +fn construction(c: &mut Criterion) { + let sources = [ + // Offset plus IANA annotation. + "2025-01-01T00:00:00-05:00[America/New_York]", + // Annotation only, so the offset is resolved from the provider. + "2025-07-04T12:30:45[America/New_York]", + // UTC designator with an annotation. + "2025-03-09T07:00:00Z[America/New_York]", + // Sub-second precision. + "2025-11-02T01:30:00.123456789-05:00[America/New_York]", + ]; + + let mut group = c.benchmark_group("construction"); + group.bench_function("from_utf8", |b| { + b.iter(|| { + for source in &sources { + black_box(parse(black_box(source))); + } + }) + }); + group.finish(); +} + +/// Arithmetic produces a new `ZonedDateTime` without necessarily reading a field, +/// so it is the clearest place an eager cache could regress. +fn arithmetic(c: &mut Criterion) { + let zdts = zoned_date_times(); + let step = step(); + let day = Duration::new(0, 0, 0, 1, 0, 0, 0, 0, 0, 0).expect("valid duration"); + + let mut group = c.benchmark_group("arithmetic"); + group.bench_function("add", |b| { + b.iter(|| { + for zdt in &zdts { + black_box(black_box(zdt).add(&step, None).unwrap()); + } + }) + }); + // Adding a day is the DST-sensitive case: the result's offset may differ from + // the receiver's, so the cached offset and ISO date-time must be recomputed. + group.bench_function("add_one_day", |b| { + b.iter(|| { + for zdt in &zdts { + black_box(black_box(zdt).add(&day, None).unwrap()); + } + }) + }); + // Parse, add, then read a field — the shape a caller that does real work takes, + // so the cache is paid for once and used once. + group.bench_function("parse_add_read", |b| { + b.iter(|| { + let zdt = parse(black_box(SOURCE)) + .add(black_box(&step), None) + .unwrap(); + black_box(zdt.hour()) + }) + }); + group.bench_function("with_timezone", |b| { + let paris = TimeZone::try_from_str("Europe/Paris").expect("tz must resolve"); + b.iter(|| { + for zdt in &zdts { + black_box(black_box(zdt).with_timezone(paris).unwrap()); + } + }) + }); + group.finish(); +} + +/// String output reads every field, so it compounds the per-getter cost. +fn to_string(c: &mut Criterion) { + let zdts = zoned_date_times(); + + c.bench_function("to_ixdtf_string", |b| { + b.iter(|| { + for zdt in &zdts { + black_box( + black_box(zdt) + .to_ixdtf_string( + Default::default(), + Default::default(), + Default::default(), + Default::default(), + ) + .unwrap(), + ); + } + }) + }); +} + +criterion_group!( + benches, + seven_getters, + single_getter, + construction, + arithmetic, + to_string +); +criterion_main!(benches); diff --git a/crates/temporal/src/builtins/core/zoned_date_time.rs b/crates/temporal/src/builtins/core/zoned_date_time.rs index 417d83259..649d41557 100644 --- a/crates/temporal/src/builtins/core/zoned_date_time.rs +++ b/crates/temporal/src/builtins/core/zoned_date_time.rs @@ -257,6 +257,9 @@ pub struct ZonedDateTime { calendar: Calendar, time_zone: TimeZone, cached_offset: UtcOffset, + // We cache the IsoDateTime, because `ZonedDateTime` is immutable, and this will + // speed up getters. + cached_iso: IsoDateTime, } // ==== Private API ==== @@ -271,11 +274,13 @@ impl ZonedDateTime { calendar: Calendar, cached_offset: UtcOffsetSeconds, ) -> Self { + let cached_offset: UtcOffset = cached_offset.into(); Self { - instant, calendar, time_zone, - cached_offset: cached_offset.into(), + cached_iso: cached_offset.get_iso_datetime_for(&instant), + cached_offset, + instant, } } @@ -285,14 +290,15 @@ impl ZonedDateTime { calendar: Calendar, provider: &(impl TimeZoneProvider + ?Sized), ) -> TemporalResult { - let offset = time_zone + let cached_offset = time_zone .get_utc_offset_for(instant.epoch_nanoseconds().0, provider) .temporal_unwrap()?; Ok(Self { - instant, calendar, time_zone, - cached_offset: offset, + cached_iso: cached_offset.get_iso_datetime_for(&instant), + cached_offset, + instant, }) } @@ -300,7 +306,7 @@ impl ZonedDateTime { /// /// (which is GetISODateTimeFor(self.[[TimeZone]], self.[[EpochNanoseconds]]).) pub(crate) fn get_iso_datetime(&self) -> IsoDateTime { - self.cached_offset.get_iso_datetime_for(&self.instant) + self.cached_iso } pub(crate) fn add_zoned_date_time(