Skip to content
Published on

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

Authors

はじめに

オペレーティングシステム(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-6448ビット):
┌──────────┬──────────┬──────────┬──────────┬────────────┐
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の活用

import mmap
import os

# 大規模データセットにメモリマップでアクセス
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でディスクデータを配列としてアクセス
import numpy as np
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. CPUDMA: "ディスクブロック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 マルチスレッディング

import time
import threading
import multiprocessing

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のコア概念を確認しましょう。

Q1. 仮想メモリでTLBミスが発生したときの処理手順を順番に説明してください。

答え: 合計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エントリ数を削減してヒット率を高めることができます。

Q2. Linux CFSがvruntimeをどのように使用し、どのように公平性を保証しているか説明してください。

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

解説:

  • 各タスクは実際のCPU実行時間に優先度の重みを適用した vruntime を累積します
  • 優先度が高いタスクは同じ実行時間でもvruntimeの増加が遅くなります
  • CFSは常にvruntimeが最も小さいタスク (Red-BlackツリーのLeftmostノード) を選択します
  • 結果として、すべてのタスクが優先度に比例してCPUを「公平に」利用できます
  • 新しいタスクの初期vruntimeは min_vruntime に設定し、既存タスクが不利にならないようにします
Q3. デッドロック発生の4つの必要条件 (Coffman条件) を説明してください。

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

解説:

  1. 相互排他: リソースは一度に一つのプロセスのみ使用可能 (プリンタやmutexなど)
  2. 占有と待機: すでにリソースを保持した状態で追加リソースを待つ
  3. 非プリエンプション: プロセスが保有するリソースは強制的に奪えない (自発的な解放のみ)
  4. 循環待機: P1がP2のリソースを、P2がP3のリソースを、P3がP1のリソースを待つ循環

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

Q4. io_uringがepollよりも高スループットI/Oに有利な理由を説明してください。

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

解説:

  • システムコールの削減: 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処理が可能になります
Q5. NUMAアーキテクチャにおけるリモートメモリアクセスがAI学習性能に与える影響を説明してください。

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

解説:

  • 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でボトルネックを診断