6

I was pondering on performance implications on whether or not to declare a function within a function scope vs outside of the scope.

To do that, I created a test using jsperf and the results were interesting to me and I'm hoping if someone can explain what is going on here.

Test: https://jsperf.com/saarman-fn-scope/1

Google Chrome results: chrome results

Microsoft Edge results: edge results

Firefox results: enter image description here

3
  • 1
    What it says to me is that edge 18 is slow Commented Feb 22, 2020 at 21:58
  • What happens if you increase the number of iterations? Commented Feb 22, 2020 at 22:44
  • Ported to jsben.ch: jsben.ch/pJLBZ Commented Jun 29, 2023 at 9:44

3 Answers 3

13

V8 developer here. In short: you've fallen victim to the traps of micro-benchmarking. Realistically, "Test 1" is slightly more efficient, but depending on your overall program the difference may well be too small to matter.

The reason "Test 1" is more efficient is because it creates fewer closures. Think of it as:

let mathAdd = new Function(...);
for (let i = 0; i < 1000; i++) {
  mathAdd();
}

vs.

for (let i = 0; i < 1000; i++) {
  let mathAdd = new Function(...);
  mathAdd();
}

Just as if you were calling new Object() or new MyFunkyConstructor(), it's more efficient to do that only once outside of the loop, rather than on every iteration.

The reason "Test 1" appears to be slower is an artifact of the test setup. The specific way how jsperf.com happens to wrap your code into functions under the hood happens to defeat V8's inlining mechanism in this case [1]. So in "Test 1", run is inlined, but mathAdd is not, so an actual call is performed, and an actual addition. In "Test 2", on the other hand, both run and mathAdd get inlined, the compiler subsequently sees that the results are not used, and eliminates all the dead code, so that you are benchmarking an empty loop: it creates no functions, calls no functions, and performs no addition (except for i++).

Feel free to inspect the generated assembly code to see it for yourself :-) In fact, if you want to create further microbenchmarks, you should get used to inspecting the assembly code, to make sure that the benchmark measures what you think it's measuring.

[1] I'm not sure why; if I had to guess: there's probably special handling to detect the fact that while run is a new closure every time the test case runs, it's always the same code underneath, but it looks like that special-casing only applies to functions in the local scope of the call, not to loads from the context chain as in the runmathAdd call. If that guess is correct, you could call it a bug (which Firefox apparently doesn't have); on the other hand, if the only impact is that dead-code elimination in microbenchmarks doesn't work any more, then it's certainly not an important issue to fix.

Sign up to request clarification or add additional context in comments.

2 Comments

Assuming no optimizations, can we say the only difference between the two is when the closure is created and only that can influence the performance?
@AvinKavish The key difference is how many closures are created: just one, or lots.
0

takeaways:

code is way faster if the engine applies clever optimizations.

Edge is damn slow.

Chrome doesn't hit the fast path in the first case for some reason. Maybe the optimization will only kick in with more iterations.

Wether a function is inside of another function doesn't realy matter², as Firefox proves in this case.

By the way, the best optimization would be:

yup, nothing, as your code has no observable effect whatsoever. Doing nothing can be really fast

²: from a performance perspective not, but from a design perspective it does matter

Comments

0

I believe what's happening in the Chrome and Firefox case is that it's inlining the mathAdd function. Because it's a simple function with no side effects that is both created and called within the function, the compiler replaces the call site with the internal code of the function.

The resulting code will look like this:

const run = count => {
  return 10 + count
}

for (let count = 0; count < 1000; count++) {
  run(count)
}

This saves the runtime a function declaration, and a new stack frame when the function is called.

I suspect that in the case where the functions are separate, the compiler can't guarantee that it's safe to inline, and you end up paying the cost of a new stack frame and function call each time run is called.


I did some more tests: https://jsperf.com/saarman-fn-scope/5

Moving the code of Test2 into the loop (All in loop), I expected the compiler to inline the function call because it's block scoped to the loop and contains very little code. This expectation was wrong, but it's still faster than Test1

Maybe the function depth was the issue? Moving the code of Test1 into the for loop (All in loop 2), the result was the slowest of all...

So to conclude, I am completely unable to predict when these call optimizations are applied by JS engines.

It's worth noting that browser engines are constantly working on optimizations for common JS patterns. So it often doesn't make sense to optimize your code for their optimizations.


In principle, when you need better performance, avoid function calls, avoid function declarations. But always beware of premature optimization. Imagine how hard it would be to read code without functions!

3 Comments

"In the case where the functions are separate, the compiler can't guarantee that it's safe to inline" ... because?
@JonasWilms, my guess as to why is that the function is accessible in a broad scope, where it could be modified by other code, thereby changing the behaviour. but honestly IDK why. Should I edit that part out if I have no evidence?
I can't prove the opposite. I'd imagine the engine will optimize nevertheless, and any change to the variable containing the function will invalidate the optimized version. Maybe some engines do that, maybe some don't. At least I'd clarify that that sentence is not a fact, but rather an educated guess.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.