2024年8月

在《
可以调用Null的实例方法吗?
》一文中,我谈到.NET方法的三种调用形式,现在我们就来着重聊聊这个话题。具体来说,这里所谓的三种方法调用形式对应着三种IL指令:Call、CallVirt和Calli。

一、三个方法调用指令
二、三种方法调用形式
三、虚方法的分发(virtual dispatch)
四、性能差异

一、三个方法调用指令

虽然C#的方法具有静态方法和实例方法之分,但是在IL层面,它们之间并没有什么不同,就是单纯的“函数”而已,而且这个函数的第一个参数的类型永远是方法所在的类型。所以在IL层面,方法总是“静态”的,调用实例方法的本质就是将目标实例作为第一个参数,对于静态方法,第一个参数永远是Null/Default(值类型)。我在《
实例方法和静态方法有区别吗?
》中曾经着重谈到过这个问题。

Call和CallVirt指令执行方法的流程只有两步:
将所有参数压入栈中 + 执行方法
。它们之间的不同之处在于:Call指令编译时就已经确定了执行的方法,而CallVirt则是在运行时根据作为第一个参数的实例类型决定最终执行的方法。Calli指令则有所不同,我们执行该指令时需要指定目标方法的指针,整个流程包括三步:
将所有参数压入栈中 + 将目标方法指针压入栈中+执行方法

二、三种方法调用形式

接下来我们使用动态方法的形式演示上述三种方法调用指令的使用。具体来说,我们采用三种方式调用定义在Calculator中用来进行加法运算的Add方法,为此我们利用CreateInvoker方法根据指定的指令生成一个对应的Func<Calculator,
int
,
int
,
int
>委托。在CreateInvoker方法中,我们创建一个与Func<Calculator,
int
,
int
,
int
>委托匹配的动态方法。在IL Emit过程中,我们先将三个参数(Calculator对象和Add方法的参数a和b)压入栈中。如果指定的是Call和CallVirt指令,我们直接执行它们就可以了。如果指定的是Calli指令,我们得执行
Ldftn
指令将Add方法的指针压入栈中(方法指针通过指定的MethodInfo对象提供),然后再执行Calli指令。

var calculator = new Calculator();

var invoker = CreateInvoker(OpCodes.Call);
Console.WriteLine($"1 + 2 = {invoker(calculator, 1, 2)} [Call]");

invoker = CreateInvoker(OpCodes.Callvirt);
Console.WriteLine($"1 + 2 = {invoker(calculator, 1, 2)} [Callvirt]");

invoker = CreateInvoker(OpCodes.Calli);
Console.WriteLine($"1 + 2 = {invoker(calculator, 1, 2)} [Calli]");

static Func<Calculator, int, int, int> CreateInvoker(OpCode opcode)
{
    var method = typeof(Calculator).GetMethod("Add")!;
    var dynamicMethod = new DynamicMethod("Add", typeof(int), [typeof(Calculator), typeof(int), typeof(int)]);
    var il = dynamicMethod.GetILGenerator();
    il.Emit(OpCodes.Ldarg_0);
    il.Emit(OpCodes.Ldarg_1);
    il.Emit(OpCodes.Ldarg_2);

    if (opcode == OpCodes.Call)
    {
        il.Emit(OpCodes.Call, method);
    }
    else if (opcode == OpCodes.Callvirt)
    {
        il.Emit(OpCodes.Callvirt, method);
    }
    else if (opcode == OpCodes.Calli)
    {
        il.Emit(OpCodes.Ldftn, method);
        il.EmitCalli(OpCodes.Calli, CallingConvention.ThisCall, typeof(int), [typeof(Calculator), typeof(int), typeof(int)]);
    }

    il.Emit(OpCodes.Ret);
    return (Func<Calculator, int, int, int>)dynamicMethod.CreateDelegate(typeof(Func<Calculator, int, int, int>));
}

public class Calculator
{
    public virtual int Add(int a, int b) => a + b;
}

演示程序利用指定的三种方法指令创建了对应的Func<Calculator,
int
,
int
,
int
>,然后指定相同的参数(Calculator实例、整数1、2)执行它们,我们最终会在控制台上得到如下的输出结果。

image

三、虚方法的分发(virtual dispatch)

虽然Calculator的Add是个虚方法,由于Call指令执行的目标方法在编译时就确定,Calli则是我们以指针的形式指定了执行的方法,不论我们指定的目标对象具体是何类型,执行的永远是定义在Calculator类型的那个Add方法。面向对象“
多态
”的能力只能通过CallVirt指令来实现。

var calculator = new FakeCalculator();

var invoker = CreateInvoker(OpCodes.Call);
Console.WriteLine($"1 + 2 = {invoker(calculator, 1, 2)} [Call]");

invoker = CreateInvoker(OpCodes.Callvirt);
Console.WriteLine($"1 + 2 = {invoker(calculator, 1, 2)} [Callvirt]");

invoker = CreateInvoker(OpCodes.Calli);
Console.WriteLine($"1 + 2 = {invoker(calculator, 1, 2)} [Calli]");


public class FakeCalculator : Calculator
{
    public override int Add(int a, int b) => a - b;
}

以如上的程序为例,我们定义了Calculator的派生类FakeCalculator,在重写的Add方法中执行“减法运算”。我们将这个FakeCalculator对象作为参数调用三个委托,会得到如下所示的输出结果,可以看出CallVirt指令才能得到我们希望的结果。
image

