DataLoader 和 Iterator 的差異

在 trace AI 課程中的訓練代碼時, 看到 iterator 和 DataLoader 同場出現時有點暈, 我想說這兩個做的事情不是一樣嗎? 所以花點時間將這個疑惑釐清.

先講結論: Iterator 是 design pattern, DataLoader 是 PyTorch 的 class.

Iterator 做為 design pattern 的好處是: 不用管 dataset 真正長什麼樣子, 每次都都抓出一包 data, 還用不到的先不抓, 等下次抓, 以節省記憶體.

那一包要抓多大呢? 透過 DataLoader 這個 class 取得 dataloader 這個 object.

然後 data_iterator 是個 iterator, iter() 把 dataloader 轉換成可迭代的 object.

first_batch 的 type 取決 Dataset, 主要就是一包 data. 而第一次呼叫 next() 拿到的是第一包, 而不是第二包.

from torch.utils.data import DataLoader, Dataset
...
dataloader = DataLoader(dataset, batch_size=32, shuffle=True)
....
data_iterator = iter(dataloader)
first_batch = next(data_iterator)

在 Tensorflow 中, 沒有 DataLoader, 但有 tf.data.Dataset. 效果也是一次抓一包.

import tensorflow as tf

# Create a basic dataset
dataset = tf.data.Dataset.range(10)

# Apply transformations (e.g., batch, shuffle)
batched_dataset = dataset.batch(2)

# Iterate through the dataset
for batch in batched_dataset:
    print(batch)

Pytorch 轉 ONNX 小筆記

基本上這個轉換有兩大類方法, 一類是用官方 tool 去轉, 另一類就是寫個 Python 小程式去做. 原先我都是嘗試後面這路, 但要顧慮的東西很多, 一下修語法, 一下 memory 爆掉, 而是默默出錯時也會轉出 model, 要測試過才知道它的智力有沒有受損?搞得滿累的.

當我再次卡在下面這個檔案限制時, 我就決定換方法了 (悔不當初).

RuntimeError: The serialized model is larger than the 2GiB limit imposed by the protobuf library. Therefore the output file must be a file path, so that the ONNX external data can be written to the same directory. Please specify the output file name.

官方做法其實很簡單, 唯一要顧慮的是 onnx, onnxruntime, onnxruntime_genai 這三個軟體有沒有跟系統衝突? 有沒有跟 NPU tool 衝突 ? 這些搞定就可以了. 用 CPU 也不會轉太久. 這次的障礙是 DeepSeek 跟我講錯指令, 下面這行跑起來找不到 builder.

python -m onnxruntime_genai.builder --model microsoft/phi-2 --precision fp16

我去 onnxruntime 安裝的目錄下找, 確實也沒有對應的程式, 所以我把 Monica 預設的 DeepSeek R1 切到提供第二個意見的 Claude Sonnet V3.7, 它就指出 DeepSeek 的錯誤了, 哈!正確指令如下:

python -m onnxruntime_genai.models.builder \
  --model microsoft/phi-2 \
  --precision fp32 \
  --output ./phi-2-onnx \
  --execution_provider cpu \
  --cache_dir ~/.cache/huggingface \
  --extra_options trust_remote_code=True

轉完之後, 當然要測試一下有沒有問題? 如果發現它答非所問, 應該就是轉錯了. 然而, 我發現 DeepSeek R1 寫的測試程式還是遜了一點, 所以我又讓 Claude 重寫一次.

import numpy as np
import onnxruntime as ort
from transformers import AutoTokenizer
from typing import List, Dict, Optional, Tuple
import time

