オープニング:マインクラフト シェーダー エピソード

Iris_install.jar

最近、チームメンバーと一緒にマインクラフトを遊ぶことになりました。今年新しく発売された、すごいと評判のMacBook M4 Proを購入したので、シェーダーModというマインクラフトのグラフィックを綺麗にしてくれるModを入れようとしました。上の画像は、シェーダーModをインストールするためのインストーラーです。

ところがなんと、調べてみたらMacOSでインストールする方法がなかったんです! 絶望に包まれて諦めかけたその時、シェーダーの設定ファイルを見てみると、そのインストーラーの拡張子が.jarで終わっていることに気づきました。なぜMacOSでのインストール方法がなかったかというと、ARM64版のインストーラーが存在しなかったので、「もしかしてMacOSはサポートしてないのかな?」と思っていたからです。

ゲームというカテゴリにおいて、MacOSはほぼ離れ小島のような扱いなので、Macはサポートしてないんだな…と思っていました。でも、マインクラフトはJavaベースで、.jar拡張子のインストーラーを使っているから、Javaさえインストールされていれば動くのでは?と思って試してみたら、なんと。本当にそのまま動いたんです。CPUがどの系列であろうと、Javaさえあれば大丈夫でした。

その時ふと、Javaの誕生理念 「Write Once, Run Anywhere」 が頭をよぎり、Javaをここまで好きになったのは初めてでした。今まで嫌っていたことを後悔した瞬間でした。

この経験から自然に疑問が湧いてきました。.jarファイルって一体何なんだ、なぜCPUの種類に関係なく動くのか?なぜあるプログラムはIntel用とApple Silicon用を別々にダウンロードしなければならないのに、JavaプログラムはそのままOKなのか?この疑問の答えを探していく過程が、まさにこの記事の旅です。ビルドとコンパイルが何かから始めて、インタプリタJITコンパイル、そしてCPUアーキテクチャまで語っていこうと思います。


1. コンパイラ vs インタプリタ 基礎

ビルド(Build)とは?

まず**ビルド(Build)**とは、言葉の意味の通り、何かを組み立てるということです。ビルドとは、ソースコードファイルをコンピュータやスマートフォンで実行できる独立したソフトウェア成果物に変換する過程、またはその成果物のことを指します。

私たちがソースコードを実行するとき、一般的にそのソースコード自体をそのまま実行しているわけではありません。そのコードをビルドすることで生まれる成果物を実行しているのですが、このビルドを手助けしてくれるのがコンパイラインタプリタです。コンパイラとインタプリタによってコンピュータが理解できるレベルに変換され、その後コンピュータがビルドされたコードを実行します。つまり、プログラミングにおけるビルドとは、実行可能なファイルにする過程を意味します。

先ほど述べたコンピュータが理解できるレベルのことを**アセンブリ言語(Assembly Language)**と言います。

In Computer Programming, assembly language often referred to simply as assembly and commonly abbreviated as ASM or asm, is any low-level programming language with a very strong correspondence between the instructions in the language and the architecture’s machine code instructions. - Wikipedia

アセンブリ言語機械語と1対1で対応する関係です。機械語は名前を聞いただけでわかると思いますが、まさにCPUの言語です。実際に0と1で構成されており、例えば次のようになります。

10110000 01100001

これはx86系CPUの機械語命令であり、これをアセンブリ言語に書き換えると次のようになります。

mov al, 061h

アセンブリ言語もかなり複雑な言語ですが、それでも機械語のことを考えればマシですね。機械語の問題は、CPUの言語であるためCPUが変わるたびに機械語も変わり、この機械語と1対1で対応するアセンブリ言語もまた変わるということです。プログラミング言語が毎回変わるなんて、なんとも悲しい話です。

こうした問題を解決するためにコンパイル(Compile)という方法が生まれました。もっと人間に近いレベルで、もっと統一された言語体系が必要だったのです。そこでC言語のような言語でソースコードを書き、それをコンパイルしてアセンブリ言語にビルドする方法が使われるようになりました。

Cコンパイルパイプライン

コンパイル(Compile)

コンパイルとは、ソースコード全体を機械語に翻訳することです。

このように一度にアセンブリ言語に翻訳される言語にはC、C++、Goなどがあります。代表的なC言語のコンパイル過程を見てみましょう。上のダイアグラムのように、.cソースが前処理過程によって.iソースに変わり、その後C Compilerがアセンブリ言語に変換し、Assemblerが再び機械語に変換します。

