ジュニアエンジニア向け コンピュータサイエンス入門シリーズ(全8回)
第3回:CPUとメモリ:1回のメモリアクセスは100回の計算より高くつく

1回のメモリアクセスは、100回の計算より高くつく:CPUとメモリの「速度の階段」と、スタック・ヒープの使い分け【第3回】

同じ処理を書いたのに、片方は0.1秒で終わり、片方は3秒かかる。差を生んだのはアルゴリズムでも言語でもなく、「データがどこに置かれていたか」でした。この記事を読み終えると、CPUとメモリの間に存在する「速度の階段」を数字で説明でき、スタックとヒープの使い分けを自分で判断できるようになります。ジュニアエンジニア向けコンピュータサイエンス入門シリーズ(全8回)の第3回です。

🎯 テーマの主役:「メモリ階層」——速いものは小さく、大きいものは遅い

今回の主役はメモリ階層(memory hierarchy)、つまり「速度の階段」です。一言で言えば、コンピュータの記憶装置は、速いものほど小さく高価で、大きいものほど遅く安価であり、その間を何段もの階段でつないでいるという構造です。

日常の例えで言うなら、料理人の厨房です。料理人が料理を作るとき、材料はあちこちに置かれています。手に持っている材料(レジスタ)は0秒で使える。まな板の上の材料(L1キャッシュ)は手を伸ばせば届く。冷蔵庫(L2・L3キャッシュ)は数歩歩く。近所のスーパー(メインメモリ)は自転車で10分。倉庫(SSD)は車で30分。そして遠くの物流センター(HDD)は半日がかりです。材料そのものは同じトマトでも、置き場所が違うだけで調理の速さが桁違いに変わる。コンピュータもこれとまったく同じ構造をしています。

第1回で「1F=ハードウェア、2F=OS」という地図を描きました。今回の記事は、その1Fの機械室の中に実際に入ってみる回です。1階には CPU・メモリ・ディスクという装置がある、という話をしましたが、その3つがどう繋がっているのかは見ていませんでした。今回そこを覗きます。

この階段を理解すると、次の4つができるようになります。第一に、「このコードはなぜ遅いのか」を計測前に当たりを付けられること。第二に、スタックとヒープという2つの置き場所を、理由をつけて選べること。第三に、同じ計算をしているのに性能が10倍違うコードの理由を説明できること。第四に、「キャッシュを意識したコード」という言葉が何を指しているのか分かることです。

料理人と食材の置き場所でメモリ階層を説明する概念イラスト 同じ料理人でも、材料が手元にあるか、まな板か、冷蔵庫か、スーパーか、倉庫かで調理の待ち時間が桁違いに変わることを示す図。 同じ料理人でも、材料をどこに置くかで待ち時間が決まる CPU=料理人。メモリの階段=材料の置き場所 CPU 料理人 手に持っている レジスタ 0.3ns未満 0秒。手の中 まな板の上 L1キャッシュ 約1ns 手を伸ばす 冷蔵庫 L2・L3キャッシュ 4〜15ns 数歩あるく 近所のスーパー メインメモリ 約100ns 自転車で10分 倉庫・物流センター SSD・HDD 20μs〜10ms 車で30分〜半日 料理の腕(CPU性能)を上げても、材料を取りに行く時間は短くならない だから「速い料理人」を活かすには、材料をなるべく手前に置いておく設計が必要になる

5つの階を、名前・容量の目安・時間・比喩で整理しておきます。ここで注目してほしいのは、容量が大きくなるほど遅くなるという関係です。速いものは小さく、大きいものは遅い。このトレードオフが階段の存在理由そのものです。

階層容量の目安レイテンシの目安厨房の比喩誰が管理するか
レジスタ数百バイト〜数KB0.3ns未満手に持っている材料コンパイラ・CPU(プログラマはほぼ触れない)
L1キャッシュ32〜64KB/コア約1nsまな板の上CPUが自動で管理(指定できない)
L2・L3キャッシュL2は数百KB〜数MB/コア、L3は数MB〜数十MB(共有)4〜15ns冷蔵庫CPUが自動で管理
メインメモリ(DRAM)数GB〜数TB約100ns近所のスーパーOSとプログラマ(変数の置き場所を選ぶ)
SSD・HDD数百GB〜数十TBNVMe SSDで20〜100μs、HDDのランダム読みで5〜10ms倉庫・物流センターOSとファイルシステム(第4回で扱う)

数値はハードウェアによって変わる目安です。ただし桁の違いは何年たっても埋まりません。ここが重要なポイントです。

😓 動機:遅い理由を「なんとなく」で説明してしまう

ジュニアエンジニアがレビューで最も詰まりやすい質問に、「この処理、なぜ遅いと思う?」があります。この問いに答えられないまま、次のような対処に走ってしまう場面がよくあります。

