分类 其它 下的文章

前言

在C# 9版本中引入了一项新特性:
顶级语句
,这一特性允许在不显式定义 Main 方法的情况下直接编写代码。

传统的写法

namespace TestStatements
{
    internal class Program
    {
        static void Main(string[] args)
        {
            foreach (var arg in args)
            {
                Console.WriteLine(arg);
            }
            Console.WriteLine("Hello, 追逐时光者!");
        }
    }
}

顶级语句写法

foreach (var arg in args)
{
    Console.WriteLine(arg);
}
Console.WriteLine("Hello, 追逐时光者!");

顶级语句的优势

  • 省去了 Main 方法和命名空间声明,使得代码更加简洁。
  • 特别适合编写简单的控制台应用、脚本和演示代码。
  • 对于初学者来说,不需要了解太多复杂的语法结构就可以开始编写 C# 程序。

顶级语句的不足

  • 顶级语句更适合于简单的程序,对于大型复杂的项目,传统的 Main 方法和命名空间这些还是非常有必要的。
  • 对于习惯了传统结构的开发者来说,顶级语句可能会让代码的组织结构显得不够明确。
  • 如果与其他 C# 版本或一些特定的项目结构混用,可能会导致兼容性问题。

最后总结

顶级语句通过简化代码结构,降低了学习曲线并提高了开发效率,特别适合初学者和编写简单程序的场景。然而,在大型项目中,传统的代码结构依然是必要的。因此,顶级语句和传统方法各有其适用的场景和优势,开发者可以根据具体需求选择使用哪种方式。对于我个人而言还是比较喜欢传统的写法,看起来更直观且代码的组织结构分明。

参考文章

Vision Transformer
(
ViT
) 架构传统上采用基于网格的方法进行标记化,而不考虑图像的语义内容。论文提出了一种模块化的超像素非规则标记化策略,该策略将标记化和特征提取解耦,与当前将两者视为不可分割整体的方法形成了对比。通过使用在线内容感知标记化以及尺度和形状不变的位置嵌入,与基于图像块的标记化和随机分区作为基准进行了对比。展示了在提升归因的真实性方面的显著改进,在零样本无监督密集预测任务中提供了像素级的粒度,同时在分类任务中保持了预测性能。

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

论文: A Spitting Image: Modular Superpixel Tokenization in Vision Transformers

Introduction


在卷积架构之后,
Vision Transformers
(
ViTs
) 已成为视觉任务的焦点。在最初的语言模型的
Transformer
中,标记化是一个至关重要的预处理步骤,旨在基于预定的熵度量最佳地分割数据。随着模型被适配于视觉任务,标记化简化为将图像分割为正方形的图像块。这种方法被证明是有效的,很快成为了标准方法,成为架构的一个重要组成部分。

尽管取得了明显的成功,论文认为基于图像块的标记化存在固有的局限性。首先,标记的尺度通过固定的图像块大小与模型架构严格绑定,忽视了原始图像中的冗余。这些局限性导致在较高分辨率下计算量显著增加,因为复杂度和内存随标记数量呈平方级增长。此外,规则的分割假设了语义内容分布的固有均匀性,从而高效地降低了空间分辨率。

随后,若干研究利用注意力图来可视化类标记的归因,以提高可解释性,这常应用于密集预测任务。然而,正方形分割产生的注意力图在图像块表示中会引起分辨率的丧失,进而无法本质上捕捉原始图像的分辨率。对于像素级粒度的密集预测,需要一个单独的解码器进行放大处理。

Motivation

论文从原始的
ViT
架构中退一步,重新评估基于图像块的标记化的作用。通过关注架构中这个被忽视的组件,将图像分割定义为一个自适应模块化标记器的角色,这是
ViTs
中未被充分利用的潜力。

与正方形分割相比,超像素提供了一个机会,通过允许尺度和形状的适应性,同时利用视觉数据中的固有冗余来缓解基于图像块的标记化的缺陷。超像素已被证明与图像中的语义结构更好地对齐,这为在视觉
Transformer
架构中的潜在用途提供了依据。论文将标准
ViTs
中的经典正方形标记化与超像素标记化模型(
SPiT
)进行比较,并使用随机
Voronoi
标记化(
RViT
)(明确定义的数学对象,用于镶嵌平面)作为对照,后者因其作为平面镶嵌的数学对象而被选中,三种标记化方案在图
1
中进行了说明。

Contributions

论文的研究引出了三个具体的问题:(
a
)对正方形图像块的严格遵守是否必要?(
b
)不规则分割对标记化表示有什么影响?(
c
)标记化方案是否可以设计为视觉模型中的一个模块化组件?

经过实验验证,论文得到了以下结论:

  1. Generalized Framework
    :超像素标记化作为模块化方案中推广到了
    ViTs
    ,为视觉任务提供更丰富的
    Transformer
    空间,其中
    Transformer
    主干与标记化框架是独立的。
  2. Efficient Tokenization
    :提出了一种高效的在线标记化方法,该方法在训练和推理时间上具有竞争力,同时在分类任务中表现出色。
  3. Refined Spatial Resolution
    :超像素标记化提供了语义对齐的标记,具有像素级的粒度。与现有的可解释性方法相比,论文的方法得到更显著的归因,并且在无监督分割中表现出色。
  4. Visual Tokenization
    :论文的主要贡献是引入了一种新颖的方法来思考
    ViTs
    中的标记化问题,这是建模过程中的一个被忽视但核心的组成部分。

论文的主要目标是评估
ViTs
的标记化方案,强调不同标记化方法的内在特性。为了进行公平的比较分析,使用基础的
ViT
架构和既定的训练协议进行研究。因此,论文设计实验以确保与知名基线进行公平比较,且不进行架构优化。这种受控的比较对于将观察到的差异归因于标记化策略至关重要,并消除了特定架构或训练方案带来的混杂因素。

Methodology


为了评估和对比不同的标记化策略,需要对图像进行分割并从这些分割中提取有意义的特征。虽然可以使用多种深度架构来完成这些任务,但这些方法会给最终模型增加一层复杂性,从而使任何直接比较标记化策略的尝试失效。此外,这也会使架构之间的有效迁移学习变得复杂。基于这一原因,论文构建了一个有效的启发式超像素标记化器,并提出了一种与经典
ViT
架构一致的非侵入性特征提取方法,以便进行直接比较。

  • Notation