重要なポイントは、Cをコンピュータが実行するためにアセンブリ言語に変わり、さらに機械語に変わるという点です。コンパイル過程のメリット・デメリットをまとめると次のようになります。

メリット

  • 高いパフォーマンス: 事前にコンパイルされた機械語コードを実行するため、パフォーマンスが良い傾向にあります。
  • エラー検出が容易: コンパイラはコードをコンパイルする過程でエラーを検知できます。

デメリット

  • 開発時間の増加: コードを書いてコンパイルするのに時間がかかります。コード修正後も再度コンパイルが必要なため、開発プロセスが手間になることがあります。
  • プラットフォーム依存: コンパイル言語で書かれたプログラムは特定のプラットフォームに依存するため、移植性が低くなります。(Windowsでgccを使ってC言語をコンパイルするとa.exeファイル、Macではa.outファイルが出力されます。)
  • 大きなサイズ: コンパイルされた実行ファイルのサイズが大きく、コンパイル過程でメモリを多く消費します。

インタプリト(Interpret)

インタプリトとは、ソースコードを1行ずつ翻訳しながら実行することです。

インタプリタ言語としては、代表的にJavaScriptやPython、Rubyなどがあります。実は厳密に言えば100%インタプリタ言語とは言い切れません。途中でコンパイルする過程がないわけではないからです。これはJavaScriptの実行過程を見ながら説明します。

V8エンジンロゴ

JavaScriptとは?簡単に言うと、オブジェクトベースのスクリプトプログラミング言語で、Webブラウザ内で使われるプログラミング言語と考えればよいでしょう。JavaScriptの実行を手助けしてくれるものをV8エンジンと呼びます。

V8エンジンは、GoogleがC++で開発した高性能のJavaScript及びWebエンジンです。Google Chromeに内蔵されており、HTML&CSSと一緒にWeb上で実行するのではなく、JavaScript単体を実行できるようにしてくれるNode.jsもV8エンジンを通じてJavaScriptを実行しています。

V8エンジンのJavaScript実行パイプライン

C言語のコンパイル過程と同様に、個別の処理(Parser、Syntax Treeなど)が動作する原理は複雑です。ざっくり全体の流れを見ると、JavaScriptはJavaScriptのバイトコードにまずコンパイルされた後、インタプリタを通じて1行ずつ翻訳しながら実行されます。先ほど100%インタプリタ言語と呼びにくいと述べた理由は、コンパイルする過程が含まれているからですが、ここではインタプリタがバイトコードを機械語に翻訳しながらリアルタイムで実行してくれます。

メリット

  • 素早い開発: ソースコードを直接実行するため、コードを素早く修正してテストできます。
  • 高い移植性: 一般的にプラットフォーム非依存です。(ただし、プラットフォームに合ったインタプリタのインストールが必要です。)
  • 動的型システム: ほとんどのインタプリタ言語は動的型システムを使用するため、柔軟かつ手軽にコードを書けます。

デメリット

  • 低いパフォーマンス: 一般的にコンパイラ言語と比べてパフォーマンスが劣ります。
  • エラー発見の遅延: インタプリタはコンパイル過程がないため、エラーをランタイムで確認することになります。

コンパイラ vs インタプリタ 比較

ハイブリッド(Hybrid):Javaのアプローチ

ハイブリッド型とは、コンパイル方式とインタプリト方式を組み合わせた方法です。

代表的なハイブリッド言語にはJavaがあります。Javaが複数のCPUで同じコードを実行するためには、C/C++プログラムの実行構造とは異なる方式が必要です。C/C++が特定CPUの機械語コードを直接生成すると、その機械語コードがメモリにロードされてそのまま実行されます。そのため、C/C++はCPUが変わるとコンパイラも変える必要があります。

しかしJavaは、同じコードを使って異なるCPUで実行するために、直接CPUの機械語コードを生成してはいけません。その代わりJavaは**バイトコード(Java bytecode)というものを生成し、これをJava仮想マシン(JVM, Java Virtual Machine)**が解釈して実行する構造になっています。JVMがインタプリタとなってコード解釈方式で実行することにより、同じバイトコードで複数のCPU上での実行が可能になります。

