Robotics

Run Native DLA Inference in Python on NVIDIA Jetson AGX Orin Using cuDLA

AI-Generated Summary

  • NVIDIA Deep Learning Accelerator (DLA) on Jetson AGX Orin executes supported neural network operations to improve power efficiency and free GPU capacity for other tasks.
  • The cuda.bindings.cudla module exposes the cuDLA C++ API to Python, enabling hybrid-mode workflows where CUDA streams coordinate DLA inference with GPU preprocessing and postprocessing.
  • A complete inference lifecycle runs through nine steps: initialize CUDA, create a DLA device in hybrid mode, load a compiled loadable from memory, introspect tensor descriptors, allocate GPU buffers, register them with cuDLA, copy preprocessed input data, submit the task on a CUDA stream, and synchronize to read back results.
  • All cuDLA calls raise Python exceptions on failure, allowing standard try/finally cleanup and granular error handling via the cudla.Status enum.
  • The same cuDLA Python API applies to NVIDIA RTX Spark, extending DLA inference beyond embedded platforms to Windows AI developer laptops.

Next Steps

  • Explore the Deep Learning Accelerator samples for a complete end-to-end inference example with model preparation and compilation scripts.
  • Visit the NVIDIA/cuda-python GitHub repo to learn more about the CUDA Python project and run hardware-free unit tests.
Powered by NVIDIA Nemotron. AI-generated content may summarize information incompletely. Verify important information. Learn more

AI applications at the edge often need to run multiple neural networks within limited compute and power budgets. NVIDIA Deep Learning Accelerator (DLA) platforms provide dedicated hardware for deep learning inference. On NVIDIA Jetson AGX Orin, DLA executes supported neural network operations, allowing applications to offload inference work from the GPU. This can improve power efficiency and leave GPU capacity available for other application tasks.

NVIDIA cuDLA is the runtime API for managing execution on this accelerator. Applications use it to create DLA devices, load compiled networks, register memory buffers, and submit inference tasks. The cuda.bindings.cudla module exposes these APIs directly to Python, so developers can integrate DLA execution into their applications without writing C++ code to manage the runtime.

This post walks through the inference lifecycle on Jetson AGX Orin using an image classification example. It briefly covers installation and compiling a model into a DLA loadable, then follows execution from creating a device and loading the network to managing buffers, synchronizing execution, and reading the output. The example also shows how GPU and DLA stages work together in a single Python application.

How cuDLA fits within a Python application

DLA is a fixed-function accelerator. This means that it efficiently performs fixed operations, such as convolutions and pooling, that are common in modern neural network architectures. It does not have as many supported layers as the GPU, but it still supports a wide variety of layers used in many popular neural network architectures.. To learn more about when and why to use DLA, see Getting Started with the Deep Learning Accelerator on NVIDIA Jetson Orin.

Introduced in cuda-bindings 13.3.0 as part of the CUDA Python project, cuda.bindings.cudla is a thin, Cython-generated Python layer over the cuDLA C++ API for hybrid-mode workflows. It exposes thirteen functions, nine data types, eight enums for hybrid-mode workflows, where CUDA streams coordinate DLA execution with GPU work. On Jetson Orin, NVIDIA JetPack supplies the underlying libcudla.so runtime. 

The Python bindings expose that runtime directly to your application, while inference executes on DLA hardware. This complements the existing C++ workflow by allowing runtime management and inference orchestration to remain in Python. Figure 1 shows where cuda.bindings.cudla fits in the stack.

While this post focuses on Jetson, NVIDIA RTX Spark brings DLA inference to mainstream Windows AI developer laptops for the first time, extending cuDLA well beyond the embedded and robotics space. The cuda.bindings.cudla API is identical on both platforms, so everything in this post related to cuDLA APIs applies directly to RTX Spark as well.

Prerequisites and installation

If you are on an NVIDIA Jetson Orin running JetPack, libcudla.so ships with the JetPack installation and is automatically discoverable by the bindings. There is nothing additional to install for the DLA runtime itself—no separate cuDLA SDK and no extra runtime libraries.

Install the cuDLA Python bindings:

pip install "cuda-bindings>=13.3.0"

The JetPack default Python may be older than 3.10, which is the minimum required version. Install Python 3.12 from the deadsnakes PPA if needed:

sudo apt update && sudo apt install -y software-properties-common
sudo add-apt-repository -y ppa:deadsnakes/ppa
sudo apt update && sudo apt install -y python3.12 python3.12-venv python3.12-dev
python3.12 -m venv ~/dla-env
~/dla-env/bin/pip install "cuda-bindings>=13.3.0"

You only need TensorRT to compile a trained model into a DLA loadable. To run an existing loadable, you only need libcudla.so, which ships with JetPack.

Compiling a DLA loadable

A DLA loadable is the binary artifact produced by the TensorRT trtexec tool. It encodes a trained network in a format DLA hardware can execute directly. This compilation example targets TensorRT 11.4.1 with CUDA 13.4 Update 1 on Jetson AGX Orin running JetPack 7.2.2 or later. For an FP16 CHW16 loadable, using an FP16-typed ONNX model with fixed input dimensions, use the following:

