この記事について
Fujitsu研究所で開発された量子化フレームワークOneCompression(以下、OneComp)がApple SiliconのMacに対応したので試してみることにしました。MPS(Metal Performance Shaders)に対応1しているとのことなので、有効化の有無でどれ程実行時間に影響するか検証してみることにしました。
検証に用いた環境
今回の検証では以下の環境を用いました。
- M1 Pro MacBook Pro
- DRAM 16GB
- macOS Tahoe 26.5.1
- uv 0.11.17
- OneComp v1.2.0
環境構築
OneCompのリポジトリを公式のGitHubからクローンし、公式のドキュメントに従い、環境構築を行います。
git clone git@github.com:FujitsuResearch/OneCompression.git
uv sync --extra mps --extra dev --extra visualize
uv pip install -U datasets python-dotenv
測定プログラムの実装
サンプルプログラムを参考に今回の測定を実行するプログラムを実装しました。もう少し工夫してすっきりとしたコードにしようかとも思いましたが、今回はお試しで実験及びOneCompの各モジュールの機能のお勉強も兼ねているので単純な実装になっています。(測定部分などは同じ名前の変数を使い回しているので関数化すればスッキリするとは思います。)CPUモードとMPSモードでそれぞれ実行時間を測定します。また初回実行時はモデルのダウンロードが発生してしまうので、測定の前にダウンロードするコードを入れています。また、.evnファイルに環境変数HF_HUB_CACHEを記載しておけば、任意の場所にモデルデータをキャッシュ出来るようにしました。
# flake8: noqa: E402
import gc
from pathlib import Path
from time import perf_counter
import torch
from dotenv import load_dotenv
# flake8: noqa: E402
load_dotenv(Path(__file__).parent / ".env")
from huggingface_hub import snapshot_download
from onecomp import GPTQ, CalibrationConfig, ModelConfig, Runner, setup_logger
from onecomp.qep import QEPConfig
setup_logger()
MODEL_ID = "TinyLlama/TinyLlama-1.1B-intermediate-step-1431k-3T"
# Modelの事前ダウンロード
snapshot_download(
repo_id=MODEL_ID,
)
# CPU実行のための初期化
model_config = ModelConfig(
model_id=MODEL_ID,
device="cpu"
)
# 4bit量子化を実行
gptq = GPTQ(wbits=4)
runner = Runner(
model_config=model_config,
quantizer=gptq,
calibration_config=CalibrationConfig(
max_length=256,
num_calibration_samples=16
),
qep=True,
qep_config=QEPConfig(device="cpu"),
)
start_time = perf_counter()
runner.run()
end_time = perf_counter()
original_ppl, _, quantized_ppl = runner.calculate_perplexity(
original_model=True, dequantized_model=False, quantized_model=True
)
print(f"Original model perplexity: {original_ppl}")
print(f"Quantized model perplexity: {quantized_ppl}")
print(f"CPU Mode Execution time: {end_time - start_time} seconds")
del runner, model_config, gptq
gc.collect()
print(f"MPS Available: {torch.backends.mps.is_available()}")
# MPS実行のための初期化
model_config = ModelConfig(
model_id=MODEL_ID,
device="mps"
)
# 4bit量子化を実行
gptq = GPTQ(wbits=4)
runner = Runner(
model_config=model_config,
quantizer=gptq,
calibration_config=CalibrationConfig(
max_length=256,
num_calibration_samples=16
),
qep=True,
qep_config=QEPConfig(device="mps"),
)
start_time = perf_counter()
runner.run()
torch.mps.synchronize() # MPSの計算が完了するまで待機
end_time = perf_counter()
original_ppl, _, quantized_ppl = runner.calculate_perplexity(
original_model=True, dequantized_model=False, quantized_model=True
)
print(f"Original model perplexity: {original_ppl}")
print(f"Quantized model perplexity: {quantized_ppl}")
print(f"MPS Mode Execution time: {end_time - start_time} seconds")
測定結果: CPUモードが終了しない
CPUモードの処理がいつまで経っても終了しません。それもその筈。。。Linearの計算部分のログを見てみると何と。。。125時間!?つまり5日掛かってしまうと。。。
理由として上記ソースではモデル内のデータ型はデフォルトでFP16[^gpu]が用いられます。ところが、CPUには半制度演算を行う回路が搭載されておらず、ソフトウェア上でエミュレートされます。よって、その変換処理などの影響で通常半精度浮動小数点演算は途轍もない時間を要します。
Replaced 154 Linear layers with GPTQLinear
1%|▏ | 1/167 [45:06<124:48:19, 2706.62s/it]
測定プログラムの改良(CPUの良条件 vs GPUの良条件)
測定プログラムを改良し、CPUモードではFP32、GPU(MPS)モードではFP16を用いることで、それぞれの最良条件での性能を検証してみます。GPU側にはFP32が逆に無いため、ビットサイズは半分になります。単純計算では2倍高速になりそうですが、実際はCPUとのデータのやり取りなども挟むため実行時間の短縮もそれだけ小さくなるでしょう。
# flake8: noqa: E402
import gc
from pathlib import Path
from time import perf_counter
import torch
from dotenv import load_dotenv
# flake8: noqa: E402
load_dotenv(Path(__file__).parent / ".env")
from datasets import load_dataset
from huggingface_hub import snapshot_download
from onecomp import GPTQ, CalibrationConfig, ModelConfig, Runner, setup_logger
from onecomp.qep import QEPConfig
setup_logger()
MODEL_ID = "TinyLlama/TinyLlama-1.1B-intermediate-step-1431k-3T"
# Modelの事前ダウンロード
snapshot_download(
repo_id=MODEL_ID,
)
# キャリブレーションデータの事前ダウンロード
load_dataset(
"allenai/c4",
data_files={"train": "en/c4-train.00001-of-01024.json.gz"},
split="train",
)
# CPU実行のための初期化
model_config = ModelConfig(
model_id=MODEL_ID,
device="cpu",
dtype="float32",
)
# 4bit量子化を実行
gptq = GPTQ(wbits=4)
runner = Runner(
model_config=model_config,
quantizer=gptq,
calibration_config=CalibrationConfig(
max_length=256,
num_calibration_samples=16
),
qep=True,
qep_config=QEPConfig(device="cpu"),
)
start_time = perf_counter()
runner.run()
end_time = perf_counter()
original_ppl, _, quantized_ppl = runner.calculate_perplexity(
original_model=True, dequantized_model=False, quantized_model=True,
dataset_name="Salesforce/wikitext", dataset_config="wikitext-2-raw-v1",
)
print(f"Original model perplexity: {original_ppl}")
print(f"Quantized model perplexity: {quantized_ppl}")
print(f"CPU Mode (dtype={model_config.dtype}) Execution time: {end_time - start_time} seconds")
del runner, model_config, gptq
gc.collect()
print(f"MPS Available: {torch.backends.mps.is_available()}")
# MPS実行のための初期化
model_config = ModelConfig(
model_id=MODEL_ID,
device="mps",
dtype="float16",
)
# 4bit量子化を実行
gptq = GPTQ(wbits=4)
runner = Runner(
model_config=model_config,
quantizer=gptq,
calibration_config=CalibrationConfig(
max_length=256,
num_calibration_samples=16
),
qep=True,
qep_config=QEPConfig(device="mps"),
)
start_time = perf_counter()
runner.run()
torch.mps.synchronize() # MPSの計算が完了するまで待機
end_time = perf_counter()
original_ppl, _, quantized_ppl = runner.calculate_perplexity(
original_model=True, dequantized_model=False, quantized_model=True,
dataset_name="Salesforce/wikitext", dataset_config="wikitext-2-raw-v1",
)
print(f"Original model perplexity: {original_ppl}")
print(f"Quantized model perplexity: {quantized_ppl}")
print(f"MPS Mode (dtype={model_config.dtype}) Execution time: {end_time - start_time} seconds")
測定結果: 現実的な実行時間で終了したが。。。
今度はCPUモードの処理は現実的な実行時間で終了し、MPSモードの実行時間の測定も実行出来ました。さて、結果はどれ程の差になったのでしょうか?
| Device | Original PPL | Quantized PPL | Execution Time[sec] |
|---|---|---|---|
| CPU(FP32) | 7.769 | 9.858 | 200.711 |
| MPS(FP16) | 7.770 | 9.942 | 182.807 |
MPS(GPU)実行の方が実行時間は短いことが測定結果から分かります。ただ、MPSモードではFP16を用いていることからビットサイズは半分です。ところが、実際の高速化は10%程度です。これは測定に用いたジョブがCPUとGPUの性能差を示すには小さすぎるジョブだったために、CPUとGPU間の性能差がそれほど結果に表れなかったのではないかと推察されます。そこで、max_length=512, num_calibration_samples=128に増大させて測定を実行します。
より負荷の大きなジョブに対しての性能測定
より負荷の大きなジョブを実行させるために、上述の様に、max_length=512, num_calibration_samples=128設定して測定を実施します。
# flake8: noqa: E402
import gc
from pathlib import Path
from time import perf_counter
import torch
from dotenv import load_dotenv
# flake8: noqa: E402
load_dotenv(Path(__file__).parent / ".env")
from datasets import load_dataset
from huggingface_hub import snapshot_download
from onecomp import GPTQ, CalibrationConfig, ModelConfig, Runner, setup_logger
from onecomp.qep import QEPConfig
setup_logger()
MODEL_ID = "TinyLlama/TinyLlama-1.1B-intermediate-step-1431k-3T"
# Modelの事前ダウンロード
snapshot_download(
repo_id=MODEL_ID,
)
# キャリブレーションデータの事前ダウンロード
load_dataset(
"allenai/c4",
data_files={"train": "en/c4-train.00001-of-01024.json.gz"},
split="train",
)
# CPU実行のための初期化
model_config = ModelConfig(
model_id=MODEL_ID,
device="cpu",
dtype="float32",
)
# 4bit量子化を実行
gptq = GPTQ(wbits=4)
runner = Runner(
model_config=model_config,
quantizer=gptq,
calibration_config=CalibrationConfig(
max_length=512,
num_calibration_samples=128
),
qep=True,
qep_config=QEPConfig(device="cpu"),
)
start_time = perf_counter()
runner.run()
end_time = perf_counter()
original_ppl, _, quantized_ppl = runner.calculate_perplexity(
original_model=True, dequantized_model=False, quantized_model=True,
dataset_name="Salesforce/wikitext", dataset_config="wikitext-2-raw-v1",
)
print(f"Original model perplexity: {original_ppl}")
print(f"Quantized model perplexity: {quantized_ppl}")
print(f"CPU Mode (dtype={model_config.dtype}) Execution time: {end_time - start_time} seconds")
del runner, model_config, gptq
gc.collect()
print(f"MPS Available: {torch.backends.mps.is_available()}")
# MPS実行のための初期化
model_config = ModelConfig(
model_id=MODEL_ID,
device="mps",
dtype="float16",
)
# 4bit量子化を実行
gptq = GPTQ(wbits=4)
runner = Runner(
model_config=model_config,
quantizer=gptq,
calibration_config=CalibrationConfig(
max_length=512,
num_calibration_samples=128
),
qep=True,
qep_config=QEPConfig(device="mps"),
)
start_time = perf_counter()
runner.run()
torch.mps.synchronize() # MPSの計算が完了するまで待機
end_time = perf_counter()
original_ppl, _, quantized_ppl = runner.calculate_perplexity(
original_model=True, dequantized_model=False, quantized_model=True,
dataset_name="Salesforce/wikitext", dataset_config="wikitext-2-raw-v1",
)
print(f"Original model perplexity: {original_ppl}")
print(f"Quantized model perplexity: {quantized_ppl}")
print(f"MPS Mode (dtype={model_config.dtype}) Execution time: {end_time - start_time} seconds")
測定結果: CPU vs MPSの性能差がより顕著に
負荷の大きいジョブで測定を実施したところ、CPU vs MPSの性能差がより顕著になりました。更に上述のパラメータを増大させたり、モデルをより大きなサイズのモデルにするなど、工夫できる点はまだまだ有りますが、この結果でもMPSがCPUの実行時間の約1.46倍、約30%の短縮を実現しました。
| Device | Original PPL | Quantized PPL | Execution Time[sec] |
|---|---|---|---|
| CPU(FP32) | 7.769 | 8.728 | 1165.722 |
| MPS(FP16) | 7.770 | 8.664 | 797.257 |
まとめ
今回OneCompがApple SiliconのMacに対応したと知り、簡単な性能検証の実行を試みました。大きなサイズの行列を扱う演算(max_length, num_calibration_samplesが大きいジョブ、サイズの大きいモデルの実行に相当)の場合、CPUとMPSの性能差が開くことが分かります。但し、小さなジョブではCPUとGPU間でデータをやり取りするオーバーヘッドが実行時間内で占める割合が高くなることから、性能差は小さくなることも観測出来ました。
-
OneCompは内部の実装にPyTorchを用いています。よって、量子化済のモデルはsafetensor形式で保存できます。 ↩