Amazon ECS の GPU 共有でコンテナごとのメモリ・演算の上限が守られるか試してみた

Amazon ECS の GPU 共有でコンテナごとのメモリ・演算の上限が守られるか試してみた

Amazon ECS の GPU 共有(NVIDIA MPS)を検証しました。メモリと演算の上限は指定通りに守られ、演算の上限の合計が 100% 以内なら性能も互いに影響しませんでした。一方で、コンテナ内で環境変数を書き換えると上限を外せることも分かりました。
2026.10.29

はじめに

2026 年 10 月 27 日、Amazon ECS が NVIDIA Multi-Process Service(MPS)による GPU 共有に対応しました。

https://aws.amazon.com/about-aws/whats-new/2026/10/amazon-ecs-gpu-sharing-nvidia-mps/

これまで ECS のタスクには GPU を 1 枚単位でしか割り当てられず、GPU の一部しか使わない小さな推論モデルでも、GPU を 1 枚専有していました。今回の対応で、1 枚の GPU を複数のタスクで共有し、コンテナごとに GPU メモリと演算の上限を指定できるようになります。

本記事では、EC2 の g5.xlarge で、コンテナごとの上限が本当に守られるのか、複数のタスクを同時に動かすと性能はどうなるのかを検証してみます!

GPU 共有の設定

GPU 共有は、タスク定義のコンテナ定義にある resourceRequirements で、GPU を専有する場合の value の代わりに sharingStrategy を指定します。

"resourceRequirements": [
  {
    "type": "GPU",
    "sharingStrategy": {
      "nvidiaMps": {
        "memory": 4096,
        "maxComputePercent": 50
      }
    }
  }
]

memory はコンテナが使える GPU メモリの上限で、単位は MiB です。maxComputePercent は演算の上限で、1〜100 の整数で指定し、省略すると 100 になります。

ECS は GPU メモリの残量だけを見てタスクを配置し、演算の上限は配置の判断に使いません。

ドキュメントには、ECS が MPS のデーモンを起動し、コンテナごとの上限を強制すると書かれています。

https://docs.aws.amazon.com/AmazonECS/latest/developerguide/ecs-gpu-sharing.html

検証環境

検証環境は次の通りです。

  • インスタンス:g5.xlarge。GPU は NVIDIA A10G で、SM は 80 個、GPU メモリは ECS 上で 22,587 MiB
  • AMI:Amazon Linux 2023 の GPU 用 ECS 最適化 AMI 2023.0.20261022
  • ソフトウェア:ECS エージェント v1.108.1、Docker v25.0.16、NVIDIA ドライバー v580.178.04
  • コンテナイメージ:pytorch/pytorch:2.14.1-cuda12.6-cudnn9-runtime

タスク定義は、GPU の指定だけを変えた 6 種類を用意しました。

タスク定義 GPU の指定
mps-bench-dedicated GPU を 1 枚専有
mps-bench-m4096-c25 memory 4096、maxComputePercent 25
mps-bench-m4096-c50 memory 4096、maxComputePercent 50
mps-bench-m4096-c75 memory 4096、maxComputePercent 75
mps-bench-m4096 memory 4096、maxComputePercent なし
mps-bench-m20480 memory 20480、maxComputePercent なし

計測用のスクリプトは Python と PyTorch で書き、環境変数 MODE で次の動作を切り替えます。

  • mem:GPU メモリを 256 MiB ずつ確保し、失敗するまでの量を記録する
  • compute:FP16 の 8192×8192 の行列積を繰り返し、スループットを TFLOPS で記録する

結果は 1 行の JSON でログに出力しています。想定通りに上限がかからなくても止まるよう、念のため確保するメモリと実行時間には上限を設けました。

スクリプトはイメージをビルドせずに済むよう SSM パラメータに入れ、シークレットとしてコンテナに渡しています。

CloudFormation テンプレート
AWSTemplateFormatVersion: "2010-09-09"
Description: Amazon ECS GPU sharing (NVIDIA MPS) benchmark on g5.xlarge

