Wednesday, July 27, 2022

在 Ubuntu 中增加 Swap 交換檔案

引言
要明白 Swap,首先要明白「隨機存取記憶體」 (RAM)。

隨機存取記憶體 (RAM),是一種能與處理器 (CPU) 快速交換資料的記憶體裝置。一般而言,作業系統需要先把程式和資料,由硬碟載入到記憶體中。然後,CPU 才能通過記憶控制器 (Memory Controller) ,去讀取記憶體中的資料,以及執行裏面的程式。

不過,由於 RAM 的價格比較高昂,一般主機都不會有太多 RAM。所以,當系統沒有足夠的 RAM,但又需要載入一些大型的軟件時,系統就有可能因為 RAM 空間不足,導致無法載入,或者被迫中止程式。為了解決這個問題,作業系統需要把一些閒置、不常用的的程式,先從 RAM 中抽取,放到硬碟中。然後,系統就能將需用的資料和程式,重新載入到釋出的 RAM 中。 這樣,系統便能在有限的 RAM 空間下,執行較多的程式。

Linux 系統設計之時,就已經考慮到這一點,並把這個概念命名為 Swap (交換):將 RAM 中的資料,與硬碟進行抽替交換 (swapping) 。而在 Windows 中,相似的概念則被稱為「虛擬記憶體」(Virtual Memory)。但為避免與「虛擬地址」(Virtual Memory Address / Virtual Address Space) 的概念混淆, 大部分參考資料,均會採用 "Swap" 的叫法。

而這一篇文章,則是在簡單介紹:如何使用數行指令,增加 Ubuntu Linux 的 Swap 空間。擁有更大的 swap 空間,Ubuntu 就能載入更多程式,可玩性自然大大增加。

---

指令
一般而言,Linux 會在安裝系統之時,已經劃分好一個硬碟分區,並命名為 swap。
不過,如果該分區不夠用(或者根本沒有),就可以使用以下指令,動態增加 swap 檔案:

Step 1. 建立新的 swap 檔案 (4GB)

sudo dd if=/dev/zero of=swapfile bs=1MiB = count=$((4*1024)) 

Step 2. 初始化 swap 檔案的內容

sudo mkswap swapfile

Step 3. 更新權限(確保非 root 用戶不能讀取/改動內容)

sudo chmod 600 swapfile 

Step 4. 開啟系統的 Swap 功能

sudo swapon swapfile

Step 5. 檢查 swapfile 是否已經生效

sudo swapon --show

完成後,可使用 cat /proc/meminfo 查看記憶體空間有否變多。

想要把它關掉,也可以用 sudo swapoff -v swapfile ,以及用 sudo rm swapfile 把文件刪掉。

---

參考

[1] - How to Add Swap Space on Ubnutu 20.04 - https://linuxize.com/post/how-to-add-swap-space-on-ubuntu-20-04/

Wednesday, February 2, 2022

DIMM 的不同單位 (Channel, Rank, Chip, Bank, Row/Column)

引言
如果你有組裝電腦的經驗、或者正在從事記憶體相關的開發工作,相信一定會經常接觸到 DIMM (Dual Inline Memory Module) 的各種單位名詞:包括例如 Channel、Rank、Chip、Bank 等等。這篇文章,我們將會先從 Byte Addressing 說起,再解釋 SIMM 和 DIMM 的架構變化。最後,我們會詳細了解各種 DIMM 中常見的單位。

---

Byte Addressing

首先,有一個基本概念:現時大部分電腦中的記憶體地址,都以 1-byte (8-bits) 作為單位的。也就是說,每一個 byte 都會有自己的記憶體地址。這一點,跟 C Programming 中的 char array 操作方法一致:

例如:有一個 C char array:

char arr[] = { 'a', 'b', 'c', 'd' };

由於 'a' 和 'b' 都是完整的 8-bit (1-byte),所以,它們都會有自己的記憶體地址:

printf("%p\n", &arr[0]);  // example output: 0x00FC00000
printf("%p\n", &arr[1]);  // example output: 0x00FC00001
printf("%p\n", &arr[2]);  // example output: 0x00FC00002
printf("%p\n", &arr[3]);  // example output: 0x00FC00003

---