具体的な症状を挙げます。ひとつ目は、遅いと聞いてとりあえず並列化する。スレッドを増やしても、ボトルネックがメモリなら何も変わりません。ふたつ目は、アルゴリズムを変えたのに速くならない。計算量は減ったはずなのに、実測が変わらない。みっつ目は、同じコードなのに環境で性能が3倍違う。よっつ目は、「配列のほうが速い」という話を聞いて書き換えたが、理由を説明できない

これらの症状は、すべて同じ知識の欠落から来ます。「データがどこに置かれているか」と「それを取りに行くのに何サイクルかかるか」という視点です。アルゴリズムの計算量(第5回で扱います)は「何回計算するか」を数えますが、速度の階段は「1回の取り出しに何サイクルかかるか」を数えます。この2つは別の軸であり、片方だけでは性能を説明できません。

そして、この視点はAI時代にむしろ重要になっています。AIが生成するコードは、計算量の観点では妥当なものを出してきます。しかし「このデータ配置はキャッシュに乗るか」という判断は、機械的に出てきにくい。データの置き場所を指定できるかどうかが、人間のレビュアーの仕事として残ります。

🧪 仮説:性能を決めるのは「計算回数」だけでなく「取りに行く回数」である

仮説を立てます。コードの速度は、計算の回数だけでなく「データを取りに行った回数」でも決まる。そして取りに行く回数は、データの置き場所と並べ方で変えられる。

この仮説を支持する観察が3つあります。第一に、1回のメインメモリアクセスは約100nsで、これは3GHzのCPUの300サイクル前後に相当します。単純な整数演算が1サイクルで済むなら、メモリを1回叩く間に、単純計算なら300回できる計算です。第二に、キャッシュミスを減らしただけで数倍速くなる事例が、言語や分野を問わず報告されています。第三に、アルゴリズムの計算量を改善したのに速くならない、という現象が実際に起きます。それは計算量は減ったがアクセス回数が増えたからです。

ここで出てくる実務的な指針が1つあります。「100回の計算を節約するより、1回のメモリアクセスを節約するほうが効くことがある」。順番を逆に考えると、コードの最適化で最初に見るべきは「計算を減らす」ではなく「遠いメモリに何回行っているか」だということです。

🔬 検証①:速度の階段を数字で見る

まず、階段の段差を数字で確認します。下の図は、容量を横方向、速度を縦方向の階段として整理したものです。上の段ほど小さく速く、下の段ほど大きく遅い。

レジスタからネットワークまでのメモリ階層を階段状に表した構造図 上の段ほど容量が小さく高速で、下の段ほど容量が大きく低速になる8段の階段を示す図。 速いものは小さく、大きいものは遅い。この関係は何十年たっても崩れていない ← 容量は小さい 容量は大きい → 速い 遅い レジスタ 0.3ns 数百B L1キャッシュ 約1ns 32〜64KB L2キャッシュ 約4ns 数百KB〜数MB L3キャッシュ 約15ns 数MB〜数十MB メインメモリ 約100ns 数GB〜数TB NVMe SSD 20〜100μs 数百GB〜数TB HDD(ランダム読み) 5〜10ms 数百GB〜数十TB ネットワーク(大陸間) 100〜150ms 事実上無限 レイテンシは目安。ハードウェア世代で変わるが、桁の差は変わらない

ここで数の感覚をつかむために、段差をCPUサイクルに換算してみます。3GHzのCPUは1秒間に30億サイクル動くので、1サイクルは約0.33ナノ秒です。するとこうなります。

アクセス先レイテンシCPUサイクル換算(3GHz)その間にできる単純計算の回数
L1キャッシュ約1ns約3サイクル3回
L2キャッシュ約4ns約12サイクル12回
L3キャッシュ約15ns約45サイクル45回
メインメモリ約100ns約300サイクル約300回
NVMe SSD20μs約60,000サイクル約6万回
HDD(ランダム読み)10ms約3,000万サイクル約3,000万回
大陸間ネットワーク150ms約4億5,000万サイクル約4億5,000万回

この表の4行目が、この記事でいちばん持ち帰ってほしい数字です。メインメモリを1回読むあいだに、単純計算なら約300回できる。逆に言えば、計算を100回節約しても、メモリアクセスが1回増えたら差し引きで負ける。これが「1回のメモリアクセスは、100回の計算より高くつく」という言葉の意味です。

🔬 検証②:人間の時間に引き伸ばすと、段差の正体が見える

数字がナノ秒だと実感が湧きません。そこで、CPUの1サイクルを「1秒」に引き伸ばしてみます。1サイクルが1秒ということは、0.33ナノ秒を1秒に引き伸ばすので、約30億倍に拡大した世界です。この世界で各アクセスがどれくらいかかるかを計算すると、階段の正体がはっきり見えます。