Parameters:
  VpcId:
    Type: AWS::EC2::VPC::Id
  SubnetIds:
    Type: List<AWS::EC2::Subnet::Id>
  DesiredCapacity:
    Type: Number
    AllowedValues: [0, 1]
    Default: 0
  BenchImage:
    Type: String
    Default: pytorch/pytorch:2.14.1-cuda12.6-cudnn9-runtime
  GpuAmiId:
    Type: AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>
    Default: /aws/service/ecs/optimized-ami/amazon-linux-2023/gpu/recommended/image_id

Resources:
  Cluster:
    Type: AWS::ECS::Cluster
    Properties:
      ClusterName: mps-bench
      ClusterSettings:
        - Name: containerInsights
          Value: enhanced

  LogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: /ecs/mps-bench
      RetentionInDays: 14

  BenchScript:
    Type: AWS::SSM::Parameter
    Properties:
      Name: /mps-bench/bench.py
      Type: String
      Value: |
        import json, os, time, urllib.request

        MODE = os.environ.get("MODE", "info")
        KEYS = ["CUDA_MPS_PINNED_DEVICE_MEM_LIMIT", "CUDA_MPS_ACTIVE_THREAD_PERCENTAGE",
                "CUDA_MPS_PIPE_DIRECTORY", "NVIDIA_VISIBLE_DEVICES"]
        ORIG = {k: os.environ.get(k) for k in KEYS}


        def emit(**kw):
            kw.update(run=os.environ.get("RUN_ID", ""), mode=MODE, ts=round(time.time(), 3))
            print(json.dumps(kw), flush=True)


        # 上限の強制のされ方を確かめるため、CUDA の初期化前に環境変数を書き換える
        BYPASS = os.environ.get("BYPASS", "")
        if BYPASS == "unset":
            for k in KEYS[:2]:
                os.environ.pop(k, None)
        elif BYPASS == "raise":
            os.environ["CUDA_MPS_PINNED_DEVICE_MEM_LIMIT"] = "0=20G"
            os.environ["CUDA_MPS_ACTIVE_THREAD_PERCENTAGE"] = "100"

        try:
            url = os.environ["ECS_CONTAINER_METADATA_URI_V4"] + "/task"
            TASK = json.load(urllib.request.urlopen(url, timeout=2))["TaskARN"].split("/")[-1]
        except Exception:
            TASK = None

        # 全員が揃ってから計測を始めるため、指定時刻まで待つ
        start_at = float(os.environ.get("START_AT", "0"))
        if start_at > time.time():
            time.sleep(min(start_at - time.time(), 300))

        import torch

        p = torch.cuda.get_device_properties(0)
        free, total = torch.cuda.mem_get_info()
        emit(event="info", task=TASK, bypass=BYPASS, env=ORIG,
             effective={k: os.environ.get(k) for k in KEYS[:2]},
             gpu=p.name, uuid=str(getattr(p, "uuid", "")), sms=p.multi_processor_count,
             total_mib=total >> 20, free_mib=free >> 20,
             torch=torch.__version__, cuda=torch.version.cuda)

        if MODE == "mem":
            chunk = int(os.environ.get("CHUNK_MIB", "256"))
            cap = min(int(os.environ.get("MAX_MIB", "24000")), 24000)
            bufs, err = [], None
            while len(bufs) * chunk < cap:
                try:
                    bufs.append(torch.empty(chunk << 20, dtype=torch.uint8, device="cuda"))
                except Exception as e:
                    err = f"{type(e).__name__}: {str(e)[:400]}"
                    break
            torch.cuda.synchronize()
            emit(event="mem", allocated_mib=len(bufs) * chunk, error=err)
            time.sleep(min(float(os.environ.get("HOLD_SEC", "0")), 300))

        elif MODE == "compute":
            n = int(os.environ.get("N", "8192"))
            dur = min(float(os.environ.get("DURATION", "60")), 600)
            win = float(os.environ.get("WINDOW", "5"))
            a = torch.randn(n, n, device="cuda", dtype=torch.float16)
            b = torch.randn_like(a)
            for _ in range(3):
                a @ b
            torch.cuda.synchronize()
            t_start, total_it = time.time(), 0
            while time.time() - t_start < dur and total_it < 200000:
                w0, it = time.time(), 0
                while time.time() - w0 < win:
                    for _ in range(10):
                        a @ b
                    torch.cuda.synchronize()
                    it += 10
                w1 = time.time()
                total_it += it
                emit(event="window", t0=round(w0, 3), t1=round(w1, 3),
                     tflops=round(2 * n**3 * it / (w1 - w0) / 1e12, 2))
            emit(event="compute_done", iters=total_it,
                 tflops=round(2 * n**3 * total_it / (time.time() - t_start) / 1e12, 2))

        elif MODE == "crash":
            # 範囲外のインデックスで GPU 側のアサーションを発生させる
            x = torch.zeros(8, device="cuda")
            try:
                x[torch.tensor([1 << 20], device="cuda")].sum().item()
                torch.cuda.synchronize()
                emit(event="crash", error=None)
            except Exception as e:
                emit(event="crash", error=f"{type(e).__name__}: {str(e)[:400]}")

  InstanceRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal: { Service: ec2.amazonaws.com }
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AmazonEC2ContainerServiceforEC2Role
        - arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore

  InstanceProfile:
    Type: AWS::IAM::InstanceProfile
    Properties:
      Roles: [!Ref InstanceRole]

  ExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal: { Service: ecs-tasks.amazonaws.com }
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy
      Policies:
        - PolicyName: read-bench-script
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action: ssm:GetParameters
                Resource: !Sub arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/mps-bench/*

  InstanceSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: mps-bench instances (no inbound)
      VpcId: !Ref VpcId

  LaunchTemplate:
    Type: AWS::EC2::LaunchTemplate
    Properties:
      LaunchTemplateData:
        ImageId: !Ref GpuAmiId
        InstanceType: g5.xlarge
        IamInstanceProfile: { Arn: !GetAtt InstanceProfile.Arn }
        SecurityGroupIds: [!Ref InstanceSecurityGroup]
        MetadataOptions: { HttpTokens: required, HttpPutResponseHopLimit: 2 }
        BlockDeviceMappings:
          - DeviceName: /dev/xvda
            Ebs: { VolumeSize: 80, VolumeType: gp3, DeleteOnTermination: true }
        UserData:
          Fn::Base64: !Sub |
            #!/bin/bash
            cat >> /etc/ecs/ecs.config <<EOF
            ECS_CLUSTER=${Cluster}
            ECS_ENABLE_GPU_SUPPORT=true
            ECS_IMAGE_PULL_BEHAVIOR=prefer-cached
            EOF
            systemctl enable --now amazon-ssm-agent || (dnf install -y amazon-ssm-agent && systemctl enable --now amazon-ssm-agent)

  AutoScalingGroup:
    Type: AWS::AutoScaling::AutoScalingGroup
    Properties:
      MinSize: "0"
      MaxSize: "1"
      DesiredCapacity: !Ref DesiredCapacity
      VPCZoneIdentifier: !Ref SubnetIds
      LaunchTemplate:
        LaunchTemplateId: !Ref LaunchTemplate
        Version: !GetAtt LaunchTemplate.LatestVersionNumber
      Tags:
        - Key: Name
          Value: mps-bench
          PropagateAtLaunch: true

  TdDedicated:
    Type: AWS::ECS::TaskDefinition
    Properties:
      Family: mps-bench-dedicated
      RequiresCompatibilities: [EC2]
      NetworkMode: bridge
      ExecutionRoleArn: !GetAtt ExecutionRole.Arn
      ContainerDefinitions:
        - Name: bench
          Image: !Ref BenchImage
          Essential: true
          Cpu: 512
          Memory: 3072
          Command: ["sh", "-c", "printf '%s' \"$BENCH_SCRIPT\" > /tmp/bench.py && exec python /tmp/bench.py"]
          Secrets:
            - Name: BENCH_SCRIPT
              ValueFrom: !Ref BenchScript
          ResourceRequirements:
            - Type: GPU
              Value: "1"
          LogConfiguration:
            LogDriver: awslogs
            Options:
              awslogs-group: !Ref LogGroup
              awslogs-region: !Ref AWS::Region
              awslogs-stream-prefix: mps-bench-dedicated

  TdMps25:
    Type: AWS::ECS::TaskDefinition
    Properties:
      Family: mps-bench-m4096-c25
      RequiresCompatibilities: [EC2]
      NetworkMode: bridge
      ExecutionRoleArn: !GetAtt ExecutionRole.Arn
      ContainerDefinitions:
        - Name: bench
          Image: !Ref BenchImage
          Essential: true
          Cpu: 512
          Memory: 3072
          Command: ["sh", "-c", "printf '%s' \"$BENCH_SCRIPT\" > /tmp/bench.py && exec python /tmp/bench.py"]
          Secrets:
            - Name: BENCH_SCRIPT
              ValueFrom: !Ref BenchScript
          ResourceRequirements:
            - Type: GPU
              SharingStrategy:
                NvidiaMps:
                  Memory: 4096
                  MaxComputePercent: 25
          LogConfiguration:
            LogDriver: awslogs
            Options:
              awslogs-group: !Ref LogGroup
              awslogs-region: !Ref AWS::Region
              awslogs-stream-prefix: mps-bench-m4096-c25

  TdMps50:
    Type: AWS::ECS::TaskDefinition
    Properties:
      Family: mps-bench-m4096-c50
      RequiresCompatibilities: [EC2]
      NetworkMode: bridge
      ExecutionRoleArn: !GetAtt ExecutionRole.Arn
      ContainerDefinitions:
        - Name: bench
          Image: !Ref BenchImage
          Essential: true
          Cpu: 512
          Memory: 3072
          Command: ["sh", "-c", "printf '%s' \"$BENCH_SCRIPT\" > /tmp/bench.py && exec python /tmp/bench.py"]
          Secrets:
            - Name: BENCH_SCRIPT
              ValueFrom: !Ref BenchScript
          ResourceRequirements:
            - Type: GPU
              SharingStrategy:
                NvidiaMps:
                  Memory: 4096
                  MaxComputePercent: 50
          LogConfiguration:
            LogDriver: awslogs
            Options:
              awslogs-group: !Ref LogGroup
              awslogs-region: !Ref AWS::Region
              awslogs-stream-prefix: mps-bench-m4096-c50

  TdMps75:
    Type: AWS::ECS::TaskDefinition
    Properties:
      Family: mps-bench-m4096-c75
      RequiresCompatibilities: [EC2]
      NetworkMode: bridge
      ExecutionRoleArn: !GetAtt ExecutionRole.Arn
      ContainerDefinitions:
        - Name: bench
          Image: !Ref BenchImage
          Essential: true
          Cpu: 512
          Memory: 3072
          Command: ["sh", "-c", "printf '%s' \"$BENCH_SCRIPT\" > /tmp/bench.py && exec python /tmp/bench.py"]
          Secrets:
            - Name: BENCH_SCRIPT
              ValueFrom: !Ref BenchScript
          ResourceRequirements:
            - Type: GPU
              SharingStrategy:
                NvidiaMps:
                  Memory: 4096
                  MaxComputePercent: 75
          LogConfiguration:
            LogDriver: awslogs
            Options:
              awslogs-group: !Ref LogGroup
              awslogs-region: !Ref AWS::Region
              awslogs-stream-prefix: mps-bench-m4096-c75

  TdMps100:
    Type: AWS::ECS::TaskDefinition
    Properties:
      Family: mps-bench-m4096
      RequiresCompatibilities: [EC2]
      NetworkMode: bridge
      ExecutionRoleArn: !GetAtt ExecutionRole.Arn
      ContainerDefinitions:
        - Name: bench
          Image: !Ref BenchImage
          Essential: true
          Cpu: 512
          Memory: 3072
          Command: ["sh", "-c", "printf '%s' \"$BENCH_SCRIPT\" > /tmp/bench.py && exec python /tmp/bench.py"]
          Secrets:
            - Name: BENCH_SCRIPT
              ValueFrom: !Ref BenchScript
          ResourceRequirements:
            - Type: GPU
              SharingStrategy:
                NvidiaMps:
                  Memory: 4096
          LogConfiguration:
            LogDriver: awslogs
            Options:
              awslogs-group: !Ref LogGroup
              awslogs-region: !Ref AWS::Region
              awslogs-stream-prefix: mps-bench-m4096

  TdMpsBig:
    Type: AWS::ECS::TaskDefinition
    Properties:
      Family: mps-bench-m20480
      RequiresCompatibilities: [EC2]
      NetworkMode: bridge
      ExecutionRoleArn: !GetAtt ExecutionRole.Arn
      ContainerDefinitions:
        - Name: bench
          Image: !Ref BenchImage
          Essential: true
          Cpu: 512
          Memory: 3072
          Command: ["sh", "-c", "printf '%s' \"$BENCH_SCRIPT\" > /tmp/bench.py && exec python /tmp/bench.py"]
          Secrets:
            - Name: BENCH_SCRIPT
              ValueFrom: !Ref BenchScript
          ResourceRequirements:
            - Type: GPU
              SharingStrategy:
                NvidiaMps:
                  Memory: 20480
          LogConfiguration:
            LogDriver: awslogs
            Options:
              awslogs-group: !Ref LogGroup
              awslogs-region: !Ref AWS::Region
              awslogs-stream-prefix: mps-bench-m20480

Outputs:
  ClusterName:
    Value: !Ref Cluster
  AutoScalingGroupName:
    Value: !Ref AutoScalingGroup

スタックは次のコマンドで作成しました。サブネットには、Docker Hub と AWS の API に接続できるものを指定します。

aws cloudformation deploy \
  --stack-name mps-bench \
  --template-file mps-bench.yaml \
  --capabilities CAPABILITY_IAM \
  --parameter-overrides \
    VpcId=<VPC の ID> \
    SubnetIds=<サブネットの ID>,<サブネットの ID> \
    DesiredCapacity=1

タスクを起動し、環境変数で動作を切り替えてから結果を収集します。スクリプトの実行には、AWS CLI のほかに jq が必要です。

aws ecs run-task \
  --cluster mps-bench \
  --task-definition mps-bench-m4096-c50 \
  --launch-type EC2 \
  --overrides '{"containerOverrides":[{"name":"bench","environment":[{"name":"MODE","value":"compute"}]}]}'
タスクの起動と結果を収集するスクリプト
#!/usr/bin/env bash
# mps-bench のタスクを起動し、終了後にログの JSON 行を集めて保存する。
# 使い方: run.sh <ラベル> '<ファミリー>|K=V|K=V' ['<ファミリー>|K=V' ...] [--delay 秒] [--no-wait]
# 同じ呼び出しで指定したタスクには同じ START_AT を渡し、計測の開始を揃える。
set -eo pipefail

CLUSTER=mps-bench
LOG_GROUP=/ecs/mps-bench
OUT="$(cd "$(dirname "$0")" && pwd)/results"

label=$1; shift
delay=45 wait=true specs=()
while (($#)); do
  case $1 in
    --delay) delay=$2; shift 2 ;;
    --no-wait) wait=false; shift ;;
    *) specs+=("$1"); shift ;;
  esac
done
start_at=$(($(date +%s) + delay))
mkdir -p "$OUT"
save() { printf '%s\n' "$rec" > "$OUT/$label.json"; }

tasks='[]' failures='[]'
for i in "${!specs[@]}"; do
  IFS='|' read -ra parts <<<"${specs[$i]}"
  family=${parts[0]}
  env=$(jq -n --arg run "$label-$i" --arg at "$start_at" '{RUN_ID: $run, START_AT: $at}')
  for kv in "${parts[@]:1}"; do
    env=$(jq --arg k "${kv%%=*}" --arg v "${kv#*=}" '.[$k] = $v' <<<"$env")
  done
  overrides=$(jq -c '{containerOverrides: [{name: "bench",
    environment: (to_entries | map({name: .key, value: .value}))}]}' <<<"$env")
  r=$(aws ecs run-task --cluster "$CLUSTER" --task-definition "$family" \
    --launch-type EC2 --count 1 --overrides "$overrides" --output json)
  failures=$(jq -c --argjson r "$r" '. + $r.failures' <<<"$failures")
  tasks=$(jq -c --argjson r "$r" --arg f "$family" --argjson env "$env" \
    '. + [$r.tasks[] | {arn: .taskArn, family: $f, env: $env}]' <<<"$tasks")
done
rec=$(jq -n --arg label "$label" --argjson at "$start_at" \
  --argjson tasks "$tasks" --argjson failures "$failures" \
  '{label: $label, start_at: $at, tasks: $tasks, failures: $failures}')
jq -c '{launched: (.tasks | length), failures}' <<<"$rec"

arns=$(jq -r '.tasks[].arn' <<<"$rec")
if [[ -z $arns ]]; then save; exit; fi
sleep $((delay < 20 ? delay : 20))
gpu='[]'
ci=$(aws ecs list-container-instances --cluster "$CLUSTER" --query containerInstanceArns --output text)
if [[ -n $ci && $ci != None ]]; then
  gpu=$(aws ecs describe-container-instances --cluster "$CLUSTER" --container-instances $ci \
    --query 'containerInstances[].gpuDevices[]' --output json)
fi
running=$(aws ecs describe-tasks --cluster "$CLUSTER" --tasks $arns --query tasks --output json)
rec=$(jq --argjson g "$gpu" --argjson d "$running" \
  '.gpu_devices_running = $g | .describe_tasks_running = $d' <<<"$rec")
if ! $wait; then save; exit; fi

# 待機は 1 回あたり最大 10 分。2 回で打ち切る
for _ in 1 2; do
  aws ecs wait tasks-stopped --cluster "$CLUSTER" --tasks $arns && break
done
stopped=$(aws ecs describe-tasks --cluster "$CLUSTER" --tasks $arns --query tasks --output json)
n=$(jq '.tasks | length' <<<"$rec")
for ((i = 0; i < n; i++)); do
  arn=$(jq -r ".tasks[$i].arn" <<<"$rec")
  family=$(jq -r ".tasks[$i].family" <<<"$rec")
  lines=$(aws logs filter-log-events --log-group-name "$LOG_GROUP" \
    --log-stream-names "$family/bench/${arn##*/}" \
    --query 'events[].message' --output json 2>/dev/null || echo '[]')
  rec=$(jq --argjson i "$i" --arg arn "$arn" --argjson d "$stopped" --argjson lines "$lines" '
    ($d[] | select(.taskArn == $arn)) as $t | $t.containers[0] as $c
    | [$lines[] | fromjson? | objects] as $json
    | .tasks[$i] += {
        stopped_reason: $t.stoppedReason, exit_code: $c.exitCode, reason: $c.reason,
        gpu_ids: $c.gpuIds, gpu_sharing: $c.gpuSharingStrategy,
        json: $json, other_lines: (($lines | length) - ($json | length)),
        tail: ([$lines[] | select(startswith("{") | not)] | .[-5:])
      }' <<<"$rec")
done
save
jq -c '.tasks[] | {family, exit: .exit_code, gpu: .gpu_ids,
  events: [.json[] | select(.event | IN("info", "mem", "compute_done", "crash"))],
  other_lines}' <<<"$rec"

