hermes icon indicating copy to clipboard operation
hermes copied to clipboard

Accessors are slower than regular property reads

Open trossimel-sc opened this issue 9 months ago • 3 comments

Problem

Executing the following benchmark on my machine:

let obj = {};
Object.defineProperty(obj, 'prop', { get: function() {} });
for (let i = 0; i < 100000000; i++) {
  obj.prop;
}

Takes around 7 seconds to execute, which is 2x slower compared to this execution:

let obj = { get: function() {} }; 
for (let i = 0; i < 100000000; i++) {
  obj.get();
}

Understanding the cause of this slowdown will enable me to optimize C++ property bindings more effectively. Currently, I'm using Object.defineProperty along with a host function, but I'm not fully leveraging my assumptions. Thank you!

trossimel-sc avatar Mar 04 '25 15:03 trossimel-sc

This behavior is normal - accessors are slow, because what looks like an ordinary property read has to turn into a function call. This leaves the "fast path" and is pretty complicated. The following loop has similar performance to the first one:

    let obj = { get prop() {} };
    for (let i = 0; i < 100000000; i++) {
      obj.prop;
    }

Accessors (or Proxy, which is even worse) should ideally be avoided when performance is important.

tmikov avatar Mar 04 '25 15:03 tmikov

I see, thank you. Is this difference caused by hermes' internal implementation, or should this behaviour be expected for other engines too? I tried with quickjs, and noticed similar results for both tests (around 3.5 seconds)

trossimel-sc avatar Mar 04 '25 16:03 trossimel-sc

Different engines have different tradeoffs, but without a speculative JIT (which can speculatively inline the accessor), the pattern is likely to be either similar, or Hermes will be faster in the "regular" property case in absolute time.

I created this little test and ran it with different engines with JIT disabled.

function f1() {
    let obj = { get: function() {} };
    for (let i = 0; i < 100000000; i++) {
      obj.get();
    }
}

function f2() {
    let obj = { get prop() {} };
    for (let i = 0; i < 100000000; i++) {
      obj.prop;
    }
}

function f3() {
    let obj = {};
    Object.defineProperty(obj, 'prop', { get: function() {} });
    for (let i = 0; i < 100000000; i++) {
      obj.prop;
    }
}

let t1 = Date.now();
f1();
let t2 = Date.now();
f2();
let t3 = Date.now();
f3();
let t4 = Date.now();
if (typeof console === "undefined")
    console = {log: print};
console.log(t2 - t1, t3 - t2, t4 - t3);

These are the results:

$ node --jitless t.js
Warning: disabling flag --expose_wasm due to conflicting flags
2804 2669 2076

$ jsc --useJIT=0 t.js
2299 5101 5099

$ qjs t.js
3402 3372 3373

$ sh-hermes-rel t.js
1259 3411 3400

I also tested our baseline JIT (no speculation):

$ sh-hermes-rel -Xforce-jit t.js
534 3477 3378

You can see how the ordinary property gets even faster, but the accessor remains slow.

v8 is the outlier here, where for some reason defineProperty() helps. That is very interesting, but still in absolute time it is slower than our "regular property" case. Of course if I enable the v8 or JSC JIT, they blow everything out of the water...

It is theoretically possible to speed up the accessor case, but the challenge is that in an interpreter speeding up one case usually causes other cases to regress slightly, so tradeoffs have to be chosen really carefully.

tmikov avatar Mar 04 '25 16:03 tmikov

Thank you for the detailed response @tmikov !

trossimel-sc avatar Mar 19 '25 10:03 trossimel-sc