본문 바로가기
C.W.K.
Stream
Lesson 05 of 06 · published

Core ML 경로와 뉴럴 엔진

~16 min · journey, core-ml, neural-engine, compute-units, bandwidth-bound, measured

Level 0스펙 시트 훑는 사람
0 XP0/91 lessons0/19 achievements
0/100 XP to next level100 XP to go0% complete
"뉴럴 엔진은 풀로 가는 문이 좁은 연산 엔진이야. 그림을 주면 빛나. 언어 모델의 디코드를 주면 버스의 2퍼센트로 가중치를 읽어."

세 번째 문, 그리고 네 번째 유닛

Core ML은 애플 자체의 추론 경로야. 모델을 한 번 컴파일해서 운영체제가 CPU, GPU, 뉴럴 엔진에 걸쳐 스케줄하는 형태로 만들고, 호출자는 compute units 설정을 고르고, 프레임워크는 연산 하나하나가 어디서 돌지 골라. 이 트랙에서 뉴럴 엔진에 아예 닿을 수 있는 유일한 경로야. CPU 트랙에서 ioreg가 나열했고 퀘스트의 다른 어떤 것도 건드리지 않은 그 고정 기능 유닛. 이 집의 유일한 Core ML 사용자는 음성 형제야. WhisperKit을 통해서, Whisper 인코더와 디코더를 뉴럴 엔진이 끼어드는 Core ML 모델로 돌려. 가족 안의 어느 것도 Core ML을 직접 부르지 않고, 어느 것도 애플의 Foundation Models 프레임워크를 쓰지 않아. 그래서 이 레슨은 유닛 자체를 재. Core ML의 중간 언어로 바로 만든 모델로, 그리고 플래너에게 뭘 결정했는지 물어.

일의 모양 둘, 설정 넷

office에서 모델 둘. 컨브 스택, 224픽셀 이미지에 3×3 컨볼루션 여덟 개, 104 GFLOP은 뉴럴 엔진의 본거지 모양이야. 537 MB 행렬에 fp16 행렬-벡터 곱 하나는 언어 모델의 디코드 단계를 축소한 거야. 모든 가중치를 한 번 읽고, 거의 아무것도 안 해. 각각을 네 설정 전부로, 워밍업 뒤 스무 번 돌렸고, 컴퓨트 플랜이 연산마다 프레임워크가 선호한 유닛을 보고했어.

모델, office, coremltools 8.1CPU만CPU+GPUCPU+NEALL(플래너의 선택)증거
컨브 스택, 104 GFLOP12.9 ms (8.0 TFLOP/s)4.65 ms (22.3) — GPU6.15 ms (17.0)6.12 ms — 뉴럴 엔진, 17개 연산 중 17실측
행렬-벡터 곱, 537 MB fp167.6–14.9 ms (36–71 GB/s)1.71 ms (314 GB/s) — GPU30.2 ms (17.8 GB/s)30.7 ms — 뉴럴 엔진실측
행렬-벡터 곱, 134 MB fp162.15 ms2.18 ms — CPU2.56 ms2.10 ms — 뉴럴 엔진실측
행렬-벡터 곱, 34 MB fp160.65 ms0.66 ms — CPU0.62 ms0.63 ms — CPU실측

컨브 행부터 읽어. 뉴럴 엔진은 이미지를 17 TFLOP/s로 돌려. 80코어 GPU의 4분의 3 넘게, 크기는 그 몇 분의 일인 유닛에서. CPU 트랙의 고정 기능 논증을 숫자로 한 거야. 이제 537 MB 행. 같은 유닛이 언어 모델의 가중치를 17.8 GB/s로 읽어. 같은 바이트에 GPU보다 열여덟 배 느리고, 풀의 819의 2퍼센트. 그리고 기본 설정에서 플래너는 그래도 그걸 골랐고, 로드할 때마다 거기 맞춰 컴파일하느라 24초를 썼어. 애플은 2022년에 이걸 적어 뒀어. 트랜스포머를 그 유닛에 배포하는 글에서. "시퀀스 길이가 비교적 짧을 때 많은 트랜스포머 구성이 ANE에서 대역폭에 묶인다 … 큰 파라미터 텐서를 메모리에서 가져와서 너무 적은 입력에만 적용하기 때문." 유닛 쪽에서 본 디코드 상한이야.

