Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1,389 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

EdgeFirst Hardware Abstraction Layer

Build Status License Crates.io PyPI

The EdgeFirst Hardware Abstraction Layer (HAL) is a Rust workspace providing hardware-accelerated tensor management, image processing, ML model output decoding, and multi-object tracking for edge AI inference pipelines. It ships as a Rust crate, a Python package, and a C library, all built from the same code. A single OpenGL ES engine runs on Linux (native EGL + DMA-BUF), macOS and iOS (ANGLE over Metal + IOSurface), and Android (native EGL + AHardwareBuffer), alongside NXP G2D on i.MX and a portable CPU fallback everywhere else.

Features

  • Zero-copy memory management — DMA-BUF, IOSurface, AHardwareBuffer, POSIX shared memory, OpenGL PBO, and heap, with automatic backend selection
  • Zero-copy CUDA tensor mappingconvert() PBO output mapped directly to a CUDA device pointer for TensorRT and other CUDA consumers; no host round-trip on Jetson (Orin-series). See Zero-copy CUDA (TensorRT) input.
  • Hardware-accelerated image processing — OpenGL → G2D → CPU dispatch with shared cache infrastructure
  • Tiled inference (SAHI) — overlapping tile grid rendered in one GPU pass, with IoS-based merge of per-tile detections back to full-frame coordinates. See Tiled inference (SAHI).
  • YOLO + ModelPack decoding — YOLOv5 / v8 / v11 / v26 (incl. end-to-end) and ModelPack post-processing
  • Multi-object tracking — ByteTrack with Kalman filtering and stable per-track UUIDs
  • Cross-platform — Linux (i.MX 8M Plus / i.MX 95 / RPi 5 / Jetson / desktop), macOS, iOS, and Android, over CPU / GPU / zero-copy-buffer tiers

Quick Start

Installation

Python:

pip install edgefirst-hal

Rust:

[dependencies]
edgefirst-hal = "0.28"

C: download a release archive from GitHub Releases and link against libedgefirst_hal.so (or .a); see crates/capi/README.md for full instructions.

Basic usage

Python:

import edgefirst_hal as ef

# Decode into a tensor you own. A real pipeline allocates once and reuses
# the tensor every frame; JPEG decodes to its native Nv12, PNG to Rgb/Rgba/Grey.
info = ef.Tensor.peek_image_info_file("image.jpg")
src = ef.Tensor.image(info.width, info.height, info.format)
src.decode_image_file("image.jpg")

processor = ef.ImageProcessor()
model_input = processor.create_image(640, 640, ef.PixelFormat.Rgb)

# convert() handles the colour conversion and the resize in one call.
# Omit letterbox= to stretch to fill instead of preserving aspect ratio.
processor.convert(src, model_input, letterbox=[114, 114, 114, 255])

# outputs is the list of Tensors your inference engine produced from model_input.
decoder = ef.Decoder(model_config, score_threshold=0.5, iou_threshold=0.45)
boxes, scores, classes, masks = decoder.decode(outputs)

# Fused decode + draw: masks never leave Rust.
processor.draw_masks(decoder, outputs, model_input)

Rust:

The umbrella edgefirst-hal crate re-exports its sub-crates as modules, so a single edgefirst-hal = "0.28" dependency is enough. There's no need to list edgefirst-image / edgefirst-tensor separately in Cargo.toml.

use edgefirst_hal::image::{ImageProcessor, ImageProcessorTrait, Rotation, Flip, Crop};
use edgefirst_hal::image::codec::{ImageDecoder, ImageLoad};
use edgefirst_hal::tensor::{PixelFormat, DType, CpuAccess};

let bytes = std::fs::read("image.jpg")?;
let mut processor = ImageProcessor::new()?;
let mut decoder = ImageDecoder::new();

// JPEG decodes to its native NV12 (colour); decode into an NV12 source tensor.
// load_image() reconfigures the tensor's shape and format to the decoded
// content, so allocate at or above the largest frame you expect.
let mut input =
    processor.create_image(1920, 1080, PixelFormat::Nv12, DType::U8, None, CpuAccess::Write)?;
let _info = input.load_image(&mut decoder, &bytes)?;

// convert() handles NV12 -> RGB and the letterbox resize in one call. The
// decode never rotates; pass the EXIF rotation here if you want it applied.
let mut output =
    processor.create_image(640, 640, PixelFormat::Rgb, DType::U8, None, CpuAccess::Read)?;
processor.convert(&input, &mut output, Rotation::None, Flip::None,
    Crop::letterbox([114, 114, 114, 255]))?;

If you prefer to depend on the sub-crates directly (e.g. to opt out of features or to track them at independent versions), add the relevant edgefirst-image, edgefirst-tensor, edgefirst-decoder, and edgefirst-tracker entries to your Cargo.toml and use the unprefixed edgefirst_image::* / edgefirst_tensor::* paths above.

C:

#include <edgefirst/hal.h>

struct hal_image_processor *proc = hal_image_processor_new();
/* `src` is decoded from disk with hal_tensor_decode_image_file(), or
 * imported from a DMA-BUF fd with hal_import_image() / hal_tensor_from_fd().
 * See the C API README for the full allocate-then-decode pattern. */
struct hal_tensor *src = /* ... */;
struct hal_tensor *dst = hal_image_processor_create_image(
    proc, 640, 640, HAL_PIXEL_FORMAT_RGB, HAL_DTYPE_U8, HAL_CPU_ACCESS_READ_WRITE);
hal_image_processor_convert(proc, src, dst, HAL_ROTATION_NONE, HAL_FLIP_NONE, NULL);

Zero-copy CUDA (TensorRT) input

On CUDA-capable devices (e.g. Jetson Orin-series) the float PBO produced by convert() can be mapped directly to a CUDA device pointer with no host round-trip. The recommended pattern is to try cuda_map() first and fall back to the host map() when CUDA is unavailable:

Rust:

use edgefirst_hal::tensor::{Tensor, TensorTrait, is_cuda_available};

// At pipeline startup — check once
if is_cuda_available() {
    println!("CUDA present; will use zero-copy PBO→CUDA path");
}

// Per frame — try CUDA, fall back to host
if let Some(cuda) = dst.cuda_map() {
    // cuda.device_ptr() is a raw device pointer valid until `cuda` is dropped.
    // Drop `cuda` before the next convert() so the PBO is free to be reused.
    trt_enqueue(cuda.device_ptr(), cuda.len());
    // `cuda` drops here → PBO released
} else {
    let host = dst.map()?;
    trt_enqueue_host(host.as_slice());
}

Python:

import edgefirst_hal as ef

proc = ef.ImageProcessor()
dst = proc.create_image(640, 640, ef.PixelFormat.PlanarRgb, "float16")

for frame in camera_frames:
    proc.convert(frame, dst)
    cuda = dst.cuda_map()          # CudaMap | None
    if cuda is not None:
        with cuda:
            # cuda.device_ptr is a CUDA device pointer (int)
            trt_context.execute(cuda.device_ptr)
    else:
        host = dst.map()
        trt_context.execute_host(bytes(host))

cuda_map() fast-fails to None when libcudart is not present at runtime — no compile-time feature gate, no link-time dependency. CUDA register/map runs on the GL worker thread; the returned device pointer is usable from any thread. Drop the CudaMap guard before the next convert() call to release the PBO back to the GL pipeline.

