Skip to content

perf(time): cache the IANA UTC offset instead of re-formatting per call - #320

Open
vinay-veerappa wants to merge 1 commit into
LuxAlgo:mainfrom
vinay-veerappa:perf/timezone-offset-cache
Open

vinay-veerappa wants to merge 1 commit into
LuxAlgo:mainfrom
vinay-veerappa:perf/timezone-offset-cache

Conversation

@vinay-veerappa

Copy link
Copy Markdown

The problem

Pine time functions run once per bar, and a script with per-bar day-key logic makes several calls per bar. timestamp(TZ, ...) and year()/month()/dayofmonth()/hour()/dayofweek() with an IANA timezone each resolve that timezone through Intl.DateTimeFormat on every call.

Profiled on a real 10k-bar chart execute (an HTF-EMA-style script making ~15 IANA time calls per bar at global scope; Node ICU):

self time
_timestampFromIANA 58.6%
getDatePartsInTimezone 20.5%
79% of a 33s execute

Why memoizing the formatter is only half of it

Two costs are stacked, and fixing one leaves most of the time on the table:

cost
new Intl.DateTimeFormat(...) ~41 µs
formatter.formatToParts(date) ~3.9 µs
parts read off a cached offset ~0.12 µs

Memoizing the formatter instance (they're pure functions of (locale, options) per ECMA-402) removes the 41 µs and takes the execute 33s → 26s — but timezone math is still 40% of the profile, because formatToParts remains per call.

Caching the resolved offset removes that too: 26s → 15s, with timezone functions dropping to 2% of the profile. Overall 33s → 15s.

src/namespaces/tzOffset.ts is the shared primitive both hot paths reduce to.

How the cache stays exact

An IANA offset is piecewise constant in UTC and the pieces are months long. The cache stores the offset at UTC-midnight boundaries:

  • a UTC day whose two boundaries agree carries that offset throughout, so calendar parts are read arithmetically;
  • a UTC day whose boundaries disagree contains a transition, and every instant in it falls through to the original formatToParts path.

So the cache never approximates across a transition. Adjacent days share a boundary probe, which means one formatToParts per calendar day regardless of bar interval — strictly cheaper than uncached even on a daily chart (one probe per bar versus one per call).

One assumption, stated in the module header: no zone transitions twice within a single UTC day and returns to the same offset. No entry in the IANA database does this.

An hour-bucketed cache would be simpler and is wrongAustralia/Lord_Howe shifts 30 minutes at an instant that isn't on a UTC hour boundary. It's in the test matrix for exactly that reason.

Behaviour

  • _timestampFromIANA keeps its single-pass semantics exactly (a wall-clock time inside a DST gap or fold is genuinely ambiguous, and Pine resolves it this way) — only the offset lookup changed.
  • getDatePartsInTimezone now derives dayOfWeek arithmetically instead of parsing a localized weekday string. Same result, one less locale dependency.

Tests

tests/namespaces/timezone-offset.test.ts uses Intl itself as the oracle and sweeps every hour of eight transition days across six zones — America/New_York, Europe/London, Australia/Sydney, Asia/Kolkata, Asia/Kathmandu, Australia/Lord_Howe — plus minute resolution across the US spring-forward instant, fixed-offset spellings, and the unknown-zone fallback. One case asserts the cached offset equals the uncached offset on a transition day and that the day genuinely straddles a change, so it can't pass vacuously.

tests/namespaces/timezone-perf.test.ts is a regression guard: 35k bars × 5 IANA calls per bar, bounded at 2000ms. ~2890ms unfixed, ~440ms fixed.

Negative control: replacing the boundary comparison with an unconditional day-start offset fails 3 of the 6 correctness cases.

Suite status

main and this branch have the same 139 pre-existing failures:

failed passed
main (beacd58) 139 1744
this branch 139 1752

The +8 are the tests added here. I haven't touched the pre-existing failures — happy to look at them separately if useful.

Notes for review

  • No public API change; no new dependency.
  • src/namespaces/Core.ts already fails prettier --check on main, so I deliberately did not run --write over it — that would have buried this diff in unrelated reformatting. The new files are prettier-clean and my edits add no new violations.
  • CONTRIBUTING asks for a Discussion before architectural changes. I read this as a perf fix to existing functions rather than an architectural one (it doesn't touch the transpiler, Series, or Context), but say the word and I'll move it to a Discussion.

Pine time functions run ONCE PER BAR, and a script with per-bar day-key
logic makes several calls per bar. `timestamp(TZ, ...)` and
`year()/month()/dayofmonth()/hour()/dayofweek()` with an IANA timezone
each resolved that timezone through `Intl.DateTimeFormat` on EVERY call.

Profiled on a real 10k-bar chart execute (HTF_EMA-style script, ~15 IANA
time calls per bar at global scope, Node ICU):

    _timestampFromIANA        58.6% self time
    getDatePartsInTimezone    20.5% self time
                              ---------------
                              79%  of a 33s execute

Two costs are stacked there, and only fixing one leaves most of it:

    new Intl.DateTimeFormat(...)   ~41us     <- construction
    formatter.formatToParts(date)  ~3.9us    <- the formatting itself
    parts read off a cached offset ~0.12us   <- 32x cheaper than either

Memoizing the formatter instance (they are pure functions of (locale,
options) per ECMA-402) removes the 41us and takes the execute 33s -> 26s,
but timezone math is still 40% of the profile because `formatToParts`
remains per call. Caching the resolved OFFSET removes that too: 26s ->
15s, and timezone functions drop to 2% of the profile.

`src/namespaces/tzOffset.ts` is the shared primitive both paths reduce to.

## How the cache stays exact

An IANA offset is piecewise constant in UTC and the pieces are months
long. The cache stores the offset at UTC-midnight boundaries:

* a UTC day whose two boundaries AGREE carries that offset throughout, so
  calendar parts are read arithmetically;
* a UTC day whose boundaries DISAGREE contains a transition, and every
  instant in it falls through to the original `formatToParts` path.

The cache therefore never approximates across a transition. Adjacent days
share a boundary probe, so a forward-marching chart costs one
`formatToParts` per calendar day regardless of bar interval - strictly
cheaper than uncached even on a daily chart (one probe per bar versus one
per call).

The one assumption: no zone transitions twice within a single UTC day and
returns to the same offset. No entry in the IANA database does this.

An hour-bucketed cache would have been simpler and is WRONG:
`Australia/Lord_Howe` shifts 30 minutes at an instant that is not on a UTC
hour boundary. It is in the test matrix for that reason.

## Behaviour

`_timestampFromIANA` keeps its single-pass semantics exactly - a
wall-clock time inside a DST gap or fold is genuinely ambiguous and Pine
resolves it this way - so only the offset lookup changed.
`getDatePartsInTimezone` now derives `dayOfWeek` arithmetically instead of
parsing a localized weekday string; same result, no locale dependency.

## Tests

`tests/namespaces/timezone-offset.test.ts` uses `Intl` itself as the
oracle and sweeps EVERY hour of eight transition days across six zones
(America/New_York, Europe/London, Australia/Sydney, Asia/Kolkata,
Asia/Kathmandu, Australia/Lord_Howe), plus minute resolution across the US
spring-forward instant, plus fixed-offset spellings and the unknown-zone
fallback. One case asserts the cached offset equals the uncached offset on
a transition day AND that the day genuinely straddles a change, so it
cannot pass vacuously.

`tests/namespaces/timezone-perf.test.ts` is a regression guard: 35k bars x
5 IANA calls per bar, bounded at 2000ms. It measures ~2890ms unfixed and
~440ms fixed.

Negative control: replacing the boundary comparison with an unconditional
day-start offset fails 3 of the 6 correctness cases.

Suite: `main` is 139 failed / 1744 passed; this branch is 139 failed /
1752 passed - the same pre-existing failures, plus the 8 tests added here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 20, 2026

Copy link
Copy Markdown

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@vinay-veerappa

Copy link
Copy Markdown
Author

I have read the CLA Document and I hereby sign the CLA

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