QMLをC++へコンパイル: 4倍 スピートアップ

本記事は「Compiling QML to C++: A 4x speedup」の抄訳です。

ご存知のように、最近はQMLのコードをC++にコンパイルすることができるようになりました。そうする理由はいくつかあります。その一つは、使用するデータ型を強制的に宣言することで、より良い構造のコードに導くことです。そして、最も重要なのは、出来上がったプログラムがより高速に実行することです。

これまでの投稿では、実際の性能の数値については、やや慎重に述べてきました。これには理由があります。Qt Quick Compilerは、どのJavaScriptでも変換できるわけではありませんし、コードの特性によって、結果として得られるスピードアップが大きく異なります。Qt Quick CompilerのQML言語に対するカバー率を高めるために常に努力していますが、まだ長い道のりです。

しかし、今日は思い切って、C++にコンパイルすることで4倍速くなるQMLサンプルを紹介しましょう。次のようなシンプルなQMLプログラムを考えてみましょう。

import QtQml

QtObject {
    id: root

    enum Parameters {
        Length = 1024,
        Iterations = 32768,

        Category0 = 0xf0f,
        Category1 = 0xf0f0,
        Category2 = 0xf0f0f,
        Maximum   = 0xf0f0f0,
        Mask      = 0xabcdef
    }

    function randomNumber() : int {
        return (Math.random() * Categorizer.Maximum);
    }

    property var numbers: {
        var result = [];
        for (var i = 0; i < Categorizer.Length; ++i)
            result[i] = randomNumber();
        return result;
    }

    function sum() : list<double> {
        var numbers = root.numbers;

        var cat1Sum = 0;
        var cat2Sum = 0;
        var cat3Sum = 0;
        var huge = 0;
        for (var i = 0; i < Categorizer.Iterations; ++i) {
            for (var j = 0; j < Categorizer.Length; ++j) {
                var num = numbers[j] & Categorizer.Mask;
                if (num < Categorizer.Category0)
                    cat1Sum += num;
                else if (num < Categorizer.Category1)
                    cat2Sum += num;
                else if (num < Categorizer.Category2)
                    cat3Sum += num;
                else
                    huge += num;
            }
        }

        return [cat1Sum, cat2Sum, cat3Sum, huge];
    }

    Component.onCompleted: {
        console.log("start")
        var result = sum();
        console.log("done");

        console.log("< " + Categorizer.Category0 + ":", result[0])
        console.log("< " + Categorizer.Category1 + ":", result[1])
        console.log("< " + Categorizer.Category2 + ":", result[2])
        console.log("huge:", result[3]);
    }
}

このサンプルでは、乱数を生成し、それを繰り返し、ビットマスクでマスクし、その大きさに応じて4つの和になるように加算します。ビジネスロジックをJavaScriptで書くべきではありませんが、このようなヘルパー関数は、ビジュアル要素を相対的に配置するために採用されるかもしれませんし、ボトルネックになる可能性さえあります。外側の繰り返しは、ミリ秒ではなく秒単位で語れるようにするためにあるに過ぎません。

このプログラムを「Categorizer.qml」というファイル名で保存し、「qml」ユーティリティで実行し、「start」から「done」までの出力時間を計測します。環境変数 QT_MESSAGE_PATTERN を使って、各メッセージのタイムスタンプを表示させることができます。例えば:

QT_MESSAGE_PATTERN="%{time process}: %{message}" /path/to/qml Categorizer.qml

私の環境では、次のような出力になります。

0.000: start
1.842: done
1.842: < 3855: 808091648
1.842: < 61680: 29789028352
1.842: < 986895: 3433703112704
1.842: huge: 170690851176448

この例を実行するには、少なくともQt 6.2が必要です。Qtを実行できるバージョンであれば、最新の6.5のスナップショットであっても、結果は非常によく似ています。"qml"ユーティリティを使用すると、Qt Quick Compilerのほとんどの利点が使えなくなります。コードはプリコンパイルされず、C++コードも生成されず、バイトコード(存在しない)もアプリケーションにコンパイルされないのです。この例を複数回実行した場合、その後の起動時にキャッシュファイルを使用して、ソースコードからバイトコードに再コンパイルすることを回避することはできますが、コンパイルはここの要因ではありません。

