Computational foundations
How the core fits together
Numerical calls from primitives, tacit functions and dfns build one lazy graph. An operation’s row defines its language rules, input roles and result description. Rustymath owns the mathematical formulas, partial derivatives and shared numerical kernels. Each graph node retains the call’s current inputs and settings. Captured data are call-time inputs rather than values hidden in cached code.
A read computes the graph it needs through one program planner and cache. Rewrites and scheduling arrange operations into native kernel calls or fused block stages. Block stages can run without compilation or use JIT code when compiling repays its cost. A function recording reuses the same program on new inputs. Reusable plans keep computation structure and refill descriptions and read maps at the current lengths.
Differentiation and batching transform ordinary calls into this same graph. Differentiation uses real floats, promoting exact differentiated inputs through the normal float-width rules. Forward derivatives use the shared mathematical partials; reverse derivatives transpose ordinary linear operations, accumulating where several results read one input. Power and custom folds/scans expand actual function calls rather than introduce another numerical executor. Reads that decide program structure and ordered effects form explicit boundaries. This is the foundation under construction; the target defines its required behavior and costs.
Why owning the language helps
Python array libraries already run numerical kernels in native code. BPL’s opportunity is in the work around those kernels. A BPL call needs no Python operator overload, array-object protocol or Python-to-native crossing in its core. We can give dispatch one entry, make its inputs small and measure the cost of every step. This matters most for short expressions, loops and the many small calls used to construct a larger calculation.
BPL also knows how a function was made. A primitive, a train and an operator applied to its operands have structural representations. We can reuse a recorded calculation for closures of the same definition while passing their different captured arrays as inputs. The cache need not depend on the identity of a newly allocated callable. Captured data are snapshots from the current call, never stale data kept inside compiled code. JAX documents the recompilation cost of repeatedly constructing temporary functions. JAX’s function caching.
BPL can also choose numerical domains that fit its execution model. Ordinary ₓ integers use machine arithmetic, including floor division, with dynamic widening within machine widths and wrapping at the maximum width. Unbounded integers and rationals belong to the explicit ᵣ domain. An ordinary integer calculation never needs to move to host BigInt arithmetic merely because its result grew or division produced a fraction. Dynamic kinds still support value-dependent real/complex results and within-machine promotion. This distinction removes automatic unbounded-promotion machinery without removing dynamic types. Numbers gives the language rules.
An ordinary weak literal retained as a keyed constant contributes its minimum width when promotion selects the fixed-integer domain. 1ₓ₁+1ₓ stays byte-sized; 1ₓ₁+256ₓ widens instead of failing admission. Names, captured data and formal arguments use their declared kind bounds, never observed extrema, even when the caller writes a literal. Pure ordinary static arrays keep their ordinary width.
Lazy arrays preserve the calculation that produces them across function calls. A transpose, a selection or a window can remain a description of reads until its consumer needs items. Both the BPL frontend and rustymath’s numerical kernels can use that description. A single runtime can then coordinate graph construction, transformations, planning and code reuse instead of translating between independently maintained execution routes.
Known kinds and fast forms
Fast calculation uses static kinds or explicit approximate forms whose result kinds follow from the arguments without reading array items. √ₐ, *ₐ and ⍟ₐ have this contract, as do ○ₐ and ○⁻ₐ with a constant code. The approximate forms select real float results at the arguments’ promoted width. Outside the real domain they return the real part of the principal value.
Known-complex arithmetic retains its declared complex kind without scanning the result’s imaginary parts. Numbers describes real-valued functions and the explicit real-part conversion.
Symbolic ∞ and ¯∞ have singleton logical kinds backed by ordinary float storage. Order-only operations can preserve an exact operand or decide a comparison from those kinds without reading array items. The existing kind key distinguishes both signs from finite inputs and ordinary floating infinities. Numbers gives the language rules.
The value-dependent dynamic forms of √, *, ⍟ and ○ have no speed, fusion or recording requirement. They remain useful for interactive work, including results that become complex according to input values. Use the explicit fast forms for these calculations. Dfn guards on traced values also remain outside the fast contract. Ordinary dynamic interactive work, including tacit code and simple dfns, still aims to cost at most four times the same static calculation.
The boundary follows whether ordinary planning needs numeric values. It is not a list of slow glyphs. Known kinds or proofs can already make a dynamic call fast. A body using an approximate operation can expose that operation normally. An outer result conversion does not permit changing every complex intermediate in an arbitrary body. No pre-kind IR or additional value-specialization machinery is needed to accelerate the excluded forms.
Calls build a graph; reads compute
A graph records operations and their dependencies. A pending array holds its producer node instead of an intermediate buffer. For example, suppose x is a real float vector:
loss←+/⊼
y←loss x⊼ squares each item. +/ sums the squares. Calling loss builds those operations without first storing a vector of squares. Displaying y reads its value. The runtime then plans the reachable graph and computes the total, with the square inside the reduction where the execution rules support it.
We borrow lazy graph construction and node interning from tinygrad. Interning gives structurally identical live nodes one representation. tinygrad separates Tensor construction, scheduling and realize, and replaces realized producers with storage. Its compiler caches generated programs by a structural key. BPL uses the same separation to retain producer information before a read and release it after storing the result. tinygrad’s UOpMetaClass, Tensor realization, compilation and execution.
A plan decides operation order, which intermediate results need storage and which operations run together. Fusion means computing a producer’s items inside its consumer rather than storing and rereading an array between them. Futhark performs producer-consumer and shared-input fusion over a dependency graph, even when the source expressions are separated by names or functions. BPL’s graph gives its rewrite rules that same view. Futhark’s fusion and views.
A trace records the graph built by an actual run. A replay binds new inputs to the recorded calculation and runs its plan. tinygrad’s TinyJit demonstrates recording executed work and replaying it without rerunning the Python function. BPL records the graph that ordinary calls already build. A trace is therefore a way to reuse construction work, not another interpretation of numerical operations. Ordinary graph reads and recorded calls use the same planning pipeline. TinyJit capture and replay.
Function structure and operation facts
With the same arguments and numeric context, (1ₓ₁+), 1ₓ₁↣+ and {1ₓ₁+⍵} build the same addition with a literal left operand. Different function templates and capture layouts do not require different numerical rules. Literal nodes retain their values in the graph. Arguments and captured data enter as inputs. Promotion, derivatives and kernels belong to the resulting operation.
Every numeric token is interpreted when its containing statement executes under the active numeric settings. A bound operand is a value evaluated during construction. A dfn’s body statement executes when called. Changing preferences between those events can produce different literal types. This is one statement-execution rule. Free names read at a call and values stored in a bound function also have genuinely different binding times.
Fold, product and marked-function consumers use one typed-body query. A declared primitive supplies its operation directly. For another function, the query reuses effect-safe planning and the existing recording/cache. It accepts an original recording that returns one native primitive call with unchanged output metadata, ordered formal inputs, identity read mappings and matching settings. Conversions, observed data-dependent control, effects, writes and custom derivative rules prevent substitution.
An admitted body reuses the operation’s existing numerical facts, including its reduction identity: {⍺+⍵}/⍬ returns 0 like +/⍬. The query proves the operation for the current arity and cell descriptions, not general mathematical equivalence. Unsupported bodies retain ordinary execution and generic empty folds still require an identity. There is no second body IR, capability cache or numerical evaluator.
Transformations keep whole-array work
Batching applies one function to many cells by transforming its operations into array operations. A cell is the part of an array that the function receives at each position. BPL’s Each, Rank and related operators use one batching mechanism instead of separate evaluators for each operator.
Records and ordinary nested arrays preserve their structure while their numeric leaves use the same graph. Uniform results can become dense arrays. Ragged cells retain the released mixed-array behavior. A native segmented representation with shared leaves, offsets and shapes could accelerate those workloads, but would require new storage, metadata and transformation contracts. That capability is deferred until its benefit can be measured against the simpler nested representation.
Stored host results use stored-value construction. Only compatible deferred native items need graph collection. A pending value changes its layout through a graph view, never a stored-only relayout. For a batched call, the outer frame axes identify the separate calls. Combining homogeneous nested items into a native array preserves those axes separately from each child’s axes. Missing outer frame axes become leading unit-axis views; child axes must not take their place.
JAX’s Autodidax tutorial supplies the model for composing these transformations. In that presentation every primitive call goes through bind, which selects the relevant transformation interpreter from its arguments. A transformation can call primitives through the same entry. Older transformed values can be lifted into a newer transformation without losing their earlier meaning. BPL calls these nested interpreters levels. Its derivative and batching levels follow those data dependencies. JAX’s interpreter stack, lifting and batching.
For the example above:
grad←loss∂
grad x∂ gives the gradient of a function with one number as its result. Here the gradient is twice each item of x. The original calculation is the primal computation. Forward differentiation propagates a tangent, the change in a result along a chosen input direction. Linearization separates that primal calculation from a calculation linear in the tangent. Reverse differentiation transposes the ordinary linear graph steps to propagate result sensitivities back to the inputs. Coefficients retain the intermediate values from the actual forward call; a recorded Description-only coefficient captures primal geometry, while merged or numerical uses retain their Dual and Batch values. BPL uses one graph and one set of mathematical partials for both modes, rather than a separate AD evaluator. JAX’s linearization and transposition.
Dex and Futhark show why preserving array structure through differentiation matters. Dex linearizes for bodies and transposes their structured loops. Its transpose uses indexed accumulations to send sensitivities back to selected array positions. Futhark differentiates maps, reductions and scans as bulk array operations, then allows fusion to run again. BPL follows that principle. The reverse of a repeated read accumulates at the source positions, without constructing a one-hot array or invoking an interpreter for every item. Dex linearization, Dex transposition, Futhark’s array differentiation.
Dex also shows the value of writing a formula for one item while retaining its array structure. BPL’s index notation follows that idea through named axes. Index formulas and whole-array spellings build operations that the same contraction and read-mapping rules can recognize. BPL uses arrays of named indices rather than Dex’s statically typed index sets. Dex’s table comprehensions and typed indexing.
The reverse derivative of a structural operation can often read from the sensitivity array instead of scattering into a new one. tinygrad’s gradient rules reverse padding with a shrink and a shrink with padding. BPL similarly prefers read-based transposes when the mapping permits them, retaining accumulation where positions repeat. tinygrad’s movement gradients.
The transformed operations still enter the ordinary graph. Rewrite rules and fusion therefore apply to the gradient as well as the original function. Scalar mathematical facts have one home, including formulas, partial derivatives and range information. A rule can recognize a contraction or a stable mathematical form after batching and differentiation have exposed it.
Agenda and conditional updates first choose masked execution or compaction using cached case descriptions. They then call the original functions and build ordinary graph operations before differentiation or batching. Masked cases gate active inputs and tangent directions before the call, protecting derivatives from inactive infinite slopes. Compaction gathers each case’s selected cells, calls the function once on that whole group, and scatters its results. It does not implicitly map the function per cell. Both forms use the ordinary graph transpose, including read-back accumulation, while actual function calls preserve custom derivative rules and the forward computation’s intermediate values.
Iteration starts with unrolled graphs
Power (⍣) and generic folds/scans repeatedly call a function. The foundation reuses the existing host iteration algorithms and sends each actual body call through the ordinary function and graph machinery. Under differentiation those calls build an unrolled linear graph. Known reduction operations keep their dedicated numerical algorithms. Exposing those operations through equivalent function bodies is the shared-boundary work described above. Effects, control flow and unknown algebra still require ordered calls. Each normally batches cells rather than interpreting a loop over every numeric item. JAX similarly traces Python loops by unrolling them, while lax.scan explicitly requests a compact loop representation. JAX’s loop transformations.
Unrolling makes graph size grow with the executed work. The linear graph already identifies the operations taken and retains their required coefficients. It needs no separate carry/residual/history-count packet or reverse-loop interpreter. A count that decides how many calls are expanded is structural and keys its exact value. Data-dependent control follows the normal recording-stop boundary while the executed computation and its derivative remain correct. This differs from an array length that can refill an unchanged plan.
Compact loop AD could keep graph construction proportional to body size and make long iteration easier to compile. Checkpointing could trade recomputation for less saved data. Those are later capabilities to add when a workload demonstrates their benefit. Dex and Futhark supply useful designs for them; they are not prerequisites for consolidating BPL’s existing AD. The foundation retains nested AD, iteration histories, predicates, convergence and inverse iteration without introducing a compact loop IR. Dex’s residual handling, Futhark’s reverse loops.
Reuse structure as lengths change
An array’s exact length often changes the amount of work without changing the computation. A kernel can take that length as a runtime argument. Futhark’s compiled libraries accept array dimensions at runtime. tinygrad also represents bounded symbolic sizes separately from their current values. BPL’s kernels use runtime counts without requiring a symbolic shape language throughout the runtime. Futhark’s array API, tinygrad’s variables.
BPL keys reusable plans by structure and the facts their validity needs. The length component distinguishes empty, unit and longer axes. Unit axes change broadcasting. Ranks, kinds, axis equalities and structural parameters also matter. Two longer vectors of different lengths can reuse a plan when those other facts agree.
Execution computes a graph key once for cache lookup and insertion and propagates checked errors for invalid structural parameters. Description and AD walks do not compute graph keys.
A reusable plan takes only intrinsic kind bounds from an opaque data input, even when its caller knows a narrower range. Forced graphs and recordings use this same rule. Literals retain their values in the key. Operations inside the graph can prove narrower ranges, such as nonnegativity after real-floating magnitude, and refill derives index/count bounds from current lengths. There are no numeric contracts on particular input values to guard, invalidate or retrace. A proof that is unavailable leaves the ordinary kind read, bounds check or numerical repair in place. The ordinary dynamic cost targets still apply outside the value-dependent forms described in Known kinds and fast forms.
Refill updates concrete result descriptions and read mappings for the new lengths. It preserves the plan’s rewrites, operation order and stage structure. Preparation uses actual counts for the few choices where size changes the preferred execution, including whether compiling is worthwhile, which direction to read a contraction and whether to recompute an intermediate.
Futhark demonstrates choosing implementations at runtime from size checks. Its multicore scheduler selects between parallel and sequential nested work, and its GPU compiler generates versions for different amounts of parallelism. BPL borrows size-sensitive choice without adopting an ahead-of-time catalogue of versions. A just-in-time compiler emits a worthwhile structural version when execution first needs it and keeps that code in one cache. A new length does not automatically require another compilation. BPL uses no size buckets or padding to a bucket. Futhark’s runtime selection.
Filtering and finding unique items determine a result’s length from data. BPL schedules only the prefix before an unobserved count. After reading it, execution looks up the downstream suffix in the same cache under the count-extended key. A hit refills that suffix at actual lengths; a miss schedules it. Completed effects and draws stay in the same run state. A changed structural fact uses the normal cache-miss path. The read is an execution boundary, not a reason to reject the expression. JAX’s jitted unique instead requires a static output size, with truncation or padding to that size. This distinction matters for interactive data analysis. JAX’s data-dependent output sizes.
Effects and errors belong in the model
Lazy execution must preserve visible actions. BPL represents output and other effects as ordered nodes. A token is a dependency with no numerical meaning that keeps one effect after the previous effect. This follows JAX’s ordered-effects design. An effect reads only its own operands, rather than forcing every pending array in the session. JAX’s effect sequencing.
A draw captures its generator and reserves a stream when its whole-array node executes. Reading that result again reuses its samples; replaying the program executes a fresh draw. Capturing inputs does not advance a counter. Independent lazy draws can receive streams in read order. Counter-based item positions keep a batched draw nonoverlapping without serializing its lanes. JAX’s random design supplies the model for those streams. BPL manages the streams inside its runtime rather than requiring JAX’s explicit user-level key splitting. JAX’s PRNG design.
Static graph-numeric execution has a non-raising policy for data errors. An invalid numeric index reads the fill, including on an empty axis, and an invalid numeric write skips that position. Dynamic execution retains checked behaviour. A potentially fallible dynamic branch gathers only its live cells before running them, a process called compaction. There is no speculative whole-read retry. Numerical execution records checked data errors without raising them. A host materialization/readback checks that slot before exposing storage; effects, error guards, display and statement ends are also checkpoints. Shape, kind, name and host errors remain checked. These choices avoid maintaining error-replay machinery alongside the graph executor.
Choose a useful result where the semantics and implementation permit one. Extra checks whose purpose is to reject a reasonable computation need justification. A wider integer result can be useful where a narrow type cannot hold an operand. Error behavior is a language choice rather than a requirement inherited from an old implementation. Memory safety, structural validity and checked host operations still require their own checks.
Integer-producing real rounding already uses saturating conversion in native execution and the JIT: positive infinity gives the maximum integer, negative infinity gives the minimum, and NaN gives zero. Dynamic unmarked ⌊ and ⌈ produce integers. Static unmarked forms retain floats; explicit ⌊ₓ and ⌈ₓ produce integers in both modes. Exact-domain and complex rounding keep their existing domains. No extra rounding algorithm or error predicate is needed for these exceptional real inputs.
Indexed reads and writes share one selection layout. Repeating a source key drops the affected result axis’s keys, as Index describes. A write preserves the original array’s keys without promising which replacement wins at a repeated position. There is no read/write planning mode or selection context to preserve through recording and replay.
One home for each decision
BPL supplies language rules and function structure. rustymath supplies numerical operations and shared array-reading machinery. Transforms rebuild operations through the same dispatch entry. The owner resolves each fact once; consumers use that result rather than infer it again from a value’s representation. A mathematical fact, read mapping, iteration algorithm or cache policy keeps the same owner across frontends and derivatives.
Graph construction uses logical values and descriptions. The runtime read boundary observes actual items. Item-processing algorithms use the existing storage view, buffer or slice that they operate on, while the original value retains graph and selection identity. Static enforcement belongs at that boundary. A proof wrapper passed through unrelated code would add another obligation to callers. Use a narrow checked API or ratchet where a type would cause that spread.
Default fills, explicitly chosen fills, inference of an empty result’s prototype and logical write destinations are different responsibilities. Shared lower-level rules do not make those facts interchangeable. The target records each construction boundary and the old entry points removed when it changes.
The same graph should remain usable by a later GPU or external backend without adding a second account of what a BPL expression means.
Development and performance builds
Development and correctness-test helpers use the optimized, incremental quick profile with LTO off. Request --profile lto for local ThinLTO when checking performance budgets. Cross-crate generic sharing reuses rustymath’s instantiated Rust implementations in BPL instead of generating them again. The build configuration enables its unstable compiler switch. Separate Cargo artifact and incremental caches remain available when switching profiles. The development guide owns the tested compiler configuration and commands.