CPUの1サイクルを1秒に引き伸ばしたときの各アクセス時間 L1は3秒、メインメモリは5分、SSDは17時間、HDDは約1年、大陸間ネットワークは約14年かかることを並べて示す図。 もしCPUの1サイクルが「1秒」だったら、各アクセスはこれくらい待つ 約30億倍に引き伸ばした世界(3GHzのCPUを基準) L1キャッシュ 3秒 隣の席に 声をかける L2キャッシュ 12秒 同じ部屋の 人に聞く L3キャッシュ 45秒 隣の部屋まで 歩いていく メインメモリ 5分 コンビニに 買いに行く NVMe SSD 17時間 隣町の倉庫へ 取りに行く HDD 1年 海外の倉庫に 船便で発注 大陸間通信 14年 生きている間に 届かない 「隣の席に声をかける」と「海外に船便で発注する」は、同じ「データを取ってくる」という1行 コードの見た目は同じでも、置き場所で14年の差が生まれる

この図を見ると、階段がなだらかではなく断崖であることが分かります。L1からL3までは3秒・12秒・45秒と、同じ「秒」の世界の話です。ところがメインメモリで5分になり、SSDで17時間、HDDで1年、ネットワークで14年になる。段差は桁で数えるほど開いています。

ここから実務的な結論が1つ出ます。「メモリにあるか、ディスクにあるか」を気にするのは、この断崖のせいです。メモリの5分とSSDの17時間では、比べるのが馬鹿らしいほどの差がある。逆に言えば、L1とL2の違い(3秒と12秒)を必死に最適化するのは、多くの場合コストに見合いません。まず狙うべきは断崖をまたぐこと、次に断崖の手前で粘ることです。

🔬 検証③:なぜ階段が必要なのか——物理的な限界

では、なぜこんな階段が必要なのか。理由は単純で、「全部を速いメモリで作る」ことが物理的にも経済的にも不可能だからです。

ここで面白い事実があります。電気が1サイクルの間に進める距離は、定規で測れる長さしかないのです。光の速さは秒速約30万km、つまり1ナノ秒で約30cm進みます。3GHzのCPUの1サイクルは約0.33ナノ秒なので、真空中でも1サイクルで進める距離は約10cmになります。実際のチップ内の配線ではそれより遅いので、届く距離はさらに短くなります。

CPUの1サイクルで電気が進める距離とキャッシュの配置 1サイクルで進める距離は真空中でも約10cmしかないため、キャッシュはコアの近くに小さく置かれ、遠いメモリは何サイクルもかかることを示す図。 1サイクルで電気が進める距離は、真空中でも約10cmしかない 3GHzのCPU・1サイクル=約0.33ナノ秒。チップ内の配線はこれより遅い ごく短い時間で往復できる範囲 CPUコア L1 L2 L3 数十サイクル メインメモリ 数百サイクル SSD・HDD 数万〜数千万サイクル だから「小さくて近いキャッシュ」を何段も置く。遠いメモリは、待つしかない 容量を増やすほど物理的に遠くなる=遅くなる。ここが階段の存在理由

物理的な制約に加えて、経済的な制約もあります。キャッシュに使われる SRAM は、1ビットを保持するのに6個ほどのトランジスタを使います。一方、メインメモリに使われる DRAM は1個のトランジスタとコンデンサで1ビットを保持します。同じ面積あたりに詰め込める量が桁違いなのです。だから「全部をSRAMで作る」と、同じ容量でコストが跳ね上がり、物理的な大きさも膨らみます。

結果として、設計者はこう考えます。「全部を最速にはできない。ならば、よく使うものを上に、たまに使うものを下に置こう」。この判断が階段を作っています。そして、この戦略が成立するのは、プログラムのアクセスに偏りがあるからです。次はそこを見ます。

🔬 検証④:局所性——キャッシュが効く理由と、効かない理由

前のセクションで「よく使うものを上に置く」と書きました。この戦略が成立するのは、プログラムのメモリアクセスには偏りがあるからです。この偏りを局所性(locality)と呼びます。局所性には2種類あります。

種類意味身近な例効く場面
時間的局所性一度アクセスした場所は、近いうちにまたアクセスされやすい使っている工具を机に置いたままにするループの中の同じ変数、同じ関数の繰り返し呼び出し
空間的局所性ある場所にアクセスすると、その近くもアクセスされやすい本棚の隣の本も一緒に取ってくる配列の順方向走査、連続したメモリをまとめて処理する

この2つ目が、実装の違いとして最もはっきり現れます。キャッシュは1バイト単位ではなく、決まった大きさの塊(キャッシュライン、典型的には64バイト)でメモリからデータを持ってきます。つまり、連続したデータを読むと「ついで」に隣も手に入るのです。一方、データが飛び飛びに置かれていると、1個読むたびにメモリまで行くことになります。

同じ1000個の数値を読むのでも、メモリまで行く回数がまったく違います。これが配列と連結リストの性能差の正体です。

配列と連結リストでメモリまでの往復回数が変わることを示す図 配列は連続しているため1回のキャッシュ取得で複数要素をまとめて取れるが、連結リストは散らばっているため1要素ごとにメモリまで取りに行く様子を示す図。 同じ8個のデータでも、並べ方でメモリに行く回数が変わる 配列:メモリ上で隣どうし 10 20 30 40 50 60 70 80 1回のキャッシュ取得で この範囲がまとめて来る (典型的には64バイト=数個分) メモリへは 1〜2回 連結リスト:メモリ上で散らばっている 値 10 次: 0x9F2 値 20 次: 0x3A8 値 30 次: 0xC10 値 40 次: 0x154 次はどこにあるか分からない。 行ってみるまで隣は来ない メモリへは 最大8回