四、性能差异

既然Call、CallVirt和Calli都是能帮助我们完成方法的执行,我们自然会进一步关系它们的性能差异了,为此我们来做一个简单的性能测试。

BenchmarkRunner.Run<Test>();

public class Test
{
    private static readonly Func<Calculator, int, int, int> _call = CreateInvoker(OpCodes.Call);
    private static readonly Func<Calculator, int, int, int> _callvirt = CreateInvoker(OpCodes.Callvirt);
    private static readonly Func<Calculator, int, int, int> _calli = CreateInvoker(OpCodes.Calli);
    private static readonly Calculator _calculator = new FakeCalculator();

    [Benchmark]
    public int Call() => _call(_calculator, 1, 2);

    [Benchmark]
    public int Callvirt() => _callvirt(_calculator, 1, 2);

    [Benchmark]
    public int Calli() => _calli(_calculator, 1, 2);
}

如上所示的测试程序很简单,我们调用CreateInvoker方法将针对三种指令的Func<Calculator,
int
,
int
,
int
>委托和目标对象FakeCalculator创建出来,并在三个Benchmark方法中执行它们。从如下的测试结果可以看出,Call由于不需要进行”虚方法分发(Virtual Dispatch)”性能会比Callvirt执行好一些,但总体来说差别不大,但是
Calli指令调用方法的性能会差很多

image

在开发过程中,但是antd中的搜索会把多余的也会带出来
就例如下图,我们本想去搜索1但是他会把其子节点都带出来,其实我们的本意是像搜2一样或者当中间隔层处理

但是我们该如何解决这样的问题呢如何做到下面两种情况
(1)搜索过滤掉不匹配的内容只留下匹配的内容
这是没有搜索之前

这是搜索之后,当我们去搜索5的时候我们就会直接把213过滤掉

(2)搜索中当子节点不是搜索内容但是孙节点和祖孙节点中存在要搜索的内容要把该子节点进行保留
这是没有搜索之前

这是搜索之后,我们要保留的结果

那么主要方法如下,antd-treeselect中的filterTreeNode属性,是否根据输入项进行筛选,默认用 treeNodeFilterProp 的值作为要筛选的 TreeNode 的属性值

方法如下使用

//toLowerCase()的方法主要是为了使用不区分大小写使用
 const filterTreeNode = (inputValue: string, treeNode: any) => {
    return treeNode.title.toLowerCase().indexOf(inputValue.toLowerCase()) > -1
  }

接下来就是搜索功能的具体实现方法

 // 此处操作主要用于前端处理搜索树时过滤掉搜索出的父节点下与搜索内容无关的其他子节点
    if (searchValue) {
      const fileData = [...oldfileTree]//主要用于记录tree节点使用的 oldfileTree就是树的节点功能
      // 用户树搜索的功能
      const searchResult = getSearchList([...fileData], searchValue)
      // 将树的列表更具搜索的内容的所有父级节点和搜索到内容id的合集
      let parentKeys
      if (name === 'apiManage') {
        parentKeys = contents
          .map((item: any) => {
            if (item.searchTitle.toLowerCase().indexOf(searchValue.toLowerCase()) > -1) {
              return getParentName(item.id, contents)
            }
            return null
          })
          .filter((item: any, i: any, self: any) => item && self.indexOf(item) === i)
      } else {
        parentKeys = contents
          .map((item: any) => {
            if (item.searchTitle.toLowerCase().indexOf(searchValue.toLowerCase()) > -1) {
              return getParentKey(item.id, contents)
            }
            return null
          })
          .filter((item: any, i: any, self: any) => item && self.indexOf(item) === i)
      }
      //所有需要的id扁平化处理
      const parentIdsList: any = parentKeys
        .flat(2)
        .filter((item: any, i: any, self: any) => item && self.indexOf(item) === i)
      // 获取需要展开的id集合由于过程中可能存在层级丢失,需要使用traverseParent向上寻找所有父级的id序列
      const getExpendKeys = parentIdsList
        .map((item: string) => {
          return traverseParent(searchResult, item)
        })
        .flat(2)
        .filter((item: any, i: any, self: any) => item && self.indexOf(item) === i)
      //设置翻开节点
      setTreeExpandedKeys(getExpendKeys)
      // 将搜索的集合转换成列表形式
      generateList(searchResult)
      // 把集合做转换成Map结构
      const listMap = dataList.reduce((map, item: any) => {
        map.set(item.id, item)
        return map
      }, new Map())
      //将所有展开的key与集合使用Map快速匹配对应值并将Map中存在的标记为true
      getExpendKeys.map((item: string) => {
        if (listMap.has(item)) {
          listMap.set(item, { ...listMap.get(item), hasSearch: true })
        }
      })
      // 将搜索的结果和Map进行匹配,如果匹配成功则将该节点换成Map中该节点的内容
      const result = hasTree(searchResult, listMap)
      // 将融合好的hasSearch tree(是否是搜索的节点)进行去除所有false的节点
      const filterTree = removeFalseNodes(result)
      // 形成所有搜索的结果
      setFileTree([...filterTree] as treeDataNode[])
    } 

