Co-authors:Sarah Clatterbuck,Chad Hietala, andTom Dale
A bit over a year ago,Ember.js got a major overhaul. In a tight collaboration between LinkedIn engineers and the open source community, we replaced Ember’s rendering engine with a new library, Glimmer VM, that improved performance and significantly reduced the size of compiled templates.
Glimmer treats Handlebars templates as a functional programming language and compiles them into a sequence of instructions that can be executed in the browser. These instructions, or opcodes, are encoded in a compact JSON data structure.
In this blog, we will discuss a recent experiment to improve load times even further by entirely eliminating the cost to parse compiled templates.
Unlocking experimentation with Glimmer.js
About six months ago, the Ember.js team announced the release of Glimmer.js as a standalone component library. Breaking off the view layer empowered us to experiment with bringing all the goodness of Ember and the Glimmer VM to developers creating lighter-weight experiences, like mobile apps for emerging markets, or SEO pages.
The break out of Glimmer has unlocked a lot of experimentation by our team in the subsequent months. Recently, for example, we introduced hybrid rendering, where HTML is generated with server-side rendering (SSR) and “rehydrated” in the browser. This is just the beginning of the performance benefits afforded by Glimmer’s virtual machine architecture.
The holy grail of web performance is the ability to load quickly for first time visitors, to update quickly when the user takes action (preserving 60fps performance), and to provide performance by default, meaning that large teams with less experienced developers can build performant web apps without significant intervention.
But this still means that parse times will increase as template size grows, just at a slower rate. What if we could bypass the parse step altogether?
Encoding Glimmer bytecode
Like many VMs, instructions in the Glimmer VM are identified by numbers. Bytecode is just an encoded sequence of these numbers. What makes Glimmer unique is that its instruction set is designed for rendering DOM in the browser.
For example, a template of
would be compiled to the following JSON “wire format” at build time:
In the browser, a “last mile” compilation would turn this JSON wire format into an array of integers, one per opcode or operand:
Note that the strings in our JSON have been replaced by integers as well. That’s because we use a technique called “string interning” to de-duplicate multiple copies of the same string; here, the strings are replaced with an offset into the string constants pool. In practice, this optimization can greatly reduce file size (just imagine how many times you repeat the string div in your templates).
Originally, our bytecode encoded every operation as four 32-bit integers, where the first 32 bits described the type of operation (the opcode) and the remaining 96 bits described up to three arguments to the instruction (the operands).
While this is efficient to execute, it caused bytecode files to be larger than necessary. Because we always reserved space for three operands even though the majority of opcodes take zero or one, the program was full of empty bytes that didn’t need to be there. Additionally, the Glimmer instruction set only includes 80 opcodes, so we could reduce the reserved space for an opcode to 8 bits.
Ultimately, we settled on a more compact encoding scheme that was still 16-bit aligned. The first 8 bits represent the opcode, the next 2 bits are used to encode the number of operands, and the final 6 bits are reserved for future use. Each operand, if present, is assigned an additional 16 bits.
With this encoding scheme, each instruction varies between two and six bytes, looking something like this:
This new layout reduces compiled program size by more than 50%. The “decoding” of this layout has negligible overhead, as we are simply masking and shifting bits to figure out the opcode and operand length.
The first step was ensuring that all of the compilation tiers could run in Node.js. We created a new interface, called the “bundle compiler,” that encapsulated all of the compilation tiers into a single public API that build tools can use to turn a “bundle” of templates into bytecode.
In our example, we assigned UserProfile the handle of 42, so our external module table is an array where the UserProfile class is the 42nd item in the array:
A build generating .gbx (Glimmer Binary Experience) wire format, sending that as a response to the browser, and the VM rendering the heading in the browser.
We just integrated the bytecode compiler into an internal proof-of-concept Glimmer.js application and look forward to gathering real world results in a production application soon. This will help us to gauge the real-world impact of these changes with a variety of members on different hardware, OS, browser, and bandwidth combinations.
Because Glimmer bytecode reduces file size and eliminates both parsing and “last mile” compilation costs entirely, we expect to see significant improvements to application start time, particularly on lower-end devices where CPU is the bottleneck. Perhaps even more importantly, the process of aligning both the file format and the VM internals towards a well-defined binary format unlocks a slew of exciting future experiments. In particular, our bytecode format means we’re well-positioned to investigate moving portions of the Glimmer VM into WebAssembly , reducing parsing costs and improving runtime performance even more.
We’re big fans of open source here at LinkedIn, and all of the work described above happened in the open on GitHub. If we’ve piqued your interest in Glimmer, we invite you to follow along in the Glimmer VM and Glimmer.js GitHub repositories.
Huge thanks toChad Hietala andTom Dale, who have been driving bytecode compilation at LinkedIn. Also, thanks toYehuda Katz andGodfrey Chan for helping us realize this vision in the open source community.