SIMM (Single Inline Memory Module)
大部分電腦及程式,都是以上述的 byte-addressing 為基礎,所以,當我們向 DRAM 查詢某個地址的資料(例如 0x00FC00001),我們只需期待記憶體能在一個 clock cycle 返回一個 byte 的內容(例如 'b')就好。初代的設計,正正是以這種方向作考慮,所以第一代的 SIMM RAM,都只能提供 8-bit 的資料頻寛。也就是說,一塊記憶體模塊,只能在一個 clock cycle 下傳輸 8-bit 的資料。

及後,人們開始注意到:電腦程式在大部分情況下,都會符合局部性原則(Principle Of Locality)。舉例來說:以上的 C 例子,開發者通常不止會用到 'a',還會經常用 loop 把 'b', 'c', 'd' 等等這些鄰近的變數都讀起來。所以,SIMM 的第二代設計,也開始容許 CPU 同一個 clock cycle 下存取多個鄰近的 bytes(也就是 Burst Read)。當時,第二代 SIMM 能提供 32-bit 的資料頻寛。也就是說,一個記憶體模塊,能在一個 clock cycle 下傳輸 32-bit (4 bytes) 的資料。

---

DIMM (Double Inline Memory Module)
由於 CPU 發展不斷加快,記憶體的密度及容量也越來越高,廠商開始覺得 32-bit 不太夠用。所以,它們又推出了 Double 版的 SIMM(也就是 DIMM),把資料頻寛翻倍成 64-bit。換句話說,現時一塊 DIMM 記憶體模塊,能在一個 clock cycle 下傳輸 64-bit (8 bytes) 的資料。

想像一下,如果一塊記憶體模組,想要在一個 clock cycle 下返回 64 bit,理論上,它就需要有 64 個儲存器同時運作。那麼,這些儲存器,到底是如何排列的呢?

---

Row / Column
簡單來說,DRAM 是由電容所組成的揮發性儲存裝置 (Volatile Memory)。每一顆電容,都可以儲存一個 bit 的資料(高電壓 = 1,低電壓 = 0),而且需要定期充電更新。而它們的排列方式,是以 Row(行)和 Column(列)進行的。

假設現在有一個 8 * 8 的矩陣,裏面放著總共 64 個電容,我們就可以用 6 個 bit(3 行,3 列,2^6 = 64)去定址它們。圖中紅色的一個電容,我們可以用 R=5 (101), C=3 (011)(也就是 101 011)表示:

▲ 在這個例子中,如果我們想要取得 101011 (紅色格)的內容,Row Address Decoder 會先開啟第六行,將內容複製到 Sense Amplifier 之中,其後,Column Selector 就會把第四列的內容挑出,並輸出至 Input/Output Buffer 中。這裡,我們就能得到 1-bit 的資料。

---

Bank
文章開始的時候,我們提到:由於大部分電腦均採用 Byte Addressing,所以每一個地址,都應該要指向 8-bits 的內容,而非 1-bit。所以,記憶體應該要儲存 8 bits,而非只有 1 bit。要解決這個問題,最簡單的做法,就是把同一個矩陣複製八份。這樣,一個地址,就能同時回傳 8 個 bits。這種排列方法,我們稱為一個 Bank:

▲ 在這個例子中,如果我們向八個矩陣都發出相同的 Row/Column 指令,我們便能同時得到 8 個 bit,也就是一個 byte 的內容。

---

Chip
Chip 就是指一塊黑色的 memory chip。通常,基於效能等種種原因,一個 memory chip 內會有多於一個 bank,而每個 Bank 均可以完全獨立運作。例如下圖的 memory chip 所示,它一共有 8 個 banks。這樣,當用戶正在存取 Bank 0 的時候,Bank 1-7 就可以忙其他事情(例如預先存取 Prefetch、更新電容等等):

▲ 在這個例子中,我們可以用 3 個 bits 去表示不同 Bank。所以,如果我們想要第 Bank 0 的 Row 1, Column 2, 我們可以把地址寫成 Bank(000), Row(001), Column(010),也就是 000 001 010。

---

Rank
從以上例子所見,每一顆 memory chip 都能在同一個 cycle 中返回 8 bits(1 byte)。但是,由於 DIMM 能支援在同一個 cycle 內返回 64 bits(8 bytes)的資料,如果我們只採用一顆 memory chip,理論上,我們就只能用到 8 bit / 64 bit = 12.5% 的頻寛,十分浪費。有見及此,DIMM 會把八顆 8-bit 的 memory chip 並聯在一起(或者四顆 16-bit 的 memory chip,視乎情況),並以 Burst Mode 等方法,令八顆 memory chip 能同時進行讀寫。這種排列,則稱為一個 Rank:

 

