Skip to the content
W06NotesEngines from nothing

Crank, a WebAssembly interpreter written from nothing

Crank reads WebAssembly’s binary format, checks it, and runs it one in­struc­tion at a time, in plain JavaScript and with­out the browser’s own WebAssembly. It was writ­ten from an empty file and the spec­i­fi­ca­tion, and it passes every test in the of­fi­cial suite it runs. Below, it runs four small pro­grams where you can watch it, and every result is held against your browser run­ning the same bytes.

PostedKindNotesSeriesEngines from nothing

        
      

·

Crank prints the list­ing from the bytes it runs in­stead of from the source, with the names stored in the module’s name sec­tion. The marked line is the in­struc­tion Crank takes next. When a run ends, your browser’s own WebAssembly runs the same bytes with the same ar­gu­ments, and the two an­swers are set side by side.

Bytes first

A WebAssembly module starts with four bytes that spell \0asm and four more for the ver­sion. After them come sec­tions, each an id, a length and its con­tents, in a fixed order: the types of the func­tions, what the module im­ports, its func­tions, tables, memory and glob­als, what it ex­ports, and last the code and the data. Numbers are writ­ten in LEB128, seven bits to a byte with the eighth saying whether an­other byte fol­lows, so a small number takes one byte and a large one takes more.

    ·

    Crank’s de­coder is strict where the spec­i­fi­ca­tion is strict. A number may not use more bytes than its width needs, and the unused bits of its last byte must be zero, or repeat the sign. A name must be valid . Writing that last check turned up a trap in the plat­form rather than the format: JavaScript’s TextDecoder drops a byte order mark at the start of its input, so a name that begins with one came out shorter than its bytes. The test suite has ex­actly such a name, and Crank’s de­coder now sets ignoreBOM on TextDecoder, so the mark stays in the name.

    Checked before it runs

    Before a single in­struc­tion runs, the whole module is val­i­dated. Crank walks every func­tion once with a stack of types in­stead of values: i32.add must find two i32 on it and leaves one, a block must end with ex­actly the types in its sig­na­ture, and a branch must leave on the stack the types of its target block. Code after an un­con­di­tional branch can never run, and there the stack be­comes poly­mor­phic, so any type it asks for is taken as given.

    After val­i­da­tion, the in­ter­preter runs with­out checks of its own. It never checks a type, never looks for a miss­ing value, and never asks whether a branch target exists. Validation also writes down where every block’s else and end are, so a branch is a jump to a known place and not a search.

    A stack of its own

    A nat­ural way to write an in­ter­preter is to have a WebAssembly call become a JavaScript call. It is short, and it hands the depth of re­cur­sion to JavaScript, whose stack runs out long before WebAssembly’s should, with an error that names the wrong thing. Crank keeps its own stack of frames in­stead. A call pushes a frame with the func­tion, its locals, the place to return to and its labels, and a return pops it, so the loop that runs in­struc­tions never calls itself.

    That is also what makes step­ping pos­si­ble. Between any two in­struc­tions the whole state of the pro­gram is data: the values on the stack and the frames above them, which is what the panel beside the list­ing shows. A run is the same loop taking a few hun­dred thou­sand steps at a time and giving the page back be­tween them. When a re­cur­sion goes too deep, Crank stops at a fixed number of frames and says the call stack is ex­hausted, as the spec­i­fi­ca­tion asks.

    A label is where a branch goes. A block is left for­wards, at its end, and a loop is left back­wards, at its start, so br to a loop is how WebAssembly re­peats. A branch keeps as many values as its label takes, drops every­thing else down to the height the block started at, and goes on from there.

    Numbers the way the machine has them

    An i32 is a JavaScript number kept in range with | 0 and Math.imul. An i64 does not fit in a double, so it is a BigInt, wrapped to sixty four bits after every op­er­a­tion. An f32 is a double rounded with Math.fround after each step, which gives the right answer be­cause a double has more than twice the pre­ci­sion of a float.

    Two cor­ners needed more. The first is NaN. WebAssembly says which bits a NaN has, and some in­struc­tions must keep them, but JavaScript has one NaN and for­gets the rest. Crank car­ries a NaN with par­tic­u­lar bits as an object that holds them and still com­pares as NaN. The second is turn­ing a large i64 into an f32. Through a double, the number is rounded twice, and a value just above halfway be­tween two floats can land ex­actly on halfway after the first round­ing and go the wrong way on the second. Crank keeps one extra bit, set when any­thing was cut off, and rounds once.

    Held against the specification

    The WebAssembly spec­i­fi­ca­tion comes with a test suite: scripts that define mod­ules and say what call­ing them must return, where they must trap, and which mod­ules must be re­fused as mal­formed, in­valid or un­link­able. The com­mand runs every script for the second ver­sion of the stan­dard. WABT, the ref­er­ence tools for WebAssembly, turns each module’s text into bytes, and Crank does the rest on its own.

    $ node web/notes/crank/spec.mjs
    89 scripts from the WebAssembly test suite, 27,831 commands
    passed 27,259, failed 0, not run 572, in 9.5 s
    passed, by kind of command:
      21,453  assert_return
       2,388  assert_trap
       1,475  assert_invalid
       1,126  module
         719  assert_malformed
          83  assert_unlinkable
          15  assert_exhaustion
    not run, and why:
         570  malformed text
           2  WABT left out the data count section

    Nothing fails. What is not run is text that is mal­formed on pur­pose, which tests a text parser Crank does not have, and two mod­ules where WABT writes the bytes with­out the sec­tion the test is about.

    Held against

    The four pro­grams above, run again in Crank and in own WebAssembly from the same bytes. For the Mandelbrot set, every byte each row writes to memory goes into a fin­ger­print, so the same result means the same image.

    $ node web/notes/crank/race.mjs
    node v22.22.0, 4 programs, each the same bytes in Crank and in V8
    
    program                             result                            steps      Crank        V8   slower
    fib fib(24)                         46368                         1,650,535      67 ms   0.29 ms     230x
    fac fac(0..25), 1000 times          -1307449385690794608          4,160,000     120 ms   1.37 ms      87x
    sieve sieve(1000000)                78493                        40,564,220    1192 ms   2.84 ms     420x
    mandelbrot 320 by 180, at most 100  1399128 903779024            57,217,382    1605 ms   4.10 ms     391x
    
    the same results in 4 of 4

    The an­swers are the same, and the times are far apart. com­piles WebAssembly to ma­chine code before it runs, and Crank looks at every in­struc­tion every time it passes, with a JavaScript value for each number on its stack. A first ver­sion was twice as slow again, be­cause it looked the arith­metic up in an object whose keys run past the one byte op­codes, which keeps as a slow dic­tio­nary.

    Crank
    Your browser
    The same program, a row at a time, in Crank and in your browser.

    What it does not have

    Crank runs the second ver­sion of WebAssembly with­out its vector in­struc­tions. It has no threads, no more than one memory, no tail calls, no ex­cep­tions and no garbage col­lected types, which are all later pro­pos­als. It reads the binary format only, so its tests and the four pro­grams here are turned from text into bytes by WABT. It does not com­pile any­thing, which is why it is hun­dreds of times slower than the browser, and why every step can be watched.

    The source is in the port­fo­lio’s repos­i­tory under web/notes/crank/: crank.js for the de­coder, the val­ida­tor and the in­ter­preter, text.js for the list­ing, spec.mjs and race.mjs for the com­mands, and the pro­grams as text under programs/. The test scripts are the WebAssembly spec­i­fi­ca­tion’s, under the Apache License.