
Pythonでa=1+2を実行したら、コンピュータでは何が起きるのか?
コンピュータはGPUの登場やメモリの増加により計算能力が飛躍的に向上しました。そのおかげでAIモデルの学習や予測も数億、数兆のパラメータを扱うことが可能になっています。
そのため、個人のパソコンでもGPUを駆使したAIモデル開発やデータサイエンスを行う時代となっています。こうした時代にあってデータサイエンティストはGPUを使ったデータ分析を行う能力が求められますが、実際にNVIDIAのGPU計算フレーワークであるCUDAを利用したプログラミングなどを開始しようとするとなかなか自分がコンピュータの計算処理についてしっかり理解していないことに気が付きます。
そこで今回は、ジュニアデータサイエンティストがGPUを駆使したデータサイエンスを行えるようにコンピュータの計算処理に関する基礎知識をご紹介していきます。
人間がコンピュータに計算を指示するとコンピュータで何が起きるのか?
例えば、以下の簡単な足し算をPythonコードで実行することを考えてきます。普段であれば、以下のコードを実行すればJupyter Notebook上のような対話型シェアであれば「3」という回答を私たちに返してくれますよね?だけどそのバックエンドでコンピュータはどのようなことが起きているのでしょうか?
a = 1 + 2
実は、Pythonで実行した人間のコードは最終的にコンピュータで計算を司るプロセッサーによって処理されています。しかし、Pythonのようなアプリケーションが直接CPUに命令をして計算させているわけではありません。実際は次のように処理が流れていきます。
Pythonコードの記述と実行
人間がPython(アプリケーション)で a = 1 + 2というPythonコードを実行
2. バイトコードへの変換
Pythonインタプリタがバイトコードに変換します。仮にJupyternotebookのような対話型シェアで実行した場合は、一行ごとに解釈されます。一方で.pyの全体を実行するような場合はバイトコードに変換された結果は.pycファイルとして保存されます。
LOAD_CONST 1 (1)
LOAD_CONST 2 (2)
BINARY_ADD
STORE_NAME a
このバイトコードは「1をロード」「2をロード」「加算」「結果を変数aに保存」という手順を表します。
3. PVM(Python Virtual Machine)による解釈
PVMがバイトコードのを解釈して、対応する処理を実行するC言語関数を呼び出し実行します。上記の例ではBINARY_ADD命令が加算処理命令になるので、PVMは「加算処理」を行うC関数を呼び出します。
PyObject* PyNumber_Add(PyObject *v, PyObject *w) {
// 引数 v, w を加算
return v + w;
}
同時に、LOAD_CONST 1という命令は「定数1をメモリにロードする」という命令です。この指示のもと定数1というデータは一時的にデータを保持するためのメモリ領域(スタック)に保存されます。
PyCode_Addr2Location
4. C関数の実行
関数は、すでにコンパイル済みのバイナリ(CPU命令)として存在しており、直接CPUが実行します。CPUは上記のバイトコードを直接解釈できないので、実際にはコンパイルされてバイナリコードとなっているコードを解釈して実行をします。なお、コンパイラはバイトコードをバイナリコードだけではなく人間が解釈可能なアセンブリコードにも変換して保持しています。以下はアセンブリコードの例です。
MOV EAX, 1 ; レジスタに1をロード
ADD EAX, 2 ; レジスタに2を加算
MOV [a], EAX ; 結果を変数 a に保存
バイナリコードの場合は0と1で表現されるので以下のようになっています。
10110000 01100001
より正確に言えば、CPUの命令フェッチユニットがメモリに存在しているバイナリコード(命令)を探し、取得します。その命令をCPUのデコーダが解釈し、具体的な動作に落とし込みます。最後にCPUのALUやレジスタがどの動作を命令として実行します。
C関数が実行される段階で計算対象となるデータ(例えば定数1)はスタックから別のメモリ領域(ヒープ)にロードされます。スタックはC関数の終了とともにデータが消えるのに対し、ヒープは長時間データを保持します。Pythonのオブジェクトやデータ構造はこのヒープに格納されることが多いです。逆に関数定義でのローカル変数はスタックで管理されます。
そして実際にCPUが計算をする段階でCPU内に存在する計算を行うためのメモリ領域(レジスタ)にロードされます。なおCPUにはレジスタとは別にキャッシュというメモリ領域も存在しています。キャッシュは過去の計算結果を保持するので、繰り返し計算をする場合キャッシュを使用して高速化することができます。
よくコンピュータでRAMとして表現されるメモリにはスタックとヒープが含まれます。一方でレジスタはCPU内にあるメモリなのでRAMには該当しません。
余談ですが、計算命令のようなものはPVMから直接CPUに命令が出されますが、例えばファイル操作などの場合はOSに対して命令が出されることになります。
GPUの場合の内部的な挙動
では、同じ計算でもGPUを用いた場合の内部的な挙動はどのようになっているのでしょうか?
GPUの場合、CPUの手順1,2は同じですが手順3から異なった挙動をします。
3. PVM(Python Virtual Machine)による解釈
PVMがバイトコードを解釈するのは同じですが、1の実行コードでGPUを利用する場合はPytorchやTensorflowといったライブラリを使用することになります。これらのライブラリを使用する際、GPUを利用することを定義する(tensor.cuda()のようなコード)ためPVMはそれを解釈して、CPUではなくライブラリに対して処理命令を出します。
4. ライブラリによる処理
命令を受けたライブラリはCUDAのようなAPIを呼び出して計算を実行します。CUDAはNVIDIAが開発したGPUを用いた計算を効率的に行うことができるフレームワークです。APIを呼び出すと実際にはC言語で書かれた関数(CUDAカーネル)が呼び出されます。カーネルはGPUのスレッドによって並列処理される計算の単位です。
このCUDAカーネルがGPU上で並列計算を行うための命令を含んでおり、GPUの実行ユニットに渡され計算が行われます。
GPUの場合の計算対象となるデータの場所
GPUを使用する場合でも、計算対象となるデータは、CPUの場合と同様にメモリに格納され、処理対象に渡されますがCPUの場合と異なります。
まず、Pytorch等でPython上に書かれたコードから計算対象となるデータはCPUメモリに格納されます。
tensor_cpu = torch.randn(1000, 1000)
例えば上記のようなコードがあった場合、randnで生成される1000×1000の行列データはPVMの解釈時点でCPU側のメモリ(RAM)に格納されます。
その上で下記のようなto(‘cuda’)のコードをPVMが解釈したときにはじめてCPU側のRAMメモリからGPU側のVRAMメモリにデータがコピー(転送)されます。
# GPUに転送(もしGPUが利用可能なら)
tensor_gpu = tensor_cpu.to('cuda')
その後、GPU上で下記のような計算コードは実行されます。この計算はPytorchのようなライブラリによってCUDAコードに変換され、CUDAカーネルが呼び出されます。GPUによって呼び出されたCUDAカーネルがCPUによって実行されます。CUDAカーネルはスレッド単位の処理です。
result = tensor_gpu * 2
計算された結果は以下のコマンドでCPU側のメモリに戻すことができます。
result_cpu = result.to('cpu')
スレッド、コア
コンピュータの挙動を理解する上でもう一つ重要な概念スレッド、コアです。
スレッド
実行されるプログラムは一度に全部が実行されるわけではなく、実行単位ごとに分けられます。この実行単位をスレッドと呼びます。
Pythonコードの下記の実行例では一行ごとの独立した計算が1スレッドとして扱われ、通常順番に実行されます。このような処理を並行処理(Concurrency)と呼びます。実際実行してみると下記3つの処理が同時に行われているように見えますが実際はタイムスライスによってCPUが順番に実行命令を出します。Pythonスレッドは特にGILの制約で1つのコアで順番に実行することを強いられます。
x = 5 + 3 # 1つ目の命令
y = x * 2 # 2つ目の命令
print(y) # 3つ目の命令
逆にこれら複数のスレッドを複数のコアで同時に行う処理を並列処理(Parallelism)と呼びます。上記の通りPythonスレッドは通常GILの制約を受けて1つのコアで並行処理を強いられるため、並列処理をする場合はMultiprocessingモジュール等を利用することで実現します。
コア
プログラムを実行するための基本的な処理ユニットです。CPUやGPUには複数のコアが搭載されており、これらのコアが同時に複数の処理を並行して実行することができます。コアは、命令を解釈し、必要な処理(計算やデータ操作)を実行します。コアは基本的にCPUでもGPUでも一度に同時に処理できるスレッドは1つです。CPU、GPUいづれにせよほとんどのマシンは複数のコアをもっているので、マルチコアで並列処理ができます。GPUはCPUと比較してコアが簡素でありますが、コアの数が非常に多いため、大量のデータを同時並行的に処理することが得意です。
CPUコアの構成要素
1つのCPUコアは、複数の重要なコンポーネントから構成されています。以下は、一般的なCPUコアの構成要素です:
1. ALU(算術論理演算装置)
ALUは、算術演算(加算、減算、乗算など)や論理演算(AND、OR、NOT)による条件判定を実行する部分です。
2. レジスタ
レジスタは、高速な記憶装置です。プログラムの実行中に必要なデータや計算結果を保持します。
3. 制御ユニット(CU)
制御ユニットは、CPUの「指揮官」とも言える部分で、メモリから命令を読み込み、それを解釈し、どの部分にどの命令を送るべきかを決定します。命令の実行順序を管理する役割があります。
4. キャッシュメモリ(L1、L2、L3)
キャッシュメモリは、データのアクセス速度を向上させるために使用されます。主にL1、L2、L3という階層に分かれており、最も高速なL1キャッシュはCPUコアに最も近く、L3キャッシュは複数のコア間で共有されることが一般的です。
5. バスインターフェース
バスインターフェースは、CPUがメモリや他のデバイスとデータをやり取りするためのインターフェースです。これは、データを読み書きするための「道路」のような役割を果たします。
6. 浮動小数点ユニット(FPU)
浮動小数点ユニットは、浮動小数点演算(小数を使った計算)を担当する部分です。
7. 命令デコーダ
命令デコーダは、メモリから取得した命令を解析し、ALUやFPU、レジスタなどのコンポーネントに指示を出す役割を果たします。
8. 分岐予測器(Branch Predictor)
分岐予測器は、プログラムが分岐(if文やループ)を含んでいる場合、どの方向に進むかを予測して、無駄な処理を減らすことを目的としています。
9. パイプライン(Pipeline)
パイプラインは、CPUが複数の命令を並行して処理するための仕組みです。命令が複数のステージ(フェッチ、デコード、実行など)を通過し、次々に処理されます。
10. スレッド管理ユニット(Thread Scheduler)
複数のスレッドを持つCPUコア(特にハイパースレッディング技術を持つコア)では、スレッド管理ユニットが異なるスレッドの処理を管理します。
Appendix
上記のようにコンピュータの中では様々な存在がそれぞれの役割において命令を作成したり、命令を渡したり、受け取ったりしています。俗にこうした命令は次のように定義されます。これらの命令は基本的にコンピュータで人間が実行するアプリケーションを起点に生成されます。
命令(Instruction)
CPUが直接理解して実行できる最小単位の処理です。
例: 「レジスタに値をロードする」「加算する」「メモリに値を書き込む」など。
命令セット(Instruction Set)
CPUが理解して実行できる基本的な命令の集合。「どんな命令があるか」を示します。
例: 加算(ADD)、データの移動(MOV)、条件分岐(JMP)など。
・命令ストリーム
「その命令がどのように順番で実行されるか」を示します。命令はこのストリームの順番に実行されます。