主講人: jserv / 課程討論區: 2024 年系統軟體課程
:mega: 返回「Linux 核心設計/實作」課程進度表
在 Google 網頁搜尋 "tictactoe",會發現網頁出現井字遊戲 (tic-tac-toe),可以在網頁直接玩。
jserv/ttt 專案提供可跟電腦對弈的互動環境,其對弈的演算法包含以下:
編譯並執行:
$ make
$ ./ttt
1 |
2 |
3 |
4 |
---+-------------
A B C D
X>
輸入 b2
(即橫向 ABCD 的 "B" 和縱向 1234 的 "2") 並按下 Enter 鍵,表示人類下 ×
棋子,電腦程式會以 ○
棋子做出回應,例如:
1 |
2 | × ○
3 |
4 |
---+-------------
A B C D
最終賽局的參考輸出:
1 |
2 | × ○
3 | × ○
4 | ×
---+-------------
A B C D
X won!
Moves: B2 -> C2 -> C3 -> D3 -> D4
閱讀 ttt 專案的 "Game Rules" 段落以得知勝負評斷規則。
關於 MCTS 演算法應用於井字遊戲,參閱〈Tic Tac Toe at the Monte Carlo〉,搭配以下解說影片:
馬里蘭大學的網站 Monte Carlo Tree Search Tic-Tac-Toe AI 提供互動的網頁,可理解 MCTS 演算法。
「蒙地卡羅方法」是利用隨機抽樣技術進行模擬以解決數學問題的策略,在第二次世界大戰期間首次被系統化應用於科學研究,促成 MANIAC(Mathematical Analyzer, Numerical Integrator and Computer)的誕生。由 Stanislaw Ulam, John von Neumann, Nicholas Metropolis、Enrico Fermi 等學者開發,這種以抽樣統計為基礎的方法,主要用於解決原子彈設計中的中子隨機擴散問題及 Schrödinger 方程特徵值的估算問題。最初由 Stanislaw Ulam 提出概念,後經 John von Neumann 深入研究,並於 1949 年以 The Monte Carlo method 一文公諸於世,隨著電腦時代的來臨,該手法從原始的手動產生隨機數解題轉變為現今的數值方法。
「蒙地卡羅方法」的命名源自 Nicholas Metropolis,靈感來自於其隨機性質與賭博的關聯,尤其是與北非蒙地卡羅城市中華麗的賭場生活息息相關。這方法適用於任何包含隨機性的過程,能夠透過大量模擬單一事件並統計平均值,來獲得在特定條件下的最可能結果。廣泛應用於自然現象如布朗運動、電波噪聲、基因突變、即時交通狀況等,顯示其多樣的適用性。又因該方法的高度可平行處理特性,廣泛應用於電腦圖形學中的光線追蹤技術以及分子動力學模擬等領域。
延伸閱讀: 亂灑一地的針其實有意義!布豐實驗與蒙地卡羅方法
Robert Love 在 Linux Kernel Development 一書論及浮點運算:
No (Easy) Use of Floating Point
When using floating-point instructions kernel normally catches a trap and then initiates the transition from integer to floating point mode. Unlike user-space, the kernel does not have the luxury of seamless support for floating point because it cannot easily trap itself. Using a floating point inside the kernel requires manually saving and restoring the floating point registers. Except in the rare cases, no floating-point operations are in the kernel.
Rusty Russell 在 Unreliable Guide To Hacking The Linux Kernel 則說:
The FPU context is not saved; even in user context the FPU state probably won't correspond with the current process: you would mess with some user process' FPU state. If you really want to do this, you would have to explicitly save/restore the full FPU state (and avoid context switches). It is generally a bad idea; use fixed point arithmetic first.
CVE-2018-3665 存在於 Intel Core 系列微處理器中,因為 speculative execution(推測執行)技術中的一些缺陷加上特定作業系統中對 FPU(Floating point unit)進行 context switch 所產生的漏洞,允許一個本地端的程式洩漏其他程序的 FPU 暫存器內容。
Lazy FP state leak 的原理是透過 FPU 的 lazy state switching 機制達成。因為 FPU 和 SIMD 暫存器不一定會在每個任務持續被使用到,因此作業系統排程器可將不需要使用到 FPU 的任務,標註為不可使用 FPU,而不更改裡面的內容。發生 context switch 時,核心先對 FPU/SIMD 做標記,假如在待會新的 context 都沒有用到 FPU/SIMD 相關運算則其 state 將持續保存上個 context 的內容,反之,則會在新的 context 進行相關 ops 前對 kernel 發 trap,以使其保存上個 context 的內容。有鑑於此特性,資訊安全人員發現其可能被濫用的缺陷,因為 FPU/SIMD 相關暫存器不只保存浮點數運算相關資料,其也被用來暫存加密相關資料,例如 AES 指令集。
作業系統相關工程師為了修復此缺陷讓作業系統效能下降 (其中一個解決方法是每次 context switch 都強制 FPU/SIMD state 一起切換,進而增加不少切換時間開銷),而麻省理工的研究員在 2019 年底提出同時能解決此缺陷又不失效能的途徑 A better approach to preventing Meltdown/Spectre attacks。
然而,在現今的亂序執行 CPU 中,lazy state switching 裡會設定的 "FPU not available" 可能沒辦法馬上被偵測到,導致我們在 process B,但仍然可以存取到 process A 的 FPU 暫存器內容,進而達到攻擊的目的。
基於上述原因,儘管我們在 Linux 核心模式中仍可使用浮點運算,但這不僅會拖慢執行速度,還需要手動儲存/取回相關的 FPU 暫存器,因此核心文件不建議在核心中使用到浮點運算。
afcidk 透過開發簡單的 Linux 核心模組,來測試在單純的浮點數運算及整數運算花費的時間差異。
程式碼可見 floating.ko
可見到,以單純的運算來說,核心模式中的浮點數運算,時間成本較整數運算高。
並非所有 Linux 支援的硬體都具備 FPU,且 Linux 核心也不建議進行浮點數計算,於是實務上要改用定點數計算 (Fixed-point arithmetic)。相較於 IEEE 754 浮點數標準,定點數運算沒有一致的格式,常見的處理方式如下:
定點數的運算存在以下常見規格:
uptime 命令可顯示過去 1 / 5 / 15 分鐘的系統平均負載,類似以下輸出:
$ uptime
11:40:45 up 49 days, 2:47, 3 users, load average: 0.01, 0.06, 0.02
Linux 核心一類的主流作業系統提供 load average,顧名思義就是要查看近期的負載平均,近期可分成三種時間間隔,計算公式如下:
這個公式很常見,在作業系統決定下一個 cpu burst 時就會運用 Exponential average。這個公式的好處是簡單且省記憶體的把過去的歷史資料都記住,而且會 aging。而很明顯的,這個公式的計算絕對牽涉的浮點數運算,而 Linux 不建議使用浮點運算,於是定點運算就派上用場。
fixed_power_int
研讀 Linux 核心原始程式碼的 Load Average 處理機制,可發現 linux/kernel/sched/loadavg.c 有一個用來計算定點數乘冪的函式 fixed_power_int
,適合作為理解定點數計算的範例
/**
* fixed_power_int - compute: x^n, in O(log n) time
*
* @x: base of the power
* @frac_bits: fractional bits of @x
* @n: power to raise @x to.
*
* By exploiting the relation between the definition of the natural power
* function: x^n := x*x*...*x (x multiplied by itself for n times), and
* the binary encoding of numbers used by computers: n := \Sum n_i * 2^i,
* (where: n_i \elem {0, 1}, the binary vector representing n),
* we find: x^n := x^(\Sum n_i * 2^i) := \Prod x^(n_i * 2^i), which is
* of course trivially computable in O(log_2 n), the length of our binary
* vector.
*/
static unsigned long fixed_power_int(unsigned long x,
unsigned int frac_bits,
unsigned int n)
{
unsigned long result = 1UL << frac_bits;
if (n) {
for (;;) {
if (n & 1) {
result *= x;
result += 1UL << (frac_bits - 1);
result >>= frac_bits;
}
n >>= 1;
if (!n)
break;
x *= x;
x += 1UL << (frac_bits - 1);
x >>= frac_bits;
}
}
return result;
}
以二進位來思考:
以
if (n & 1)
是否成立,即對應到 1UL << frac_bits
代表定點數 1 (二進位,小數點後有 frac_bits
位)x += 1UL << (frac_bits - 1)
會讓 x
進行無條件進位因為是進行定點數乘法,還要再除 x >>= frac_bits
loadavg_proc_show
從 man proc(5) 可知 /proc/loadavg
記錄著 load average 相關的資訊
$ cat /proc/loadavg
觀察 fs/proc/loadavg.c 會發現 /proc/loadavg
是個 pseudo 檔案系統,對照實際輸出資料的函式 loadavg_proc_show
static int loadavg_proc_show(struct seq_file *m, void *v)
{
unsigned long avnrun[3];
get_avenrun(avnrun, FIXED_1 / 200, 0);
seq_printf(m, "%lu.%02lu %lu.%02lu %lu.%02lu %ld/%d %d\n",
LOAD_INT(avnrun[0]), LOAD_FRAC(avnrun[0]), LOAD_INT(avnrun[1]),
LOAD_FRAC(avnrun[1]), LOAD_INT(avnrun[2]), LOAD_FRAC(avnrun[2]),
nr_running(), nr_threads,
idr_get_cursor(&task_active_pid_ns(current)->idr) - 1);
return 0;
}
觀察 seq_printf
使用的格式 %lu.%02lu
,再由 include/linux/sched/loadavg.h 理解 LOAD_INT
和 LOAD_FRAC
的實作
#define LOAD_INT(x) ((x) >> FSHIFT)
#define LOAD_FRAC(x) LOAD_INT(((x) & (FIXED_1-1)) * 100)
%lu.%02lu
分別使用 LOAD_INT
與 LOAD_FRAC
來組成完整的十進位數值,其中 LOAD_INT
直接取用整數部分,而 LOAD_FRAC
會取用小數點 (fraction) 部分,注意 FIXED_1-1
可視為小數點部分的 mask,乘上 100 的目的是保留十進位下的小數點後 2 位,而 FIXED_1/200
可理解為是十進位下的定點數 0.005
,由於結果會保留小數點後 2 位,加上此數值目的是進行四捨五入
load average 的資料保存於 avnrun
。
從 linux/kernel/sched/loadavg.c 可見負責更新 avnrun
的函式
/*
* calc_load - update the avenrun load estimates 10 ticks after the
* CPUs have updated calc_load_tasks.
*
* Called from the global timer code.
*/
void calc_global_load(unsigned long ticks)
{
unsigned long sample_window;
long active, delta;
sample_window = READ_ONCE(calc_load_update);
if (time_before(jiffies, sample_window + 10))
return;
/* Fold the 'old' NO_HZ-delta to include all NO_HZ CPUs. */
delta = calc_load_nohz_fold();
if (delta)
atomic_long_add(delta, &calc_load_tasks);
active = atomic_long_read(&calc_load_tasks);
active = active > 0 ? active * FIXED_1 : 0;
avenrun[0] = calc_load(avenrun[0], EXP_1, active);
avenrun[1] = calc_load(avenrun[1], EXP_5, active);
avenrun[2] = calc_load(avenrun[2], EXP_15, active);
WRITE_ONCE(calc_load_update, sample_window + LOAD_FREQ);
/*
* In case we went to NO_HZ for multiple LOAD_FREQ intervals
* catch up in bulk.
*/
calc_global_nohz();
}
calc_load
是實際計算 load average 的函式,在 include/linux/sched/loadavg.h 可見其函式實作
/*
* a1 = a0 * e + a * (1 - e)
*/
static inline unsigned long calc_load(unsigned long load,
unsigned long exp,
unsigned long active)
{
unsigned long newload;
newload = load * exp + active * (FIXED_1 - exp);
if (active >= load)
newload += FIXED_1 - 1;
return newload / FIXED_1;
}
計算公式使用 Exponential smoothing,可參閱〈Linux Kernel Load Average 計算分析〉。其公式如下:
t
的平滑均值。t-1
的平滑均值。t-1
的實際值。對應 Linux 核心程式碼如下所示: (取自 include/linux/sched/loadavg.h
)
newload = load * exp + active * (FIXED_1 - exp);
calc_load_tasks
)使用定點數:
因此定點數 1.0
為 (1 << 11)
,也就是 FIXED_1
(第 19 行),這就是為何
include/linux/sched/loadavg.h
#define FSHIFT 11 /* nr of bits of precision */
#define FIXED_1 (1<<FSHIFT) /* 1.0 as fixed-point */
#define LOAD_FREQ (5*HZ+1) /* 5 sec intervals */
#define EXP_1 1884 /* 1/exp(5sec/1min) as fixed-point */
#define EXP_5 2014 /* 1/exp(5sec/5min) */
#define EXP_15 2037 /* 1/exp(5sec/15min) */
/*
* a1 = a0 * e + a * (1 - e)
*/
static inline unsigned long
calc_load(unsigned long load, unsigned long exp, unsigned long active)
{
unsigned long newload;
newload = load * exp + active * (FIXED_1 - exp);
if (active >= load)
newload += FIXED_1-1;
return newload / FIXED_1;
}
extern unsigned long calc_load_n(unsigned long load, unsigned long exp,
unsigned long active, unsigned int n);
#define LOAD_INT(x) ((x) >> FSHIFT)
#define LOAD_FRAC(x) LOAD_INT(((x) & (FIXED_1-1)) * 100)
calc_load
函式:第 20 行: 每隔 5 秒計算 1 / 5/ 15 分鐘的系統平均負載。
第 21-23 行: 定義 1 / 5 / 15 分鐘的
0.92004441462 * FIXED_1 = 1884
0.98347145382 * FIXED_1 = 2014
0.994459848 * FIXED_1 = 2037
newload
無條件進位。FIXED_1
)。calc_global_load
函式第 355-256 行: 每隔 5 秒 (其實是,5秒 + 10 ticks) 計算 1/5/15 分鐘的系統平均負載。
第 362-367 行: 計算當下 RUNNABLE + TASK_UNINTERRUPTIBLE
Process/Task 總數。為何需要加入 TASK_UNINTERRUPTIBLE
task 呢?可參閱 Brendan Gregg 的文章 Linux Load Averages: Solving the Mystery,詳盡地解說其來龍去脈,Brendan 一開始想透過 git log -p kernel/sched/loadavg.c
歷史紀錄找出編修紀錄,但對應修改歷史更久遠,回溯至 v0.99.13 到 v0.99.15 (1993 年),而 git 要在 2005 年才出現。如下所示 (節錄自 Linux Load Averages: Solving the Mystery):
From: Matthias Urlichs <urlichs@smurf.sub.org>
Subject: Load average broken ?
Date: Fri, 29 Oct 1993 11:37:23 +0200The kernel only counts "runnable" processes when computing the load average.
I don't like that; the problem is that processes which are swapping or waiting on "fast", i.e. noninterruptible, I/O, also consume resources.It seems somewhat nonintuitive that the load average goes down when you replace your fast swap disk with a slow swap disk…
Anyway, the following patch seems to make the load average much more consistent WRT the subjective speed of the system. And, most important, the load is still zero when nobody is doing anything. ;-)
--- kernel/sched.c.orig Fri Oct 29 10:31:11 1993
+++ kernel/sched.c Fri Oct 29 10:32:51 1993
@@ -414,7 +414,9 @@
unsigned long nr = 0;
for(p = &LAST_TASK; p > &FIRST_TASK; --p)
- if (*p && (*p)->state == TASK_RUNNING)
+ if (*p && ((*p)->state == TASK_RUNNING) ||
+ (*p)->state == TASK_UNINTERRUPTIBLE) ||
+ (*p)->state == TASK_SWAPPING))
nr += FIXED_1;
return nr;
}
一言以蔽之,考慮 system load average,而非 CPU load average。
信件發文者 Matthias Urlichs 認為 load average 代表以使用者角度觀察系統忙碌程度,也就是 system load average,而不是 CPU load average。TASK_UNINTERRUPTIBLE
表示 process 正在等待某特定事件 (例如: Task swapping、Disk I/O等等),這也需要算在 system load。底下場景說明為何需要考慮 TASK_UNINTERRUPTIBLE
(節錄自 Linux Load Averages: Solving the Mystery):
A heavily disk-bound system might be extremely sluggish but only have a TASK_RUNNING average of 0.1, which doesn't help anybody.
第 368-370 行: 計算 1/5/15 分鐘的系統平均負載。全域變數 avenrun
用定點數格式儲存,所以使用 printk
輸出時,需做對應轉換。
第 372 行: 更新下次計算系統平均負載時間,即 n + 5 秒
。
kernel/sched/loadavg.c
/*
* calc_load - update the avenrun load estimates 10 ticks after the
* CPUs have updated calc_load_tasks.
*
* Called from the global timer code.
*/
void calc_global_load(unsigned long ticks)
{
unsigned long sample_window;
long active, delta;
sample_window = READ_ONCE(calc_load_update);
if (time_before(jiffies, sample_window + 10))
return;
/*
* Fold the 'old' NO_HZ-delta to include all NO_HZ CPUs.
*/
delta = calc_load_nohz_read();
if (delta)
atomic_long_add(delta, &calc_load_tasks);
active = atomic_long_read(&calc_load_tasks);
active = active > 0 ? active * FIXED_1 : 0;
avenrun[0] = calc_load(avenrun[0], EXP_1, active);
avenrun[1] = calc_load(avenrun[1], EXP_5, active);
avenrun[2] = calc_load(avenrun[2], EXP_15, active);
WRITE_ONCE(calc_load_update, sample_window + LOAD_FREQ);
/*
* In case we went to NO_HZ for multiple LOAD_FREQ intervals
* catch up in bulk.
*/
calc_global_nohz();
}
loadavg_proc_show
輸出 1 / 5 / 15 分鐘的系統平均負載:
LOAD_INT
: 取得整數部分。LOAD_FRAC
: 取得小數點部分。因為只取小數點兩位,所以此巨集裡可看到乘以 100。fs/proc/loadavg.c
static int loadavg_proc_show(struct seq_file *m, void *v)
{
unsigned long avnrun[3];
get_avenrun(avnrun, FIXED_1/200, 0);
seq_printf(m, "%lu.%02lu %lu.%02lu %lu.%02lu %ld/%d %d\n",
LOAD_INT(avnrun[0]), LOAD_FRAC(avnrun[0]),
LOAD_INT(avnrun[1]), LOAD_FRAC(avnrun[1]),
LOAD_INT(avnrun[2]), LOAD_FRAC(avnrun[2]),
nr_running(), nr_threads,
idr_get_cursor(&task_active_pid_ns(current)->idr) - 1);
return 0;
}
hlist
相關的程式碼抽出,成為 hlist.h
,並確保後者可與 lab0-c 既有的 list.h
並存,jserv/ttt 專案的 zobrist.[ch]
會用到 hlist.h
qtest
程式,使其新增 ttt
命令,其作用是執行與人類對弈的井字遊戲,並使用 Monte Carlo tree search (MCTS) 演算法,確保使用定點數來取代原本 jserv/ttt 的浮點數運算。ttt
命令,在 qtest
引入新的 option
(參照 cmd>
命令提示的 help
說明文字),允許使用者切換「人 vs.電腦」或「電腦 vs. 電腦」的對弈,注意後者應該採取 MCTS 以外的演算法 (也可納入你發展的演算法)。對弈的過程中,要在螢幕顯示當下的時間 (含秒數),並持續更新。ttt
命令的「電腦 vs. 電腦」的對弈模式,參照〈並行程式設計:排程器原理〉,引入若干 coroutine (注意:不得使用 POSIX Thread,採取教材提到的實作方式),分別對應到 AI1, AI2 (具備玩井字遊戲的人工智慧程式,採取不同的演算法) 和鍵盤事件處理,意味著使用者可連續觀看多場「電腦 vs. 電腦」的對弈,當按下 Ctrl-P 時暫停或回復棋盤畫面更新 (但運算仍持續)、當按下 Ctrl-Q 時離開「電腦 vs. 電腦」的對弈,注意不該使用 (n)curses 函式庫。當離開「電腦 vs. 電腦」的對弈時,ttt
應顯示多場對弈的過程,亦即類似 Moves: B2 -> C2 -> C3 -> D3 -> D4
的輸出,儘量重用現有的結構體和程式碼。
關於鍵盤事件和終端機畫面的處理機制,可參見 mazu-editor 原始程式碼和〈Build Your Own Text Editor〉。
qtest
輸出,開發過程中應有對應的數學分析和羅列實作考量因素。printf
的訊息,可運用 GDB 進行遠端除錯。shannon_entropy.c
和 log2_lshift16.h
,採用更有效、準確度 (accuracy) 更高的定點數運算實作,需要有對應的數學統計分析和實際執行的討論。
Linux 核心原始程式碼 fs/btrfs/compression.c 存在 Shannon Entropy 的計算,其程式碼註解提到 "Use of ilog2() decreases precision, we lower the LVL to 5 to compensate"。檔名的 "compression" 意味著 btrfs 檔案系統具備壓縮,而這機制就用到 Shannon Entropy 進行估算。
lab0-c
開發紀錄後方即可,程式碼的變更應提交於你所 fork 的 lab0-c
儲存庫 (repository) 中,留意 M03: review 的規範。