Components

This is an example app to demonstrate how Julia code for DiffEq-type simulations can be compiled for use on the web. This app is built with the following:

  • StaticCompiler compiles a Julia model to WebAssembly. This uses GPUCompiler which does most of the work. StaticTools helps with this static compilation.

  • DiffEqGPU provides simulation code that is amenable to static compilation.

  • WebAssemblyInterfaces and wasm-ffi provide convenient ways to interface between JavaScript and Julia/WebAssembly code.

  • mdpad provides features for single-page web apps.

  • PkgPage and Franklin build this page from Markdown. The source code on this page also compiles the WebAssembly modeling code.

WebAssembly

Here is the model with initial conditions that we'll compile. The important part is using DiffEqGPU to set up an integrator. Because it is designed to run on a GPU, it is natural for static compilation. It doesn't allocate or use features from libjulia.

using DiffEqGPU, StaticArrays, OrdinaryDiffEq

function lorenz(u, p, t)
    σ = p[1]
    ρ = p[2]
    β = p[3]
    du1 = σ * (u[2] - u[1])
    du2 = u[1] * (ρ - u[3]) - u[2]
    du3 = u[1] * u[2] - β * u[3]
    return SVector{3}(du1, du2, du3)
end

u0 = @SVector [1.0; 0.0; 0.0]
tspan = (0.0, 20.0)
p = @SVector [10.0, 28.0, 8 / 3.0]
prob = ODEProblem{false}(lorenz, u0, tspan, p)

integ = DiffEqGPU.gputsit5_init(GPUTsit5(), lorenz, false, u0, 0.0, 0.005, p, nothing, CallbackSet(nothing), true, false)

Now, we can define a function to solve this model. We won't use DiffEqGPU.solve() because that's too complicated. Instead, we'll use integ and manually step through the solution. We'll update solution vectors along the way.

function solv(integ, tres, u1, u2, u3)
    for i in Int32(1):Int32(10000)
        @inline DiffEqGPU.step!(integ, integ.t + integ.dt, integ.u)
        tres[i] = integ.t
        u1[i] = integ.u[1]
        u2[i] = integ.u[2]
        u3[i] = integ.u[3]
    end
    nothing
end

Now, we can compile solv to the WebAssembly file _libs/julia_solv.wasm using StaticCompiler.compile_wasm. StaticTools.MallocVector is used for the solution vectors. When compiling, flags are passed to the WebAssembly linker (lld -flavor wasm), and we can include the initial memory size and other files to link in. Initial memory must be big enough to hold objects we'll use.

using StaticCompiler, StaticTools

compile_wasm(solv, 
    Tuple{typeof(integ), 
          MallocVector{Float64}, MallocVector{Float64}, 
          MallocVector{Float64}, MallocVector{Float64}}, 
    path = "_libs",
    flags = `--initial-memory=1048576 walloc.o`, filename = "julia_solv")

StaticCompiler can only compile a restricted subset of Julia code. DiffEqGPU is amenable to static compilation. It doesn't have internal allocations or use of Arrays or other code needing libjulia functionality. Note that DiffEqGPU has fewer options for solvers, and solvers are not as robust as standard DiffEq packages.

Note that WebAssembly in browsers is mainly a 32-bit system (wasm32). A 64-bit Julia can compile to wasm32, but the best approach is to use a 32-bit version of Julia, so the memory layouts are closer. This page was developed locally with 64-bit Julia.

Interfacing

wasm-ffi is a great JavaScript package that provides convenient ways to interface between JavaScript and WebAssembly code. It can allocate objects in WebAssembly memory and provides conveniences to read and write to those objects. We use the Julia package WebAssemblyInterfaces to generate JavaScript code for wasm-ffi.

WebAssembly has no automatic memory management. All WebAssembly memory must be manually allocated and freed. wasm-ffi will allocate objects upon definition. The WebAssembly code must include allocate and deallocate functions. Up above, we linked to the file walloc.o in the --initial-memory=1048576 walloc.o statement. This is from walloc. The flags argument is passed to the linker and can include other wasm32 object files. The memory must be a multiple of 65536 bytes.

This is the definition of the integrator used by solv. It is a mutable struct. Here is how we generate interfacing code that generates types in JavaScript that will replicate the memory layout we need in Julia:

using WebAssemblyInterfaces

integ_types = js_types(typeof(integ))
integ_def = js_def(integ)

println(integ_types)
const SArray = new ffi.Struct({
    data: ['f64', 3],
});

const SArrayTuple_6__Float64_1_6 = new ffi.Struct({
    data: ['f64', 6],
});

const SArrayTuple_21__Float64_1_21 = new ffi.Struct({
    data: ['f64', 21],
});

const SArrayTuple_22__Float64_1_22 = new ffi.Struct({
    data: ['f64', 22],
});

