# Qwenputer 怎麼把 DeltaNet 狀態當 RAM 用：精確讀寫的條件，和它沒保證的事

> Qwenputer 將 gate 與 write strength 設成 1，用 one-hot key 精確讀寫。以 numpy 示範，區分 RAM 規則與推論重現性。

原文：https://sheng.page/posts/qwenputer-deltanet-as-ram/ · 發布：2026-11-08 · 更新：2026-11-09 · 作者：Sheng

Qwenputer 是 Divyansh Agrawal 在 2026 年 10 月發表的實驗，把一顆 32-bit RISC-V CPU 編譯成權重，放進 Qwen 3.5 4B，並用模型的 Gated DeltaNet（GDN）層當記憶體。原文的 RAM 設計把 gate 設為 1，讓舊狀態保留下來，write strength 也設為 1，讓新值覆寫舊值，再用 one-hot key 指定位址。下面的 numpy 範例示範這些設定如何讓寫入值原樣讀回。

這條讀寫規則成立，只保證狀態矩陣這一層的語意正確，整個模型在不同執行條件下是否位元一致是另一回事，成因也不同：Thinking Machines 的實驗把觀察到的差異歸因於 kernel 不滿足 batch invariance。本文最後一節把兩者分開談，並用一個小範例看哪些層會受影響。

## Qwenputer 做了什麼

以下全部出自原文，本文沒有重現模型本身。

底模型是 Qwen 3.5 4B。作者指出 Qwen3-Next 與 Qwen 3.5 的注意力層裡每四層有三層是 GDN，這是這個實驗能成立的前提。CPU 沒有經過訓練，是用一個編譯器從電路描述直接產生權重：查表編進 attention head，算術編進 feed-forward，RAM 編進 DeltaNet head。CPU 佔 11 層，放在 Qwen 原本 32 層的前面，hidden size 也加寬了，原文的說法是「Qwen's original weights stay exactly as they are」，也就是原權重沒動，但多了維度和層，模型架構已經和原版不同。

CPU 本身是 RV32IM 加 Zba 擴充，另外有一個小的 bignum 單元。作者說它通過官方 riscv-tests，執行結果和 QEMU 一致，free-running 跑了 1,800 萬條指令沒有出錯，可以跑 C 與 MicroPython，但還稱不上 Linux-class。每條指令在生成序列裡對應幾個 token：ADDR 指定要寫的記憶體列，VAL 是值，STEP 結束這條指令。這串 token 就是 CPU 的執行軌跡，Qwen 一邊生成，CPU 一邊在跑。

接上 Qwen 的方式是沿用它本來就訓練過的 tool-call 格式，不需要 post-training。原文另外寫了一個叫 RvChat 的小驅動，負責把韌體載進 GDN 狀態，並且不讓軌跡 token 進到 Qwen 自己的層。Hacker News 討論裡有人對「no external runtime」這個說法有意見，理由是韌體載入和跳過 Qwen 層都還是由主機端的 runtime 處理。

原文回報 checkpoint 是 18.8 GB，約是底模型的兩倍，多出來的主要是補零，並稱單張 24 GB GPU 可以執行。benchmark 的批次執行器在 L4 上約每秒產生 3,200 個 CPU token。原文也回報約 23 萬個輸出位置與參考機器比對後沒有不一致，一般文字輸出分布與底模型的 KL 約為 6e-4，雜訊底線為 5e-4。這些都是作者環境下的結果。

## 一個位址怎麼寫、怎麼讀

Gated DeltaNet 論文（Yang、Kautz、Hatamizadeh，2024）的狀態更新式是

```text
S_t = S_{t-1} (α_t (I − β_t k_t k_tᵀ)) + β_t v_t k_tᵀ
o_t = S_t q_t
```

S 是狀態矩陣，大小 d_v × d_k。α_t 是 gate，論文裡介於 0 和 1 之間（不含端點），控制舊狀態衰減多少；β_t 是 write strength，論文裡同樣介於 0 和 1 之間（不含端點），決定新值取代舊值的比例；k、v、q 分別是 key、value、query。讀出就是拿 query 去乘狀態。原文的 RAM 條件對應到這條式子，就是 α_t = 1、β_t = 1、k_t 取單位向量 e_i。代進去，中間那個括號變成 I − e_i e_iᵀ，作用是把 S 的第 i 欄清成零，後面那項再把 v 填進第 i 欄，其他欄完全不動。讀的時候 q = e_i，取出的就是第 i 欄。原文說的是 row，這裡是欄，差別只是矩陣怎麼擺。

