2024年10月

数轴
是数学中的一个基本概念,它规定了原点、正方向和单位长度的直线。

Manim
中的
NumberLine
就是一个专门用来表示数轴的对象,它允许用户设置数轴的范围、间隔和显示长度等参数,从而灵活地在动画中展示数学中的一维数值变化。

下面将介绍
Manim
中的
NumberLine
对象的基本功能到使用示例。

1. 主要参数

NumberLine
的参数很多,定制性很强,能够满足各式各样对于数轴显示的需求。

参数名称 类型 说明
x_range Sequence[float] 数轴刻度的范围
length float 数轴的长度
unit_size float 刻度之间的距离
include_ticks bool 是否包含刻度
tick_size float 刻度标记的长度
numbers_with_elongated_ticks Iterable[float] 拉伸的特殊刻度列表
longer_tick_multiple int 被拉伸刻度的拉伸比例
rotation float 数轴旋转的角度
stroke_width float 数轴线的粗细
include_tip bool 数轴是否包含箭头
tip_width float 箭头的宽度
tip_height float 箭头的高度
tip_shape ArrowTip 数轴的箭头类型
include_numbers bool 刻度是否包含数值
scaling _ScaleBase 数轴的比例
font_size float 刻度上文字的大小
label_direction Sequence[float] 刻度上文字的方向
label_constructor VMobject 构建刻度上文字的对象
line_to_number_buff float 数轴的线和文字的距离
decimal_number_config dict 刻度上文字的样式配置
numbers_to_exclude Iterable[float] 不需要显示文字的刻度列表
numbers_to_include Iterable[float] 需要显示文字的刻度列表

参数很多,但是主要分为3个部分(数轴的
线

刻度

数值
)的设置。

比如,参数名称中含有
tick
的,一般是设置上图中蓝色部分(刻度)的;

参数名称中含有
tip

line
的,一般是设置上图中红色线以及箭头部分(数轴线)的;

参数名称中含有
label

number
的,一般是设置上图中黄色数字部分(数值)的。

其他的参数也是与上面3部分相关的,后面的示例会演示各种参数的使用。

2. 主要方法

数轴提供的主要方法有:

名称 说明
add_labels 数轴上某些位置添加标签文字
add_numbers 数轴上添加数值
add_ticks 数轴上添加刻度
get_labels 获取数轴上的标签文字
get_number_mobject 获取数轴上的数值对象
get_tick 获取数轴上的刻度
get_tick_range 获取数轴上的刻度范围
n2p(number_to_point) 根据数值得到此数值在数轴上的位置
p2n(point_to_number) 根据数轴上的位置得到对应的数值

动态在数轴上添加,修改,删除内容时,这些方法很有用。

3. 使用示例

数轴(NumberLine)的参数和方法都不少,无法一一演示,下面的示例从数轴的样式,

刻度和数值设置,以及数据点在数轴上动态移动等几个方面演示数轴的使用。

3.1. 数轴大小和颜色

数轴

NumberLine
)的
大小

颜色
是最常设置的,相关的参数主要有
length

stroke_width

color

# 长短不同的数轴
l1 = NumberLine(
    x_range=[-5, 5],
    length=2,
)
l2 = NumberLine(
    x_range=[-5, 5],
    length=4,
)
l3 = NumberLine(
    x_range=[-5, 5],
    length=6,
)

# 粗细,颜色不同的数轴
l4 = NumberLine(
    x_range=[-5, 5],
    stroke_width=1,
    color=BLUE,
)
l5 = NumberLine(
    x_range=[-5, 5],
    stroke_width=3,
    color=RED,
)
l6 = NumberLine(
    x_range=[-5, 5],
    stroke_width=6,
    color=GREEN,
)

3.2. 数轴线的箭头

数轴默认是没有箭头的,通过
tip
相关参数,可以在数轴的正方向添加不同样式箭头。

l1 = NumberLine(
    x_range=[-5, 6],
    include_tip=True,
    tip_width=0.15,
    tip_height=0.15,
)
l2 = NumberLine(
    x_range=[-5, 6],
    include_tip=True,
    tip_shape=ArrowCircleTip,
    tip_width=0.15,
    tip_height=0.15,
)
l3 = NumberLine(
    x_range=[-5, 6],
    include_tip=True,
    tip_shape=ArrowCircleFilledTip,
    tip_width=0.15,
    tip_height=0.15,
)

l4 = NumberLine(
    x_range=[-5, 6],
    include_tip=True,
    tip_shape=ArrowSquareTip,
    tip_width=0.15,
    tip_height=0.15,
)
l5 = NumberLine(
    x_range=[-5, 6],
    include_tip=True,
    tip_shape=StealthTip,
    tip_width=0.15,
    tip_height=0.15,
)

3.3. 刻度和数值

默认情况下,数轴上的
刻度

数值
是依次显示的,而通过
x_range
参数,可以设置数值间隔显示;

通过
scaling
参数,可以在相同间隔的刻度上显示不均匀的数值;还可以突出显示某些刻度。

# 默认的数轴
l1 = NumberLine(
    x_range=[-6, 6, 1],
)

# 数值间隔的数轴
l2 = NumberLine(
    x_range=[-6, 6, 2],
)

# 数值是10为底的指数
l3 = NumberLine(
    x_range=[0, 5, 1],
    scaling=LogBase(base=10),
)

# 突出显示某些刻度
l4 = NumberLine(
    x_range=[-6, 6, 1],
)
l4.numbers[7].set_color(RED).scale(2)
l4.numbers[3].set_color(GREEN).scale(2)

3.4. 数轴上的数据点

数轴上的点和显示在屏幕的位置是不一样的,通过数轴提供的函数
n2p

p2n

可以很容易的将屏幕上的点映射到数轴上,也可以根据数轴上的点计算其在屏幕上的位置。

下面的动画示例,首先根据数轴上的值获取其在屏幕上的位置,然后绘制这个点。

接下来,移动这个点,移动的过程中同时显示此点在数轴上的数值。

l = NumberLine(
    x_range=[-6, 6, 1],
)

# n2p 获取-4在屏幕的位置
p = l.n2p(-4)
d = Dot(p, color=BLUE)

# 初始数值
txt = MathTex("-4.00")

# p2n 获取在数轴上的值
txt.add_updater(
    lambda x: x.become(
        MathTex(
            round(l.p2n(d.get_center()), 2),
            color=RED,
            font_size=30,
        ).next_to(d, UP, buff=0.2)
    )
)

# 移动这个点,查看数值变化
self.play(d.animate.move_to(RIGHT))

4. 附近

文中完整的代码放在网盘中了(
number_line.py
),

下载地址:
完整代码
(访问密码: 6872)

后训练量化(
PTQ
)在视觉
Transformer

ViTs
)领域引起了广泛关注,因为它在模型压缩方面表现出了高效率。然而,现有的方法通常忽视了量化权重和激活之间复杂的相互依赖关系,导致了相当大的量化误差。论文提出了一种名为
ERQ
的两步
PTQ
方法,精心设计用于顺序降低激活和权重量化带来的量化误差。
ERQ
首先引入了激活量化误差减小(
Aqer
),将激活量化误差的最小化策略性地表述为一个岭回归问题,并通过使用全精度更新权重来解决。随后,
ERQ
引入了权重量化误差减小(
Wqer
),采用迭代的方法来减轻由权重量化引起的量化误差。在每次迭代中,采用经验推导出的有效代理来细化量化权重的舍入方向,并结合岭回归求解器以减少权重量化误差。实验结果证明了该方法的有效性。值得注意的是,
ERQ

W3A4 ViT-S
的准确性上超越了最先进的
GPTQ
,提升幅度达
22.36%

来源:晓飞的算法工程笔记 公众号,转载请注明出处

论文: ERQ: Error Reduction for Post-Training Quantization of Vision Transformers

Introduction


视觉
Transformer

ViTs
)显著挑战了卷积神经网络(
CNNs
),成为计算机视觉领域的新范式。
ViTs
利用多头自注意力(
MHSA
)机制来捕捉图像块之间的长距离关系,在各种视觉任务中展现出令人印象深刻的进展。

然而,强大的能力伴随着相当的复杂性。
ViTs
固有的架构复杂性导致了高计算需求和可观的内存要求,这在资源受限的环境中部署时带来了挑战。为了缓解这一困境,模型量化吸引了业界和学术界的持续关注。量化通过实现权重和激活的低位表示来减少模型复杂性,为高效部署提供了一条有前景的途径。最近,研究人员逐渐关注于视觉
Transformer
的后训练量化(
PTQ
),该方法旨在利用一个小型校准数据集和较低的成本对模型进行量化。

为了适应
ViTs
独特的结构,已经许多研究探索了各种后训练量化(
PTQ
)方法。例如,为了处理长尾
post-Softmax
激活,有研究提出了
\(log2/log \sqrt{2}\)
量化器和
twin uniform
量化器。为了管理高度变化的激活,有研究采用了重参数化技术和
power-of-two
因子。此外,有研究采用进化搜索方法来确定不稳定的缩放因子。然而,现有的方法通常忽视了权重和激活量化之间复杂的相互依赖关系,这在权重-激活量化时导致了相当大的量化误差。

论文提出一种为
ViTs
量身定制的两步后训练量化方法
ERQ
,旨在顺序减小由量化激活和权重引起的量化误差。如图
1
所示,
ERQ
由两个步骤组成,即激活量化误差减少(
Aqer
)和权重量化误差减少(
Wqer
)。
Aqer
将激活量化引起的量化误差公式化为一个岭回归问题,该问题可以通过权重更新以闭式解的方式解决。随后,引入
Wqer
以迭代的量化和修正方式减小由权重量化引起的量化误差。特别地,在每次迭代中,量化全精度权重的前半部分,并通过先执行四舍五入细化,后再次解决岭回归问题来减小产生的量化误差。前者推导出输出误差的有效代理,用于细化量化权重的四舍五入方向,以降低量化误差。后者则通过更新剩余的全精度权重进一步减小量化误差。这样的过程持续进行,直到所有权重被准确量化。