普遍而言,一塊單邊 DIMM 會有 8 顆 memory chip。如果每一粒 memory chip 的頻寬均為 8-bit,那麼 8 顆同時運作,就有 64 bits,就代表一個 Rank,這種就稱為 single rank。

有些 DIMM 的 memory chip 為 16 bit,那麼四顆就已經 64 bit (16 bits * 4 = 64 bits)。這種情況下,擁有八顆就稱為 dual rank。還有一種雙面 DIMM,計算方法也大同小異。

---

Channel
Memory Controller 的數量,決定了 memory channel 的數量。大部分情況下,一個 CPU 可以有一個或多個 memory controller,而每一個 memory controller 則只能有一個 channel。每一個 channel 也只能同時讀寫同一個 Rank 的 memory chip。

例如有一個 CPU,它只有一個 memory controller,而該 memory controller 也只支援一個 channel。如果該主機插上了兩條 single rank 的 DIMM,它也只能同時對其中一條 DIMM 進行讀寫。

又例如有一個 CPU,它有兩個 memory controller,所以它能同時用到兩個 channel(dual channel)。如果該主機插上了兩條 single rank 的 DIMM,它就能同時對兩條 DIMM 進行讀寫。

理論上,如果 CPU 的 memory controller 支援兩個 channel,CPU 就能在一個 clock cycle 讀取 64 bits * 2 = 128 bits 的資料。但如果只有一個 channel,它就只能在一個 clock cycle 讀取 64 bits 的資料。所以,同一個資料量,在理想的情況下,dual channel 能比 single channel 節省一半的存取時間。

當然,現實情況下,資料擺放的位置不一、程式設計造成的瓶頸等,都會令實際速度有所折扣。所以,現實中的 dual channel 並沒有想像中那麼快。

▲ 從以上圖片可見,一個 CPU 可以支援兩個(或以上)的 channel,而每一個 channel 可以支援多個 DIMM 模塊。不過,在同一個 channel 下,只有一個 rank 的 memory chips 能被同時讀取。

---

近年發展
隨著近年 DDR4 / DDR5 等高速記憶體開始普及,廠商也積極引入更多新的技術,以便進行更多的並行運算(parallelism)和更低的延遲(例如 Group Memory Banks 等等)。最經典的例子則是 DDR (Double Data Rate):DDR 相比起 SDR (Single Data Rate),會同時用上 reference clock 的 rising edge 和 falling edge 進行讀寫。但整體而言,記憶體的基本架構仍然不變。

---

參考

[1] Random Access Memory - https://www.youtube.com/playlist?list=PLTd6ceoshpreE_xQfQ-akUMU1sEtthFdB

[2] 圖解RAM結構與原理,系統記憶體的Channel、Chip與Bank - https://www.techbang.com/posts/18381-from-the-channel-to-address-computer-main-memory-structures-to-understand 


Thursday, January 13, 2022

C++ Reference Collapsing (引用折叠) 及 Forwarding Reference (轉發引用)

1. 引言
在 C++ 98 初推出的時候,C++ 並沒有 Reference of Reference 的概念 (&&),所以,開發者不但不能直接使用 &&,即使用了 typedef 將 reference type 包裝好,然後再加上 &,編譯器都一律會報錯。

後來,C++11 終於開放了對 Reference of Reference (&&) 的支援。不過由於 C++11 也同時引進了 Move Semantics (也是 &&) 的寫法,所以,Reference of Reference 就會跟 Move Semantics 有所衝突。

以下是一個例子,開發者想做的是:func(k) 要跑的,是 lvalue 的版本的 func()(第一個,單 & 的版本),而非 rvalue 的版本( &&,也就是 move semantics 的版本):

#include <iostream>

typedef int& INTR;

template<class T>
void func(T& i) {
       std::cout << __PRETTY_FUNCTION__ << std::endl;
}

template<class T>
void func(T&& i) {
        std::cout << __PRETTY_FUNCTION__ << std::endl;
}

int main() {
        int i   = 123;
        INTR j  = i;    // int&  j = i
        INTR& k = j;    // int&& k = j

        func(k);        // k is lvalue (should run T(&i))
        func(123);      // 123 is rvalue (should run T(&&i))
        return 0;
}

它的輸出應為:

void func(T&) [with T = int]
void func(T&&) [with T = int]