애플은 뭘 어디서 돌리나

애플 자체의 답 둘이 표와 일치해. 맥의 8B 모델엔 Core ML 글이 조용한 부분을 대놓고 말해. "Llama-3.1-8B-Instruct 같은 모델은 보통 메모리 대역폭에 묶이고, GPU가 이 장치에서 연산 FLOPS와 메모리 대역폭의 최선의 조합을 주기 때문에 GPU를 특정해서 겨냥한다." 그리고 M1 Max에서 4비트 빌드에 초당 33토큰쯤을 보고해. 400 GB/s에 4.2 GB면 그 칩 상한의 35%고, 실험의 MLX 사다리가 9B와 27B로 모든 맥에서 넘긴 비율이야. 30억 파라미터쯤의 온디바이스 파운데이션 모델엔 애플이 "우리 뉴럴 엔진에서의 효율적인 KV 캐시 갱신"을 서술하고, iPhone 15 Pro에서 "토큰 추측 기법을 쓰기 전에" 초당 30토큰. 좁은 문에 맞춰 크기를 잡고 배치한 모델, 버스보다 문의 에너지 비용이 더 중요한 장치에서. 큰 모델은 GPU로, 작은 것 하나는 뉴럴 엔진으로, 그 위에 추측. 벤더의 분할이 물리 트랙의 분할과 맞고, Ollama 레슨의 요령이 애플의 문장에도 나타나.

Code

coreml_units.py — 모든 컴퓨트 유닛에 일의 모양 둘, 그리고 플래너가 고른 것·python
#!/usr/bin/env python3
"""Build two Core ML models straight from MIL (no PyTorch needed), then time each
on every compute-unit setting and ask the compute plan which unit ran each op.
Model A: a decode-shaped matvec over a large fp16 matrix (bandwidth-bound).
Model B: a conv stack on an image (the Neural Engine's home turf)."""
import time, os, sys
import numpy as np
import coremltools as ct
from coremltools.converters.mil import Builder as mb
from coremltools.converters.mil.mil import types

OUT = os.path.expanduser("~/silicon-lab/coreml")
os.makedirs(OUT, exist_ok=True)
UNITS = {"CPU_ONLY": ct.ComputeUnit.CPU_ONLY, "CPU_AND_GPU": ct.ComputeUnit.CPU_AND_GPU,
         "CPU_AND_NE": ct.ComputeUnit.CPU_AND_NE, "ALL": ct.ComputeUnit.ALL}


def build_matvec(n: int):
    W = (np.random.randn(n, n) * 0.02).astype(np.float16)

    @mb.program(input_specs=[mb.TensorSpec(shape=(1, n), dtype=types.fp16)], opset_version=ct.target.iOS17)
    def prog(x):
        return mb.matmul(x=x, y=W, name="y")
    return ct.convert(prog, convert_to="mlprogram", minimum_deployment_target=ct.target.macOS14,
                      compute_precision=ct.precision.FLOAT16), {"x": np.random.randn(1, n).astype(np.float16)}, W.nbytes


def build_conv(c: int = 128, layers: int = 8, hw: int = 224):
    Ws = [(np.random.randn(c, c if i else 3, 3, 3) * 0.05).astype(np.float16) for i in range(layers)]

    @mb.program(input_specs=[mb.TensorSpec(shape=(1, 3, hw, hw), dtype=types.fp16)], opset_version=ct.target.iOS17)
    def prog(x):
        h = x
        for i, W in enumerate(Ws):
            h = mb.conv(x=h, weight=W, pad_type="same", name=f"conv{i}")
            h = mb.relu(x=h, name=f"relu{i}")
        return mb.reduce_mean(x=h, axes=[2, 3], name="y")
    flops = sum(2 * hw * hw * c * (c if i else 3) * 9 for i in range(layers))
    return ct.convert(prog, convert_to="mlprogram", minimum_deployment_target=ct.target.macOS14,
                      compute_precision=ct.precision.FLOAT16), {"x": np.random.randn(1, 3, hw, hw).astype(np.float16)}, flops