For the full mechanism, aliasing rules, DMA-BUF import path, and per-language API reference, see crates/tensor/README.md § CUDA tensor mapping and crates/tensor/ARCHITECTURE.md § Zero-copy CUDA tensor mapping.

Tiled inference (SAHI)

Small objects in a high-resolution frame disappear when the whole frame is squeezed down to a 640×640 model input. SAHI (Slicing Aided Hyper Inference) runs the same model at its native resolution over an overlapping grid of native-resolution crops instead, then stitches the per-tile detections back together. HAL covers both halves: edgefirst-image cuts and renders the grid, edgefirst-decoder lifts and merges the results.

The input side renders every tile into one tall packed batch tensor with a single GL import and a single flush, so N tiles cost roughly one GPU sync rather than N. The output side merges with GREEDYNMM using the IoS (intersection-over-smaller) metric, because an object split across a tile seam has low IoU with its own fragments but high IoS. A TilePlacement produced by plan_tiles / tile_into is the shared record of how each tile was cut, and it is what the merge uses to lift boxes back to full-frame coordinates.

Rust:

use edgefirst_hal::image::{ImageProcessor, ImageProcessorTrait, TilingConfig};
use edgefirst_hal::decoder::{DecoderBuilder, DetectBox, Nms, Segmentation};
use edgefirst_hal::decoder::tiling::{MergeConfig, TiledFrameAccumulator};
use edgefirst_hal::tensor::{CpuAccess, DType, PixelFormat};

// 640x640 tiles with at least 20% overlap. The realized overlap is
// redistributed evenly so every tile is full-size and the last one lands flush.
let cfg = TilingConfig::new(640, 640).with_overlap(0.2);

// plan_tiles is pure geometry (no GPU work), so its length sizes the batch.
let placements = processor.plan_tiles(src_w, src_h, &cfg)?;

// One tall [tile_w, N * tile_h] destination. Allocate once, reuse per frame.
let mut batch = processor.alloc_tile_batch(
    placements.len(), &cfg, PixelFormat::Rgb, DType::U8, None, CpuAccess::None)?;

// Render every tile: deferred convert per tile, one flush at the end.
let placements = processor.tile_into(&src, &mut batch, &cfg)?;

// Per-tile decoding is deliberately permissive. A fragment clipped at a seam
// scores low, and a high per-tile threshold discards it before the merge can
// rebuild the object. Gate the final scores in MergeConfig instead.
let decoder = DecoderBuilder::new()
    .with_config_yaml_str(model_config_yaml)
    .with_score_threshold(0.05)
    .with_nms(Some(Nms::ClassAware))
    .build()?;

let mut acc = TiledFrameAccumulator::new(
    (src_w as f32, src_h as f32),
    placements.len(),       // tiles_total — the fan-in fence
    MergeConfig::default(), // Ios metric, 0.5 threshold, max_det 300
    16,                     // estimated detections per tile (capacity hint)
);

// HAL does not run inference. `tile_results` pairs each placement with the
// output tensors your engine produced for that tile; tiles may arrive in any
// order, so pair them explicitly rather than relying on loop position.
for (tile_outputs, placement) in tile_results {
    let mut boxes: Vec<DetectBox> = Vec::new();
    let mut masks: Vec<Segmentation> = Vec::new();
    decoder.decode(&tile_outputs, &mut boxes, &mut masks)?;
    acc.push_tile(boxes, &placement);
}

// Merged, deduplicated, normalized to [0, 1] for the tracker.
let detections = acc.finalize_normalized();

Python:

import edgefirst_hal as ef

cfg = ef.TilingConfig(640, 640, overlap=0.2)
placements = processor.plan_tiles(src.width, src.height, cfg)

batch = processor.alloc_tile_batch(len(placements), cfg, ef.PixelFormat.Rgb)
placements = processor.tile_into(src, batch, cfg)

acc = ef.TiledFrameAccumulator(
    (float(src.width), float(src.height)), len(placements), ef.MergeConfig())

for tile_outputs, placement in tile_results:
    boxes, scores, classes, _masks = decoder.decode(tile_outputs)
    acc.push_tile(boxes, scores, classes, placement)

boxes, scores, classes = acc.finalize_normalized()

push_tile is idempotent per placement.index, so tiles can arrive in any order and an at-least-once delivery retry stays harmless. The merge runs once at finalize, never per push, which is what a pipelined runtime needs: plan_tiles sizes the ring up front, tile_one streams individual tiles through inference into a caller-owned slot, and is_complete() / remaining() fence the frame.

MergeConfig tunes the metric (Ios by default, or Iou), the match threshold (0.5), class_agnostic (false), max_det (300), and a final score_threshold (0.0). That last default is deliberate: per-tile decoding is the real flood control, and the merged score gate belongs after fragments have been joined.

Note

An IoS merge reconstructs the enclosing union of fragments, so it cannot recover an object larger than a single tile. For mixed-scale datasets, add a full-frame downscaled pass as one more push_tile at origin=(0, 0), crop_size=frame_dims.

The C API mirrors the same split (hal_image_processor_plan_tiles / _tile_into / _tile_one on the input side, hal_tiled_frame_accumulator_* on the output side). Full per-language detail lives in image/README.md § Tiled Preprocessing and decoder/README.md § Tiled Inference.

Per-language quick-starts and richer examples live in each crate's README: Rust (edgefirst-hal), C API, Python.

System Architecture

graph TB
    subgraph "EdgeFirst HAL Ecosystem"
        Python["Python Bindings (edgefirst-hal)<br/>PyO3"]
        CAPI["C API (edgefirst-hal-capi)<br/>cbindgen"]
        Main["Umbrella crate (edgefirst-hal)<br/>Re-exports"]

        Python --> Main
        CAPI --> Main

        Tensor["edgefirst-tensor<br/>Zero-copy buffers"]
        Codec["edgefirst-codec<br/>Image decode"]
        Image["edgefirst-image<br/>Format conv + draw"]
        Decoder["edgefirst-decoder<br/>Model output decode"]
        Tracker["edgefirst-tracker<br/>ByteTrack"]

        Main --> Tensor
        Main --> Codec
        Main --> Image
        Main --> Decoder
        Main -.->|tracker feature| Tracker
        CAPI --> Tracker

        Codec --> Tensor
        Image --> Tensor
        Image --> Decoder
        Image -.optional.-> G2D["g2d-sys<br/>NXP i.MX"]
    end

    Tensor -.-> DMA["Linux DMA-Heap<br/>Shared Memory"]
    Decoder -.-> PostProc["Model Output<br/>Post-Processing"]

    style Python fill:#e1f5ff
    style CAPI fill:#e1f5ff
    style Main fill:#fff4e1
    style Tensor fill:#e8f5e9
    style Codec fill:#e8f5e9
    style Image fill:#e8f5e9
    style Decoder fill:#e8f5e9
    style Tracker fill:#e8f5e9
Loading

Core Components

