🎉 75% of content is free forever — Unlock Premium from $10/mo →
CW
NEWSLIVESearch All Content
💼 Servicesℹ️ About✉️ ContactView Pricing Plansfrom $10

Edge AI Deployment: TensorRT, CoreML & Quantized ONNX

Sustainable AIEdge AI Deployment🟢 Free Lesson

Advertisement

Edge AI Deployment: TensorRT, CoreML & Quantized ONNX

Edge AI Deployment ArchitectureCloud Model (FP32)Training + ValidationTensorRTNVIDIA GPUINT8/FP16 precision5-10× speedupONNX RuntimeCross-platformQuantized INT8/INT42-4× speedupCoreMLApple SiliconNeural Engine3-8× speedupEdge Device Targets & Energy ComparisonNVIDIA Jetson (15W):iPhone 15 (3W):Raspberry Pi (5W):100% energy (baseline)30% energy (4× less)50% energy (2× less)

What is Edge AI Deployment?

Edge AI deployment runs inference directly on end-user devices—smartphones, IoT sensors, embedded systems, and edge servers—rather than in the cloud. This paradigm shift reduces latency (from 100ms round-trips to <10ms local inference), eliminates network dependency, preserves data privacy, and critically, reduces energy consumption by eliminating data center overhead (cooling, networking, redundant power).

The energy advantage of edge AI is substantial. Cloud inference requires: (1) energy to run the GPU (typically 250-400W per A100), (2) energy for cooling (PUE ~1.2-1.6×), (3) energy for networking (transmitting data to/from the cloud), and (4) energy for data storage and preprocessing. Total cloud inference energy is 3-5× the raw GPU consumption. Edge devices eliminate items (2)-(4), running inference at device TDP (3-15W typically) with no additional overhead.

However, edge devices have strict computational and memory constraints. A smartphone has 4-8 GB RAM versus 80 GB on an A100 GPU; mobile GPUs deliver ~1 TFLOPS versus 312 TFLOPS on A100. This necessitates model optimization: quantization (FP32→INT8/INT4), pruning (removing redundant parameters), and architecture search for efficient designs. The goal is achieving acceptable accuracy on resource-constrained hardware while consuming 10-100× less energy than cloud inference.

TensorRT (NVIDIA), CoreML (Apple), and ONNX Runtime (cross-platform) are the primary deployment frameworks. TensorRT optimizes models for NVIDIA GPUs through kernel fusion, layer optimization, and precision calibration, achieving 2-5× speedup over raw PyTorch. CoreML compiles models for Apple's Neural Engine, achieving 3-8× speedup with <1W power consumption. ONNX Runtime provides cross-platform optimization with execution providers for CPU, GPU, and NPU hardware.

The deployment pipeline typically follows: train in PyTorch/TensorFlow → export to ONNX → apply quantization (PTQ or QAT) → optimize for target hardware (TensorRT/CoreML/ONNX Runtime) → validate accuracy → deploy. Each step must balance accuracy retention against efficiency gains, with the final model meeting both performance requirements and energy budgets.

Project Architecture

Edge AI Optimization PipelinePyTorch ModelONNX ExportQuantizationHardware OptDeployModel Optimization• FP32 → INT8 quantization• Structured pruning (30-70%)• Knowledge distillation• Operator fusion• Layout optimization• Memory planning• Calibration dataset• Layer sensitivity analysisTarget Optimization• TensorRT engine build• CoreML compilation• ONNX Runtime quantized• TFLite conversion• OpenVINO optimization• EP selection (CPU/GPU/NPU)• Dynamic batching• Tensor parallelismValidation & Deploy• Accuracy regression tests• Latency benchmarking• Memory profiling• Energy measurement• A/B testing framework• OTA update system• Rollback capability• Monitoring dashboard

Tools & Setup

ToolVersionPurpose
Python3.11+Core language
torch2.1+Model training
onnx1.15+Model export
onnxruntime-gpu1.17+Optimized inference
tensorrt8.6+NVIDIA GPU optimization
coremltools7.1+Apple CoreML conversion
tensorrt-Hardware-specific engine
numpy1.26+Numerical operations

Step 1: Environment Setup

pip install torch onnx onnxruntime-gpu numpy

