From 49c15ba3cd10f7d7ff658b3a65b8caed1ce57fbe Mon Sep 17 00:00:00 2001 From: Finley McIlwaine Date: Mon, 10 Jun 2024 15:04:08 +0200 Subject: [PATCH] Haskell > OCaml --- .gitignore | 3 + README.md | 6 + build.sh | 20 +++ cabal.project | 12 ++ haskell/Main.hs | 402 ++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 15 ++ src/index.css | 67 ++++++++ src/index.html | 62 ++++++++ src/index.js | 22 +++ wasm-sim.cabal | 26 ++++ 10 files changed, 635 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100755 build.sh create mode 100644 cabal.project create mode 100644 haskell/Main.hs create mode 100644 package.json create mode 100644 src/index.css create mode 100644 src/index.html create mode 100644 src/index.js create mode 100644 wasm-sim.cabal diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1c31e1f --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +dist-newstyle +dist +.envrc diff --git a/README.md b/README.md new file mode 100644 index 0000000..76da122 --- /dev/null +++ b/README.md @@ -0,0 +1,6 @@ +# GHC WebAssembly Backend Demonstration - ZuriHac 2024 + +Credits: +- Finley McIlwaine (author) +- Andreas Klebinger (@AndreasPK) +- Zubin Duggal (@wz1000) diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..bf76a12 --- /dev/null +++ b/build.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +pushd "$(dirname "$0")" || exit + +DIST_DIR=./dist + +cabal build wasm-sim + +hs_wasm_path=$(find dist-newstyle -name "*.wasm") + +"$(wasm32-wasi-ghc --print-libdir)"/post-link.mjs \ + --input "$hs_wasm_path" --output $DIST_DIR/ghc_wasm_jsffi.js + +cp $hs_wasm_path $DIST_DIR/bin.wasm + +# wizer --allow-wasi --wasm-bulk-memory true --init-func _initialize -o $DIST_DIR/bin.wasm "$hs_wasm_path" +# wasm-opt ${1+"$@"} $DIST_DIR/bin.wasm -o $DIST_DIR/bin.wasm +# wasm-tools strip -o $DIST_DIR/bin.wasm $DIST_DIR/bin.wasm + +cp ./src/* ./dist/ diff --git a/cabal.project b/cabal.project new file mode 100644 index 0000000..9365aa6 --- /dev/null +++ b/cabal.project @@ -0,0 +1,12 @@ +packages: . + +with-compiler: wasm32-wasi-ghc +with-hc-pkg: wasm32-wasi-ghc-pkg + +source-repository-package + type: git + location: https://github.com/amesgen/splitmix + tag: 83b906c4bcdc2720546f1779a16eb65e8e12ecba + +allow-newer: + splitmix:* \ No newline at end of file diff --git a/haskell/Main.hs b/haskell/Main.hs new file mode 100644 index 0000000..5f9a2f6 --- /dev/null +++ b/haskell/Main.hs @@ -0,0 +1,402 @@ +module Main where + +import Control.Concurrent +import Control.Concurrent.MVar +import Control.Monad +import Control.Exception +import Data.IORef +import Data.List +import GHC.Float +import System.Random + +import GHC.Wasm.Prim + +main :: IO () +main = do + -- Simulation canvas and context, and results charts canvases + canvas <- js_document_getElementById (toJSString "game-canvas") + context <- js_canvas_getContext canvas (toJSString "2d") + resultsCanvas <- js_document_getElementById (toJSString "results-canvas") + ratioCanvas <- js_document_getElementById (toJSString "ratio-canvas") + + -- Thin lines + js_canvas_context_lineWidth context 0.5 + + -- Make the chart.js charts + resultsChart <- js_new_results_Chart resultsCanvas + ratiosChart <- js_new_ratios_Chart ratioCanvas + + -- Listen for clicks on the run button + runButton <- js_document_getElementById (toJSString "sim-input-button") + runSimulationsCallback <- + js_as_callback $ + runSimulations resultsChart ratiosChart canvas context + js_addEventListener runButton (toJSString "click") runSimulationsCallback + +runSimulations :: + JSVal -- ^ Chart object for results + -> JSVal -- ^ Chart object for ratios + -> JSVal -- ^ Simulation canvas + -> JSVal -- ^ Simulation canvas context + -> IO () +runSimulations resultsChart ratiosChart canvas context = do + -- Get inputs + numSims <- getNumberOfSimulationsInput + maxNumCuts <- getNumberOfCutsInput + isRandom <- getIsRandomInput + isSort <- getIsSortInput + + -- List of cut numbers, potentially sorted + sortedCuts <- (if isSort then fmap sort else id) . replicateM numSims $ do + if isRandom then + randomNumCuts maxNumCuts + else + pure maxNumCuts + + -- Clear the canvas, draw the circles + clearCanvas canvas context + circles <- drawCircles canvas context numSims + + -- Set up a mutex for drawing and run the simulations + drawMut <- newMVar () + mapM_ + ( runSimulation + resultsChart + ratiosChart + canvas + context + drawMut + ) + ( zip sortedCuts circles + ) + where + randomNumCuts :: Int -> IO Int + randomNumCuts maxNumCuts = do + t <- randomRIO (0, 1.0) + return . roundDouble $ 1 + (int2Double maxNumCuts - 1) * t * t; + +runSimulation :: + JSVal + -- ^ Results chart object + -> JSVal + -- ^ Ratios chart object + -> JSVal + -- ^ Simulation canvas + -> JSVal + -- ^ Simulation canvas context + -> MVar () + -- ^ Drawing mutex + -> (Int, (Double, Double, Double)) + -- ^ Number of cuts for this circle, paired with the circle's radius and + -- center coordinates + -> IO ThreadId +runSimulation resultsChart ratiosChart canvas context drawMut (numCuts, circle) = do + let simEnv = toSimEnv drawMut numCuts circle + simulate simEnv `forkFinally` trackResult simEnv + where + toSimEnv :: MVar () -> Int -> (Double, Double, Double) -> SimEnv + toSimEnv drawMut numCuts (rad, x, y) = + SimEnv + { simEnv_canvas = canvas + , simEnv_context = context + , simEnv_delayRef = undefined + , simEnv_numLines = numCuts + , simEnv_radius = rad + , simEnv_centerX = x + , simEnv_centerY = y + , simEnv_drawMut = drawMut + } + + trackResult :: SimEnv -> Either SomeException Int -> IO () + trackResult SimEnv{..} (Right n) = do + -- Add simulation results points + js_Chart_add_data + resultsChart + 0 + simEnv_numLines + (int2Double n) + + -- Add lower bound points + js_Chart_add_data + resultsChart + 1 + simEnv_numLines + (int2Double $ lowerBound simEnv_numLines) + + -- Add upper bound points + js_Chart_add_data + resultsChart + 2 + simEnv_numLines + (int2Double $ upperBound simEnv_numLines) + + -- Add ratio points + js_Chart_add_data + ratiosChart + 0 + simEnv_numLines + ((int2Double $ upperBound simEnv_numLines) / (int2Double n)) + + -- Update charts + js_Chart_update resultsChart + js_Chart_update ratiosChart + +-- | Run a simulation in the given environment +simulate :: SimEnv -> IO Int +simulate env@SimEnv{..} = do + go simEnv_numLines 1 [] + where + go 0 numPieces _ = return numPieces + go numLines !numPieces drawnLines = do + -- Generate a random line + line@Line{..} <- randomLine + + -- need a mutex here to avoid drawing sporadic lines to other circles + takeMVar simEnv_drawMut + js_canvas_context_beginPath simEnv_context + js_canvas_context_moveTo simEnv_context line_startX line_startY + js_canvas_context_lineTo simEnv_context line_endX line_endY + js_canvas_context_stroke simEnv_context + putMVar simEnv_drawMut () + + -- Check how many of our existing lines it intersects with + let + morePieces = + foldl' (checkIntersect line) 1 drawnLines + go (numLines - 1) (numPieces + morePieces) (line : drawnLines) + + randomLine :: IO Line + randomLine = do + angle1 <- randomRIO (0, 2 * pi) + angle2 <- randomRIO (0, 2 * pi) + let + startX = simEnv_radius * cos angle1 + simEnv_centerX + startY = simEnv_radius * sin angle1 + simEnv_centerY + endX = simEnv_radius * cos angle2 + simEnv_centerX + endY = simEnv_radius * sin angle2 + simEnv_centerY + return + Line + { line_startX = startX + , line_startY = startY + , line_endX = endX + , line_endY = endY + } + + checkIntersect line1 acc line2 = + let + (intersectX, intersectY) = intersectionPoint line1 line2 + dx = intersectX - simEnv_centerX + dy = intersectY - simEnv_centerY + dh = sqrt (dx * dx + dy * dy) + in + if dh < simEnv_radius then acc + 1 else acc + + intersectionPoint :: Line -> Line -> (Double, Double) + intersectionPoint line1 line2 = + let + slope1 = lineSlope line1 + slope2 = lineSlope line2 + c1 = line_startY line1 - slope1 * line_startX line1 + c2 = line_startY line2 - slope2 * line_startX line2 + + intersectX = (c2 - c1) / (slope1 - slope2) + intersectY = c1 + intersectX * slope1 + in + (intersectX, intersectY) + +-- | Draw the given number of circles on the canvas and return their radii and +-- central coordinates +drawCircles :: + JSVal + -- ^ Simulation canvas + -> JSVal + -- ^ Simulation canvas context + -> Int + -- ^ Number of simulations + -> IO [(Double, Double, Double)] +drawCircles canvas context numSims = do + mapM (drawCircle numSims) [1 .. numSims] + where + drawCircle :: Int -> Int -> IO (Double, Double, Double) + drawCircle total i = do + let + n = sqrt $ int2Double total + w = js_canvas_width canvas + radius = w / n / 2 + col = (i - 1) `mod` (double2Int n) + row = (i - 1) `div` (double2Int n) + centerX = int2Double col * (radius * 2) + radius + centerY = int2Double row * (radius * 2) + radius + + -- Circle arc + js_canvas_context_beginPath context + js_canvas_context_arc + context + centerX + centerY + radius + 0 + (2 * pi) + + -- Thick red borders + js_canvas_context_lineWidth context 3 + js_canvas_context_strokeStyle context (toJSString "red") + js_canvas_context_stroke context + + -- Reset line style + js_canvas_context_lineWidth context 0.5 + js_canvas_context_strokeStyle context (toJSString "black") + return (radius, centerX, centerY) + +-- | Clear the entire canvas +clearCanvas :: JSVal -> JSVal -> IO () +clearCanvas canvas ctx = do + js_canvas_context_clearRect + ctx + (js_canvas_width canvas) + (js_canvas_height canvas) + +------------------------------------------------------------------------------- +-- Types +------------------------------------------------------------------------------- + +-- | Environment of the game +data SimEnv = + SimEnv + { simEnv_canvas :: JSVal + , simEnv_context :: JSVal + , simEnv_delayRef :: IORef Int + , simEnv_numLines :: !Int + , simEnv_radius :: !Double + , simEnv_centerX :: !Double + , simEnv_centerY :: !Double + , simEnv_drawMut :: MVar () + } + +data Line = + Line + { line_startX :: !Double + , line_startY :: !Double + , line_endX :: !Double + , line_endY :: !Double + } + +lineSlope :: Line -> Double +lineSlope Line{..} = (line_endY - line_startY) / (line_endX - line_startX) + +------------------------------------------------------------------------------- +-- Reference functions +------------------------------------------------------------------------------- + +lowerBound :: Int -> Int +lowerBound n = n + 1 + +upperBound :: Int -> Int +upperBound n = (n * n + n + 2) `div` 2 + +------------------------------------------------------------------------------- +-- View functions +------------------------------------------------------------------------------- + +getNumberOfSimulationsInput :: IO Int +getNumberOfSimulationsInput = do + numSimsSpan <- js_document_getElementById (toJSString "num-sims-value") + js_innerText numSimsSpan >>= js_parseInt + +getNumberOfCutsInput :: IO Int +getNumberOfCutsInput = do + numCutsInput <- js_document_getElementById (toJSString "num-cuts-input") + js_input_value numCutsInput + +getIsRandomInput :: IO Bool +getIsRandomInput = do + isRandomInput <- js_document_getElementById (toJSString "random-input") + js_input_checked isRandomInput + +getIsSortInput :: IO Bool +getIsSortInput = do + isSortInput <- js_document_getElementById (toJSString "sort-input") + js_input_checked isSortInput + +------------------------------------------------------------------------------- +-- Bindings +------------------------------------------------------------------------------- + +-- | The entry point +foreign export javascript "hs_start" main :: IO () + + +foreign import javascript unsafe "document.getElementById($1)" + js_document_getElementById :: JSString -> IO JSVal + +foreign import javascript unsafe "$1.getContext($2)" + js_canvas_getContext :: JSVal -> JSString -> IO JSVal + +foreign import javascript safe "$1.beginPath()" + js_canvas_context_beginPath :: JSVal -> IO () + +foreign import javascript safe "$1.arc($2, $3, $4, $5, $6)" + js_canvas_context_arc :: + JSVal + -> Double + -> Double + -> Double + -> Double + -> Double + -> IO () + +foreign import javascript safe "$1.stroke()" + js_canvas_context_stroke :: JSVal -> IO () + +foreign import javascript safe "$1.moveTo($2, $3)" + js_canvas_context_moveTo :: JSVal -> Double -> Double -> IO () + +foreign import javascript safe "$1.lineTo($2, $3)" + js_canvas_context_lineTo :: JSVal -> Double -> Double -> IO () + +foreign import javascript safe "$1.lineWidth = $2" + js_canvas_context_lineWidth :: JSVal -> Double -> IO () + +foreign import javascript safe "$1.strokeStyle = $2" + js_canvas_context_strokeStyle :: JSVal -> JSString -> IO () + +foreign import javascript unsafe "$1.width" + js_canvas_width :: JSVal -> Double + +foreign import javascript unsafe "$1.height" + js_canvas_height :: JSVal -> Double + +foreign import javascript safe "$1.clearRect(0, 0, $2, $3)" + js_canvas_context_clearRect :: JSVal -> Double -> Double -> IO () + +foreign import javascript safe "$1.addEventListener($2, $3)" + js_addEventListener :: JSVal -> JSString -> JSVal -> IO () + +foreign import javascript unsafe "parseInt($1, 10)" + js_parseInt :: JSVal -> IO Int + +foreign import javascript unsafe "$1.value" + js_input_value :: JSVal -> IO Int + +foreign import javascript unsafe "$1.checked" + js_input_checked :: JSVal -> IO Bool + +foreign import javascript unsafe "$1.innerText" + js_innerText :: JSVal -> IO JSVal + +-- Yikes! +foreign import javascript unsafe "new Chart($1,{type:'scatter',data:{datasets:[{type:'scatter',label:'Simulation result',data:[],borderWidth:1},{type:'scatter',label:'Lower bound',data:[]},{type:'scatter',label:'Upper bound',data:[]}]},options:{scales:{x:{beginAtZero:true,title:{display:true,text:'Number of cuts'}},y:{beginAtZero:true,title:{display:true,text:'Number of pieces'}}}}})" + js_new_results_Chart :: JSVal -> IO JSVal + +-- Yikes! +foreign import javascript unsafe "new Chart($1,{type:'scatter',data:{datasets:[{type:'scatter',label:'Upper bound divided by simulation result',data:[],borderWidth:1}]},options:{scales:{x:{beginAtZero:true,title:{display:true,text:'Number of cuts'}},y:{beginAtZero:true,title:{display:true,text:'Quotient'}}}}})" + js_new_ratios_Chart :: JSVal -> IO JSVal + +foreign import javascript unsafe "$1.data.datasets[$2].data.push({ x: $3, y: $4 })" + js_Chart_add_data :: JSVal -> Int -> Int -> Double -> IO () + +foreign import javascript unsafe "$1.update()" + js_Chart_update :: JSVal -> IO () + +foreign import javascript "wrapper" + js_as_callback :: IO () -> IO JSVal diff --git a/package.json b/package.json new file mode 100644 index 0000000..80f4016 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "wasm-game", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "", + "devDependencies": { + "@bytecodealliance/wizer": "^3.0.1" + } +} diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..b373be2 --- /dev/null +++ b/src/index.css @@ -0,0 +1,67 @@ +body { + font-family: 'Fira Sans', sans-serif; +} + +#sim-container { + display: flex; + flex-direction: column; + align-items: center; + justify-items: center; +} + +.sim-input-container { + margin-bottom: 1.2em; +} + +.sim-input { + text-align: center; +} + +.sim-input[type=range] { + width: 100%; +} + +.sim-label { + display: block; + margin-bottom: 0.4em; +} + +.input-section { + margin-bottom: 1.2em; + padding-left: 1em; + padding-right: 1em; +} + +#sim-input-button { + border: 0; + line-height: 2.5; + padding: 0 20px; + font-size: 1rem; + text-align: center; + color: #fff; + text-shadow: 1px 1px 1px #000; + border-radius: 10px; + background-color: rgb(220 0 0 / 100%); + background-image: linear-gradient(to top left, + rgb(0 0 0 / 20%), + rgb(0 0 0 / 20%) 30%, + rgb(0 0 0 / 0%)); + box-shadow: + inset 2px 2px 3px rgb(255 255 255 / 60%), + inset -2px -2px 3px rgb(0 0 0 / 60%); +} + +#sim-input-button:hover { + background-color: rgb(255 0 0 / 100%); +} + +#sim-input-button:active { + box-shadow: + inset -2px -2px 3px rgb(255 255 255 / 60%), + inset 2px 2px 3px rgb(0 0 0 / 60%); +} + +#game-canvas { + background: #eee; + border-radius: 3px; +} \ No newline at end of file diff --git a/src/index.html b/src/index.html new file mode 100644 index 0000000..e23696a --- /dev/null +++ b/src/index.html @@ -0,0 +1,62 @@ + + + + + + + + + + +
+

