AdaCore Blog

Designing a WebAssembly toolchain for Ada/SPARK

Designing a WebAssembly toolchain for Ada/SPARK

by Thijs Dreef

WebAssembly (Wasm) is a binary instruction format for a stack-based virtual machine, which was designed as a portable compilation target for programming languages. Wasm can be executed in browsers, native runtimes and embedded contexts.

Compiling a programming language to Wasm is done using a language specific toolchain. A toolchain often does more than just compiling some code to Wasm, e.g. it generates auxiliary code for communication between the runtime and the compiled program. Using these toolchains, it becomes possible to compile existing applications and libraries into a Wasm version.

The goal of my six-month internship at AdaCore was to draft a design for a toolchain that would support an Ada/SPARK workflow to WebAssembly. In this blog post the drafted design is introduced and discussed.

Current status WebAssembly

Before exploring how the design works, let's first see how Wasm is used and supported in other languages. Wasm is mainly used by the industry to:

  • Port existing code bases as done by Adobe and Autodesk

  • Speed up existing web based applications as done by Google Meet

  • Run serverless functions as showcased by Fermyon’s business model

A Wasm program is executed inside a runtime instead of an OS. You can find WebAssembly runtimes in many places such as: in your web browser, as a native application and there is even a micro runtime for embedded systems. WebAssembly is currently executed in two different environments. It can either run in a JavaScript environment or a Wasi (WebAssembly System Interface) environment. In a JavaScript environment a Wasm binary is used as a library, while in a Wasi environment it is run as a standalone program.

Wasm is a purely computational environment, meaning that it has no way to communicate with the outside world. To allow communication between a runtime and a Wasm program, two sets of functions are used; a set of imported functions as well as a set of exported functions The imported functions allow a Wasm program to call into the runtime to perform a specific task. The implementation of an import is provided by the runtime. The exported functions are used by the runtime to call into the Wasm program. The flow of data between WebAssemby and the runtime is illustrated in figure 1 and 2 for a Wasi and a JavaScript based runtime respectively.


To compile from a programming language into Wasm, a toolchain for that language is required. The toolchain is responsible for compiling the source code into Wasm and providing a way to interface with the runtime. Compilation to Wasm is the main functionality of the toolchain, which is accomplished by using the LLVM Wasm backend. During the compilation process some annotated functions are added to the import or export set. With this, the toolchain can produce Wasm files which can run in any runtime.

Toolchains such as Emscripten (C/C++) and wasm-bindgen (Rust) also generate additional JavaScript code when targeting non Wasi platforms. This allows a JavaScript developer to directly interface with the Wasm module as if it were an NPM package. The generated JavaScript code provides the required imports for the program to function correctly. The set of imported functions often contains functionalities used by the standard library, such as printing to the console. The set of exported functions can be imported by the JavaScript developer and called as if they were normal JavaScript functions. An overview of how a generic toolchain that generates glue code works can be seen in figure 3.


In a Wasi based environment no glue code is required. The runtime automatically provides the Wasi API as a set of imports. The Wasi API is a set of functions that provide access to the system the program runs on. This set of functions consists of but is not limited to: file access, networking, and system time. The set of imports is used to implement standard libraries so regular programs can be trivially compiled and executed in a Wasm runtime. Some additional code has to be written when a program requires non-Wasi-based imports. How this import is provided depends on the runtime used.

What is available for Ada

The current approach to compile Ada to Wasm is to use AdaWebPack. AdaWebPack uses a patched version of GNAT LLVM, a modified version of the GNAT runtime library and a set of bindings for Web APIs. AdaWebpack’s approach lets developers build Wasm applications targeting the web using Ada. This project demonstrates that it is possible to compile Ada to Wasm using GNAT LLVM and interface with JavaScript. It also shows that by using GNAT LLVM and some additional tools a toolchain can be created which has feature parity with existing toolchains. There is currently no way to develop Wasi applications using Ada.

WebAssembly developments

Wasm is a relatively young standard announced in 2015 and first released in 2017. Since then, Emscripten (C/C++) has changed its target from ASM.js (precursor of Wasm) to Wasm, wasm-bindgen (Rust) was developed to provide Rust with a way to target Wasm and other languages followed soon after. The tooling around Wasm is not the only thing that has changed. Development of the Wasm standard is done in the form of proposals. Eight proposals made it into the specification and 30 are currently in development. Designing a toolchain for such a fast-evolving landscape led us to explore these proposals.