ここは誤解されやすいので、正直に書いておきます。連結リストが常に遅いわけではありません。先頭への挿入や削除は連結リストのほうが速い場面があります。大事なのは優劣ではなく、「連続しているか、散らばっているか」がメモリ往復回数を決め、往復回数が速度を決めるという因果です。だから「配列のほうが速い」とだけ覚えると、理由を説明できず応用が利きません。

なお、この効果はPythonのようなインタプリタ言語では見えにくくなります。インタプリタ自体の処理が重く、メモリアクセスの差が埋もれるからです。C・C++・Rust・Goのようなコンパイル言語では、同じアルゴリズムでもデータ配置だけで数倍の差が出ます。逆に言えば、Pythonで性能が出ないときに「アルゴリズムを変える」より先に「データの形を変える(NumPyのような連続配列に寄せる)」が効くのは、この理由です。

🔬 検証⑤:スタックとヒープ——2つの置き場所の使い分け

ここまでは「メモリという装置」の話でした。ここからはメモリの中身の使い分けです。プログラムが使うメモリには、性格のまったく違う2つの領域があります。スタックヒープです。

スタックを食堂のトレイ、ヒープをホテルの部屋の貸し出しにたとえた概念イラスト スタックは関数呼び出しごとにトレイを積み上げて戻ると取り除く仕組み、ヒープは空室を探して部屋番号を受け取り使い終わったら返す仕組みにたとえて対比する図。 スタックは「積む」、ヒープは「借りる」。性格がまったく違う スタック ── 食堂のトレイ main の領域 funcA の領域 funcB の領域(実行中) ↑ いちばん上だけを使える ローカル変数・引数・戻り先がここに載る 関数から戻ると、トレイが1枚外れる トレイを外す 速い・大きさが決まっている ポインタを動かすだけなので、ほぼ一瞬 上限を超えると「スタックオーバーフロー」(無限再帰で起きる) ヒープ ── ホテルの部屋貸し出し フロント係 =メモリアロケータ 空室を探す 時間がかかる 部屋番号カード =ポインタ チェックアウトを忘れると部屋が埋まる =メモリリーク。使えなくなった部屋が増えていく 遅い・大きさは自由 空きを探して確保するので、スタックより手間がかかる 使い終わった領域の返却が必要(自動=GC、手動=free・delete) 空きが飛び飛びになると「断片化」して確保しにくくなる

2つの違いを表で整理します。ここで押さえてほしいのは、スタックの速さは「管理が単純だから」という点です。上に積むか、上から外すかしかないので、管理コストがほぼゼロです。ヒープは「どこが空いているか」を探す必要があるため、どうしても手間がかかります。

観点スタックヒープ
確保の速さほぼ一瞬(ポインタを動かすだけ)空き領域の探索が必要で、スタックより遅い
大きさあらかじめ決まっている(Linuxの既定でスレッドあたり数MB程度)空きメモリの範囲で自由。大きな配列も置ける
寿命関数から戻ると自動的に消える明示的に返すか、GCが回収するまで残る
主なトラブルスタックオーバーフロー(無限再帰、巨大なローカル配列)メモリリーク、断片化、GCによる停止(一時的な遅延)
向いているもの関数の引数、ローカル変数、小さくて短命な値大きくて寿命が読めない値、関数をまたいで共有する値

実務での判断は、次の1つの問いに集約できます。「この値は、この関数が終わったら消えてよいか」。消えてよければスタックに置けます。関数を超えて生き残る必要があるなら、ヒープに置くしかありません。「なぜ new や malloc が必要なのか」の答えは、ここにあります

そして、ヒープには代償が伴います。確保が遅いこと、解放を忘れると漏れること、確保と解放を繰り返すと断片化すること、そしてGC(ガベージコレクタ)がある言語ではGCの実行中に処理が止まることです。この「止まる」が、次の活用事例の主役になります。

📊 結果:置き場所を意識すると、何が変わるか

ここまでの内容を、実務でどう使うかの形にまとめます。アルゴリズムを変えずにデータの置き方だけ変えるだけで効果が出る場面は、思っている以上に多くあります。

よくある症状疑うべきこと打ち手効く理由
大量データの集計が遅いデータが散らばっている連続した配列に詰め替える、列指向の形式にする1回のキャッシュ取得で複数要素が来るようになる
ループが遅い走査の順序がメモリの並びと逆行方向と列方向のどちらが連続かを確認し、連続側を内側にするキャッシュミスが減る
たまに数百ミリ秒止まるGCの実行、ヒープの断片化確保するオブジェクトを減らす、使い回す、必要なら手動管理の言語を検討するヒープの仕事そのものを減らす
小さなデータなのに遅い毎回ディスクやネットワークへ行っているメモリに載せる、まとめて取る、近い場所に置く断崖をまたぐ回数を減らす
同じ処理を繰り返すと遅い毎回ゼロから作り直している結果を保持して再利用する時間的局所性を利用する

