NPUでELYZA-japanese-Llama-2-7bを実行する！

## 背景

好奇心と物欲に負けて、最近Intel Core Ultra7のCPUを搭載したPCを購入しました。しかし、AIプロセッサー(NPU)など最新機能をいまだに体感できておりません。

なにかできないかと考えていた際、Intel社のOpenVINOを使えばIntelのCPU内蔵GPUで、ディープラーニングが可能であることを思い出しました。

そこで、LLM（大規模言語モデル）のような高い要求スペックのモデルをOpenVINOで動かすことで、Intel Core Ultra7の最新機能を体感できるのではないかと思い、調査と検証してみました。

## 検証環境

- 検証環境①
    - CPU：Intel® Core™ Ultra 7 155H
    - GPU：Intel® Arc™ graphics
    - NPU：Intel® AI Boost
    - 分析環境
        - Anaconda
            - JupyterLAB

## 用語解説

### OpenVINO

https://www.intel.com/content/www/us/en/developer/tools/openvino-toolkit/overview.html

### ELYZA-japanese-Llama-2-7b

https://note.com/elyza/n/na405acaca130

### NPU

NPUは、CPUやGPUと比較して、AI計算に特化したアーキテクチャを持っているため、特定のAIタスクに対しては非常に高い性能を発揮します。これにより、AIモデルの推論やトレーニングが効率的に行えるため、スマートフォンや自動運転車、IoTデバイスなど、さまざまな応用分野で利用されています。

## 検証概要

LLMをローカルPCのNPUで演算させることを目的であり、性能を検証するものではありません。
モデルは、ELYZA-japanese-Llama-2-7bを利用します。

今回の検証では、以下のライブラリを試しています。

1. OpenVINO
2. intel-npu-acceleration-library

なお、OpenVINO形式のモデルでLLMをNPU演算させるのはエラーの解消が困難と判断し、挫折しました。
その代替手段として、2の手段を実施しています。

### OpenVINO

以下、ドキュメントを参考に実装。ただし、下記の実装ではNPU演算がうまくいきません。
そのため、本項目は、CPUとGPUで演算させる実装となります。

https://docs.openvino.ai/2024/learn-openvino/llm_inference_guide/llm-inference-hf.html

#### モデル取得

ELYZA-japanese-Llama-2-7bをOpenVINO形式のモデルに変換して保存する。

```python
from optimum.intel import OVModelForCausalLM, OVWeightQuantizationConfig
from huggingface_hub import login
import nncf

# Hugging Face Hubにログイン
login("Hugging Faceから取得")

# モデルIDの設定
model_id = "elyza/ELYZA-japanese-Llama-2-7b-instruct"

# 量子化の設定を指定
quantization_config = OVWeightQuantizationConfig(
    bits=4,  # 4ビットに量子化する（GPUがメモリリークするため軽量化）
)

# モデルをHugging Faceから読み込み、OpenVINO形式に変換し、量子化設定を適用
model = OVModelForCausalLM.from_pretrained(
    model_id,  # 使用するモデルのID
    export=True,  # OpenVINO形式に変換するかどうか
    quantization_config=quantization_config  # 量子化設定を指定
)

# 保存先のパスを設定
optimized_model_path = "./optimized_model"

# 最適化後のモデルを指定したパスに保存
model.save_pretrained(optimized_model_path)
```

#### 推論

推論のコードは以下の実装となります。
なお、出力の性能を評価する目的ではないため、プロンプトは固定しています。