下面的範例照這條式子寫，寫四個位址，再覆寫其中一個，然後逐一讀回。

```python
import numpy as np

# Gated delta rule (Yang, Kautz, Hatamizadeh, 2024, eq. 10):
#   S_t = S_{t-1} (alpha_t (I - beta_t k_t k_t^T)) + beta_t v_t k_t^T
#   o_t = S_t q_t
# S has shape (d_v, d_k). A one-hot key k = e_i touches only column i.
D_K, D_V = 4, 3
I = np.eye(D_K, dtype=np.float32)


def step(S, k, v, alpha, beta):
    k = k.reshape(-1, 1)
    v = v.reshape(-1, 1)
    return S @ (alpha * (I - beta * k @ k.T)) + beta * v @ k.T


def write(S, addr, v):
    return step(S, I[addr], v, alpha=np.float32(1), beta=np.float32(1))


def read(S, addr):
    return S @ I[addr]


rng = np.random.default_rng(7)
S = np.zeros((D_V, D_K), dtype=np.float32)
vals = rng.integers(-100, 100, size=(D_K, D_V)).astype(np.float32)
for addr in range(D_K):
    S = write(S, addr, vals[addr])
S = write(S, 2, np.array([7, 7, 7], dtype=np.float32))  # overwrite addr 2
vals[2] = [7, 7, 7]

worst = 0.0
for addr in range(D_K):
    got = read(S, addr)
    err = float(np.max(np.abs(got - vals[addr])))
    worst = max(worst, err)
    print(f"addr {addr}: wrote {vals[addr]}  read {got}  max|err| {err}")
print("worst error over all addresses:", worst)
```

```text
addr 0: wrote [88. 25. 36.]  read [88. 25. 36.]  max|err| 0.0
addr 1: wrote [79. 15. 55.]  read [79. 15. 55.]  max|err| 0.0
addr 2: wrote [7. 7. 7.]  read [7. 7. 7.]  max|err| 0.0
addr 3: wrote [-40. -43.  74.]  read [-40. -43.  74.]  max|err| 0.0
worst error over all addresses: 0.0
```

在這個範例的數值與運算方式下，誤差為零：這組參數只需要乘 1、乘 0，再加到原本為零的位置，float32 在這裡不會產生捨入誤差。這能說明精確讀寫的原理，但範例只示範狀態矩陣的語意，真實 kernel 的數值路徑和模型裡的精度設定都不在範圍內，也沒有跑任何 CPU 指令。原文的 23 萬個位置零不一致是作者的驗證結果。

## 放寬條件後的讀寫結果

下面三組分別把 gate 改成 0.9、write strength 改成 0.5、其中一個 key 改成非 one-hot，同一份程式就會讀出不同的值。

```python
import numpy as np

# Same gated delta rule as exact_rw.py, but with the RAM conditions relaxed.
D_K, D_V = 4, 3
I = np.eye(D_K, dtype=np.float32)


def step(S, k, v, alpha, beta):
    k = k.reshape(-1, 1)
    v = v.reshape(-1, 1)
    return S @ (alpha * (I - beta * k @ k.T)) + beta * v @ k.T


def fill(alpha, beta, keys):
    S = np.zeros((D_V, D_K), dtype=np.float32)
    vals = np.array([[10, 20, 30], [40, 50, 60], [70, 80, 90], [1, 2, 3]],
                    dtype=np.float32)
    for addr in range(D_K):
        S = step(S, keys[addr], vals[addr], alpha, beta)
    return S, vals


def report(label, S, vals, queries):
    print(label)
    for addr in range(D_K):
        got = S @ queries[addr]
        print(f"  addr {addr}: wrote {vals[addr]}  read {np.round(got, 3)}")


one_hot = [I[i] for i in range(D_K)]

# (a) gate < 1: every later write shrinks what is already stored.
S, vals = fill(alpha=np.float32(0.9), beta=np.float32(1), keys=one_hot)
report("gate 0.9, write strength 1, one-hot keys", S, vals, one_hot)

# (b) write strength < 1: the new value is blended with the old one.
S, vals = fill(alpha=np.float32(1), beta=np.float32(0.5), keys=one_hot)
report("gate 1, write strength 0.5, one-hot keys", S, vals, one_hot)

# (c) a key that overlaps two addresses writes into both of them.
mixed = list(one_hot)
mixed[3] = np.array([0, 0, 1, 1], dtype=np.float32) / np.sqrt(np.float32(2))
S, vals = fill(alpha=np.float32(1), beta=np.float32(1), keys=mixed)
report("gate 1, write strength 1, key 3 = (e2 + e3) / sqrt(2)", S, vals, mixed)
```