特に1行目と2行目は、アルゴリズムを変えずに数倍の改善が出ることがあります。逆に言えば、ここを知らないまま「アルゴリズムを変えよう」とすると、効果の出ない努力を積み重ねることになります。

💭 考察:レイテンシは「減らす」より「隠す」方が効く

ここまでの話を一段深く掘ると、面白い原則が出てきます。レイテンシは、減らすより隠すほうが現実的だということです。

理由は単純です。メモリのレイテンシは、物理と経済の制約で決まっているので、プログラム側から直接短くできません。100nsを50nsにするのは、ハードウェアの仕事です。しかし、「待つ代わりに別の仕事をする」ことはソフトウェアでできます。OSやCPUはこれを行います。CPUが1つの命令を待っている間に、関係のない別の命令を先に実行する仕組み(アウトオブオーダー実行)、キャッシュの先読み、複数の処理を切り替える並行処理。これらはすべて「待ち時間を隠す」技術です。

この原則を理解すると、設計の判断が変わります。「この処理は必ず1回メモリに行く。ならば、その待ち時間に何かを詰め込めないか」と考えるようになるからです。大量のデータを扱うときに「まとめて取る」「先に取っておく」「別の処理を挟む」が有効なのは、レイテンシを隠す発想です。

もう1つ、深い見方があります。階層は「記憶」だけでなく「あらゆる計算資源」に現れるということです。CPUにも階層があります(複数のコア、複数種類の実行ユニット)。ネットワークにも階層があります(同じデータセンター内、同じ国、大陸間)。GPUにも階層があります(共有メモリ、L2、グローバルメモリ)。「速いものは小さく、大きいものは遅い」という原則は、階層を持つあらゆるシステムに共通です。この原則を1回理解しておくと、新しい技術を学ぶときに「この技術の階層はどこにあるのか」を探すだけで構造が飲み込めます。

そして、ここから実務的な教訓がもう1つ出ます。答えを先に計算しておく(キャッシュする)という発想も、レイテンシを隠す技術です。ただしキャッシュには必ず「いつ捨てるか」という問題が付いてきます。古いデータを返してしまうと、速いけれど間違った答えになります。つまりキャッシュとは、速度と正確さのトレードオフを、時間軸に沿って選択する行為です。この視点は、第7回で扱うデータベースの索引や、HTTPのキャッシュの理解にも直結します。

📌 注目ポイント

この記事の核心を4点に絞ります。

第一に、1回のメインメモリアクセスは約300サイクルです。 単純計算であれば、その間に約300回できます。だから「計算を100回減らすより、メモリアクセスを1回減らす」が効く場面があります。

第二に、階段はなだらかではなく断崖です。 L1とL3の違いは数十ナノ秒の世界ですが、メモリとディスクの違いは数万〜数千万倍です。最適化の優先順位は「断崖をまたぐ回数を減らす」が最上位になります。

第三に、キャッシュは「連続している」データに効きます。 同じ要素数でも、連続した配列はまとめて取れますが、散らばった連結リストはその都度メモリまで行きます。アルゴリズムを変えずにデータの形を変えるだけで性能が変わることがあります。

第四に、スタックは「速いが小さい」、ヒープは「自由だが遅い」です。 判断の基準は「この値は関数が終わったら消えてよいか」。消えてよければスタック、生き残るならヒープです。

💡 活用事例:アルゴリズムを変えずに、メモリの使い方を変えた話

ここまでの話が現実のサービスでどう現れたかを見ます。2020年、チャットサービス大手の Discord が、あるサービスを Go から Rust に書き換えたと公式ブログで発表しました。対象は「Read States」という、誰がどのチャンネルをどこまで読んだかを管理する機能です。

この書き換えの動機のひとつが、メモリ使用量とガベージコレクタ(GC)の問題でした。Go版ではメモリ上に保持するデータが増えるにつれて、GCが動く頻度と時間が増え、それがレイテンシのスパイク(一時的な遅延の跳ね上がり)として利用者に現れていたのです。アルゴリズムが根本的に間違っていたわけではありません。データをヒープに置きすぎたことが問題でした。

Rust にはガベージコレクタがありません。メモリの解放はコンパイル時に決まる仕組み(所有権)で扱われるため、GCによる停止が原理的に起きません。加えて、データ構造を見直してメモリ上に持つ量を大きく減らしました。結果として、メモリ使用量が大幅に減り、レイテンシのスパイクが解消されたと報告されています。

この事例からジュニアエンジニアが持ち帰れる教訓は3つあります。第一に、「コードのロジックを変えていないのに性能が変わった」という現象は、実際に起きます。第二に、ヒープの使い方が性能を決める。GCのある言語では、確保するオブジェクトの量がそのまま停止時間に跳ね返ります。第三に、言語選択は「速いか遅いか」ではなく「メモリをどう管理するか」で選ぶ。GoはGCがあり、Rustにはない。この違いが問題に直結したのです。

