Cは低水準言語ではない(2018年)
(queue.acm.org)- CはPDP-11時代にはハードウェア抽象化とうまく適合していたが、現代のCPUでは 逐次実行・フラットメモリ というCの抽象機械が実際のハードウェアと大きくずれている
- SpectreとMeltdownは、プロセッサがCの逐次モデルを高速に実行しようとして 分岐予測・投機実行・命令レベル並列性 に大きく依存した結果と結びついている
- Cコードを高速にするには単純な機械語変換ではなく、LLVM/Clang規模の複雑な最適化が必要であり、一部の最適化は Cの意味論 と衝突しうる
- ポインタの出自、構造体のパディング、未初期化値、signed integer overflow のような規則は実行結果の予測を難しくし、セキュリティ脆弱性 にもつながりうる
- 現代のハードウェアにより適したモデルは、多数のスレッド、広いベクトルユニット、単純なメモリモデルを活用するものだが、既存の Cコード互換性 が最大の制約として残っている
Cが「低水準」に見えた理由
- 低水準言語であるなら、ハードウェアが提供する抽象化と言語の 抽象機械 が容易に対応しているべきである
- CはPDP-11上では低水準言語と見なせた
- プログラムは逐次的に実行される
- メモリは平坦な空間のように扱われる
- pre-increment と post-increment 演算子はPDP-11のアドレッシング方式とうまく適合していた
- Alan Perlisは「プログラムが無関係なことに注意を払わなければならないとき、その言語は低水準である」と定義したが、この定義だけでは、人々が低水準言語に期待する「ハードウェアに近い」という感覚を十分には説明しにくい
現代のCPUは高速なPDP-11エミュレータのように動く
- SpectreとMeltdownの根本原因は、高速なプロセッサを作ることだけでなく、PDP-11のような抽象機械を高速に露出させようとしたプロセッサ設計と結びついている
- CコードはC11以前には、非標準のベンダー拡張を除けば、事実上 完全な逐次機械 を提供しており、C11以後も大部分は逐次的な抽象機械を維持している
- 現代のCPUは、実行ユニットを継続的に忙しく保つために命令レベル並列性(ILP)を抽出する
- 隣接する演算を調べ、独立した演算を並列に発行する
- プログラマが主に逐次コードを書ける代わりに、複雑性と消費電力が増える
- GPUはこの種のロジックなしでも高性能を出せるが、明示的な並列プログラムを要求する
Spectre・Meltdownと投機実行のコスト
- 現代のIntelプロセッサは、一度に最大 180命令 を飛行中の状態に置ける
- Cコードでは平均して約 7命令ごとに分岐 があると考えられる
- 単一スレッドでパイプラインを埋めるには、次の25個の分岐先を予測しなければならない
- 予測を外すと、実行した後で捨てる結果が生じ、電力も浪費する
- SpectreとMeltdownは、このように捨てられた作業の 可視的な副作用 をサイドチャネルとして悪用できた
- 現代の高性能コアの register rename engine は、ダイ面積と電力の大きな消費先の一つである
- 命令が実行中だと、停止したり電源遮断したりしにくい
- GPUにはこのようなユニットはなく、並列性は複数のスレッドから得られる
Cのフラットメモリモデルはキャッシュの現実と合わない
- C抽象機械の中核である フラットメモリ は、20年以上にわたって実際のハードウェアと一致していない
- 現代のプロセッサでは、レジスタと主記憶の間に通常3段階のキャッシュがある
- キャッシュはその名のとおりプログラマから隠されており、Cからは見えない
- 現代のプロセッサで高速なコードを書くには、キャッシュを効率的に使う必要がある
- Cプログラマは抽象機械だけでなく、実装の詳細まで理解しなければ性能を出せない
- たとえば64バイト境界に整列された2つの値が同じキャッシュラインに入ることがある
Cコードを高速化するために必要なコンパイラの複雑さ
- 低水準言語なら、複雑なコンパイラなしでも高速なコードへ容易に変換できるべきだが、Cはそうではない
- Clangと関連するLLVM部分は約 200万行 規模である
- Cを高速に実行するために必要な解析・変換パスだけを数えても、コメントと空行を除いてほぼ 20万行 に達する
- Cで大量データを処理する際は、通常、各要素を順次処理するループを書く
- 現代CPUで最適に実行するには、コンパイラがまずループ反復間の独立性を判断しなければならない
restrictキーワードは、あるポインタ経由の書き込みが別のポインタ経由の読み出しと干渉しないという保証を与えられる
- Fortranはこの種の情報提供という点でCより有利であり、Cが高性能計算でFortranを置き換えられていない主な理由の一つである
ベクトル化とCのメモリ配置保証の衝突
- ループ反復が独立していれば、コンパイラは結果を ベクトル化 しようとする
- 現代のプロセッサは、スカラコードよりベクトルコードで4〜8倍のスループットを得られる
- この種のプロセッサ向け低水準言語なら、任意長のネイティブベクトル型を持つのが自然である
- LLVM IRは、大きなベクトル演算を小さな演算に分割するほうが逆より容易なため、そのようなモデルを提供している
- Cのメモリ配置保証は最適化と衝突する
- 同じ prefix を持つ構造体を相互交換可能に使える
- 構造体フィールドのオフセットが言語に露出している
- コンパイラがベクトル化を改善するためにフィールド順序を変えたりパディングを挿入したりしにくい
- データ構造の配置を細かく制御できる性質は低水準言語の長所になりうるが、同時にCを高速化しにくくしている
パディング、SROA、loop unswitching の問題
- Cは、配列内にパディングがないことを保証するため、構造体末尾に パディング を要求する
- 構造体は
memcmpのような型非依存の比較が可能でなければならないため、構造体コピーはパディングも保持しなければならない- 一部の実験では、特定ワークロードの総実行時間の中で目立つ割合がパディングコピーに費やされていた
- SROAは、構造体や固定長配列を個別の変数に置き換えようとする最適化である
- アクセスを独立して扱えるようにし、結果が観測されない演算を除去できる
- 場合によってはパディングを除去するが、常にそうとは限らない
- loop unswitching は、条件文を含むループから条件文を外に出し、両方の経路にループを置く最適化である
- 低水準言語のコードが実行されるとき、プログラマはどのコードがいつ実行されるかを知っているという考えと衝突する
- Cの unspecified value や undefined behavior の概念とも問題を起こしうる
未初期化値と未定義動作
- Cで未初期化変数を読むと unspecified value となり、読むたびに異なる値を取りうる
- この規則は、ページの遅延再利用のような動作を可能にする
- FreeBSDの
malloc実装は、現在使っていないページをOSに通知する - OSはそのページへの最初の書き込みを、そのページが再び必要になったというヒントとして使える
- FreeBSDの
- unspecified value が制御フローに使われると undefined behavior になる
- たとえば
if条件に未初期化値を使う場合がそれにあたる
- たとえば
- loop unswitching でループが0回実行される場合、元のコードではループ本体全体がデッドコードである
- unswitching 後には、未初期化かもしれない変数で分岐する可能性がある
- つまりデッドコードが未定義動作に変わることになる
- Cコードを高速化することはできるが、十分に賢いコンパイラを作るには数千人年が必要で、ときには言語規則の一部を破らなければならないことさえある
Cが理解しにくくなった理由
- 低水準言語なら、プログラマが抽象機械と現実の物理機械の対応を容易に理解できるべきである
- PDP-11ではCの式は1〜2個の命令に簡単に対応づけられ、ローカル変数やプリミティブ型もハードウェアに単純に対応していた
- その後のC実装は、高速なコードとハードウェア対応という幻想を維持するために、ますます複雑になっていった
- 2015年にCプログラマ、コンパイラ作者、標準委員会メンバーを対象に行われた調査では、Cの理解可能性の問題が明らかになった
- 構造体を0で初期化した後に一部のフィールドを設定した場合、パディングビットがすべて0かどうかについて、36%はそうだと確信し、29%は分からないと答えた
- 実際の結果は、コンパイラと最適化レベルによって変わりうる
ポインタの出自とセキュリティ脆弱性
- BCPLモデルでは値はワードであり、各ワードはデータまたはデータのアドレスという比較的単純なモデルだった
- Cモデルは、セグメントアーキテクチャやガベージコレクション仮想マシンを含む多様な対象上で実装できるよう設計された
- C標準は、そのようなシステムで問題を避けるため、ポインタに対する有効な演算を制限している
- C Defect Report 260 は、ポインタ定義に pointer provenance の概念を含めた
- 実装はビットパターンの出自を追跡できる
- ビット単位で同じでも、異なる出自のポインタを区別できる
provenanceという語はC11仕様には登場しないため、コンパイラ作者がその意味を決めなければならない- GCCとClangは、ポインタを整数に変換してから再びポインタに戻したとき provenance が保持されるかについて見解が異なる
- signed integer overflow や null check 前にポインタを逆参照したコードで、セキュリティ脆弱性が観測された事例がある
- null pointer の逆参照はCでは undefined behavior なので、コンパイラは、すでに逆参照されたポインタが null であるはずがないと仮定できる
- 例として CVE-2009-1897 がある
Cではないプロセッサを想像する
- SpectreとMeltdownに対して提案された修正は、かなり大きな性能ペナルティを課し、この10年のマイクロアーキテクチャの進歩の相当部分を相殺してしまう
- Cコードを高速化することに注力する代わりに、高速なプロセッサに適したプログラミングモデルを改めて考える時期に来ている
- Sun/Oracle UltraSPARC Tx のような高度なマルチスレッドチップは、実行ユニットを埋めるのに大量のキャッシュを必要としない
- 十分な高水準並列性があれば、メモリ待ちのスレッドを停止し、別のスレッドの命令で実行ユニットを埋められる
- 問題は、Cプログラムが忙しいスレッドを少なくしか持たない傾向にあることだ
- ARM SVE(Scalar Vector Extensions)は、プログラムとハードウェアのより良いインターフェースの例である
- 既存のベクトルユニットは固定サイズのベクトル演算を露出し、コンパイラがアルゴリズムをそのサイズに合わせることを期待する
- SVEは、プログラマが利用可能な並列性の度合いを記述し、ハードウェアがそれを実行ユニット数に合わせてマッピングする
- Cでは autovectorizer がループ構造から並列性を推論しなければならないため複雑だが、関数型スタイルの
map演算では、マッピング対象配列の長さがそのまま利用可能な並列性なのでコード生成が単純になる
より単純なメモリモデルと並列プログラミング
- 現代CPUでは cache coherency protocol は、高速かつ正しく作るのが難しい部分の一つである
- 複雑性のかなりの部分は、データが共有され変更可能であると期待する言語を支えることから生じる
- Erlangスタイルの抽象機械では、すべてのオブジェクトがスレッドローカルであるか不変である
- Erlangは、1スレッドあたり変更可能なオブジェクトが1つしかない、より単純なモデルを持つ
- このようなシステムのキャッシュ一貫性プロトコルは、mutable または shared の2通りに分けられる
- 不変オブジェクトはキャッシュをより単純にし、多くの演算をより低コストにできる
- Sun Labsの Project Maxwell は、キャッシュ内オブジェクトと young generation に割り当てられるオブジェクトがほぼ同じ集合であることに着目した
- オブジェクトがキャッシュから追い出される前に死ぬなら、主記憶へ書き戻さずに済み、電力を節約できる
- ヒープ上の不変オブジェクトと変更可能なスタックを使えば、ガベージコレクタはハードウェア実装しやすい単純な状態機械になりうる
- 純粋に速度のために設計されたプロセッサは、多数のスレッド、広いベクトルユニット、より単純なメモリモデルをサポートする可能性が高い
- そのようなシステムでCコードを実行することは問題になりうる
- 世界中に大量のレガシーCコードがあるため、商業的に成功するのは難しい
- 並列プログラミングは難しいという通念は、Cのような抽象機械を持つ言語での並列プログラミングに対してより正確に当てはまる
- Alan Kay は actor-model 言語を子どもたちに教え、彼らは200を超えるスレッドを持つ動作プログラムを書いた
- Erlangプログラマは数千の並列コンポーネントを持つプログラムを日常的に書く
- マルチコアCPUや many-core GPU が広く使われる状況で、Cは現代ハードウェアにうまくマッピングされていない
1件のコメント
Hacker News のコメント
C が低水準である理由は、少なくとも 手動メモリ管理にある
特に現代のハードウェアでは、メモリ管理がプログラミングの中心にある。Rust がガベージコレクタなしのメモリ安全性を掲げるのも、結局のところ Rust が存在する核心的な理由がメモリ管理に近いからだ。C が速い理由もメモリであり、C が安全でない理由もおおむねメモリにある。並列コンピューティングが難しい大きな理由の一つも、同時メモリアクセスだ。関数型プログラミングは数学的概念で包まれていることが多いが、かなりの部分はオブジェクトが不変であるかのように見せ、内部ではコンパイラが可変メモリを扱うことにある
C ではアロケータを使うなら、その呼び出しはすべて明示的だ。昔の C++ の
new/deleteと生ポインタはアロケータを明示的に呼ぶこともあるが、デストラクタで自動的に起きることも多い。現代 C++ のスマートポインタは、割り当てと解放がどちらも自動で行われるという点で、本質的にはガベージコレクション言語に似ているどのデータをどのキャッシュ階層に置くか、何を仮想メモリへ送るかなどをプロセッサに指示することはできない。Python よりは低水準だが、PDP-11 時代の C のような低水準メモリ管理とは見なしにくい
マイクロコントローラ級のシステムや MMU のないシステムでは話が変わるが、それはまた別の問題だ
Rust 開発者である私でさえ、ポインタをメモリアドレスのような実際の物理的対象と見なす幻想の中で作業している。Rust と、ある程度 C++ は、参照と借用という管理抽象化を前面に置くが、核心的な概念はそのまま残っている
実際には、OS カーネルが物理メモリとプログラムの間に巨大な階層を置いており、「アドレス」と「ポインタ」は OS と MMU がさまざまな処理を行うハンドルに近い
「生ポインタ」も実は生ではない。ページ内オフセットへのハンドルであり、実際のページはあちこちに散らばっている可能性がある。libc と C のモデルを完全に離れ、VM サブシステムのページと直接やり取りする純粋な参照、ある種の「オブジェクトハンドル」の世界へ行けば、むしろ実際の下位システムの動作に近づくかもしれない
mallocとfreeはライブラリ関数であるハードウェアにはそのようなバイト単位の割り当てはないので、ハードウェア上の抽象化であるだけでなく、OS がメモリを割り当てる方法も抽象化している
C ではスタックにも直接アクセスできない。スタックフレームは抽象化されており、使えるのは
longjmp程度だ未定義動作や厳格なエイリアシング規則まで考えると、メモリを好き勝手につつき回すアクセス権もそれほど多くない
Cプログラマーでありコンパイラ作者として言うと、Cを理解し専門的に使う人にとって、Cは明らかに低水準言語です
低水準言語を探しているなら、Cとその親戚が最良の選択肢です
Cを初めて学びながら、専門家のように使う方法を知りたいなら、この記事は無視したほうがよいです。混乱を招くだけで、Cを効果的に使う能力を低下させかねません
実際の機械がかなり苦労してエミュレートしなければならない機械への低水準アクセスを提供しています。実際の機械にアクセスするために、年月を経て付け加えられてきた不格好な仕掛けや継ぎはぎは、Cの中では比較的異質な要素です
ただし、タイトルが修辞的に荒っぽいという点には同意します。間違った低水準言語だからといって、高水準言語になるわけではありません。WASMも現代ハードウェアに直接対応していると主張すれば「間違って」いますが、だからといって高水準というわけではありません
Cが対応として悪いという事実自体がもどかしいわけではありません。1970年代の言語なのでそういう面はあり得ますし、今でも多くの場合で明らかに有用です。よりもどかしいのは、Cがいまだに言語設計を大きく左右し、言語設計者がハードウェアを見る見方を強く染めていることです。そのため現代の言語設計は、ハードウェアとうまく合う言語を作る代わりに、Cの断片を混ぜ直すだけに終わることがあまりにも多いのです
書いたコードがアセンブリと一対一の関係を持つはずだと考えると問題が起きます。こうした部分がどのように足を引っ張るのかをもっと深く見たいなら、https://youtu.be/w3_e9vZj7D8を見るとよいです
著者の核心は「Cはシステムプログラミングに向いた言語ではない」ということではないでしょう。Haskellで
volatile int *dma_register = SCATTER_GATHER_BASE;のようなものを同等に書くのは難しいです著者の要旨は、Cや他の「フォン・ノイマン機械をモデル化する」言語を高速に実行しようとする動機がコンパイラを非常に複雑にし、著者は「低水準なら単純なコンパイラが必要なはずだ」と示唆している、という点にあります。そうしたコードを高速に動かすために作られたプロセッサも非常に複雑で、その複雑さにはコストが伴います
多くの面でこれはプログラミングモデルの転換を促す記事であり、GPUを「新しいプログラミングモデル」と「それを支えるシリコン」が一緒に作られたときの可能性を示す例として挙げているのです
本来の意味は、記事で使われているほうに近いです。低水準言語は移植性がなく、実行されるハードウェアに結び付いており、高水準言語は複数のプラットフォームを対象にできます。この定義では、Cは明らかに高水準言語です
著者が言葉遊びをしているというより、古い用語にしがみつくことでかえって理解を曇らせている点が不満です。「世代」による分類のほうが通常は説明的です
第1世代は機械語、第2世代はアセンブリ、第3世代は汎用言語、第4世代は応用分野特化言語です
第3世代と第4世代の区別はときどき曖昧になり、80〜90年代には結局定着しなかった第5世代の話もありました。それでもSQL、HyperCard、Mathematicaはかなり明確な第4世代言語の例だと思います
このアプローチがよい理由は、言語をいつ使うかについての比較的明確な違いに基づいて分けるからです。そのうえで「高水準/低水準」は相対的な用語として使えばよいのです。より高水準な言語ほど、コンピュータが実際に何をしているかの細部をより多く抽象化する傾向があります。こうしても、より上位世代の言語が概してより高水準であるという点は保たれ、失うのは、完全に恣意的で正直役に立たない境界線をめぐる愚かな論争だけです
この方式なら、.NET IL、WebAssembly、Javaバイトコードを非常に高水準な第2世代言語と見ることもできて面白いです。そしてForthは第3世代言語です。Chuckならかかってきてもいいでしょう
ハンマーをどう使うかではなく、ハンマーをどこにでも使うやり方、つまりCの設計が私たちを制限しているのではないかと問う記事に近いです
CPU命令セットがCPU実装をもっと多く露出すべきだという著者の主張には同意しません
過去にも試みられ、長期的には失敗しました。例として、80年代後半から90年代前半に設計された一部のRISCプロセッサ、たとえばMIPSやSuperHの分岐遅延スロットがあります。この概念を知らない人に説明すると、分岐命令の次の命令は、分岐したかどうかに関係なく実行されるという意味です
短期的には、分岐後のパイプライン停止を避ける仕事をプログラマーに委ねることで、プロセッサをより単純で安価にできました。しかし時間が経つにつれ、プロセッサ設計とパイプラインはより複雑になり、単一の命令だけでは分岐遅延を覆い隠すには不十分になりました。結局、互換性のために将来のプロセッサが処理しなければならない遺産となり、分岐予測とパイプライン論理をより複雑にしました
間違った詳細を露出するのは当然悪いことです。ただし、現代CPUの世界ではCモデルに重大な限界があると言っているのです
あるプロセッサのそうしたサブシステムを開発者たちが使った発表を聞いたことがあります。使わなければ時間枠の95%をデータコピーだけに費やすが、そのエンジンで事前にデータを要求すると、データ取得には時間枠の10%だけを使い、全体の時間枠のおよそ50%以内に目的の処理を終えて、追加機能や改善のための時間が多く残ったそうです
x86にそうした機能があれば、博士課程のときにアクセスする行列データを事前に要求するのに使っていたでしょう。私が使うパターンは線形ではありませんが、よく定義されています。今そのコードをさらに高速化しようとすると、プリフェッチャーが好むように行列を再配置し、コードベース全体を上から下までリファクタリングしなければなりません
望めばひどく設計することはできますが、一般化できるほどの歴史的事例がどれだけあるのか気になります
低水準から高水準までは二分法ではなく、スペクトラムだと見るべき
C は言語の中では低い側の 3 分の 1 に入ると言えるし、メモリやスレッド管理のような多くの機械プリミティブを露出させる。アセンブリほど低くはないとしても Java や Go よりは低く、Python や JavaScript 側とは明らかに距離がある
そのうえ C は、セグメント化メモリやフラットでないアドレスを使うプラットフォームにはかなり不向きだ。そうしたものが再び流行しそうな兆しがあるが、C の広範な普及がそれを非常に大きく妨げている
だから頭の中のモデルは常に「C はプロセッサへ直接命令を出す直前まで降りられる最も低い段階」だった
「C は典型的な『高水準』言語のようには動作しない。アセンブリ言語のような『低水準』言語により一般的に結び付けられる多くの機能を提供するからだ。特定のメモリアドレスにデータを書き込み、読み出す能力、メモリ位置の内容に対する演算機能、整数変数を増減させる命令などがこれに含まれる … したがって C は、プログラマに低水準で作業する柔軟性と効率を提供しつつ、今日のコンピュータ言語に典型的な、より発展したデータ構造やプログラムのフロー制御といった高水準作業の利点も提供する。このため C は時に『高水準の低水準言語』または『低水準の高水準言語』と表現される。」 - https://archive.org/details/computerprogramm0000ford/page/13...
記事末尾の「ソフトウェア開発には並列プログラミングは難しいというよくある神話がある」という文は誤解を招く
著者は難しくない具体的な状況を示してはいるが、一般に当てはまる問いなら並列プログラミングは難しく、よくある神話ではない
並列プログラミングは難しいのか? より詳しい条件なしに問うなら、そうだ。コードの命令が順番に 1 つずつ実行されるより、同時に実行されることを概念化するほうがはるかに難しい
(map inc [0 1 2 3])をプログラムするとき、各要素に対してinc関数が逐次実行される場合と並列に実行される場合で、概念化の難しさは本当に違うのか?並列プログラミングの難しさは先天的なものというより、2 つの点に近いと思う
第一に、言語はたいてい逐次実行を基本にしているため、非同期を行うにはプログラマに追加のプリミティブを導入させる必要がある
第二に、並列プログラミングをいつ効果的に使うべきかを知っている必要がある
独立した計算だけを必要とする独立要素のリストやストリームがあるなら、並列プログラミングは直感的だ
人々がつまずくのは、非同期が不要なところ、つまり逐次実行と性能が同じか、むしろ悪いところに無理やり押し込んだり、実際には計算が相互依存しているのに非同期を入れて動作を壊したりするときだ
「追加の詳細や具体性がなければ」と言うとき、実は C/C 系の世界観を基本フレームワークとして使っている
著者の要点は、逐次プログラミングは単純なプログラミングの一類型にすぎず、唯一の類型ではなく、現代のハードウェアには簡単には合わないというところにある
Erlang が存在し、人々がうまく使っているという事実は、より難しいものが難しくないという意味ではない
プロセスやスレッドのような並行プログラミングのインフラで並列アルゴリズムを実装するのも難しい。だが並列プログラミング、つまり多数の処理要素が同じことを一緒に行うようにするのは、適切な抽象化があればはるかに簡単だ
ただし行列乗算のような一部のユースケースでは例外だ
コンピュータが高速な PDP-11 ではないという点ではこの記事は正しいが、それが C と関係しているという点では間違っている
たとえば「C 抽象機械メモリモデルのもう 1 つの核心であるフラットメモリ。これは 20 年以上も事実ではなかった」という文がある
これは C とは関係ない。ハードウェアがこの抽象化を強制している。そしてそれは幸いなことだ。そうでなければ別のキャッシュを持つマシンへ移したときにプログラムが止まってしまう
フラットな RAM のふりをする階層的メモリ構造、命令セットが示唆するよりはるかに大きい CPU とアウトオブオーダー・投機実行、書かれたプログラムと実際の実行をさらに分離する最適化コンパイラがその例だ
IBM は C が台頭するずっと前の 1970 年代にこうしたものに取り組んでいた。このモデルを批判し代替案を探すのは妥当だが、C を責めるのは公平ではない
この記事はすでに5年前のもので、コンピュータが構造的にPDP-11にあまり似ていないという前提はむしろいっそう正しくなっているが、「Cではないプロセッサを想像しよう」という結論は少し弱く見える。
私たちは、線形なコードと高度に並列なコードの強い分離を目にしており、2018年の時点でもすでにそうだった。最も顕著な例は、機械学習や科学計算におけるPythonの台頭だ。性能が最優先でないときは、シングルスレッド風のスタイルとフラットなメモリモデルで書くことが、今でも非常に便利である。
性能が重要になるなら、並列プログラミングにより適した言語へ移るのが妥当だ。Pytorchのようなものの計算グラフ言語、CUDA上の別のプリミティブ集合、あるいはFutharkのようなより実験的な言語がこれに当たる。性能の中核となるコードは常にドメイン固有言語を伴ってきたし、それらは減るどころか、むしろより一般的になっているように見える。ハードウェアもそれに合わせて作られている。デスクトップPCで一般的なCPU+GPUの組み合わせ、実質的に独自のDSLをなすプリミティブを持つx86のベクトル拡張、GPUをCPUに接続して両者が同じシステムメモリへ高速にアクセスできるようにするM1のようなものが例だ。
言い換えれば、本当に古びているのはCではなく、あらゆる種類の作業に同じようにうまく適合する汎用言語という概念なのかもしれない。
現代CPUの精巧さのためにCがもはや「低水準」言語ではないのだとすれば、同じ論理はアセンブリ言語にも当てはまる。
アウトオブオーダ実行やレジスタリネーミングのようなものは、アセンブリにも適用されるからだ。
ここ数十年でコンパイラが高度化したことも、この主張を補強している。Cコンパイラが生成したアセンブリ、つまりオブジェクトコードでさえ、ループ外への移動、共通部分式の削除などによって予想と異なるものになり得る。
それでも、Cを「低水準」言語と呼ぶ概念は、今なお有用なラベルだと思う。そうでないなら、この呼称自体を引退させるべきだ。
実際のコンピュータ上の抽象化であることは確かだが、Cが仮想的なコンピュータモデルの上に積み上げたものよりははるかに少ない。現在のアセンブリは、Cが作られた当時のCと同じくらいの水準だ。現在のCは高水準すぎて、より良く現代的な言語では得られない機能を提供していない。
ただし、最近では「低水準」と「高水準」という名前があまり有用ではないという点には同意する。
この記事は、互いに調和させにくい二つの論証を展開しているように見える。
第一は、Cは低水準言語ではないという主張で、構造体のパディングや符号付き整数オーバーフローが未定義動作であることを例に挙げている。この部分は理解できるし、仮想の「本当に低水準」な言語のための言語機能を提案する形なので建設的に見える。
第二は、Cの支配力のために、CPU設計者がCを自然に実行する何かを作ろうとして無理をしなければならなかったという主張だ。ここにはレジスタリネーミング、フラットメモリ、キャッシュといった例がある。この主張も理解はできるが、第一の主張や記事タイトルの文脈でどうつながるのかはよく分からない。文字通り受け取ると、現代のハードウェアでは低水準言語を作ること自体が不可能で、機械語でさえ「高水準」だという意味のように見える。そうなると、命令セットアーキテクチャにさらに多くの複雑性を露出する新世代のハードウェアを先に作り、そのうえで初めて、それを活用する低水準言語を設計できる、という結論になる。
どちらの主張にも価値はあるが、一つの記事にまとめて入れ、タイトルを「Cは低水準言語ではない」としたのは少し不安定だ。第一の主張はこのタイトルに合っており、第二の主張は「機械語も低水準言語ではない」という続編で扱ったほうがよかったように思う。
しかしコンパイル時間が長く、コンパイラは期待されていた最適化水準に結局到達できなかったと聞いている。x86と互換性がなかったことも、採用の助けにはならなかった。
VLIW が思い浮かびます。Wikipedia の Itanium の記事によると、次のようにあります。
「1つの VLIW 命令ワードには、独立性を評価せずに並列実行できる複数の独立した命令を含められる。コンパイラは同時に実行できる有効な命令の組み合わせを見つけようとする必要があり、実質的に従来のスーパースカラ・プロセッサが実行時にハードウェアで行う命令スケジューリングを行う。」
CPU が単一フローの並列性をインターフェイスに露出するなら、コンパイル時に処理することも、インラインアセンブリで直接決めることもできるはずです。
これが定着しなかった理由が業界のビジネス上の力学によるものなのか、それともこの戦略が実際に良くないという技術的理由があるのか気になります。
第一に、コンパイラがその種の命令スケジューリングをうまく行えず、後に改善された頃には Itanium はすでに沈んでいました。第二に、既存の命令セット、つまり x86 が実行時にハードウェアでこれをかなりうまく処理するようになり、実際には静的スケジューリングよりもやや良い結果を出しました。実行時にはプロファイリングデータがあるからです。
Linus がこの話題に少し関係する良い rant を [0] に残していたと思います。「RISC の人たちが32本のレジスタすべてを効率よく使うループを作れるようコンパイラを最適化しようと苦労している間に、x86 の実装者たちは代わりに、さまざまな負荷でチップが高速に動くようにし、巨大なレジスタ・リネーミング・ハードウェアを使った。メモリのリネーミングも見据えている。」
[0] https://yarchive.net/comp/linux/x86.html
VLIW は一部のニッチでは本当にうまく機能します。順番に実行される単一命令よりも、手書きでもコンパイラでもプログラムするのは難しいですが、ハードウェア側のスケジューリングは単純化されます。束ねられた命令のレイテンシが似ている場合、よりうまく機能します。
現在の重要な設計上のパズルは、メモリアクセスが算術演算よりもはるかに多くのサイクルを消費する点にあります。数サイクルの算術演算を数百サイクルのメモリロードと束ねても、あまり意味がありません。そのため VLIW は、メモリアクセスが速いと分かっているとき、だいたい L1 キャッシュかそれに相当する場所に収まると分かっているときにうまく機能します。DSP スタイルのシステムに向いている理由の一つはそこにあると思います。
露出したパイプラインも、こうしたシステムの一部に見られる興味深い特性です。VLIW バンドル内のある命令がレジスタに書き込むと、同じレジスタを読む後続の命令はその後 N サイクルの間は以前の値を見て、その後になって初めて書き込みが見えるようになります。手でプログラムするには本当に混乱しますが、コンパイラはそのようなスケジューリングを処理できます。
最近まで DSP と HPC は市場のごく小さな部分だったため、動的スケジューリングが可能な構造のほうが多くの投資を受け、そうした市場まで支配しました。
GPU ではもちろん状況が変わり、実際 GPU は静的スケジューリングにより多く依存していました。しかし GPU もより多様な負荷へ広がるにつれて、ますます動的な要素を獲得しています。
https://news.ycombinator.com/context?id=37900987
Itanium はこれを CPU として出そうとした主要な試みでした。今は AMD64 と ARM が支配していますが、将来また目にすることがあるかもしれません。