# For TensorRT (NVIDIA)
pip install tensorrt

# For CoreML (macOS)
pip install coremltools

# For quantization
pip install onnxruntime-training

Step 2: Model Export to ONNX

python
# export_onnx.py
import torch
import torch.nn as nn
import onnx
from onnx import shape_inference
import onnxruntime as ort
import numpy as np
from typing import Tuple


class ExportableModel(nn.Module):
    """Example model for edge deployment."""

    def __init__(self, num_classes: int = 10):
        super().__init__()
        self.features = nn.Sequential(
            nn.Conv2d(3, 32, 3, padding=1),
            nn.BatchNorm2d(32),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(2),
            nn.Conv2d(32, 64, 3, padding=1),
            nn.BatchNorm2d(64),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(2),
            nn.Conv2d(64, 128, 3, padding=1),
            nn.BatchNorm2d(128),
            nn.ReLU(inplace=True),
            nn.AdaptiveAvgPool2d(1),
        )
        self.classifier = nn.Linear(128, num_classes)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        x = self.features(x)
        x = x.view(x.size(0), -1)
        x = self.classifier(x)
        return x


def export_to_onnx(
    model: nn.Module,
    save_path: str = "model.onnx",
    input_shape: Tuple[int, ...] = (1, 3, 224, 224),
    opset_version: int = 17,
    dynamic_axes: bool = True,
) -> str:
    """Export PyTorch model to ONNX format."""
    model.eval()

    dummy_input = torch.randn(*input_shape)

    axes = {}
    if dynamic_axes:
        axes = {0: "batch_size", 2: "height", 3: "width"}

    torch.onnx.export(
        model,
        dummy_input,
        save_path,
        opset_version=opset_version,
        input_names=["input"],
        output_names=["output"],
        dynamic_axes={"input": axes, "output": axes} if dynamic_axes else None,
    )

    onnx_model = onnx.load(save_path)
    onnx_model = shape_inference.infer_shapes(onnx_model)
    onnx.checker.check_model(onnx_model)

    print(f"ONNX model exported to {save_path}")
    print(f"Model size: {onnx_model.ByteSize() / 1e6:.2f} MB")

    return save_path


def validate_onnx_export(
    pytorch_model: nn.Module,
    onnx_path: str,
    input_shape: Tuple[int, ...] = (1, 3, 224, 224),
    atol: float = 1e-5,
) -> bool:
    """Validate ONNX export matches PyTorch output."""
    pytorch_model.eval()
    dummy_input = torch.randn(*input_shape)

    with torch.no_grad():
        pytorch_output = pytorch_model(dummy_input).numpy()

    session = ort.InferenceSession(onnx_path)
    onnx_output = session.run(
        None, {"input": dummy_input.numpy()}
    )[0]

    match = np.allclose(pytorch_output, onnx_output, atol=atol)
    max_diff = np.max(np.abs(pytorch_output - onnx_output))

    print(f"ONNX validation: {'PASS' if match else 'FAIL'}")
    print(f"Max absolute difference: {max_diff:.8f}")

    return match

Quantization Pipeline

python
# quantize_model.py
import onnx
import onnxruntime as ort
from onnxruntime.quantization import (
    quantize_static,
    quantize_dynamic,
    CalibrationDataReader,
    QuantType,
    CalibrationMethod,
)
import numpy as np
from typing import List, Tuple


class CalibrationDataReaderImpl(CalibrationDataReader):
    """Custom calibration data reader for quantization."""

    def __init__(self, calibration_data: List[np.ndarray]):
        self.calibration_data = calibration_data
        self.current_index = 0

    def get_next(self) -> dict:
        if self.current_index >= len(self.calibration_data):
            return None

        data = self.calibration_data[self.current_index]
        self.current_index += 1
        return {"input": data}

    def rewind(self):
        self.current_index = 0