Crate Role Architecture Testing
edgefirst-tensor Zero-copy multi-dim buffers (DMA / SHM / Mem / PBO) ARCH TEST
edgefirst-codec JPEG/PNG decode into pre-allocated tensors (strided, multi-dtype) ARCH TEST
edgefirst-image OpenGL / G2D / CPU image processor + mask rendering ARCH TEST
edgefirst-decoder YOLO + ModelPack post-processing, NMS, proto-mask APIs ARCH TEST
edgefirst-tracker ByteTrack multi-object tracking ARCH TEST
edgefirst-hal Umbrella + tracing subscriber ARCH TEST
edgefirst-hal-capi C ABI + Delegate DMA-BUF framework ARCH TEST
crates/python/ (PyPI: edgefirst-hal) PyO3 bindings, numpy buffer protocol ARCH TEST

The deep dive on each component (class diagrams, supported operations, backend dispatch, performance considerations) lives in the per-crate ARCHITECTURE.md. The cross-cutting story (DMA-BUF identity, performance tracing internals, design patterns) lives in the project ARCHITECTURE.md.

Optimization Guide

This section is the rules part of the cross-language performance contract. Each rule has a measurable cost when broken; see BENCHMARKS.md for empirical penalties per platform, ARCHITECTURE.md for why the rule exists, and TESTING.md for how to verify your integration follows it.

Rule Why it matters Measured penalty when broken
Reuse tensors across frames Each new tensor mints a fresh BufferIdentity; the EGL image cache misses every frame 1.7–3.3× slower preprocessing on Vivante / Mali
Allocate via ImageProcessor::create_image() Auto-selects DMA-buf / PBO / heap based on the active GPU; bypassing forces a slow transfer path Forced glTexSubImage2D upload or full CPU readback
Cache imported camera tensors by inode, not by fd V4L2 / libcamera recycle fd numbers across a small buffer pool; an fd-keyed cache misses on every frame even when the physical buffer is the same Full EGL re-import per frame (≈0.5–1.5 ms on Vivante, doubled with chroma planes)
Build Decoder once, decode many Decoder construction parses model metadata and allocates working buffers Parse + alloc cost per frame
One ImageProcessor per pipeline Each instance owns its own GL context, EGL display, and per-thread caches On Vivante / paravirtual GPUs multiple contexts serialize on the global GL_MUTEX; on Mali / V3D / Tegra / Apple they run concurrently (one per thread is the portable rule)
Use native fp16 / AVX build overrides only on supporting CPUs These flags unlock native widening / vector paths for local perf testing Unsupported targets may SIGILL or fail to build; portability loss
Pass numpy arrays straight to Tensor.from_numpy() — do not pre-ascontiguousarray() HAL detects strided sources and materializes via numpy's vectorized C strided→contig pass; a manual workaround above HAL adds a redundant copy Redundant pre-copy on every call (≈ 1.5 ms on a (1, 116, 8400) f32 view, rpi5-hailo)
For COCO/IoU evaluation use MaskResolution::Scaled { width, height }, not Proto Scaled upsamples the proto plane before thresholding (clean sub-pixel edges); Proto thresholds at proto resolution and callers typically nearest-upsample (blocky) Mask mAP regression of up to 0.04–0.05 absolute when Proto is nearest-upsampled

Important

The single most common performance bug is calling Tensor::from_fd() (or import_image()) on every frame from a V4L2 / libcamera buffer pool. The HAL's internal EGL image cache cannot rescue you — the cache key includes a per-tensor monotonic ID that is fresh on every import. The fix lives in the calling code, not in HAL.

Rule 1 — Reuse tensors across frames

Allocate input and output tensors once at pipeline startup; reuse the same objects on every frame. The DMA memory backing a tensor is live: when an upstream producer (V4L2 DQBUF, codec output, ISP) writes new pixels into it, the existing tensor and its cached EGLImage remain valid. No re-import, no re-allocation.

let mut proc = ImageProcessor::new()?;
let mut dst = proc.create_image(640, 640, PixelFormat::Rgb, DType::U8, None, CpuAccess::ReadWrite)?;

for frame in camera_frames {
    proc.convert(&frame, &mut dst, Rotation::None, Flip::None, Crop::default())?;
    run_inference(&dst)?;
}
proc = ef.ImageProcessor()
dst = proc.create_image(640, 640, ef.PixelFormat.Rgb)
for frame in camera_frames:
    proc.convert(frame, dst)
    run_inference(dst)

Rule 2 — Allocate via ImageProcessor::create_image()

create_image() selects the fastest memory backend for the active GPU at construction time:

Priority Backend Transfer Platforms
1st DMA-buf Zero-copy EGLImage import NXP i.MX 8M Plus, i.MX 95
2nd PBO Zero-copy GL buffer binding NVIDIA desktop
3rd Mem (heap) CPU memcpy fallback All platforms

The probe runs once at ImageProcessor::new() time. All subsequent create_image() calls reuse the same backend. Use create_image() for every destination passed to convert(); direct Tensor::new(memory=...) bypasses the probe.

Declare CPU access. Every image constructor takes a required CpuAccess parameter: hardware (GPU/NPU/ISP/codec) access is always implied, CPU access is the opt-in. Declare Write for decode targets, Read for buffers you verify/consume on the CPU, ReadWrite when both, and CpuAccess::None for pure hardware pipelines — on Android a hardware-only buffer is eligible for gralloc's vendor tile compression (UBWC/AFBC/PVRIC/DCC), and on every platform the declaration selects the cheapest mapping mode (write-combined for Write, read-only IOSurface locks / dma-buf sync direction for Read). Mapping beyond the declaration still works best-effort but warns once per buffer and counts in unplanned_cpu_access_count(); on Android hardware-only buffers refuse CPU maps deterministically.

For DMA-buf access, the process needs /dev/dma_heap/{linux,cma|system} and a DRM render/card node — the GL backend probes /dev/dri/renderD128, then /dev/dri/card0, then /dev/dri/card1 and uses the first one that opens. On embedded Linux, add the user to video and render groups, or set udev rules. If DMA-buf fails, create_image() transparently falls back to PBO or heap.

Rule 3 — Cache imported camera tensors by inode, not by fd

V4L2, libcamera, and codec output all surface frames as DMA-BUF file descriptors drawn from a small fixed pool (typically 4–16 buffers). The fd number is recycled: the same fd can refer to a different physical buffer between frames, and the same physical buffer can be exported with a different fd over time. A cache keyed by fd will produce false hits or false misses.

The kernel assigns each dma_buf object a unique inode in the anonymous inode filesystem. The inode is constant for the buffer's lifetime regardless of how many times it is exported. Cache imported HAL tensors by (inode, plane_offset):

#include <sys/stat.h>

typedef struct { ino_t inode; size_t offset; } BufferKey;

struct stat st;
if (fstat(fd, &st) != 0) continue;
BufferKey key = { .inode = st.st_ino, .offset = plane_offset };

struct hal_tensor *tensor = lookup_tensor(cache, &key);
if (!tensor) {
    struct hal_plane_descriptor *pd = hal_plane_descriptor_new(fd);
    if (!pd) { perror("hal_plane_descriptor_new"); continue; }
    tensor = hal_import_image(proc, pd, NULL, w, h,
                              HAL_PIXEL_FORMAT_NV12, HAL_DTYPE_U8,
                              NULL /* colorimetry: NULL = default */);
    // pd is consumed by hal_import_image (success or failure)
    if (!tensor) { perror("hal_import_image"); continue; }
    insert_tensor(cache, &key, tensor);
}
hal_image_processor_convert(proc, tensor, dst, /* ... */);
import os
buffer_cache: dict[tuple[int, int], ef.Tensor] = {}