コンテナに上限が設定される仕組み

まず、インスタンスの中で MPS がどう動いているのかを確認しました。

GPU 用の AMI では、systemd の nvidia-mps.service が MPS の制御デーモンを nvidia-cuda-mps-control -f -multiuser-server で起動していました。ecs.service より先に起動する設定で、MPS との通信には /run/mps-ecs を使います。

memory が 4096、maxComputePercent が 50 のタスクを起動すると、コンテナには ECS が次の環境変数を設定していました。

{
  "CUDA_MPS_PINNED_DEVICE_MEM_LIMIT": "0=4096M",
  "CUDA_MPS_ACTIVE_THREAD_PERCENTAGE": "50",
  "CUDA_MPS_PIPE_DIRECTORY": "/run/mps-ecs",
  "NVIDIA_VISIBLE_DEVICES": "void"
}

複数のタスクを起動しても MPS のサーバーのプロセスは 1 つだけで、全てのタスクがこのサーバーを共有していました。サーバー側の設定は、演算が 100%、メモリは上限なしのままです。

つまり、コンテナごとの上限は、サーバーではなくコンテナの環境変数で指定されているということです。

ECS の GPU 共有の構成図。ECS エージェントが上限を環境変数としてコンテナに設定し、各コンテナは 1 つの MPS サーバーを経由して GPU を使う

