Add an experimental static library web demo using Emscripten. (#8171)

This is a proof of concept for deploying to the web using IREE. See the README for more details.

Rough structure:
* `build_static_emscripten_demo.sh` compiles an MNIST sample model targeting WebAssembly (`--iree-hal-target-backends=llvm --iree-llvm-target-triple=wasm32-unknown-unknown -iree-llvm-link-static`), compiles the runtime using Emscripten, then starts a local webserver for the provided index.html
  * Parts of this could be folded into the CMakeLists.txt, but I think this split is easier to follow
* `index.html` has the web demo code (`<canvas>` for drawing, javascript for interfacing between the UI and the Wasm module)
* `main.c` has the native demo code (using IREE's high level `runtime/` API)
* `device_sync.c` and `device_multithreaded.c` implement CPU devices using the static library output

Future work _not_ finished here:

* Finish multithreading setup. I'm still debugging something in our use of threading/synchronization primitives and Emscripten's pthreads implementation (an infinite timeout instead always returns immediately, breaking our implementation)
* Support for non-static deployment by implementing an emscripten loader (similar to dlopen)
* Building/testing on our CI. We have an Emscripten build in https://github.com/google/iree/blob/main/build_tools/buildkite/cmake/build_configurations.yml that could be extended to cover this though

The resulting web page looks like this:  ![mnist_demo](https://cdn.discordapp.com/attachments/782059441641881630/935243419029225523/iree_mnist_web_demo.gif)

(again - proof of concept, the demo program is not very accurate and the preprocessing logic may be wrong :D)
diff --git a/experimental/sample_web_static/CMakeLists.txt b/experimental/sample_web_static/CMakeLists.txt
new file mode 100644
index 0000000..2cf9582
--- /dev/null
+++ b/experimental/sample_web_static/CMakeLists.txt
@@ -0,0 +1,103 @@
+# Copyright 2022 The IREE Authors
+#
+# Licensed under the Apache License v2.0 with LLVM Exceptions.
+# See https://llvm.org/LICENSE.txt for license information.
+# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+if(NOT EMSCRIPTEN)
+  return()
+endif()
+
+set(_MNIST_OBJECT_NAME "iree_experimental_sample_web_static_mnist")
+add_library(${_MNIST_OBJECT_NAME} STATIC ${CMAKE_CURRENT_BINARY_DIR}/mnist_static.o)
+SET_TARGET_PROPERTIES(${_MNIST_OBJECT_NAME} PROPERTIES LINKER_LANGUAGE C)
+
+#-------------------------------------------------------------------------------
+# Sync
+#-------------------------------------------------------------------------------
+
+set(_NAME "iree_experimental_sample_web_static_sync")
+add_executable(${_NAME} "")
+target_include_directories(${_NAME} PUBLIC
+    $<BUILD_INTERFACE:${CMAKE_CURRENT_BINARY_DIR}>
+)
+target_sources(${_NAME}
+  PRIVATE
+    main.c
+    device_sync.c
+    ${CMAKE_CURRENT_BINARY_DIR}/mnist_static.h
+    ${CMAKE_CURRENT_BINARY_DIR}/mnist_bytecode.h
+    ${CMAKE_CURRENT_BINARY_DIR}/mnist_bytecode.c
+)
+set_target_properties(${_NAME} PROPERTIES OUTPUT_NAME "sample-web-static-sync")
+
+# Note: we have to be very careful about dependencies here.
+#
+# The general purpose libraries link in multiple executable loaders and HAL
+# drivers/devices, which include code not compatible with Emscripten.
+target_link_libraries(${_NAME}
+  ${_MNIST_OBJECT_NAME}
+  iree_runtime_runtime
+  iree_hal_local_loaders_static_library_loader
+  iree_hal_local_sync_driver
+)
+
+target_link_options(${_NAME} PRIVATE
+  # https://emscripten.org/docs/porting/connecting_cpp_and_javascript/Interacting-with-code.html#interacting-with-code-ccall-cwrap
+  "-sEXPORTED_FUNCTIONS=['_setup_sample', '_cleanup_sample', '_run_sample']"
+  "-sEXPORTED_RUNTIME_METHODS=['ccall','cwrap']"
+  #
+  "-sASSERTIONS=1"
+  #
+  "-gsource-map"
+  "-source-map-base="
+)
+
+#-------------------------------------------------------------------------------
+# Multithreaded
+#-------------------------------------------------------------------------------
+
+set(_NAME "iree_experimental_sample_web_static_multithreaded")
+add_executable(${_NAME} "")
+target_include_directories(${_NAME} PUBLIC
+    $<BUILD_INTERFACE:${CMAKE_CURRENT_BINARY_DIR}>
+)
+target_sources(${_NAME}
+  PRIVATE
+    main.c
+    device_multithreaded.c
+    ${CMAKE_CURRENT_BINARY_DIR}/mnist_static.h
+    ${CMAKE_CURRENT_BINARY_DIR}/mnist_bytecode.h
+    ${CMAKE_CURRENT_BINARY_DIR}/mnist_bytecode.c
+)
+set_target_properties(${_NAME} PROPERTIES OUTPUT_NAME "sample-web-static-multithreaded")
+
+# Note: we have to be very careful about dependencies here.
+#
+# The general purpose libraries link in multiple executable loaders and HAL
+# drivers/devices, which include code not compatible with Emscripten.
+target_link_libraries(${_NAME}
+  ${_MNIST_OBJECT_NAME}
+  iree_runtime_runtime
+  iree_hal_local_loaders_static_library_loader
+  iree_hal_local_task_driver
+  iree_task_api
+)
+
+target_link_options(${_NAME} PRIVATE
+  # https://emscripten.org/docs/porting/connecting_cpp_and_javascript/Interacting-with-code.html#interacting-with-code-ccall-cwrap
+  "-sEXPORTED_FUNCTIONS=['_setup_sample', '_cleanup_sample', '_run_sample']"
+  "-sEXPORTED_RUNTIME_METHODS=['ccall','cwrap']"
+  #
+  "-sASSERTIONS=1"
+  #
+  "-gsource-map"
+  "-source-map-base="
+  #
+  # https://emscripten.org/docs/porting/pthreads.html#compiling-with-pthreads-enabled
+  "-pthread"
+  # "-sINITIAL_MEMORY=67108864"  # 64MB
+  "-sPTHREAD_POOL_SIZE=2"
+  # https://emscripten.org/docs/porting/pthreads.html#additional-flags
+  # "-sPROXY_TO_PTHREAD"
+)
diff --git a/experimental/sample_web_static/README.md b/experimental/sample_web_static/README.md
new file mode 100644
index 0000000..c2d55b5
--- /dev/null
+++ b/experimental/sample_web_static/README.md
@@ -0,0 +1,37 @@
+# Static Web Sample
+
+This experimental sample demonstrates one way to target the web platform with
+IREE. The output artifact is a web page containing an interactive MNIST digits
+classifier.
+
+## Quickstart
+
+1. Install IREE's host tools (e.g. by building the `install` target with CMake)
+2. Install the Emscripten SDK by
+   [following these directions](https://emscripten.org/docs/getting_started/downloads.html)
+3. Initialize your Emscripten environment (e.g. run `emsdk_env.bat`)
+4. From this directory, run `bash ./build_static_emscripten_demo.sh`
+    * You may need to set the path to your host tools install
+5. Open the localhost address linked in the script output
+
+To rebuild most parts of the demo (C runtime, sample HTML, CMake config, etc.),
+just `control + C` to stop the local webserver and rerun the script.
+
+## How it works
+
+This [MNIST model](../../iree/samples/models/mnist.mlir), also used in the
+[Vision sample](../../iree/samples/vision/), is compiled using the "static
+library" output setting of IREE's compiler (see the
+[Static library sample](../../iree/samples/static_library)). The resulting
+`.h` and `.o` files are compiled together with `main.c`, while the `.vmfb` is
+embedded into a C file that is similarly linked in.
+
+[Emscripten](https://emscripten.org/) is used (via the `emcmake` CMake wrapper)
+to compile the output binary into WebAssembly and JavaScript files.
+
+The provided `index.html` file can be served together with the output `.js`
+and `.wasm` files.
+
+## Multithreading
+
+TODO(scotttodd): this is incomplete - more changes are needed to the C runtime
diff --git a/experimental/sample_web_static/build_static_emscripten_demo.sh b/experimental/sample_web_static/build_static_emscripten_demo.sh
new file mode 100644
index 0000000..e91b96c
--- /dev/null
+++ b/experimental/sample_web_static/build_static_emscripten_demo.sh
@@ -0,0 +1,102 @@
+#!/bin/bash
+# Copyright 2022 The IREE Authors
+#
+# Licensed under the Apache License v2.0 with LLVM Exceptions.
+# See https://llvm.org/LICENSE.txt for license information.
+# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+set -e
+
+###############################################################################
+# Setup and checking for dependencies                                         #
+###############################################################################
+
+if ! command -v emcmake &> /dev/null
+then
+  echo "'emcmake' not found, setup environment according to https://emscripten.org/docs/getting_started/downloads.html"
+  exit
+fi
+
+CMAKE_BIN=${CMAKE_BIN:-$(which cmake)}
+ROOT_DIR=$(git rev-parse --show-toplevel)
+
+BUILD_DIR=${ROOT_DIR?}/build-emscripten
+mkdir -p ${BUILD_DIR}
+
+BINARY_DIR=${BUILD_DIR}/experimental/sample_web_static/
+mkdir -p ${BINARY_DIR}
+
+###############################################################################
+# Compile from .mlir input to static C source files using host tools          #
+###############################################################################
+
+# TODO(scotttodd): portable path ... discover from python install if on $PATH?
+INSTALL_ROOT="D:\dev\projects\iree-build\install\bin"
+TRANSLATE_TOOL="${INSTALL_ROOT?}/iree-translate.exe"
+EMBED_DATA_TOOL="${INSTALL_ROOT?}/generate_embed_data.exe"
+INPUT_NAME="mnist"
+INPUT_PATH="${ROOT_DIR?}/iree/samples/models/mnist.mlir"
+
+echo "=== Translating MLIR to static library output (.vmfb, .h, .o) ==="
+${TRANSLATE_TOOL?} ${INPUT_PATH} \
+  --iree-mlir-to-vm-bytecode-module \
+  --iree-input-type=mhlo \
+  --iree-hal-target-backends=llvm \
+  --iree-llvm-target-triple=wasm32-unknown-unknown \
+  --iree-llvm-link-embedded=false \
+  --iree-llvm-link-static \
+  --iree-llvm-static-library-output-path=${BINARY_DIR}/${INPUT_NAME}_static.o \
+  --o ${BINARY_DIR}/${INPUT_NAME}.vmfb
+
+echo "=== Embedding bytecode module (.vmfb) into C source files (.h, .c) ==="
+${EMBED_DATA_TOOL?} ${BINARY_DIR}/${INPUT_NAME}.vmfb \
+  --output_header=${BINARY_DIR}/${INPUT_NAME}_bytecode.h \
+  --output_impl=${BINARY_DIR}/${INPUT_NAME}_bytecode.c \
+  --identifier=iree_static_${INPUT_NAME} \
+  --flatten
+
+###############################################################################
+# Build the web artifacts using Emscripten                                    #
+###############################################################################
+
+echo "=== Building web artifacts using Emscripten ==="
+
+pushd ${ROOT_DIR?}/build-emscripten
+
+# Configure using Emscripten's CMake wrapper, then build.
+# Note: The sample creates a task device directly, so no drivers are required,
+#       but some targets are gated on specific CMake options.
+emcmake "${CMAKE_BIN?}" -G Ninja .. \
+  -DIREE_HOST_BINARY_ROOT=$PWD/../build-host/install \
+  -DIREE_BUILD_EXPERIMENTAL_WEB_SAMPLES=ON \
+  -DIREE_HAL_DRIVER_DEFAULTS=OFF \
+  -DIREE_HAL_DRIVER_DYLIB=ON \
+  -DIREE_BUILD_COMPILER=OFF \
+  -DIREE_BUILD_TESTS=OFF
+
+"${CMAKE_BIN?}" --build . --target \
+  iree_experimental_sample_web_static_sync
+  # iree_experimental_sample_web_static_multithreaded
+popd
+
+###############################################################################
+# Serve the demo using a local webserver                                      #
+###############################################################################
+
+echo "=== Copying static files (index.html) to the build directory ==="
+
+cp ${ROOT_DIR?}/experimental/sample_web_static/index.html ${BINARY_DIR}
+
+echo "=== Running local webserver ==="
+echo "    open at http://localhost:8000/build-emscripten/experimental/sample_web_static/"
+
+# **Note**: this serves from the root so source maps can reference code in the
+# source tree. A real deployment would bundle the output artifacts and serve
+# them from a build/release directory.
+
+# local_server.py is needed when using SharedArrayBuffer, with multithreading
+# python3 local_server.py --directory ${ROOT_DIR?}
+
+# http.server on its own is fine for single threaded use, and this doesn't
+# break CORS for external resources like easeljs from a CDN
+python3 -m http.server --directory ${ROOT_DIR?}
diff --git a/experimental/sample_web_static/device_multithreaded.c b/experimental/sample_web_static/device_multithreaded.c
new file mode 100644
index 0000000..4121f16
--- /dev/null
+++ b/experimental/sample_web_static/device_multithreaded.c
@@ -0,0 +1,62 @@
+// Copyright 2022 The IREE Authors
+//
+// Licensed under the Apache License v2.0 with LLVM Exceptions.
+// See https://llvm.org/LICENSE.txt for license information.
+// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+#include "iree/hal/local/loaders/static_library_loader.h"
+#include "iree/hal/local/task_device.h"
+#include "iree/task/api.h"
+#include "mnist_static.h"
+
+iree_status_t create_device_with_static_loader(iree_allocator_t host_allocator,
+                                               iree_hal_device_t** out_device) {
+  iree_hal_task_device_params_t params;
+  iree_hal_task_device_params_initialize(&params);
+
+  // Load the statically embedded library.
+  const iree_hal_executable_library_header_t** static_library =
+      mnist_linked_llvm_library_query(
+          IREE_HAL_EXECUTABLE_LIBRARY_LATEST_VERSION,
+          /*reserved=*/NULL);
+  const iree_hal_executable_library_header_t** libraries[1] = {static_library};
+
+  iree_hal_executable_loader_t* library_loader = NULL;
+  iree_status_t status = iree_hal_static_library_loader_create(
+      IREE_ARRAYSIZE(libraries), libraries,
+      iree_hal_executable_import_provider_null(), host_allocator,
+      &library_loader);
+
+  // Create a task executor.
+  iree_task_executor_t* executor = NULL;
+  iree_task_scheduling_mode_t scheduling_mode = 0;
+  iree_host_size_t worker_local_memory = 0;
+  iree_task_topology_t topology;
+  iree_task_topology_initialize(&topology);
+  // TODO(scotttodd): Try with more threads
+  iree_task_topology_initialize_from_group_count(/*group_count=*/1, &topology);
+  if (iree_status_is_ok(status)) {
+    status = iree_task_executor_create(scheduling_mode, &topology,
+                                       worker_local_memory, host_allocator,
+                                       &executor);
+  }
+  iree_task_topology_deinitialize(&topology);
+
+  iree_string_view_t identifier = iree_make_cstring_view("task");
+  iree_hal_allocator_t* device_allocator = NULL;
+  if (iree_status_is_ok(status)) {
+    status = iree_hal_allocator_create_heap(identifier, host_allocator,
+                                            host_allocator, &device_allocator);
+  }
+
+  if (iree_status_is_ok(status)) {
+    status = iree_hal_task_device_create(
+        identifier, &params, executor, /*loader_count=*/1, &library_loader,
+        device_allocator, host_allocator, out_device);
+  }
+
+  iree_hal_allocator_release(device_allocator);
+  iree_task_executor_release(executor);
+  iree_hal_executable_loader_release(library_loader);
+  return status;
+}
diff --git a/experimental/sample_web_static/device_sync.c b/experimental/sample_web_static/device_sync.c
new file mode 100644
index 0000000..82e291d
--- /dev/null
+++ b/experimental/sample_web_static/device_sync.c
@@ -0,0 +1,45 @@
+// Copyright 2022 The IREE Authors
+//
+// Licensed under the Apache License v2.0 with LLVM Exceptions.
+// See https://llvm.org/LICENSE.txt for license information.
+// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+#include "iree/hal/local/loaders/static_library_loader.h"
+#include "iree/hal/local/sync_device.h"
+#include "mnist_static.h"
+
+iree_status_t create_device_with_static_loader(iree_allocator_t host_allocator,
+                                               iree_hal_device_t** out_device) {
+  iree_hal_sync_device_params_t params;
+  iree_hal_sync_device_params_initialize(&params);
+
+  // Load the statically embedded library.
+  const iree_hal_executable_library_header_t** static_library =
+      mnist_linked_llvm_library_query(
+          IREE_HAL_EXECUTABLE_LIBRARY_LATEST_VERSION,
+          /*reserved=*/NULL);
+  const iree_hal_executable_library_header_t** libraries[1] = {static_library};
+
+  iree_hal_executable_loader_t* library_loader = NULL;
+  iree_status_t status = iree_hal_static_library_loader_create(
+      IREE_ARRAYSIZE(libraries), libraries,
+      iree_hal_executable_import_provider_null(), host_allocator,
+      &library_loader);
+
+  iree_string_view_t identifier = iree_make_cstring_view("sync");
+  iree_hal_allocator_t* device_allocator = NULL;
+  if (iree_status_is_ok(status)) {
+    status = iree_hal_allocator_create_heap(identifier, host_allocator,
+                                            host_allocator, &device_allocator);
+  }
+
+  if (iree_status_is_ok(status)) {
+    status = iree_hal_sync_device_create(
+        identifier, &params, /*loader_count=*/1, &library_loader,
+        device_allocator, host_allocator, out_device);
+  }
+
+  iree_hal_allocator_release(device_allocator);
+  iree_hal_executable_loader_release(library_loader);
+  return status;
+}
diff --git a/experimental/sample_web_static/index.html b/experimental/sample_web_static/index.html
new file mode 100644
index 0000000..d3da38f
--- /dev/null
+++ b/experimental/sample_web_static/index.html
@@ -0,0 +1,235 @@
+<!DOCTYPE html>
+<html>
+
+<!--
+Copyright 2022 The IREE Authors
+
+Licensed under the Apache License v2.0 with LLVM Exceptions.
+See https://llvm.org/LICENSE.txt for license information.
+SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+-->
+
+<head>
+  <meta charset="utf-8" />
+  <title>IREE Static Web Sample</title>
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+
+  <!-- TODO(scotttodd): use local copy for CORS webserver / SharedArrayBuffer workarounds? -->
+  <script src="https://code.createjs.com/1.0.0/easeljs.min.js"></script>
+</head>
+
+<body style="background-color: #2b2c30; color: #ABB2BF">
+  <h1>IREE Static Web Sample</h1>
+
+  <canvas id="drawingCanvas" width="256" height="256"
+          style="border:2px solid #000000; background-color: #FFFFFF;"
+          oncontextmenu="return false;">
+  </canvas>
+  <canvas id="rescaledCanvas" width="28" height="28"
+          style="border:2px solid #000000; background-color: #FFFFFF;">
+  </canvas>
+
+  <br>
+  <div style="border:2px solid #000000; background-color: #CCCCCC; padding: 8px; color: #111111" width="400px" height="300px">
+    <button id="predictButton" disabled onclick="predictDigit()">Predict handwritten digit</button>
+    <br>
+    Prediction result: <div id="predictionResult"></div>
+  </div>
+
+  <script>
+    let setupNativeSample;
+    let cleanupNativeSample;
+    let runNativeSample;
+    let nativeState;
+    const predictionResultElement = document.getElementById("predictionResult");
+    const predictButtonElement = document.getElementById("predictButton");
+    let initialized = false;
+
+    const imagePixelCount = 28 * 28;
+    let imageBuffer;
+
+    var Module = {
+      print: function(text) {
+        console.log(text);
+      },
+      printErr: function(text) {
+        console.error(text);
+      },
+      onRuntimeInitialized: function() {
+        console.log("WebAssembly module onRuntimeInitialized()");
+
+        setupNativeSample = Module.cwrap("setup_sample", "number", []);
+        cleanupNativeSample = Module.cwrap("cleanup_sample", null, ["number"]);
+        runNativeSample = Module.cwrap("run_sample", "number", ["number", "number"]);
+
+        setupSample();
+      },
+      // https://emscripten.org/docs/api_reference/module.html#Module.noInitialRun
+      noInitialRun: true,
+    };
+
+    function setupSample() {
+      nativeState = setupNativeSample();
+      predictButtonElement.disabled = false;
+      imageBuffer = Module._malloc(imagePixelCount * Float32Array.BYTES_PER_ELEMENT);
+      initialized = true;
+    }
+
+    // TODO(scotttodd): call this on page suspend?
+    function cleanupSample() {
+      initialized = false;
+      Module._free(imageDataBuffer);
+      predictButtonElement.disabled = true;
+      cleanupNativeSample();
+      nativeState = null;
+    }
+
+    function predictDigit() {
+      const rawImageData = getRescaledCanvasData();
+      preprocessImageData(rawImageData);
+
+      result = runNativeSample(nativeState, imageBuffer);
+      if (result != -1) {
+        predictionResultElement.innerHTML = result;
+      } else {
+        predictionResultElement.innerHTML = "Error";
+      }
+    }
+
+    // https://becominghuman.ai/passing-and-returning-webassembly-array-parameters-a0f572c65d97
+    // https://developers.google.com/web/updates/2018/03/emscripting-a-c-library#get_an_image_from_javascript_into_wasm
+    function preprocessImageData(rawImageData) {
+      // * getImageData() returns a Uint8ClampedArray with RGBA image data
+      // * this MNIST model takes tensor<1x28x28x1xf32> with grayscale pixels
+      //   in [0.0, 1.0]
+
+      // This conversion is terrible, but this is a toy demo with a small image
+      // Hopefully there aren't any logic / iteration order issues...
+      const typedArray = new Float32Array(imagePixelCount);
+      for (let y = 0; y < 28; ++y) {
+        for (let x = 0; x < 28; ++x) {
+          const typedIndex = y * 28 + x;
+          const rawIndex = 4 * (y * 28 + x) + 3;  // Assume colorSpace srgb
+          typedArray[typedIndex] = rawImageData.data[rawIndex] / 255.0;
+        }
+      }
+
+      // Copy into Wasm heap.
+      // Note: we could have done the conversion in-place, but this is demo code
+      Module.HEAPF32.set(typedArray, imageBuffer >> 2);
+    }
+
+  </script>
+  <script src="sample-web-static-sync.js"></script>
+  <!-- <script src="sample-web-static-multithreaded.js"></script> -->
+
+
+  <script>
+    // Forked from:
+    //   https://createjs.com/demos/easeljs/curveto
+    //   https://github.com/CreateJS/EaselJS/blob/master/examples/CurveTo.html
+
+    let drawingCanvasElement;
+    let rescaledCanvasElement, rescaledCanvasContext;
+    let stage;
+    let drawingCanvasShape;
+    let oldPt, oldMidPt;
+    let titleText;
+    const primaryColor = "#000000";
+    const eraseColor = "#FFFFFF";
+    const stroke = 32;
+
+    function initDrawing() {
+      drawingCanvasElement = document.getElementById("drawingCanvas");
+
+      rescaledCanvasElement = document.getElementById("rescaledCanvas");
+      rescaledCanvasContext = rescaledCanvasElement.getContext("2d");
+      rescaledCanvasContext.imageSmoothingEnabled = false;
+      rescaledCanvasContext.mozImageSmoothingEnabled = false;
+      rescaledCanvasContext.webkitImageSmoothingEnabled = false;
+      rescaledCanvasContext.msImageSmoothingEnabled = false;
+
+      stage = new createjs.Stage(drawingCanvasElement);
+      stage.autoClear = false;
+      stage.enableDOMEvents(true);
+
+      createjs.Touch.enable(stage);
+      createjs.Ticker.framerate = 24;
+
+      stage.addEventListener("stagemousedown", handleMouseDown);
+      stage.addEventListener("stagemouseup", handleMouseUp);
+
+      drawingCanvasShape = new createjs.Shape();
+      stage.addChild(drawingCanvasShape);
+
+      // Add instruction text.
+      titleText = new createjs.Text("Click and Drag to draw", "18px Arial", "#000000");
+      titleText.x = 30;
+      titleText.y = 100;
+      stage.addChild(titleText);
+
+      stage.update();
+    }
+
+    function handleMouseDown(event) {
+      if (!event.primary && !event.secondary) { return; }
+
+      if (stage.contains(titleText)) {
+        stage.clear();
+        stage.removeChild(titleText);
+      }
+
+      oldPt = new createjs.Point(stage.mouseX, stage.mouseY);
+      oldMidPt = oldPt.clone();
+      stage.addEventListener("stagemousemove", handleMouseMove);
+    }
+
+    function handleMouseMove(event) {
+      if (!event.primary && !event.secondary) { return; }
+
+      const midPt = new createjs.Point(
+        oldPt.x + stage.mouseX >> 1, oldPt.y + stage.mouseY >> 1);
+
+      const color = event.nativeEvent.which == 1 ? primaryColor : eraseColor;
+      drawingCanvasShape.graphics.clear()
+          .setStrokeStyle(stroke, 'round', 'round')
+          .beginStroke(color).moveTo(midPt.x, midPt.y)
+          .curveTo(oldPt.x, oldPt.y, oldMidPt.x, oldMidPt.y);
+
+      oldPt.x = stage.mouseX;
+      oldPt.y = stage.mouseY;
+      oldMidPt.x = midPt.x;
+      oldMidPt.y = midPt.y;
+
+      stage.update();
+      updateRescaledCanvas();
+
+      if (initialized) {
+        // TODO(scotttodd): debounce / rate limit this
+        predictDigit();
+      }
+    }
+
+    function handleMouseUp(event) {
+      if (!event.primary && !event.default) { return; }
+      stage.removeEventListener("stagemousemove", handleMouseMove);
+    }
+
+    function updateRescaledCanvas() {
+      rescaledCanvasContext.drawImage(
+          drawingCanvasElement,
+          /*sx=*/0, /*sy=*/0,
+          /*sWidth=*/256, /*sHeight=*/256,
+          /*dx=*/0, /*dy=*/0,
+          /*dWidth=*/28, /*dHeight=*/28);
+    }
+
+    function getRescaledCanvasData() {
+      return rescaledCanvasContext.getImageData(0, 0, 28, 28);
+    }
+
+    initDrawing();
+  </script>
+</body>
+
+</html>
diff --git a/experimental/sample_web_static/local_server.py b/experimental/sample_web_static/local_server.py
new file mode 100644
index 0000000..835a760
--- /dev/null
+++ b/experimental/sample_web_static/local_server.py
@@ -0,0 +1,66 @@
+#!/usr/bin/env python3
+# Copyright 2022 The IREE Authors
+#
+# Licensed under the Apache License v2.0 with LLVM Exceptions.
+# See https://llvm.org/LICENSE.txt for license information.
+# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+"""Local server for development, with support for CORS headers and MIME types.
+
+NOTE: This is NOT suitable for production serving, it is just a slightly
+extended version of https://docs.python.org/3/library/http.server.html.
+
+Usage:
+  python3 local_server.py --directory {build_dir}
+  (then open http://localhost:8000/ in your browser)
+"""
+
+import os
+from functools import partial
+from http import server
+
+
+class CORSHTTPRequestHandler(server.SimpleHTTPRequestHandler):
+
+  def __init__(self, *args, **kwargs):
+    # Include MIME types for files we expect to be serving.
+    # https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types
+    self.extensions_map.update({
+        ".js": "application/javascript",
+        ".wasm": "application/wasm",
+    })
+    super().__init__(*args, **kwargs)
+
+  # Inspiration for this hack: https://stackoverflow.com/a/13354482
+  def end_headers(self):
+    self.send_cors_headers()
+
+    server.SimpleHTTPRequestHandler.end_headers(self)
+
+  def send_cors_headers(self):
+    # Emscripten uses SharedArrayBuffer for its multithreading, which requires
+    # Cross Origin Opener Policy and Cross Origin Embedder Policy headers:
+    #   * https://emscripten.org/docs/porting/pthreads.html
+    #   * https://developer.chrome.com/blog/enabling-shared-array-buffer/
+    self.send_header("Cross-Origin-Embedder-Policy", "require-corp")
+    self.send_header("Cross-Origin-Opener-Policy", "same-origin")
+
+
+if __name__ == '__main__':
+  import argparse
+  parser = argparse.ArgumentParser()
+  parser.add_argument('--directory',
+                      '-d',
+                      default=os.getcwd(),
+                      help='Specify alternative directory '
+                      '[default:current directory]')
+  parser.add_argument('port',
+                      action='store',
+                      default=8000,
+                      type=int,
+                      nargs='?',
+                      help='Specify alternate port [default: 8000]')
+  args = parser.parse_args()
+
+  server.test(HandlerClass=partial(CORSHTTPRequestHandler,
+                                   directory=args.directory),
+              port=args.port)
diff --git a/experimental/sample_web_static/main.c b/experimental/sample_web_static/main.c
new file mode 100644
index 0000000..23441bf
--- /dev/null
+++ b/experimental/sample_web_static/main.c
@@ -0,0 +1,183 @@
+// Copyright 2022 The IREE Authors
+//
+// Licensed under the Apache License v2.0 with LLVM Exceptions.
+// See https://llvm.org/LICENSE.txt for license information.
+// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+#include <float.h>
+#include <stdio.h>
+
+#include "iree/runtime/api.h"
+#include "iree/vm/bytecode_module.h"
+#include "mnist_bytecode.h"
+
+//===----------------------------------------------------------------------===//
+// Public API
+//===----------------------------------------------------------------------===//
+
+typedef struct iree_sample_state_t iree_sample_state_t;
+static void iree_sample_state_initialize(iree_sample_state_t* out_state);
+
+// TODO(scotttodd): figure out error handling and state management
+//     * out_state and return status would make sense, but emscripten...
+iree_sample_state_t* setup_sample();
+void cleanup_sample(iree_sample_state_t* state);
+
+int run_sample(iree_sample_state_t* state, float* image_data);
+
+//===----------------------------------------------------------------------===//
+// Implementation
+//===----------------------------------------------------------------------===//
+
+extern iree_status_t create_device_with_static_loader(
+    iree_allocator_t host_allocator, iree_hal_device_t** out_device);
+
+typedef struct iree_sample_state_t {
+  iree_runtime_instance_t* instance;
+  iree_hal_device_t* device;
+  iree_runtime_session_t* session;
+  iree_vm_module_t* module;
+  iree_runtime_call_t call;
+} iree_sample_state_t;
+
+iree_status_t create_bytecode_module(iree_vm_module_t** out_module) {
+  const struct iree_file_toc_t* module_file_toc = iree_static_mnist_create();
+  iree_const_byte_span_t module_data =
+      iree_make_const_byte_span(module_file_toc->data, module_file_toc->size);
+  return iree_vm_bytecode_module_create(module_data, iree_allocator_null(),
+                                        iree_allocator_system(), out_module);
+}
+
+iree_sample_state_t* setup_sample() {
+  iree_sample_state_t* state = NULL;
+  iree_status_t status = iree_allocator_malloc(
+      iree_allocator_system(), sizeof(iree_sample_state_t), (void**)&state);
+
+  iree_runtime_instance_options_t instance_options;
+  iree_runtime_instance_options_initialize(IREE_API_VERSION_LATEST,
+                                           &instance_options);
+  // Note: no call to iree_runtime_instance_options_use_all_available_drivers().
+
+  if (iree_status_is_ok(status)) {
+    status = iree_runtime_instance_create(
+        &instance_options, iree_allocator_system(), &state->instance);
+  }
+
+  if (iree_status_is_ok(status)) {
+    status = create_device_with_static_loader(iree_allocator_system(),
+                                              &state->device);
+  }
+
+  iree_runtime_session_options_t session_options;
+  iree_runtime_session_options_initialize(&session_options);
+  iree_runtime_session_t* session = NULL;
+  if (iree_status_is_ok(status)) {
+    status = iree_runtime_session_create_with_device(
+        state->instance, &session_options, state->device,
+        iree_runtime_instance_host_allocator(state->instance), &state->session);
+  }
+
+  if (iree_status_is_ok(status)) {
+    status = create_bytecode_module(&state->module);
+  }
+  if (iree_status_is_ok(status)) {
+    status = iree_runtime_session_append_module(state->session, state->module);
+  }
+
+  const char kMainFunctionName[] = "module.predict";
+  if (iree_status_is_ok(status)) {
+    status = iree_runtime_call_initialize_by_name(
+        state->session, iree_make_cstring_view(kMainFunctionName),
+        &state->call);
+  }
+
+  if (!iree_status_is_ok(status)) {
+    iree_status_fprint(stderr, status);
+    iree_status_free(status);
+    cleanup_sample(state);
+    return NULL;
+  }
+
+  return state;
+}
+
+void cleanup_sample(iree_sample_state_t* state) {
+  iree_runtime_call_deinitialize(&state->call);
+
+  // Cleanup session and instance.
+  iree_hal_device_release(state->device);
+  iree_runtime_session_release(state->session);
+  iree_runtime_instance_release(state->instance);
+  iree_vm_module_release(state->module);
+
+  free(state);
+}
+
+int run_sample(iree_sample_state_t* state, float* image_data) {
+  iree_status_t status = iree_ok_status();
+
+  iree_runtime_call_reset(&state->call);
+
+  iree_hal_buffer_view_t* arg_buffer_view = NULL;
+  iree_hal_dim_t buffer_shape[] = {1, 28, 28, 1};
+  iree_hal_memory_type_t input_memory_type =
+      IREE_HAL_MEMORY_TYPE_HOST_LOCAL | IREE_HAL_MEMORY_TYPE_DEVICE_VISIBLE;
+  if (iree_status_is_ok(status)) {
+    status = iree_hal_buffer_view_allocate_buffer(
+        iree_hal_device_allocator(state->device), buffer_shape,
+        IREE_ARRAYSIZE(buffer_shape), IREE_HAL_ELEMENT_TYPE_FLOAT_32,
+        IREE_HAL_ENCODING_TYPE_DENSE_ROW_MAJOR, input_memory_type,
+        IREE_HAL_BUFFER_USAGE_DISPATCH | IREE_HAL_BUFFER_USAGE_TRANSFER,
+        iree_make_const_byte_span((void*)image_data, sizeof(float) * 28 * 28),
+        &arg_buffer_view);
+  }
+  if (iree_status_is_ok(status)) {
+    status = iree_runtime_call_inputs_push_back_buffer_view(&state->call,
+                                                            arg_buffer_view);
+  }
+  iree_hal_buffer_view_release(arg_buffer_view);
+
+  if (iree_status_is_ok(status)) {
+    status = iree_runtime_call_invoke(&state->call, /*flags=*/0);
+  }
+
+  // Get the result buffers from the invocation.
+  iree_hal_buffer_view_t* ret_buffer_view = NULL;
+  if (iree_status_is_ok(status)) {
+    status = iree_runtime_call_outputs_pop_front_buffer_view(&state->call,
+                                                             &ret_buffer_view);
+  }
+
+  // Read back the results. The output of the mnist model is a 1x10 prediction
+  // confidence values for each digit in [0, 9].
+  float predictions[1 * 10] = {0.0f};
+  if (iree_status_is_ok(status)) {
+    status =
+        iree_hal_buffer_read_data(iree_hal_buffer_view_buffer(ret_buffer_view),
+                                  0, predictions, sizeof(predictions));
+  }
+  iree_hal_buffer_view_release(ret_buffer_view);
+
+  if (!iree_status_is_ok(status)) {
+    iree_status_fprint(stderr, status);
+    iree_status_free(status);
+    return -1;
+  }
+
+  // Get the highest index from the output.
+  float result_val = FLT_MIN;
+  int result_idx = 0;
+  for (iree_host_size_t i = 0; i < IREE_ARRAYSIZE(predictions); ++i) {
+    if (predictions[i] > result_val) {
+      result_val = predictions[i];
+      result_idx = i;
+    }
+  }
+  fprintf(stdout,
+          "Prediction: %d, confidences: [%.2f, %.2f, %.2f, %.2f, %.2f, %.2f, "
+          "%.2f, %.2f, %.2f, %.2f]\n",
+          result_idx, predictions[0], predictions[1], predictions[2],
+          predictions[3], predictions[4], predictions[5], predictions[6],
+          predictions[7], predictions[8], predictions[9]);
+  return result_idx;
+}