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 aipu kernel 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/lib exported 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"}
  ]
end

Tagged 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.exs to the GitHub release, and publishes to Hex (needs the HEX_API_KEY secret).

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

LayerRole
CixP1.ContextNOE UMD context (opens /dev/aipu)
CixP1.Grapha loaded .cix graph + tensor descriptors
CixP1.Jobone inference invocation (load inputs → infer → read outputs)
CixP1run/2 one-shot convenience
CixP1.Nxoptional Nx.Tensor ↔ binary conversion + dequantization
CixP1.Nifraw 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 :hardware

On-device, run the hardware-tagged tests with mix test --include hardware.

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 checkout

Inside 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.