```python
from optimum.intel import OVModelForCausalLM, OVConfig
from huggingface_hub import login
from transformers import AutoTokenizer

class TextGenerator:
    def __init__(self, optimized_model_path, target_device="CPU"):
        # Hugging Face Hubにログイン
        login("Hugging Faceのマイページから取得")
        self.optimized_model_path = optimized_model_path
        self.target_device = target_device
        self.model, self.tokenizer = self.load_model_and_tokenizer()

    def load_model_and_tokenizer(self):
        # モデルの読み込みとデバイス設定
        config = OVConfig(device=self.target_device)
        model = OVModelForCausalLM.from_pretrained(self.optimized_model_path, config=config)
        model.to(self.target_device)
        
        # トークナイザーの読み込み
        model_name = "elyza/ELYZA-japanese-Llama-2-7b-instruct"
        tokenizer = AutoTokenizer.from_pretrained(model_name)
        return model, tokenizer

    def create_prompt(self):
        # インストラクションの開始と終了を示すトークン
        B_INST, E_INST = "
", "
"
        # システムプロンプトの開始と終了を示すトークン
        B_SYS, E_SYS = "
\n", "\n
\n\n"
        # デフォルトのシステムプロンプト
        DEFAULT_SYSTEM_PROMPT = "あなたは誠実で優秀な日本人のアシスタントです。"
        # ユーザーがアシスタントに指示する具体的なプロンプトの内容
        text = "クマが海辺に行ってアザラシと友達になり、最終的には家に帰るというプロットの短編小説を書いてください。"
        # トークンを組み合わせて最終的なプロンプトを生成
        prompt = "{bos_token}{b_inst} {system}{prompt} {e_inst} ".format(
            bos_token=self.tokenizer.bos_token,
            b_inst=B_INST,
            system=f"{B_SYS}{DEFAULT_SYSTEM_PROMPT}{E_SYS}",
            prompt=text,
            e_inst=E_INST,
        )
        return prompt

    def generate_text(self, prompt):

        token_ids = self.tokenizer.encode(prompt, add_special_tokens=False, return_tensors="pt")
        
        output_ids = self.model.generate(
            token_ids.to(self.model.device),
            max_new_tokens=256,
            pad_token_id=self.tokenizer.pad_token_id,
            eos_token_id=self.tokenizer.eos_token_id,
        )
        output = self.tokenizer.decode(output_ids.tolist()[0][token_ids.size(1) :], skip_special_tokens=True)

        # 生成されたテキストをデコードし、特殊トークンをスキップして出力
        return output
```

##### CPU結果

4bitに量子化したものの多少時間がかかるように思えます。

```python
text_generator = TextGenerator(optimized_model_path="./optimized_model", target_device="CPU")
```

```python
%%time
prompt = text_generator.create_prompt()
generated_text = text_generator.generate_text(prompt)
print(generated_text)

>>> 承知しました。以下にクマが海辺に行ってアザラシと友達になり、最終的には家に帰るというプロットの短編小説を記述します。
>>> 
>>> クマは家の外に出ると、海辺にやってきました。クマは海辺に来ると、アザラシがい ることが多いため、今回もアザラシがいるのではないかと思い、その場所に行きました。クマが海辺に着いたところでは、アザラシはいませんでした。クマはアザラシがいないことに少々がっかりしましたが、そこでクマはアザラシと友達になることを決め、その日を待ちわびるようになり
>>> CPU times: total: 1min 3s
>>> Wall time: 2min 4s
```

##### GPU結果

CPU演算より高速であることが確認できました。

```python
text_generator = TextGenerator(optimized_model_path="./optimized_model", target_device="GPU")
```
```python
%%time
prompt = text_generator.create_prompt()
generated_text = text_generator.generate_text(prompt)
print(generated_text)

>>> 承知しました。お伝えしたいことは、「短編小説を書くことは容易ではないことについて」です。
>>>
>>> 短編小説とは、物語のあ る一断面を描写したものであり、長い時間や空間を要する物語と比べて、物語の舞台を絞ることができます。しかし、短編小説であるがゆえに、物語を絞ることは容易である反 面、物語を膨らませることは難しくなるのです。
>>> CPU times: total: 39.9 s
>>> Wall time: 1min 18s
```

##### NPU結果

エラーが発生しました。解決を試みましたが、こちら解消できませんでした。

```python
text_generator = TextGenerator(optimized_model_path="./optimized_model", target_device="NPU")
```
```python
%%time
prompt = text_generator.create_prompt()
generated_text = text_generator.generate_text(prompt)
print(generated_text)

>>> RuntimeError                              Traceback (most recent call last)
>>> File <timed exec>:2
>>> ・・・
>>> RuntimeError: Exception from src\inference\src\cpp\core.cpp:109:
>>> Exception from src\inference\src\dev\plugin.cpp:54:
>>> Exception from src\plugins\intel_npu\src\plugin\src\plugin.cpp:513:
>>> get_shape was called on a descriptor::Tensor with dynamic shape
```

### intel-npu-acceleration-libraryを利用

下記サイトを参考に、intel-npu-acceleration-libraryを利用して、NPU演算で実行を試みる。

https://intel.github.io/intel-npu-acceleration-library/