```text
gate 0.9, write strength 1, one-hot keys
  addr 0: wrote [10. 20. 30.]  read [ 7.29 14.58 21.87]
  addr 1: wrote [40. 50. 60.]  read [32.4 40.5 48.6]
  addr 2: wrote [70. 80. 90.]  read [63. 72. 81.]
  addr 3: wrote [1. 2. 3.]  read [1. 2. 3.]
gate 1, write strength 0.5, one-hot keys
  addr 0: wrote [10. 20. 30.]  read [ 5. 10. 15.]
  addr 1: wrote [40. 50. 60.]  read [20. 25. 30.]
  addr 2: wrote [70. 80. 90.]  read [35. 40. 45.]
  addr 3: wrote [1. 2. 3.]  read [0.5 1.  1.5]
gate 1, write strength 1, key 3 = (e2 + e3) / sqrt(2)
  addr 0: wrote [10. 20. 30.]  read [10. 20. 30.]
  addr 1: wrote [40. 50. 60.]  read [40. 50. 60.]
  addr 2: wrote [70. 80. 90.]  read [35.707 41.414 47.121]
  addr 3: wrote [1. 2. 3.]  read [1. 2. 3.]
```

gate 為 0.9 時，每次寫入都讓先前狀態再乘一次 0.9，最早寫入的位址 0 因此只剩原值的 0.729 倍。這種衰減適合用來淡化舊狀態，卻不符合這裡對 RAM 的要求。write strength 為 0.5 時，新值與舊值（這裡是零）各佔一半，讀回來也只剩新值的一半。第三組的位址 3 用了跨在 e2 與 e3 上的 key，讀取自身時看似正確，寫入卻同時影響位址 2，讓它讀出 35.7，而非 70。在這個定址方式下，one-hot key 才能把寫入限制在指定欄位。

這三個值在真實模型裡都是從輸入算出來的，由學到的參數決定，所以一般的 GDN 層本來就不是 RAM。Qwenputer 是靠編譯出來的權重把它們釘死在 1 和單位向量上。和 Schlag、Irie、Schmidhuber 2021 年「delta rule 線性注意力是聯想記憶」那條線相比，差別就在 α、β 和 key 是釘死的，模型沒有自己學。

## 精確讀寫和推論重現性是兩個層次

按 Wikipedia 的定義，確定性演算法是給定同樣輸入永遠產生同樣輸出、機器經過同樣狀態序列的演算法。固定參數、初始狀態和輸入，帶衰減的更新式照樣是確定性的，α 等於 0.9 和等於 1 在這一點上沒有差別，參數是從訓練學來的也一樣。上一節的偏差是語意上的偏差，每次跑都偏得一模一樣。

LLM 在同一個 prompt、temperature 0 下仍可能產生不同答案。Thinking Machines 在 2025 年的文章裡，針對他們實驗的推論路徑說明：浮點數不滿足結合律加上 GPU 平行執行，這兩點本身還不足以解釋使用者看到的差異；該路徑沒有需要 atomic add 的運算，同一個 kernel 處理同一批資料時可以逐位元重現。他們把差異歸因於 batch size 隨負載變動，RMSNorm、矩陣乘法與 attention kernel 因而採用不同的 reduction 順序。實驗用 Qwen3-235B 的特定 checkpoint，temperature 0，同一個 prompt 執行 1,000 次，得到 80 種輸出，前 102 個 token 相同，從第 103 個 token 開始不同；換成 batch-invariant kernel 後，1,000 次輸出一致。這些結果有各自的執行條件，不能直接套到所有推論服務。