ERQ
在对各种
ViTs
变体(
ViT

DeiT

Swin
)及任务(图像分类、目标检测和实例分割)进行的广泛实验中证明了其有效性。值得注意的是,在图像分类任务中,
ERQ

W3A4 ViT-S
上比
GPTQ
的性能提高了
22.36%

Method


相互纠缠的
\(\delta{\mathbf{x}}\)

\(\delta\mathbf{W}\)
使得找到公式
4
的最优解变得具有挑战性。为使问题变得可处理,将公式
4
放宽为两个顺序的子问题,通过分别最小化来自量化激活和权重的误差。如图
1
所示,首先进行激活量化误差减少 (
Aqer
),然后进行权重量化误差减少 (
Wqer
)。

Activation Quantization Error Reduction

为减轻由激活量化引起的误差,引入激活量化误差减少 (
Aqer
),将误差减轻问题形式化为岭回归问题。具体来说,将权重保留为全精度,仅考虑由激活量化误差
\(\delta{\mathbf{x}}\)
引起的均方误差 (
MSE
):

\[\begin{align}
\mathcal{L}^{\text{MSE}} = \mathbb{E} \left[ \| \mathbf{W}\mathbf{x} - \mathbf{W}(\mathbf{x}+\delta{\mathbf{x}})\|_2^2 \right].
\label{eq:obj-act}
\end{align}
\]

为了最小化公式
5
,将其形式化为岭回归问题,其中通过将权重
\(\mathbf{W}\)
与调整项
\(\delta\mathbf{W}^*\)
相加来完成最小化:

\[\begin{equation}
\begin{aligned}
&\mathbb{E} \left[ \| \mathbf{W}\mathbf{x} - (\mathbf{W} + \delta\mathbf{W}^*)(\mathbf{x}+\delta{\mathbf{x}})\|_2^2 \right] + \lambda_1 \| \delta\mathbf{W}^* \|_2^2
\\
& = \mathbb{E} \left[\| - \delta\mathbf{W}^*(\mathbf{x}+\delta{\mathbf{x}}) - \mathbf{W}\delta{\mathbf{x}} \|_2^2\right] + \lambda_1 \| \delta\mathbf{W}^* \|_2^2
\\
& = \mathbb{E} \left[ \| \delta\mathbf{W}^*\bar{\mathbf{x}} + \mathbf{W}\delta{\mathbf{x}} \|_2^2 \right] + \lambda_1 \| \delta\mathbf{W}^* \|_2^2.
\label{eq:obj-act1}
\end{aligned}
\end{equation}
\]

这里,
\(\delta\mathbf{W}^*\)
表示通过岭回归计算出的调整项,
\(\bar{\mathbf{x}}=\mathbf{x}+\delta\mathbf{x}\)
是量化输入,
\(\lambda_1\| \delta\mathbf{W}^* \|_2^2\)
作为正则化项,
\(\lambda_1\)
是控制正则化强度的超参数。公式
6
构成了岭回归问题。为了最小化它,首先计算其相对于
\(\delta\mathbf{W}^*\)
的梯度:

\[\begin{equation}
\begin{aligned}
\frac{\partial}{\partial \delta\mathbf{W}^*} & \mathbb{E}\left[ \| \delta\mathbf{W}^*\bar{\mathbf{x}} + \mathbf{W}\delta{\mathbf{x}} \|_2^2 \right] + \lambda_1 \| \delta\mathbf{W}^* \|_2^2
\\
& = \mathbb{E} \left[ 2 (\delta\mathbf{W}^*\bar{\mathbf{x}} + \mathbf{W}\delta{\mathbf{x}})\bar{\mathbf{x}}^T \right] + 2\lambda_1 \delta\mathbf{W}^*.
\label{eq:obj-act2}
\end{aligned}
\end{equation}
\]

然后,通过将公式
7
设置为零来求解
\(\delta\mathbf{W}^*\)

\[\begin{equation}
\begin{aligned}
& \mathbb{E}\left[ 2 (\delta\mathbf{W}^*\bar{\mathbf{x}} + \mathbf{W}\delta{\mathbf{x}})\bar{\mathbf{x}}^T \right] + 2\lambda_1 \delta\mathbf{W}^* = 0
\\
& \Rightarrow \delta\mathbf{W}^* = -\mathbf{W} \mathbb{E} \left[\delta{\mathbf{x}}\bar{\mathbf{x}}^T\right](\mathbb{E} \left[\bar{\mathbf{x}}\bar{\mathbf{x}}^T \right] + \lambda_1 \mathbf{I})^{-1}.
\end{aligned}
\end{equation}
\]

正则化项
\(\lambda_1 \mathbf{I}\)
确保
\(\mathbb{E} \left[\bar{\mathbf{x}}\bar{\mathbf{x}}^T \right] + \lambda_1 \mathbf{I}\)
的逆始终存在,这对计算稳定性至关重要。此外,它抑制了异常值,从而减轻了过拟合,提高了模型的泛化能力。抑制异常值对于随后的权重量化也至关重要,因为它限制了权重的范围。这种限制防止量化点分布在未覆盖的区域,从而增强了量化的表达能力。

在实践中,给定校准数据集,使用
\(\frac{1}{N}\sum_n^N \delta{\mathbf{x}}_n\bar{\mathbf{x}}_n^T\)

\(\frac{1}{N}\sum_n^N \bar{\mathbf{x}}_n\bar{\mathbf{x}}_n^T\)
分别估计
\(\mathbb{E}\left[\delta{\mathbf{x}}\bar{\mathbf{x}}^T\right]\)

\(\mathbb{E}\left[\bar{\mathbf{x}}\bar{\mathbf{x}}^T \right]\)
。这里,
\(N = B\times T >> D_{in}^s\)
,其中
\(B\)
是校准数据集的大小,
\(T\)
是一张图像的标记数量。请注意,
\(\delta{\mathbf{x}}\)

\(\bar{\mathbf{x}}\)
是在给定输入和量化参数的情况下确定的。在得到
\(\delta\mathbf{W}^*\)
后,通过
\(\mathbf{W} = \mathbf{W} + \delta\mathbf{W}^*\)
将其合并到网络的权重中。通过这样做,所提出的
Aqer
明确减轻了从量化激活到权重的量化误差。

Weight Quantization Error Reduction

在进行
Aqer
后需执行权重量化,提出权重量化误差减少(
Wqer
)来减轻由此产生的量化误差。在这里,目标被定义为:

\[\begin{equation}
\begin{aligned}
\mathcal{L}^{\text{MSE}} & = \mathbb{E} \left[\| \mathbf{W}\bar{\mathbf{x}} - (\mathbf{W}+\delta\mathbf{W})\bar{\mathbf{x}}\|_2^2 \right] = \sum_i^{D_{out}} \mathcal{L}^{\text{MSE}}_i
\\
& = \sum_i^{D_{out}} \mathbb{E} \left[\| \mathbf{W}_{i,:}\bar{\mathbf{x}} - (\mathbf{W}_{i,:}+\delta\mathbf{W}_{i,:})\bar{\mathbf{x}}\|_2^2 \right].
\label{eq:obj-weight0}
\end{aligned}
\end{equation}
\]

注意,在进行
Aqer
后,激活值被量化。公式
9
表明输出通道之间的最小化是独立进行的。因此,分别分析每个
\(\mathcal{L}^{\text{MSE}}_i\)
的最小化。同时对整个全精度权重进行量化会导致无法恢复的量化误差。因此,采用迭代的量化和修正方法,逐步减少由权重量化引起的量化误差。

在每次迭代中,首先对未量化权重的前半部分进行量化,然后减轻由此产生的量化误差。具体来说,从当前的全精度权重
\(\mathbf{W}_{i,:}\)
和相应的
\(\bar{\mathbf{x}}\)
开始。然后,将
\(\mathbf{W}\)
划分为两个部分:前半部分
\(\mathbf{W}^s_{i,:} \in \mathbb{R}^{ 1\times D_{in}^s}\)
用于量化,而剩余部分
\(\mathbf{W}^r_{i,:} \in \mathbb{R}^{1 \times D_{in}^r}\)
保持全精度。对应地,从
\(\bar{\mathbf{x}}\)
中派生出
\(\bar{\mathbf{x}}^s \in \mathbb{R}^{D_{in}^s}\)

\(\bar{\mathbf{x}}^r \in \mathbb{R}^{D_{in}^r}\)
,其中
\(\bar{\mathbf{x}}^s\)

\(\bar{\mathbf{x}}^r\)
分别包含与
\(\mathbf{W}^s_{i,:}\)

\(\mathbf{W}^r_{i,:}\)
对应的
\(\bar{\mathbf{x}}\)
的行。量化后的
\(\mathbf{W}^s_{i,:}\)
的量化误差记为
\(\delta\mathbf{W}^s_{i,:} = \bar{\mathbf{W}}^s_{i,:} - \mathbf{W}^s_{i,:}\)
,由此产生的均方误差(
MSE
)为:

\[\begin{equation}
\begin{split}
\mathcal{L}^{\text{MSE}}_i & = \mathbb{E} \big[ \| [ \mathbf{W}^s_{i,:},\mathbf{W}^r_{i,:} ] [ \bar{\mathbf{x}}^s, \bar{\mathbf{x}}^r ]
\\
& \quad\quad\quad - [ \mathbf{W}^s_{i,:}+\delta\mathbf{W}^s_{i,:},\mathbf{W}^r_{i,:} ] [ \bar{\mathbf{x}}^s, \bar{\mathbf{x}}^r ] \|_2^2 \big]
\\
& = \mathbb{E} \left[ \| \delta\mathbf{W}^s_{i,:}\bar{\mathbf{x}}^s \|_2^2 \right].
\end{split}
\label{eq:obj-weight-divide}
\end{equation}
\]

