TensorFlow C++ (#2 チュートリアル編)

何事もチュートリアルをしっかりとこなすことが大事である。
あれこれ悩んだ後になって、チュートリアルのページにでかでかと該当箇所が書いてあったなんてこと、俺はもう経験したくない。

しかし悲しいかな、TensorFlow C++ のチュートリアルは、ほとんど無いのだ。

まずは心を無にして写経

まずはチュートリアルを自分のコーディングスタイルで適当に写経した。
この手順が結構大事で、バカバカしく思えても手を動かして写してみることが一番の理解の近道だと思う。

で、書き直したものが以下なのだが、元のコードから結構変わっている上に、整理のためにけっこうアレなスタイルになっているので、読んでいる人が(もし)いれば、オリジナルのチュートリアルも参照したほうが良い。

関数やクラスの所属を明確にするという意味で、可読性が下がるのを承知の上で using namespace は排除している。

元の浮動小数点型は float なのだが、 1.f というリテラルの表記がどうしても好きになれないので、 double に変更している。

39行目の operator<< の左辺は、オリジナルでは LOG(INFO) だったのだが、どうも時間情報を付け加えているだけっぽいのと、出力先が標準エラーなのが気に食わなかったので、普通に std::cout に吐くように変更した。

実行方法についてだが、前の記事を参考にインストールしたならば、コンパイル時に tensorflow_cc をリンクするだけで十分なはずである。
コンパイル方法については本筋から外れるのであまり長く書きたくないのだが、知らない人にとって一番面倒くせぇ部分であることは確かなので俺が実際にビルドに使用した CMakeLists.txt を貼り付けておこう。

どうせ後で使うことになるだろの精神で Boost がリンクしてあったり、コンパイラに clang を指定していたりと、こういう場所に貼れるほどに洗練されていないのがいまいち気が進まない理由なのだが、まあいい。
使い方が分からない人は Twitter で聞くなり、直接聞くなり、まあ上手いことしてくれ。

では話を戻そう。

出力先を std::cout にした理由はもうひとつあって、実行するとなんだかエライ量のワーニングが出てくるのだ。
どうも共有ライブラリのビルド時のオプションが足りなかったか、あるいは GPU を積んでいないノートで実行しているのが悪いのか、なんにせよワーニングがうるせえ。

参考程度に実際の出力を付記しておくと、こんな感じ。

まああくまでワーニングなのでひとまず無視する。
とはいえ鬱陶しいことには変わりないので、 ./hoge 2> /dev/null とすることで標準エラーの出力先をビットバケツに繋いで闇に葬り去る。

確認したい情報もどっかにいってしまっては困るので、計算結果は標準出力、つまりファイルディスクリプタの1番へ吐いてもらいたい。そういう理由で、最初のコードの39行目はああなっている。

ではコード中に登場するクラスをひとつづつ調べていこう。

tensorflow::Scope

非テンプレートクラス。
クラスリファレンスはここ

デフォルトコンストラクタは定義されていない。何故だろうか。
どうせこれから何百回と構築することになるクラスなので、説明がてら使い勝手を向上させていこう。

というわけで適当に作ったのがこちら。

やってることは単純なので特に説明はしない。
意味が分からない人は public 継承、メンバイニシャライザ、Variadic Templates あたりを勉強すると理解できると思う。

さて、リファレンスによればこの Scope クラスは TensorFlow Op プロパティのコンテナであるという。ではプロパティとは何か。プロパティのうちの幾つかはその場に例示されていた。曰く、

Operation names
Set of control dependencies for an operation
Device placement for an operation
Kernel attribute for an operation

頻出する Operation の語は直訳すると『操作』だが、TensorFlow のコンセプトからして『演算』と訳すのがいいのだろう。Operator の語もあることだし、『演算』にして『演算子』として振る舞う関数オブジェクト的な存在と解釈すれば適当か。
tensorflow::Operation クラスも後で読むとしよう。

こうして見ていみると、どうもこれまで培ってきた手続き型プログラミングの認識を改める必要性があることに気づいてくる。

つまり、手続き型プログラミング的な考え方では、上のチュートリアルのコードの内容としては、2次元テンソルの A と1次元テンソルの b を定義し、その行列積(tensorflow::ops::MatMul; matrix multiplication)の演算結果を1次元テンソル v へ代入しているという具合なのだが、これを『 A と b の行列積を求めるという演算それ自体に v というラベルを振る』と考えなおす必要がありそうだ。

代入演算子( operator= )の意味をプログラミング的な『代入』ひいてはメモリへの書き込みではなく、本来の数学的な意味である『等価』として考える、とも言い換えられるだろうか。

演算結果ではなく、演算それ自体なのだ。

ここで最も大事な事項として、TensorFlow では37行目の session.Run({v}, &outputs) に到達するまで、テンソルの演算それ自体を一切行っていないことを知らなければならない。

コンパイラによりバイナリの実行ファイルを生成するプログラミング言語では演算の『手順』をその言語によりプログラムし、コンパイルされたバイナリがそのとおりに演算を実行する。
しかし、TensorFlow を用いたプログラミングでは、言語により TensorFlow ライブラリへ 演算の『手順』の構築手順をプログラムし、コンパイルされたバイナリがそのとおりに演算手順を構築し、 tensorflow::Session によってはじめて演算が実行されるのだ。

TensorFlow は C++ 内に生息するドメイン固有言語とも考えられるかもしれない。
つまり、C++er な TensorFlow ユーザはプリプロセッサ、テンプレート、バイナリの3つの実行タイミングに加えて、TensorFlow の計算実行タイミングを考慮しなければならないらしい。あな恐ろしや。

ここまで話を進めて来ると、各演算には変数名以外の実行時に扱える名前が必要となることに気づく。所詮変数名は人間が識別するためのものだから、実行時にはただのメモリアドレスに成り下がっている。
それを管理するのが、ひとつ Scope オブジェクトの大きな役割なのだろう。

チュートリアルのコードでは使用されていないが、 tensorflow::Scope::NewSubScope(const std::string& child_scope_name) が存在している。返り値型は tensorflow::Scope 。これによりスコープを階層構造で管理できるらしい。

tensorflow::Scope  がディレクトリ(prefix)、 tensorflow::Scope::WithOpName(const std::string& op_name) を付されて定義された Operation がファイル名(suffix)だという例えが使えるかと思ったが、違うなこれは。

まだ tensorflow::Scope が管理するプロパティには例示されているものだけでも Device placement やら Kernel attribute なるよくわからんヤツが残ってるが、これ以上混乱したくないし調べるべきクラスも残ってるのでいつかまた別の記事で書く機会が出てくることにしよう。

tensorflow::ops::MatMul

コイツは Operation Constructor と呼ぶそうな。

さて、突然だがこいつ MatMul は matrix multiplicate なのか、matrix multiplication なのか、はたまた matrix multiplicator なのか分かるだろうか。
俺には分からん。

関数だから matrix multiplicate だというのは早計だ。
チュートリアルの書き方が関数スタイルの初期化を推奨していたから、一応それに則って俺もそう写経したが、こいつは関数ではなくクラスだ

関数スタイルを推奨していることには意味があるのだろう。それは多分、こいつはクラスではあるが関数然とした存在であるということである。前述の通り、Operation は演算結果ではなく演算それ自体を意味している。

auto で受けているのも、クラスであるという意味を薄れさせたかったからなのだろう。C++ である以上、データ構造は変数として保存する必要があるのだが、おそらく本当はそうしたくないのだ。
こいつは C++ というプログラミング言語から見ればクラス以外の何物でもないが、ドメイン固有言語 TensorFlow としては演算なのだ。

本当は v = A * b とだけ書いて済ませたいはずなのだ(はずなのか?)。
しかしそれは C++ が許さない。この演算を実行するのは C++ ではなく TensorFlow なのだから、 tensorflow::Session::Run() が実行されるその時まで、この『演算』はオブジェクトとして大切に保管されなければならないのだ。

うーん。
このあたり、演算子オーバーロードを使って上手いこと出来ないだろうか。障害となるのは引数にスコープと転置の情報も要ることで、単純な二項演算として書けないことか。

ああ、もしかすると、意図的に MatMul と略してるのかもしれんな。

……とまあ、そういうわけで tensorflow::ops::MatMul クラスである。行列積。
名前の議論はさておいて、C++ 的な役割は行列積の演算定義の保存。TensorFlow 的な役割は行列積の演算、あるいは演算子。

まあ特にそれ以外の説明は必要ないだろう。
引数に与える tensorflow::Scope オブジェクトも、その後の A 、 b もそのままだし、唯一疑問となる点があるとすれば、さらにその後、最後の引数だろう。

チュートリアルのページによると、次のように2通りの書き方が出来るらしい。

わざわざ「こういう書き方が出来るよ!」と書いてある割には有り難みが感じられない。
疑問を放置したまま次へ進むのは嫌なのできちんと調べておこう。

調査するべきは tensorflow::ops::MatMul::Attrs だろう。

憶測やチュートリアルの大雑把な文章をアテにするのも飽きてきたので、手っ取り早くクラス定義を直接見に行こう。
結局のところ、ソースコードを直接見るのが一番なのだ。

ヘッダファイル tensorflow/cc/ops/math_ops.h の1573行あたりのコードからコメントを抜いて適当に整形したものがこちら。

何のことは無かった。
本当に「こう書くことも出来ますよ」以上の意味がない。
わざわざ説明として書いてある意図がよくわからない。

もういいや。次に行こう。

tensorflow::ClientSession

恐らく本記事の目玉となる部分。

非テンプレートクラス。
クラスリファレンスはここ

役割は C++ API で定義された TensorFlow グラフの評価と実行。
public function も tensorflow::ClientSession::Run しか持たない漢らしい存在。何がなんでもグラフを実行するという気概を感じる。

チュートリアルのコードの ClientSession に関係してそうな部分を再掲しておく。
この記事も結構長くなってきて書いてる俺もスクロールが面倒になってきた。

疑問点としては、何故演算結果を tensorflow::Tensor のベクタで受けているのか、と TF_CHECK_OK オブジェクト形式マクロの存在があるだろう。

まずは TF_CHECK_OK から解決しよう。
マクロをバラさないことには実際の挙動が見えてこない。

TF_CHECK_OK の定義は以下である。
ヘッダファイル tensorflow/core/lib/core/status.h の128行から3行の抜粋。

TfCheckOpHelper の返り値がぬるぽでない場合にその内容(つまり  tensorflow::string )を LOG(FATAL) に吐く。
if ではなく while を使っているあたり、何か複雑な挙動をしているのかと思ったが、どうも単に実装者の趣味であるようだ。

TfCheckOpHelper に与えている引数は2つで、第一引数が  TF_CHECK_OK に与えた引数 = tensorflow::ClientSession::Run の返り値 = tensorflow::Status で、第二引数にはプリプロセッサの # 演算子により与えた引数の記述自体がリテラル文字列として渡されている。
なるほど、確かにプリプロセッサでなければ実現できない挙動である。

では気になる  TfCheckOpHelper の定義は以下。場所は TF_CHECK_OK のすぐ上だった。

まあこれ以上は深入りするまい。
結局大事だったのは、 tensorflow::ClientSession::Run の返り値 = tensorflow::Status のパブリックメンバ tensorflow::Status::ok() の確認であって、マクロでなければならなかった理由は出力のために引数それ自体の記述が必要だったから、だ。

念の為、こいつに仕事をしてもらって出力を確認しておこう。
tensorflow::Status::ok() が false を返せばいいわけで、適当に tensorflow::ops::MatMul に与える行列の大きさが不一致となるようにしてみよう。

つまりは、

を、

と変更して実行してみよう。

ここが TensorFlow の奇妙なところで(もちろん当たり前の挙動なのだが)、こんなトチ狂った演算を書いてもコンパイルには通ってしまうのだ

恐ろしいことに、この演算定義の間違いが判明するのはバイナリの実行時。
気になる実行結果は以下である(冒頭で述べたワーニングの部分は除いてある)。

ちなみにプログラム自体は上記の文字列を標準エラー出力に吐いてコアダンプを出力して落ちた。

ここから TF_CHECK_OK の最も重要な役割が見えてくる。
そう、コイツ、ドメイン固有言語 TensorFlow のコンパイル処理に相当するのだ
より正確には、 tensorflow::ClientSession::Run の返り値を精査することで問題が顕在化するわけだから、コンパイルというよりはクラッシュする前の最後っ屁なのだが。

多少読みづらいが、こういったものを出してくれるだけ相当マシである。
勿論 tensorflow::Status の各種メンバを駆使してより解りやすい出力を書くことも出来るだろうが、流石に労力に見合わないだろう。

このエラー出力の感じがなんともテンプレートメタプログラミングを彷彿とさせるが、いうなれば C++ API によるプログラミングは TensorFlow メタプログラミングとも考えられるわけだ。

tensorflow::Input / tensorflow::Output

では大本命  tensorflow::ClientSession::Run を見るとしよう。
こいつはオーバーロードにより引数が異なる4種が用意されている。
いずれも返り値型は先ほど登場した  tensorflow::Status で、クラスリファレンスはここ

C++ を書き慣れている人は既に気にづいているだろうが、 session.Run({v}, &outputs)) の部分、コイツに与えている第一引数 v は auto で受けた tensorflow::ops::MatMul であるが、問題は {v} と記述している点だ。
これは型名を省略しての暗黙的なコンストラクタ呼び出しである。 tensorflow::ClientSession::Run の定義から、与えた v から構築出来る、引数に見合ったクラスが推論される。