用 float32 加法可以觀察 reduction 順序造成的差異，這個範例只示範推論重現性問題的一個數值機制。

```python
import numpy as np

# The same float32 numbers, summed in a different order, do not always
# give the same float32 result. Fixed input, no randomness.
xs = np.array([1e8, 1.0, -1e8, 1.0], dtype=np.float32)

left_to_right = np.float32(0)
for x in xs:
    left_to_right = np.float32(left_to_right + x)

pairwise = np.float32(np.float32(xs[0] + xs[2]) + np.float32(xs[1] + xs[3]))

print("left to right:", left_to_right)
print("pairwise     :", pairwise)
print("equal        :", left_to_right == pairwise)

# A reduction over a bigger array, with the same values in two orders.
rng = np.random.default_rng(1)
big = rng.standard_normal(100_000).astype(np.float32) * np.float32(1000)
a = np.float32(0)
for x in big:
    a = np.float32(a + x)
b = np.float32(0)
for x in big[::-1]:
    b = np.float32(b + x)
print("forward sum  :", a)
print("reverse sum  :", b)
print("difference   :", np.float32(a - b))
```

```text
left to right: 1.0
pairwise     : 2.0
equal        : False
forward sum  : -459056.06
reverse sum  : -459057.84
difference   : 1.78125
```

四個數字，答案應該是 2，從左到右加會得到 1，因為 1e8 + 1 在 float32 裡就是 1e8。十萬個數字正著加和倒著加，差了 1.78。兩種順序各自都是確定性的，實際採用的順序要看 kernel 與 batch size，使用服務的人未必能控制。

順序會改變結果，哪些層受影響要看那一層在算什麼。下面把前面的 RAM 步驟和一層普通的 float32 dense layer 放在一起，各自套上同樣兩種擾動：打亂 reduction 順序，以及把 batch size 從 1 改成其他值。RAM 步驟和直接把 v 填進第 17 欄的結果逐位元比對，比的是 float32 位元樣式，+0.0 與 −0.0 也算不同，最後再把同一次寫入改用代數上相等的 S + (v − S e) eᵀ 算一次。

```python
import numpy as np

# Two float32 layers under the same two perturbations: a shuffled reduction
# order, and a batch size other than 1. The RAM step (alpha = 1, beta = 1,
# one-hot key) is compared bit for bit with direct column assignment.
rng = np.random.default_rng(2026)
f32 = np.float32
D_K, D_V = 64, 48


def bit_equal(a, b):
    """Same bit pattern in every float32 (unlike ==, tells +0.0 from -0.0)."""
    return np.array_equal(a.view(np.uint32), b.view(np.uint32))


def ordered_matmul(A, B, order):
    """A @ B with the reduction over the shared axis done in the given order."""
    out = np.zeros((A.shape[0], B.shape[1]), dtype=f32)
    for j in order:
        out = f32(out + np.outer(A[:, j], B[j]))
    return out


# (a) RAM step: S (I - e e^T) + v e^T must equal S with column addr replaced by v.
S = rng.standard_normal((D_V, D_K)).astype(f32) * f32(1000)
v = rng.standard_normal(D_V).astype(f32)
addr = 17
e = np.eye(D_K, dtype=f32)[addr]
proj = np.eye(D_K, dtype=f32) - np.outer(e, e)
expected = S.copy()
expected[:, addr] = v

# (b) dense layer: x @ W with learnt-looking weights, no exact zeros to help it.
W = rng.standard_normal((D_K, D_V)).astype(f32)
x = rng.standard_normal((1, D_K)).astype(f32) * f32(1000)
dense_1 = x @ W

ram_bad = dense_bad = 0
for _ in range(200):
    order = rng.permutation(D_K)
    ram_bad += not bit_equal(ordered_matmul(S, proj, order) + np.outer(v, e), expected)
    dense_bad += not bit_equal(ordered_matmul(x, W, order), dense_1)
print(f"200 shuffled orders   RAM step mismatches: {ram_bad}   dense layer mismatches: {dense_bad}")

for b in (1, 2, 7, 64, 513):
    ram_b = (np.broadcast_to(S, (b, D_V, D_K)).copy() @ proj + np.outer(v, e))[0]
    dense_b = (np.broadcast_to(x, (b, D_K)).copy() @ W)[0]
    print(f"batch {b:>3}   RAM step equals expected: {bit_equal(ram_b, expected)}   "
          f"dense row 0 equals batch 1: {bit_equal(dense_b, dense_1[0])}")

# (c) The same write evaluated as S + (v - S e) e^T, which is equal on paper.
delta_form = S + np.outer(v - S @ e, e)
print("RAM step in delta form   equals expected:", bit_equal(delta_form, expected),
      "  mismatched elements:", int((delta_form.view(np.uint32) != expected.view(np.uint32)).sum()))

# (d) Two candidate tokens whose logits are nearly tied: the order alone decides.
X = rng.standard_normal((20_000, D_K)).astype(f32) * f32(1000)
Wt = np.repeat(rng.standard_normal((D_K, 1)).astype(f32), 2, axis=1)
Wt[0, 1] = np.nextafter(Wt[0, 1], f32(np.inf))  # one weight differs by one ulp
fwd = ordered_matmul(X, Wt, range(D_K))
rev = ordered_matmul(X, Wt, reversed(range(D_K)))
print("near-tie logits, forward vs reverse order: argmax differs in",
      int((fwd.argmax(1) != rev.argmax(1)).sum()), "of 20000 rows")
```

