hermes: Date 800x slower than JSC

Bug Description

Date so slow that it can be freeze app. Impossible to use third party date library(much worse perf, 1800x). I found that local date is a problem. UTC date still slow but not 800x, just 8x slower than JSC which can be ignored.

The code outputs: JSC: 0.2605330003425479 Hermes: 166.44708999991417 Hermes UTC: 1.6460870001465082

  • I have run gradle clean and confirmed this bug does not occur with JSC

Hermes version: Bundled version with RN 0.71.3 React Native version (if any): 0.71.3 OS version (if any): iPhone 14 (simulator) Platform (most likely one of arm64-v8a, armeabi-v7a, x86, x86_64):

Steps To Reproduce

  1. Create initial app with latest RN version.
  2. Add somewhere the code that down below. Look console to see the numbers.

code example:

var a = performance.now();
Array(1000)
  .fill(0)
  .map(() => {
    new Date(new Date().setMonth(1));
    // fast utc version 
    // new Date(new Date().setUTCMonth(1));
  });
var b = performance.now();

console.log(b - a);

The Expected Behavior

Fast Date operations.

About this issue

  • Original URL
  • State: open
  • Created a year ago
  • Reactions: 28
  • Comments: 58 (21 by maintainers)

Most upvoted comments

Hmm, unfortunately Hermes doesn’t own a thread or an NSOperationQueue. It executes in the context of an arbitrary thread provided by an integrator. Adding functionality like this is never as simple as it appears.

So, let’s clarify the scope of the problem. Other engines like v8 and JSC cache the timezone permanently with no ability to update it, even if the system timezone changes. Apparently RN developers find that behavior acceptable. It seems like we can duplicate that behavior in Hermes. Is that a reasonable conclusion?

Any updates on this? Still blocking us from switching to hermes

I ran into the same performance issues when dealing with dates on the iOS simulator and investigated a bit. I’ll share some of my learnings:

tzset() is the culprit

I’ve reproduced the issue by making a native app that calls Hermes’ localTime() in a loop. I’ve used the following code to measure performance:

#import "DateUtil.h"

double getTime(void) {
    struct timeval tv;
    gettimeofday(&tv, NULL);
    return (double)(tv.tv_sec) * 1000 + (double)(tv.tv_usec) / 1000;
}

int main(int argc, char * argv[]) {
    double t1 = getTime();
    for (int i = 0; i < 100000; i++) {
        localTime(getTime());
    }
    double t2 = getTime();
    printf("Duration: %lf\n", t2 - t1);
}

Results:

  • When executed as a macOS app, it takes 43ms to complete
  • When executed as an iOS app on a simulator, it takes 5011ms to complete (116x slower)
  • Nearly all that time is spent running tzset(), either called explicitly by DateUtil here, or indirectly by C stdlib’s localtime().
  • Optimizations:
    • we don’t need to explicitly call tzset() as it’s called anyway by localtime() (see here). It should make the code run twice as fast.
    • localTZA() can easily be cached as well as it doesn’t rely on a timestamp. It should lead to an additional 25% improvement.

On caching

Javascript Core implements caching of local time offsets here. The cache is invalidated if consecutive dates are separated by more than one month from each other. I’ve benchmarked the impact on cache using this code, executed on an iOS simulator from a React Native app with JSC enabled:

    const iters = 1000000;
    const msPerHour = 60 * 60 * 1000;
    const msPer2Months = 2 * 30 * 24 * msPerHour;
    const t1 = performance.now();
    for (let i = 0; i < iters; i++) {
      // Cache will be effective as the dates are separated by just one hour
      const date = new Date(1696830297000 + i * msPerHour);
      date.getHours();
    }
    const t2 = performance.now();
    for (let i = 0; i < iters; i++) {
      // Cache will be invalidated each time as the dates are separated by 2 months
      const date = new Date(1696830297000 + i * msPer2Months);
      date.getHours();
    }
    const t3 = performance.now();
    console.log('Benchmark results:', t2 - t1, t3 - t2);

Results:

  • Benchmark results: 66.47054199874401 387.2435830011964
  • Cache results in 6x improved performances, in the best case scenario where dates are close enough to each other.
  • Even uncached performance is far faster than Hermes. It’s because JavascriptCore doesn’t call localtime() (confirmed by adding breakpoints) but relies on ICU for timezones instead.

