Skip to content

Latest commit

 

History

History
365 lines (275 loc) · 9.14 KB

File metadata and controls

365 lines (275 loc) · 9.14 KB
theme seriph
background https://source.unsplash.com/collection/94734566/1920x1080
class text-center
highlighter shiki
lineNumbers false
info ## Slidev Starter Template Presentation slides for developers. Learn more at [Sli.dev](https://sli.dev)
drawings
persist
transition slide-left
title Cover Slide
mdc true

Parallel JavaScript execution using Dedicated Web wokrers

A guide on how to elevate client side performance with Worker APIs and custom Worker managers.

Beertalk by CuddlyBunion341 @ Renuo AG


When should you care about parallel execution?

  • Complex Mathematical Calculations
  • Big data processing on the client
  • Expensive network calls
  • Video compression / encoding
  • Real time data streaming
  • Text analysis / processing

My Problems and Solutions

Challanges I encountered when implementing world generation in my minecraft clone.

Render Performance

Problem: Frametimes were greatly hindered while world was generating.

Solution: Move terrain generation to dedicated worker.

Generation time

Problem: It took > 30s to generate chunks in render distance of player

Solution: Manage multiple worker instances in a worker pool


Multithreaded Dish

Recipe Title: Deliciously Responsive Web Soup with Dedicated Web Worker Croutons

Serves

Web application in need of a performance boost.

Ingredients

  • At least one dedicated or shared web worker
  • A pinch of JavaScript or TypeScript logic
  • API endpoints or data sources, finely chopped
  • Complex calculations or algorithms, to taste
  • Optional: Transfer objects and Managers according to preference

transition: slide-up layout: quote

What is a Web Worker?

"Web Workers makes it possible to run a script operation in a background thread separate from the main execution thread of a web application. The advantage of this is that laborious processing can be performed in a separate thread, allowing the main (usually the UI) thread to run without being blocked/slowed down."

layout: two-cols-header

Difference between Workers

https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API

::left::

Dedicated / Shared Workers

  • run in a separate execution context
  • don't support direct DOM manipulation
  • are used for simple background tasks
  • die as soon as page is closed
  • support ES modules
  • don't support caching

::right::

Service Workers

  • run in a separate execution context
  • don't support direct DOM manipulation
  • act as a proxy between application, browser and network
  • are long lived
  • support ES modules
  • support caching

Basic example

// my-worker.js

self.onmessage = (message) => {
  // perform expensive calculation on data
  postMessage(result)
}

// index.js

const worker = new Worker('my-worker.js')
worker.postMessage(message)

worker.onmessage = (message) => {
  // do something with processed data
}

Terrain Generation Example

// Chunk.ts
class Chunk {
  ...
  prepareGeneratorWorkerData() {
    const payload = {
      chunkX: this.x,
      chunkY: this.y,
      chunkZ: this.z,
      chunkWidth: this.chunkData.width,
      chunkHeight: this.chunkData.height,
      chunkDepth: this.chunkData.depth,
      terrainGeneratorSeed: this.terrainGenerator.seed
    }

    const transferable = [this.chunkData.data.data.buffer] // not used at the moment

    const callback = (payload: { data: ArrayBuffer }) => {
      this.chunkData.data.data = new Uint8Array(payload.data)
    }

    return { payload, transferable, callback }
  }
  ...
}

// TerrainGenerationWorker.ts
import { ChunkData } from '../ChunkData'
import { TerrainGenerator } from '../TerrainGenerator'

self.onmessage = (message: any) => {
  const { data } = message
  const { chunkX, chunkY, chunkZ, chunkWidth, chunkHeight, chunkDepth, terrainGeneratorSeed } = data

  const terrainGenerator = new TerrainGenerator(terrainGeneratorSeed)
  const chunkData = new ChunkData(chunkWidth, chunkHeight, chunkDepth)

  for (let x = -1; x < chunkWidth + 1; x++) {
    for (let y = -1; y < chunkHeight + 1; y++) {
      for (let z = -1; z < chunkDepth + 1; z++) {
        const block = terrainGenerator.getBlock(
          x + chunkX * chunkWidth,
          y + chunkY * chunkHeight,
          z + chunkZ * chunkDepth
        )
        chunkData.set(x, y, z, block)
      }
    }
  }

  const arrayBuffer = chunkData.data.data.buffer

  postMessage(arrayBuffer, [arrayBuffer])
}

Potential improvements at first glance

  1. Implement Chunk serialization / deserialization in Chunk class.
  2. Extract Worker logic into separate class?
  3. Reduce memory allocation by using existing Buffer objects.
  4. Initialize Worker with world seed.

image


transition: slide-up

Before / After

Note that chunk meshing is not parallelized, only terrain generation is.


Observation

One problem solved, one more to go.

  1. Chunk generation no longer impedes rendering.
  2. The loading screen is gone.
  3. Terrain generation is "parallel" but could be "paralleler"
  4. World generation became slower.
  5. Workers are not getting utilized enough.

Solution

  1. Implement a Worker Manager.
  2. Store a list of active / inactive workers.
  3. Queue tasks when no worker is available.
  4. Figure out how many workers can be utilized at once via navigator.

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type Callback = (args: any) => void

export type WorkerTask = {
  payload: unknown
  transferable?: Transferable[]
  callback: Callback
}

export class WorkerManager {
  public idleWorkers: Worker[] = []
  public activeWorkers: Worker[] = []

  public taskQueue: WorkerTask[] = []

  constructor(public readonly scriptUrl: string, public readonly workerCount: number) {
    this.initializeWebWorkers()
  }

  public initializeWebWorkers() {
    for (let i = 0; i < this.workerCount; i++) {
      const worker = new Worker(this.scriptUrl, { type: 'module' })
      this.idleWorkers.push(worker)
    }
  }

  public enqueueTask(task: WorkerTask) {
    const { payload, callback, transferable } = task

    const idleWorker = this.idleWorkers.pop()

    if (!idleWorker) {
      this.taskQueue.push(task)
      return
    }

    idleWorker.postMessage(payload, transferable || [])
    idleWorker.onmessage = (message: unknown) => {
      callback(message)
      this.idleWorkers.push(idleWorker)
      requestAnimationFrame(() => {
        this.enqueueTaskFromQueue()
      })
    }

    this.activeWorkers.push(idleWorker)
  }

  public enqueueTaskFromQueue() {
    if (this.taskQueue.length === 0) return

    const task = this.taskQueue.shift()
    if (task) this.enqueueTask(task)
  }
}

// main.ts
export default class Game implements Experience {
  ...
  init() {
    ...
    const workerPath = './src/game/world/workers/TerrainGenerationWorker.ts'
    const workerCount = navigator.hardwareConcurrency

    const workerManager = new WorkerManager(workerPath, workerCount)

    chunks.forEach((chunk) => {
      this.engine.scene.add(chunk.mesh)

      const task = chunk.prepareGeneratorWorkerData()

      workerManager.enqueueTask({
        payload: task.payload,
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        callback: (args: any) => {
          task.callback(args)
          requestAnimationFrame(() => chunk.updateMeshGeometry())
        }
      })
    })
  }
  ...
}

Before / After


layout: center


layout: center class: text-center

More about Web Workers

Mozilla Documentation · TSMC GitHub · Blog by Max Peng · Blog by Badmus Kola