阅读视图

发现新文章,点击刷新页面。
🔲 ☆

自動微分 | DIY 實現自己的 PyTorch

在機器學習問題中,模型訓練的核心是梯度下降:

  1. 計算損失函數(Loss Function)
  2. 計算損失對參數的導數(Gradient)
  3. 根據梯度更新參數
  4. 重復直到收斂

我們需要找到一個高效的梯度計算方法。

幾種梯度計算方法

梯度計算方法包括:數值微分、符號微分、自動微分

1. 數值微分

數值微分的基本思想,是使用一個非常小的 hh 去近似計算。

dfdxf(x+h)f(x)h\frac{df}{dx} \approx \frac{f(x+h) - f(x)}{h}

但是,這種計算方法存在舍入誤差,而且計算復雜度高。

如果我們有 nn 個參數,需要執行 O(n)O(n) 次函數計算。在參數量達百萬級的神經網絡中,這種計算成本是不可接受的。

2. 符號微分

符號微分的思路是通過鏈式法則得到一個完整的解析表達式。在 Python 中,我們可以使用 sympy 去實現。

但是,一旦函數變得復雜,表達式長度增長極快,同一個子表達式在求導過程中會被多次重復計算。這種方法都理論上很優雅,在工程上卻不可行。

自動微分

自動微分將復雜函數拆解成一系列簡單的「基本運算」,並在計算過程中同步或反向計算導數。

自動微分通過記錄這些基礎運算的執行軌跡(即構建「計算圖」),並在計算過程中系統化地應用微積分中的鏈式法則##,從而極其精確且高效地計算出復雜函數對各個變量的導數。

要實現自動微分,首先要將復雜的算式轉化為「計算圖」。在這張有向無環圖(DAG)中:葉子節點代表輸入變量或模型參數。內部節點代表基本運算(如加法、乘法、sinsin 等)。邊代表數據流向。

我們可以在計算圖上進行反向傳播,這一過程分為兩個階段:

  1. 前向階段: 從輸入到輸出正常計算,但會保存所有的中間變量(計算圖)。
  2. 反向階段: 從最終的標量輸出(如 Loss 損失值)開始,從輸出向輸入反向遍歷計算圖。它通過鏈式法則,將輸出節點對當前節點的導數(稱為伴隨值 Adjoint,用 vˉi\bar{v}_i 表示)一步步往回傳遞。

工程實現上,我們可以使用一個簡單的拓撲排序,按照拓撲順序,從輸出節點反向計算梯度,完成梯度下降。

工程實現

筆者自己完成了一自動微分的簡單實現,包括了一些常用的神經網絡 Loss Function 和 Optimizer,在 GitHub 上開源:https://github.com/aeilot/simplegrad

我實現的是類似 PyTorch 的動態圖。

下面簡單介紹一些核心模塊。

Tensor

1
2
3
4
5
6
7
8
9
10
11
12
class Tensor:
def __init__(self, data, requires_grad: bool = False):
if not isinstance(data, np.ndarray):
data = np.array(data, dtype=np.float32)

self.data = data
self.requires_grad = requires_grad
self.grad = None # 梯度
self.parents = [] # 父節點
self.grad_fn = None # 創造了這個 `Tensor` 的數學操作

# 以下省略

Tensor 不僅僅是簡單的張量,還存儲了梯度、父節點等信息。通過運算符重載,我們可以隱式地構建計算圖,實現反向傳播。

Ops / Function

對於數學操作,我們需要定義前向運算和反向運算。

1
2
3
4
5
6
7
8
9
10

class Function:
def __init__(self):
pass

def forward(self):
raise NotImplementedError

def backward(self, grad_output):
raise NotImplementedError

定義了接口,我們就可以實現各種各樣的算子。

以矩陣乘法為例。

眾所周知,矩陣乘法求導有如下公式:

Y=ABY = AB

dA=LA=LYBTdA = \frac{\partial L}{\partial A} = \frac{\partial L}{\partial Y} B^T

dB=LB=ATLYdB = \frac{\partial L}{\partial B} = A^T \frac{\partial L}{\partial Y}

我們很容易寫出代碼:

1
2
3
4
5
6
7
8
9
10
class MatMul(Function):
def __init__(self, a, b):
super().__init__()
self.a = a
self.b = b

def backward(self, grad):
grad_a = grad @ self.b.data.T
grad_b = self.a.data.T @ grad
return [grad_a, grad_b]

然後進行運算符重載:

1
2
3
4
5
6
7
8
9
10
def __matmul__(self, other):
other = ensure_tensor(other) # 防止類型異常
out = Tensor(
self.data @ other.data,
requires_grad=self.requires_grad or other.requires_grad,
)
if out.requires_grad and GradMode.enabled:
out.grad_fn = ops.MatMul(self, other)
out.parents = [self, other]
return out

運算符重載的時候,我們儲存了父節點,用戶不需要自己手動建圖,我們自動構建了計算圖。

用這種方法,我實現了加法、減法、Hadamard 積、對數、Softmax、求和、平均數等運算,可以在 GitHub 倉庫 查看(不要白嫖 給個 star)

GradMode

細心的讀者可能註意到了,在前面 MatMul 的重載代碼中,有一個判斷條件 if out.requires_grad and GradMode.enabled:

這對應了 PyTorch 中的 torch.no_grad()。在模型訓練好後進行推理(Inference)或評估時,我們不需要更新參數,也就不需要計算梯度。如果此時框架還在後臺默默「建圖」(保存父節點和 grad_fn),會白白浪費大量內存。

我們可以用一個簡單的上下文管理器(Context Manager)來實現這個開關:

1
2
3
4
5
6
7
8
9
10
11
class GradMode:
enabled = True

@contextmanager
def no_grad():
prev = GradMode.enabled
GradMode.enabled = False
try:
yield
finally:
GradMode.enabled = prev

使用時非常優雅:

1
2
3
with no_grad():
# 這裏的運算不會構建計算圖,節省內存
y_pred = model(x)

backward

反向傳播的任務是從輸出節點出發,一路沿著圖的邊,把梯度精準地傳回給所有的輸入節點。

我們基於 DFS + 拓撲排序實現,保證在計算某個節點的梯度之前,依賴於它的所有「下遊」節點的梯度都已經被完全計算出來。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
def backward(output):
# 1. 拓撲排序:確保梯度的計算順序正確
def toposort(tensor):
visited = set()
order = []

def dfs(node):
node_id = id(node)
if node_id in visited:
return
visited.add(node_id)
for pa in node.parents:
dfs(pa)
order.append(node)

dfs(tensor)
order.reverse() # 翻轉得到從輸出到輸入的遍歷順序
return order

order = toposort(output)

# 2. 梯度初始化:最終輸出節點(如 Loss)的梯度設為 1
output.grad = np.ones_like(output.data)

# 3. 鏈式法則與梯度傳播
for node in order:
# 如果是葉子節點(如用戶直接輸入的變量),則跳過
if node.grad_fn is None:
continue

# 調用計算節點對應的局部反向函數
grads = node.grad_fn.backward(node.grad)

# 遍歷當前節點的所有父節點,將梯度回傳
for p, g in zip(node.parents, grads):
if p.grad is None:
p.grad = unbroadcast(g, p.shape)
else:
# 關鍵點:梯度累加
p.grad += unbroadcast(g, p.shape)

當一個變量在計算圖中被多次使用時(例如 y=xxy = x * x),它的梯度必須是各個分支回傳梯度的總和。

這裏註意一點細節,有的時候 numpy 運算會出現 broadcasting,導致父節點和子節點 shape 不符。

在 NumPy 中,如果我們把一個形狀為 (3, 1) 的張量與一個形狀為 (1, 3) 的張量相加,NumPy 會極其聰明地將它們自動擴展(Broadcast)成 (3, 3) 並計算出結果。然而,在反向傳播時,上遊傳下來的梯度形狀是 (3, 3),但我們原本的節點形狀是 (3, 1),直接將梯度傳回去會導致形狀不匹配而報錯。

因此,在把梯度賦值給父節點之前,我們必須進行一次反廣播(Unbroadcasting),把多出來的維度通過求和操作「擠壓」回原本的形狀:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def unbroadcast(grad, target_shape):
# 如果形狀已經完美匹配,直接返回
if grad.shape == target_shape:
return grad

# 情況一:處理由於廣播而新增的前置維度(例如標量與矩陣運算)
ndims_added = grad.ndim - len(target_shape)
for _ in range(ndims_added):
grad = grad.sum(axis=0)

# 情況二:處理被擴展的維度(原本大小為 1 的維度被擴展成了 N)
for i, dim in enumerate(target_shape):
if dim == 1:
# 沿著被擴展的維度進行求和降維,並保持維度結構
grad = grad.sum(axis=i, keepdims=True)

return grad

unbroadcast 函數的邏輯非常清晰,它分兩步解決了形狀還原的問題:

  1. 首先,如果正向計算時由於維度不對齊導致新增了維度,我們將這些多余的維度全部按軸求和並抹除。

  2. 其次,逐一對比目標形狀。如果發現原本該維度的大小為 1(意味著它被 NumPy 復製擴展了),我們就將傳回來的梯度沿著這個維度進行聚合求和,並使用 keepdims=True 維持其 (..., 1, ...) 的骨架。

優化器與梯度清零

我實現了一個帶動量的隨機梯度下降優化器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import numpy as np

class SGD:
def __init__(self, parameters, lr=0.01, momentum=0.0):
self.parameters = list(parameters)
self.lr = lr
self.momentum = momentum

# 為每個參數初始化一個全零的速度矩陣
self.velocities = {id(p): np.zeros_like(p.data) for p in self.parameters}

def step(self):
for p in self.parameters:
if p.grad is None:
continue

pid = id(p)
grad = p.grad

if self.momentum != 0.0:
# 核心動量公式:v = momentum * v + grad
self.velocities[pid] = self.momentum * self.velocities[pid] + grad
update_dir = self.velocities[pid]
else:
update_dir = grad

# 更新參數
p.data -= self.lr * update_dir

def zero_grad(self):
for p in self.parameters:
# 這裏要求我們的 Tensor 類中實現一個簡單的 zero_grad 方法
# def zero_grad(self): self.grad = None
p.zero_grad()