同じ構造の話は、もっと身近なところにもあります。たとえば「大量のレコードをループで1件ずつAPIから取る」コードと「まとめて1回で取る」コードの違いです。1件ずつ取る実装は、1件あたり数十ミリ秒のネットワーク往復が積み重なります。処理の内容は同じでも、行き来の回数が10倍違う。これは第1回で扱った「階をまたぐ回数を減らす」という話と同じ形です。

✅ 要点まとめ

読み終えたあなたが持ち帰るべきエッセンスを、6つに圧縮します。

  • 記憶装置は速いものほど小さく高価。この関係は物理と経済の制約で決まっており、何十年たっても崩れていない
  • レイテンシは桁で違う。メモリは約100ns(約300サイクル)、SSDは数十マイクロ秒、HDDは数ミリ秒
  • 1サイクルで電気が進める距離は、真空中でも約10cm。キャッシュが小さくて近いのは物理的な必然
  • キャッシュが効くのは、プログラムのアクセスに時間的・空間的な偏りがあるから。連続したデータはまとめて取れる
  • スタックは積むだけの速い領域、ヒープは空きを探して借りる遅い領域。基準は「関数が終わったら消えてよいか」
  • 最適化の優先順位は「断崖をまたぐ回数を減らす」が最上位。次に「断崖の手前で粘る」

🚀 取り込み方

「明日から使うには何をすればいいか」を、期間ごとに分けて示します。

今日(5分でできること)

自分のマシンのキャッシュ容量を確認してください。数字を見るだけで、この記事の話が自分の環境の話になります。OSごとに次のコマンドが使えます。

  • Linux: lscpu を実行し、「L1d cache」「L2 cache」「L3 cache」の行を見る。free -h でメインメモリの量も確認できます
  • macOS: sysctl hw.l1icachesize hw.l2cachesize hw.l3cachesize を実行。sysctl hw.memsize でメモリ量も確認できます(Apple Silicon の機種では表示されない項目があります。その場合は sysctl hw の出力から探してみてください)
  • Windows(PowerShell): Get-CimInstance Win32_Processor | Select-Object Name, L2CacheSize, L3CacheSize を実行

さらに、自分のプログラムがどれだけスタックを使えるかも確認できます。LinuxやmacOSなら ulimit -s を実行してみてください(キロバイト単位で表示されます)。「意外と小さい」と感じるはずです。これがスタックオーバーフローの正体です。

今週(小さく試す)

担当コードから、次の3つを探してください。(1) ループの中で1件ずつ外部に問い合わせている箇所、(2) 大量の小さなオブジェクトを生成している箇所、(3) 多次元配列を走査している箇所。見つけたら、すぐ直さずに「メモリ往復が何回起きるか」を数えてみてください。1件ずつの問い合わせが1000件あれば、往復は1000回です。これだけで、次にどこを直すべきかの優先順位が決まります。

Pythonを使っているなら、多次元リストの走査順による差を自分の手で確認できます。次のコードは、同じ合計を2通りの順序で計算して時間を比べるものです。

import time

N = 4096
a = [[0] * N for _ in range(N)]

t0 = time.perf_counter()
s = 0
for row in a:            # 行ごとに読む(各行の並び順にたどる)
    for v in row:
        s += v
t1 = time.perf_counter()

s2 = 0
for j in range(N):
    for i in range(N):
        s2 += a[i][j]    # 列ごとに読む(行を飛び越えてたどる)
t2 = time.perf_counter()

print(f"行方向: {t1 - t0:.2f}秒")
print(f"列方向: {t2 - t1:.2f}秒")

手元の環境では列方向のほうが遅くなるはずです。差の倍率は環境によって変わりますが、「同じ計算なのに順序で差が出る」こと自体を一度見ておくと、この記事の内容が記憶に残ります。

今月(業務に組み込む)

チームのレビュー観点に「メモリの往復回数」を1行足せないか提案してみてください。たとえば「ループ内で外部I/Oを呼んでいないか」「大きなデータを1件ずつ処理していないか」。アルゴリズムの話より合意が取りやすく、効果が測定しやすいのがこの観点の利点です。あわせて、性能を語るときの作法として「まず計測する」を習慣にしてください。この記事の数字はすべて目安なので、自分の環境で測った値だけが自分の根拠になります

🔥 ハマりポイント

つまずきやすい5つの落とし穴を、「〜と思いがちだが、実は〜」の形で整理します。

その1:遅いのはアルゴリズムのせいだと思いがちだが、実はデータ配置のせいであることが多い

症状は、計算量を改善したのに速くならないこと。原因は、改善によってアクセス回数が増えていたこと。対処法は、変更の前後で「メモリ往復回数」を数えることです。O記法が改善しても、1回あたりの定数が10倍になれば負けます。

