| import { h, Component, render } from 'https://unpkg.com/preact?module'; | |
| import htm from 'https://unpkg.com/htm?module'; | |
| const html = htm.bind(h); | |
| const BURNED_IN_MODEL_INFO = null; | |
| // https://stackoverflow.com/a/20732091 | |
| function humanFileSize(size) { | |
| if (size == 0) { return "0 B"; } | |
| var i = Math.floor( Math.log(size) / Math.log(1024) ); | |
| return (size / Math.pow(1024, i)).toFixed(2) * 1 + ' ' + ['B', 'kB', 'MB', 'GB', 'TB'][i]; | |
| } | |
| function caret(down) { | |
| return down ? "\u25BE" : "\u25B8"; | |
| } | |
| class Blamer { | |
| constructor() { | |
| this.blame_on_click = false; | |
| this.aux_content_pane = null; | |
| } | |
| setAuxContentPane(pane) { | |
| this.aux_content_pane = pane; | |
| } | |
| readyBlame() { | |
| this.blame_on_click = true; | |
| } | |
| maybeBlame(arg) { | |
| if (!this.blame_on_click) { | |
| return; | |
| } | |
| this.blame_on_click = false; | |
| if (!this.aux_content_pane) { | |
| return; | |
| } | |
| this.aux_content_pane.doBlame(arg); | |
| } | |
| } | |
| let blame = new Blamer(); | |
| class Hider extends Component { | |
| constructor() { | |
| super(); | |
| this.state = { shown: null }; | |
| } | |
| componentDidMount() { | |
| this.setState({ shown: this.props.shown === "true" }); | |
| } | |
| render({name, children}, {shown}) { | |
| let my_caret = html`<span class=caret onClick=${() => this.click()} >${caret(shown)}</span>`; | |
| return html`<div data-hider-title=${name} data-shown=${shown}> | |
| <h2>${my_caret} ${name}</h2> | |
| <div>${shown ? this.props.children : []}</div></div>`; | |
| } | |
| click() { | |
| this.setState({shown: !this.state.shown}); | |
| } | |
| } | |
| function ModelSizeSection({model: {file_size, zip_files}}) { | |
| let store_size = 0; | |
| let compr_size = 0; | |
| for (const zi of zip_files) { | |
| if (zi.compression === 0) { | |
| // TODO: Maybe check that compressed_size === file_size. | |
| store_size += zi.compressed_size; | |
| } else { | |
| compr_size += zi.compressed_size; | |
| } | |
| } | |
| let zip_overhead = file_size - store_size - compr_size; | |
| // TODO: Better formatting. Right-align this. | |
| return html` | |
| <${Hider} name="Model Size" shown=true> | |
| <pre>. | |
| Model size: ${file_size} (${humanFileSize(file_size)}) | |
| Stored files: ${store_size} (${humanFileSize(store_size)}) | |
| Compressed files: ${compr_size} (${humanFileSize(compr_size)}) | |
| Zip overhead: ${zip_overhead} (${humanFileSize(zip_overhead)}) | |
| </pre><//>`; | |
| } | |
| function StructuredDataSection({name, data, shown}) { | |
| return html` | |
| <${Hider} name=${name} shown=${shown}> | |
| <div style="font-family:monospace;"> | |
| <${StructuredData} data=${data} indent="" prefix=""/> | |
| </div><//>`; | |
| } | |
| class StructuredData extends Component { | |
| constructor() { | |
| super(); | |
| this.state = { shown: false }; | |
| this.INLINE_TYPES = new Set(["boolean", "number", "string"]) | |
| this.IGNORED_STATE_KEYS = new Set(["training", "_is_full_backward_hook"]) | |
| } | |
| click() { | |
| this.setState({shown: !this.state.shown}); | |
| } | |
| expando(data) { | |
| if (data === null || this.INLINE_TYPES.has(typeof(data))) { | |
| return false; | |
| } | |
| if (typeof(data) != "object") { | |
| throw new Error("Not an object"); | |
| } | |
| if (Array.isArray(data)) { | |
| // TODO: Maybe show simple lists and tuples on one line. | |
| return true; | |
| } | |
| if (data.__tuple_values__) { | |
| // TODO: Maybe show simple lists and tuples on one line. | |
| return true; | |
| } | |
| if (data.__is_dict__) { | |
| // TODO: Maybe show simple (empty?) dicts on one line. | |
| return true; | |
| } | |
| if (data.__module_type__) { | |
| return true; | |
| } | |
| if (data.__tensor_v2__) { | |
| return false; | |
| } | |
| if (data.__qtensor__) { | |
| return false; | |
| } | |
| throw new Error("Can't handle data type.", data); | |
| } | |
| renderHeadline(data) { | |
| if (data === null) { | |
| return "None"; | |
| } | |
| if (typeof(data) == "boolean") { | |
| const sd = String(data); | |
| return sd.charAt(0).toUpperCase() + sd.slice(1); | |
| } | |
| if (typeof(data) == "number") { | |
| return JSON.stringify(data); | |
| } | |
| if (typeof(data) == "string") { | |
| return JSON.stringify(data); | |
| } | |
| if (typeof(data) != "object") { | |
| throw new Error("Not an object"); | |
| } | |
| if (Array.isArray(data)) { | |
| return "list(["; | |
| } | |
| if (data.__tuple_values__) { | |
| return "tuple(("; | |
| } | |
| if (data.__is_dict__) { | |
| return "dict({"; | |
| } | |
| if (data.__module_type__) { | |
| return data.__module_type__ + "()"; | |
| } | |
| if (data.__tensor_v2__) { | |
| const [storage, offset, size, stride, grad] = data.__tensor_v2__; | |
| const [dtype, key, device, numel] = storage; | |
| return this.renderTensor( | |
| "tensor", dtype, key, device, numel, offset, size, stride, grad, []); | |
| } | |
| if (data.__qtensor__) { | |
| const [storage, offset, size, stride, quantizer, grad] = data.__qtensor__; | |
| const [dtype, key, device, numel] = storage; | |
| let extra_parts = []; | |
| if (quantizer[0] == "per_tensor_affine") { | |
| extra_parts.push(`scale=${quantizer[1]}`); | |
| extra_parts.push(`zero_point=${quantizer[2]}`); | |
| } else { | |
| extra_parts.push(`quantizer=${quantizer[0]}`); | |
| } | |
| return this.renderTensor( | |
| "qtensor", dtype, key, device, numel, offset, size, stride, grad, extra_parts); | |
| } | |
| throw new Error("Can't handle data type.", data); | |
| } | |
| renderTensor( | |
| prefix, | |
| dtype, | |
| storage_key, | |
| device, | |
| storage_numel, | |
| offset, | |
| size, | |
| stride, | |
| grad, | |
| extra_parts) { | |
| let parts = [ | |
| "(" + size.join(",") + ")", | |
| dtype, | |
| ]; | |
| parts.push(...extra_parts); | |
| if (device != "cpu") { | |
| parts.push(device); | |
| } | |
| if (grad) { | |
| parts.push("grad"); | |
| } | |
| // TODO: Check stride and indicate if the tensor is channels-last or non-contiguous | |
| // TODO: Check size, stride, offset, and numel and indicate if | |
| // the tensor doesn't use all data in storage. | |
| // TODO: Maybe show key? | |
| void(offset); | |
| void(stride); | |
| void(storage_key); | |
| void(storage_numel); | |
| return prefix + "(" + parts.join(", ") + ")"; | |
| } | |
| renderBody(indent, data) { | |
| if (data === null || this.INLINE_TYPES.has(typeof(data))) { | |
| throw "Should not reach here." | |
| } | |
| if (typeof(data) != "object") { | |
| throw new Error("Not an object"); | |
| } | |
| if (Array.isArray(data)) { | |
| let new_indent = indent + "\u00A0\u00A0"; | |
| let parts = []; | |
| for (let idx = 0; idx < data.length; idx++) { | |
| // Does it make sense to put explicit index numbers here? | |
| parts.push(html`<br/><${StructuredData} prefix=${idx + ": "} indent=${new_indent} data=${data[idx]} />`); | |
| } | |
| return parts; | |
| } | |
| if (data.__tuple_values__) { | |
| // Handled the same as lists. | |
| return this.renderBody(indent, data.__tuple_values__); | |
| } | |
| if (data.__is_dict__) { | |
| let new_indent = indent + "\u00A0\u00A0"; | |
| let parts = []; | |
| for (let idx = 0; idx < data.keys.length; idx++) { | |
| if (typeof(data.keys[idx]) != "string") { | |
| parts.push(html`<br/>${new_indent}Non-string key`); | |
| } else { | |
| parts.push(html`<br/><${StructuredData} prefix=${data.keys[idx] + ": "} indent=${new_indent} data=${data.values[idx]} />`); | |
| } | |
| } | |
| return parts; | |
| } | |
| if (data.__module_type__) { | |
| const mstate = data.state; | |
| if (mstate === null || typeof(mstate) != "object") { | |
| throw new Error("Bad module state"); | |
| } | |
| let new_indent = indent + "\u00A0\u00A0"; | |
| let parts = []; | |
| if (mstate.__is_dict__) { | |
| // TODO: Less copy/paste between this and normal dicts. | |
| for (let idx = 0; idx < mstate.keys.length; idx++) { | |
| if (typeof(mstate.keys[idx]) != "string") { | |
| parts.push(html`<br/>${new_indent}Non-string key`); | |
| } else if (this.IGNORED_STATE_KEYS.has(mstate.keys[idx])) { | |
| // Do nothing. | |
| } else { | |
| parts.push(html`<br/><${StructuredData} prefix=${mstate.keys[idx] + ": "} indent=${new_indent} data=${mstate.values[idx]} />`); | |
| } | |
| } | |
| } else if (mstate.__tuple_values__) { | |
| parts.push(html`<br/><${StructuredData} prefix="" indent=${new_indent} data=${mstate} />`); | |
| } else if (mstate.__module_type__) { | |
| // We normally wouldn't have the state of a module be another module, | |
| // but we use "modules" to encode special values (like Unicode decode | |
| // errors) that might be valid states. Just go with it. | |
| parts.push(html`<br/><${StructuredData} prefix="" indent=${new_indent} data=${mstate} />`); | |
| } else { | |
| throw new Error("Bad module state"); | |
| } | |
| return parts; | |
| } | |
| if (data.__tensor_v2__) { | |
| throw "Should not reach here." | |
| } | |
| if (data.__qtensor__) { | |
| throw "Should not reach here." | |
| } | |
| throw new Error("Can't handle data type.", data); | |
| } | |
| render({data, indent, prefix}, {shown}) { | |
| const exp = this.expando(data) ? html`<span class=caret onClick=${() => this.click()} >${caret(shown)} </span>` : ""; | |
| const headline = this.renderHeadline(data); | |
| const body = shown ? this.renderBody(indent, data) : ""; | |
| return html`${indent}${exp}${prefix}${headline}${body}`; | |
| } | |
| } | |
| function ZipContentsSection({model: {zip_files}}) { | |
| // TODO: Add human-readable sizes? | |
| // TODO: Add sorting options? | |
| // TODO: Add hierarchical collapsible tree? | |
| return html` | |
| <${Hider} name="Zip Contents" shown=false> | |
| <table> | |
| <thead> | |
| <tr> | |
| <th>Mode</th> | |
| <th>Size</th> | |
| <th>Compressed</th> | |
| <th>Name</th> | |
| </tr> | |
| </thead> | |
| <tbody style="font-family:monospace;"> | |
| ${zip_files.map(zf => html`<tr> | |
| <td>${{0: "store", 8: "deflate"}[zf.compression] || zf.compression}</td> | |
| <td>${zf.file_size}</td> | |
| <td>${zf.compressed_size}</td> | |
| <td>${zf.filename}</td> | |
| </tr>`)} | |
| </tbody> | |
| </table><//>`; | |
| } | |
| function CodeSection({model: {code_files}}) { | |
| return html` | |
| <${Hider} name="Code" shown=false> | |
| <div> | |
| ${Object.entries(code_files).map(([fn, code]) => html`<${OneCodeSection} | |
| filename=${fn} code=${code} />`)} | |
| </div><//>`; | |
| } | |
| class OneCodeSection extends Component { | |
| constructor() { | |
| super(); | |
| this.state = { shown: false }; | |
| } | |
| click() { | |
| const shown = !this.state.shown; | |
| this.setState({shown: shown}); | |
| } | |
| render({filename, code}, {shown}) { | |
| const header = html` | |
| <h3 style="font-family:monospace;"> | |
| <span class=caret onClick=${() => this.click()} >${caret(shown)} </span> | |
| ${filename}</h3> | |
| `; | |
| if (!shown) { | |
| return header; | |
| } | |
| return html` | |
| ${header} | |
| <pre>${code.map(c => this.renderBlock(c))}</pre> | |
| `; | |
| } | |
| renderBlock([text, ist_file, line, ist_s_text, s_start, s_end]) { | |
| return html`<span | |
| onClick=${() => blame.maybeBlame({ist_file, line, ist_s_text, s_start, s_end})} | |
| >${text}</span>`; | |
| } | |
| } | |
| function ExtraJsonSection({files}) { | |
| return html` | |
| <${Hider} name="Extra files (JSON)" shown=false> | |
| <div> | |
| <p>Use "Log Raw Model Info" for hierarchical view in browser console.</p> | |
| ${Object.entries(files).map(([fn, json]) => html`<${OneJsonSection} | |
| filename=${fn} json=${json} />`)} | |
| </div><//>`; | |
| } | |
| class OneJsonSection extends Component { | |
| constructor() { | |
| super(); | |
| this.state = { shown: false }; | |
| } | |
| click() { | |
| const shown = !this.state.shown; | |
| this.setState({shown: shown}); | |
| } | |
| render({filename, json}, {shown}) { | |
| const header = html` | |
| <h3 style="font-family:monospace;"> | |
| <span class=caret onClick=${() => this.click()} >${caret(shown)} </span> | |
| ${filename}</h3> | |
| `; | |
| if (!shown) { | |
| return header; | |
| } | |
| return html` | |
| ${header} | |
| <pre>${JSON.stringify(json, null, 2)}</pre> | |
| `; | |
| } | |
| } | |
| function ExtraPicklesSection({files}) { | |
| return html` | |
| <${Hider} name="Extra Pickles" shown=false> | |
| <div> | |
| ${Object.entries(files).map(([fn, content]) => html`<${OnePickleSection} | |
| filename=${fn} content=${content} />`)} | |
| </div><//>`; | |
| } | |
| class OnePickleSection extends Component { | |
| constructor() { | |
| super(); | |
| this.state = { shown: false }; | |
| } | |
| click() { | |
| const shown = !this.state.shown; | |
| this.setState({shown: shown}); | |
| } | |
| render({filename, content}, {shown}) { | |
| const header = html` | |
| <h3 style="font-family:monospace;"> | |
| <span class=caret onClick=${() => this.click()} >${caret(shown)} </span> | |
| ${filename}</h3> | |
| `; | |
| if (!shown) { | |
| return header; | |
| } | |
| return html` | |
| ${header} | |
| <pre>${content}</pre> | |
| `; | |
| } | |
| } | |
| function assertStorageAreEqual(key, lhs, rhs) { | |
| if (lhs.length !== rhs.length || | |
| !lhs.every((val, idx) => val === rhs[idx])) { | |
| throw new Error("Storage mismatch for key '" + key + "'"); | |
| } | |
| } | |
| function computeTensorMemory(numel, dtype) { | |
| const sizes = { | |
| "Byte": 1, | |
| "Char": 1, | |
| "Short": 2, | |
| "Int": 4, | |
| "Long": 8, | |
| "Half": 2, | |
| "Float": 4, | |
| "Double": 8, | |
| "ComplexHalf": 4, | |
| "ComplexFloat": 8, | |
| "ComplexDouble": 16, | |
| "Bool": 1, | |
| "QInt8": 1, | |
| "QUInt8": 1, | |
| "QInt32": 4, | |
| "BFloat16": 2, | |
| }; | |
| let dtsize = sizes[dtype]; | |
| if (!dtsize) { | |
| throw new Error("Unrecognized dtype: " + dtype); | |
| } | |
| return numel * dtsize; | |
| } | |
| // TODO: Maybe track by dtype as well. | |
| // TODO: Maybe distinguish between visible size and storage size. | |
| function getTensorStorages(data) { | |
| if (data === null) { | |
| return new Map(); | |
| } | |
| if (typeof(data) == "boolean") { | |
| return new Map(); | |
| } | |
| if (typeof(data) == "number") { | |
| return new Map(); | |
| } | |
| if (typeof(data) == "string") { | |
| return new Map(); | |
| } | |
| if (typeof(data) != "object") { | |
| throw new Error("Not an object"); | |
| } | |
| if (Array.isArray(data)) { | |
| let result = new Map(); | |
| for (const item of data) { | |
| const tensors = getTensorStorages(item); | |
| for (const [key, storage] of tensors.entries()) { | |
| if (!result.has(key)) { | |
| result.set(key, storage); | |
| } else { | |
| const old_storage = result.get(key); | |
| assertStorageAreEqual(key, old_storage, storage); | |
| } | |
| } | |
| } | |
| return result; | |
| } | |
| if (data.__tuple_values__) { | |
| return getTensorStorages(data.__tuple_values__); | |
| } | |
| if (data.__is_dict__) { | |
| return getTensorStorages(data.values); | |
| } | |
| if (data.__module_type__) { | |
| return getTensorStorages(data.state); | |
| } | |
| if (data.__tensor_v2__) { | |
| const [storage, offset, size, stride, grad] = data.__tensor_v2__; | |
| const [dtype, key, device, numel] = storage; | |
| return new Map([[key, storage]]); | |
| } | |
| if (data.__qtensor__) { | |
| const [storage, offset, size, stride, quantizer, grad] = data.__qtensor__; | |
| const [dtype, key, device, numel] = storage; | |
| return new Map([[key, storage]]); | |
| } | |
| throw new Error("Can't handle data type.", data); | |
| } | |
| function getTensorMemoryByDevice(pickles) { | |
| let all_tensors = []; | |
| for (const [name, pickle] of pickles) { | |
| const tensors = getTensorStorages(pickle); | |
| all_tensors.push(...tensors.values()); | |
| } | |
| let result = {}; | |
| for (const storage of all_tensors.values()) { | |
| const [dtype, key, device, numel] = storage; | |
| const size = computeTensorMemory(numel, dtype); | |
| result[device] = (result[device] || 0) + size; | |
| } | |
| return result; | |
| } | |
| // Make this a separate component so it is rendered lazily. | |
| class OpenTensorMemorySection extends Component { | |
| render({model: {model_data, constants}}) { | |
| let sizes = getTensorMemoryByDevice(new Map([ | |
| ["data", model_data], | |
| ["constants", constants], | |
| ])); | |
| return html` | |
| <table> | |
| <thead> | |
| <tr> | |
| <th>Device</th> | |
| <th>Bytes</th> | |
| <th>Human</th> | |
| </tr> | |
| </thead> | |
| <tbody style="font-family:monospace;"> | |
| ${Object.entries(sizes).map(([dev, size]) => html`<tr> | |
| <td>${dev}</td> | |
| <td>${size}</td> | |
| <td>${humanFileSize(size)}</td> | |
| </tr>`)} | |
| </tbody> | |
| </table>`; | |
| } | |
| } | |
| function TensorMemorySection({model}) { | |
| return html` | |
| <${Hider} name="Tensor Memory" shown=false> | |
| <${OpenTensorMemorySection} model=${model} /><//>`; | |
| } | |
| class AuxContentPane extends Component { | |
| constructor() { | |
| super(); | |
| this.state = { | |
| blame_info: null, | |
| }; | |
| } | |
| doBlame(arg) { | |
| this.setState({...this.state, blame_info: arg}); | |
| } | |
| render({model: {interned_strings}}, {blame_info}) { | |
| let blame_content = ""; | |
| if (blame_info) { | |
| const {ist_file, line, ist_s_text, s_start, s_end} = blame_info; | |
| let s_text = interned_strings[ist_s_text]; | |
| if (s_start != 0 || s_end != s_text.length) { | |
| let prefix = s_text.slice(0, s_start); | |
| let main = s_text.slice(s_start, s_end); | |
| let suffix = s_text.slice(s_end); | |
| s_text = html`${prefix}<strong>${main}</strong>${suffix}`; | |
| } | |
| blame_content = html` | |
| <h3>${interned_strings[ist_file]}:${line}</h3> | |
| <pre>${s_start}:${s_end}</pre> | |
| <pre>${s_text}</pre><br/> | |
| `; | |
| } | |
| return html` | |
| <button onClick=${() => blame.readyBlame()}>Blame Code</button> | |
| <br/> | |
| ${blame_content} | |
| `; | |
| } | |
| } | |
| class App extends Component { | |
| constructor() { | |
| super(); | |
| this.state = { | |
| err: false, | |
| model: null, | |
| }; | |
| } | |
| componentDidMount() { | |
| const app = this; | |
| if (BURNED_IN_MODEL_INFO !== null) { | |
| app.setState({model: BURNED_IN_MODEL_INFO}); | |
| } else { | |
| fetch("./model_info.json").then(function(response) { | |
| if (!response.ok) { | |
| throw new Error("Response not ok."); | |
| } | |
| return response.json(); | |
| }).then(function(body) { | |
| app.setState({model: body}); | |
| }).catch(function(error) { | |
| console.log("Top-level error: ", error); | |
| }); | |
| } | |
| } | |
| componentDidCatch(error) { | |
| void(error); | |
| this.setState({...this.state, err: true}); | |
| } | |
| render(_, {err}) { | |
| if (this.state.model === null) { | |
| return html`<h1>Loading...</h1>`; | |
| } | |
| const model = this.state.model.model; | |
| let error_msg = ""; | |
| if (err) { | |
| error_msg = html`<h2 style="background:red">An error occurred. Check console</h2>`; | |
| } | |
| return html` | |
| ${error_msg} | |
| <div id=main_content style="position:absolute;width:99%;height:79%;overflow:scroll"> | |
| <h1>TorchScript Model (version ${model.version}): ${model.title}</h1> | |
| <button onClick=${() => console.log(model)}>Log Raw Model Info</button> | |
| <${ModelSizeSection} model=${model}/> | |
| <${StructuredDataSection} name="Model Data" data=${model.model_data} shown=true/> | |
| <${StructuredDataSection} name="Constants" data=${model.constants} shown=false/> | |
| <${ZipContentsSection} model=${model}/> | |
| <${CodeSection} model=${model}/> | |
| <${ExtraJsonSection} files=${model.extra_files_jsons}/> | |
| <${ExtraPicklesSection} files=${model.extra_pickles}/> | |
| <${TensorMemorySection} model=${model}/> | |
| </div> | |
| <div id=aux_content style="position:absolute;width:99%;top:80%;height:20%;overflow:scroll"> | |
| <${AuxContentPane} | |
| err=${this.state.error} | |
| model=${model} | |
| ref=${(p) => blame.setAuxContentPane(p)}/> | |
| </div> | |
| `; | |
| } | |
| } | |
| render(h(App), document.body); |