diff options
Diffstat (limited to 'ext/wasm/batch-runner.js')
-rw-r--r-- | ext/wasm/batch-runner.js | 405 |
1 files changed, 405 insertions, 0 deletions
diff --git a/ext/wasm/batch-runner.js b/ext/wasm/batch-runner.js new file mode 100644 index 000000000..437424b48 --- /dev/null +++ b/ext/wasm/batch-runner.js @@ -0,0 +1,405 @@ +/* + 2022-08-29 + + The author disclaims copyright to this source code. In place of a + legal notice, here is a blessing: + + * May you do good and not evil. + * May you find forgiveness for yourself and forgive others. + * May you share freely, never taking more than you give. + + *********************************************************************** + + A basic batch SQL runner for sqlite3-api.js. This file must be run in + main JS thread and sqlite3.js must have been loaded before it. +*/ +'use strict'; +(function(){ + const toss = function(...args){throw new Error(args.join(' '))}; + const warn = console.warn.bind(console); + + const App = { + e: { + output: document.querySelector('#test-output'), + selSql: document.querySelector('#sql-select'), + btnRun: document.querySelector('#sql-run'), + btnRunNext: document.querySelector('#sql-run-next'), + btnRunRemaining: document.querySelector('#sql-run-remaining'), + btnExportMetrics: document.querySelector('#export-metrics'), + btnClear: document.querySelector('#output-clear'), + btnReset: document.querySelector('#db-reset'), + cbReverseLog: document.querySelector('#cb-reverse-log-order') + }, + cache:{}, + metrics:{ + /** + Map of sql-file to timing metrics. We currently only store + the most recent run of each file, but we really should store + all runs so that we can average out certain values which vary + significantly across runs. e.g. a mandelbrot-generating query + will have a wide range of runtimes when run 10 times in a + row. + */ + }, + log: console.log.bind(console), + warn: console.warn.bind(console), + cls: function(){this.e.output.innerHTML = ''}, + logHtml2: function(cssClass,...args){ + const ln = document.createElement('div'); + if(cssClass) ln.classList.add(cssClass); + ln.append(document.createTextNode(args.join(' '))); + this.e.output.append(ln); + //this.e.output.lastElementChild.scrollIntoViewIfNeeded(); + }, + logHtml: function(...args){ + console.log(...args); + if(1) this.logHtml2('', ...args); + }, + logErr: function(...args){ + console.error(...args); + if(1) this.logHtml2('error', ...args); + }, + + openDb: function(fn, unlinkFirst=true){ + if(this.db && this.db.ptr){ + toss("Already have an opened db."); + } + const capi = this.sqlite3.capi, wasm = capi.wasm; + const stack = wasm.scopedAllocPush(); + let pDb = 0; + try{ + if(unlinkFirst && fn && ':memory:'!==fn){ + capi.wasm.sqlite3_wasm_vfs_unlink(fn); + } + const oFlags = capi.SQLITE_OPEN_CREATE | capi.SQLITE_OPEN_READWRITE; + const ppDb = wasm.scopedAllocPtr(); + const rc = capi.sqlite3_open_v2(fn, ppDb, oFlags, null); + pDb = wasm.getPtrValue(ppDb) + if(rc){ + if(pDb) capi.sqlite3_close_v2(pDb); + toss("sqlite3_open_v2() failed with code",rc); + } + }finally{ + wasm.scopedAllocPop(stack); + } + this.db = Object.create(null); + this.db.filename = fn; + this.db.ptr = pDb; + this.logHtml("Opened db:",fn); + return this.db.ptr; + }, + + closeDb: function(unlink=false){ + if(this.db && this.db.ptr){ + this.sqlite3.capi.sqlite3_close_v2(this.db.ptr); + this.logHtml("Closed db",this.db.filename); + if(unlink) capi.wasm.sqlite3_wasm_vfs_unlink(this.db.filename); + this.db.ptr = this.db.filename = undefined; + } + }, + + /** + Loads batch-runner.list and populates the selection list from + it. Returns a promise which resolves to nothing in particular + when it completes. Only intended to be run once at the start + of the app. + */ + loadSqlList: async function(){ + const sel = this.e.selSql; + sel.innerHTML = ''; + this.blockControls(true); + const infile = 'batch-runner.list'; + this.logHtml("Loading list of SQL files:", infile); + let txt; + try{ + const r = await fetch(infile); + if(404 === r.status){ + toss("Missing file '"+infile+"'."); + } + if(!r.ok) toss("Loading",infile,"failed:",r.statusText); + txt = await r.text(); + const warning = document.querySelector('#warn-list'); + if(warning) warning.remove(); + }catch(e){ + this.logErr(e.message); + throw e; + }finally{ + this.blockControls(false); + } + const list = txt.split(/\n+/); + let opt; + if(0){ + opt = document.createElement('option'); + opt.innerText = "Select file to evaluate..."; + opt.value = ''; + opt.disabled = true; + opt.selected = true; + sel.appendChild(opt); + } + list.forEach(function(fn){ + if(!fn) return; + opt = document.createElement('option'); + opt.value = fn; + opt.innerText = fn.split('/').pop(); + sel.appendChild(opt); + }); + this.logHtml("Loaded",infile); + }, + + /** Fetch ./fn and return its contents as a Uint8Array. */ + fetchFile: async function(fn, cacheIt=false){ + if(cacheIt && this.cache[fn]) return this.cache[fn]; + this.logHtml("Fetching",fn,"..."); + let sql; + try { + const r = await fetch(fn); + if(!r.ok) toss("Fetch failed:",r.statusText); + sql = new Uint8Array(await r.arrayBuffer()); + }catch(e){ + this.logErr(e.message); + throw e; + } + this.logHtml("Fetched",sql.length,"bytes from",fn); + if(cacheIt) this.cache[fn] = sql; + return sql; + }/*fetchFile()*/, + + /** Throws if the given sqlite3 result code is not 0. */ + checkRc: function(rc){ + if(this.db.ptr && rc){ + toss("Prepare failed:",this.sqlite3.capi.sqlite3_errmsg(this.db.ptr)); + } + }, + + /** Disable or enable certain UI controls. */ + blockControls: function(disable){ + document.querySelectorAll('.disable-during-eval').forEach((e)=>e.disabled = disable); + }, + + /** + Converts this.metrics() to a form which is suitable for easy conversion to + CSV. It returns an array of arrays. The first sub-array is the column names. + The 2nd and subsequent are the values, one per test file (only the most recent + metrics are kept for any given file). + */ + metricsToArrays: function(){ + const rc = []; + Object.keys(this.metrics).sort().forEach((k)=>{ + const m = this.metrics[k]; + delete m.evalFileStart; + delete m.evalFileEnd; + const mk = Object.keys(m).sort(); + if(!rc.length){ + rc.push(['file', ...mk]); + } + const row = [k.split('/').pop()/*remove dir prefix from filename*/]; + rc.push(row); + mk.forEach((kk)=>row.push(m[kk])); + }); + return rc; + }, + + metricsToBlob: function(colSeparator='\t'){ + const ar = [], ma = this.metricsToArrays(); + if(!ma.length){ + this.logErr("Metrics are empty. Run something."); + return; + } + ma.forEach(function(row){ + ar.push(row.join(colSeparator),'\n'); + }); + return new Blob(ar); + }, + + downloadMetrics: function(){ + const b = this.metricsToBlob(); + if(!b) return; + const url = URL.createObjectURL(b); + const a = document.createElement('a'); + a.href = url; + a.download = 'batch-runner-js-'+((new Date().getTime()/1000) | 0)+'.csv'; + this.logHtml("Triggering download of",a.download); + document.body.appendChild(a); + a.click(); + setTimeout(()=>{ + document.body.removeChild(a); + URL.revokeObjectURL(url); + }, 500); + }, + + /** + Fetch file fn and eval it as an SQL blob. This is an async + operation and returns a Promise which resolves to this + object on success. + */ + evalFile: async function(fn){ + const sql = await this.fetchFile(fn); + const banner = "========================================"; + this.logHtml(banner, + "Running",fn,'('+sql.length,'bytes)...'); + const capi = this.sqlite3.capi, wasm = capi.wasm; + let pStmt = 0, pSqlBegin; + const stack = wasm.scopedAllocPush(); + const metrics = this.metrics[fn] = Object.create(null); + metrics.prepTotal = metrics.stepTotal = 0; + metrics.stmtCount = 0; + metrics.malloc = 0; + metrics.strcpy = 0; + this.blockControls(true); + if(this.gotErr){ + this.logErr("Cannot run ["+fn+"]: error cleanup is pending."); + return; + } + // Run this async so that the UI can be updated for the above header... + const ff = function(resolve, reject){ + metrics.evalFileStart = performance.now(); + try { + let t; + let sqlByteLen = sql.byteLength; + const [ppStmt, pzTail] = wasm.scopedAllocPtr(2); + t = performance.now(); + pSqlBegin = wasm.alloc( sqlByteLen + 1/*SQL + NUL*/) || toss("alloc(",sqlByteLen,") failed"); + metrics.malloc = performance.now() - t; + metrics.byteLength = sqlByteLen; + let pSql = pSqlBegin; + const pSqlEnd = pSqlBegin + sqlByteLen; + t = performance.now(); + wasm.heap8().set(sql, pSql); + wasm.setMemValue(pSql + sqlByteLen, 0); + metrics.strcpy = performance.now() - t; + let breaker = 0; + while(pSql && wasm.getMemValue(pSql,'i8')){ + wasm.setPtrValue(ppStmt, 0); + wasm.setPtrValue(pzTail, 0); + t = performance.now(); + let rc = capi.sqlite3_prepare_v3( + this.db.ptr, pSql, sqlByteLen, 0, ppStmt, pzTail + ); + metrics.prepTotal += performance.now() - t; + this.checkRc(rc); + pStmt = wasm.getPtrValue(ppStmt); + pSql = wasm.getPtrValue(pzTail); + sqlByteLen = pSqlEnd - pSql; + if(!pStmt) continue/*empty statement*/; + ++metrics.stmtCount; + t = performance.now(); + rc = capi.sqlite3_step(pStmt); + capi.sqlite3_finalize(pStmt); + pStmt = 0; + metrics.stepTotal += performance.now() - t; + switch(rc){ + case capi.SQLITE_ROW: + case capi.SQLITE_DONE: break; + default: this.checkRc(rc); toss("Not reached."); + } + } + }catch(e){ + if(pStmt) capi.sqlite3_finalize(pStmt); + this.gotErr = e; + //throw e; + reject(e); + return; + }finally{ + wasm.dealloc(pSqlBegin); + wasm.scopedAllocPop(stack); + this.blockControls(false); + } + metrics.evalFileEnd = performance.now(); + metrics.evalTimeTotal = (metrics.evalFileEnd - metrics.evalFileStart); + this.logHtml("Metrics:");//,JSON.stringify(metrics, undefined, ' ')); + this.logHtml("prepare() count:",metrics.stmtCount); + this.logHtml("Time in prepare_v2():",metrics.prepTotal,"ms", + "("+(metrics.prepTotal / metrics.stmtCount),"ms per prepare())"); + this.logHtml("Time in step():",metrics.stepTotal,"ms", + "("+(metrics.stepTotal / metrics.stmtCount),"ms per step())"); + this.logHtml("Total runtime:",metrics.evalTimeTotal,"ms"); + this.logHtml("Overhead (time - prep - step):", + (metrics.evalTimeTotal - metrics.prepTotal - metrics.stepTotal)+"ms"); + this.logHtml(banner,"End of",fn); + resolve(this); + }.bind(this); + let p; + if(1){ + p = new Promise(function(res,rej){ + setTimeout(()=>ff(res, rej), 50)/*give UI a chance to output the "running" banner*/; + }); + }else{ + p = new Promise(ff); + } + return p.catch((e)=>this.logErr("Error via evalFile("+fn+"):",e.message)); + }/*evalFile()*/, + + run: function(sqlite3){ + delete this.run; + this.sqlite3 = sqlite3; + const capi = sqlite3.capi, wasm = capi.wasm; + this.logHtml("Loaded module:",capi.sqlite3_libversion(), capi.sqlite3_sourceid()); + this.logHtml("WASM heap size =",wasm.heap8().length); + this.loadSqlList(); + const pDir = capi.sqlite3_web_persistent_dir(); + const dbFile = pDir ? pDir+"/speedtest.db" : ":memory:"; + if(!pDir){ + document.querySelector('#warn-opfs').remove(); + } + this.openDb(dbFile, !!pDir); + const who = this; + const eReverseLogNotice = document.querySelector('#reverse-log-notice'); + if(this.e.cbReverseLog.checked){ + eReverseLogNotice.classList.remove('hidden'); + this.e.output.classList.add('reverse'); + } + this.e.cbReverseLog.addEventListener('change', function(){ + if(this.checked){ + who.e.output.classList.add('reverse'); + eReverseLogNotice.classList.remove('hidden'); + }else{ + who.e.output.classList.remove('reverse'); + eReverseLogNotice.classList.add('hidden'); + } + }, false); + this.e.btnClear.addEventListener('click', ()=>this.cls(), false); + this.e.btnRun.addEventListener('click', function(){ + if(!who.e.selSql.value) return; + who.evalFile(who.e.selSql.value); + }, false); + this.e.btnRunNext.addEventListener('click', function(){ + ++who.e.selSql.selectedIndex; + if(!who.e.selSql.value) return; + who.evalFile(who.e.selSql.value); + }, false); + this.e.btnReset.addEventListener('click', function(){ + const fn = who.db.filename; + if(fn){ + who.closeDb(true); + who.openDb(fn,true); + } + }, false); + this.e.btnExportMetrics.addEventListener('click', function(){ + who.logHtml2('warning',"Triggering download of metrics CSV. Check your downloads folder."); + who.downloadMetrics(); + //const m = who.metricsToArrays(); + //console.log("Metrics:",who.metrics, m); + }); + this.e.btnRunRemaining.addEventListener('click', async function(){ + let v = who.e.selSql.value; + const timeStart = performance.now(); + while(v){ + await who.evalFile(v); + if(who.gotError){ + who.logErr("Error handling script",v,":",who.gotError.message); + break; + } + ++who.e.selSql.selectedIndex; + v = who.e.selSql.value; + } + const timeTotal = performance.now() - timeStart; + who.logHtml("Run-remaining time:",timeTotal,"ms ("+(timeTotal/1000/60)+" minute(s))"); + }, false); + }/*run()*/ + }/*App*/; + + self.sqlite3TestModule.initSqlite3().then(function(theEmccModule){ + self._MODULE = theEmccModule /* this is only to facilitate testing from the console */; + App.run(theEmccModule.sqlite3); + }); +})(); |