その2:遅いから並列化すればよいと思いがちだが、実はボトルネックが共有資源だと悪化する

症状は、スレッドを増やしたのに速くならない、あるいは遅くなること。原因は、全スレッドが同じメモリ帯域や同じディスクを取り合っていること。対処法は、まず1スレッドでボトルネックを特定することです。メモリ帯域が上限なら、並列化では超えられません。この話は第8回で詳しく扱います。

その3:「配列のほうが速い」と覚えがちだが、実は操作によっては逆になる

症状は、連結リストを配列に書き換えて、別の操作が遅くなること。原因は、先頭への挿入・削除では連結リストが有利だという性質を見落としたこと。対処法は、「連続しているか」と「何をするか」をセットで考えることです。速さはデータ構造単体ではなく、操作との組み合わせで決まります。

その4:小さいデータならキャッシュは関係ないと思いがちだが、実はGCやアロケータは全体に効く

症状は、データ量が少ないのにレイテンシが跳ねること。原因は、オブジェクトの生成と破棄が頻繁で、GCやアロケータが忙しくなること。対処法は、ループの中で新しいオブジェクトを作らないことです。小さくても、回数が多ければ効いてきます。

その5:性能の数字は一度覚えれば通用すると思いがちだが、実は世代で変わる

症状は、昔の記事の数値をそのまま前提に設計してしまうこと。原因は、SSDの進化やキャッシュ構成の変化を無視していること。対処法は、「桁の違い」だけを記憶して、細かい数値は都度確認することです。L1がメモリより2桁速いという関係は変わりませんが、SSDの絶対値は数年のうちに何倍も変わります。

🔄 置き場所の比較:何をどこに置くか

最後に、データの置き場所ごとの向き不向きを整理します。「速い場所に置けばよい」という単純な話ではないところがポイントです。速い場所には容量の制限があり、遅い場所には永続性があります。

置き場所強み弱み向いているケース
スタック確保がほぼ一瞬。解放忘れが起きない大きさの上限が小さい。関数をまたげない関数の引数、ローカル変数、小さな一時値
ヒープ(メモリ)大きさ自由。関数をまたいで共有できる確保・解放のコスト。リーク・断片化・GC停止大きなデータ、寿命が読めないオブジェクト、共有する状態
キャッシュ(結果の再利用)計算し直しを避けられる。断崖をまたがずに済む古い値を返す危険。無効化の設計が必要同じ計算の繰り返し、外部への問い合わせ結果
ディスク電源を切っても残る。容量が大きいメモリの数万倍遅い。断崖をまたぐ永続化が必要なデータ、ログ、大きなファイル
ネットワーク先複数のマシンで共有できる。容量は事実上無制限最も遅い。失敗もする共有が必要なデータ、バックアップ、別サービスとの連携

この表の3行目「キャッシュ」だけは、装置ではなく設計上の判断です。速い場所に置くのではなく、計算した結果を取っておくという選択です。ここで必ず付いてくるのが「いつ捨てるか」という問題です。キャッシュを入れるなら、無効化の条件を同時に決める。これが、この表から持ち帰ってほしい1行です。

📅 今後の展望

メモリの階段は、これからどうなるのでしょうか。方向性は3つ考えられます。

第一に、階段そのものは残るということです。物理と経済の制約が消えるわけではないので、「速いものは小さく、大きいものは遅い」という関係は変わりません。過去数十年、この構造は何度も「なくなる」と言われてきましたが、なくなるどころか段数が増えています。

第二に、新しい段が追加され続けるということです。近年は、メモリとSSDの間を埋める技術(不揮発性メモリ)や、CPUとメモリの間に置かれる大容量キャッシュが実用化されてきました。また、AI計算向けのチップでは、計算ユニットのすぐ隣にメモリを置く設計が広がっています。これは「断崖をまたぐ回数を減らす」という、この記事で扱った発想の延長です。

第三に、ソフトウェア側の責任が増えるという方向です。ハードウェアが階段を作っている以上、どの段に何を置くかを選ぶのはソフトウェアの仕事です。そして先に見たとおり、AIが生成するコードは「データがどこに置かれるか」まで最適化してくれるとは限りません。階層を意識できる人が、性能の議論で発言権を持ちます

なお、この記事で挙げた数値はすべて目安です。ハードウェアの世代で数倍から数十倍変わります。ただし「キャッシュはメモリより1〜2桁速い」「HDDのランダム読みはメモリより5桁ほど遅い」といった桁の関係は、この先も当分変わりません。数値を覚えるのではなく、桁の関係と因果を覚えてください。

🗺️ 次回予告:あなたのプログラムは、誰に止められるのか

第3回では「1階の機械室」の中身を覗き、CPUとメモリの速度の階段、そしてスタックとヒープの使い分けを見ました。次に自然に出てくる疑問があります。「そのメモリは、誰が、いつ、どこに割り当てているのか」です。

