Skip to content

Edge NPUs & Accelerators (Coral Edge TPU)

Levels 1-2 ran every model on a microcontroller's CPU: a Cortex-M core executing CMSIS-NN kernels one multiply-accumulate at a time. That's fine for a keyword spotter running at a few hundred kHz of audio, but it falls apart the moment you want a camera running a real-time object detector at 30 fps. The fix isn't a faster CPU — it's a different kind of chip entirely: a Neural Processing Unit (NPU), purpose-built silicon that does nothing but tensor math, and does it 10-100x more efficiently per watt than a general-purpose core. This module covers what an NPU actually is, using Google's Coral Edge TPU as the concrete example, and how the compilation model changes once inference moves off the CPU.

Why a separate chip beats a faster CPU

A CPU core spends most of its transistor budget on things that have nothing to do with arithmetic: branch prediction, out-of-order execution, cache coherency, instruction decoding. A convolution is enormous numbers of the same operation (multiply, accumulate) repeated over a predictable data access pattern. An NPU throws away everything a CPU needs for general-purpose code and replaces it with a systolic array — a grid of simple multiply-accumulate cells that pass partial sums to their neighbors in lockstep, so data loaded once gets reused across many computations without round-tripping to memory.

The Coral Edge TPU is a good teaching example because its numbers are public and its constraints are typical of the whole NPU category:

Property Value
Peak throughput 4 TOPS (int8)
Power draw ~2W
Effective efficiency ~2 TOPS/W
Supported precision int8 only (no float)
Host interface USB, PCIe, or M.2, depending on module
Model format TensorFlow Lite, compiled ahead-of-time

Compare that 2 TOPS/W to a Cortex-M7 running CMSIS-NN kernels, which lands around 0.01-0.05 TOPS/W for int8 convolutions — the Edge TPU is roughly two orders of magnitude more efficient per operation, at the cost of being able to do only one kind of operation.

The int8-only constraint and why it's non-negotiable

Every NPU in this class trades generality for efficiency, and the trade is almost always the same: int8 arithmetic only. The systolic array's cells are wired for 8-bit multiply-accumulate; there is no floating-point unit on the chip at all. This means:

  • Your model must be fully integer-quantized (Level 2 covered post-training quantization) before compilation — not "mostly int8 with a float fallback."
  • Every op in the graph must have a fixed, ahead-of-time-known integer scale. Ops that don't (some custom layers, certain resize modes) simply cannot run on the accelerator.
  • Any op the compiler can't map to the TPU's instruction set falls back to the host CPU, and that fallback is where most first-time deployments quietly lose all their speedup.

Compiling a model for the Edge TPU

The Coral toolchain is a two-step, offline compilation pipeline: first TFLite converts and fully quantizes the model (as in Level 2), then a separate edgetpu_compiler binary maps the quantized graph onto Edge TPU instructions and rewrites the ops it can't map into CPU-fallback nodes.

"""
Conceptual walkthrough of the Coral compile step. This mirrors the real
edgetpu_compiler CLI's decision process; running it requires the Coral
compiler binary and hardware this environment doesn't have, so treat this
as an annotated model of what happens rather than executable code.
"""

def simulate_edgetpu_mapping(op_list, supported_ops):
    """op_list: sequence of (name, op_type) from a fully int8-quantized
    TFLite graph. supported_ops: the set of op types the Edge TPU
    compiler can map to hardware instructions (a fixed list per compiler
    version -- CONV_2D, DEPTHWISE_CONV_2D, FULLY_CONNECTED, and a limited
    set of others; things like custom ops or certain reshape patterns are
    excluded)."""
    mapped, fallback = [], []
    for name, op_type in op_list:
        if op_type in supported_ops:
            mapped.append(name)
        else:
            fallback.append(name)

    # In the real compiler, a single unsupported op doesn't just cost that
    # op -- it splits the graph into "segments" bounced between TPU and
    # CPU, and each hop pays a fixed transfer latency.
    segments = 1 if not fallback else 1 + len(fallback)
    return {
        "on_device_ops": mapped,
        "cpu_fallback_ops": fallback,
        "graph_segments": segments,
        "fully_mapped": len(fallback) == 0,
    }


supported = {"CONV_2D", "DEPTHWISE_CONV_2D", "FULLY_CONNECTED", "ADD",
             "AVERAGE_POOL_2D", "RESHAPE"}
example_graph = [
    ("conv1", "CONV_2D"), ("dwconv1", "DEPTHWISE_CONV_2D"),
    ("resize1", "RESIZE_BILINEAR"),   # not in supported set
    ("conv2", "CONV_2D"), ("fc1", "FULLY_CONNECTED"),
]

result = simulate_edgetpu_mapping(example_graph, supported)
print(result)
# {'on_device_ops': ['conv1', 'dwconv1', 'conv2', 'fc1'],
#  'cpu_fallback_ops': ['resize1'], 'graph_segments': 2, 'fully_mapped': False}

That graph_segments: 2 is the number that matters in practice. Every segment boundary means a round trip across the USB/PCIe bus carrying activation tensors, which at real frame rates can cost more time than the fallback op itself would have taken on the CPU. The practical workflow is: compile, read the compiler's op-mapping report, and if you see fallback ops, restructure the model (swap RESIZE_BILINEAR for a supported resize mode, move a custom layer to pre/post-processing on the host) rather than accept the split.

