How it is built
Architecture overview
An array-computing environment lets a scientist write C = A*B + 1 on whole matrices and get speed close to hand-tuned C. Its central object is the n-dimensional array: one flat buffer of numbers plus metadata (element type, shape, strides) that says how to read it. The one idea that makes it work is a split of labour: a slow, flexible language handles control flow and orchestration, and a small set of heavily optimized kernels (elementwise loops, matrix multiplication, factorizations, transforms, random numbers) handle the O(n) to O(n³) numeric work. Environments differ in how they bridge the two. NumPy and MATLAB keep the language interpreted and call precompiled kernels; Julia compiles the user's own loops to machine code, specialized on the argument types, so the user's code and the library code are equally fast.
The cost of the split is memory traffic: each whole-array operation reads and writes whole buffers, so chains of operations pay for temporaries. That makes fusion (one loop for a whole expression) the central optimization, done by the user (NumExpr-style or Julia dot syntax), by a compiler (JIT, GPU graph compilers), or by a library (lazy expressions in C++).
Parts:
- Array object and views — buffer, dtype, shape, strides; slices and transposes as metadata only — memory-mapped files through mmap and the page cache, Virtual memory and the TLB
- Language front end and execution tiers — interpreter for orchestration, JIT for user kernels — Python, Bytecode interpreter, JIT compilation, Inline caches, Deoptimization
- Compiler middle end — type-specialized IR, optimizations that turn array loops into vector code — SSA form, Dataflow analysis, Alias analysis (no page yet), Loop optimizations (no page yet), Auto-vectorization (no page yet), Control-flow graph
- Code generation — machine code for hot kernels — Instruction selection, Register allocation, Machine code emission (no page yet), Executable memory under W^X
- Elementwise and reduction engine — loops over strided data, vector instructions, accurate sums — SIMD programming, Floating-point error, Topological sort
- Dense linear algebra — matrix products and factorizations — Tiled matrix multiplication, LU and Cholesky factorization, PCA and SVD
- Special-purpose libraries — spectra, sparse data, random numbers — Fast Fourier transform, Sparse matrices (CSR), PRNGs (PCG, xoshiro)
- GPU path — the same array API on a device — CUDA and GPU languages, GPU memory coalescing and banking (no page yet), Kernel fusion, CUDA graphs (no page yet)
- Memory management — who frees buffers and when — Reference counting, Generational garbage collection, Mark-sweep garbage collection, Copying garbage collection (no page yet), Dirty-page tracking (no page yet)
- Foreign-function layer — calling C, Fortran and GPU libraries, exchanging buffers — FFI bindings generation, Calling conventions and ABI (no page yet), IDL code generation (no page yet)
user program (A, B, C = A*B .+ 1; loops; indexing)
│
▼
┌─────────────────┐ interpreted ┌──────────────────────────────────┐
│ language front │ calls ─────────► │ kernel library │
│ end + runtime │ │ BLAS/LAPACK, FFT, sparse, RNG, │
│ (types, GC/RC) │ compiled │ ufunc loops (SIMD, dispatch) │
│ │ loops ────────┐ └──────────────┬───────────────────┘
└───────┬─────────┘ ▼ │ strided buffers
│ ┌────────────────┐ ▼
│ array API │ JIT: infer → │ ┌───────────┐ ┌─────────┐
└───────────────►│ SSA → opt → │──►│ array │◄──│ FFI: │
│ vectorize → │ │ buffer + │ │ C, GPU │
│ machine code │ │ metadata │ │ libs │
└────────────────┘ └───────────┘ └─────────┘
How the parts fit together
Array object and views
The array is a data pointer, an element type, a shape and one stride per dimension (bytes to step along that axis). With strides, slicing, transposing, reversing, broadcasting (a stride of 0 repeats a value) and reshaping of compatible layouts all create a new metadata object over the same buffer; nothing is copied (NumPy documentation: "Copies and views"; the van der Walt, Colbert and Varoquaux paper). The cost is that every kernel must handle arbitrary strides, or first copy to a contiguous layout. Memory order is a language choice: NumPy defaults to row-major (C order), Julia and MATLAB use column-major (Fortran order, matching BLAS and LAPACK). Large arrays can be backed by a file (mmap and the page cache), giving arrays bigger than RAM; Virtual memory and the TLB explains why huge pages help large strided scans.
Language front end and execution tiers
Python is slow as an interpreter, so an array library designs for few calls with a lot of work each. The interpreter loop (Bytecode interpreter) dispatches A*B to the array type's operator, which finds a loop for the dtypes and runs it. Inline caches and, in JIT-ed Python implementations, Deoptimization speed up the interpretive overhead around the calls, but the arithmetic never runs in the interpreter. Julia reverses the design: a function is compiled the first time it is called with a given tuple of argument types (JIT compilation); type inference determines concrete types, and the compiler emits specialized machine code through LLVM. Writing a loop is then as fast as calling a library, so the library itself is written in the language. Code that defeats inference (values whose type depends on runtime data) falls back to boxed values and dynamic dispatch, which the Julia performance tips treat as the main thing to avoid.
Numba is a third route: compile selected Python numeric functions with LLVM on first call, specialized on argument types.
Compiler middle end and code generation
For compiled array code the compiler's job is to make the loop nest over strided data look like the loop a human would write. It starts from SSA form in a Control-flow graph, uses Dataflow analysis (type inference is itself a dataflow problem) and then:
- Alias analysis (no page yet) must prove that the output array does not overlap the inputs; without it, the compiler cannot reorder loads and stores or vectorize. Languages give hints (Fortran forbids aliasing of arguments;
restrictin C; Julia's@inboundsand@simd). - Loop optimizations (no page yet): invariant code motion, unrolling, interchange so the inner loop runs along the contiguous axis, tiling for caches.
- Auto-vectorization (no page yet): turn the inner loop into SIMD instructions after dependence tests and a cost model.
Back end: Instruction selection, Register allocation, Machine code emission (no page yet) and, for a JIT, Executable memory under W^X for installing the result. All of these exist so that y .= a .* x .+ b becomes one tight loop with no temporaries.
Elementwise and reduction engine
Where there is no compiler in the loop (NumPy), the same effect is obtained by hand-written kernels: a ufunc is an inner loop over strided data, with versions for each dtype. NumPy builds several kernel variants per function from "universal intrinsics" and selects the best one the running CPU supports at start-up (NumPy documentation, "CPU/SIMD optimizations"); see SIMD programming for the dispatch pattern. Reductions deserve care: summing n numbers left to right accumulates rounding error that grows with n, so NumPy sums with partial pairwise summation along the fast axis (the numpy.sum notes say this is not guaranteed along other axes) (Floating-point error). The order of a reduction changes the result in the last bits, which matters when comparing runs. Expression evaluation order is a Topological sort of the expression graph when a library or compiler schedules fused expressions.
Fusion
Interpreter-driven environments allocate a temporary per operation: a*x + b creates a*x, then adds. Julia's dot syntax makes fusion part of the language: nested dotted calls such as sin.(cos.(X)) become a single loop and a single result allocation, and the manual states that this is a syntactic guarantee and not an optional optimization. On GPUs Kernel fusion does the same job for memory-bound elementwise chains and is the first thing a GPU array library such as CuPy or a graph compiler does. CUDA graphs (no page yet) record the resulting launch sequence once so repeated iterations pay one launch.
Dense linear algebra
A*B on matrices calls BLAS, not a loop in the host language: NumPy's matmul uses an optimized BLAS library when possible, and Julia does the same for * on dense matrices. The kernels follow the structure of Tiled matrix multiplication: block into panels that fit the cache levels, pack, and run a small register-blocked micro-kernel written for each CPU (Goto and van de Geijn 2008). OpenBLAS selects its kernels at run time for the CPU it finds when built with dynamic architecture support, and BLIS makes the micro-kernel the only architecture-specific piece. Higher routines such as A\b, inv, eigen and svd call LAPACK: LU and Cholesky factorization for solves and PCA and SVD for low-rank structure. The environment's contribution is the dispatch (symmetric positive definite? triangular? pick Cholesky or a triangular solve) and using a factorization instead of an explicit inverse.
Special-purpose libraries
Fast Fourier transform routines come from a planned library (see the FFT library system); Sparse matrices (CSR) store only nonzeros and have their own multiply and solve paths, with a different performance profile (memory-bound, not compute-bound); PRNGs (PCG, xoshiro) supply random numbers with seeding rules that allow independent parallel streams. NumPy's modern Generator uses PCG64 by default; the choice of generator is an explicit object, not a global state.
GPU path
The array API is mirrored on the device (CuPy mirrors NumPy; JAX and PyTorch do their own variants). Operations launch kernels written in CUDA and GPU languages, where performance depends on coalesced access and bank-conflict-free shared memory (GPU memory coalescing and banking, no page yet). Host-device transfers are slower than any single kernel, so the main rule is to keep arrays on the device between operations.
Memory management
Arrays are large, so when a buffer is freed matters. Reference counting (CPython, so NumPy) frees a buffer the instant the last reference disappears: deterministic and local, which keeps peak memory low, with cycles the unsolved case. Tracing collectors (Mark-sweep garbage collection, Generational garbage collection) free in batches. Julia's collector is non-moving, generational and mark-sweep (Julia developer documentation), so array buffers are never moved and can be handed to C directly. A moving collector (Copying garbage collection, no page yet) would force pinning for FFI. Copy-on-write semantics (MATLAB's value semantics, shared buffers that copy at first write) need write detection; Dirty-page tracking (no page yet) is the OS-level mechanism for it.
Foreign-function layer
Most kernels are C, C++ or Fortran. FFI bindings generation describes the call surface once and generates the glue; Calling conventions and ABI (no page yet) fix how arguments reach the callee, and IDL code generation (no page yet) covers generators for the declarations. The data contract is the array's pointer, shape, strides and dtype: Python's buffer protocol (PEP 3118) and the DLPack exchange format pass such metadata between libraries without copying, so a NumPy array can become a GPU library's tensor on the same memory.
Data and control flow
C = A @ B + 1 in NumPy (interpreted environment):
- The interpreter evaluates
A @ B: dispatches tomatmul, checks dtypes and shapes, allocates the result. matmulcalls the BLASgemmfor the dtype; the library packs panels and runs the micro-kernel on all cores. Time goes to flops.+ 1creates a second temporary: a ufunc loop reads the whole matrix and writes the whole matrix. Time goes to memory bandwidth; for large arrays this pass can cost a noticeable fraction of the matrix product.- The old temporary is released by reference counting as soon as nothing refers to it.
y .= a .* x .+ b in Julia (compiled environment):
- The dot syntax is lowered to one
broadcast!call that fuses all three operations. - The first call with these argument types triggers type inference, SSA optimization, LLVM and machine-code generation; later calls reuse the compiled method. First-call latency is seconds for big code, microseconds afterwards.
- The inner loop reads
a,x,b, writesy, vectorized across elements where the compiler can prove independence. - No temporary is allocated.
Calling a C library with an array:
- The caller passes the data pointer, length, strides and dtype (buffer protocol, DLPack, or a pointer plus size in Julia's
ccall). - The binding converts types, checks contiguity (copies if the library needs a different layout) and calls through the ABI.
- Results are written into a caller-provided buffer or a new array that adopts the library's allocation.
Scaling
In the order you hit the limits:
- Temporaries and passes over memory. Fuse expressions, use in-place operations, preallocate outputs, process in blocks that fit the cache.
- Kernel quality. Reach a tuned BLAS and LAPACK, match layouts to the kernels (contiguous, column-major for Fortran libraries), use the CPU's vector instructions.
- Threads. Parallel BLAS uses all cores by default; running several independent jobs on top of it oversubscribes the machine, so set the library's thread count or run one level of parallelism.
- Interpreter overhead. Move loops into array operations or into a compiled function; the signal is a profile dominated by the interpreter and not the kernels.
- Compilation latency. In JIT environments, cache compiled code, avoid recompiling because of changing types, and keep the hot kernels small and type-stable.
- Single-node memory and compute. Move to a GPU (keep data resident), memory-map files, process chunked arrays out of core, then distribute across nodes with a library that splits arrays into chunks and schedules tasks.
Failure modes
| Failure | Symptom | Cause | Fix |
|---|---|---|---|
| Hidden temporaries | memory spikes, slow chains of operations | each operator allocates a full array | fuse (dot syntax, compiled kernels), in-place operators, blocking |
| View/copy confusion | writes change another array, or changes are silently lost | slices are views, fancy indexing copies | know which operations return views; copy explicitly where needed |
| Silent dtype surprises | integer overflow wraps, float32 results change, unexpected upcast | fixed-width integer arithmetic, mixed-type promotion | choose dtypes explicitly, check ranges, test with small and large values |
| Broadcasting mistake | wrong shape of result with no error (e.g. a column plus a row gives a matrix) | rules extend size-1 and missing axes | assert shapes at function boundaries |
| Type instability (JIT) | code 10 to 100 times slower than expected (order of) | inference cannot determine a concrete type, so values are boxed | find with the language's inference reports, make return types depend only on argument types |
| Wrong memory order | loops 5 to 10 times slower than the library routine (order of) | inner loop runs against the contiguous axis | iterate along the contiguous axis, transpose once, or call a library kernel |
| Thread oversubscription | high CPU, low throughput, noisy timings | library threads times application threads exceed the cores | one level of parallelism; set thread counts explicitly |
| Result differs between machines | last-digit differences, rarely a failed test | different BLAS kernels, reduction order, fused multiply-add | compare with tolerances; pin the BLAS if bitwise equality is a requirement |
| First-call latency | first request takes seconds | JIT compilation | warm up at start, cache compiled code, ahead-of-time compile the hot kernels |
| GPU transfer dominates | device idle, copies in the profile | arrays moved between host and device every operation | keep data on the device, fuse kernels |
Real implementations
| name | what it is | repo | licence | notes |
|---|---|---|---|---|
| NumPy | the array type and ufunc/kernel library for Python | numpy/numpy | BSD-3-Clause | strided views, SIMD dispatch, BLAS/LAPACK through a linked library |
| Julia | language with type-specialized JIT; arrays and BLAS integration in the standard library | JuliaLang/julia | MIT | dot-syntax fusion, LLVM code generation, non-moving generational GC |
| GNU Octave | interpreted, MATLAB-compatible array language | gnu-octave/octave | GPL-3.0 | project site octave.org |
| MATLAB | proprietary array language with an execution engine and bundled math libraries | mathworks.com | proprietary | column-major arrays with value semantics |
| OpenBLAS | BLAS and LAPACK implementation with per-CPU kernels, from GotoBLAS2 | OpenMathLib/OpenBLAS | BSD-3-Clause | the default BLAS in many NumPy distributions |
| BLIS | framework for building BLAS-like libraries from one micro-kernel per architecture | flame/blis | BSD-3-Clause | 2020 SIAM Best Paper Prize for its design, per its README |
| Numba | LLVM JIT for numeric Python functions | numba/numba | BSD-2-Clause | type-specialized compilation of NumPy-style loops |
| CuPy | NumPy-compatible arrays on NVIDIA and AMD GPUs | cupy/cupy | MIT | kernel fusion, vendor libraries |
| ndarray | n-dimensional arrays in Rust | rust-ndarray/ndarray | MIT or Apache-2.0 | strided views, BLAS optional |
| xtensor | C++ arrays with lazy expressions and NumPy-style semantics | xtensor-stack/xtensor | BSD-3-Clause | expression templates fuse loops at compile time |
Sources
- C. R. Harris et al., Array programming with NumPy, Nature 585, 2020. paper
- S. van der Walt, S. C. Colbert, G. Varoquaux, The NumPy array: a structure for efficient numerical computation, 2011. paper
- J. Bezanson, A. Edelman, S. Karpinski, V. B. Shah, Julia: A Fresh Approach to Numerical Computing, SIAM Review 59(1), 2017. paper
- K. Goto, R. van de Geijn, Anatomy of high-performance matrix multiplication, ACM TOMS 34(3), 2008. doi
- F. G. Van Zee, R. A. van de Geijn, BLIS: A Framework for Rapidly Instantiating BLAS Functionality, ACM TOMS 41(3), 2015. doi
- NumPy docs: ndarray internals, broadcasting, CPU/SIMD optimizations, numpy.sum, numpy.matmul
- Julia docs: Functions (dot syntax and loop fusion), Garbage collection, Performance tips
- PEP 3118, Revising the buffer protocol. PEP; DLPack. docs