共有の状態は、DescribeContainerInstances の応答の gpuDevices で確認できます。以下は maxComputePercent が 50 のタスクを 2 つ実行している間の値です。

[
  {
    "gpuId": "GPU-e2499c30-9e84-17c2-4315-944340692a58",
    "sharingMode": "NVIDIA_MPS",
    "totalMemoryInMiB": 22587,
    "remainingMemoryInMiB": 14395,
    "containerCount": 2
  }
]

remainingMemoryInMiB は、22,587 MiB から 2 タスク分の 4,096 MiB を引いた値です。

コンテナごとの設定は、DescribeTasks の応答の gpuIds と gpuSharingStrategy で確認できます。

"gpuIds": ["GPU-e2499c30-9e84-17c2-4315-944340692a58"],
"gpuSharingStrategy": {"nvidiaMps": {"memory": 4096, "maxComputePercent": 50}}

メモリの上限が守られているか検証

memory が 4096 のタスクで GPU メモリを確保していくと、3,840 MiB で OutOfMemoryError になりました。確保の単位を 32 MiB に細かくすると、3,936 MiB まで確保できました。

CUDA の初期化直後に取得した空き容量は 3,960 MiB でした。上限の 4,096 MiB から、CUDA 自身が使う分を引いた量なのではないかと思われます。

