Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 20 additions & 5 deletions .github/workflows/hugo.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,15 +53,18 @@ jobs:
id: pages
uses: actions/configure-pages@v6

# The playground's two assets are build artifacts, never committed: the
# wasm compiled from playground/ at this commit, and wasm_exec.js taken
# from the SAME toolchain (the two must match). Built before Hugo, which
# reads the wasm's size for the loader's progress bar.
# The playground's three assets are build artifacts, never committed: the
# wasm compiled from playground/ at this commit, the Ruby tab's
# interpreter (go-embedded-ruby at the version playground/rubywasm/go.mod
# pins), and wasm_exec.js taken from the SAME toolchain (all must match).
# Built before Hugo, which reads the two wasm sizes for the progress bars.
- name: Setup Go
uses: actions/setup-go@v7
with:
go-version: "1.27.2"
cache-dependency-path: playground/go.sum
cache-dependency-path: |
playground/go.sum
playground/rubywasm/go.sum

- name: Build the playground
working-directory: playground
Expand All @@ -75,6 +78,18 @@ jobs:
-o ../static/playground/playground.wasm ./cmd/playground-wasm
cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" ../static/playground/

# Fetched by the page only when a visitor opens the Ruby tab.
- name: Build go-embedded-ruby for the Ruby tab
working-directory: playground/rubywasm
env:
GOWORK: "off"
GOTOOLCHAIN: local
run: |
GOOS=js GOARCH=wasm go build -trimpath -ldflags "-s -w" \
-o ../../static/playground/rbgo.wasm github.com/go-embedded-ruby/ruby/cmd/wasm
raw=$(stat -c %s ../../static/playground/rbgo.wasm); gz=$(gzip -9 -c ../../static/playground/rbgo.wasm | wc -c)
echo "rbgo.wasm ($(go list -m -f '{{.Version}}' github.com/go-embedded-ruby/ruby)): $raw bytes, $gz gzipped" | tee -a "$GITHUB_STEP_SUMMARY"

- name: Build
env:
HUGO_ENVIRONMENT: production
Expand Down
24 changes: 20 additions & 4 deletions .github/workflows/playground.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@
# and dark forced, 1x and 2x, plus a phone (390x844 at 3x,
# mobile, touch): real clicks and keys, asserted on the app's
# state AND on canvas pixels, and the canvas must fit the
# viewport width. A missing browser is a
# failure here, never a skip.
# viewport width. The Ruby tab: go-embedded-ruby is fetched
# only once the tab is opened (requests counted), and the
# README example run in Ruby prints go-ndarray's shape and
# values. A missing browser is a failure here, never a skip.
name: playground

on:
Expand Down Expand Up @@ -102,7 +104,9 @@ jobs:
- uses: actions/setup-go@v7
with:
go-version: "1.27.2"
cache-dependency-path: playground/go.sum
cache-dependency-path: |
playground/go.sum
playground/rubywasm/go.sum
- uses: actions/setup-node@v7
with:
node-version: "24"
Expand All @@ -118,12 +122,24 @@ jobs:
install-dependencies: true
- name: install puppeteer-core
run: npm install --no-save --prefix "${{ runner.temp }}/pptr" puppeteer-core@24.43.1
# The Ruby tab's interpreter, built as the deploy builds it, once for both
# Ruby checks below.
- name: build go-embedded-ruby (js/wasm)
working-directory: playground/rubywasm
run: |
GOOS=js GOARCH=wasm go build -trimpath -ldflags "-s -w" -o "$RUNNER_TEMP/rbgo.wasm" github.com/go-embedded-ruby/ruby/cmd/wasm
raw=$(stat -c %s "$RUNNER_TEMP/rbgo.wasm"); gz=$(gzip -9 -c "$RUNNER_TEMP/rbgo.wasm" | wc -c)
echo "rbgo.wasm ($(go list -m -f '{{.Version}}' github.com/go-embedded-ruby/ruby)): $raw bytes, $gz gzipped" | tee -a "$GITHUB_STEP_SUMMARY"
- name: run the proof
env:
CHROME: ${{ steps.chrome.outputs.chrome-path }}
PLAYGROUND_NODE_PATH: ${{ runner.temp }}/pptr/node_modules
PLAYGROUND_CAPTURE_DIR: ${{ runner.temp }}/captures
run: go test -count=1 -v -run TestBrowserProof .
PLAYGROUND_RUBY_WASM: ${{ runner.temp }}/rbgo.wasm
# TestRubyAgreesWithGo runs every operation's Ruby program on the
# interpreter. The proof downloads its ~100 MB twice in Chrome: give the
# run more than go test's 10 minutes.
run: go test -count=1 -timeout 30m -v -run 'TestBrowserProof|TestRubyAgreesWithGo' . ./engine
- name: keep the screenshots
if: always()
uses: actions/upload-artifact@v7
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ resources/
# Built by the pages workflow from playground/ (never committed).
static/playground/playground.wasm
static/playground/wasm_exec.js
static/playground/rbgo.wasm
61 changes: 52 additions & 9 deletions layouts/_default/playground.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Playground — {{ .Site.Title }}</title>
<meta name="description" content="Try go-ndarray in your browser: create arrays, apply views, broadcasting, ufuncs, reductions and linear algebra step by step, and see shapes, strides, values, timings and the equivalent Go and NumPy code. go-ndarray itself, compiled to WebAssembly.">
<meta name="description" content="Try go-ndarray in your browser: create arrays, apply views, broadcasting, ufuncs, reductions and linear algebra step by step, and see shapes, strides, values, timings and the equivalent Go, Ruby and NumPy code, the Ruby program running in your browser on go-embedded-ruby. go-ndarray itself, compiled to WebAssembly.">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
{{ partial "styles.html" . }}
<style>
Expand Down Expand Up @@ -51,15 +51,18 @@
</div>
<noscript><p style="padding:16px">The playground runs go-ndarray compiled to WebAssembly and needs JavaScript to start it.</p></noscript>

