メインコンテンツへスキップ

大規模言語モデル(Llama2など)を正攻法でファインチューニングする際のメモ(ZeRO-Offload, not QLoRA)

    背景と目的

    大きめのサイズ(>数b)の大規模言語(LLM)をファインチューニングします。
    ファインチューニングにはLoRAやQLoRAと呼ばれる手法が良く使われ、一般家庭レベル(?)のGPUでも動かせるようになってきています。
    しかし、LoRAで学習させられる知識や情報には、制約があるのでは、とも囁かれています。

    そこで、本記事は、loraではないフルパラメータのファインチューニングを、限られたGPUメモリで行います。

    deepspeedというライブラリを使います。
    deepspeedにはモデルの動作に必要なメモリをCPUメモリに移す機能などがあるようで、それを使います(キーワード: offload, ZeRO)。

    7bモデルは20GB程度のVRAMで学習できました。

    以下の公式チュートリアルをもとに進めたいところですが、情報が断片的で、自分にはあまり理解できなかったので、webサイトを適当に探りながら進めました。

    環境構築

    以下のような感じで構築しました(メモ書き)。

    
    pip install deepspeed
    pip install transformers datasets mecab-python3 unidic-lite sentencepiece accelerate pynvml deepspeed
    pip install protobuf
    conda install mpi4py #pipでエラーが出たので。

    推論テスト

    まずは推論で、deepspeedの効果を試します。
    モデルは "elyza/ELYZA-japanese-Llama-2-7b-instruct"を使います。
    ELYZAは、llama2をベースにファインチューニングした、わりと最近のモデルです。


    対照実験: 普通に推論

    transformersでモデルを呼び出して、推論をしてみます。
    notebook環境で動かしています。

    #普通に推論
    from transformers import pipeline
    from transformers import AutoModelForCausalLM, AutoTokenizer, AutoConfig
    import torch
    model_name= "elyza/ELYZA-japanese-Llama-2-7b-instruct"
    
    model = AutoModelForCausalLM.from_pretrained(model_name)
    tokenizer = AutoTokenizer.from_pretrained(model_name)
    
    
    if torch.cuda.is_available():
      model.to("cuda")
    
    
    def gen(input_text="今日の天気は"):
        text_pipe = pipeline('text-generation', 
                             model=model,
                             tokenizer=tokenizer,
                             device="cuda:0",
                             max_length=200,
                                 )
        output = text_pipe(input_text)
    
        return output[0]['generated_text']
    
    %time gen()

    出力:
    '今日の天気は曇り時々雨。\n気温は15℃。\n今日は朝から雨が降っています。\n降り方が強くて、洗濯物が干せそうにありません。\n昨日は、午後から雨が降っていたので、洗濯物は干せましたが、今日は干せそうにありません。\n洗濯機を回して、洗濯物を部屋に干しておきます。\n今日の天気は曇り時々雨。\n気温は'

    使用VRAM: 約28GB
    推論時間: 5 sec
    CPUメモリ: VIRTで約60GB、RESで2.5GB

    7Bのモデルを32bitの変数で動かしているので、7x4=28 GBという計算だと思います。

    DeepSpeedの利用

    メモリをCPUにオフロードするなどできるようです。詳細は勉強中です。
    以下の記事を参考に、推論のテストをしてみました。

    以下のjsonファイル(zero_infer.json)を作ります。

    {
        "fp16": {
            "enabled": "auto",
            "loss_scale": 0,
            "loss_scale_window": 1000,
            "initial_scale_power": 16,
            "hysteresis": 2,
            "min_loss_scale": 1
        },
        "zero_optimization": {
            "stage": 3,
            "offload_optimizer": {
                "device": "cpu",
                "pin_memory": true
            },
            "offload_param": {
                "device": "cpu",
                "pin_memory": true
            },
            "overlap_comm": true,
            "contiguous_gradients": true,
            "sub_group_size": 1e9,
            "reduce_bucket_size": "auto",
            "stage3_prefetch_bucket_size": "auto",
            "stage3_param_persistence_threshold": "auto",
            "stage3_max_live_parameters": 1e9,
            "stage3_max_reuse_distance": 1e9,
            "stage3_gather_16bit_weights_on_model_save": true
        },
        "steps_per_print": 2000,
        "train_batch_size": "auto",
        "train_micro_batch_size_per_gpu": "auto",
        "wall_clock_breakdown": false
    }

    実行処理

    import torch
    import deepspeed
    from transformers.deepspeed import HfDeepSpeedConfig
    from transformers import AutoModelForCausalLM, AutoTokenizer, AutoConfig
    import json
    import os
    
    
    model_name= "elyza/ELYZA-japanese-Llama-2-7b-instruct"
    
    
    # multi-GPU関連の設定
    os.environ["TOKENIZERS_PARALLELISM"] = "false"  # To avoid warnings about parallelism in tokenizers
    local_rank = int(os.getenv("LOCAL_RANK",0))
    world_size = int(os.getenv("WORLD_SIZE",1))
    
    torch.cuda.set_device(local_rank)
    deepspeed.init_distributed()
    
    # ベースとなるZeRO3 configの読み込み
    ds_config_file = "zero_infer.json"
    with open(ds_config_file) as f:
        ds_config = json.load(f)
    
    # 推論用に修正
    model_config = AutoConfig.from_pretrained(model_name)
    hidden_size = model_config.hidden_size
    
    ds_config["train_batch_size"] = 1 * world_size
    ds_config["train_micro_batch_size_per_gpu"] = 1
    ds_config["reduce_bucket_size"] = hidden_size*hidden_size
    ds_config["stage3_prefetch_bucket_size"] = 0.9 * hidden_size * hidden_size
    ds_config["stage3_param_persistence_threshold"] = 10 * hidden_size
    
    
    dschf = HfDeepSpeedConfig(ds_config)  #zero3を使用するために必要(モデルロード前に実行する必要がある)
    model = AutoModelForCausalLM.from_pretrained(model_name)
    tokenizer = AutoTokenizer.from_pretrained(model_name)
    
    ds_engine = deepspeed.initialize(model=model, config_params=ds_config)[0]
    ds_model = ds_engine.module.eval()
    
    
    from transformers import pipeline
    
    
    if torch.cuda.is_available():
      model.to("cuda")
    
    
    def gen(input_text="今日の天気は"):
        text_pipe = pipeline('text-generation', 
                             model=ds_model,
                             tokenizer=tokenizer,
                             device="cuda:0",
                             max_length=200,
                                 )
        output = text_pipe(input_text)
    
        return output[0]['generated_text']
    %time gen()

    出力:
    '今日の天気は曇り時々雨。\n気温は15℃。\n今日は朝から雨が降っています。\n降り方が強くて、洗濯物が干せそうにありません。\n昨日は、午後から雨が降っていたので、洗濯物は干せましたが、今日は干せそうにありません。\n洗濯機を回して、洗濯物を部屋に干しておきます。\n今日の天気は曇り時々雨。\n気温は'

    使用VRAM: 約4.8GB
    推論時間: 1min 51 sec
    CPUメモリ: VIRTで約83GB、RESで52GB

    CPU側にメモリを移すことで、VRAMは大幅に節約できたようです。
    (加えて、16 bit推論をしているので、必要なメモリサイズが半減した効果もあります)

    VRAMは節約できた一方で、推論時間が20倍以上になってしまいました。

    訓練

    学習用に、少し追記した設定ファイルを作ります(zero_train.json)。

    {
        "fp16": {
            "enabled": "auto",
            "loss_scale": 0,
            "loss_scale_window": 1000,
            "initial_scale_power": 16,
            "hysteresis": 2,
            "min_loss_scale": 1
        },
        "optimizer": {
            "type": "AdamW",
            "params": {
                "lr": "auto",
                "betas": "auto",
                "eps": "auto",
                "weight_decay": "auto"
            }
        },
        "scheduler": {
            "type": "WarmupDecayLR",
            "params": {
                "warmup_min_lr": "auto",
                "warmup_max_lr": "auto",
                "warmup_num_steps": "auto",
                "total_num_steps": "auto"
            }
        },
        "zero_optimization": {
            "stage": 3,
            "offload_optimizer": {
                "device": "cpu",
                "pin_memory": true
            },
            "offload_param": {
                "device": "cpu",
                "pin_memory": true
            },
            "overlap_comm": true,
            "contiguous_gradients": true,
            "sub_group_size": 1e9,
            "reduce_bucket_size": "auto",
            "stage3_prefetch_bucket_size": "auto",
            "stage3_param_persistence_threshold": "auto",
            "stage3_max_live_parameters": 1e9,
            "stage3_max_reuse_distance": 1e9,
            "stage3_gather_16bit_weights_on_model_save": true
        },
        "steps_per_print": 2000,
        "train_batch_size": "auto",
        "train_micro_batch_size_per_gpu": "auto",
        "wall_clock_breakdown": false
    }

    モデルの定義類は、推論と基本的に同じです。

    import torch
    import deepspeed
    from transformers.deepspeed import HfDeepSpeedConfig
    from transformers import AutoModelForCausalLM, AutoTokenizer, AutoConfig
    import json
    import os
    
    model_name= "elyza/ELYZA-japanese-Llama-2-7b-instruct"
    
    
    # multi-GPU関連の設定
    os.environ["TOKENIZERS_PARALLELISM"] = "false"  # To avoid warnings about parallelism in tokenizers
    local_rank = int(os.getenv("LOCAL_RANK",0))
    world_size = int(os.getenv("WORLD_SIZE",1))
    
    torch.cuda.set_device(local_rank)
    deepspeed.init_distributed()
    
    # ベースとなるZeRO3 configの読み込み
    ds_config_file = "zero_infer.json"
    with open(ds_config_file) as f:
        ds_config = json.load(f)
    
    
    model_config = AutoConfig.from_pretrained(model_name)
    hidden_size = model_config.hidden_size
    
    ds_config["train_batch_size"] = 1 * world_size
    ds_config["train_micro_batch_size_per_gpu"] = 1
    ds_config["reduce_bucket_size"] = hidden_size*hidden_size
    ds_config["stage3_prefetch_bucket_size"] = 0.9 * hidden_size * hidden_size
    ds_config["stage3_param_persistence_threshold"] = 10 * hidden_size
    
    
    dschf = HfDeepSpeedConfig(ds_config)  #zero3を使用するために必要(モデルロード前に実行する必要がある)
    model = AutoModelForCausalLM.from_pretrained(model_name)
    tokenizer = AutoTokenizer.from_pretrained(model_name)
    
    ds_engine = deepspeed.initialize(model=model, config_params=ds_config)[0]
    ds_model = ds_engine.module#.eval()
    

    データセットの定義など
    train_data_pathに、適当なテキストデータへのパスを入れて下さい。

    from transformers import (AutoModelForCausalLM,
                              DataCollatorForLanguageModeling, T5Tokenizer,AutoTokenizer,
                              TextDataset, Trainer, TrainingArguments)
    
    train_data_path = "ここにデータセットのパスを指定"
    
    train_dataset = TextDataset(
        tokenizer=tokenizer,
        file_path=train_data_path,
        block_size=1024, #文章の長さを揃える,
        #block_size=100, #文章の長さを揃える,
        cache_dir="cache/"+model_name,
    )
    
    data_collator = DataCollatorForLanguageModeling(
        tokenizer=tokenizer,
        mlm=False
    )
    
     #deepspeed epochs=1
    
    training_args = TrainingArguments(
        output_dir='./outputs',
        num_train_epochs=epochs,  # エポック数
        #per_device_train_batch_size=1,  # バッチサイズ
        gradient_accumulation_steps=1,
        gradient_checkpointing=True,  #勾配チェックポイント
        fp16=True,  #fp16
        optim='adafactor',  # オプティマイザの種類
        deepspeed='./zero_train.json',  # deepspeedのconfigへのpath
        logging_steps=100,  # 途中経過を表示する間隔
    )
    
    trainer = Trainer(
        model=ds_model,
        args=training_args,
        data_collator=data_collator,
        train_dataset=train_dataset
    )
    
    #訓練
    result = trainer.train()
    print_summary(result)

    使用VRAM: 約19GB
    CPUメモリ: VIRTで約206GB、RESで150GB

    学習時間はデータサイズ次第ですが、4MBのテキストで1 hr程度でした。
    offloadを使わない場合と同等レベルな気がしたので、驚きました※。
    (※ このモデルは今回使った環境では、offloadを使わないと、out of memoryで学習できませんでした。より小さなモデルの学習時間との比較です)

    学習したモデルの保存と読み込み

    0922追記 モデルの保存コマンドに問題があったので修正(model.save_pretrainedではなく、trainer.save_modelを使う)。

    # Save the model and the tokenizer.
    dir_name = f'finetuned/{model_name}_{epochs}'
    
    #0922追記: model.save_pretrainedだと、うまくモデルを読み込めない感じでした
    #model.save_pretrained(dir_name)
    trainer.save_model(dir_name)
    tokenizer.save_pretrained(dir_name)

    保存したモデルのパスを、推論用コード(上述)のmodel_nameに指定してあげれば、ファインチューニングしたモデルを呼び出せます。

    #普通に推論
    from transformers import pipeline
    from transformers import AutoModelForCausalLM, AutoTokenizer, AutoConfig
    
    #ファインチューニングモデルの保存パスを指定
    model_name="finetuned/elyza/ELYZA-japanese-Llama-2-7b-instruct_1_test"
    
    model = AutoModelForCausalLM.from_pretrained(model_name,ignore_mismatched_sizes=True)
    tokenizer = AutoTokenizer.from_pretrained(model_name)
    
    
    if torch.cuda.is_available():
      model.to("cuda")
    
    
    def gen(input_text="今日の天気は"):
        text_pipe = pipeline('text-generation', 
                             model=model,
                             tokenizer=tokenizer,
                             device="cuda:0",
                             max_length=200,
                            #temperature = 100,
                                 )
        output = text_pipe(input_text)
    
        return output[0]['generated_text']
    gen()


    deepspeedはマルチGPUにも対応しているので、この調子でいけば、13b以上のモデルも動かせるかもしれません。
    速度は落ちそうですが、CPUではなく、SSDのメモリ(NVME)にoffloadすることもできるようです(未検証: リンク)。

    引き続き検討します。

     
     
     
    化学・大規模言語モデル・ロボットなど 記事はメモ書きですのでご了承ください

    あなたへのおすすめ