MaxScriptとPythonとC++の実行速度を比べてみた

2020年2月28日

3dsmax で支援ツールを作るとき、実行速度の観点では MaxScript と Python と C++、どれが速いのだろうか、と気になって試してみました。

同じアルゴリズムでなるべく同じような処理をそれぞれの言語で書き、実行速度を比較してみよう、というものです。あらかじめ断っておきますが、そもそも比較するのに適切なコードになっているのか、といった部分はあまり気にしておらず、あくまで「興味本位」ぐらいのもので厳密なデータではありませんので、その点ご了承の上で楽しんでもらえればと思います。

概要

テスト環境

このエントリ執筆時の最新バージョンである3dsmax 2020 を使用しました。MaxScript と Python はそれぞれ関数を書き、C++ はMaxScriptから呼び出す関数を書いたものを3dsmax SDK 2020 + Visual Studio 2017 でビルドし、それぞれMaxScript から呼び出します。

テスト内容

大量のオブジェクトを含んだネストされた親子構造を用意し、最ルートオブジェクトを渡してそのツリーに含まれる全オブジェクトを回収する、という処理を行います。

テスト用シーンの作成

まずテストに使用するシーンを作成します。

このような親子構造を持つシーンを作る

図のように、プリミティブ立方体を三角形状に並べ、向かって左から順に、一つ上の行の一つ左の要素に親子付けします。最初の一つは一つ上の行の最初の要素に親子付けします。

これにより、各行一番左のものだけが2つの子を持ち、それ以外は1つずつ子を持つという構造になります。このツリー構造で一番上のノードはすべてのルート(根)になります。今回速度を計測する関数は、このルートノードを渡してツリーに所属するすべてのノードを回収する、という動作を想定します。

まずは上記のようにボックスを配置して親子付けするスクリプトを作ります。

function makeChildren objArr &num= (
	local ret_arr = #()
	local initial_cnt = num
	for o in objArr while (num > 0) do (
		if(num == initial_cnt) do (
			local c = box()
			c.length = 10
			c.width = 10
			c.height = 10
			c.transform = o.transform
			c.pos.x -= 20
			c.pos.z -= 20
			c.parent = o
			append ret_arr c
			num -= 1
		)
		local c = box()
		c.length = 10
		c.width = 10
		c.height = 10
		c.transform = o.transform
		c.pos.x += 20
		c.pos.z -= 20
		c.parent = o
		append ret_arr c
		num -= 1
	)
	return ret_arr
)

function makeFugeBoxes num = (
	if(num < 1)  do (
		return false
	)
	
	local r = box()
	r.length = 10
	r.width = 10
	r.height = 10
	local next = #(r)
	num -= 1
	while(num > 0) do (
		next = makeChildren next &num
	)
)

関数を2つに分けました。各行のオブジェクトを配列で受け取り、次の行を生成してその生成したものを配列で返す、という makeChildren を用意し、最初の一つを生成してそれを配列に格納した上で makeChildren に渡す、という呼び出し側の makeFugeBoxes の2つです。

makeFugeBoxes は個数を引数として受け取り、指定された個数に達するまで三角状に配置するというルールに則ってボックスを生成します。最初に提示した図はボックスを21個配置したものですが、これぐらいだと回収処理も一瞬で終わってしまうので、テストシーンとしてはボックスを1万個生成したものを使用しました。

前述のスクリプトをスクリプトエディタに貼り付けて評価し、2つの関数を定義した上で下記を実行します。

makeFugeBoxes 10000

私のところではこの生成に約9秒かかり、以下のような状態になりました。

1万個のボックス。壮観。

このシーンで、一番上のボックスを引数として渡し、親子関係をたどって1万個のボックスを回収するという処理をMaxScript、Python、C++のそれぞれで用意して処理時間を計測してみよう、というのが今回の主旨です。

テスト用の関数を定義

MaxScript

まずは MaxScript でこの処理を書いてみます。

function _getAllChildrenMS r &ret_arr = (
	ret_arr += r.children
	for c in r.children do _getAllChildrenMS c &ret_arr
)

function getAllChildrenMS root_node = (
	local ret_arr = #(root_node)
	_getAllChildrenMS root_node &ret_arr
	return ret_arr
)

使用するときに呼び出す関数と、実際の処理を行う関数を分けました。使用メモリ量の観点から参照引数で配列を渡してその配列を更新していく、という処理を再帰実行したいわけですが、呼び出しはルートノードだけ渡すと配列が返ってくる、という風にしたかったからです。

Python

