- name
- specialized-path
- description
- How to discover, validate, build, and honestly verify a SPECIALIZED path for ANY function — a cheaper route for a recognizable SUBSET of an operation's inputs, sitting behind the general implementation with a gate + fallback so the general case never regresses and semantics stay bit-identical. The cheaper route can be a different KERNEL (SIMD vs scalar, gemv/syrk/stencil), a different KERNEL CONTRACT (one call per block instead of per row), a REPRESENTATION trick (bit-level f16, byte-lane bool, widen-compute-narrow), a different ALGORITHM (hash vs sort, with an observed-growth bailout), a VIEW instead of a copy (O(1) stride tricks), an EARLY EXIT (all-false / empty / k==0 / provably-constant output), a FIXED-COST BYPASS (skip iterator/broadcast setup for the trivial subset), a MEMORY strategy (cpblk, prefetch, write-once, privatized accumulators, retained scratch), a FUSED pass, a composition of EXISTING validated kernels, wrapper-glue removal on a parity-bound op, or a native BACKEND seam. Use this whenever you make an np.* op faster or cheaper, ask "why is <op> slow / can we beat NumPy / why is 1K slow", add a fast path / special-case / view-return / short-circuit, optimize a composition, decide whether a speedup is even reachable, or need to prove a fast path actually ENGAGED. Trigger on: "specialized path", "fast path", "special case", "make <op> faster/cheaper", "why is <op> slow", "beat numpy", "return a view instead of a copy", "hash vs sort", "gemv/syrk/stencil/fused", "short-circuit / early exit", "degenerate/tall-skinny/narrow shape", "is this memory-bound", "fixed cost / small-N floor", "bit-level kernel", "widen-compute-narrow", "floor probe", "did the fast path engage", "measure the speedup". Reach for it before hand-writing a special case OR claiming a win.
# Specialized paths (for any function)
A **specialized path** is a cheaper route for a recognizable SUBSET of an operation's inputs, sitting
behind the general implementation: `if (subset recognized) cheaper(); else general();`. The general
implementation stays correct for everything; the specialized route only has to be correct+cheaper for
the subset it claims, and **must fall back cleanly (general case unchanged) and stay bit-identical in
semantics.** That invariant is the whole game — everything below serves it.
The cycle is **DISCOVER → EXPERIMENT → CREATE → MEASURE → VERIFY.** Most of the value (and the failure
modes) is in EXPERIMENT (prove the win before building) and MEASURE/VERIFY (prove it after). Depth is
in `references/`; this file is the map + the hard-won rules. **`references/catalog.md` lists every
specialized path that ships in NumSharp today** — by kind, with its file, gate condition, measured
crossover and commit — copy the nearest exemplar rather than inventing a shape.
**Hard rule (Eli): NumSharp kernels are single-threaded.** No `Parallel.For`/`Task`/threads in any
kernel or np.* path, ever — a parallel int min/max that was bit-exact and 5× NumPy was reverted
(`1c709bf0` → `7b8dac64`). Parity with single-core NumPy on a memory-bound op is the ACCEPTED end
state, not a defect to beat with threads. The only exception is the opt-in, off-by-default
`np.multithreading`, which must not be extended.
## The KINDS of specialized path (recognize which you're building)
The "cheaper route" is not always a SIMD kernel. Each kind below is the same pattern — recognize a
subset, take a cheaper route, gate + fallback — and each has a live NumSharp example (files + gates in
`references/catalog.md`):
| kind | recognized subset | cheaper route | NumSharp example |
|------|-------------------|---------------|------------------|
| **kernel swap** | shape / layout / dtype | a tighter compute kernel | gemv/gevm (`N==1`/`M==1`), syrk (`A@A.T`), `diff` stencil, f32 `argmax` tournament, MAXPS skip-NaN `nanmax`, i64→f64 axis widening |
| **kernel contract** | narrow inner axis × many rows | the kernel loops the outer axis ITSELF — prologue + driver once per BLOCK, not per row | `ND2DElementwiseKernel` (+ masked form), flat gather/scatter for `a[idx]` |
| **representation** | a dtype whose op is defined on its BIT PATTERN, or a narrow lane | integer lane algebra on raw bits; byte lanes; widen-compute-narrow | f16 min/max/cmp/clip/round on the `bits ^ (0x8000 \| asr15)` order key, f16 ±×÷ via f32 SIMD, bool `min(v,1)` byte lanes, `sum(bool)` = popcount, Half sum in an f32 shadow |
| **algorithm swap** | a data property (cardinality, value range) — possibly only OBSERVABLE while running | a different algorithm, or a mid-run bailout | `unique` hash(int)/radix(float) + bailout to radix at 2^17 distinct; `isin` table vs searchsorted by value range; `np.block` concat vs single-alloc; `kron` direct vs tile |
| **compose existing kernels** | a subset two already-validated kernels serve together | route through them — zero new SIMD IL, zero fuzz risk | `bool<<scalar` = `astype` + the SIMD shift kernel ≤ 262144 elems; gevm = a widened `SimpleStrided` condition |
| **view vs copy** | contiguity / stride relationship | an O(1) view, no data movement | `flip`/`rot90` (stride negation), `trim_zeros` (bounding box), `unstack`, reshape-when-contig |
| **early exit / constant output** | a structural state that decides the result | skip the work, or just allocate the answer | `all`/`any` early exit, `where(cond)` all-false prescan, matmul `k==0`, bool argmax find-first, `bool >> s≠0` → lazy zeros |
| **fixed-cost bypass** | the TRIVIAL subset of a route whose per-call SETUP dominates at small N | skip the iterator / broadcast construction entirely | same-layout copy hoisted above `CreateCopyState`; small single-broadcast → direct SimdChunk (≤ 8192); same-dims skip of `Broadcast` in the `*UfuncInto` routes |
| **memory strategy** | a size / layout / microarchitecture threshold | a cheaper allocation or access pattern | `cpblk` contiguous copy, `take` prefetch above 2 MiB, `np.tri` write-once below 64 MiB, `bincount` privatized accumulators ≤ 2048 bins, `fillZeros:false` for fully-written outputs, LAPACK scratch retention pool |
| **fused pass** | contiguous / no-cast shape | collapse a composition to one pass | `np.select` fused chain, `np.evaluate`, `ediff1d`, the fused bool shift body |
| **wrapper-glue removal** | a PARITY-BOUND op (both sides share a native floor) | strip managed overhead around the kernel | `cond`'s two string-slice parses (1.7 µs of Regex EACH) → `Slice[]`; `MakeGeneric` alias → `AsGeneric`; per-call `long[0]` → `Array.Empty` |
| **backend seam** | operands a backend implements | a native route, else managed | `IBlasBackend.Try*` (false → managed), `ISlidingDotBackend.DotBatch` (dispatch once per call, not per position) |
## THE meta-rule: prove the win on the subset BEFORE you build
Not every specialized path is worth it. For **view / early-exit** kinds the win is asymptotic (O(1) vs
O(N)) — obviously worth it, but you still must prove *semantics are identical* (see VERIFY). For
**kernel / algorithm / memory** kinds the win is empirical — you must measure it, and often it only
wins in a *band* (a crossover). Two pre-checks decide whether there is anything to win:
1. **The floor probe (decisive).** Hand-write the ideal loop for the subset as a raw-pointer routine
(a static method, `AggressiveOptimization`, no captures) and time production vs floor vs NumPy on
the same buffers. `floor ≈ production ≈ NumPy` → memory-bound, STOP. `floor ≪ production` → the
gap is overhead and the win is real: the 2-D block kernel's floor was **1.7–2.1× NumPy** while
production sat 2× off its own floor (`docs/stale-docs/NDITER_2D_BLOCK_KERNEL.md` §2.2).
2. **Ceiling analysis.** Measure the general route's **GB/s vs the hardware floor**
(`references/discover.md` §4) and place it in the taxonomy below.
| ceiling | how you recognize it | what is still reachable |
|---------|----------------------|-------------------------|
| **pathological general path** | far below the bandwidth floor: strided-transpose GEMM, length-1 panel packing, all-coordinate materialization, a prologue per row, quadratic re-reads | a big win (cov syrk 10×, gemv 8.6× over old, 2-D block 0.82× → 2.5×) |
| **memory-bound at the floor** | GB/s ≈ per-core DRAM (~36–48 GB/s) or an L2/L3 stream, and NumPy is already there | parity (0.8–1.1×); FMA / unrolling / prefetch measured as no-ops or regressions |
| **shared native floor** | both sides call the SAME BLAS/LAPACK routine | parity; only wrapper glue is removable (`full − engine − raw` decomposition) |
| **allocation floor (small N)** | an undisposed 1K op pays ~0.5–1 µs of NDArray + pool + finalizer vs NumPy's ~0.3 µs refcounted C call | disposed (`using`/NDScope) already BEATS NumPy at 1K; undisposed 1K is unwinnable and sits under the report's 1 µs credibility floor (▫ negligible) |
| **memcpy ceiling** | a byte-moving op at 100K; memcpy on this box = 146 GB/s | 1.5× is physically impossible (bool `&` would need 210 GB/s); ~1.2× is the cap |
| **CRT-parity ceiling** | a transcendental NumPy computes with the scalar CRT (asinh/acosh/atanh, `exp2` on win-amd64, f16 T3 unaries) | parity; a SIMD polynomial would be faster AND diverge — rejected |
| **sort-core gap** | a FLAT ratio at every size (sort/partition 0.2–0.4×; BCL `Array.Sort` alone is 3.4× slower than NumPy's vqsort) | a shared sort-core port — not a specialized path |
| **allocator asymmetry** | wins with `Dispose()`, loses without; NumPy's refcount-prompt warm-block reuse | a pool/ARC concern (pool pacing landed in `160ecbba`), not fixable inside the op |
**Fingerprints — read the size profile before touching code:**
| profile | meaning | lever |
|---------|---------|-------|
| slow small, fast large (0.3× at 1K → 1× at 10M) | per-call FIXED cost (NDIter setup 62–113 ns, Shape/NDArray allocs, Regex slice parse) | fixed-cost bypass / wrapper-glue removal |
| flat gap at every size | algorithmic (the sort core) | not a specialization |
| bad ONLY at 100K | allocator / page-fault regime, the leaked-output asymmetry, or a contaminated snapshot | re-measure idle, min-of-N, `+dispose` vs drop |
| bad ONLY at 10M | a bandwidth wall, or GC-storm / host contamination | isolate the cell, `GC.Collect()` between cases |
| identical µs at 100K and 10M | a lazy short-circuit — commit-OOMs under BDN batching unless disposed | dispose per invocation in the bench |
| "before ≈ after" on a route you just built | the fast path did NOT engage (stale DLL, wrong overload, dead gate) | prove engagement (below) |
Worked instances (all single-thread-pinned, NPY/NS):
| path | commit | vs OLD | vs single-thread NumPy | why |
|------|--------|--------|------------------------|-----|
| cov/corrcoef **syrk** | d64f3df5 | 10× | **2.88× (beats)** | general route was a strided-transpose GEMM (553 µs) — pathological |
| **gemv/gevm** | 4f666fc8 | 3–17× | ~0.8–1.0× (**parity**) | memory-bound; cblas dgemv already at the floor — no pathology |
| diff/ediff1d **stencil** | 6e94dbcc | 8% + 4× fewer allocs | already faster in isolation | the "slowness" was a benchmark leaked-alloc artifact, not the kernel |
| **2-D block kernel** (narrow rows) | af25a746 → ccadeef4 | 2× | 0.82× → **1.4–2.5×** | a prologue + odometer PER ROW; the floor probe proved 1.7–2.1× was reachable |
| fancy `a[idx]` **flat gather** | 09d1fc59 | 5.6–7× | 0.81× → **4.97×** (int32 idx) | the general take kernel (0.75 ns/elem, runtime-size imuls) could NOT beat NumPy's trivial path (0.57); a compile-time-width kernel did |
| f16 **bit-level min/max** | a05b5b1e | — | **6–60×** | the pathology is on NumPy's side: its HALF loops are scalar branchy bit-compares |
| f32 **argmax tournament** | 48f894ea | 7× | 0.15× → **1.06×** | scalar NaN-aware walk → NumPy's own 4×-unrolled port; 10M is the bandwidth ceiling, two "improvements" regressed it |
| bool **bitwise** | — | none possible | 0.6× at 100K, **STOP** | the kernel alone is 1.36× faster than NumPy's WHOLE op (190 GB/s); the rest is the allocation floor |
Do NOT conflate "faster than the OLD path" with "faster than NumPy" — that mistake inflated a "6–7×"
claim that was really parity. Report both baselines, explicitly.
## DISCOVER — where the subset hides
Find where the general path handles a recognizable subset suboptimally. The subset can be keyed on:
**shape** (a degenerate/small/tall-skinny/narrow dimension), **layout** (contiguity, stride,
transpose, aliasing), **dtype** (SIMD-capable vs not, int vs float, a bit-pattern-defined op),
**data property** (cardinality, value range, all-false, empty — sometimes knowable only while
running), **size** (a threshold where a different allocation/algorithm wins), or an **aliasing
relationship** (`A@A.T`, `a[1:]` vs `a[:-1]`).
- **Grep for compositions over a general primitive** — that's where structured operands appear:
`grep -rn "np\.dot(\|np\.matmul(\|\.Sum(\|argwhere\|concatenate\|NDIter" src/NumSharp.Core --include=*.cs | grep -v test`.
Also grep for setup-heavy routes reached by trivial inputs (`CreateCopyState`, `MultiNew`,
`Broadcast(`), string slicing in hot wrappers (`["..., 0"]` = a Regex per call), blanket upcasts
(`astype(NPTypeCode.Double)` — isclose did 2× the memory work at f32), and `MakeGeneric<`.
- **Profile the composition** to confirm which component dominates — specialize only the dominant one
(cov's `dot` was 553 µs of 762 µs; at 10M concat + centering dominate instead). For a parity-bound
op decompose by LAYER: `full − engine = wrapper`, `engine − raw = broadcast/alloc`, `raw = the
shared floor` (how `cond`/`vdot`/`eig`/`vecmat` went from 0.88–0.93× to parity, `a0a6a439`).
- **Audit engagement before assuming absence.** A dtype-specialized helper may already exist and be
UNREACHABLE: `ArgMaxBoolHelper` sat behind a gate that rejected Boolean, so bool argmax ran a 24 ms
full scan for an answer at index ~1 (`d967d99b`). Trace the gate to the helper; add a counter.
- **Read NumPy's own dispatch** (`docs/numpy/PERFORMANCE_EXECUTION_PATHS.md`, `refs/numpy/`) — every
route NumPy specializes (`small_correlate` ≤ 11, `BOOL_argmax`, `simd_argmax`, the unconditional
`npyiter_coalesce_axes`) is a subset it already decided was worth it; porting the SELECTION keeps
edge behaviour aligned for free.
- **Check "already optimal"** — `dot(1d,1d)`/`norm(2)` are at the SimdDot floor; `outer` is a broadcast
multiply near its write floor. Measure GB/s against the hardware floor first.
- **Probe the regimes a report omitted before accepting its ceiling.** The first 2-D-kernel report
measured only f64, 2M, w ≥ 4 and ops with a vector body — and hid three LOSSES on the kernel's own
shapes (w < lanes 0.41×, inner-broadcast 1.15×, `exp` 0.84×). Checklist: sub-vector widths,
cache-resident sizes, other dtypes (bool/f16/int), ops without a vector body, broadcast shapes,
non-contiguous inputs, `out=` vs fresh alloc, disposed vs dropped.
Recipes + the structural-pattern table: `references/discover.md`.
## EXPERIMENT — validate before building
1. **Floor probe + ceiling analysis first** (above). Then **prototype the cheaper route (throwaway)
and prove it wins on the subset** — a hand kernel raced against the general path (`-c Release`,
fresh filename, `PublishAot=false`, pinned); or, for a view/algorithm path, an asymptotic argument
+ a micro-check. Not clearly better → **STOP**.
2. **Try composition before new IL.** Route the subset through EXISTING validated kernels (astype +
SIMD shift, a widened dispatch condition) — byte-exact by construction and fuzz-risk-free. Write
new SIMD IL only when composition cannot reach the floor (the general take kernel could not beat
NumPy's trivial gather; only a compile-time-width kernel did).
3. **Find the CROSSOVER → set the gate.** The route usually wins only in a band (cov Gram `M<=16`,
loses above; broadcast-direct wins to ~16–32K, gated at 8192 with margin; bool shift ≤ 262144 =
an L2-resident 2 MB int64 temp; bincount ≤ 2048 bins = 64 KB of accumulators in L2, a LOSS at
3072). The gate is part of the design, not an afterthought — firing outside the band is a
regression.
4. **Prove the prototype ENGAGED.** Watch a kernel-cache count grow (`_innerLoop2DCache.Count`), add
a debug counter, or check for a distinctive timing signature. Two traps manufacture "no change":
a stale runfile DLL and a gate wired into only one of two overloads.
5. **Any placement / alignment / prefetch claim needs the interleaved, repeated form.** A one-shot
"4K-aliasing" win of 1.30× vanished when the two placements were alternated 5× best-of-15.
## CREATE — integrate cleanly
- **Gate at the op's DISPATCH, not a per-caller hack** — so every consumer benefits (gemv went into the
matmul dispatch and lifted `matvec`/`inner`/`dot`/`einsum`/`tensordot` at once). Shape it as a
`Try…()` that returns `null`/`false` on any miss and falls through to the general path. Make the
gate CHEAP and type-independent (`Is2DElementwiseShape` reads flags and strides only; callers build
operand arrays only after it returns true).
- **The gate + fallback is the contract.** On ANY unmet condition (dtype out of scope, non-contiguous
reduction axis, size outside the band, structure not recognized) → general path, byte-for-byte and
perf-for-perf unchanged. Never widen a fast path to a shape you didn't measure.
- **A threshold is a named `const`/`static readonly` with the measured crossover in its comment and an
env override for tuning** (`DirectBroadcastMaxElements` 8192 / `NS_BROADCAST_DIRECT_MAX`,
`BoolSimdScalarShiftMaxElements` 262144 / `NS_BOOL_SHIFT_SIMD_MAX`, `WriteOnceMaxBytes`,
Voir sur GitHub