libjay
Independent, embeddable implementations of the J and APL array languages, as one dependency-light Rust crate. Not a DataFrame library and not a framework: the relationship to your code is the one PCRE has — a small language inside a string literal, compiled once, run many times, with the heavy machinery hidden inside.
The Cargo package is libjay; the library is jay (so the linker artifact
is not liblibjay), matching the C -ljay and the Python import jay.
Compile once, run many times
use ;
let program = compile?;
let x = new;
let value = program.run?; // Some(2.8) — the mean
let mut lines = .into_iter.map;
let echoed = compile?
.run_io?; // reads one line
{name} holes become parameters. program.params reports them in the order
run expects; arguments are positional. run returns Option<Array> —
None when the last sentence yields no value (an assignment, or echo/⎕←).
The closure is the output sink for echo, ⎕← and ⍞←; stdout is the
sandbox default and no other I/O is open. run_io is the same call with an
input source too — a closure answering one line per call and None at the
end — which is what APL's ⍞ and ⎕ and J's 1!:1 ]1 read; run itself
has none, and an expression that reads says so rather than reading
anything.
Dialect carries the host's settings — today APL's ⎕IO
(Dialect { index_origin: Some(0) }); J's index origin is 0 and is not
configurable. Lang::Apl selects the other frontend, with its own semantics:
J reduces along the leading axis, APL along the trailing one.
Errors carry a span into the source. Error::render(source) renders a
compile error, Program::render_error a run error, both the way the CLI
would:
length error: arguments do not agree: left shape 3, right shape 2
1 2 3 + 1 2
^^^^^^^^^^^
note: frames first differ at axis 0: 3 vs 2
jay::fmt::format_array(&value, &program.fmt) renders a result the way its
language displays it.
What is inside
Both frontends lower to one language-agnostic IR: an Expr tree over a
Verb combinator tree (Prim / Rank / Reduce / Fork / Hook / Atop /
Windowed). One generic rank-and-agreement engine executes everything, so
APL's +/ is simply Rank(Reduce(+), 1) and no J assumption reaches the
runtime. A compile-time pass fuses chains of elementwise primitives into one
blockwise kernel, absorbing a trailing full-rank reduction; anything it will
not fuse falls back to the subtree it replaced, so results and error messages
cannot change. Dense arrays of bool / i64 / f64 / characters. Array carries
a private row-major/column-major layout flag — every array libjay builds for
itself is row-major, a table crosses from its columns without a copy, and
transpose (|:, ⍉) is exactly the flag flipped, moving no elements.
Threads
Execution is parallel by default — part of what a compiled expression is, not a switch the caller flips. Elementwise passes, pure rank cells and leading-axis reductions split above 65,536 element operations.
A Program is immutable, holds no data, and is Send + Sync: share one
across threads, or wrap it in an Arc and run it concurrently. The pool is
the crate's own, not rayon's global one, so an embedding host that also uses
rayon keeps its pool intact; work started inside a rayon worker stays in that
worker's pool. LIBJAY_THREADS sets the size (read once, at first use),
otherwise it is the machine's available parallelism.
An associative float reduction may be regrouped across chunks, which reorders the rounding; everything else is bit-identical to the sequential path.
Devices
Where a program runs is separate from what it is bound to, and the CPU is one of the answers:
let device = default_gpu; // None where there is none
let value = match &device ;
jay::device::available() lists the adapters. What reaches a GPU is the
fused elementwise kernels, generated as WGSL at run time and dispatched
through wgpu (Metal, Vulkan, DX12); everything else, and any chain the
device will not take, runs on the CPU, and Program::explain_on names each
placement and its reason. The backend is in every build and dormant without
an adapter — there is no feature flag and no second artifact.
libjay computes floats in f64 and will not lose that quietly: on an adapter
without SHADER_F64 — Metal has none — an f64 chain stays on the CPU unless
the caller asks for Precision::F32. Device::upload returns an array that
carries its own device allocation, so repeated runs over it upload nothing.
More
- Language coverage — what each frontend understands today, and the deliberate divergences from the reference implementations.
- Benchmarks — against Polars, numba and numpy.
- Embedding from C
— the
libjay-capicrate buildslibjay.so/.dylibpluslibjay.aand a hand-writtenjay.h. - Python surface of the same engine.
- Source and issues.
MIT licensed.