File size: 8,025 Bytes
2b6ab7c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
#!/usr/bin/env python3
"""Turn results/io_audit_raw.log (from sdq-ling/audit_io.sh) into io_audit.{json,md}."""
import json
import re
from pathlib import Path

R = Path('/root/app/results')
raw = (R / 'io_audit_raw.log').read_text()


def seg(marker):
    i = raw.index(marker)
    j = raw.find('[audit]', i + 10)
    return raw[i:j if j > 0 else len(raw)]


def one(s, pat, cast=float):
    m = re.search(pat, s)
    return cast(m.group(1)) if m else None


def allv(s, pat):
    return [float(m.group(1)) for m in re.finditer(pat, s)]


def parse(marker, mode, note):
    s = seg(marker)
    rb = allv(s, r'read_bytes=\s+([\d.]+) GiB')
    res = allv(s, r'model_file_resident=\s*([\d.]+) GiB')
    return {
        'mode': mode,
        'device_read_gib_max': max(rb) if rb else None,
        'device_read_gib_samples': len(rb),
        'model_file_resident_gib_peak': one(s, r'peak model_file_resident in page cache :\s+([\d.]+)'),
        'model_file_resident_gib_final': one(s, r'final model_file_resident\s+:\s+([\d.]+)'),
        'rss_anon_gib_peak': one(s, r'peak process RssAnon\s+:\s+([\d.]+)'),
        'rss_file_gib_peak': one(s, r'peak process RssFile\s+:\s+([\d.]+)'),
        'system_cached_delta_gib_peak': one(s, r'system Cached delta \(peak\)\s+:\s+([+\-][\d.]+)'),
        'sdq_chat_reported_ssd_gb': one(s, r'全程 SSD 實際讀取: ([\d.]+) GB'),
        'samples_tail': [f'read_bytes={b:.3f} GiB model_file_resident={r:.3f} GiB'
                         for b, r in list(zip(rb, res))[-6:]],
        'note': note,
    }


doc = {
    'what': 'I/O honesty audit: are the expert weights really coming from the block device, '
            'or is the page cache quietly serving them?',
    'method': 'per-file page-cache eviction (posix_fadvise(DONTNEED) on both GGUF shards), '
              'then mincore(2) over the whole 71.72 GiB of shard data every 0.5 s while '
              '/proc/<pid>/io read_bytes and /proc/<pid>/status RssAnon/RssFile are sampled.',
    'tools': ['sdq-ling/pagecache_resident.cpp', 'sdq-ling/watch_io.py', 'sdq-ling/audit_io.sh'],
    'settings': {'ram_budget_mb': 8192, 'ctx': 2048, 'ubatch': 128, 'threads': 16,
                 'n_predict': 32, 'temp': 0},
    'results': {
        'A_default_o_direct': parse('A) default (O_DIRECT)', 'O_DIRECT (pager default)',
            'the model file stays at ~2.09 GiB resident, which is exactly the non-expert '
            'weights; the tens of GiB of expert reads leave no page-cache footprint'),
        'B_control_buffered_cache_kept': parse(
            'B) control: buffered reads + page cache kept',
            'SDQ_NO_DIRECT_IO=1 + --no-fadvise',
            'same reads, but the page cache retains them; residency tracks the read '
            'volume ~1:1, which is what a buffered implementation would cost'),
    },
    'conclusion': [
        'The pager reads expert weights with O_DIRECT: read_bytes (real device I/O) grows '
        'while mincore residency of the GGUF stays flat.',
        'The only resident part of the model file is the 2.26 GiB of non-expert weights '
        'that llama.cpp keeps in RAM (1.54 GiB of it file-backed via mmap). Not one byte '
        'of the 69.46 GiB of expert weights reaches the page cache.',
        'The control run proves the measurement is sensitive: with buffered reads the same '
        'file accumulates tens of GiB of page cache.',
        'Memory numbers: an 8 GiB pager budget yields 5.83 GiB anonymous RSS '
        '(3.54 GiB arena + ~2.3 GiB non-expert weights) + 1.73 GiB file-backed RSS '
        '= 7.0 GiB total RSS.',
        'sdq-chat\'s "SSD read" line starts its counter after model load, so it excludes '
        'the ~3.9 GiB the loader itself reads; the watcher numbers above cover the whole '
        'process lifetime.',
    ],
}
(R / 'io_audit.json').write_text(json.dumps(doc, ensure_ascii=False, indent=2) + '\n')