def plan_devices(path, unit):
    keep = ct.models.MLModel(path, compute_units=unit)          # the plan wants the .mlmodelc, which lives as long as this object
    plan = ct.models.compute_plan.MLComputePlan.load_from_path(path=keep.get_compiled_model_path(), compute_units=unit)
    used = {}
    for op in plan.model_structure.program.functions["main"].block.operations:
        du = plan.get_compute_device_usage_for_mlprogram_operation(op)
        if du is not None:
            k = type(du.preferred_compute_device).__name__.replace("ML", "").replace("ComputeDevice", "")
            used[k] = used.get(k, 0) + 1
    return used


def bench(name, model, inputs, reps=20):
    path = os.path.join(OUT, f"{name}.mlpackage")
    model.save(path)
    print(f"\n== {name}")
    for label, unit in UNITS.items():
        t = time.perf_counter(); m = ct.models.MLModel(path, compute_units=unit); load = time.perf_counter() - t
        for _ in range(3):
            m.predict(inputs)                                        # warm-up (the ANE compile happens here)
        t = time.perf_counter()
        for _ in range(reps):
            m.predict(inputs)
        print(f"{label:12} load {load:5.2f}s  predict {(time.perf_counter() - t) / reps * 1e3:8.2f} ms")
    print("planner, ALL:", plan_devices(path, ct.ComputeUnit.ALL), " CPU_AND_GPU:", plan_devices(path, ct.ComputeUnit.CPU_AND_GPU))


if __name__ == "__main__":
    which = sys.argv[1] if len(sys.argv) > 1 else "both"
    if which in ("matvec", "both"):
        for n in (4096, 8192, 16384):
            model, inputs, nbytes = build_matvec(n)
            print(f"\nmatvec {n}x{n} fp16 = {nbytes/1e6:.0f} MB of weights")
            bench(f"matvec{n}", model, inputs)
    if which in ("conv", "both"):
        model, inputs, flops = build_conv()
        print(f"\nconv stack: {flops/1e9:.1f} GFLOP per image")
        bench("conv8", model, inputs)

# office, M3 Ultra, coremltools 8.1, macOS 26.6.2, 2026-09-15 (predict ms; planner's preferred unit):
# matvec 16384^2 (537 MB): CPU_ONLY 8.04  CPU_AND_GPU 1.71  CPU_AND_NE 30.18  ALL 30.74   ALL -> NeuralEngine (load 24 s), CPU_AND_GPU -> GPU
# matvec  8192^2 (134 MB): CPU_ONLY 2.15  CPU_AND_GPU 2.18  CPU_AND_NE  2.56  ALL  2.10   ALL -> NeuralEngine,            CPU_AND_GPU -> CPU
# matvec  4096^2  (34 MB): CPU_ONLY 0.65  CPU_AND_GPU 0.66  CPU_AND_NE  0.62  ALL  0.63   ALL -> CPU,                     CPU_AND_GPU -> CPU
# conv8 (104 GFLOP):       CPU_ONLY 12.95 CPU_AND_GPU 4.65  CPU_AND_NE  6.15  ALL  6.12   ALL -> NeuralEngine x17,        CPU_AND_GPU -> GPU x17

External links

Exercise

네 맥에서 coreml_units.py를 돌려. 모델마다 카드에 네 칸짜리 행을 채워. ALL 플래너가 고른 유닛, 그리고 CPU만, CPU+GPU, CPU+NE의 밀리초. 그다음 537 MB 행렬-벡터 곱에서 뉴럴 엔진이 끌어온 대역폭을 계산해서 네 칩 스펙의 비율로 적어. 네 맥이 16 GB 미만이면 8192 크기를 쓰고 카드에 그렇게 적어.
Hint
GB/s = 바이트 ÷ 초. 행렬-벡터 곱은 predict마다 행렬 전체를 한 번 읽어. 뉴럴 엔진 숫자가 스펙의 몇 퍼센트인데 GPU는 30 이상이면 레슨을 재현한 거야. 네 맥에서 ALL이 GPU를 골랐으면 macOS 빌드를 적어 둬. 플래너의 규칙은 애플 것이고 바뀌어.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.