在我們的 backward 實現中,有一行代碼是 p.grad += unbroadcast(...)。我們使用的是梯度累加(+=),而不是直接賦值(=)。這意味著,如果不手動清空梯度,第二輪叠代的梯度就會疊加上第一輪的梯度,導致參數更新完全錯誤!因此,每次叠代前必須調用 optimizer.zero_grad()

其他

我在 SimpleGrad 中還實現了如 Adam 優化器等其他模塊,大家可以自行查看。

🔲 ☆

OpenAI招募OpenClawd创始人:并非收购,意在争夺标准

山姆·奥特曼身着西装与一位身穿休闲装的程序员握手,背景是一个由代码构成的云朵形状,云朵中隐约伸出一只机械爪,羊皮纸,钢笔彩色手绘的统一风格。

山姆·奥特曼突然官宣 OpenClawd,创始人 Peter Steinberg加入了 OpenAI。是不是 OpenAI 收购了 OpenClawd?甚至有些人出来哀嚎说,OpenClawd 现在变成 CloseClawd 了。事情并没有大家想象的那么简单。

大家好,欢迎收听“老范讲故事”的 YouTube 频道

OpenClawd 应该算是 2026 年年初的一个现象级产品,甚至有很多人说,这又是一次 ChatGPT 3.5 时刻了,确实是引起了整个社会的关注。这位 OpenClawd 的创始人 Peter Thielberg 就同时收到了山姆·奥特曼扎克伯格两个人的电话,这两个人都说:“我们聊一聊吧。”

他还回顾了说,扎克伯格给他打电话的时候是这样的。突然打个电话来说:“你好,我是扎克伯格,咱们能不能约个时间聊一下?”这位老哥,因为是个退休程序员嘛,说:“我不习惯跟人家去约时间,要么就现在聊,要么就拉倒。”扎克伯格说:“你等我 10 分钟,我要写一段代码,把这段代码写完了以后我来找你。”这老哥特别感动,说这么大 CEO、Meta 的老大创始人,自己还在这写代码。写了 10 分钟代码以后打电话回来聊,说:“我真的在用,有什么样的想法,我觉得应该怎么改,哪个地方我喜欢,哪地方不喜欢。”跟他聊了半天。

扎克伯格在凌乱的办公桌前专注地敲击代码,旁边放着一部正在通话中的手机,窗外是硅谷的黄昏,羊皮纸,钢笔彩色手绘的统一风格

当时大家就认为,OpenClawd 大概就是会被这两家中的一家所收购。但是最后其实并没有走收购这条路,而是创始人加入团队的这条路。这个到底有什么样的区别?咱们后面再去讲。

今天这故事咱们分三段来讲:第一段叫 OpenClawd 并没有被收购;第二段,大型的开源项目和大厂之间的几种合作方式,咱们要稍微掰一掰;第三段,OpenAI 为什么不直接收购 OpenClawd。

首先咱们来讲,OpenClawd 并没有被收购

OpenAI 到底出了多少钱?应该没多少钱,可能也就是几百万美金。这个对于一个像 OpenClawd 这样的、引起整个社会关注的项目来说的话,相当于是白捡了。他这个钱是怎么给的?就是我们直接把人招回来,有可能会有一个入职奖金,甚至这种奖金还是以股票的形式来发放的。就是真正出的现金应该没多少。这位 Peter Stinebrink 就成为 OpenAI 的一个员工。

那你说那 OpenClawd 怎么办?这开源项目你还做不做?这个项目会继续留在一个叫 OpenClawd 基金会的管理下,由他们来去管理,这是一个开源项目。OpenClawd 的商标、OpenClawd 的域名、里头所有的代码,依然是属于 OpenClawd 基金会的。只是它的创始人、这个最核心的贡献者,上 OpenAI 上班去了。上班了以后,他其实依然是在管理 OpenClawd 这个项目,但是他要分清楚,哪些是 OpenAI 的指令,哪些是 OpenClawd 基金会的指令。

一座标有“基金会”字样的坚固石屋内存放着代码卷轴和印章,一个人正走出石屋走向远处的OpenAI科技大楼,羊皮纸,钢笔彩色手绘的统一风格。

而加入到 OpenAI 里边的,只有 Peter Stinebarger 一个人。其实现在去维护这个项目的人已经有很多了,核心的大概也有快 10 个人了,但是真正加进去的就他一个,其他人都没有加进去。而 OpenClawd 自己的话,主要是由这个基金会来运作。这个基金会需要什么?付服务器的钱,或者组织各种活动,制定各种的标准。说我们这个项目以后要向什么样的方向前进,跟谁兼容跟谁不兼容,这都是由基金会来定的。

OpenAI 原来就是 OpenClawd 基金会的一个赞助者。只是你赞助了多少钱不知道,因为你要成为他的赞助者,最少赞助 5 美元就行了,一个月 5 美元就可以。当然以 OpenAI 这样的一个体量来说,应该还是给了不少钱的。而且现在 OpenAI 已经告诉大家了,说以后 OpenClawd 就不用再担心了,你们再用服务器、再用算力、再用这些东西,我包圆了,你们就不用管了。因为原来 Peter Thielberg 也讲过,每个月还要赔进去一两万美金,因为需要付服务器成本,收到的捐款根本就不够。以后这个钱就通通归 OpenAI 来付了。

但是这点钱对于 OpenAI 来说算个什么?一个月一两万美金,这都不是什么事。当然 OpenAI 肯定还会出很多其他的钱,比如说组织各种的研讨会,组织各种线下活动,或者做各种的标准的修订,这个是 OpenAI 会去做的事情。当然 OpenAI 也不可能直接做,还是会把钱给到基金会,让基金会去做这个事情。只是坐在那领导基金会、去做所有工作的人,是从 OpenAI 领薪水的。

开源软件跟这些大厂有几种合作方式?

这里要注意,大型开源软件咱们可以去讨论这个事,那些小型开源软件其实跟这个没有特别大的关系。

第一种方式:人员加入,继续做开源社区的事情

就像这一次 Peter Steinberger 加入 OpenAI 这个事情是一样的。这个里头有一个很典型的案例,就是 Python。Python 是现在最火热的编程语言,因为现在大模型都是使用 Python 语言再去做各种的编程。那么 Python 的创始人其实很长一段时间是在谷歌上班的,后来被谷歌开了。这个很有意思,当时他从谷歌就直接被优化掉了。很多人还很奇怪,说你怎么就被优化掉了?这个兄弟后来好像又跑到微软继续去上班去了。他们这些人到公司里头只是领薪水,具体的事情还是干原来的基金会的事情,或者是干原来这种开源项目的事情。谷歌除了发薪水之外,其他啥也不管。

包括一些开源的编辑器,他们的这些创始人实际上都是谷歌在发薪水。就是这些人在谷歌有时候会也参与一些谷歌的项目,但是他的主要工作就是领了谷歌的薪水去维护自己的项目。谷歌属于确实有钱,他们也特别喜欢干这个事情。你说谷歌给他们发薪水了,到底从他们身上挣到什么?其实也没挣到什么。你说我把 Python 项目的老大搁在这,那我能不让别人使吗?谁使谁给我交钱?他也不能干这个活。或者说我把这个标准改到你离开谷歌的环境你就跑不了?他也不能干。所以除了发钱,他们啥也干不了。这是谷歌的一个比较有意思的玩法。

第二种方式:开源之后再成立基金会,控制权外移

就是一开始这个项目是公司里边的项目,做一段时间我们把它开源了,然后拿出去。这个里头最典型的一个案例叫 PyTorch,就是现在最火热的运营大模型用的这个工具。这是谁做的?是 Meta 做的。做完了以后就成立了一个基金会,说我们以后把 PyTorch 这个项目就放在这基金会里头运营了,Meta 跟它就没有特别直接的关系了。它的创始人依然在 Meta 上班,上了很多年的班,大概是在去年才从 Meta 离职。现在是加入到了叫 Thinking Machine Lab,就是那个从 OpenAI 离职的那美女 CTO,她创建那公司,加到那去了。

就这种项目,你说为什么?明明我把它做出来了,干嘛要把它交到基金会里去管理?原因也很简单,就是你要去跟其他人竞争。竞争的时候靠你一家又搞不定,你需要大家凑在一块来竞争。谁会愿意说我们出人出力去使用一个 Meta 控制的项目?没有人会愿意干这个事。那他说我们放基金会里,这东西是中立的。PyTorch 最后战胜了谷歌的 TensorFlow,成为现在最流行的、大模型支援的这种架构,就是通过这种开放的方式来搞定的。其他人你说,我们使 TensorFlow 不就完了吗?但是 TensorFlow 是完全谷歌控制的,别人就不愿意用,所以最后 PyTorch 赢了。

一个公共广场上燃烧着一只明亮的火炬(PyTorch),周围围着举着不同公司旗帜的小人,远处一座带有谷歌标志的堡垒里有一个孤独的机器人,羊皮纸,钢笔彩色手绘的统一风格。

第三种方式:直接收购型

就是人家原来是开源的,我把它买下来,我自己来去运营这个项目。但是这种它分两种情况。

  • 第一种:买完后闭源或限制。 我就找人收钱,或者我就想办法让他跟别人不兼容。这种就会翻车。一旦被收购了以后说:“我现在闭源了,或者我现在要收钱了,我对你进行限制了。”原来的开源项目就会进行分叉,我再做一个别的项目,跟你做同样的功能。这样的话其实最终两个项目都不会发展起来,全都做的很惨。



    这个里头比较典型的案例,一个是 Sun 收购了 OpenOffice。Sun 当时收购了很多的这种开源项目,收完了以后说这东西只有我能使,别人不能使了。后来他们就去分叉了,分叉成叫 LibreOffice,但是这两个项目发展的也都不怎么样。还有一个特别典型的案例叫 MySQL,它是被 Oracle 收购了。收购完了以后说:“我们对它进行各种限制,你们以后就少用这玩意,都上我这来买 Oracle 数据库来。”他们后来也是分叉的,一个 m 开头的一个数据库的名字,跟 MySQL 完全兼容的,但是后面我觉得发展的也都不是很好吧。就是你一旦收购回来以后说我要管你了,这就翻车了。
  • 第二种:买完后投入巨资快速迭代。 虽然要管,但是我还是开放的,你们还是可以随便用,而且我投入巨大量的经费,让整个的项目极快的迭代起来。一旦说这个项目快速迭代起来以后,大家就顾不上说你这东西到底是谁家的了,跟都跟不上了。这里头有两个典型的案例:一个叫 安卓,一个叫 Chromium。都是谷歌花钱买回来的,买完了以后就投入巨大的资金,开始快速的迭代。谷歌现在这两个当家的软件,都是这么来的。现在安卓也是开源的,Chromium 这个是开源的,Chrome 是谷歌自己的产品,咱们要分清楚。