One proposal of note is the component model proposal. This proposal spearheaded by Luke Wagner and others has the goal of supporting the definition of language-agonistic interfaces such as those defined by Wasi. Additionally it allows the composition of multiple of these interfaces. These interfaces when implemented and compiled are called components. To be language agnostic the interface is defined in an IDL (Interface Design Language) called WIT (WebAssembly Interface Types). From the WIT file, bindings can be generated for a specific language. This provides a standardized way for Wasm components to interface with components developed in other languages.

Designed toolchain

The design of an Ada/SPARK to Wasm toolchain has to take both the current and the future Wasm requirements into account. It is crucial to support both the component model proposal and the current approach The goal is to be able to compile source code that contains a set of imports and exports defined using the source language. At the core of the designed plan lies the WIT IDL from the component proposal. The WIT IDL is used to generate Ada bindings to export and import functionality. The same information is used to generate JavaScript glue code. A developer can then implement the exported functionality as if it were normal Ada code. The bindings and the developer-written Ada code can then be compiled to a Wasm binary. This can then be consumed as a component or a core Wasm binary with the combined glue code. A NPM package is generated to support the usage of Ada/SPARK modules with the JavaScript ecosystem.


Importing functionality

Importing functionality is done in two ways: either by generating bindings from a WIT descriptor or writing import statements by hand. An example of such an import statement is shown in the code indicated below. The import convention used in the examples below is not a language feature but part of the proposed plan.


function Random_Float return Float
   with Import        => True,
        Convention    => Wasm,
        Import_Module => "rng"
        Link_Name     => "randomFloat";

The Wasm import convention requires two arguments to create a correct entry in the imports section. The “Import_Module” specifies the module in which the function is located and the “Link_Name” specifies the function name. By compiling this function into your Ada project you create a dependency on a module RNG which contains at least the “randomFloat” function. This function has to be provided by your runtime or composed using the tools provided by the component model proposal.

The example given could easily be written by hand, however when more complex types need to be passed from the runtime to Ada and vice versa an ABI (Application Binary Interface) needs to be established. The WIT specification provides provides a complete ABI to pass higher level constructs such as records, variants and more. An example is given below where a WIT descriptor describing imports is transformed into Ada code.

We start from a WIT descriptor which describes the API provided by the runtime.

interface http-fetch-imports {
   record request{
      method: string,
      uri: string,
      body: string
   }

   record response{
      status: u16,
      body: string
   }

   fetch: func(req: request) -> result<response>
}

Which generates the following Ada specification

-- Generated types
   type Wasm_String is private;
   type Response is record
      Status: Interfaces.Unsigned_16;
      Body_Keyword: Wasm_String;
   end record;
   type Request is record
      Method: Wasm_String;
      Uri: Wasm_String;
      Body_Keyword: Wasm_String;
   end record;
   type Result_Response_Null (Is_Error: Boolean := False) is record
      case Is_Error is
         when True =>
            null;
         when False =>
            Succ_Value: Response;
      end case;
   end record;
   -- Generated functions
   function Fetch (Req: Request) return Result_Response_Null;

The generated Fetch function is responsible for the translation between the Ada ABI and the WIT ABI. The declaration of the fetch call which is imported from the runtime is done in the declare region of the generated Fetch function. The signature of this imported function is shown below.

procedure Wit_Import
   (Arg_0: Interfaces.Integer_32;
    Arg_1: Interfaces.Integer_32;
    Arg_2: Interfaces.Integer_32;
    Arg_3: Interfaces.Integer_32;
    Arg_4: Interfaces.Integer_32;
    Arg_5: Interfaces.Integer_32;
    Arg_6: Interfaces.Integer_32)
   with Import        => True,
        Convention    => Wasm,
        Import_Module => "http-fetch-imports"
        Link_Name     => "fetch";

The function does not resemble what we previously saw in the WIT specification because the WIT ABI lowers types into supported WebAssembly types (I32 I64, F32, F64). This conversion process is done according to a set of rules. The wit-bindgen tool follows these rules and generates the required code. The benefit of this is that an Ada developer only has to interface with the Fetch call described in the specification file; all the low-level interfacing code is generated by a tool.

Exporting functionality

Exporting functionality can be done by manually writing an export statement as seen below, however manually writing an export statement will not output any JavaScript glue code. As a result, this can only be done for function signatures with primitive types (I32, I64, F32, F64). In the example below all records are turned into pointers but the compiler does not guarantee how parameters are passed and returned.


function Generate_Image(Parameters: Render_Parameters) return Color_Array;
   with Export     => True,
        Convention => Wasm,
        Link_Name  => "generateImage";