trtexec \
    --onnx=your_model_fp16.onnx \
    --useDLACore=0 \
    --inputIOFormats=chw16 \
    --outputIOFormats=chw16 \
    --memPoolSize=dlaSRAM:1 \
    --saveEngine=model.bin \
    --buildDLAStandalone

The key flags are: 

  • --useDLACore=0 targets the first DLA engine on Orin
  • --buildDLAStandalone produces a raw DLA loadable that does not require TensorRT at inference time
  • I/O format options select CHW16 layout

TensorRT 11 derives precision from the model’s tensor types; --fp16 and --int8 are no longer supported. This example assumes fixed input dimensions, so --shapes is omitted.

This reflects the sample’s current FP16 path and TensorRT 11 flag changes.

Not every network layer is DLA-compatible, and preprocessing the ONNX graph to trim DLA-incompatible head and tail layers is often necessary. The Python workflow in the next section works with any .bin or .nvdla file you produce. Figure 2 shows the split between the one-time build step and the on-device runtime.

The Python workflow

For the CUDA and cuDLA calls, three imports cover everything:

import numpy as np
from cuda.bindings import cudla
from cuda.bindings import driver, runtime

cudla is the new module. driver and runtime are the existing cuda-python bindings for the CUDA driver and runtime APIs—used here for stream management, memory allocation, and data transfers. Everything runs through a single CUDA stream, the coordination point for DLA and GPU work in hybrid mode.

Step 1: Initialize CUDA and create a DLA device

cudla.Mode.CUDA_DLA selects hybrid mode, where you submit DLA tasks through a CUDA stream. This enables GPU-DLA pipelines: preprocessing kernels, DLA inference, and postprocessing kernels can all be enqueued on the same stream and executed in order.

driver.cuInit(0)
runtime.cudaSetDevice(0)

cudla_device = cudla.create_device(0, int(cudla.Mode.CUDA_DLA))

This release does not support standalone mode (which requires the NvSci buffer and sync primitives). create_device raises CudlaError(ErrorUnsupportedOperation) if you request it.

Step 2: Load the loadable

Pass the loadable as a Python bytes object directly to module_load_from_memory. No file path or filesystem handle—just raw bytes, equally easy to load from memory-mapped files, embedded resources, or network streams.

with open("model.bin", "rb") as f:
    loadable_data = f.read()

module_handle = cudla.module_load_from_memory(
    cudla_device, loadable_data, len(loadable_data), 0
)

Step 3: Introspect the loaded module

Before allocating any memory, query the module to find out exactly what tensors it expects:

num_inputs = cudla.module_get_attributes(
    module_handle, int(cudla.ModuleAttributeType.NUM_INPUT_TENSORS)
)
num_outputs = cudla.module_get_attributes(
    module_handle, int(cudla.ModuleAttributeType.NUM_OUTPUT_TENSORS)
)
input_descs = cudla.module_get_attributes(
    module_handle, int(cudla.ModuleAttributeType.INPUT_TENSOR_DESCRIPTORS)
)
output_descs = cudla.module_get_attributes(
    module_handle, int(cudla.ModuleAttributeType.OUTPUT_TENSOR_DESCRIPTORS)
)

Each descriptor is a cudla.ModuleTensorDescriptor with properties for buffer size (size_), tensor shape (n, c, h, w), name, data format, and stride. All subsequent allocation calls are driven by desc.size_ with no hardcoded sizes anywhere in the application. This introspection step is what makes the workflow model-agnostic: the same code paths handle any loadable regardless of network architecture.

Step 4: Allocate GPU memory

input_gpu  = runtime.cudaMalloc(input_descs[0].size_)[1]
output_gpu = runtime.cudaMalloc(output_descs[0].size_)[1]

CUDA driver and runtime calls return a status code as the first element of a tuple. The full sample wraps every such call in a helper that checks the returned status and raises on failure—the snippets here elide that for readability, but error checking is essential in production code. For models with multiple inputs or outputs, iterate over input_descs and output_descs and allocate one buffer per tensor. The size_ field on each descriptor gives the exact byte count.

Step 5: Register GPU buffers with cuDLA

This is the step unique to cuDLA. GPU buffers must be registered with the DLA device before they can appear in a task. Registration returns a DLA-side handle that the hardware uses to access the buffer:

input_registered  = cudla.mem_register(
    cudla_device, int(input_gpu), input_descs[0].size_, 0
)
output_registered = cudla.mem_register(
    cudla_device, int(output_gpu), output_descs[0].size_, 0
)

The int() cast extracts the raw pointer from the CUDA memory object. cuda.bindings returns opaque handle objects, and int() is how you pass them to C-level APIs that expect a pointer.

Step 6: Prepare input data and copy to GPU

Preprocessing—normalizing values, matching the expected tensor stride and data format—is the only model-specific code in the script. The tensor descriptor stride, data_format, and data_type fields tell you exactly how to lay out the data. For a multi-input model, repeat the copy for each input buffer. Everything else before and after this block is identical regardless of what network is inside the loadable.

