Why is JavaScript executing callbacks in a for-loop so fast the first time?
Asked Answered
M

1

15

The following code with callback argument runs faster in the first loop.

const fn = (length, label, callback) => {
  console.time(label);
  for (let i = 0; i < length; i++) {
    callback && callback(i);
  }
  console.timeEnd(label);
};

const length = 100000000;
fn(length, "1", () => {})  // very few intervals
fn(length, "2", () => {})  // regular
fn(length, "3", () => {})  // regular

and then I removed the third argument callback, and their execution times are very near:

const fn = (length, label, callback) => {
  console.time(label);
  for (let i = 0; i < length; i++) {
    callback && callback(i);
  }
  console.timeEnd(label);
};

const length = 100000000;
fn(length, "1")  // regular
fn(length, "2")  // regular
fn(length, "3")  // regular

Why?

Mulligan answered 25/7 at 9:18 Comment(7)
Did you test this across different JavaScript engines (e.g. Chrome/Node (V8) vs. Firefox vs. Bun (JSCore))?Heterocercal
Interesting that it exposes the same behavior in SpiderMonkey and in V8. One thing to note: If you don't reuse the same function every time but instead duplicate them (fn1 = (...) => ...; fn2 = (...) = ; fn3 = (...) => ) or if you use the same callback function (so cb = () => {}; fn(length, label, cb) then you get all acting fast. This kind of rings a bell and I believe it's already been asked here before... but I'm not sure I'll be able to find the Q/A if it exists.Lm
Probably related to mrale.ph/blog/2015/01/11/whats-up-with-monomorphism.html. In the first run, it can just inline the callback and optimise the check away. In the subsequent runs, it notices that there's different callback values so it has to make an actual call.Anthia
What exactly does "very few intervals" mean? Please report the actual execution times you get.Anthia
If you define the arrow function like const clb = () => {}; and then pass it fn(length, "1", clb), the difference disappears.Sangsanger
@Anthia That mrale.ph article is related but not the same: it talks about object shape monomorphism, whereas the effect here is due to call target monomorphism (or lack thereof), as your own comment suggests. -- I assume "very few intervals" means "very little time".Poirer
@Poirer Yes, I meant related, that's just the first article I found about monomorphism and inline caches (and which is still excellent despite its age). I'm sure you can explain the concrete details much better, looking forward to your answer :-)Anthia
P
18

In short: it's due to inlining.

When a call such as callback() has seen only one target function being called, and the containing function ("fn" in this case) is optimized, then the optimizing compiler will (usually) decide to inline that call target. So in the fast version, no actual call is performed, instead the empty function is inlined.
When you then call different callbacks, the old optimized code needs to be thrown away ("deoptimized"), because it is now incorrect (if the new callback has different behavior), and upon re-optimization a little while later, the inlining heuristic decides that inlining multiple possible targets probably isn't worth the cost (because inlining, while sometimes enabling great performance benefits, also has certain costs), so it doesn't inline anything. Instead, generated optimized code will now perform actual calls, and you'll see the cost of that.

As @0stone0 observed, when you pass the same callback on the second call to fn, then deoptimization isn't necessary, so the originally generated optimized code (that inlined this callback) can continue to be used. Defining three different callbacks all with the same (empty) source code doesn't count as "the same callback".

FWIW, this effect is most pronounced in microbenchmarks; though sometimes it's also visible in more real-world-ish code. It's certainly a common trap for microbenchmarks to fall into and produce confusing/misleading results.

In the second experiment, when there is no callback, then of course the callback && part of the expression will already bail out, and none of the three calls to fn will call (or inline) any callbacks, because there are no callbacks.

Poirer answered 25/7 at 14:24 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.