AWS Trainiumを利用してLlama 3.1 8Bの継続事前学習をしてみた

## はじめに
AWS Trainiumを使ったLlama 3.1の継続事前学習に携わる機会をいただいたので、
Trainiumの概要や継続事前学習の手順について解説したいと思います。
また、学習を行う際に躓いた点があったため、都度解説します。

## AWS Trainiumとは
AWS Trainiumとは、AWSが提供する深層学習を行うための機械学習アクセラレータです。

Llamaなどの大規模言語モデルの学習には、GPUなどの並列計算に強い機械学習アクセラレータが必要不可欠です。

Trainiumは大規模言語モデルのトレーニングに最適化されており、AWS上でTrainium搭載のTrn1インスタンスとして提供されています。

### 従来のGPUインスタンスと比べたメリット
コスト面でメリットがあると考えられます。

AWSの公式サイトでは、「他の同等の Amazon EC2 インスタンスと比べて、トレーニングコストを最大 50% 削減」とあります。
この「同等の Amazon EC2 インスタンス」が何かははっきり言及されていませんが、おそらく`P4de.24xlarge`インスタンスを指しているものと思われます。このインスタンスとの比較をしてみます。
（価格はOregonリージョン。2024年12月10日現在）
| Instance Size | GPU / Trainium | アクセラレータ メモリ | vCPU | メモリ | アクセラレータ P2P BW | オンデマンド価格* (USD/時間) |
|--------------|----------------|-------------------|--------|--------|-------------------|------------------------|
| P4de.24xlarge | 8 | 640 GB | 96 | 1152 GiB | 600 GB/s | 40.96 |
| Trn1.32xlarge | 16 | 512 GB | 128 | 512 GB | 768 Gbps | 21.50 |
| Trn1n.32xlarge | 16 | 512 GB | 128 | 512 GB | 768 Gbps | 24.78 |

単純な価格比較では50%削減に至りません。しかし、TrainiumにはサポートするAWS Neuron SDKなど、AWS側がTrainiumに機械学習の高速化に対する工夫を多数施しているため、学習時間を考慮すれば「最大 50% 削減」できるとAWSは謳っているようです。

### P5インスタンス、Trn2インスタンスの登場
記事を書いている最中に、AWSから[P5インスタンス・P5enインスタンス](https://aws.amazon.com/jp/ec2/instance-types/p5/)、[Trn2インスタンス](https://aws.amazon.com/jp/ec2/instance-types/trn2/)が登場しました。

P5およびP5enインスタンスは、それぞれNVIDIAのH100、H200を搭載したインスタンスです。前世代（おそらくP4）のインスタンスと比較してトレーニングコストを40%削減できるそうです。

Trn2インスタンスは、EC2 P5e および P5en インスタンスよりも 30 〜 40% 優れたコストパフォーマンスを提供しているそうです。

詳しく書くと記事が長くなりすぎるため、今回は割愛します。


## 全体アーキテクチャ図

全体アーキテクチャ図を以下に示します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3804959/1f9efbe2-a3fa-c058-36d8-e12f10c1aa6d.png)

主要なリソースの役割は以下の通りです。

**Head Node**
- クラスター全体の制御・管理を行い、Worker nodeへのタスク分配やスケジューリングを担当します。  
- 学習の進捗管理やWorker node間の同期、モデルの統合なども行います。

**Worker Nodes**
- GPUを使用して実際の学習処理や計算処理を実行します。  
- バッチ処理やデータの前処理、モデルパラメータの更新、モデル形式の変換を担当します。

**FSx**
- 学習データやモデルチェックポイントを保存する高速なストレージシステムです。  
- 複数のノード間でデータを共有し、並列読み込みや低レイテンシでのアクセスを可能にします。