第4回は OSの仕事 を扱います。複数のプログラムが同時に動いているのに、なぜ互いに相手のメモリを壊さないのか。なぜ自分のマシンに16GBしかメモリがないのに、20GBのデータを扱えるように見えるのか。ファイルを「開く」とは、実際には何をしているのか。そして、権限エラーの正体は何なのか。第1回で「2階=資源を配る階」と呼んだ場所の仕事を、具体的に見ていきます。

第3回で扱ったスタックとヒープは、第4回で扱う仮想メモリの上に載っています。この2回は地続きです。続けて読むと、第1回の地図の下半分がつながります。

まとめ

この記事を読んだあなたは、遅いコードを見たときに「アルゴリズムが悪い」と即断しなくなります。まず「データはどこに置かれているか」を考え、「そこへ何回行っているか」を数えるようになります。そのうえで、断崖をまたぐ回数を減らし、連続した配置に寄せ、必要なものだけを速い場所に置く。この順番で考えられるようになります。

そして、スタックとヒープを見たときに「なぜ new が必要なのか」「なぜ解放が要るのか」を説明できるようになります。それは文法の知識ではなく、置き場所の性質の知識です。コンピュータは、速い記憶と遅い記憶を組み合わせて作られています。速い記憶は小さいから、うまく使うには「何を近くに置くか」を選ぶ必要がある。この記事で身につけたのは、その選び方です。

参考文献

  1. Jeff Dean, “Latency Numbers Every Programmer Should Know”(Peter Norvig の原案を元にした更新版。Colin Scott による可視化を含む) — https://colin-scott.github.io/personal_website/research/interactive_latency.html
  2. Wm. A. Wulf, Sally A. McKee, “Hitting the Memory Wall: Implications of the Obvious”, ACM SIGARCH Computer Architecture News, 1995 — https://dl.acm.org/doi/10.1145/216585.216588
  3. Ulrich Drepper, “What Every Programmer Should Know About Memory”, Red Hat, 2007 — https://people.freebsd.org/~lstewart/articles/cpumemory.pdf
  4. Intel, “Intel 64 and IA-32 Architectures Optimization Reference Manual” — https://www.intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html
  5. Randal E. Bryant, David R. O’Hallaron, “Computer Systems: A Programmer’s Perspective”(第6章 メモリ階層) — https://csapp.cs.cmu.edu/
  6. Remzi H. Arpaci-Dusseau, Andrea C. Arpaci-Dusseau, “Operating Systems: Three Easy Pieces”(仮想メモリ・メモリ管理の各章) — https://pages.cs.wisc.edu/~remzi/OSTEP/
  7. John L. Hennessy, David A. Patterson, “Computer Architecture: A Quantitative Approach” — https://www.elsevier.com/books/computer-architecture/hennessy/978-0-12-811905-1
  8. Peter J. Denning, “The Locality Principle”, Communications of the ACM, 2005 — https://dl.acm.org/doi/10.1145/1076211.1076216
  9. Peter J. Denning, “Virtual Memory”, ACM Computing Surveys, 1970 — https://dl.acm.org/doi/10.1145/356571.356573
  10. Agner Fog, “Software optimization resources”(命令のレイテンシとスループットの実測表) — https://www.agner.org/optimize/
  11. Discord, “Why Discord is switching from Go to Rust”, 2020 — https://discord.com/blog/why-discord-is-switching-from-go-to-rust
  12. Linux man-pages project, “getrlimit(2)”(スタックサイズの上限 RLIMIT_STACK を扱う) — https://www.kernel.org/doc/man-pages/
  13. Python Software Foundation, “time.perf_counter”(性能計測に使う高分解能タイマー) — https://docs.python.org/3/library/time.html
  14. Ulrich Drepper, “Memory part 2: CPU caches”(LWN.net 連載) — https://lwn.net/Articles/252125/
  15. 独立行政法人情報処理推進機構(IPA), 「基本情報技術者試験 シラバス」(メモリ階層・キャッシュの出題範囲) — https://www.ipa.go.jp/
  16. ACM/IEEE-CS Joint Task Force, “Computer Science Curricula 2023 (CS2023)”(Architecture and Organization 領域) — https://csed.acm.org/
ジュニアエンジニア向け コンピュータサイエンス入門 ── 全8回の一覧
いま読んでいるのは 第3回 です。読みたい回から始めても構いません。
  1. 第1回:全体地図:その1行は「5階建てのビル」で動いている
  2. 第2回:データの正体:0.1+0.2はなぜ0.3にならないのか
  3. ▶ 第3回:CPUとメモリ:1回のメモリアクセスは100回の計算より高くつく(この記事)
  4. 第4回:OSの仕事:「ファイルを開く」は何をしているのか
  5. 第5回:アルゴリズムとデータ構造:100万件から1人を探すのに20回で足りる
  6. 第6回:ネットワーク:クリックした荷物はどうやって海を渡るのか
  7. 第7回:データベース:100人が同時に書き換えても壊れないのはなぜか
  8. 第8回:並行と並列:awaitを1つ忘れただけで、なぜ本番だけ壊れるのか

© Copyright 2005-2026| Rui Software | All Rights Reserved