Javaのソースコードは*.javaの拡張子を持ちます。当然ながらそのままではCPUが認識できないので、ビルド過程を通じてソースコードを実行可能な状態にする必要があります。JDK(Java Developer Kit)をインストールすると、JDK内のjavac.exe.java拡張子のファイルを.class拡張子のファイルにコンパイルしてくれます。こうして生まれた.class拡張子のファイルがJavaのバイトコードになります。

JVMアーキテクチャ

つまり、同一の.classバイトコードがJVMがインストールされたどのプラットフォームでも実行できるのです。

こうして生まれた.classファイルはJVMが認識できるようになり、この構造を持っているからこそJava言語はハイブリッド言語と呼ばれ、先述のコンパイル言語が持っていたプラットフォーム依存というデメリットを克服できるようになりました。これこそが、冒頭のマインクラフトシェーダーの.jarファイルがARM Macでもそのまま動いた理由なのです。


2. 深掘り:JITコンパイルとインタプリタ/コンパイラ スペクトラム

基礎編ではコンパイラとインタプリタ、そしてハイブリッド方式まで見てきました。しかし現実のプログラミング言語は、二分法的に「これはコンパイラ、これはインタプリタ」と断定するのは難しいです。インタプリタとコンパイラをスペクトラムとして捉えるほうが、現実をより正確に反映しています。

バイナリファイルの実行過程

一般論としては、ビルドの成果物がバイナリファイルであれば「これはコンパイラ言語だ!」と言います。C言語やGo言語がそうです。しかし、バイナリファイル内の機械語がCPU内部でデコードされ実行される過程までもインタプリティングと見ることができます。なぜなら、CPUアーキテクチャによって同じ機械語でも内部的に解釈する方法が異なるからです。

インタプリタはソースコードを1つずつ読んで実行します。**コンパイラは文字通り実行可能なバイナリファイルを作り出すだけで、実行はしません。**バイナリファイルが実際に実行される過程を見てみると、この過程もインタプリティングと言えるのではないでしょうか?

バイナリファイルが実行される過程を見ていきましょう。

バイナリ実行過程

1. ローダー(Loader)の役割

  • OSがバイナリファイルをメモリにロード
  • ELF(Linux)、PE(Windows)、Mach-O(macOS)などの実行ファイルフォーマットを解析
  • メモリアドレスの再配置(relocation)、動的リンキングを実行

2. CPUレベルでの解釈

  • バイナリ命令がCPUの命令デコーダを経てマイクロ演算に分解
  • 例えば、ADD EAX, EBXのようなx86命令はバイナリ01 D8(2バイト)になります。これを命令デコーダが解釈してALUで実際の加算を実行

3. マイクロアーキテクチャレベル

  • 複雑なCISC命令(x86)は内部的に複数のRISCスタイルのマイクロ演算に変換
  • IntelやAMDのCPU内部でリアルタイムに行われる変換

バイナリファイルが機械語に変換される過程で、複数段階の**「解釈」過程があることがわかります。この観点から見ると、Cコンパイラが作ったバイナリも結局はOSローダーとCPUによって「インタプリティング」**されていると考えることができます。

JIT(Just-In-Time)コンパイル

Pythonも実は内部的にはバイトコードにコンパイルされています。また、JIT(Just-In-Time)コンパイルという技術もあります。.py拡張子のソースコードをpythonではなくpypyというランタイムで実行すると、JIT方式を通じて部分的にコンパイルを行い、速度がずっと速くなります。

PyPy JITコンパイル構造 https://www.ulrich-scheller.de/a-pypy-runtime-for-aws-lambda/

AtCoderやLeetCodeのようなオンラインジャッジで、Pythonで実行するとTLE(時間超過)になるコードがPyPyだと通ることがあります。Node.jsもV8エンジンを通じてJIT方式を使用しています。最初はインタプリティングして、頻繁に使われるコードはJITコンパイルするのです。

インタプリタとコンパイラのスペクトラム

プログラミング言語のインタプリタ/コンパイラ スペクトラム プログラミング言語のインタプリタ/コンパイラ スペクトラム

コンパイラ寄りにぴったりくっついている言語でも、CPUレベルの機械語に変換される過程を考えると、若干のインタプリティング領域があります。そしてPythonのような言語も内部的にバイトコードに変換する部分がありますが、他の言語と比べるとその割合は大きくありません。したがって、インタプリタ寄りにぴったりくっついている言語にも、同様に若干のコンパイル領域があると言えます。

