3ebcb3f758
Adds a `throughput` benchmark to every target and closes two small test-coverage documentation/QA gaps. One PR, no merge of binding code beyond the additive benchmarks and one C test. ## 1. Per-binding throughput benchmarks (all 9 targets) Each benchmark feeds a deterministic synthetic OHLCV series through three indicators chosen by **FFI call-signature archetype** (not algorithm — the same Rust core runs underneath all bindings): - `SMA(20)` — 1-in → 1-out (baseline boundary cost) - `ATR(14)` — multi-in → 1-out (input marshalling) - `MACD(12,26,9)` — 1-in → multi-out (output marshalling) Streaming is timed for all three; batch for the single-output SMA and ATR (median of 3 runs, after a warmup pass). New: Python (PyO3), WASM, C (CMake), C# (Stopwatch), Go, Java (FFM), R, and the Rust core baseline (`examples/rust/.../throughput.rs`, **no FFI** — the ceiling the bindings are measured against and the value their batch paths converge towards). Node already had `throughput.js`. **Not a speed claim:** there is no comparable streaming TA library for C, C#, Go, Java, R or WASM to compare against, so these are raw per-binding throughput numbers documenting each language's FFI overhead — see BENCHMARKS.md §3. The "Wickra is fast" claim still lives in §1/§2 (Rust core + the Python/Rust cross-library runs). ## 2. README `## Testing`: C# and C bullets The section listed every layer except C# and C, even though both have suites. Adds the two missing bullets. ## 3. C archetype ctest `examples/c/archetypes.c` drives one indicator per FFI archetype through the real C boundary (scalar + batch==streaming, multi-output, bars, profile, array input) plus reset, invalid-parameter and NULL-safety — the C counterpart of the Go/R/Java archetype suites. Runs on three OSes via the existing CMake/ctest. ## Notes - Benchmarks are not CI-gated (manual-run scripts, like the existing `throughput.js`); no `ci.yml`/`release.yml` changes. - Docs: BENCHMARKS.md §3, a `## Benchmark` section in every binding README, a CHANGELOG entry. - Verified locally by running: Rust, Python, C, C#, Go, Java (real numbers); the C archetype ctest with `-Wall -Wextra -Wpedantic -Werror`. WASM and R are API-correct and syntax-checked but need their own toolchains to run.
128 lines
4.5 KiB
JavaScript
128 lines
4.5 KiB
JavaScript
// Throughput benchmark for the Wickra WebAssembly bindings.
|
|
//
|
|
// Measures how many indicator updates per second the wasm binding sustains,
|
|
// both per-tick (streaming `update`) and bulk (`batch`), over a synthetic
|
|
// OHLCV series. It is the wasm counterpart of the Node `throughput.js` and the
|
|
// Rust criterion benches: it benchmarks Wickra's own O(1) streaming engine
|
|
// across the JS<->wasm boundary (there is no install-free TA library with a
|
|
// comparable surface to compare against), so the headline number is raw
|
|
// per-binding throughput / FFI overhead, not a cross-library ratio.
|
|
//
|
|
// Three indicators are timed, chosen by FFI call-signature archetype rather
|
|
// than algorithm (the algorithm is identical to the Rust core; only the
|
|
// boundary cost differs): SMA (1-in -> 1-out), ATR (multi-in -> 1-out), and
|
|
// MACD (1-in -> multi-out). Streaming is timed for all three; batch only for
|
|
// the single-output SMA and ATR (multi-output batch is not exposed uniformly).
|
|
//
|
|
// Build the nodejs-target package first (needs the wasm32-unknown-unknown
|
|
// target, i.e. a rustup toolchain), then run:
|
|
//
|
|
// cd bindings/wasm && wasm-pack build --target nodejs --out-dir pkg-node --release
|
|
// node benchmarks/throughput.mjs # 200k bars (default)
|
|
// node benchmarks/throughput.mjs --bars 1000000
|
|
|
|
import { createRequire } from 'node:module';
|
|
import { hrtime } from 'node:process';
|
|
|
|
const require = createRequire(import.meta.url);
|
|
// wasm-pack --target nodejs emits a CommonJS module named after the crate.
|
|
const wasm = require('../pkg-node/wickra_wasm.js');
|
|
const { SMA, ATR, MACD } = wasm;
|
|
|
|
function parseBars() {
|
|
const idx = process.argv.indexOf('--bars');
|
|
if (idx !== -1 && process.argv[idx + 1]) {
|
|
const n = Number(process.argv[idx + 1]);
|
|
if (Number.isFinite(n) && n >= 1000) return Math.floor(n);
|
|
console.error('--bars must be a number >= 1000');
|
|
process.exit(1);
|
|
}
|
|
return 200_000;
|
|
}
|
|
|
|
const BARS = parseBars();
|
|
|
|
// Deterministic synthetic OHLCV (no RNG, so runs are comparable).
|
|
const close = new Float64Array(BARS);
|
|
const high = new Float64Array(BARS);
|
|
const low = new Float64Array(BARS);
|
|
for (let i = 0; i < BARS; i++) {
|
|
const mid = 100 + Math.sin(i * 0.001) * 20 + i * 1e-4;
|
|
close[i] = mid + Math.sin(i * 0.05) * 2;
|
|
high[i] = Math.max(close[i], mid) + 1.5;
|
|
low[i] = Math.min(close[i], mid) - 1.5;
|
|
}
|
|
|
|
// Median elapsed-ns over a few repetitions, after one warmup pass.
|
|
function timeNs(fn, reps = 3) {
|
|
fn(); // warmup (JIT + cache)
|
|
const samples = [];
|
|
for (let r = 0; r < reps; r++) {
|
|
const t0 = hrtime.bigint();
|
|
fn();
|
|
samples.push(Number(hrtime.bigint() - t0));
|
|
}
|
|
samples.sort((a, b) => a - b);
|
|
return samples[Math.floor(samples.length / 2)];
|
|
}
|
|
|
|
function mupsFromNs(ns) {
|
|
return BARS / (ns / 1e9) / 1e6; // million updates per second
|
|
}
|
|
|
|
// SMA (scalar 1-in/1-out), ATR (multi-in/1-out), MACD (1-in/multi-out).
|
|
const indicators = [
|
|
{
|
|
name: 'SMA(20)',
|
|
stream: () => {
|
|
const ind = new SMA(20);
|
|
for (let i = 0; i < BARS; i++) ind.update(close[i]);
|
|
ind.free();
|
|
},
|
|
batch: () => {
|
|
const ind = new SMA(20);
|
|
ind.batch(close);
|
|
ind.free();
|
|
},
|
|
},
|
|
{
|
|
name: 'ATR(14)',
|
|
stream: () => {
|
|
const ind = new ATR(14);
|
|
for (let i = 0; i < BARS; i++) ind.update(high[i], low[i], close[i]);
|
|
ind.free();
|
|
},
|
|
batch: () => {
|
|
const ind = new ATR(14);
|
|
ind.batch(high, low, close);
|
|
ind.free();
|
|
},
|
|
},
|
|
{
|
|
name: 'MACD(12,26,9)',
|
|
stream: () => {
|
|
const ind = new MACD(12, 26, 9);
|
|
for (let i = 0; i < BARS; i++) ind.update(close[i]);
|
|
ind.free();
|
|
},
|
|
batch: null, // multi-output: streaming only
|
|
},
|
|
];
|
|
|
|
console.log(`Wickra WASM throughput — ${BARS.toLocaleString('en-US')} bars (median of 3 runs)\n`);
|
|
console.log(`${'Indicator'.padEnd(22)}${'streaming (Mupd/s)'.padStart(20)}${'batch (Mupd/s)'.padStart(18)}`);
|
|
console.log('-'.repeat(60));
|
|
|
|
for (const ind of indicators) {
|
|
const streamMups = mupsFromNs(timeNs(ind.stream)).toFixed(1);
|
|
const batchMups = ind.batch ? mupsFromNs(timeNs(ind.batch)).toFixed(1) : '—';
|
|
console.log(`${ind.name.padEnd(22)}${streamMups.padStart(20)}${batchMups.padStart(18)}`);
|
|
}
|
|
|
|
console.log(
|
|
'\nMupd/s = million indicator updates per second. Streaming is the per-tick\n' +
|
|
'`update` path crossing the JS<->wasm boundary once per value; batch is the\n' +
|
|
'bulk array path (one boundary crossing). Higher is better. Numbers are\n' +
|
|
'machine-dependent — use them for relative comparison, not as a speed claim.',
|
|
);
|