const GPUTsit5Integrator = new ffi.Struct({
    uprev: SArray,
    u: SArray,
    tmp: SArray,
    tprev: 'f64',
    t: 'f64',
    t0: 'f64',
    dt: 'f64',
    tdir: 'f64',
    p: SArray,
    u_modified: 'bool',
    tstops_idx: 'int64',
    save_everystep: 'bool',
    saveat: 'bool',
    cur_t: 'int64',
    step_idx: 'int64',
    event_last_time: 'int64',
    vector_event_last_time: 'int64',
    last_event_error: 'f64',
    k1: SArray,
    k2: SArray,
    k3: SArray,
    k4: SArray,
    k5: SArray,
    k6: SArray,
    k7: SArray,
    cs: SArrayTuple_6__Float64_1_6,
    as: SArrayTuple_21__Float64_1_21,
    rs: SArrayTuple_22__Float64_1_22,
    retcode: 'int32',
});

We will later use both of these results to splice this into our JavaScript code included in this file.

On the JavaScript side, we can manipulate the object as you would expect, like integ.dt = 0.2 or integ.p = [12, 3, 4].

Publishing

WebAssembly files can be used in any type of web page, including those created with static-site generators like Jekyll. Julia has several great options for creating HTML pages, including Documenter, Franklin, and Literate. For this page, I used PkgPage which is nice for "one pagers". Using a Julia-based option is nicer in that we can use the results and stuff them in the page. For example, the interfacing code above is directly included with a custom PkgPage/Franklin HTML command.

We also need JavaScript to control interactivity. (Doing this on the Julia/WebAssembly side is not yet feasible.) There are so many JavaScript packages, it's hard to pick. Here, I use mdpad which has features that are nice for one-page apps. To use it, we need to define mdpad_init and mdpad_update functions. I used Mithril.js to generate inputs and outputs.

To start with, we'll write out our interfacing code from above. We'll use a custom Franklin HTML command to insert this into the HTML for this page ({{ rawoutput j5 }} later in this file). integ_types is just stored as regular definition. integ_def is defined as a function to allow new instances to be created.

println("<script>\n", integ_types, "\n\n")
println("function new_integ() {return ", integ_def, "\n}\n</script>")

Now, we need to define our interfacing code using wasm-ffi. This code is included in this Markdown file with ~~~ delimeters. ffi.rust.vector maps to a StaticTools.MallocVector.

const library = new ffi.Wrapper({
  julia_solv: ['number', [GPUTsit5Integrator, ffi.rust.vector('f64'), ffi.rust.vector('f64'),
                                              ffi.rust.vector('f64'), ffi.rust.vector('f64')]],
}, {debug: false});

library.imports(wrap => ({
  env: {
    memory: new WebAssembly.Memory({ initial: 16 }),
  },
}));

Here are definitions for output vectors passed to Julia code.

var t = new ffi.rust.vector('f64', new Float64Array(10000))
var u1 = new ffi.rust.vector('f64', new Float64Array(10000))
var u2 = new ffi.rust.vector('f64', new Float64Array(10000))
var u3 = new ffi.rust.vector('f64', new Float64Array(10000))

In mdpad_init, we load the WebAssembly file libs/julia_solv.wasm and then create an input form.

async function mdpad_init() {
    await library.fetch('libs/julia_solv.wasm')
    var layout =
      m(".row",
        m(".col-md-3",
          m("br"),
          m("br"),
          m("form.form",
            minput({ title:"σ", mdpad:"p1", step:0.2, value:10.0 }),
            minput({ title:"ρ", mdpad:"p2", step:1.0, value:28.0 }),
            minput({ title:"β", mdpad:"p3", step:0.1, value:8 / 3 }),
           )),
        m(".col-md-1"),
        m(".col-md-8",
          m("#results"),
          m("#plot1", {style:"max-width:500px"})),
      m(".row",
        m(".col-md-1"),
        m(".col-md-8",
          m("#plot2"))))
    await m.render(document.querySelector("#mdpad"), layout);
}

mdpad_update, creates allocates a new integrator, updates the initial conditions using data from the form, and runs julia_solv. julia_solv fills up the output vectors, and we plot these with Plotly.

function mdpad_update() {
    var integ = new_integ();
    integ.p.data = [mdpad.p1, mdpad.p2, mdpad.p3];
    library.julia_solv(integ, t, u1, u2, u3);
    integ.free();
    tdata = [{x: t.values, y: u1.values, type: "line", name: "x"}, 
            {x: t.values, y: u2.values, type: "line", name: "y"}, 
            {x: t.values, y: u3.values, type: "line", name: "z"}] 
    tplot = mplotly(tdata, { width: 900, height: 300, margin: { t: 20, b: 20 }}, {responsive: true})
    m.render(document.querySelector("#plot2"), tplot)
    xydata = [{x: u1.values, y: u2.values, type: "line", name: "x"}] 
    xyplot = mplotly(xydata, { width: 400, height: 400, margin: { t: 20, b: 20, l: 20, r: 20 }}, {responsive: true})
    m.render(document.querySelector("#plot1"), xyplot)
}

That's it! Overall, the experience with PkgPage is rather interactive. During development, make changes to the Julia code, the Markdown, or the JavaScript code, save the file, and watch results update in the browser.

This work takes inspiration from this cool fluid simulation tool in Julia/WebAssembly by Alexander Barth.