class Phi2ONNXGenerator:
    def __init__(self, model_path: str, tokenizer_path: str = "microsoft/phi-2"):
        """初始化 Phi-2 ONNX 生成器"""
        # 載入分詞器
        self.tokenizer = AutoTokenizer.from_pretrained(tokenizer_path)
        self.tokenizer.pad_token = self.tokenizer.eos_token
        
        # 設定 ONNX 執行選項以優化效能
        sess_options = ort.SessionOptions()
        sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
        sess_options.intra_op_num_threads = 4  # 調整為您的 CPU 核心數
        sess_options.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL
        
        # 建立推理會話
        self.session = ort.InferenceSession(
            model_path, 
            sess_options=sess_options,
            providers=['CPUExecutionProvider']
        )
        
        # 獲取模型輸入輸出資訊
        self.input_names = [input.name for input in self.session.get_inputs()]
        self.output_names = [output.name for output in self.session.get_outputs()]
        
        # 模型常數
        self.num_layers = 32  # Phi-2 有 32 層注意力層
        self.head_dim = 80    # 每個注意力頭的維度
        self.num_heads = 32   # 注意力頭數量
        
        # 快取字首
        self.key_prefix = 'past_key_values.'
        self.key_suffix = '.key'
        self.value_suffix = '.value'

    def _initialize_kv_cache(self, batch_size: int = 1) -> Dict[str, np.ndarray]:
        """初始化 KV 快取為零張量,使用預分配記憶體"""
        kv_cache = {}
        for i in range(self.num_layers):
            k_name = f'{self.key_prefix}{i}{self.key_suffix}'
            v_name = f'{self.key_prefix}{i}{self.value_suffix}'
            
            # 預分配零張量
            kv_cache[k_name] = np.zeros(
                (batch_size, self.num_heads, 0, self.head_dim), dtype=np.float32
            )
            kv_cache[v_name] = np.zeros(
                (batch_size, self.num_heads, 0, self.head_dim), dtype=np.float32
            )
        return kv_cache

    def _prepare_inputs(self, 
                        input_ids: np.ndarray, 
                        attention_mask: np.ndarray, 
                        kv_cache: Optional[Dict[str, np.ndarray]] = None) -> Dict[str, np.ndarray]:
        """準備模型輸入"""
        inputs = {
            'input_ids': input_ids,
            'attention_mask': attention_mask
        }
        
        # 加入 KV 快取(如果提供)
        if kv_cache:
            inputs.update(kv_cache)
            
        return inputs

    def _update_kv_cache(self, outputs, start_idx: int = 1) -> Dict[str, np.ndarray]:
        """從模型輸出更新 KV 快取"""
        kv_cache = {}
        for i in range(self.num_layers):
            k_idx = start_idx + 2*i
            v_idx = start_idx + 2*i + 1
            
            k_name = f'{self.key_prefix}{i}{self.key_suffix}'
            v_name = f'{self.key_prefix}{i}{self.value_suffix}'
            
            kv_cache[k_name] = outputs[k_idx]
            kv_cache[v_name] = outputs[v_idx]
            
        return kv_cache

    def generate(self, 
                prompt: str, 
                max_new_tokens: int = 100,
                temperature: float = 1.0,
                top_k: int = 50,
                top_p: float = 0.9,
                do_sample: bool = True) -> str:
        """生成文本"""
        start_time = time.time()
        
        # 編碼輸入文本
        encoded_input = self.tokenizer(prompt, return_tensors="np")
        input_ids = encoded_input['input_ids'].astype(np.int64)
        attention_mask = encoded_input['attention_mask'].astype(np.int64)
        
        # 初始化 KV 快取
        kv_cache = self._initialize_kv_cache()
        
        # 初始化輸入
        onnx_inputs = self._prepare_inputs(input_ids, attention_mask, kv_cache)
        
        # 保存原始提示的 token IDs
        prompt_ids = input_ids[0].tolist()
        generated_ids = []
        
        # 逐步生成文本
        for i in range(max_new_tokens):
            # 執行推理
            outputs = self.session.run(None, onnx_inputs)
            
            # 獲取 logits
            logits = outputs[0][:, -1, :]  # [batch, vocab_size]
            
            # 應用溫度
            if temperature > 0:
                logits = logits / temperature
            
            # 選擇下一個 token
            if do_sample:
                # Top-K 過濾
                if top_k > 0:
                    indices_to_remove = logits < np.partition(logits, -top_k, axis=-1)[..., -top_k:][..., :1]
                    logits[indices_to_remove] = -float('Inf')
                
                # Top-p (nucleus) 採樣
                if top_p < 1.0:
                    sorted_logits = np.sort(logits, axis=-1)[:, ::-1]
                    cumulative_probs = np.cumsum(np.exp(sorted_logits) / np.sum(np.exp(sorted_logits), axis=-1, keepdims=True), axis=-1)
                    
                    sorted_indices_to_remove = cumulative_probs > top_p
                    sorted_indices_to_remove[:, 1:] = sorted_indices_to_remove[:, :-1].copy()
                    sorted_indices_to_remove[:, 0] = False
                    
                    # 將索引轉換回原始順序
                    indices_to_remove = np.zeros_like(logits, dtype=bool)
                    for batch_idx in range(logits.shape[0]):
                        indices_to_remove[batch_idx, np.argsort(-logits[batch_idx])[sorted_indices_to_remove[batch_idx]]] = True
                    
                    logits[indices_to_remove] = -float('Inf')
                
                # 計算概率並採樣
                probs = np.exp(logits) / np.sum(np.exp(logits), axis=-1, keepdims=True)
                next_token = np.random.choice(probs.shape[-1], p=probs[0])
            else:
                # 貪婪解碼
                next_token = np.argmax(logits, axis=-1)[0]
            
            # 終止條件
            if next_token == self.tokenizer.eos_token_id:
                break
                
            # 更新生成的 token 列表
            generated_ids.append(int(next_token))
            
            # 更新輸入
            onnx_inputs['input_ids'] = np.array([[next_token]], dtype=np.int64)
            
            # 更新注意力遮罩
            new_attention_mask = np.ones((1, attention_mask.shape[1] + 1), dtype=np.int64)
            new_attention_mask[0, :attention_mask.shape[1]] = attention_mask[0]
            attention_mask = new_attention_mask
            onnx_inputs['attention_mask'] = attention_mask
            
            # 更新 KV 快取
            kv_cache = self._update_kv_cache(outputs)
            onnx_inputs.update(kv_cache)
        
        # 計算生成時間
        generation_time = time.time() - start_time
        tokens_per_second = len(generated_ids) / generation_time if generation_time > 0 else 0
        
        # 解碼並返回生成的文本
        result = self.tokenizer.decode(generated_ids, skip_special_tokens=True)
        
        print(f"生成了 {len(generated_ids)} 個 tokens,耗時 {generation_time:.2f} 秒 ({tokens_per_second:.2f} tokens/秒)")
        
        return result

