The company Reaktor shared in its blog the principles and examples of optimizing JavaScript-code, applied in the library of Bluebird promises, created by their employee Petka Antonov (Petka Antonov).
Bluebird is the popular JavaScript library of promises. It was first noticed in 2013, when it turned out that it can surpass other implementations of promises with similar sets of properties up to 100 times. Bluebird is so fast thanks to the consistent application of some basic optimization principles in JavaScript. This article details the 3 most important principles used to optimize Bluebird.
Creating objects and, in particular, creating function objects ( translator's note: any function is an object ) can be very costly in terms of performance, since it requires the use of a large amount of internal data. Practical implementations of JavaScript contain garbage collectors, which means that the created objects are not just sitting in memory - the garbage collector constantly searches for unused objects in order to free the memory they occupy. The more memory you use in javascript, the more cpu the garbage collection takes and less is left to run the code itself. In JavaScript, functions are first class objects . This means that they have the same features and properties as any other objects. If you have a function that contains a declaration of another function (or functions), then each time you call the original function, new unique functions will be created that do the same thing. Consider a simple example:
function trim(string) { function trimStart(string) { return string.replace(/^\s+/g, ""); } function trimEnd(string) { return string.replace(/\s+$/g, ""); } return trimEnd(trimStart(string)) }
Whenever trim
is called, two function objects are created, representing trimStart
and trimEnd
. But they are not needed, since neither the unique behavior of objects, such as assigning properties, or closure over variables, is used in them. The only reason they are used is for the functionality of the code they contain.
This example is easy to optimize - you just need to remove functions from trim
. Since the example is contained in the module, and the module is loaded in the program once, there is only one representation for the functions:
function trimStart(string) { return string.replace(/^\s+/g, ""); } function trimEnd(string) { return string.replace(/\s+$/g, ""); } function trim(string) { return trimEnd(trimStart(string)) }
However, most often the functions look like a necessary evil, from which you just can not get rid of. For example, almost every time you pass a callback function for a deferred call, the callback needs a unique context to work. As a rule, the context is implemented in a simple and intuitive, but inefficient manner - through the use of closures. A simple example is reading a file from JSON to a node using a standard asynchronous callback interface.
var fs = require('fs'); function readFileAsJson(fileName, callback) { fs.readFile(fileName, 'utf8', function(error, result) { // readFileAsJson. // , Context // . if (error) { return callback(error); } // try-catch // - JSON try { var json = JSON.parse(result); callback(null, json); } catch (e) { callback(e); } }) }
In this example, the callback passed to fs.readFile
cannot be removed from readFileAsJson
, because it creates a closure around a unique callback
variable. It should be noted that an attempt to render an anonymous callback to a named function does not lead to anything.
Optimization constantly used inside Bluebird — using an explicit simple object to hold context data. In order to pass the callback through multiple levels, you only need to allocate memory for one such object. Instead of creating a new closure at each level, when the callback is passed to the next level, we will pass an explicit simple object with an additional argument. For example, if there are 5 levels in the original function, it means that using closures, 5 functions and Context-objects will be created along with them. In the case of this optimization, only one object will be created for this purpose.
If you could change fs.readFile
to pass a context object there, you could apply the optimization like this:
var fs = require('fs-modified'); function internalReadFileCallback(error, result) { // readFile callback , // `this`, // if (error) { return this(error); } // try-catch // - JSON try { var json = JSON.parse(result); this(null, json); } catch (e) { this(e); } } function readFileAsJson(fileName, callback) { // fs.readFile . // , callback, // fs.readFile(fileName, 'utf8', internalReadFileCallback, callback); }
Of course, you need to control both parts of the API — without the support of the context parameter, such optimization is not applicable. However, where it is used (for example, when you control multiple internal levels), the performance benefit is significant. A little-known fact: some of the built-in JavaScript Array APIs, such as Array.prototype.forEach
, accept a context object as a second parameter.
It is critical to minimize the size of frequently created objects and objects created in large quantities, such as promises. The heap in which objects are created in most JavaScript implementations is divided into occupied and free sites. Objects of smaller sizes fill the free space longer than large ones, as a result, leaving the garbage collector less work. Also, small objects usually contain fewer fields, so it is easier for the garbage collector to bypass them, marking live and dead objects.
Boolean and / or limited numeric fields are compressed much more by bitwise operations . Bitwise JavaScript operations work with 32-bit numbers. In one field, you can place 32 boolean fields, or 8 4-bit numbers, or 16 boolean and 2 8-bit numbers, etc. For the code to remain readable, each logical field must have a getter and a setter that performs the necessary bitwise operations on physical meaning. Here is an example of how to compress one Boolean field into a number (which can later be extended to other logical fields):
// 1 << 1 , 1 << 2 .. const READONLY = 1 << 0; class File { constructor() { this._bitField = 0; } isReadOnly() { // . return (this._bitField & READONLY) !== 0; } setReadOnly() { this._bitField = this._bitField | READONLY; } unsetReadOnly() { this._bitField = this._bitField & (~READONLY); } }
Access methods are so short that, most likely, they will be built into runtime without additional function calls.
Translator's note: Basic information about the work of the JavaScript compiler, the concept of inline caching and embedding functions - in the article The Past and Future of Compiling JavaScript . On the work of the optimizer - Optimization killers (from time to time the original is updated) Petki Antonova and the translation of the Killer Optimization (published in 2014).
Two or more fields that are not used at the same time can be compressed into one field using a flag that tracks the type of value placed in the field. However, this method will only save space when the flag is implemented as a compressed number, as shown above.
In Bluebird, this trick is used to preserve the value of the promise performed or the cause of the failure. There is no separate field for this: if the promise is done, the result of the execution is stored in the field for the failure callback, if the promise is rejected, then the cause of the failure can be stored in the callback field of successful execution. Again, accessing values should be done through access functions that hide implementation details.
If an object needs to store a list of entities, you can avoid creating a separate array by storing the values directly into the indexed properties of the object. Instead of writing:
class EventEmitter { constructor() { this.listeners = []; } addListener(fn) { this.listeners.push(fn); } }
You can avoid the array:
class EventEmitter { constructor() { this.length = 0; } addListener(fn) { var index = this.length; this.length++; this[index] = fn; } }
If the .length
field can be limited to a small number (for example, 10-bit, that is, the event emitter
can have a maximum of 1024 listeners), it can be made part of a bitwise field containing other limited numbers and Boolean values.
Bluebird contains several optional functions that cause a uniform loss of performance of the entire library when used. These are versions, stack traces, cancellation, Promise.prototype.bind, monitoring of states of promises, etc. These functions require interceptor calls throughout the library. For example, the function of monitoring promises requires an interceptor to be called each time a promise is created.
It is much easier to check before the call whether the monitoring function is enabled than to run it every time, regardless of the actual state. However, thanks to the inline caches ( translator's note: here is another note on the topic) and the embedding of functions, this operation can be completely simplified for users who have monitoring disabled. To do this, assign the empty function to the original interceptor method:
class Promise { // ... constructor(executor) { // ... this._promiseCreatedHook(); } // no-op . _promiseCreatedHook() {} }
Now, if the user has not turned on the monitoring function, the optimizer sees that the interceptor does nothing and simplifies it. It turns out that the interceptor method in the constructor simply does not exist.
In order for the monitoring function to work, its inclusion must overwrite all the related no-op functions to their actual implementation:
function enableMonitoringFeature() { Promise.prototype._promiseCreatedHook = function() { // }; // ... }
Such a rewrite of the method invalidates the inline caches created for objects of the Promise class. This should be done when the application starts, before any promises are created. Thus, inline caches made after this will not know that no-op functions existed.
Original: Three JavaScript performance fundamentals that make Bluebird fast , by Petka Antonov.
Source: https://habr.com/ru/post/309848/
All Articles