在这里,
\(\mathbf{W}_{i,:} = [ \mathbf{W}^s_{i,:},\mathbf{W}^r_{i,:} ]\)

\(\bar{\mathbf{x}} = [ \bar{\mathbf{x}}^s, \bar{\mathbf{x}}^r ]\)
。为了减轻公式
10
,首先引入四舍五入优化(
Rounding Refinement
),在该过程中会细化量化权重的四舍五入方向。比如调整
\(\delta\mathbf{W}^s_{i,:}\)
,以减少
\(\mathbb{E} \left[ \| \delta\mathbf{W}^s_{i,:}\bar{\mathbf{x}}^s \|_2^2 \right]\)
本身。然后,在四舍五入优化之后,给定
\(\mathbb{E} \left[ \| \delta\mathbf{W}^s_{i,:}\bar{\mathbf{x}}^s \|_2^2 \right]\)
,构建一个岭回归(
Ridge Regression
)问题,通过调整
\(\mathbf{W}^r_{i, :}\)
来进一步减轻该误差。

Rounding Refinement

最初,目标是调整量化权重的四舍五入方向,以最小化
\(\mathbb{E} \left[ \| \delta\mathbf{W}^s_{i,:}\bar{\mathbf{x}}^s \|_2^2 \right]\)
。具体来说,对于
\(\mathbf{W}^s_{i,:}\)
中的第
\(j\)
个值,记作
\(\mathbf{W}^s_{i,j}\)
,量化过程涉及向下取整或向上取整。因此,
\(\mathbf{W}^s_{i,:}\)
的量化误差,记作
\(\delta\mathbf{W}^s_{i,j}\)
,可以表示为
\(\delta\mathbf{W}^{s\downarrow}{i, j}\)

\(\delta\mathbf{W}^{s\uparrow}{i, j}\)
。这里,
\(\delta\mathbf{W}^{s\downarrow}_{i, j} = \mathbf{W}^s_{i,j} - \text{Q}_{un\downarrow}(\mathbf{W}^s_{i,j}, b) > 0\)
表示采用向下取整策略所产生的误差,
\(\delta\mathbf{W}^{s\uparrow}_{i, j} = \mathbf{W}^s_{i,j} - \text{Q}_{un\uparrow}(\mathbf{W}^s_{i,j}, b) < 0\)
表示采用向上取整策略所产生的误差,其中
\(\downarrow/\uparrow\)
表示在公式
1
中将
\(\left\lfloor \cdot \right\rceil\)
替换为
\(\left\lfloor \cdot \right\rfloor\)
/
\(\left\lceil \cdot \right\rceil\)

选择
\(\delta\mathbf{W}^s_{i,:}\)
是一个
NP
难题,其解可以通过混合整数二次规划(
MIPQ
)进行搜索。然而,
\(\mathbb{E} \left[ \| \delta\mathbf{W}^s_{i,:}\bar{\mathbf{x}}^s \|_2^2 \right]\)
的高计算复杂度使得在合理时间内找到解决方案成为一项挑战。如表
1
所示,使用
\(\mathbb{E} \left[ \| \delta\mathbf{W}^s_{i,:}\bar{\mathbf{x}}^s \|_2^2 \right]\)
作为
MIPQ
的目标消耗了约
130
小时的巨大时间成本。

  • Efficient Proxy

因此,目标是找到
\(\mathbb{E} \left[ \| \delta\mathbf{W}^s_{i,:}\bar{\mathbf{x}}^s \|_2^2 \right]\)
的一个高效代理。首先,将
\(\mathbb{E} \left[ \| \delta\mathbf{W}^s_{i,:}\bar{\mathbf{x}}^s \|_2^2 \right]\)
重写为:

\[\begin{equation}
\begin{aligned}
\mathbb{E} \left[ \| \delta\mathbf{W}^s_{i,:}\bar{\mathbf{x}}^s \|_2^2 \right] & \overset{\Delta}{=} (\mathbb{E} \left[ \delta\mathbf{W}^s_{i,:}\bar{\mathbf{x}}^s \right])^2 + \text{Var} \left[ \delta\mathbf{W}^s_{i,:}\bar{\mathbf{x}}^s \right].
\label{eq:obj-weight1}
\end{aligned}
\end{equation}
\]

这里,
\(\Delta\)
表示利用
\(\mathbb{E}\left[ Z^2 \right] = (\mathbb{E}\left[ Z \right])^2 + \text{Var}\left[ Z \right]\)

根据中心极限定理,神经网络中的大量乘法和加法运算使得激活值通常呈现出高斯分布,这也是许多以前量化领域研究的基本假设。同时,图
2
展示了全精度和量化激活的通道分布。可以看出,量化激活仍然表现出近似的高斯分布。

因此,论文认为
\(\bar{\mathbf{x}}^s\)
的通道分布仍然可以通过高斯分布进行捕捉,并用
\(D_{in}^s\)
维的高斯分布
\(\mathcal{N}(\boldsymbol{\mu}^s, \boldsymbol{\Sigma}^s)\)

\(\bar{\mathbf{x}}^s\)
进行建模,其中
\(D_{in}^s\)

\(\bar{\mathbf{x}}^s\)
的维度,
\(\boldsymbol{\mu}^s \in \mathbb{R}^{D_{in}^s}, \boldsymbol{\Sigma}^s \in \mathbb{R}^{D_{in}^s \times D_{in}^s}\)
。然后,公式
11
变为:

\[\begin{equation}
\begin{aligned}
& \mathbb{E} \left[ \delta\mathbf{W}^s_{i,:}\bar{\mathbf{x}}^s \right]^2 + \text{Var} \left[ \delta\mathbf{W}^s_{i,:}\bar{\mathbf{x}}^s \right]
\\
& \quad = \delta\mathbf{W}^s_{i,:}\boldsymbol{\mu}^s\boldsymbol{\mu}^{sT}(\delta\mathbf{W}^s_{i,:})^T + \delta\mathbf{W}_{i,:}\boldsymbol{\Sigma}^s(\delta\mathbf{W}^s_{i,:})^T
\\
& \quad = \delta\mathbf{W}^s_{i,:}(\boldsymbol{\mu}^s\boldsymbol{\mu}^{sT} + \boldsymbol{\Sigma}^s)(\delta\mathbf{W}^s_{i,:})^T.
\label{eq:obj-weight3}
\end{aligned}
\end{equation}
\]

这里,公式
12
是得到的
\(\mathbb{E} \left[ \| \delta\mathbf{W}^s_{i,:}\bar{\mathbf{x}}^s \|_2^2 \right]\)
的代理。在实践中,使用给定的校准数据集来估计经验值
\(\hat{\boldsymbol{\mu}}^s\)

\(\hat{\boldsymbol{\Sigma}}^s\)
。请注意,对于所有输出通道,
\(\hat{\boldsymbol{\mu}}^s\)

\(\hat{\boldsymbol{\Sigma}}^s\)
是共享的,只需进行一次计算。


3
展示了代理与
\(\mathbb{E} \left[ \| \delta\mathbf{W}^s_{i,:}\bar{\mathbf{x}}^s \|_2^2 \right]\)
之间的关系。可以看出,所提出的代理与真实值成比例,证明了其可信度。

使用代理的计算复杂度为
\(O((D_{in}^s)^2)\)
,而
\(\mathbb{E} \left[ \| \delta\mathbf{W}^s_{i,:}\bar{\mathbf{x}}^s \|_2^2 \right]\)
的复杂度为
\(O(ND_{in}^s)\)
,其中
\(N >> D_{in}^s\)
。因此,该代理可以作为一个低成本的目标,用于求解
\(\delta\mathbf{W}^s_{i,:}\)
。如表
1
所示,将方程
12
作为
MIPQ
的目标将时间成本从约
130
小时降低到约
10
小时。然而,由于当前开源的
MIPQ
实现仅支持
CPU
,无法充分利用
GPU
的能力,这样的成本仍然是适度的。接下来将介绍
Rounding Refinement
,一种支持
GPU
的方法,利用代理的梯度更快地调整
\(\delta\mathbf{W}^s_{i,:}\)

  • Rounding Refinement

首先,使用最接近取整策略初始化
\(\delta\mathbf{W}^s_{i,j}\)
。此时,
\(\delta\mathbf{W}^s_{i,j}\)
要么等于
\(\delta\mathbf{W}^{s\downarrow}_{i, j}\)
,要么等于
\(\delta\mathbf{W}^{s\uparrow}_{i, j}\)
。然后,目标是确定一个索引集合
\(\mathcal{S}\)
,该集合包含需要修改的元素的索引集合,其取整方向被颠倒:

\[\begin{equation}
\begin{aligned}
\delta\mathbf{W}_{i, j}^s =
\begin{cases}
\delta\mathbf{W}^{s\downarrow}_{i, j} & \text{if} \,\, \delta\mathbf{W}_{i, j}^s = \delta\mathbf{W}^{s\uparrow}_{i, j} \\
\delta\mathbf{W}^{s\uparrow}_{i, j} & \text{otherwise.}
\end{cases}
, j \in \mathcal{S}.
\label{eq:obj-weight6}
\end{aligned}
\end{equation}
\]

为了确定
\(\mathcal{S}\)
,首先对代理(公式
12
)相对于
\(\delta\mathbf{W}^s_{i,:}\)
求导。

\[\begin{equation}
\begin{aligned}
\boldsymbol{G}_{\delta\mathbf{W}^s_{i,:}} & = \frac{\partial}{\partial \delta\mathbf{W}^s_{i,:}} \delta\mathbf{W}^s_{i,:}(\boldsymbol{\mu}^s\boldsymbol{\mu}^{sT} + \boldsymbol{\Sigma}^s)(\delta\mathbf{W}^s_{i,:})^T \\
& = 2 \delta\mathbf{W}^s_{i,:}(\boldsymbol{\mu}^s\boldsymbol{\mu}^{sT} + \boldsymbol{\Sigma}^s) .
\label{eq:obj-weight4}
\end{aligned}
\end{equation}
\]