# 使用範例
if __name__ == "__main__":
    # 初始化生成器
    generator = Phi2ONNXGenerator(
        model_path='./phi-2-onnx/model.onnx',
        tokenizer_path="microsoft/phi-2"
    )
    
    # 生成文本
    prompt = "find all prime numbers below 120"
    result = generator.generate(
        prompt=prompt,
        max_new_tokens=200,
        temperature=0.7,
        top_p=0.9,
        do_sample=True
    )
    
    print(f"\n提示:\n{prompt}")
    print(f"\n生成結果:\n{result}")

DeepSeek R1 給的 inference 程式會寫出大致正確但有錯誤的程式 – 邏輯正確, 但引用函數未定義. 我以為 PHI-2 的極限就是這樣了. 想不到 Claude inference 程式寫得好, 答案竟然也跟著好很多 (雖然還有錯)! 在同樣的 model 下也會有顯著的差異, 令我太意外了.

Claude V3.7 Inference 產生的答案:

import numpy as np

def find_primes(n):
    primes = np.arange(2, n)
    for i in range(2, int(np.sqrt(n))+1):
        primes = primes[primes%i!= 0]
    return primes

print(find_primes(120))

DeepSeek-R1 Inference 產生的答案:

import numpy as np

# Define the upper limit
upper_limit = 120

# Create an array of numbers from 2 to the upper limit
numbers = np.arange(2, upper_limit)

# Use the isprime function to find all prime numbers
primes = numbers[np.vectorize(isprime)(numbers)]

print(primes)

LoRA 小複習

LoRA = Low Rank Adaptation, 先前也提到過這個 fine tuning 的技術. 本來想好好複習一下, 但這次看的 IBM 課程真的速度太快了. 為了確定我在幹什麼? 我決定來整理一遍今天 Lab 的成果.

本課程使用 AG News (新聞) dataset 訓練出來的 model, 希望把它調整為適用於 IMDB (影評) dataset. 兩個 dataset 的性質不同, label 也不同. 前者的 label 是把所有的新聞分為四個類別:世界新聞(World News)、體育新聞(Sports News)、商業新聞(Business News)和科技新聞(Technology News)。後者是把所有的影評分為正面情緒和負面情緒兩類.

