linkedin / linkedin/css-blocks
Proposal: Binary Encoding of Boolean Expression Shapes
- Dominant language
- TypeScript
- Stars
- 6.3k
- Forks
- 154
- PR merge metrics
- No merged PRs in 30d
Description
## Problem
1. Currently, pushing styling logic to the templates bloats compiled template size and, in some cases, results in a net app size increase. We need a more efficient way to encode boolean logic back into the template to reduce template bloat.
2. Template rewriters have the unnecessary responsibility of translating boolean expression objects emitted by `opticss` to the `css-blocks` runtime helper syntax. Is is possible to remove this overhead and push responsibility back to `css-blocks` core.
## Proposal
We can greatly compress the `css-blocks` runtime by treating the runtime helper as a projection of arguments over unique boolean expression "shapes".
To maximize space efficiency we store these boolean expression instructions as a list of binary opcodes. All required opcodes for boolean expression evaulation may be represented using just three (3) bits:
```typescript
const OP_CODES = {
0: 'OPEN', // 000
1: 'VAL', // 001
2: 'NOT', // 010
3: 'OR', // 011
4: 'AND', // 100
5: 'EQUAL', // 101
6: '---', // 110
7: 'CLOSE' // 111
};
```
> Note: The VAL opcode is **always** followed by an integer representing the index of the dynamic value passed to our helper it represents. The number of bits to look at to fetch an index is determined by the number of dynamic expressions passed to ensure minimal size. All other opcodes have no special concerns or lookahead.
The following boolean expression:
``` js
!(exp1 || exp2)
```
May be compiled to the following opcodes:
```js
let opcodes = "NOT OPEN VAL 0 OR VAL 1 CLOSE";
```
And then be represented by the following binary string:
```js
let binaryOpcodes = "010 000 001 0 011 001 1 111";
```
Encoded shape expressions can be delivered to the runtime helper as an array of base 36 encoded Uint32 integers. The binary opcode order is reversed when inserted into base 36 integers to a) reduce encoded size when delivered to the browser and b) simplify the runtime function implementation.
Continuing the previous example, the above binary opcodes can be delivered as base 36 encoded Uint32 integers by undergoing the following transformations:
```js
// Original binary opcode list:
// 010 000 001 0 011 001 1 111
let integer = parseInt("000000000000 111 1 001 011 0 001 000 010", 2); // 994370
let encoded = integer.toString(36); // "lb9e"
```
When a set of binary opcodes exceed the 32 bit limit, the opcodes overflow into the next encoded integer delivered in the expression shape's array.
To account for the logic required by many classes that must be applied in a single runtime call to an individual element, the boolean expressions for multiple classes may be encoded into a single expression shape by delineating standalone expressions with an extra `CLOSE` opcode, demonstrated below.
> Note: Because expression shapes are encoded as strings we have the potential to reap gzip/brotli benefits if many identical expression shapes are reused across the app.
The runtime helper public interface now takes the following shape:
``` typescript
function runtime(shape: string[], classes: string[], expressions: boolean[]) => string;
```
An example with logic for multiple classes call may look like:
```js
// Original: objstr({ class1: expr1 == expr2, class2: expr1 !== expr2 })
// VAL 0 EQUALS VAL 1 CLOSE VAL 0 NOT EQUALS VAL 1
// 001 0 101 001 1 111 001 0 010 101 001 1
// 162036945
// "2oh0i9"
runtime(["2oh0i9"], ["class1", "class2"], [expr1, expr2]);
```
Here we encode the boolean expressions for two classes. `class1` should be displayed when `expr1` and `expr2` are equal. `class2` should be displayed when `expr1` and `expr2` are not equal.
In practice, the runtime implementation for this type of binary encoding system is very fast. An opcode parser may look something like this:
```js
const INT_SIZE = 32;
function computeStyles(shape, classes, expressions) {
// We dynamically determine the variable window size based on the number of
// expressions it is possible to reference. It is the compiler's responsibility
// to guarentee the expression shape matches at build time.
const VAR_SIZE = ~~Math.log2(expressions.length - 1) + 1;
let klass = 0, // Current class we are determining state of
opcode = null, // Current opcode to evaluate.
lookahead = null, // This is a single lookahead parser – lookahead opcode will be stored here.
step = 0, // Character count used for opcode discovery
invertNext = false, // Should we invert the next discovered value
val, // Working boolean value
stack = []; // Stack for nested boolean expressions
// For each 32 bit integer passed to us as a base 36 string
for ( let segment of shape ) {
// Convert to a base 10 integer
let integer = parseInt(segment, 36);
// Process each bit in this integer.
// Note: `while` loop is faster than a `for` loop here.
let iters = INT_SIZE;
while (iters--) {
// Construct our lookahead opcode and "pop" a bit off the end
// of our integer's binary representation.
lookahead += integer % 2 * (2 * step || 1);
integer = integer >>> 1;
// When we have discovered the next opcode, process.
if (!(step = ++step % (opcode == 1 ? VAR_SIZE : 3))) {
// Each opcode type requires implementation
switch (opcode) {
// START
case 0: break;
// VAL
case 1: break;
// NOT
case 2: break;
// OR
case 3: break;
// AND
case 4: break;
// EQUAL
case 5: break;
// ---
case 6: break;
// CLOSE
case 7: break;
}
// Begin construction of new opcode.
opcode = lookahead;
lookahead = null;
}
}
}
}
```
This example implementation runs at sub-millisecond times (~0.2ms) for even exceptionally long opcode sequences (~2000 bits). The addition of opcode functionality should not increase this significantly as boolean expression shape parsing should be the limiting operation.
## Problems
This proposal, as written, does away with the current concept of [Source Expressions](https://github.com/css-blocks/css-blocks/blob/master/packages/runtime/src/runtime.ts#L50). All source expressions – Boolean, Ternary and Switch – can be encoded for individual classes in their respective boolean expressions. This may not be the most efficient way to encode this logic and there is probably a way to encode these source expressions into the expression shape without increasing opcode size. Certainly something to explore more.
Another source of template bloat comes from having to write all substate values into the template to accommodate dynamic state logic. Substate strings may be best hard coded into a generated file and shared among all templates and instead reference them by UID. However, this opens a whole new can of worms, especially for code splitting. Also, with gzip, the benefits of substate abstraction may be compressed away and is possibly a worthless optimization.
We would be able to get even more template size savings by encoding the Uint32s in base 85, but this would bloat the runtime and slow down base conversion – may not be worth it.
Contributor guide
Research direction
Start by reading packages/runtime/src/runtime.ts and the current runtime helper and source-expression handling described in the issue. Trace how template rewriters and opticss provide boolean expression objects, then determine whether the proposed opcode shapes can replace that flow; done requires a decided design plus compatible runtime, compiler, and template behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- frontend, performance
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Stale
- Clarity
- Mostly clear
- Newbie friendliness
- 20/100