A PoC for writing M²NDP (RISC-V Vector based µthread GPNDP) workloads in Mojo, compiling them for a RISC-V/RVV target, and running them.
A workload is one file: its kernels, the device_main that launches them,
and the host code that feeds it and checks the answer -- single source, the
way a .cu is. Naming the task compiles it for the target the task declares
and runs it on the M²NDP-Detour timing simulator:
_ = Histogram.launch(PooledRange.over(samples, n),
HistogramParams(samples, hist))It started without a backend, and the seam is still there: M²NDP
operations are expressed as external symbols, so compiler work and
workload work can proceed in parallel, and the symbol set is the contract
between them. There is now a backend behind that seam --
an LLVM fork with an
XM2ndp vendor extension -- so the symbols lower to real instructions and a
kernel gets the calling convention the hardware wants. What has not changed
is that benchmarks/ never had to know.
The shortest path from nothing to something running on the simulator is the development image, which carries both toolchains already built:
docker run --rm -it ghcr.io/psal-postech/mojo-m2ndp:main
./scripts/host-run.sh helloThat compiles benchmarks/hello.mojo for the device, links it, runs it on
M²NDP-Detour, and — among a few hundred lines of the simulator's own logging —
prints:
[2026-08-06 21:25:31.222] [info] [UART] Hello, world!
[2026-08-06 21:25:31.623] [info] [UART] 2 + 2 = 4
...
[host] hello ok
The [UART] lines are the device talking: device_main wrote them to the
controller's UART, which the simulator logs a line at a time.
[host] hello ok is the host program, after the launch returned.
Do not mount over /work: the toolchains live there and a mount hides them.
The whole workload — kernels, device_main, and the host main that launches
it — is one file, the way a .cu is:
from m2ndp import NDPTask, DeviceConsole, PooledRange
from m2ndp_host import cxl_alloc
@fieldwise_init
struct HelloParams(Movable):
var scratch: UnsafePointer[Int32, MutAnyOrigin]
struct Hello(NDPTask):
comptime Params = HelloParams
@staticmethod
def device_main():
var con = DeviceConsole()
con.write("Hello, world!\n")
con.write("2 + 2 = ", 2 + 2, "\n")
def main() raises:
if Hello.emit_ir_if_asked():
return
var scratch = cxl_alloc[Int32](8)
var rc = Hello.launch(PooledRange.over(scratch, 8), HelloParams(scratch))
if rc != 0:
print("[host] hello failed, exit", rc)
return
print("[host] hello ok")device_main is the device side: it runs on the NDP controller and says which
kernels run in what order. This one launches none — it only prints, which is
what makes it the smallest workload there is.
DeviceConsole writes to the controller's UART, which the simulator logs a
line at a time as [UART]. write takes any Writable, so a value formats
the way print does on the host. Only device_main gets one: a kernel runs on
the cores, and they own no UART.
The rest is what every task needs. Params is declared once and both sides
read it — the pool is shared memory, so a parameter is a plain pointer and
nothing is transferred. launch takes the range of the pool the task runs
over, which is what settles how many microthreads there are (one per 32-byte
packet). Nothing here launches a kernel, so the range goes unused, but a range
there must be.
From here, benchmarks/vector_add.mojo is the
same shape with one kernel and real data, and
Writing a benchmark below walks through it.
git clone <this-repo> && cd mojo-m2ndp
./scripts/setup.sh # install the Mojo toolchain (./toolchain)
./scripts/build.sh # each benchmark's device IR -> out/*.ll (+ .s with our llc)
./scripts/verify.sh # check the artifactsTo run one rather than inspect it, you also need our LLVM:
./scripts/build-llvm.sh # our LLVM, with the vendor extension
./scripts/host-run.sh # every benchmark, checked against its own answer
./scripts/test.sh # the tiers, cheapest first — see tests/The image has no LLVM source in it — 2.6 GB that nothing above reads, since the compiler is already built. Rebuilding LLVM or running its lit suite needs a checkout.
If Mojo is already installed, skip setup and point at it:
export MOJO_ROOT=/path/to/modular # the directory holding bin/ and lib/
./scripts/build.shThe toolchain must have a RISC-V backend registered. Not every Mojo nightly does — a build without it fails with:
error: no compiler backend is registered for target 'riscv64-unknown-unknown-elf';
this target is not supported by this build
Pin a known-good build with MOJO_VERSION=<version> ./scripts/setup.sh;
the repo targets 1.0.0b2.dev2026061203. Which nightlies work, and why no
current one is ideal, is in docs/STATUS.md.
The benchmarks are ports of
M2NDP-public's hand-written
kernels, so each one can be read against the assembly it came from. Full
annotated walkthrough in docs/EXAMPLES.md.
out/vector_add.s, the whole kernel:
ld a1, 0(a0) # the task's parameters, from the
ld a2, 8(a0) # scratchpad -- a0 being the base
ld a0, 16(a0) # the hardware gave
sext.w a5, a5 # a5 IS global_uthread_id -- no call
slli a5, a5, 6 # x 64: one packet
vsetivli zero, 16, e32, m4, ta, ma # RVV, 16 int32 lanes = one packet
vle32.v v8, (a1)
vadd.vv v8, v8, v12
vse32.v v8, (a0)
ret # no frame: a kernel preserves nothingTwo halves are visible here. RVV comes from the Mojo compiler, reached purely through a hand-written target attribute. Everything else is the extension: a kernel that takes no arguments and reads the task's parameters out of the scratchpad, the identity values as live-in registers rather than calls, and no frame because nothing resumes after a kernel.
One row to a µthread: it walks the row itself and stores the answer once, so
nothing has to be combined. Indirect access (x[col_idx[k]]) is just a
dependent load chain — no special construct needed:
%36 = load i32, ptr %35, align 4 ; col_idx[k]
%37 = sext i32 %36 to i64
%38 = getelementptr inbounds float, ptr %3, i64 %37 ; &x[col_idx[k]]
%40 = load float, ptr %38, align 4 ; x[col_idx[k]]
%41 = fmul contract float %39, %40
%42 = fadd contract float %16, %41 ; -> fmadd.s in asmEvery call left in out/spmv.s is controller-side -- the launch and the range
the runtime sets before it -- and none is in a kernel, where a call is
rejected outright.
M²NDP has no barrier. µthreads are created and retired by hardware FGMT, so there is no well-defined set to synchronize; where µthreads do share an output, as histogram's do, they combine with an atomic, and kernel boundaries synchronize between phases.
Three kernels share a per-core bin array. Declaring the scratchpad at struct level is what keeps them on the same storage:
struct Histogram(NDPTask):
comptime bins = scratchpad[BINS, Int32, name="hist_bins"]()@memory_blob_de5f15ab6daf7941 = internal addrspace(3) global [1024 x i8] zeroinitializer
; init: store i32 0, ptr addrspace(3) %9
; body: %9 = atomicrmw add ptr addrspace(3) %8, i32 1 monotonic
; final: %13 = atomicrmw add ptr %10, i32 %12 monotonicOne global for the bins, all three kernels indexing off it. Calling
scratchpad() separately in each would mint a fresh symbol per call site and
they would silently use different memory. The task's parameter block is a
second such global, declared by the trait rather than the workload -- which is
why a kernel reads it at a constant offset and needs no argument.
The body tallies sixteen samples with one instruction, which is the point of the extension's headline addition -- an indexed vector atomic, where every lane has its own address:
m2ndp.vamoaddei32.v v12, (a0), v8, v12Neither layer could express that before. LLVM's vector atomicrmw is
contiguous, and RVV's indexed AMOs were dropped before 1.0, so there was
nothing standard to lower to. Sixteen scalar atomics were the alternative.
src/m2ndp.mojo the model: kernels' primitives, NDPTask, launching a task
src/m2ndp_host.mojo host-side machinery a launch runs on (files, processes, tools)
benchmarks/ one file per workload, each holding its kernels, its
device_main and its host main. Mostly ports of
M2NDP-public/examples/benchmarks -- 24 of them -- plus
hello.mojo and the devmain_* controller cases.
docs/STATUS.md lists what each one exercises
tests/manifest.tsv which cases each test tier draws from
sim/ the device-side launcher (m2ndp_launcher.c) and host stubs
scripts/
setup.sh install the Mojo toolchain
env.sh environment variables (source it)
build.sh each benchmark's device IR + assembly -> out/
verify.sh check the artifacts
test.sh the test tiers, cheapest first (t0 toolchain .. t4 E2E)
build-llvm.sh our LLVM (vendor extension) + lld
link-m2ndp.sh link a task against the controller launcher
host-run.sh run a workload and check its own answer
timing-run.sh run a benchmark on M²NDP-Detour's controller
docs/SIMULATION.md running compiled workloads, and what that does not catch
docs/TIMING.md the M²NDP-Detour timing model and how a task runs on it
docs/INTERFACE.md the backend contract in detail
docs/EXAMPLES.md annotated source -> LLVM IR -> assembly walkthrough
docs/STATUS.md what works, what the backend must supply, open work
third_party/ the LLVM fork and M²NDP-Detour, as submodules
out/ generated artifacts (not tracked by git)
One file, three parts: what the host passes, the task, and the host code.
comptime UNROLL = VECTOR_WIDTH // size_of[Int32]() # samples in one vector
@fieldwise_init
struct HistogramParams(Movable):
var samples: UnsafePointer[Int32, MutAnyOrigin] # plain pointers:
var out_hist: UnsafePointer[Int32, MutAnyOrigin] # the pool is shared
struct Histogram(NDPTask):
comptime Params = HistogramParams # what the host fills in
# Declared once at struct level so every kernel shares one allocation.
comptime bins = scratchpad[BINS, Int32, name="hist_bins"]()
# A kernel takes no arguments: the parameters are a scratchpad global
# like `bins`, so reading a field is a constant offset from the base.
@staticmethod
def body():
...Histogram.params[].samples...
@staticmethod
def device_main():
launch_serial[Histogram.initialize]()
launch_parallel[Histogram.body]()
launch_serial[Histogram.finalize]()
def main() raises:
if Histogram.emit_ir_if_asked():
return
var samples = cxl_alloc[Int32](n) # the shared pool; host writes here
var hist = cxl_alloc[Int32](BINS)
...fill samples...
_ = Histogram.launch(PooledRange.over(samples, n),
HistogramParams(samples, hist))
...hist already holds the result; check it here...Kernels take ordinary parameters and index them; recovering the hardware's
mapped-address form is the compiler's job. Nothing is exported: conforming to
NDPTask gives the task the one entry point the host launches it through,
and naming a kernel from device_main is what keeps it alive -- and what
tells the backend it is a kernel at all.
device_main decides the order. The kernel is a parameter of the launch
rather than an argument to it, which is what keeps the launch symbols inside
the library -- external_call takes a function only where it is named at the
call site, and a parameter is where it keeps that name.
What a benchmark never says is what hardware it runs on -- not the core count,
not the interleave, and not the packet its kernels index by, which is VECTOR_WIDTH
and comes with the library. That is all the simulator config, which the runtime
reads; pointing M2NDP_CONFIG at another description is how the same program is
shown to give the same answer on a different machine.
M²NDP is near-data processing: the data is already in the CXL pool and the
NDP cores are attached to that memory. Host and device see the same bytes.
There is no cudaMemcpy here because there is nothing to copy — which is why
a parameter is a plain pointer and no field says "in" or "out".
Two processes do not get that for free, so the pool is a file both sides map at the same address:
host Detour
mmap(MAP_FIXED, pool_base) ──┐ ┌── mmap(MAP_FIXED, pool_base)
└───┴── the same pages
cxl_alloc[Int32](n) returns an address that is a device address too, so a
kernel loads exactly what the host stored. There is one pool per process --
the runtime creates it on the first cxl_alloc and launch reaches the same
one -- so a workload never names it. Nothing is uploaded before a run
and nothing is downloaded after it; results are in the caller's pool because
they were written there.
A launch is then five steps, and only the middle three are compilation:
-
Fill. The host writes into the mapping.
Poolbumps a pointer peralloc; there is no free, a run being short. -
Compile.
Task.device_ir()askscompile_infofor the entry point and what it reaches, for the target the task declares. -
Finish and link. Our
llcdoes the M²NDP lowering the frontend's LLVM cannot, andld.lldputs the result beside the controller launcher. -
Run.
m2ndp_runattaches the pool the host mapped and runs the task on Detour's controller; the addresses that cross are pool addresses:m2ndp_run <config> task.elf <pool_file> <pool_base> <pool_bytes> \ <range_base> <range_size> <params> <params_bytes>The controller runs
device_main, whose launches ring a doorbell to run each kernel on the units. It reads no files and writes none: the parameter block is already in the pool, and the controller copies it into each unit's scratchpad so a kernel finds it at a constant offset. -
Read. The host reads its own pointers. The process exits; the mapping does not need unwinding for the answer to be there.
Nothing else has to be told where the pool is: both sides map the same file at
the base fixed in address_map.h, so no simulator device or MMIO plumbing sits
between the kernel and the bytes.
pool_bytes is the simulator config's memory_expander_size; the base is fixed
in address_map.h. How much memory the device has is the hardware's business,
not a workload's.
The seam between workload and compiler is a set of names. Details in
docs/INTERFACE.md.
What a µthread is handed — lowered to live-in registers, not calls:
| Symbol | Signature | Meaning |
|---|---|---|
__m2ndp_local_uthread_id |
i32 () |
index among the µthreads on this core |
__m2ndp_global_uthread_id |
i32 () |
index across all cores; identifies the mapped data |
__m2ndp_group_id |
i32 () |
which group this µthread belongs to |
__m2ndp_declare_params |
void (ptr addrspace(3)) |
which global the host fills; the backend exports its offset and drops the call |
Launching — implemented by the runtime, and load-bearing besides: the backend decides a function is a kernel by seeing its address reach one of these, so a kernel that is never launched is not one.
| Symbol | Meaning |
|---|---|
__m2ndp_rt_launch_task |
a task's entry point; the host calls it |
__m2ndp_set_task_range |
the range a task is mapped over, hence the µthread count |
__m2ndp_launch_parallel |
one µthread per packet of the range |
__m2ndp_launch_serial |
one µthread per core |
Operations with no spelling at this level — __m2ndp_vamoadd_* for the
indexed vector atomic, since neither atomicrmw nor RVV 1.0 can express it.
Mask-to-bitmap still has none and is open.
| Address space | Use |
|---|---|
addrspace(3) |
scratchpad (group-shared memory) |
The extension supplies all of the above except mask-to-bitmap. Where a symbol
becomes an intrinsic, only the function bodies in src/m2ndp.mojo change —
benchmark code does not. See docs/STATUS.md for what is
left.
Three things make this work.
1. The compile target can be constructed by hand.
std.gpu's hardware detection (is_nvidia_gpu() and friends) is locked
inside the closed-source std.sys.info, so new hardware cannot be
registered in that scheme. But the target parameter the kernel-compilation
entry point takes is a !kgen.target MLIR attribute, and that can be
written directly — bypassing the detection scheme entirely.
The same trick covers stdlib routines that branch on the vendor check: where
a routine takes a different path for GPUs, the library open-codes the MLIR
operation that path emits. scratchpad() does exactly this with
pop.global_alloc.
(Newer stdlib releases add std/_plugin, a supported hook interface for
registering a backend, selected by a stdlib_plugin field on the target
attribute. No nightly yet ships it and a RISC-V backend — see the
toolchain table above — but it is where this should eventually move, and the
library's scratchpad() signature already matches the corresponding hook.)
def m2ndp_target() -> __mlir_type.`!kgen.target`:
return __mlir_attr[
`#kgen.target<triple = "riscv64-unknown-elf", `,
`arch = "generic-rv64", `,
`features = "+m,+a,+f,+d,+v,+zvl128b,+xm2ndp", `,
...
]+v,+zvl128b turns RVV on. +xm2ndp is our extension: the frontend's own
LLVM does not know it and says so on every build, but it copies the string
into the target-features attribute verbatim, so the marker survives into
the IR and our llc picks it up. A task can override the whole target, which
is the whole of what lowering the same workload elsewhere takes.
2. M²NDP operations are expressed as external symbols.
Mojo's llvm_intrinsic[...] only accepts intrinsics LLVM already knows;
unknown names are rejected during translation. So external_call instead.
It survives into the LLVM IR as declare + call, which makes the mapping
points explicit — and lets the workload half be written before the compiler
half exists, which is how this was built.
3. The task compiles itself, for its own target.
compile_info takes that same !kgen.target, so a host program asks a task
for its device code rather than a build script compiling the module:
var ir = Histogram.device_ir() # compile_info, for Histogram.targetOnly what the entry point reaches comes back, which is why a single file can
hold both halves: the host main is not device code and is never compiled as
any. That is nvcc's two-pass model, arrived at from the other direction.
python3 + pip for the Mojo toolchain, and that is all build.sh and
verify.sh need. Running a workload also wants the LLVM fork and M²NDP-Detour:
build-llvm.sh builds the fork from its submodule and CMake builds Detour; the
development image carries both already. riscv64-unknown-elf-gcc compiles the
device-side launcher.
Verified on Linux x86-64.