メモリアライメントとは?構造体パディングとC言語での制御・アーキテクチャ別の挙動

メモリアライメントとは、変数や構造体を「型のサイズに応じた特定のバイト境界」に配置し、CPUが1回のアクセスでデータを読み書きできるようにする仕組みです。この記事はメモリ(CPU)のアライメントを扱います(AIの価値整合や組織のアライメントとは別の概念です)。定義とCPUが遅くなる理由から、構造体パディングの具体的なバイト配置、C言語のalignof・alignas・#pragma packでの制御、x86とARMでの挙動の違い、Go言語の自動調整までをコード付きで整理します。

まとめ:メモリアライメントの要点

  • アライメントとは、N バイトの型を N の倍数のアドレスに置くこと。int は4、double とポインタは8バイト境界が基本。
  • 整列していないと、キャッシュライン(64バイト)をまたいだ読み込みで2回のメモリアクセスが必要になり得る。速度低下の主因はこの境界跨ぎ。
  • 構造体はメンバ間に自動でパディングが入る。大きい型から順に宣言するとパディングが減り、サイズが縮む。
  • C言語では alignof で要求を調べ、alignas で強制、#pragma pack で詰め、offsetof でパディング位置を確認する。
  • x86 は非整列でも動くが、ARM は SIMD・排他命令・Device メモリで整列フォルト(Linux では SIGBUS)を起こす。Go は自動調整するが 32bit 環境の64bitアトミックだけ例外。

メモリアライメントの定義とCPU高速化の理由

アライメント(alignment)は「整列」を意味し、メモリアライメントはデータを型のサイズに合った境界アドレスへ揃えることを指します。現代のCPUはメモリを1バイト単位ではなく、ワードやキャッシュライン単位でまとめて読み書きするため、この境界が性能を左右します。

自然境界(naturally aligned)の考え方

各型には「自然境界」があり、サイズが N バイトの型はアドレスが N の倍数になる位置に置かれるのが基本です。char は1、short は2、int と float は4、double とポインタ(64bit環境)は8バイト境界に揃います。たとえば4バイトの int は 0, 4, 8, 12… のアドレスに配置するのが正しく、アドレス 5 から始めると非整列(misaligned)になります。この境界要求は型ごとに決まっており、C言語では後述の alignof で実際の値を確認できます。

非整列アクセスでCPUに何が起きるか

CPUはメモリをキャッシュライン(多くのx86/ARMで64バイト)単位でキャッシュに載せます。4バイトの int がアドレス 62 から始まると、その4バイトは 62〜63 と 64〜65 の2本のキャッシュラインに分割されます。CPUは両方のラインを読み、結果を結合してから値を返すため、整列時の1アクセスに対して2アクセス分のコストがかかります。キャッシュの仕組みそのものはディスクキャッシュとメモリキャッシュの違いで整理していますが、アライメントが問題になるのは常に「境界を跨いだとき」だという点が要です。

構造体のパディングとメモリ配置

アライメントが最も表面化するのが構造体です。コンパイラは各メンバをその型の境界に揃えるため、メンバの間や末尾に「パディング」と呼ばれる詰め物のバイトを自動で挿入します。宣言順によってこのパディング量が変わり、同じメンバでも構造体のサイズが変わります。

パディングが挿入される仕組み(バイト配置の具体例)

char・int・char を順に並べた次の構造体を、64bit環境(int=4バイト境界)で考えます。

struct Bad {
    char  a;   // offset 0(1バイト)
    int   b;   // offset 4(aの後ろに3バイトのパディング)
    char  c;   // offset 8
};             // sizeof(struct Bad) == 12(末尾に3バイトのパディング)

a は1バイトですが、次の int b を4の倍数アドレスに置くため a の後ろに3バイトの穴が空きます。さらに構造体全体のサイズは、その構造体の最大境界(ここでは4)の倍数に切り上げられるため、c の後ろにも3バイトのパディングが入り、合計12バイトになります。実際に使われているのは6バイト(12バイト中)で、半分が詰め物です。

オフセット 0 1〜3 4〜7 8 9〜11
内容 a パディング b c パディング

メンバ並べ替えによるサイズ削減

大きい型から小さい型へ並べ替えると、中間のパディングが消えてサイズが縮みます。

struct Good {
    int   b;   // offset 0
    char  a;   // offset 4
    char  c;   // offset 5
};             // sizeof(struct Good) == 8(末尾2バイトのパディングのみ)

同じ3メンバでも 12バイト→8バイトへ、3分の1削減できました。構造体を大量に配列で確保する場面(数百万件のレコードなど)では、この差がそのままメモリ使用量とキャッシュ効率に直結します。迷ったら「サイズの大きいメンバから宣言する」のが実用的な指針です。

C言語でのアライメント制御

C言語はアライメントを言語仕様として明示的に操作できます。C11 で _Alignof_Alignas<stdalign.h> が導入され、C23 では alignofalignas がそのまま組み込みキーワードになりました(C11〜C17 では <stdalign.h> をインクルードすると alignof という綴りが使えます)。

alignofによる要求値の確認

#include <stdalign.h>   // C11〜C17。C23では不要
#include <stdio.h>

printf("%zu\n", alignof(int));      // 4
printf("%zu\n", alignof(double));   // 8
printf("%zu\n", alignof(char));     // 1

alignof は型の境界要求をバイト数(size_t)で返します。移植性のあるコードでは「4バイト境界」と決め打ちせず、この値を使って判断します。

alignas・#pragma packでの指定

alignas は境界を「広げる」指定です。SIMD 命令はオペランドを16バイトや32バイト境界に要求することが多く、次のように明示します。

alignas(16) float vec[4];   // 16バイト境界に配置(SSE向け)

逆にパディングを「詰める」のが #pragma pack です。ネットワークプロトコルやファイルフォーマットのように、バイト配置を厳密に一致させたいときに使います。

#pragma pack(push, 1)
struct Packed {
    char a;
    int  b;
};                 // sizeof == 5(パディングなし)
#pragma pack(pop)

ただし詰めた構造体のメンバは非整列配置になるため、ARM など整列に厳しい環境では後述のフォルトを招きます。#pragma pack は「外部フォーマットと一致させる目的」に限定し、常用しないのが安全です。

offsetofでのパディング位置の確認

#include <stddef.h>

offsetof(struct Bad, b);   // 4 ← aの後ろに3バイトのパディングがある証拠
offsetof(struct Bad, c);   // 8

offsetof は構造体先頭から各メンバまでのバイト数を返します。想定より大きな値が出たらそこにパディングが入っています。メモリ配置はポインタでアドレスを直接扱う場面で意識する必要があり、非整列なポインタ経由のアクセスはアーキテクチャによって挙動が分かれます。

アーキテクチャ別の非整列アクセスの挙動

非整列アクセスが「遅いだけ」で済むか「クラッシュする」かは、CPUアーキテクチャで大きく異なります。ここを取り違えると、x86 で動いたコードが ARM 実機で落ちます。

アーキテクチャ 通常のロード/ストア フォルトする場合
x86 / x86-64 非整列を許容(速度低下のみ) SSEの整列必須命令(MOVAPS等)で#GP
ARMv6以降(v7/v8含む) Normalメモリなら非整列を許容 LDM/STM、排他/順序付き命令、SIMD、Deviceメモリ
ARMv5以前 非整列を正しく扱えない フォルトまたは値のローテーション(実装依存)

x86 は非整列アクセスをハードウェアが吸収するため実行は継続し、コストは境界跨ぎ時の追加アクセスに留まります。ただし SSE の整列必須ロード(MOVAPS など)は16バイト境界でないと一般保護例外(#GP)で落ちるため、SIMD では alignas が必須です。ARMv6 以降は通常の単一ロード/ストアなら非整列を許容しますが、複数レジスタ転送(LDM/STM)、排他・順序付きアクセス、NEON の一部 SIMD 命令、Device メモリへのアクセスは整列フォルトを発生させ、Linux ユーザー空間では SIGBUS になります。「x86 で問題ないから大丈夫」という判断はクロスプラットフォームでは成り立ちません。

非整列アクセスは本当に遅いのか

「非整列は必ず遅い」と語られがちですが、現代の x86-64 ではそのペナルティは限定的です。境界を跨がない非整列アクセスは、整列アクセスとほぼ同じ速度で実行されます。速度差が顕在化するのは主に次の2ケースに絞られます。

  • キャッシュライン(64バイト)を跨ぐアクセス:2本のラインを読むため追加コストが発生する。ページ境界(4KB)跨ぎはさらに重い。
  • SIMD の整列必須命令:ここは速度以前に、非整列だと例外で停止する。

したがって実務の判断は「すべてを整列させる」ではなく、ホットループで扱う大きな配列・SIMD 対象・境界を跨ぎやすいデータだけを整列させ、それ以外は詰めてメモリを節約するのが合理的です。構造体パディングの削減(メモリ節約・キャッシュ効率)と、SIMD 向けの過剰整列(alignas)は目的が逆であり、同じ「アライメント最適化」でも狙いを分けて考える必要があります。むやみに全体を #pragma pack で詰めると、非整列アクセスが増えて ARM で落ちるだけでなく x86 でも境界跨ぎが増え、逆効果になり得ます。

Go言語のアライメント自動調整と例外

Go はコンパイラがアライメントを自動で管理するため、通常はパディングを意識せずに書けます。C の alignofsizeofoffsetof に相当する機能は unsafe パッケージにあります。

import "unsafe"

unsafe.Alignof(int64(0))   // 8
unsafe.Sizeof(myStruct)    // パディング込みのサイズ
unsafe.Offsetof(s.field)   // メンバのオフセット

Go でも構造体のパディングは C と同じ規則で入るため、フィールドを大きい型から並べるとサイズが縮みます。並べ替えの余地は fieldalignment アナライザ(golang.org/x/tools)が自動検出してくれます(go vet -vettool や gopls 経由で実行)。

注意すべき例外が1つあります。32bit 環境(32bit ARM や 386)では、sync/atomic の64bit操作(AtomicなInt64など)が対象を8バイト境界に要求し、構造体の途中フィールドだと非整列になってパニックする既知の制約があります。Go 1.19 以降の atomic.Int64 型を使えばこの整列は保証されるため、32bit 対応が必要なら生の int64 + atomic.AddInt64 ではなく型ベースのAPIを選ぶのが安全です。メモリ配置全般の理解にはRustで見るスタックとヒープのメモリ管理もあわせて参照してください。

よくある質問

メモリアライメントとは簡単に言うと何ですか?

N バイトの型を N の倍数のアドレスに置くことです。CPU がメモリをまとめて読む単位に合わせておくと、1回のアクセスでデータを取得でき、境界を跨いだ余分な読み込みを避けられます。

アライメントを揃えないとどうなりますか?

x86 では速度がやや落ちる程度で動作は継続します。一方 ARM や MIPS では、SIMD 命令や複数レジスタ転送などで整列フォルトが起き、Linux では SIGBUS でプログラムが停止することがあります。

構造体のパディングを減らすにはどうすればいいですか?

メンバをサイズの大きい型から順に宣言します。char・int・char の順(12バイト)を int・char・char の順(8バイト)に変えるだけでサイズが縮みます。offsetof で穴の位置を確認できます。

C言語でアライメントを指定するには何を使いますか?

境界を広げるなら alignas、詰めるなら #pragma pack、要求値を調べるなら alignof を使います。alignofalignas は C23 でキーワード、C11〜C17 では <stdalign.h> をインクルードして使います。

#pragma pack は常に使ってよいですか?

常用は避けるべきです。詰めた構造体は非整列配置になり、ARM などでフォルトの原因になります。ネットワークパケットやファイルフォーマットなど、外部とバイト配置を一致させたい箇所だけに限定してください。

関連記事

資料請求

RELATED POSTS 関連記事