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 00000000..0aae33f6 Binary files /dev/null and b/nnotepad/res/webml.png differ 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 + + + + + + +