対照として、GPU を専有するタスクで同じことを試すと、22,272 MiB まで確保できました。

つまり、メモリの上限は指定した通りに守られていることが分かります。

演算の上限が守られているか検証

maxComputePercent を指定すると、PyTorch から見える SM の数が割合通りに減りました。

maxComputePercent SM の数
指定なし 80
75 60
50 40
25 20

1 タスクだけで行列積を実行した場合のスループットは次の通りでした。

1 タスクだけで実行した場合のスループットのグラフ。GPU を専有すると 64.3、MPS で上限なしは 64.0、上限 75、50、25 はそれぞれ 50.8、34.2、17.3 TFLOPS

GPU を専有した場合の 64.3 TFLOPS に対して、上限 25、50、75 のスループットはそれぞれ 26.9、53.2、79.0% でした。3 回の計測で、ほとんど差はありません。

設定した割合より少し高いのは、メモリ帯域など SM 以外の資源は分割されないためなのではないかと推測しています。

上限を指定しない MPS のタスクは 64.0 TFLOPS で、GPU を専有した場合とほぼ同じでした。MPS を経由すること自体による性能の低下は、ほとんどなさそうです。

複数のタスクを同時に動かした場合の性能

次に、複数のタスクを同時に動かし、60 秒間のスループットを計測しました。