大家看到这几家,Meta 其实有点浑浑噩噩的。它其实站在了一个非常非常强的生态位上,它是 PyTorch 开始的这个公司,创始人也一直在 Meta 上班,但是 PyTorch 实际上没有给 Meta 带来任何的帮助,最后人还离职了。就是在前面把这个亚历山大·汪招回来以后,这哥们就走了。Sun 和 Oracle 就属于格局小了,我把这个开源软件买回来以后说,我要把它管起来,不许跟别人兼容了,你们通通都得上我这来交钱来,这就属于格局小了。

而这个谷歌是真正财大气粗的,他支持了非常非常多的项目。在这些项目对于谷歌本身的发展不是那么重要的时候,他就发钱,我也不管你,你就自己玩去,什么时候需要钱,你什么时候来找我要就可以了。我到时候给你发薪水,给你发各种各样的社区活动的钱。就社区里头真正花钱是底下各种的线下活动,包括各种标准制定。谷歌说我就愿意花钱养着你,你们也不用给我回报任何东西。一旦发现里头有这种跟他们的未来发展方向特别息息相关的东西,那马上冲出来,全情投入买下来,快速迭代更新。他是来走这样的一个方式的。一定要广种薄收,就是非常非常多的种子选手在那培养,有那么一两个特别核心的,砸重金进去发展,就有了谷歌的安卓和 Chromium。

OpenAI 这次肯定是赚到了,这样的一个核心产品直接被他也算是收入囊下吧。但是最终的结果还是需要时间检验的。所有跟开源相关的项目,没有说我今天花钱把它买下来,明天就有结果的,除非是像 Oracle 和 Sun 那么干活,就是我一花完钱以后,我马上就去改各种的开源协议,我就限制着别人使用,这种会马上翻车。只要不做这种杀鸡取卵的事情,它未来的效果都是需要很漫长的时间积累,叫日久见人心才能看出来。

OpenAI 为什么不直接收购 OpenClawd?

那下一个问题是,OpenAI 为什么不直接收购 OpenClawd,而是要选择这样的一种很难以控制的方式?

1. 保持中立标准

第一个最重要的原因叫保持中立标准。就跟当时 PyTorch 去战胜 TensorFlow 这个过程是一样的,我是开放的,我是中立的,任何人都可以在这个平台上去干活。比如谷歌说,我也愿意在这个平台上去干活,这个没有任何问题,它不是属于 OpenAI 的,它是属于 OpenClawd 基金会的。再加上中国的一大堆的模型厂商说,我们也愿意上去弄去,给他提供各种支持和服务,提供代码,我们也愿意给钱。这个是 OpenAI 所乐于见到的。

一个圆桌会议,坐着代表不同科技公司和不同国家的代表,圆桌中心是一个发光的开放接口装置,连接着各方的电缆,羊皮纸,钢笔彩色手绘的统一风格。

你要想,一旦他把它收购下来了,你后边跟不跟这些中国厂商合作?比如说像 MiniMax,比如说像 GLM 这种。GLM 专门有 OpenClawd 套餐,GLM 智谱是美国实体清单上的公司;MiniMax 现在还在被一堆的美国的电影公司在那告。那你说干还是不干?包括字节跳动也是专门提供了 OpenClawd 套餐。那你说我现在属于是 OpenAI 的一个项目了,那 OpenClawd 以后还跟不跟这些中国团队合作了?你要想跑得快的话,还是要留着这口子,你要继续跟中国团队合作。那你要收进去了以后,OpenAI 的原则是我不跟中国人做生意,特别是不能跟这种在实体清单里的公司做生意,那这事就没法整了。所以他必须要保持开放和中立这样的一个位置。

2. 架构与责任归属

第二个原因是 OpenClawd 本身的架构还有很多问题,也有很多的这种不完善的地方。你一旦把它收进来,那么所有这些问题的话,你就要承担责任。你比如说过两天谁用了 OpenClawd 说:“我这个数据丢了,我这造成什么经济损失了。”你 OpenAI 赔不赔?这个跟我没关系,它是 OpenClawd 基金会的,我们只是把人拎回来发工资了,它不用赔。这个是很重要的一点。

3. 安全性与合规风险

第三点是什么?OpenClawd 本身的安全性有待提升,而且很多的黑灰产的用户在使用 OpenClawd 做事情,就是做一些不是那么正规的事情,不是那么好的事情,或者拿出去做诈骗了,都是有的。OpenAI 肯定也是不愿意承担相应的法律责任的。你们接着该干嘛干嘛去,跟我没关系。

OpenAI 未来也并不一定会推出基于 OpenClawd 的产品。一旦说我们准备推出 OpenClawd 产品了,那他可能就会选择像谷歌处理安卓和 Chrome 那样的方式,我直接把它买下来,然后完全控制。这是 OpenAI 的一个选择。但是如果说我以后的产品形态可能是把一个类似功能的服务放到 ChatGPT 的客户端或者是 Codex 客户端里头,那就没有必要说再去跟 OpenClawd 这个东西较真了,没必要费这个劲了。他只需要说我们把这个 Peter Thielberg 拎回来说,你就给我们做这个个人代理的负责人,你来去指挥说我们以后要往哪个方向走就可以了。这不就是挺好的事情吗?

OpenAI 的实际收益

但即使如此,OpenAI 拥有了 Peter Stinebrink 之后,他依然是可以做很多事情的。比如说各种的联盟的建立,我们要去组织各种各样的这种 OpenClawd 联盟,或者 OpenClawd 的这种线下会议。现在各个地方都在开 OpenClawd 线下会,就是我们拿这东西到底干什么了。

然后主导 OpenClawd 标准。我们以后是不是只支持 OpenAI 标准的大模型?中国的所有这些开源模型都是走 OpenAI 标准接口的。在 Claude Code 火起来之前,咱们都从来不去兼容 Anthropic 接口。但是现在我们很多的模型公司都跑去兼容 Anthropic 接口去了。那么以后 OpenAI 说我要出一些什么新的标准、什么样新的接口,可能 OpenClawd 就会第一个站出来支持。其他人说我想去内卷一下,我想去比赛谁兼容最新的标准,就都会去跟着 OpenAI 的路子去走。这是 OpenAI 真正想要得到的东西。

还有一个 OpenAI 想得到的东西,他们现在在各种新闻报道里没有写,但是是必然可以得到的是什么?就是在极限的这种 AI 编程之中,Codex 要去战胜 Claude Code。原来 OpenClawd 里边大量的代码是使用 Claude Code 去写的,但是现在它的最核心的创始人 Peter Steinberg 上 OpenAI 上班去了。那你说我不能继续使用 Claude Code 吗?不行,因为把 OpenAI 员工的账号都给封了,你不能用了。所以你想以后再继续去维护 OpenClawd 代码,你就只能用 Codex 了,你就不能再去用 Claude Code 了。以后其他人说我们想继续去在这个 OpenClawd 代码库上再去做各种各样的工作的话,对不起,你们也要用 Codex。在这一点上 Codex 又胜出一局。这就是 OpenAI 为什么不去直接收购 OpenClawd,以及 OpenAI 从这一次交易里头到底能够得到什么。

复古电脑屏幕上显示着复杂的代码战役,代表Codex的盾牌击碎了代表Claude的剑,背景是流动的二进制数字,羊皮纸,钢笔彩色手绘的统一风格。

最后总结一下吧

Peter Stinebrg 加入了 OpenAI,也算是尘埃落定了。他最后没有选择 Meta,而是加入了 OpenAI。这是一种更先进的开源协作方式,更有利于不同的公司之间,甚至是不同的地缘政治与法律架构之间,在统一的标准下进行协作,推进技术和推进技术的发展。

OpenAI 这一次肯定是赚大了,花了很少的钱就得到了未来的一个制定标准的机会。但是这一次交易的结果还是需要时间检验的。这种开源策略很难在短时间内看到成效。

好,这就是咱们今天讲的故事。不要再出去说 OpenAI 收购了 OpenClawd,OpenClawd 变成 CloseClawd 了,这个属于外行说的话,开源圈里内行会告诉你事不是这样的。

这个故事今天就讲到这里,感谢大家收听,请帮忙点赞、点小铃铛,参加 DISCORD 讨论群,也欢迎有兴趣有能力的朋友加入我们的付费频道。再见。


背景图片

Prompt:in the style of Moebius (Jean Giraud), Franco-Belgian ligne claire illustration, hand-drawn ink linework with watercolor gouache textures, ultra-maximalist interior storytelling, an unoccupied high-rise family computer studio in Beijing’s bustling metropolis, modern Chinese home aesthetics with wood lattice shelving, ink-scroll accents, porcelain decor, dual-monitor desk setup, gaming console dock, retro game devices, hi-fi speakers, mechanical keyboard, headphones, layered cables and gadgets, Lunar New Year decorations in every corner with red lanterns spring couplets paper-cuts Chinese knots and festive ornaments, floor-to-ceiling window with glowing city skyline, 24mm wide environmental interior shot, eye-level, dense yet readable composition, warm tungsten ambient light mixed with subtle RGB tech glow, cozy lived-in atmosphere with strong futuristic vibe –no people, person, human, face, body, text, watermark, logo, sterile showroom, lowres blur, photoreal CGI texture –ar 16:9 –stylize 180 –chaos 8 –v 7.0 –p lh4so59

🔲 ☆

Pytorch转ONNX报错-Cannot insert a Tensor that requires grad as a constant

pytorch模型转换onnx的时候,遇到了下面的报错信息:

