Skip to content

perf(quickjs): cache Date timezone offset lookups - #1687

Open
zzjjob wants to merge 2 commits into
quickjs-ng:masterfrom
zzjjob:perf/date-timezone-cache
Open

perf(quickjs): cache Date timezone offset lookups#1687
zzjjob wants to merge 2 commits into
quickjs-ng:masterfrom
zzjjob:perf/date-timezone-cache

Conversation

@zzjjob

@zzjjob zzjjob commented Aug 23, 2026

Copy link
Copy Markdown

Summary

Date.prototype.getTimezoneOffset() (and anything that formats a local date string) recomputes the timezone offset on every call via GetTimeZoneInformation() on Windows or localtime_r() on Unix. Both are slow system calls. This PR caches the offset per runtime.

Changes

  • Add a small per-runtime ring cache (JSTimezoneOffsetCache, 8 entries) keyed by a TZ hash with a 1s TTL. Stable UTC hours collapse into one cached range; across DST boundaries it falls back to caching the exact second.
  • Thread JSRuntime through getTimezoneOffset and set_date_fields so the cache is reachable.
  • Read getenv("TZ") only on cache miss, not on every lookup: on some platforms getenv is surprisingly slow (measured ~126 us here). A TZ change is therefore picked up at most 1s later, matching the cache TTL.
  • Add a focused regression test tests/date-timezone-cache.js covering repeated lookups, local-string round trips, Date limit values, and TZ changes (skipped on Windows, where GetTimeZoneInformation reads the registry and does not honor TZ).

Results

Measured on Windows x64 (MSVC):

operation before after
getTimezoneOffset() 563 ns 126 ns
Date.prototype.toString() 1131 ns 613 ns

Measured on macOS (Apple M2, CMake Release build, median of 3 runs):

operation before after
getTimezoneOffset() 130 ns 40 ns
Date.prototype.toString() 430 ns 335 ns
Date.parse() (local string) ~200 ns ~145 ns
upstream date_parse microbench (bellard/quickjs) 485 ns 452 ns

Notes

  • make test: 0/111 errors (Windows x64 and macOS).
  • The getenv("TZ")-on-miss behavior is a deliberate deviation from reading it on every lookup: it trades up-to-1s-late TZ detection for avoiding the slow getenv per call. Happy to restore per-lookup detection if you'd prefer.

The timezone offset for a Date is recomputed on every lookup via
GetTimeZoneInformation() (Windows) or localtime_r() (Unix), both of
which are slow system calls. Cache it per runtime in a small ring
buffer keyed by a TZ hash with a 1s TTL, collapsing stable hours and
falling back to an exact second across DST boundaries.

Thread the JSRuntime through getTimezoneOffset and set_date_fields so
the cache is reachable. getenv("TZ") is read only on cache miss since
it can be surprisingly slow on some platforms; a TZ change is picked
up at most 1s later, matching the cache TTL.

Adds a focused regression test in tests/date-timezone-cache.js.

Measured on Windows x64 (MSVC): getTimezoneOffset 563 -> 126 ns
(-76%), Date.prototype.toString 1131 -> 613 ns (-46%).
@saghul

saghul commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

What kind of use case do you have where this is somehow a problem?

@zzjjob

zzjjob commented Aug 24, 2026

Copy link
Copy Markdown
Author

Hi @saghul, thanks for the question.

The motivation actually came from the project's own benchmark: upstream tests/microbench.js (bellard/quickjs) has a date_parse entry that round-trips local date strings through toString() / Date.parse(), and on my Windows setup it dominated the whole suite (~17.8 µs/op baseline). Each round trip was paying for a GetTimeZoneInformation (Windows) / localtime_r (Unix) call — pure overhead for what is otherwise a bit of arithmetic.

Where this shows up in real code: loggers stamping every line with a local timestamp, telemetry/metrics exporters stamping every sample, dashboards/feeds rendering one timestamp per row — all calling Date.prototype.toString() / getTimezoneOffset() per item. On Unix there's an extra wrinkle: glibc's tz conversion path takes a global lock (BZ 16145), so repeated calls serialize threads in multi-threaded runtimes.

Measured results:

Windows x64 (MSVC):

operation before after
getTimezoneOffset() 563 ns 126 ns
Date.prototype.toString() 1131 ns 613 ns

macOS (Apple M2, release build, median of 3 runs):

operation before after
getTimezoneOffset() 130 ns 40 ns
Date.prototype.toString() 430 ns 335 ns
Date.parse() (local string) ~200 ns ~145 ns
upstream date_parse microbench 485 ns 452 ns

The change is small and self-contained (~100 lines + one regression test) and keeps the raw path as fallback. Per the description: if the up-to-1s delay on TZ pickup is a concern, I'm happy to restore per-lookup detection.

@zzjjob
zzjjob force-pushed the perf/date-timezone-cache branch from 9add32e to dd57d02 Compare August 24, 2026 08:35
@saghul

saghul commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

My fear is that this may cause problems in long running processes when DST changes for example.

@bnoordhuis thoughts on this?

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.

2 participants