オーバーロードにより4種が用意されているこの関数だが、引数が2つであるものはひとつだけだ。
第一引数の型は const std::vector<tensorflow::Output>& 。
状況から察するに、与えた tensorflow::ops::MatMul からコイツが構築可能であるらしい。

では tensorflow::Output だが、 tensorflow::ops::MatMul がキャスト演算子 operator tensorflow::Output を定義しているのであれこれと探しまわる必要は無かった(よかった)。
tensorflow::ops::MatMul のクラス定義は tensorflow::ops::MatMul::Attrs とは何ぞやのくだりで掲載しておいたのでそちらを参照して欲しい。

個人的に気になったので tensorflow::Input へのキャスト演算子も確認してみた。
するとどうだ、驚くことに(俺だけか?)入出力どちらへのキャスト演算子も tensorflow::Output 型のパブリッククラスメンバ product を指しているではないか。

俺はてっきり、 tensorflow::ops::MatMul は二項演算を表しているのだから、その入力は例えば std::pair<tensorflow::Input, tensorflow::Input> なんて形にでもなっているだろうと思っていたのだ。

どうも俺の感覚と隔たりがあるらしい。
こいつは是正せねばならぬ。掘り下げて考えよう。

では tensorflow::ops::MatMul のコンストラクタ引数を再考しよう。クラス自体を説明した部分では見りゃ分かるとすっとばした部分である。
見なおしてみれば、その定義は tensorflow::ops::MatMul(const tensorflow::Scope & scope, tensorflow::Input a, tensorflow::Input b) であった。