現在我們要把 model 從適用於 AG News 分類, 改為適用 IMDB 分類, 並且儘量借用已經訓練好的文字理解能力. 當然原本的四類輸出就不能用了, 我們要攔胡中間的成果, 將它重新分為兩類.

首先課程會安裝很多 lib, 這點做得比 Google 課程好. Google cloud platform (GCP) 不是為教學而生的, 自己猛進版, 然後課程的版本會慢慢變得跟 GCP 不相容, 解答版都有 pip error, 做個 Lab 還要解決相容性問題. IBM 這邊沒有這個狀況. 只是課講得超快, 我都要放 0.75 倍才聽得懂, 而投影片則是會一閃即逝, 沒學會速讀都不知道看到啥了.

總之, 基本的東西都安裝定義好之後, 下載 IMDB dataset, 分為 train 和 test 兩部分, 前者隨機抓 95%, 後者抓剩下5%.

接下來定義一個 IMDB 適用的 mode, 將詞彙表中最多 400,000 個詞彙轉換成 100 維的向量表示(Embedding), 接著經過 2 個全連接層 (FC1, FC2) 和中間夾的 RELU. 如果沒有非線性層, 根據線性代數, 不管幾層都可以等效為一層. 所以這邊每層的 node 為 100 –> 128 –> 2.

我們把這上面這個簡單的 model 拿來訓練 IMDB dataset. 為節省時間, Lab 只跑 2 個 epoch. 不過可以看到跑 300 epoch 的結果, 正確率 66.176%. 其實這個部分跟 Lora 還無關.

接下來開始做 AG News re-trained model, 它的模型只有輸出是 4 個 classes. 其他和前面一樣.

為了準備後續的 LoRA, 先把這個 mode 叫做 model_lora, 且設定為 gradient 不更新, 也就是 parameter freeze. 包括整個 neural network 和 embedding layer.

把 LoRA 放進 AG News model 的版本, 首先在 FC1 後面加上 LoRALayer(), 很顯然地它的輸入是 128 個 node, 輸出也是 128. 還有呢, 輸出的地方也配合 IMDB 改成 2 個 classes 了.

然後我們用 IMDB dataset 訓練這個 model_RoLA, 因為前面的 parameter 都 freeze, 所以真正訓練的是 LoRALayer() 到 FC2這一段. 訓練完 300 個 epoch, 正確率來到 69.156%. 也就是說藉由 AG News pre-trained model 的加持, 在其他條件不變的狀況下, 效果提升 3%. 而且沒有動到 AG News model FC1 的參數.

故事還沒有結束. LoRALayer 是一個低階數的連接層. 它相對於原來的網路是多出來的. 數學上理解為參數的調整. 原本的 h(x) = Wox. 其中 W 是參數矩陣, x 是輸入向量, h 是輸出向量.

有了 LoRA 之後, h(x) 當然還是 Wox 的函數, 而且這部分不能動. 能動的部分就是多了 ΔWx. h(x) = Wox + ΔWx.

而 ΔWx 又表現為 B 和 A 兩個低 rank 的矩陣. 甚至是 d * r 乘 r*k 中的 r = 1這麼 “low", 只要乘出來是 dxk, 就可以和 Wo 相加了. 當然 r 低得太誇張效果可能不好.

對於訓練過的新 model, 我們只要存 B 和 A, 其它可以套用既有的 pre-trained model. 上面的例子中因為 FC2 也變了, 所以就不能只存 B 和 A.

walk through 範例之後, 完整的課程還要做一個 excise, 這樣總共只給一小時, 真的太看得起我了. 不過其實 IBM 的課很佛心, 只要繼續用, 它就不會把你踢掉.

反觀 Google 的課程都有倒數計時, 明明時間都是被它自己雲端吃掉的, 只要時間一到就關門了. 想要拿到 credit, 只能靠瘋狂執行 “run all cell" 來搶時間, 其中需要手動改的 code, 更新 tool 版本的 script 都先存好, 趁前面還在安裝中, 後面找 cell 貼 patch, 才能避免一再"重修"~~還好噩夢已經過去了.

ONNX 的 version 小筆記

ONNX (Open Neural Network Exchange) 是一種 AI 檔案交換標準 最近在 porting DeepSeek 到 NPU 的過程中, 卡住了一下, 所以特別記錄下來.

