DOCUMENTATION XS Using XS Preload to Optimize Applications
Overview
Getting Started
BASE
Reference
Worker
Setup modules
COMMODETTO
Reference
Poco Renderer
Outlines
Creating Fonts
Crypt
Data
DEVICES
Moddable One
Moddable Two
Moddable Three
Moddable Four
ESP32
ESP8266
nRF52
nRF52 Low Power Notes
Raspberry Pi Pico
M5Core Ink
M5Paper
Wasm
SiLabs Gecko
QCA4020
Moddable Zero
DISPLAYS
Overview
Adafruit 1.8" ST7735
Adafruit OLED Display
BuyDisplay 2.8" CTP - ESP8266
Crystalfontz ePaper
DotStar
Generic 1.44"
Generic 2.4" & 2.8" (Resistive Touch) - ESP32
Generic 2.4" & 2.8" (Resistive Touch) - ESP8266
Generic 2.4" & 2.8" (Resistive Touch) - Pico
Sharp Memory
Sharp Memory Screen 1.3"
SparkFun TeensyView
Switch Science Reflective LCD
DRIVERS
DESTM32S display
DotStar display
ILI9341 display
LPM013M126A display
LS013B4DN04 display
MCP230XX GPIO expander
NeoStrand
SSD1306 display
SSD1351 display
ST7735 display
Files
IO
TC53 IO
Firmata
NETWORK
Reference
TLS (SecureSocket)
BLE
Ethernet
Web Things
PINS
Reference
Audio Output
PIU
Reference
Localization
Keyboard
Expanding Keyboard
Die Cut
Using After Effects Motion Data
TOOLS
Reference
Manifest
Defines in Manifests
Testing
XS
Handle
JavaScript language considerations on embedded devices using the XS engine
Mods – User Installed Extensions
ROM Colors
Using XS Preload to Optimize Applications
XS Conformance
XS Marshalling
XS Platforms
XS in C
XS linker warnings
xsbug
xst
XS Compartment
XS Profiler

Using XS Preload to Optimize Applications

Copyright 2019-2023 Moddable Tech, Inc.
Revised: June 8, 2023

Preloading of modules is a unique feature of the XS JavaScript engine. Preloading executes parts of a JavaScript application during the the build process, before the application is downloaded to the target device. This has two major benefits:

  • It speeds start-up of the application by running initialization during the build rather than each time the application launches on the target microcontroller.

  • It increases the memory available to scripts by moving memory for objects creating during preload from RAM to Flash (ROM).

Not all modules can be preloaded because not all operations may be performed on the build machine - for example, initializing a Digital pin or connecting to a Wi-FI network. Most of the modules in the Moddable SDK are designed and implemented to support preloading. This document explains more about the preload feature of XS, how to use it in your projects, and how to apply it to your own modules.

Specifying Modules to Preload

A project's build manifest, usually a file named manifest.json, lists the modules to include together with many other options. A list of modules to preload is one optional part of the manifest.

"modules": {
	"*": [
		"./main",
		"$(MODULES)/network/http/*"
	]
},
"preload": [
	"http"
]

In this example, the http network protocol module is preloaded but the main module is not. For convenience most of the examples in the Moddable SDK do not preload their main module, though with a little additional work they could. The details of how are described below.

Executing a Module

To understand what it means to execute a module, let's look at a trivial module:

class CountingLog {
	constructor() {
		this.count = 1;
	}
	log(msg) {
		trace(`${this.count++}: ${msg}\n`);
	}
}

export default CountingLog;

Because this module contains only a class definition it may seem that there is no benefit to be had from preloading. In JavaScript, the class is built dynamically when the class statement is executed, not at compilation time. The JavaScript compiler outputs byte code to build the CountingLog class. That byte code is executed when the module is loaded. Creating the class and each of its methods executes byte code (which takes time) and allocates objects (which consume memory). By preloading the CountingLog module, both the time and memory are recovered.

Here's another example module, one that imports CountingLog and extends it to print the current date and time at the beginning of each log line.

import CountingLog from "countinglog";

