Elixir NIF bindings for the CIX P1 (Arm-China "Zhouyi") NPU on the Orange Pi
6 / 6 Plus, via the CIX NOE runtime (libnoe).
It is a thin, low-level wrapper: load a pre-compiled model graph, feed raw input
tensors, run inference on the NPU, read raw output tensors. Model
pre/post-processing (image resize, detection decoding, …) stays in your code. An
optional Nx layer converts tensor binaries
to/from Nx.Tensor.
Requirements
Runs only on a Nerves image built from
nerves_system_orangepi6 v0.2.0 or later, which
provides:
- the
aipukernel driver (/dev/aipu, world-accessible via udev), - the NOE/AIPU userspace runtime (
libnoe,libaipudrv) on the rootfs and in the staging sysroot (so this NIF cross-links at firmware build time), LD_LIBRARY_PATH=/usr/share/cix/libexported to the BEAM.
Models must be compiled to the NOE .cix graph format — either with the NOE
Compiler (NOE SDK / AI ModelHub Development Guide) or downloaded pre-built from
the CIX AI Model Hub. The Radxa Orion O6 shares this silicon, so the
Zhouyi NPU tutorial applies for
model compilation.
Installation
Add it to a Nerves firmware project that targets nerves_system_orangepi6:
def deps do
[
{:cix_p1_tpu, "~> 0.1"},
# optional, for the CixP1.Nx helpers:
{:nx, "~> 0.7"}
]
endTagged releases publish a precompiled aarch64 NIF (built with the matched
Nerves toolchain) to GitHub Releases + Hex via cc_precompiler, so consumers
don't rebuild the native code — mix deps.get downloads it and verifies it
against checksum.exs. If no precompiled artifact matches, the NIF builds from
source during mix firmware against the system's staging sysroot. On a plain
host (no NOE SDK) the native build is skipped so pure-Elixir compilation and
mix test still work.
Releases
Cut a release by tagging vX.Y.Z (matching the mix.exs version); the
Release Precompiled NIF workflow cross-compiles the NIF with the Nerves
aarch64 toolchain against the system's libnoe/libaipudrv, uploads the tarball
checksum.exsto the GitHub release, and publishes to Hex (needs theHEX_API_KEYsecret).
Usage
One-shot inference
{:ok, ctx} = CixP1.Context.new()
{:ok, graph} = CixP1.Graph.load(ctx, "/data/models/mobilenet.cix")
# inputs is a list of raw binaries, one per input tensor, in order
{:ok, [logits]} = CixP1.run(graph, [image_binary], timeout_ms: 5_000)Step-by-step (reuse a job for repeated inference)
{:ok, ctx} = CixP1.Context.new()
{:ok, graph} = CixP1.Graph.load(ctx, "/data/models/model.cix")
{:ok, job} = CixP1.Job.create(graph)
:ok = CixP1.Job.load_input(job, 0, input_binary)
:ok = CixP1.Job.infer(job, 5_000)
{:ok, output_binary} = CixP1.Job.get_output(job, 0)Inspecting tensors
{:ok, 1} = CixP1.Graph.input_count(graph)
{:ok, in_desc} = CixP1.Graph.input_descriptor(graph, 0)
{:ok, out_desc} = CixP1.Graph.output_descriptor(graph, 0)
# => %{id: _, size: bytes, scale: _, zero_point: _, data_type: :u8 | :f32 | ...}Note: NOE descriptors expose the element type and flat byte size, not the
logical shape. Track the shape from your model definition (or noe_get_tensor_shape
on the .cix file offline) and pass it to CixP1.Nx.to_nx/3.
Nx interop (optional)
{:ok, [out]} = CixP1.run(graph, [input])
{:ok, desc} = CixP1.Graph.output_descriptor(graph, 0)
probs =
out
|> CixP1.Nx.to_nx(desc, shape: {1, 1000})
|> CixP1.Nx.dequantize(desc) # (q - zero_point) * scale; no-op if scale == 0.0
|> Nx.squeeze()
top1 = probs |> Nx.argmax() |> Nx.to_number()Architecture
| Layer | Role |
|---|---|
CixP1.Context | NOE UMD context (opens /dev/aipu) |
CixP1.Graph | a loaded .cix graph + tensor descriptors |
CixP1.Job | one inference invocation (load inputs → infer → read outputs) |
CixP1 | run/2 one-shot convenience |
CixP1.Nx | optional Nx.Tensor ↔ binary conversion + dequantization |
CixP1.Nif | raw NIF over libnoe (c_src/cix_p1_nif.c) — internal |
Resources are reference-counted by the BEAM and form a keep-alive chain
(Job → Graph → Context), so noe_clean_job / noe_unload_graph /
noe_deinit_context run in the correct order regardless of GC timing. Inference
and buffer operations run on dirty schedulers so they never block the BEAM's
normal schedulers.
Development
mix deps.get
mix compile # native build skipped off-target
mix test # host: runs pure-Elixir tests; NIF tests are tagged :hardwareOn-device, run the hardware-tagged tests with mix test --include hardware.
Full off-target compile+link (CI)
make crossbuild cross-compiles the NIF for aarch64 and links it against the
real libnoe/libaipudrv, proving the native code fully compiles and every
symbol resolves — not just a syntax check. It clones nerves_system_orangepi6
(LFS blobs) into .nerves-system for the libraries:
make crossbuild # clones the system for blobs
make crossbuild NERVES_SYSTEM_DIR=/path/to/system # reuse an existing checkoutInside the devenv shell the aarch64 toolchain (CROSS_CC) is already on PATH,
and devenv test runs mix test + make crossbuild. The resulting .so is a
compile/link artifact (built with the nixpkgs cross-glibc) — the deployable
binary comes from the Nerves firmware build (see example/).
License
Apache-2.0. The NOE/AIPU runtime libraries and headers are proprietary CIX/Arm components shipped by the Nerves system, not by this package.