左から順に、なぜその位置にあるのか、代表的な言語で説明すると以下の通りです。

  • Python: .py拡張子を持つPythonソースコードをpython3で実行すると、ソースコードをそのまま実行してくれます。
  • JavaScript: .js拡張子を持つJavaScriptソースコードをnodeで実行するとソースコードをそのまま実行しますが、最初から積極的なJITコンパイル戦略やコード実行直後の最適化など、JITが大きく発展しているため、コンパイルの領域がPythonより大きいです。
  • Java/C#: JavaとC#はどちらもプラットフォーム独立な中間表現(Javaバイトコード、ILなど)に変換され、JVMおよびCLRというマシン上でインタプリティングされます。
  • C/C++: OSで直接実行可能なバイナリファイルレベルにコンパイルされ、残りはOSに応じて解釈されて機械語を制御します。

では、PyPyはどのあたりに位置するでしょうか?上記に従えば、おそらくPythonとJavaScriptの間にあるでしょう。積極的なJITを使うことでどんどん右へ移動し、パフォーマンスも向上します。逆に、左側にあるインタプリタ言語ほど書きやすく、素早く開発できます。nodeで起動したサーバーに--watchオプションをつけて、ソースコードが変わるたびに再起動できるようにすれば、瞬時に変更したコードでテストができます。

プログラミング言語のパフォーマンスを比較した資料も参考にしてみましょう。

プログラミング言語ベンチマーク 1 各プログラミング言語の実行速度比較 ―― コンパイル言語ほど速い傾向がある

プログラミング言語ベンチマーク 2 同一アルゴリズムを各言語で実装した場合の実行時間比較


3. ARM vs x86 アーキテクチャ

先ほど、コンパイラが作ったバイナリはCPUによって異なるという話をしました。では、CPUアーキテクチャは具体的にどう違うのでしょうか?

ソフトウェアをインストールしたり、Dockerイメージをビルドしたりするたびに、ARMとAMDを設定しなければならない場面がありました。たまにどっちがどっちか忘れてしまいます。似ているし、うっかり間違えて入力してしまうこともあります。

CPU各社の製品アーキテクチャは次のように整理できます。

CPU各社のアーキテクチャ分類

ポイントは、IntelとAMDはAMD(x86)系列であり、AppleはARM系列だということです。したがって、IntelとAMDのCPU言語の違いは東京弁と大阪弁くらいの差ですが、AMDとARMの違いはほぼ外国語レベルで、全く互換性がありません。

CPUごとに機械語が異なります。だからどんなコードでも、機械語に変換するときにアーキテクチャによって異なります。IntelとAMDは歴史的にも一緒に発展してきたためAMD系列としてまとめられ、x86_64/amd64のように一括りで表現されることもあります。

AMD(x86)の足跡

AMDの歴史 https://www.youtube.com/watch?v=TqOCC65HkCQ

AMD(Advanced Micro Devices)は1969年に設立された会社で、ARMと比較すると先に登場しました。1970年代からIntelのセカンドソース供給業者であり、1990年代から独自のプロセッサを開発して2003年にAMD64(x86-64)アーキテクチャを発表しました。

AMDの大きな特徴はCISC方式を使用するという点です。CISCはComplex Instruction Set Computerという意味です。

  • 複雑で多様な命令セット
  • 1つの命令で複数の作業を同時に処理可能
  • 複雑な演算に有利だが消費電力が大きい

ARMの歩み

ARMは1990年にイギリスで始まった会社で、元々はAcorn RISC Machineの略でした。ARMのユニークな点は、自らチップを製造せず、設計だけをライセンス供与する会社だったということです。2010年代にはスマートフォン市場を席巻し始め、2020年代にはデスクトップ/ノートPCにも進出して大きな人気を博しています。Apple Siliconがまさにこれに当たります。

ARMはRISC方式を使用します。RISCはReduced Instruction Set Computerという意味です。

  • シンプルで効率的な命令セット
  • 各命令が一度に1つの作業のみを実行
  • ハードウェアがシンプルなため電力効率が高い

CISC vs RISC 比較

レジスタ構造の比較