定义
\(H {\mkern1mu\times\mkern1mu} W = \big\{(y, x) : 1 \leq y \leq h, 1 \leq x \leq w\big\}\)
表示一个空间维度为
\((h, w)\)
的图像的坐标,并让
\(\mathcal I\)
为映射
\(i \mapsto (y, x)\)
的索引集。将一个
\(C\)
通道的图像视为信号
\({\xi\colon \mathcal I \to \mathbb R^C}\)
,定义向量化操作符
\(\mathrm{vec}\colon \mathbb{R}^{d_1 {\mkern1mu\times\mkern1mu} \dots {\mkern1mu\times\mkern1mu} d_n} \to \mathbb{R}^{d_1 \dots d_n}\)
,并用
\(f(g(x)) = (f \circ g)(x)\)
表示函数的组合。

Framework

论文通过允许模块化的标记化器和不同的特征提取方法,来对经典
ViT
架构进行泛化。值得注意的是,经典的
ViT
通常被呈现为一个由三部分组成的系统,包括一个标记嵌入器
\(g\)
、一个由一系列注意力块组成的主干网络
\(f\)
,以及一个后续的预测头
\(h\)
。实际上,可以将图像块嵌入模块重写为一个由三个部分组成的模块化系统,包含一个标记化器
\(\tau\)
、一个特征提取器
\(\phi\)
和一个嵌入器
\(\gamma\)
,使得
\(g = \gamma \circ \phi \circ \tau\)

这些是原始架构中的固有组件,但在简化的标记化策略下被掩盖了。这为模型作为一个五部分系统提供了更完整的评估。

\[\label{eqn:pipeline}
\begin{align}
\Phi(\xi;\theta) &= (h \circ f \circ g)(\xi; \theta), \\
&= (h \circ f \circ \gamma \circ \phi \circ \tau)(\xi; \theta),
\end{align}
\]

其中
\(\theta\)
表示模型的可学习参数集合。在标准的
ViT
模型中,标记化器
\(\tau\)
将图像分割为固定大小的方形区域。这直接提供了向量化的特征,因为这些图像块具有统一的维度和顺序,因此在标准的
ViT
架构中,
\(\phi = \mathrm{vec}\)
。嵌入器
\(\gamma\)
通常是一个可学习的线性层,将特征映射到特定架构的嵌入维度。另一种做法是,将
\(g\)
视为一个卷积操作,其卷积核大小和步幅等于所需的图像块大小
\(\rho\)

Partitioning and Tokenization

语言任务中的标记化需要将文本分割为最优信息量的标记,这类似于超像素将空间数据分割为离散的连通区域。层级超像素是一种高度可并行化的基于图的方法,适合用于在线标记化。基于此,论文提出了一种新方法,该方法在每一步
\(t\)
中进行批量图片图的完全并行聚合,此外还包括对大小和紧凑性的正则化。在每一步产生不同数量的超像素,动态适应图像的复杂性。

  • Superpixel Graphs


\(E^{(0)} \subset \mathcal I {\mkern1mu\times\mkern1mu} \mathcal I\)
表示在
\(H {\mkern1mu\times\mkern1mu} W\)
下的四向邻接边。将超像素视为一个集合
\(S \subset \mathcal I\)
,并且如果对于
\(S\)
中的任意两个像素
\(p\)

\(q\)
,存在一个边的序列
\(\big((i_j, i_{j+1}) \in E^{(0)}\big)_{j=1}^{k-1}\)
,使得
\(i_1 = p\)

\(i_k = q\)
,则认为
\(S\)
是连通的。如果对于任意两个不同的超像素
\(S\)

