From 0a582fff2dd0b70316a04f23320b8677d39cde75 Mon Sep 17 00:00:00 2001 From: Justin Grant Date: Thu, 8 Jul 2021 18:52:09 -0700 Subject: [PATCH] Speed up non-ISO calendar tests about 6x I was wrong about what was making non-ISO calendars so slow. I thought the problem was `formatToParts()`, but it turns out that the `DateTimeFormat` constructor is really slow and also allocates ridiculous amounts of RAM. See more details here: https://bugs.chromium.org/p/v8/issues/detail?id=6528 @littledan in https://bugs.chromium.org/p/v8/issues/detail?id=6528#c4 recommended to cache DateTimeFormat instances, so that's what this commit does. The result is a 6x speedup in non-ISO calendar tests. Before: 6398.83ms After: 1062.26ms A similar speedup is likely for `ES.GetCanonicalTimeZoneIdentifier`. Caching time zone canonicalization (in a separate PR) should have a big positive impact on ZonedDateTIme and TimeZone perf. Many thanks to @fer22f for uncovering this optimization in https://github.com/js-temporal/temporal-polyfill/issues/7. --- polyfill/lib/calendar.mjs | 49 +++++++++++++++++++++++---------------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/polyfill/lib/calendar.mjs b/polyfill/lib/calendar.mjs index 8659de5cfe..0059fd5b72 100644 --- a/polyfill/lib/calendar.mjs +++ b/polyfill/lib/calendar.mjs @@ -7,6 +7,10 @@ import { CALENDAR_ID, ISO_YEAR, ISO_MONTH, ISO_DAY, CreateSlots, GetSlot, HasSlo const ArrayIncludes = Array.prototype.includes; const ArrayPrototypePush = Array.prototype.push; const ObjectAssign = Object.assign; +const ObjectEntries = Object.entries; +const IntlDateTimeFormat = globalThis.Intl.DateTimeFormat; +const MathAbs = Math.abs; +const MathFloor = Math.floor; const impl = {}; @@ -427,19 +431,31 @@ function simpleDateDiff(one, two) { */ const nonIsoHelperBase = { // The properties and methods below here should be the same for all lunar/lunisolar calendars. + getFormatter() { + // `new Intl.DateTimeFormat()` is amazingly slow and chews up RAM. Per + // https://bugs.chromium.org/p/v8/issues/detail?id=6528#c4, we cache one + // DateTimeFormat instance per calendar. Caching is lazy so we only pay for + // calendars that are used. Note that the nonIsoHelperBase object is spread + // into each each calendar's implementation before any cache is created, so + // each calendar gets its own separate cached formatter. + if (typeof this.formatter === 'undefined') { + this.formatter = new IntlDateTimeFormat(`en-US-u-ca-${this.id}`, { + day: 'numeric', + month: 'numeric', + year: 'numeric', + era: this.eraLength, + timeZone: 'UTC' + }); + } + return this.formatter; + }, isoToCalendarDate(isoDate, cache) { let { year: isoYear, month: isoMonth, day: isoDay } = isoDate; const key = JSON.stringify({ func: 'isoToCalendarDate', isoYear, isoMonth, isoDay, id: this.id }); const cached = cache.get(key); if (cached) return cached; - const dateTimeFormat = new Intl.DateTimeFormat(`en-US-u-ca-${this.id}`, { - day: 'numeric', - month: 'numeric', - year: 'numeric', - era: this.eraLength, - timeZone: 'UTC' - }); + const dateTimeFormat = this.getFormatter(); let parts, isoString; try { isoString = toUtcIsoDateString({ isoYear, isoMonth, isoDay }); @@ -763,7 +779,7 @@ const nonIsoHelperBase = { }, addMonthsCalendar(calendarDate, months, overflow, cache) { const { day } = calendarDate; - for (let i = 0, absMonths = Math.abs(months); i < absMonths; i++) { + for (let i = 0, absMonths = MathAbs(months); i < absMonths; i++) { const days = months < 0 ? -this.daysInPreviousMonth(calendarDate, cache) : this.daysInMonth(calendarDate, cache); const isoDate = this.calendarToIsoDate(calendarDate, 'constrain', cache); const addedIso = this.addDaysIso(isoDate, days, cache); @@ -970,7 +986,7 @@ const helperHebrew = ObjectAssign({}, nonIsoHelperBase, { minMaxMonthLength(calendarDate, minOrMax) { const { month, year } = calendarDate; const monthCode = this.getMonthCode(year, month); - const monthInfo = Object.entries(this.months).find((m) => m[1].monthCode === monthCode); + const monthInfo = ObjectEntries(this.months).find((m) => m[1].monthCode === monthCode); if (monthInfo === undefined) throw new RangeError(`unmatched Hebrew month: ${month}`); const daysInMonth = monthInfo[1].days; return typeof daysInMonth === 'number' ? daysInMonth : daysInMonth[minOrMax]; @@ -1096,7 +1112,7 @@ const helperIslamic = ObjectAssign({}, nonIsoHelperBase, { constantEra: 'ah', estimateIsoDate(calendarDate) { const { year } = this.adjustCalendarDate(calendarDate); - return { year: Math.floor((year * this.DAYS_PER_ISLAMIC_YEAR) / this.DAYS_PER_ISO_YEAR) + 622, month: 1, day: 1 }; + return { year: MathFloor((year * this.DAYS_PER_ISLAMIC_YEAR) / this.DAYS_PER_ISO_YEAR) + 622, month: 1, day: 1 }; } }); @@ -1587,7 +1603,7 @@ const helperChinese = ObjectAssign({}, nonIsoHelperBase, { calendarType: 'lunisolar', inLeapYear(calendarDate, cache) { const months = this.getMonthList(calendarDate.year, cache); - return Object.entries(months).length === 13; + return ObjectEntries(months).length === 13; }, monthsInYear(calendarDate, cache) { return this.inLeapYear(calendarDate, cache) ? 13 : 12; @@ -1601,14 +1617,7 @@ const helperChinese = ObjectAssign({}, nonIsoHelperBase, { const key = JSON.stringify({ func: 'getMonthList', calendarYear, id: this.id }); const cached = cache.get(key); if (cached) return cached; - const dateTimeFormat = new Intl.DateTimeFormat(`en-US-u-ca-${this.id}`, { - day: 'numeric', - month: 'numeric', - year: 'numeric', - era: 'short', - timeZone: 'UTC' - }); - + const dateTimeFormat = this.getFormatter(); const getCalendarDate = (isoYear, daysPastFeb1) => { const isoStringFeb1 = toUtcIsoDateString({ isoYear, isoMonth: 2, isoDay: 1 }); const legacyDate = new Date(isoStringFeb1); @@ -1723,7 +1732,7 @@ const helperChinese = ObjectAssign({}, nonIsoHelperBase, { } } else if (monthCode === undefined) { const months = this.getMonthList(year, cache); - const monthEntries = Object.entries(months); + const monthEntries = ObjectEntries(months); const largestMonth = monthEntries.length; if (overflow === 'reject') { ES.RejectToRange(month, 1, largestMonth);