この数値は、このプログラムが実際に行っていることを考えると、かなり物足りないものです。では、C++にコンパイルしたときの挙動をみてみましょう。Qt CreatorでQt 6.2以上のテンプレートを使ってサンプルプロジェクトを作成し、そこにCategorizer.qmlを追加します。そして、main.cppでmain.qmlではなく、Categorizer.qmlを読み込むようにします。アプリケーションは必ずリリースモードでコンパイルしてください。これにより、QMLファイルがC++にコンパイルされることが確認されます。では、結果を実行してみてください。じゃじゃーん~最新の6.5スナップショットでも、数値は同じです。

"そうか、何が言いたいんだ?"と聞かれるかもしれません。では、別のことを試してみましょう。「numbers"のプロパティ宣言に型を追加してみましょう。

property list<double> numbers: { ... }

これを実行するにはQt 6.4が必要であることにお気づきでしょうか。それ以前のバージョンのQtでは、シンタックスエラーとみなされます。6.4で実行した場合、あるいは6.5でC++にコンパイルせずに実行した場合のパフォーマンスは、実際以前より悪くなっています。Qt 6.5 beta1 のサンプルプロジェクトで新しいコードを使ってみてください。私の実行結果です。

0.000: start
0.392: done
0.392: < 3855: 607322112
0.392: < 61680: 27637481472
0.392: < 986895: 3592250556416
0.392: huge: 181336245927936

もう私をワットマンと呼んでくれてもいい。そして、お望みなら、そのままにしておいてください。その場合、私はあなたのためにNaNNaNNaNの曲を歌ったりはしない。もし、C++へのコンパイルが高速化の原因であることを本当に確認したいのであれば、QML_DISABLE_DISK_CACHE環境変数を定義することができます。

QML_DISABLE_DISK_CACHE=1 ./my_example
これにより、QMLエンジンは、コンパイル時にQMLソースコード用に生成されたバイトコードやネイティブコードを使用することができなくなります。そして、実行時にソースコードを再コンパイルし、それを解釈したりJITしたりします。
 
しかし、ここで少しNaNNaNNaNを歌わせてください。

JavaScriptについて

この小さな「sum()」関数を純粋なJavaScriptのレンズで見てみると、「numbers」というものを「root」という別のものから取り出していることがわかります。この「numbers」は数字の配列である可能性があります。あるいは、URLオブジェクト、文字列、「幽」「霊」「文」「字」の組み合わせでキーが決まる辞書など、あらゆるものが考えられます。

次に、数値の範囲に対して反復処理を行います。iが整数で始まることは確実である。しかし、境界条件については、またしても何もわからない。私たちは単に「Iterations」と呼ばれるものを「Categorizer」と呼ばれるものから取り出しているだけであり、どちらもこの関数の範囲外である。

次に、"numbers "から何かを取り出します。このとき、"j "が "numbers "に存在しない場合は、未定義を含む、あらゆるものを取得することができます。しかし、このマスク処理は、巧妙なトリックである。ECMAScriptの標準では、「&」演算子の中に何を入れても、必ずに整数が出力されます。さて、ECMAScriptは整数について知らないようにしようと努力していますが、幸運なことに完全に隠すことはできません。したがって、「+=」演算については何かわかるかもしれません。それらは結局のところ、すべて数字である。そこで起こりうる最悪の事態は、整数から実数にオーバーフローしてしまうことだ(ダジャレです、聞かないでください)。

つまり、ループの内側の部分については、ファイルの残りの部分について何も知らなくても、かなり効率的なコードを生成することができるのです。それ以外の部分については、ワットマン節を歌わなければならないかもしれないことを常に想定し、そのための汎用的なコードを生成する必要があるのです。

QMLのJavaScriptインタプリタ(とJIT)は、具体的には、ループの内側を最適化する能力すらないのですが。単純さとコンパイル速度に最適化されているため、型推論を行いません。列挙のルックアップをキャッシュすることで、その後の反復処理を高速化しますが、キャッシュされたルックアップでさえ、いくつかの関数呼び出しを必要とします。