首先 ONNX 有兩個主要的 domain: ai.onnx 和 ai.onnx.ml. 雖然他們都是為 AI 而生, 但ai.onnx domain 的 operator (=運算子, 算子, 運算符) 更加常用, 像是加減、乘加這種很基本的運算, 或是比較複雜的 convolution, activation function, 以及 DNN 有關的 RNN, dropput…等等.

ai.onnx.ml 的算子適用於傳統的機器學習, 包括非深度學習的算法, 像是 one hot encoder, 還有 SVM, imputer…等等. 至於其他的 domain, 屬於客製化擴展, 官方不維護 [1]. 包括: ai.onnx.training, com.microsoft, com.ms.internal….等等.

接下來也很妙. ONNX 進版後, 雖然看起來也是以 1.0, 1.1,….這樣長大, 但是官方又出了個 IR version, 用來表示是否有 breaking change. 所以顯然很多版 version 會對應到同一版 IR version.

“The IR format is versioned using simple numbers, which MUST be monotonically increasing. Breaking changes to the format or semantics of the ONNX specification require an increment of the version. Non-breaking changes to the IR format do not require changing the version number."

光是這樣也就罷了, 由於還有 ai.onnx 和 ai.onnx.ml 兩個 domain 之分. 如果對某個 domain 是 breaking change, 但對另外一個 domain 不是呢? 所以對於不同的 domain, 又有 Opset 的 version.

“ONNX uses operator sets to group together immutable operator specifications. An operator set represents a specific version of a domain, indicated by a pair (domain, version). This represents the set of all operators belonging to the specified domain with the specified version (referred to as the opset_version). When the inventory of a given operator set changes either by addition, removal, or a change in semantics of a contained operator, its version MUST increase."

因此我們可以得到這樣的表格 – Released Versions

ONNX versionIR versionOpset version ai.onnxOpset version ai.onnx.mlOpset version ai.onnx.training
1.0311
1.1351
1.1.2361
1.2371
1.3381
1.4.1491
中間省略, 詳情參考 [1]
1.14.191931
1.15.092041
1.16.0102151
1.17.0102251

當我試圖將 ONNX INT4 的 DeepSeek 翻成 NPU 指令時, converter 跑出下面這個 error, 表示在 domain version 14 當中找不到 SimplifiedLayerNormalization 這個算子. 而我們安裝的 ONNX 通常是 ai.onnx domain. 既然在預設的 domain 裡面找不到, 我們就要檢查一下從 huggingface 抓來的 DeepSeek 是哪個 domain 的 ONNX?