{{/* Both files are build artifacts: the pages workflow compiles the wasm from
playground/ and copies wasm_exec.js from the SAME Go toolchain, so the two
always match. Neither is committed. ?v= is the deployed commit, so a new
{{/* The three files are build artifacts: the pages workflow compiles the wasm
from playground/, the Ruby wasm (go-embedded-ruby, pinned in
playground/rubywasm/go.mod) with the SAME Go toolchain, and copies that
toolchain's wasm_exec.js, which both modules use: they match. Neither is committed. ?v= is the deployed commit, so a new
deploy is a new URL and a cached copy can only be the bytes it names. */}}
{{ $sha := .Site.Params.wasmsha }}
{{ $wasm := "/playground/playground.wasm" }}
{{ $exec := "/playground/wasm_exec.js" }}
{{ with $sha }}{{ $wasm = printf "%s?v=%s" $wasm . }}{{ $exec = printf "%s?v=%s" $exec . }}{{ end }}
{{ $rbwasm := "/playground/rbgo.wasm" }}
{{ with $sha }}{{ $wasm = printf "%s?v=%s" $wasm . }}{{ $exec = printf "%s?v=%s" $exec . }}{{ $rbwasm = printf "%s?v=%s" $rbwasm . }}{{ end }}
{{ $bytes := 0 }}{{ with (os.Stat "static/playground/playground.wasm") }}{{ $bytes = .Size }}{{ end }}
{{ $rbbytes := 0 }}{{ with (os.Stat "static/playground/rbgo.wasm") }}{{ $rbbytes = .Size }}{{ end }}
<script>
/* Go's wasm runtime reads globalThis.fs/process/path; an element id of the
same name would shadow them and kill the runtime at init. None here. */
Expand All @@ -74,6 +77,11 @@
// so Content-Length is the compressed size while the reader below counts
// decompressed bytes. 0 when the wasm was not built (a bare `hugo server`).
var wasmBytes={{ $bytes }};
// go-embedded-ruby, the Ruby tab's interpreter: several times the playground's
// size, so it is fetched only when the app asks (the visitor opens the
// Ruby tab), never with the page.
var rubyURL={{ $rbwasm }};
var rubyBytes={{ $rbbytes }};