他のJITコンパイラ、例えばV8で使われているコンパイラでは、このようなコードに対して興味深い発見的最適化を行うことができることに注意することが重要です。実行時に型の出現を観察することで、頻繁に出現する型に特化したコードを生成し、予期せぬ型の不一致には最適化解除のステップを設けることができるのです。しかし、これにはメモリとコードの複雑さという代償が伴います。

JavaScriptについてはここまで。

QMLについて

このファイル全体をQML文書として見てみると、かなり多くのことがわかります。まず、これまで不透明だった「Categorizer.Iterations」「Categorizer.Length」などの値は、列挙型の値であることがわかり、その定義から整数値で表すことができます。コンパイル時には、その値もわかっており、変更できないこともわかっています。さらに、"root "が何であるかもわかっている。"root "はIDである。IDで参照される要素は、プロパティとは対照的に、変化することはありません。しかし、ここが問題です。"numbers" プロパティが単なる "var" である限り、私たちはまだそれについて何も知らないということなのです。Qt Quick Compilerは、ワットマン節を歌うコードの生成を拒否しています。したがって、この場合、「sum()」関数全体が拒否され、解釈やJITコンパイルに逆戻りすることになります。

しかし、"numbers "プロパティの型が与えられている場合、最新の6.5スナップショットに同梱されているQt Quick Compilerは、"sum() "関数の効率的なC++コードを生成しました。これは、"numbers "の索引検索の結果が、数字か未定義しか生成できないことを知っているからです。"numbers "プロパティの値をより短いリストに置き換えたために、undefinedが生成される可能性があります。

この情報だけで、Qt Quick CompilerはQJSPrimitiveValueを使用して、比較と "+="演算のコードを生成することができました。その結果、多少遅くなりますが、それでも解釈よりは速くなります。Qtクイック・コンパイラは、intとdoubleを使ったC++の演算をそのまま生成できるように、「&」演算子を使って型をさらに制約しています。また、生成されたコードにenumエントリーの数値を貼り付けるだけなので、JavaScriptで行うルックアップの手間が省けます。

生成されたC++のコードは次のようになります。

// var num = numbers[j] & Categorizer.Mask;
// generate_MoveReg
r17_1 = r14_1;
// generate_LoadReg
r2_3 = r12_1;
// generate_LoadElement
if (!QJSNumberCoercion::isInteger(r2_3))
    r2_5 = QJSPrimitiveValue();
else if (r2_3 >= 0 && r2_3 < r17_1.size())
    r2_5 = QJSPrimitiveValue(r17_1.at(r2_3));
else
    r2_5 = QJSPrimitiveValue();
// generate_StoreReg
r18_1 = r2_5;
// generate_GetLookup
r2_6 = 11259375;
// generate_BitAnd
r2_3 = double((r18_1.toInteger() & r2_6));
// generate_StoreReg
r13_1 = r2_3;
// if (num < Categorizer.Category0)
// generate_StoreReg
r17_2 = r2_3;
// generate_GetLookup
{
int retrieved;
retrieved = 3855;
r2_3 = double(retrieved);
}
// generate_CmpLt
r2_4 = r17_2 < r2_3;
// generate_JumpFalse
if (!r2_4) {
    goto label_4;
}
;
// cat1Sum += num;
// generate_MoveReg
r18_2 = r7_1;
// generate_LoadReg
r2_3 = r13_1;
// generate_Add
r2_3 = (r18_2 + r2_3);
// generate_StoreReg
r7_1 = r2_3;
// generate_Jump
{
    goto label_5;
}
[...]

不要なリネームがたくさんありますが、それなりのC++コンパイラであれば、それらを排除できるはずです。つまり、これはECMAScriptの標準に違反することなく、内部ループで実現できるほぼ同程度の速度なのです。ループ・カウンターの範囲分析を行って、整数以外にはなり得ないことを発見できるかもしれませんが、今のところそうではありません。

未来について話そう

明らかに、上記の例は、パフォーマンス効果を最大化するために慎重に調整されています。しかし、これはQMLが目指しているところです。Qt Quick Compilerの言語サポートが向上するにつれて、このような例はより一般的になっていくでしょう。


Blog Topics:

Comments