Sparsr Docs

Writing a Kernel in C

Runs on: the Sparsr VM (SPARSR_BACKEND=vm). Write your kernel against the VM, and assume it does not run on a card yet. As of September 2026 the FPGA decodes the same wide encoding this page uses, but only part of it, and its scalar decoder covers a subset of RV32I that leaves out branches, jumps and lui. A compiler emits all three, so a C kernel of any size needs the VM today. The Instruction Set page has the exact support, instruction by instruction. Everything on this page keeps working as the hardware catches up.

How to get it: everything on this page is in the Sparsr SDK download. You also need a RISC-V bare-metal GCC, which is not included in the SDK.

A Sparsr kernel written in C is ordinary C. You write it against the compiler you already have, and the ordinary build step is the whole build step.

The shortest complete kernel

#include "sparsr_intrinsics.h"

#define ROW_A      1
#define ROW_B      2
#define ROW_RESULT 3

void kernel_main(void) {
    _sparsr_wl(SPARSR_W1, ROW_A);
    _sparsr_wl(SPARSR_W2, ROW_B);

    _sparsr_wxor(SPARSR_W3, SPARSR_W1, SPARSR_W2);

    _sparsr_ws(SPARSR_W3, ROW_RESULT);
}

That reads two wide vectors out of wide memory, exclusive-ORs them, and writes the result back. The whole thing is four wide instructions.

The complete example, with the host program that feeds it, is in the SDK at examples/03_kernel_in_c/. Build and run it with make run from that directory.

Three rules, and that is all of them

1. Your entry point is a function called kernel_main. Not main. The device sets up the stack pointer and the return address before it starts your kernel, exactly as any caller sets them up before a function call, so kernel_main is a normal C function. Returning from it ends the batch and reports success.

To stop early, or to report a failure the host should see:

_sparsr_exit(1);   /* 0 means success, by the usual convention */

2. The wide intrinsics are macros, and their register and row numbers must be constants. _sparsr_wl(SPARSR_W1, ROW_A) compiles to exactly one instruction. The wide register number and the WMEM row are fields inside the instruction word, with no register behind them, so they cannot come from a variable.

_sparsr_wl(SPARSR_W1, 3);      /* fine */

int row = get_row();
_sparsr_wl(SPARSR_W1, row);    /* compiler error, and it names the rule */

For a row chosen at run time, reach for the register-indirect form _sparsr_wlr / _sparsr_wsr. It takes the base address in an ordinary register.

3. There is no floating point. The header poisons float and double, so using one is a compiler error rather than a wrong answer later.

Building it

The kernel is compiled by stock RISC-V GCC. Sparsr does not provide a compiler of its own, only the three files GCC needs.

riscv64-unknown-elf-gcc \
  -march=rv32i -mabi=ilp32 -O2 \
  -ffreestanding -nostdlib -nostartfiles -ffunction-sections -fno-pie -no-pie \
  -I<sdk>/include \
  -T<sdk>/ldscripts/sparsr.ld \
  -Wl,--defsym=__sparsr_loads_every_segment=1 \
  <sdk>/startup/sparsr_crt0.S kernel.c -o kernel.spex

On Debian or Ubuntu the toolchain is one command:

sudo apt install gcc-riscv64-unknown-elf binutils-riscv64-unknown-elf

Each unusual flag is doing a specific job:

Flag Why
-march=rv32i -mabi=ilp32 The device is RV32I. Any other architecture is a compile error.
-nostdlib -nostartfiles Drop the C library and GCC's startup files. sparsr_crt0.S replaces them and is far smaller.
-ffunction-sections Gives kernel_main its own section, so the link script can place it first.
-T .../sparsr.ld The link script. It puts the entry point at address 0 and assigns memory regions.
-Wl,--defsym=__sparsr_loads_every_segment=1 Your promise that you deliver the linked file whole. See below.

The linked ELF is the kernel image. There is no packaging step and no objcopy. Its program headers already say which bytes go to instruction memory and which to data memory, which is exactly what the host needs to place both.

That is what __sparsr_loads_every_segment is about. If your build ends with objcopy -O binary -j .text, it takes the code and silently throws your initialised data away. The link script refuses to produce such an image unless the build declares it delivers everything - so a build that would drop your lookup table fails at link time with a message, instead of computing the wrong answer.

IMEM, DMEM and WMEM

A kernel has three separate memories, and none of them is the host's RAM.