getSearchList 就是用于搜索高亮使用的,hasSearch搜索到的值为true,搜索不到的值则为false

  const getSearchList = (data: treeDataNode[], searchValue: string) => {
    const result: treeDataNode[] = data.map(item => {
      const strTitle = item.searchTitle as string
      const index = strTitle.toLowerCase().indexOf(searchValue.toLowerCase())
      const beforeStr = strTitle.substring(0, index)
      const afterStr = strTitle.slice(index + searchValue.length)
      const regExp = new RegExp(searchValue, 'gi')
      const matches = strTitle.match(regExp)
      let value = ''
      if (matches) {
        strTitle.replace(regExp, (match: any) => {
          value = match
          return match
        })
      }

      const alias =
        index > -1 ? (
          <span>
            {beforeStr}
            <span className='site-tree-search-value'>{value}</span> //site-tree-search-value设置css样式,设置你需要的高亮的颜色什么颜色都可以
            {afterStr}
          </span>
        ) : (
          <span>{strTitle}</span>
        )

      if (item.children) {
        return {
          ...item,
          alias,
          value: item.id,
          hasSearch: index > -1 ? true : false, //将所有搜索结果是真的标记为true否则为false
          children: getSearchList(item.children, searchValue)
        }
      }
      return {
        ...item,
        value: item.id,
        hasSearch: index > -1 ? true : false, //将所有搜索结果是真的标记为true否则为false
        alias
      }
    })
    return result
  }

getParentKey 的目的是找到给定 key 所对应的节点的直接父节点,并返回该父节点的 id 和 parentId。
getParentKey 函数没有明确处理未找到父节点的情况,可能会返回意外的结果或 undefined或者空数组。因而要使用
.flat(2).filter((item: any, i: any, self: any) => item && self.indexOf(item) === i)
来过滤

  const getParentKey = (key: React.Key, tree: any): React.Key => {
    let parentKey: any
    for (let i = 0; i < tree.length; i++) {
      const node = tree[i]
      if (node.children) {
        if (node.children.some((item: any) => item.id === key)) {
          parentKey = [node.id, node.parentId]
        } else if (getParentKey(key, node.children)) {
          parentKey = [getParentKey(key, node.children), node.parentId]
        }
      }
    }
    return parentKey
  }

traverseParent 的目的是递归地查找给定 parentId 的所有祖先节点,并将它们的 id 收集到一个数组中。
traverseParent 在未找到指定 parentId 的情况下会返回一个空数组。因而要使用
.flat(2).filter((item: any, i: any, self: any) => item && self.indexOf(item) === i)
来过滤

  const traverseParent = (treeData: treeDataNode[], parentId?: string) => {
    let result: string[] = []
    function traverse(nodes: treeDataNode[], parentId: string) {
      for (let i = 0; i < nodes.length; i++) {
        const node = nodes[i]
        if (node.id === parentId) {
          result = [...result, node.id]
          if (node.parentId) {
            traverse(treeData, node.parentId)
          }
          break
        } else if (node.children) {
          traverse(node.children, parentId)
        }
      }
    }
    if (parentId) traverse(treeData, parentId)

    return result
  }

generateList 的目的是用于扁平化树形数据结构并转换每个节点的格式

 const dataList: { key: React.Key; title: string; name: string }[] = []
  const generateList = (data: treeDataNode[]) => {
    for (let i = 0; i < data.length; i++) {
      const node = data[i]
      dataList.push({ ...node, name: node.title })
      if (node.children) {
        generateList(node.children)
      }
    }
  }

hasTree 就是将树重新构建,将树中存在的与Map结构中同样内容的值换成Map结构的信息

  const hasTree = (tree: treeDataNode[], map: any) => {
    return tree.map(node => {
      if (map.has(node.id)) {
        node = map.get(node.id)
      }
      // 如果节点有子节点,递归处理子节点
      if (node.children && node.children.length > 0) {
        node.children = hasTree(node.children, map)
      }
      return node
    })
  }

removeFalseNodes 是删除hasSearch 为false的置换成undefined在将其过滤掉最后剩下的就是搜索出的结果

const removeFalseNodes = (data: treeDataNode[]) => {
    return data
      .map(item => {
        // 递归处理children数组
        item.children = item.children && item.children.filter(child => child.hasSearch)
        if (item.children && item.children.length > 0) {
          removeFalseNodes(item.children)
        }
        // 如果当前对象的hasSearch为false且children为空数组,则返回undefined以从结果中排除
        return item.hasSearch || (item.children && item.children.length > 0) ? item : undefined
      })
      .filter(item => item !== undefined)
  }

总之,在一些时候搜索为了迎合需要不得不这么操作,那么该操作结合了antd官方的搜索操作,在此之前需要保持清醒的头脑

首先我们搜索出来高亮这个操作antd TreeSelect的是可以实现,但是搜索中我们发现实现不了搜索过滤,但是又要解决这个问题,想尝试使用数组方法将不是的部分删除,只能解决节点是的情况,当出现差层,何为差层就是当子节点不是搜索内容但是孙节点和祖孙节点中存在要搜索的内容要把该子节点进行保留的时候发现数据保留不住,不知道该如何解决,翻阅了ES6后发现使用Map做一层数据存储,并结合搜索情况将所有搜索的父节点向上遍历将其hasSearch设置为true,这样在重新构建树的时候可以将所有需要的节点变成true,再最后将所有节点是false的节点进行删除,只保留hasSearch为true的节点。总之该操作中使用了数组的方法,以及ES6的Map结构,当做出来的时候感觉雨过天晴,但是个人觉得这些还是太冗余了,之后会更进方法,如果大家有什么更好的方法请多指教 (´・Д・)」