```text
200 shuffled orders   RAM step mismatches: 0   dense layer mismatches: 200
batch   1   RAM step equals expected: True   dense row 0 equals batch 1: True
batch   2   RAM step equals expected: True   dense row 0 equals batch 1: False
batch   7   RAM step equals expected: True   dense row 0 equals batch 1: False
batch  64   RAM step equals expected: True   dense row 0 equals batch 1: False
batch 513   RAM step equals expected: True   dense row 0 equals batch 1: False
RAM step in delta form   equals expected: False   mismatched elements: 48
near-tie logits, forward vs reverse order: argmax differs in 3015 of 20000 rows
```

RAM 步驟在 200 種順序和五種 batch size 下都和直接指定欄位的結果逐位元相同，原因在矩陣本身：α = 1、β = 1、key 是精確的單位向量時，I − e_i e_iᵀ 的第 i 欄全是零，其餘每一欄只有一個 1，其他位置是精確的零，輸出每個元素最多只有一個非零乘積，其餘加的都是 0，加法順序改變不了結果。這一點對本例的數值成立，若狀態裡存的是 −0.0，加上 0.0 會變成 +0.0，位元比對就會不同。dense layer 沒有這種結構，每個輸出元素是 64 個非零乘積的和，200 種順序各得一組不同的位元；batch size 改成 2、7、64、513 時，在本文的執行環境（CPU 上的 OpenBLAS）第 0 列也和 batch 1 的結果不同。兩個候選 token 的權重只差一個 ulp 時，正著加和倒著加在 20,000 列裡有 3,015 列選到不同的 token。

是否受這兩種擾動影響，要看這一步怎麼算，公式本身不保證。同一次寫入改寫成 S + (v − S e) eᵀ，紙上和投影形式相等，float32 卻要先算 v − S e 再加回去，兩者數量級差很多時減法會捨入，第 17 欄的 48 個元素全部和預期不同。所以 key 與 gate 精確只是必要條件，kernel 用哪一種形式算這一步同樣重要，原文沒有交代。這些矩陣的維度只有 64 和 48，是玩具尺寸，和 Qwenputer 的 kernel 與精度設定無關，但分層的結論仍可沿用：以投影形式求值、key 與 gate 都精確時，RAM 步驟不受這兩種擾動影響；編進 attention 的查表和編進 feed-forward 的算術沒有這種結構保護，順序和 batch 有可能改變結果，實際 kernel 要另外檢查，那裡選錯一個 token，後面的執行軌跡就可能跟著改變。

