⚙️

Kubernetesの分散処理をOptunaで最適化してみた

2021/02/17に公開

概要

  • レンダリングを高速化したい。どうせなら手持ちのPCを活用したい。ということでオンプレミス環境でK8sクラスタを構築しました。
  • K8sクラスタ化により高速化はできたので、さらなる高速化のためOputunaでK8sパラメーターを最適化しました。
  • レンダリング時間: 16分40秒 -> 1分40秒。

注意

  • オンプレミス環境でK8sを使用しています。クラウドの話はありません。
  • K8sを用いた分散処理の話です。DevOpsの話はありません。
  • K8s v1.19.2 を使用しています。

詳細

レイトレーシングを用いたレンダリングプログラムを書いており、処理に非常に時間が掛かるので、K8sクラスタで処理できるようにしました。
クラウドは使わず、手持ちのPCのみでK8sクラスタを構築しています。

レイトレーシングについて

※ レイトレーシングをご存知の方はこの項目を読み飛ばして構いません。

レイトレーシングにおいては視界を例えば1920x1080に分割して、それぞれのピクセルに対してレイを飛ばし、反射や拡散等を計算していきます。この時、各ピクセル内において、複数回レイを飛ばします。ピクセルの中心からランダム距離だけずれた位置に向かってレイを飛ばして平均を取ることで、より鮮明な画像を得るためです。例えばピクセルごとに100個のレイを飛ばした場合と10000個のレイを飛ばした場合では、下図のような違いとなって現れます。

100レイ/ピクセル 10000レイ/ピクセル

美しい結果を得るためには多くのレイを飛ばしたい、でも多く飛ばすと処理に時間が掛かるということでK8sを使ってみた次第です。

構成

VMを使わずに、PCにUbuntuをインストールし、その上でUbuntuコンテナを動かします。
下記3台のPCをNodeとして使用します。

Node CPU CPUコア数 スレッド数 メモリ OS
Node A Intel Corei7-3770 4 8 16G Ubuntu 18.04 x64
Node B Intel Corei7-4810MQ 4 8 16G Ubuntu 20.04 x64
Node C AMD Ryzen 5 PRO 3500U 4 8 6G Ubuntu 20.04 x64

Producer Podからレンダリング対象ピクセルを通知し、Consumer Podでレンダリングを行います。レンダリング処理はそれぞれ独立しているので、個々のConsumer Podで分散処理を行います。

Producer yaml

apiVersion: v1
kind: Service
metadata:
  name: producer-external-service
  labels:
    run: producer-external-service
spec:
  type: NodePort
  selector:
    app: producer-pod
  ports:
  - port: 12345
    protocol: TCP
---
apiVersion: v1
kind: Service
metadata:
  name: producer-internal-service
  labels:
    run: producer-internal-service
spec:
  selector:
    app: producer-pod
  ports:
  - port: 12346
    protocol: TCP
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: render-config
data:
  picture-width: "256"
  picture-height: "256"
  rays-per-pixel-producer: "1"
  rays-per-pixel-consumer: "10000"
  producer-internal-address: "producer-internal-service"
---
apiVersion: apps/v1
kind: ReplicaSet
metadata:
  name: producer-pod
  labels:
    app: producer-pod
spec:
  replicas: 1
  selector:
    matchLabels:
      app: producer-pod
  template:
    metadata:
      labels:
        app: producer-pod
    spec:
      containers:
      - name: producer-pod
        image: producer:20.04
        imagePullPolicy: Never
        env:
        - name: RAYS_PER_PIXEL_PRODUCER
          valueFrom:
            configMapKeyRef:
              name: render-config
              key: rays-per-pixel-producer
        - name: RAYS_PER_PIXEL_CONSUMER
          valueFrom:
            configMapKeyRef:
              name: render-config
              key: rays-per-pixel-consumer
        - name: PICTURE_WIDTH
          valueFrom:
            configMapKeyRef:
              name: render-config
              key: picture-width
        - name: PICTURE_HEIGHT
          valueFrom:
            configMapKeyRef:
              name: render-config
              key: picture-height
        resources:
          requests:
            cpu: "100m"
            memory: "128Mi"
        #  limits:
        #    cpu: "2000m"
        #    memory: "256Mi"
        ports:
        - containerPort: 12345
        - containerPort: 12346
      nodeName: corei7

Consumer yaml

apiVersion: apps/v1
kind: ReplicaSet
metadata:
  name: consumer-pod
  labels:
    app: consumer-pod
spec:
  replicas: 128
  selector:
    matchLabels:
      app: consumer-pod
  template:
    metadata:
      labels:
        app: consumer-pod
    spec:
      containers:
      - name: consumer-pod
        image: consumer:20.04
        imagePullPolicy: Never
        env:
        - name: PRODUCER_INTERNAL_ADDRESS
          valueFrom:
            configMapKeyRef:
              name: render-config
              key: producer-internal-address
        - name: PICTURE_WIDTH
          valueFrom:
            configMapKeyRef:
              name: render-config
              key: picture-width
        - name: PICTURE_HEIGHT
          valueFrom:
            configMapKeyRef:
              name: render-config
              key: picture-height
        resources:
          requests:
            cpu: "100m"
            memory: "128Mi"

Producer - Consumer間通信