def _genChildList(r):
    yield r.Children
    for c in r.Children:
        for ret in _genChildList(c):
            yield ret


def GetAllChildrenPY(r):
    ret_list = MaxPlus.INodeTab()
    ret_list.Append(r)
    for gn in _genChildList(r):
        [ret_list.Append(n) for n in gn]
    return ret_list

Python も関数が2つに分かれました。Python の場合はメモリ使用の観点からジェネレータを使用します。yield を使用すると処理の途中で逐次値を戻せます。実行関数側はループを回しながら取得したオブジェクトをプールせずに yield でどんどん返し、呼び出し側でそれを INodeTab に格納しています。この戻り値は Python の List オブジェクトにすることもできますが、3dsmax のノード(INode)を格納することになるので INodeTab のほうが望ましいです。

余談ですが INodeTab は List を継承したオブジェクトで、INode オブジェクト専用のリストのようなものです。INodeTab のSelect メソッドで格納されたすべてのオブジェクトを一気に選択できるなど、3dsmax 上で複数ノードを操作できる機能が用意されているため、通常のリストよりもこれを使用する方が良いでしょう。

C++

void getAllChildren(INode* rNode, Array* retArr){
	retArr->append(new MAXNode(rNode));
	if (rNode->NumberOfChildren() > 0){
		for (int i = 0; i < rNode->NumberOfChildren(); i++){
			getAllChildren(rNode->GetChildNode(i), retArr);
		}
	}
}

Value* TG_getAllChildren_cf(Value **arg_list, int count){
	check_arg_count(TG_getAllChildren, 1, count);

	INode* pRootNode = arg_list[0]->to_node();
	one_typed_value_local(Array* ret);
	vl.ret = new Array(0);

	getAllChildren(pRootNode, vl.ret);

	return_value(vl.ret);
}

C++ は関数の宣言と定義が別になるので、宣言はヘッダファイルにまとめて記述しておきます。ここでは定義の方だけ載せてあります。

これも二つに分かれており、実行部分とそれを呼び出す部分です。呼び出し側はこのままC++ の関数として使用するのではなく、MaxScript 側へインターフェイスを提供して MaxScript から実行する、という形になります。_cf というサフィックスは C++ の関数をMaxScript 側へ提供するための識別子です。

MaxScript からC++ の関数を呼ぶと引数はいくつあってもすべてValue 型のオブジェクトになり、配列のような感じでインデックスを使用して取り出すことになります。

戻り値はMaxScript の配列になってほしいので、Array オブジェクトとして生成します。

実行速度計測

さぁ、いよいよそれぞれの実行速度を比較してみましょう。比較用に以下のようなスクリプトを用意しました。

-- Max Script
st = timestamp()
getAllChildrenMS $
et = timestamp()

format "MaxScript: % sec." ((et-st) as float /1000.0)

-- Python
st = timestamp()
python.execute "GetAllChildrenPY(MaxPlus.SelectionManager.GetNodes()[0])"
et = timestamp()

format "Python: % sec." ((et-st) as float /1000.0)

-- C++
st = timestamp()
TG_getAllChildren $
et = timestamp()

format "C++: % sec." ((et-st) as float /1000.0)

結果

さぁ、お待ちかねの結果です。

MaxScriptが約1.46秒、Pythonは0.6秒、C++は0.1秒という結果でした。回収するオブジェクトの個数が少ないと差は小さくなり、MaxScriptのほうがC++よりも速いという結果が得られたりもしました。しかし個数が多くなるとC++ > Python > MaxScript でどんどん差が広がり、やはりC++が圧倒的に速い、と結論付けてよさそうです。

そうか、じゃぁなんでもC++で書けばいいんだね、となるのかというとことはそう単純でもありません。実際のツール開発でどの言語を選択するのか、という点については改めて記事にしようと思います。

追記[2020/02/28] MaxScriptで値渡しにした場合

MaxScript は参照渡しが遅いので、参照ではなく値渡しにすると速くなるよ、という情報をコメントでいただいたので試してみました。

結論から言うと、その通りでした。

まず従来通りのコードで実行し、その後参照渡しを値渡しに書き換えて実行した結果

今回実行したマシンが投稿時のものと異なるものだったので全部再計測しました。参照渡しを値渡しに変更するのは、引数の受け渡し部分にある「&」を削除するだけです。

結果、値渡しにするとコメントでいただいた通り、10倍ぐらい速くなり、Python よりもMaxScript の方が速いことがわかりました。

コメントを頂いた通りすがりさん、ありがとうございます!