On Kotlin/Wasm and web‑workers
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
long
s 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.