這裏看起來,明明 k就應該要是 int &&,但編譯器居然知道它不是指 rvalue,而是指 reference of reference。當中的原因是, C++ 11 新增了 Reference Collapsing (引用折叠) 的規則,確保編譯器在在大部分情況下(也就是使用了 typedef / decltype / template 的情況下), 都能辨認出 Reference of Reference 仍然是 "reference",而非 "rvalue" 。 

---

2. Reference Collapsing 四個規則
引用折叠有四個主要的編譯規則(其實有更多,不過不太重要,在此不述):

  • A& & becomes A&
  • A& && becomes A&
  • A&& & becomes A&
  • A&& && becomes A&&

反正重點是,編譯器會在碰到兩次 && 的時候,它才會當它是指 rvalue。其他情況的話,它會把 Reference of Reference (&&) 都降級成 Reference (&)。

---

3. Universal Reference / Forwarding Reference (轉發引用)
既然 && 會在編譯的時候,自動按情況降級成 &,那麼我們在寫 function template 的時候,是否可以寫少一個,然後讓編譯器自行將 function 變成兩個 (&& 和 &) 的版本呢?答案是可以的。

先看以下例子:

#include <iostream>

template<class T>
void function(T&& t) {
        std::cout << __PRETTY_FUNCTION__ << std::endl;
}

int main() {
        int i = 123;
        function(i);
        function(996);
        function(std::move(i));
        return 0;
}

它的執行結果為:

void function(T&&) [with T = int&]
void function(T&&) [with T = int]
void function(T&&) [with T = int]

首先,i 是 lvalue (int &),所以 T = int& = T&&& = T & ,
其餘的 996 和 std::move(i) 則因為本身是 rvalue(int &&),所以直接 T = int。

---

4. Perfect Forwarding 完美轉發
有一個情況要注意:如果在 template function 中,我們想要把變數傳到下一個 function 的話, 正常來講,我們可能會這樣做:

template<class T>
void function(T&& t) {
        subFunction(t);
}

然後,我們會期待傳入 subFunction(t) 的 t 應該是 T&& / rvalue,但其實並不一定 ...

以下有一個例子:

#include <iostream>

void subFunction(int&& i) {
        std::cout << __PRETTY_FUNCTION__ << std::endl;
}

void subFunction(int& i) {
        std::cout << __PRETTY_FUNCTION__ << std::endl;
}

template<class T>
void function(T&& t) {
        std::cout << "Value: " << t << std::endl;
        std::cout << __PRETTY_FUNCTION__ << std::endl;
        subFunction(t);
        subFunction(std::forward<T>(t));
        subFunction(static_cast<T&&>(t));
        subFunction((T&&)(t));
}

int main() {
        int i = 123;
        function(i);
        function(996);
        function(std::move(i));
        return 0;
}

它的輸出為:

Value: 123
void function(T&&) [with T = int&]
void subFunction(int&)
void subFunction(int&)
void subFunction(int&)
void subFunction(int&)

Value: 996
void function(T&&) [with T = int]
void subFunction(int&)
void subFunction(int&&)
void subFunction(int&&)
void subFunction(int&&)

Value: 123
void function(T&&) [with T = int]
void subFunction(int&)
void subFunction(int&&)
void subFunction(int&&)
void subFunction(int&&)

由結果可見,如果只用  subFunction(t),編譯器會認為 t 是一個 lvalue。
在 function 的 scope 之中,t 的確是一個變數,是 T&& 的 reference (T&& & = T&)。

如果要解決這個問題,把 && 正確地傳到 subFunction,最簡單的方法,就是把 t 的型態轉強制換成 T&&。如此,傳入來的 t 就能完全不經處理地傳到 subFunction。在解 subFunction 的時候,編譯器會找回最初的 i / 996 / std::move(i),再決定執行那一個版本的 subFunction。

而 std::forward<T>,其實就是 static_cast<T&&> 的語法糖而已。

---

小結
C++11 為了讓 Reference of Reference 變得簡單,並加上對 Move Semantics 的支援,同時令編譯器做到最大程度的向後兼容(不加新 keyword + 舊程式碼可跑),似乎把事情變得更複雜了:它使用了 Reference Collapsing 將不同情況的 && 分開。但同時,這種做法也導致 template function 如果用了 &&,很大機會要在 function call 時,對 variables 加上 static_cast,才能正常地運作 。

但是,我們只要無腦採用 universal reference (兩個 &&)加上 std::forward<T>,基本上就能解決 99% 的問題。向好的方向去想:使用 move semantics 加上 universal reference,不但能加快執行時間,或許更能節省程式碼行數,加快 debug 效率。