Repository navigation
Miso.Canvas: direct JavaScript imports for canvas operations and rgb()/rgba() colours - #1673
Merged
Merged
Conversation
Draws N coloured rectangles per frame through canvasSub, one fillStyle and one fillRect each, with the layout computed once up front (the shape of webcc's canvas benchmark). The frame rate is measured once a second, drawn on the canvas and logged as "fps: N ms/frame: T rects: M"; ?rects=N sets the count. bench.mjs drives it with Playwright and reports the median. The per-frame arithmetic is kept out of the loop on purpose: on MicroHs fromIntegral, mod and floor on Double cost 15 to 300 us each (floor goes through Integer), which would hide the canvas cost being measured. Baseline, MicroHs -tbrowser in headless Chromium on this machine (ms/frame, median): 1000 rects 78, 2000 rects 133, 4000 rects 207, 10000 rects 478; about 50 to 80 us per rectangle.
Every void canvas operation (fillRect, moveTo, fillStyle, ...) now calls a dedicated foreign import in Miso.Canvas.FFI instead of going through the generic (#)/setField path, which marshalled the method name to a JS string, looked the method up, built an argument array and allocated a JSVal handle per argument and result on every call. Miso.Canvas.FFI has the 43 imports once in GHCJS arrow syntax, once in statement syntax (GHCJS 8.6, GHC wasm, MicroHs), and a native GHC arm that keeps the generic calls, since native GHC has no JavaScript FFI. Operations that return a value (gradients, patterns, image data) and addColorStop stay on the generic path. The public API is unchanged. sample-app canvas benchmark, MicroHs -tbrowser (jsval-2ltt build), headless Chromium, ms/frame (median), master -> this: 1000 rects 49 -> 20 4000 rects 194 -> 79 10000 rects 489 -> 200 about 2.5x. Only the MicroHs backend has been built and measured here; the GHCJS, wasm and native arms are untested.
GHC wasm: JSString was imported without its constructor, so GHC could
not unwrap the newtype in the foreign imports that take one and fell
back to a C stub with an unknown HsJSString type ("unknown type name
'HsJSString'", "call to undeclared function 'rts_getJSString'").
Import JSString (..); the JS backend exports it abstractly, so this is
a no-op there.
Native GHC: the generic arm passes method names as string literals to
(#) and setField, which take a MisoString (Text), so the module needs
OverloadedStrings.
startSub/stopSub take any ToMisoString key, so "canvas" was ambiguous under GHC's OverloadedStrings; annotate it. ?rects=N was only read on MicroHs; read it on GHC wasm and the GHC JavaScript backend too.
fillStyle, strokeStyle and shadowColor with an RGB or RGBA colour (which
includes the named colours) now pass the channels as numbers to a direct
import that builds the rgb(...) string in JavaScript. renderColor built
it in Haskell, and on the GHC backends every ms and <> on a JSString is
an FFI call, then the result needed a JSVal handle: about 5.7 us per
colour, eight times a fillRect. Other colours, gradients and patterns
keep the generic path. Native GHC keeps setField with renderColor.
At 10000 rects the colour was 57 of 72 ms per frame on GHC wasm; the
fills themselves are about 9 ms.
sample-app canvas benchmark, headless Chromium with the frame rate
limiter off, fps (median of 3 interleaved rounds), master -> this branch:
1000 rects 4000 rects 10000 rects
GHC JS 90.9 -> 361.8 24.5 -> 112.5 9.4 -> 47.5 (4.0-5.1x)
GHC wasm 60.9 -> 340.8 15.5 -> 117.4 5.3 -> 46.0 (5.6-8.7x)
MicroHs 20.4 -> 98.4 5.2 -> 27.8 1.9 -> 10.1 (4.8-5.3x)
Hand written JavaScript drawing the same scene (an rgb() string per rect
per frame) runs at about 68 fps at 10000 rects on the same machine.
master no longer supports GHCJS 8.6 (#1672), so ghcjs_HOST_OS is never defined; the statement-syntax imports are for GHC wasm and MicroHs.
Miso.Canvas: direct JavaScript imports for canvas operations and rgb()/rgba() colours
sample-app is the counter app again (as on master), with its INTERACTIVE branches: 'live' for hot reload and no hs_start export under GHCi, so make watch and make repl work again. The benchmark is now its own package, canvas-bench, in sample-app-canvas with a Makefile for GHC wasm (build optim), the GHC JavaScript backend (build-js) and MicroHs (mhs). It has its own cabal.project that builds miso without the 'native' flag the top-level project sets: the benchmark runs in the browser, and native mode's startApp needs JSON instances for the model and action, which a DOMRef cannot have. bench.mjs now defaults to http://localhost:8080/, where make serve and make serve-mhs listen (it pointed at 8123).
sample-app-canvas (the canvas-bench package, bench.mjs and its Makefile targets) is kept on the canvas-bench branch.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Every void
CanvasRenderingContext2Doperation inMiso.Canvas(fillRect,arc,moveTo,globalAlpha,fillText, …) now calls a dedicated foreign import in the newMiso.Canvas.FFIinstead of the generic(#)/setFieldpath, which marshalled the method name, built an argument array and allocated aJSValper argument on every call.fillStyle,strokeStyleandshadowColorwith anRGBorRGBAcolour (this includes the named colours) pass the channels as numbers to an import that builds thergb(…)string in JavaScript. Before,renderColorbuilt it in Haskell, and on the GHC backends everymsand<>on aJSStringis an FFI call: about 5.7 µs per colour, eight times afillRect. Other colours, gradients and patterns keep the generic path.The public API is unchanged. Native GHC (no JavaScript FFI) keeps the generic calls.
Commits
rgb()/rgba()imports forfillStyle,strokeStyle,shadowColorJSString (..)import for GHC wasm (it failed withunknown type name 'HsJSString'),OverloadedStringsfor the native GHC arm, GHCJS 8.6 arm dropped after Drop GHCJS 8.6 and the legacy nixpkgs pin #1672sample-app-canvas(new packagecanvas-bench): a render intensive 2D canvas benchmark with an FPS counter (?rects=N),bench.mjs, and Makefile targets for GHC wasm, the GHC JavaScript backend and MicroHs. It has its owncabal.projectthat builds miso without thenativeflag.sample-appis unchanged from master.Built on GHC JS 9.14.1, GHC wasm 9.14, native GHC 9.14.1 and MicroHs 0.16.7.
Benchmarks
Headless Chromium 152 with
--disable-gpu-vsync --disable-frame-rate-limit(so nothing is capped at 60 fps). Each figure is the median of 3 rounds with master and this branch interleaved.sample-app-canvas: N rectangles per frame, one
fillStyle+fillRecteachfps, master → this branch:
-tbrowser)Against the same scene written directly in JavaScript, in one run at 10000 rects:
rgb()string per rect per frame)haskell-miso/canvas2d, unmodified
N canvases, each animating 8 planets per frame through
canvasSub; fps and main-thread script time per frame:Script time per frame drops 2.6–5.6×. With this branch both backends reach the same frame rate, which is then limited by the browser rasterising several canvases in software rather than by miso.
Orbital Breakdown, unmodified
The game builds against this branch without changes (GHC JavaScript backend, as deployed). Its own replay tool (
tools/replay) plays fixed input scripts with a fake clock and seeded randomness, so both builds do exactly the same work, and records main-thread time per frame through the Chrome DevTools Protocol. The game makes about 700–1300 canvas calls per frame.This branch was faster in all 9 paired runs. Script execution time on its own is about the same on both (within ±3%), so the saving is in the rest of the main-thread work per frame. The gain is smaller than in the benchmarks above because the game sets its colours as strings through
Miso.Canvas.setand builds its gravity grid and gradients withMiso.DSLcalls, which this branch does not change. The game traces are identical on master and this branch in every run.