只选择梯度符号相同的元素,因为这才是允许颠倒的唯一方式。例如,当
\(\delta\mathbf{W}_{i, j}^s = \delta\mathbf{W}^{s\downarrow}_{i, j}\)
时,仅当
\(\boldsymbol{G}_{\delta\mathbf{W}_{i, j}^s}\)

\(\delta\mathbf{W}_{i, j}^s\)
具有相同的符号时,才能将其替换为
\(\delta\mathbf{W}^{s\uparrow}_{i, j}\)
。因此,索引集合
\(\mathcal{S}\)
定义为:

\[\begin{equation}
\begin{aligned}
& \mathcal{S} = \mathrm{topk\_index}(\mathcal{M}),
\\
& \mathcal{M} = \lvert \boldsymbol{G}_{\delta\mathbf{W}_{i, :}^s} \odot \mathbb{1}(\boldsymbol{G}_{\delta\mathbf{W}_{i, :}^s} \odot \delta\mathbf{W}_{i, :}^s ) \rvert \in \mathbb{R}^{D_{in}^s}.
\label{eq:obj-weight5}
\end{aligned}
\end{equation}
\]

这里,
\(\mathrm{topk\_index}\)
返回前
\(\mathrm{k}\)
个元素的索引,
\(\mathbb{1}(\cdot)\)
对于非负输入返回
1
,对负输入返回
0

\(\lvert \cdot \rvert\)
返回输入的绝对值。

在获得
\(\mathcal{S}\)
后,通过公式
13
进行颠倒。上述过程会迭代,直到调整后的
\(\delta\mathbf{W}^s_{i, :}\)
引发更大的代理值或达到最大迭代次数。在获得
\(\delta\mathbf{W}^s_{i, :}\)
后,量化可以通过
\(\bar{\mathbf{W}}^s_{i, :} = \mathbf{W}^s_{i, :}+\delta\mathbf{W}^s_{i, :}\)
完成。然后,将
\(\bar{\mathbf{W}}^s_{i, :}\)
添加到量化权重集合中。
Rounding Refinement
的整体过程在算法
1
的第
7
行到第
18
行中给出。如表
1
所示,
Rounding Refinement
通过
\(150\times\)
的成本显著减少了时间开销,从
10
小时减少到
4
分钟,同时可接受的准确性损失。

  • Ridge Regression


Rounding Refinement
之后,建议用
\(\delta\mathbf{W}^{r*}_{i, :}\)
调整
\(\mathbf{W}^r_{i, :}\)
,以进一步抵消
\(\mathbb{E} \left[ \| \delta\mathbf{W}^s_{i,:}\bar{\mathbf{x}}^s \|_2^2 \right]\)
,从而得到以下目标:

\[\begin{equation}
\begin{split}
\mathbb{E} \big[ \|\delta\mathbf{W}^s_{i, :}\bar{\mathbf{x}}^s + \delta\mathbf{W}^{r*}_{i, :}\bar{\mathbf{x}}^r \|_2^2 \big] + \lambda_2\| \delta\mathbf{W}^{r*}_{i, :} \|_2^2,
\end{split}
\label{eq:obj-weight7}
\end{equation}
\]

其中,
\(\lambda_2\)
是一个超参数,用于控制正则化项
\(\lambda_2\| \delta\mathbf{W}^{r*}_{i, :} \|_2^2\)
的强度。公式
16
的最小化形成了岭回归问题,解决方案定义为:

\[\begin{equation}
\begin{split}
\delta\mathbf{W}^{r*}_{i, :} = - \delta\mathbf{W}^s_{i, :}\mathbb{E} \left[ \bar{\mathbf{x}}^s\bar{\mathbf{x}}^{rT} \right](\mathbb{E} \left[ \bar{\mathbf{x}}^r \bar{\mathbf{x}}^{rT} \right] + \lambda_2 \mathbf{I})^{-1}.
\end{split}
\label{eq:obj-steptwosolution}
\end{equation}
\]

在实践中,通过使用
\(\frac{1}{N}\sum_n^N \bar{\mathbf{x}}_n^r\bar{\mathbf{x}}_n^{sT}\)

\(\frac{1}{N}\sum_n^N \bar{\mathbf{x}}_n^r\bar{\mathbf{x}}_n^{rT}\)
来估计
\(\mathbb{E}\left[\bar{\mathbf{x}}^r \bar{\mathbf{x}}^{sT}\right]\)

\(\mathbb{E}\left[\bar{\mathbf{x}}^r \bar{\mathbf{x}}^{rT} \right]\)
。随后,
\(\mathbf{W}^r_{i, :} = \mathbf{W}^r_{i, :}+\delta\mathbf{W}^{r*}_{i, :}\)
以减小误差。目前,
\(\mathbf{W}^r_{i, :}\)
仍然保持为全精度,并将在下一次迭代中处理。该过程持续进行,直到所有权重被准确量化。所提出的
Rounding Refinement

Ridge Regression
共同形成了
Wqer
,其整体过程在算法
1
中给出。在实践中,对多个输出通道并行执行
Wqer

Experiments




如果本文对你有帮助,麻烦点个赞或在看呗~
更多内容请关注 微信公众号【晓飞的算法工程笔记】

work-life balance.

树是一种非线性数据结构,是以分支关系定义的层次结构,因此形态上和自然界中的倒挂的树很像,而数据结构中树根向上树叶向下。

什么是树?

01
、定义

树是由n(n>=0)个元素节点组成的有限集合,当n=0时,称为空树。

对于非空树应满足以下要求:

(1)有且仅有一个根节点;

(2)当n>1时,其余节点可分成m(m>=0)个互不相交的有限集合,其中每一个集合本身又是一棵树,称为根的子树。

从定义中我们可以得到以下结论:

1)树是分支分层结构;

2)树中仅有根节点没有父节点;

3)除根节点外,其余节点有且仅有一个父节点;

4)树中每个节点,可以有零个或多个子节点;

5)根节点到任何除自身之外的节点,有且仅有一条路径;

02
、术语

1、节点相关

根节点
:树中仅存在一个根节点,位于树的最顶层,并且其没有父节点;

叶节点
:叶节点位于树的最末端,其下层没有任何节点。

子节点
:某个节点的下层节点,相对于该节点叫做子节点;

父节点
:某个节点的上层节点,相对于该节点叫做父节点;

2、结构相关

深度
:从根节点到某一节点所经过的边的个数;根节点为0,其子节点为1,自上而下,以此类推。

高度
:从某一节点到其最远叶节点的边数。树的高度为根节点的高度,所有叶节点高度0,其父节点为1,自下而上,以此类推。

层次
:指节点所在的层级,根节点为第0层,其子节点为第1层,自上而下,以此类推。

子树
:在一棵树中,任何一个以某个节点为根节点的树结构。

3、关系相关

兄弟节点
:拥有共同的父节点的子节点。

祖先节点
:从根节点到该节点的路径上经历的所有节点,除自身外,包括父节点、祖父节点等。

后代节点
:该节点的所有下层节点,包括子节点、孙节点等。

4、其他术语

树的度
:指树的宽度,也可以理解为节点的分支数,即节点的直接子节点数量,所有节点中度的最大值被视为树的度;

路径和路径长度
:从一个节点到另一个节点经历的所有边的序列即为路径,路径上所有边的个数即为路径长度;

森林
:指若干棵互不相交的树的集合;

03
、二叉树

根据节点个数我们可以把树分成两类:二叉树和N叉树。

二叉树
:每个节点最多有两个子节点的树;

n叉树
:每个节点最多有n个子节点的树;

其中最常用的就是二叉树,下面我们来详细聊聊二叉树。

1、定义

(1)每个节点最多有两个子节点,分别称为左子节点和右子节点;

(2)左右子节点所构成左子树和右子树也都是二叉树;

2、性质

(1)任意一颗二叉树树,若节点数为n,则边的数量为n-1;

(2)在二叉树中,第i层最多有2^i个节点;

(3)深度为k的二叉树,总节点数最少有2
k个节点,最多有2
(k+1)-1个节点;

(4)在非空二叉树中,如果n0表示叶节点数量,n2表示度为2(即有两个节点)的节点数量,则n0=n2+1;

3、遍历

二叉树遍历指按照特定顺序访问二叉树中所有节点,常用的遍历方式包括:前序遍历、中序遍历、后序遍历和层次遍历。

前序遍历

访问顺序
:根节点->左子树->右子树

步骤

(1)访问根结点;

(2)前序遍历左子树;

(3)前序遍历右子树。

示意图

中序遍历

访问顺序
:左子树->根节点->右子树

步骤

(1)中序遍历左子树;

(2)访问根结点;

(3)中序遍历右子树。

示意图

后序遍历

访问顺序
:左子树->右子树->根节点

步骤

(1)后序遍历左子树;

(2)后序遍历右子树;

(3)访问根结点;

示意图

层次遍历

访问顺序
:第0层->第1层->……->第n层(每层从左至右依次处理)

步骤

(1)初始化:创建一个空队列,将根节点加入队列;

(2)遍历:

当队列不为空时:

从队列中取出一个节点,并访问该节点的值;

如果该节点有左子节点,将左子节点加入队列;

如果该节点有右子节点,将右子节点加入队列;

(3)重复步骤2,直到队列为空;

示意图


:测试方法代码以及示例源码都已经上传至代码库,有兴趣的可以看看。
https://gitee.com/hugogoos/Planner

使用枚举替换布尔值主要基于以下几个原因
● 可读性
● 可拓展性
● 安全防控

可读性

我们会定义
boolean
类型(
true

false
)作为方法参数,虽然比较简洁,但有时候参数的含义往往不够清晰,造成阅读上的障碍,

比如:参数可能表示“是否开启某个功能”,但仅凭
true

false
并不能一眼看出其真实意图:

setDisable(false):到底是禁用还是启用 --!
setInvalid(false):到底是无效还是有效 --!