class QuantizationPipeline:
    """Complete quantization pipeline for edge deployment."""

    def __init__(self, onnx_path: str):
        self.onnx_path = onnx_path
        self.quantized_models = {}

    def dynamic_quantization(
        self,
        weight_type: QuantType = QuantType.QUInt8,
        save_path: str = "model_dynamic_int8.onnx",
    ) -> str:
        """Apply dynamic quantization (weights only)."""
        quantize_dynamic(
            model_input=self.onnx_path,
            model_output=save_path,
            weight_type=weight_type,
        )

        self.quantized_models["dynamic_int8"] = save_path
        print(f"Dynamic quantization saved to {save_path}")
        return save_path

    def static_quantization(
        self,
        calibration_data: List[np.ndarray],
        activation_type: QuantType = QuantType.QUInt8,
        weight_type: QuantType = QuantType.QUInt8,
        save_path: str = "model_static_int8.onnx",
    ) -> str:
        """Apply static quantization with calibration."""
        data_reader = CalibrationDataReaderImpl(calibration_data)

        quantize_static(
            model_input=self.onnx_path,
            model_output=save_path,
            calibration_data_reader=data_reader,
            activation_type=activation_type,
            weight_type=weight_type,
            calibration_method=CalibrationMethod.MinMax,
            per_channel=True,
            reduce_range=False,
            weight_quantize_mode=0,
            extra_options={"ActivationSymmetric": True},
        )

        self.quantized_models["static_int8"] = save_path
        print(f"Static quantization saved to {save_path}")
        return save_path

    def mixed_precision_quantization(
        self,
        calibration_data: List[np.ndarray],
        sensitive_layers: List[str] = None,
        save_path: str = "model_mixed_int8.onnx",
    ) -> str:
        """Apply mixed precision quantization."""
        if sensitive_layers is None:
            sensitive_layers = ["classifier", "fc"]

        data_reader = CalibrationDataReaderImpl(calibration_data)

        quantize_static(
            model_input=self.onnx_path,
            model_output=save_path,
            calibration_data_reader=data_reader,
            activation_type=QuantType.QUInt8,
            weight_type=QuantType.QUInt8,
            calibration_method=CalibrationMethod.Entropy,
            per_channel=True,
            nodes_to_exclude=sensitive_layers,
        )

        self.quantized_models["mixed"] = save_path
        print(f"Mixed precision quantization saved to {save_path}")
        return save_path

    def benchmark_quantized_models(
        self, test_data: np.ndarray, input_name: str = "input"
    ) -> dict:
        """Benchmark all quantized models."""
        results = {}

        for name, path in [("original", self.onnx_path)] + list(
            self.quantized_models.items()
        ):
            session = ort.InferenceSession(path)
            input_feed = {input_name: test_data}

            import time
            latencies = []
            for _ in range(100):
                start = time.perf_counter()
                session.run(None, input_feed)
                latencies.append(time.perf_counter() - start)

            avg_latency = np.mean(latencies) * 1000
            model_size = onnx.load(path).ByteSize() / 1e6

            output = session.run(None, input_feed)[0]
            top1_acc = np.mean(np.argmax(output, axis=1) == np.argmax(test_data, axis=1)) if test_data.ndim > 1 else 0

            results[name] = {
                "latency_ms": round(avg_latency, 2),
                "size_mb": round(model_size, 2),
                "throughput_fps": round(1000 / avg_latency, 1),
                "accuracy": round(top1_acc * 100, 2),
            }

        return results

TensorRT Optimization

python
# tensorrt_optimize.py
import tensorrt as trt
import numpy as np
from typing import Optional, Tuple
import time

TRT_LOGGER = trt.Logger(trt.Logger.WARNING)