本ライブラリはOpenVINO形式のモデルに対応していないかつ、量子化する手段がないため、生成するトークンの最大数を大幅に落として検証しました。

#### 実装

intel-npu-acceleration-libraryはoptimum.intelと共存ができないみたいですので、
参考にされる方いれば、環境にご注意ください。

```python
from intel_npu_acceleration_library import NPUModelForCausalLM
from huggingface_hub import login
from transformers import AutoTokenizer, TextStreamer
import torch

class TextGenerator:
    def __init__(self):
        # Hugging Face Hubにログイン
        login("Hugging Faceのマイページから取得")
        self.model, self.tokenizer = self.load_model_and_tokenizer()

    def load_model_and_tokenizer(self):
        # トークナイザーの読み込み
        model_id = "elyza/ELYZA-japanese-Llama-2-7b-instruct"
        # モデルの読み込みとデバイス設定
        model = NPUModelForCausalLM.from_pretrained(model_id, use_cache=False, dtype=torch.int8).eval()
        tokenizer = AutoTokenizer.from_pretrained(model_id)
        return model, tokenizer

    def create_prompt(self):
        # インストラクションの開始と終了を示すトークン
        B_INST, E_INST = "
", "
"
        # システムプロンプトの開始と終了を示すトークン
        B_SYS, E_SYS = "
\n", "\n
\n\n"
        # デフォルトのシステムプロンプト
        DEFAULT_SYSTEM_PROMPT = "あなたは誠実で優秀な日本人のアシスタントです。"
        # ユーザーがアシスタントに指示する具体的なプロンプトの内容
        text = "クマが海辺に行ってアザラシと友達になり、最終的には家に帰るというプロットの短編小説を書いてください。"
        # トークンを組み合わせて最終的なプロンプトを生成
        prompt = "{bos_token}{b_inst} {system}{prompt} {e_inst} ".format(
            bos_token=self.tokenizer.bos_token,
            b_inst=B_INST,
            system=f"{B_SYS}{DEFAULT_SYSTEM_PROMPT}{E_SYS}",
            prompt=text,
            e_inst=E_INST,
        )
        return prompt

    def generate_text(self, prompt):

        token_ids = self.tokenizer.encode(prompt, add_special_tokens=False, return_tensors="pt")
        
        output_ids = self.model.generate(
            token_ids.to(self.model.device),
            max_new_tokens=4, # 4より大きくするとスペックが足りずエラーになります
            pad_token_id=self.tokenizer.pad_token_id,
            eos_token_id=self.tokenizer.eos_token_id,
        )
        output = self.tokenizer.decode(output_ids.tolist()[0][token_ids.size(1) :], skip_special_tokens=True)

        # 生成されたテキストをデコードし、特殊トークンをスキップして出力
        return output
```

```python
text_generator = TextGenerator()
prompt = text_generator.create_prompt()
generated_text = text_generator.generate_text(prompt)
print(generated_text)
```

#### NPUの実行状況の抜粋

演算中にNPUが動いていることを確認しました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/677305/a8e761b8-4add-c68e-591b-765087e00de6.png)

### 総括

intel-npu-acceleration-libraryはまだ開発途中のライブラリで、NPUの機能の探索目的のもののようです。

https://medium.com/@alespalla.ap/boost-llms-inference-on-ai-pcs-with-intel-npu-acceleration-library-5e1e024c3ed5

今回、PCのスペックが不足しており、モデルの量子化や生成するトークンの最大数を大幅に下げる対応が必要でした。

TinyLlamaのような軽量なモデルであれば、Intel NPU Acceleration Libraryを使用することで、今回のような極端な対応をしなくても問題なく動作することが確認できています。

また、OpenVINOを利用してNPU上でLLMを実行できれば、モデルを量子化するなどの手段で、今回使用したPCのスペックでも違和感のないレベルの出力も得られたと思います。


## 最後に

最近になって、Copilot+PCなど最近よくニュースを見ますね、
NPU搭載したCPUに手を出すのはもう少し待ったほうがよかったかも？

また、エッジデバイスでText2Textのモデルを実行させたいユースケースは果たしてあるのか？
もし、ユースケースがあったとしても、個人的にはNVIDIA jetsonでいいじゃんって感じです。

多少ニッチな検証でしたが、少しでもお役に立てたなら、いいねやコメントをいただけると嬉しいです。