A = doc['results']['A_default_o_direct']
B = doc['results']['B_control_buffered_cache_kept']
md = f"""# I/O 稽核:expert 權重真的有從 block device 讀嗎?

問題:如果 pager 其實是被 page cache 服務的,那「69 GiB 的權重留在 SSD、每次按需讀取」這句話就是假的。

## 方法

1. 用 `posix_fadvise(POSIX_FADV_DONTNEED)` 把兩個 GGUF shard(71.72 GiB)**逐檔案**從 page cache 驅逐
   (不依賴 `/proc/sys/vm/drop_caches`,容器裡也做得到),並用 `mincore` 確認回到 0.000 GiB resident。
2. 開跑 `sdq-chat`,每 0.5 s 抽樣:
   * `/proc/<pid>/io` 的 `read_bytes` = 真正從 block device 讀了多少
   * `mincore(2)` 掃過整個模型檔 = 有多少頁留在 page cache
   * `/proc/<pid>/status` 的 `RssAnon` / `RssFile`
3. 跑兩次,其他設定完全相同:
   * **A(預設)**:pager 用 `O_DIRECT` 讀
   * **B(對照組)**:`SDQ_NO_DIRECT_IO=1` + `--no-fadvise` = buffered 讀且**保留** page cache

B 的存在是為了證明這個量測**有鑑別力**:如果看不出差別,A 的數字也沒有意義。

## 結果

| | A:預設(O_DIRECT) | B:對照組(buffered + 保留 cache) |
|---|---:|---:|
| 真正讀進來的量(read_bytes 峰值) | {A['device_read_gib_max']:.2f} GiB | {B['device_read_gib_max']:.2f} GiB |
| **模型檔留在 page cache 的峰值** | **{A['model_file_resident_gib_peak']:.3f} GiB** | **{B['model_file_resident_gib_peak']:.2f} GiB** |
| 程序 RssAnon 峰值 | {A['rss_anon_gib_peak']:.2f} GiB | {B['rss_anon_gib_peak']:.2f} GiB |
| 程序 RssFile 峰值 | {A['rss_file_gib_peak']:.2f} GiB | {B['rss_file_gib_peak']:.2f} GiB |
| 系統 Cached 增量(峰值) | {A['system_cached_delta_gib_peak']:+.2f} GiB | {B['system_cached_delta_gib_peak']:+.2f} GiB |
| `sdq-chat` 自報「SSD 實際讀取」 | {A['sdq_chat_reported_ssd_gb']:.2f} GB | {B['sdq_chat_reported_ssd_gb']:.2f} GB |

A 的時間序列(最後 6 個抽樣)——`read_bytes` 一路爬到
{A['device_read_gib_max']:.1f} GiB,而模型檔 resident 從 2.09 GiB 之後**完全不動**:

```
{chr(10).join(A['samples_tail'])}
```

## 結論

1. **pager 讀 expert 權重走 `O_DIRECT`**:`read_bytes`(真實裝置 I/O)上升時,
   模型檔的 page cache 用量沒有增加。
2. 模型檔唯一留在 page cache 的部分是 **{A['model_file_resident_gib_peak']:.2f} GiB 的非 expert 權重**
   (shard1 1.54 + shard2 0.55 GiB;非 expert 權重總共 2.26 GiB),
   那是 llama.cpp 本來就該留在 RAM 的東西,其中約 1.54 GiB 以 mmap file-backed 方式存在。
   **69.46 GiB 的 expert 權重一個位元組都沒有進 page cache。**
3. **對照組證明量測有鑑別力**:同樣的讀取量改成 buffered,模型檔會在 page cache 堆到
   {B['model_file_resident_gib_peak']:.1f} GiB。如果量測是瞎的,A 和 B 會一樣。
4. 對記憶體數字的影響:8 GiB 的 pager 預算實際是
   **{A['rss_anon_gib_peak']:.2f} GiB 匿名 RSS(其中 arena 3.54 GiB、非 expert 權重 ~2.3 GiB)
   + {A['rss_file_gib_peak']:.2f} GiB 檔案對映 RSS = 7.0 GiB 總 RSS**。
5. 兩個數字的差異是可解釋的:`sdq-chat` 的「SSD 實際讀取」在**載入模型之後**才開始計,
   所以不含 loader 自己讀的 ~3.9 GiB;watcher 的數字涵蓋整個行程生命週期。

> 順帶修掉一個原本會誤導的報表 bug:`sdq-chat` 的記憶體摘要讀 `/proc/self/smaps_rollup` 的
> `RssFile:`,但本機核心的 `smaps_rollup` **沒有這個 key**(只有 `Anonymous:`),
> 於是長期都印「檔案對映 RSS 0 MB」。已改成讀 `/proc/self/status` 的
> `RssAnon:` / `RssFile:` / `VmRSS:`,並在記憶體實驗裡一併記錄三個數字。

原始輸出:`results/io_audit_raw.log`;結構化資料:`results/io_audit.json`
重跑:`./sdq-ling/audit_io.sh <model.gguf> <sdq-chat> 8192 32`
"""
(R / 'io_audit.md').write_text(md)
print('wrote io_audit.json / io_audit.md')
print('A:', A['device_read_gib_max'], A['model_file_resident_gib_peak'])
print('B:', B['device_read_gib_max'], B['model_file_resident_gib_peak'])