複数のタスクを同時に実行した場合の合計スループットのグラフ。上限 50 を 2 つで 68.3、上限 75 を 2 つで 67.3、上限 25 を 4 つで 67.4、上限なしを 2 つで 57.7 TFLOPS

maxComputePercent が 50 のタスクを 2 つ動かすと、それぞれ 34.16 TFLOPS で、1 タスクだけのときの 34.21 TFLOPS とほぼ同じでした。上限 25 のタスクを 4 つ動かした場合も同様です。

上限 50 のタスク 2 つの合計は 68.3 TFLOPS で、GPU を専有した場合の 64.3 TFLOPS を上回りました。上限を指定して分けると、1 つのタスクで専有するより合計のスループットが上がるようです。

上限の合計が 150% になる、上限 75 のタスク 2 つでは、それぞれ 33.65 TFLOPS で、GPU をほぼ半分ずつ分け合う結果になりました。

一方、上限を指定しないタスクを 2 つ動かすと、それぞれ 28.87 TFLOPS で、合計は 57.7 TFLOPS と GPU を専有した場合より下がりました。2 つのタスクが同じ SM を取り合うことによる損失なのではないか、と推測しています。

同じ GPU に載せるタスクの maxComputePercent の合計を 100% 以内にしておくと、タスク同士の性能は互いに影響しませんでした。