Memory Size Holds
IMEM 64 KiB on the Sparsr VM (4 KiB on today's hardware) your compiled kernel
DMEM 64 KiB on the Sparsr VM (4 KiB on today's hardware) your globals, your stack, scalar data
WMEM 4,096 rows through the host library, 15 on a standalone sparsr-vm serve one whole wide vector per row

Globals work the way you expect. .bss is cleared, and .data and .rodata are carried in the image and placed for you - so a lookup table, a string literal, or an initialised global all work.

A static does not survive between batches, which surprises people. The startup file clears .bss at the start of every batch, so a zero-initialised global is zero again each time. The device is deliberately not reset between batches. Anything that must last beyond one batch belongs in WMEM or a wide register. A DMEM word the host controls also works.

If your host writes operands to the bottom of DMEM, move your globals out of the way:

-Wl,--defsym=__bss_origin=0x80000100

Keeping results in wide memory

WMEM rows have byte addresses, so plain C can read and write them. _sparsr_wmem_row(n) returns a uint32_t * to the lanes of row n. Lane 0 is the row's first four bytes.

#define ROW_INPUT  4
#define ROW_RESULT 0

void kernel_main(void) {
    _sparsr_wl(SPARSR_W1, ROW_INPUT);
    _sparsr_wmem_row(ROW_RESULT)[0] = _sparsr_wpopcount(SPARSR_W1);
}

The host reads the count back with sparsr_read_data_wmem(ROW_RESULT). The count stays in WMEM, so DMEM keeps its room for your stack and globals.

Row 0 has the same address at every width. Other rows move with SPARSR_WIDE_BITS, so build the kernel for the width of the device it runs on.

The complete program is in the SDK at examples/04_kernel_row_result/. See Memory Model for the address map.

Registers you have

  • 32 scalar registers - ordinary RV32I, used by the compiler as usual.
  • 32 wide registers, SPARSR_W0 to SPARSR_W31, of SPARSR_WIDE_BITS bits each: 8,192 on the Sparsr VM and on the hardware, and 4,096 when a host program reaches the VM through the host library. Build with -DSPARSR_WIDE_BITS=4096 when your kernel sizes anything by the width. SPARSR_WIDE_BYTES and SPARSR_WIDE_LANES follow from it.

The intrinsics, one instruction each

Every one compiles to one instruction. The full list is in sparsr_intrinsics.h; these are the ones most kernels use.

Intrinsic Does
_sparsr_wl(wd, row) / _sparsr_ws(ws, row) Load / store a wide vector at a fixed WMEM row
_sparsr_wlr(wd, base, disp) / _sparsr_wsr(...) The same, with the row computed at run time
_sparsr_wand, _sparsr_wor, _sparsr_wxor, _sparsr_wnor, _sparsr_wxnor, _sparsr_wandn, _sparsr_wnot Bitwise, a whole register at a time
_sparsr_wpopcount(ws) Population count into a scalar register
_sparsr_whamming(a, b) popcount(a XOR b) - one instruction
_sparsr_woverlap(a, b) popcount(a AND b) - one instruction
_sparsr_wany(ws) / _sparsr_wall(ws) OR-reduce / AND-reduce to a scalar
_sparsr_wrnd(wd) Fill a wide register with random bits
_sparsr_wcsa(wsum, wcarry, a, b) Carry-save add. The one instruction that writes two wide registers
_sparsr_wreduce(f, mode, a, b) Any ALU function with any reduction mode, when the rows above do not cover what you want
_sparsr_exit(status) End the batch early

A wide load or store moves one whole row, and a row stores one whole wide vector. You never have to track a second view of the same bytes. The older names _sparsr_wlu, _sparsr_wsu, _sparsr_wlur and _sparsr_wsur still compile, and each one now means the same as the plain load or store above.

Running it

A C kernel is RV32I, so it needs the Sparsr VM. Name it:

SPARSR_BACKEND=vm ./my_host_app

Or run the kernel on its own, with no host program at all:

sparsr-vm run kernel.spex --trace

See Sparsr VM for what that gives you.

A limit on the way in, not on the row

A WMEM row stores one whole wide vector at any density. The limit is on one of the two ways the host puts a vector there.

  • sparsr_write_data_wmem() compresses the vector on the way to the device, so it refuses one with more than 48 non-zero 32-bit lanes. The call rejects the vector and reports an error, and the row keeps what it had.
  • sparsr_write_data_wmem_raw() sends the whole vector, at any density.

Through the host library, a kernel's own wide store is held to the same 48 lanes. That keeps working the libraries that detect a too-dense result by the store failing. Your host writes a dense vector with the raw call above. See Memory Model.

Built 2026-10-05 19:25 UTC · c0237a4