function isDark(){
var t=document.documentElement.getAttribute("data-theme");
Expand All @@ -87,6 +95,7 @@
new MutationObserver(syncTheme).observe(document.documentElement,{attributes:true,attributeFilter:["data-theme"]});
if(window.matchMedia){ try{ window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",syncTheme); }catch(e){} }

function pageProgress(got,total){ progress(total?got/total:null); }
function progress(f){
var fill=$("pg-fill"), pct=$("pg-pct");
if(f==null){ fill.style.width="100%"; fill.style.opacity=".45"; pct.textContent="…"; return; }
Expand All @@ -99,10 +108,10 @@
err.textContent=String(e)+(location.protocol==="file:"?"\n(serve the page over HTTP: a page opened from a file cannot fetch the wasm)":"\n(a hard refresh may help if a stale copy is cached)");
err.classList.add("show");
}
function fetchWasm(url){
function fetchWasm(url,known,progress){
return fetch(url).then(function(r){
if(!r.ok) throw new Error("HTTP "+r.status+" fetching "+url);
var total=wasmBytes||parseInt(r.headers.get("Content-Length")||"0",10)||0;
var total=known||parseInt(r.headers.get("Content-Length")||"0",10)||0;
if(!r.body||!r.body.getReader) return r.arrayBuffer();
var reader=r.body.getReader(), got=0, chunks=[];
return (function pump(){
Expand All @@ -113,16 +122,50 @@
return buf.buffer;
}
chunks.push(res.value); got+=res.value.length;
progress(total?got/total:null);
progress(got,total);
return pump();
});
})();
});
}
// ndarrayRubyLoad(onProgress) downloads go-embedded-ruby and starts it in a
// Go runtime of its own (a second `new Go()`: wasm_exec.js keeps each
// instance's state on the instance), resolving once it has published
// rbgoEval. onProgress(got, total) follows the download in bytes. One
// download serves every call; a failed one may be retried.
var rubyLoading=null;
globalThis.ndarrayRubyLoad=function(onProgress){
if(rubyLoading) return rubyLoading;
rubyLoading=fetchWasm(rubyURL,rubyBytes,function(got,total){ if(onProgress) onProgress(got,total); }).then(function(ab){
var go=new Go();
return WebAssembly.instantiate(ab,go.importObject).then(function(res){
go.run(res.instance);
return new Promise(function(resolve,reject){
var waited=0, t=setInterval(function(){
if(typeof globalThis.rbgoEval==="function"&&globalThis.rbgoReady){ clearInterval(t); resolve(); }
else if((waited+=20)>60000){ clearInterval(t); reject(new Error("go-embedded-ruby did not start")); }
},20);
});
});
});
rubyLoading.catch(function(){ rubyLoading=null; });
return rubyLoading;
};
// rbgoEval, guarded: a Go panic in the interpreter ends its runtime, and a
// call into a stopped runtime throws. Report that as the run's error rather
// than throwing into the playground's own runtime.
globalThis.ndarrayRubyEval=function(src){
try{
var r=globalThis.rbgoEval(src);
return r||{output:"",error:"go-embedded-ruby returned nothing"};
}catch(e){
return {output:"",error:"go-embedded-ruby stopped ("+String(e)+"): reload the page to restart it"};
}
};
function start(){
if(typeof Go!=="function"){ fail("wasm_exec.js did not load"); return; }
var go=new Go();
fetchWasm(wasmURL).then(function(ab){
fetchWasm(wasmURL,wasmBytes,pageProgress).then(function(ab){
progress(1);
$("pg-msg").textContent="Starting…";
return WebAssembly.instantiate(ab,go.importObject);
Expand Down
54 changes: 48 additions & 6 deletions playground/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
The page at <https://go-ndarray.github.io/playground/>: go-ndarray itself,
compiled to WebAssembly, inside a [go-widgets](https://github.com/go-widgets)
canvas application. Nothing is computed in JavaScript — the page only loads the
wasm, forwards input and follows the landing's theme toggle.
wasm, forwards input and follows the landing's theme toggle. The Ruby tab runs
its program on [go-embedded-ruby](https://github.com/go-embedded-ruby/ruby), a
Ruby interpreter in Go, compiled to WebAssembly too and fetched only when the
tab is opened.

A visitor builds a pipeline of steps — create an array (`Arange`, `Linspace`,
`Zeros`/`Ones`/`Full`, `Eye`, typed-in `FromData`), then apply views
Expand All @@ -12,21 +15,24 @@ arithmetic, ufuncs, reductions along an axis, `Concatenate`/`Stack`,
`MatMul`/`Dot`/`Outer` — and sees for each step its shape, its strides (in
elements and in bytes), whether it is a view sharing memory with earlier steps,
the time one call took in the browser, its values as a heatmap table, and the
whole pipeline as a Go program that compiles and its NumPy equivalent (both
copyable). Errors are go-ndarray's own (`ndarray: shapes are not broadcastable:
whole pipeline as a Go program that compiles, a Ruby program and its NumPy
equivalent (all three copyable). The Ruby program uses go-embedded-ruby's
`NDArray`, which is go-ndarray bound into the interpreter; **Run** executes it
in the page and prints every step, then the selected step's `shape` and `to_a`. Errors are go-ndarray's own (`ndarray: shapes are not broadcastable:
[2 3] vs [3 2]`).

## Layout

| package | role |
|---|---|
| `engine` | the model: the operation catalog, argument parsing, running a step (timed), its Go and NumPy spellings, and the layout of a result read from go-ndarray's `Array` |
| `engine` | the model: the operation catalog, argument parsing, running a step (timed), its Go, Ruby and NumPy spellings, and the layout of a result read from go-ndarray's `Array` |
| `vm` | the ViewModel: every piece of state as a `go-widgets/mvvm` Observable, ObservableList or Command; no widget |
| `.` (`playground`) | the View: toolkit widgets in box layouts, bound to the ViewModel (`mvvmtk`, plus `view_binding.go`); event routing through the root box |
| `cmd/playground-wasm` | the browser shell: canvas at device pixels, input forwarding, `ndarraySetTheme`, `ndarrayDebug`/`ndarrayRects` for the proofs |
| `cmd/playground-wasm` | the browser shell: canvas at device pixels, input forwarding, `ndarraySetTheme`, `ndarrayDebug`/`ndarrayRects` for the proofs, and the Ruby hooks (`ndarrayRubyLoad`, `ndarrayRubyEval` around `rbgoEval`) |
| `rubywasm` | a module of its own that only pins go-embedded-ruby (`tool` directive): the deploy builds its `cmd/wasm` from here, and Renovate bumps the pin |

Widgets used: `Label`, `DropDown`, `ListBox`, `Entry`, `Button`,
`ViewSwitcher`, `TextView`, `Table`, `VBox`/`HBox`. The heatmap is
`ViewSwitcher`, `TextView`, `Table`, `ProgressBar`, `VBox`/`HBox`. The heatmap is
`Table.CellFill` with `toolkit.Viridis`, added to go-widgets/toolkit for this
page.

Expand Down Expand Up @@ -70,3 +76,39 @@ CHROME=… PLAYGROUND_NODE_PATH=<dir with puppeteer-core> HUGO=hugo \

Screenshots go to `$PLAYGROUND_CAPTURE_DIR`, else the user config directory —
never into a repository (the helper refuses a directory inside a git tree).

## Ruby: go-embedded-ruby in the same page

The Ruby tab's program is written against go-embedded-ruby's `NDArray`
(`NDArray.arange(0, 6, 1)`, `a.reshape(2, -1)`, `b[true, (1..)]`,
`b.sum(0)`, `NDArray.concatenate([a, b], axis: 0)`, …); `engine` emits nothing
else, and `engine/ruby_test.go` checks every operation's spelling and that it
calls only the methods of that API.

The interpreter is a second Go program compiled to WebAssembly,
`github.com/go-embedded-ruby/ruby/cmd/wasm`, at the version `rubywasm/go.mod`
pins:

```sh
cd rubywasm
GOOS=js GOARCH=wasm go build -trimpath -ldflags="-s -w" \
-o ../../static/playground/rbgo.wasm github.com/go-embedded-ruby/ruby/cmd/wasm
```

It is far larger than the playground (about 100 MB, 21 MB gzipped), so the page
never loads it up front: the first time the visitor opens the Ruby tab, the
ViewModel asks the host, the page's `ndarrayRubyLoad` fetches it with a
progress line and bar counting the bytes, and starts it in its own Go runtime
(a second `new Go()` with the same `wasm_exec.js`: both modules are built by one
toolchain). **Run** then calls its `rbgoEval(src)` and shows the output.

The browser proof counts the requests for `rbgo.wasm`: none through the Go and
NumPy tabs, one when the Ruby tab opens, still one after two runs. It runs the
README example in Ruby and requires the printed `shape` and `to_a` to equal
go-ndarray's result for the same step. `TestRubyAgreesWithGo` (in `engine`) goes
further, without a browser: it runs the Ruby program of every operation case
and of every step of every preset on the interpreter under node
(`engine/testdata/rbeval.cjs`) and compares each printed shape and value with
go-ndarray's. Both read `PLAYGROUND_RUBY_WASM=<file>`, a prebuilt
interpreter (the proof builds the pinned one without it; CI builds it once
for both).
Loading
Loading