Measuring the speedup honestly

"NPU vs. CPU" comparisons are easy to get wrong by comparing the wrong things — a common mistake is timing only the inference call and ignoring the transfer cost of moving frames to and from the accelerator.

import time

def benchmark_pipeline(preprocess_fn, transfer_fn, infer_fn, postprocess_fn,
                        frame, n_runs=100):
    """Times every stage separately so a 'fast' inference number doesn't
    hide a slow transfer stage -- a mistake common enough it's worth
    building the harness to prevent it structurally."""
    stages = {"preprocess": 0.0, "transfer": 0.0,
              "infer": 0.0, "postprocess": 0.0}
    for _ in range(n_runs):
        t0 = time.perf_counter()
        x = preprocess_fn(frame)
        t1 = time.perf_counter()
        x = transfer_fn(x)          # host -> accelerator memory copy
        t2 = time.perf_counter()
        y = infer_fn(x)
        t3 = time.perf_counter()
        _ = postprocess_fn(y)
        t4 = time.perf_counter()
        stages["preprocess"] += t1 - t0
        stages["transfer"] += t2 - t1
        stages["infer"] += t3 - t2
        stages["postprocess"] += t4 - t3
    return {k: (v / n_runs) * 1000 for k, v in stages.items()}  # ms/frame

On a USB-attached Edge TPU, transfer frequently costs as much as infer for small models — the accelerator is so fast at the matmul that the bottleneck moves entirely to getting bytes across the bus. This is why Coral's PCIe and M.2 modules (which skip USB's protocol overhead) show a bigger real-world win than the raw TOPS number suggests, and why batching multiple frames per transfer (when latency budget allows) is a standard optimization.

Edge-AI tradeoffs

Factor CPU (CMSIS-NN) NPU (Edge TPU class)
Peak efficiency ~0.01-0.05 TOPS/W ~1-4 TOPS/W
Precision flexibility int8, int16, float32 int8 only
Op coverage anything you can write in C fixed, compiler-defined set
Cold-start / setup cost none driver + runtime init, non-trivial
Best fit small models, tight power budget, simple ops vision models, high throughput, fixed op set
Failure mode when mismatched just slow silent CPU fallback, split-graph latency

How It Actually Works

Why a systolic array reuses data instead of re-fetching it from memory. In a standard CPU, each multiply-accumulate reads its operands from cache/memory, computes, and writes back — for a convolution's inner loop, the same weight value is read repeatedly across many output positions, and the same input value is read repeatedly across many overlapping receptive fields, so memory bandwidth (not arithmetic) becomes the bottleneck at scale. A systolic array instead loads weights into the array's cells once and streams activations through the grid; each cell holds its operand, multiplies it against the value passing through, adds to a running partial sum, and passes both operand and sum to its neighbor on the next clock tick — so a value loaded once participates in dozens of MACs before ever needing to be re-fetched. This is precisely the architectural reason a systolic-array NPU achieves ~2 TOPS/W where a general-purpose core managing its own memory traffic tops out near 0.01–0.05 TOPS/W for the same int8 convolution: the difference is memory movement eliminated, not faster arithmetic per se.

Why "int8 only" is a wiring decision, not a firmware limitation. A floating-point multiplier needs separate exponent and mantissa alignment logic, normalization, and rounding circuitry — several times the transistor count of a fixed-point 8-bit multiplier-accumulator cell. An NPU built for maximum MACs-per-watt-per-mm² simply never fabricates that floating-point circuitry into the array at all; every cell is a small, identical int8×int8→int32 MAC unit repeated thousands of times across the die. This is why "the chip has no floating-point unit" is stated as an absolute rather than a configuration flag — there is no code path that could enable float32 execution on this silicon, which is exactly why full integer quantization (Level 2) is a hard prerequisite, not an optimization choice, for this class of accelerator.

Why a single unsupported op fragments the whole graph's latency, not just that op's runtime. simulate_edgetpu_mapping's graph_segments counter models a real constraint: the compiler can only emit one contiguous command stream per unbroken run of NPU-eligible ops. The moment an op like RESIZE_BILINEAR can't be mapped, the graph must be split into "segment ending before the unsupported op," "the unsupported op running on CPU," and "segment resuming after it" — and each boundary crossing requires the accelerator's output tensor to be copied back across the host bus (USB/PCIe), transformed by the CPU, then copied back across the bus again for the next NPU segment. Because that round-trip transfer often costs as much wall-clock time as the fast NPU segments it surrounds (as benchmark_pipeline is built to reveal), a single unsupported op deep in an otherwise-mappable graph can dominate total latency far out of proportion to its own compute cost.

Exercise

Take a TFLite int8 model you quantized in Level 2 (or reuse the keyword spotter). List every op type in its graph (interpreter.get_tensor_details() or inspect the .tflite with Netron), then check each one against Coral's published supported-ops list for the current compiler version. Write down which ops would fall back to CPU and estimate, using the graph_segments idea above, how many host/accelerator boundary crossings your model would incur — before you ever touch real hardware.