@thi.ng/bench
Version:
Benchmarking & profiling utilities w/ various statistics & formatters (CSV, JSON, Markdown etc.)
306 lines (305 loc) • 7.84 kB
JavaScript
import { benchResult } from "./bench.js";
import { asMillis, now } from "./now.js";
class Profiler {
_session;
_profiles;
_enabled;
_overhead = 0;
constructor(opts = {}) {
const { warmup = 1e6, enabled = true } = opts;
this.enable();
if (warmup > 0) this.warmup(warmup);
enabled ? this.reset() : this.disable();
}
isEnabled() {
return this._enabled;
}
/**
* Disables profiler and clears all existing profiles.
*
* @remarks
* Calls to {@link Profiler.start} and {@link Profiler.end} only are no-ops
* if the profiler is currently disabled.
*/
disable() {
if (this._enabled) {
this._enabled = false;
this.reset();
}
}
/**
* Enables profiler and clears all existing profiles.
*
* @remarks
* Calls to {@link Profiler.start} and {@link Profiler.end} only are no-ops
* if the profiler is currently disabled.
*/
enable() {
if (!this._enabled) {
this._enabled = true;
this.reset();
}
}
/**
* Resets profiler state and clears all recorded profiles.
*/
reset() {
this._profiles = {};
this._session = void 0;
return this;
}
/**
* Prepare and return all recorded profiles as object of
* {@link ProfileResult}s.
*
* @remarks
* Automatically computes and subtracts internal overhead from each
* profile's total (Overhead is computed during profiler ctor and/or
* {@link Profiler.warmup}).
*
* Also see {@link Profiler.asCSV} to obtain results in CSV format.
*/
deref() {
const { _profiles, _session, _overhead } = this;
if (!_session) return {};
const res = {};
const sessionTotal = asMillis(_session.total) - _session.calls * _overhead;
for (let id in _profiles) {
const profile = _profiles[id];
const total = asMillis(profile.total) - profile.calls * _overhead;
const totalPercent = total / sessionTotal * 100;
const callsPercent = profile.calls / _session.calls * 100;
res[id] = {
id,
total,
timePerCall: total / profile.calls,
totalPercent,
calls: profile.calls,
callsPercent,
maxDepth: profile.maxDepth
};
}
return res;
}
/**
* Start a new profile (or add to an existing ID) by recording current
* timestamp (via {@link now}), number of calls (to this method and for this
* ID), as well as max. recursion depth. Use {@link Profiler.end} to
* stop/update measurements.
*
* @remarks
* Profiling only happens if the profiler is currently enabled, else a
* no-op.
*
* * @example
* ```ts tangle:../export/profiler.ts
* import { Profiler } from "@thi.ng/bench";
*
* const profiler = new Profiler();
*
* // recursive function
* const countdown = (n: number, acc: number[] = []) => {
* profiler.start("countdown");
* if (n > 0) countdown(n - 1, (acc.push(n),acc));
* profiler.end("countdown");
* return acc;
* }
*
* console.log(countdown(10));
* // [ 10, 9, 8, 7, 6, 5, 4, 3, 2, 1 ]
*
* console.log(countdown(5));
* // [ 5, 4, 3, 2, 1 ]
*
* console.log(profiler.deref());
* // {
* // countdown: {
* // id: 'countdown',
* // total: 0.029665688286,
* // timePerCall: 0.0017450404874117648,
* // totalPercent: 96.0872831622525,
* // calls: 17,
* // callsPercent: 100,
* // maxDepth: 11
* // }
* // }
* ```
*
* @param id
*/
start(id) {
if (!this._enabled) return;
let profile = this._profiles[id];
const t0 = now();
if (!profile) {
this._profiles[id] = this.newProfile(t0);
} else {
profile.maxDepth = Math.max(profile.t0.push(t0), profile.maxDepth);
profile.calls++;
}
if (!this._session) {
this._session = this.newProfile(t0);
} else {
this._session.calls++;
}
}
/**
* Ends/updates measurements for given profile ID. Throws error if `id` is
* invalid or if no active profiling iteration exists for this ID (e.g. if
* this method is called more often than a corresponding
* {@link Profiler.start}).
*
* @remarks
* Profiling only happens if the profiler is currently enabled, else a
* no-op.
*
* @param id
*/
end(id) {
if (!this._enabled) return;
const t = now();
const profile = this._profiles[id];
if (!profile) throw new Error(`invalid profile ID: ${id}`);
const t1 = profile.t0.pop();
if (t1 === void 0)
throw new Error(`no active profile for ID: ${id}`);
if (!profile.t0.length) {
profile.total += t - t1;
this._session.total += t - t1;
}
}
/**
* Takes a profile `id`, function `fn` and any (optional) arguments. Calls
* `fn` with given args and profiles it using provided ID. Returns result
* of `fn`.
*
* @remarks
* Also see {@link Profiler.wrap}
*
* @param id
* @param fn
*/
profile(id, fn, ...args) {
this.start(id);
const res = fn.apply(null, args);
this.end(id);
return res;
}
/**
* Higher-order version of {@link Profiler.profile}. Takes a profile `id`
* and vararg function `fn`. Returns new function which when called, calls
* given `fn` and profiles it using provided `id`, then returns result of
* `fn`.
*
* @example
* ```ts tangle:../export/profiler-wrap.ts
* import { Profiler } from "@thi.ng/bench";
*
* const profiler = new Profiler();
*
* const sum = profiler.wrap(
* "sum",
* (vec: number[]) => vec.reduce((acc, x) => acc + x, 0)
* );
*
* console.log(sum([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]));
* // 55
*
* console.log(profiler.deref());
* // {
* // sum: {
* // id: 'sum',
* // total: 0.015644915291,
* // timePerCall: 0.015644915291,
* // totalPercent: 100,
* // calls: 1,
* // callsPercent: 100,
* // maxDepth: 1
* // }
* // }
* ```
*
* @param id
* @param fn
*/
wrap(id, fn) {
return (...args) => {
this.start(id);
const res = fn.apply(null, args);
this.end(id);
return res;
};
}
/**
* Estimates the internal overhead of the {@link Profiler.start} and
* {@link Profiler.end} methods by performing given number of `iter`ations
* (distributed over 10 runs) and taking the mean duration of those runs.
*
* @remarks
* The computed overhead (per iteration) will be subtracted from the all
* recorded profiles (see {@link Profiler.deref} and
* {@link Profiler.asCSV}).
*
* @param iter
*/
warmup(iter) {
let total = 0;
for (let i = 0; i < 10; i++) {
const id = `prof-${i}`;
const [_, taken] = benchResult(() => {
this.start(id);
this.end(id);
}, ~~(iter / 10));
total += taken;
}
this._overhead = total / iter;
}
/**
* Same as {@link Profiler.deref}.
*/
toJSON() {
return this.deref();
}
/**
* Returns {@link Profiler.deref} formatted as CSV string.
*/
asCSV() {
const res = [
`"id","total (ms)","time/call (ms)","total (%)","calls","calls (%)","max depth"`
];
const stats = this.deref();
for (let id of Object.keys(stats).sort()) {
const {
total,
timePerCall,
totalPercent,
calls,
callsPercent,
maxDepth
} = stats[id];
res.push(
[
`"${id}"`,
total.toFixed(5),
timePerCall.toFixed(5),
totalPercent.toFixed(2),
calls,
callsPercent.toFixed(2),
maxDepth
].join(",")
);
}
return res.join("\n");
}
newProfile(t0) {
return {
t0: [t0],
total: typeof t0 === "bigint" ? BigInt(0) : 0,
calls: 1,
maxDepth: 1
};
}
}
export {
Profiler
};