Conclusion: Even if a cache can definitely help, I think the way to go is to ditch localtime() for timezone calculation as it is very slow on the iOS simulator. Or we need to find a way to disable tzset() from being called each time.

We have exactly the same issue. Because of it we cannot switch to Hermes engine unfortunately.

I run this function in a sorting loop (~500 items in a list)

const isSameDay = (date1: Date, date2: Date) =>
  date1.getFullYear() === date2.getFullYear() &&
  date1.getMonth() === date2.getMonth() &&
  date1.getDate() === date2.getDate();

Here is a preview:

<div> </div>

And then I tried to use isSame function form dayjs and it took ~15500ms!!! to complete sorting

Hello 👋🏻 @tmikov, following our discussion during React Native Europe, I’d love to keep this moving. Can you clarify if:

  • This is being worked on internally
  • This needs to be contributed from community
  • You mentioned opening a discussion, should we still do it? Also I’m happy to provide more reproductions if that would help, so, let me know 😃 .

@Titozzz thanks for posting here! Here are my initial comments:

  • This is not being worked on currently, though we are fixing a bug in that area (https://github.com/facebook/hermes/issues/1121) and are planning to eventually work on it. It is a real problem that must be fixed.
  • It would be great if it could be contributed by the community.
  • I think this issue is a good place for a discussion. I know I mentioned it, but I am not sure whether a discussion provides advantages compared to just using the existing issue.

If this could be a community contribution, we should discuss the approach in detail ahead of time.

As I mentioned before, the main problem here is caching. Caching of the time range with a fixed offset surrounding a given timestamp. So for example if we are working with a timestamp on Aug 15th 2010 and DST starts on Sep 15th and ends June 1st, then the range is from June 1st 2010 to Sep 15th 2010 (with hours and minutes, of course). Once we have that, we can convert times in that range to UTC and back very efficiently. Of course we should cache multiple of these for multiple timezones. I am simplifying a bit, but I hope this gives enough of initial context. Plus, keep in mind that I am not an expert on this, not even a little bit!

Additionally there should be probably be another layer of cache for a certain number of timestamps, since it is likely that the same timestamp will be operated on multiple times, for example when sorting. This is an optional improvement that can be added later.

So, there seem to be two approaches for achieving this:

  1. Use the system APIs on every platform to determine the start and end of these time ranges. We believe (or may be hope would be a more accurate word) that there exist platform APIs on each of our target platforms that can provide this data. Determining that requires some research. If the APIs are dramatically different between platforms, it may be impossible to abstract and may require separate implementations. It is hard to say at this moment.

  2. Package a part of the Olson timezone database inside Hermes. The database contains this information, so we would not need platform APIs. There however are a few complications with this approach: – The Olson db is large and we can’t package all of it with Hermes. That means that we would have to resort to the existing slow algorithm for dates that are beyond the packaged range. Or perhaps we can provide the option of a separate .so that contains the entire DB and users can add it to Hermes if they need it. – This runs the risk of having subtle incompatibilities with the platform APIs and the Intl library which uses those. – The Olson DB changes all the time (multiple times per year), so the process of obtaining and incorporating it in Hermes should be automated.

I hope this gives you an idea of what is necessary and why we haven’t fixed it yet…

@tmikov Most of the logic uses UTC, but we also use libraries like date-fns that might or might not use Date instances under the hood. As others have said however, it doesn’t really matter – it’s not an app problem since the app is fine on JSC and v8, it’s a hermes problem. If using particular date libraries or accidentally doing some date calculations using Date objects instead of UTC timestamps can cause such a massive slowdown that’s a serious bug in hermes, you can’t expect app/library developers to work around that just for hermes.

@tmikov you can’t control how many calls / sec are out there. Most modern apps use translation and text formatting libraries which rely heavily on Date APIs. UUID, crypto key generation or time stamping rely on Date APIs. Just a simple sorting algorithm for a small item list can freeze your app with hermes.js when date comparison is involved.

This is why Date needs to be blazing fast. It is used everywhere.

@tmikov can’t speak for others but it seems ok for our case.

@evelant I was hoping to avoid such things, but it seems there is no other solution but for now(). Haha, get it?.. Thank you for your advice, I’ll just have to start this painstakingly task.

FYI, we are working on this. Asking questions and commenting here doesn’t mean we are waiting.

@tmikov if relying on timezone change events is a deviation from the standard behaviour, then trying to debounce the timezone info every second could be an acceptable compromise solution which could alleviate the perf bottleneck and is better than JSC or v8 since these two won’t update the timezone data at all.

Also the timezone debouncing solution can be easily exposed through a hermes runtime config flag and then you can keep both behaviours and let the user decide if they need super accurate timezone info or they can settle for something less accurate but more performant

@tmikov this is a major problem for anything which relies on dates to generate unique identifiers or localized translations…

Both iOS and android have mechanisms to notify the app when there is a timezone change. https://developer.apple.com/documentation/uikit/uiapplicationdelegate/1622992-applicationsignificanttimechange https://developer.android.com/reference/android/content/Intent#ACTION_TIMEZONE_CHANGED

IMO, caching the timeszone info and then updating that info only when there is an actual timezone change could be a potential solution here.

I think this seems like the most likely culprit destroying my app performance with hermes. We have a fair amount of date logic since part of the app is a todo list. The app runs 4-6x slower on hermes than on JSC or v8.

Hopefully this can get bumped up in priority. It seems like it’s impacting a fair number of projects. There are probably many projects using hermes by default now that are suffering poor performance because of this.

Perhaps some basic performance tests should be added to the hermes test suite to hopefully catch app breaking performance regressions like this before they get out to production apps.

In our case DayJS was the problem, so I made some quick performance improvments and it now runs 1000x faster on iOS simulator:

diff --git a/node_modules/dayjs/esm/index.js b/node_modules/dayjs/esm/index.js
index a82986b..1fb8898 100644
--- a/node_modules/dayjs/esm/index.js
+++ b/node_modules/dayjs/esm/index.js
@@ -117,17 +117,101 @@ var Dayjs = /*#__PURE__*/function () {
 
   _proto.init = function init() {
     var $d = this.$d;
-    this.$y = $d.getFullYear();
-    this.$M = $d.getMonth();
-    this.$D = $d.getDate();
-    this.$W = $d.getDay();
-    this.$H = $d.getHours();
-    this.$m = $d.getMinutes();
-    this.$s = $d.getSeconds();
-    this.$ms = $d.getMilliseconds();
+    // this.$y = $d.getFullYear();
+    // this.$M = $d.getMonth();
+    // this.$D = $d.getDate();
+    // this.$W = $d.getDay();
+    // this.$H = $d.getHours();
+    // this.$m = $d.getMinutes();
+    // this.$s = $d.getSeconds();
+    // this.$ms = $d.getMilliseconds();
   } // eslint-disable-next-line class-methods-use-this
   ;
 
+  // Avoid running native Date functions like getFullYear until needed because these
+  // functions are super slow in Hermes. Then run once and cache since Dayjs is immutable.
+
+  Object.defineProperty(Dayjs.prototype, '$y', {
+    get() {
+      if (!this.cached_y) {
+        this.cached_y = this.$d.getFullYear();
+      }
+      return this.cached_y;
+    }
+  });
+
+  Object.defineProperty(Dayjs.prototype, '$M', {
+    get() {
+      if (!this.cached_M) {
+        this.cached_M = this.$d.getMonth();
+      }
+      return this.cached_M;
+    }
+  });
+
+  Object.defineProperty(Dayjs.prototype, '$D', {
+    get() {
+      if (!this.cached_D) {
+        this.cached_D = this.$d.getDate();
+      }
+      return this.cached_D;
+    }
+  });
+
+  Object.defineProperty(Dayjs.prototype, '$W', {
+    get() {
+      if (!this.cached_W) {
+        this.cached_W = this.$d.getDay();
+      }
+      return this.cached_W;
+    }
+  });
+
+  Object.defineProperty(Dayjs.prototype, '$H', {
+    get() {
+      if (!this.cached_H) {
+        this.cached_H = this.$d.getHours();
+      }
+      return this.cached_H;
+    }
+  });
+
+  Object.defineProperty(Dayjs.prototype, '$m', {
+    get() {
+      if (!this.cached_m) {
+        this.cached_m = this.$d.getMinutes();
+      }
+      return this.cached_m;
+    }
+  });
+
+  Object.defineProperty(Dayjs.prototype, '$s', {
+    get() {
+      if (!this.cached_s) {
+        this.cached_s = this.$d.getSeconds();
+      }
+      return this.cached_s;
+    }
+  });
+
+  Object.defineProperty(Dayjs.prototype, '$ms', {
+    get() {
+      if (!this.cached_ms) {
+        this.cached_ms = this.$d.getMilliseconds();
+      }
+      return this.cached_ms;
+    }
+  });
+
+  Object.defineProperty(Dayjs.prototype, '$timezoneOffset', {
+    get() {
+      if (!this.cached_timezoneOffset) {
+        this.cached_timezoneOffset = this.$d.getTimezoneOffset();
+      }
+      return this.cached_timezoneOffset
+    }
+  });
+
   _proto.$utils = function $utils() {
     return Utils;
   };
@@ -137,15 +221,27 @@ var Dayjs = /*#__PURE__*/function () {
   };
 
   _proto.isSame = function isSame(that, units) {
+    if (units === undefined) {
+      return this.toISOString() === dayjs(that).toISOString()
+    }
+
     var other = dayjs(that);
     return this.startOf(units) <= other && other <= this.endOf(units);
   };
 
   _proto.isAfter = function isAfter(that, units) {
+    if (units === undefined) {
+      return this.toISOString() > dayjs(that).toISOString()
+    }
+
     return dayjs(that) < this.startOf(units);
   };
 
   _proto.isBefore = function isBefore(that, units) {
+    if (units === undefined) {
+      return this.toISOString() < dayjs(that).toISOString()
+    }
+
     return this.endOf(units) < dayjs(that);
   };
 
@@ -408,7 +504,7 @@ var Dayjs = /*#__PURE__*/function () {
   _proto.utcOffset = function utcOffset() {
     // Because a bug at FF24, we're rounding the timezone offset around 15 minutes
     // https://github.com/moment/moment/pull/1871
-    return -Math.round(this.$d.getTimezoneOffset() / 15) * 15;
+    return -Math.round(this.$timezoneOffset / 15) * 15;
   };
 
   _proto.diff = function diff(input, units, _float) {

@rrebase I was able to simplify your example to the following:

const t0 = Date.now();
for (let i = 0; i < 500; i++) {
  new Date('2021-09-15T18:30:00.000Z').toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
const t1 = Date.now();
(typeof print !== "undefined" ? print: console.log)(`Call took ${t1 - t0} milliseconds.`);

Running this simplified test, v8 takes about 30 ms, while Hermes takes 230 ms.

This test exercises only the performance of Date.prototype.toLocareTimeString(), which when Intl is enabled is provided by Intl. FWIW, MDN says that when calling Date.prototype.toLocareTimeString() repeatedly, “it is better to create an Intl.DateTimeFormat object and use its format() method”.

So, I modified the example in the following way:

const t0 = Date.now();
const df = new Intl.DateTimeFormat('en-US', { hour: '2-digit', minute: '2-digit' });
for (let i = 0; i < 500; i++) {
  df.format(new Date('2021-09-15T18:30:00.000Z'));
}
const t1 = Date.now();
(typeof print !== "undefined" ? print: console.log)(`Call took ${t1 - t0} milliseconds.`);

With this change both v8 and Hermes take less than 15ms.

My conclusion is that Date.prototype.toLocaleTimeString() in Hermes can indeed benefit from caching some Intl structures, but it seems like the same result can be achieved manually.

Thank you for the quick response @tmikov! I just wonder how people handle it in their projects at the moment (don’t use Hermes?). It’s quite hard to find any opened issues or articles about the problem but I think almost every project has some interactions with dates

The “solution” in our case is indeed not using Hermes atm.

@TiStyle I refactored all of my code to avoid Date instances (except for display in the UI). I just use plain numbers and do simple math when I need to add/subtract/calculate things. Use Date.now() to get a number instead of a Date.

@burakgormek Standalone examples like this are exactly what we need, thank you!

Now, I tried your test and got somewhat different results:

$ v8 --jitless index.js
Warning: disabling flag --expose_wasm due to conflicting flags
ms 3

$ hermes-rel index.js
ms 12

Hermes is slower, but not 70 times. When I increase the loop count from 1000 to 100,000, the difference becomes more pronounced:

$ v8 --jitless index.js
Warning: disabling flag --expose_wasm due to conflicting flags
ms 74

$ hermes index.js
ms 497

So, Hermes is about 7x slower than jitless v8 on this code, which is pretty bad. I think this is something that we can work with, I will examine what is going on closer and report back here.

First some background.

I hope everyone understands that running Hermes in “React Native” and running on desktop should be exactly the same thing, especially when using Arm64 devices. The desktop is faster than a phone, sometimes 20x faster, but the relative performance differences between engines are the same. If Hermes is 2x slower than v8 on desktop, it will be about 2x slower than v8 on a mobile device.

When we are debugging an issue, be it performance or correctness, we don’t want to debug React Native, which is large and complex. This repository is for the Hermes JavaScript engine, not for React Native, the Hermes team consists of compiler engineers with little expertise in React Native, so we don’t really have the ability to debug React Native problems. Thus we need isolated examples, not entire RN apps.

Since most people don’t know how to run only Hermes without React Native on device, we are asking for reproduction on desktop CLI (if possible, of course).

Now, @rpopovici, your results from running Hermes in iOS simulator are extremely puzzling and point to an additional problem. As you know, the iOS simulator runs software directly on the host CPU or under Rosetta, but in either case the performance of CPU-bound tasks should be close to the host (in my experience Rosetta is shockingly fast).

Still, it is worth checking, are you running am arm64 or x86-64 image in your simulator?

If you are seeing something so dramatically different, this might mean that something is broken in the RN packaging of Hermes. Perhaps it is compiled in debug mode, or even in “slow debug” mode, which is even slower.

(Note that we fully acknowledge that Hermes has a performance problem with localized dates, but perhaps we are additionally looking at something different here)

@tmikov i think nobody is having issues with hermes on pc. Only on react native.

Testing shows that creating spacetime objects is whats taking 95% of the time in hermes, so I tested it using node locally and here is the same test pretty much.

image

image

Just to show you the difference.

Ill accept that the 85ms is in big reason because we are running in development mode.

But here we can compare creation of 600 spacetime objects in hermes takes 4 seconds. Creating 600 spacetime objects in node locally takes 1.5 ms

Thats 2600x slower.

I hope this is enough to show how extremely slow hermes is with date calculations

Listening for NSSystemClockDidChangeNotification or NSSystemTimeZoneDidChange can be done directly from PlatformIntlApple.mm using objective-c blocks. That should work without any side effects from C++

id __block token = [
    [NSNotificationCenter defaultCenter] addObserverForName: NSSystemTimeZoneDidChangeNotification
    object: nil
    queue: nil
    usingBlock: ^ (NSNotification * note) {
        // do stuff here, like calling a C++ method
    }
];

Don’t forget to remove the subscription for NSSystemTimeZoneDidChange event on destructor. https://developer.apple.com/documentation/foundation/nsnotificationcenter/1411723-addobserverforname?language=objc

[[NSNotificationCenter defaultCenter] removeObserver:token];

This should allow you to update the cached timezone data only when NSSystemTimeZoneDidChange event fires.

@tmikov you are correct. spacetime is just one of many. I believe nowadays date-fns and moment.js(deprecated) are the most popular. As for my benchmark, spacetime was on average 30% slower with hermes than it was with JSC.

As a non-synthetic example, displaying hundreds of data points on a graph. The Intl options passed to toLocaleTimeString might are also different in case of a more complex batched group of data points. So having to workaround all the logic in UTC only due to Hermes is not ideal.

On my machine the rough numbers I’m getting with code below, show almost a 10x difference:

  • V8 (M1, nodejs cli) - 30ms
  • Hermes (M1, react-native iOS simulator) - 235ms

This is only amplified with additional Intl API calls for every data point so these 1000x numbers can be realistic.

const t0 = performance.now();

function convertIsoToHour(isoTimestamp) {
  return new Date(isoTimestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}

const arrayOfObjects = [
  ...Array(500)
    .fill(0)
    .map((_, i) => ({
      id: `${i}`,
      name: `name${i}`,
      timestamp: '2021-09-15T18:30:00.000Z',
    })),
];

const plotData = [];

for (let i = 0; i < arrayOfObjects.length; i++) {
  const obj = arrayOfObjects[i];
  const hour = convertIsoToHour(obj.timestamp);

  plotData.push({
    id: obj.id,
    name: obj.name,
    hour,
  });
}

const t1 = performance.now();
console.log(`Call took ${t1 - t0} milliseconds.`);

Here is the same function, and results using just Date objects.

image

image

Which means this takes 4-5 frames to calculate, which is incredibly slow even that

Here you go:

image

image

Doing date operations on about 600 objects in an array takes 4.2s, which is extremely slow

Spacetime is this lib: https://github.com/spencermountain/spacetime

@rpopovici Agreed. For example our app by default groups tasks by day for a calendar view. If someone has a couple hundred tasks we’re tripping over exactly the issue @DePavlenko described above where a simpleisSameDay calculation can be 2600x slower on hermes.