aiml
SearchMy learning
Menu

Lesson 15 of 20Module 5

Compare eager and compiled normalization on real tensors

Compare eager and compiled normalization on real Metal tensors. Check numerical agreement and synchronized timings across several input shapes.

Prerequisites

Python, basic causal attention, and the preceding inference lessons.

By the end, you can

  • Compare eager and compiled normalization on real tensors.
  • Explain the bottleneck, measurement boundary, and tradeoffs in this implementation.
  • Check numerical parity and timing distributions before accepting compiled execution.
Runtime
Python 3.12+
Dependencies
Offline checks have no external imports; live tensor execution uses mlx 0.32.4 on Metal

Lab code, tests and fixtures (ZIP, 113 KB)Quiz check your understanding

Progress is saved in this browser.

When you need this. A model profile contains many small normalization operations. Combining work may reduce intermediate tensors and dispatch overhead, but a shorter expression does not establish a faster kernel. Compare numerical output and synchronized execution on the available backend.

What you run. Fresh Metal eager-versus-compiled tensor experiment. CUDA/Triton execution, kernel traffic, and whole-model acceleration are unmeasured.

This lesson builds on investigate host overhead before changing gpu settings.

Run the same normalization two ways

RMS normalization scales each row using the reciprocal root of its mean squared values, then applies learned weights. Residual addition can feed that calculation without requiring a separately retained intermediate in a fused implementation.

The live program compares eager and compiled MLX expressions on Metal. Compilation may fuse operations; only a device trace can confirm the exact kernels and memory traffic. The offline analyzer checks numerical parity and the timing samples. It does not verify Triton memory safety or infer GPU memory traffic.

Input tensorsEager expressionCompiled expressionpath Apath BInput tensorsEager expressionCompiled expressionpath A
Compare two independent calculations on the same input. There is no process flow from one alternative into the other.

fusion_live.py creates actual tensors, warms both paths, checks maximum absolute output difference, and records repeated synchronized timings for several shapes, including an irregular row width. It replaces the unexecuted Triton snippet as the tested implementation.

Compare the tensor execution paths

Download the lab from this page, extract it, and run these commands from its directory. The default command reads saved run records or runs the explicitly described local algorithm; it never downloads a model or calls a paid API.

Run the local analysis and checksTerminal · bash
python3 lab.py
python3 -m unittest -v test_lab.py
Read lab.py
lab.pySource · pythonDownload
"""Validate recorded numerical parity and summarize actual Metal expression timings."""

import json, math, statistics
from pathlib import Path


def compare_case(case, tolerance=1e-5):
    if (
        tolerance < 0
        or not math.isfinite(case["max_abs_error"])
        or case["max_abs_error"] > tolerance
    ):
        raise ValueError("Numerical parity exceeds tolerance")
    result = {
        "rows": case["rows"],
        "width": case["width"],
        "max_abs_error": case["max_abs_error"],
    }
    for name in ["eager", "compiled"]:
        samples = case["timings"][name]["samples_ms"]
        if not samples or any(not math.isfinite(v) or v <= 0 for v in samples):
            raise ValueError("Invalid timing samples")
        result[name + "_median_ms"] = round(statistics.median(samples), 6)
    return result


def main():
    record = json.loads(Path("fusion-run.json").read_text())
    print(
        json.dumps(
            {
                "execution_mode": "offline analysis of fresh Metal eager and compiled expression runs",
                "cases": [compare_case(c) for c in record["cases"]],
                "limits": "Shape-specific tensor timings; no Triton/CUDA run, device-memory counter or whole-model speedup.",
            },
            indent=2,
        )
    )


if __name__ == "__main__":
    main()
Read fusion_live.py
fusion_live.pySource · pythonDownload
"""Compare eager and compiled RMS normalization on Metal; no CUDA/Triton execution claimed."""

import argparse
import hashlib
import importlib.metadata
import json
import platform
import statistics
import time
from pathlib import Path