## 学習実行までの手順
ここからの手順は
- [Train your model on ParallelCluster](https://awsdocs-neuron.readthedocs-hosted.com/en/latest/general/devflows/training/parallelcluster/parallelcluster-training.html)
- [Training Llama3.1-8B, Llama3-8B and Llama2-7B with Tensor Parallelism and ZeRO-1 Optimizer](https://awsdocs-neuron.readthedocs-hosted.com/en/latest/libraries/neuronx-distributed/tutorials/training_llama_tp_zero1.html)
をベースに説明します。
us-west-2 (Oregon) リージョン上での作業を想定しています（リソースが比較的確保しやすいため）。

:::note warn
警告
以降の操作には数百ドルのコストがかかります。
実行する際はリソースの消し忘れにご注意ください（特にFSx周り）。
:::


### Trn1インスタンスのサービスクォータの引き上げ
Trn1インスタンスにはAWSリージョンごとに利用可能なリソースの最大値（サービスクォータ）が定められています。
デフォルトでは0（=利用できない）となっているため、この値を引き上げます。
ここではクォータの単位がvCPUとなっています。
`Trn1n.32xlarge` インスタンスのvCPU数は128であり、以降の手順では4台のインスタンスを利用するため、128×4=512 以上の値に設定します（画像では1024に設定されています）。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3804959/085703a4-11a1-ac47-bf17-8a20abd2c808.png)

### VPCの準備
デフォルトのVPCを利用してもよいですが、リソースの簡潔な管理のために新しくVPCを作成します。
[このページ](https://github.com/aws-neuron/aws-neuron-parallelcluster-samples/blob/master/examples/general/network/vpc-subnet-setup.md)の手順を参考に作成しました。
AZはTrn1が利用できるゾーンを指定する必要があります。私はus-west-2dを指定しました。

また、VPC作成後、VPC内のパブリックサブネットにパブリックIPv4アドレス自動割り当て設定を適用します。
この設定により、後ほど実行する`pcluster`コマンドで、Head NodeにパブリックIPアドレスが付与されSSHログインが可能になります。

### Parallel Clusterの構築
AWS CloudShell上で以下のコマンドを入力し、AWS ParallelClusterをインストールします。
[AWS公式のParallelClusterインストール手順](https://docs.aws.amazon.com/parallelcluster/latest/ug/install-v3-virtual-environment.html)を参考にしました。

```
python3 -m pip install --upgrade pip
python3 -m pip install --user --upgrade virtualenv
python3 -m virtualenv ~/apc-ve
python3  -m virtualenv -p $(which python3) ~/apc-ve
source ~/apc-ve/bin/activate
```

```
python3 -m pip install --upgrade "aws-parallelcluster"
```
```
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.38.0/install.sh | bash
chmod ug+x ~/.nvm/nvm.sh
source ~/.nvm/nvm.sh
nvm install --lts
node --version
```

インストールされたか確認します。バージョンが出力されたらOKです。私の場合は`{"version": "3.11.1"}`でした。
```
pcluster version
```

Head Node、Worker Node、FSxを作成するための`launch.yaml`ファイルを作成します。
以下のタグを、自身が作成したリソースに基づいて置き換えます。
- \<PUBLIC SUBNET ID>: VPC作成時に作成したパブリックサブネットのID
- \<PRIVATE SUBNET ID>: VPC作成時に作成したプライベートサブネットのID
- \<KEY NAME WITHOUT .PEM>: SSHに用いるpemキー名（拡張子.pemは**含まない**）

```yaml
tee launch.yaml > /dev/null << EOF
Region: us-west-2 # 対象リージョン 
Image:
  Os: ubuntu2004
HeadNode:
  InstanceType: r6a.12xlarge #ヘッドノードとして使用する EC2 インスタンスタイプ
  Networking:
    SubnetId: subnet-<PUBLIC SUBNET ID>
  Ssh:
    KeyName: <KEY NAME WITHOUT .PEM> 
  LocalStorage:
    RootVolume:
      Size: 1024
  CustomActions:
    OnNodeConfigured:
      Script: s3://neuron-s3/pcluster/post-install-scripts/neuron-installation/v2.20.1/u20/pt/install_neuron.sh
  Iam:
    S3Access:
       - BucketName: neuron-s3
         EnableWriteAccess: false
Scheduling:
  Scheduler: slurm
  SlurmQueues:
    - Name: compute1
      CapacityType: ONDEMAND
      ComputeSettings:
        LocalStorage:
          RootVolume:
            Size: 1024
          EphemeralVolume:
            MountDir: /local_storage
      ComputeResources:
        - Efa:
            Enabled: true
          InstanceType: trn1.32xlarge
          MaxCount: 4
          MinCount: 4
          Name: queue1-i1
      Networking:
        SubnetIds:
          - subnet-<PRIVATE SUBNET ID>
        PlacementGroup:
          Enabled: false
      CustomActions:
        OnNodeConfigured:
          Script: s3://neuron-s3/pcluster/post-install-scripts/neuron-installation/v2.20.1/u20/pt/install_neuron.sh
      Iam:
        S3Access:
          - BucketName: neuron-s3
            EnableWriteAccess: false
SharedStorage:
- FsxLustreSettings:
    DeploymentType: SCRATCH_2
    StorageCapacity: 1200
  MountDir: /fsx
  Name: pclusterfsx
  StorageType: FsxLustre
EOF
```
クラスターのデプロイを行います。
```
pcluster create-cluster --cluster-configuration launch.yaml -n cluster-test
```
出力されているjsonに`"cloudformationStackStatus": "CREATE_IN_PROGRESS"`があれば、作成自体は開始しています。内部的にはCloudFormationを使ってリソースを作成しているため、作成の進捗はマネジメントコンソールから追うことができます。かなり時間がかかりますが（自分は25分程度かかりました）、気長に待ちましょう。

`pcluster`コマンドの利用法について補足します。
クラスターのステータスは以下のコマンドでも確認することができます。

```
pcluster describe-cluster --cluster-name cluster-test
```
また、Worker Nodeを停止させるには以下のコマンドを実行します。1インスタンスあたり$21.5/hと高額なインスタンスであるため、こまめに停止することをおすすめします。
```
pcluster update-compute-fleet --cluster-name cluster-test --status STOP_REQUESTED
```

Worker Nodeを起動させるには以下のコマンドを実行します。
```
pcluster update-compute-fleet --cluster-name cluster-test --status START_REQUESTED
```


### 必要なファイル・データセットの準備
SSHでHead Nodeに接続します。pemキーの実行権限がowner以外に付与されているとエラーが出て接続できないため、権限を変更しておく必要があります。

```
chmod 700 <KEY NAME>.pem
ssh -i <PATH_TO_YOUR_PEM_KEY> ubuntu@<PUBLIC_IP>
```

Neuron SDKがダウンロードされた環境をactivateします。
```
source ~/aws_neuron_venv_pytorch/bin/activate
```

作業用のディレクトリを作成し、その配下に必要なスクリプト群をダウンロードしておきます。
```
mkdir -p ~/examples/tp_zero1_llama_hf_pretrain
cd ~/examples/tp_zero1_llama_hf_pretrain

wget https://raw.githubusercontent.com/aws-neuron/neuronx-distributed/master/examples/training/llama/requirements.txt
wget https://raw.githubusercontent.com/aws-neuron/neuronx-distributed/master/examples/training/llama/tp_zero1_llama_hf_pretrain/tp_zero1_llama_hf_pretrain.py
wget https://raw.githubusercontent.com/aws-neuron/neuronx-distributed/master/examples/training/llama/tp_zero1_llama_hf_pretrain/logger.py
wget https://raw.githubusercontent.com/aws-neuron/neuronx-distributed/master/examples/training/llama/training_utils.py
wget https://raw.githubusercontent.com/aws-neuron/neuronx-distributed/master/examples/training/llama/modeling_llama_nxd.py
wget https://raw.githubusercontent.com/aws-neuron/neuronx-distributed/master/examples/training/llama/get_dataset.py
wget https://raw.githubusercontent.com/aws-neuron/neuronx-distributed/master/examples/training/llama/tp_zero1_llama_hf_pretrain/tp_zero1_llama3_8B_hf_pretrain.sh
```

`requirements.txt`を利用して、依存するパッケージのインストールを行います。
```
python3 -m pip install -r requirements.txt
```

Llama3.1 8BのConfigファイルをダウンロードします。
```
mkdir 8B_config_llama3 && cd 8B_config_llama3
wget https://raw.githubusercontent.com/aws-neuron/neuronx-distributed/master/examples/training/llama/tp_zero1_llama_hf_pretrain/8B_config_llama3.1/config.json
cp config.json ../
cd ..
```

tokenizerをダウンロードするスクリプトを作成し、実行します。
`'your_own_hugging_face_token'`は自身が取得したトークンに置き換えてください。

```python
tee ./get_tokenizer.py > /dev/null << EOF
from huggingface_hub import login
from transformers import AutoTokenizer

login(token='your_own_hugging_face_token')
tokenizer = AutoTokenizer.from_pretrained('meta-llama/Llama-3.1-8B')
tokenizer.save_pretrained(".")
EOF

python3 get_tokenizer.py
```

学習データをダウンロードし、tokenizeを実行します。実行後、データセットをFSxに移動します。

```
cd ~/examples/tp_zero1_llama_hf_pretrain
python3 get_dataset.py --llama-version 3
mv ~/examples_datasets /fsx/
```

### モデルのダウンロード・変換
初期ウェイトをダウンロードするスクリプトを作成・実行します。
`'your_own_hugging_face_token'`は自身が取得したトークンに置き換えてください。
```python
cd ~/examples/tp_zero1_llama_hf_pretrain
tee ./save_model_weights.py > /dev/null << EOF
from transformers import AutoConfig, AutoModelForCausalLM
from huggingface_hub import login
import torch
login(token='your_own_hugging_face_token')
config = AutoConfig.from_pretrained('./config.json')
model = AutoModelForCausalLM.from_pretrained(
  "meta-llama/Llama-3.1-8B",
  config=config
)
torch.save(model.state_dict(), 'llama-31-8b.pt')
EOF
python3 save_model_weights.py
```

HuggingFace形式からNxD形式に変換するためのスクリプトをダウンロードします。
```
wget https://raw.githubusercontent.com/aws-neuron/neuronx-distributed/master/examples/training/llama/convert_checkpoints.py
```

変換後のウェイトの保存先ディレクトリを作成します。

```
mkdir -p checkpoint/pretrained_weight
```

変換を行います。Head NodeからWorker NodeのいずれかにSSHアクセスして、環境をactivateします。

```
ssh compute1-st-queue1-i1-1
source ./aws_neuron_venv_pytorch/bin/activate
cd ~/examples/tp_zero1_llama_hf_pretrain/
```

変換スクリプトを実行します。実行に5分程度かかります。

```
python3 convert_checkpoints.py --tp_size 32 --pp_size 1 --n_layers 32\
  --input_dir llama-31-8b.pt\
  --output_dir checkpoint/pretrained_weight\
  --convert_from_full_state --save_xser 1\
  --kv_size_multiplier 4 --qkv_linear 1 --config config.json
```

変換完了後、ウェイトをFSxボリュームにコピーし、Head Nodeに戻ります。
```
cp -r checkpoint/pretrained_weight /fsx/checkpoint/pretrained_weight
exit
```

### 学習スクリプトの編集

ダウンロードした学習スクリプト`tp_zero1_llama3_8B_hf_pretrain.sh`に以下の修正を加えます。

- データセットのパスを指定します。
```
DATA_PATH="/fsx/examples_datasets/wikicorpus_llama3_tokenized_8k"
```
- 学習途中でウェイトを保存できるように、CHECKPOINT周りの設定を行います。
（2ステップで学習を終わらせるため、CHECKPOINT_FREQは1に設定しました。）
```
CHECKPOINT_FREQ=1 # 10から1に変更
CHECKPOINT_DIR=/fsx/checkpoint
```
- torchrunの箇所に、以下のオプションを付け加えます。
```
    --checkpoint_freq $CHECKPOINT_FREQ \
    --checkpoint_dir $CHECKPOINT_DIR \
    --pretrained_weight \
```

チェックポイントの保存先ディレクトリを作成します。

```
mkdir /fsx/checkpoint
```

また、今回は継続事前学習の完了を確認したいだけであるため、
2ステップで継続事前学習を終わらせるようにします。

```bash
if [ $NEURON_EXTRACT_GRAPHS_ONLY -gt 0 ]; then
    STEPS_THIS_RUN=2
    OUTPUT_LOG=log_compile-$NODE_ID.log
elif [ -v PERF_TEST ] && [ $PERF_TEST -gt 0 ]; then
    STEPS_THIS_RUN=100
    OUTPUT_LOG=log_exe-$NODE_ID.log
else
    STEPS_THIS_RUN=2 # ここの値を-1から2に変更する
    OUTPUT_LOG=log_exe-$NODE_ID.log
fi
```

### 学習ジョブの実行

モデルをコンパイルします。実行に10分程度かかります。

```
sbatch --exclusive --nodes 4\
  --cpus-per-task 128\
  --wrap="srun neuron_parallel_compile bash $(pwd)/tp_zero1_llama3_8B_hf_pretrain.sh"\
  --output=/fsx/slurm_out/slurm-%j.out
```

実行ステータスは、`squeue`コマンドを使って確認することができます。
```
squeue
```

学習ジョブを実行します。

```
sbatch --exclusive --nodes 4\
  --cpus-per-task 128\
  --wrap="srun bash $(pwd)/tp_zero1_llama3_8B_hf_pretrain.sh"\
  --output=/fsx/slurm_out/slurm-%j.out
```


スクリプトを実行し、ログを確認します。しばらくするとlossの値が出力され、学習が進んでいることが分かります。

```
$ cat /fsx/slurm_out/slurm-6.out | grep step_loss
LOG Mon Dec  9 05:56:18 2024 - (0, 1) step_loss : 2.1128 learning_rate : 1.50e-06 throughput : 4.62 seq/s 
LOG Mon Dec  9 05:59:01 2024 - (0, 2) step_loss : 2.1125 learning_rate : 3.00e-06 throughput : 5.33 seq/s 
```

継続事前学習済みモデルは、`CHECKPOINT_DIR`で指定したディレクトリに格納されています。今回は1ステップごとにチェックポイントを保存しているため、`/fsx/checkpoint/step_2/model/`が最終的な成果物となります。
```
$ ls /fsx/checkpoint/step_2/model/
dp_rank_00_tp_rank_00_pp_rank_00.pt
dp_rank_00_tp_rank_00_pp_rank_00.pt.info.pt
dp_rank_00_tp_rank_00_pp_rank_00.pt.tensors
...
```

動作確認を行うために、NxD形式で保存されている継続事前学習済みモデルをHuggingface形式に変換します。変換はWorker nodeで行うため、Worker NodeへのSSHログインが必要です。
```
ssh compute1-st-queue1-i1-1
source ~/aws_neuron_venv_pytorch/bin/activate
cd ~/examples/tp_zero1_llama_hf_pretrain
```

変換を実行後、`exit`コマンドでHead nodeに戻ります。
```
mkdir -p /fsx/checkpoint_hf/
python3 convert_checkpoints.py --model_style hf\
  --input_dir /fsx/checkpoint/step_2/model/\
  --output_dir /fsx/checkpoint_hf\
  --load_xser True --config config.json\
  --tp_size 32 --pp_size 1 --kv_size_multiplier 4\
  --qkv_linear True --convert_to_full_state

exit
```

Transformersで読み込めるように、ファイル名を`pytorch_model.bin`にリネームします。
```
mv /fsx/checkpoint_hf/checkpoint.pt /fsx/checkpoint_hf/pytorch_model.bin
```

`config.json`などの各種ファイルを全て`pytorch_model.bin`と同階層にコピーします。

```
cd ~/examples/tp_zero1_llama_hf_pretrain
cp config.json /fsx/checkpoint_hf/
cp tokenizer.json /fsx/checkpoint_hf/
cp tokenizer_config.json /fsx/checkpoint_hf/
cp special_tokens_map.json /fsx/checkpoint_hf/
```

## 動作確認
Worker Node上で動作確認を行います。動作確認に必要なパッケージをインストールします。
```
pip config set global.extra-index-url https://pip.repos.neuron.amazonaws.com
pip install transformers==4.43.2 tokenizers==0.19.1 sentencepiece
pip install neuronx-cc==2.* torch-neuronx
pip install transformers-neuronx==0.12.313
```

プログラムを作成し、実行します。
[Transformers-neuronxのサンプルノートブック](https://github.com/aws-neuron/aws-neuron-samples/blob/master/torch-neuronx/transformers-neuronx/inference/meta-llama-3.1-8b-sampling.ipynb)を参考に作成しました。

```python
tee ./llama31-8B-sampling.py > /dev/null << EOF
import time
import torch
from transformers import AutoTokenizer
from transformers_neuronx import LlamaForSampling
from transformers import LlamaForCausalLM, LlamaTokenizer, PreTrainedTokenizerFast
from transformers_neuronx import LlamaForSampling, NeuronConfig, GQA, QuantizationConfig
from transformers_neuronx.config import GenerationConfig 

# Set this to the Hugging Face model ID
model_id = "/fsx/checkpoint_hf/"

neuron_config = NeuronConfig(
                    on_device_embedding=False,
                    attention_layout='BSH',
                    fuse_qkv=True,
                    group_query_attention=GQA.REPLICATED_HEADS,
                    quant=QuantizationConfig(quant_dtype='s8', dequant_dtype='bf16'),
              )

# load meta-llama/Llama-3.1-8B to the NeuronCores with 32-way tensor parallelism and run compilation
neuron_model = LlamaForSampling.from_pretrained(model_id, neuron_config=neuron_config, batch_size=1, tp_degree=32, amp='bf16', n_positions=8192)
neuron_model.to_neuron()

# construct a tokenizer and encode prompt text
tokenizer = AutoTokenizer.from_pretrained(model_id)
prompt = "Hello, I'm a language model and I like to"
input_ids = tokenizer.encode(prompt, return_tensors="pt")

# run inference with top-k sampling
with torch.inference_mode():
    start = time.time()
    generated_sequences = neuron_model.sample(input_ids, sequence_length=8196, top_k=50)
    elapsed = time.time() - start

generated_sequences = [tokenizer.decode(seq) for seq in generated_sequences]
print(f'generated sequences {generated_sequences} in {elapsed} seconds')

EOF
```

最後に、pythonプログラムを実行します。モデルのロードにかなり時間がかかりますが、推論結果が出力されていればOKです。
今回は2ステップのみの実行ということで、継続事前学習の効果は体感できませんが、Trainiumで継続事前学習を一通り実行できることが確認できたと思います。

```
python llama31-8B-sampling.py
```

```
# 出力
... (ログ出力)
generated sequences ['
Hello, I\'m a language model and I like to discuss about any topic. For that reason, I\'ve prepared a set of questions to help you have an interesting and educational conversation. Do you mind talking with me?\n
...（中略）
and friends in the office.
'] in 19.089792013168335 seconds
```

## 躓きポイント

### `tp_zero1_llama3_8B_hf_pretrain.sh`のソースコードでLlama3.1は動作するのか？

動作します。Llama3.1用のスクリプトもGitHubには存在しますが（[tp_zero1_llama31_8B_hf_pretrain.sh](https://github.com/aws-neuron/neuronx-distributed/blob/main/examples/training/llama/tp_zero1_llama_hf_pretrain/tp_zero1_llama31_8B_hf_pretrain.sh)）、
内部的には `tp_zero1_llama3_8B_hf_pretrain.sh`を実行しているだけなので問題ありません。

## 後片付け
### Clusterの削除
`pcluster`コマンドの`delete-cluster`を用いてClusterを削除します。
```
pcluster delete-cluster --cluster-name cluster-test --region us-west-2
```

### VPCの削除
マネジメントコンソールから「VPC」を検索し、作成したVPCを選択し、「VPCの削除」をクリックします。VPC内のリソースが削除されていない場合は削除し、VPCを削除します。


## おわりに
- 今までGPUで学習するのが当たり前だと思っていたため、Trainiumを利用した分散学習でも無事に実行できる点で新鮮さがありました。
- 学習自体のコストパフォーマンスは優れているかもしれませんが、学習に至るまでのソースコード修正に時間がかかる感覚がありました。
    - NxD周りは開発中のものが多く、情報が少ないため学習を動かすまでの時間がかかってしまいました。
    - ある程度サービスが整ってくると使いやすくなるのではと期待しています。
- trn2インスタンスが発表されたため、より高速化されることを期待しています！

## 謝辞
本記事は、[AWS Generative AI Innovation Center](https://aws.amazon.com/jp/ai/generative-ai/innovation-center/) の支援を受けて執筆いたしました。ご協力いただいたAWSの皆様、ありがとうございました。

## 参考文献
- Trainiumの概要: https://aws.amazon.com/jp/ai/machine-learning/trainium/
- Trainiumに関する詳細（アーキテクチャ回り）: https://speakerdeck.com/htokoyo/aws-innovate-aws-inferentia-trainium
- AWSで作る自社用基盤モデル: https://pages.awscloud.com/rs/112-TZM-766/images/3_20230511-aiml_AWS-FM-Model.pdf