ここで俺はようやく自分の勘違いに気づいた。
tensorflow::ops::MatMul::operator tensorflow::Input と tensorflow::ops::MatMul::operator tensorflow::Output は tensorflow::ops::MatMul への入力とその出力を意味しているのではなく、こいつ自身を入力・出力と扱うことの表現なのだ
考えてみれば当然だ。だからキャスト演算子なのだ。メンバではなくキャスト演算子である意味はそこにあるのだ。

入り口はどこかからの出口でもある、そういうことだ。
tensorflow::Input と tensorflow::Output はそれを表現するために存在している。

公式リファレンスはそれぞれここここ

tensorflow::Input は tensorflow::Outputから構築可能で、また、C++ の他の多種の型から生成できるようにイニシャライザリストやテンプレートコンストラクタやらを備えている(おそらくこれらのコンストラクタを直接扱うことは無いだろうが)。

こいつらの定義を見ていて、気になる型がひとつ。
Node なる存在である。どうせ tensorflow::Node なんて定義してあるんだろと思って探してみたが、そんなものは存在しなかった

無いと言われても書いてあるのだからどこかに定義はあるのだろうと思ったが、いくらソースを grep しまくっても一向に見当たらない。
そうしているうちに気づいた。

こいつ、ポインタとしてしか使われてない

