Kubernetesの分散処理をOptunaで最適化してみた
概要
- レンダリングを高速化したい。どうせなら手持ちの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倍の高速化です。
-
Producerが最初のConsumer socketを受け付けてから、最後のレンダリング結果をConsumerから受信するまでです。時間測定にはsteady_clockを使用します。 ↩︎
-
簡単のために各Consumerはシングルスレッドで動作するように実装しておきます。マルチスレッドの替わりに複数Consumerを使用するイメージです。 ↩︎
-
Producer PodがどのNodeで動作するかによって性能が変わる可能性があるので、Node A で動作するように固定します。 ↩︎
-
ピクセルごとに飛ばすレイの数は10000で固定なので1つのPodで飛ばすレイの数は10000の約数とします。 ↩︎
-
Producer/Consumerは同一PCです。 ↩︎
Discussion