Because of the complexity of returning types from manually annotated functions the preferred method is to specify the exports in the WIT descriptor. The tool then generates an empty subprogram which is called by the generated exported function. This empty subprogram can then be modified by a developer to call the intended function from their Ada code. The corresponding JavaScript glue code is generated from the WIT description meaning no manual code has to be written to call the Ada subprogram from JavaScript. An example of this workflow is given below.

interface path-tracer {
    record render-parameters {
        width: u32,
        height: u32
    }
    
    record color {
        r: u8,
        g: u8,
        b: u8,
        a: u8
    }

    type color-array = list<color>

    generate-image: func(parameters: render-parameters) -> color-array
}

default world path-tracer {
    export path-tracer: self.path-tracer
}

The implementation of the generated function constructs the required Ada objects and calls the function declared in the spec file.

function Generate_Image(Arg_0: Interfaces.Integer_32; Arg_1: Interfaces.Integer_32) return Interfaces.Integer_32;
   with Export     => True,
        Convention => Wasm,
        Link_Name  => "generateImage";

The tool only generates a spec declaration, it is up to the developer to implement this function and provide desired functionality.

function Generate_Image(Parameters: Render_Parameters) return Color_Array;

Generating glue code

When targeting a JavaScript-based runtime, it is required to provide the set of imports using JavaScript functions. This set of imports has to be supplied when the Wasm module is initialized. To simplify this initialization process, JavaScript code is generated which fetches the Wasm file and initializes the module. After initialization the exported functions can be called from JavaScript.

However, since the exports from the Ada program use the WIT ABI, it is not intuitive to directly call these functions. To make this more intuitive, a wrapper for these exports is provided which accepts a JavaScript representation of the WIT type. This WIT type is then used to call the exported function using the WIT ABI. Since JavaScript is a dynamically typed language, some extra code is required to reason about the WIT types. These WIT types are either constructed using JavaScript object notation or as a proxy object which stores its data in an ArrayBuffer.

To summarize, the JavaScript glue code has to automatically provide the set of imports and provide an interface to the exports using WIT types. To provide all this functionality, a NPM package is generated which contains a class for each WIT type and a main file with an init function and the exported functionality. This generated code can then be used as follows:


import * as wasm from 'ada-path-tracer'
// Do async operation in a separate functions so we can use await
const main = async () => {
   // Wait for the Wasm file to be downloaded and initialized.
   await wasm.init();
   // Call exported generateImage functionality
   const image = wasm.generateImage(
      // Create an instance of RenderParameters using JavaScript object notation.
      {width, height}
   );
   // A list of colors is now available from the image variable.
}
// Call asynchronous main
main();

Results

The previously mentioned design has been executed by hand as a proof of concept. The results of this proof of concept is compared to existing toolchains. A path tracer has been developed to compare differences between the languages and their toolchains. The path tracer renders a scene with two spheres at a maximum recursion depth of 100 and at a resolution of 500 x 281. The demo was written in Rust (wasm-bindgen), C (Emscripten), C++ (Emscripten) and Ada (Proof of concept).

One metric which is of importance when deploying to the web is file size. On slower internet connections such as mobile connections, the difference between a Wasm footprint of 20 kilobytes or 40 kilobytes can make a big difference. The file size metric is calculated by combining the size of the Wasm binary file and the required JavaScript glue code. The JavaScript written by the end user has not been taken into account.

We see that Ada comes in at a solid 2nd place in terms of file size. C outperformed Ada in terms of file size. This can be attributed to the fact that C requires less glue code. C provides no form of rich type support (meaning only scalar types can be communicated). Rich type support is a large portion of the file size, which explains why a large increase in file size is observed with Rust, C++ and Ada which all support rich types. This means that Ada comes in first among languages that support rich types in terms of file size.

The other metric of importance is execution time. A lower execution time means we get closer to native performance. However we observe that Ada underperforms when compared to C, Rust and C++. Rust slows down significantly when run in firefox while C++ and Ada perform more consistently between the two browsers. Overall Ada underperforms in this path tracing benchmark, it would be interesting to see how it would fare in other benchmarks.

Closing remarks

In this blog post, a design for an Ada to Wasm toolchain has been proposed. This design aims to be future proof by incorporating the component model proposal into its design while also supporting the current situation. By generating a NPM package, a workflow from Ada to JavaScript is presented. The findings of this six-month internship can be used to build out WebAssembly support for Ada and SPARK.


Posted in

About Thijs Dreef

Thijs joined AdaCore in 2023 for a 6 months internship as part of his B.Sc. in Software Engineering at Amsterdam University of Applied Sciences.