相信我,这种“绕弯”的“双重否定”表达方式,一定会耗费你不多的脑细胞一会儿:)
当然你可能会说:“不使用否定的名词”,换成“直接表达”,
setEnable(true)
,这一眼能识别是启用,非常直观;
是的,没错,但在我 10 余年的编程生涯里,相信我
setDisable(false)
遇到过无数次;
再举个例子:
下面代码你能“一眼知道”参数
true
代表什么含义吗?

public static void main(String[] args) {
    convert("12.3456", 2, true);
}

/**
 * 将字符串转换成指定小数位数的值
 *
 * @param value
 * @param scale
 * @param enableHalfUp 是否需要四舍五入
 * @return
 */
public static String convertToValue(String value, int scale, boolean enableHalfUp) {
    if (enableHalfUp){
        //将字符串"四舍五入"换成指定小数位数的值
    }else{
        //将字符串"截断"换到指定小数位数的值
    }
}

当然,现在 IDE 都有比较好的提示,但从“可读性”角度,是不是只能进入到方法定义看注释去了解,甚至没有注释还得去翻代码去研究这个
boolean
到底是啥语义,参数再爆炸下,你能知道每个
boolean
类型参数代表什么吗?

convert("12.3456", 2, true,false,true,false,true);

这里额外扩展一句,木宛哥搞过一段时间的 iOS 开发,如果是 Objective-C 语言,方法命名采用了较为直观的格式,可以包含多个参数名称“线性叙事”,以提高可读性。这种情况,boolean 变量前往往有“名词修饰”,会容易理解,如下所示:
[NSString stringWithCString:"something" enableASCIIStringEncoding:true]

再从 OC 语言回过来,对于这个问题,让看看 JDK 是怎么设计的

public static void main(String[] args) {
    BigDecimal value = new BigDecimal("12.34567");
    //四舍五入到两位小数
    BigDecimal roundedValue = value.setScale(2, RoundingMode.HALF_UP);
    System.out.println(roundedValue);
}

看到了没,
BigDecimal

setScale
方法,通过定义枚举:
RoundingMode
代表转换规则,看到:
RoundingMode.HALF_UP
一眼就知道要四舍五入,根本不需要看代码。
这样增加了可读性的,同时定义了枚举也支持更多扩展,如下马上引入第二点好处:
可扩展

可扩展性

如果未来需要增加更多状态,使用
boolean
会受到扩展的限制

例如,如果当前有两个状态:
enable
(开)和
disable
(关),而将来需要添加
待机
状态,使用
boolean
就显得不够灵活。枚举则很容易扩展,能够清晰地表示更多的状态。
使用 boolean 表达功能状态:

public void configureFeature(boolean enable) {
    if (enable) {
        // 开启功能
    } else {
        // 关闭功能
    }
}

使用枚举表达功能状态:

public enum FeatureMode {
    ENABLED,
    DISABLED,
    MAINTENANCE
}

public void configureFeature(FeatureMode mode) {
    switch (mode) {
        case ENABLED:
            // 开启功能
            break;
        case DISABLED:
            // 关闭功能
            break;
        case MAINTENANCE:
            // 维护状态
            break;
        default:
            throw new IllegalArgumentException("Unknown mode: " + mode);
    }
}

类型安全

错误的使用 Boolean 包装类,有可能会引发空指针异常;

先抛一个问题:包装类
Boolean
有几种“值”?
Boolean
是包含两个值的枚举:
Boolean.TRUE

Boolean.FALSE

但别忘了,还可以是 null

一个真实的线上故障,
Boolean
在某些情况下被错误地使用,
可能会造成空指针异常

例假设你正在修改一个老旧系统的某个方法,这个方法返回
Boolean
,有几千行代码:

public static void main(String[] args) {
    if (checkIfMatched("Dummy")){
        System.out.println("matched");
    }
}

/**
 * 老旧系统里一个异常复杂的方法,有几千行
 * @param str
 * @return
 */
public static Boolean checkIfMatched(String str) {
    Boolean matched;
    //假设此处存在:复杂处理逻辑,暂时用dummy代替
    if ("Dummy".equals(str)) {
        matched = true;
    } else {
        matched = false;
    }
    return matched;
}

目前没问题,但当功能不断迭代后,复杂度也陡然上升,在某个特别的分支里,没有对
Boolean
赋值,至少在编译时是不会报错的:

public static void main(String[] args) {
    if (checkIfMatched("Dummy")) {
        System.out.println("matched");
    }
}

/**
   * 老旧系统里一个异常复杂的方法,有几千行
   *
   * @param str
   * @return
   */
public static Boolean checkIfMatched(String str) {
Boolean matched = null;
//假设此处存在:复杂处理逻辑,暂时用 dummy 代替
if ("Dummy".equals(str)) {
    //模拟:代码在演进的时候,有可能存在 matched 未赋值情况
    if (false) {
        matched = true;
    }
} else {
    matched = false;
}
return matched;
}

这个时候,危险悄然而至,还记得上面的问题吗:
包装类 Boolean 有几种“值”?
现在
checkIfMatched()
方法在不同的情况下,方法会返回三个不同的值:
true/false/null
这里
null
是非常危险的,如果上游使用如下方式判断条件,考虑下是否有问题?

if (checkIfMatched("Dummy")) {
    System.out.println("matched");
}

首先这里不会编译错误,但此处
if
条件处会自动拆箱,对于
null
值会得到
NullPointerException
异常;

小小总结

再回过头看:“哪些场景建议使用枚举来替换布尔值”,我认为要看功能点的易变程度去综合评估:“越容易变化,越不能让复杂度发散,越要由一处收敛,试想下一个
Boolean
的方法的变动是不是要评估所有上游的业务”;
所以并不是完全推翻布尔值,木宛哥在此也只是抛出一些代码的优化手段仅供参考。

Pooling(
https://github.com/inversionhourglass/Pooling
),编译时对象池组件,在编译时将指定类型的
new
操作替换为对象池操作,简化编码过程,无需开发人员手动编写对象池操作代码。同时提供了完全无侵入式的解决方案,可用作临时性能优化的解决方案和老久项目性能优化的解决方案等。

快速开始

引用Pooling.Fody

dotnet add package Pooling.Fody

确保
FodyWeavers.xml
文件中已配置Pooling,如果当前项目没有
FodyWeavers.xml
文件,可以直接编译项目,会自动生成
FodyWeavers.xml
文件:

<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
  <Pooling /> <!--确保存在Pooling节点-->
</Weavers>
// 1. 需要池化的类型实现IPoolItem接口
public class TestItem : IPoolItem
{
    public int Value { get; set; }

    // 当对象返回对象池化时通过该方法进行重置实例状态
    public bool TryReset()
    {
        return true;
    }
}

// 2. 在任何地方使用new关键字创建该类型的对象
public class Test
{
    public void M()
    {
        var random = new Random();
        var item = new TestItem();
        item.Value = random.Next();
        Console.WriteLine(item.Value);
    }
}

// 编译后代码
public class Test
{
    public void M()
    {
        TestItem item = null;
        try
        {
            var random = new Random();
            item = Pool<TestItem>.Get();
            item.Value = random.Next();
            Console.WriteLine(item.Value);
        }
        finally
        {
            if (item != null)
            {
                Pool<TestItem>.Return(item);
            }
        }
    }
}

IPoolItem

正如
快速开始
中的代码所示,实现了
IPoolItem
接口的类型便是一个池化类型,在编译时Pooling会将其new操作替换为对象池操作,并在finally块中将池化对象实例返还到对象池中。
IPoolItem
仅有一个
TryReset
方法,该方法用于在对象返回对象池时进行状态重置,该方法返回false时表示状态重置失败,此时该对象将会被丢弃。

PoolingExclusiveAttribute

默认情况下,实现
IPoolItem
的池化类型会在所有方法中进行池化操作,但有时候我们可能希望该池化类型在部分类型中不进行池化操作,比如我们可能会创建一些池化类型的管理类型或者Builder类型,此时在池化类型上应用
PoolingExclusiveAttribute
便可指定该池化类型不在某些类型/方法中进行池化操作。

[PoolingExclusive(Types = [typeof(TestItemBuilder)], Pattern = "execution(* TestItemManager.*(..))")]
public class TestItem : IPoolItem
{
    public bool TryReset() => true;
}

public class TestItemBuilder
{
    private readonly TestItem _item;

    private TestItemBuilder()
    {
        // 由于通过PoolingExclusive的Types属性排除了TestItemBuilder,所以这里不会替换为对象池操作
        _item = new TestItem();
    }

    public static TestItemBuilder Create() => new TestItemBuilder();

    public TestItemBuilder SetXxx()
    {
        // ...
        return this;
    }

    public TestItem Build()
    {
        return _item;
    }
}

public class TestItemManager
{
    private TestItem? _cacheItem;

    public void Execute()
    {
        // 由于通过PoolingExclusive的Pattern属性排除了TestItemManager下的所有方法,所以这里不会替换为对象池操作
        var item = _cacheItem ?? new TestItem();
        // ...
    }
}

如上代码所示,
PoolingExclusiveAttribute
有两个属性
Types

Pattern

Types

Type
类型数组,当前池化类型不会在数组中的类型的方法中进行池化操作;
Pattern

string
类型AspectN表达式,可以细致的匹配到具体的方法(AspectN表达式格式详见:
https://github.com/inversionhourglass/Shared.Cecil.AspectN/blob/master/README.md
),当前池化类型不会在被匹配到的方法中进行池化操作。两个属性可以使用其中一个,也可以同时使用,同时使用时将排除两个属性匹配到的所有类型/方法。

NonPooledAttribute

前面介绍了可以通过
PoolingExclusiveAttribute
指定当前池化对象在某些类型/方法中不进行池化操作,但由于
PoolingExclusiveAttribute
需要直接应用到池化类型上,所以如果你使用了第三方类库中的池化类型,此时你无法直接将
PoolingExclusiveAttribute
应用到该池化类型上。针对此类情况,可以使用
NonPooledAttribute
表明当前方法不进行池化操作。

