Hacker News new | ask | show | jobs
by ridiculous_fish 2687 days ago
Modern JS engines rely on tracing JITs to achieve good performance. These optimizations only kick in once code has executed multiple times.

But a lot of JS code executes only once, such as layout code running at app launch. Heck, lots of JS code executes zero times. This code still imposes a cost (parsing, etc).

Consider the complaints about app launch time of Electron apps. A static compiler can be more effective at the runs-once or runs-zero cases.

4 comments

Not tracing. Speculation.

The difference is that you compile user control flow as-is unless you have overwhelming evidence that you should do otherwise.

And yes you are right. This is promising for run-once code, but all of the cost will be in dynamic things like the envGet. An interpreter can actually do better here because most environment resolution can be done as part of bytecode generation. So it’s possible that this experiment leads to something that is slower than JSC’s interpreter.

Agreed that envGet() will be brutal, but optimizations can eliminate a lot of that, e.g. by promoting locals to stack variables instead of heap variables.

It is possible to statically decide which environment contains each upvar. So I don't understand your conclusion that this experiment may be slower than JSC's bytecode interpreter. What information can JSC exploit at this stage that is statically unavailable?

Yeah, and escape analysis could be used to avoid putting variables that can't be easily placed onto the stack in the scope of a garbage collector. Further optimization work could be performed to compute variables with constant results or to partially compute expressions. It's interesting, got me thinking about building one.
I got to thinking... with the right types, isn't it possible to take each JS function and emit a Rust generic function over every JS type (value/object) and have the Rust compiler emit optimized code for each specialization. Each invocation at compile time can be optimized by the Rust compiler then and so long as there's no eval or dynamic dispatch in the translation unit, each call should be maximally efficient and all unused paths can be trimmed statically.
Not possible to do statically if you’ve got eval or with. But maybe this experiment will not support those.

JSC has speculations that eval won’t introduce variable names we’ve already resolved for example.

v8 is remarkable but what is described in those links is primarily a JIT with a cache tacked on. A pipeline optimized for static compilation would make different tradeoffs.
Indeed, only so much can be done to improve v8 in this regard.

Tracing and other dynamic analysis can do a lot to improve the quality of generated code -- it's effectively a realtime PGO. On the other hand, there's a substantial activation energy in the form of the initial lex, parse, AST generation/manipulation, interpretation and dynamic analysis, re-optimization, etc. that can all be handled ahead of time, albeit with less information to optimize.

> Modern JS engines rely on tracing JITs

Which modern JS engine still relies on tracing? I thought they’d all moved on from that technique many years ago, but I’m not an expert in JS.

Yes, I misused a term of art, thank you for the correction. I meant only to distinguish modern engines that rely on runtime profiling, from the wholly static approach in this post.
Can the compiler really do much considering how dynamic Javascript is?
Depends on your definition of "much", but more than you might expect.

Pre-parsing is an obvious big win, but there's also classical compiler optimizations: constant propagation, DCE, certain inlining, etc. Local variables can be statically determined to be non-captured, which means they can be stack allocated.

One totally bizarre but profoundly useful property of JS is the restrictions on eval. eval has the ability to run new code, but this code is globally scoped unless it's run as the "eval" function itself. [1] Thus it's easy to decide which local variables are immune to eval; this in turn unlocks lots of optimizations. To my knowledge this weirdo behavior is not present in Python or other dynamic languages.

1: https://es5.github.io/#x15.1.2.1.1

I can not visualize what you mean. What does it mean to be run as the eval function itself?
It's the dorkiest thing you can imagine, literally a string comparison against "eval".

In JS functions are first class, so one might attempt:

    function wut() {
      var x = 1;
      var obj = {sneaky: eval};
      obj.sneaky("x++");
      console.log(x);
    }

Here we are calling `obj.sneaky()` with some JS code. The sneaky property is the eval function: won't it run the code and thereby increment x?

The answer is no: because the caller's name 'obj.sneaky' does not literally compare equal to "eval", the eval function is run with global scope instead of local scope. Therefore the 'x' inside the eval'd string is a global property, not the local variable.

So with this analysis we can (statically) apply constant propagation and replace x with 1.

Now if we replace obj.sneaky with eval, the string comparison succeeds, it becomes "direct eval", and the constant propagation is invalid.

Since this seems such a roundabout way of calling eval, does anyone actually use this?
Well usually we want to run code at global scope, and direct eval is an obstacle.

There's workarounds like "(1, eval)", but the most idiomatic way is the Function constructor, which always runs code at global scope. That leads to this pattern:

    var global = (new Function("return this")())
which really is the safest way to get the global object. I know right.
TIL. I love this.
I guess that makes sense. You can optimize the not so dynamic parts of the code.