ようやく見つかったのは tensorflow/core/framework/node_def_util.h の31行目にあった class Node; の宣言だけである。
(ウェブからは「TensorFlow ではノードとエッジで計算を表現しまーす」などという毒にも薬にもならない記述がやたらヒットしただけであった)

そう、宣言だけなのだ。
ここから、あくまで俺個人の推測であるが、いろいろ考えた結論を述べておく(誰か知ってる人が居たら教えて下さい)。

これまで TensorFlow とは C++ 内のドメイン固有言語であると述べてきた。
C++ API を叩くことはドメイン固有言語 TensorFlow から見たメタプログラミングに相当するとも言った。

一言で、C++er にとって(あるいはC使いにとって)最も解りやすい表現をしよう。
Node* とは ドメイン固有言語 TensorFlow において、C言語で言うところの void* に相当するものである。

これが結論(合ってますように)。

なんか書いてるうちにどんどん話題が逸れて結局 tensorflow::ClientSession::Run の解説してないけど面倒になったのでまたいつか

次回予告

長かった。
気になったところを片っ端から掘り下げていったらこんなに長くなってしまった。
こんな記事にするつもりじゃなかったのに。

続く解説記事だが、実は今回参照したページ以外で C++ 公式チュートリアルというとこいつしか見当たらない(しかしまあこのページのコードのとっちらかった感じからもはや C++ で書こうというのが無謀であると言われているような気さえする)。
というかこれ以外のを知っている人が居たら教えて欲しい。

じゃあそれを説明したら終わりなのかというとそうでもない。
ネットから Python のに比べてクッソ少ないながらも C++ の記事を漁ってみたところ、以外にも英語の記事よりも日本語の記事のほうがよさ気なものが多くヒットした。

有用そうなのを列挙しておく。

この人の記事全部。
【TensorFlow】カーネル作成プロセスからGPU(CUDA)のアタッチまでの大まかな流れを解析し, Multi CPU, Multi GPUの現状を調査 – Part1

それと TensorFlow 公式リポジトリの奥に落ちてた謎の README.md