Table of Contents

NumSharp.Build Compiler - build-time [NDScoped] memory reclamation

NumSharp.Build is the optional build-time compiler package that makes a composition method eagerly return its transients' pooled buffers instead of waiting on the finalizer. Mark a method [NDScoped] and, at build time, the weaver rewrites it to open an NDScope - an ambient reclamation scope that tracks every NDArray the method constructs (whatever C# construct constructs it - an operator, a cast, an initializer, a helper call) and disposes the ones it does not return the moment the method exits, on the exception path included. Your source keeps its 100 % original body; the scope is injected into the compiled IL, so the reclamation is invisible in the code and free of boilerplate.

NumSharp.Core already uses this compiler on its own build - 265+ members carry [NDScoped] (effectively the whole hot np.* entry surface, plus ~30 [NDScopedCovered] helpers riding their callers' scopes). This package packages the same transform so you can apply it to your composition methods, and NumSharp.Core stays 100 % managed with nothing to install: with the package absent, an [NDScoped] method simply runs unscoped and its transients fall back to the finalizer - the exact pre-weave behaviour.

[NDScoped] covers synchronous methods and synchronous iterators (yield return). For a method that suspends across await - an async method, an async iterator, or a non-async method returning Task/ValueTask - use the companion [NDScopedAsync] attribute instead: it weaves the compiler state machine (or the deferral egress) so temps survive awaits and are reclaimed at the invocation's completion. Choosing the wrong attribute is a build error, not a silent miss - see Which returns are woven.

On this page: Why it exists · Quick start · What the weaver injects · Which members can carry the attribute · Which returns are woven · Retained arguments ([NDScopedExit]) · The NDScope API · Hand-scoping · How the build step works · When to use it · Leak detection (NDW012 / NDW013) · Escape hatches · Troubleshooting

Mono.Cecil transform, invoked after each per-TargetFramework compile · net8.0 / net10.0. Behaviour is covered by gates: NDScopeTests (the scope contract), NDScopeWeaveTests (every attributed method was woven), NDScopeWeaveCarrierTests and NDScopeWeaveNestingTests (tuple / carrier / storage egress - recursive component dispatch included - and nesting), NDScopeAsyncTests (the async/iterator seam), NDScopeExitTests (retained arguments), and StrongNameTests (identity survives the re-sign).

Why this package exists

A dropped NDArray's buffer is reclaimed only when its finalizer runs. In a tight loop the finalizer lags the allocation rate, so most calls miss the warm buffer pool and pay a cold NativeMemory.Alloc plus a first-touch page fault, on top of ~950 ns of finalization per dropped result. NumPy does not have this problem: it is refcounted, so an intermediate array's memory is freed deterministically the instant the last reference drops.

A NumSharp composition - np.cross, np.linalg.norm, a polynomial fit, your own helper built out of np.* calls - allocates a handful of intermediate NDArrays, keeps one as its result, and drops the rest. Under the finalizer backstop those dropped intermediates linger. [NDScoped] closes the gap: it makes the method's boundary a deterministic reclamation point, so every transient it owns is returned to the pool the moment the method exits - matching NumPy's refcount discipline at the one place it matters, the composition boundary, without asking the caller to dispose anything.

This is the same lever NumSharp.Core pulls internally. The package lets you pull it in your code.

Quick start

dotnet add package NumSharp.Build

Referencing the package is the whole opt-in: it wires the weave step into your build, and it is not a dependency and not contagious - it weaves the project it is installed in and nothing downstream. The package ships MSBuild targets and a build tool only (no lib/, no dependency entries, build/ never buildTransitive/), so a project that merely references your woven library - by ProjectReference or by package - is never woven and its own [NDScoped] stays inert. dotnet add package installs it with PrivateAssets="all" automatically, so it never appears in your own package's dependency graph; if you write the reference by hand (or under Central Package Management) add PrivateAssets="all" yourself - dotnet pack otherwise refuses to ship NumSharp.Build as a dependency of your package (NDW018). The companion Roslyn analyzer ships with the NumSharp package itself (not this one), so it is already active the moment you reference NumSharp: it flags a wrong or unsupported target (an async method under [NDScoped], an unsupported return, …) as a build error in the editor - before the weave runs - so mistakes surface immediately, weaver installed or not. Now mark any method that owns NDArray transients [NDScoped] and keep the body exactly as you wrote it -

using NumSharp;

[NDScoped]
public static NDArray Normalize(NDArray a)
{
    var mean = np.mean(a);           // transient
    var std  = np.std(a);            // transient
    var centered = a - mean;         // transient
    return centered / std;           // the one you keep
}

At build time the method is rewritten to what the hand-written pattern spells - a scope opened at the top, the whole body wrapped in try/finally, the result routed through scope.Returns(...) - so mean, std and centered are reclaimed on return while the result survives (see What the weaver injects). The input a is never touched: it was constructed before the scope opened, so it is never tracked.

If the NumSharp.Build package is absent, the [NDScoped] attribute is inert and Normalize runs exactly as its source reads - unscoped, the finalizer backstop. Adding or removing the package never changes results, only when the transients' buffers come back.

What the weaver injects

Per [NDScoped] method, the transform is exactly the hand-written scope pattern, applied to the compiled IL:

[NDScoped]                                          // what you WRITE - the original body, unchanged
public static NDArray<bool> logical_and(NDArray x1, NDArray x2)
{
    var b1 = x1.typecode == NPTypeCode.Boolean ? x1 : (x1 != 0);
    var b2 = x2.typecode == NPTypeCode.Boolean ? x2 : (x2 != 0);
    return (b1 & b2).MakeGeneric<bool>();
}

// what the built assembly CARRIES (logically):
public static NDArray<bool> logical_and(NDArray x1, NDArray x2)
{
    using var scope = NDScope.Open();               // 1. prologue, OUTSIDE the protected region
    try                                             // 2. the whole original body becomes the try
    {
        var b1 = x1.typecode == NPTypeCode.Boolean ? x1 : (x1 != 0);
        var b2 = x2.typecode == NPTypeCode.Boolean ? x2 : (x2 != 0);
        return scope.Returns((b1 & b2).MakeGeneric<bool>());   // 3. every ret routed through Returns
    }
    finally { scope.Dispose(); }                    // 4. dispose reclaims every tracked, un-yielded array
}

Three properties make the rewrite trivially safe:

  • The CLR forbids ret inside a protected region. Wrapping the body in try/finally forces every original return through a rewritable seam; bodies that already contain try/using compile their inner returns to leave-chains, and only the real ret instructions are touched (mutated in place, so branches and nested-handler boundaries that referenced them stay valid).
  • scope.Returns(x) is the one egress rule. It re-tracks the yielded array into the parent scope (so an enclosing scope still reclaims it if the caller drops it) and is a provable no-op for an array the scope never tracked - an input passthrough, a caller-supplied out. So "wrap every egress in Returns" is a safe blanket rule; the weaver applies it for you.
  • Inputs are never tracked. They were constructed before the scope opened, so a passthrough (ravel() returning its operand) or a caller-owned array is never at risk.

out parameters are yielded the same way before each successful return (their contents are undefined after a throw, so only the success path escapes them) - the full carrier vocabulary: out NDArray(<T>), out NDArray[], an out ValueTuple/Tuple of supported shapes (boxed through Returns(ITuple)), an out INDArrayCarrier struct (YieldTo through the byref, no box), and an out IArraySlice/UnmanagedStorage (counted-ref protection). An out shape that carries NDArrays but cannot be yielded - a List<NDArray>, a Task<NDArray>, a multi-dimensional array, a T : NDArray - is build error NDW015, never a silent sweep.

Operand roots - the caller's arrays stay alive during the call

A scope roots the arrays it tracks, the ones built while it is open. An operand was built by the caller before the scope opened, so the scope never tracks it - which is what makes a passthrough safe, but it also means the scope does not keep the operand alive. In optimized code the JIT reports a parameter to the GC only up to its last use. A method that reads an operand's raw address (a.Address, a.Storage.Address) and then runs a kernel has no later use of a, so a caller that passes a temporary (np.take(a, np.arange(n))) leaves the operand reachable from nothing while the kernel runs. A collection then finalizes it, its buffer goes back to the pool, and the kernel reads or writes freed memory: wrong values, spurious errors, access violations. NumSharp's lifetime oracle found 28 such sites (test/NumSharp.Tests.Oracle/Fuzz/Lifetime).

So the weaver also gives every method that opens a scope operand roots:

// what the built assembly CARRIES for [NDScoped] NDArray take(NDArray a, NDArray indices, ...):
object root0 = a, root1 = indices;   // 1. shadows, taken BEFORE the body runs
var scope = NDScope.Open();
try { /* ...the original body, ending in scope.Returns(result)... */ }
finally { scope.Dispose(); }
NDScope.Anchor(root0);               // 2. before every ret: GC.KeepAlive, no code emitted
NDScope.Anchor(root1);
return result;
  • Which parameters. Every by-value parameter that can reach an NDArray: NDArray and its subclasses, object, interfaces and delegates, arrays of references, a struct that carries an NDArray (a tuple of arrays, an INDArrayCarrier result - boxed once), a generic T constrained to a reference type - and this of a class instance method. Numbers, enums, string, Type, spans, long[] shapes and ref/out/in parameters are left alone.
  • Why shadows. They are taken before the body runs, so a body that reassigns a parameter (x = x.astype(...)) still roots the caller's original array; the reassigned value is built under the scope and tracked anyway.
  • Which methods. Every [NDScoped] synchronous method, and every method that opens a scope by hand (using var scope = NDScope.Open();), with or without the attribute: "a method that opens a scope roots its parameters" holds however the scope was written. An iterator or async method needs none: its parameters live in the compiler state machine, which the caller holds.
  • Cost. NDScope.Anchor inlines to GC.KeepAlive, a JIT intrinsic that emits no code; the shadows only stay in registers or stack slots until the return.

[NDAnchored] - roots without a scope

An entry point that opens no scope (a hot primitive such as np.copyto, an engine method such as DefaultEngine.ReduceAMax) but hands an operand's address to a kernel carries [NDAnchored]: the weaver gives it the same operand roots and nothing else - no scope, no tracking, no disposal. It does not root temporaries the method builds itself (no scope tracks them): a method that converts an operand and then reads the converted array's address needs [NDScoped]. Rejected shapes are build error NDW019: a method without a body, an async method or an iterator, a body with a tail call, a method that is also [NDScoped]/[NDScopedAsync] (its weave already roots), and a method with nothing to root. The attribute is not inherited; mark each override that needs it. Without the weaver it is inert, and the analyzer reports NDW013.

Which members can carry the attribute

[NDScoped]/[NDScopedAsync] target methods and properties (AttributeTargets.Method | Property), and "method" is broader than it sounds:

member supported?
ordinary static/instance methods ✔ woven
user-defined operators (operator +, …) and conversion operators (op_Implicit / op_Explicit) ✔ woven - they are static methods; NumSharp's own binary-operator overloads are scoped exactly this way
property getters (property-level attribute resolves to the getter; NDW006 if setter-only) and accessors directly ✔ woven
local functions ✔ woven - the attribute lands on the compiler-generated method and the weaver collects it
async methods / async iterators / yield return iterators ✔ woven through their state machines (see below)
virtual / abstract / interface members ✔ the attribute is a contract: every override and implementation is woven (see Inheritance); the abstract/interface declaration itself has no body and is never woven - and never an error
constructors / finalizers ✘ not an attribute target (C# rejects it) - and a ctor's egress is typically a field, which the weave cannot express; hand-scope with _field = scope.Returns(...)
lambdas ✘ not attributable - a lambda that runs while the scope is open is covered by the ambient scope; one that escapes and runs later (a stored delegate, deferred LINQ) runs unscoped (finalizer backstop)

Two runtime facts make the coverage syntax-agnostic: tracking hooks NDArray construction (the single InitializeArc funnel), so any C# construct that creates an array under the scope - a cast operator, an object/collection initializer, a collection expression, a Deconstruct, LINQ executing inside the method - is tracked with no per-construct support; and inputs were constructed before the scope opened, so they are structurally untouchable whatever syntax reads them.

Inheritance - virtual, abstract and interface members

[NDScoped] / [NDScopedAsync] (and [NDScopedCovered], and the [NDScopedExit] parameter attribute) placed on a virtual, abstract or interface member is a contract its inheritors inherit: every override and every implementation is woven exactly as if it carried the attribute itself, and keeps its 100 % original body. State the scoping once, where the API is declared:

public abstract class Layer
{
    [NDScoped] public abstract NDArray Forward(NDArray x);          // the contract
    [NDScoped] public virtual NDArray Twice(NDArray x) => x * 2.0;   // scoped body AND contract
    public abstract void Adopt([NDScopedExit] NDArray weights);     // "this parameter is retained"
}

public interface IOp { [NDScoped] NDArray Apply(NDArray a); }

public class Dense : Layer, IOp
{
    public override NDArray Forward(NDArray x) { var h = x + 1.0; return h.copy(); } // woven: inherits Forward's [NDScoped]
    public override NDArray Twice(NDArray x)   { var h = x * 3.0; return h.copy(); } // woven: inherits Twice's
    public NDArray Apply(NDArray a)            { var h = a - 1.0; return h.copy(); } // woven: implements IOp.Apply
    public override void Adopt(NDArray w) => _w = w;                                  // woven: w is detached, by position
    private NDArray _w;
}

The rules, all of them what the CLR's own slot resolution implies:

  • What inherits. Class overrides (override, through any number of levels - a base that does not declare the slot is climbed past), implicit interface implementations (a public method matching the member's name and signature, base interfaces of a listed interface included), explicit interface implementations, covariant-return overrides, and generic instantiations (Derived : Base<int> matches Base<T>.M(T) against M(int)). A property-level attribute on a base property reaches its getter override. Which interface member a method implements is the CLR's interface map, not the name alone:

    • an interface listed on a base that does not declare the slot (class Stage : StageRoot, IStage { }) is implemented by the inherited StageRoot.Run, so an override below Stage runs for calls through IStage and inherits;
    • a member a type implements explicitly is what the interface runs, so a public method of the same name and signature beside it - the forwarding shape NDArray IOp.Apply(NDArray a) => Apply(a); plus public virtual NDArray Apply(NDArray a) - inherits nothing: it is not woven, and an array it keeps survives the call;
    • a type that lists the interface again re-implements it, and its own map decides for it; an override still inherits through the base declaring its slot when that base implements the interface.

    The declaration may live in another assembly - a consumer overriding a NumSharp member, or a member of any library that used this weaver, is woven in the consumer's build (the weaver walks the base chain through the compile's reference list).

  • Nearest declaration wins, and an explicit attribute on the override wins over it. An override carrying its own [NDScoped]/[NDScopedAsync] re-states (or changes) the model; an override marked [NDScopedCovered] opts out of the inherited weave (its transients then ride the caller's ambient scope, on the covered author's assertion). [NDScopedCovered] on the declaration is inherited the same way - its overrides are covered, not woven.

  • The declaration is the contract, not a target. An abstract or interface declaration has no body: nothing is injected there, and it is not NDW005 - that error is now an extern's alone (no body, no inheritors). The declaration's signature is still gated (a hidden ref egress on the contract is NDW002 on the declaration and on every override that inherits it), and an async/iterator override weaves through its own state machine under the inherited model.

  • [NDScopedExit] inherits by parameter position. An override that marks no parameter of its own takes the nearest attributed declaration's set; an override that marks any parameter uses only its own marks.

  • The analyzer resolves the same graph: an inheriting override draws no NDW012 for its transients, its target gate reads the declaration's attribute (the diagnostic names the declaration it was inherited from), and reflection agrees - the attributes declare Inherited = true, so GetCustomAttribute(inherit: true) on an override finds the base declaration's attribute (the coverage gate uses exactly that).

  • One thing the MSBuild NDW013 scan cannot see is an override-only assembly: it inherits a scope from another assembly and carries no attribute text of its own. The weaver's targets therefore spawn the tool for any compile that references NumSharp (the tool exits without rewriting when nothing inherits), and the missing-weaver nudge for that case comes from the analyzer instead - see NDW013.

Which returns are woven

The weaver dispatches on the method's return shape. Every carrier of NDArrays NumSharp returns is handled. A shape whose egress it cannot see is a build error, not a silent miss, with one exception for a type that holds NDArrays only in its own fields (below the table).

Two attributes, one per scoping model. [NDScoped] weaves synchronous methods (every row below except the [NDScopedAsync]-tagged ones) and synchronous iterators (yield return). [NDScopedAsync] weaves the shapes that suspend across await or defer disposal to a task's completion: async methods, async iterators, and non-async Task/ValueTask returns. A method has exactly one scoping model - marking it with the wrong attribute is a build error (NDW009 = an async/Task shape under [NDScoped]; NDW010 = a plain synchronous method or synchronous iterator under [NDScopedAsync]; NDW011 = both attributes on one method), never a silent unwoven ship.

return / signature weaver action
NDArray / NDArray<T> value routed through scope.Returns<T>(T) at every ret
NDArray[] / NDArray<T>[] scope.Returns<T>(T[]) - a tuple-of-arrays result (nonzero, meshgrid, split, …)
ValueTuple of 2–4 NDArrays ((NDArray, NDArray) - modf / qr / svd / lstsq / …) each component yielded through the matching strongly-typed Returns<T1,…> overload (no boxing)
any other ValueTuple / Tuple (any arity - Rest-packed 8+ flatten - a non-NDArray component, or a reference Tuple) scope.Returns(ITuple) - each component dispatches by its runtime type: bare NDArrays, NDArray[] elements, nested tuples (recursively), INDArrayCarrier structs and bare buffers are all yielded; components carrying no NDArray are skipped. A component that carries NDArrays in a shape the dispatch cannot see through (List<NDArray>, Task<NDArray>, T : NDArray) is error NDW003
a result-struct carrier implementing INDArrayCarrier (UniqueResult, MeshgridResult, PolyfitResult, …) retVar.YieldTo(scope) via a boxing-free constrained.callvirt - the struct's own method re-parents each NDArray it holds
bare IArraySlice / UnmanagedStorage (a lower-layer buffer, not wrapped in an NDArray) scope.Returns(slice/storage) takes a counted ARC reference so the scope's reclamation of an intermediate NDArray sharing the buffer can't free it under the caller
void / scalar (long, bool, double, string, enums, …) scope only; out params of any supported carrier shape still escaped
[NDScopedAsync] - non-async Task / ValueTask[<T>] (T any supported shape above, or none) scope.ReturnsTask / ReturnsValueTask - a completed task's result is yielded immediately; an incomplete task defers reclamation to its completion (the in-flight callee may still hold tracked temps); an incomplete ValueTask is Preserve()d and the caller receives the multi-observable form
[NDScopedAsync] - async method (Task, Task<T>, ValueTask[<T>], void, custom task-like) woven through the compiler state machine: one scope spans the logical invocation - suspended before every await's continuation is scheduled, resumed on the thread that continues - so temps survive awaits and everything is reclaimed at SetResult / SetException, the result routed through Returns first
[NDScoped] synchronous iterator (IEnumerable[<T>] / IEnumerator[<T>]) / [NDScopedAsync] async iterator (IAsyncEnumerable<T>) state-machine weave; every yield returned element is routed through Returns (the consumer owns it), hoisted state is reclaimed at the end of iteration or at the enumerator's Dispose() (early break included)
ref / in parameter over any NDArray-carrying shape (ref NDArray([]), a tuple with an NDArray component, a carrier struct, a List<NDArray>, a T : NDArray) error NDW002 - a hidden egress the weaver can't see; scope by hand
an unsupported carrier (a bespoke reference type, a collection, a struct that does NOT implement INDArrayCarrier, a nested task Task<Task<…>>, or a tuple with an unsupported ND-carrying component) - returned directly, inside a Task<T>, produced by an async method, or yield returned error NDW003 - its members would be handed back disposed; add INDArrayCarrier, or scope by hand
an out parameter whose NDArray-carrying shape the out-escape cannot yield (out List<NDArray>, out Task<NDArray>, out NDArray[,], out T with T : NDArray) error NDW015 - the caller would receive it with its arrays already swept; scope by hand

A holder inside a tuple or out is not refused yet. The build-time checks decide whether a type carries NDArrays from its signature: NDArray, arrays, tuples, generic arguments, INDArrayCarrier. Two kinds of type hold NDArrays only in their own fields:

  • a class such as NDMaskedArray, poly1d or a holder of your own;
  • a struct that does not implement INDArrayCarrier.

Returned directly, such a type is refused (NDW003). But as a tuple component, returned or assigned to an out parameter, or as a bare out parameter, it passes the build. The scope then skips it at run time and disposes its arrays with the other temporaries, so the caller receives an object whose arrays are already disposed. This was measured on woven code (2026-10-16): [NDScoped] on the private core of np.ma.average, which returns (NDMaskedArray, NDArray), and probes of a bare out holder class, a (holder class, int) tuple and a (non-carrier struct, int) tuple. All of them built cleanly and handed the caller disposed arrays, while an (NDArray, int) control did not. Keep such values out of a scoped method's tuples and out parameters (NDW012's case 3 says the same), or scope that method by hand and detach them.

A result struct opts in by implementing the INDArrayCarrier interface - one explicit method that yields each NDArray it holds:

public readonly struct MyResult : INDArrayCarrier
{
    public NDArray Values { get; }
    public NDArray Counts { get; }

    void INDArrayCarrier.YieldTo(NDScope scope)   // called by the weaver at each return
    {
        scope.Returns(Values);
        scope.Returns(Counts);
    }
}

The interface exists because a struct's members can live behind private fields (auto-property backing fields), and the CLR grants a nested type access to its enclosing type's privates but not the reverse - so the woven method cannot read them directly. The struct yields its own members from the inside; the weaver just calls YieldTo. The opt-in is struct-only by design (record structs included): a class wrapping NDArrays is aliasable and mutable, so "sweep the temps, re-parent the members at return" has no sound ownership story - a class carrier is NDW003 whatever it implements; hand-scope it or restructure it as a result struct. A conversion operator on your type does not change this: return myWrapper; invokes no conversion, so the weaver classifies the declared return type.

Retained arguments - [NDScopedExit]

A scope auto-protects the return value and out parameters - everything else it tracked is swept at exit. So an array handed to something that keeps it - a field store, a property, a long-lived collection, a captured closure - would be disposed under the retainer. Mark the retaining parameter and the weaver detaches the argument from the caller's ambient scope at the callee's entry:

public NDArray Weights { get; private set; }
public void Adopt([NDScopedExit] NDArray w) => Weights = w;   // w survives the caller's scope
  • Works with or without a method-level scope attribute (the callee runs inside the caller's ambient scope, so the injected NDScope.Detach(param) reaches it with zero call-site plumbing), and is a no-op when no scope is open.
  • Covers NDArray(<T>), NDArray[], and ValueTuple/Tuple of NDArrays - nested tuples and NDArray[] components detach recursively.
  • A property setter's value parameter counts - which also covers the object-initializer spelling (new Wrapper { Prop = temp } compiles to a setter call), and a constructor parameter covers new Wrapper(temp).
  • A raw public-field store (obj.field = temp) has no parameter to annotate - route it through a setter or call NDScope.Detach by hand.
  • An unsupported parameter type - ref/out/in, a scalar, a bare buffer, an INDArrayCarrier struct, or a tuple with an ND-carrying component Detach cannot see through - is error NDW014.

Detachment trades eager reclamation for survival: the retainer now owns the array (dispose it there, or let the finalizer backstop reclaim it).

The NDScope API

The runtime type the weaver targets. You rarely touch it directly - the attribute does - but it is public, so you can hand-scope (below) or manage a scope yourself.

member purpose
NDScope.Open() opens a scope on the current thread; nests (the previous scope resumes on dispose)
scope.Returns(x) / Returns(x[]) yield the result (or a tuple of arrays): re-tracks it into the parent scope, so an enclosing scope still reclaims a dropped inner result. The one egress rule.
scope.Returns((a, b[, …])) / Returns(ITuple) the ValueTuple / Tuple egress - yields every NDArray the tuple carries (components dispatch by runtime type: bare arrays, NDArray[] elements, nested tuples recursively, carrier structs, bare buffers)
scope.Returns(slice) / Returns(storage) protect a returned bare IArraySlice / UnmanagedStorage with a counted reference
NDScope.Attach(nd) adopt an array the scope did not construct into the current scope (the hot-loop pattern - attach a received result instead of a per-item using)
NDScope.Detach(nd) / Detach(nd[]) / Detach(ITuple) permanently un-track an array (for one being cached into a static / long-lived field) - the array/tuple overloads detach every element, nested tuples and NDArray[] components recursively; also the egress [NDScopedExit] weaves
scope.Dispose() dispose every tracked, un-yielded array and reinstate the parent scope

Two properties are load-bearing:

  • Tracked disposal is ordinary ARC release - the buffer frees only at refcount 0. So releasing a base whose view was yielded never corrupts: the yielded view keeps the buffer alive. It is the same safety a hand-written Dispose gives, with the bookkeeping automated.
  • Scopes are [ThreadStatic] and nest per thread. A scope is opened, used and disposed on one thread - a hand-written scope must not span await (its Dispose on the continuation's thread throws InvalidOperationException and leaves the scope's arrays to the finalizer); only the weaver's state-machine seam may carry a scope across suspensions (it uninstalls the scope before each continuation is scheduled and re-installs it on the resuming thread). Arrays constructed on other threads (parallel kernel workers) see no scope and fall back to the finalizer; a parallel region that wants eager reclamation opens its own scope inside each worker body.

Hand-scoping

When a method's egress isn't expressible as a return value plus out params - a ref/in flow over an ND-carrying shape (NDW002), an unsupported carrier like a class wrapper or List<NDArray> (NDW003/NDW015), a constructor whose egress is a field, or a mid-method handback that must free before a later allocation - write the scope by hand instead of the attribute. The attribute and a hand-written scope are the same thing; the weaver skips a method that already opens an NDScope (idempotence), so a hand-scoped method may still carry [NDScoped] without double-wrapping.

public static NDArray WeightedMean(NDArray a, NDArray w)
{
    using var scope = NDScope.Open();
    var num = np.sum(a * w);                 // two transients: the product and the sum
    var den = np.sum(w);
    return scope.Returns(num / den);         // the one egress, yielded
}

How the build step works

The weave runs as an MSBuild target after each per-TargetFramework compile, on the intermediate assembly, before it is copied to the output:

  • A Mono.Cecil transform rewrites every [NDScoped] method into the IL above. Instructions are only inserted or mutated in place, so the original sequence points survive and source stepping still lands on the right lines - the rewritten portable PDB is preserved.
  • Cross-assembly resolution. In your project NDScope and the attribute live in the referenced NumSharp assembly, so the target hands the weaver the compiler's own reference list (a response file of @(ReferencePathWithRefAssemblies)); the weaver resolves the types through it and emits cross-assembly member references. The same list is how an override's base chain and interfaces are walked for an inherited attribute, which is why the target spawns the tool for every compile that references NumSharp - an override-only assembly has no attribute text for the cheap pre-scan to find. NumSharp.Core's self-weave runs the same target; there the types are in-module, so the list is merely the resolver's probe set.
  • One definition. The NDScopeWeave target lives in exactly one file - this package's build/NumSharp.Build.targets. NumSharp.Core's own self-weave imports that same file (adding only a step that builds the tool from source and points $(NumSharpBuildToolDll) at it), and so does the source-mode example examples/NDScoping; the consumer weave and the self-weave therefore cannot drift apart, and a hardening added to the target reaches all three at once.
  • Re-signing. IL rewriting invalidates a compile-time strong-name signature, so on a strong-named project the weaver re-signs the assembly with the project's own key; identity (public-key token, version binding) is unchanged - StrongNameTests gates it for NumSharp itself. An unsigned project is simply written unsigned; delay- and public-signed projects are left as-is (they carry no valid signature to restore).
  • Idempotent & incremental. A body that already opens an NDScope is skipped, so re-weaving is a no-op; the target is incremental on a per-TargetFramework marker, so an unchanged assembly is not re-woven.
  • Build cost. The weave is one short process per TargetFramework: about 0.2-0.3 s on a typical project's assembly (mostly the process starting), about 1.0-1.1 s for NumSharp's own multi-megabyte assembly (measured as a cold process on an i9-13900K's performance cores). The tool declares itself foreground work to Windows (a windowless build step is otherwise parked on a hybrid CPU's efficiency cores, which doubled the weave), runs without dynamic PGO (whose instrumentation a one-shot process never repays), and reads property and event accessors in linear time (Mono.Cecil's own read is quadratic in a type's property count: a generated type with 50,000 properties took 8 s, now 1.8 s). The analyzers below are pay-for-play: a per-compilation index of the names through which NumSharp can reach code lets them skip, unread, every method body and type that cannot involve an NDArray, so a project's NumSharp-free code costs next to nothing to analyze.

A coverage gate (NDScopeWeaveTests) reflects over the shipped assembly and asserts every [NDScoped] method carries a local of type NDScope - a necessary consequence of scoping. It turns a silently un-run weave (a broken target, or a -p:SkipNDScopeWeave=true build) red instead of letting those methods quietly revert to the finalizer backstop.

The weaver itself is gated in-process (test/NumSharp.Tests.Build.Analyzer, WeaverTestHarness): a fixture is compiled with Roslyn, woven by ScopeWeaver.WeaveAssembly directly, read back through Mono.Cecil (the scope local, the try/finally Dispose, every NDScope call) and then loaded and executed so reclamation is asserted on real arrays - across every return, egress and body shape on both Release and Debug IL, the whole inheritance walk, a weaver-vs-analyzer parity check over the same fixtures, a seeded hierarchy fuzzer against a reference model, and the tool's process surface (exit codes, the reference response file, --snk, NDW001).

When to use it

  • Scope a call, not a caller loop. The temporaries a scope holds are all alive simultaneously, and batch-disposing thousands of same-size buffers overflows their pool bucket. Put [NDScoped] on the composition method, not on a loop that calls it a million times.
  • Reach for it on transient-owning compositions - a method built from several np.* calls that keeps one result and drops the rest. A single-kernel wrapper or a pure view / passthrough owns no transients, so scoping it buys nothing (and the weaver leaves such shapes as a plain scope).
  • Hot loops should still using the results they receive. [NDScoped] reclaims a method's internal transients; the result it hands you is yours to dispose (or NDScope.Attach into your own scope). The two-audience contract - a callee that cleans up after itself, a caller that cleans up what it receives - is unchanged.

This mirrors buffer ownership: the scope is the deterministic-reclamation layer above the raw ARC-refcounted buffers that page describes, and it is woven with the same IL-emission machinery NumSharp uses for its kernels.

Leak detection - NDW012 & NDW013

Two diagnostics catch the two ways an NDArray transient can go unreclaimed at build time, so a leak surfaces in the editor and on every build instead of only as a runtime finalizer cost.

NDW012 - an NDArray is created but never disposed or scoped

The NumSharp package ships a Roslyn analyzer that warns when a method creates an NDArray (or any carrier of one - NDArray[], a ValueTuple/Tuple of NDArrays, an INDArrayCarrier result struct) and then never returns it, assigns it to an out/ref parameter, stores it, disposes it, or yields it through an NDScope - so its pooled buffer is left to the finalizer. Referencing NumSharp is the whole opt-in (no weaver install needed); NumSharp.Core runs the same analyzer on its own build.

public NDArray Bad(NDArray a, NDArray b)
{
    var t = a + b;              // ⚠ NDW012: 't' is created but only read, never disposed/returned
    np.add(a, b);               // ⚠ NDW012: the result is dropped on the floor
    return np.sum(a * b);       // ⚠ NDW012: the 'a * b' temporary is handed to np.sum and never reclaimed
}

What the message tells you to do. The warning line names the four fixes below in the order to try them, in a few words each, and the help link the compiler prints after it leads here; the diagnostic's description (shown on demand by an IDE) carries the same conditions in short form. Adding [NDScoped] is not the reflex fix, because of the one fact the line starts from: when a scoped method finishes, its scope disposes every array created during the call, by the method or by anything it calls, except what the method returns, yields or assigns to out parameters and what was detached with NDScope.Detach. An array that is still in use somewhere else is disposed with the rest. So act on the first case that holds:

  1. Not a leak. The value is, or contains only, arrays the method already holds (an @out argument that the call returns, or the receiver of inplace: true; a view or a copy of one does not count), or the method it is passed to takes it over (disposes it, detaches it, or returns it or an object that owns it). Keep the code and suppress just that line between #pragma warning disable NDW012 and #pragma warning restore NDW012, with a comment that gives the reason.

  2. A caller already has a scope. Every call to the method (count calls through a delegate, an interface or a base virtual) happens while a scope is open on the calling thread: an [NDScoped], [NDScopedAsync] or [NDScopedCovered] method, or a using var scope = NDScope.Open(), is up the call stack. That scope already reclaims the array, which this check cannot see. No caller runs the method in a long loop, the method creates no array after an await, and nothing outside the assembly can call it. Mark it [NDScopedCovered], which costs nothing at run time, not [NDScoped]. Scopes nest, so [NDScoped] here would not be wrong: its inner scope frees the method's arrays when the method returns and passes the result to the caller's scope. It costs a scope per call, and it is the right choice when a caller runs the method in a long loop, where a covered method's arrays would pile up in the caller's scope until that scope closes.

  3. An entry point that keeps nothing. This case needs three conditions:

    • No array created during the call is kept past it by the method or by anything it calls (in a field, a property, a static cache, a collection or the caller's array, or by a lambda or task that runs later), unless it is detached (NDScope.Detach, or an [NDScopedExit] parameter).
    • Everything the method returns, yields or assigns to out parameters is an NDArray, an NDArray[], an INDArrayCarrier struct, a tuple of those, or a value that holds no arrays. An NDMaskedArray, a poly1d or a holder of your own does not qualify, even inside a tuple.
    • It hands out no pointer into a temporary.

    Then mark it [NDScoped] ([NDScopedAsync] for an async method). Or mark its unscoped callers instead (each with the attribute that fits it, under the same conditions) and apply case 2.

  4. Otherwise dispose it after its last use (using var t = ...; or t.Dispose(); an array created in a loop, before the next pass). Never dispose an array the method returns, stores or was given, nor a result that may be that same input (np.asarray(x), x.astype(t, copy: false)).

Cases 2 and 3 mark the whole method, which silences every NDW012 in it, and a constructor can take neither attribute. Each leaves the method's arrays to a scope: the caller's for [NDScopedCovered], the method's own for [NDScoped]. That scope tracks only arrays created on its own thread while it is open, and frees them only when it closes. So a case does not apply in two situations:

  • The scope stays open across a loop, in the method or in a caller, that creates arrays on every pass and can run many times. The scope would hold every pass's arrays until it closes. An explicit Dispose frees the buffer but not the scope's reference to the object; see When to use it.
  • The array is created in a lambda or task that runs on another thread, or after the scope has closed. No scope tracks it.

Why this order. The cases run from the best fix to the most manual one. Case 1 changes nothing, and any other fix applied to a value that is not a leak can dispose an array that is still in use. Case 2 costs nothing at run time. Case 3 reclaims every array the method creates: the flagged one, the temporaries this check deliberately leaves unflagged (those read only through a member call), and the ones on an exception path. It costs one scope per call. Case 4 frees only the array you dispose, but it needs no package and works everywhere.

Without NumSharp.Build. [NDScoped] and [NDScopedAsync] are woven by the NumSharp.Build package; without it they are inert (NDW013), and a caller marked with one opens no scope either. The analyzer reads the same build property NDW013 reads (NumSharpBuildActive: NumSharp's targets default it to false, NumSharp.Build's targets set true when they weave). When the build declares the weaver inactive, a dropped NDArray gets a text with three cases instead of four. Case 1 is unchanged. Case 2 counts only scopes a caller opened with NDScope.Open(). Case 3 is to dispose the array, because the scope attribute would change nothing. The text then names installing NumSharp.Build as the way to scope a method with many temporaries. A scope opened by hand (see Hand-scoping) also works without the weaver, as long as every return goes through scope.Returns(...); a return that skips it hands back a disposed array. When the property is absent (a build that imports neither targets file), the weaver's state is unknown and the four-case text stays.

A method that is already [NDScoped]/[NDScopedAsync] or [NDScopedCovered], or that opens an NDScope itself, is exempt, and so is an override that inherits one of those attributes. Returning the value, storing it, or passing it to a non-NumSharp API are all egress, and are never flagged.

Limits, by design. The analysis reads one method at a time and follows no calls. This has three consequences:

  • A helper under a caller's scope is still flagged. A private helper whose temporaries a caller's ambient scope reclaims (the "scope the boundary, not the helpers" pattern) is flagged in isolation. Case 2 is the fix: [NDScopedCovered] is an analyzer-only, runtime-inert assertion that the helper always runs under a caller's open scope.
  • A call that hands back an array the method already holds still looks like a new array. np.add(a, b, @out: x) returns x, and so does a helper that takes its argument over and returns it. Each such call reads as a dropped array; that is case 1.
  • A temporary read only through a member call is deliberately not flagged (var t = a + b; return t.sum();). So much of NumSharp is fluent reinterpret/view chains (x.MakeGeneric<T>(), x.reshape(...), x[...]), whose result shares the receiver's buffer, that a view cannot be told apart from a fresh result by syntax alone. Following the receiver keeps those chains from flooding the build with false positives.

Tune the warning per project in .editorconfig (dotnet_diagnostic.NDW012.severity = none|suggestion|warning|error). On NumSharp.Core, turn it off entirely with -p:EnableNDArrayLeakAnalyzer=false.

Ownership is contagious. An instance of a disposable type that stores NDArrays (your own holder class, NumSharp's np.nditer iterator, an NpzFile) is an owned value exactly like a bare NDArray. Constructing one, or getting one from a factory, and then dropping it is NDW012, with a text of its own. No scope attribute fixes it: an NDScope reclaims only NDArrays and never calls the holder's Dispose, so the native state or the open file it owns would still wait for the finalizer. Reclaim it with using, .Dispose() or .Close() (NumSharp's close() too). Returning it or storing it is egress. When the warning flags a hand-off to a method that takes the holder over (disposes it, or returns it or an object that owns it), suppress the line as in case 1.

A foreach over a produced NDArray or such an instance is also flagged, because C# disposes the enumerator it obtains, never the enumerable. So foreach (var x in np.nditer(a)) leaves the iterator open; hold it in a using first.

NDW016 - a type stores NDArrays but is not disposable

NDW012 treats a value stored into a field or property as a legitimate escape: the storing type owns it now. NDW016 closes that hand-off. A class or struct that declares instance fields or auto-properties holding NDArrays - an NDArray (or subclass), an NDArray[] of any rank, a tuple or collection of them (List<NDArray>, Dictionary<K, NDArray>, (NDArray, int)), any generic instantiated over one (Lazy<NDArray>, Task<NDArray>, your own Box<NDArray>), an INDArrayCarrier result struct, or another type that stores them - and implements neither IDisposable nor IAsyncDisposable can never reclaim those buffers promptly:

public class Model                          // ⚠ NDW016: 'Model' stores NDArrays in '_weights' and 'Cache' …
{
    private NDArray _weights;
    public List<NDArray> Cache { get; set; }
}

The fix is the one the message names: implement IDisposable (or IAsyncDisposable) and dispose the members there (NDW017 then checks that you actually do), give a transient result struct INDArrayCarrier so a scope can yield through it, give a ref struct the public void Dispose() pattern - or, when a member only references an array owned elsewhere (a view over a caller's array, a shared lookup table, an operand an iterator walks), mark it [NDBorrowed]. The attribute goes on a field, a property, or a whole class/struct ("every array this type references is borrowed"); it is runtime-inert and analyzer-only, like [NDScopedCovered]. Static members, computed properties (NDArray View => _a.T), delegates, comparers, observers and weak references never make a type an owner; a static class, an interface, an [NDBorrowed] type and an INDArrayCarrier are exempt.

Contagion. Because a member whose type stores NDArrays is itself a holder, the diagnostic walks up the object graph one level per fix: a class storing a Batch (which owns arrays) warns until it disposes the batch; a class storing that class warns until it disposes it, and so on. A type that stores NDArrays but is not yet disposable still counts as a holder - its owner's message tells you to make it disposable first. Marking a disposable type [NDBorrowed] (it owns unmanaged state of its own but only borrows its arrays) stops the contagion at that type.

Back-references. A member whose type holds NDArrays only by coming back to the declaring type is not a holder: a parent pointer (Node _parent), the link of a linked list (Node _next), a collection of the type's own instances (List<Node> _children), or a reference into a cycle of types that reaches arrays only through this one. The analyzer cannot tell an owning link from a non-owning one, so it never asks a type to dispose instances of itself (that would ask a child to dispose its parent); dispose owned children yourself. A member whose type reaches arrays another way - a child that stores arrays of its own, an owner that stores arrays the child does not - is a holder, cycle or not; mark it [NDBorrowed] when it does not own them. The answer depends only on the types, never on the order the compiler happens to analyze them in, and a model of any size is analyzed in one pass over its types.

NDW017 - a disposable type never disposes an NDArray-holding member

The type is disposable, but this member is not disposed on any path reachable from its Dispose() / Dispose(bool) / DisposeAsync() / DisposeAsyncCore():

public class Model : IDisposable
{
    private NDArray _weights;
    private List<NDArray> _cache;           // ⚠ NDW017: 'Model' implements IDisposable but never disposes '_cache' …
    public void Dispose() => _weights?.Dispose();
}

The path analysis follows same-type helpers the Dispose methods call (Release(), a chain of them, a property setter, a delegate over a method, a local function) and recognises every ordinary spelling of disposal: _a.Dispose(), _a?.Dispose(), ((IDisposable)_a).Dispose(), (_a as IDisposable)?.Dispose(), using (_a) / using var x = _a, Interlocked.Exchange(ref _a, null)?.Dispose(), a local copy disposed later, foreach / for / Array.ForEach / List.ForEach / Parallel.ForEach over an array, list or dictionary member (values, pairs, deconstructed pairs, nested collections), tuple components (_pair.a?.Dispose(), var (a, b) = _pair;), carrier fields, Lazy<T>.Value, NumSharp's close(), and handing the member (or a component of it) to any method (Free(_list), DisposeAll(_map.Values))

  • the callee is assumed to dispose it. What does not count: _a = null, _list.Clear(), a finalizer (the backstop prompt disposal exists to avoid), a helper that nothing on the Dispose path calls, and an argument merely computed from the member (Log(_a.ToString()), Print(x.size)). A type whose Dispose is inherited from a base and never overridden is told so - override Dispose(bool) or reimplement IDisposable; a member whose type stores arrays but is not itself disposable is told to make that type disposable first. [NDBorrowed] excludes a member here too.

Both are warnings (never errors), enabled by default, tunable per project in .editorconfig (dotnet_diagnostic.NDW016.severity / dotnet_diagnostic.NDW017.severity); NumSharp.Core runs them on its own build and carries [NDBorrowed] on its own borrowing types (FlatIterator, MemoryView, NDEnumerate, the typed nditer enumerables, an iterator's operand slots, NpzFile's hand-out cache).

NDW013 - [NDScoped] used, but the weaver is not installed

[NDScoped]/[NDScopedAsync]/[NDAnchored] - and [NDScopedExit] on a parameter - are inert without the NumSharp.Build package: the method keeps its original body, no scope is injected, no argument is detached, no operand is rooted, and every transient it drops is left to the finalizer - a silent leak (and, for [NDAnchored], an operand a kernel can lose to the GC). NDW013 catches exactly that. NumSharp does not depend on or bundle the weaver (weaving is the explicit opt-in dotnet add package NumSharp.Build), so the guard ships in NumSharp itself - the attributes live there, which makes it present whenever they could be used and lets it fire when the weaver is absent. Two reporters share the one code:

  • The analyzer - the primary reporter. The Roslyn analyzer the NumSharp package applies to every PackageReference compile reports NDW013 at each member the weaver would have woven - with a source location, in the IDE as well as on the command line - when the build has declared the weaver inactive: a method or property carrying [NDScoped]/[NDScopedAsync] itself, an override or implementation inheriting one from a declaration in the same assembly or another (the shape no text scan can see - the override spells no attribute of its own), and a method with an own or inherited [NDScopedExit] parameter. Abstract and interface declarations are contracts (nothing is woven there; their inheritors are warned one by one), a target the gate rejects draws its error alone, [NDScopedCovered] is the opt-out, and a member is warned once whichever attribute earned it.

    S.cs(3,30): warning NDW013: [NDScoped] on 'S.F(NumSharp.NDArray)' has no effect: the NumSharp.Build
    package is not installed (or weaving is disabled), so the attribute is INERT - no NDScope is injected
    ... Install the weaver with 'dotnet add package NumSharp.Build' ...
    
  • The MSBuild scan - the fallback. build/NumSharp.targets in the NumSharp package also carries a metadata text scan that warns once, project-wide, when the compile used any of the three attribute names - for a compile the analyzer does not reach: a bare <Reference HintPath> to the NumSharp assembly (no package, so NuGet applies nothing), or an analyzer item removed by hand. It yields whenever NumSharp.Build.Analyzer is among the compile's analyzers, so a project never sees the same finding twice (once per member and once project-wide). It cannot see an inherited target; without the analyzer that shape goes unreported. (ExcludeAssets="analyzers" on the NumSharp reference does not produce an analyzer-less compile: NuGet records the exclusion, but the .NET SDK - observed on 10.0.101 - still applies the package's analyzers, so that consumer keeps the per-member warning.)

Both read the same two MSBuild properties, which NumSharp's targets expose to the analyzer as build_property.* options: the weaver's own targets set $(NumSharpBuildActive)=true when it is installed and weaving (under -p:SkipNDScopeWeave=true the attributes really are inert, so the warning correctly fires), and NumSharp's targets default it to false. A build that imports neither targets file (a source-mode ProjectReference to NumSharp.Core with no weaver import) leaves the property undeclared, which the analyzer reads as "unknown" and stays silent on. Opt out with -p:NumSharpDisableWeaverMissingWarning=true, by adding NDW013 to $(MSBuildWarningsAsMessages), or - the intended fix - by installing the weaver.

NDW018 - NumSharp.Build would ship as a dependency of your package

NumSharp.Build is not contagious: it weaves the project it is installed in and nothing downstream. Three layers keep it that way, and NDW018 is the third:

  1. Weaving never flows. The package ships build/ targets only - never buildTransitive/ - and no lib/, so NuGet imports its weave target only into a project that references the package directly. A project that merely references your woven library, by ProjectReference or by package, never imports the targets, is never woven, and its own [NDScoped] stays inert. This holds even in the worst case below, where the dependency is listed: NuGet records the default PrivateAssets of build as exclude="Build,Analyzers" on that edge, so the consumer restores the payload but never runs it.

  2. dotnet add package NumSharp.Build writes PrivateAssets="all" (the package is marked developmentDependency), which keeps NumSharp.Build out of your own package's nuspec.

  3. A hand-written reference is refused at pack time. A PackageReference typed by hand - or under Central Package Management - carries no PrivateAssets, so dotnet pack would list NumSharp.Build as a dependency of your package and every consumer would restore the weaver's payload. The packaged targets check the project's own project.assets.json (what the pack task reads for the dependency list: the NumSharp.Build record must carry "suppressParent": "All") before GenerateNuspec and fail the pack:

    error NDW018: MyLib references NumSharp.Build without PrivateAssets="all", so packing would list
    NumSharp.Build as a DEPENDENCY of MyLib ... Add PrivateAssets="all" to the PackageReference ...
    

    Only a packable project being packed is checked - an application that references the weaver has nothing to leak and is never bothered - and -p:NumSharpBuildAllowAsDependency=true (or the property in the project) ships it as a dependency on purpose. The package cannot fix this for you silently: NuGet restores with ExcludeRestorePackageImports=true, so a props file shipped in the package (setting PrivateAssets on your reference) never reaches the restore graph that pack reads - measured before the guard was written - which is why it is an error with the one-line fix rather than a quiet correction.

Escape hatches

flag effect
-p:SkipNDScopeWeave=true build without weaving - every [NDScoped] method runs unscoped (the finalizer backstop, the pre-weave status quo). Nothing else changes; the attribute is inert.
-p:NDScopeWeaveILVerify=true additionally run dotnet-ilverify on the woven output (opt-in, so machines without the tool still build). The weave adds zero ILVerify findings over the unwoven baseline.
<NumSharpBuildToolEnvironment> extra environment variables for the weaver process (NAME=value;NAME=value, empty by default). NumSharp itself sets DOTNET_gcServer=1;DOTNET_GCHeapCount=4 for its own multi-megabyte self-weave (about a quarter faster); a typical project's assembly is small enough that the default GC starts faster, so leave it empty unless your assembly is several megabytes.

Troubleshooting

A [NDScoped] / [NDScopedAsync] build error. A shape whose egress cannot be seen is refused rather than mis-woven. Two layers report it: the NumSharp package ships a Roslyn analyzer that flags the SOURCE-detectable mistakes at compile time - in the editor, with a red squiggle, and as a build error that runs before (and preempts) the weave - and the IL weaver (this package) reports the rest post-compile. The analyzer covers NDW002, NDW003, NDW005, NDW006, NDW009, NDW010, NDW011, NDW015 (the [NDScoped]-target gate) plus NDW012 (the leak warning, a separate always-on analyzer) and NDW016 / NDW017 (the type-level ownership warnings, a third always-on analyzer); the weaver covers the IL-only NDW001, NDW004, NDW007, NDW008, NDW014, NDW019 (a resolution failure, an unrecognized state machine, a tail-call, a NumSharp too old for the async seam, a bad [NDScopedExit] parameter, or an [NDAnchored] method it cannot root). NDW013 also ships in NumSharp itself so it can warn when [NDScoped] is used without the weaver installed. Same code, same fix, whichever layer fires.

A PackageReference consumer gets the analyzer automatically - NuGet applies it from the NumSharp package's analyzers/dotnet/cs/, so referencing NumSharp alone is enough (no weaver install required). A source-mode consumer - one that references NumSharp.Core by ProjectReference and <Import>s build/NumSharp.Build.targets directly, so no package delivers the analyzer - turns it on the same way it points the tool at a built weaver: either reference the analyzer project with <ProjectReference "…/NumSharp.Build.Analyzer.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />, or set <NumSharpBuildAnalyzerDll>…/NumSharp.Build.Analyzer.dll</NumSharpBuildAnalyzerDll> at a built analyzer DLL (the parallel to <NumSharpBuildToolDll>). Without either, a source-mode build still fails on a bad target - via the weaver post-compile - just not in the editor.

code meaning fix
NDW001 the project has [NDScoped]/[NDScopedAsync] methods but NumSharp.NDScope could not be resolved from the assembly or its references reference the NumSharp package (the attributes and the scope live there); if you invoke the weaver by hand, pass --refs with the compile's reference list
NDW002 a ref/in parameter over any NDArray-carrying shape (ref NDArray([]), a tuple with an NDArray component, a carrier struct, a List<NDArray>) - a hidden egress hand-scope and yield it explicitly
NDW003 an unsupported carrier return (a bespoke reference type, a collection, a struct without INDArrayCarrier, a nested Task<Task<…>>, or a tuple with an unsupported ND-carrying component) implement INDArrayCarrier on the struct, or hand-scope
NDW004 an async/iterator state machine the weaver does not recognize (not compiled by Roslyn/C#: no MoveNext, no <>t__builder / <>2__current) compile the method with the C# compiler, or don't scope it (real C# async/iterator methods weave fine)
NDW005 the method has no body and no inheritors - an extern (an abstract or interface declaration carrying the attribute is the contract its overrides inherit, not an error) remove the attribute
NDW006 [NDScoped]/[NDScopedAsync] on a setter-only property put the attribute on the getter accessor
NDW007 the body contains a tail-call prefix (the C# compiler does not emit these; refuse rather than mis-weave)
NDW008 an async/iterator/Task-shaped target, but the referenced NumSharp predates the async scope seam update the NumSharp package
NDW009 an async method, async iterator, or Task/ValueTask-returning method marked [NDScoped] mark it [NDScopedAsync] (that attribute owns the shapes that suspend across await)
NDW010 a plain synchronous method or a synchronous iterator marked [NDScopedAsync] mark it [NDScoped] (synchronous bodies and synchronous iterators are its job)
NDW011 a method carries BOTH [NDScoped] and [NDScopedAsync] keep only the one that matches the method's scoping model
NDW012 an NDArray, or a disposable that stores NDArrays, is created but never returned, out'd, stored, disposed, or reclaimed by an NDScope - left to the finalizer follow the ordered procedure the message carries, best fix first: suppress a false positive (a call that returns an array the method already holds, a callee that takes it over), mark [NDScopedCovered] when every caller already has a scope, mark [NDScoped]/[NDScopedAsync] only for a method that keeps no array past the call and returns no holder, else dispose it. Without NumSharp.Build the text drops the scope attribute (it would be inert) and counts only scopes opened with NDScope.Open(). A dropped holder is always disposed (no scope attribute reclaims it). Tune via .editorconfig (dotnet_diagnostic.NDW012.severity)
NDW013 [NDScoped]/[NDScopedAsync]/[NDScopedExit]/[NDAnchored] used - carried by the member itself, or inherited from a declaration in this or another assembly - but the NumSharp.Build package is not installed (or -p:SkipNDScopeWeave=true): the attributes are inert and the temporaries leak. Reported per member by the analyzer; project-wide by the MSBuild fallback scan when no analyzer runs install NumSharp.Build, or remove the attributes (mark an inheriting override [NDScopedCovered]; detach a retained argument by hand with NDScope.Detach) and dispose by hand; suppress with -p:NumSharpDisableWeaverMissingWarning=true
NDW014 [NDScopedExit] on an unsupported parameter type (a ref/out/in, a scalar, a bare buffer, an INDArrayCarrier struct, or a tuple with an ND-carrying component Detach cannot see through) hand-detach with NDScope.Detach
NDW015 an out parameter whose NDArray-carrying shape the out-escape cannot yield (out List<NDArray>, out Task<NDArray>, out NDArray[,], out T with T : NDArray) hand-scope and yield the final value explicitly
NDW019 an [NDAnchored] method the weaver cannot or need not root: no body (abstract, interface, extern), an async method or an iterator, a tail call, a method that is also [NDScoped]/[NDScopedAsync] (or inherits one), nothing to root, or a NumSharp that predates NDScope.Anchor mark the implementation that runs the kernel; remove the attribute from a state machine or a scoped method (their parameters are already rooted); update the NumSharp package
NDW016 a class/struct stores NDArrays (a field or auto-property holding an NDArray, an array/tuple/collection/generic of them, a carrier struct, or another NDArray-owning type - ownership is contagious) but implements neither IDisposable nor IAsyncDisposable implement IDisposable/IAsyncDisposable and dispose the members, make a transient result struct an INDArrayCarrier, give a ref struct a Dispose(), or mark a member / the type [NDBorrowed] when the arrays are owned elsewhere
NDW017 a disposable type never disposes an NDArray-holding member on any path from its Dispose/Dispose(bool)/DisposeAsync/DisposeAsyncCore dispose it there (directly, in a helper the path calls, in a foreach/ForEach over a collection, by handing it to a method), override an inherited Dispose(bool), or mark it [NDBorrowed]
NDW018 dotnet pack of a project whose reference to NumSharp.Build lacks PrivateAssets="all" - the weaver would ship as a dependency of your package and every consumer would restore it (a hand-written reference or Central Package Management; dotnet add package writes the metadata itself) add PrivateAssets="all" to the PackageReference, restore and pack again; or -p:NumSharpBuildAllowAsDependency=true to ship it as a dependency on purpose (consumers are still never woven)

An [NDScoped] method still allocates cold buffers in a loop. Check the coverage gate - a method that carries the attribute but no NDScope local was not woven (a -p:SkipNDScopeWeave=true build, or the package/target missing). And remember the scope reclaims the method's internal transients: the result you receive is still yours to using / Dispose / NDScope.Attach in the hot loop.

Results changed after adding the package. They should not - the weave is byte-neutral (it changes when buffers are reclaimed, never the values). If you see a difference, it is a bug; the value is covered by NumSharp's differential-fuzz gate against NumPy.