Add Perf jobs for PathKit

We have a similar ingestion strategy to Gold.

I tried to use something off the shelf like benchmark.js
but passing the PathKit context into the benchmarks was
non-trivial. Plus, making a basic benchmarking tool
ended up being not too hard.

We should be able to re-use the docker container/aggregator
for CanvasKit too.

Bug: skia:
Change-Id: I613dfc58ea57c31cf71566a8ac55f8df9272ad25
Reviewed-on: https://skia-review.googlesource.com/c/161620
Commit-Queue: Kevin Lubick <kjlubick@google.com>
Reviewed-by: Joe Gregorio <jcgregorio@google.com>
Reviewed-by: Stephan Altmueller <stephana@google.com>
diff --git a/modules/pathkit/externs.js b/modules/pathkit/externs.js
index c6c5fc9..e5b6036 100644
--- a/modules/pathkit/externs.js
+++ b/modules/pathkit/externs.js
@@ -23,6 +23,7 @@
 var PathKit = {
 	SkBits2FloatUnsigned: function(num) {},
 	_malloc: function(size) {},
+	_free: function(ptr) {},
 	onRuntimeInitialized: function() {},
 	_FromCmds: function(ptr, size) {},
 	loadCmdsTypedArray: function(arr) {},
@@ -31,6 +32,9 @@
 	cubicYFromX: function(cpx1, cpy1, cpx2, cpy2, X) {},
 	cubicPtFromT: function(cpx1, cpy1, cpx2, cpy2, T) {},
 
+	/**
+	 * @type {Float32Array}
+	 */
 	HEAPF32: {},
 
 	SkPath: {
diff --git a/modules/pathkit/helper.js b/modules/pathkit/helper.js
index 51e1ffd..82713ba 100644
--- a/modules/pathkit/helper.js
+++ b/modules/pathkit/helper.js
@@ -63,7 +63,10 @@
   // See above for example of cmds.
   PathKit.FromCmds = function(cmds) {
     var ptrLen = PathKit.loadCmdsTypedArray(cmds);
-    return PathKit._FromCmds(ptrLen[0], ptrLen[1]);
+    var path = PathKit._FromCmds(ptrLen[0], ptrLen[1]);
+    // TODO(kjlubick): cache this memory blob somehow.
+    PathKit._free(ptrLen[0]);
+    return path;
   }
 
   /**
diff --git a/modules/pathkit/karma.bench.conf.js b/modules/pathkit/karma.bench.conf.js
new file mode 100644
index 0000000..a2808a6
--- /dev/null
+++ b/modules/pathkit/karma.bench.conf.js
@@ -0,0 +1,88 @@
+const isDocker = require('is-docker')();
+
+module.exports = function(config) {
+  // Set the default values to be what are needed when testing the
+  // WebAssembly build locally.
+  let cfg = {
+    // frameworks to use
+    // available frameworks: https://npmjs.org/browse/keyword/karma-adapter
+    frameworks: ['jasmine'],
+
+    // list of files / patterns to load in the browser
+    files: [
+      { pattern: 'npm-wasm/bin/pathkit.wasm', included:false, served:true},
+      'perf/perfReporter.js',
+      'npm-wasm/bin/pathkit.js',
+      'perf/*.bench.js'
+    ],
+
+    proxies: {
+      '/pathkit/': '/base/npm-wasm/bin/'
+    },
+
+    // test results reporter to use
+    // possible values: 'dots', 'progress'
+    // available reporters: https://npmjs.org/browse/keyword/karma-reporter
+    reporters: ['dots'],
+
+    // web server port
+    port: 4444,
+
+    // enable / disable colors in the output (reporters and logs)
+    colors: true,
+
+    // level of logging
+    // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG
+    logLevel: config.LOG_INFO,
+
+    // enable / disable watching file and executing tests whenever any file changes
+    autoWatch: true,
+
+    browserDisconnectTimeout: 10000,
+    browserNoActivityTimeout: 10000,
+
+    // start these browsers
+    browsers: ['Chrome'],
+
+    // Continuous Integration mode
+    // if true, Karma captures browsers, runs the tests and exits
+    singleRun: false,
+
+    // Concurrency level
+    // how many browser should be started simultaneous
+    concurrency: Infinity,
+  };
+
+  if (isDocker) {
+    // See https://hackernoon.com/running-karma-tests-with-headless-chrome-inside-docker-ae4aceb06ed3
+    cfg.browsers = ['ChromeHeadlessNoSandbox'],
+    cfg.customLaunchers = {
+        ChromeHeadlessNoSandbox: {
+            base: 'ChromeHeadless',
+            flags: [
+            // Without this flag, we see an error:
+            // Failed to move to new namespace: PID namespaces supported, Network namespace supported, but failed: errno = Operation not permitted
+                '--no-sandbox'
+            ],
+        },
+    };
+  }
+
+  if (process.env.ASM_JS) {
+    console.log('asm.js is under test');
+    cfg.files = [
+      { pattern: 'npm-asmjs/bin/pathkit.js.mem', included:false, served:true},
+      'perf/perfReporter.js',
+      'npm-asmjs/bin/pathkit.js',
+      'perf/*.bench.js'
+    ];
+
+    cfg.proxies = {
+      '/pathkit/': '/base/npm-asmjs/bin/'
+    };
+  } else {
+    console.log('wasm is under test');
+  }
+
+  config.set(cfg);
+}
diff --git a/modules/pathkit/karma.conf.js b/modules/pathkit/karma.conf.js
index faa870e..649d21d 100644
--- a/modules/pathkit/karma.conf.js
+++ b/modules/pathkit/karma.conf.js
@@ -18,7 +18,7 @@
     ],
 
     proxies: {
-      "/pathkit/": "/base/npm-wasm/bin/test/"
+      '/pathkit/': '/base/npm-wasm/bin/test/'
     },
 
     // test results reporter to use
@@ -80,7 +80,7 @@
     ];
 
     cfg.proxies = {
-      "/pathkit/": "/base/npm-asmjs/bin/test/"
+      '/pathkit/': '/base/npm-asmjs/bin/test/'
     };
   } else {
     console.log('wasm is under test');
diff --git a/modules/pathkit/npm-asmjs/package.json b/modules/pathkit/npm-asmjs/package.json
index 701e307..306c543 100644
--- a/modules/pathkit/npm-asmjs/package.json
+++ b/modules/pathkit/npm-asmjs/package.json
@@ -1,6 +1,6 @@
 {
   "name": "pathkit-asmjs",
-  "version": "0.4.1",
+  "version": "0.4.2",
   "description": "A asm.js version of Skia's PathOps toolkit",
   "main": "bin/pathkit.js",
   "homepage": "https://github.com/google/skia/tree/master/modules/pathkit",
diff --git a/modules/pathkit/npm-wasm/package.json b/modules/pathkit/npm-wasm/package.json
index 80cbaec..a18e613 100644
--- a/modules/pathkit/npm-wasm/package.json
+++ b/modules/pathkit/npm-wasm/package.json
@@ -1,6 +1,6 @@
 {
   "name": "pathkit-wasm",
-  "version": "0.4.1",
+  "version": "0.4.2",
   "description": "A WASM version of Skia's PathOps toolkit",
   "main": "bin/pathkit.js",
   "homepage": "https://github.com/google/skia/tree/master/modules/pathkit",
diff --git a/modules/pathkit/perf/effects.bench.js b/modules/pathkit/perf/effects.bench.js
new file mode 100644
index 0000000..d1be9ec
--- /dev/null
+++ b/modules/pathkit/perf/effects.bench.js
@@ -0,0 +1,143 @@
+
+
+describe('PathKit\'s Effects', function() {
+    // Note, don't try to print the PathKit object - it can cause Karma/Jasmine to lock up.
+    var PathKit = null;
+    const LoadPathKit = new Promise(function(resolve, reject) {
+        if (PathKit) {
+            resolve();
+        } else {
+            PathKitInit({
+                locateFile: (file) => '/pathkit/'+file,
+            }).then((_PathKit) => {
+                PathKit = _PathKit;
+                resolve();
+            });
+        }
+    });
+
+    // see https://fiddle.skia.org/c/@discrete_path
+    function drawStar(X=128, Y=128, R=116) {
+        let p = PathKit.NewPath();
+        p.moveTo(X + R, Y);
+        for (let i = 1; i < 8; i++) {
+          let a = 2.6927937 * i;
+          p.lineTo(X + R * Math.cos(a), Y + R * Math.sin(a));
+        }
+        p.closePath();
+        return p;
+    }
+
+    it('effects_dash', function(done) {
+        function setup(ctx) {
+            ctx.path = drawStar();
+        }
+
+        function test(ctx) {
+            let path = ctx.path.copy().dash(10, 3, 1);
+            path.delete();
+        }
+
+        function teardown(ctx) {
+            ctx.path.delete();
+        }
+
+        LoadPathKit.then(() => {
+            benchmarkAndReport('effects_dash', setup, test, teardown).then(() => {
+                done();
+            }).catch(reportError(done));
+        });
+    });
+
+    it('effects_trim', function(done) {
+        function setup(ctx) {
+            ctx.path = drawStar();
+        }
+
+        function test(ctx) {
+            let path = ctx.path.copy().trim(0.25, .8);
+            path.delete();
+        }
+
+        function teardown(ctx) {
+            ctx.path.delete();
+        }
+
+        LoadPathKit.then(() => {
+            benchmarkAndReport('effects_trim', setup, test, teardown).then(() => {
+                done();
+            }).catch(reportError(done));
+        });
+    });
+
+    it('effects_trim_complement', function(done) {
+        function setup(ctx) {
+            ctx.path = drawStar();
+        }
+
+        function test(ctx) {
+            let path = ctx.path.copy().trim(0.25, .8, true);
+            path.delete();
+        }
+
+        function teardown(ctx) {
+            ctx.path.delete();
+        }
+
+        LoadPathKit.then(() => {
+            benchmarkAndReport('effects_trim_complement', setup, test, teardown).then(() => {
+                done();
+            }).catch(reportError(done));
+        });
+    });
+
+    it('effects_transform', function(done) {
+        function setup(ctx) {
+            ctx.path = drawStar();
+        }
+
+        function test(ctx) {
+            let path = ctx.path.copy().transform(3, 0, 0,
+                                             0, 3, 0,
+                                             0, 0, 1);
+            path.delete();
+        }
+
+        function teardown(ctx) {
+            ctx.path.delete();
+        }
+
+        LoadPathKit.then(() => {
+            benchmarkAndReport('effects_transform', setup, test, teardown).then(() => {
+                done();
+            }).catch(reportError(done));
+        });
+    });
+
+    it('effects_stroke', function(done) {
+        function setup(ctx) {
+            ctx.path = drawStar();
+        }
+
+        function test(ctx) {
+            let path = ctx.path.copy().stroke({
+                    width: 15,
+                    join: PathKit.StrokeJoin.BEVEL,
+                    cap: PathKit.StrokeCap.BUTT,
+                    miter_limit: 2,
+                });
+            path.delete();
+        }
+
+        function teardown(ctx) {
+            ctx.path.delete();
+        }
+
+        LoadPathKit.then(() => {
+            benchmarkAndReport('effects_stroke', setup, test, teardown).then(() => {
+                done();
+            }).catch(reportError(done));
+        });
+    });
+
+});
\ No newline at end of file
diff --git a/modules/pathkit/perf/path.bench.js b/modules/pathkit/perf/path.bench.js
new file mode 100644
index 0000000..35d8b25
--- /dev/null
+++ b/modules/pathkit/perf/path.bench.js
@@ -0,0 +1,313 @@
+
+
+describe('PathKit\'s Path Behavior', function() {
+    // Note, don't try to print the PathKit object - it can cause Karma/Jasmine to lock up.
+    var PathKit = null;
+    const LoadPathKit = new Promise(function(resolve, reject) {
+        if (PathKit) {
+            resolve();
+        } else {
+            PathKitInit({
+                locateFile: (file) => '/pathkit/'+file,
+            }).then((_PathKit) => {
+                PathKit = _PathKit;
+                resolve();
+            });
+        }
+    });
+
+    function drawPath() {
+        let path = PathKit.NewPath();
+        path.moveTo(20, 5);
+        path.lineTo(30, 20);
+        path.lineTo(40, 10);
+        path.lineTo(50, 20);
+        path.lineTo(60, 0);
+        path.lineTo(20, 5);
+
+        path.moveTo(20, 80);
+        path.bezierCurveTo(90, 10, 160, 150, 190, 10);
+
+        path.moveTo(36, 148);
+        path.quadraticCurveTo(66, 188, 120, 136);
+        path.lineTo(36, 148);
+
+        path.rect(5, 170, 20, 20);
+
+        path.moveTo(150, 180);
+        path.arcTo(150, 100, 50, 200, 20);
+        path.lineTo(160, 160);
+
+        path.moveTo(20, 120);
+        path.arc(20, 120, 18, 0, 1.75 * Math.PI);
+        path.lineTo(20, 120);
+
+        let secondPath = PathKit.NewPath();
+        secondPath.ellipse(130, 25, 30, 10, -1*Math.PI/8, Math.PI/6, 1.5*Math.PI, false);
+
+        path.addPath(secondPath);
+
+        let m = document.createElementNS('http://www.w3.org/2000/svg', 'svg').createSVGMatrix();
+        m.a = 1; m.b = 0;
+        m.c = 0; m.d = 1;
+        m.e = 0; m.f = 20.5;
+
+        path.addPath(secondPath, m);
+        secondPath.delete();
+        return path;
+    }
+
+    it('path_path2dapi', function(done) {
+        function setup(ctx) { }
+
+        function test(ctx) {
+            path = drawPath();
+            path.delete();
+        }
+
+        function teardown(ctx) { }
+
+        LoadPathKit.then(() => {
+            benchmarkAndReport('path_path2dapi', setup, test, teardown).then(() => {
+                done();
+            }).catch(reportError(done));
+        });
+    });
+
+    describe('import options', function() {
+        it('path_copy', function(done) {
+            function setup(ctx) {
+                ctx.path = PathKit.FromSVGString('M 205,5 L 795,5 L 595,295 L 5,295 L 205,5 z');
+            }
+
+            function test(ctx) {
+                let p = ctx.path.copy();
+                p.delete();
+            }
+
+            function teardown(ctx) {
+                ctx.path.delete();
+            }
+
+            LoadPathKit.then(() => {
+                benchmarkAndReport('path_copy', setup, test, teardown).then(() => {
+                    done();
+                }).catch(reportError(done));
+            });
+        });
+
+        it('path_from_api_calls', function(done) {
+            function setup(ctx) { }
+
+            function test(ctx) {
+                let p = PathKit.NewPath()
+                               .moveTo(205, 5)
+                               .lineTo(795, 5)
+                               .lineTo(595, 295)
+                               .lineTo(5, 295)
+                               .lineTo(205, 5)
+                               .close();
+                p.delete();
+            }
+
+            function teardown(ctx) { }
+
+            LoadPathKit.then(() => {
+                benchmarkAndReport('path_from_api_calls', setup, test, teardown).then(() => {
+                    done();
+                }).catch(reportError(done));
+            });
+        });
+
+        it('path_fromCmds', function(done) {
+            function setup(ctx) { }
+
+            function test(ctx) {
+                let p = PathKit.FromCmds(
+                    [[PathKit.MOVE_VERB, 205, 5],
+                    [PathKit.LINE_VERB, 795, 5],
+                    [PathKit.LINE_VERB, 595, 295],
+                    [PathKit.LINE_VERB, 5, 295],
+                    [PathKit.LINE_VERB, 205, 5],
+                    [PathKit.CLOSE_VERB]]);
+                p.delete();
+            }
+
+            function teardown(ctx) { }
+
+            LoadPathKit.then(() => {
+                benchmarkAndReport('path_fromCmds', setup, test, teardown).then(() => {
+                    done();
+                }).catch(reportError(done));
+            });
+        });
+
+        it('path_fromSVGString', function(done) {
+            function setup(ctx) {}
+
+            function test(ctx) {
+                // https://upload.wikimedia.org/wikipedia/commons/e/e7/Simple_parallelogram.svg
+                let p = PathKit.FromSVGString('M 205,5 L 795,5 L 595,295 L 5,295 L 205,5 z');
+                p.delete();
+            }
+
+            function teardown(ctx) { }
+
+            LoadPathKit.then(() => {
+                benchmarkAndReport('path_fromSVGString', setup, test, teardown).then(() => {
+                    done();
+                }).catch(reportError(done));
+            });
+        });
+    });
+
+    describe('export options', function() {
+        it('path_toCmds', function(done) {
+            function setup(ctx) {
+                ctx.path = drawPath();
+            }
+
+            function test(ctx) {
+                ctx.path.toCmds();
+            }
+
+            function teardown(ctx) {
+                ctx.path.delete();
+            }
+
+            LoadPathKit.then(() => {
+                benchmarkAndReport('path_toCmds', setup, test, teardown).then(() => {
+                    done();
+                }).catch(reportError(done));
+            });
+        });
+
+        it('path_toPath2D', function(done) {
+            function setup(ctx) {
+                ctx.path = drawPath();
+            }
+
+            function test(ctx) {
+                ctx.path.toPath2D();
+            }
+
+            function teardown(ctx) {
+                ctx.path.delete();
+            }
+
+            LoadPathKit.then(() => {
+                benchmarkAndReport('path_toPath2D', setup, test, teardown).then(() => {
+                    done();
+                }).catch(reportError(done));
+            });
+        });
+
+        it('path_toSVGString', function(done) {
+            function setup(ctx) {
+                ctx.path = drawPath();
+            }
+
+            function test(ctx) {
+                ctx.path.toSVGString();
+            }
+
+            function teardown(ctx) {
+                ctx.path.delete();
+            }
+
+            LoadPathKit.then(() => {
+                benchmarkAndReport('path_toSVGString', setup, test, teardown).then(() => {
+                    done();
+                }).catch(reportError(done));
+            });
+        });
+    });
+
+    describe('matrix options', function() {
+        function drawTriangle() {
+            let path = PathKit.NewPath();
+            path.moveTo(0, 0);
+            path.lineTo(10, 0);
+            path.lineTo(10, 10);
+            path.close();
+            return path;
+        }
+
+        it('path_add_path_svgmatrix', function(done) {
+            function setup(ctx) {
+                ctx.path = drawTriangle();
+            }
+
+            function test(ctx) {
+                let path = PathKit.NewPath();
+                let m = document.createElementNS('http://www.w3.org/2000/svg', 'svg').createSVGMatrix();
+                m.a = 1; m.b = 0;
+                m.c = 0; m.d = 1;
+                m.e = 0; m.f = 20.5;
+                path.addPath(ctx.path, m);
+                path.delete();
+            }
+
+            function teardown(ctx) {
+                ctx.path.delete();
+            }
+
+            LoadPathKit.then(() => {
+                benchmarkAndReport('path_add_path_svgmatrix', setup, test, teardown).then(() => {
+                    done();
+                }).catch(reportError(done));
+            });
+        });
+
+        it('path_add_path_svgmatrix_reuse', function(done) {
+            function setup(ctx) {
+                ctx.path = drawTriangle();
+                let m = document.createElementNS('http://www.w3.org/2000/svg', 'svg').createSVGMatrix();
+                ctx.matrix = m;
+            }
+
+            function test(ctx) {
+                let path = PathKit.NewPath();
+                let m = ctx.matrix
+                m.a = 1; m.b = 0;
+                m.c = 0; m.d = 1;
+                m.e = 0; m.f = 20.5;
+                path.addPath(ctx.path, m);
+                path.delete();
+            }
+
+            function teardown(ctx) {
+                ctx.path.delete();
+            }
+
+            LoadPathKit.then(() => {
+                benchmarkAndReport('path_add_path_svgmatrix_reuse', setup, test, teardown).then(() => {
+                    done();
+                }).catch(reportError(done));
+            });
+        });
+
+        it('path_add_path_svgmatrix_bare', function(done) {
+            function setup(ctx) {
+                ctx.path = drawTriangle();
+            }
+
+            function test(ctx) {
+                let path = PathKit.NewPath();
+                path.addPath(ctx.path, 1, 0, 0, 1, 0, 20.5);
+                path.delete();
+            }
+
+            function teardown(ctx) {
+                ctx.path.delete();
+            }
+
+            LoadPathKit.then(() => {
+                benchmarkAndReport('path_add_path_svgmatrix_bare', setup, test, teardown).then(() => {
+                    done();
+                }).catch(reportError(done));
+            });
+        });
+    });
+
+});
\ No newline at end of file
diff --git a/modules/pathkit/perf/pathops.bench.js b/modules/pathkit/perf/pathops.bench.js
new file mode 100644
index 0000000..2da6553
--- /dev/null
+++ b/modules/pathkit/perf/pathops.bench.js
@@ -0,0 +1,172 @@
+
+
+describe('PathKit\'s Pathops', function() {
+    // Note, don't try to print the PathKit object - it can cause Karma/Jasmine to lock up.
+    var PathKit = null;
+    const LoadPathKit = new Promise(function(resolve, reject) {
+        if (PathKit) {
+            resolve();
+        } else {
+            PathKitInit({
+                locateFile: (file) => '/pathkit/'+file,
+            }).then((_PathKit) => {
+                PathKit = _PathKit;
+                resolve();
+            });
+        }
+    });
+
+    // see https://fiddle.skia.org/c/@discrete_path
+    function drawStar(X=128, Y=128, R=116) {
+        let p = PathKit.NewPath();
+        p.moveTo(X + R, Y);
+        for (let i = 1; i < 8; i++) {
+          let a = 2.6927937 * i;
+          p.lineTo(X + R * Math.cos(a), Y + R * Math.sin(a));
+        }
+        p.closePath();
+        return p;
+    }
+
+    it('pathops_simplify', function(done) {
+        function setup(ctx) {
+            ctx.path = drawStar();
+        }
+
+        function test(ctx) {
+            let path = ctx.path.copy().simplify();
+            path.delete();
+        }
+
+        function teardown(ctx) {
+            ctx.path.delete();
+        }
+
+        LoadPathKit.then(() => {
+            benchmarkAndReport('pathops_simplify', setup, test, teardown).then(() => {
+                done();
+            }).catch(reportError(done));
+        });
+    });
+
+    it('pathops_diff', function(done) {
+        function setup(ctx) {
+            // Values chosen abitrarily to have some overlap and some not.
+            ctx.path1 = drawStar(X=120, Y=120);
+            ctx.path2 = drawStar(X=140, Y=145);
+        }
+
+        function test(ctx) {
+            let path = PathKit.MakeFromOp(ctx.path1, ctx.path2, PathKit.PathOp.DIFFERENCE);
+            path.delete();
+        }
+
+        function teardown(ctx) {
+            ctx.path1.delete();
+            ctx.path2.delete();
+        }
+
+        LoadPathKit.then(() => {
+            benchmarkAndReport('pathops_diff', setup, test, teardown).then(() => {
+                done();
+            }).catch(reportError(done));
+        });
+    });
+
+    it('pathops_intersect', function(done) {
+        function setup(ctx) {
+            // Values chosen abitrarily to have some overlap and some not.
+            ctx.path1 = drawStar(X=120, Y=120);
+            ctx.path2 = drawStar(X=140, Y=145);
+        }
+
+        function test(ctx) {
+            let path = PathKit.MakeFromOp(ctx.path1, ctx.path2, PathKit.PathOp.INTERSECT);
+            path.delete();
+        }
+
+        function teardown(ctx) {
+            ctx.path1.delete();
+            ctx.path2.delete();
+        }
+
+        LoadPathKit.then(() => {
+            benchmarkAndReport('pathops_intersect', setup, test, teardown).then(() => {
+                done();
+            }).catch(reportError(done));
+        });
+    });
+
+    it('pathops_union', function(done) {
+        function setup(ctx) {
+            // Values chosen abitrarily to have some overlap and some not.
+            ctx.path1 = drawStar(X=120, Y=120);
+            ctx.path2 = drawStar(X=140, Y=145);
+        }
+
+        function test(ctx) {
+            let path = PathKit.MakeFromOp(ctx.path1, ctx.path2, PathKit.PathOp.UNION);
+            path.delete();
+        }
+
+        function teardown(ctx) {
+            ctx.path1.delete();
+            ctx.path2.delete();
+        }
+
+        LoadPathKit.then(() => {
+            benchmarkAndReport('pathops_union', setup, test, teardown).then(() => {
+                done();
+            }).catch(reportError(done));
+        });
+    });
+
+    it('pathops_xor', function(done) {
+        function setup(ctx) {
+            // Values chosen abitrarily to have some overlap and some not.
+            ctx.path1 = drawStar(X=120, Y=120);
+            ctx.path2 = drawStar(X=140, Y=145);
+        }
+
+        function test(ctx) {
+            let path = PathKit.MakeFromOp(ctx.path1, ctx.path2, PathKit.PathOp.XOR);
+            path.delete();
+        }
+
+        function teardown(ctx) {
+            ctx.path1.delete();
+            ctx.path2.delete();
+        }
+
+        LoadPathKit.then(() => {
+            benchmarkAndReport('pathops_xor', setup, test, teardown).then(() => {
+                done();
+            }).catch(reportError(done));
+        });
+    });
+
+    it('pathops_reverse_diff', function(done) {
+        function setup(ctx) {
+            // Values chosen abitrarily to have some overlap and some not.
+            ctx.path1 = drawStar(X=120, Y=120);
+            ctx.path2 = drawStar(X=140, Y=145);
+        }
+
+        function test(ctx) {
+            let path = PathKit.MakeFromOp(ctx.path1, ctx.path2, PathKit.PathOp.REVERSE_DIFFERENCE);
+            path.delete();
+        }
+
+        function teardown(ctx) {
+            ctx.path1.delete();
+            ctx.path2.delete();
+        }
+
+        LoadPathKit.then(() => {
+            benchmarkAndReport('pathops_reverse_diff', setup, test, teardown).then(() => {
+                done();
+            }).catch(reportError(done));
+        });
+    });
+
+});
\ No newline at end of file
diff --git a/modules/pathkit/perf/perfReporter.js b/modules/pathkit/perf/perfReporter.js
new file mode 100644
index 0000000..d74110c
--- /dev/null
+++ b/modules/pathkit/perf/perfReporter.js
@@ -0,0 +1,72 @@
+const REPORT_URL = 'http://localhost:8081/report_perf_data'
+// Set this to enforce that the perf server must be up.
+// Typically used for debugging.
+const fail_on_no_perf = false;
+
+
+function benchmarkAndReport(benchName, setupFn, testFn, teardownFn) {
+    let ctx = {};
+    // warmup 3 times (arbitrary choice)
+    setupFn(ctx);
+    testFn(ctx);
+    testFn(ctx);
+    testFn(ctx);
+    teardownFn(ctx);
+
+    ctx = {};
+    setupFn(ctx);
+    let start = Date.now();
+    let now = start;
+    times = 0;
+    // See how many times we can do it in 100ms (arbitrary choice)
+    while (now - start < 100) {
+        testFn(ctx);
+        now = Date.now();
+        times++;
+    }
+
+    teardownFn(ctx);
+
+    // Try to make it go for 2 seconds (arbitrarily chosen)
+    // Since the pre-try took 100ms, multiply by 20 to get
+    // approximate tries in 2s
+    let goalTimes = times * 20;
+    setupFn(ctx);
+    start = Date.now();
+    times = 0;
+    while (times < goalTimes) {
+        testFn(ctx);
+        times++;
+    }
+    let end = Date.now();
+    teardownFn(ctx);
+
+    let us = (end - start) * 1000 / times;
+    console.log(benchName, `${us} microseconds`)
+    return _report(us, benchName);
+}
+
+
+function _report(microseconds, benchName) {
+    return fetch(REPORT_URL, {
+        method: 'POST',
+        mode: 'no-cors',
+        headers: {
+            'Content-Type': 'application/json',
+        },
+        body: JSON.stringify({
+            'bench_name': benchName,
+            'time_us': microseconds,
+        })
+    }).then(() => console.log(`Successfully reported ${benchName} to perf aggregator`));
+}
+
+function reportError(done) {
+    return (e) => {
+        console.log("Error with fetching. Likely could not connect to aggegator server", e.message);
+        if (fail_on_no_perf) {
+            expect(e).toBeUndefined();
+        }
+        done();
+    };
+}
\ No newline at end of file