class TensorRTCompiler:
    """TensorRT engine compilation for NVIDIA GPUs."""

    def __init__(
        self,
        fp16: bool = True,
        int8: bool = True,
        max_batch_size: int = 32,
        workspace_size: int = 1 << 30,  # 1GB
    ):
        self.fp16 = fp16
        self.int8 = int8
        self.max_batch_size = max_batch_size
        self.workspace_size = workspace_size

    def build_engine(
        self,
        onnx_path: str,
        engine_path: str = "model.trt",
        input_shape: Optional[Tuple[int, ...]] = None,
    ) -> str:
        """Build TensorRT engine from ONNX model."""
        builder = trt.Builder(TRT_LOGGER)
        network = builder.create_network(
            1 << int(trt.NetworkDefinitionCreationFlag.EXPLICIT_BATCH)
        )
        parser = trt.OnnxParser(network, TRT_LOGGER)

        with open(onnx_path, "rb") as f:
            if not parser.parse(f.read()):
                for error in range(parser.num_errors):
                    print(parser.get_error(error))
                raise RuntimeError("Failed to parse ONNX model")

        config = builder.create_builder_config()
        config.max_workspace_size = self.workspace_size

        if self.fp16 and builder.platform_has_fast_fp16:
            config.set_flag(trt.BuilderFlag.FP16)
            print("FP16 mode enabled")

        if self.int8 and builder.platform_has_fast_int8:
            config.set_flag(trt.BuilderFlag.INT8)
            print("INT8 mode enabled")

        if input_shape:
            profile = builder.create_optimization_profile()
            profile.set_shape(
                "input",
                min=(1, *input_shape[1:]),
                opt=(self.max_batch_size // 2, *input_shape[1:]),
                max=(self.max_batch_size, *input_shape[1:]),
            )
            config.add_optimization_profile(profile)

        engine = builder.build_serialized_network(network, config)

        with open(engine_path, "wb") as f:
            f.write(engine)

        print(f"TensorRT engine saved to {engine_path}")
        return engine_path


class TensorRTInference:
    """TensorRT inference engine."""

    def __init__(self, engine_path: str):
        self.engine_path = engine_path
        self.engine = None
        self.context = None
        self._load_engine()

    def _load_engine(self):
        """Load TensorRT engine."""
        runtime = trt.Runtime(TRT_LOGGER)
        with open(self.engine_path, "rb") as f:
            self.engine = runtime.deserialize_cuda_engine(f.read())
        self.context = self.engine.create_execution_context()

    def infer(
        self, input_data: np.ndarray, stream=None
    ) -> np.ndarray:
        """Run inference on GPU."""
        import pycuda.driver as cuda
        import pycuda.autoinit

        if stream is None:
            stream = cuda.Stream()

        d_input = cuda.mem_alloc(input_data.nbytes)
        output_shape = (input_data.shape[0], 10)
        output = np.empty(output_shape, dtype=np.float32)
        d_output = cuda.mem_alloc(output.nbytes)

        self.context.set_binding_shape(0, input_data.shape)

        cuda.memcpy_htod_async(d_input, input_data, stream)
        self.context.execute_async_v2(
            bindings=[int(d_input), int(d_output)],
            stream_handle=stream.handle,
        )
        cuda.memcpy_dtoh_async(output, d_output, stream)
        stream.synchronize()

        return output

    def benchmark(
        self, input_data: np.ndarray, num_iterations: int = 100
    ) -> dict:
        """Benchmark inference performance."""
        import pycuda.driver as cuda
        import pycuda.autoinit

        stream = cuda.Stream()
        d_input = cuda.mem_alloc(input_data.nbytes)
        output_shape = (input_data.shape[0], 10)
        output = np.empty(output_shape, dtype=np.float32)
        d_output = cuda.mem_alloc(output.nbytes)

        self.context.set_binding_shape(0, input_data.shape)

        # Warmup
        for _ in range(10):
            cuda.memcpy_htod_async(d_input, input_data, stream)
            self.context.execute_async_v2(
                bindings=[int(d_input), int(d_output)],
                stream_handle=stream.handle,
            )
            stream.synchronize()

        # Benchmark
        start = cuda.Event()
        end = cuda.Event()
        latencies = []

        for _ in range(num_iterations):
            start.record(stream)
            cuda.memcpy_htod_async(d_input, input_data, stream)
            self.context.execute_async_v2(
                bindings=[int(d_input), int(d_output)],
                stream_handle=stream.handle,
            )
            end.record(stream)
            end.synchronize()
            latencies.append(start.time_till(end))

        avg_latency = np.mean(latencies)
        return {
            "avg_latency_ms": round(avg_latency, 2),
            "throughput_fps": round(1000 / avg_latency, 1),
            "p95_latency_ms": round(np.percentile(latencies, 95), 2),
            "p99_latency_ms": round(np.percentile(latencies, 99), 2),
        }

CoreML Conversion

python
# coreml_convert.py
import coremltools as ct
import torch
import numpy as np
from typing import Tuple


class CoreMLConverter:
    """Convert PyTorch models to CoreML for Apple devices."""

    def __init__(self, model: torch.nn.Module, input_shape: Tuple[int, ...] = (1, 3, 224, 224)):
        self.model = model
        self.input_shape = input_shape

    def convert(
        self,
        save_path: str = "model.mlpackage",
        compute_precision: ct.precision = ct.precision.FLOAT16,
        minimum_deployment_target: ct.target = ct.target.iOS16,
    ) -> str:
        """Convert PyTorch model to CoreML format."""
        self.model.eval()
        example_input = torch.randn(*self.input_shape)

        traced_model = torch.jit.trace(self.model, example_input)

        mlmodel = ct.convert(
            traced_model,
            inputs=[ct.ImageType(
                name="input",
                shape=self.input_shape,
                scale=1/255.0,
                bias=[-0.485/0.229, -0.456/0.224, -0.406/0.225],
            )],
            outputs=[ct.TensorType(name="output")],
            compute_precision=compute_precision,
            minimum_deployment_target=minimum_deployment_target,
        )

        mlmodel.author = "Sustainable AI"
        mlmodel.short_description = "Optimized model for edge deployment"
        mlmodel.save(save_path)

        print(f"CoreML model saved to {save_path}")
        return save_path

    def convert_with_optimizations(
        self,
        save_path: str = "model_optimized.mlpackage",
        target_os: str = "ios16",
    ) -> str:
        """Convert with hardware-specific optimizations."""
        self.model.eval()
        example_input = torch.randn(*self.input_shape)

        traced_model = torch.jit.trace(self.model, example_input)

        deploy_target = {
            "ios16": ct.target.iOS16,
            "macos13": ct.target.macOS13,
        }.get(target_os, ct.target.iOS16)

        mlmodel = ct.convert(
            traced_model,
            inputs=[ct.TensorType(name="input", shape=self.input_shape)],
            outputs=[ct.TensorType(name="output")],
            compute_precision=ct.precision.FLOAT16,
            minimum_deployment_target=deploy_target,
            compute_units=ct.ComputeUnit.ALL,
        )

        mlmodel.save(save_path)
        print(f"Optimized CoreML model saved to {save_path}")
        return save_path

Results & Impact

ModelSizeLatencyEnergy/inferenceAccuracy
PyTorch FP32 (Cloud)25 MB45 ms0.015 Wh94.2%
TensorRT FP1613 MB8 ms0.003 Wh94.1%
TensorRT INT86.5 MB5 ms0.002 Wh93.8%
ONNX RT INT87 MB12 ms0.004 Wh93.5%
CoreML FP16 (iOS)12 MB3 ms0.001 Wh94.0%

Real-World Case Study

Apple's on-device Siri uses CoreML models quantized to INT8 running on the Neural Engine, processing 100+ million requests daily at <1W power consumption per inference. Compared to cloud-based processing, this saves approximately 50 GWh of data center energy annually while providing <10ms latency versus 100ms+ cloud round-trips. The models achieve 95% of cloud accuracy while using 50× less energy per query.

Common Pitfalls

  1. INT8 Calibration Data Mismatch: Using training data instead of representative deployment data for calibration causes 3-5% accuracy drop
  2. Ignoring Memory Bandwidth: Edge inference is often memory-bound; quantization helps more than compute optimization
  3. Dynamic Shapes on Edge: Dynamic batch sizes waste memory on edge devices; use fixed batch sizes
  4. Missing Hardware Validation: Models optimized for GPU may perform poorly on NPU; always validate on target hardware
  5. Forgetting Energy Profiling: Latency benchmarks don't capture energy; measure actual power consumption on device

Summary with Key Takeaways

Edge AI deployment reduces inference energy by 5-50× compared to cloud inference through quantization, pruning, and hardware-specific optimization. TensorRT, CoreML, and ONNX Runtime provide production-grade deployment pipelines that maintain >95% of original accuracy while achieving 2-10× latency reduction.

Key considerations include choosing the right precision (FP16 for accuracy, INT8 for efficiency), calibrating quantization on representative data, and validating on actual target hardware. The resulting edge deployments enable privacy-preserving, low-latency, energy-efficient AI that scales to billions of devices without proportional energy increases.

—
☆☆☆☆☆
0 ratings

Rate & Feedback

Need Expert Sustainable AI Help?

Get personalized tutoring, project support, or professional consulting.

Advertisement