1
RuntimeError: Cannot insert a Tensor that requires grad as a constant. Consider making it a parameter or input, or detaching the gradient 

翻译过来就是不能将一个需要梯度的tensor转换为constant。

定位到报错的层,是一个Conv2D,看起来是它对应的weight设置了requires_grad为True。本以为直接修改requires_grad = False 就可以了,但比较诡异的是,实际试下来并不行。

具体来说,尝试了下面的方案,都不work:

  • 给forward 函数增加torch.inference_mode() 装饰符
  • 在报错的层前面加torch.no_grad() context
  • 给输入增加.detach() 函数,去掉梯度
  • 给层对应的weight设置requires_grad 为False

最后还是在网上发现了解决方案,尝试之后是work的。具体来说,就是将对应模型的所有层的参数都设置为requires_grad = False:

1
2
for param in model.parameters():
param.requires_grad = False

大功告成。

🔲 ⭐

[译] Understanding Incremental Decoding in fairseq

近来一直在使用 fairseq 做项目,因为其功能较多而源码也比较复杂,光靠官方文档也难以完全理解。ankur6ue 的一篇文章对 fairseq 中的增量解码(Incremental Decoding)操作做了详尽的介绍,于是我节选了其中的一部分,将其译为中文,希望对和我一样在读源码的朋友有所帮助。

推理过程中的增量解码

在语言翻译任务的推理过程,解码器逐步地输出目标语言词汇的概率分布。最简单的翻译算法通过贪心策略直接选择概率最高的目标词,这个方法和训练过程中计算损失函数的方式一致。另一种方法则是保存所有可能的目标序列,再从中选出最小化对数似然的的结果。但这种方法需要基于似然搜索所有的可能序列,同时由于词表大小通常在数百上千的规模,导致计算开销随着序列长度指数上升。

集束搜索(Beam Search)在两种极端策略间取得了平衡,由于网络上已经有许多非常好的教程,本文不在此展开介绍集束搜索的工作原理。因为集束搜索在每个步骤只考虑 \(B\) 个前缀序列,搜索空间就由 \(V\times V\) 下降至 \(B\times V\)(其中 \(B\) 为集束宽度,\(V\) 为目标词表的大小),所以相比暴力搜索的方法显著高效。解码结果缺乏多样性是集束搜索的一项缺陷,因为一条输入序列可能具有多条正确翻译,这项缺陷就会影响翻译任务。针对该问题也提出了许多解决方法,例如 Diverse Beam Search 在标准的集束分数中添加了一个差异项,通过对先前步骤已使用过的词施加惩罚并使用 top-k 随机取样在下一步的生成中随机选出前 k 个最有可能的备选项(代替了集束搜索中永远选择前 \(B\) 个备选项的取样方式),从而产生更多样的结果。

尽管集束搜索比蛮力搜索更高效,但由于每个步骤都要重新计算所有前缀 token(prefix tokens),其计算开销会随着解码序列长度的增加而线性增长。

n

在这个例子中,用 A、B、C 等字母表示 token,在推理时,集束会扩展成由翻译出句子所构成的 batch。如果输入 batch 由 2 个句子构成,同时将集束宽度设置为 3,最终得到 batch 的大小就为 6。在计算过程中,每个集束都作为 batch 中的元素并行计算。

当模型完成前面一部分的 token 的解码计算后,我们就会思考:是否可以重复利用这些计算结果?其实增量解码正是这个思路的实现。增量解码使用名为增量状态(incremental state)的数据结构保存先前计算结果,用于后续的卷积计算。在每个计算步骤中,解码器只需对当前 token 做计算,若是模型中的某些层需要先前 token 的信息(例如卷积层),则从增量状态中取出所需结果。而在编码器的计算过程中,编码器与解码出的目标序列无关,它只在一开始时计算输入序列并产生每个输入字词的编码,这些编码本就会被解码器重复使用。

增量解码如何节省计算开销?

增量解码的具体实现稍有些复杂,希望下图能够帮助读者更好地理解整个过程。我推荐读者尝试使用 Python Debugger 在以下代码中设置断点,相信能够更容易理解每一步所做的操作。

n

{caption}在第(1)步中,input_buffer 中每个卷积模块对应的值都是 None,其内存大小由 beam_size (3)、conv_kernel_width (3) 和 conv_kernel_input_dimension (512) 分配,初始化为 0。{end caption}

我们假设输入 1 条句子,那么 batch 的大小与集束宽度相等(该样例中为 3)。首先,每个集束都由开始标记构成(BOS),因而输入卷积层的嵌入向量相等。

n

{caption}在完成创建和初始化后,input_buffer 如上图所示{end caption}

然后 input_buffer 左移 1 个位置并将输入添加到最后一列。由于 input_buffer 全由 0 填充,左移后并没有明显的变化,不过我们在下一步就会看到它的作用。

n

{caption}在最右侧填入输入列后的 incremental_state 如上图所示,因为在第(1)步中的所有输入 token 都是 BOS token,填入每个集束的输入向量也都相同。{end caption}

接着,输入数据与卷积滤波器做计算,将计算结果传递给后续层——即 GLU 和注意力层。

n

现在我们考虑下一个线性卷积层模块,进入该层的输入来源于前一个模块。正如前一个部分中的卷积层,该卷积层也有自己的增量状态,同样由 0 初始化并填入输入数据。

n

n

和先前一样,输入数据与卷积核做计算并传入 GLU 和注意力层,解码器中的所有结构都重复这一过程,最后的输出就为每个集束的词表概率分布向量。集束搜索算法从该结果中得到最优的 token,用于下一步的计算,在这里用 A、B、C 表示解码结果。

接下来我们考虑在步骤(2)所做的操作,我们目前的集束为

n

再一次重复第一层的卷积操作,由于每个集束的输入 token 不同(A、B 和 C),其嵌入向量也不再相等。此外,由于步骤(1)中的初始化过程,input_buffer 也不再全为 0。

n

接着,input_buffer 左移并将新的输入添加至最后一列。

n

每个集束的 input_buffer 与卷积核做计算后传入到后续层中,与先前步骤中相同。

n

整个过程中,input_buffer 作为内存记录先前步骤给出的结果,用于计算卷积结果。input_buffer 同时节省了计算开销,这一点可以在下一个卷积操作中看出。

n

由于 input_buffer 中保存了先前步骤的输入,在当前步骤中可以直接用于完成卷积计算。最重要的是,先前步骤的输入是再前一个步骤的计算结果,保存的计算结果就避免了解码过程中的重复计算,从而节省计算开销。后续过程与前文所述一样,左移并填入输入,完成卷积计算。

n

为什么需要重排增量状态?

在每一步开始前,generator 都会重新排列解码器和编码器的 incremental_state

n

这是由于集束搜索会导致每个集束中前缀 token 的顺序发生改变,通过一个简单的样例描述这个过程。假设下图是步骤(2)得到的集束状态,其中展示了每个步骤得到的 token 和预测分数。

n

到了步骤(3)时,通过预测分数得到 N、P 和 S,箭头表示了每个结果 token 的来源。

n

(译者案:作者在这里描述得不是很清晰,需要额外补充一些说明。集束搜索过程如下图示意,其主要操作是在每一步中只取 top-k 个预测分数最高的结果作为下一步的前缀 token,其他分支中止不再计算。在该例中,输入 BOS token 后,在若干结果中取 top-3 分数最高的结果,分别为 A、B 和 C。那么下一步的输入就为 [BOS A]、[BOS B] 和 [BOS C],再取 top-3 分数最高的结果。由 A 生成的结果分数无法位于 top-3,A token 所属的分支就被中止,后续不会再计算,在 buffer 中存储其状态也是无用的了,因此要将其替换为有效的前缀 token。)

n

于是就如下图所示,重新排列每个集束。

n

当对当前 token N、P 和 S 执行解码操作(预测下一个 token)时,我们必须重排 incremental_state 使卷积操作能够使用正确的前缀 token。这个操作可能不能马上明白,需要花些时间仔细理解。

另外还有一点,fairseq 的代码也重新排列了编码器的状态,然而由于编码器状态只取决于输入 token,并不会随集束状态改变,其重排也就不是必需的,至少在本文的例子中不需要这样的操作。

为什么集束搜索返回的 token 数量是集束数量的两倍?

在 fairseq 中,集束搜索返回输出 token 的数量是集束数量的两倍。这是由于集束搜索中的部分集束可能会返回表示句子结束的 EOS token,而我们不想要集束搜索太早就停止。当 EOS token 出现在结果的前半部分时,可以将预测总分与其他已有结果的分数相比较从而完成句子。下图展示了相关代码并附上了一些注释,希望能有助读者理解。

n

{caption}(a)返回表示集束中具有 EOS token 的掩码;(b)具有 EOS token 集束对应的索引,只有在 EOS 出现在前半部分的情况下(:beam_size)。注意集束搜索返回 2 * beam_size 个结果;(c)对于前 beam_size 个具有 EOS 的集束,组合预测结果并判断是否完成句子。如果是,减少剩余句子的数量,注意我们处理的是一整个 batch 的输入句子;(d)如果剩余句子的数量是 0,完成;(e)如果能够完成一整个 batch 的目标句子,从 batch 中移除元素并调整 batch 索引。{end caption}

🔲 ☆

python环境配置

python相关环境配置:Miniconda,PyTroch,Jupyter, venv。

Miniconda

Miniconda是一个轻量级的Conda包管理器

安装包

所有安装包:repo.anaconda.com/miniconda

Linux: python3.8 Miniconda3 Linux 64-bit

python3.7 Miniconda3 Linux 32-bit

1
2
# 以前的版本没有ssh问题
wget https://repo.anaconda.com/miniconda/Miniconda3-4.7.12.1-Linux-x86_64.sh

配置

初始化终端:

1
~/miniconda3/bin/conda init

如果失败可以手动添加bin目录。

1
echo "export PATH=\$PATH:/home/pi/miniconda3/bin" >> .bashrc

使用

新建环境

1
2
3
4
5
# 新建名称为test的环境
conda create --name test python=3.8 -y

# arm只能创建python=3.4的环境
conda create -n test python=3.4 -y

查看所有的环境

1
conda env list

使用环境