public class TestItem1 : IPoolItem
{
    public bool TryReset() => true;
}
public class TestItem2 : IPoolItem
{
    public bool TryReset() => true;
}
public class TestItem3 : IPoolItem
{
    public bool TryReset() => true;
}

public class Test
{
    [NonPooled]
    public void M()
    {
        // 由于方法应用了NonPooledAttribute,以下三个new操作都不会替换为对象池操作
        var item1 = new TestItem1();
        var item2 = new TestItem2();
        var item3 = new TestItem3();
    }
}

有的时候你可能并不是希望方法里所有的池化类型都不进行池化操作,此时可以通过
NonPooledAttribute
的两个属性
Types

Pattern
指定不可进行池化操作的池化类型。
Types

Type
类型数组,数组中的所有类型在当前方法中均不可进行池化操作;
Pattern

string
类型AspectN类型表达式,所有匹配的类型在当前方法中均不可进行池化操作。

public class Test
{
    [NonPooled(Types = [typeof(TestItem1)], Pattern = "*..TestItem3")]
    public void M()
    {
        // TestItem1通过Types不允许进行池化操作,TestItem3通过Pattern不允许进行池化操作,仅TestItem2可进行池化操作
        var item1 = new TestItem1();
        var item2 = new TestItem2();
        var item3 = new TestItem3();
    }
}

AspectN类型表达式灵活多变,支持逻辑非操作符
!
,所以可以很方便的使用AspectN类型表达式仅允许某一个类型,比如上面的示例可以简单改为
[NonPooled(Pattern = "!TestItem2")]
,更多AspectN表达式说明,详见:
https://github.com/inversionhourglass/Shared.Cecil.AspectN/blob/master/README.md

NonPooledAttribute
不仅可以应用于方法层级,还可以应用于类型和程序集。应用于类等同于应用到类的所有方法上(包括属性和构造方法),应用于程序集等同于应用到当前程序集的所有方法上(包括属性和构造方法),另外如果在应用到程序集时没有指定
Types

Pattern
两个属性,那么就等同于当前程序集禁用Pooling。

无侵入式池化操作

看了前面的内容再看看标题,你可能就在嘀咕“这是哪门子无侵入式,这不纯纯标题党”。现在,标题的部分来了。Pooling提供了无侵入式的接入方式,适用于临时性能优化和老久项目改造,不需要实现
IPoolItem
接口,通过配置即可指定池化类型。

假设目前有如下代码:

namespace A.B.C;

public class Item1
{
    public object? GetAndDelete() => null;
}

public class Item2
{
    public bool Clear() => true;
}

public class Item3 { }

public class Test
{
    public static void M1()
    {
        var item1 = new Item1();
        var item2 = new Item2();
        var item3 = new Item3();
        Console.WriteLine($"{item1}, {item2}, {item3}");
    }

    public static async ValueTask M2()
    {
        var item1 = new Item1();
        var item2 = new Item2();
        await Task.Yield();
        var item3 = new Item3();
        Console.WriteLine($"{item1}, {item2}, {item3}");
    }
}

项目在引用
Pooling.Fody
后,编译项目时项目文件夹下会生成一个
FodyWeavers.xml
文件,我们按下面的示例修改
Pooling
节点:

<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
  <Pooling>
    <Items>
      <Item pattern="A.B.C.Item1.GetAndDelete" />
      <Item pattern="Item2.Clear" inspect="execution(* Test.M1(..))" />
      <Item stateless="*..Item3" not-inspect="method(* Test.M2())" />
	</Items>
  </Pooling>
</Weavers>

上面的配置中,每一个
Item
节点匹配一个池化类型,上面的配置中展示了全部的四个属性,它们的含义分别是:

  • pattern
    : AspectN类型+方法表达式。匹配到的类型为池化类型,匹配到的方法为状态重置方法(等同于IPoolItem的TryReset方法)。需要注意的是,重置方法必须是无参的。
  • stateless
    : AspectN类型表达式。匹配到的类型为池化类型,该类型为无状态类型,不需要重置操作即可回到对象池中。
  • inspect
    : AspectN表达式。
    pattern

    stateless
    匹配到的池化类型,只有在该表达式匹配到的方法中才会进行池化操作。当该配置缺省时表示匹配当前程序集的所有方法。
  • not-inspect
    : AspectN表达式。
    pattern

    stateless
    匹配到的池化类型不会在该表达式匹配到的方法中进行池化操作。当该配置缺省时表示不排除任何方法。最终池化类型能够进行池化操作的方法集合为
    inspect
    集合与
    not-inspect
    集合的差集。

那么通过上面的配置,
Test
在编译后的代码为:

public class Test
{
    public static void M1()
    {
        Item1 item1 = null;
        Item2 item2 = null;
        Item3 item3 = null;
        try
        {
            item1 = Pool<Item1>.Get();
            item2 = Pool<Item2>.Get();
            item3 = Pool<Item3>.Get();
            Console.WriteLine($"{item1}, {item2}, {item3}");
        }
        finally
        {
            if (item1 != null)
            {
                item1.GetAndDelete();
                Pool<Item1>.Return(item1);
            }
            if (item2 != null)
            {
                if (item2.Clear())
                {
                    Pool<Item2>.Return(item2);
                }
            }
            if (item3 != null)
            {
                Pool<Item3>.Return(item3);
            }
        }
    }

    public static async ValueTask M2()
    {
        Item1 item1 = null;
        try
        {
            item1 = Pool<Item1>.Get();
            var item2 = new Item2();
            await Task.Yield();
            var item3 = new Item3();
            Console.WriteLine($"{item1}, {item2}, {item3}");
        }
        finally
        {
            if (item1 != null)
            {
                item1.GetAndDelete();
                Pool<Item1>.Return(item1);
            }
        }
    }
}

细心的你可能注意到在
M1
方法中,
item1

item2
在重置方法的调用上有所区别,这是因为
Item2
的重置方法的返回值类型为
bool
,Poolinng会将其结果作为是否重置成功的依据,对于
void
或其他类型的返回值,Pooling将在方法成功返回后默认其重置成功。

零侵入式池化操作

看到这个标题是不是有点懵,刚介绍完无侵入式,怎么又来个零侵入式,它们有什么区别?

在上面介绍的无侵入式池化操作中,我们不需要改动任何C#代码即可完成指定类型池化操作,但我们仍需要添加Pooling.Fody的NuGet依赖,并且需要修改FodyWeavers.xml进行配置,这仍然需要开发人员手动操作完成。那如何让开发人员完全不需要任何操作呢?答案也很简单,就是将这一步放到CI流程或发布流程中完成。是的,零侵入是针对开发人员的,并不是真的什么都不需要做,而是将引用NuGet和配置FodyWeavers.xml的步骤延后到CI/发布流程中了。

优势是什么

类似于对象池这类型的优化往往不是仅仅某一个项目需要优化,这种优化可能是普遍性的,那么此时相比一个项目一个项目的修改,统一的在CI流程/发布流程中配置是更为快速的选择。另外在面对一些古董项目时,可能没有人愿意去更改任何代码,即使只是项目文件和FodyWeavers.xml配置文件,此时也可以通过修改CI/发布流程来完成。当然修改统一的CI/发布流程的影响面可能更广,这里只是提供一种零侵入式的思路,具体情况还需要结合实际情况综合考虑。

如何实现

最直接的方式就是在CI构建流程或发布流程中通过
dotnet add package Pooling.Fody
为项目添加NuGet依赖,然后将预先配置好的FodyWeavers.xml复制到项目目录下。但如果项目还引用了其他Fody插件,直接覆盖原有的FodyWeavers.xml可能导致原有的插件无效。当然,你也可以复杂点通过脚本控制FodyWeavers.xml的内容,这里我推荐一个.NET CLI工具,
Cli4Fody
可以一步完成NuGet依赖和FodyWeavers.xml配置。

<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
  <Pooling>
    <Items>
      <Item pattern="A.B.C.Item1.GetAndDelete" />
      <Item pattern="Item2.Clear" inspect="execution(* Test.M1(..))" />
      <Item stateless="*..Item3" not-inspect="method(* Test.M2())" />
	</Items>
  </Pooling>
</Weavers>

上面的FodyWeavers.xml,使用Cli4Fody对应的命令为:

fody-cli MySolution.sln \
        --addin Pooling -pv 0.1.0 \
            -n Items:Item -a "pattern=A.B.C.Item1.GetAndDelete" \
            -n Items:Item -a "pattern=Item2.Clear" -a "inspect=execution(* Test.M1(..))" \
            -n Items:Item -a "stateless=*..Item3" -a "not-inspect=method(* Test.M2())"

Cli4Fody的优势是,NuGet引用和FodyWeavers.xml可以同时完成,并且Cli4Fody并不会修改或删除FodyWeavers.xml中其他Fody插件的配置。更多Cli4Fody相关配置,详见:
https://github.com/inversionhourglass/Cli4Fody

Rougamo零侵入式优化案例

肉夹馍(Rougamo)
,一款静态代码编织的AOP组件。肉夹馍在2.2.0版本中新增了结构体支持,可以通过结构体优化GC。但结构体的使用没有类方便,不可继承父类只能实现接口,所以很多
MoAttribute
中的默认实现在定义结构体时需要重复实现。现在,你可以使用Pooling通过对象池来优化肉夹馍的GC。在这个示例中将使用Docker演示如何在Docker构建流程中使用Cli4Fody完成零侵入式池化操作:

目录结构:

.
├── Lib
│   └── Lib.csproj                       # 依赖Rougamo.Fody
│   └── TestAttribute.cs                 # 继承MoAttribute
└── RougamoPoolingConsoleApp
    └── BenchmarkTest.cs
    └── Dockerfile
    └── RougamoPoolingConsoleApp.csproj  # 引用Lib.csproj,没有任何Fody插件依赖
    └── Program.cs