class CountingDateLog extends CountingLog {
	log(msg) {
		trace(`${new Date} `);
		super.log(msg);
	}
}

export default CountingDateLog;

When CountingDateLog imports CountingLog, the import is resolved, which takes some time and uses some memory to keep track of the import. By preloading CountingDateLog the import statement is executed at build time, which allows this memory to be kept in flash memory instead of RAM.

Note that when CountingDateLog is preloaded, CountingLog is also preloaded whether or not it is included in the manifest's preload array. Consequently, if a module is to be preloaded, all the modules that it preloads must also support preloading.

Freezing

The JavaScript language allows objects to be changed at any time. Preloading puts objects into flash memory and flash memory is, for practical purposes, read-only memory. The XS engine allows objects stored in flash to be modified by storing the modifications in RAM. It achieves this by maintaining a pointer in RAM for each object in ROM that may be modified. Each pointer takes up 4 bytes on a typical 32-bit microcontroller.

Freezing Classes

Because XS allows objects stored in flash to be modified, code that modifies the prototype of a class works, as in the following example.

import CountingLog from "countinglog";

CountingLog.prototype.reset = function() {
	this.count = 0;
}

Assuming the above module is not preloaded, the reset property and the function object it references is stored in RAM. In some cases, that is desirable behavior. However, to enable that behavior XS must reserve a four byte pointer in RAM for CountingLog to allow it to be patched. In many, if not most, cases developers of modules do not want to have their objects modified in this way because it can lead to reliability problems and security issues.

The JavaScript language provides the Object.freeze function to prevent changes to existing properties and to prevent the addition of new properties. Here is the CountingLog module modified to use Object.freeze on its prototype.

class CountingLog {
	constructor() {
		this.count = 1;
	}
	log(msg) {
		trace(`${this.count++}: ${msg}\n`);
	}
}
Object.freeze(CountingLog.prototype);

export default CountingLog;

With this change, the code above that patches the prototype to add the reset function throws an exception. The CountingDateLog example continues to work as it subclasses CountingLog which references CountingLog.prototype but does not change it.

The XS engine recognizes that objects which have been frozen cannot be modified, and therefore does not allocate a four byte pointer for them, reducing runtime RAM use. While four bytes may seem like a small benefit, on a device with just a few dozen KB of RAM in a project with a large number of classes, the impact is meaningful.

Freezing Data

JavaScript applications often use objects to store data. Here's an example from a script that runs in a light bulb.

const Colors = [
	{name: "blue", value: 0x0000FF},
	{name: "white", value: 0xFFFF00},
	{name: "red", value: 0xFF0000},
	{name: "green", value: 0x00FF00},
	{name: "purple", value: 0xFF00FF},
	{name: "yellow", value: 0xFFFF00},
	{name: "cyan", value: 0x00FFFF},
];

The Colors object is an Array with seven entries. XS reserves a pointer to track changes to Colors, as described above. Freezing the Array eliminates that pointer:

Object.freeze(Colors);

However, the array contains seven objects, and a pointer must also be reserved for each of those, requiring an additional 28 bytes. These objects should also be frozen. Here's one way.