def get_or_import(proc, fd, offset, width, height, fmt):
    key = (os.fstat(fd).st_ino, offset)
    t = buffer_cache.get(key)
    if t is None:
        t = proc.import_image(fd, width, height, fmt, "uint8", offset=offset)
        buffer_cache[key] = t
    return t

EdgeFirst's GStreamer elements implement this as a reference. For other pipelines (libcamera direct, custom V4L2, RTSP decoder) you are responsible for the equivalent layer above HAL. See ARCHITECTURE.md § Appendix C for the full identity-and-caching story.

Rule 4 — Build the decoder once

Decoder parses the model output schema, resolves quantization, and allocates working buffers at construction time. Build it once outside the loop; the decoder clears its output vectors per call:

let decoder = DecoderBuilder::default()
    .with_config_yaml_str(config_yaml)
    .with_score_threshold(0.5)
    .with_iou_threshold(0.45)
    .build()?;

for frame in frames {
    let outputs = run_inference(frame)?;
    let refs: Vec<&TensorDyn> = outputs.iter().collect();
    decoder.decode(&refs, &mut boxes, &mut masks)?;
}

The same applies to ByteTrack: construct once, call update() per frame.

Rule 5 — One ImageProcessor per pipeline

ImageProcessor owns its OpenGL context, dedicated GL thread, and EGL image cache. The EGL display itself is process-global (a shared SharedEglDisplay initialized once and never terminated), so additional processors don't pay the display-creation cost — but each one still creates a fresh context and per-instance caches. Whether GL operations across processors run in parallel is a per-driver policy: on Vivante (i.MX 8M Plus) and virtualized/paravirtual GPUs every command serializes on a global GL_MUTEX; on Mali, V3D, Tegra, llvmpipe, and real Apple GPUs they execute concurrently (override with EDGEFIRST_GL_SERIALIZE). Construct one per pipeline (or one per worker thread for parallel pipelines) and share it across all convert(), draw_*(), and create_image() calls.

ImageProcessor is Send + Sync, so it can be moved or shared across threads. On serializing drivers, concurrent use of a single shared instance funnels through GL_MUTEX; per-worker ownership runs in parallel wherever the driver allows and gives more predictable cache behaviour everywhere.

Rule 6 — Local fp16 / AVX build overrides

The default HAL binary is built to the target triple's guaranteed baseline ISA so a single distributed binary runs on every CPU within that triple. Richer ISAs (ARMv8.2-FP16, x86_64 F16C / FMA / AVX2) are not enabled by default; until HAL gains runtime CPU-feature detection with dynamic dispatch, baking them in would SIGILL on older CPUs.

For local benchmarking on supporting hosts, enable them via RUSTFLAGS:

# Orin Nano (Cortex-A78AE) — exclude the PyO3 binding (cross-Python toolchain not configured)
RUSTFLAGS="-C target-cpu=cortex-a78ae" cargo build --release \
  --target aarch64-unknown-linux-gnu --workspace --exclude edgefirst_hal

# Generic aarch64 with FEAT_FP16 (do NOT use on Cortex-A53 / imx8mp)
RUSTFLAGS="-C target-feature=+fp16" cargo build --release \
  --target aarch64-unknown-linux-gnu -p edgefirst-image

# x86_64 Haswell+ (F16C + FMA + AVX2)
RUSTFLAGS="-C target-feature=+f16c,+fma,+avx2" cargo build --release \
  -p edgefirst-image

When active, the f16 mask kernel at crates/image/src/cpu/masks.rs compiles to native widening (fcvt on aarch64, vcvtph2ps on x86_64), and on x86_64 with +f16c,+fma an explicit 8-lane _mm256_cvtph_ps + _mm256_fmadd_ps intrinsic path is enabled via cfg gate. Verify with scripts/audit_f16_codegen.sh.

Rule 7 — NumPy interop: pass arrays straight to from_numpy()

Tensor.from_numpy() (and the implicit copy from numpy arrays passed to Decoder.decode_proto()) handles strided / non-contiguous sources internally. Do not maintain a manual np.ascontiguousarray() workaround — it wastes a copy.

The Python binding's copy_numpy_to_tensor_dyn selects one of three paths based on the source array's layout:

Source layout Path Cost
Fully contiguous Single copy_from_slice (memcpy), rayon-parallel ≥ 256 KiB Lower bound
Strided with contiguous inner rows (column slice, sub-volume, negative stride) Per-row memcpy iterating outer dimensions ≈ same as contiguous
Fully strided (transposed view, every-other-element) Internal np.ascontiguousarray() materialization, then Path 1 memcpy ≈ 4× contiguous

The fully-strided case is the one that bites users in practice: HailoRT's natural output is arr.transpose(0, 2, 1) over a (1, anchors, channels) buffer. PR #58 replaced the legacy element-wise loop with internal np.ascontiguousarray materialization (≈ 4× faster than the legacy loop, within ≈ 1.5× of the manual workaround).

# Wrong (post-PR #58): adds an extra copy above HAL.
tensor.from_numpy(np.ascontiguousarray(arr_strided))

# Right: HAL detects the strided layout and materializes internally.
tensor.from_numpy(arr_strided)

The regression tests in tests/test_tensor.py (test_from_numpy_hailort_shape, test_from_numpy_hailort_shape_perf_sanity) pin the behaviour and the ≤ 1.5× perf bound.

Rule 8 — Choose the correct MaskResolution

ImageProcessor.materialize_masks() accepts a MaskResolution parameter:

Mode Output Pipeline When to use
MaskResolution::Proto (default) (roi_h, roi_w, 1) u8 binary at 160×160 proto resolution dot → sign threshold → emit Real-time visualization, when proto-resolution binary suffices
MaskResolution::Scaled { width, height } (roi_h, roi_w, 1) u8 binary at requested resolution dot → sigmoid → upsample to (W, H) → threshold (>127) All COCO / IoU / mAP evaluation
import edgefirst_hal as hal

# Wrong: threshold then upsample → blocky edges, mAP regression.
tiles = proc.materialize_masks(boxes, scores, classes, proto_data, letterbox=lb)
for tile, box in zip(tiles, boxes):
    binary = (tile[:, :, 0] > 127).astype(np.uint8)
    canvas[y:y+h, x:x+w] = cv2.resize(binary, (W, H), cv2.INTER_NEAREST)

# Right: HAL upsamples-then-thresholds inside its batched-GEMM kernel.
tiles = proc.materialize_masks(boxes, scores, classes, proto_data,
                               letterbox=lb,
                               resolution=hal.MaskResolution.Scaled(W, H))
for tile, box in zip(tiles, boxes):
    canvas[y:y+h, x:x+w] = (tile[:, :, 0] > 127).astype(np.uint8)