stream = driver.cuStreamCreate(driver.CUstream_flags.CU_STREAM_NON_BLOCKING)[1]

# Preprocess your input into a numpy array `input_cpu`
# matching the tensor layout described by input_descs[0] ...

runtime.cudaMemcpyAsync(
    input_gpu, input_cpu, input_descs[0].size_,
    runtime.cudaMemcpyKind.cudaMemcpyHostToDevice, stream
)
runtime.cudaMemsetAsync(output_gpu, 0, output_descs[0].size_, stream)

Step 7: Build the task and submit

cudla.Task wraps the cudlaTask C struct. Assigning a Python list to task.input_tensor or task.output_tensor automatically updates the corresponding count field. submit_task enqueues the inference work onto the CUDA stream, sequencing it with any GPU kernels before or after it on the same stream.

task = cudla.Task()
task.module_handle = module_handle
task.input_tensor  = [int(input_registered)]
task.output_tensor = [int(output_registered)]
task.wait_events   = 0
task.signal_events = 0

cudla.submit_task(cudla_device, int(task), 1, int(stream), 0)

Step 8: Synchronize and read back

The first sync waits for the DLA task to complete. Allocating output_cpu as a numpy array of the descriptor’s byte size gives the D2H copy a destination buffer; the second sync ensures the transfer is done before you read from it.

runtime.cudaStreamSynchronize(stream)

output_cpu = np.empty(output_descs[0].size_, dtype=np.uint8)
runtime.cudaMemcpyAsync(
    output_cpu, output_gpu, output_descs[0].size_,
    runtime.cudaMemcpyKind.cudaMemcpyDeviceToHost, stream
)
runtime.cudaStreamSynchronize(stream)

Step 9: Cleanup

Every handle created has a matching destroy call—DLA device, module, registered memory handles, GPU allocations, and stream.

cudla.mem_unregister(cudla_device, input_registered)
cudla.mem_unregister(cudla_device, output_registered)
cudla.module_unload(module_handle, 0)
cudla.destroy_device(cudla_device)
runtime.cudaFree(input_gpu)
runtime.cudaFree(output_gpu)
driver.cuStreamDestroy(stream)

Error handling is native to Python

All cuDLA API calls raise cudla.CudlaError on failure rather than returning error codes. The exception carries both the numeric status and its human-readable name:

try:
    device = cudla.create_device(0, int(cudla.Mode.CUDA_DLA))
except cudla.CudlaError as e:
    print(e)  # e.g. "ErrorInvalidDevice (8)"

DLA workflows fit naturally into standard Python exception handling. I recommend try/finally for cleanup. DLA device handles are opaque C resources and will not release themselves on garbage collection. Branch on e.status against cudla.Status enum values to handle specific failure modes, from parameter errors (ErrorInvalidParam) up to hardware-level faults (ErrorDlaErrEngineTimeout).

What is the resulting output? 

After cudaStreamSynchronize returns, output_cpu holds the raw DLA output for your network as a numpy array, ready for any standard Python postprocessing. What you do with it depends entirely on the model, including class scores, feature maps, bounding box coordinates. The cuDLA Python layer hands you the bytes. Interpreting them is the same postprocessing you would write in any Python inference pipeline, and you can chain it directly with NumPy, PyTorch, or OpenCV without leaving the Python process.

To make this concrete: for a ResNet-50 backbone compiled as a DLA loadable, the sample output would look like this:

cuDLA device created: 0x55a1b2c30000
Module loaded: 0x55a5b6c70000
numInputTensors = 1
numOutputTensors = 1
Input 0:  size=..., shape=(1,64,56,56), name=resnetv17_pool0_fwd
Output 0: size=..., shape=(1,2048,7,7), name=resnetv17_stage4_activation2
SUBMIT IS DONE (task queued; completion pending)
Output tensor written to host

The sample output tells you the exact buffer sizes and tensor names the loadable expects, which your preprocessing and postprocessing code can read directly rather than hardcoding them. After synchronization, output_cpu is a flat numpy array; interpret or reshape it using the data_type, data_format, stride, and shape fields from output_descs[0] and pass it to whatever downstream stage your pipeline requires.

The workflow from create_device to cudaStreamSynchronize is the same regardless of network architecture. Only the tensor names, sizes, and postprocessing change.

Final steps

The cuda.bindings.cudla module ships in cuda-bindings 13.3.0. Install it, compile a loadable with trtexec, and the nine steps above take you from a .bin file to DLA inference results directly in Python on the same board.

pip install "cuda-bindings>=13.3.0"

The unit tests for enums, POD types, and API surface ship in the NVIDIA/cuda-python GitHub repo under cuda_bindings/tests/cudla/ and run entirely without DLA hardware—no libcudla.so required:

pytest cuda_bindings/tests/cudla/ -v

Get started with cuDLA in Python

For a complete end-to-end inference example, sample inputs, and scripts to prepare the model and compile a DLA loadable locally, refer to the Deep Learning Accelerator samples.

To learn more, visit the NVIDIA/cuda-python GitHub repo.

Discuss (0)

Tags