def main():
    import mlx.core as mx

    p = argparse.ArgumentParser()
    p.add_argument("--output", required=True)
    a = p.parse_args()
    mx.random.seed(0)

    def norm(x, r, w):
        z = x + r
        return z * mx.rsqrt(mx.mean(z * z, axis=-1, keepdims=True) + 1e-6) * w

    compiled = mx.compile(norm)
    cases = []
    for rows, width in [(1, 896), (32, 896), (32, 1003)]:
        x = mx.random.normal((rows, width))
        r = mx.random.normal((rows, width))
        w = mx.ones((width,))
        mx.eval(x, r, w)
        reference = norm(x, r, w)
        candidate = compiled(x, r, w)
        mx.eval(reference, candidate)
        error = float(mx.max(mx.abs(reference - candidate)).item())
        timings = {}
        for name, fn in [("eager", norm), ("compiled", compiled)]:
            for _ in range(3):
                mx.eval(fn(x, r, w))
            samples = []
            for _ in range(20):
                mx.synchronize()
                start = time.perf_counter()
                out = fn(x, r, w)
                mx.eval(out)
                mx.synchronize()
                samples.append((time.perf_counter() - start) * 1000)
            timings[name] = {
                "median_ms": round(statistics.median(samples), 6),
                "samples_ms": [round(s, 6) for s in samples],
            }
        cases.append(
            {
                "rows": rows,
                "width": width,
                "dtype": "float32",
                "max_abs_error": error,
                "timings": timings,
            }
        )
    result = {
        "execution_mode": "fresh Metal tensor execution; eager versus MLX compiled expression",
        "environment": {
            "python": platform.python_version(),
            "platform": platform.platform(),
            "mlx": importlib.metadata.version("mlx"),
        },
        "source_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
        "cases": cases,
        "limits": "Expression compilation may fuse operations. No kernel trace or measured HBM byte count; no whole-model, CUDA or Triton speedup claim.",
    }
    Path(a.output).write_text(json.dumps(result, indent=2) + "\n")


if __name__ == "__main__":
    main()

The compiled expression and eager reference agree for these recorded shapes. Their timings include repeated synchronization and evaluation. The distributions, not a fixed advertised ratio, are the result worth inspecting.

Inspect the complete local result
Recorded local resultRecorded output · jsonDownload
{
  "execution_mode": "offline analysis of fresh Metal eager and compiled expression runs",
  "cases": [
    {
      "rows": 1,
      "width": 896,
      "max_abs_error": 0.0,
      "eager_median_ms": 0.45025,
      "compiled_median_ms": 0.290083
    },
    {
      "rows": 32,
      "width": 896,
      "max_abs_error": 0.0,
      "eager_median_ms": 0.222666,
      "compiled_median_ms": 0.192312
    },
    {
      "rows": 32,
      "width": 1003,
      "max_abs_error": 0.0,
      "eager_median_ms": 0.228792,
      "compiled_median_ms": 0.196583
    }
  ],
  "limits": "Shape-specific tensor timings; no Triton/CUDA run, device-memory counter or whole-model speedup."
}
Inspect the actual execution record
Actual execution recordRecorded output · jsonDownload
{
  "execution_mode": "fresh Metal tensor execution; eager versus MLX compiled expression",
  "environment": {
    "python": "3.12.8",
    "platform": "macOS-27.0.1-arm64-arm-64bit",
    "mlx": "0.32.4"
  },
  "source_sha256": "62653ea247e893a6c1b2a70709fa0e9a363e1b2ce25e6b522c0c8308fd230a05",
  "cases": [
    {
      "rows": 1,
      "width": 896,
      "dtype": "float32",
      "max_abs_error": 0.0,
      "timings": {
        "eager": {
          "median_ms": 0.45025,
          "samples_ms": [
            0.507958,
            0.464666,
            0.450875,
            0.45325,
            0.449625,
            0.503083,
            0.501917,
            0.456292,
            0.472334,
            0.473333,
            0.516667,
            0.401541,
            0.393125,
            0.401959,
            0.222625,
            0.180541,
            0.174334,
            0.180542,
            0.175917,
            0.1745
          ]
        },
        "compiled": {
          "median_ms": 0.290083,
          "samples_ms": [
            0.174417,
            0.1545,
            0.160917,
            0.161667,
            0.161125,
            0.412792,
            0.464209,
            0.464167,
            0.509875,
            0.450125,
            0.459958,
            0.473375,
            0.533375,
            0.470042,
            0.38675,
            0.187459,
            0.175958,
            0.176375,
            0.183375,
            0.193416
          ]
        }
      }
    },
    {
      "rows": 32,
      "width": 896,
      "dtype": "float32",
      "max_abs_error": 0.0,
      "timings": {
        "eager": {
          "median_ms": 0.222667,
          "samples_ms": [
            0.254083,
            0.229667,
            0.22275,
            0.194417,
            0.196792,
            0.204542,
            0.21225,
            0.204917,
            0.198125,
            0.216666,
            0.216125,
            0.470042,
            0.483125,
            0.5025,
            0.475292,
            0.515,
            0.21125,
            0.225208,
            0.222583,
            0.223292
          ]
        },
        "compiled": {
          "median_ms": 0.192312,
          "samples_ms": [
            0.194792,
            0.200791,
            0.196542,
            0.181,
            0.189917,
            0.192041,
            0.200792,
            0.20125,
            0.195417,
            0.192083,
            0.187625,
            0.178333,
            0.178083,
            0.179417,
            0.183041,
            0.196666,
            0.192083,
            0.192541,
            0.212208,
            0.23525
          ]
        }
      }
    },
    {
      "rows": 32,
      "width": 1003,
      "dtype": "float32",
      "max_abs_error": 0.0,
      "timings": {
        "eager": {
          "median_ms": 0.228791,
          "samples_ms": [
            0.269583,
            0.224083,
            0.249292,
            0.234417,
            0.23475,
            0.229583,
            0.2335,
            0.231584,
            0.232292,
            0.226375,
            0.227375,
            0.224875,
            0.222291,
            0.216875,
            0.228,
            0.222334,
            0.235542,
            0.223666,
            0.230167,
            0.223459
          ]
        },
        "compiled": {
          "median_ms": 0.196583,
          "samples_ms": [
            0.196291,
            0.208083,
            0.211208,
            0.214792,
            0.203625,
            0.191208,
            0.196875,
            0.181042,
            0.190583,
            0.187208,
            0.190167,
            0.192334,
            0.194334,
            0.192125,
            0.246042,
            0.191875,
            0.231625,
            0.269167,
            0.23475,
            0.208583
          ]
        }
      }
    }
  ],
  "limits": "Expression compilation may fuse operations. No kernel trace or measured HBM byte count; no whole-model, CUDA or Triton speedup claim."
}
Saved output report for compare eager and compiled normalization on real tensors; execution mode and values come from the linked raw result.