最后就是如果这种问题可以放在后端在搜索的时候进行请求来减少前端遍历和重组的过程减少渲染次数会更好

技术背景

如果我们有一个蛋白质X和一个配体Y,那么可以对这个X+Y的体系跑一段长时间的分子动力学模拟,以观测这个体系在不同结合位点下的稳定性。类似于前面一篇
博客
中计算等高面的方法,我们可以计算轨迹的KDE函数,然后保存成Cube格式(高斯中用于保存电子轨道的一种格式)的文件。然后就可以通过可视化软件如VMD等,来加载蛋白和cube文件进行分析。

软件介绍与安装

CyFES
是一款基于Python开发用户层,基于Cython开发链路层以及CUDA C++开发物理层Kernel函数的一个高性能
开源
FES计算软件。

目前功能还比较简单,仅支持三维分子运动轨迹的数据透视。安装支持源码和pip安装,pip安装方法如下:

$ python3 -m pip install cyfes --user --upgrade -i https://pypi.org/simple

之所以建议采取
--user
的策略,是因为很多环境中没有权限在site-packages路径下创建文件,会导致动态链接库缺失的问题,
--user
可以一定程度上规避这个问题。下载的时候国内的镜像源同步会有一定的延迟,如果需要及时的下载最新版本的CyFES,可以使用
-i https://pypi.org/simple
参数配置。安装成功后,可以使用如下指令确认CyFES是否安装成功:

$ python3 -m cyfes --help
python3 -m cyfes --help
usage: __main__.py [-h] [-i I] [-ic IC] [-ib IB] [-s S] [-e E] [-g G] [-o O]
                   [-no_bias NO_BIAS] [-f32 F32] [-sigma SIGMA]

optional arguments:
  -h, --help        show this help message and exit
  -i I              Set the input record file path.
  -ic IC            Set the cv index of input record file. Default: 0,1,2
  -ib IB            Set the bias index of input record file. Default: 3
  -s S              CV length. Default: None
  -e E              Edge length. Default: 1.0
  -g G              Grid numbers. Default: 10,10,10
  -o O              Set the output FES file path.
  -no_bias NO_BIAS  Do not use the bias from input file. Default: false
  -f32 F32          Use float32. Default: false
  -sigma SIGMA      Sigma value when calculating FES. Default: 0.3

当然,也可以从
CyFES-Gitee主页
下载源码进行源码安装。安装完成后,可以执行一个python脚本确认一下动态链接库是否缺失的问题:

# check_dynamics.py
import os
import site
from pathlib import Path

site_path = Path(site.getsitepackages()[0])
site_file_path = site_path.parent.parent.parent / 'cyfes' / 'libcufes.so'
site_dynamics_path = str(site_file_path)

user_site_path = Path(site.USER_SITE)
user_file_path = user_site_path.parent.parent.parent / 'cyfes' / 'libcufes.so'
user_dynamics_path = str(user_file_path)

if not os.path.exists(site_dynamics_path) and not os.path.exists(user_dynamics_path):
    print ('Check dynamics complete, no libcufes.so file founded!')
else:
    print ('Installation of CyFES success!')

确认安装成功之后,我们可以开始使用CyFES进行轨迹数据透视。

轨迹输入

CyFES支持读取这样的一个轨迹文件
xyz_bias.txt

23.5578 33.8817 37.8341 0.000000
23.4752 33.7842 37.8489 0.882319
23.4557 33.7728 37.8236 1.544485
23.4979 33.6253 37.8524 1.952011
23.5502 33.6256 37.8981 2.140049
23.6389 33.6791 37.9141 1.437173
...

其中前三列表示轨迹的x、y、z坐标,最后一列表示对应坐标位置的偏置势bias,用于计算权重。在CyFES-2.6之后的版本中,可以支持不给定bias,那么默认就全部都是0。

CyFES终端指令

最简单的场景,我们可以使用这样一行简单的代码执行CyFES的计算:

$ python3 -m cyfes -i xyz_bias.txt -o z.cub

这样就可以根据轨迹文件生成一个可以用于可视化的cube格式文件。如果需要更多的一些配置,常用的有:

$ python3 -m cyfes -i xyz_bias.txt -o z.cub -e 5.0

表示边缘增加5A(这里注意单位是埃,而最终保存的cube格式文件会转为波尔bohr长度)的空隙。

$ python3 -m cyfes -i xyz_bias.txt -o z.cub -g 20,30,40

表示x、y、z方向的格点数量分别为20,30和40个格点。

$ python3 -m cyfes -i xyz_bias.txt -o z.cub -sigma 0.2

表示禁带宽度band width设置为0.2。屏幕打印输出的样式大致是这样的:

$ python3 -m cyfes -i xyz_bias.txt -e 5.0 -g 10,20,30 -sigma 0.1 -o z_x.cub
2024-08-19 09:34:26,304 [CyFES] Start to initialize parameters
2024-08-19 09:34:26,318 [CyFES] CV (1000, 3)
2024-08-19 09:34:26,318 [CyFES] Bias (1000,)
2024-08-19 09:34:26,319 [CyFES] Origin crd [16.0653 28.2584 32.4803]
2024-08-19 09:34:26,319 [CyFES] Final crd [29.4832 41.7948 47.3887]
2024-08-19 09:34:26,320 [CyFES] Grids (6000, 3)
2024-08-19 09:34:26,320 [CyFES] BandWidth [0.1 0.1 0.1]
2024-08-19 09:34:26,320 [CyFES] Start to calculate FES
2024-08-19 09:34:27,285 [CyFES] Writting FES into file /home/cy-fes/tests/z_x.cub
2024-08-19 09:34:27,306 [CyFES] Task complete :)

这里主要是记录一些参数和运行日志,最终的计算结果会按照Cube文件的格式输出到
z_x.cub
文件中,文件格式可以查阅下一个章节的介绍。

Cube输出

简单的说明一下我们这里生成的cube文件格式内容,大体如下:

Generated by CyFES
Total	1000000	grids
1	30.3589	53.4004	61.3785
100	0.256121	0	0
100	0	0.258383	0
100	0	0	0.284572
1	1.000000	43.1649	66.3195	75.6072
34.6304	34.1533	33.6897	33.2396	32.8028	32.3795	
31.9696	31.5731	31.19	30.8203	30.464	30.121	
29.7914	29.4751	29.1721	28.8823	28.6057	28.3422	
28.0918	27.8543	27.6297	27.4177	27.2182	27.031	
...

第一行是标题,第二行是格点数声明,第三行是原子数和原点坐标,第四行到第六行是x、y、z方向的格点数和偏移矢量。第七行是原子信息,这里我们直接在轨迹盒子的中心位置放了一个氢原子,第二列是核电荷数,后面三列是坐标。第八列开始是每一个格点的势能数值,每一行最多6个格点数据。

数据可视化

在此前的文章中我们介绍过关于
VMD可视化软件的安装与使用
,用户可以直接用VMD来对cub文件和原始的pdb文件做可视化。不过这里要推荐的是另外一个免安装和部署的工具:
在线的molstar平台
,在这里可以像VMD一样直接加载PDB格式的文件和Cub格式的轨道:

大家可以根据自己的使用习惯来选择相应的工具。这是两个效果对比图,首先是VMD生成的效果:

然后是molstar生成的效果:

得到的那个区域就是分子运动轨迹的数据透视图,可以一定程度上衡量区域自由能的相对大小。

总结概要

分子动力学模拟是一个以时间换空间的方法,那么在时间尺度上留下轨迹之后,如何把轨迹做一个静态的展现,正是数据透视所解决的问题。CyFES是一个开源的、基于GPU硬件加速的数据透视高性能计算工具,我们通过一个蛋白-配体相互作用的运动轨迹的示例,演示一下CyFES的基本使用方法。

版权声明

本文首发链接为:
https://www.cnblogs.com/dechinphy/p/cyfes.html

作者ID:DechinPhy

更多原著文章:
https://www.cnblogs.com/dechinphy/

请博主喝咖啡:
https://www.cnblogs.com/dechinphy/gallery/image/379634.html

Differentiable Model Scaling

DMS
)以直接、完全可微的方式对宽度和深度进行建模,是一种高效且多功能的模型缩放方法。与先前的
NAS
方法相比具有三个优点:1)
DMS
在搜索方面效率高,易于使用。2)
DMS
实现了高性能,可与
SOTA NAS
方法相媲美。3)
DMS
是通用的,与各种任务和架构兼容。

来源:晓飞的算法工程笔记 公众号

论文: Differentiable Model Scaling using Differentiable Topk

Introduction


在近年来,像
GPT

ViT
这样的大型模型展示了出色的性能。值得注意的是,
GPT-4
的涌现强调了通过扩展网络来实现人工通用智能(
AGI
)的重要性。为了支持这个扩展过程,论文引入了一种通用而有效的方法来确定网络在扩展过程中的最佳宽度和深度。

目前,大多数网络的结构设计仍然依赖于人类专业知识。通常需要大量资源来调整结构超参数,导致很难确定最佳结构。与此同时,神经架构搜索(
NAS
)方法已经被引入到自动化网络结构设计中。根据搜索策略将
NAS
方法分为两类:随机搜索方法和基于梯度的方法。

随机搜索方法需要对大量子网络进行采样以比较性能。然而,这些方法的搜索效率受到样本评估周期的限制,导致性能降低和搜索成本增加。

与随机搜索方法不同,基于梯度的方法采用梯度下降法来优化结构参数、 提高效率,使其更善于平衡搜索成本和最终性能。然而,一个巨大的挑战依然存在:如何以直接和可微的方式为结构超参数建模?早期的方法一直在努力应对这一挑战,结果导致性能下降、成本增加。具体来说,根据建模策略将先前的方法分为三类:

  1. 多元素选择:在搜索卷积层中的通道数时,将通道数建模为通道选择(比如
    PaS
    通过可学习二值卷积生成
    0/1
    掩码对通道进行剪枝),如图
    1 a.1
    所示。
  2. 单数字选择:在搜索卷积层中的通道数时,将通道数建模为从多个候选数字中的选择一个(比如
    FBNetV2
    在一层中学习不同大小的候选卷积的权重),如图
    1 a.2
    所示。
  3. 梯度估计
    topk
    :尝试直接建模宽度和深度(比如通过梯度估计学习动态的
    k
    值以及给每个通道生成重要性分数,随后选择
    topk
    通道),如图
    1 a.3
    所示。这个可能跟多元素选择有点类似,核心区别是多元素选择是为了生成掩码,而这个则是为了生成动态
    k
    值。