コンテナの中で環境変数を書き換えた場合

上限が環境変数で渡されているのであれば、コンテナの中で書き換えるとどうなるのでしょうか?気になったので試してみました。

memory が 4096、maxComputePercent が 25 のタスクで、CUDA を初期化する前に環境変数を消すか、値を変えます。

import os

# 上限を消す場合
os.environ.pop("CUDA_MPS_PINNED_DEVICE_MEM_LIMIT", None)
os.environ.pop("CUDA_MPS_ACTIVE_THREAD_PERCENTAGE", None)

# 上限を変える場合
# os.environ["CUDA_MPS_PINNED_DEVICE_MEM_LIMIT"] = "0=20G"
# os.environ["CUDA_MPS_ACTIVE_THREAD_PERCENTAGE"] = "100"

import torch  # この後の最初の CUDA の呼び出しで初期化される

すると、どちらの方法でも上限が外れました。

項目 上限通り 環境変数を消した場合 環境変数を変えた場合
SM の数 20 80 80
スループット 17.27 TFLOPS 64.26 TFLOPS 63.86 TFLOPS
確保できたメモリ 3,840 MiB 20,224 MiB 20,224 MiB

20,224 MiB は、スクリプトに設けた上限で止めた値です。GPU のメモリが足りなくなったわけではありません。