\(S' \in \pi\)
,它们的交集
\(S \cap S' = \emptyset\)
,并且所有超像素的并集等于图像中所有像素位置的集合,即
\(\bigcup_{S \in \pi^{(t)}} S = \mathcal I\)
,那么一组超像素就形成了图像的分割
\(\pi\)


\(\Pi(\mathcal I) \subset 2^{2^{\mathcal I}}\)
表示图像的所有分割的空间,并且有一系列分割
\((\pi^{(t)})_{t=0}^T\)
。如果对于
\(\pi^{(t)}\)
中的所有超像素
\(S\)
,存在一个超像素
\(S' \in \pi^{(t+1)}\)
使得
\(S \subseteq S'\)
,则认为分割
\(\pi^{(t)}\)
是另分割
\(\pi^{(t+1)}\)
的细化,用
\(\pi^{(t)} \sqsubseteq \pi^{(t+1)}\)
来表示。目标是构造一个 像素索引的
\(T\)
级层级分割
\({\mathcal H = \big( \pi^{(t)} \in \Pi(\mathcal I) : \pi^{(t)} \sqsubseteq \pi^{(t+1)} \big)_{t=0}^T}\)
,使得每个超像素都是连通的。

为了构造
\(\mathcal H\)
,通过并行边收缩(用一个顶点代替多个顶点,被代替的点的内部边去掉,外部边由代替的顶点继承)的方式逐步连接顶点,以更新分割
\({\pi^{(t)} \mapsto \pi^{(t+1)}}\)
。通过将每个层级视为图
\(G^{(t)} = (V^{(t)}, E^{(t)})\)
来实现,其中每个顶点
\(v \in V^{(t)}\)
是分割
\(\pi^{(t)}\)
中一个超像素的索引,每条边
\((u, v) \in E^{(t)}\)
代表在
\(t = 0, \dots, T\)
层级中相邻的超像素。因此,初始图像可以表示为一个网格图
\({G^{(0)} = (V^{(0)}, E^{(0)})}\)
,对应于单像素分割
\({\pi^{(0)} = \big\{\{i\} : i \in \mathcal I \big\}}\)

  • Weight function

为了应用边收缩,定义一个边权重函数
\(w_\xi^{(t)}\colon E^{(t)} \to \mathbb R\)
。保留图中的自环(超像素包含的节点互指,合并后表现为超像素指向自身。这里保留自环是因为不一定每一次都需要加入新像素,自环权重高于其它节点时则不加),通过相对大小对自环边进行加权作为正则化器,对区域大小的方差进行约束。对于非自环边,使用平均特征
\(\mu_\xi^{(t)}(v) = \sum_{i \in \pi^{(t)}_v} \xi(i) / \lvert \pi^{(t)}_v \rvert\)
并应用相似性函数
\(\mathrm{sim}\colon E^{(t)} \to \mathbb{R}\)
作为权重。自环的权重使用在层级
\(t\)
时,区域大小的特征均值
\(\mu^{(t)}_{\lvert \pi \rvert}\)
和特征标准差
\(\sigma^{(t)}_{\lvert \pi \rvert}\)
进行加权。

整体权重计算如下:

\[\begin{align}
w_\xi(u, v) = \begin{cases}
\mathrm{sim}\Big(\mu_\xi^{(t)}(u), \mu_\xi^{(t)}(v)\Big), & \text{for $u \neq v$;} \\
\Big(\lvert \pi^{(t)}_u \rvert - \mu_{\lvert \pi \rvert}^{(t)}\Big) / \sigma_{\lvert \pi \rvert}^{(t)}, & \text{otherwise.}
\end{cases}
\end{align}
\]

紧凑性可以通过计算无穷范数密度来选择性地进行调节:

\[\begin{equation}
\delta_\infty(u, v) = \frac{4 (\lvert \pi_u \rvert^{(t)} + \lvert \pi_v \rvert^{(t)})}{\mathrm{per}_\infty(u,v)^2},
\end{equation}
\]

其中
\(\mathrm{per}_\infty\)
是包围超像素
\(u\)

\(v\)
的边界框的周长。这突出了两个相邻的超像素
\(u\)

\(v\)
在其边界框内的紧密程度,从而得出了一个正则化的权重函数。

\[\begin{equation}
w_\xi^{(t)}(u,v;\lambda) = \lambda \delta_\infty(u,v) + (1 - \lambda)w_\xi^{(t)}(u, v)
\end{equation}
\]

其中
\(\lambda \in [0,1]\)
作为紧凑性的超参数。

  • Update rule

使用贪婪的并行更新规则进行边收缩,使得每个超像素与具有最高边权重的相邻超像素连接,包括所有
\(G^{(t)}\)
中的自环,适用于
\(t \geq 1\)
。设
\(\mathfrak{N}^{(t)}(v)\)
表示在第
\(t\)
层中索引为
\(v\)
的超像素的相邻顶点的邻域,构造一个中间边集:

\[\begin{align}
\hat E^{(t)} = \bigg(v, \underset{u \in \mathfrak{N}^{(t)}(v)}{\text{arg\ max}}\ w_\xi(u, v; \lambda) : v \in V^{(t)}\bigg).
\end{align}
\]

然后,传递闭包
\(\hat E_+^{(t)}\)
(传递闭包是指多个二元关系存在传递性,通过该传递性推导出更多的关系,比如可从A->B和B->C中推导出A->C,这里即是
\(\hat E^{(t)}\)
的连通分量)可明确地得出一个映射
\({V^{(t)} \mapsto V^{(t+1)}}\)
,使得

\[\begin{align}
\pi^{(t+1)}_v = \bigcup_{u \in \hat{\mathfrak{N}}_+^{(t)}(v)} \pi^{(t)}_u,
\end{align}
\]

其中
\(\hat{\mathfrak{N}}_+^{(t)}(v)\)
表示在
\(\hat E_+^{(t)}\)
中顶点
\(v\)
的连通分量。这个分区更新规则确保了在
\((t+1)\)
层的每个分区都是一个连通区域,因为它是通过合并具有最高边权重的相邻超像素形成的,如图
3
中所示。

  • Iterative refinement

重复计算聚合映射、正则化边权重和边收缩的步骤,直到达到所需的层级数
\(T\)
。在每一层,分区变得更加粗糙,表示图像中更大的同质区域。层级结构提供了图像的多尺度表示,捕捉了局部和全局结构。在第
\(T\)
层,即可获得一系列分区
\((\pi^{(t)})_{t=0}^T\)
,其中每一层的分区在层级
\(t\)
时是一个连通区域,并且对所有
\(t\)

\({\pi^{(t)} \sqsubseteq \pi^{(t+1)}}\)

在经典的
ViT
分词器中,论文尝试验证不同的
\(T\)
和图像块大小
\(\rho\)
分别产生的标记数量之间的关系。设
\(N_\mathrm{SPiT}\)

\(N_\mathrm{ViT}\)
分别表示
SPiT
分词器和
ViT
分词器的标记数量,这种关系为
\(\mathbb{E}(T \mid N_\mathrm{SPiT} = N_\mathrm{ViT}) = \log_2 \rho\)
,无论图像大小如何。

Feature Extraction with Irregular Patches

虽然
ViT
架构中选择正方形图像块是出于简洁性的考虑,但这自然也反映了替代方案所带来的挑战。非规则的图像块是不对齐的,表现出不同的形状和维度,并且通常是非凸的(形状非常不规则)。这些因素使得将非规则图像块嵌入到一个共同的内积空间中变得不容易。除了保持一致性和统一的维度外,论文还提出任何此类特征需要捕捉的最小属性集;即颜色、纹理、形状、尺度和位置。

  • Positional Encoding

ViTs
通常为图像网格中的每个图像块使用可学习的位置嵌入。论文注意到这对应于下采样图像的位置直方图,可以通过使用核化方法将可学习的位置嵌入扩展到处理更复杂的形状、尺度和位置,对每个
\(n=1,\dots,N\)
分区的超像素
\(S_n\)
的坐标应用联合直方图。首先,将位置归一化,使得所有
\((y', x') \in S_n\)
都落在
\([-1, 1]^2\)
范围内。设定固定
\(\beta\)
为每个空间方向上的特征维度,特征由高斯核
\(K_\sigma\)
提取:

\[\begin{equation}
\hat\xi^{\text{pos}}_{n,y,x} = \mathrm{vec}\Bigg(\sum_{(y_j, x_j) \in S_n} K_\sigma (y - y_j, x - x_j) \Bigg),
\end{equation}
\]

通常,带宽
\(\sigma\)
取值较低,范围为
\([0.01, 0.05]\)
。这样,实际上就编码了图像块在图像中的位置,以及其形状和尺度。

  • Color Features

为了将原始像素数据中的光强信息编码到特征中,使用双线性插值将每个图像块的边界框插值到固定分辨率
\(\beta {\mkern1mu\times\mkern1mu} \beta\)
,同时屏蔽其他周围图像块中的像素信息。这些特征本质上捕捉了原始图像块的原始像素信息,但经过重采样并缩放到统一的维度。将特征提取器
\(\phi\)
称为插值特征提取器,
RGB
特征也被归一化到
\([-1, 1]\)
并向量化,使得
\(\hat\xi^{\text{col}} \in \mathbb{R}^{3\beta^2}\)

  • Texture Features

梯度算子提供了一种简单而稳健的纹理信息提取方法。基于改进的旋转对称性和离散化误差,论文选择使用
Scharr
提出的梯度算子。将该算子归一化,使得
\(\nabla \xi \in [-1, 1]^{H{\mkern1mu\times\mkern1mu} W{\mkern1mu\times\mkern1mu} 2}\)
,其中最后两个维度对应于梯度方向
\(\nabla y\)

\(\nabla x\)
。与位置特征的处理过程类似,在每个超像素
\(S_n\)
内部对梯度应用高斯核构建联合直方图,使得
\(\hat\xi^{\text{grad}}_n \in \mathbb{R}^{\beta^2}\)

最终特征模态被拼接为
\(\hat\xi_n = [\hat\xi^{\text{col}}_n, \hat\xi^{\text{pos}}_n, \hat\xi^{\text{grad}}_n] \in \mathbb{R}^{5\beta^2}\)
。虽然论文提出的梯度特征与标准的
ViT
架构相同,但它们代表了额外的信息维度。因此,论文评估了包括或省略梯度特征的效果。对于那些省略这些特征的模型,即
\(\hat\xi_n \setminus \hat\xi^{\text{grad}}_n = [\hat\xi^{\text{col}}_n, \hat\xi^{\text{pos}}_n] \in \mathbb{R}^{4\beta^2}\)
,称该提取器
\(\phi\)
为不包括梯度的提取器。

Generalization of Canonical ViT

在设计上,论文的框架是对标准
ViT
标记化的一个概括,等同于使用固定图像块大小
\(\rho\)
和排除梯度的插值特征提取的标准图像块嵌入器。


\(\tau^*\)
表示一个固定图像块大小
\(\rho\)
的标准
ViT
标记化器,
\(\phi\)
表示一个排除梯度的插值特征提取器,
\(\gamma^*\)

\(\gamma\)
表示具有等效线性投影的嵌入层,其中
\(L^*_\theta = L_\theta\)
。设
\(\hat\xi^{\text{pos}} \in \mathbb{R}^{N {\mkern1mu\times\mkern1mu} \beta^2}\)
表示在
\(\tau^*\)
分割下的联合直方图位置嵌入矩阵。那么,对于维度
\(H = W = \beta^2 = \rho^2\)
,由
\(\gamma \circ \phi \circ \tau^*\)
给出的嵌入与由
\(\gamma^* \circ \phi^* \circ \tau^*\)
给出的标准
ViT
嵌入在数量上是等效的。

Experiments and Results




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

work-life balance.

前言

给大家推荐一款开源的 Winform 控件库,可以帮助我们开发更加美观、漂亮的 WinForm 界面。

项目介绍

SunnyUI.NET 是一个基于 .NET Framework 4.0+、.NET 6、.NET 7 和 .NET 8 的 WinForm 开源控件库,同时也提供了工具类库、扩展类库和多页面开发框架。

基于 .NET Framework 4.0,采用原生控件开发,参考 Element 主题风格,包含超过 70 个常用控件,如按钮、编辑框、下拉框、数据表格、工控仪表和统计图表,满足常规开发需求,每个控件都精心设计,注重细节。

提供 11 个 Element 风格主题和 6 个其他主题,支持多彩主题模式自定义。包含主题管理组件 UIStyleManager,可自由切换主题。

项目架构

控件库拥有不同的主题、字体和语言、包括了常见的组件Button、Label、CheckBox、TreeView和TabControl、对话框、进度条、消息提示,提供了扩展库和工具库,方便我们开发和使用,具体如下图所示:

项目环境

1、源码编译环境

VS 2022,安装.NET Framework 4.0 目标包

编译源码,.NET 8 需要 VS 2022 17.8+ 版本,或者修改 SunnyUI.csproj 文件的 TargetFrameworks 属性以适应 VS 环境

2、动态库应用环境

VS 2010 及以上均可,支持.NET Framework 4.0+、.NET 6、.NET 7

推荐通过 Nuget 安装

PM->Install-Package SunnyUI

或者通过 Nuget 搜索 SunnyUI 安装,不支持(.NET Framework 4
Client Profile
)。

项目展示

1、主题

SunnyUI为了避免视觉传达差异,使用一套特定的调色板来规定颜色,为你所搭建的产品提供一致的外观视觉感受。

主要颜色参照Element(
https://element.eleme.cn/

主色

SunnyUI主要品牌颜色是鲜艳、友好的蓝色。

Style主题

SunnyUI包含 Element 风格主题 11 个,DotNetBar 主题 3 个,其他主题 2 个,包含主题管理组件 UIStyleManager,可自由切换主题。

UIStyle.Blue

UIStyle.Purple

2、国际化

SunnyUI 控件内部默认使用中文,若希望使用其他语言,则需要进行多语言设置。

本页面所描述的国际化是针对SunnyUI内的按钮、标题等中文资源的国际化

如您开发的系统需要做国际化请自行开发。

常用的按钮、标题、提示等文字已经设置为静态字符串变量,存于ULocalize.cs文件中。

public static classUILocalize
{
public static string InfoTitle = "提示";public static string SuccessTitle = "正确";public static string WarningTitle = "警告";public static string ErrorTitle = "错误";public static string AskTitle = "提示";public static string InputTitle = "输入";public static string CloseAll = "全部关闭";public static string OK = "确定";public static string Cancel = "取消";public static string GridNoData = "[ 无数据 ]";public static string GridDataLoading = "数据加载中 ......";public static string GridDataSourceException = "数据源必须为DataTable或者List";
}

可以重写UILocalize类静态变量值来改变语言。

UILocalizeHelper类已经包含中文和英文的默认配置函数:

public static classUILocalizeHelper
{
public static voidSetEN()
{
UILocalize.InfoTitle
= "Info";
UILocalize.SuccessTitle
= "Success";
UILocalize.WarningTitle
= "Warning";
UILocalize.ErrorTitle
= "Error";
UILocalize.AskTitle
= "Query";
UILocalize.InputTitle
= "Input";
UILocalize.CloseAll
= "Close all";
UILocalize.OK
= "OK";
UILocalize.Cancel
= "Cancel";
UILocalize.GridNoData
= "[ No data ]";
UILocalize.GridDataLoading
= "Data loading ......";
UILocalize.GridDataSourceException
= "The data source must be DataTable or List";
}
public static voidSetCH()
{
UILocalize.InfoTitle
= "提示";
UILocalize.SuccessTitle
= "正确";
UILocalize.WarningTitle
= "警告";
UILocalize.ErrorTitle
= "错误";
UILocalize.AskTitle
= "提示";
UILocalize.InputTitle
= "输入";
UILocalize.CloseAll
= "全部关闭";
UILocalize.OK
= "确定";
UILocalize.Cancel
= "取消";
UILocalize.GridNoData
= "[ 无数据 ]";
UILocalize.GridDataLoading
= "数据加载中 ......";
UILocalize.GridDataSourceException
= "数据源必须为DataTable或者List";
}
}

如需要其他语言,则在自己程序里写函数更新UILocalize类静态变量值即可。

中英文效果展示:

3、字体图标

SunnyUI的字体图标目前主要有两个:

FontAwesome

https://fontawesome.com/

https://github.com/FortAwesome/Font-Awesome

ElegantIcons.ttf V1.0

https://www.elegantthemes.com/blog/resources/elegant-icon-font

这两个都是目前 Web 开发常用的字体图标,SunnyUI经过精心研发,将他们用于.NET Winform开发,省去了到处找图标的麻烦。

SunnyUI 常用字体图标的控件为 UISymbolButton 和 UISymbolLabel

字体图标的选择方法是设置UISymbolButton和UISymbolLabel的以下属性

Symbol:字体图标(int)

SymbolSize:字体图标的大小(int)

4、控件库

常用的各类组件都有,具体内容可以下载源码学习。

5、窗体

UIForm
常用的窗体基类

UILoginForm:登录窗体基类

6、多页面框架

SunnyUI多页面框架由框架和页面构成。最基本的实现是框架(IFrame)由UIForm实现,页面由(UIPage)实现。

在UIForm放置一个UITabControl,将多个UIPage放置于UIForm的UITabControl的TabPage内。

通过PageIndex(正整数,唯一)进行页面的关联和切换。

听起来有点复杂,其实主要就IFrame接口的三个函数:AddPage,ExistPage,SelectPage。

7、工具类库

简易的 Json 静态类库,可以在不引用 NewtonJson 即可简单处理 Json 对象。当然如果有复杂需求,第三方库还是推荐NewtonJson。另外在.NET 5,System.Text.Json的性能已经非常不错了,也可以尝试。

项目地址

GitHub:
https://github.com/yhuse/SunnyUI

Gitee:
https://gitee.com/yhuse/SunnyUI

帮助文档:
https://gitee.com/yhuse/SunnyUI/wikis/pages

最后

如果你觉得这篇文章对你有帮助,不妨点个赞支持一下!你的支持是我继续分享知识的动力。如果有任何疑问或需要进一步的帮助,欢迎随时留言。

也可以加入微信公众号
[DotNet技术匠]
社区,与其他热爱技术的同行一起交流心得,共同成长!
优秀是一种习惯,欢迎大家留言学习!

作者:来自 vivo 互联网存储团队- Xu Xingbao

Redis 集群经常需要进行在线水平扩缩容,实际操作过程中发现迁移期间服务时延剧烈抖动,业务侧感知明显,为了应对以上问题对原生 Redis 集群 slot 迁移功能进行优化改造。

一、背景介绍

Redis 集群服务在互联网公司被广泛使用,众所周知服务集群化可以突破单节点的能力瓶颈,带来规模、可用性、扩展性等多方面的收益。在实际使用 Redis 集群的过程中,发现在进行涉及集群数据迁移的水平扩缩容操作时,业务侧多次反馈 Redis 请求的时延升高问题,甚至发生过扩容操作导致集群节点下线的可用性故障,并进一步引发迁移流程中断、节点间数据脑裂等一系列严重影响,给运维同事带来极大困扰,严重影响线上服务的稳定。

二、问题分析

2.1 原生迁移介绍

Redis 集群功能采用无中心架构设计,集群中各个节点都维护各自视角的集群拓扑并保存自有的分片数据,集群节点间通过 gossip 协议进行信息协调和变更通知。具体来说 Redis 集群数据管理上采用虚拟哈希槽分区机制,将数据的键通过哈希函数映射到 0~16383 整数槽内,此处的槽位在 Redis 集群设计中被称为 slot。这样实际上每一个节点只需要负责维护一部分 slot 所映射的键值数据,slot 就成为 Redis 集群管理数据的基本单位,集群扩缩容本质上就是 slot 信息和 slot 对应数据数据在节点之间的转移。Redis 集群水平扩展的能力就是基于 slot 维度进行实现,具体流程如下图所示。

上图所示的迁移步骤中,步骤 1-2 是对待迁移 slot 进行状态标记,方便满足迁移过程中数据访问,步骤 3-4 是迁移的核心步骤,这两个步骤操作会在步骤 5 调度下持续不断进行,直到待迁移 slot 的键值数据完全迁移到了目标节点,步骤 6 会在数据转移完成后进行,主要是发起集群广播消息更新集群内节点 slot 拓扑。

由于正常的迁移时一个持续的处理过程,不可避免地会出现正在迁移 slot 数据分布于迁移两端地“分裂”状态,这种状态会随着 slot 迁移的流程进行而持续存在。为了保证迁移期间正在迁移的 slot 数据能够正常读写,Redis 集群实现了下图所示的一种 ask-move 机制,如果请求访问正在迁移的 slot 数据,请求首先会按照集群拓扑正常访问到迁移的源节点,如果在源节点查询到数据则正常处理响应请求;如果在源节点没有找到请求所需数据,则会给客户端回复 ASK {ip}:{port}  消息回包。

Redis 集群智能客户端收到该回包后会按照包内节点信息找到新节点重试命令,但是由于此时目标节点还没有迁移中 slot 的所属权,所以在重试具体命令之前智能客户端会首先向目的节点发送一个 asking 命令,以此保证接下来访问迁移中 slot 数据的请求能被接受处理。由于原生迁移时按照 key 粒度进行的,一个 key 的数据要不存在源节点,要不存在目的节点,所以 Redis 集群可以通过实现上述 ask-move 机制,保证迁移期间数据访问的一致性和完整性。

2.2 迁移问题分析

(1)时延分析
根据上述原生 Redis 集群迁移操作步骤的了解,可以总结出原生迁移功能按照 key 粒度进行的,即不断扫描源节点上正在迁移的 slot 数据并发送数据给目的节点,这是集群数据迁移的核心逻辑。微观来说迁移单个 key 数据对于服务端来说包含以下操作:

  • 序列化待迁移键值对数据;
  • 通过网络连接发送序列化的数据包;
  • 等待回复(目标端接收完包并加载成功才会返回);
  • 删除本地残留的副本,释放内存。

上述操作中涉及多个耗费线程处理时长的操作,首先序列化数据是非常耗费 CPU 时间的操作,如果遇到待迁移 key 比较大线程占用时长也会随之恶化,这对于单工作线程的 Redis 服务来说是不可接受的,进一步地网络发送数据到目标节点时会同步等待结果返回,而迁移目的端又会在进行数据反序列化和入库操作后才会向源节点进行结果返回。需要注意的是在迁移期间会不断循环进行以上步骤的操作,而且这些步骤是在工作线程上连续处理的,期间无法对正常请求进行处理,所以此处就会导致服务响应时延持续突刺,这一点可以通过 slowlog 的监控数据得到验证,迁移期间会在 slowlog 抓取到大量的 migrate 和 restore 命令。

(2)ask-move 开销
正常情况下每个正在迁移的 slot 数据都会一段时间内存在数据分布在迁移的两端的情况,迁移期间该 slot 数据访问请求可以通过 ask-move 机制来保证数据一致性,但是不难看出这样的机制会导致单个请求网络访问次数出现成倍的增加,对客户端也存在一定的开销压力。另外,对于可能存在的用户采用 Lua 或者 Pipline 这种需要对单个 slot 内多 key 连续访问的场景,目前大部分集群智能客户端支持有限,可能会遇到迁移期间相关请求不能正常执行的报错。另外需要说明的是,由于 ask-move 机制的只在迁移两端的主节点上能触发,所以迁移期间从节点是不能保证数据请求结果一致性的,这对于采用读写分离方式访问集群数据的用户也非常不友好。

(3)拓扑变更开销
为了降低迁移期间数据 ask-move 的机制对请求的影响,正常情况下原生迁移每次只会操作一个 slot 迁移,这就导致对每一个迁移完成的 slot 都会触发集群内节点进行一次拓扑更新,而每次集群拓扑的更新都会触发正在执行指令的业务客户端几乎同时发送请求寻求更新集群拓扑,拓扑刷新请求结果计算开销高、结果集大,大大增加了节点的处理开销,也会造成正常服务请求时延的突刺,尤其对于连接数较大、集群节点多的集群,集中的拓扑刷新请求很容易造成节点计算资源紧张和网络拥塞,容易触发出各种服务异常告警。

(4)迁移无高可用
原生的迁移的 slot 标记状态只存在于迁移双端的主节点,其对应的从节点并不知道迁移状态,这也就导致一旦在迁移期间发生节点的 failover,迁移流程将会中断和出现 slot 状态残留,也将进一步导致迁移 slot 数据的访问请求无法正常触发 ask-move 机制而发生异常。例如迁移源节点异常,那么其 slave 节点 failover 上线,由于新主节点并不能同步到迁移状态信息,那么对于迁移中 slot 的请求就不能触发 ask 回复,如果是一个对已经迁移至目标节点的数据的写请求,新主节点会直接在本节点新增 key,导致数据出现脑裂,类似地如果处理的是已经迁移数据的读取请求也无法保证返回正确结果。

三、优化方案

3.1 优化方向思考

通过原生数据迁移机制分析,可以发现由于迁移操作涉及大量的同步阻塞操作会长时间占用工作线程,以及频繁的拓扑刷新操作,会导致请求时延不断出现上升。那么是否可以考虑将阻塞工作线程的同步操作改造成为异步线程处理呢?这样改造有非常大的风险,因为原生迁移之所以能够保证迁移期间数据访问的正确性,正是这些同步接口进行了一致性保证,如果改为异步操作将需要引入并发控制,还要考虑迁移数据请求与 slave 节点的同步协调问题,此方案也无法解决拓扑变动开销问题。所以 vivo 自研 Redis 放弃了原生按照 key 粒度进行迁移的逻辑,结合线上真实扩容需求,采用了类似主从同步的数据迁移逻辑,将迁移目标节点伪装成迁移源节点的从节点,通过主从协议来转移数据。

3.2 功能实现原理

Redis 主从同步机制是指在 Redis 主节点(Master)和从节点(Slave)之间进行数据同步和复制的过程,主从同步机制可以提高 Redis 集群的可用性,避免单点故障和数据丢失等问题。Redis 目前主从同步有全量同步和部分同步两种方式,从节点发送同步位点给主节点,如果是首次同步则需要走全量同步逻辑,主节点通过发送 RDB 基础数据文件和传播增量命令方式将数据同步给从节点;如果不是首次同步,主节点则会通过从节点同步请求中的位点等信息判断是否满足增量同步条件,优先进行增量同步以控制同步开销。由于主节点在同步期间也在持续处理新的命令请求,所以从节点对主节点的数据同步是一个动态追齐的过程,正常情况下,主节点会持续发送写命令给从节点。

基于同步机制,我们设计实现了一套如下图所示的 Redis 集群数据迁移的功能。迁移数据逻辑主要走的全量同步逻辑,迁移数据和同步数据最大的区别在于,正常情况下需要迁移的是源节点部分 slot 数据,目标节点并不需要复制源节点的全量数据,完全复用同步机制会产生不必要的开销,需要对主从同步逻辑进行修改适配。为了解决该问题,我们对相关逻辑做了一些针对性的改造。首先在同步命令交互上,针对迁移场景增加了迁移节点间 slot 信息交互,从而让迁移源节点获知需要迁移哪些 slot 到哪个节点。另外,我们还对 RDB 文件文件结构按照 slot 顺序进行了调整改造,并且将各个 slot 数据的文件起始偏移量数据作为元数据记录到 RDB 文件尾部固定位置,这样在进行迁移操作的 RDB 传输步骤时就可以方便地索引到 RDB 文件中目标 slot 数据片段。

3.3 改造效果分析

(1)时延影响小
对于 slot 迁移操作而言,主要涉及迁移源和目的两端的开销,对于基于主从同步机制实现的新 slot 迁移,其源节点主要开销在于生成 RDB 和传送网络包,正常对于请求时延影响不大。但是因为目的节点需要对较大的 RDB 文件片段数据进行接收、加载,由于目的节点迁移时也需要对正常服务请求响应,此时不再能采用类似 slave 节点将所有数据收取完以后保存本地文件,然后进行阻塞式数据加载的方案,所以新 slot 迁移功能对迁移目的节点的数据加载流程进行了针对性改造,目的节点会按照接收到的网络包粒度将数据按照下图所示进行递进式加载,即 slot 迁移目标节点每接收完一个 RDB 数据网络包就会尝试加载,每次只加载本次网络包内包含的完整元素,这样复合类型数据就可以按照 field 粒度加载,从而降低多元素大 key 数据迁移对访问时延的剧烈影响。通过这样的设计保持原来单线程简洁架构的同时,有效地控制了时延影响,所有数据变更操作都保持在工作线程进行,不需要进行并发控制。通过以上改造,基本消除了迁移大 key 对迁移目的节点时延影响。

(2)数据访问稳定
新 slot 迁移操作期间,正在迁移的数据还是存储在源节点上没有变,请求继续在源节点上正常处理,用户侧的请求不会触发 ask-move 转发机制。这样用户就不需要担心读写分离会出现数据不一致现象,在进行事务、pipeline 等方式封装执行命令时也不会出现大量请求报错的问题。迁移动作一旦完成,残留在源端的已迁移 slot 数据将成为节点的残留数据,这部分数据不会再被访问,对上述残留数据的清理被设计在 serverCron 中逐步进行,这样每一次清理多少数据可以参数化控制,可以根据需要进行个性化设置,保证数据清理对正常服务请求影响完全可控。

(3)拓扑变更少
原生的迁移功能为了降低 ask-move 机制对正常服务请求的影响,每次仅会对一个 slot 进行数据迁移,迁移完了会立即发起拓扑变更通知来集群节点转换 slot 的属主,这就导致拓扑变化的次数随着迁移 slot 的数量增加而变多,客户端也会在每一次感知到拓扑变化后发送命令请求进行拓扑更新。更新拓扑信息的命令计算开销较大,如果多条查询拓扑的命令集中处理,就会导致节点资源的紧张。新的 slot 迁移按照节点进行数据同步,可以支持同时迁移源节点的多个 slot 甚至全部数据,最后可以通过一次拓扑变更转换多个 slot 的属主,大大降低了拓扑刷新的影响。

(4)支持高可用
集群的数据迁移是一个持续的过程,这个过程可能长达几个小时,期间服务可能发生各种异常情况。正常情况下的 Redis 集群具有 failover 机制,从节点可感知节点异常以代替旧主节点进行服务。新 slot 迁移功能为了应对这样的可用性问题,将 slot 迁移状态同步给从节点,这样迁移期间如果集群迁移节点发生 failover,其从节点就可以代替旧主节点继续推进数据迁移流程,保证了迁移流程的高可用能力,避免人工干预,大大简化运维操作复杂度。

四、功能测试对比

为了验证改造后迁移功能的效果,对比自研迁移和原生迁移对请求响应的影响,在三台同样配置物理机上部署了原生和自研两套相同拓扑的集群,选择后对 hash 数据类型的 100k 和 1MB 两种大小数据分别进行了迁移测试,每轮在节点间迁移内存用量 5G 左右的数据。测试主要目的是对比改造前后数转移对节点服务时延影响,所以在实际测试时没有对集群节点进行背景流量操作,节点的时延数据采用每秒钟 ping 10 次节点的方式进行采集,迁移期间源节点和目的节点的时延监控数据入下表所示(纵轴数值单位:ms)。

通过对比以上原生和自研集群 slot 迁移期间的时延监控数据,可以看出自研 slot 迁移功能迁移数据期间迁移两端节点的请求响应时延表现非常平稳,也可以表现出经过主从复制原理改造的 Redis 集群 slot 迁移功能具备的优势和价值。

五、总结和展望

原生 Redis 集群的扩缩容功能按照 key 粒度进行数据转移,较大的 key 会造成工作线程的长时间占用,进而引起正常服务请求时延飙高问题,甚至导致节点长时间无法回复心跳包而被判定下线的情况,存在稳定性风险。通过同步机制改造实现的新 slot 迁移功能,能显著降低数据迁移对用户访问时延的影响,提升线上 Redis 集群稳定性和运维效率,同时新的 slot 迁移功能还存在一些问题,例如新的迁移造成节点频繁的 bgsave 压力,迁移期间节点内存占用增加等问题,未来我们将围绕这些具体问题,继续不断优化总结。

状态模式(State Pattern)的定义是这样的:
类的行为是基于它的状态改变的。

注意这里的状态不是狭义的指对象维护了一个“状态”字段,我们传入了不同的枚举值,对象整体的表现行为(对外方法)就改变了。
而是指内部的(任意)字段如果发生了变化,那么它的状态就变了,那么它对外的表现形式就变了。
它是面向对象的23种设计模式中的一种,属于行为模式的范围。
通常我们在解决不同状态下,对外方法的不同表现时,可以定义若干的枚举,然后写一大堆if、 elseif、 switch等选择命令来区分不同的状态,然后走不同的业务分支。
而状态模式是支持将这些分支业务抽离出一个独立类(状态类),我们通过传入不同的状态类,就可以动态的执行不同的业务方法。
整体的结构大概是这样的:

业务类维护了一个内部状态对象,这个状态对象支持由外部传入,切换为不同的状态对象。
而这些状态对象都统一实现了具体的方法,业务类内部在执行业务方法时,会调用这些状态对象中实现的方法。(防盗连接:本文首发自http://www.cnblogs.com/jilodream/ )这样在切换状态时,业务方法就会调用不同的状态对象的方法了。从面向对象的角度,实现了状态变化,类行为的同步变化。
来看一个具体的代码示例:

枚举类

1 packagecom.example.demo.learn.pattern.behavior.status;2 
3 public enumTextStatusEnum {4 ONLY_READ,5 READ_WRITE,6 UNAVAILABLE;7 
8 }

状态定义接口

1 packagecom.example.demo.learn.pattern.behavior.status;2 
3 /**
4 * @discription5  */
6 public interfaceTextState {7 TextStatusEnum getStatus();8 
9      voidwrite(String content);10 
11      voidclear();12 
13 String read();14 
15      voidsetContent(StringBuilder sb);16 }

只读状态

1 packagecom.example.demo.learn.pattern.behavior.status;2 
3 importlombok.Data;4 importlombok.extern.slf4j.Slf4j;5 
6 /**
7 * @discription8  */
9 @Slf4j10 @Data11 public class OnlyReadState implementsTextState {12     private static final TextStatusEnum textStatus =TextStatusEnum.ONLY_READ;13 
14     privateStringBuilder sb;15 
16 @Override17     publicTextStatusEnum getStatus() {18         returntextStatus;19 }20 
21     public voidwrite(String content) {22         log.error("sorry, you can not write");23 }24 
25     public voidclear() {26         log.error("sorry, you can not clear");27 }28 
29     publicString read() {30         returnsb.toString();31 }32 
33 @Override34     public voidsetContent(StringBuilder sb) {35         this.sb =sb;36 }37 }

读写状态

1 packagecom.example.demo.learn.pattern.behavior.status;2 
3 importlombok.Data;4 importlombok.extern.slf4j.Slf4j;5 
6 /**
7 * @discription8  */
9 @Data10 @Slf4j11 public class ReadWriteState implementsTextState {12     private static final TextStatusEnum textStatus =TextStatusEnum.ONLY_READ;13 
14     private StringBuilder sb = newStringBuilder();15 
16 @Override17     publicTextStatusEnum getStatus() {18         returntextStatus;19 }20 
21     public voidwrite(String content) {22 sb.append(content);23 }24 
25     public voidclear() {26         sb.setLength(0);27 }28 
29     publicString read() {30         returnsb.toString();31 }32 
33 @Override34     public voidsetContent(StringBuilder sb) {35         this.sb =sb;36 }37 }

本文编辑器(业务类/上下文)

1 packagecom.example.demo.learn.pattern.behavior.status;2 
3 importlombok.Data;4 importlombok.extern.slf4j.Slf4j;5 
6 /**
7 * @discription8  */
9 @Slf4j10 public classTextEditor {11 
12     private StringBuilder sb = newStringBuilder();13 
14     privateTextState textState;15 
16     public voidsetState(TextState textState) {17 textState.setContent(sb);18         this.textState =textState;19 }20 
21     public voidwrite(String content) {22         if (textState == null) {23             log.error("no state exist");24             return;25 }26 textState.write(content);27 }28 
29     public voidclear() {30         if (textState == null) {31             log.error("no state exist");32             return;33 }34 textState.clear();35 }36 
37     publicString read() {38         if (textState == null) {39             log.error("no state exist");40             return "no state";41 }42         returntextState.read();43 }44 
45 }

主类

1 packagecom.example.demo.learn.pattern.behavior.status;2 
3 importlombok.extern.slf4j.Slf4j;4 
5 /**
6 * @discription7  */
8 @Slf4j9 public classPatternMain {10     public static voidmain(String[] args) {11         TextEditor editor = newTextEditor();12 String text;13 
14         //可读写状态
15         TextState rw = newReadWriteState();16 editor.setState(rw);17         for (int i = 0; i < 3; i++) {18             editor.write("write" +i);19             text =editor.read();20             log.warn("read :" +text);21 }22 editor.clear();23         text =editor.read();24         log.warn("after clear, we read :" +text);25         editor.write("last write");26 
27         log.warn("-----------------------now, we exchange state to only read-----------------------");28         //只读状态
29         TextState or = newOnlyReadState();30 editor.setState(or);31         for (int i = 0; i < 3; i++) {32             editor.write("write" +i);33             text =editor.read();34             log.warn("read :" +text);35 }36 editor.clear();37         text =editor.read();38         log.warn("after clear, we read :" +text);39 }40 }

输出效果如下:

10:02:52.356 [main] WARN com.example.demo.learn.pattern.behavior.status.PatternMain -read :write010:02:52.368 [main] WARN com.example.demo.learn.pattern.behavior.status.PatternMain -read :write0write110:02:52.369 [main] WARN com.example.demo.learn.pattern.behavior.status.PatternMain -read :write0write1write210:02:52.371 [main] WARN com.example.demo.learn.pattern.behavior.status.PatternMain -after clear, we read :(防盗连接:本文首发自http://www.cnblogs.com/jilodream/ )10:02:52.372 [main] WARN com.example.demo.learn.pattern.behavior.status.PatternMain - -----------------------now, we exchange state to only read-----------------------
10:02:52.376 [main] ERROR com.example.demo.learn.pattern.behavior.status.OnlyReadState -sorry, you can not write10:02:52.378 [main] WARN com.example.demo.learn.pattern.behavior.status.PatternMain -read :last write10:02:52.378 [main] ERROR com.example.demo.learn.pattern.behavior.status.OnlyReadState -sorry, you can not write10:02:52.378 [main] WARN com.example.demo.learn.pattern.behavior.status.PatternMain -read :last write10:02:52.379 [main] ERROR com.example.demo.learn.pattern.behavior.status.OnlyReadState -sorry, you can not write10:02:52.379 [main] WARN com.example.demo.learn.pattern.behavior.status.PatternMain -read :last write10:02:52.379 [main] ERROR com.example.demo.learn.pattern.behavior.status.OnlyReadState -sorry, you can not clear10:02:52.380 [main] WARN com.example.demo.learn.pattern.behavior.status.PatternMain -after clear, we read :last write

Process finished with exit code
0

我们可以看到在最初设置读写状态后,可以做读、写、清除等操作

在设置读状态后则只能读了。

这样回头来看,其实我们就是将不同if Switch的选择分支,连同选择的状态,一同封装到不同的状态类中,我们需要新增一种分支逻辑,不再需要修改选择分支,而是只需要新增一个状态类即可。
那是否状态模式可以替代传统的if 选择分支,答案是不能,本质上还是一个度的原因,面相对象如果过度设计,会导致类的数量无限膨胀,难以维护,试想如果存在多个状态字段(status、type等),则实体对象的状态是由多个状态字段组合而成的,每增加一个新的状态字段,都会导致状态的数量快速增加,这显然不是我们想看到的。