aboutsummaryrefslogtreecommitdiff
path: root/ext/wasm/batch-runner.js
diff options
context:
space:
mode:
Diffstat (limited to 'ext/wasm/batch-runner.js')
-rw-r--r--ext/wasm/batch-runner.js405
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);
+ });
+})();