回到 Qwenputer，在 α = 1、β = 1、one-hot key 設定下，RAM 步驟可以精確讀寫，以投影形式求值時對 reduction 順序和 batch size 都不敏感。要驗證的條件在兩處：RAM head 這邊，編譯出來的 key 是否精確等於單位向量、gate 是否精確乘上 1，以及 kernel 實際用哪一種形式算這一步；attention 與 feed-forward 這邊，放查表和算術的層在不同 batch size 下結果是否一致，原文沒有說明這些 kernel 是否 batch-invariant。原文回報 23 萬個位置比對一致、1,800 萬條指令 free-running 無錯，支持的是作者測過的條件，還推不出換 batch size 或 GPU 後也逐位元一致。

〈[遊戲 RNG 正式環境要猜不到、測試要能重播](/posts/rng-unpredictable-in-prod-replayable-in-tests/)〉也討論重播所需的固定條件。

## 原文列的限制

原文列出的限制包括：checkpoint 18.8 GB，大部分是補零；要靠 RvChat 驅動載入韌體，還不能用單純的 `generate()` 跑；每條 CPU 指令要花幾個解碼 token，大型計算要秒級而非毫秒級；還不是 Linux-class 的 CPU；檔案系統功能是實驗性的。作者列的後續工作是把 MicroPython 包成 `python()` 工具，以及加快 CPU 執行速度。

## 補充筆記

- Gated DeltaNet 的更新式是 S_t = S_{t-1}(α_t(I − β_t k_t k_tᵀ)) + β_t v_t k_tᵀ，讀出是 o_t = S_t q_t。
- α = 1、β = 1、key 為單位向量時，寫入只清掉並填入一欄，其他欄不動，本例的 float32 運算誤差為零。
- gate 小於 1 會讓舊位址隨每次寫入衰減，write strength 小於 1 會把新舊值混合，範例中的混合 key 會影響其他位址。
- 依作者說明，Qwenputer 原權重沒動，但加了維度和放在前面的 11 層，checkpoint 18.8 GB，需要 RvChat 驅動。
- 學來的參數和會衰減的狀態，都不等於非確定性；Thinking Machines 把他們實驗的推論輸出差異歸因於 kernel 缺乏 batch invariance，浮點加法順序只是其中一個機制。
- 以投影形式求值且 key 與 gate 精確時，RAM 步驟每個輸出元素最多一個非零乘積，對 reduction 順序和 batch size 都不敏感；代數上相等的 delta 形式在本例無法精確覆寫。編進 attention 與 feed-forward 的 dense 運算可能受順序和 batch 影響，實際 kernel 仍需檢查，RAM 規則成立推不出跨執行條件位元一致。

## 延伸閱讀

- Divyansh Agrawal〈[Qwenputer: A functioning, deterministic RISC-V CPU inside Qwen3.5](https://divagr.com/blog/qwenputer)〉：架構、層數、gate 與 write strength 設定、one-hot 定址、checkpoint 大小、GPU 需求、限制與 RvChat 的出處，本文的案例事實全部來自這裡。
- Hacker News〈[Qwenputer 討論串](https://news.ycombinator.com/item?id=50282838)〉：對「no external runtime」說法的質疑出自這裡。
- Yang、Kautz、Hatamizadeh〈[Gated Delta Networks: Improving Mamba2 with Delta Rule](https://arxiv.org/abs/2412.06464)〉：gated delta rule 更新式與 α、β 的定義出處。
- Thinking Machines〈[Defeating Nondeterminism in LLM Inference](https://thinkingmachines.ai/blog/defeating-nondeterminism-in-llm-inference/)〉：batch invariance 的說法與 Qwen3-235B 實驗數字出處，一手研究。
- Wikipedia〈[Deterministic algorithm](https://en.wikipedia.org/wiki/Deterministic_algorithm)〉：確定性演算法的定義。
- Schlag、Irie、Schmidhuber〈[Linear Transformers Are Secretly Fast Weight Programmers](https://arxiv.org/abs/2102.11174)〉（ICML 2021）：這類 delta rule 更新的出處。

> 想法與技術判斷出自 Sheng，和 Claude 一起起草 · 範例在 Linux 上以 Python 3.13.18 與 numpy 2.5.3 實測 · 修訂由 Fable 規劃、Codex 審查、Grok 調整語氣。