diff --git a/nnotepad/.eslintrc.js b/nnotepad/.eslintrc.js
new file mode 100644
index 00000000..329c64ed
--- /dev/null
+++ b/nnotepad/.eslintrc.js
@@ -0,0 +1,4 @@
+module.exports = {
+ env: {'es6': true, 'browser': true, 'jquery': false, 'node': true},
+ parserOptions: {ecmaVersion: 2021, sourceType: 'module'},
+};
diff --git a/nnotepad/Makefile b/nnotepad/Makefile
new file mode 100644
index 00000000..c7ad810e
--- /dev/null
+++ b/nnotepad/Makefile
@@ -0,0 +1,9 @@
+.PHONY: clean
+
+all: res/docs.html
+
+res/docs.html: README.md
+ bin/makedocs
+
+clean:
+ rm -f res/docs.html
diff --git a/nnotepad/README.md b/nnotepad/README.md
new file mode 100644
index 00000000..344a23c6
--- /dev/null
+++ b/nnotepad/README.md
@@ -0,0 +1,106 @@
+# What is this?
+
+**NNotepad** is a browser-based playground for experimenting with [WebNN](https://webmachinelearning.github.io/webnn/) expressions without boilerplate code. As of mid-2024, WebNN is available as a prototype in Chromium-based browsers, but requires launching the browser with particular flags enabled.
+
+
+# Usage
+
+Type assignments like `foo = 1 + 2` or expressions like `2 * foo`. The result of the last assignment or expression is shown. Some examples:
+
+```
+1 + 2
+# yields 3
+
+a = 123
+b = 456
+a / b
+# yields 0.2697368562221527
+
+A = [[1,7],[2,4]]
+B = [[3,3],[5,2]]
+matmul(A,B)
+# yields [[38,17],[26,14]]
+```
+
+**NNotepad** translates what you type into script that builds a WebNN graph, evaluates the script, then executes the graph. Click 🔎 to see the generated script.
+
+Expressions can use:
+
+* Operators `+`, `-`, `*`, `/`, `^`, `==`, `<`, `<=`, `>`, `>=`, `!` with precedence, and `(`,`)` for grouping.
+* Function calls like `add()`, `matmul()`, `sigmoid()`, and so on.
+* Numbers like `-12.34`.
+* Tensors like `[[1,2],[3,4]]`.
+* Dictionaries like `{alpha: 2, beta: 3}`, arrays like `[ A, B ]`, strings like `"float32"`, and booleans `true` and `false`.
+
+Functions and operators are turned into [`MLGraphBuilder`](https://webmachinelearning.github.io/webnn/#mlgraphbuilder) method calls.
+
+Array literals (`[...]`) and number literals (`12.34`) are interpreted contextually:
+
+* In assignments, they are intepreted as tensor/scalar constant [`MLOperand`](https://webmachinelearning.github.io/webnn/#mloperand)s, e.g. `alpha = 12.34` or `T = [1,2,3,4]`.
+* In most function calls, they are interpreted as tensor/scalar constant [`MLOperand`](https://webmachinelearning.github.io/webnn/#mloperand)s, e.g. `neg(123)` or `neg([1,2,3])`.
+* In some function calls, they are interpreted as arrays/numbers for some positional parameters, e.g. `concat([A,B,C],0)`. This includes: [`concat()`](https://webmachinelearning.github.io/webnn/#dom-mlgraphbuilder-concat), [`expand()`](https://webmachinelearning.github.io/webnn/#dom-mlgraphbuilder-expand), [`pad()`](https://webmachinelearning.github.io/webnn/#dom-mlgraphbuilder-pad), [`reshape()`](https://webmachinelearning.github.io/webnn/#dom-mlgraphbuilder-reshape), [`slice()`](https://webmachinelearning.github.io/webnn/#dom-mlgraphbuilder-slice), [`split()`](https://webmachinelearning.github.io/webnn/#dom-mlgraphbuilder-split).
+* In dictionaries, they are interpreted as arrays/numbers, e.g. `linear(123, {alpha: 456, beta: 789})` or `transpose(T, {permutation: [0,2,1]})`. To pass a tensor/scalar constant in a dictionary, use a variable or wrap it in [`identity()`](https://webmachinelearning.github.io/webnn/#dom-mlgraphbuilder-identity) e.g. `gemm(A, B, {c:identity([4])})` or `gemm(A, B, {c:identity(4)})`.
+
+The default [data type](https://webmachinelearning.github.io/webnn/#enumdef-mloperanddatatype) for scalars and tensors is [`float32`](https://webmachinelearning.github.io/webnn/#dom-mloperanddatatype-float32). To specify a different data type, suffix with one of `i8`, `u8`, `i32`, `u32`, `i64`, `u64`, `f16`, `f32`, e.g. `123i8` or `[1,2,3]u32`.
+
+
+# Helpers
+
+In addition to WebNN [`MLGraphBuilder`](https://webmachinelearning.github.io/webnn/#mlgraphbuilder) methods, you can use these helpers:
+
+* **load(_url_, _shape_, _dataType_)** - fetch a tensor resource. Must be served with appropriate [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) headers. Example: `load('https://www.random.org/cgi-bin/randbyte?nbytes=256', [16, 16], 'uint8')`
+
+
+# Details & Gotchas
+
+* [`float16`](https://webmachinelearning.github.io/webnn/#dom-mloperanddatatype-float16) support (and the `f16` suffix) is experimental.
+* Whitespace including line breaks is ignored.
+* Parsing around the "unary minus" operator can be surprising. Wrap expressions e.g. `(-a)` if you get unexpected errors.
+* If output is a constant, it will be wrapped with [`identity()`](https://webmachinelearning.github.io/webnn/#dom-mlgraphbuilder-identity) if your back-end supports it. Otherwise, you must introduce a supported expression.
+
+What ops are supported, and with what data types, depends entirely on your browser's WebNN implementation. Here be dragons!
+
+
+# Parsing & Grammar
+
+```
+Anything after # or // on a line is ignored (outside other tokens)
+
+{} means 0-or-more repetitions
+[] means 0-or-1 repetitions
+() for grouping
+| separates options
+'' is literal
+// is regex
+
+program = line { line }
+line = assigment | expr
+assigment = identifier '=' expr
+
+expr = relexpr
+relexpr = addexpr { ( '==' | '<' | '<=' | '>' | '>=' ) addexpr }
+addexpr = mulexpr { ( '+' | '-' ) mulexpr }
+mulexpr = powexpr { ( '*' | '/' ) powexpr }
+powexpr = unyexpr { '^' unyexpr }
+unyexpr = ( '-' | '!' ) unyexpr
+ | finexpr
+finexpr = number [ suffix ]
+ | array [ suffix ]
+ | string
+ | boolean
+ | dict
+ | identifier [ '(' [ expr { ',' expr } ] ')' ]
+ | '(' expr ')'
+
+string = /("([^\\\x0A\x0D"]|\\.)*"|'([^\\\x0A\x0D']|\\.)*')/
+number = /NaN|Infinity|-Infinity|-?\d+(\.\d+)?([eE]-?\d+)?/
+boolean = 'true' | 'false'
+identifier = /[A-Za-z]\w*/
+suffix = 'u8' | 'u32' | 'i8' | 'i32' | 'u64' | 'i64' | 'f16' | 'f32'
+
+array = '[' [ expr { ',' expr } ] ']'
+
+dict = '{' [ propdef { ',' propdef } [ ',' ] ] '}'
+propdef = ( identifier | string ) ':' expr
+```
+
diff --git a/nnotepad/TODO.md b/nnotepad/TODO.md
new file mode 100644
index 00000000..299e3512
--- /dev/null
+++ b/nnotepad/TODO.md
@@ -0,0 +1,17 @@
+# To-Do
+
+## Basics
+
+* Style to match rest of webnn-samples.
+* Improve default text.
+* Consider incorporating [WebNN Polyfill](https://github.com/webmachinelearning/webnn-polyfill).
+* Make input/output areas resizable.
+* Add to `../README.md` once we're happy with it.
+
+## WebNN Support
+
+* Allow size-0 dimensions in tensors per [#391](https://github.com/webmachinelearning/webnn/issues/391).
+
+## Advanced
+
+* Show line/col in parse error messages, and line numbers in textarea.
diff --git a/nnotepad/bin/makedocs b/nnotepad/bin/makedocs
new file mode 100755
index 00000000..cce5e63c
--- /dev/null
+++ b/nnotepad/bin/makedocs
@@ -0,0 +1,53 @@
+#!/usr/bin/env python3
+
+import markdown
+
+with open('README.md', 'r', encoding='utf-8') as input_file:
+ text = input_file.read()
+
+html = markdown.markdown(text, extensions=['extra'])
+
+with open('res/docs.html', 'w', encoding='utf-8', errors='xmlcharrefreplace') as output_file:
+ output_file.write('''
+
+
+
+
+
+
diff --git a/nnotepad/js/float16arraypolyfill.js b/nnotepad/js/float16arraypolyfill.js
new file mode 100644
index 00000000..0ed0265f
--- /dev/null
+++ b/nnotepad/js/float16arraypolyfill.js
@@ -0,0 +1,283 @@
+// Hacky "polyfill" for Float16Array
+// See "Approach" notes below for behavior and limitations.
+
+(function(global) {
+ 'use strict';
+
+ if ('Float16Array' in global) {
+ return;
+ }
+
+ // Based on https://github.com/inexorabletash/polyfill/blob/master/typedarray.js
+ function packIEEE754(v, ebits, fbits) {
+ const bias = (1 << (ebits - 1)) - 1;
+
+ function roundToEven(n) {
+ const w = Math.floor(n); const f = n - w;
+ if (f < 0.5) {
+ return w;
+ }
+ if (f > 0.5) {
+ return w + 1;
+ }
+ return w % 2 ? w + 1 : w;
+ }
+
+ // Compute sign, exponent, fraction
+ let s; let e; let f;
+ if (v !== v) {
+ // NaN
+ // http://dev.w3.org/2006/webapi/WebIDL/#es-type-mapping
+ e = (1 << ebits) - 1;
+ f = Math.pow(2, fbits - 1);
+ s = 0;
+ } else if (v === Infinity || v === -Infinity) {
+ e = (1 << ebits) - 1;
+ f = 0;
+ s = (v < 0) ? 1 : 0;
+ } else if (v === 0) {
+ e = 0;
+ f = 0;
+ s = (1 / v === -Infinity) ? 1 : 0;
+ } else {
+ s = v < 0;
+ v = Math.abs(v);
+
+ if (v >= Math.pow(2, 1 - bias)) {
+ // Normalized
+ e = Math.min(Math.floor(Math.log(v) / Math.LN2), 1023);
+ let significand = v / Math.pow(2, e);
+ if (significand < 1) {
+ e -= 1;
+ significand *= 2;
+ }
+ if (significand >= 2) {
+ e += 1;
+ significand /= 2;
+ }
+ const d = Math.pow(2, fbits);
+ f = roundToEven(significand * d) - d;
+ e += bias;
+ if (f / d >= 1) {
+ e += 1;
+ f = 0;
+ }
+ if (e > 2 * bias) {
+ // Overflow
+ e = (1 << ebits) - 1;
+ f = 0;
+ }
+ } else {
+ // Denormalized
+ e = 0;
+ f = roundToEven(v / Math.pow(2, 1 - bias - fbits));
+ }
+ }
+
+ // Pack sign, exponent, fraction
+ const bits = []; let i;
+ for (i = fbits; i; i -= 1) {
+ bits.push(f % 2 ? 1 : 0);
+ f = Math.floor(f / 2);
+ }
+ for (i = ebits; i; i -= 1) {
+ bits.push(e % 2 ? 1 : 0);
+ e = Math.floor(e / 2);
+ }
+ bits.push(s ? 1 : 0);
+ bits.reverse();
+ let str = bits.join('');
+
+ // Bits to bytes
+ const bytes = [];
+ while (str.length) {
+ bytes.unshift(parseInt(str.substring(0, 8), 2));
+ str = str.substring(8);
+ }
+ return bytes;
+ }
+
+ function unpackIEEE754(bytes, ebits, fbits) {
+ // Bytes to bits
+ const bits = [];
+
+ for (let i = 0; i < bytes.length; ++i) {
+ let b = bytes[i];
+ for (let j = 8; j; j -= 1) {
+ bits.push(b % 2 ? 1 : 0);
+ b = b >> 1;
+ }
+ }
+ bits.reverse();
+ const str = bits.join('');
+
+ // Unpack sign, exponent, fraction
+ const bias = (1 << (ebits - 1)) - 1;
+ const s = parseInt(str.substring(0, 1), 2) ? -1 : 1;
+ const e = parseInt(str.substring(1, 1 + ebits), 2);
+ const f = parseInt(str.substring(1 + ebits), 2);
+
+ // Produce number
+ if (e === (1 << ebits) - 1) {
+ return f !== 0 ? NaN : s * Infinity;
+ } else if (e > 0) {
+ // Normalized
+ return s * Math.pow(2, e - bias) * (1 + f / Math.pow(2, fbits));
+ } else if (f !== 0) {
+ // Denormalized
+ return s * Math.pow(2, -(bias - 1)) * (f / Math.pow(2, fbits));
+ } else {
+ return s < 0 ? -0 : 0;
+ }
+ }
+
+ function unpackF16(b) {
+ return unpackIEEE754(b, 5, 10);
+ }
+ function packF16(v) {
+ return packIEEE754(v, 5, 10);
+ }
+ function f16ToU16(u16) {
+ const [lo, hi] = packF16(u16);
+ return lo | (hi << 8);
+ }
+ function u16ToF16(u16) {
+ return unpackF16([u16 & 0xFF, (u16 >> 8) & 0xFF]);
+ }
+
+ function isArrayIndex(s) {
+ return s === String(Number(s) | 0);
+ }
+
+ function makeProxy(target) {
+ return new Proxy(target, {
+ get(target, property) {
+ if (property === Symbol.iterator) {
+ return function* () {
+ for (const u16 of target) {
+ yield u16ToF16(u16);
+ }
+ };
+ } else if (typeof property === 'string' && isArrayIndex(property)) {
+ const u16 = target[property];
+ return typeof u16 === 'number' ? u16ToF16(u16) : u16;
+ } else {
+ return target[property];
+ }
+ },
+ set(target, property, value, receiver) {
+ if (typeof property === 'string' && isArrayIndex(property)) {
+ target[property] = f16ToU16(value);
+ return true;
+ } else {
+ return Reflect.set(target, property, value, receiver);
+ }
+ },
+ });
+ }
+
+ // Approach #1: subclass Uint16Array, with a Proxy
+ // * Pro: `instanceof Float16Array` works
+ // * Con: Not recognized as an ArrayBufferView by DOM methods
+ global.Float16Array = class Float16Array extends Uint16Array {
+ constructor(...args) {
+ if (Array.isArray(args[0])) {
+ const array = args[0];
+ super(array.length);
+ for (let i = 0; i < array.length; ++i) {
+ this[i] = f16ToU16(array[i]);
+ }
+ } else {
+ super(...args);
+ }
+
+ return makeProxy(this);
+ }
+ };
+
+
+ // Approach #2: Proxy for Uint16Array
+ // * Pro: Can extract target
+ // * Con: Not recognized as an ArrayBufferView by DOM methods
+ global.Float16Array = function Float16Array(...args) {
+ let target;
+ if (Array.isArray(args[0])) {
+ const array = args[0];
+ target = new Uint16Array(array.length);
+ for (let i = 0; i < array.length; ++i) {
+ this[i] = f16ToU16(array[i]);
+ }
+ } else {
+ target = new Uint16Array(...args);
+ }
+
+ return makeProxy(target);
+ };
+
+
+ // Approach #3: Return Uint16Array with getters/setters
+ // * Pro: Can pass to DOM methods
+ // * Con: Fails, as the indexed properties are not configurable!
+ global.Float16Array = function Float16Array(...args) {
+ let target;
+ if (Array.isArray(args[0])) {
+ const array = args[0];
+ target = new Uint16Array(array.length);
+ for (let i = 0; i < array.length; ++i) {
+ this[i] = f16ToU16(array[i]);
+ }
+ } else {
+ target = new Uint16Array(...args);
+ }
+
+ const proxy = new Uint16Array(target.buffer);
+ for (let property = 0; property < target.length; ++property) {
+ proxy.__defineGetter__(property, () => {
+ return u16ToF16(target[property]);
+ });
+
+ proxy.__defineSetter__(property, (value) => {
+ target[property] = f16ToU16(value);
+ });
+ }
+ return proxy;
+ };
+
+
+ // Approach #4: Separate ctor and proxy helpers
+ //
+ // Construction is done with `new Float16Array(...)` but a plain Uint16Array
+ // is returned, initialized with the passed float16 data.
+ //
+ // To read values, call `proxy = proxyForFloat16(array)` and then use
+ // the proxy instead of the original for all use of the array. If the
+ // passed array is not a Uint16Array it just returns it. Note that if
+ // Float16Array is not a polyfill then this will **not** be added to the
+ // global, so check that the function exists before using it!
+ global.Float16Array = function Float16Array(arg, ...rest) {
+ let target;
+ if (arg instanceof ArrayBuffer) {
+ throw new Error('Constructing from ArrayBuffer not supported');
+ } else if (typeof arg === 'object') {
+ const arrayLike = arg;
+ const length = Number(arrayLike.length);
+ target = new Uint16Array(length);
+ const proxy = makeProxy(target);
+ for (let index = 0; index < length; ++index) {
+ proxy[index] = arrayLike[index];
+ }
+ return target;
+ } else {
+ const length = Number(arg);
+ return new Uint16Array(length);
+ }
+ };
+ global.proxyForFloat16Array = function(target) {
+ if (!(target instanceof Uint16Array)) {
+ return target;
+ }
+
+ return makeProxy(target);
+ };
+})(self);
+
diff --git a/nnotepad/js/index.js b/nnotepad/js/index.js
new file mode 100644
index 00000000..b4137417
--- /dev/null
+++ b/nnotepad/js/index.js
@@ -0,0 +1,111 @@
+import {Util} from './util.js';
+import {NNotepad, ParseError} from './nnotepad.js';
+
+const $ = (s) => document.querySelector(s);
+const $$ = (s) => [...document.querySelectorAll(s)];
+document.addEventListener('DOMContentLoaded', async (e) => {
+ try {
+ const req = await fetch('res/default.txt');
+ if (req.ok) {
+ $('#input').value = await req.text();
+ }
+ } catch (ex) {
+ console.warn(ex);
+ }
+
+ async function refresh(e) {
+ const code = $('#input').value;
+ $('#output').innerText = '';
+ $('#output').style.color = '';
+ $('#srcText').innerText = '';
+
+ if (!code.trim()) {
+ return;
+ }
+
+ try {
+ const [builderFunc, src] = NNotepad.makeBuilderFunction(code);
+ $('#srcText').innerText = src;
+ const result =
+ await NNotepad.execBuilderFunction($('#device').value, builderFunc);
+ $('#output').innerText = explain(result);
+ } catch (ex) {
+ $('#output').style.color = 'red';
+ $('#output').innerText = ex.name + ': ' + ex.message;
+ if (!(ex instanceof ParseError || ex instanceof ReferenceError)) {
+ let tip = '(See console for more)';
+ if (ex.message.match(/read properties of undefined/)) {
+ tip = 'Maybe WebNN is not supported by your browser?';
+ } else if (ex.message.match(/is not an output operand/)) {
+ tip = 'Tip: Try wrapping expression with identity()';
+ }
+
+ $('#output').innerText += '\n\n' + tip;
+ console.warn(ex);
+ }
+ }
+ }
+
+ $('#input').addEventListener('input', Util.debounce(refresh, 500));
+ $('#device').addEventListener('change', refresh);
+
+ refresh();
+
+ $$('dialog > button').forEach((e) => e.addEventListener('click', (e) => {
+ e.target.parentElement.close();
+ }));
+ $$('dialog').forEach((e) => e.addEventListener('close', (e) => {
+ $('#input').focus();
+ }));
+ $('#peek').addEventListener('click', (e) => $('#srcDialog').showModal());
+ $('#help').addEventListener('click', (e) => $('#helpDialog').showModal());
+});
+
+function explain(outputs) {
+ return outputs
+ .map(
+ (output) => ['dataType: ' + output.dataType,
+ 'shape: ' + Util.stringify(output.shape),
+ 'tensor: ' + dumpTensor(output.shape, output.buffer, 8),
+ ].join('\n'))
+ .join('\n\n');
+
+
+ function dumpTensor(shape, buffer, indent) {
+ // Scalar
+ if (shape.length === 0) {
+ return String(buffer[0]);
+ }
+
+ const width = [...buffer]
+ .map((n) => String(n).length)
+ .reduce((a, b) => Math.max(a, b));
+
+ const out = [];
+ let bufferIndex = 0;
+
+ return (function convert(dim = 0) {
+ out.push('[');
+ for (let i = 0; i < shape[dim]; ++i) {
+ if (dim + 1 === shape.length) {
+ out.push(String(buffer[bufferIndex++]).padStart(width));
+ } else {
+ convert(dim + 1);
+ }
+ if (i !== shape[dim] - 1) {
+ out.push(', ');
+ if (dim + 1 !== shape.length) {
+ if (dim + 2 !== shape.length) {
+ out.push('\n');
+ }
+ out.push('\n');
+ out.push(' '.repeat(indent + dim + 1));
+ }
+ }
+ }
+ out.push(']');
+ return out.join('');
+ })();
+ }
+}
+
diff --git a/nnotepad/js/nnotepad.js b/nnotepad/js/nnotepad.js
new file mode 100644
index 00000000..f4ee73a0
--- /dev/null
+++ b/nnotepad/js/nnotepad.js
@@ -0,0 +1,569 @@
+/* global BigInt64Array, BigUint64Array, Float16Array */
+
+import {Util} from './util.js';
+
+// ============================================================
+// General Utilities
+// ============================================================
+
+export class ParseError extends Error {
+ constructor(message) {
+ super(message);
+ this.name = 'ParseError';
+ }
+}
+
+export class BuildError extends Error {
+ constructor(message) {
+ super(message);
+ this.name = 'build()';
+ }
+}
+
+export class ComputeError extends Error {
+ constructor(message) {
+ super(message);
+ this.name = 'compute()';
+ }
+}
+
+// ============================================================
+// General WebNN Utilities
+// ============================================================
+
+class WebNNUtil {
+ static bufferForOperand(operand) {
+ const size = [...operand.shape()].reduce((a, b) => a * b, 1);
+ const ctor = WebNNUtil.dataTypeToBufferType(operand.dataType());
+ return Reflect.construct(ctor, [size]);
+ }
+
+ static dataTypeToBufferType(type) {
+ switch (type) {
+ case 'int8':
+ return Int8Array;
+ case 'uint8':
+ return Uint8Array;
+ case 'int32':
+ return Int32Array;
+ case 'uint32':
+ return Uint32Array;
+ case 'int64':
+ return BigInt64Array;
+ case 'uint64':
+ return BigUint64Array;
+ case 'float16':
+ return Float16Array;
+ case 'float32':
+ return Float32Array;
+ }
+ throw new Error(`Unsupported dataType ${type}`);
+ }
+
+ static isNonOperandArg(name, index) {
+ return ({
+ concat: [0, 1],
+ expand: [1],
+ gru: [3, 4],
+ gruCell: [4],
+ lstm: [3, 4],
+ lstmCell: [5],
+ pad: [1, 2],
+ reshape: [1],
+ slice: [1, 2],
+ softmax: [1], // TODO: Distinguish overloads
+ split: [1],
+ })[name]
+ ?.includes(index);
+ }
+}
+
+export class NNotepad {
+ // ============================================================
+ // Script Converter
+ // ============================================================
+
+ // Returns a tuple:
+ // * async JS function with input MLGraphBuilder and output MLOperand
+ // * the source to the body of the function
+
+ static makeBuilderFunction(text) {
+ // Operators
+ const kAdditiveOperators = {
+ '+': 'add',
+ '-': 'sub',
+ };
+ const kMultiplicativeOperators = {
+ '*': 'mul',
+ '/': 'div',
+ };
+ const kPowerOperators = {
+ '^': 'pow',
+ };
+ const kRelationalOperators = {
+ '==': 'equal',
+ '>': 'greater',
+ '>=': 'greaterOrEqual',
+ '<': 'lesser',
+ '<=': 'lesserOrEqual',
+ };
+ const kBinaryOperators = Object.assign(
+ {}, kAdditiveOperators, kMultiplicativeOperators, kPowerOperators,
+ kRelationalOperators);
+
+ const kUnaryOperators = {
+ '-': 'neg',
+ '!':
+ 'logicalNot', // See
+ // https://github.com/webmachinelearning/webnn/issues/496#issuecomment-2123895106
+ };
+
+ const kDefaultDataType = 'float32';
+
+ // ------------------------------------------------------------
+ // Tokenizer
+
+ // Output `tokens` is an array; each token is one of:
+ // * a comment
+ // * a number
+ // * a string
+ // * a boolean
+ // * a type suffix
+ // * an identifier
+ // * an operator (single character or digraph)
+ // e.g. 'sqrt(a + 12)' -> ['sqrt', '(', 'a', '+', 'b', '12', ')']
+
+ const kOperators = Object.assign({}, kBinaryOperators, kUnaryOperators);
+
+ // Tokens
+ const kCommentPattern = '(#|//).*';
+ const kNumberPattern =
+ 'NaN|Infinity|-Infinity|-?\\d+(\\.\\d+)?([eE]-?\\d+)?';
+ const kStringPattern =
+ `"([^\\\\\\x0A\\x0D"]|\\\\.)*"|'([^\\\\\\x0A\\x0D']|\\\\.)*'`;
+ const kBooleanPattern = 'true|false';
+ const kSuffixPattern = `u8|u32|u64|i8|i32|i64|f16|f32`;
+ const kIdentifierPattern = '[A-Za-z]\\w*';
+
+ const rescape = (s) => s.replace(/[\\^$*+?.()|[\]{}]/g, '\\$&');
+ const longestFirst = (a, b) => b.length - a.length;
+ const kTokenRegEx = new RegExp(
+ [
+ kCommentPattern,
+ kNumberPattern,
+ kStringPattern,
+ kBooleanPattern,
+ kSuffixPattern,
+ kIdentifierPattern,
+ ...Object.keys(kOperators).sort(longestFirst).map(rescape),
+ '.',
+ ].join('|'),
+ 'g');
+
+ const toRegEx = (p) => new RegExp('^(' + p + ')$');
+ const kCommentRegEx = toRegEx(kCommentPattern);
+ const kNumberRegEx = toRegEx(kNumberPattern);
+ const kStringRegEx = toRegEx(kStringPattern);
+ const kBooleanRegEx = toRegEx(kBooleanPattern);
+ const kSuffixRegEx = toRegEx(kSuffixPattern);
+ const kIdentifierRegEx = toRegEx(kIdentifierPattern);
+ const isComment = (token) => token && token.match(kCommentRegEx);
+ const isNumber = (token) => token && token.match(kNumberRegEx);
+ const isString = (token) => token && token.match(kStringRegEx);
+ const isBoolean = (token) => token && token.match(kBooleanRegEx);
+ const isSuffix = (token) => token && token.match(kSuffixRegEx);
+ const isIdentifier = (token) => token && token.match(kIdentifierRegEx);
+
+ const tokens = text.match(kTokenRegEx)
+ .map((s) => s.trim())
+ .filter((s) => s.length && !isComment(s));
+ // console.log('tokens: ', tokens);
+
+ // ------------------------------------------------------------
+ // Parser
+
+ // Recursive descent parser; see README.md for grammar
+ // `lines` is populated with AST
+
+ const lines = [];
+ while (tokens.length) {
+ lines.push(parseLine());
+ }
+
+ function peek(n) {
+ n = n || 0;
+ return tokens[n];
+ }
+ function take() {
+ return tokens.shift();
+ }
+ function expect(expected) {
+ const token = take();
+ if (token !== expected) {
+ throw new ParseError(`Expected '${expected}', saw '${token}'`);
+ }
+ }
+ function parseLine() {
+ if (isIdentifier(peek()) && peek(1) === '=') {
+ const identifier = take();
+ take();
+ return {type: 'assignment', identifier, expr: parseExpr()};
+ }
+
+ return {type: 'expression', expr: parseExpr()};
+ }
+ function parseExpr() {
+ return parseRelExpr();
+ }
+ function parseRelExpr() {
+ let lhs = parseAddExpr();
+ while (Object.keys(kRelationalOperators).includes(peek())) {
+ const op = take();
+ const rhs = parseAddExpr();
+ lhs = {lhs, op, rhs};
+ }
+ return lhs;
+ }
+ function parseAddExpr() {
+ let lhs = parseMulExpr();
+ while (Object.keys(kAdditiveOperators).includes(peek())) {
+ const op = take();
+ const rhs = parseMulExpr();
+ lhs = {lhs, op, rhs};
+ }
+ return lhs;
+ }
+ function parseMulExpr() {
+ let lhs = parsePowExpr();
+ while (Object.keys(kMultiplicativeOperators).includes(peek())) {
+ const op = take();
+ const rhs = parsePowExpr();
+ lhs = {lhs, op, rhs};
+ }
+ return lhs;
+ }
+ function parsePowExpr() {
+ let lhs = parseUnaryExpr();
+ while (Object.keys(kPowerOperators).includes(peek())) {
+ const op = take();
+ const rhs = parseUnaryExpr();
+ lhs = {lhs, op, rhs};
+ }
+ return lhs;
+ }
+ function parseUnaryExpr() {
+ if (Object.keys(kUnaryOperators).includes(peek())) {
+ const op = take();
+ const rhs = parseUnaryExpr();
+ return {op, rhs};
+ }
+ return parseFinalExpr();
+ }
+ function parseFinalExpr() {
+ const token = take();
+ if (isNumber(token)) {
+ let dataType = kDefaultDataType;
+ if (isSuffix(peek())) {
+ dataType = suffixToDataType(take());
+ }
+ return {type: 'number', value: Number(token), dataType};
+ }
+ if (isString(token)) {
+ return {type: 'string', value: eval(token)};
+ }
+ if (isBoolean(token)) {
+ return {type: 'boolean', value: token === 'true'};
+ }
+ if (token === '[') {
+ const value = parseArray();
+ let dataType = kDefaultDataType;
+ if (isSuffix(peek())) {
+ dataType = suffixToDataType(take());
+ }
+ return {type: 'array', value, dataType};
+ }
+ if (token === '{') {
+ const dict = parseDict();
+ return {type: 'dict', dict};
+ }
+ if (isIdentifier(token)) {
+ if (peek() !== '(') {
+ return {type: 'identifier', value: token};
+ }
+ take();
+ const args = [];
+ if (peek() !== ')') {
+ args.push(parseExpr());
+ while (peek() === ',') {
+ take();
+ args.push(parseExpr());
+ }
+ }
+ expect(')');
+ return {type: 'call', identifier: token, args};
+ }
+ if (token === '(') {
+ const expr = parseExpr();
+ expect(')');
+ return expr;
+ }
+ throw new ParseError(`Expected expression, saw '${token}'`);
+ }
+ function parseArray() {
+ const array = [];
+ if (peek() !== ']') {
+ const expr = parseExpr();
+ array.push(expr);
+ while (peek() === ',') {
+ take();
+ const expr = parseExpr();
+ array.push(expr);
+ }
+ }
+ expect(']');
+ return array;
+ }
+ function parseDict() {
+ const dict = {};
+ if (isIdentifier(peek()) || isString(peek())) {
+ const [key, value] = parsePropDef();
+ dict[key] = value;
+ while (peek() === ',') {
+ take();
+ if (peek() === '}') {
+ break;
+ }
+ if (!(isIdentifier(peek()) || isString(peek()))) {
+ throw new ParseError(`Expected identifier, saw '${peek()}'`);
+ }
+ const [key, value] = parsePropDef();
+ dict[key] = value;
+ }
+ }
+ expect('}');
+ return dict;
+
+ function parsePropDef() {
+ let key = take();
+ if (isString(key)) {
+ key = eval(key);
+ }
+ expect(':');
+ const expr = parseExpr();
+ return [key, expr];
+ }
+ }
+
+ // ------------------------------------------------------------
+ // Serializer
+
+ // Generates WebNN code as the body of a function. `_` is passed as the
+ // `MLGraphBuilder`. The output of the last expression is returned.
+
+ const src = lines
+ .map(
+ (line, index) =>
+ serializeLine(line, index === lines.length - 1))
+ .map((line) => line + ';\n')
+ .join('');
+
+ const AsyncFunction = async function() {}.constructor;
+ return [new AsyncFunction(['_'], src), src];
+
+ function serializeLine(line, last) {
+ const expr = serializeExpr(line.expr);
+ switch (line.type) {
+ case 'assignment':
+ return last ? `return ${expr}` : `const ${line.identifier} = ${expr}`;
+ case 'expression':
+ return last ? `return ${expr}` : expr;
+ }
+ throw new Error(`unexpected line type: ${line.type}`);
+ }
+ function serializeExpr(expr, nonOperand = false) {
+ if (expr.op) {
+ if (expr.lhs) {
+ return `_.${kBinaryOperators[expr.op]}(${serializeExpr(expr.lhs)}, ${
+ serializeExpr(expr.rhs)})`;
+ } else {
+ return `_.${kUnaryOperators[expr.op]}(${serializeExpr(expr.rhs)})`;
+ }
+ }
+ switch (expr.type) {
+ case 'string':
+ return Util.stringify(expr.value);
+ case 'boolean':
+ return String(expr.value);
+ case 'number':
+ return nonOperand ? Util.stringify(expr.value) :
+ serializeScalar(expr.value, expr.dataType);
+ case 'array':
+ return nonOperand ? serializeArray(expr.value) :
+ serializeTensor(expr.value, expr.dataType);
+ case 'dict':
+ return serializeDict(expr.dict);
+ case 'identifier':
+ return expr.value;
+ case 'call':
+ return serializeCall(expr.identifier, expr.args);
+ }
+ throw new Error(`unexpected expr type: ${expr.type}`);
+ }
+ function serializeDict(dict) {
+ return '{' +
+ Object.keys(dict)
+ .map((k) => {
+ const v = dict[k];
+ k = Util.stringify(k);
+ return `${k}: ${serializeExpr(v, true)}`;
+ })
+ .join(', ') +
+ '}';
+ }
+
+ function serializeScalar(number, dataType) {
+ const ctor = WebNNUtil.dataTypeToBufferType(dataType);
+ return `_.constant({dataType:"${dataType}"}, new ${ctor.name}([${
+ Util.stringifyNumber(number, dataType)}]))`;
+ }
+ function suffixToDataType(suffix) {
+ return {
+ 'i8': 'int8',
+ 'u8': 'uint8',
+ 'i32': 'int32',
+ 'u32': 'uint32',
+ 'i64': 'int64',
+ 'u64': 'uint64',
+ 'f16': 'float16',
+ 'f32': 'float32',
+ }[suffix];
+ }
+
+ function serializeTensor(tensor, dataType) {
+ const dimensions = [];
+ const elements = [];
+ (function measure(t, d) {
+ if (d >= dimensions.length) {
+ dimensions[d] = t.length;
+ } else if (dimensions[d] !== t.length) {
+ throw new Error('Invalid tensor: inconsistent dimensions');
+ }
+ t.forEach((e) => {
+ if (e.type === 'array') {
+ measure(e.value, d + 1);
+ } else if (e.type !== 'number') {
+ throw new Error(`Invalid tensor: saw ${e.type}`);
+ } else if (d + 1 !== dimensions.length) {
+ throw new Error('Invalid tensor: saw scalar');
+ } else {
+ elements.push(e.value);
+ }
+ });
+ }(tensor, 0));
+ const ctor = WebNNUtil.dataTypeToBufferType(dataType);
+ return `_.constant({dataType: "${dataType}", dimensions: ${
+ Util.stringify(dimensions)}}, new ${ctor.name}([${
+ elements.map((n) => Util.stringifyNumber(n, dataType)).join(',')}]))`;
+ }
+
+ function serializeArray(array) {
+ return '[' + array.map((expr) => serializeExpr(expr)).join(', ') + ']';
+ }
+
+ function serializeCall(name, args) {
+ if (name === 'load') {
+ const [url, shape, dataType] = args;
+ if (url.type !== 'string') {
+ throw new TypeError('load(): expected string');
+ }
+ if (shape.type !== 'tensor') {
+ throw new TypeError('load(): expected array');
+ }
+ if (dataType.type !== 'string') {
+ throw new TypeError('load(): expected string');
+ }
+ const ctor = WebNNUtil.dataTypeToBufferType(dataType.value);
+ return `_.constant({dataType: "${dataType.value}", dimensions: ${
+ Util.stringify(shape.value)}}, new ${
+ ctor.name}(await Util.loadBuffer(${Util.stringify(url.value)})))`;
+ }
+
+ return `_.${name}(${
+ args.map(
+ (arg, index) => serializeExpr(
+ arg, WebNNUtil.isNonOperandArg(name, index)))
+ .join(', ')})`;
+ }
+ }
+
+ // ============================================================
+ // Script Executor
+ // ============================================================
+
+ // Call with the output of `makeBuilderFunc()`. Builds an MLContext and
+ // MLGraphBuilder, executes the function to make an MLGraph, then runs
+ // compute() on it. The output is mapped.
+
+ static async execBuilderFunction(deviceType, builderFunc) {
+ const context = await navigator.ml.createContext({deviceType});
+ const builder = new self.MLGraphBuilder(context);
+
+ const outputOperands = [];
+ let output = await builderFunc(builder);
+ if (output instanceof self.MLOperand) {
+ // TODO: remove try/catch once all back-ends support `identity()`.
+ try {
+ // In case `output` is a constant.
+ output = builder.identity(output);
+ } catch (ex) {
+ // Just live with it for now.
+ }
+ outputOperands.push(output);
+ } else if (Array.isArray(output)) {
+ outputOperands.push(...output);
+ // no-op
+ } else {
+ throw new ParseError(`Non-MLOperand output: ${output}`);
+ }
+
+ const namedOutputs = {};
+ const outputBuffers = {};
+ outputOperands.forEach((op, index) => {
+ const name = `output-${index}`;
+ namedOutputs[name] = op;
+ outputBuffers[name] = WebNNUtil.bufferForOperand(op);
+ });
+
+ let graph;
+ try {
+ graph = await builder.build(namedOutputs);
+ } catch (ex) {
+ console.warn(ex);
+ throw new BuildError(`${ex.name} : ${ex.message}`);
+ }
+ const inputBuffers = {};
+
+ let result;
+ try {
+ result = await context.compute(graph, inputBuffers, outputBuffers);
+ } catch (ex) {
+ console.warn(ex);
+ throw new ComputeError(`${ex.name} : ${ex.message}`);
+ }
+
+ function maybeProxyForFloat16Array(array) {
+ return ('proxyForFloat16Array' in self) ?
+ self.proxyForFloat16Array(array) :
+ array;
+ }
+
+ // window.result = result;
+ // console.log(result);
+ return outputOperands.map(
+ (op, index) => ({
+ dataType: op.dataType(),
+ shape: op.shape(),
+ buffer: maybeProxyForFloat16Array(result.outputs[`output-${index}`]),
+ }));
+ }
+}
diff --git a/nnotepad/js/testharness.js b/nnotepad/js/testharness.js
new file mode 100644
index 00000000..994a747f
--- /dev/null
+++ b/nnotepad/js/testharness.js
@@ -0,0 +1,46 @@
+// ============================================================
+// Test Harness
+// ============================================================
+
+// Harness.section(description) - start a section (required)
+// Harness.ok(message) - add a success to current section
+// Harness.error(message) - add a failure to current section
+
+export class Harness {
+ static section(s) {
+ Harness.current = {
+ details: document.createElement('details'),
+ summary: Object.assign(document.createElement('summary'), {innerText: s}),
+ counts:
+ Object.assign(document.createElement('div'), {className: 'counts'}),
+ pass: 0,
+ fail: 0,
+ };
+ Harness.current.summary.append(Harness.current.counts);
+ Harness.current.details.append(Harness.current.summary);
+ document.body.append(Harness.current.details);
+ }
+
+ static updateCounts() {
+ Harness.current.counts.innerText =
+ `pass: ${Harness.current.pass} / fail: ${Harness.current.fail}`;
+ }
+
+ static log(s, options) {
+ Harness.current.details.append(
+ Object.assign(document.createElement('div'), options, {innerText: s}));
+ }
+
+ static ok(s) {
+ Harness.log(s, {});
+ Harness.current.pass += 1;
+ Harness.updateCounts();
+ }
+
+ static error(s) {
+ Harness.log(s, {className: 'failure'});
+ Harness.current.fail += 1;
+ Harness.updateCounts();
+ Harness.current.details.open = true;
+ }
+}
diff --git a/nnotepad/js/tests.js b/nnotepad/js/tests.js
new file mode 100644
index 00000000..8b07a9d7
--- /dev/null
+++ b/nnotepad/js/tests.js
@@ -0,0 +1,154 @@
+import {Harness} from './testharness.js';
+import {NNotepad} from './nnotepad.js';
+
+// ============================================================
+// Helper for NNotepad-specific tests
+// ============================================================
+
+async function test(expr, expected) {
+ function assert(message, actual, expected) {
+ if (Array.isArray(expected)) {
+ if (!Object.is(actual.length, expected.length)) {
+ throw new Error(`${message} length, expected: ${
+ expected.length}, actual: ${actual.length}`);
+ }
+ for (let i = 0; i < expected.length; ++i) {
+ if (!Object.is(actual[i], expected[i])) {
+ throw new Error(`${message}[${i}], expected: ${
+ expected[i]}, actual: ${actual[i]}`);
+ }
+ }
+ } else if (!Object.is(actual, expected)) {
+ throw new Error(`${message}, expected: ${expected}, actual: ${actual}`);
+ }
+ }
+
+ try {
+ const [builderFunc] = NNotepad.makeBuilderFunction(expr);
+ const result = await NNotepad.execBuilderFunction('cpu', builderFunc);
+ if (!Array.isArray(expected)) {
+ assert('single tensor', result.length, 1);
+ assert('dataType', result[0].dataType, expected.dataType);
+ assert('shape', result[0].shape, expected.shape);
+ assert('buffer', [...result[0].buffer], expected.buffer);
+ } else {
+ assert('number of outputs', result.length, expected.length);
+ for (let i = 0; i < expected.length; ++i) {
+ assert('dataType', result[i].dataType, expected[i].dataType);
+ assert('shape', result[i].shape, expected[i].shape);
+ assert('buffer', [...result[i].buffer], expected[i].buffer);
+ }
+ }
+ Harness.ok(`ok: ${expr}`);
+ } catch (ex) {
+ Harness.error(`failed: ${expr} - ${ex.message}`);
+ }
+}
+
+// ============================================================
+// Test Cases
+// ============================================================
+
+document.addEventListener('DOMContentLoaded', async (e) => {
+ Harness.section('Operators');
+ await test('1 + 2', {dataType: 'float32', shape: [], buffer: [3]});
+ await test('2 * 3', {dataType: 'float32', shape: [], buffer: [6]});
+ await test('3 / 2', {dataType: 'float32', shape: [], buffer: [1.5]});
+ await test('2 ^ 3', {dataType: 'float32', shape: [], buffer: [8]});
+ await test('-(1)', {dataType: 'float32', shape: [], buffer: [-1]});
+ await test('--(1)', {dataType: 'float32', shape: [], buffer: [1]});
+
+ await test('1 < 2', {dataType: 'uint8', shape: [], buffer: [1]});
+ await test('2 < 1', {dataType: 'uint8', shape: [], buffer: [0]});
+ await test('1 < 1', {dataType: 'uint8', shape: [], buffer: [0]});
+
+ await test('1 <= 2', {dataType: 'uint8', shape: [], buffer: [1]});
+ await test('2 <= 1', {dataType: 'uint8', shape: [], buffer: [0]});
+ await test('1 <= 1', {dataType: 'uint8', shape: [], buffer: [1]});
+
+ await test('1 > 2', {dataType: 'uint8', shape: [], buffer: [0]});
+ await test('2 > 1', {dataType: 'uint8', shape: [], buffer: [1]});
+ await test('1 > 1', {dataType: 'uint8', shape: [], buffer: [0]});
+
+ await test('1 >= 2', {dataType: 'uint8', shape: [], buffer: [0]});
+ await test('2 >= 1', {dataType: 'uint8', shape: [], buffer: [1]});
+ await test('1 >= 1', {dataType: 'uint8', shape: [], buffer: [1]});
+
+ await test('1 == 2', {dataType: 'uint8', shape: [], buffer: [0]});
+ await test('2 == 0', {dataType: 'uint8', shape: [], buffer: [0]});
+ await test('1 == 1', {dataType: 'uint8', shape: [], buffer: [1]});
+
+ await test('!1u8', {dataType: 'uint8', shape: [], buffer: [0]});
+ await test('!0u8', {dataType: 'uint8', shape: [], buffer: [1]});
+ await test('!!1u8', {dataType: 'uint8', shape: [], buffer: [1]});
+ await test('!!0u8', {dataType: 'uint8', shape: [], buffer: [0]});
+
+ Harness.section('Scalar type suffixes');
+ await test('-123i8', {dataType: 'int8', shape: [], buffer: [-123]});
+ await test('123u8', {dataType: 'uint8', shape: [], buffer: [123]});
+ await test('-123i32', {dataType: 'int32', shape: [], buffer: [-123]});
+ await test('123u32', {dataType: 'uint32', shape: [], buffer: [123]});
+ await test('-123i64', {dataType: 'int64', shape: [], buffer: [-123n]});
+ await test('123u64', {dataType: 'uint64', shape: [], buffer: [123n]});
+ await test(
+ '12.34f32',
+ {dataType: 'float32', shape: [], buffer: [Math.fround(12.34)]});
+ await test('12.34f16', {dataType: 'float16', shape: [], buffer: [12.34375]});
+
+ Harness.section('Tensor type suffixes');
+ await test('[-123]i8', {dataType: 'int8', shape: [1], buffer: [-123]});
+ await test('[123]u8', {dataType: 'uint8', shape: [1], buffer: [123]});
+ await test('[-123]i32', {dataType: 'int32', shape: [1], buffer: [-123]});
+ await test('[123]u32', {dataType: 'uint32', shape: [1], buffer: [123]});
+ await test('[-123]i64', {dataType: 'int64', shape: [1], buffer: [-123n]});
+ await test('[123]u64', {dataType: 'uint64', shape: [1], buffer: [123n]});
+ await test(
+ '[12.34]f32',
+ {dataType: 'float32', shape: [1], buffer: [Math.fround(12.34)]});
+ await test(
+ '[12.34]f16', {dataType: 'float16', shape: [1], buffer: [12.34375]});
+
+ Harness.section('Tensors');
+ await test(
+ `A = [[1,7],[2,4]] B = [[3,3],[5,2]] matmul(A,B)`,
+ {dataType: 'float32', shape: [2, 2], buffer: [38, 17, 26, 14]});
+ await test(
+ `M = [[2,8,3],[5,4,1]] N = [[4,1],[6,3],[2,4]] matmul(M,N)`,
+ {dataType: 'float32', shape: [2, 2], buffer: [62, 38, 46, 21]});
+
+ Harness.section('Dictionaries');
+ await test('linear(10, {})', {dataType: 'float32', shape: [], buffer: [10]});
+ await test(
+ 'linear(10, {alpha: 2, beta: 3})',
+ {dataType: 'float32', shape: [], buffer: [23]});
+
+ Harness.section('String arguments');
+ await test(
+ `cast([1,2,3], 'int8')`,
+ {dataType: 'int8', shape: [3], buffer: [1, 2, 3]});
+ await test(
+ `cast([1,2,3], "int8")`,
+ {dataType: 'int8', shape: [3], buffer: [1, 2, 3]});
+
+ Harness.section('Multiple output tensors');
+ await test(`split([1,2,3,4], 2)`, [
+ {dataType: 'float32', shape: [2], buffer: [1, 2]},
+ {dataType: 'float32', shape: [2], buffer: [3, 4]},
+ ]);
+
+ Harness.section('Multiple input tensors');
+ await test(
+ `A = [1,2] B = [3,4] concat([A,B], 0)`,
+ {dataType: 'float32', shape: [4], buffer: [1, 2, 3, 4]});
+ await test(
+ `concat([identity([1,2]),identity([3,4])], 0)`,
+ {dataType: 'float32', shape: [4], buffer: [1, 2, 3, 4]});
+
+ Harness.section('Regression tests');
+ await test(
+ `concat([[1,2],[3,4]], 0)`,
+ {dataType: 'float32', shape: [4], buffer: [1, 2, 3, 4]});
+ // await test(`input = [[[1,2],[3,4]],[[5,6],[7,8]]] weight =
+ // [[[1,2],[1,2],[1,2],[1,2]]] rweight = [[[1],[1],[1],[1]]] lstm(input,
+ // weight, rweight, 2, 1)`, {});
+});
diff --git a/nnotepad/js/util.js b/nnotepad/js/util.js
new file mode 100644
index 00000000..eed174a6
--- /dev/null
+++ b/nnotepad/js/util.js
@@ -0,0 +1,52 @@
+export class Util {
+ static debounce(func, delay) {
+ let timeoutId = 0;
+ return function(...args) {
+ if (timeoutId) {
+ clearTimeout(timeoutId);
+ }
+ timeoutId = setTimeout(() => {
+ func.apply(this, ...args); // eslint-disable-line no-invalid-this
+ timeoutId = 0;
+ }, delay);
+ };
+ }
+
+ // Like JSON.stringify(), but handles BigInts, NaN, +/-Infinity, and -0
+ static stringify(value) {
+ const json = JSON.stringify(value, (k, v) => {
+ if (typeof v === 'bigint') {
+ return 'ℝ:' + String(v) + 'n';
+ }
+ if (Object.is(v, NaN)) {
+ return 'ℝ:NaN';
+ }
+ if (Object.is(v, Infinity)) {
+ return 'ℝ:Infinity';
+ }
+ if (Object.is(v, -Infinity)) {
+ return 'ℝ:-Infinity';
+ }
+ if (Object.is(v, -0)) {
+ return 'ℝ:-0';
+ }
+ return v;
+ });
+ return json.replaceAll(/"ℝ:(.*?)"/g, '$1');
+ }
+
+ static stringifyNumber(value, dataType) {
+ if (dataType === 'int64' || dataType === 'uint64') {
+ return String(value) + 'n';
+ }
+ return String(value);
+ }
+
+ static async loadBuffer(url) {
+ const request = await fetch(url);
+ if (!request.ok) {
+ throw new Error(`load failed ${request.statusText}`);
+ }
+ return await request.arrayBuffer();
+ }
+}
diff --git a/nnotepad/res/default.txt b/nnotepad/res/default.txt
new file mode 100644
index 00000000..a019613c
--- /dev/null
+++ b/nnotepad/res/default.txt
@@ -0,0 +1,41 @@
+# Scalars
+
+a = 123
+b = 456
+c = a * b
+inf = Infinity
+nan = NaN
+
+# Tensors
+
+A = [[1,7],[2,4]]
+B = [[3,3],[5,2]]
+C = matmul(A,B)
+# Expect: [[38,17],[26,14]]
+
+# T = [[1,2],3] # invalid
+
+M = [[2,8,3],[5,4,1]]
+N = [[4,1],[6,3],[2,4]]
+P = matmul(M,N)
+# Expect: [[62,38],[46,21]]
+
+# Explicit Data Types (float32 is default)
+
+scalar_int8 = -123i8
+Tensor_uint8 = [1,2,3]u8
+scalar_int32 = -123i32
+Tensor_uint32 = [1,2,3]u32
+scalar_float32 = 1.23e38f32
+
+# Dictionaries
+
+linear(10, {alpha: 2, beta: 3})
+linear(10, {})
+
+# Result of last expression is returned
+
+R = 11 + P * 2
+cast(R > 100, "float32") * R
+
+split([1,2,3,4], 2)
diff --git a/nnotepad/res/docs.html b/nnotepad/res/docs.html
new file mode 100644
index 00000000..311f14e5
--- /dev/null
+++ b/nnotepad/res/docs.html
@@ -0,0 +1,128 @@
+
+
+NNotepad
+
+
+
+
What is this?
+
NNotepad is a browser-based playground for experimenting with WebNN expressions without boilerplate code. As of mid-2024, WebNN is available as a prototype in Chromium-based browsers, but requires launching the browser with particular flags enabled.
+
Usage
+
Type assignments like foo = 1 + 2 or expressions like 2 * foo. The result of the last assignment or expression is shown. Some examples:
NNotepad translates what you type into script that builds a WebNN graph, evaluates the script, then executes the graph. Click 🔎 to see the generated script.
+
Expressions can use:
+
+
Operators +, -, *, /, ^, ==, <, <=, >, >=, ! with precedence, and (,) for grouping.
+
Function calls like add(), matmul(), sigmoid(), and so on.
+
Numbers like -12.34.
+
Tensors like [[1,2],[3,4]].
+
Dictionaries like {alpha: 2, beta: 3}, arrays like [ A, B ], strings like "float32", and booleans true and false.
+
+
Functions and operators are turned into MLGraphBuilder method calls.
+
Array literals ([...]) and number literals (12.34) are interpreted contextually:
+
+
In assignments, they are intepreted as tensor/scalar constant MLOperands, e.g. alpha = 12.34 or T = [1,2,3,4].
+
In most function calls, they are interpreted as tensor/scalar constant MLOperands, e.g. neg(123) or neg([1,2,3]).
+
In some function calls, they are interpreted as arrays/numbers for some positional parameters, e.g. concat([A,B,C],0). This includes: concat(), expand(), pad(), reshape(), slice(), split().
+
In dictionaries, they are interpreted as arrays/numbers, e.g. linear(123, {alpha: 456, beta: 789}) or transpose(T, {permutation: [0,2,1]}). To pass a tensor/scalar constant in a dictionary, use a variable or wrap it in identity() e.g. gemm(A, B, {c:identity([4])}) or gemm(A, B, {c:identity(4)}).
+
+
The default data type for scalars and tensors is float32. To specify a different data type, suffix with one of i8, u8, i32, u32, i64, u64, f16, f32, e.g. 123i8 or [1,2,3]u32.
+
Helpers
+
In addition to WebNN MLGraphBuilder methods, you can use these helpers:
+
+
load(url, shape, dataType) - fetch a tensor resource. Must be served with appropriate CORS headers. Example: load('https://www.random.org/cgi-bin/randbyte?nbytes=256', [16, 16], 'uint8')
+
+
Details & Gotchas
+
+
float16 support (and the f16 suffix) is experimental.
+
Whitespace including line breaks is ignored.
+
Parsing around the "unary minus" operator can be surprising. Wrap expressions e.g. (-a) if you get unexpected errors.
+
If output is a constant, it will be wrapped with identity() if your back-end supports it. Otherwise, you must introduce a supported expression.
+
+
What ops are supported, and with what data types, depends entirely on your browser's WebNN implementation. Here be dragons!