但所有上述策略都无法以直接和完全可微分的方式对结构超参数进行建模。为了解决上述挑战,论文引入了一个完全可微分的
topk
运算符,可以无缝地以直接和可微分的方式对深度和宽度进行建模。值得注意的是,每个可微分的
topk
运算符都有一个可学习参数,表示深度或宽度结构超参数,可以基于任务损失和资源约束损失的指导进行优化。与现有的基于梯度的方法相比,论文的方法在优化效率方面表现出色。

基于可微分
topk
,论文提出了一种可微分模型缩放(
DMS
)算法来搜索网络的最佳宽度和深度。为了验证功效和效率,在各种任务中进行了严格的测试,包括视觉任务和
NLP
任务,以及不同的架构,包括
CNN

Transformer
。由于可微分
topk
具有高效的搜索效率,
DMS
在性能或搜索成本方面均优于先前的
SOTA
方法。

总的来说,论文的贡献如下:

  1. 引入了可微分的
    topk
    运算符,可以以直接和可微分的方式对结构超参数进行建模,因此很容易进行优化。
  2. 基于可微分的
    topk
    提出了一种可微分模型缩放(
    DMS
    )算法,用于搜索网络的最佳宽度和深度。
  3. 评估了
    DMS
    在各种任务和架构上的性能。例如,
    DMS
    在搜索过程中只需
    0.4 GPU
    天,就能比最先进的
    zero-shot NAS
    方法
    ZiCo
    表现出
    1.3%
    的优势。与性能相当的
    one-shot NAS
    方法
    ScaleNet
    以及
    multi-shot NAS
    方法
    Amplification
    相比,
    DMS
    所需的搜索成本仅为几十分之一。此外,
    DMS
    是一种广泛适用的方法,在
    COCO
    数据集上将
    Yolo-v8-n
    的提高了
    2.0%
    ,并提高了裁剪
    Llama-7B
    模型样本分类精度。

Related Work


Stochastic Search Methods

随机搜索方法通常通过采样和评估的循环过程进行操作。在每一步中,它们会对具有不同构的模型进行采样,然后对其进行评估。这种策略非常灵活,可以处理连续和离散的搜索空间。然而,它的一个显著缺点搜索效率低下,导致资源消耗高和性能不理想。具体而言,基于随机搜索的方法可以分为三种:

  1. multi-shot NAS
    :需要训练多个模型,这非常耗时,如
    EfficientNet
    用了
    1714

    TPU
    天来进行搜索。
  2. one-shot NAS
    :需要训练一个庞大的超网络,也需要大量资源,如
    ScaleNet
    用了
    379

    GPU
    天来训练一个超网络。
  3. zero-shot NAS
    :通过消除训练任何模型来减少成本,但其性能尚未达到所期望的标准。

Gradient-based Methods

基于梯度的结构搜索方法使用梯度下降来探索模型的结构,这些方法一般比随机搜索方法更高效。基于梯度的方法的关键在于如何使用可学习参数来建模结构超参数并计算其梯度,理想情况下,可学习参数应直接建模结构超参数并且其梯度应以完全可微的方式计算。然而,先前的方法在建模网络的宽度和深度时往往难以同时满足这两个条件,可以将它们分为三类:(
1
)多元素选择,(
2
)单数字选择和(
3
)梯度估计
topk
。前两类间接地建模结构超参数,而第三类不可微分,需要进行梯度估计。

为了提高结构搜索的优化效率,论文引入了一种新的可微分的
topk
方法,可以直接建模宽度和深度,并且是完全可微分的。从实验结果来看,论文的方法更加高效和有效。

Method


Differentiable Top-k

假设存在一个由
\(k\)
表示的结构超参数,表示元素的数量,比如卷积层中的
\(k\)
个通道或网络阶段中的
\(k\)
个残差块。
\(k\)
的最大值为
\(N\)
,使用
\({\mathbf{c}} \in \mathbb{R}^N\)
来表示元素的重要性,其中较大的值表示更高的重要性。可微分
topk
方法的目标是输出一个软掩码
\({\mathbf{m}} \in 0,1^N\)
,代表具有前
\(k\)
个重要分数的选定元素。

topk
运算符使用可学习的参数
\(a\)
作为阈值,选择那些重要性值大于
\(a\)
的元素。
\(a\)
能够直接建模元素数量
\(k\)
,因为
\(k\)
可以看作是
\(a\)
的一个函数,其中
\(k=\sum^N\_{i=1}{1c\_i>a}\)

\(1A\)
是一个指示函数,如果
\(A\)
为真则等于
1
,否则等于
0

\(c\_i\)
表示第
\(i\)
个元素的重要性。将
topk
表示为一个函数
\(f\)
,如下所示:

\[\begin{align}
m\_i = f(a) \approx \begin{cases}
1 & \text{if } c\_i > a \
0 & \text{otherwise}
\end{cases}
\end{align}
\]