これらの環境変数は、制御デーモンに設定した上限を更に厳しくする方向にしか働きません[1]。今回の環境ではデーモン側に上限がないため、環境変数を消すとそのまま上限がなくなる、ということだと思います。

なお、RunTask の containerOverrides でこれらの環境変数を指定すると、リクエスト自体は受け付けられますが、コンテナには ECS が設定した値が入っていました。外から上書きすることはできず、上限を外せるのはコンテナの中で動くコードだけです。

上限を外したタスクが他のタスクに与える影響

上限を外したタスクが 20,224 MiB を確保している間に、memory が 4096 の別のタスクを起動しました。

すると、このタスクは自分の上限の 4,096 MiB に届かず、1,792 MiB で OutOfMemoryError になりました。

このときの remainingMemoryInMiB は 14,395 MiB で、上限を外したタスクの超過は反映されていませんでした。ECS は、タスク定義の値で残量を計算しているようです。

ドキュメントの「コンテナごとの上限を強制する」という説明から、私はサーバー側で強制されるものを想像していたので、この挙動は少し意外でした。上限は、コンテナが環境変数を書き換えないことを前提にした仕組みだと考えた方が良さそうです。

ドキュメントには、別のサービスのタスクが同じ GPU に載らないようにするには、キャパシティープロバイダーかクラスターを分けるよう書かれています。

GPU メモリが足りない場合

memory が 20480 のタスクを実行している間に、memory が 4096 のタスクを起動しました。残りは 2,107 MiB なので、配置できないはずです。

想定通り、RunTask の応答の failures に RESOURCE:GPU_MEMORY が返り、タスクは起動しませんでした。

"failures": [
  {
    "arn": "arn:aws:ecs:ap-northeast-1:123456789012:container-instance/b6c1f565ead746628ff6280bbdc27ed3",
    "reason": "RESOURCE:GPU_MEMORY"
  }
]

Container Insights で取得できる GPU のメトリクス

オブザーバビリティが強化された Container Insights を有効にしていましたが、MPS で共有するタスクには GPU に関する項目が記録されませんでした。GPU を専有するタスクでは、パフォーマンスログに GpuDevicesReserved が 1.0 と記録されています。

ドキュメントによれば、MPS で共有する場合は、タスク単位とコンテナ単位の GPU の使用率やメモリ使用量のメトリクスが出力されません。

電力や温度は出力されるとのことですが、今回は GPU を専有した場合を含めて、使用率などのメトリクスが出ていませんでした。EC2 の場合は、CloudWatch エージェントの導入が必要なのかもしれません。

おわりに

結論としては、ECS の GPU 共有では、メモリと演算の上限が指定した通りに守られ、演算の上限の合計を 100% 以内にすると、タスク同士の性能も互いに影響しませんでした。

ただし、上限はコンテナの環境変数で伝えられているため、コンテナの中のコードが書き換えると制限が外れてしまいます。別のチームや顧客のワークロードを同じ GPU に載せる場合は、キャパシティープロバイダーやクラスターを分けておきましょう。

GPU を使い切らないワークロードを複数お持ちの方は、ぜひ試してみてください!

脚注
  1. Appendix: Environment Variables - Multi-Process Service ↩︎

この記事をシェアする

関連記事