NumSharp Benchmark Harness
The exhaustive guide is benchmark/CLAUDE.md (architecture, every suite, config, troubleshooting). This skill
is the distilled map + the actionable playbooks. Read benchmark/CLAUDE.md when you need depth beyond this.
THE convention: NPY/NS (memorize this)
ratio = NumPy_ms / NumSharp_ms. >1 = NumSharp faster, <1 = slower, =1 = parity. Higher is better.
Timing basis: best window (min) — ratios compare each side's per-case minimum, never the mean. Interference
(GC pauses, page-fault storms, ambient machine load) only ever adds time, and it lands almost entirely in the
NumSharp side's right tail, so comparing means turned machine state into fake ratio regressions. Min-based
harnesses run each case to a ~200 ms time budget (a >20 ms/call op runs exactly 100 times); the C# op-matrix keeps
BenchmarkDotNet's 50 iterations. Per-case means stay in the JSON (numpy_mean_ms/numsharp_mean_ms) as tail
diagnostics — a large mean/min gap on one side flags a contaminated window.
Used everywhere — matrices, geomeans, commit messages, every *_sheet.py. The report's status icons
(✅/🟡/🟠/🔴 faster→slower · ▫ negligible/excluded · ⚪ pending · ❌ crashed) and their exact cutoffs live in
references/run-and-report.md; the source of truth is get_status() + classify() in scripts/merge-results.py,
so read those rather than trusting a hardcoded threshold. (The legacy run-benchmarks.ps1 prints the INVERSE
NS/NPY — prefer NPY/NS for anything new.)
THE pitfall: Debug taints timings ~2×
Ad-hoc dotnet run file.cs / dotnet_run (file-based apps) compile both the script AND any #:project
NumSharp.Core in Debug (DebuggableAttribute(DisableOptimizations)), which the JIT honors even over
[MethodImpl(AggressiveOptimization)]. Hand-written C# hot loops run ~2× slow; IL-emitted kernels look normal.
Every timing script MUST run dotnet run -c Release - < script.cs. #:property Optimize=true fixes only the
script assembly, not Core. The BenchmarkDotNet projects are exempt (they mandate -c Release).
Structure — two sides, joined on (op, dtype, N)
| Side |
Where |
What |
| C# |
benchmark/NumSharp.Benchmark.CSharp/Benchmarks/<Category>/*.cs |
Core-only BenchmarkDotNet project plus shared benchmark classes; [Benchmark(Description="np.foo(a)")] methods. |
| OpenBLAS C# |
benchmark/NumSharp.Benchmark.CSharp.OpenBLAS/ |
Enables one-thread OpenBLAS and reruns the shared official LinearAlgebra classes/config. |
| NumPy |
benchmark/NumSharp.Benchmark.Python/numpy_benchmark.py |
run_<suite>_benchmarks(...) emitting BenchmarkResult rows. |
| Merge |
benchmark/scripts/{merge-results,merge-backend-profiles}.py |
Joins language timings, then backend profiles on the exact cell. |
| Orchestrator |
benchmark/run_benchmark.py |
Builds C#, runs each suite (BDN) + warm NumPy across 1K/100K/10M, merges, snapshots. |
run_benchmark.py --depth pass|light|measure is the execution-depth contract shared by BDN and
NumPy (defined once in scripts/benchmark_modes.py, so the two languages can't drift). pass is an
execution gate (one workload call, zero warmups); light uses 8 BDN measurements after 3 warmups plus
one-sixth of NumPy's normal time budget; measure is the full 50-measurement / 5-warmup BDN + full
NumPy publication profile. Pass/light write only under benchmark/results/ and must never replace
root/docs/history artifacts. --dtypes f32,float64 selects comma-separated dtypes/aliases (canonical
set + aliases in benchmark_modes.py); a multi-dtype scenario (for example src→dst casts) matches when
any dtype side is requested.
The join is by normalized op name: normalize_op_name strips the dtype tag, [annotations], and
identifier-only arg lists — so C# "np.foo(a)" and NumPy "np.foo" both collapse to np.foo and join. Get the
names to normalize identically or the row shows as "C# not run" / "NumPy only".
The matrix + subsystems
- Op matrix — 18 comparison suites, each a C# namespace filter in
run_benchmark.py's SUITES map
(arithmetic, unary, reduction, broadcast, creation, manipulation, slicing, comparison, bitwise, logic, statistics, sorting, linalg, selection, fft, random, ndarray, api). Universal-tier rule: an
operation/dtype with both 1K and 100K must also schedule 10M; the official merge checks NumPy and C#
independently. Scalar/1K-only dispatch cases are the only intentional non-throughput exception.
Allocation-heavy families map the 10M label to 1M physical elements symmetrically on both sides;
1M is not a separate report/dashboard tier.
- Backend profiles — the complete official LinearAlgebra BDN classes run under both the Core-only
executable and
NumSharp.Benchmark.CSharp.OpenBLAS; a merge gate rejects any missing exact-cell OpenBLAS
peer. benchmark/backends/backend_profiles.py supplements backend-only product/LAPACK routes with
the same schema; MissingBackendException and NotSupportedException are availability outcomes. Every
profile publishes 1K / 100K / 10M; operation-specific physical work remains bounded (LAPACK maps
those tiers to matrix sides 32 / 96 / 128). Separate profile JSON files are merged into one
effective dataset, with actual_backend: managed retained for controls that never dispatch to BLAS.
- Five appended subsystems (own result models, appended not merged):
nditer (iterator machinery),
layout (op × 8 memory layouts × dtype), operand (1-D/scalar/mixed/broadcast), cast (astype 15×15 × layout),
and fusion (np.evaluate). Each is a
*_bench.{cs,py} pair + a *_sheet.py renderer.
Playbook — add a benchmark for a new op
The most common task. Full worked example in references/add-benchmark.md. In brief:
- C# side — add a
[Benchmark(Description = "np.foo(a)")] method to the class in
Benchmarks/<Category>/ that fits (or a new class in that namespace so the suite's *Benchmarks.<Category>.*
filter auto-includes it). Use BenchmarkBase (single-dtype, float64) like the manipulation classes, or
TypedBenchmarkBase (dtype-swept) like arithmetic. [Params(Medium, Large)] for size.
- NumPy twin — append to the matching
run_<suite>_benchmarks(...) in numpy_benchmark.py, setting
r.name, r.category, r.suite, r.dtype. Make .name normalize to the C# Description ("np.foo" ↔ "np.foo(a)").
- Smoke it (this is usually the right scope — a full measured run is the post-release CI job):
python benchmark/run_benchmark.py --depth pass --suites <suite> --dtypes <dtype> executes each
selected BDN/NumPy cell exactly once and fails on workload errors. Use --depth light for a rough ratio.
- Full numbers come from
python run_benchmark.py --depth measure (or the benchmark.yml post-release workflow).
Other tasks → where to go
- Run the suite (official / subset), interpret the report, the reports/UI surfaces + snapshots →
references/run-and-report.md. (The human-facing UI is the DocFX page docs/website-src/docs/benchmarks-dashboard.md; its Function Explorer data is generated, while narrative cards are curated. Generated dashboard data lives on the orphan data branch and is consumed at docs-build via the refs/data submodule (floated to the branch tip); publisher in tools/dashboard_data/ — see references/run-and-report.md.)
- Add or edit a matrix subsystem or backend profile case →
references/subsystems.md.
- "Is
np.<foo> benchmarked yet? What's still missing?" → the generated coverage ledger
benchmark/coverage/generated/summary.md (+ coverage.{json,csv}), refreshed by scripts/audit_coverage.py
from the [Benchmark(Description)] attributes across the op-matrix namespaces plus the reviewed
coverage/overrides.json. Its "Missing benchmark coverage" table is the to-do list; its route map says which
APIs need OpenBLAS. This is a source-level audit, not a timing run.
- Everything else (all suites, config internals, troubleshooting, type map) →
benchmark/CLAUDE.md.
Gotchas
BenchmarkDotNet's out-of-process toolchain fails here ("project names need to be unique") because sibling
.claude/worktrees/ checkouts hold same-named benchmark projects. The official run uses InProcessEmit
(OfficialBenchmarkConfig). For a smoke check use --list flat (reflection only, no toolchain) rather than
trying to run BDN ad-hoc.
What we commit is benchmark/history/<date>_<sha>/, not the gitignored benchmark/results/<ts>/ scratch.
Reference benchmark/history/latest/benchmark-report.md.
A row shows "C# not run" / "NumPy only"? The two sides' names didn't normalize to the same join key — the
first thing to check for any new/renamed benchmark. scripts/check_smoke_joins.py verifies every C#
[Benchmark(Description)] ↔ NumPy name join in both directions using the exact merge normalizer (from a quick
numpy_benchmark.py --quick --size small smoke run), so it catches the mismatch without a full measured run.
These are mostly view ops → sub-µs. flip/rot90/transpose-aliases are O(1) views; their benchmark tracks
allocation/dispatch overhead, not throughput. benchmark/scripts/credibility.py marks every reviewed
O(1)-in-N scenario negligible regardless of measured duration, and every dashboard rollup must honor that
status. The proof ledger is benchmark/O1_EXCLUSIONS.md. Ops doing real work (trim_zeros, reductions) are
where ratios are meaningful.
A 30–50 % faster excursion in a few BDN iterations that never reproduces is turbo boost, not code.
The host is a hybrid i9-13900K whose High-performance plan boosts Aggressively (~1.7× clock swing,
hidden from powercfg -query until unhidden); BDN keeps fast outliers, so the lucky window becomes
Statistics.Min. run_benchmark.py now locks the clock (boost Disabled, restored on exit) and pins
BOTH languages to one P-core (NUMSHARP_BENCHMARK_AFFINITY, default 0x4) — see
benchmark/scripts/benchmark_host.py; --no-lock-clock / --no-pin-core opt out — and it covers EVERY
runner (sheets, backend profiles and the nditer harness pin themselves after their build, and every
dotnet run - script / Python twin inherits the mask at spawn). Both languages also trim outliers on
BOTH tails (OutlierMode.RemoveAll in every BDN config, Tukey k=1.5 in numpy_benchmark.py). Pinning trap: a
bare ctypes.windll.kernel32.SetProcessAffinityMask(GetCurrentProcess(), …) (no argtypes/restype)
fails silently — always read the mask back.
References
references/add-benchmark.md — the detailed add-a-benchmark playbook (C# + NumPy twin + join-key rules + smoke).
references/run-and-report.md — running the official run / subsets, the report + history snapshots, InProcessEmit.
references/subsystems.md — backend profiles plus the five appended subsystems.
1---2name: benchmark3description: NumSharp's NumPy-vs-NumSharp performance harness — the op/dtype/N matrix (C# BenchmarkDotNet vs a warm NumPy process), unified Managed/OpenBLAS backend profiles, and five appended subsystems (nditer, layout, operand, cast, fusion), all in the NPY/NS convention. Use this whenever you add a benchmark for an np.* op, wire a C# benchmark to its NumPy twin, run the official suite or a subset, read/interpret the ratio matrix or history snapshots, add a whole subsystem, or debug a suspicious measurement (the Debug-taint 2x pitfall, the InProcessEmit toolchain). Trigger on: "benchmark", "add a benchmark", "how fast is np.add vs numpy", "run_benchmark.py", "BenchmarkDotNet", "NPY/NS ratio", "perf comparison", "benchmark np.add", "benchmark-report", "history snapshot", "why is my timing 2x slow". Reach for it before quoting any NumSharp-vs-NumPy speed number.4---56# NumSharp Benchmark Harness78The exhaustive guide is **`benchmark/CLAUDE.md`** (architecture, every suite, config, troubleshooting). This skill9is the distilled map + the actionable playbooks. Read `benchmark/CLAUDE.md` when you need depth beyond this.1011## THE convention: NPY/NS (memorize this)1213> **ratio = NumPy_ms / NumSharp_ms.** `>1` = NumSharp **faster**, `<1` = slower, `=1` = parity. **Higher is better.**14> **Timing basis: best window (min)** — ratios compare each side's per-case *minimum*, never the mean. Interference15> (GC pauses, page-fault storms, ambient machine load) only ever *adds* time, and it lands almost entirely in the16> NumSharp side's right tail, so comparing means turned machine state into fake ratio regressions. Min-based17> harnesses run each case to a ~200 ms time budget (a >20 ms/call op runs exactly 100 times); the C# op-matrix keeps18> BenchmarkDotNet's 50 iterations. Per-case means stay in the JSON (`numpy_mean_ms`/`numsharp_mean_ms`) as tail19> diagnostics — a large mean/min gap on one side flags a contaminated window.2021Used everywhere — matrices, geomeans, commit messages, every `*_sheet.py`. The report's status icons22(✅/🟡/🟠/🔴 faster→slower · ▫ negligible/excluded · ⚪ pending · ❌ crashed) and their exact cutoffs live in23`references/run-and-report.md`; the source of truth is `get_status()` + `classify()` in `scripts/merge-results.py`,24so read those rather than trusting a hardcoded threshold. (The legacy `run-benchmarks.ps1` prints the INVERSE25NS/NPY — prefer NPY/NS for anything new.)2627## THE pitfall: Debug taints timings ~2×2829Ad-hoc `dotnet run file.cs` / `dotnet_run` (file-based apps) compile **both the script AND any `#:project`30NumSharp.Core in Debug** (`DebuggableAttribute(DisableOptimizations)`), which the JIT honors even over31`[MethodImpl(AggressiveOptimization)]`. Hand-written C# hot loops run ~2× slow; IL-emitted kernels look normal.32**Every timing script MUST run `dotnet run -c Release - < script.cs`.** `#:property Optimize=true` fixes only the33script assembly, not Core. The BenchmarkDotNet projects are exempt (they mandate `-c Release`).3435## Structure — two sides, joined on (op, dtype, N)3637| Side | Where | What |38|------|-------|------|39| C# | `benchmark/NumSharp.Benchmark.CSharp/Benchmarks/<Category>/*.cs` | Core-only BenchmarkDotNet project plus shared benchmark classes; `[Benchmark(Description="np.foo(a)")]` methods. |40| OpenBLAS C# | `benchmark/NumSharp.Benchmark.CSharp.OpenBLAS/` | Enables one-thread OpenBLAS and reruns the shared official LinearAlgebra classes/config. |41| NumPy | `benchmark/NumSharp.Benchmark.Python/numpy_benchmark.py` | `run_<suite>_benchmarks(...)` emitting `BenchmarkResult` rows. |42| Merge | `benchmark/scripts/{merge-results,merge-backend-profiles}.py` | Joins language timings, then backend profiles on the exact cell. |43| Orchestrator | `benchmark/run_benchmark.py` | Builds C#, runs each suite (BDN) + warm NumPy across 1K/100K/10M, merges, snapshots. |4445`run_benchmark.py --depth pass|light|measure` is the execution-depth contract shared by BDN and46NumPy (defined once in `scripts/benchmark_modes.py`, so the two languages can't drift). `pass` is an47execution gate (one workload call, zero warmups); `light` uses 8 BDN measurements after 3 warmups plus48one-sixth of NumPy's normal time budget; `measure` is the full 50-measurement / 5-warmup BDN + full49NumPy publication profile. Pass/light write only under `benchmark/results/` and must never replace50root/docs/history artifacts. `--dtypes f32,float64` selects comma-separated dtypes/aliases (canonical51set + aliases in `benchmark_modes.py`); a multi-dtype scenario (for example src→dst casts) matches when52any dtype side is requested.5354The join is by **normalized op name**: `normalize_op_name` strips the dtype tag, `[annotations]`, and55identifier-only arg lists — so C# `"np.foo(a)"` and NumPy `"np.foo"` both collapse to `np.foo` and join. Get the56names to normalize identically or the row shows as "C# not run" / "NumPy only".5758## The matrix + subsystems5960- **Op matrix** — 18 comparison suites, each a C# namespace filter in `run_benchmark.py`'s `SUITES` map61 (`arithmetic, unary, reduction, broadcast, creation, manipulation, slicing, comparison, bitwise, logic,62 statistics, sorting, linalg, selection, fft, random, ndarray, api`). Universal-tier rule: an63 operation/dtype with both 1K and 100K must also schedule 10M; the official merge checks NumPy and C#64 independently. Scalar/1K-only dispatch cases are the only intentional non-throughput exception.65 Allocation-heavy families map the 10M label to 1M physical elements symmetrically on both sides;66 1M is not a separate report/dashboard tier.67- **Backend profiles** — the complete official LinearAlgebra BDN classes run under both the Core-only68 executable and `NumSharp.Benchmark.CSharp.OpenBLAS`; a merge gate rejects any missing exact-cell OpenBLAS69 peer. `benchmark/backends/backend_profiles.py` supplements backend-only product/LAPACK routes with70 the same schema; MissingBackendException and NotSupportedException are availability outcomes. Every71 profile publishes `1K / 100K / 10M`; operation-specific physical work remains bounded (LAPACK maps72 those tiers to matrix sides `32 / 96 / 128`). Separate profile JSON files are merged into one73 effective dataset, with `actual_backend: managed` retained for controls that never dispatch to BLAS.74- **Five appended subsystems** (own result models, appended not merged): `nditer` (iterator machinery),75 `layout` (op × 8 memory layouts × dtype), `operand` (1-D/scalar/mixed/broadcast), `cast` (astype 15×15 × layout),76 and `fusion` (`np.evaluate`). Each is a77 `*_bench.{cs,py}` pair + a `*_sheet.py` renderer.7879## Playbook — add a benchmark for a new op8081The most common task. Full worked example in **`references/add-benchmark.md`**. In brief:82831. **C# side** — add a `[Benchmark(Description = "np.foo(a)")]` method to the class in84 `Benchmarks/<Category>/` that fits (or a new class in that namespace so the suite's `*Benchmarks.<Category>.*`85 filter auto-includes it). Use `BenchmarkBase` (single-dtype, float64) like the manipulation classes, or86 `TypedBenchmarkBase` (dtype-swept) like arithmetic. `[Params(Medium, Large)]` for size.872. **NumPy twin** — append to the matching `run_<suite>_benchmarks(...)` in `numpy_benchmark.py`, setting88 `r.name, r.category, r.suite, r.dtype`. Make `.name` normalize to the C# Description (`"np.foo"` ↔ `"np.foo(a)"`).893. **Smoke it** (this is usually the right scope — a full measured run is the post-release CI job):90 `python benchmark/run_benchmark.py --depth pass --suites <suite> --dtypes <dtype>` executes each91 selected BDN/NumPy cell exactly once and fails on workload errors. Use `--depth light` for a rough ratio.924. **Full numbers** come from `python run_benchmark.py --depth measure` (or the `benchmark.yml` post-release workflow).9394## Other tasks → where to go9596- **Run the suite (official / subset), interpret the report, the reports/UI surfaces + snapshots** → `references/run-and-report.md`. (The human-facing UI is the DocFX page `docs/website-src/docs/benchmarks-dashboard.md`; its Function Explorer data is generated, while narrative cards are curated. Generated dashboard data lives on the orphan **`data`** branch and is consumed at docs-build via the **`refs/data`** submodule (floated to the branch tip); publisher in `tools/dashboard_data/` — see `references/run-and-report.md`.)97- **Add or edit a matrix subsystem or backend profile case** → `references/subsystems.md`.98- **"Is `np.<foo>` benchmarked yet? What's still missing?"** → the generated coverage ledger99 `benchmark/coverage/generated/summary.md` (+ `coverage.{json,csv}`), refreshed by `scripts/audit_coverage.py`100 from the `[Benchmark(Description)]` attributes across the op-matrix namespaces plus the reviewed101 `coverage/overrides.json`. Its "Missing benchmark coverage" table is the to-do list; its route map says which102 APIs need OpenBLAS. This is a source-level audit, not a timing run.103- **Everything else (all suites, config internals, troubleshooting, type map)** → `benchmark/CLAUDE.md`.104105## Gotchas106107- **BenchmarkDotNet's out-of-process toolchain fails here** ("project names need to be unique") because sibling108 `.claude/worktrees/` checkouts hold same-named benchmark projects. The official run uses **InProcessEmit**109 (`OfficialBenchmarkConfig`). For a smoke check use `--list flat` (reflection only, no toolchain) rather than110 trying to run BDN ad-hoc.111- **What we commit is `benchmark/history/<date>_<sha>/`**, not the gitignored `benchmark/results/<ts>/` scratch.112 Reference `benchmark/history/latest/benchmark-report.md`.113- **A row shows "C# not run" / "NumPy only"?** The two sides' names didn't normalize to the same join key — the114 first thing to check for any new/renamed benchmark. `scripts/check_smoke_joins.py` verifies every C#115 `[Benchmark(Description)]` ↔ NumPy name join in both directions using the exact merge normalizer (from a quick116 `numpy_benchmark.py --quick --size small` smoke run), so it catches the mismatch without a full measured run.117- **These are mostly view ops → sub-µs.** flip/rot90/transpose-aliases are O(1) views; their benchmark tracks118 allocation/dispatch overhead, not throughput. `benchmark/scripts/credibility.py` marks every reviewed119 O(1)-in-N scenario negligible regardless of measured duration, and every dashboard rollup must honor that120 status. The proof ledger is `benchmark/O1_EXCLUSIONS.md`. Ops doing real work (trim_zeros, reductions) are121 where ratios are meaningful.122123- **A 30–50 % *faster* excursion in a few BDN iterations that never reproduces is turbo boost, not code.**124 The host is a hybrid i9-13900K whose High-performance plan boosts *Aggressively* (~1.7× clock swing,125 hidden from `powercfg -query` until unhidden); BDN keeps fast outliers, so the lucky window becomes126 `Statistics.Min`. `run_benchmark.py` now locks the clock (boost Disabled, restored on exit) and pins127 BOTH languages to one P-core (`NUMSHARP_BENCHMARK_AFFINITY`, default `0x4`) — see128 `benchmark/scripts/benchmark_host.py`; `--no-lock-clock` / `--no-pin-core` opt out — and it covers EVERY129 runner (sheets, backend profiles and the nditer harness pin themselves after their build, and every130 `dotnet run -` script / Python twin inherits the mask at spawn). Both languages also trim outliers on131 BOTH tails (`OutlierMode.RemoveAll` in every BDN config, Tukey k=1.5 in `numpy_benchmark.py`). Pinning trap: a132 bare `ctypes.windll.kernel32.SetProcessAffinityMask(GetCurrentProcess(), …)` (no `argtypes`/`restype`)133 fails silently — always read the mask back.134135## References136137- `references/add-benchmark.md` — the detailed add-a-benchmark playbook (C# + NumPy twin + join-key rules + smoke).138- `references/run-and-report.md` — running the official run / subsets, the report + history snapshots, InProcessEmit.139- `references/subsystems.md` — backend profiles plus the five appended subsystems.