From a705655551479e669d93e6bbfc277c240483e4ac Mon Sep 17 00:00:00 2001 From: huningxin Date: Thu, 6 Jun 2024 07:07:12 +0000 Subject: [PATCH] =?UTF-8?q?Deploying=20to=20gh-pages=20from=20@=20webmachi?= =?UTF-8?q?nelearning/webnn-samples@4a62cc3f1c8767c2837a2ff152c7c1887d9a13?= =?UTF-8?q?ad=20=F0=9F=9A=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nnotepad/.eslintrc.js | 4 + nnotepad/Makefile | 9 + nnotepad/README.md | 106 ++++++ nnotepad/TODO.md | 17 + nnotepad/bin/makedocs | 53 +++ nnotepad/index.html | 128 +++++++ nnotepad/js/float16arraypolyfill.js | 283 ++++++++++++++ nnotepad/js/index.js | 111 ++++++ nnotepad/js/nnotepad.js | 569 ++++++++++++++++++++++++++++ nnotepad/js/testharness.js | 46 +++ nnotepad/js/tests.js | 154 ++++++++ nnotepad/js/util.js | 52 +++ nnotepad/res/default.txt | 41 ++ nnotepad/res/docs.html | 128 +++++++ nnotepad/res/manifest.json | 15 + nnotepad/res/webml.png | Bin 0 -> 14775 bytes nnotepad/tests.html | 35 ++ 17 files changed, 1751 insertions(+) create mode 100644 nnotepad/.eslintrc.js create mode 100644 nnotepad/Makefile create mode 100644 nnotepad/README.md create mode 100644 nnotepad/TODO.md create mode 100755 nnotepad/bin/makedocs create mode 100644 nnotepad/index.html create mode 100644 nnotepad/js/float16arraypolyfill.js create mode 100644 nnotepad/js/index.js create mode 100644 nnotepad/js/nnotepad.js create mode 100644 nnotepad/js/testharness.js create mode 100644 nnotepad/js/tests.js create mode 100644 nnotepad/js/util.js create mode 100644 nnotepad/res/default.txt create mode 100644 nnotepad/res/docs.html create mode 100644 nnotepad/res/manifest.json create mode 100644 nnotepad/res/webml.png create mode 100644 nnotepad/tests.html 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(''' + +NNotepad + + + +'''); + output_file.write(html) + diff --git a/nnotepad/index.html b/nnotepad/index.html new file mode 100644 index 00000000..f2e42db9 --- /dev/null +++ b/nnotepad/index.html @@ -0,0 +1,128 @@ + + +NNotepad + + + + + + + + + + + +
+Results will show here
+
+ +
+ NNotepad +
+ +
+
+
+ +
+ + + An MLGraphBuilder is passed as _ +

+  
+
+ + +
+ +
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:

+
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:

+ +

Functions and operators are turned into MLGraphBuilder method calls.

+

Array literals ([...]) and number literals (12.34) are interpreted contextually:

+ +

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:

+ +

Details & Gotchas

+ +

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
+
\ No newline at end of file diff --git a/nnotepad/res/manifest.json b/nnotepad/res/manifest.json new file mode 100644 index 00000000..83babe36 --- /dev/null +++ b/nnotepad/res/manifest.json @@ -0,0 +1,15 @@ +{ + "name": "NNotepad", + "short_name": "NNotepad", + "start_url": "..", + "display": "standalone", + "background-color": "#fff", + "icons": [ + { + "src": "webml.png", + "sizes": "200x200", + "type": "image/png" + } + ], + "description": "A playground for WebNN expressions." +} diff --git a/nnotepad/res/webml.png b/nnotepad/res/webml.png new file mode 100644 index 0000000000000000000000000000000000000000..0aae33f633e0497543ecb22bebf4fc1c0afecb07 GIT binary patch literal 14775 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 literal 0 HcmV?d00001 diff --git a/nnotepad/tests.html b/nnotepad/tests.html new file mode 100644 index 00000000..a7607d23 --- /dev/null +++ b/nnotepad/tests.html @@ -0,0 +1,35 @@ + + +NNotepad tests + + + + + + +