backend/native design
Design goal
backend/native is where luna_thread actually runs code in parallel. It has
two jobs: run data-parallel integer kernels on MoonBit arrays without copying
them, and run a workflow graph’s synchronisation protocol on real threads. Both
are implemented in C, behind a narrow interface of integers and borrowed
arrays, so that the same runtime can later serve the JavaScript addon.
This page describes the threading model, the memory model and the mathematics
of the kernels as they are implemented in native/src/runtime.c and the bridge
ffi_runtime_bridge.c.
Mathematical background
Chunked evaluation
Let be the input, the worker count and the chunk size, with and . The runtime requires the cover condition
so that one worker can take each chunk, and splits into contiguous chunks with
Chunk sizes. Every chunk has or elements, and therefore at most :
The first claim is the usual balanced-partition argument: if the remaining elements and chunks satisfy , then lies between and , and the inequality holds again for and . At it holds with , .11 The code computes the chunk count as , which equals under the cover condition. Without the condition the chunks would be larger than , so the runtime rejects it instead.
Reductions as monoid folds
A reduction combines the elements with an associative operation :
Sum, minimum and maximum on the integers are associative and commutative. Associativity is what makes chunking correct: with chunk results ,
by the generalised associative law, whatever and are. Minimum and maximum seed each chunk with its first element, so they only need a semigroup; this is one reason the runtime requires .
Prefix sums by blocks
The scan computes the inclusive prefix sums in three phases. With the sum of chunk :
- In parallel, each chunk computes its local prefix sums for , writing them to the output, and its total .
- Sequentially, the carries and .
- In parallel, each chunk adds its carry: .
Phase 3 is correct because, by induction on , :
The scan does additions; with workers its critical path is , against for the sequential loop.
Design decisions
Checked integer arithmetic
The kernels work on and , where +
wraps around. The runtime instead treats them as the integers and refuses to
return a wrapped value: every addition is checked before it is made,
and these tests do not overflow themselves. The map kernel () first checks every element in one parallel pass and writes only if none overflows, so on failure the output is untouched.
The consequence is that a successful result is exact: each machine addition that was performed had an in-range true result, so it equals the integer addition, and by induction the returned sum or prefix equals in .
The cost is that checked addition is a partial operation and is not associative: whether some partial sum leaves the range depends on the bracketing. With :
So the values are independent of and , but the set of accepted inputs is not. The alternative, wrapping arithmetic, would make every result defined and chunking-independent, but silently wrong as an integer; the runtime chose exactness.
Fixed kernels in C
Plans can name Map and reduction kernels, but MoonBit closures cannot cross
the C interface and run on C threads. The runtime therefore implements a fixed
set of kernels in C: doubling, sum, minimum, maximum and prefix sum, for 32-
and 64-bit integers. The facade wraps the 32-bit doubling, sum and prefix sum;
the others are reachable through the ffi_* declarations.
Borrowed arrays, caller-allocated output
The MoonBit side passes FixedArray payloads to C with #borrow: C reads the
input and writes the output in place and takes no ownership, so no copy is made
and no reference count changes. The typed wrappers allocate the output with
FixedArray::make before the call. Reductions return their value directly;
the bridge passes a stack variable as the one-element output.
One scheduler lock for workflows
The workflow runtime keeps all scheduling state, the ready queue, dependency
counters, channel slots, mutex flags and barrier counters, under a single
pthread_mutex_t, and workers execute each node while holding it. This makes
every node step atomic with respect to the others without any per-capability
locks, at the price that non-compute nodes never run simultaneously; they are
bookkeeping steps that take constant time.
The threading model
Kernels
Each kernel runs its chunk loop as an OpenMP parallel for with
num_threads(w) when the runtime is compiled with OpenMP, one iteration per
chunk. Without OpenMP the pragmas are ignored and select_thread_count
returns , so the same chunks run one after another on the calling thread.
The moon build compiles the native stubs without OpenMP flags, so from
MoonBit the kernels currently run sequentially; the CMake build of native/
enables OpenMP. Because the chunking is the same in both builds, the results
and the overflow behaviour are the same.
Workflows
submit_workflow_async starts POSIX threads: workers and one
compute lane. Scheduling is Kahn’s topological sort run by a pool:
- Each node has a counter of unfinished predecessors, initially its in-degree. Nodes with counter go into a FIFO ring buffer of capacity .
- A worker waits on the scheduler’s condition variable until the queue is non-empty, dequeues a node and executes it. Completing a node decrements the counters of its successors and enqueues those that reach .
- A compute node is handed to the compute lane, and the worker waits for it. In v1 the lane only checks the node kind and reports success; the plan is not executed.
- When all nodes have completed the state becomes
Completed; the first failing node setsFailed, its status andfailed_node_id. Both wake every waiting thread so that the pool shuts down.
There are no per-thread queues and no work stealing: all workers share one queue, and FIFO order with execution under the lock makes the order in which ready nodes run the order in which they became ready.
Node semantics
| Node | Effect when executed |
|---|---|
Spawn, Join, ReadShared, WriteShared | Completes immediately; no data moves. |
Send | Blocks if the channel slot is full, otherwise fills it. |
Recv | Blocks if the slot is empty, otherwise empties it. |
Lock | Blocks if the mutex flag is set, otherwise sets it. |
Unlock | Fails with status 12 if the flag is clear, otherwise clears it. |
Wait | Blocks until a Signal on the same condition variable wakes it. |
Signal | Wakes one blocked Wait, if any; otherwise the signal is lost. |
Barrier | Blocks until every barrier node of its group has arrived. |
A channel is a slot of capacity one, and capabilities carry no payload: the runtime enforces the protocol, not the transfer of data.
A barrier’s group is the set of Barrier nodes with the same capability and
the same depth , the length of the longest path from a source to :
The runtime computes by rounds of relaxation over all edges, which is
enough because a longest path in an acyclic graph has at most edges.
A group with fewer than two nodes fails with status 14 (BARRIER_BROKEN).
The memory model
For kernels, the chunks partition , and in each parallel phase
iteration writes only output indices in and its own
partials[j] or summaries[j] cell. Distinct iterations therefore write
disjoint locations, and there is no data race. The end of an OpenMP
parallel for is a barrier, so the sequential carry phase sees every
phase-one write, and phase three sees the carries. The MoonBit caller is
blocked for the whole call, so no MoonBit code touches the arrays while C
threads run, and the borrowed arrays stay alive because the caller still holds
them.
For workflows, every access to shared scheduler state happens under the
scheduler mutex, and condition-variable waits re-acquire it, so all such
accesses are ordered. poll_workflow takes the same mutex to copy a
consistent snapshot.
Correctness / invariants
- Kernel results. When a kernel succeeds, the doubled values, the sum and the prefix sums equal their values in , independent of and , as derived above.
- Validation before work. Every kernel checks null pointers, positive sizes, , , buffer lengths and the cover condition before it allocates or computes, and returns status 1 otherwise (7 for a null pointer).
- Workflow admission. Before starting threads the runtime requires a
positive worker and node count, capabilities for the nodes that need them
with matching kinds, no
RwLock,SemaphoreorOpaquecapability, no self edge, existing edge endpoints, and acyclicity, which it checks completely by Kahn’s algorithm: a finite graph is acyclic exactly when repeatedly removing nodes without incoming edges removes them all. - Termination. If no
Send,RecvorLocknode ever blocks, everyWaitis signalled after it blocks, and every barrier group has at least two nodes, every node completes once and the workflow reachesCompletedorFailed.
Known defects
These behaviours of the current branch contradict the intent of the code and are tracked for fixing:
- Request lifetime.
luna_mbt_workflow_submit_asyncbuilds the request on its stack and frees the capability, node and edge arrays right after the runtime starts, but the worker threads keep a pointer to that request. Workflow runs read freed memory and crash intermittently. - Lost wake-ups. Only
Signaland a completed barrier re-enqueue blocked nodes. ASend,RecvorLockthat blocks is never retried, so the workflow never finishes andwait_workflowdoes not return. - Errors without a channel. A rejected workflow becomes an empty handle
reported as
Ok, andexecute_reduce_sum_i32reports failures as0. - Runtime identity.
supports_openmp()is the constanttrue, while themoonbuild has no OpenMP.
Alternatives rejected
- Work-stealing deques. Per-worker deques pay off when tasks are many and uneven. Workflow nodes here are constant-time protocol steps executed under one lock, so a shared FIFO is simpler and enough; data parallelism is left to OpenMP inside each kernel.
- Executing MoonBit closures on C threads. The MoonBit runtime gives no guarantee that its objects may be used from foreign threads, so the kernels are fixed C functions instead.
- Copying arrays across the interface. Copies would make the memory model trivial but double the memory traffic of every kernel; borrowing plus disjoint writes gives the same safety.
- Wrapping arithmetic. Rejected in favour of exact results, as derived above.
Boundaries
The backend does not execute plans, user-defined kernels, floating-point data
or Bytes; it does not move data through channels or shared capabilities; it
does not implement read-write locks or semaphores; it does not detect
deadlocks or offer timeouts; and it does not build for any target but
native.
Footnotes
-
The code computes the chunk count as , which equals under the cover condition. Without the condition the chunks would be larger than , so the runtime rejects it instead. ↩