8.2.1. Example: timm Model Inference

timm からモデルを取得し、 Image (beignets-task-guide.png) を対象に推論を行う応用例

beignets-task-guide.png

図 8.2 beignets-task-guide.png

実行方法 (resnet50.a1h_in1k)

$ cd /opt/pfn/pfcomp/codegen/MLSDK/examples/
$ ./run_timm.sh --model_name resnet50.a1h_in1k --batch_size 16

想定出力 (resnet50.a1h_in1k)

MNCore2 top-5 classes:
- espresso (967)
- cup (968)
- chocolate sauce, chocolate syrup (960)
- consomme (925)
- eggnog (969)
Torch top-5 classes:
- espresso (967)
- cup (968)
- chocolate sauce, chocolate syrup (960)
- eggnog (969)
- consomme (925)

実行方法 (mobilenetv3_small_050.lamb_in1k)

$ cd /opt/pfn/pfcomp/codegen/MLSDK/examples/
$ ./run_timm.sh --model_name mobilenetv3_small_050.lamb_in1k --batch_size 16

想定出力 (mobilenetv3_small_050.lamb_in1k)

MNCore2 top-5 classes:
- cup (968)
- trifle (927)
- face powder (551)
- ice cream, icecream (928)
- coffee mug (504)
Torch top-5 classes:
- cup (968)
- trifle (927)
- ice cream, icecream (928)
- face powder (551)
- coffee mug (504)

スクリプト

リスト 8.12 /opt/pfn/pfcomp/codegen/MLSDK/examples/run_timm.sh
 1#! /bin/bash
 2set -eux -o pipefail
 3
 4VENVDIR=/tmp/run_timm_venv
 5CURRENT_DIR=$(realpath $(dirname $0))
 6CODEGEN_DIR=$(realpath ${CURRENT_DIR}/../../)
 7BUILD_DIR=${BUILD_DIR:-${CODEGEN_DIR}/build}
 8
 9if [[ ! -d ${VENVDIR} ]]; then
10    python3 -m venv --system-site-packages ${VENVDIR}
11    source ${VENVDIR}/bin/activate
16    pip3 install timm==1.0.14 huggingface-hub==0.28.1
17else
18    source ${VENVDIR}/bin/activate
19fi
20
21source "${BUILD_DIR}/codegen_pythonpath.sh"
22
23# Set Hugging Face cache directory to avoid filling up the home directory
24HF_HOME=${HF_HOME:-"/tmp/huggingface"} \
25    exec python3 ${CURRENT_DIR}/run_timm.py "$@"
リスト 8.13 /opt/pfn/pfcomp/codegen/MLSDK/examples/run_timm.py
  1import argparse
  2import json
  3import logging
  4import os
  5import sys
  6import time
  7from pathlib import Path
  8from typing import Any, Callable, Optional, Union
  9
 10import timm
 11import torch
 12from mlsdk import (
 13    Context,
 14    MNCoreSGD,
 15    MNDevice,
 16    set_buffer_name_in_optimizer,
 17    set_tensor_name_in_module,
 18    storage,
 19)
 20from PIL import Image
 21
 22logger = logging.getLogger(__name__)
 23SAMPLE_IMAGE_PATH = os.path.join(
 24    os.path.dirname(__file__), "./datasets/mncore2_chip.png"
 25)
 26ACTION_CHOICES = ["compile", "run", "validate"]
 27CUSTOM_EXIT_CODES = {
 28    "error": 1,
 29    "unexpected_error": -1,
 30    # Unknown since the test is skipped.
 31    # e.g. compile success and we want to skip the run/validate to save time.
 32    "unknown": -2,
 33}
 34
 35
 36def escape_path(path: str) -> str:
 37    escaped = ""
 38    for c in path:
 39        if c.isalnum() or c in "_-":
 40            escaped += c
 41        else:
 42            escaped += "_"
 43    return escaped
 44
 45
 46def create_model_with_cache(
 47    model_name: str, model_cache_dir: Optional[str] = None, **kwargs: Any
 48) -> Any:
 49    if not model_cache_dir:
 50        return timm.create_model(model_name, **kwargs)
 51    else:
 52        timm_version = "timm_version" + timm.__version__
 53        torch_version = "torch_version" + torch.__version__
 54        cache_dir = os.path.join(
 55            model_cache_dir,
 56            escape_path(f"{torch_version}_{timm_version}_{model_name}"),
 57        )
 58        # Load the model always from the cache to return the same model object always.
 59        # This should also create the cache if it does not exist.
 60        return timm.create_model(model_name, **kwargs, cache_dir=cache_dir)
 61
 62
 63def imagenet_classes() -> list[str]:
 64    script_dir = os.path.dirname(__file__)
 65    imagenet_classes_path = os.path.join(script_dir, "imagenet_classes.txt")
 66    with open(imagenet_classes_path) as f:
 67        return [line.strip() for line in f]
 68
 69
 70def run_inference(
 71    args: argparse.Namespace,
 72) -> None:
 73    img = Image.open(SAMPLE_IMAGE_PATH)
 74    try:
 75        model = create_model_with_cache(
 76            args.model_name,
 77            pretrained=True,
 78            model_cache_dir=args.model_cache_dir,
 79        )
 80    except RuntimeError as e:
 81        print(f"Failed to load pretrained weights for the model: {e}")
 82        print("Falling back to creating the model without pretrained weights.")
 83        model = create_model_with_cache(
 84            args.model_name,
 85            pretrained=False,
 86            model_cache_dir=args.model_cache_dir,
 87        )
 88    model = model.eval()
 89
 90    def infer(input: dict[str, torch.Tensor]) -> dict[str, torch.Tensor]:
 91        with torch.no_grad():
 92            x = input["images"]
 93            return {"out": model(x)}
 94
 95    data_config = timm.data.resolve_model_data_config(model)
 96    transforms = timm.data.create_transform(**data_config, is_training=False)
 97    images = transforms(img).unsqueeze(0).expand(args.batch_size, -1, -1, -1)
 98    sample = {"images": images}
 99