const Colors = [
	Object.freeze({name: "blue", value: 0x0000FF}),
	Object.freeze({white", value: 0xFFFF00}),
	*..*.

Unfortunately, that obscures the data. Here's another approach:

const Colors = [
	{name: "blue", value: 0x0000FF},
	{name: "white", value: 0xFFFF00},
	...
];
Object.freeze(Colors);
Colors.forEach(color => Object.freeze(color));

This is better, but it is more code. Further, if these objects had their own sub-objects, additional code would be required.

Deep Freezing

Freezing objects is more common using XS than in other JavaScript environments. To make it easier for developers to reliably freeze objects, XS extends Object.freeze with an optional second argument that that requests the object be frozen recursively. This allows the Colors object above to completely frozen with a single call.

const Colors = [
	{name: "blue", value: 0x0000FF},
	{name: "white", value: 0xFFFF00},
	...
];
Object.freeze(Colors, true);

Because this extension is not part of the JavaScript language, care should be taken to only use it in code that is intended for exclusive use by the XS engine. If equivalent functionality becomes available in a standard way such as harden, XS will move to use that mechanism exclusively.

Hardened JavaScript formalizes recursive freeze as the harden() global function. XS implements harden() as part of its Hardened JavaScript (formerly Secure ECMAScript) support, but it is not included in Moddable SDK builds at this time.

Automatic Freezing of Built-ins

Following the preload build phase, the XS linker freezes the following:

  • The prototypes of built-in objects -- e.g. Object, Math, Date, Proxy, etc -- are frozen.
  • All functions, both built-in and part of preloaded modules. This include class constructors.

The result of this step generates a runtime environment with characteristics in common with the Frozen Realms proposal. In addition to memory savings already explained, it provides a reliable execution environment because scripts know the built-in objects are those defined by the JavaScript language specification and that will not change during execution due to runtime patching. Eliminating patching of runtime objects also contributes to providing a secure execution environment.

Module Scope

Sometimes modules need to maintain information for their entire lifetime, independent of a single class instance. These variables are part of the module's closure, lexically scoped to the module's body. They are created when the module executes. The following revision of CountingLog shares a single counter variable across all instances.

let count = 1;

class CountingLog {
	log(msg) {
		trace(`${count++}: ${msg}\n`);
	}
}
Object.freeze(CountingLog.prototype);

export default CountingLog;

When this module is preloaded, the value of the count variable is frozen in ROM as part of the module's closure. As with objects, XS allows such variables to be modified by storing the modification in RAM. It achieves this by maintaining a pointer in RAM for each variable in ROM that may be modified. Each pointer takes up 4 bytes on a typical 32-bit microcontroller.

Use const to declare module variables that are not intended to be modified at runtime. Declaring a module variable with const conveys to XS that the variable cannot be modified. This saves RAM by eliminating the pointer otherwise needed to allow the variable to be modified.

What Cannot be Preloaded

Preloading occurs on the build machine, not the target device. That limits the operations that may be performed during preload.

Native Functions

Because the build is for the target device, not the build machine, any native functions cannot be executed as they expect a different environment, perhaps even a different instruction set. If a module attempts to call a native function, an error is generated during the build.

For example, the following fails to preload because Digital.write is a native function.

import Digital from "pins/digital";

Digital.write(1, 0);

This image shows the error generated during build when this module is preloaded.

Note that it is safe to define native functions during preload, as in the following example.

class Example {
	static aNativeFunction() @ "xs_nativefunction";
}

Because calling a native function is not possible, this generates an error at build time:

class Example {
	static aNativeFunction() @ "xs_nativefunction";
}
Example.aNativeFunction();

Some JavaScript Built-ins

Many of the basic JavaScript types and objects may be created at build time allowing the objects created be stored in flash memory. Those which may be safely used include:

  • Array
  • ArrayBuffer
  • BigInt
  • Boolean
  • class
  • DataView
  • Date
  • Error
  • FinalizationGroup
  • Function
  • Map
  • null
  • Number
  • Object
  • Promise
  • RegExp
  • Set
  • String
  • Symbol
  • TypedArrays (e.g. Uint8Array)
  • undefined
  • WeakMap
  • WeakRef
  • WeakSet

These objects cannot be stored in flash memory:

  • AsyncGenerator
  • Generator
  • SharedArrayBuffer

In the future XS may support storing additional built-in objects in flash memory. For details on built-in objects stored in flash memory see the XS Linker Warnings document.

These objects cannot be stored in flash. However, they maybe used during preload as long as they do not need to be stored. For example, code that executes a part of preload can safely use RegExp as long as there are no regular expression instances remaining when the preload phase ends.

Preloading main

The main module is the first application script executed. To do its work, the main module usually imports other modules. The main module of a project is often the only module that is not set to preload. This is done for convenience, and for small projects, like examples in the Moddable SDK, it is often not a problem. The application's main module invariably invokes native functions, to connect to Wi-Fi, display an image, or toggle a digital pin. As noted above native functions cannot be called during preload.

Here's a trivial example of an application that turns on one LED using a Digital pin at start-up and sets a repeating timer to toggle the state of another LED.

import Digital from "pins/digital:

let toggle = false;

Digital.write(1, true);
Timer.repeat(() => {
	toggle = !toggle;
	Digital.write(2, toggle)
}

In the Moddable SDK runtime, if the main module returns a function, that function is executed immediately. This can be used to make main support preloading. Here is a naive example of doing that:

import Digital from "pins/digital:

export default function() {
	let toggle = false;

	Digital.write(1, true);
	Timer.repeat(() => {
		toggle = !toggle;
		Digital.write(2, toggle)
	}, 1000);
}

It is sometimes useful to organize main with a simple class that is instantiated from the exported function. This structures the code more cleanly and any needed state, such as toggle in the above example, is part of the instance state accessed using this.

import Digital from "pins/digital:

class App {
	constructor() {
		Digital.write(1, true);

		this.toggle = false;
		Timer.repeat(this.blink.bind(this), 1000);
	}
	blink() {
		this.toggle = !this.toggle;
		Digital.write(2, this.toggle)
	}
}

export default function() {
	new App;
}

Pre-calculating During Preload

Because microcontrollers are relatively slow in performance, a common optimization technique is to use tables of pre-calculated values to minimize the complex calculations that need to be performed on the microcontroller. The values are typically calculated by another program and then entered into a data structure such as an array.

For example, the floating square root function is relatively complex and so is a good candidate for optimization when used in performance critical situations. The following example, contrived for simplicity, uses an array containing the square roots of the integers from 0 to 10 as a look-up table.

const roots = [
	0,
	1,
	1.4142135624,
	1.7320508076,
	2,
	2.2360679775,
	2.4494897428,
	2.6457513111,
	2.8284271247,
	3,
	3.1622776602
];
Object.freeze(roots);

function fastSquareRootToTen(x) {
	return roots[x];
}

With eleven elements, the table is already awkward. With 100 or 1000, it becomes unwieldy. Worse, there is no way to be certain the values are correct by visual inspection, so errors could creep in unnoticed. Taking advantage of preloading solves these problems by generating the table programmatically at build time.

const roots = [];
for (let i = 0; i <= 10; i++)
	roots[i] = Math.sqrt(i)
Object.freeze(roots);

function fastSquareRootToTen(x) {
	return roots[x];
}

This technique may be applied to perform more sophisticated calculations and to generate data structures more complex than arrays.

Using xsbug to Check Preloaded Modules

Determining if all the modules in a project are set to preload is difficult by inspecting the source code. The xsbug debugger has two features to help.

In the Instruments pane, there is a "Modules loaded" area that shows the number of runtime loaded modules for each second of execution. In most projects this number should be either one or zero.

The module pane shows a list of all loaded modules and indicates by color which of the modules are preloaded. Modules which are preloaded are shown in blue and those loaded at runtime are shown in black.

In the image below, the Instruments shows one module is loaded at the time of breakpoint and the Modules pane shows that the main module was loaded at runtime.

Additional Notes

Preloaded objects may not be serialized using JSON.stringify(). Attempting to do so results in a "read only value" exception. This is because the implementation of JSON.stringify() depends on the objects being in RAM to detect cycles. A workaround is to use structuredClone to make a deep copy of an object that can be passed to JSON.stringify().

Conclusion

Preloading of modules is a unique feature of the XS JavaScript engine to enable more efficient use of the limited RAM and performance of microcontrollers. It is widely supported by the modules provided in the Moddable SDK, so developers benefit from preloading even if they don't understand it fully. By understanding the preload mechanism, developers can realize its benefits for their own code. Those benefits include:

  • Faster start-up time by moving initialization code from the microcontroller to build machine
  • Increased free memory for their project code by moving objects created during initialization to flash (ROM) memory
  • Nearly instant importing of modules
  • More errors caught at build time
  • Simplification of embedded look-up tables and data structures using pre-calculation techniques

Thank You

This document was created in response to a request on Twitter from @hori__hiro.

An initial draft of this document was written by Lizzie Prader, who helped edit this document.

The use of terminology at the start of the Module Scope section in the initial posting was imprecise. Thank you to Allen Wirfs-Brock for suggesting improvements.