+ Cut a cake randomly N times. What is the expected number of pieces? +

+
+
+ + + 500 +
+ +
+ + +
+ +
+ + + 1 +
+ +
+ + +
+ +
+ +
+
+
+ +
+

+ Results: +

+
+ +
+
+ +
+
+ + + + + \ No newline at end of file diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..d9381f8 --- /dev/null +++ b/src/index.js @@ -0,0 +1,22 @@ +import { WASI, OpenFile, File, ConsoleStdout } from "https://cdn.jsdelivr.net/npm/@bjorn3/browser_wasi_shim@0.2.21/dist/index.js"; +import ghc_wasm_jsffi from "./ghc_wasm_jsffi.js"; + +const args = []; +const env = []; +const fds = [ + new OpenFile(new File([])), // stdin + ConsoleStdout.lineBuffered((msg) => console.log(`[WASI stdout] ${msg}`)), + ConsoleStdout.lineBuffered((msg) => console.warn(`[WASI stderr] ${msg}`)), +]; +const options = { debug: false }; +const wasi = new WASI(args, env, fds, options); + +const instance_exports = {}; +const { instance } = await WebAssembly.instantiateStreaming(fetch("bin.wasm"), { + wasi_snapshot_preview1: wasi.wasiImport, + ghc_wasm_jsffi: ghc_wasm_jsffi(instance_exports), +}); +Object.assign(instance_exports, instance.exports); + +wasi.initialize(instance); +await instance.exports.hs_start(globalThis); diff --git a/wasm-sim.cabal b/wasm-sim.cabal new file mode 100644 index 0000000..fe013eb --- /dev/null +++ b/wasm-sim.cabal @@ -0,0 +1,26 @@ +cabal-version: 3.8 +name: wasm-sim +version: 0.1.0 + +common lang + hs-source-dirs: + haskell + default-language: + GHC2021 + default-extensions: + RecordWildCards + build-depends: + , base + +executable wasm-sim + import: + lang + main-is: + Main.hs + build-depends: + , ghc-experimental + , random + ghc-options: + -no-hs-main + -optl-mexec-model=reactor + "-optl-Wl,--export=hs_start" \ No newline at end of file