该测试项目在
BenchmarkTest.cs
里面定义了两个空的测试方法
M

N
,两个方法都应用了
TestAttribute
。本次测试将在Docker的构建步骤中使用Cli4Fody为项目增加Pooling.Fody依赖并将
TestAttribute
配置为池化类型,同时设置其只能在
TestAttribute.M
方法中进行池化,然后通过Benchmark对比
M

N
的GC情况。

// TestAttribute
public class TestAttribute : MoAttribute
{
    // 为了让GC效果更明显,每个TestAttribute都将持有长度为1024的字节数组
    private readonly byte[] _occupy = new byte[1024];
}

// BenchmarkTest
public class BenchmarkTest
{
    [Benchmark]
    [Test]
    public void M() { }

    [Benchmark]
    [Test]
    public void N() { }
}

// Program
var config = ManualConfig.Create(DefaultConfig.Instance)
    .AddDiagnoser(MemoryDiagnoser.Default);
var _ = BenchmarkRunner.Run<BenchmarkTest>(config);

Dockerfile

FROM mcr.microsoft.com/dotnet/sdk:8.0
WORKDIR /src
COPY . .

ENV PATH="$PATH:/root/.dotnet/tools"
RUN dotnet tool install -g Cli4Fody
RUN fody-cli DockerSample.sln --addin Rougamo -pv 4.0.4 --addin Pooling -pv 0.1.0 -n Items:Item -a "stateless=Rougamo.IMo+" -a "inspect=method(* RougamoPoolingConsoleApp.BenchmarkTest.M(..))"

RUN dotnet restore

RUN dotnet publish "./RougamoPoolingConsoleApp/RougamoPoolingConsoleApp.csproj" -c Release -o /src/bin/publish

WORKDIR /src/bin/publish
ENTRYPOINT ["dotnet", "RougamoPoolingConsoleApp.dll"]

通过Cli4Fody最终
BenchmarkTest.M
中织入的
TestAttribute
进行了池化操作,而
BenchmarkTest.N
中织入的
TestAttribute
没有进行池化操作,最终Benchmark结果如下:

| Method | Mean     | Error   | StdDev   | Gen0   | Gen1   | Allocated |
|------- |---------:|--------:|---------:|-------:|-------:|----------:|
| M      | 188.7 ns | 3.81 ns |  6.67 ns | 0.0210 |      - |     264 B |
| N      | 195.5 ns | 4.09 ns | 11.74 ns | 0.1090 | 0.0002 |    1368 B |

完整示例代码保存在:
https://github.com/inversionhourglass/Pooling/tree/master/samples/DockerSample

在这个示例中,通过在Docker的构建步骤中使用Cli4Fody完成了对Rougamo的对象池优化,整个过程对开发时完全无感零侵入的。如果你准备用这种方法对Rougamo进行对象池优化,需要注意的是当前示例中的切面类型
TestAttribute
是无状态的,所以你需要跟开发确认所有定义的切面类型都是无状态的,对于有状态的切面类型,你需要定义重置方法并在定义Item节点时使用pattern属性而不是stateless属性。

在这个示例中还有一点你可能没有注意,只有Lib项目引用了Rougamo.Fody,RougamoPoolingConsoleApp项目并没有引用Rougamo.Fody,默认情况下应用到
BenchmarkTest

TestAttribute
应该是不会生效的,但我这个例子中却生效了。这是因为在使用Cli4Fody时还指定了Rougamo的相关参数,Cli4Fody会为RougamoPoolingConsoleApp添加了Rougamo.Fody引用,所以Cli4Fody也可用于避免遗漏项目队Fody插件的直接依赖,更多Cli4Fody的内容详见:
https://github.com/inversionhourglass/Cli4Fody

配置项


无侵入式池化操作
中介绍了
Items
节点配置,除了
Items
配置项Pooling还提供了其他配置项,下面是完整配置示例:

<Pooling enabled="true" composite-accessibility="false">
  <Inspects>
    <Inspect>any_aspectn_pattern</Inspect>
    <Inspect>any_aspectn_pattern</Inspect>
  </Inspects>
  <NotInspects>
    <NotInspect>any_aspectn_pattern</NotInspect>
    <NotInspect>any_aspectn_pattern</NotInspect>
  </NotInspects>
  <Items>
    <Item pattern="method_name_pattern" stateless="type_pattern" inspect="any_aspectn_pattern" not-inspect="any_aspectn_pattern" />
    <Item pattern="method_name_pattern" stateless="type_pattern" inspect="any_aspectn_pattern" not-inspect="any_aspectn_pattern" />
  </Items>
</Pooling>
节点路径 属性名称 用途
/Pooling enabled 是否启用Pooling
/Pooling composite-accessibility AspectN是否使用类+方法综合可访问性进行匹配。默认仅按方法可访问性进行匹配,比如类的可访问性为internal,方法的可访问性为public,那么默认情况下该方法的可访问性认定为public,将该配置设置为true后,该方法的可访问性认定为internal
/Pooling/Inspects/Inspect [节点值] AspectN表达式。
全局筛选器,只有被该表达式匹配的方法才会检查内部是否使用到池化类型并进行池化操作替换。即使是实现了
IPoolItem
的池化类型也会受限于该配置。
该节点可配置多条,匹配的方法集合为多条配置的并集。
该节点缺省时表示匹配当前程序集所有方法。
最终的方法集合是该节点配置匹配的集合与
/Pooling/NotInspects
配置匹配的集合的差集。
/Pooling/NotInspects/NotInspect [节点值] AspectN表达式。
全局筛选器,被该表达式匹配的方法的内部不会进行池化操作替换。即使是实现了
IPoolItem
的池化类型也会受限于该配置。
该节点可配置多条,匹配的方法集合为多条配置的并集。
该节点缺省时表示不排除任何方法。
最终的方法集合是
/Pooling/Inspects
配置匹配的集合与该节点配置匹配的集合的差集。
/Pooling/Items/Item pattern AspectN类型+方法名表达式。
匹配的类型会作为池化类型,匹配的方法会作为重置方法。
重置方法必须是无参方法,如果方法返回值类型为
bool
,返回值还会被作为是否重置成功的依据。
该属性与
stateless
属性仅可二选一。
/Pooling/Items/Item stateless AspectN类型表达式。
匹配的类型会作为池化类型,该类型为无状态类型,在回到对象池之前不需要进行重置。
该属性与
pattern
仅可二选一。
/Pooling/Items/Item inspect AspectN表达式。
pattern

stateless
匹配到的池化类型,只有在该表达式匹配到的方法中才会进行池化操作。
当该配置缺省时表示匹配当前程序集的所有方法。
当前池化类型最终能够应用的方法集合为该配置匹配的方法集合与
not-inspect
配置匹配的方法集合的差集。
/Pooling/Items/Item not-inspect AspectN表达式。
pattern

stateless
匹配到的池化类型不会在该表达式匹配到的方法中进行池化操作。
当该配置缺省时表示不排除任何方法。
当前池化类型最终能够应用的方法集合为
inspect
配置匹配的方法集合与该配置匹配的方法集合的差集。

可以看到配置中大量使用了AspectN表达式,了解更多AspectN表达式的用法详见:
https://github.com/inversionhourglass/Shared.Cecil.AspectN/blob/master/README.md

另外需要注意的是,程序集中的所有方法就像是内存,而AspectN就像指针,通过指针操作内存时需格外小心。将预期外的类型匹配为池化类型可能会导致同一个对象实例被并发的使用,所以在使用AspectN表达式时尽量使用精确匹配,避免使用模糊匹配。

对象池配置

对象池最大对象持有数量

每个池化类型的对象池最大持有对象数量为逻辑处理器数量乘以2
Environment.ProcessorCount * 2
,有两种方式可以修改这一默认设置。

  1. 通过代码指定

    通过
    Pool.GenericMaximumRetained
    可以设置所有池化类型的对象池最大对象持有数量,通过
    Pool<T>.MaximumRetained
    可以设置指定池化类型的对象池最大对象持有数量。后者优先级高于前者。

  2. 通过环境变量指定

    在应用启动时指定环境变量可以修改对象池最大持有对象数量,
    NET_POOLING_MAX_RETAIN
    用于设置所有池化类型的对象池最大对象持有数量,
    NET_POOLING_MAX_RETAIN_{PoolItemFullName}
    用于设置指定池化类型的对象池最大对象持有数量,其中
    {PoolItemFullName}
    为池化类型的全名称(命名空间.类名),需要注意的是,需要将全名称中的
    .
    替换为
    _
    ,比如
    NET_POOLING_MAX_RETAIN_System_Text_StringBuilder
    。环境变量的优先级高于代码指定,推荐使用环境变量进行控制,更为灵活。

自定义对象池

我们知道官方有一个对象池类库
Microsoft.Extensions.ObjectPool
,Pooling没有直接引用这个类库而选择自建对象池,是因为Pooling作为编译时组件,对方法的调用都是通过IL直接织入的,如果引用三方类库,并且三方类库在后续的更新对方法签名有所修改,那么可能会在运行时抛出
MethodNotFoundException
,所以尽量减少三方依赖是编译时组件最好的选择。

有的朋友可能会担心自建对象池的性能问题,可以放心的是Pooling对象池的实现是从
Microsoft.Extensions.ObjectPool
拷贝而来,同时精简了
ObjectPoolProvider
,
PooledObjectPolicy
等元素,保持最精简的默认对象池实现。同时,Pooling支持自定义对象池,实现
IPool
接口定义通用对象池,实现
IPool<T>
接口定义特定池化类型的对象池。下面简单演示如何通过自定义对象池将对象池实现换为
Microsoft.Extensions.ObjectPool

// 通用对象池
public class MicrosoftPool : IPool
{
    private static readonly ConcurrentDictionary<Type, object> _Pools = [];

    public T Get<T>() where T : class, new()
    {
        return GetPool<T>().Get();
    }

    public void Return<T>(T value) where T : class, new()
    {
        GetPool<T>().Return(value);
    }

