Javascript wonkery

Here I will take a break from the ever-burbling stream of IF and Myst news, and talk about Javascript optimization.

You could say it's relevant because it came up in a Quixe bug report. ("I tried loading Emily Short's Counterfeit Monkey.... Load time dropped from 29.4 seconds to 10.4 seconds...") IF interpreter improvements are a high priority for me -- particularly if they could be big speed improvements for a fairly small code change. Double-particularly if they imply I've had crappy Javascript code out there for years.

Whoops.

I usually build my Javascript libraries according to the private namespace pattern. I can't remember where I learned this. I assume it originated in this blog post (Douglas Crockford), but I use the cleaner layout described here (Ben Cherry) or here (Todd Motto).

With this trick, all your internal functions and variables wind up hidden from the outside world. You decide which ones to export, and the rest remain private.

Here's a toy example:

Lib = function() {
    var counter = 0;
    var prefix = "The current value is ";

    function add(val) {
        counter += val;
        return counter;
    };

    function get() {
        return prefix + add(0);
    };

    return {
        add: add,
        get: get
    };
}();

Here counter and prefix are private variables. The add() function increases counter and returns it. The get() function returns it as part of a string. (I've set get() up to call add(0) just to demonstrate that private functions can call each other.) Finally, we return an object which exports the add and get symbols. This becomes the global Lib object, so a user can call Lib.add() and Lib.get(). But there is no Lib.counter; that's private.

Crockford says "This pattern of public, private, and privileged members is possible because JavaScript has closures... This is an extremely powerful property of the language." Okay, that's top-grade hand-waving. What he means is "Javascript is a terrible language, but it has stolen enough features from other languages that you can pull this crap off if you're incredibly careful and don't mind confusing onlookers."

Anyhow. The trick works pretty well until you start constructing Javascript functions on the fly. Let's extend our example:

Lib = function() {
    var counter = 0;
    var prefix = "The current value is ";

    var compiledfuncs = {};

    function add(val) {
        var func = compiledfuncs[val];
        if (func === undefined) {
            var text = "counter += " + val + ";";
            text += "return counter;";
            func = eval("(function func() { " + text + " })");
            compiledfuncs[val] = func;
        }
        return func();
    };

    function get() {
        return prefix + add(0);
    };

    return {
        add: add,
        get: get
    };
}();

What the heck is going on in add()? Imagine that this is an IF virtual machine interpreter, like Quixe. We're doing just-in-time (JIT) compilation. We take a Glulx function -- that is, a string of Glulx opcodes -- and convert it into a Javascript function. The browser's Javascript engine will then convert that function into native machine code and the result will run really fast.

Since this is a toy example, our only opcode is "increase counter by N". When we see a call to add(1), for example, we convert that into this Javascript function:

function func() {
    counter += 1;
    return counter;
}

We eval that source (compile it) and stash the function (in case another add(1) comes along). Then we execute it.

So that's great, and it works. But if you profile this in Chrome, for example, you'll see a couple of little yellow warning flags:

  • Not optimized: Function calls eval
  • Not optimized: Reference to a variable which requires dynamic lookup

You see, Javascript is a terrible language, and its scoping model is a haystack of ideas jammed together without any planning, and it can never be fixed because backwards compatibility. Certain operations in closures are legal, but slow.

(To be fair, every language's scoping model sucks. You know how the only hard problems are naming and cache invalidation? This is naming. But Javascript is worse than most.)

We could get rid of the eval() if we used a Function() constructor:

func = new Function(text);

But then the code would fail, because the function would exist outside the library closure. It wouldn't have access to the private variable counter.

I fussed around with a couple of different solutions. I tried inner and outer closures, I tried using this a couple different ways. None of them worked. (Crockford progresses from hand-waving to passive-aggressive sniping: "...an error in the ECMAScript Language Specification which causes this to be set incorrectly for inner functions." You tell 'em.)

I eventually settled on this:

Lib = function() {
    var self = {};
    self.counter = 0;

    var prefix = "The current value is ";

    var compiledfuncs = {};

    function add(val) {
        var func = compiledfuncs[val];
        if (func === undefined) {
            var text = "var self = this;";
            text += "self.counter += " + val + ";";
            text += "return self.counter;";
            func = new Function(text);
            func = func.bind(self);
            compiledfuncs[val] = func;
        }
        return func();
    };

    function get() {
        return prefix + add(0);
    };

    return {
        add: add,
        get: get
    };
}();

Our compiled function can't get access to the private namespace. We need a second namespace which can be handed in for that purpose. We'll call that self. We create self up top, and put the counter in it. We'll have to write self.counter to access it now.

To hand self in to our generated function, we call bind. This is a confusing Javascript feature which lets us glue any object in as the function's this. Then we compile it this way:

function func() {
    var self = this;
    self.counter += 1;
    return self.counter;
}

This is more verbose, but it works. And if we check it in Chrome's profiler, the yellow warning flags are gone.

Note that if we wanted the generated function to call private methods, we'd have to copy them into the self object too:

function get() {
    return prefix + add(0);
};
self.get = get;

Not too messy.

Now the real question is, does this actually make Quixe run faster? I don't know! I've started converting the code to this new model, but it's going to take more work. (The virtual machine has lots of state to shove into the self object.) The person who filed the original report got good results, but I'm not sure I've snagged the right tricks yet.

The toy example in this post seems to run a little slower with these changes. That's not encouraging, but of course it's not a realistic work model. Hopefully I'll have real results in a few days.


UPDATE, May 29th:

Got an updated Quixe branch working.

Turns out the func.bind(self) plan is terrible. Performance doesn't improve as much as it should; on Firefox it actually gets worse.

Instead, I've given each generated function an explicit self argument, and called them all as func(self). With this plan, Quixe is 65% faster in Safari, 55% faster in Chrome, 10% faster in Firefox. Woot!

This entry was posted in Uncategorized and tagged  , , , , , . Bookmark the permalink.

11 Responses to Javascript wonkery

  1. David Cornelson says:

    Any chance you could separate the glk stuff so IO is more flexible? The work Panici did to get channel IO works, but he's basically piggy-backing on glk in a very hacky way.

    Just a small request. (:

  2. Andrew Plotkin says:

    It is separated out. quixe.js is the Glulx engine, gi_dispa.js is the Glk dispatch layer, glkote.js is the Glk display library.

    The only dependencies directly to Glk from quixe.js are "send a character/string to the current output stream". You're going to need some version of that in any UI model. Also "read/write a buffer to a file stream", used during @save and @restore.

    • David Cornelson says:

      Not entirely true. The VM is launched from glkote and the as far as send a character/string to output stream, that's what we're replacing in channel IO, no? And save/load for my lib is just a byte array interface.

      • Andrew Plotkin says:

        The VM has to be launched from the display layer, yes. Call quixe_prepare() to set up and then quixe_init() to run up to the first input.

        "as far as send a character/string to output stream, that's what we're replacing in channel IO, no?"

        I'm not sure what you mean here. The @streamchar, @streamnum, @streamstr, @streamunichar opcodes send output to the current output stream. I assume your channel system has a notion of "the current channel" -- you need a global Glk object with hooks to send data there.

        Or maybe you're building a system where every character of output must be sent to a specific channel. If so, those opcodes will be illegal. (They have no way to specify an output destination.)

        "And save/load for my lib is just a byte array interface."

        The abstract control flow looks like this:

        - The Glulx game code executes the @save opcode, passing in a numeric argument. The argument can mean anything you want.
        - Quixe calls GiDispa.class_obj_from_id('stream', argument). This must convert the numeric argument into an object which represents your output destination. Again, this can be any Javascript object. (If you want, this could be the identity function and just return argument unchanged.)
        - Quixe assembles an array containing the save state.
        - Quixe calls Glk.glk_put_buffer_stream(obj, array). The first argument is the object that class_obj_from_id() returned. This must do the work of storing the array.

        The logic is completely general. The function names (and global object name) are biased -- I could change them to be non-Glk-specific -- but that's just renaming stuff.

        • David Cornelson says:

          We've got it mostly figured out, but I've also contracted a third guy to port FyreVM to TypeScript. I really want an engine that's not at all reliant on Glk and works as a black boxed service (send stuff in, get stuff out). The TS implementation will enable that. I'm not even sure, if we implement FyreVM in TS that adding Glk later will even be possible without impacting the VM itself.

          I get that my "wish" for a truly contextual VM is wishful thinking. That would require a major rewrite to the I6 and I7 standard libraries, something that is far too daunting an undertaking.

          My vision for a new IF platform has changed over the years. I'm starting to see something very different though. Something that is componentized so that operations like paragraph handling are a separate service, there are notifications for action results, and metadata is reported separately. I may write a blog about this...

        • Andrew Plotkin says:

          I have written up my notes on this stuff at greater length:

          https://github.com/erkyrath/quixe/wiki/Quixe-Without-GlkOte

  3. Ross Angle says:

    I don't know if it'll make much difference, but here are some alternate approaches: You could create the function as Function("self", text) and call it using func(self), or you could create it as Function("self", "return function func() {" + text + "};")(self), which is a lot like what you had originally.

    • Andrew Plotkin says:

      I didn't want to change the calling convention of the generated functions -- for some cases, they're called externally, so self isn't available.

      I will check, though. Calling func(self) might be faster.

      • Andrew Plotkin says:

        Turns out calling func(self) is *much* faster! The .bind call was killing me. See results (added) at the end of the post.

  4. ironwallaby says:

    Maybe I'm missing something, but this example doesn't require eval at all. `add()` should just be:

    function add(val) {
    return function(val) {
    self.counter += val;
    return self.counter;
    };
    };

    That'll run a *lot* faster since there's no dynamic compilation involved, just a closure around `self` and `val`.

Leave a Reply

Your email address will not be published. Required fields are marked *

*

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>