100    device = MNDevice(args.device)
101    context = Context(device)
102    Context.switch_context(context)
103    context.registry.register("model", model)
104
105    compile_options: dict[str, str] = {}
106    if args.option_json is not None:
107        compile_options = {"option_json": str(args.option_json)}
108
109    compiled_infer = context.compile(
110        infer,
111        sample,
112        storage.path(args.outdir) / "infer",
113        options=compile_options,
114    )
115
116    if args.action == "compile":
117        context.synchronize()
118        return
119
120    result_as_proxy = compiled_infer(sample)
121
122    if args.action == "run":
123        context.synchronize()
124        return
125
126    result_on_torch = infer(sample)
127
128    # Tensors obtained via ".cpu()" from TensorProxy exist on GPU in CUDA environments,
129    # so they need to be moved to CPU before the comparison.
130    result = result_as_proxy["out"].cpu()
131    if result.is_cuda:
132        result = result.cpu()
133
134    context.synchronize()
135    torch.allclose(result, result_on_torch["out"], atol=1e-5)
136
137    if "in1k" in args.model_name:
138        classes = imagenet_classes()
139        device_top5_classes = torch.topk(result[0], 5).indices.cpu()
140        logger.info("Device top-5 classes:")
141        for i in device_top5_classes:
142            logger.info(f"- {classes[i]} ({i.item()})")
143        torch_top5_classes = torch.topk(result_on_torch["out"][0], 5).indices
144        logger.info("Torch top-5 classes:")
145        for i in torch_top5_classes:
146            logger.info(f"- {classes[i]} ({i.item()})")
147
148
149# return mncore.runtime_core._context._function.CompiledFunction
150# but this is not directly exposed in the public API, so we use Any here.
151def compile_train_step_with_torch_onnx(
152    model: Any,
153    sample: dict[str, Any],
154    context: Context,
155    outdir: str,
156    option_json: str | None = None,
157) -> Any:
158    model = model.train()
159    context.registry.register("model0", model)
160    optimizer = torch.optim.SGD(model.parameters(), lr=0.1, momentum=0.9)
161    context.registry.register("optimizer0", optimizer)
162    loss_fn = torch.nn.CrossEntropyLoss()
163
164    def f(inputs: dict[str, torch.Tensor]) -> dict[str, torch.Tensor]:
165        return {"loss": loss_fn(model(inputs["images"]), inputs["labels"])}
166
167    compile_options: dict[str, Union[str, bool]] = {"backprop": True}
168    if option_json is not None:
169        compile_options["option_json"] = str(option_json)
170
171    compiled_train_step = context.compile(
172        f,
173        sample,
174        storage.path(outdir) / "train_step_torch_onnx",
175        optimizers=[optimizer],
176        options=compile_options,
177    )
178
179    def wrapped(inputs: dict[str, Any]) -> Any:
180        inputs["optimizer0@0@mncore_learning_rate"] = torch.tensor(0.1)
181        inputs["optimizer0@0@mncore_global_step"] = torch.tensor(wrapped.global_step)  # type: ignore
182        inputs["mncore_grad_scale_factor"] = torch.tensor(1)
183        wrapped.global_step += 1  # type: ignore
184        return compiled_train_step(inputs)
185
186    wrapped.global_step = 0  # type: ignore
187
188    return wrapped
189
190
191# return mncore.runtime_core._context._function.CompiledFunction
192# but this is not directly exposed in the public API, so we use Any here.
193def compile_train_step_with_fx2onnx(
194    model: Any,
195    sample: dict[str, Any],
196    context: Context,
197    outdir: str,
198    option_json: str | None = None,
199) -> Any:
200    model = model.train()
201    set_tensor_name_in_module(model, "model0")
202    for p in model.parameters():
203        context.register_param(p)
204    optimizer = MNCoreSGD(model.parameters(), 0.1, 0.9, 0.0)
205    set_buffer_name_in_optimizer(optimizer, "optimizer0")
206    context.register_optimizer_buffers(optimizer)
207    loss_fn = torch.nn.CrossEntropyLoss()
208
209    def train_step(input: dict[str, torch.Tensor]) -> dict[str, torch.Tensor]:
210        x = input["images"]
211        t = input["labels"]
212        optimizer.zero_grad()
213        y = model(x)
214        loss = loss_fn(y, t)
215        loss.backward()
216        optimizer.step()
217        return {"loss": loss}
218
219    compile_options: dict[str, Union[str, bool]] = {}
220    if option_json is not None:
221        compile_options["option_json"] = str(option_json)
222
223    return context.compile(
224        train_step,
225        sample,
226        storage.path(outdir) / "train_step_fx2onnx",
227        options=compile_options,
228        export_kwargs={"use_fx2onnx": True},
229    )
230
231
232class StepError(Exception):
233    """Raised by a pipeline step to report a specific exit code on failure
234    instead of the default ``error`` code."""
235
236    def __init__(self, exit_code: int) -> None:
237        super().__init__(f"step failed with exit code {exit_code}")
238        self.exit_code = exit_code
239
240
241class Pipeline:
242    """Runs the ``compile`` → ``run`` → ``validate`` steps in order.
243
244    The shared driver (:meth:`execute`) times each step independently, stops
245    early when ``args.action`` only asks for an earlier step, and cascades a
246    failure to every step that can no longer run. Subclasses implement the
247    three steps; a step signals failure by raising, and may raise
248    :class:`StepError` to report a non-default exit code.
249    """
250
251    def __init__(self, args: argparse.Namespace) -> None:
252        self.args = args
253        self.context: Optional[Context] = None
254        self.sample: dict[str, Any] = {}
255
256    def compile(self) -> None:
257        raise NotImplementedError
258
259    def run(self) -> None:
260        raise NotImplementedError
261
262    def validate(self) -> None:
263        raise NotImplementedError
264
265    def execute(self) -> dict[str, dict[str, Any]]:
266        # Steps not reached (skipped via --action, or never run because an
267        # earlier step failed) keep the default "unknown"/zero-duration entry.
268        results: dict[str, dict[str, Any]] = {
269            action: {"exit_code": CUSTOM_EXIT_CODES["unknown"], "duration_s": 0.0}
270            for action in ACTION_CHOICES
271        }
272        steps: list[tuple[str, Callable[[], None]]] = [
273            ("compile", self.compile),
274            ("run", self.run),
275            ("validate", self.validate),
276        ]
277
278        for index, (name, step) in enumerate(steps):
279            step_start = time.perf_counter()
280            try:
281                step()
282            except Exception as e:
283                exit_code = (
284                    e.exit_code
285                    if isinstance(e, StepError)
286                    else CUSTOM_EXIT_CODES["error"]
287                )
288                results[name] = {
289                    "exit_code": exit_code,
290                    "duration_s": time.perf_counter() - step_start,
291                }
292                logger.error(f"Error during {name}: {e}")
293                # Downstream steps cannot proceed once a step fails.
294                for downstream, _ in steps[index + 1 :]:
295                    results[downstream]["exit_code"] = CUSTOM_EXIT_CODES["error"]
296                break
297
298            results[name] = {
299                "exit_code": 0,
300                "duration_s": time.perf_counter() - step_start,
301            }
302            # Stop once we have completed the step the caller asked for.
303            if self.args.action == name:
304                break
305
306        return results
307
308
309class InferencePipeline(Pipeline):
310    def compile(self) -> None:
311        args = self.args
312        img = Image.open(SAMPLE_IMAGE_PATH)
313        try:
314            model = create_model_with_cache(
315                args.model_name,
316                pretrained=True,
317                model_cache_dir=args.model_cache_dir,
318            ).eval()
319        except RuntimeError as e:
320            logger.warning(f"Failed to load pretrained weights for the model: {e}")
321            logger.warning(
322                "Falling back to creating the model without pretrained weights."
323            )
324            model = create_model_with_cache(
325                args.model_name,
326                pretrained=False,
327                model_cache_dir=args.model_cache_dir,
328            ).eval()
329
330        def infer(input: dict[str, torch.Tensor]) -> dict[str, torch.Tensor]:
331            with torch.no_grad():
332                return {"out": model(input["images"])}
333
334        self.infer = infer
335
336        data_config = timm.data.resolve_model_data_config(model)
337        transforms = timm.data.create_transform(**data_config, is_training=False)
338        self.sample = {
339            "images": transforms(img).unsqueeze(0).expand(args.batch_size, -1, -1, -1)
340        }
341
342        self.context = Context(MNDevice(args.device))
343        Context.switch_context(self.context)
344        self.context.registry.register("model", model)
345
346        compile_options: dict[str, str] = {}
347        if args.option_json is not None:
348            compile_options = {"option_json": str(args.option_json)}
349
350        self.compiled_infer = self.context.compile(
351            infer,
352            self.sample,
353            storage.path(args.outdir) / "infer",
354            options=compile_options,
355        )
356        self.context.synchronize()
357
358    def run(self) -> None:
359        assert self.context is not None
360        self.result_as_proxy = self.compiled_infer(self.sample)
361        self.context.synchronize()
362
363    def validate(self) -> None:
364        assert self.context is not None
365        try:
366            result_on_torch = self.infer(self.sample)
367        except Exception as e:
368            # A failure of the PyTorch reference path is unexpected rather than
369            # a genuine output mismatch, so report it with a distinct code.
370            raise StepError(CUSTOM_EXIT_CODES["unexpected_error"]) from e
371
372        # Obtain torch.Tensor from TensorProxy
373        result = self.result_as_proxy["out"].cpu()
374
375        # In case of CUDA, result is a GPU tensor, so we need to move it to CPU
376        # before the comparison. Note that this is not necessary for MN-Core
377        # backend since TensorProxy will return a CPU tensor.
378        if result.is_cuda:
379            result = result.cpu()
380
381        torch.allclose(result, result_on_torch["out"], atol=1e-5)
382        self._log_top5_classes(result, result_on_torch)
383
384        # Safety synchronize after validation to prevent potential side effects
385        # to subsequent steps in case of a failure in the validation logic above.
386        self.context.synchronize()
387
388    def _log_top5_classes(self, result: Any, result_on_torch: dict[str, Any]) -> None:
389        # Best-effort diagnostics; failures here must not fail validation.
390        try:
391            if "in1k" in self.args.model_name:
392                classes = imagenet_classes()
393                logger.info("MNCore2 top-5 classes:")
394                for i in torch.topk(result[0], 5).indices.cpu():
395                    logger.info(f"- {classes[i]} ({i.item()})")
396                logger.info("Torch top-5 classes:")
397                for i in torch.topk(result_on_torch["out"][0], 5).indices:
398                    logger.info(f"- {classes[i]} ({i.item()})")
399        except Exception as e:
400            logger.error(f"Error during post-validation processing: {e}")
401
402
403class TrainingPipeline(Pipeline):
404    def compile(self) -> None:
405        args = self.args
406        self.context = Context(MNDevice(args.device))
407        Context.switch_context(self.context)
408
409        img = Image.open(SAMPLE_IMAGE_PATH)
410        try:
411            model = create_model_with_cache(
412                args.model_name,
413                pretrained=True,
414                num_classes=1000,
415                model_cache_dir=args.model_cache_dir,
416            )
417        except RuntimeError as e:
418            logger.warning(f"Failed to load pretrained weights for the model: {e}")
419            logger.warning(
420                "Falling back to creating the model without pretrained weights."
421            )
422            model = create_model_with_cache(
423                args.model_name,
424                pretrained=False,
425                num_classes=1000,
426                model_cache_dir=args.model_cache_dir,
427            )
428        data_config = timm.data.resolve_model_data_config(model)
429        transforms = timm.data.create_transform(**data_config, is_training=False)
430        images = transforms(img).unsqueeze(0).expand(args.batch_size, -1, -1, -1)
431        labels = torch.randint(0, 1000, (args.batch_size,))
432        self.sample = {"images": images, "labels": labels}
433
434        # TODO (akirakawata): Should we make this argument?
435        use_fx2onnx = not bool(
436            int(os.environ.get("MNCORE_USE_LEGACY_ONNX_EXPORTER", False))
437        )
438        if use_fx2onnx:
439            # NOTE (puchupala): fx2onnx training needs the optimizer in the
440            # exported graph and lr, step, and grad scale factor in the inputs,
441            # so it follows a separate code path.
442            self.compiled_train_step = compile_train_step_with_fx2onnx(
443                model,
444                self.sample,
445                self.context,
446                args.outdir,
447                option_json=args.option_json,
448            )
449        else:
450            self.compiled_train_step = compile_train_step_with_torch_onnx(
451                model,
452                self.sample,
453                self.context,
454                args.outdir,
455                option_json=args.option_json,
456            )
457        self.context.synchronize()
458
459    def run(self) -> None:
460        assert self.context is not None
461        self.first_loss = self.compiled_train_step(self.sample)["loss"].cpu()
462        self.context.synchronize()
463
464    def validate(self) -> None:
465        # Heuristically check that the loss decreases after a few iterations to
466        # validate that training is working. This is not a perfect validation,
467        # but it's a simple check that the training loop is doing something
468        # reasonable. If subsequent iterations somehow fail, it is treated as a
469        # validation failure for simplicity.
470        assert self.context is not None
471        for _ in range(self.args.num_iters - 2):
472            self.compiled_train_step(self.sample)
473        last_loss = self.compiled_train_step(self.sample)["loss"].cpu()
474        self.context.synchronize()
475        assert last_loss < self.first_loss
476
477
478def safe_run(pipeline: Pipeline) -> dict[str, dict[str, Any]]:
479    # Backstop for unexpected errors raised outside of the per-step handling.
480    start_s = time.perf_counter()
481    try:
482        return pipeline.execute()
483    except Exception as e:
484        duration_s = time.perf_counter() - start_s
485        logger.error(f"Unexpected error during {type(pipeline).__name__}: {e}")
486        return {
487            action: {
488                "exit_code": CUSTOM_EXIT_CODES["unexpected_error"],
489                "duration_s": duration_s,
490            }
491            for action in ACTION_CHOICES
492        }
493
494
495if __name__ == "__main__":
496    parser = argparse.ArgumentParser(
497        description=(
498            "Compile and execute timm vision models on MN-Core2. "
499            "Executes three pipeline phases: compile (generate device code), "
500            "run (execute on device), and validate (compare against PyTorch or "
501            "verify loss reduction). "
502            "Supports both inference and training modes."
503        ),
504        formatter_class=argparse.RawDescriptionHelpFormatter,
505        epilog=(
506            "OUTPUT FORMAT:\n"
507            "The script outputs JSON with the following structure:\n"
508            "  {\n"
509            '    "compile": {"exit_code": int, "duration_s": float},\n'
510            '    "run": {"exit_code": int, "duration_s": float},\n'
511            '    "validate": {"exit_code": int, "duration_s": float}\n'
512            "  }\n\n"
513            "EXIT CODES:\n"
514            "  0: Phase completed successfully\n"
515            "  1: Phase failed with a recoverable error\n"
516            " -1: Phase failed with an unexpected error (e.g., PyTorch reference failed)\n"
517            " -2: Phase status unknown (action stopped at an earlier phase)\n\n"
518            "EXAMPLES:\n"
519            "  # Compile, run, and validate resnet18 on auto-detected MN-Core2 device\n"
520            "  %(prog)s --model_name resnet18 --action validate\n\n"
521            "  # Only compile efficientnet_b0 with custom output directory\n"
522            "  %(prog)s --model_name efficientnet_b0 --outdir ./out --action compile\n\n"
523            "  # Train with batch size 32 for 20 iterations\n"
524            "  %(prog)s --mode train --model_name resnet18 --batch_size 32 --num_iters 20"
525        ),
526    )
527    parser.add_argument("--batch_size", type=int, default=1)
528    parser.add_argument("--model_name", type=str, required=True)
529    parser.add_argument("--outdir", type=str, default="/tmp/mlsdk_timm")
530    parser.add_argument("--option_json", type=Path, default=None)
531    parser.add_argument("--mode", type=str, default="infer", choices=["infer", "train"])
532    parser.add_argument(
533        "--device",
534        type=str,
535        default="mncore2:auto",
536        choices=[
537            "mncore2:auto",
538            "mncore2:0",
539            "mncore2:1",
540            "mncore2:2",
541            "mncore2:3",
542            "mncore2:4",
543            "mncore2:5",
544            "mncore2:6",
545            "mncore2:7",
546            "pfvm:cpu",
547            "pfvm:cuda",
548        ],
549    )
550    parser.add_argument(
551        "--model_cache_dir",
552        type=str,
553        default=None,
554        help="Directory to cache the model weights. "
555        "If not set, weights are always downloaded from the hub. default: None",
556    )
557    parser.add_argument(
558        "--action",
559        type=str,
560        default="validate",
561        choices=ACTION_CHOICES,
562        help="Whether to only compile, run without validation, "
563        "or run with validation (default: validate)",
564    )
565    parser.add_argument(
566        "--log_level",
567        type=str,
568        default="INFO",
569        choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
570        help="Logging level (default: INFO)",
571    )
572
573    train_group = parser.add_argument_group(
574        "Training options", "Options for training mode (ignored in inference mode)"
575    )
576    train_group.add_argument(
577        "--num_iters",
578        type=int,
579        default=12,
580        help="Number of training iterations to run (default: 12)",
581    )
582
583    args = parser.parse_args()
584    logging.basicConfig(level=getattr(logging, args.log_level))
585
586    # Simple args validation
587    assert args.batch_size > 0, "Batch size must be positive"
588    assert (
589        args.num_iters >= 2
590    ), "Number of iterations must be at least 2 to observe loss decrease"
591    if args.option_json is not None:
592        assert (
593            args.option_json.is_file()
594        ), f"Option JSON file not found: {args.option_json}"
595
596    pipelines: dict[str, type[Pipeline]] = {
597        "infer": InferencePipeline,
598        "train": TrainingPipeline,
599    }
600    if args.mode not in pipelines:
601        raise ValueError(f"Unsupported mode: {args.mode}")
602    if os.path.exists(args.outdir):
603        logger.warning(
604            f"Output directory {args.outdir} already exists. "
605            "It may cause issues with the compilation."
606        )
607    result = safe_run(pipelines[args.mode](args))
608
609    print(json.dumps(result))
610    # If any of the actions resulted in an error (non-zero and not intentionally
611    # skipped), exit with code 1 to indicate failure.
612    for action_result in result.values():
613        if action_result["exit_code"] not in (0, CUSTOM_EXIT_CODES["unknown"]):
614            sys.exit(1)