The Scaled path uses the batched-GEMM materializer (PR #54). At N ≥ 16 detections it amortizes a single GEMM at proto resolution and upsamples per-detection in rayon-parallel — both more accurate than threshold-then-resize and faster than per-detection scalar work in caller code.

Tip

If you see a mask-mAP gap between your HAL validator and a reference (ONNX / numpy) implementation, this rule is almost always the first thing to check.

Where to go next

Document Level Use it for
ARCHITECTURE.md § Appendix C: DMA-BUF Identity and Tensor Caching Architecture Why the rules exist: BufferIdentity, EGL image cache, the v4l2 / GStreamer fd-recycling story, and the inode-keyed downstream cache pattern
image/ARCHITECTURE.md § Performance Considerations Architecture Backend dispatch and per-instance caches; see also § GL Concurrency Model for the per-driver GL_MUTEX policy
TESTING.md § Validating Optimizations Testing Confirming your integration follows the rules
BENCHMARKS.md Benchmarks Empirical cost of breaking each rule, per platform

Platform Support

Feature Linux (i.MX) Linux (other) macOS iOS Android Windows
DMA tensors Yes Yes No No No No
PBO tensors (GPU) Yes Yes No No No No
IOSurface tensors (zero-copy) No No Yes (with ANGLE) Yes (with ANGLE) No No
AHardwareBuffer tensors (zero-copy) No No No No Yes No
Shared memory tensors Yes Yes Yes Yes Import-only¹ No
Heap tensors Yes Yes Yes Yes Yes Yes
G2D acceleration Yes No No No No No
OpenGL acceleration Yes (optional) Yes (optional) Yes (with ANGLE) Yes (with ANGLE) Yes (native EGL) No
CPU fallback Yes Yes Yes Yes Yes Yes

¹ Android's bionic libc has no POSIX shm_open, so shared-memory tensor allocation reports NotImplemented; importing an existing segment received as a file descriptor (from_fd) works.

On macOS the OpenGL backend is enabled when ANGLE is installed — see macOS GPU Acceleration below for setup. If ANGLE is not present the HAL falls back to the CPU backend. On iOS the OpenGL backend uses the same ANGLE-over-Metal path — see iOS below. On Android the OpenGL backend uses the platform's native GLES driver directly (no translation layer) — see Android below.

macOS GPU Acceleration

The HAL uses Google's ANGLE to translate the same OpenGL ES 3.0 calls used on Linux to Metal, and Apple's IOSurface for zero-copy buffer interchange (the role DMA-BUF plays on Linux). ANGLE is not part of macOS and must be installed separately. If it is not present at runtime the HAL logs a warning and falls back to the CPU backend.

ANGLE access: ANGLE itself is an open-source Google project, and our pre-built, signed + notarized xcframework integration is published from the public repository (EdgeFirstAI/angle-package). Anyone can fetch it — no credentials or organization membership required. Two ways to get ANGLE:

  • Recommended (macOS + iOS) — fetch the pre-built release with scripts/fetch-angle.sh (see Option A below). This is exactly what CI uses.
  • macOS alternative — install ANGLE via the public Homebrew tap: brew install startergo/angle/angle (then re-sign the dylibs — see Option B — Homebrew tap below). The HAL finds it automatically.
  • Build without macOS/iOS GL — the HAL's default features include opengl, but you can disable it (--no-default-features --features ndarray,tracing) to build the CPU-only path, which needs no ANGLE at all.

Installing ANGLE (macOS)

The HAL looks for libEGL.dylib / libGLESv2.dylib via the EDGEFIRST_ANGLE_PATH env var, then standard search paths (Homebrew, @loader_path, @executable_path). There are two ways to satisfy this:

Option A — EdgeFirst pre-built release (recommended)

Our pre-built, signed + notarized xcframeworks (built from a pinned ANGLE revision) are published in the EdgeFirstAI/angle-package releases. This repo is public — anyone can fetch the release with no credentials. A single helper downloads, sha256-verifies, and extracts them (into both the xcframework layout for iOS app embedding and a flat-lib layout for the macOS runtime dlopen path):

scripts/fetch-angle.sh                       # → target/angle/ (default tag v2.1.28252)
EDGEFIRST_ANGLE_PATH=target/angle/macos-flat-lib \
  cargo run --release --example pipeline_demo

Because the release is public, scripts/fetch-angle.sh needs no authentication — it works out of the box both locally and in CI. (It still honors gh auth login / GH_TOKEN / GITHUB_TOKEN if present, which raises GitHub's API rate limit, but none are required.)

Why a flat-lib dir for macOS? ANGLE's libEGL internally dlopens libGLESv2.dylib from its own directory (located via dladdr) to resolve GL entry points, so the two must be flat siblings. The signed framework bundles do not satisfy this, so the helper stages libEGL.dylib + libGLESv2.dylib siblings copied out of the framework binaries. Pulling a binary out of its framework invalidates the Developer-ID signature (it is scoped to the bundle's Info.plist, so dlopen then fails with "code signature invalid"), so scripts/fetch-angle.sh ad-hoc re-signs the two flat dylibs for you — you never re-sign manually (unlike the Homebrew path below).

Option B — Homebrew tap (macOS alternative)

ANGLE is also available via a public third-party Homebrew tap — an alternative to Option A if you prefer a package manager on macOS. Homebrew's install_name_tool step invalidates the bundled code signatures and macOS 26 (Tahoe) refuses to load dylibs with broken signatures at dlopen time (immediate SIGKILL (Code Signature Invalid) with no stdout), so an ad-hoc re-sign is mandatory after each install/upgrade:

brew install startergo/angle/angle
codesign --force --sign - $(brew --prefix)/opt/angle/lib/libEGL.dylib
codesign --force --sign - $(brew --prefix)/opt/angle/lib/libGLESv2.dylib

See Homebrew/brew#19144 for the upstream tracking issue. The release path above avoids this problem entirely.

Verifying the GPU backend is active

RUST_LOG=edgefirst_image=debug cargo run --release --example pipeline_demo

Look for ANGLE (Apple, ANGLE Metal Renderer: ...) in the bring-up log. If ANGLE is missing or signatures are still broken you will see a warning and the CPU backend is selected.

Custom ANGLE locations

If your ANGLE install is not on the default search path, set EDGEFIRST_ANGLE_PATH to the directory containing libEGL.dylib and libGLESv2.dylib (flat siblings — see the note above):

EDGEFIRST_ANGLE_PATH=/path/to/angle/lib cargo run --release ...

The lookup order is: EDGEFIRST_ANGLE_PATH → Homebrew → @loader_path (alongside the binary) → @executable_path → unqualified libEGL.dylib on the dyld search path. For bundled distributions, drop the re-signed ANGLE dylibs next to the executable (or into <App>.app/Contents/Frameworks/) and no env var is needed.

When you don't need this setup

  • pip install edgefirst-hal — the macOS wheel ships ANGLE bundled alongside the Python extension; no separate install required.
  • EdgeFirst-signed binary distribution — official binary releases bundle ANGLE re-signed under the EdgeFirst Apple Developer ID. Install and run with no additional setup.

These channels exist precisely so end users do not need to deal with the Homebrew install or re-signing step.

iOS

The HAL Rust library closure builds for iOS (arm64 device + arm64 simulator) with the default features (including opengl), reusing the same ANGLE-over-Metal GL backend as macOS. The supported targets are:

  • aarch64-apple-ios — iOS devices (arm64)
  • aarch64-apple-ios-sim — iOS Simulator on Apple-Silicon Macs (arm64)

ANGLE note: iOS GL requires ANGLE xcframeworks. There is no public Homebrew equivalent for iOS (unlike macOS), so fetch them from the public angle-package release with scripts/fetch-angle.sh (no credentials needed). If you would rather not fetch ANGLE at all, you can still build the Rust library for iOS with the opengl feature disabled: cargo build --target aarch64-apple-ios --no-default-features --features ndarray,tracing. The Rust cargo build itself (with opengl) succeeds without ANGLE present — see How the GL backend resolves ANGLE on iOS.

Intel-simulator (x86_64-apple-ios) is not supported — the angle-package distribution ships arm64-only slices (see below).

Prerequisites

Xcode + the iOS SDKs (xcode-select --install or a full Xcode), plus the Rust iOS targets:

rustup target add aarch64-apple-ios aarch64-apple-ios-sim

Build

The one-command entry point builds for both targets and validates the link closure against the ANGLE xcframeworks:

scripts/build-ios.sh                 # device + sim, build + link-validate
scripts/build-ios.sh device          # device only
scripts/build-ios.sh --no-validate   # build only, skip link validation

Or build the library closure directly:

cargo build --target aarch64-apple-ios     --release -p edgefirst-hal
cargo build --target aarch64-apple-ios-sim --release -p edgefirst-hal

How the GL backend resolves ANGLE on iOS

ANGLE's EGL/GLES symbols are resolved at runtime via libloading, not at link time. On macOS the HAL dlopens libEGL.dylib from the release flat-lib (or Homebrew); on iOS the symbols are already in the process image (the ANGLE xcframeworks are embedded in the app bundle), so the loader resolves them via Library::this() (equivalent to dlopen(NULL)).

Consequence: a standalone cargo build for an iOS target succeeds without the ANGLE frameworks present — the Rust staticlib has no link-time references to eglInitialize etc. The frameworks are only needed at app-link/runtime. The .cargo/config.toml iOS entries therefore carry no rustflags or linker overrides.

The ANGLE xcframeworks

iOS GL requires shipping ANGLE as embedded dynamic frameworks in the app bundle. Our integration uses the signed + notarized xcframeworks from the public EdgeFirstAI/angle-package release (EGL.xcframework + GLESv2.xcframework, each with ios-arm64, ios-arm64-simulator, macos-arm64). scripts/fetch-angle.sh downloads and verifies them (default tag v2.1.28252, matching the ANGLE GL_VERSION string):

scripts/fetch-angle.sh       # → target/angle/{EGL,GLESv2}.xcframework

A consuming iOS app target embeds them (Xcode "Embed & Sign", or XcodeGen embed: true):

dependencies:
  - { framework: ../hal/target/angle/EGL.xcframework,   embed: true }
  - { framework: ../hal/target/angle/GLESv2.xcframework, embed: true }

What is validated vs. deferred

scripts/validate-ios-link.sh builds the production C API staticlib (libedgefirst_hal.a — a Rust staticlib archives the full HAL closure) and links a test executable referencing real C API entry points against the ANGLE xcframeworks + the Apple system frameworks the HAL references via #[link(kind = "framework")] (IOSurface, CoreFoundation, Metal). It also verifies with nm that the archive carries the IOSurface references and that the ANGLE binaries export the EGL entry-point names the runtime loader will look up. This proves the native symbol closure of the shipped artifact is complete.

What is not covered by this effort (future work):

  • Swift bindings — a C/Swift API surface and a ship-able HAL .xcframework. The Rust staticlib is the deliverable here.
  • Runtime validation — actual EGL initialization on a device or simulator requires the app shell (a future effort). The internal hal-mobile assessment already proved the ANGLE-over-Metal + IOSurface path works on iPhone 17 Pro (GL_EXT_color_buffer_half_float present).

fp16 / target features

Unlike aarch64-apple-darwin (where +fp16,+dotprod,+i8mm are baked in — every M-series chip is ARMv8.6-A+), the iOS targets carry no target-feature rustflags. The iOS 16 deployment floor still includes A11 (iPhone 8, ARMv8.1-A, no fp16/dotprod/i8mm), so enabling them would SIGILL on older devices. The deployment target matches the angle-package build (IPHONEOS_DEPLOYMENT_TARGET = 16.0).

Android

The HAL builds for Android with the default features (including opengl), using the platform's native OpenGL ES driver directly — unlike macOS/iOS there is no ANGLE translation layer to install, because Android ships a first-class GLES implementation (Adreno, Mali, etc.). Zero-copy buffer interchange uses AHardwareBuffer (the role DMA-BUF plays on Linux and IOSurface on Apple platforms), imported into GL via EGL_ANDROID_image_native_buffer. The supported targets are:

  • aarch64-linux-android — Android devices (arm64-v8a)
  • x86_64-linux-android — the Android emulator on x86_64 hosts

The minimum supported API level is 26 (Android 8.0) — the floor of the stable AHardwareBuffer NDK ABI.

Prerequisites

rustup target add aarch64-linux-android x86_64-linux-android
cargo install cargo-ndk
# Android NDK r26+ (r27c LTS recommended); set ANDROID_NDK_HOME or let
# cargo-ndk auto-detect it under your Android SDK.

Building

# HAL + C API (the future JNI library) for both ABIs at API 26:
scripts/build-android.sh
# or directly:
cargo ndk -t arm64-v8a -t x86_64 -P 26 build --release -p edgefirst-hal

Link validation

scripts/validate-android-link.sh [arm64|x86_64] builds the production C API staticlib (libedgefirst_hal.a — a Rust staticlib archives the full HAL closure), verifies with llvm-nm that the archive carries the AHardwareBuffer references and that the NDK's API-26 stubs export every EGL/GLES entry point the runtime resolves dynamically, then links a test executable referencing real C API entry points against the NDK system libraries. CI runs this for both ABIs on every PR (build-android lane).

What is not covered by this effort (future work):

  • Kotlin bindings — a JNI/Kotlin API surface, like the Swift API on iOS. The edgefirst-hal-capi cdylib (libedgefirst_hal.so) is the deliverable here.
  • Runtime validation — on-device GL correctness and performance run via the internal hal-mobile AWS Device Farm harness, which drives the real ImageProcessor through JNI (see TESTING.md § Android On-Device Validation); the Phase-1 assessment already proved the native-GLES + AHardwareBuffer path on a Galaxy S26 Ultra (GL_EXT_color_buffer_half_float present, letterbox 720p→640×640 F16 in 741 µs).
  • Deferred zero-copy paths — YUV camera buffers (external-OES sampling) and single-channel Grey/NV imports (R8_UNORM needs API 29); these fall back to CPU conversion today.

NPU-direct output (zero CPU readback)

The convert destination is a real AHardwareBuffer, so an NPU runtime can consume it directly — no map(), no CPU readback:

// Allocate once, reuse every frame (Rule 1). F16 NCHW model input;
// auto-select yields an AHardwareBuffer when the GL backend is active —
// assert hal_tensor_memory_type(dst) == HAL_TENSOR_MEMORY_DMA at startup.
HalTensor* dst = hal_image_processor_create_image(
    proc, 640, 640, HAL_PIXEL_FORMAT_PLANAR_RGB, HAL_DTYPE_F16, HAL_CPU_ACCESS_NONE);

// One-time: hand the SAME buffer to the NPU runtime.
AHardwareBuffer* ahb = hal_tensor_hardware_buffer_ptr(dst);
ANeuralNetworksMemory* mem;
ANeuralNetworksMemory_createFromAHardwareBuffer(ahb, &mem);   // NNAPI
// (LiteRT: wrap `ahb` via TfLiteAHardwareBufferAttachment instead.)

// Per frame: when convert() returns, the GPU has finished writing and
// the handle contents are safe to execute against.
hal_image_processor_convert(proc, src, dst, HAL_ROTATION_NONE,
                            HAL_FLIP_NONE, &letterbox);
// ... ANeuralNetworksExecution_setInputFromMemory(exec, 0, NULL, mem, 0, bytes);

For a pipelined handoff that skips the blocking GPU sync entirely, use hal_image_processor_convert_fence(): it returns a sync-fence fd (EGL_ANDROID_native_fence_sync) the NPU runtime waits on instead (ANeuralNetworksExecution_startComputeWithDependencies), or -1 with the work already synced on drivers without fence support.

Flatness: gralloc chooses the row pitch and may pad it (observed on the S26 Ultra: 640-px planar F16 → 1536-byte rows, natural 1280). Check hal_tensor_recorded_row_stride(dst):

  • 0 — the buffer IS the flat [1, C, H, W] stream; hand it off as-is.
  • nonzero — describe the pitch to the runtime, pick a width whose pitch the device does not pad, or fall back to hal_tensor_copy_to_flat(dst, buf, len) (~0.3 ms at 2.4 MB — still cheaper than a full CPU convert, but no longer zero-copy; profile).

INT8 NPUs: allocate the destination as HAL_PIXEL_FORMAT_RGB / HAL_PIXEL_FORMAT_RGBA with HAL_DTYPE_U8 or HAL_DTYPE_I8 (NHWC, zero-copy on Android via the RGBA8888 texel packing) and attach the model's quantization so consumers agree on the scale:

hal_tensor_set_quantization(dst, /*scale=*/1.0f / 255.0f, /*zero_point=*/0);

The I8 path applies the ^0x80 bias in-shader during the convert — the buffer bytes are already signed model input.

Tile compression (bandwidth): a hardware-only destination can additionally request the device's vendor tile layout through the image-descriptor path — the GPU renders into it and Qualcomm's QNN can consume it natively (UBWC data formats declared at context-binary preparation); other NPU stacks take the linear default:

HalImageDesc* desc = hal_image_desc_new(640, 640, HAL_PIXEL_FORMAT_RGBA, HAL_DTYPE_U8);
hal_image_desc_set_compression(desc, HAL_COMPRESSION_ANY);   // linear fallback is counted
HalTensor* dst = hal_image_processor_create_image_desc(proc, desc);
hal_image_desc_free(desc);
// hal_tensor_compression(dst) records the scheme actually allocated
// (HAL_COMPRESSION_UBWC on Adreno, ..._NONE = linear fallback — see
// hal_compression_fallback_count()).

Compression requires HAL_CPU_ACCESS_NONE (CPU mapping pins the layout linear) and a compressed tensor has no meaningful linear row stride — it is a hardware-to-hardware handle only.

macOS parity: the same pattern works with hal_tensor_iosurface_ref() — wrap the IOSurface in a CVPixelBuffer (CVPixelBufferCreateWithIOSurface) for CoreML/ANE input; convert() returning likewise guarantees GPU completion.

fp16 / target features

Like iOS, the Android targets carry no target-feature rustflags: the API-26 device floor spans ARMv8.0-A cores (Cortex-A53 class, no fp16/dotprod/i8mm), so baking those features in would SIGILL on real older hardware (see .cargo/config.toml).

Build System

The workspace builds with standard cargo. The Makefile wraps the common workflows (make test, make bench, make build, make format lint check) with the right flags and gates.

For Python wheels, see crates/python/README.md and crates/python/TESTING.md. For the C library and consumer linking, see crates/capi/README.md.

Environment Variables

Variable Description
EDGEFIRST_TENSOR_FORCE_MEM 1 forces heap memory (disables DMA / SHM)
EDGEFIRST_DISABLE_G2D Disable G2D backend
EDGEFIRST_DISABLE_GL Disable OpenGL backend
EDGEFIRST_DISABLE_CPU Disable CPU backend
EDGEFIRST_FORCE_BACKEND Force one backend: cpu, g2d, or opengl (disables fallback)
EDGEFIRST_FORCE_TRANSFER Force GL transfer: pbo, dmabuf, or sync
EDGEFIRST_NV_CONVERT_PATH NV12/16/24 GPU conversion path: sampler, shader, or auto (default). auto prefers the portable, colorimetry-exact in-shader ShaderR8, except BT.601-limited single-plane NV12 on Vivante (hardware sampler is ~12× faster and correct). sampler/shader force a path for benchmarking/bring-up
EDGEFIRST_COLORIMETRY fast (default) or exact. High-performance colour conversion is the default; exact opts into the colorimetry-exact path where it costs more. Takes precedence over the per-processor setting
EDGEFIRST_GL_SERIALIZE full or lifecycle — pin the GL command serialization policy instead of using the per-driver default (see Rule 5)
EDGEFIRST_ENABLE_NVJPEG 1 opts into the nvJPEG GPU JPEG decoder on CUDA hosts (off by default so it never silently contends with the inference engine)
EDGEFIRST_EGL_CACHE_CAPACITY Override the per-cache EGLImage capacity (default 64) for high-cardinality varied-geometry streams
EDGEFIRST_ALLOW_SOFTWARE_GL 1 opts in to running the GL backend on a software renderer (otherwise rejected); for CI / headless bring-up
EDGEFIRST_OPENGL_RENDERSURFACE 1 enables EGL renderbuffer path for non-dma_heap DMA-BUF (i.MX 95 Neutron NPU)
EDGEFIRST_PROTO_COMPUTE 1 enables GLES 3.1 compute shader for HWC→CHW proto repack
EDGEFIRST_DISABLE_V4L2 1 forces the software JPEG decoder, bypassing the V4L2 hardware JPEG backend (Linux)
EDGEFIRST_CODEC_V4L2_DEVICE Probe a specific V4L2 device node for hardware JPEG decode instead of auto-discovery
EDGEFIRST_ANGLE_PATH macOS only: directory containing libEGL.dylib / libGLESv2.dylib. Overrides the default search (Homebrew → @loader_path@executable_pathlibEGL.dylib on dyld). Set this when deploying a bundled or custom-signed ANGLE alongside the binary.
EDGEFIRST_TESTDATA_DIR Override testdata location (used by benches and CI)
RUST_LOG Standard env_logger filter — RUST_LOG=edgefirst_image=debug for backend dispatch + cache stats

Per-crate variables and additional detail live in each crate's README.

Testing

See TESTING.md for the cross-cutting testing guide (single-threaded rule, on-target gating, cross-compilation, CI matrix, optimization validation). Per-crate testing detail lives in each crate's TESTING.md — links in the Core Components table.

Benchmarking

Binary Crate What it measures
tensor_benchmark edgefirst-tensor Tensor allocation and map/unmap latency across buffer types
codec_benchmark edgefirst-codec Strided decode into pre-allocated tensors vs. the image crate and raw zune-png
image_benchmark edgefirst-image Crop, flip, rotate, resize, draw
pipeline_benchmark edgefirst-image Letterbox pipeline + format conversion
convert_matrix_benchmark edgefirst-image Full src/dst memory × format × dtype GL convert matrix
batch_convert_benchmark edgefirst-image Batched convert_deferred + flush vs. eager per-tile convert
tiled_convert_benchmark edgefirst-image Crop contract: per-convert CPU cost scales with tile area, not source area
decode_pipeline_benchmark edgefirst-image JPEG decode → letterbox convert (strided, HWC/CHW)
nv_path_benchmark edgefirst-image NV12/16/24 ExternalSampler vs. ShaderR8 conversion paths
cpu_preprocess_benchmark edgefirst-image CPU-only JPEG decode + preprocess path (for targets that reserve the GPU for inference)
parallel_processors_benchmark edgefirst-image Aggregate convert throughput with 1 / 2 / 4 concurrent ImageProcessor instances
mask_benchmark edgefirst-image draw_decoded_masks, draw_proto_masks, hybrid path
mask_decode_benchmark edgefirst-image materialize_scaled_segmentations — the COCO-eval scaled-mask path
nvjpeg_benchmark edgefirst-image nvJPEG GPU decode into a CUDA-registered PBO (Jetson / CUDA targets)
opencv_benchmark edgefirst-image OpenCV baseline comparison
decoder_benchmark edgefirst-decoder YOLO post-processing, NMS, dequant
tracker_benchmark edgefirst-tracker ByteTrack throughput vs. simultaneous tracks

Run on host:

cargo bench -p edgefirst-image --bench pipeline_benchmark -- --bench

# Force a backend
EDGEFIRST_FORCE_BACKEND=cpu cargo bench -p edgefirst-image --bench pipeline_benchmark -- --bench

Cross-compile + deploy to a target (SSH hostnames in ~/.ssh/config: imx8mp-frdm, imx95-frdm, rpi5-hailo, jetson-orin-nano, maivin):

cargo-zigbuild zigbuild --target aarch64-unknown-linux-gnu --release \
  -p edgefirst-image --features opengl --bench pipeline_benchmark

scp target/aarch64-unknown-linux-gnu/release/deps/pipeline_benchmark-* imx8mp-frdm:/tmp/
ssh imx8mp-frdm '/tmp/pipeline_benchmark-* --bench --json /tmp/pipeline.json'

All benchmarks accept --bench --json <path> for structured output. Store results under benchmarks/<platform>/<name>.json. Update BENCHMARKS.md via:

python3 .github/scripts/generate_benchmark_tables.py --data-dir benchmarks/

Performance Tracing

The HAL captures performance traces across every processing stage. Traces are written in the Chrome JSON format and open directly in the Perfetto UI.

How it works

Every HAL library crate emits tracing spans on hot paths. Those spans cost close to nothing when no subscriber is active: each site compiles to a single relaxed atomic load, with no heap allocations, no string formatting, and no function calls on the hot path.

When a session is started via the API, a Chrome JSON subscriber records all span enter/exit events with high-resolution timestamps and structured metadata (detection counts, proto dimensions, format conversions, memory types, etc.) to a file.

Span coverage

The tracing surface covers decode, image conversion, GL multi-pass, mask materialization, tensor lifecycle, tracker association, and the Python entry points. Each span carries structured fields — see the per-crate ARCHITECTURE.md files for the authoritative list of spans and fields per component.

Enabling tracing

Python:

import edgefirst_hal as hal
with hal.Tracing("/tmp/trace.json"):
    # ... run inference pipeline ...
    pass

Rust:

use edgefirst_hal::trace::{start_tracing, stop_tracing};

start_tracing("/tmp/trace.json").expect("start tracing");
// ... inference pipeline ...
stop_tracing(); // flushes and closes the trace file

C:

#include <edgefirst/hal.h>
hal_start_tracing("/tmp/trace.json");
/* ... inference pipeline ... */
hal_stop_tracing();

Viewing traces

  1. Open https://ui.perfetto.dev/
  2. Drag the generated .json file onto the page
  3. Click slices to see structured fields in the Current Selection panel

Using traces for optimization

The tracing infrastructure complements the rules in the Optimization Guide and the data in BENCHMARKS.md:

  1. Identify bottlenecks — common findings:
    • decoder.decode_proto.extract_proto_data > 3 ms → model emits NCHW protos but HAL is transposing (check the layout field)
    • image.convert.cpu.format_convert appearing twice → intermediate format conversion (consider matching src/dst formats)
    • tensor.alloc per-frame → tensors not being reused (Rule 1)
    • image.convert.gl.egl_import on every frame → camera tensors re-imported instead of cached (Rule 3)
  2. Validate rules — re-run with tracing after applying a rule to confirm the expected spans disappear or shrink.
  3. Cross-reference with perf — for CPU-bound spans, combine trace data with perf record for instruction-level hotspots.

Limitations

  • Only one trace session per process lifetime (Rust global subscriber model).
  • Rayon worker spans are not automatically parented to the calling span.
  • The log::* output (via env_logger / C callback logger) operates independently from trace capture; both can be active simultaneously.

Dependencies

Key external dependencies

Internal dependency graph

graph TD
    EF[edgefirst-hal<br/>umbrella]
    Tensor[edgefirst-tensor]
    Image[edgefirst-image]
    Decoder[edgefirst-decoder]
    Tracker[edgefirst-tracker<br/>optional]
    G2D[g2d-sys<br/>optional]

    EF --> Tensor
    EF --> Image
    EF --> Decoder
    Image --> Tensor
    Image --> Decoder
    Image -.optional.-> G2D
    Image -.->|tracker feature| Tracker
    Decoder -.->|tracker feature| Tracker

    Python[edgefirst_hal<br/>PyO3]
    CAPI[edgefirst-hal-capi]

    Python --> EF
    CAPI --> EF
    CAPI --> Tensor
    CAPI --> Image
    CAPI --> Decoder
    CAPI --> Tracker

    style EF fill:#fff4e1
    style Python fill:#e1f5ff
    style CAPI fill:#e1f5ff
    style Tracker fill:#e8f5e9
Loading

Future Considerations

  1. Model HAL — planned abstraction for inference engines (ONNX, TFLite, Kinara)
  2. VPI integration — support for NVIDIA Vision Programming Interface
  3. Additional trackers — SORT, Deep SORT
  4. Async I/O — non-blocking image loading and processing

Support

Community resources

EdgeFirst ecosystem

This project is part of the EdgeFirst Perception stack:

Professional services

Au-Zone Technologies supports production deployments with training and workshops, custom development, integration services, enterprise SLAs, and hardware reference designs.

Contact: [email protected] · au-zone.com

Contributing

Contributions are welcome. See CONTRIBUTING.md for development setup and guidelines. This project follows our Code of Conduct.

Security

For security vulnerabilities, see SECURITY.md or email [email protected] with subject "Security Vulnerability".

Documentation

License

Apache License 2.0 — see LICENSE for details.

Copyright 2025-2026 Au-Zone Technologies

About

Hardware Abstraction Layer

Resources

Code of conduct

Contributing

Security policy

Stars

6 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages