Skip to content

필사 모드: オペレーティングシステム完全ガイド: プロセス・メモリ・ファイルシステムからAIワークロード最適化まで

日本語
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

はじめに

オペレーティングシステム(OS)はハードウェアとアプリケーションの間の**仲介者**です。AI/MLエンジニアとして、Pythonコードを書くだけでなく、以下の質問に答えられる必要があります。

- PyTorchの学習が遅い原因は、CPUスケジューリングなのかメモリ帯域幅なのか?

- マルチプロセッシングとマルチスレッディング、どちらが効率的か?

- 複数チームがGPUリソースを共有するとき、どのように分離を実装するか?

このガイドでは、これらの質問に対してOSのコア概念と実践的なサンプルを通じて答えます。

1. プロセスとスレッド

プロセスとは?

プロセスは**実行中のプログラムのインスタンス**です。各プロセスは独立した仮想アドレス空間、ファイルディスクリプタ、シグナルハンドラを持ちます。

**プロセス状態ダイアグラム:**

新規(New) ──→ 準備(Ready) ──→ 実行(Running)

↑ │

│ スケジューラ │ I/O要求

└── 待機(Wait) ←┘

終了(Terminated)

**PCB(Process Control Block)** はカーネルが各プロセスに対して保持するデータ構造です。

// Linux の task_struct (簡略化)

struct task_struct {

pid_t pid; // プロセスID

int state; // 現在の状態

struct mm_struct *mm; // 仮想メモリマッピング

struct files_struct *files; // オープンファイルテーブル

struct thread_info thread_info; // レジスタ保存

long prio; // スケジューリング優先度

};

fork() + exec() によるプロセス生成

#include <stdio.h>

#include <unistd.h>

#include <sys/wait.h>

int main(void) {

pid_t pid = fork();

if (pid < 0) {

perror("fork失敗");

return 1;

} else if (pid == 0) {

// 子プロセス

char *args[] = {"/bin/ls", "-la", NULL};

execv("/bin/ls", args);

perror("exec失敗"); // exec成功時はここに到達しない

} else {

// 親プロセス

int status;

waitpid(pid, &status, 0);

printf("子プロセス終了コード: %d\n", WEXITSTATUS(status));

}

return 0;

}

`fork()` は親の仮想アドレス空間を **Copy-on-Write(CoW)** 方式でコピーします。実際の物理メモリは書き込みが発生した時だけコピーされるため効率的です。

スレッド vs プロセス

| 項目 | プロセス | スレッド |

| ------------ | ---------------------- | ------------------------- |

| アドレス空間 | 独立 | 共有 |

| 生成コスト | 高い | 低い |

| 通信方法 | IPC (パイプ、ソケット) | 共有メモリ |

| 障害分離 | 強い | 弱い |

| Python GIL | 影響なし | CPUバウンドでボトルネック |

コンテキストスイッチ

CPUがプロセスAからBに切り替える際:

1. AのレジスタをPCBに保存

2. 仮想メモリマッピング (ページテーブルポインタ) を切り替え

3. TLBをフラッシュ (キャッシュ無効化)

4. BのレジスタをPCBから復元

コンテキストスイッチは数マイクロ秒のコストがあります。AI推論サーバーでは過剰なスレッドがかえって性能を低下させることがあります。

2. CPUスケジューリング

主要アルゴリズムの比較

**FIFO (First-In, First-Out)**

- シンプルだが、短いジョブが長いジョブの後ろで待たされる**コンボイ効果**が発生

**SJF (Shortest Job First)**

- 平均待ち時間を最小化するが、実行時間の予測が難しい

**Round Robin**

- 各プロセスに固定のタイムスライス(quantum)を割り当て

- タイムスライスが短すぎるとコンテキストスイッチのオーバーヘッドが増大

**Linux CFS (Completely Fair Scheduler)**

- `vruntime`ベース: 各プロセスがCPUをどれだけ使用したかを追跡

- Red-Blackツリーで最も少なく実行されたプロセスをO(log n)で選択

// vruntimeの計算 (概念的な疑似コード)

void update_vruntime(struct task_struct *task, u64 delta_exec) {

// 優先度が高いほど vruntim の増加が遅くなる

u64 weight = prio_to_weight[task->nice + 20];

task->vruntime += delta_exec * NICE_0_WEIGHT / weight;

}

優先度逆転(Priority Inversion)問題

高優先度 H ────────────────────→ 待機中 (lockが必要)

中優先度 M ──────────────────→ Hより先に実行!

低優先度 L ──→ lockを保有中 ──→ プリエンプトされる

LがlockをLを保持したままMにプリエンプトされると、HはMよりも遅く実行されます。解決策は **Priority Inheritance**: LがLockを保有している間、Hの優先度を一時的に継承させます。

3. メモリ管理

仮想メモリとページング

すべてのプロセスは自分が物理メモリ全体を独占していると錯覚しています。カーネルは**ページテーブル**で仮想アドレスを物理アドレスに変換します。

仮想アドレス (x86-64 の 48ビット):

┌──────────┬──────────┬──────────┬──────────┬────────────┐

│ PGD(9) │ PUD(9) │ PMD(9) │ PTE(9) │ offset(12) │

└──────────┴──────────┴──────────┴──────────┴────────────┘

TLB (Translation Lookaside Buffer)

ページテーブルの参照にはメモリアクセスが必要で遅いため、TLBは最近の変換結果をキャッシュします。

**TLBミスの処理手順:**

1. CPUが仮想アドレスでTLBを参照 → ミス

2. CPUがCR3レジスタのページテーブル基準アドレスを参照

3. 4段階のページテーブルウォーク (メモリへの4回のアクセス)

4. PTEから物理アドレスを取得 → TLBに格納

5. 元のメモリアクセスをリトライ

ページ置換アルゴリズム

**LRU (Least Recently Used):**

最も長く使われていないページを置換します。LinuxはLRUを近似したクロックアルゴリズムを使用しています。

クロックアルゴリズム:

各ページに参照ビット(R)を保持

ポインタが循環しながら R=0 のページを置換対象として選択

R=1 の場合は R=0 にリセットして次のページへ

Pythonでのmmapの活用

大規模データセットにメモリマップでアクセス

with open("large_dataset.bin", "r+b") as f:

ファイル全体を仮想アドレス空間にマッピング

mm = mmap.mmap(f.fileno(), 0)

スライシングで必要な領域だけアクセス (実際のI/Oは必要な時だけ)

header = mm[:128]

record = mm[128:256]

mm.close()

AI学習: numpy memmapでディスクデータを配列としてアクセス

data = np.memmap("features.npy", dtype="float32", mode="r", shape=(1_000_000, 512))

batch = data[0:1024] # 必要なバッチだけをディスクからロード

4. 同期

MutexとCondition Variable

#include <pthread.h>

#include <stdio.h>

#include <stdlib.h>

#define BUFFER_SIZE 10

int buffer[BUFFER_SIZE];

int count = 0;

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

pthread_cond_t not_full = PTHREAD_COND_INITIALIZER;

pthread_cond_t not_empty = PTHREAD_COND_INITIALIZER;

void *producer(void *arg) {

for (int i = 0; i < 50; i++) {

pthread_mutex_lock(&mutex);

while (count == BUFFER_SIZE)

pthread_cond_wait(&not_full, &mutex); // バッファが満杯なら待機

buffer[count++] = i;

printf("生産: %d (count=%d)\n", i, count);

pthread_cond_signal(&not_empty);

pthread_mutex_unlock(&mutex);

}

return NULL;

}

void *consumer(void *arg) {

for (int i = 0; i < 50; i++) {

pthread_mutex_lock(&mutex);

while (count == 0)

pthread_cond_wait(&not_empty, &mutex); // バッファが空なら待機

int val = buffer[--count];

printf("消費: %d (count=%d)\n", val, count);

pthread_cond_signal(&not_full);

pthread_mutex_unlock(&mutex);

}

return NULL;

}

int main(void) {

pthread_t prod, cons;

pthread_create(&prod, NULL, producer, NULL);

pthread_create(&cons, NULL, consumer, NULL);

pthread_join(prod, NULL);

pthread_join(cons, NULL);

return 0;

}

デッドロック(Deadlock)

**Coffman条件** — 4つすべてが成立するとデッドロックが発生:

1. **相互排他(Mutual Exclusion)**: リソースは一度に一つのプロセスのみ使用可能

2. **占有と待機(Hold and Wait)**: リソースを保持したまま別のリソースを待つ

3. **非プリエンプション(No Preemption)**: 保有リソースを強制的に奪えない(自発的な解放のみ)

4. **循環待機(Circular Wait)**: P1→P2→P3→P1 という循環依存が存在

**予防戦略:**

- リソースの取得順序を固定する (循環待機を排除)

- 必要なリソースをすべて一度に要求する (占有と待機を排除)

- バンカーズアルゴリズムで安全状態を維持する

5. ファイルシステム

ext4とinode

inode情報を確認

stat /etc/hostname

File: /etc/hostname

Size: 12 Blocks: 8 IO Block: 4096 regular file

Inode: 131073 Links: 1

Access: 2026-03-17 10:00:00

残りinode数を確認 (inode枯渇もディスク満杯と同じ効果)

df -i /

**inode構造:**

- ファイルメタデータ (権限、オーナー、タイムスタンプ)

- データブロックポインタ (直接/間接/二重間接)

- ファイル名はinodeに含まれない — ディレクトリエントリに格納

ジャーナリング

ext4は**ジャーナリング**により、突然の電源断後の復旧を保証します。

書き込み前: ジャーナルに変更内容を先に記録 (Write-Ahead Log)

書き込み後: 実際のブロックに反映

完了後: ジャーナルエントリを削除 (Commit)

VFS抽象化レイヤー

ユーザー空間: open() read() write()

カーネルVFS: vfs_open() vfs_read() ← 共通インターフェース

ファイルシステム: ext4 | btrfs | tmpfs | procfs | nfs

ブロックデバイス層 → 実際のハードウェア

6. I/Oと割り込み

DMAと割り込みハンドラ

CPUが直接データをメモリにコピーせず、DMAコントローラが処理します。

1. CPU → DMA: "ディスクブロックXをメモリアドレスYにコピーして"

2. DMAが独立して転送 (CPUは別の作業を継続)

3. DMA完了 → 割り込み発生

4. CPU: 現在の命令を完了 → 割り込みベクタテーブルを参照 → ISR実行

5. ISR: 完了処理、待機中のプロセスを起こす

epoll vs io_uring

**epoll** (イベント駆動I/O多重化):

// epoll方式: 複数のシステムコールが必要

int epfd = epoll_create1(0);

epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &event);

epoll_wait(epfd, events, MAX_EVENTS, -1); // システムコール

read(fd, buf, size); // またシステムコール

**io_uring** (Linux 5.1以降, 共有リングバッファ):

// io_uring: I/O送信にシステムコール不要

struct io_uring ring;

io_uring_queue_init(256, &ring, 0);

struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);

io_uring_prep_read(sqe, fd, buf, size, 0);

io_uring_submit(&ring); // 1回のシステムコールで複数のI/Oを送信

// 完了を待機 (共有メモリでカーネルと通信)

struct io_uring_cqe *cqe;

io_uring_wait_cqe(&ring, &cqe);

**io_uringが高性能な理由:**

- SQ/CQリングバッファをユーザー/カーネルで共有 → データコピー不要

- 複数のI/Oをバッチ送信 → システムコール回数を削減

- `IORING_SETUP_SQPOLL` モード: カーネルスレッドがポーリング → システムコールゼロ

- バッファ事前登録: `io_uring_register_buffers()` でI/Oごとのアドレスマッピングが不要

7. AI/ML視点でのオペレーティングシステム

NUMAアーキテクチャ

マルチソケットサーバーでは、各CPUソケットがローカルメモリを持ちます。

Socket 0 Socket 1

┌─────────┐ ┌─────────┐

│ CPU 0 │──QPI────│ CPU 1 │

│ 32GB RAM│ │ 32GB RAM│

└─────────┘ └─────────┘

ローカルアクセス: 約100ns リモートアクセス: 約300ns

AI学習へのNUMAの影響:

- DataLoaderワーカーがSocket 0で動作し、GPUがSocket 1のPCIeに接続されている場合、すべてのデータがQPIインターコネクトを経由する

- `numactl --cpunodebind=0 --membind=0 python train.py` でGPUと同じNUMAノードに固定

NUMAトポロジーの確認

numactl --hardware

PythonプロセスのNUMA統計を監視

numastat -p python

Pythonマルチプロセッシング vs マルチスレッディング

def cpu_bound_task(n):

"""素数カウント (CPU集約的)"""

count = 0

for i in range(2, n):

if all(i % j != 0 for j in range(2, int(i**0.5) + 1)):

count += 1

return count

N = 100_000

マルチスレッディング: GILによりCPUバウンドでは性能向上なし

start = time.time()

threads = [threading.Thread(target=cpu_bound_task, args=(N,)) for _ in range(4)]

[t.start() for t in threads]

[t.join() for t in threads]

print(f"スレッディング: {time.time() - start:.2f}秒")

マルチプロセッシング: 別プロセスでGILを回避

start = time.time()

with multiprocessing.Pool(4) as pool:

pool.map(cpu_bound_task, [N] * 4)

print(f"プロセッシング: {time.time() - start:.2f}秒")

結果: プロセッシングが約4倍速い

cgroupsによるGPUリソースの分離

cgroups v2でチームごとのGPUリソース制限

チームAのグループを作成

mkdir /sys/fs/cgroup/team_a

CPU制限: 全体の25%のみ使用

echo "25000 100000" > /sys/fs/cgroup/team_a/cpu.max

メモリ制限: 16GB

echo $((16 * 1024 * 1024 * 1024)) > /sys/fs/cgroup/team_a/memory.max

現在のプロセスをグループに追加

echo $$ > /sys/fs/cgroup/team_a/cgroup.procs

NVIDIA MIG + cgroupsの組み合わせでGPU分離

MIG: 一つのA100を複数のインスタンスに分割

nvidia-smi mig -cgi 3g.40gb -C # 40GBインスタンスを作成

DockerでGPUリソースを制限

docker run --gpus '"device=0,1"' \

--cpuset-cpus="0-15" \

--memory="32g" \

pytorch/pytorch:latest python train.py

/procファイルシステムの探索

プロセスのメモリマッピングを確認

cat /proc/$(pgrep python)/maps

プロセスの状態を確認

cat /proc/$(pgrep python)/status | grep -E "VmRSS|VmPeak|Threads"

CPUスケジューリング統計

cat /proc/$(pgrep python)/schedstat

NUMAメモリバインディングの確認

cat /proc/$(pgrep python)/numa_maps | head -20

システム全体のメモリ情報

cat /proc/meminfo | grep -E "MemTotal|MemFree|Cached|HugePages"

クイズ

OSのコア概念を確認しましょう。

**答え**: 合計5ステップで処理されます。

**解説**:

1. CPUが仮想アドレスでTLBを参照するが、該当エントリがない (TLBミス)

2. CPUのMMUがCR3レジスタに格納されたページテーブル基準アドレス (PGD) を参照

3. PGD → PUD → PMD → PTE の順に4段階のページテーブルをウォークして物理アドレスを取得 (メモリへの4回のアクセス)

4. 取得した仮想-物理アドレスのマッピングをTLBに格納

5. 元のメモリアクセスをリトライして完了

TLBヒット率が低いと大幅な性能低下が起きます。Huge Pages (2MB/1GB) を使うとTLBエントリ数を削減してヒット率を高めることができます。

**答え**: vruntimeは優先度で重み付けされた仮想実行時間で、公平なCPU配分を実現します。

**解説**:

- 各タスクは実際のCPU実行時間に優先度の重みを適用した `vruntime` を累積します

- 優先度が高いタスクは同じ実行時間でもvruntimeの増加が遅くなります

- CFSは常にvruntimeが最も小さいタスク (Red-BlackツリーのLeftmostノード) を選択します

- 結果として、すべてのタスクが優先度に比例してCPUを「公平に」利用できます

- 新しいタスクの初期vruntimeは `min_vruntime` に設定し、既存タスクが不利にならないようにします

**答え**: 相互排他、占有と待機、非プリエンプション、循環待機

**解説**:

1. **相互排他**: リソースは一度に一つのプロセスのみ使用可能 (プリンタやmutexなど)

2. **占有と待機**: すでにリソースを保持した状態で追加リソースを待つ

3. **非プリエンプション**: プロセスが保有するリソースは強制的に奪えない (自発的な解放のみ)

4. **循環待機**: P1がP2のリソースを、P2がP3のリソースを、P3がP1のリソースを待つ循環

この4条件のうち一つでも成立しなければデッドロックは発生しません。予防戦略はこの中の一つを排除する形で設計します。

**答え**: システムコールオーバーヘッドの最小化とゼロコピー共有リングバッファ

**解説**:

- **システムコールの削減**: epollはイベント検知と実際のread/writeでそれぞれシステムコールが必要ですが、io_uringは一つの `io_uring_submit()` で複数のI/Oをバッチ送信

- **共有リングバッファ**: SQ (Submission Queue) とCQ (Completion Queue) をユーザー/カーネル空間が共有するメモリで実装し、データコピーが不要

- **SQPOLLモード**: カーネルスレッドがSQをポーリングするため `submit` システムコールも不要

- **バッファ事前登録**: `io_uring_register_buffers()` でバッファを事前に登録することで、I/Oごとのアドレスマッピングが不要

- 結果として、最小限のCPUオーバーヘッドで毎秒数百万件のI/O処理が可能になります

**答え**: メモリ帯域幅の低下とレイテンシ増加による学習速度の低下

**解説**:

- NUMAのローカルメモリアクセスは約100nsですが、リモートアクセスは約300nsと3倍遅い

- AI学習では大容量のテンソルデータを毎イテレーションGPUに転送するため、メモリ帯域幅が重要なボトルネックになる

- DataLoaderワーカーがSocket 0にバインドされ、GPUがSocket 1のPCIeに接続されている場合、すべてのデータがQPIインターコネクトを経由しなければならない

- **最適化方法**: `numactl --cpunodebind=N --membind=N` でGPUと同じNUMAノードにプロセスとメモリを固定し、`torch.cuda.set_device()` とNUMAアウェアなDataLoaderを組み合わせて使用する

まとめ

OSはAIエンジニアにとってブラックボックスであってはなりません。重要なポイント:

- **スケジューリング**: CFSのvruntimeによる公平なCPU配分、優先度逆転に注意

- **メモリ**: 仮想メモリで分離、TLBミスを最小化、Huge Pagesを活用

- **同期**: mutex/condvarで競合状態を排除、ロック順序でデッドロックを予防

- **I/O**: io_uringでシステムコールオーバーヘッドを最小化

- **AI最適化**: NUMAアウェアな配置、cgroupsによるGPU分離、/procでボトルネックを診断

현재 단락 (1/276)

オペレーティングシステム(OS)はハードウェアとアプリケーションの間の**仲介者**です。AI/MLエンジニアとして、Pythonコードを書くだけでなく、以下の質問に答えられる必要があります。

작성 글자: 0원문 글자: 9,957작성 단락: 0/276