1.1 記憶體層級與儲存設備#
概念說明#
電腦的儲存設備可以依「速度、容量、成本」三個軸構成一個金字塔般的記憶體層級(memory hierarchy):
暫存器(registers) ~0.3 ns KB 級
CPU 快取 L1/L2/L3 ~1-40 ns KB~數十 MB
主記憶體 DRAM ~100 ns GB 級
SSD 固態硬碟 ~100 μs TB 級
HDD 傳統硬碟 ~10 ms TB 級
網路/雲端儲存 ~100 ms 幾乎無限
從上到下,大約每一層慢 10 到 1000 倍,但容量與單位成本卻便宜許多。這個「速度落差」正是本章的重點:一個在 DRAM 上只有 100 奈秒的操作,在磁碟上可能要 10 毫秒,相差 10 萬倍。換句話說,如果在演算法設計時不考慮磁碟 I/O,一個理論上 O(n log n) 的演算法,實際可能比 DRAM 上的 O(n²) 還慢。
層級之所以能運作,靠的是兩種區域性(locality):
- 時間區域性(temporal locality):剛被存取的資料,很可能很快會再度被存取。
- 空間區域性(spatial locality):被存取的資料位址附近,很可能也會被存取。
緩衝池(buffer pool)、作業系統分頁快取(page cache)、CPU 快取,全部都是利用這兩種區域性,把「下一筆可能用到的資料」預先放在較快、較貴的層級。
演算法設計的核心轉變:以「塊」為單位思考#
在主記憶體演算法中,我們假設任意存取都是常數時間。但在磁碟演算法中,存取單位是區塊(block)或頁(page),例如 4KB。這帶來兩個重要推論:
- 演算法的 I/O 次數(I/O count)比 CPU 運算次數更重要。一個演算法如果能少讀一次磁碟,往往比多算十幾 quote 次比較更划算。
- 區域性可以被設計。良好的資料結構把相關資料放在同一頁(例如 B-tree 的節點恰好一頁),就能一次 I/O 拿到多筆資訊。
演算法步驟:模擬兩層記憶體的存取#
我們將用 Python 模擬「DRAM 緩衝池 + 磁碟」兩層結構,並展示線性掃描與隨機存取在區域性差異下的效能差距。
# 01.1 範例:兩層記憶體模擬器
import time, random
class SimDisk:
"""模擬磁碟:每次 read_page 花磁碟延遲(以較快的虛擬時間計)"""
def __init__(self, pages: int, page_size: int, io_latency_ms: float):
self.data = [bytearray(page_size) for _ in range(pages)]
self.io_latency_ms = io_latency_ms
self.io_count = 0
def read_page(self, pid: int) -> bytes:
self.io_count += 1
time.sleep(self.io_latency_ms / 1000) # 真實等待,僅示範用
return bytes(self.data[pid])
class BufferPool:
"""簡單的緩衝池:記憶體快取最近讀過的頁"""
def __init__(self, disk: SimDisk, nframes: int):
self.disk = disk
self.frames = {} # pid -> bytes
self.order = [] # FIFO 替換用的佇列
self.nframes = nframes
self.hit = 0
self.miss = 0
def get_page(self, pid: int) -> bytes:
if pid in self.frames:
self.hit += 1
return self.frames[pid]
self.miss += 1
if len(self.frames) >= self.nframes:
victim = self.order.pop(0)
del self.frames[victim]
page = self.disk.read_page(pid)
self.frames[pid] = page
self.order.append(pid)
return page
# 展示:線性掃描(好區域性)vs 隨機存取(壞區域性)
def demo():
disk = SimDisk(pages=1000, page_size=4096, io_latency_ms=10) # 10ms 延遲
bp = BufferPool(disk, nframes=100)
# 線性掃描:每頁讀一次,共 1000 次 I/O
for pid in range(1000):
bp.get_page(pid)
print(f"線性掃描: I/O 次數={disk.io_count}")
# 重新建立緩衝池做隨機存取示範
disk2 = SimDisk(pages=1000, page_size=4096, io_latency_ms=10)
bp2 = BufferPool(disk2, nframes=100)
random.seed(42)
for _ in range(1000):
bp2.get_page(random.randint(0, 999))
print(f"隨機存取: I/O 次數={disk2.io_count}, 緩衝命中率={bp2.hit/1000:.2%}")
if __name__ == "__main__":
demo()
執行時可觀察到:同樣 1000 次 get_page,線性掃描觸發 1000 次真實磁碟 I/O,而隨機存取在緩衝池的幫助下 I/O 次數相近,但命中率高;若把延遲調大,時間差距會立刻顯現。這正是層級效應的具體化。
複雜度與 I/O 成本分析#
設問題有 n 筆資料,每頁 B 筆,共 n/B 頁:
- DRAM 內線性掃描:O(n) 次處理器存取,全部微秒內完成。
- 磁碟線性掃描:
n/B次 I/O,若 B=4096、n=10⁹(約 4GB 資料),約需 25 萬次頁讀取;在 HDD 上以 10ms/次計算,持續 40 分鐘;但若這 4GB 是連續存放,每頁只需邏輯定址且循序讀取可達 200MB/s,實際只需約 20 秒。客群:循序 vs 循序的差距是幾百倍。 - 隨機 4KB 讀取(HDD):10ms/次 → 100 IOPS → 讀 1GB 隨機頁需要
1000MB/4KB × 10ms ≈ 2.8 小時。這個數字解釋了為何「隨機小 I/O」是磁碟演算法的第一公敵。
練習#
- 使用上面的模擬器,將
nframes從 100 改成 10,比較線性掃描的 I/O 次數是否不變,並解釋為什麼(提示:線性掃描本身也是壞區域性的一種)。 - 假設 HDD 延遲 10ms、SSD 延遲 100μs,計算隨機讀取 100 萬個 4KB 頁分別需要多久。這說明了 SSD 對哪一類演算法最友善?
- 寫一個簡單實驗:對同一份頁序列,先用 FIFO 替換、再用 LRU 替換,觀察命中率差異。什麼存取模式會讓 FIFO 表現明顯較差?