1
2
3
4
5
6
7
8
9
10
# 激活环境
conda activate test
source activate test # arm

# 退出当前环境
conda deactivate
source deactivate # arm

# 删除环境
conda remove -n test1 --all

rpi

armv7l: miniconda3-latest-linux-armv7l.sh

Berryconda3-2.0.0-Linux-armv7l.sh

arm版本的miniconda最高只能安装python3.4,如果需要安装更高版本的python,需要第三方conda,这里使用的是 berryconda,目前最高支持到python3.6

1
2
3
conda config --add channels rpi

conda create --name test python=3.6 -y

venv

python自带的虚拟环境模块

1
2
3
4
5
6
7
8
# 新建
python -m venv /path/venv_name

# 激活
source /path/venv_name/bin/activate

# 退出
deactivate

https://www.python.org/ftp/python/3.8.5/Python-3.8.5.tgz

Pytorch

old version lib torch: https://github.com/pytorch/pytorch/issues/40961

Pytorch,选择对应cuda版本。

1
2
3
4
5
torch.version # PyTorch version
torch.cuda.is_available()
torch.version.cuda # Corresponding CUDA version
torch.backends.cudnn.version() # Corresponding cuDNN version
torch.cuda.get_device_name(0) # GPU type

build

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# install pytorch and dependences
git clone --depth 1 --recurse-submodule https://github.com/pytorch/pytorch.git
conda create -y --name pytorch-build python=3.8
conda activate pytorch-build
conda install -y astunparse numpy ninja pyyaml mkl mkl-include setuptools cmake cffi typing_extensions future six requests dataclasses pkg-config libuv

# arm64
mkdir pytorch-build-arm64
cd pytorch-build-arm64
cmake -DBUILD_SHARED_LIBS:BOOL=ON -DCMAKE_OSX_ARCHITECTURES=arm64 DCMAKE_OSX_DEPLOYMENT_TARGET=12.10 -DUSE_MKLDNN=OFF -DUSE_QNNPACK=OFF -DUSE_PYTORCH_QNNPACK=OFF -DBUILD_TEST=OFF -DUSE_NNPACK=OFF -DCMAKE_BUILD_TYPE:STRING=Release -DPYTHON_EXECUTABLE:PATH=`which python3` -DCMAKE_INSTALL_PREFIX:PATH=../pytorch-install-arm64 ../pytorch
cmake --build . --target install
cd ..

# x86_64
mkdir pytorch-build-x86_64
cd pytorch-build-x86_64
cmake -DBUILD_SHARED_LIBS:BOOL=ON -DCMAKE_OSX_ARCHITECTURES=x86_64 -DCMAKE_OSX_DEPLOYMENT_TARGET=12.10 -DUSE_MKLDNN=OFF -DUSE_QNNPACK=OFF -DUSE_PYTORCH_QNNPACK=OFF -DBUILD_TEST=OFF -DUSE_NNPACK=OFF -DCMAKE_BUILD_TYPE:STRING=Release -DPYTHON_EXECUTABLE:PATH=`which python3` -DCMAKE_INSTALL_PREFIX:PATH=../pytorch-install-x86_64 ../pytorch
cmake --build . --target install
cd ..

test

1
2
3
4
5
6
7
8
9
10
11
12
# CMakeLists.txt
cmake_minimum_required(VERSION 3.0 FATAL_ERROR)
project(example-app)

set(Torch_DIR /Users/sanzo/Software/pytorch-install-arm64)
set(CMAKE_PREFIX_PATH "/Users/sanzo/Software/pytorch-install-arm64/share/cmake/Torch")
find_package(Torch REQUIRED)
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} ${TORCH_CXX_FLAGS}")

add_executable(example-app example-app.cpp)
target_link_libraries(example-app "${TORCH_LIBRARIES}")
set_property(TARGET example-app PROPERTY CXX_STANDARD 14)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <torch/torch.h>
#include <iostream>

int main() {
std::cout << "PyTorch version: "
<< TORCH_VERSION_MAJOR << "."
<< TORCH_VERSION_MINOR << "."
<< TORCH_VERSION_PATCH << std::endl;
torch::Tensor tensor = torch::rand({2, 3});
std::cout << tensor << std::endl;
}

/*
0.0598 0.7058 0.0322
0.2230 0.4112 0.9342
[ CPUFloatType{2,3} ]
*/

references

old version lib torch: https://github.com/pytorch/pytorch/issues/40961

https://dev-discuss.pytorch.org/t/universal-binaries-for-libtorch-mac/229

https://github.com/pytorch/pytorch/issues/63558

jupyter notebook

ipykernel

通过ipykernel管理jupyter notebook的内核。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 激活环境
activate env_name

# 安装ipykernel
pip install ipykernel

# 添加kernel
python -m ipykernel install --name env_name

# 删除内核
jupyter kernelspec remove kernelname

# 查看所有内核
jupyter kernelspec list

远程访问

生成密钥

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 通过ipython生成密码
ipython

In [1]: from notebook.auth import passwd
In [2]: passwd()
Enter password:
Verify password:
Out[3]: 'xxxxxxxxxxxxxxxxxxxxxx'

# 生成配置文件,并添加如下配置
jupyter notebook --generate-config

c.NotebookApp.ip='0.0.0.0'
c.NotebookApp.password = u'xxxxxxxxxxxxxxxxxxxxxx'
c.NotebookApp.open_browser = False
c.NotebookApp.port =8888

然后就可以通过https://ip:8888远程访问jupyter notebook

🔲 ☆

FastViT 论文阅读

1. 概述

论文地址:arxiv
代码地址:ml-fastvit

FastViT 是苹果公司在 ICCV 2023上发表的网络结构设计的论文,在速度和精度上取得比较好的折衷,速度上既能和MobileOne这种轻量级网络匹敌,精度上也不输PoolFormer、ConvNeXt等比较新的大网络结构。

这是网络整体的结构图:

整体还是分成Stem和4个Stage,以及最后的输出Head。可以看到所有结构都在推理时进行了重参数化,保证只有一个网络分支。虽然叫ViT,但网络的核心还是由Conv层组成。

整个网络的的大部分模块是以MobileOne 的核心 MobileOneBlock 打底的,所以说是 MobileOne V2 也不为过。

比较有意思的是,FastVit 这篇论文的作者列表、作者顺序都和 MobileOne 一模一样!

所以可以说,FastViT 是 MobileOne 框架的延续,核心是在推理的时候保证只有一条网络分支,提升网络的推理速度。

具体来说,为了提升效果,网络设计上参考了比较新的 ConvMixer 结构。然后为了保证能够重参数化,将其中的非线性层省略掉,去掉残差模块。为了缓解 Self-Attention 模块计算量太大的问题,在浅层特征图比较大的情况下,采用 Large Kernel,也就是7x7 Kernel Size 的Conv网络。

下面依次对网络的几个核心模块进行说明。

2. RepMixer

ConvMixer 提出了用Conv网络替代ViT网络的方法,在效果上超越了ViT方法。

已有的一些方法已经验证,Skip-Connection因为会有额外的内存访问开销,因此会显著增加网络延迟,如果能合并Skip-Connection,对于网络的加速会有很帮助。注意论文中的Skip-Connection其实指的是类似残差模块中的两个分支相加的操作(如下图),而不是更常见的Encoder和Decoder之间的跳层连接。

FastViT利用了 ConvMixer 网络结构优异的性能,同时为了能够在推理时进行重参数化,对 ConvMixer 进行了几个修改:

  1. 去掉非线性层,否则没法进行重参数化
  2. 将BN放在DepthWiseConv之前
  3. 在推理时合并 Skip-Connection,用来加速推理。

具体代码实现时,训练时采用了2个MobileOneBlock,分别表示mixer和normal,与原始输入x相加;推理的时候去掉残差相加,直接转换为一个MobileOne模块:

3. 训练时过参数化

过参数化是指训练的时候将结构相同的网络模块重复多遍,通过增加模型的复杂度来提点。在推理的时候,再通过重参数化trick将多个分支的结构合并到一个分支来提速。下面是过参数化的示意图(图片来自这里):

MobileOne 论文中就采用了过参数模块,验证可以提高网络的学习能力。

在这篇论文中,为了提速,先是将普通的 KxK 的Conv修改为DepthWise KxK 的 Conv + 1x1 PointWise 的 Conv层,发现在提速后精度下降,例如论文中 Table 1 所示,这步修改后耗时从 1.58ms 下降到 1.26ms,但精度也从78.5下降到78.0:

为了弥补这一步造成的精度损失,作者叠加了上面提到的训练时重参数化的trick,保证速度不变的情况下,效果超过了之前的方法,从78.0上升到78.9。

当然这部分的结构优化其实比较”水”,是现有的两个工作的简单组合……

4. Large Kernel

由于Transformer结构的核心模块是Self-Attention模块,而且已经被无数实验验证具有强大的特征提取能力。
但Self-Attention的计算量很大,要做到手机上实时难度不小。

作者认为,Self-Attention 效果好跟它有很大的感受野有关系。而普通 Conv 层通过增加 Kernel. Size,也能达到提高感受野的效果。

因此最终网络结构设计上,在每个Stage开始的时候,采用 7x7 的 MobileOneBlock。7x7 的 Kernel Size 也是通过实验试出来的。

为了既能跟MobileOne这种轻量级网络对比,又能在 ImageNet 上和别的模型一较高下,论文中提出了7个 Fast-ViT的变种,各个变种的设置如下:

5. 实验

对比实验在 ImageNet-1K 分类任务、COCO 物体检测,ADE20K 语义分割等标准任务上进行了对比




另外这篇论文还比较了FastVit在3D手重建这个下游任务上的效果,也是比MobRecon这些端侧实时的方法效果更好,当然还是刷不过MeshGraphormer等基于HRNet Backbone的模型。

6. 总结

整个论文是比较实用的,没有太多自己的原创性的点子,更多的是将一些现有的网络结构设计思想融合进MobileOne的推理时单分支的网络结构中来。