    private ObjectPool<T> GetPool<T>() where T : class, new()
    {
        return (ObjectPool<T>)_Pools.GetOrAdd(typeof(T), t =>
        {
            var provider = new DefaultObjectPoolProvider();
            var policy = new DefaultPooledObjectPolicy<T>();
            return provider.Create(policy);
        });
    }
}

// 特定池化类型对象池
public class SpecificalMicrosoftPool<T> : IPool<T> where T : class, new()
{
    private readonly ObjectPool<T> _pool;

    public SpecificalMicrosoftPool()
    {
        var provider = new DefaultObjectPoolProvider();
        var policy = new DefaultPooledObjectPolicy<T>();
        _pool = provider.Create(policy);
    }

    public T Get()
    {
        return _pool.Get();
    }

    public void Return(T value)
    {
        _pool.Return(value);
    }
}

// 替换操作最好在Main入口直接完成,一旦对象池被使用就不再运行进行替换操作

// 替换通用对象池实现
Pool.Set(new MicrosoftPool());
// 替换特定类型对象池
Pool<Xyz>.Set(new SpecificalMicrosoftPool<Xyz>());

不仅仅用作对象池

虽然Pooling的意图是简化对象池操作和无侵入式的项目改造优化,但得益于Pooling的实现方式以及提供的自定义对象池功能,你可以使用Pooling完成的事情不仅仅是对象池,Pooling的实现相当于在所有无参构造方法调用的地方埋入了一个探针,你可以在这里做任何事情,下面简单举几个例子。

单例

// 定义单例对象池
public class SingletonPool<T> : IPool<T> where T : class, new()
{
    private readonly T _value = new();

    public T Get() => _value;

    public void Return(T value) { }
}

// 替换对象池实现
Pool<ConcurrentDictionary<Type, object>>.Set(new SingletonPool<ConcurrentDictionary<Type, object>>());

// 通过配置,将ConcurrentDictionary<Type, object>设置为池化类型
// <Item stateless="System.Collections.Concurrent.ConcurrentDictionary&lt;System.Type, object&gt;" />

通过上面的改动,你成功的让所有的
ConcurrentDictionary<Type, object>>
共享一个实例。

控制信号量

// 定义信号量对象池
public class SemaphorePool<T> : IPool<T> where T : class, new()
{
    private readonly Semaphore _semaphore = new(3, 3);
    private readonly DefaultPool<T> _pool = new();

    public T Get()
    {
        if (!_semaphore.WaitOne(100)) return null;

        return _pool.Get();
    }

    public void Return(T value)
    {
        _pool.Return(value);
        _semaphore.Release();
    }
}

// 替换对象池实现
Pool<Connection>.Set(new SemaphorePool<Connection>());

// 通过配置,将Connection设置为池化类型
// <Item stateless="X.Y.Z.Connection" />

在这个例子中使用信号量对象池控制
Connection
的数量,对于一些限流场景非常适用。

线程单例

// 定义现成单例对象池
public class ThreadLocalPool<T> : IPool<T> where T : class, new()
{
    private readonly ThreadLocal<T> _random = new(() => new());

    public T Get() => _random.Value!;

    public void Return(T value) { }
}

// 替换对象池实现
Pool<Random>.Set(new ThreadLocalPool<Random>());

// 通过配置,将Connection设置为池化类型
// <Item stateless="System.Random" />

当你想通过单例来减少GC压力但对象又不是线程安全的,此时便可以
ThreadLocal
实现线程内单例。

额外的初始化

// 定义现属性注入对象池
public class ServiceSetupPool : IPool<Service1>
{
    public Service1 Get()
    {
        var service1 = new Service1();
        var service2 = PinnedScope.ScopedServices?.GetService<Service2>();
        service1.Service2 = service2;

        return service1;
    }

    public void Return(Service1 value) { }
}

// 定义池化类型
public class Service2 { }

[PoolingExclusive(Types = [typeof(ServiceSetupPool)])]
public class Service1 : IPoolItem
{
    public Service2? Service2 { get; set; }

    public bool TryReset() => true;
}

// 替换对象池实现
Pool<Service1>.Set(new ServiceSetupPool());

在这个例子中使用Pooling结合
DependencyInjection.StaticAccessor
完成属性注入,使用相同方式可以完成其他初始化操作。

发挥想象力

前面的这些例子可能不一定实用,这些例子的主要目的是启发大家开拓思路,理解Pooling的基本实现原理是将临时变量的new操作替换为对象池操作,理解自定义对象池的可扩展性。也许你现在用不上Pooling,但未来的某个需求场景下,你可能可以用Pooling快速实现而不需要大量改动代码。

注意事项

  1. 不要在池化类型的构造方法中执行复用时的初始化操作


    从对象池中获取的对象可能是复用的对象,被复用的对象是不会再次执行构造方法的,所以如果你有一些初始化操作希望每次复用时都执行,那么你应该将该操作独立到一个方法中并在new操作后调用而不应该放在构造方法中


    // 修改前池化对象定义
    public class Connection : IPoolItem
    {
        private readonly Socket _socket;
    
        public Connection()
        {
            _socket = new(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
            // 不应该在这里Connect,应该将Connect操作单独独立为一个方法,然后再new操作后调用
            _socket.Connect("127.0.0.1", 8888);
        }
    
        public void Write(string message)
        {
            // ...
        }
    
        public bool TryReset()
        {
            _socket.Disconnect(true);
            return true;
        }
    }
    // 修改前池化对象使用
    var connection = new Connection();
    connection.Write("message");
    
    // 修改后池化对象定义
    public class Connection : IPoolItem
    {
        private readonly Socket _socket;
    
        public Connection()
        {
            _socket = new(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
        }
    
        public void Connect()
        {
            _socket.Connect("127.0.0.1", 8888);
        }
    
        public void Write(string message)
        {
            // ...
        }
    
        public bool TryReset()
        {
            _socket.Disconnect(true);
            return true;
        }
    }
    // 修改后池化对象使用
    var connection = new Connection();
    connection.Connect();
    connection.Write("message");
    
  2. 仅支持将无参构造方法的new操作替换为对象池操作


    由于复用的对象无法再次执行构造方法,所以构造参数对于池化对象毫无意义。如果希望通过构造参数完成一些初始化操作,可以将新建一个初始化方法接收这些参数并完成初始化,或通过属性接收这些参数。

    Pooling在编译时会检查new操作是否调用了无参构造方法,如果调用了有参构造方法,将不会将本次new操作替换为对象池操作。

  3. 注意不要将池化类型实例进行持久化保存


    Pooling的对象池操作是方法级别的,也就是池化对象在当前方法中创建也在当前方法结束时释放,不可将池化对象持久化到字段之中,否则会存在并发使用的风险。如果池化对象的声明周期跨越了多个方法,那么你应该手动创建对象池并手动管理该对象。

    Pooling在编译时会进行简单的持久化排查,对于排查出来的池化对象将不进行池化操作。但需要注意的是,这种排查仅可排查一些简单的持久化操作,无法排查出复杂情况下的持久化操作,比如你在当前方法中调用另一个方法传入了池化对象实例,然后在被调用方法中进行持久化操作。所以根本上还是需要你自己注意,避免将池化对象持久化保存。

  4. 需要编译时进行对象池操作替换的程序集都需要引用Pooling.Fody


    Pooling的原理是在编译时检查所有方法(也可以通过配置选择部分方法)的MSIL,排查所有newobj操作完成对象池替换操作,触发该操作是通过Fody添加了一个MSBuild任务完成的,而只有当前程序集直接引用了Fody才能够完成添加MSBuild任务这一操作。Pooling.Fody通过一些配置使得直接引用Pooling.Fody也可完成添加MSBuild任务的操作。

  5. 多个Fody插件同时使用时的注意事项


    当项目引用了一个Fody插件时,在编译时会自动生成一个
    FodyWeavers.xml
    文件,如果在
    FodyWeavers.xml
    文件已存在的情况下再引用一个其他Fody插件,此时再编译,新的插件将不会追加到
    FodyWeavers.xml
    文件中,需要手动配置。同时在引用多个Fody插件时需要注意他们在
    FodyWeavers.xml
    中的顺序,
    FodyWeavers.xml
    顺序对应着插件执行顺序,部分Fody插件可能存在功能交叉,不同的顺序可能产生不同的效果。

AspectN

在文章的最后再提一下AspectN,之前一直称其为AspectJ-Like表达式,因为确实是参照AspectJ表达式的格式设计的,不过一直这么叫也不是办法,现在按照惯例更名为AspectN表达式(搜了一下,.NET里面没有这个名词,应该不存在冲突)。AspectN最早起源于肉夹馍2.0,用于提供更加精确的切入点匹配,现在再次投入到Pooling中使用。

在使用Fody或直接使用Mono.Cecil开发MSBuild任务插件时,如何查找到需要修改的类型或方法永远是首要任务。最常用的方式便是通过类型和方法上的Attribute元数据进行定位,但这样做基本确定了必须要修改代码来添加Attribute应用,这是侵入性的。AspectN提供了非侵入式的类型和方法匹配机制,字符串可承载的无穷信息给予了AspectN无限的精细化匹配可能。很多Fody插件都可以借助AspectN实现无侵入式代码织入,比如ConfigureAwait.Fody,可以使用AspectN实现通过配置指定哪些类型或方法需要应用ConfigureAwait,哪些不需要。

AspectN不依赖于Fody,仅依赖于Mono.Cecil,如果你有在使用Fody或Mono.Cecil,或许可以尝试一下AspectN(
https://github.com/inversionhourglass/Shared.Cecil.AspectN
)。AspectN是一个共享项目(Shared Project),没有发布NuGet,也没有依赖具体Mono.Cecil的版本,使用AspectN你需要将AspectN克隆到本地作为共享项目直接引用,如果你的项目使用git进行管理,那么推荐将AspectN作为一个submodule添加到你的仓库中(可以参考
Rougamo

Pooling
)。