This image is an application-generated report of the saved program result, not a new inference run. Open the full-resolution report or read the raw result.

Compare numerical error and timing

Fusion can reduce dispatch and data movement but increase register pressure or compilation overhead. Small and large shapes can behave differently. A reduction in modeled tensor accesses is not an equal reduction in model latency; normalization may be a small fraction of the critical path.

Numerical parity exceeds tolerance. A compiled result outside the numerical tolerance is rejected before its timings are compared.

Invalid timing samples. Missing, nonfinite, or nonpositive execution times are rejected. The analyzer allows equal timings; it never assumes compilation must win.

Read the executable checks
Executable checksSource · pythonDownload
import unittest
from lab import compare_case


class FusionEvidenceTests(unittest.TestCase):
    def case(self):
        return {
            "rows": 1,
            "width": 3,
            "max_abs_error": 0,
            "timings": {
                "eager": {"samples_ms": [1, 3, 2]},
                "compiled": {"samples_ms": [2, 2, 2]},
            },
        }

    def test_parity_is_required_before_timing(self):
        case = self.case()
        case["max_abs_error"] = 0.1
        with self.assertRaises(ValueError):
            compare_case(case)

    def test_invalid_execution_timing_is_rejected(self):
        case = self.case()
        case["timings"]["compiled"]["samples_ms"] = [-1]
        with self.assertRaises(ValueError):
            compare_case(case)

    def test_medians_are_compared_without_a_speedup_promise(self):
        result = compare_case(self.case())
        self.assertEqual(result["eager_median_ms"], 2)
        self.assertEqual(result["compiled_median_ms"], 2)


if __name__ == "__main__":
    unittest.main()

Run this optional command only for the actual local endpoint or backend shown. Model paths refer to the first lesson’s explicit download; the server binds to loopback.

Run the live implementationTerminal · bash
python3 -m venv .venv
.venv/bin/python -m pip install mlx==0.32.4
.venv/bin/python fusion_live.py --output new-fusion-run.json

Try a change and explain the result

Compare eager and compiled timing distributions for each shape. Check numerical error before choosing the faster path. Explain why neither a fixed speedup nor an exact HBM-access ratio follows from these results.

A compiled normalization expression is faster for one tested shape. What can you claim?

Choose one answer, then check your reasoning.

Read the explanation

Retain the shape, dtype, repetitions, backend, and error check. Whole-model impact requires a separate measurement.

Decide whether fusion belongs on the critical path

Prefer runtime-provided kernels before maintaining custom device code. Benchmark warm steady state separately from compilation, and test dtype, shape, and numerical tolerances. A CUDA/Triton port requires its own hardware execution and bounds tests.

References

Sources checked October 24, 2026. Runtime-specific commands and results apply to the recorded versions; linked rolling documentation can change. The application scenarios are teaching examples, not claims about an author’s production incident.

Written by Arjun Prabhulal.

Module 5

Debug slow GPUs and tail latency