另外一个值得注意的事情是,论文中给出的Mobile Latency都很低,像 FastVit-MA36 7.9G 的FLOPS,移动端延迟4.5毫秒。但要明白这是用iPhone 12 Pro Max上使用CoreML来测试的,本身iPhone 12 Pro Max 采用的A14芯片很强,而且CoreML针对苹果的硬件有专门的优化,所以在安卓机器或者低端一些的iPhone 上,采用别的推理引擎(如ONNX, MNN, TCNN)进行推理时,很有可能达不到这么高的速度,所以像 FastVit-MA36这种FLOPS 约为8G的模型在手机上用起来还是需要验证的。

总之对于想试用 FastViT 的小伙伴来说,用就完了,代码已经开源,也不存在复现的问题,直接用起来,好用就加入到自己的任务中,效果比较差或者速度有瓶颈抛弃即可。

另外 FastViT 的代码实现很简洁优雅,阅读起来很舒服,后面有空可以写一篇代码阅读的文章,欢迎感兴趣的小伙伴关注、点赞和评论区留言~

🔲 ☆

libtorch系列教程3:优雅地训练MNIST分类模型

在这篇文章中,我们对如何使用Libtorch进行MNIST分类模型的训练和测试进行详细描述。首先会浏览官方MNIST示例,然后对其进行模块化重构,为后续别的模型的训练提供 codebase。

由于Libtorch中包含很多和Pytorch中没有的类型,所以看Libtorch代码的时候时常会遇到不了解的函数或者类,这时候可以在这里查找对应的类的实现,了解其作用。Libtorch C++ 代码中的注释虽然不多但基本够用了。

这里列举一些常见的类的代码路径,方便查询:

  • 1. 官方MNIST示例

    Libtorch官方的训练代码仓库在这里,拿里面的训练MNIST为例,代码如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    139
    140
    141
    142
    143
    144
    145
    146
    147
    148
    149
    150
    151
    152
    153
    154
    #include <torch/torch.h>

    #include <cstddef>
    #include <cstdio>
    #include <iostream>
    #include <string>
    #include <vector>

    // Where to find the MNIST dataset.
    const char* kDataRoot = "./data";

    // The batch size for training.
    const int64_t kTrainBatchSize = 64;

    // The batch size for testing.
    const int64_t kTestBatchSize = 1000;

    // The number of epochs to train.
    const int64_t kNumberOfEpochs = 10;

    // After how many batches to log a new update with the loss value.
    const int64_t kLogInterval = 10;

    struct Net : torch::nn::Module {
    Net()
    : conv1(torch::nn::Conv2dOptions(1, 10, /*kernel_size=*/5)),
    conv2(torch::nn::Conv2dOptions(10, 20, /*kernel_size=*/5)),
    fc1(320, 50),
    fc2(50, 10) {
    register_module("conv1", conv1);
    register_module("conv2", conv2);
    register_module("conv2_drop", conv2_drop);
    register_module("fc1", fc1);
    register_module("fc2", fc2);
    }

    torch::Tensor forward(torch::Tensor x) {
    x = torch::relu(torch::max_pool2d(conv1->forward(x), 2));
    x = torch::relu(
    torch::max_pool2d(conv2_drop->forward(conv2->forward(x)), 2));
    x = x.view({-1, 320});
    x = torch::relu(fc1->forward(x));
    x = torch::dropout(x, /*p=*/0.5, /*training=*/is_training());
    x = fc2->forward(x);
    return torch::log_softmax(x, /*dim=*/1);
    }

    torch::nn::Conv2d conv1;
    torch::nn::Conv2d conv2;
    torch::nn::Dropout2d conv2_drop;
    torch::nn::Linear fc1;
    torch::nn::Linear fc2;
    };

    template <typename DataLoader>
    void train(
    size_t epoch,
    Net& model,
    torch::Device device,
    DataLoader& data_loader,
    torch::optim::Optimizer& optimizer,
    size_t dataset_size) {
    model.train();
    size_t batch_idx = 0;
    for (auto& batch : data_loader) {
    auto data = batch.data.to(device), targets = batch.target.to(device);
    optimizer.zero_grad();
    auto output = model.forward(data);
    auto loss = torch::nll_loss(output, targets);
    AT_ASSERT(!std::isnan(loss.template item<float>()));
    loss.backward();
    optimizer.step();

    if (batch_idx++ % kLogInterval == 0) {
    std::printf(
    "\rTrain Epoch: %ld [%5ld/%5ld] Loss: %.4f",
    epoch,
    batch_idx * batch.data.size(0),
    dataset_size,
    loss.template item<float>());
    }
    }
    }

    template <typename DataLoader>
    void test(
    Net& model,
    torch::Device device,
    DataLoader& data_loader,
    size_t dataset_size) {
    torch::NoGradGuard no_grad;
    model.eval();
    double test_loss = 0;
    int32_t correct = 0;
    for (const auto& batch : data_loader) {
    auto data = batch.data.to(device), targets = batch.target.to(device);
    auto output = model.forward(data);
    test_loss += torch::nll_loss(
    output,
    targets,
    /*weight=*/{},
    torch::Reduction::Sum)
    .template item<float>();
    auto pred = output.argmax(1);
    correct += pred.eq(targets).sum().template item<int64_t>();
    }

    test_loss /= dataset_size;
    std::printf(
    "\nTest set: Average loss: %.4f | Accuracy: %.3f\n",
    test_loss,
    static_cast<double>(correct) / dataset_size);
    }

    auto main() -> int {
    torch::manual_seed(1);

    torch::DeviceType device_type;
    if (torch::cuda::is_available()) {
    std::cout << "CUDA available! Training on GPU." << std::endl;
    device_type = torch::kCUDA;
    } else {
    std::cout << "Training on CPU." << std::endl;
    device_type = torch::kCPU;
    }
    torch::Device device(device_type);

    Net model;
    model.to(device);

    auto train_dataset = torch::data::datasets::MNIST(kDataRoot)
    .map(torch::data::transforms::Normalize<>(0.1307, 0.3081))
    .map(torch::data::transforms::Stack<>());
    const size_t train_dataset_size = train_dataset.size().value();
    auto train_loader =
    torch::data::make_data_loader<torch::data::samplers::SequentialSampler>(
    std::move(train_dataset), kTrainBatchSize);

    auto test_dataset = torch::data::datasets::MNIST(
    kDataRoot, torch::data::datasets::MNIST::Mode::kTest)
    .map(torch::data::transforms::Normalize<>(0.1307, 0.3081))
    .map(torch::data::transforms::Stack<>());
    const size_t test_dataset_size = test_dataset.size().value();
    auto test_loader =
    torch::data::make_data_loader(std::move(test_dataset), kTestBatchSize);

    torch::optim::SGD optimizer(
    model.parameters(), torch::optim::SGDOptions(0.01).momentum(0.5));

    for (size_t epoch = 1; epoch <= kNumberOfEpochs; ++epoch) {
    train(epoch, model, device, *train_loader, optimizer, train_dataset_size);
    test(model, device, *test_loader, test_dataset_size);
    }
    }

代码具体细节可以先不用理解,后文有一些说明。可以看到所有的模型搭建、数据读取、网络训练和测试代码都混在一个文件里面,别的几个例子里面也是类似的写法。

这样写当然是可以的,但对于习惯了Pytorch训练的我们来说,这样所有的代码在一个文件中的写法很不易读,
修改数据和网络都相互有影响,且不利用真正严肃地模型训练迭代。

2. 重构 MNIST 示例代码

所以一个简单的想法是改进写法,将DataLoader, Model 和训练逻辑拆分出来,分别进行模块化,放到单独的文件中处理。

2.1 简单拆分的问题

第一次尝试是将Dataset和DataLoader放到一个模块中,网络定义放到一个模块中,训练和测试代码放到一个模块中。
但这样拆分遇到很大问题,核心原因是 Libtorch 的DataLoader类别太复杂了,对于我这种C++了解不深入的人来说改造难度太大。

举个例子,我们对MNIST Dataset类进行Normalize后Stack,然后构造一个DataLoader对象train_loader,代码如下:

1
2
3
4
5
auto train_dataset = torch::data::datasets::MNIST(data_root)
.map(torch::data::transforms::Normalize<>(0.1307, 0.3081))
.map(torch::data::transforms::Stack<>());
auto train_loader =
torch::data::make_data_loader<torch::data::samplers::SequentialSampler>(std::move(train_dataset), 64);

生成的train_loader对象的类型是:

1
torch::disable_if_t<MapDataset<MapDataset<MNIST, Normalize<>>, Stack<>>::is_stateful || !std::is_constructible<SequentialSampler, size_t>::value, std::unique_ptr<StatelessDataLoader<MapDataset<MapDataset<MNIST, Normalize<>>, Stack<>>, SequentialSampler>>>

这个类型太复杂了……

因为官方示例是所有代码在一个文件,因此可以通过auto 来让编译器自动判定类型,省去了写着一长串类型的问题。

但如果我们要拆分DataLoader到单独的类里面的话,就没法使用auto,需要显式的指出DataLoader的类型,然而即使是这样一长串的类型写上了,还是会有不知道是哪里的问题,导致编译报错。

当然也有可能有简单的方法来解决这个问题,欢迎C++高手讨论指导。

这次体验让我真正体会到了动态类型语言的简洁性,以及Python的所有类型转C++会存在哪些坑。

2.2 一种比较简单的重构方案

最后给出了一个妥协的方案:DataSet在单独的类中定义里面,而DataLoader在训练逻辑中构造,避免繁琐的类型问题。

整体代码结构如下:

1
2
3
4
5
6
7
8
├── CMakeLists.txt # CMake配置文件
├── main.cpp # 主入口
├── my_dataset.cpp # 数据集实现
├── my_dataset.h
├── my_model.cpp # 模型定义
├── my_model.h
├── my_trainer.cpp # 训练和测试脚手架代码
└── my_trainer.h
2.2.1 CMake 配置文件

CMake 配置文件CMakeLists.txt中将几个实现文件加入到编译依赖即可,别的部分与前两篇文章中的类似。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
cmake_minimum_required(VERSION 3.0 FATAL_ERROR)
project(mnist_train)

# 需要找到Libtorch
find_package(Torch REQUIRED)

set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} ${TORCH_CXX_FLAGS}")

add_executable(${PROJECT_NAME} main.cpp my_model.cpp my_dataset.cpp my_trainer.cpp)
target_link_libraries(${PROJECT_NAME} "${TORCH_LIBRARIES}")

