On Kotlin⁠/⁠Wasm and web‑workers

10 May 2023 • 6 minutes read

Note

Since first publishing this article Kotlin 2.0.20 and Korge 6.0 came out which brought some significant changes. Checkout the updated article.

I recently implemented a fun pet project: a 2048 game AI. Not a very up-to-date idea, but still, the experience was fun!

Sources are on GitHub and you can play it here: 2048.marchuk.io

I used Kotlin, and this time tried Korge game engine which uses Kotlin Multiplatform. The game runs in JVM and browsers but Korge supports almost all other MPP targets: native, Android, iOS, etc. But I was only interested in JS and JVM.

The game AI is a pretty interesting topic in itself. Briefly, it uses:

  • A variation on common chess AI technique that uses 64-bit longs to represent a 2048 4x4 game board (shamelessly taken from this awesome 2048 AI written in C++). And some bit-magic tricks to implement fast board movements, heuristic score evaluations, etc.
  • Expectimax search algorithm, which is a variation of the classic Minimax

ParallelismSection titled Parallelism

On the JVM the game uses CoroutineScope.async to launch 4 threads that search the game tree in parallel. This gives a significant boost to the search speed and allows for the Expectimax algorithm to use depths of up to 8-10 with tolerable <1 second wait times.

Multi-threading in the browser is done with web workers and naturally, I wanted to utilize them for the search.

Web workers are spawned from the main browser “thread” and the communication is done with message-passing-like API. But to spawn a worker a separate Javascript file must be used.

Kotlin/JS uses WebPack to produce one javascript file per target with all the dependent code bundled inside that one file. To utilize web workers a separate javascript target must be created. This is pretty straightforward:

js("jsWorker", IR) {
    binaries.executable()
    browser {
        webpackTask {
            outputFileName = "js-worker.js"
        }
        distribution {
            name = "jsWorker"
        }
    }
}

We need the distribution part for the separate folder jsWorker under the build directory. This config will produce a file named build/jsWorker/js-worker.js. Then it must be included as a resource for the jsMain source set:

val jsMain by getting {
    resources.srcDirs("./build/jsWorker")
}

And a worker then can be spawned from the main js source code like so:

const worker = new Worker("./js-worker.js");

So far, so good.

WASM as a targetSection titled WASM%20as%20a%20target

But the performance of the produced JS code is terrible. The positions can be searched only ~3 moves deep for the “playable” experience.

Here comes the experimental Kotlin/Wasm support. We replace js with wasm and we are good to go.

wasm("wasmWorker", IR) {
    binaries.executable()
    browser {
        webpackTask {
            outputFileName = "wasm-worker.js"
        }
        distribution {
            name = "wasmWorker"
        }
    }
}

Kotlin/Wasm is very experimentalSection titled Kotlin/Wasm%20is%20very%20experimental

When you create a custom multiplatform target Kotlin Gradle plugin automatically creates <customTargetName>Main source set for it, and makes it dependent on commonMain. By default, Korge Gradle plugin applies the game engine and other dependencies to the commonMain, But wasm target is supported neither by Korge nor by kotlinx-coroutines nor pretty much anything else yet.

Fortunately, the code for board moves, Expectimax search etc. was almost dependency-free. And kotlin-stdlib-wasm exists.

So, remove all the necessary dependencies from the commonMain and re-apply the dependency-free game code to the commonMain and Korge-dependent code to a new commonKorge source set and then selectively apply commonKorge only to the jvmMain and jsMain source sets, but not to the wasmWorkerMain source set.

Hacks for Wasm at runtimeSection titled Hacks%20for%20Wasm%20at%20runtime

When compiling to Wasm, Kotlin compiler ( like others) emits a “glue” companion Javascript file with all the necessary steps to bootstrap a Wasm file execution.

For now, I believe, Kotlin/Wasm compiler doesn’t consider the fact that it can be run inside the web worker. The compiler emits three “runtime” checks for the NodeJS, a standalone javascript VM or browser:

const isNodeJs =
    typeof process !== "undefined" && process.release.name === "node";