x86-64

  • 16個の汎用レジスタ(RAX、RBX、RCXなど)
  • 比較的少ないが、複雑な命令で補完

ARM64

  • 31個の汎用レジスタ(X0-X30)
  • より多いレジスタでメモリアクセス回数を削減
  • 効率的なデータ処理が可能

x86 vs ARM 比較 https://www.tothenew.com/blog/x86-or-arm64-making-sense-of-the-architectural-variations/

一般的にARMが電力効率面で勝利しており、当然ながら電力効率が重要なモバイル市場からじわじわとシェアを広げています。MacBook M1の登場、そして続くM2、M3、M4とMac miniとともに、デスクトップとノートPC市場を席巻しつつあります。

しかし、あらゆる技術には一長一短があります。消費電力は大きいものの、高性能が求められる分野(ゲーミング、ハイパフォーマンスコンピューティングなど)では依然としてx86アーキテクチャが有利です。

現実的な理由もあります。1970年代からいち早くCPU市場に参入したAMD(x86)は、あらゆる逆風に晒されてきたはずです。16ビット → 32ビット → 64ビットへと拡張される中で技術的な複雑さが蓄積され、1978年の8086から始まって下位互換性をずっと維持し続けているため、40年前の設計上の決定が今なお足を引っ張っています。一方ARMは新星であり、クリーンな設計からスタートし、モバイル時代の要件に合致し、電力効率がますます重要になる時代が到来したのです。

実際の使用における違い

インストーラーのプラットフォーム

Macを使っていると、プログラムをダウンロードする際にさまざまな選択肢を目にします。Intel Mac(x86 or AMD)Apple Silicon(M chip or ARM64)――どちらをインストールするかという選択です。

chromedriver ダウンロードページ chromedriver ダウンロードページ

上のように、さまざまなバージョンでChromeドライバーのインストーラーが分かれています。これは当然ながら、CPUによって全く異なる機械語を使用するからです。したがって、常に自分のプラットフォームに合ったインストーラーを選ぶ必要があります。

Rosettaと Docker マルチプラットフォームビルド

Rosettaエミュレーション エミュレーション

Dockerの長い歴史を振り返ると、ハイパーバイザーを改善するためにホストOS上に仮想OSをダウンロードしていました。しかしDockerの登場により、Docker Engine上ではどんなDockerイメージでもうまく動くようになり、OSに依存する問題はなくなりましたが、CPUへの依存は避けられませんでした。そのため、Dockerをビルドする際に--platformパラメータを使ってAMDやARMアーキテクチャを指定できます。

特に、ARMを使用している場合はRosettaというエミュレータを通じて、x86_64/amd64アーキテクチャを利用できるようにすることができます。もちろんエミュレーションのオーバーヘッドによりパフォーマンスは落ちますが、悪くはありません。


おわりに

マインクラフトのシェーダーModの.jarファイル1つから始まった好奇心が、かなり長い旅になりました。まとめると次の通りです。

  1. ビルドとコンパイル: ソースコードをコンピュータが理解できる形に変換する過程です。コンパイラは一度に全体を、インタプリタは1行ずつ翻訳します。
  2. JITコンパイルとスペクトラム: 現実の言語はコンパイラ/インタプリタと二分法的には分けられません。JITのような技術により、インタプリタ言語もどんどんコンパイル領域を広げています。
  3. ARM vs x86: CPUアーキテクチャによって機械語は全く異なります。ARM(RISC)は電力効率、x86(CISC)は高性能に有利であり、Javaの「Write Once, Run Anywhere」はこの違いをJVMで克服したものです。

結局、これらの話すべてに共通するのは抽象化です。機械語の違いをコンパイラと仮想マシンが隠してくれて、CPUアーキテクチャの違いをJVMのようなランタイムが克服してくれます。技術が進歩するほどこうした抽象化レイヤーはより精巧になり、開発者はより高いレベルで問題を解決できるようになります。

個人的には、プログラミング言語をコンパイラ/インタプリタで二分法的に分けるよりも、スペクトラムの観点から見るほうが現実をより正確に反映していると思います。マインクラフトのシェーダーインストール体験で感じたJavaの魅力も、結局このスペクトラム上で適切な位置を占めているからこそです。バイトコードにコンパイルされてパフォーマンスを確保しつつ、JVM上でプラットフォーム独立性を維持する――まさにそういうことです。

References