# Libtorch是基于C++14来实现的
set_property(TARGET ${PROJECT_NAME} PROPERTY CXX_STANDARD 14)

2.2.2 主入口文件定义

主入口文件实现了超参数设置,网络和数据集初始化,以及调用Trainer进行训练和测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
#include <string>

#include <torch/torch.h>

#include "my_dataset.h"
#include "my_model.h"
#include "my_trainer.h"

int main() {
// 超参数设置
std::string data_root = "./data";
int train_batch_size = 128;
int test_batch_size = 1000;
int total_epoch_num = 30;
int log_interval = 10;
int num_workers = 32;

// 设置随机数种子
torch::manual_seed(1);

// 获取设备类型
torch::DeviceType device_type = torch::kCPU;
if (torch::cuda::is_available()) {
device_type = torch::kCUDA;
}
torch::Device device(device_type);

// 构造网络
MyModel model;
model.to(device);

// 设置优化器
torch::optim::SGD optimizer(
model.parameters(), torch::optim::SGDOptions(0.01).momentum(0.5));

// 构造训练和测试dataset
auto train_dataset =
MyDataset(data_root, torch::data::datasets::MNIST::Mode::kTrain);
auto test_dataset =
MyDataset(data_root, torch::data::datasets::MNIST::Mode::kTest);

// Trainer初始化
auto trainer = MyTrainer(log_interval);
for (size_t epoch = 1; epoch < total_epoch_num; ++epoch) {
// 运行训练
trainer.train(
epoch,
model,
optimizer,
device,
train_dataset,
train_batch_size,
num_workers);

// 运行测试
trainer.test(model, device, test_dataset, test_batch_size, num_workers);
}
}
2.2.3 网络定义

网络结构采用简单的LeNet,两个conv层和2个fc层。
头文件 my_model.h 内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#pragma once
#include <torch/torch.h>

class MyModel : public torch::nn::Module {
public:
MyModel();
torch::Tensor forward(torch::Tensor x);

private:
torch::nn::Conv2d conv1 = nullptr;
torch::nn::Conv2d conv2 = nullptr;
torch::nn::Dropout2d conv2_drop;
torch::nn::Linear fc1 = nullptr;
torch::nn::Linear fc2 = nullptr;
};

实现文件 my_model.cpp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include "my_model.h"

MyModel::MyModel() {
conv1 = torch::nn::Conv2d(torch::nn::Conv2dOptions(1, 10, 5));
conv2 = torch::nn::Conv2d(torch::nn::Conv2dOptions(10, 20, 5));
fc1 = torch::nn::Linear(320, 50);
fc2 = torch::nn::Linear(50, 10);

register_module("conv1", conv1);
register_module("conv2", conv2);
register_module("conv2_drop", conv2_drop);
register_module("fc1", fc1);
register_module("fc2", fc2);
}

torch::Tensor MyModel::forward(torch::Tensor x) {
// conv1
x = conv1->forward(x);
x = torch::max_pool2d(x, 2);
x = torch::relu(x);

// conv2
x = conv2->forward(x);
x = conv2_drop->forward(x);
x = torch::max_pool2d(x, 2);
x = torch::relu(x);

// fc1
x = x.view({-1, 320});
x = fc1->forward(x);
x = torch::relu(x);

// dropout
x = torch::dropout(x, 0.5, is_training());

// fc2
x = fc2->forward(x);

// log softmax
x = torch::log_softmax(x, 1);

return x;
}

可以看到网络的定义还是比较简单直接,可以直接从Python 网络定义迁移过去,几个核心点:

  • 网络类的定义需要继承torch::nn::Module
  • 实现forward 函数来进行网络前项运算,其中每个层需要显式地调用forward 函数
2.2.4 数据集定义

由于 Libtorch 自带 MNIST的实现,我们这里只是做了一个简单的封装,作为模块化的例子。
头文件my_dataset.h 内容:

1
2
3
4
5
6
7
8
9
10
11
12
#pragma once
#include <torch/torch.h>

class MyDataset {
public:
MyDataset(
const std::string& data_root,
torch::data::datasets::MNIST::Mode phase);

public:
torch::data::datasets::MNIST mnist_dataset;
};

实现文件my_dataset.cpp 内容:

1
2
3
4
5
6
#include "my_dataset.h"

MyDataset::MyDataset(
const std::string& data_root,
torch::data::datasets::MNIST::Mode phase)
: mnist_dataset(torch::data::datasets::MNIST(data_root, phase)) {}

这里有一个需要注意的点,由于MNIST类本身没有默认构造函数,所以在MyDataset 类的初始化列表中就必须给成员变量mnist_dataset赋值,否则会报下面的错:

1
constructor for 'MyDataset' must explicitly initialize the member 'mnist_dataset' which does not have a default constructor
2.2.5 Trainer定义

Trainer 包含训练和测试的两个函数,对数据和网络,优化器等输入进行计算,得到输出,计算loss和准确率。
头文件my_trainer.h内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#pragma once
#include <torch/torch.h>

#include "my_dataset.h"
#include "my_model.h"

class MyTrainer {
public:
MyTrainer(int log_interval) : log_interval_(log_interval){};

void train(
size_t epoch,
MyModel& model,
torch::optim::Optimizer& optimizer,
torch::Device device,
MyDataset& train_dataset,
int batch_size,
int num_workers);

void test(
MyModel& model,
torch::Device device,
MyDataset& test_dataset,
int batch_size,
int num_workers);

private:
int log_interval_;
};

实现文件my_trainer.cpp 内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
#include "my_trainer.h"

#include <torch/torch.h>

#include <cstdio>
#include <string>
#include <vector>

void MyTrainer::train(
size_t epoch,
MyModel& model,
torch::optim::Optimizer& optimizer,
torch::Device device,
MyDataset& train_dataset,
int batch_size,
int num_workers) {
model.train();

// 对MNIST数据进行Normalize和Stack(将多个Tensor stack成一个Tensor)
auto dataset = train_dataset.mnist_dataset
.map(torch::data::transforms::Normalize<>(0.1307, 0.3081))
.map(torch::data::transforms::Stack<>());

// 构造 DataLoader, 设置 batch size 和 worker 数目
auto data_loader = torch::data::make_data_loader(
dataset,
torch::data::DataLoaderOptions()
.batch_size(batch_size)
.workers(num_workers));
auto dataset_size = dataset.size().value();

size_t batch_idx = 0;
// 网络训练
for (auto& batch : *data_loader) {
// 获取数据和label
auto data = batch.data.to(device);
auto targets = batch.target.to(device);

// 优化器 梯度清零
optimizer.zero_grad();

// 模型前向操作,得到预测输出
auto output = model.forward(data);

// 计算loss
auto loss = torch::nll_loss(output, targets);

// loss 反传
loss.backward();
optimizer.step();

// 打印log信息
if (batch_idx++ % log_interval_ == 0) {
std::printf(
"\rTrain Epoch: %ld [%5llu/%5ld] Loss: %.4f",
epoch,
batch_idx * batch.data.size(0),
dataset_size,
loss.template item<float>());
}
}
}

void MyTrainer::test(
MyModel& model,
torch::Device device,
MyDataset& test_dataset,
int batch_size,
int num_workers) {
// 测试时要将模型置为eval模式
model.eval();
double test_loss = 0;
int32_t correct = 0;

// 对MNIST数据进行Normalize和Stack(将多个Tensor stack成一个Tensor)
auto dataset = test_dataset.mnist_dataset
.map(torch::data::transforms::Normalize<>(0.1307, 0.3081))
.map(torch::data::transforms::Stack<>());

// 构造 DataLoader, 设置 batch size 和 worker 数目
auto data_loader = torch::data::make_data_loader(
dataset,
torch::data::DataLoaderOptions()
.batch_size(batch_size)
.workers(num_workers));
auto dataset_size = dataset.size().value();

for (const auto& batch : *data_loader) {
// 获取数据和label
auto data = batch.data.to(device);
auto targets = batch.target.to(device);

// 模型前向操作,得到预测输出
auto output = model.forward(data);

// 计算测试时的 loss
test_loss += torch::nll_loss(
output,
targets,
/*weight=*/{},
torch::Reduction::Sum)
.item<float>();
auto pred = output.argmax(1);
correct += pred.eq(targets).sum().template item<int64_t>();
}

test_loss /= dataset_size;
std::printf(
"\nTest set: Average loss: %.4f | Accuracy: %.3f\n",
test_loss,
static_cast<double>(correct) / dataset_size);
}
2.2.6 编译和运行方式

我们基于CMake 编译上面的代码,同时下载MNIST数据集,完整的执行命令如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
mkdir build
cd build
cmake .. -DCMAKE_PREFIX_PATH=`python -c 'import torch;print(torch.utils.cmake_prefix_path)'`
make -j8
# 下载MNIST数据
mkdir data && cd data
wget "http://yann.lecun.com/exdb/mnist/train-images-idx3-ubyte.gz" && gunzip train-images-idx3-ubyte.gz
wget "http://yann.lecun.com/exdb/mnist/train-labels-idx1-ubyte.gz" && gunzip train-labels-idx1-ubyte.gz
wget "http://yann.lecun.com/exdb/mnist/t10k-images-idx3-ubyte.gz" && gunzip t10k-images-idx3-ubyte.gz
wget "http://yann.lecun.com/exdb/mnist/t10k-labels-idx1-ubyte.gz" && gunzip t10k-labels-idx1-ubyte.gz
cd ../

# 运行可执行文件
./mnist_train

训练和测试输出如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Train Epoch: 1 [59008/60000] Loss: 0.6824
Test set: Average loss: 0.3265 | Accuracy: 0.910
Train Epoch: 2 [59008/60000] Loss: 0.5521
Test set: Average loss: 0.2018 | Accuracy: 0.941
Train Epoch: 3 [59008/60000] Loss: 0.3403
Test set: Average loss: 0.1523 | Accuracy: 0.954
Train Epoch: 4 [59008/60000] Loss: 0.3885
Test set: Average loss: 0.1236 | Accuracy: 0.965
Train Epoch: 5 [59008/60000] Loss: 0.3502
Test set: Average loss: 0.1083 | Accuracy: 0.967
Train Epoch: 6 [59008/60000] Loss: 0.1389
Test set: Average loss: 0.0961 | Accuracy: 0.970
Train Epoch: 7 [59008/60000] Loss: 0.3550
Test set: Average loss: 0.0899 | Accuracy: 0.972
...