Pod間通信にはFlannelを使用しており、ProducerとConsumerはTCPにて接続します。
通信手順は以下の通りです。

  • Consumer起動時にProducerとのTCPコネクションを確立。

  • ProducerからConsumerにレンダリング対象ピクセルを伝える。
    Producer <= Consumer: 1 バイトの 'R' を送る。
    Producer => Consumer: struct pixel_position を送る。

  • ConsumerからProducerにレンダリング結果を伝える。
    Producer <= Consumer: 1 バイトの 'S' を送る。struct pixel_position_result を送る。

  • レンダリング対象ピクセルが無くなるまで繰り返す。

  • Consumer終了時にProducerとのTCPコネクションを切断。

struct pixel_position {
	int count; // ピクセルごとのレイの数
	int row, col; // レンダリング対象のピクセルの座標
};

struct pixel_position_result {
	int count; // ピクセルごとのレイの数
	int row, col; // レンダリング対象のピクセルの座標
	float red, green, blue; // レンダリング結果の合計
};

動かしてみる

Podの数(spec.replicas)は24とします。Nodeの3台は物理CPUは12個で、ハイパースレッディングを入れて合計24個だからです。Podのresources.requests.cpuは1000mとします。各コンテナに1つづつ割り当てた仮想CPUがフルで動作するということです。

256x256ピクセルでピクセルあたり10000個レイを飛ばしたところレンダリングに235.5秒掛かりました[1]。約4倍の高速化ですが、RunningになっているConsumerは21個だけですし、各NodeのCPU利用率をtopで見ても35%程度しか使用していませんのでまだ余裕がありそうです。

そこでresources.requests.cpu=100mに減らし、replicas=256まで増やすと201個までConsumerが起動しました。CPU利用率は90%を超え、レンダリング時間も99.2秒と改善しました。

さらにresources.requests.cpu=10mに減らし、replicas=512まで増やすと259個までConsumerが起動しました。が、レンダリング時間は101.2秒とあまり変わりません。

replicas cpu 起動Consumer レンダリング時間(sec)
24 1000m 21 235.5
256 100m 201 99.2
512 10m 259 101.2

当初はspec.replicas=24,resources.requests.cpu=1000mとしておけばK8sのスケジューラがうまいことPodを割り当ててくれると考えていましたが、この結果を見ると、CPUの性能を100%引き出すためにはパラメータの調整が必要なようです。Consumerを増やせばある程度までは処理能力が上がりますが、Podの作成/起動コストやコンテキストスイッチのコストも増加するからです。

また、タスクの細分化を行うことで高速化が期待できるかも知れません。具体的には、1ピクセルあたり10000個のレイを1つのPodで処理していますが、1000個のレイを10個のPodで処理するといったことです。

Optunaによる最適化

これらのパラメータを総当りで解を求めることもできますが、より効率的な方法としてOptunaを使用します。
最適化対象は下記パラメータです[2][3]

replicas cpu 1つのConsumerで飛ばすレイの数[4]
10 - 300 10m - 1000m 10 - 10000
def objective(trial):
    rays_per_pixel_producer = trial.suggest_categorical('rays_per_pixel_producer', [1, 2, 4, 5, 8, 10])
    replicas = trial.suggest_int('replicas', 10, 300)
    cpu = trial.suggest_int('cpu', 10, 1000)

    producer_service_update(rays_per_pixel_producer)
    consumer_pod_update(replicas, cpu)

    subprocess.run(['/usr/bin/kubectl', 'apply', '-f', 'producer_service.yaml'])
    time.sleep(5)
    subprocess.run(['/usr/bin/kubectl', 'apply', '-f', 'consumer_pod.yaml'])

    duration = 0
    while True:
        time.sleep(3)
        duration = get_duration(service_port())
        if (duration > 0):
            break

    subprocess.run(['/usr/bin/kubectl', 'delete', '-f', 'consumer_pod.yaml'])
    subprocess.run(['/usr/bin/kubectl', 'delete', '-f', 'producer_service.yaml'])

    while pod_is_alive():
        time.sleep(3)

    return duration

128回の試行結果と1ピクセルあたりのレイの数を重ねて表示してみると、下図のようになります。これを見るとレイの数は10000が最適値のようです。

実際、K8sを使用せずにPCで測定したところ、下記のようにProducer - Consumer間の通信がボトルネックになっていることがわかります[5]。1ピクセルあたりのレイの数が100の場合はレンダリング時間は0.01%で、10000の場合であっても55.9%に留まっています。

そこでレイの数を10000に固定し再度Optunaで最適化を行いました。

ほぼ100秒辺りで収束しています。最適値はspec.replicas=181,resources.requests.cpu=136mでレンダリング時間は97.984秒です。

まとめ

元々Node Aにて8スレッドを使用して約16分40秒掛かっていたレンダリングが約1分40に短縮できました。10倍の高速化です。

脚注
  1. Producerが最初のConsumer socketを受け付けてから、最後のレンダリング結果をConsumerから受信するまでです。時間測定にはsteady_clockを使用します。 ↩︎

  2. 簡単のために各Consumerはシングルスレッドで動作するように実装しておきます。マルチスレッドの替わりに複数Consumerを使用するイメージです。 ↩︎

  3. Producer PodがどのNodeで動作するかによって性能が変わる可能性があるので、Node A で動作するように固定します。 ↩︎

  4. ピクセルごとに飛ばすレイの数は10000で固定なので1つのPodで飛ばすレイの数は10000の約数とします。 ↩︎

  5. Producer/Consumerは同一PCです。 ↩︎

Discussion