File "/local/workspace/hailo_virtualenv/lib/python3.10/site-packages/onnx/checker.py", line 163, in check_model
    C.check_model_path(
onnx.onnx_cpp2py_export.checker.ValidationError: No Op registered for SimplifiedLayerNormalization with domain_version of 14
 
==> Context: Bad node spec for node. Name: /model/layers.0/input_layernorm/LayerNorm OpType: SimplifiedLayerNormalization

寫個小程式檢查 node 的 domain:

import onnx
model = onnx.load("model.onnx")

# 查看所有算子的 domain
for node in model.graph.node:
    print(f"Op: {node.op_type}, Domain: {node.domain}")

結果悲劇了… 大部分都是 com.microsoft domain, 難怪會出錯. 經過測試, 這個 int4 版本除了回答品質不太穩之外, 還會無限迴圈重複自己的話, 所以暫且就不 debug 下去了, 僅記錄下 ONNX version 的複雜性.

[REF]

  1. https://github.com/onnx/onnx/blob/main/docs/Versioning.md

訓練模型需要多少計算量?

DeepSeek 問世後, 很多人討論它需要多少計算量? 我在此做個小整理.

基本上, 而一個 neural node 至少有 weighting 和 bias 要訓練. 而每層輸出共用一個 bias. 請 DeepSeek R1 想了 17 秒, 整理如下:

  • 對於 Dense layer, 參數 = Node 數 + 1.
  • 對於 convolution layer, 參數 = channel 數 * width * height + 1.
  • 對於 RNN layer, 參數 = (輸入維度 + hidden state 維度 h) * h + h.
  • 對於 LSTM, 參數=RNN * 4.
  • 對於 L 層 transformer, 參數 = L * (12h2 + 13h) * Vh ~= 12Lh2 , 其中 V 是 vocabulary size (詞表). [3]

如果是做研究, 可能要參考 [2] 的表 1.

不管是哪一種 weighting parameter, 只要可以被訓練, 我們就算它一個參數. 至於不能訓練的, 像是 ReLU, 這些都不會列入參數, 只會佔據 memory 和 CPU time.

接下來的問題是每個參數要被算幾次才完成 training. 如果去問 AI 的話, 有些回答真的很離譜, 像是 Gemini 算出來比別人大好幾個數量級; 有些 model 會說要除以 batch size. 實際上 batch size 只影響 gradient 的更新頻率. batch 愈多次應該要乘愈多次而不是除. 所以還是問人類比較可靠 [3], DeepSeek R1 也還行, 只是它想了 128 秒.

FLOP s≈ 6 × 參數數量 × 訓練總 token 總數.

例如 GPT-3 175B 參數, 訓練 300B token, 需要 3.15 * 1023 FLOPS. 和官方公布的 3.14* 1023 FLOPS 接近.

其中 6 來自 forward (矩陣, activation) 2 次計算和 backward (梯度, 參數更新) 的 4 次相加. 至於訓練總 token 數, 其實已經吸收了 batch size 參數. 這邊再找個證據.

根據 [2], 跳過各種英文或數學, 3.3 或 5.1. 裡面都引用了 C = 6NBS. 其中 C = training computing, B = batch size, S = number of parameter updates (訓練總 token 數), and N = non-embedding parameter count (參數數量).

什麼是 non-embedding parameter? 就是所有的 parameter 扣掉 token 和 position embedding . 包括:

  • Dense/Linear layers
  • Attention layers
  • Convolutional layers
  • Batch normalization layers
  • Any other trainable layers except embeddings

另外根據 Chinchilla 法則 [5], 訓練量應為參數的 20 倍. 所以 BS = 20N. 前面提到的 GPT-3 175B, 大約需要 3500 B token 的訓練才達到最佳效果. 它只訓練了 300B token, 理論上還有進步空間. 據說這是因為 GPT-3 發表於 2020 年, 而 Chinchilla 法則發表於 2022 年.

再回到風暴的起點, Deepseek 自稱是用 2.788K (pre-train 2664K) H800 GPU hours train 14.8 T tokens. 然而, 如果用上面的 6ND, 搭配 H800 Spec. 和論文中的數據 [7], MFU (Model FLOPS Utilization) 會超過 100%, 完全不合理.

MFU = 訓練需要的算力 / GPU 提供的算力 = (6 * 14.8T * 671B) / (2664 K Hours * 3600 second/Hours * 3026 TFLOPS) = 205%.

其中 MOE, MLA, MTP 可能有省到算力, 但就算把 MFU 壓到 100% 都解釋不通. 當然也可以說 DeepSeek 說謊 – 短報時數. 解決不了問題就解決人. 哈!

歸納起來, 此時不能用 6ND, 要參考 [8]. 用 “3 * 計算 Deepseek v3 的所有 forward 操作" 來代替 6ND. 如此就可以算出 MFU = 35~45% [6], 這樣起碼是落在合理範圍 (< 60%).

其實 [3] 和 [6] 都寫得很好, [6] 尤其完整. 不過我悶著頭寫了一大段才 reference 到他們, 只好改一改重點, 讓它們當配角了.

附帶一提. GPU 卡上的 RAM size, 決定了要用幾張卡才塞得下整個 model 的參數. Training 時要存 weighting, activation function, optimizer, gradient, 抓個 4 倍. Inference 時需要weighting, activation 和 bias. 抓 2.5 倍. 這是一般狀況, 非特別指DeepSeek.

[REF]

  1. GPT-3, The Model Simply Knows! – Analixa
  2. Scaling Laws for Neural Language Models
  3. AI大模型训练相关参数如何估算?有这一篇就够了
  4. https://zhuanlan.zhihu.com/p/606038646
  5. https://arxiv.org/pdf/2203.15556
  6. 【LLM 專欄】Deepseek v3 的訓練時間到底合不合理?淺談 LLM Training efficiency
  7. https://en.wikipedia.org/wiki/DeepSeek
  8. https://zhuanlan.zhihu.com/p/16445683081