可以看到准确率在逐渐提升。

这篇文章的内容主要就是这些,后面会根据训练一个实际一些的例子,比如nanoGPT,将在本文的codebase基础上,主要覆盖下面的内容:

  • 自定义数据集的Dataset类的搭建
  • 复杂网络的定义(如ResNet, Transformer)
  • 模型checkpoint的保存和读取

欢迎点赞和关注!

🔲 ⭐

libtorch系列教程2:torch::Tensor的使用

系列教程列表:

这篇文章中,我们暂时忽略网络训练和推理,详细展开Libtorch中Tensor对象的使用,看看将Libtorch当作一个纯粹的Tensor库来使用时,有哪些注意事项。如有未涉及的内容,请访问Libtorch官方文档,通过搜索框获取更多的信息。Libtorch的环境搭建参考上一篇文章

1. torch::Tensor基本操作

Libtorch中的Tensor是与Pytorch中的Tensor对应的,使用方式上很类似,只在一些Python语法C++不支持的时候有些不同,例如slice操作。
使用Libtorch前需要包含 Libtorch 的头文件torch/torch.h:

1
#include <torch/torch.h>

这篇文章用到的所有函数都在此头文件中声明,而且所有的函数namespace都是torch,因此都可以以torch::xxx的形式来调用。

1.1 Tensor创建

Tensor 创建的方式比较多,包括从字面量创建,从C++ 原生的数组创建,从vector创建,从Libtorch自带的函数创建等。

从字面量创建:

1
torch::Tensor foo = torch::tensor({1.0, 2.0, 3.0, 4.0});

从C++ 原生的float数组创建,使用from_blob函数:

1
2
3
4
float arr[] = {1.0, 2.0, 3.0, 4.0};
// 第二个参数表示创建的Tensor shape,会自动对原生数组进行reshape
torch::Tensor bar = torch::from_blob(arr, {1, 4}); // shape是[1, 4]
bar = torch::from_blob(arr, {2, 2}); // shape是[2, 2]

其中第二个参数表示创建的Tensor shape,会自动对原生数组进行reshape。

从vector 创建,使用from_blob函数:

1
2
std::vector<float> v = {1.0, 2.0, 3.0, 4.0};
bar = torch::from_blob(v.data(), {2, 2});

还可以用Libtorch的函数创建,跟Numpy和Pytorch类似:

1
2
3
4
5
6
7
8
foo = torch::arange(4);
foo = torch::eye(2);
foo = torch::ones(2);
bar = torch::ones_like(foo);
foo = torch::rand(4);
foo = torch::randn(4);
foo = torch::zeros(2);
bar = torch::zeros_like(foo);

创建好以后,Tensor对应可以直接用std::cout来输出:

1
2
torch::Tensor foo = torch::tensor({1.0, 2.0, 3.0, 4.0});
std::cout <<"==> foo is:\n" << foo << std::endl;

输出如下:

1
2
3
4
5
6
==> foo is:
1
2
3
4
[ CPUFloatType{4} ]

可以看到最后打印了Tensor的类型。

1.2 Tensor对象的属性函数

创建Tensor后,我们还需要看到它的一些属性,判断是否跟预期相符。注意Libtorch的Tensor是没有公开可访问的属性attribute的,Tensor信息需要属性函数来获取。常见的属性函数包括:

  • dim(): Tensor的维度
  • sizes(): 跟Pytorch中的shape属性一样
  • size(n): 第N个维度的shape
  • numel(): 总的元素数目,sizes中的每个元素相乘
  • dtype(): 数据类型
  • device(): Tensor所在的设备类型,CPU, CUDA, MPS等。

使用方式如下:

1
2
3
4
5
6
7
8
9
// Tensor 属性函数
torch::Tensor foo = torch::randn({1, 3, 224, 224});
auto dim = foo.dim(); // 4
auto sizes = foo.sizes(); // [1, 3, 224, 224]
auto size_0 = foo.size(0); // 1
auto numel = foo.numel(); // 150528
auto dtype = foo.dtype(); // float
auto scalar_type = foo.scalar_type(); // Float
auto device = foo.device(); // cpu

1.3 Tensor对象的索引

Tensor 默认是支持[]操作符的,因此可以使用这样的方式来获取元素:

1
2
auto foo = torch::randn({1, 2, 3, 4});
float value = foo[0][1][2][2];

另一种方式是用Tensor对象的index函数,它的优势是支持slice。
对于单个元素,可以类似Pytorch中,直接用index({i, j, k})的方式来索引:

1
2
auto foo = torch::randn({1, 2, 3, 4});
float value = foo.index({0, 1, 2, 2});

那么python中很常用的slice呢?例如foo[..., :2, 1:, :-1],该怎么在Libtorch中表示?
这里需要用到torch::indexing::Slice 对象,来实现Python中的Slice,看看下面的例子你就明白了:

1
2
3
4
5
using namespace torch::indexing;

auto foo = torch::randn({1, 2, 3, 4});
// 等效于Python中的foo[:, 0:1, 2:, :-1]
auto bar = foo.index({Slice(), Slice(0, 1), Slice(2, None), Slice(None, -1)});

应该是能满足Python中slice同样的使用场景。

1.4 更新Tensor中元素的值

有了索引之后,我们就可以更新Tensor的值了:

1
2
3
torch::Tensor foo = torch::tensor({1.0, 2.0, 3.0, 4.0});
foo[0] = 10.0;
foo.index({0}) = 2.0;

但还没找到用给部分Tensor元素赋值的方法,类似Python中的foo[:2] = bar,欢迎补充。

1.5 获取Tensor中的数据

Tensor是一个Libtorch的对象,那怎么把它中的数据拿出来保存到文件中或传给别的函数呢?
使用data_ptr函数就可以:

1
2
torch::Tensor foo = torch::randn({3, 3});
float* data = foo.data_ptr<float>();

对于单个元素的Tensor,还可以用item函数得到具体的数值:

1
2
torch::Tensor one_element_tensor = foo.index({Slice(), Slice(0, 1), Slice(0, 1), Slice(0, 1)});
float value = one_element_tensor.item<float>();

1.6 数据类型

Libtorch中支持float16, float32, float64, int8, int16, int32, uint8这几类的Tensor数据类型,可以用to函数来进行类型转换:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 数据类型, 参见 https://pytorch.org/cppdocs/api/file_torch_csrc_api_include_torch_types.h.html#variables
bar = foo.to(torch::kF16);
bar = foo.to(torch::kF32);
bar = foo.to(torch::kF64);
bar = foo.to(torch::kFloat16);
bar = foo.to(torch::kFloat32);
bar = foo.to(torch::kFloat64);
bar = foo.to(torch::kI8);
bar = foo.to(torch::kI16);
bar = foo.to(torch::kI32);
bar = foo.to(torch::kI64);
bar = foo.to(torch::kInt8);
bar = foo.to(torch::kInt16);
bar = foo.to(torch::kInt32);
bar = foo.to(torch::kInt64);
bar = foo.to(torch::kU8);
bar = foo.to(torch::kUInt8);

全部数据类型,参见官方文档的数据类型页面

1.7 设备类型

设备类型是Tensor保存的设备的种类。由于Libtorch不仅仅支持CPU,还支持各种类型的GPU,因此有很多设备类型。

所有的设备类型参见这里
需要注意的是,设备是跟编译时的配置,机器是否支持强相关的,而且某些设备支持并不好,例如我想用下面的代码将CPU上的Tensor转移到MPS上:

1
2
auto foo = torch::randn({3, 3});
auto bar = foo.to(torch::kMPS);

编译是没有问题的,但运行时会报下面的错:

libc++abi: terminating with uncaught exception of type c10::TypeError: Cannot convert a MPS Tensor to float64 dtype as the MPS framework doesn’t support float64. Please use float32 instead.

提示说MPS不支持float64,但我打印foo的类型,它其实是float32,本身报错比较奇怪,搜了一圈也没找到怎么解决。

1.8 Tensor 变形函数

很多时候我们需要将Tensor进行形状的修改,这方面Libtorch支持的比较好,这些操作都支持:

  • reshape
  • flatten
  • squeeze
  • unsqueeze
  • transpose
  • cat/concat/concatenate

而且支持torch::reshape这种静态函数和tensor.reshape这种对象函数。下面是一些例子:

1
2
3
4
5
6
7
8
9
// 变形操作
bar = foo.reshape({2, -1});
bar = foo.flatten();
bar = foo.squeeze();
bar = foo.unsqueeze(0);
bar = torch::unsqueeze(foo, -1);
bar = foo.transpose(0, 1).transpose(2, 3).transpose(3, 1);
bar = torch::transpose(foo, 0, 1);
bar = torch::cat({foo, foo}, 2);

一个比较特殊的地方是transpose只支持两个轴的交换,多个轴的交换需要调用多次来实现。

1.9 Tensor之间的操作函数

Tensor库中,Tensor和Tensor之间的操作是很常见的,比如求矩阵相乘,内积外积等,有内置的函数支持能避免很多额外的开发工作。这里是一些例子:

1
2
3
4
5
foo = torch::randn({3, 3});
bar = torch::matmul(foo, foo);
bar = foo.matmul(foo);
bar = torch::cross(foo, foo);
bar = torch::mul(foo, foo);

1.10 线性代数相关函数

torch::linalg namespace中包含常见的线性代数操作,几个简单的使用例子:

1
2
bar = torch::linalg::inv(foo);
bar = torch::linalg::norm(foo, 2, {0, 1}, false, torch::nullopt);

所有支持的函数详见官方文档

1.11 神经网络相关函数

神经网络是torch的核心模块,常见的一些激活函数,卷积层都可以以函数的形式作用在Tensor上,这里写几个简单的例子:

1
2
3
4
bar = torch::softmax(foo, -1);
bar = torch::sigmoid(foo);
bar = torch::relu(foo);
bar = torch::gelu(foo);
❌