const isStandaloneJsVM =
    !isNodeJs &&
    (typeof d8 !== "undefined" || // V8
        typeof inIon !== "undefined" || // SpiderMonkey
        typeof jscOptions !== "undefined"); // JavaScriptCore
const isBrowser =
    !isNodeJs && !isStandaloneJsVM && typeof window !== "undefined";

if (!isNodeJs && !isStandaloneJsVM && !isBrowser) {
  throw "Supported JS engine not detected";
}

First hackSection titled First%20hack

Neither of the checks succeeds inside a web worker. So the first hack is to create a *.js file inside a webpack.config.d directory in the root directory of the project and place some Webpack’y things there to patch Kotlin compiler-generated files:

config.module.rules.push({
  test: /\uninstantiated\.m?js$/,
  loader: "string-replace-loader",
  options: {
    search: /isBrowser\s*=\s*.*?;/,
    replace: "isBrowser=true;",
  },
});

If isBrowser check succeeds, the “glue” code uses WebAssembly.instantiateStreaming to create a Wasm instance. This works fine inside a web worker.

Second hackSection titled Second%20hack

Then, I quickly failed to make Kotlin Gradle plugin (or Webpack) copy that *.wasm file to the output directory from where the final project is being served. So, a second hack:

const CopyWebpackPlugin = require("copy-webpack-plugin");

config.module.rules.push({
  test: /\uninstantiated\.m?js$/,
  loader: "string-replace-loader",
  options: {
    search: /\.\/game2048-wasm-worker.wasm/,
    replace: "./wasm-worker.wasm",
  },
});
config.plugins.push(
    new CopyWebpackPlugin({
      patterns: [
        {
          from: require("path").resolve(
              __dirname,
              "kotlin/game2048-wasm-worker.wasm"
          ),
          to: "wasm-worker.wasm",
        },
      ],
    })
);

And we place all of this under the if (config.output.filename({ chunk: { name: "main" } }).startsWith("wasm-worker")) because, apparently, the files inside webpack.config.d directory are appended to every target and there is no way to configure that. Without the if main js target fails to be bundled with Webpack.

Third “hack”Section titled Third%20%u201Chack%u201D

Apart from getting a couple of hardcore low-level compiler errors (mostly related to Kotlin/Js dynamic), the actual compiling and running (not the Gradle fiddling) part of my Kotlin/Wasm experience went pretty smoothly. Except for one thing.

As I said earlier, web workers communicate with the “main” browser thread by passing messages in the form of plain JS objects (in fact, not only that but anything that satisfies the structured clone algorithm). Okay, we can serialize to string (by hand, because kotlinx-serialization does not yet support wasm target) and pass strings around. And conveniently, this line in Kotlin documentation says that at runtime

kotlin.String is mapped to JavaScript String.

So, after successfully running all the worker-related codes with js target I was a bit surprised to get an error from this code when targeting wasm:

val self = js("self") as DedicatedWorkerGlobalScope

self.postMessage(
    someObject.serializeToString()
)

Here js("self") part “extracts” invisible to Kotlin self variable that is only present when the code is run inside a web worker. Then we postMessage a string. And the error when run with wasm target is the following:

Ucaught DOMException: Failed to execute 'postMessage' on 'DedicatedWorkerGlobalScope': [object Object] could not be cloned.

Apparently, for Wasm, Kotlin fails to “map” kotlin.String to JavaScript string. But I am not 100% sure this is the case because when viewed from Chrome debugger the [object Object] passed to the postMessage is a simple empty object {}.

After countless hours of reading the docs & searching the web, I randomly tried appending asDynamic to the kotlin.String and it worked!

val self = js("self") as DedicatedWorkerGlobalScope

self.postMessage(
    someObject.serializeToString()
        .asDynamic()
)

This one I am not sure if it is even a hack, but a simple bug in the Kotlin/Wasm, considering the fact it is in so early stage of development.

ConclusionSection titled Conclusion

A bit sad is that Kotlin/Wasm performance on this project is still pretty bad compared to JVM. And to achieve that promised Wasm near-native performance you should probably stick to C++, Rust or Go. For now.