在先前的方法中,
\(f\)
通常是一个分段函数,不平滑也不可微分,且
\(a\)
的梯度是通过估计计算得出的。论文认为采用相对于
\(a\)
完全可微分的
\(f\)
的最大挑战是重要性分数分布不均匀。具体来说,不均匀的分布导致重要性值排序中的两个相邻元素之间的差异较大。假设每次迭代时通过固定值更新
\(a\)
,当前后元素的重要性差异很大时,则需要许多步才能使
\(a\)
跨越这两个元素。当差异很小时,
\(a\)
可以在一步内跨越许多元素。因此,在元素重要性不均匀时,以完全可微分的方式优化
\(a\)
是非常困难。

为了解决这个挑战,论文采用了一种重要性归一化过程,将不均匀分布的重要性强制转换为均匀分布的值,使得
topk
函数在可微分的情况下变得平滑且易于优化。总结起来,可微分
topk
有两个步骤:重要性归一化和软掩码生成。

Importance Normalization

根据以下方式,通过将所有元素的重要性映射到从
0

1
的均匀分布的值来对所有元素的重要性归一化:

\[\begin{align}
& c_i' = \frac{1}{N}\sum^N_{j=1}{1c\_i>c\_j}.
\end{align}
\]

归一化后的元素重要性用
\({\mathbf{c}}'\)
表示。
\(1A\)
是与上面相同的指示函数,
\({\mathbf{c}}\)
中的任意两个元素通常是不同的。值得注意的是,虽然
\(\mathbf{c}'\)

0

1
之间均匀分布,但
\(\mathbf{c}\)
可以遵循任何分布。

直观地说,
\(c'\_i\)
表示
\({\mathbf{c}}\)
中值小于
\(c\_i\)
的部分。此外,可学习的阈值
\(a\)
也变得有意义,表示元素的剪枝比例。
\(k\)
可以通过
\(k=\lfloor(1-a)N\rceil\)
计算,其中
\(\lfloor \, \rceil\)
是一个取整函数。
\(a\)
限制在
\(0,1\)
的范围内,其中
\(a=0\)
表示不剪枝,
\(a=1\)
表示剪枝所有元素。

Soft Mask Generation

在归一化之后,可以使用基于相对大小的剪枝比例
\(a\)
和归一化元素重要性
\({\mathbf{c}}'\)
的平滑可微函数轻松生成软掩码${\mathbf{m}} $。

\[\begin{align}
& m\_i = f(a)= \text{Sigmoid}(\lambda({\mathbf{c}}'\_i- a)) = \frac{1}{1+e^{-\lambda({\mathbf{c}}'\_i - a)}}.
\end{align}
\]

论文添加了一个超参数
\(\lambda\)
来控制从公式
3
到硬掩码生成函数的逼近程度。当
\(\lambda\)
趋近于无穷大时,公式
3
接近于硬掩码生成函数(根据固定阈值
\(a\)
直接得出
0/1
)。通常将
\(\lambda\)
设置为
\(N\)
,因为当
\(c'\_i>a+3/N\)

\(c'\_i<a-3/N\)
时,$|(m_i-\lfloor m_i \rceil)|<0.05 $。这意味着除了重要性值接近剪枝比例的六个元素外,其他元素的掩码接近于
0

1
,近似误差小于
0.05
。因此,
\(\lambda=N\)
足以逼近
topk
的硬掩码生成函数。

公式
3
的前向和反向图分别如图
2(a)
和图
2(b)
所示,可以观察到以下两点:

  1. topk
    直接使用可学习的剪枝比例
    \(a\)
    来建模元素数量
    \(k\)
    ,并在前向过程中生成极化的软掩码
    \({\mathbf{m}}\)
    ,以完美模拟剪枝后的模型。
  2. 可微分
    topk
    完全可微分,并且能够稳定地进行优化。
    \(a\)
    相对于
    \(m_i\)
    的梯度为 $\frac{\partial m_i}{\partial a} = -\lambda(1-m_i)m_i $。我们的__
    topk
    直观地检测模糊区域中
    \(0.05<m\_i<0.95\)
    的掩码梯度。请注意,图

    2
    __(b) 描述的是
    \(\frac{\partial m\_i}{\partial a}\)
    的值,而不是
    \(a\)
    的总梯度,
    \(a\)
    的总梯度为
    \(\sum_{i=1}^{N}{\frac{\partial task\_loss}{\partial m\_i}\frac{\partial m\_i}{\partial a}}+\frac{\partial resource\_loss}{\partial a}\)

Element Evaluation

由于元素重要性会被归一化后再进行掩码生成,所以不限制元素重要性的分布,可以通过多种方法来量化元素重要性,例如
L1-norm
等。论文以滑动平均方式实现了
Taylor importance
,具体如下所示:

\[\begin{align}
c^{t+1}_i = c^t\_i \times decay + (m^t\_i \times g_{i})^2 \times (1-decay).
\end{align}
\]

在这里,
\(t\)
表示训练步骤,
\(g\_i\)

\(m\_i\)
相对于训练损失的梯度,
\(Decay\)
是衰减率,
\(c\_i^0\)
的初始值设为零,衰减率设为
0.99
。请注意,元素的重要性不是通过梯度下降来更新的。通过利用
Taylor importance
,可以高效且稳定地估计元素的重要性。

Differentiable Model Scaling

依靠可微分
topk
,论文提出了可微分模型缩放(
Differentiable Model Scaling

DMS
)来优化网络的宽度和深度。
DMS
有三种基于基于训练的模型剪枝的流水线变体,如表
1
所示。

  • \(\text{DMS}\_{\text{p}}\)

\(\text{DMS}\_{\text{p}}\)
是基于训练的模型剪枝流水线,由预训练阶段、搜索阶段和重新训练阶段组成:

  1. 预训练阶段用于预训练一个超网络,通常需要大量时间和资源。
  2. 搜索阶段在特定资源约束下搜索超网络的最优宽度和深度,由于论文方法具有较高的搜索效率,因此搜索阶段只使用了大约
    1
    /
    10
    或更少的重新训练轮数。
  3. 在重新训练阶段,重新对已经进行了搜索的模型进行训练。与
    SOTA
    剪枝方法进行比较时,使用这个流水线。
  4. \(\text{DMS}\_{\text{np}}\)

\(\text{DMS}_{\text{np}}\)
是论文中默认和最常用的流水线。预训练阶段的高本占据了总成本的大部分,这是__
NAS
__和剪方法在实际应用中面临的一个重大障碍。为克服这个问题,从
\(\text{DMS}_{\text{}}\)
中去除了预训练阶段,直接从随机初始化超网络开始搜索。通过增加超网络大小,
\(\text{DMS}_{\text{np}}\)
在性能和效率上都超过了
\(\text{DMS}_{\text{p}}\)
,并且比其他
NAS
方法更加高效。

  • \(\text{DMS}\_{\text{p-}}\)

\(\text{DMS}_{\text{p-}}\)
用于快速比较不同搜索方法。与
\({\text{DMS}_\text{p}}\)
相比,它只优化结构参数,不对搜索到的模型进行重新训练。利用现有的预训练超网络,也能输出合理的结果。此外,它只需要数百次迭代,在单个
RTX3090
上花费不到
10
分钟就可以搜索出一个模型。

  • Search Space

如图
1(b)
所示,论文的搜索空间涵盖了网络的宽度和深度,这是模型扩展最关键的结构超参数。为了表示这些维度,使用了可微分的
topk
方法。在网络中,宽度通常涵盖了卷积层中的通道维度、全连接层中的特征维度等。关于深度,论文专注于具有残差连接的网络,并搜索每个阶段中的块数。具体来说,将可微分
topk
的软掩码合并到残差连接中,使得每个块可以表示为
\(x\_{i+1}=x\_i+f(x\_i)\times m\_i\)

此外,对于结构超参数
\(x\)
,在范围
\(1, x\_{max}\)
内以步长
1
进行搜索,而大多数先前的
NAS
方法则在范围
\(x_{min}, x_{max}\)
内以步长
32
进行搜索。这个搜索空间是由人类专家设计的一个较好的子空间,然而论文的搜索空间更加通用且成本最低。实验结果显示,论文的方法在精细搜索空间上可以达到更好的性能,这个空间更难搜索,而先前的方法在粗粒度搜索空间上的表现较好,这种空间更容易搜索。

  • Resource Constraint Loss

为了确保网络遵循特定的资源约束,在优化过程中加入了一个额外的组件,称为
Resource Constraint Loss
。因此,整体的损失函数为:

\[\begin{align}
loss= & loss_{task}+\lambda_{resource}\times loss\_{resource}. \
& loss\_{resource}=\begin{cases}
\log(\frac{r_{c}}{r_{t}}) & \text{if } r_{c} > r_{t} \
0 & \text{otherwise}
\end{cases}.
\end{align}
\]

在这里,
\(loss_{task}\)
表示任务损失。
\(loss_{resource}\)
表示额外的资源约束损失,
\(\lambda\_{resource}\)
作为其权重系数。
\(r\)
表示当前资源消耗水平,根据可学习参数的不同
topk
操作符进行计算。
\(r\_t\)
代表目标资源耗水平,由用户指定。由于
topk
是完全可微分的,可学习结构参数可以在任务损失和资源约束失的指导下进行优化。

Experiment


如果本文对你有帮助,麻烦点个赞或在看呗

更多内容请关注 微信公众号【晓飞的算法工程笔记】

work-life balance.

全志TinyVision芯片

  • TinyVision开发交流QQ群:821628986

文章目录汇总

教程共计14章,下面是章节汇总:

第0章_
TinyVision套件简述

第1章_
源码工具文档手册

第2章_
快速启动使用

第3章_
安装并配置开发环境

第4章_
选择合适系统

第5章_
Tina5 Liunx开发

第6章_
TinalLinux NPU 开发

第7章_
E907 小核开发

第8章_
主线Buildroot开发

第9章_
LCD模组驱动开发

第10章_
WIFI驱动开发

第11章_
USB_OTG切换模式

第12章_
TinyVision 手动构建 Linux 6.1 + Debian 12 镜像

第13章_
TinyVision 使用 SyterKit 启动 Linux 6.7 主线内核

为了更好的阅读体验,我们提供了在线文档阅读
https://dshanpi.100ask.net/docs/TinyVision/BoardIntroduction
,需要的同学可以通过点击网站自行阅读。