8.2.1. Example: timm Model Inference
timm からモデルを取得し、 Image (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)
スクリプト
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 "$@"
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)