2024年3月

一、摘要

从 JDK 1.7 开始,引入了一种新的 Fork/Join 线程池框架,它可以把一个大任务拆成多个小任务并行执行,最后汇总执行结果。

比如当前要计算一个数组的和,最简单的办法就是用一个循环在一个线程中完成,但是当数组特别大的时候,这种执行效率比较差,例如下面的示例代码。

long sum = 0;
for (int i = 0; i < array.length; i++) {
    sum += array[i];
}
System.out.println("汇总结果:" + sum);

还有一种办法,就是将数组进行拆分,比如拆分成 4 个部分,用 4 个线程并行执行,分别计算,最后进行汇总,这样执行效率会显著提升。

如果拆分之后的部分还是很大,可以继续拆,直到满足最小颗粒度,再进行计算,这个过程可以反复“裂变”成一系列小任务,这个就是 Fork/Join 的工作原理。

Fork/Join 采用的是分而治之的基本思想,分而治之就是将一个复杂的任务,按照规定的阈值划分成多个简单的小任务,然后将这些小任务的执行结果再进行汇总返回,得到最终的执行结果。分而治之的思想在大数据领域应用非常广泛。

下面我们一起来看看 Fork/Join 的具体用法。

二、ForkJoin 用法介绍

以计算 2000 个数字组成的数组为例,进行并行求和, Fork/Join 简单的应用示例如下:

public class ForkJoinTest {

    public static void main(String[] args) throws Exception {
        // 创建2000个数组成的数组
        long[] array = new long[2000];
        // 记录for循环汇总计算的值
        long sourceSum = 0;
        for (int i = 0; i < array.length; i++) {
            array[i] = i;
            sourceSum += array[i];
        }
        System.out.println("for循环汇总计算的值: " + sourceSum);

        System.out.println("---------------");

        // fork/join汇总计算的值
        ForkJoinPool forkJoinPool = new ForkJoinPool();
        ForkJoinTask<Long> taskFuture = forkJoinPool.submit(new SumTask(array, 0, array.length));
        System.out.println("fork/join汇总计算的值: " + taskFuture.get());
    }
}
public class SumTask extends RecursiveTask<Long> {

    /**
     * 最小任务数组最大容量
     */
    private static final int THRESHOLD = 500;

    private long[] array;
    private int start;
    private int end;

    public SumTask(long[] array, int start, int end) {
        this.array = array;
        this.start = start;
        this.end = end;
    }

    @Override
    protected Long compute() {
        // 检查任务是否足够小,如果任务足够小,直接计算
        if (end - start <= THRESHOLD) {
            long sum = 0;
            for (int i = start; i < end; i++) {
                sum += this.array[i];
            }
            return sum;
        }
        // 任务太大,一分为二
        int middle = (end + start) / 2;
        // 拆分执行
        SumTask leftTask = new SumTask(this.array, start, middle);
        leftTask.fork();
        SumTask rightTask = new SumTask(this.array, middle, end);
        rightTask.fork();
        System.out.println("进行任务拆分,leftTask数组区间:" + start + "," + middle + ";rightTask数组区间:" + middle + "," + end);
        // 汇总结果
        return leftTask.join() +  rightTask.join();
    }
}

输出结果如下:

for循环汇总计算的值: 1999000
---------------
进行任务拆分,leftTask数组区间:0,1000;rightTask数组区间:1000,2000
进行任务拆分,leftTask数组区间:1000,1500;rightTask数组区间:1500,2000
进行任务拆分,leftTask数组区间:0,500;rightTask数组区间:500,1000
fork/join汇总计算的值: 1999000

从日志上可以清晰的看到,for 循环方式汇总计算的结果与
Fork/Join
方式汇总计算的结果一致。

因为最小任务数组最大容量设置为
500
,所以
Fork/Join
对数组进行了三次拆分,过程如下:

  • 第一次拆分,将
    0 ~ 2000
    数组拆分成
    0 ~ 1000

    1000 ~ 2000
    数组
  • 第二次拆分,将
    0 ~ 1000
    数组拆分成
    0 ~ 500

    500 ~ 1000
    数组
  • 第三次拆分,将
    1000 ~ 2000
    数组拆分成
    1000 ~ 1500

    1500 ~ 2000
    数组
  • 最后合并计算,将拆分后的最小任务计算结果进行合并处理,并返回最终结果

当数组量越大的时候,采用
Fork/Join
这种方式来计算,程序执行效率优势非常明显。

三、ForkJoin 框架原理

从上面的用例可以看出,
Fork/Join
框架的使用包含两个核心类
ForkJoinPool

ForkJoinTask
,它们之间的分工如下:

  • ForkJoinPool
    是一个负责执行任务的线程池,内部使用了一个无限队列来保存需要执行的任务,而执行任务的线程数量则是通过构造函数传入,如果没有传入指定的线程数量,默认取当前计算机可用的 CPU 核心量
  • ForkJoinTask
    是一个负责任务的拆分和合并计算结果的抽象类,通过它可以完成将大任务分解成多个小任务计算,最后将各个任务执行结果进行汇总处理

正如上文所说,
Fork/Join
框架采用的是分而治之的思想,会将一个超大的任务进行分解,按照设定的阈值分解成多个小任务计算,最后将各个计算结果进行汇总。它的应用场景非常多,比如大整数乘法、二分搜索、大数组快速排序等等。

有个地方可能需要注意一下,
ForkJoinPool
线程池和
ThreadPoolExecutor
线程池,两者实现原理是不一样的。

两者最明显的区别在于:
ThreadPoolExecutor
中的线程无法向任务队列中再添加一个任务并在等待该任务完成之后再继续执行;而
ForkJoinPool
可以实现这一点,它能够让其中的线程创建新的任务添加到队列中,并挂起当前的任务,此时线程继续从队列中选择子任务执行。

因此在 JDK 1.7 中,
ForkJoinPool
线程池的实现是一个全新的类,并没有复用
ThreadPoolExecutor
线程池的实现逻辑,两者用途不同。

3.1、ForkJoinPool

ForkJoinPool

Fork/Join
框架中负责任务执行的线程池,核心构造方法源码如下:

/**
 * 核心构造方法
 * @param parallelism   可并行执行的线程数量
 * @param factory       创建线程的工厂   
 * @param handler       异常捕获处理器
 * @param asyncMode     任务队列模式,true:先进先出的工作模式,false:先进后出的工作模式
 */
public ForkJoinPool(int parallelism,
                    ForkJoinWorkerThreadFactory factory,
                    UncaughtExceptionHandler handler,
                    boolean asyncMode) {
    this(checkParallelism(parallelism),
            checkFactory(factory),
            handler,
            asyncMode ? FIFO_QUEUE : LIFO_QUEUE,
            "ForkJoinPool-" + nextPoolId() + "-worker-");
    checkPermission();
}

默认无参的构造方法,源码如下:

public ForkJoinPool() {
    this(Math.min(MAX_CAP, Runtime.getRuntime().availableProcessors()),
         defaultForkJoinWorkerThreadFactory, null, false);
}

默认构造方法创建
ForkJoinPool
线程池,关键参数设置如下:

  • parallelism
    :取的是当前计算机可用的 CPU 数量
  • factory
    :采用的是默认
    DefaultForkJoinWorkerThreadFactory
    类,其中
    ForkJoinWorkerThread

    Fork/Join
    框架中负责真正执行任务的线程
  • asyncMode
    :参数设置的是
    false
    ,也就是说存在队列的任务采用的是先进后出的方式工作

其次,也可以使用
Executors
工具类来创建
ForkJoinPool
,例如下面这种方式:

// 创建一个 ForkJoinPool 线程池
ExecutorService forkJoinPool = Executors.newWorkStealingPool();


ThreadPoolExecutor
线程池一样,
ForkJoinPool
也实现了
Executor

ExecutorService
接口,支持通过
execute()

submit()
等方式提交任务。

不过,正如上面所说,
ForkJoinPool

ThreadPoolExecutor
在实现上是不一样的:


  • ThreadPoolExecutor
    中,多个线程都共有一个阻塞任务队列

  • ForkJoinPool
    中每一个线程都有一个自己的任务队列,当线程发现自己的队列里没有任务了,就会到别的线程的队列里获取任务执行。

这样设计的目的主要是充分利用线程实现并行计算的效果,减少线程之间的竞争。

比如线程 A 负责处理队列 A 里面的任务,线程 B 负责处理队列 B 里面的任务,两者如果队列里面的任务数差不多,执行的时候互相不干扰,此时的计算性能是最佳的;假如线程 A 的任务执行完毕,发现线程 B 中的队列数还有一半没有执行,线程 A 会主动从线程 B 的队列里获取任务执行。

在这时它们会同时访问同一个队列,为了减少线程 A 和线程 B 之间的竞争,通常会使用双端队列,线程 B 从双端队列的头部拿任务执行,而线程 A 从双端队列的尾部拿任务执行,确保两者不会从同一端获取任务,可以显著加快任务的执行速度。

Fork/Join
框架中负责执行任务的线程
ForkJoinWorkerThread
,部分源码如下:

public class ForkJoinWorkerThread extends Thread {
    
    // 所在的线程池
    final ForkJoinPool pool;

    // 当前线程下的任务队列
    final ForkJoinPool.WorkQueue workQueue;

    // 初始化时的构造方法
    protected ForkJoinWorkerThread(ForkJoinPool pool) {
        // Use a placeholder until a useful name can be set in registerWorker
        super("aForkJoinWorkerThread");
        this.pool = pool;
        this.workQueue = pool.registerWorker(this);
    }
}

3.2、ForkJoinTask

ForkJoinTask

Fork/Join
框架中负责任务分解和合并计算的抽象类,它实现了
Future
接口,因此可以直接作为任务类提交到线程池中。

同时,它还包括两个主要方法:
fork()

join()
,分别表示任务的分拆与合并。

可以使用下图来表示这个过程。

ForkJoinTask
部分方法,源码如下:

public abstract class ForkJoinTask<V> implements Future<V>, Serializable {
    
    // 将任务推送到任务队列
    public final ForkJoinTask<V> fork() {
        Thread t;
        if ((t = Thread.currentThread()) instanceof ForkJoinWorkerThread)
            ((ForkJoinWorkerThread)t).workQueue.push(this);
        else
            ForkJoinPool.common.externalPush(this);
        return this;
    }

    // 等待任务的执行结果
    public final V join() {
        int s;
        if ((s = doJoin() & DONE_MASK) != NORMAL)
            reportException(s);
        return getRawResult();
    }
}

在 JDK 中,
ForkJoinTask
有三个常用的子类实现,分别如下:

  • RecursiveAction
    :用于没有返回结果的任务
  • RecursiveTask
    :用于有返回结果的任务
  • CountedCompleter
    :在任务完成执行后,触发自定义的钩子函数

我们最上面介绍的用例,使用的就是
RecursiveTask
子类,通常用于有返回值的任务计算。

ForkJoinTask
其实是利用了递归算法来实现任务的拆分,将拆分后的子任务提交到线程池的任务队列中进行执行,最后将各个拆分后的任务计算结果进行汇总,得到最终的任务结果。

四、小结

整体上,
ForkJoinPool
可以看成是对
ThreadPoolExecutor
线程池的一种补充,在工作线程中存放了任务队列,充分利用线程进行并行计算,进一步提升了线程的并发执行性能。

通过
ForkJoinPool

ForkJoinTask
搭配使用,将超大计算任务拆分成多个互不干扰的小任务,提交给线程池进行计算,最后将各个任务计算结果进行汇总处理,得到跟单线程执行一致的结果,当计算任务越大,
Fork/Join
框架执行任务的效率,优势更突出。

但是并不是所有的任务都适合采用
Fork/Join
框架来处理,比如读写数据文件这种 IO 密集型的任务就不合适,因为磁盘 IO、网络 IO 的操作特点就是等待,容易造成线程阻塞。

五、参考

1.
https://www.liaoxuefeng.com/wiki/1252599548343744/1306581226487842

2.
https://juejin.cn/post/6986899215163064333

3.
https://developer.aliyun.com/article/806887

毫不夸张地说,路由是ASP.NET Core最为核心的部分。路由的本质就是注册一系列终结点(Endpoint),每个终结点可以视为“路由模式”和“请求处理器”的组合,它们分别用来“选择”和“处理”请求。请求处理器通过RequestDelegate来表示,但是当我们在进行路由编程的时候,却可以使用任意类型的Delegate作为处理器器,这一切的背后是如何实现的呢?

一、指定任意类型的委托处理路由请求
二、参数绑定
三、返回值处理

一、指定任意类型的委托处理路由请求

路由终结点总是采用一个RequestDelegate委托作为请求处理器,上面介绍的这一系列终结点注册的方法提供的也都是RequestDelegate委托。实际上IEndpointConventionBuilder接口还定义了如下这些用来注册终结点的扩展方法,它们接受任意类型的委托作为处理器。

public static class EndpointRouteBuilderExtensions
{
    public static RouteHandlerBuilder Map(this IEndpointRouteBuilder endpoints, string pattern, Delegate handler);
    public static RouteHandlerBuilder Map(this IEndpointRouteBuilder endpoints, RoutePattern pattern, Delegate handler);
    public static RouteHandlerBuilder MapMethods(this IEndpointRouteBuilder endpoints, string pattern, IEnumerable<string> httpMethods, Delegate handler);
    public static RouteHandlerBuilder MapGet(this IEndpointRouteBuilder endpoints, string pattern, Delegate handler);
    public static RouteHandlerBuilder MapPost(this IEndpointRouteBuilder endpoints, string pattern, Delegate handler);
    public static RouteHandlerBuilder MapPut(this IEndpointRouteBuilder endpoints, string pattern, Delegate handler);
    public static RouteHandlerBuilder MapDelete(this IEndpointRouteBuilder endpoints, string pattern, Delegate handler);
}

由于表示路由终结点的RouteEndpoint对象总是将RequestDelegate委托作为请求处理器,所以上述这些扩展方法提供的Delegate对象最终还得转换成RequestDelegate类型,两者之间的适配或者类型转换是由如下这个RequestDelegateFactory类型的Create方法完成的。这个方法根据提供的Delegate对象创建一个RequestDelegateResult对象,后者不仅封装了转换生成的RequestDelegate委托,终结点的元数据集合也在其中。RequestDelegateFactoryOptions是为处理器转换提供的配置选项。

public static class RequestDelegateFactory
{
    public static RequestDelegateResult Create(Delegate handler,RequestDelegateFactoryOptions options = null);
}

public sealed class RequestDelegateResult
{
    public RequestDelegate RequestDelegate { get; }
    public IReadOnlyList<object> EndpointMetadata { get; }

    public RequestDelegateResult(RequestDelegate requestDelegate,   IReadOnlyList<object> metadata);
}

public sealed class RequestDelegateFactoryOptions
{
    public IServiceProvider ServiceProvider { get; set; }
    public IEnumerable<string> RouteParameterNames { get; set; }
    public bool ThrowOnBadRequest { get; set; }
    public bool DisableInferBodyFromParameters { get; set; }
}

我并不打算详细介绍从Delegate向RequestDelegate转换的具体流程,而是通过几个简单的实例演示一下提供的各种类型的委托是如何执行的,这里主要涉及“参数绑定”和“返回值处理”两方面的处理策略。

二、参数绑定

既然可以将一个任意类型的委托终结点的处理器,意味着路由系统在执行委托的时候能够自行绑定其输入参数。这里采用的参数绑定策略与ASP.NET MVC的“模型绑定”如出一辙。当定义某个用来处理请求的方法时,我们可以在输入参数上标注一些特性显式指定绑定数据的来源,这些特性大都实现了如下这些接口。从接口命名可以看出,它们表示绑定的目标参数的原始数据分别来源于路由参数、查询字符串、请求报头、请求主体以及依赖注入容器提供的服务。

public interface IFromRouteMetadata
{
    string Name { get; }
}

public interface IFromQueryMetadata
{
    string Name { get; }
}

public interface IFromHeaderMetadata
{
    string Name { get; }
}

public interface IFromBodyMetadata
{
    bool AllowEmpty { get; }
}

public interface IFromServiceMetadata
{
}

如下这些特性实现了上面这几个接口,它们都定义在“Microsoft.AspNetCore.Mvc”命名空间下,因为它们原本是为了ASP.NET MVC下的模型绑定服务的。值得一提的是FromQueryAttribute特性不被支持,不知道是刻意为之还是把这个漏掉了。

[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)]
public class FromRouteAttribute : Attribute, IBindingSourceMetadata, IModelNameProvider, IFromRouteMetadata
{
    public BindingSource BindingSource { get; }
    public string Name { get; set; }
}

[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)]
public class FromQueryAttribute : Attribute, IBindingSourceMetadata, IModelNameProvider, IFromQueryMetadata
{

    public BindingSource BindingSource { get; }
    public string Name { get; set; }
}

[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)]
public class FromHeaderAttribute : Attribute, IBindingSourceMetadata, IModelNameProvider, IFromHeaderMetadata
{
    public BindingSource BindingSource { get; }
    public string Name { get; set; }
}

[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)]
public class FromBodyAttribute : Attribute, IBindingSourceMetadata, IConfigureEmptyBodyBehavior, IFromBodyMetadata
{
    public BindingSource BindingSource { get; }
    public EmptyBodyBehavior EmptyBodyBehavior { get; set; }
    bool IFromBodyMetadata.AllowEmpty { get; }
}

[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)]
public class FromServicesAttribute : Attribute, IBindingSourceMetadata, IFromServiceMetadata
{
    public BindingSource BindingSource { get; }
}

如下这个演示程序调用WebApplication对象的MapPost方法注册了一个采用“/{foo}”作为模板的终结点。作为终结点处理器的委托指向静态方法Handle,我们为这个方法定义了五个参数,分别标注了上述五个特性。我们将五个参数组合成一个匿名对象作为返回值。

using Microsoft.AspNetCore.Mvc;
var app = WebApplication.Create();
app.MapPost("/{foo}", Handle);
app.Run();

static object Handle(
    [FromRoute] string foo,
    [FromQuery] int bar,
    [FromHeader] string host,
    [FromBody] Point point,
    [FromServices] IHostEnvironment environment)
    => new { Foo = foo, Bar = bar, Host = host, Point = point, Environment = environment.EnvironmentName };

public class Point
{
    public int X { get; set; }
    public int Y { get; set; }
}

程序启动之后,我们针对“http://localhost:5000/abc?bar=123”这个URL发送了一个POST请求,请求的主体内容为一个Point对象序列化成生成的JSON。如下所示的是请求报文和响应报文的内容,可以看出Handle方法的foo和bar参数分别绑定的是路由参数“foo”和查询字符串“bar”的值,参数host绑定的是请求的Host报头,参数point是请求主体内容反序列化的结果,参数environment则是由针对当前请求的IServiceProvider对象提供的服务。

POST http://localhost:5000/abc?bar=123 HTTP/1.1
Content-Type: application/json
Host: localhost:5000
Content-Length: 18

{"x":123, "y":456}
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Sat, 06 Nov 2021 11:55:54 GMT
Server: Kestrel
Content-Length: 100

{"foo":"abc","bar":123,"host":"localhost:5000","point":{"x":123,"y":456},"environment":"Production"}

如果请求处理器方法的参数没有显式指定绑定数据的来源,路由系统也能根据参数的类型尽可能地从当前HttpContext上下文中提取相应的内容予以绑定。针对如下这几个类型,对应参数的绑定源是明确的。

  • HttpContext
    :绑定为当前HttpContext上下文。
  • HttpRequest
    :绑定为当前HttpContext上下文的Request属性。
  • HttpResponse
    : 绑定为当前HttpContext上下文的Response属性。
  • ClaimsPrincipal
    : 绑定为当前HttpContext上下文的User属性。
  • CancellationToken
    : 绑定为当前HttpContext上下文的RequestAborted属性。

上述的绑定规则体现在如下演示程序的调试断言中。这个演示实例还体现了另一个绑定规则,那就是
只要当前请求的IServiceProvider能够提供对应的服务,对应参数(“httpContextAccessor”)上标注的FromSerrvicesAttribute特性不是必要的

但是倘若缺少对应的服务注册,请求的主体内容会一般会作为默认的数据来源
,所以FromSerrvicesAttribute特性最好还是显式指定为好。对于我们演示的这个例子,如果我们将前面针对AddHttpContextAccessor方法的调用移除,对应参数的绑定自然会失败,但是错误消息并不是我们希望看到的。

using System.Diagnostics;
using System.Security.Claims;

var builder = WebApplication.CreateBuilder();
builder.Services.AddHttpContextAccessor();
var app = builder.Build();
app.MapGet("/", Handle);
app.Run();

static void Handle(
    HttpContext httpContext,
    HttpRequest request,
    HttpResponse response,
    ClaimsPrincipal user,
    CancellationToken cancellationToken,
    IHttpContextAccessor httpContextAccessor)
{
    var currentContext = httpContextAccessor.HttpContext;
    Debug.Assert(ReferenceEquals(httpContext, currentContext));
    Debug.Assert(ReferenceEquals(request, currentContext.Request));
    Debug.Assert(ReferenceEquals(response, currentContext.Response));
    Debug.Assert(ReferenceEquals(user, currentContext.User));
    Debug.Assert(cancellationToken == currentContext.RequestAborted);
}

对于字符串类型的参数,
路由参数

查询字符串
是两个候选数据源,前者具有更高的优先级。也就是说如果路由参数和查询字符串均提供了某个参数的值,此时会优先选择路由参数提供的值。我个人倒觉得两种绑定源的优先顺序应该倒过来,查询字符串优先级似乎应该更高。对于我们自定义的类型,对应参数默认由请求主体内容反序列生成。由于请求的主体内容只有一份,所以不能出现多个参数都来源请求主体内容的情况,所以下面代码注册的终结点处理器是不合法的。

var app = WebApplication.Create();
app.MapGet("/", (Point p1, Point p2) => { });
app.Run();

public class Point
{
    public int X { get; set; }
    public int Y { get; set; }
}

如果我们在某个类型中定义了一个名为TryParse的静态方法将指定的字符串表达式转换成当前类型的实例,路由系统在对该类型的参数进行绑定的时候会优先从路由参数和查询字符串中提取相应的内容,并通过调用这个方法生成绑定的参数。

var app = WebApplication.Create();
app.MapGet("/", (Point foobar) => foobar);
app.Run();

public class Point
{
    public int X { get; set; }
    public int Y { get; set; }

    public Point(int x, int y)
    {
        X = x;
        Y = y;
    }
    public static bool TryParse(string expression, out Point? point)
    {
        var split = expression.Trim('(', ')').Split(',');
        if (split.Length == 2 && int.TryParse(split[0], out var x) && int.TryParse(split[1], out var y))
        {
            point = new Point(x, y);
            return true;
        }
        point = null;
        return false;
    }
}

上面的演示程序为自定义的Point类型定义了一个静态的
TryParse
方法使我们可以将一个以“(x,y)”形式定义的表达式转换成Point对象。注册的终结点处理器委托以该类型为参数,指定的参数名称为“foobar”。我们在发送的请求中以查询字符串的形式提供对应的表达式“(123,456)”,从返回的内容可以看出参数得到了成功绑定。

image

图1  TryParse方法针对参数绑定的影响

如果某种类型的参数具有特殊的绑定方式,我们还可以将具体的绑定实现在一个按照约定定义的
BindAsync
方法中。按照约定,这个BindAsync应该定义成返回类型为ValueTask<T>的静态方法,它可以拥有一个类型为HttpContext的参数,也可以额外提供一个ParameterInfo类型的参数,这两个参数分别与当前HttpContext上下文和描述参数的ParameterInfo对象绑定。前面演示实例中为Point类型定义了一个TryParse方法可以替换成如下这个 BingAsync方法。

public class Point
{
    public int X { get; set; }
    public int Y { get; set; }

    public Point(int x, int y)
    {
        X = x;
        Y = y;
    }

    public static ValueTask<Point?> BindAsync(HttpContext httpContext, ParameterInfo parameter)
    {
        Point? point = null;
        var name = parameter.Name;
        var value = httpContext.GetRouteData().Values.TryGetValue(name!, out var v)
            ? v
            : httpContext.Request.Query[name!].SingleOrDefault();

        if (value is string expression)
        {
            var split = expression.Trim('(', ')')?.Split(',');
            if (split?.Length == 2 && int.TryParse(split[0], out var x) && int.TryParse(split[1], out var y))
            {
                point = new Point(x, y);
            }
        }
        return new ValueTask<Point?>(point);
    }
}

三、返回值处理

作为终结点处理器的委托对象不仅对输入参数没有要求,它还可以返回任意类型的对象。如果返回类型为
Void

Task
或者
ValueTask
,均表示没有返回值。如果返回类型为
String
、Task<String>或者ValueTask<String>,返回的字符串将直接作为响应的主体内容,响应的媒体类型会被设置为“
text/plain
”。对于其他类型的返回值(包括Task<T>或者ValueTask<T>),默认情况都会序列化成JSON作为响应的主体内容,响应的媒体类型会被设置为“
application/json
”,即使返回的是原生类型(比如Int32)也是如此。

var app = WebApplication.Create();
app.MapGet("/foo", () => "123");
app.MapGet("/bar", () => 123);
app.MapGet("/baz", () => new Point {  X = 123, Y = 456});
app.Run();

public class Point
{
    public int X { get; set; }
    public int Y { get; set; }
}

上面的演示程序注册了三个终结点,作为处理器的返回值分别为字符串、整数和Point对象。如果我们针对这三个终结点发送对应的GET请求,将得到如下所示的响应。

HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Date: Sun, 07 Nov 2021 01:13:47 GMT
Server: Kestrel
Content-Length: 3

123
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Sun, 07 Nov 2021 01:14:11 GMT
Server: Kestrel
Content-Length: 3

123
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Sun, 07 Nov 2021 01:14:26 GMT
Server: Kestrel
Content-Length: 17

{"x":123,"y":456}

如果曾经从事过ASP.NET MVC应用的开发,应该对IActionResult接口感到很熟悉。定义在Controller类型中的Action方法一般返回会IActionResult(或者Task<IActionResult>和ValueTask<IActionResult>)对象。当Action方法执行结束后,MVC框架会直接调用返回的IActionResult对象的ExecuteResultAsync方法完整最终针对响应的处理。相同的设计同样被“移植”到这里,并为此定义了如下这个IResult接口。

public interface IResult
{
    Task ExecuteAsync(HttpContext httpContext);
}

如果终结点处理器方法返回一个IResult对象或者返回一个Task<T>或ValueTask<T>(T实现了IResult接口),那么IResult对象ExecuteAsync方法将用来完成后续针对响应的处理工作。IResult接口具有一系列的原生实现类型,不过它们大都被定义成了内部类型。虽然我们不能直接调用构造函数构建它们,但是我们可以通过调用定义在Results类型中的如下这些静态方法来使用它们。

public static class Results
{
    public static IResult Accepted(string uri = null, object value = null);
    public static IResult AcceptedAtRoute(string routeName = null, object routeValues = null, object value = null);
    public static IResult BadRequest(object error = null);
    public static IResult Bytes(byte[] contents, string contentType = null, string fileDownloadName = null, bool enableRangeProcessing = false, DateTimeOffset? lastModified = default, EntityTagHeaderValue entityTag = null);
    public static IResult Challenge(AuthenticationProperties properties = null, IList<string> authenticationSchemes = null);
    public static IResult Conflict(object error = null);
    public static IResult Content(string content, MediaTypeHeaderValue contentType);
    public static IResult Content(string content, string contentType = null, Encoding contentEncoding = null);
    public static IResult Created(string uri, object value);
    public static IResult Created(Uri uri, object value);
    public static IResult CreatedAtRoute(string routeName = null, object routeValues = null, object value = null);
    public static IResult File(byte[] fileContents, string contentType = null, string fileDownloadName = null, bool enableRangeProcessing = false, DateTimeOffset? lastModified = default, EntityTagHeaderValue entityTag = null);
    public static IResult File(Stream fileStream, string contentType = null, string fileDownloadName = null, DateTimeOffset? lastModified = default, EntityTagHeaderValue entityTag = null, bool enableRangeProcessing = false);
    public static IResult File(string path, string contentType = null, string fileDownloadName = null, DateTimeOffset? lastModified = default, EntityTagHeaderValue entityTag = null, bool enableRangeProcessing = false);
    public static IResult Forbid(AuthenticationProperties properties = null, IList<string> authenticationSchemes = null);
    public static IResult Json(object data, JsonSerializerOptions options = null, string contentType = null, int? statusCode = default);
    public static IResult LocalRedirect(string localUrl, bool permanent = false, bool preserveMethod = false);
    public static IResult NoContent();
    public static IResult NotFound(object value = null);
    public static IResult Ok(object value = null);
    public static IResult Problem(string detail = null, string instance = null, int? statusCode = default, string title = null, string type = null);
    public static IResult Redirect(string url, bool permanent = false, bool preserveMethod = false);
    public static IResult RedirectToRoute(string routeName = null, object routeValues = null, bool permanent = false, bool preserveMethod = false, string fragment = null);
    public static IResult SignIn(ClaimsPrincipal principal, AuthenticationProperties properties = null, string authenticationScheme = null);
    public static IResult SignOut(AuthenticationProperties properties = null, IList<string> authenticationSchemes = null);
    public static IResult StatusCode(int statusCode);
    public static IResult Stream(Stream stream, string contentType = null, string fileDownloadName = null, DateTimeOffset? lastModified = default, EntityTagHeaderValue entityTag = null, bool enableRangeProcessing = false);
    public static IResult Text(string content, string contentType = null, Encoding contentEncoding = null);
    public static IResult Unauthorized();
    public static IResult UnprocessableEntity(object error = null);
    public static IResult ValidationProblem(IDictionary<string, string[]> errors, string detail = null, string instance = null, int? statusCode = default, string title = null, string type = null);
}

一、引言
在.NET开发中,操作Office文档(特别是Excel和Word)是一项常见的需求。然而,在服务器端或无Microsoft Office环境的场景下,直接使用Office Interop可能会面临挑战。为了解决这个问题,开源库NPOI应运而生,它提供了无需安装Office即可创建、读取和修改Excel (.xls, .xlsx) 和 Word (.doc) 文件的能力。
二、NPOI简介
NPOI是一个基于.NET的API,用于读写微软的OLE 2 Compound Document formats,如Microsoft Office Excel和Word。它是Apache POI项目的.NET移植版本,允许开发者在没有安装Office的情况下处理这些格式的文件。
三、主要组件与方法
1. 安装与引用
Install-Package NPOI
或者在Visual Studio中右键项目 -> 管理NuGet程序包 -> 搜索并安装NPOI。
2. 引用命名空间

using NPOI.SS.UserModel; // Excel相关的接口和类
using NPOI.XSSF.UserModel; // 处理.xlsx文件
using NPOI.HSSF.UserModel; // 处理.xls文件
using System.IO; // 文件操作

3. 创建/打开工作簿

  • 创建一个新的Excel工作簿(.xlsx):
    var workbook = new XSSFWorkbook();
  • 打开一个现有的Excel文件(.xlsx或.xls):
FileStream file = new FileStream("path_to_your_file.xlsx", FileMode.Open, FileAccess.Read);
var workbook = new XSSFWorkbook(file); // .xlsx格式
var workbook = new HSSFWorkbook(file); // .xls格式

4. 创建/获取工作表

  • 创建新的工作表:
    ISheet sheet = workbook.CreateSheet("Sheet1");
  • 获取已存在的工作表:
    ISheet sheet = workbook.GetSheetAt(0); // 获取索引为0的工作表
    5. 写入单元格数据
  • 创建行与单元格:
IRow row = sheet.CreateRow(rowIndex);
ICell cell = row.CreateCell(columnIndex);
  • 设置单元格值:
cell.SetCellValue("文本内容"); // 文本
cell.SetCellValue(12345); // 数字

6. 合并单元格

var region = new CellRangeAddress(startRow, endRow, startColumn, endColumn);
sheet.AddMergedRegion(region);

7. 设置单元格样式

  • 创建样式对象:
    var style = workbook.CreateCellStyle();
  • 设置样式属性(如字体、颜色、对齐方式等):
style.Alignment = HorizontalAlignment.Center;
IFont font = workbook.CreateFont();
font.Boldweight = (short)FontBoldWeight.Bold;
style.SetFont(font);
  • 应用到单元格:
    cell.CellStyle = style;
    8. 保存文件
FileStream outputStream = new FileStream("output.xlsx", FileMode.Create);
workbook.Write(outputStream);
outputStream.Close();

9. 关闭资源

  • 确保关闭Workbook以及相关的文件流以释放资源:
workbook.Close();
file.Close();

四、高级功能

  • 读取现有文件:通过FileStream打开并读取Excel文件内容。

  • 读取公式结果:通过ICell.CachedFormulaResultType或ICell.CellFormula属性获取公式计算结果。

  • 操作图表和图片:NPOI支持插入、更新和删除Excel中的图表和图片。

  • 处理Word文档:通过XWPFDocument类来创建、读取和修改Word(.doc)文件。

五、总结

NPOI库凭借其强大的功能和轻量级设计,已成为.NET环境下处理Office文档的标准工具之一。无论是在Web应用程序后台处理用户上传的Excel文件,还是在批处理任务中生成报表,NPOI都能提供高效且易于使用的解决方案。通过深入理解和熟练运用NPOI的各种方法,开发者可以轻松应对各种复杂的文档处理任务。

六、完整的读取数据例子

using NPOI.HSSF.UserModel;
using NPOI.SS.UserModel;
using System.IO;
  public void ReadExcelFile(string filePath)
    {
        // 创建一个FileStream对象来打开Excel文件
        using (FileStream file = new FileStream(filePath, FileMode.Open, FileAccess.Read))
        {
            // 根据文件扩展名选择合适的Workbook实现
            IWorkbook workbook;
            if (Path.GetExtension(filePath).ToLower() == ".xlsx")
            {
                workbook = new XSSFWorkbook(file);
            }
            else
            {
                workbook = new HSSFWorkbook(file); // 适用于.xls文件
            }

            // 获取第一个工作表(索引从0开始)
            ISheet sheet = workbook.GetSheetAt(0);

            // 遍历所有行
            for (int rowIndex = 0; rowIndex <= sheet.LastRowNum; rowIndex++)
            {
                IRow row = sheet.GetRow(rowIndex);

                // 如果当前行不为空,则遍历所有单元格
                if (row != null)
                {
                    for (int cellIndex = 0; cellIndex < row.LastCellNum; cellIndex++)
                    {
                        ICell cell = row.GetCell(cellIndex);

                        // 检查单元格是否存在并获取其数据类型和值
                        if (cell != null)
                        {
                            switch (cell.CellType)
                            {
                                case CellType.Numeric:
                                    double numericValue = cell.NumericCellValue;
                                    Console.WriteLine($"第{rowIndex + 1}行,第{cellIndex + 1}列:{numericValue}");
                                    break;
                                case CellType.String:
                                    string stringValue = cell.StringCellValue;
                                    Console.WriteLine($"第{rowIndex + 1}行,第{cellIndex + 1}列:{stringValue}");
                                    break;
                                case CellType.Formula:
                                    // 如果需要显示公式计算结果,使用CachedFormulaResultType
                                    if (cell.CachedFormulaResultType == CellType.Numeric)
                                    {
                                        double formulaValue = cell.NumericCellValue;
                                        Console.WriteLine($"第{rowIndex + 1}行,第{cellIndex + 1}列(公式结果):{formulaValue}");
                                    }
                                    else if (cell.CachedFormulaResultType == CellType.String)
                                    {
                                        string formulaValue = cell.StringCellValue;
                                        Console.WriteLine($"第{rowIndex + 1}行,第{cellIndex + 1}列(公式结果):{formulaValue}");
                                    }
                                    // 其他类型的公式处理...
                                    break;
                                default:
                                    // 对于日期、布尔值等其他类型,做相应处理
                                    break;
                            }
                        }
                        else
                        {
                            Console.WriteLine($"第{rowIndex + 1}行,第{cellIndex + 1}列:空单元格");
                        }
                    }
                }
            }

            // 关闭workbook以释放资源
            workbook.Close();
        }
    }

在这个例子中,我们首先打开了指定路径下的Excel文件,并根据文件类型创建了相应的HSSFWorkbook或XSSFWorkbook对象。然后,我们循环遍历每一行和每一列,获取每个单元格的内容,并根据单元格类型输出对应的值。

请确保已安装NPOI NuGet包并在项目中引用了必要的命名空间。

七、保存数据例子

using NPOI.HSSF.UserModel;
using NPOI.SS.UserModel;
using System.IO;
 public void SaveDataToExcel(string filePath)
    {
        // 创建一个新的HSSFWorkbook(适用于.xls格式)或XSSFWorkbook(适用于.xlsx格式)
        IWorkbook workbook = new XSSFWorkbook(); // 使用.xlsx格式

        // 创建新的工作表并设置名称
        ISheet sheet = workbook.CreateSheet("Sheet1");

        // 创建第一行数据
        IRow headerRow = sheet.CreateRow(0);
        headerRow.CreateCell(0).SetCellValue("Name");
        headerRow.CreateCell(1).SetCellValue("Age");
        headerRow.CreateCell(2).SetCellValue("City");

        // 添加三行数据
        for (int i = 1; i <= 3; i++)
        {
            IRow dataRow = sheet.CreateRow(i);

            dataRow.CreateCell(0).SetCellValue($"Person {i}");
            dataRow.CreateCell(1).SetCellValue(i * 10); // 年龄假设为i*10
            dataRow.CreateCell(2).SetCellValue($"City{i}");

            // 设置单元格样式(可选,例如设置字体加粗和居中)
            ICellStyle style = workbook.CreateCellStyle();
            style.Alignment = HorizontalAlignment.Center;
            IFont font = workbook.CreateFont();
            font.Boldweight = (short)FontBoldWeight.Bold;
            style.SetFont(font);
            dataRow.Cells[0].CellStyle = style;
        }

        // 保存到文件
        using (FileStream fileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write))
        {
            workbook.Write(fileStream);
        }

        // 关闭workbook以释放资源
        workbook.Close();
    }

在这个例子中,我们首先创建了一个新的XSSFWorkbook对象,并在其中创建了一个名为"Sheet1"的工作表。接着,我们在表头添加了列名,并插入了三行示例数据。为了演示单元格样式的设置,我们将第一列的数据设置为了加粗和居中的样式。最后,将整个工作簿内容写入指定路径的Excel文件。

请注意,根据实际需求选择创建HSSFWorkbook或XSSFWorkbook,以及调整保存的数据和样式。确保已安装NPOI NuGet包并在项目中引用了必要的命名空间。

论文标题:High-Performance Large-Scale Image Recognition Without Normalization

论文作者:Andrew Brock Soham De  Samuel L.Smith  Karen Simonyan

论文地址:https://arxiv.org/abs/2102.06171

论文中模型的实现地址:https://github.com/deepmind/deepmind-research/tree/master/nfnets

声明:小编翻译论文仅为学习,如有侵权请联系小编删除博文,谢谢!

小编是一个机器学习初学者,打算认真研究论文,但是英文水平有限,所以论文翻译中用到了Google,并自己逐句检查过,但还是会有显得晦涩的地方,如有语法/专业名词翻译错误,还请见谅,并欢迎及时指出。

如果需要小编其他论文翻译,请移步小编的GitHub地址

传送门:
请点击我

如果点击有误:https://github.com/LeBron-Jian/DeepLearningNote

其实很久没有发翻译深度学习论文的博客了,一方面是工作中一些事情太忙,另一方面也是自己看英文文献的水平提高了,但是最近得空将自己之前的翻译的放出来,这就是我们常使用的一个backbone论文,我今天将自己博客里面的文章好好看了一遍,发现这个已经写好了,但是没有发布,它就是Normalizer-Free ResNets家族,简称NFNets(是不使用BatchNorm的大尺度图像分类网络)。也是得空将此整理好发出来。

众所周知,Batch Norm 作为深度学习的常用优化方法之一,几乎被应用于所有的模型中,但是Batch Norm也存在一些问题:

  • 1,它带来昂贵的计算代价,增加内存开销,并显著增加了计算梯度所需要的时间(前向传播和反向传播都需要额外的计算,并且需要暂存中间值)
  • 2,它带来模型训练和推理时的差异,引入需要微调的超参数(训练时不同batch独立计算,推理时使用训练时的滑动平均)
  • 3,它打破了训练样本不同 minibatch 之间的独立性(使用多块GPU计算时,每块GPU处理不同的 minibatch,前向传播时独立计算 Batch Norm,但反向传播时需要交互)

作者提出了一种自适应梯度裁剪(Adaptive Gradient Clipping, AGC)策略,通过应用该策略,模型即使不使用Batch Norm也取得最好的性能。

梯度裁剪是指限制梯度的范数值,通常的梯度裁剪表示如下:

作者提出的自适应梯度裁剪,不是限制梯度的范数值,而是限制参数的梯度和参数值的范数值之比:

摘要

BN层是大多数分类模型的关键成分,但是因为其依赖于batch的大小以及在样本间引入相关性,也存在许多不受欢迎的特点,虽然近期也有一些work 成功不使用BN训练了较深的ResNet,但是这些模型的测试准确率通常无法与最佳的BN网络的结果相比较,并且对于较大的学习率和较强的数据增强不稳定。在本文中作者提出了一种AGC技术来克服这些问题,并且设计了一类效果显著的网络 NFNets,在ImageNet上,较小的模型测试结果可以与 EfficientNet-B7相当,训练时间则加快了8.7倍,而最大的模型取得了 86.5%的SOTA指标。并且,在300 million 数据集上预训练后在 ImageNet 微调,NFNets 相比于BN网络取得了更加优异的表现,Top1 准确率达到了 89.2%。

图1:
ImageNet验证准确性与训练延迟
。所有数字都是单一型号、单一裁剪。我们的NFNet-F1模型实现了与EffNet-B7相当的精度,同时训练速度快8.7倍。我们的NFNet-F5模型具有与EffNet-B7类似的训练延迟,但在ImageNet上实现了最先进的86.0%的top-I准确率。我们使用Sharpness Aware Minimization(Foret et al.,2021)对此进行了进一步改进,以实现86.5%的top-I准确率。

1,简介

最近计算机视觉中的绝大多数模型都是深度残差网络的变体(He et al.,2016b;a),使用批量归一化进行训练(Ioffe&Szegedy,2015)。这两种架构创新的结合使从业者能够训练更深入的网络,从而在训练集和测试集上实现更高的准确性。批量归一化还平滑了损失的landscape(Santurkar等人,2018),这使得能够以更大的学习率和更大的批量进行稳定的训练(Bjorck等人,2018;De&Smith,2020),并且它可以具有正则化效果(Hoffer等人,2017;Luo等人,2018年)。

简单说,就是computer vision中绝大多数模型是使用BN的深度残差网络,两个架构的结合可以使研究人员可以训练更深的网络,并且在训练集和测试集上取得更高的准确率。BN也平滑了损失函数,使模型在更大的学习率和batch size下可以稳定训练。此外也可以起到正则化的效果。

但是Batch Normalization也有以下三个问题:1,额外的计算开销:额外占用内存,影响某些网络的梯度计算时间;2,在训练和推理时引入了一些偏差,引入一些隐藏的超参使得我们不得不微调;3,也是most important,打破了训练样本间的独立性(BN的期望和方差都基于每个batch内的数据计算,不可避免的引入样本间的相关性)。

其中第三点有一系列的nagetive的后果,比如,BN网络在不同的硬件上难以精确复现,进一步的,分布式训练存在细微误差,不适用于某些网络和任务。因为训练的一些例子在BN中可能会使得网络欺骗损失函数。比如,在一些对比学习算法中BN层需要一些特殊的关心去阻止信息泄露,这主要的关心是指序列模型任务中,同时驱动一些大的模型去适应可选的正则化。BN网络的表现也可以使降级,如果批量数据在训练时有大量的变量的时候,最后,BN的表现是对于batch size是相当敏感的,并且当Batch size很小时,BN网络表现不充分,而batch size 也限制模型在有限的硬件上的最大size。

为此,虽然BN已经使深度学习社区取得了实质性进展,但是我们如果从长远来看,它可能会impede进展。我们相信社区应该寻找一种简单的替代方案,它可以实现具有竞争力的测试精度,并且可以用于广泛的任务。尽管有一系列可替代的方案被提出,但这些替代品经常在测试集有交叉的精度,并且会引入他们本身的缺陷,比如额外的计算成本。幸运的是,近些年来出现了两个有希望的研究主题。第一项研究了训练期间批量归一化的好处,第二个试图在没有归一化层的情况下训练深度ResNet以达到具有竞争力的精度。

这些工作的一个关键主题是,通过抑制残差分支上隐含激活层的规模,可以在没有归一化的情况下训练非常深的ResNet。实现这一点的最简单的方式是在每一个残差分支的最后引入一个科学系的归一化因子,但仅靠这一点难以取得令人满意的测试精度。此外有工作表明ReLU 激活函数引入了均值漂移,导致随着网络深度加深,不同训练样本的隐含激活层逐渐相关。最近也有工作提出了“Normalizer-Free" ResNets,在初始化时抑制残差分支并使用 Scaled Weight Standardization 去除均值漂移。辅以额外的正则化方法,这些非归一化网络在ImageNet上可以取得和BN ResNets 相媲美的表现,但是在大batch size下不稳定且效果不如EfficientNets。本文的贡献如下:

1,提出了自适应梯度裁剪技术(AGC ),根据梯度范数与参数在单元尺度上的比例裁剪梯度,并论证AGC允许训练更大 batchsize和更强 数据增强的NFNets

2,设计了一系列 Normalizer-Free ResNets,称之为 NFNets,在ImageNet上取得了SOTA的指标,NFNet-F1的准确率和EfficientNet-B7相近,训练时间快 8.7倍,且最大的NFNet在使用额外数据预训练的前提下获得了 86.5%的SOTA指标

3,当使用3亿带标注数据预训练的前提下,NFNets比BN网络在ImageNet上取得了更高的验证集准确率。在微调后,最佳模型取得了 89.2%的Top1的准确率。

2,理解 Batch Normalization

为了在没有Batch Normalization的情况下训练网络可以达到有竞争性的精度,我们必须理解Batch Bormalization在训练期间带来的好处,并且确定要恢复这些benefit的替代策略,在这里我们列出了先前工作确定的四个主要好处。

1,BN减小了残差分支的规模,跳跃连接和BN是研究人员得以训练更深的网络。这主要得益于BN置于残差分支时减小了其隐含激活层的规模,使得信号偏向skip path 直接传输,有助于训练超深的网络。

2,BN消除了 mean-shift:激活函数是非对称且均值非零的,使得训练初期激活后的特征值会变大且均为正数,batch normalization恰好可以消除这一问题。

3,BN起到一定的正则化效应:由于batch normalization 训练时用的是 minibatch 统计信息,相当于为当前 batch 引入了噪声,起到正则化的作用,可以防止过拟合,提高准确率。

4,BN允许高效的大Batch训练:batch normalization 能够平滑loss曲线,可以使用更大的学习率和 batch size 进行训练。

这里再结合自己对Batch Normalization 的理解做一些笔记:

众所周知,BN是由谷歌提出的一种训练优化方法。BatchNorm就是深度神经网络训练过程中使得每一层神经网络的输入保持相同分布的。因为在训练模型的过程中,如果深度学习包含很多隐层的网络结构,那么各层参数都在变,所以每个隐层都会面临covariate shift 问题(即隐层的输入分布老是变来变去,这就是所谓的”Internal Convariate Shift")

Normalization 就是数据标准化(归一化,规范化),Batch就是批量,结合起来就是批量标准化,Batch在CNN中就是训练网络中所设定的图片数量 batch size。Normalization 的过程,引用其论文的解释就是:

我们知道,在正向传播的时候,通过可学习的 gamma 和 bate 参数可以求出新的分布值,而在反向传播的时候,通过链式求导方式,可以求出 gamma 和 beta 以及相关权重。

网络训练中以 batch size 为最小单元不断迭代,很显然,新的batch size进入网络,由于每一次的 batch 有差异,实际是通过变量,以及滑动平均来记录均值与方差。训练完成后,推断阶段时通过 gamma,beta以及记录的均值与方差计算BN层输出。结合论文中给出的使用过程进行解释:

BN自从提出后,因为小改起来特别方便,很快就作为深度学习的标准工具应用在各种场合,BN虽然好,但是也存在一些局限和问题,比如当Batch size太小时效果不佳,对于RNN等动态网络无法有效应用BN等。

GELU的学习

GELU 激活函数是使用在Google AI's BERT 和 OpenAI's GPT模型,这些模型的结果在各种各样的NLP任务中都达到了 SOTA的结果。

GELU的使用技巧:当训练过程中使用gelu作为激活函数进行训练的时候,建议使用一个带有动量(momentum)的优化器

GELU激活函数公式如下:

x是输入值,而X是具有零均值和单位方差的高斯随机变量。 P(X<=x) 是X小于或等于给定值x的概率。

3,走向移除Batch Normalization

许多作者试图在不进行归一化的情况下,通过恢复上述批量归一化的一个或多个好处,将深度ResNets训练到具有竞争力的精度。这些工作中的大多数通过引入小常数或可学习标量来抑制初始化时残差分支上的活动规模(Hanin&Rol-nick,2018;张等人,2019a;De Smith,2020;Shao等人,2020)。此外,张等人(2019a)和De Smith(2020)观察到,非正规化ResNets的性能可以通过额外的正规化来提高。然而,仅恢复批量归一化的这两个好处不足以在具有挑战性的基准上实现有竞争力的测试精度(De&Smith,2020)。

在这项工作中,我们采用并建立在“无归一化ResNets”(NF ResNets)(Brock et al.,2021)的基础上,这是一类预激活ResNets(He et al.,2016a),可以在没有归一化层的情况下进行竞争性训练和测试准确性。NF ResNets使用以下形式的残差块hi+1=h+f(h;/B;),其中h表示的输入残差块,f表示由第i个残差分支计算的函数。函数f;被参数化为在初始化时保持方差,使得所有i的Var(f(z)=Var(z)。标量o指定在每个残差块(在初始化时)之后激活的方差增加的速率,并且通常被设置为一个小值,如a=0.2。标量3是通过预测it残差块3的输入的标准偏差来确定的,其中Var(h+1)=Var(h,)+2,除了转换块(发生空间下采样),其中跳跃路径对缩小的输入(h,/B,)进行操作,并且在转换块之后将预期方差重置为h+1=1+a2。挤压激发层的输出(Hu等人,2018)乘以2。根据经验,Brock等人(2021)发现,在结束时将可学习标量初始化为零也是有益的每个剩余分支('SkipInit'(De Smith,2020))。

此外,Brock等人(2021)通过引入标度权重标准化(对权重标准化的一个小修改(Huang et al.,2017;Qiao et al.,2019)),防止了隐藏激活的平均偏移的出现。该技术将卷积层重新参数化为:

其中u,=(1/N)∑,Wio=(1/N)∑(Wg-)和N表示扇入。激活函数还通过非线性特定标量增益y进行缩放,这确保了y缩放的激活函数和缩放权重标准化层的组合是保方差的。对于ReLU,y=/2/(1-(1/)(Arpit等人,2016)。我们请读者参考Brock等人(2021)了解如何计算其他非线性的y。

通过额外的正则化(Dropout(Srivastava et al.,2014)和随机深度(Huang et al.,2016)),Normalizer Free ResNets与ImageNet上批量规格化预激活ResNets以1024的批量大小实现的测试精度相匹配。当批处理大小很小时,它们也显著优于批处理规范化的同类产品,但对于大批处理大小(4096或更高),它们的性能不如批处理规范的网络。至关重要的是,它们与EfficientNets等最先进网络的性能不匹配(Tan&Le,2019;Gong等人,2020)。

这篇论文的研究基于作者之前的 Normalizer-Free ResNets(NF-ResNets)进行拓展,NF-ResNets在去掉 normalization层后依然可以有相当不错的训练和测试准确率。NF-ResNets的核心是采用 residual block。

此外,NF-ResNet的另一个核心是 Scaled Weight Standardization,用于解决激活层带来的 mean-shift现象,对于卷积层做了如下权值重新初始化。

4,AGC(自适应梯度裁剪模块)

梯度裁剪技术常用于语言模型来稳定训练,最近的研究表明,与梯度下降相比,它允许以更大的学习率进行训练从而加速收敛。这对于条件较差的损失或大批量训练尤为重要,因为在这些设置中,最佳学习率往往会受到最大学习率的限制。因此作者假定梯度裁剪有利于NFNet的大批尺度训练。梯度裁剪往往是对梯度的范数进行约束来实现的,对于梯度向量 G=∂ L / ∂ θ 而言,L表示损失值, θ 表示模型所有参数向量,标准的裁剪算法会在更新 θ 之前以如下的公式裁剪梯度:

上式的 λ 是必须调整的超参数,根据经验,作者发现虽然这个裁剪算法能够以比以前更高的批尺度进行训练,但训练稳定性对裁剪阈值 λ的选择极为敏感,在改变模型深度,批尺度或学习率时都需要精调阈值。

为了解决这个问题作者引入了自适应梯度裁剪算法 AGC,下面详细阐述这个算法,记 W€ R 为第 l 层的权重矩阵, G €  R为对应于 W 的梯度矩阵, ||*||F 表示F范数,即有:

AGC算法的动机源于观察到梯度与权重的范数比,这其实是一个单次梯度下降对原始权重影响的简单度量。举个例子,如果使用无动量的梯度下降算法,有:

那么第 l 层的参数更新公式为:

其中 h 表示学习率。

直观上我们认为如果 ||ΔW|| / || W|| 很大,那么训练就会变得不稳定,这就启发了一种基于 ||G|| F /  ||W||F 的梯度裁剪策略,然而实际上,逐单元的梯度范数和参数范围比会比逐层的效果好,因此定义第 l 层上第 i 个单元的梯度矩阵 Gi(表示G的第i行)的裁剪公式如下,其中 λ 是一个标量超参数,定义 ||Wi||F = max(||Wi||F),这避免 0 初始化参数总是裁剪为 0,对于卷积滤波器中的参数,我们在扇入范围(包括通道和空间维度)上评估逐单元范数。

使用上述的 AGC 模块,NFNet 能够以高达 4096 的批尺寸训练同时使用 RandAugment 这样的数据增强策略,不适用 AGC 模块,NFNet 是无法这样训练的。注意,最优裁剪参数 λ 可能取决于优化器的选择,学习率和批大尺寸。经验上,batch size越大,λ 就应该越小。

上图是论文针对AGC做的两个消融实验,左图a表示实验BN的 ResNet以及使用和不使用AGC的NFNet之间的对比,实验表面AGC使得NFNet有着媲美BN网络的效果,而且批尺寸越小,AGC收益越低。右图b则表示不同批尺寸不同 λ 选择的效果,结果表面,当批尺寸较大的时候,应该选择较小的 λ 以稳定训练。

4.1. 用于自适应梯度削波(AGC)的消融

我们现在介绍了一系列旨在测试AGC效果的消融。我们在ImageNet上对预激活的NF-ResNet-50和NF-ResNet-200进行了实验,使用具有Nesterov动量的SGD在256到4096的批量范围内训练了90个epoch。如Goyal等人(2017)所述,我们对批量大小256使用0.1的基本学习率,其与批量大小线性缩放。我们考虑一系列入 值[0.01,0.02,0.04,0.08,0.16]。

在图2(a)中,我们将批量归一化的ResNets与具有和不具有AGC的NF ResNets进行了比较。我们显示了每个批次大小在最佳剪切阈值A下的测试准确性。我们发现AGC有助于将NF ResNets扩展到大批量,同时在ResNet50和ResNet200上保持与批量归一化网络相当或更好的性能。正如预期的那样,当批量较小时,使用AGC的好处较小。在图2(b)中,我们展示了性能

对于不同的剪裁阈值入 在ResNet50上的一系列批量大小中。我们看到,较小(更强)的剪裁阈值对于较高批量的稳定性是必要的。我们在附录D中提供了更多消融细节。

接下来,我们研究AGC是否对所有层都有利。使用批量大小4096和剪裁阈值入=0.01,我们从第一卷积、最终线性层和任何给定残差级集合中的每个块的不同组合中去除AGC。例如,一个实验可以去除线性层中的剪切以及第二和第四阶段中的所有块。出现了两个关键趋势:首先,最好不要剪裁最后的线性层。其次,通常可以在不剪裁初始卷积的情况下进行稳定训练,但当以批量大小4096以1.6的默认学习率进行训练时,必须剪裁所有四个阶段的权重以实现稳定性。对于本文的其余部分(以及图2中的消融),我们将AGC应用于除最终线性层之外的每一层。

5.无归一化器架构,提高了准确性和训练速度

在上一节中,我们介绍了AGC,这是一种梯度裁剪方法,使我们能够在大批量和强数据增强的情况下进行有效训练。有了这项技术,我们现在寻求设计具有最先进精度和训练速度的无归一化器架构。

图像分类的当前技术状态通常由EfficientNet模型家族(Tan&Le,2019)持有,该模型基于反向瓶颈块的变体(Sandler et al.,2018),具有源自神经架构搜索的主干和模型缩放策略。这些模型经过优化,在最小化参数和FLOP计数的同时最大限度地提高了测试精度,但其较低的理论计算复杂性并不能转化为现代加速器上训练速度的提高。尽管与ResNet-50相比,EffNet BO的FLOPS减少了10倍,但在GPU或TPU上训练时,它具有相似的训练延迟和最终性能。

选择哪种指标进行优化——理论FLOPS、目标设备上的推理延迟或加速器上的训练延迟是一个偏好问题,每个指标的性质将产生不同的设计要求。在这项工作中,我们选择专注于手动设计模型

针对现有加速器上的训练延迟进行了优化,如Radosavovic等人(2020)所述。未来的加速器可能能够充分利用EfficientNets等模型在很大程度上无法实现的潜在训练速度,因此我们认为这一方向不应被忽视(Hooker,2020),但我们预计,在当前硬件上开发训练速度提高的模型将有利于加速研究。我们注意到,GPU和TPU等加速器倾向于支持密集计算,尽管这两个平台之间存在差异,但它们有足够的共同点,为一个设备设计的模型可能会在另一个设备上快速训练。

因此,我们通过手动搜索设计趋势来探索模型设计的空间,这些设计趋势相对于设备上的实际训练延迟对ImageNet上的前1名的排列前沿产生了改进。本节描述了我们发现效果良好的更改(更多细节见附录C),而我们发现效果不佳的想法见附录E。这些更改的摘要如图3所示,它们对保持准确性的影响如表2所示。

NFNets系列如上,类似于EfficientNets一样,深度,分辨率以及不同的Dropout。这一些网络基于SE-ResNeXt-D,使用GELU作为激活函数,并处于对硬件性能与特点的考虑将3*3卷积的group width设为 128,并修改深度模式为 [1, 2, 6, 3]的倍数,修改 ResNets 的通道模式 [ 256, 512, 1024, 2048] 至 [ 256, 512, 1536, 1536],使用了FixRes。

NFNet的命名规则,如NFNet-F0 对应的 stage 使用的 block 数为 [1, 2, 6, 3],NFNet-F1 对应 [ 2, 4, 12, 6],NFNet-F6 对应 [7, 14, 42, 21]。

我们从SE-ResNeXt-D模型开始(Xie等人,2017;Hu等人,2018;He等人,2019)和GELU激活(Hendrycks&Gimpel,2016),我们发现这是Normalizer Free Networks的一个惊人的强基线。我们做了以下更改。首先,我们将3×3卷积中的组宽度(每个输出单元连接的通道数)设置为128,而与块宽度无关。较小的组宽度降低了理论FLOPS,但计算密度的降低意味着在许多现代加速器上没有实现实际的加速。例如,在TPU3上,组宽度为8列的SE-ResNeXt-50与组宽度为128列的SE-ResNeXt-50以相同的速度运行,但每个设备的批量大小为128或更大(Google,2021),这通常由于内存限制而无法实现。

接下来,我们对模型主干进行两次更改。首先,我们注意到ResNets的默认深度缩放模式(例如,增加深度以从ResNet50构建ResNet101或ResNet200的方法)涉及非均匀增加第二层中的层数。和第三阶段,同时在第一和第四阶段中保持3个块,其中“阶段”是指激活宽度相同且分辨率相同的残差块序列。我们发现这种策略是次优的。早期阶段的层以更高的分辨率运行,需要更多的内存和计算,并倾向于学习本地化的任务通用特征(Krizhevsky et al.,2012),而后期阶段的层则以较低的分辨率工作,包含模型的大部分参数,并学习更多的任务特定特征(Raghu et al.,2017a)。然而,对早期阶段过于吝啬(例如通过激进的下采样)可能会影响性能,因为模型需要足够的能力来提取良好的局部特征(Raghu等人,2017b)。还希望有一个简单的缩放规则来构建更深层次的变体(Tan&Le,2019)。考虑到这些原则,在确定简单模式[1,2,6,3](指示要分配给每个阶段的瓶颈块数量)之前,我们探索了最小模型变体FO的几种骨干选择。我们通过将每个阶段的深度乘以标量N来构建更深的变量,例如,变量F1具有深度模式[2,4,12,6],而变量F4具有深度模式[5,10,30,15]。

此外,我们重新考虑中的默认宽度模式 ResNets,其中第一级具有256个通道,这些通道在每个后续级加倍,从而产生模式[256,512,1024,2048]。采用上述深度模式,我们考虑了一系列替代模式(灵感来自Radosavovic等人(2020),但发现只有一种选择比默认模式更好:[256,512,1531,6536]。这种宽度模式旨在提高第三阶段的能力,同时在第四阶段略微降低能力,大致保持训练速度。与我们选择的深度模式和ResNets的默认设计一致,我们发现第三阶段往往是增加容量的最佳位置,我们假设这是因为这个阶段足够深,可以有一个大的感受野和访问更深层次的特征,同时具有比最后阶段略高的分辨率。

我们还考虑了瓶颈残差块本身的结构。我们考虑了各种预先存在的和新的修改(见附录E),但发现最好的改进来自于在第一个修改之后添加额外的3×3分组conv(伴随非线性)。这种额外的卷积对FLOPS的影响最小,对目标加速器的训练时间几乎没有影响。

我们还发现,随着模型容量的增加,增加正则化强度是有帮助的。然而,修改权重衰减或随机深度率是无效的,相反,我们根据Tan Le(2019)对Dropout的下降率进行了缩放(Srivastava et al.,2014)。这一步骤尤其重要,因为我们的模型缺乏批规范化的隐式正则化,并且没有显式正则化往往会显著地过拟合。我们得到的模型具有很高的性能,尽管针对训练延迟进行了优化,但在FLOP与准确性方面(尽管不是在参数与准确性方面),仍与更大的EfficientNet变体保持竞争力,如附录A中的图4所示。

作者发现:

1,位于浅层的残差块通常分辨率较高,需要更多内存和计算资源,倾向学习一般的特征。位于深层的残差块分辨率较低,包含模型的大部分参数,倾向于学习特定特征。对早期阶段采用激进的下采样会损害性能,模型需要足够容量提取好的局部特征。

2,对于ResNet  backbone 而言,采样固定的宽度,缩放深度和训练分辨率可以提高速度随着模型容量的增加,有助于增加正则化强度。

3,与批量归一化模型相比,无归一化模型更适合在非常大规模的数据集上进行预训练后进行微调。

所以论文以待GLEU激活函数的 SE-ResNeXt-D模型作为 Normalizer-Free网络的基础,除训练加入AGC外,主要做了以下改进:

  • 将3*3卷积变为分组卷积,每组的维度固定为 128, 组数由卷积的输入维度决定。更小的分组维度可以降低理论的计算量,但计算密度的降低导致不能很好的利用设备稠密计算的优势,实际不会带来更多加速。
  • ResNet的深度扩展(从ResNet50扩展至ResNet200) 主要集中在 stage2和stage3,而stage1和stage4保持3个block的形式。这样的做法不是最优的,因为不管低层特征或高层特征,都需要足够的空间去学习。因此,论文先制定最小的 F0 网络的各 stage的block数为 [1, 2, 6, 3],后续更大的网络都是在此基础上以倍数扩展。
  • ResNet的各stage 维度为 [256, 512, 1024, 2048],经过测试之后,改为 [256, 512, 1536, 1536], stage3采用更大的容量,因为其足够深,需要更大的容量去收集特征,而stage4不增加深度主要是为了保持训练速度。

5.1. 总结

我们的训练配方可以总结如下:首先,将Brock等人的Normalizer Free设置应用于SE-ResNeXt-D,具有修改的宽度和深度模式,以及第二个空间卷积。其次,将AGC应用于除分类器层的线性权重之外的每个参数。对于批量大小1024到4096,请设置入= 0.01,并利用强正则化和数据扩充。有关每个型号变体的附加信息,请参见表1。

6 实验

6.1 在ImageNet上评估NFNets

现在,我们将注意力转向在ImageNet上评估我们的NFNet模型,首先是在批量大小为4096的360个时期进行训练时,对我们的架构修改进行消融。我们使用动量系数为0.9的Nesterov动量,如第4节所述,AGC 0.01的削波阈值,以及在通过余弦退火衰减到零之前在5个时期内从0线性增加到1.6的学习率(Loshchilov-Hutter,2017)。从表2的前三行中,我们可以看到,我们对模型所做的两个更改都会导致性能的轻微改进,而训练延迟只有微小的变化(延迟请参见附录中的表6)。

接下来,我们评估了逐步添加更强增强的效果,将MixUp(Zhang et al.,2017)、RandAugment(RA,(Cubuk et al.,2020)和Cut-Mix(Yun et al.,2019)相结合。我们应用了4层RA,并根据Cubuk等人(2020)的说法,根据图像的分辨率缩放幅度。我们发现,这种缩放特别重要,因为如果幅度相对于图像大小设置得太高(例如,在分辨率为224的图像上使用20的幅度),那么大多数增强图像将完全空白。有关这些震级的完整说明以及如何选择这些震级,请参见附录A。我们在表2中显示,这些数据增强大大提高了性能。最后,在表2的最后一行中,我们还展示了使用默认ResNet阶段宽度烧蚀的完整模型的性能,表明我们在第三和第四阶段略微修改的模式在直接比较下确实有所改进。

为了完整起见,在附录的表6中,我们还报告了当使用批处理规范化而不是NF策略进行训练时,我们的模型架构的性能。与NF模型相比,这些模型的测试精度略低,训练速度慢20%至40%,即使在使用高度优化的批处理规范化实现而不进行跨副本同步的情况下也是如此。此外,我们发现较大的模型变体F4和F5
在使用批量归一化、使用或不使用AGC进行训练时不稳定。我们将此归因于使用bfloat16训练来在内存中拟合这些较大模型的必要性,这可能会引入数值不精确性,与批量归一化统计的计算交互较差。

我们在表3中提供了六种模型变体(NFNet FO到F5)的大小、训练延迟(在具有tensorcores的TPU3和V100上)和ImageNet验证准确性的详细总结,以及与具有类似训练延迟的其他模型的比较。我们的NFNet-F5模型达到了86.0%的前1级验证准确率,比之前的技术状态EfficientNet-B8 with MaxUp(Gong et al.,2020)提高了一小部分,我们的NFNet-F1模型与EfficientNet-B7 with RA(Cubuk et al.,2019)的84.7%相匹配,同时训练速度快8.7倍。有关如何测量训练延迟的详细信息,请参阅附录A。

我们的模型还受益于最近提出的清晰度感知最小化(SAM)(Foret等人,2021)。SAM不是我们标准培训渠道的一部分,因为默认情况下它会使培训时间翻倍,通常只能用于分布式培训。然而,我们对SAM程序进行了一个小的修改,将这一成本降低到增加了20-40%的训练时间(在附录a中解释),并用它来训练我们的两个最大的模型变体,结果是NFNet-F5达到了86.3%的top 1,而NFNet-F6达到了86.5%的top 1。在没有额外数据的情况下,大大提高了ImageNet上现有的技术水平。

最后,我们还评估了我们的数据挖掘策略在EfficientNets上的性能。我们发现,虽然RA大大提高了EfficientNets相对于基线增强的性能,但将层数增加到2层以上或添加MixUp和CutMix并不能进一步提高它们的性能,这表明我们的性能改进很难通过简单地使用更强的数据增强来获得。我们还发现,使用带余弦退火的SGD代替带阶跃衰减的RMSProp(Tieleman&Hinton,2012)会严重降低EfficientNet的性能,这表明我们的性能改进也不仅仅是因为选择了不同的优化器。

我们在这个大型数据集上预训练了10个时期的一系列批处理归一化和NF ResNets,然后使用2048的批处理大小和0.1的小学习率同时微调ImageNet上的所有层,并进行15000步的余弦退火,以获得范围[2224320384]内的输入图像分辨率。如表4所示,Normalizer-Free网络在每一种情况下都优于其批量归一化网络,通常以大约1%的绝对前1的幅度。这表明,在迁移学习机制中,去除批量归一化可以直接提高最终性能。

我们使用我们的NFNet模型进行了同样的实验,预训练了一个NFNet-F4和一个稍宽的变体,我们称之为NFNet-F4+(见附录C)。如附录表5所示,经过20个时期的预训练,我们的NFNet-F4+获得了89.2%的ImageNet前1级准确率。这是迄今为止使用额外训练数据实现的第二高验证精度,仅次于最近的半监督学习基线(Pham et al.,2020),也是使用迁移学习实现的最高精度。

图3展示了六个不同的NFNets(F0-F5)与其他模型在模型大小,训练延迟和ImageNet验证准确率方面的对比情况。NFNets-F5达到了 86%的SOAT top-1准确率,相比于 EfficientNet-B8有了一定提升;NFNet-F1的测试准确率与 EfficientNet-B7相媲美,同时训练速度提升了 8.7倍。NFNet-F6+SAM 达到了 86.5% 的 top-1 准确率。

NFNets和其他模型在 ImageNet 数据集上的准确率对比。延迟是指在TPU或者GPU(V100)上运行单个完整训练步骤所需要的毫秒时间。

此外,研究者使用了一个3亿标注图像的数据集对 NFNet的变体进行了预训练,并针对 ImageNet进行微调。最终,NFNet-F4+在 ImageNet 上获得了 89.2%的top1 准确率。这是迄今为止通过额外训练数据达到的第二高的验证准确率,仅次于目前最强大的半监督学习基线和通过迁移学习达到的最高准确率。

总结

我们首次展示了在没有归一化层的情况下训练的图像识别模型,不仅可以匹配大规模数据集上最佳BN模型的分类精度,而且大大超过他们,同时仍然可以更快的训练。为了实现这一点,我们引入了自适应梯度裁剪(AGC),这是一种简单的剪裁算法,可以稳定大批量训练,并使我们能够通过强大的数据增强来优化非归一化网络。利用这种技术和简单的架构设计原则,我们开发了一系列模型,无需额外数据即可在ImageNet上获得最先进的性能,同时比竞争方法的训练速度要快得多。我们还表面,与批量标准化模型相比,无标准化模型更适合在超大规模数据集上进行预训练后微调。

分享是最有效的学习方式。

博客:
https://blog.ktdaddy.com/

背景

小猫维护现有的系统也有一段时间了,踩坑也不少,事故不少。感兴趣的小伙伴可以了解一下,往期的
小猫踩坑记合集

这天,小猫找到了商城系统的第一任开发老A开始聊天。

“你们这系统是真坑,我都吃过好多次亏了,太烂了...”小猫开玩笑地吐槽道。

“我们当时其实还是花了很长的一段时间去做设计以及评审的,每一步都是有严格把控的,当时系统总体骨架还是相当清晰的,也没有那么多新的概念,你说现在系统不行了,可不能怪我们啊。一会我给你原始设计文档看看。后面经过了很多研发的手了,甚至运营和产品都换了好几拨了,每任运营可能对当前系统的要求都不一样吧,大家理解可能都不同......”老A侃侃而言着。

小猫抿了口刚倒的茶,意味深长地看着老A...

写在前面

相信有很多小伙伴都有小猫这样的体会,尤其是接手一个老的系统的时候,总是会吐槽当前的系统很烂,恨不得马上将其完完全全重构掉。

前段时间老猫还遇到一个比较逗的小伙伴,他想表达的意思大概是“代码写的烂也就算了,他居然还在注释里撒谎...”,结果他楼下哥们还在一个劲追问他的注释是怎么撒谎的,老猫当时边吃午饭边在刷手机,老猫看到评论后,笑到喷饭,当然在此也对这位小伙伴表示同情。

其实很多时候一个系统的腐烂和破败并不是在开始的时候就出现了,而是在持续地迭代升级中渐渐腐化继而沦为“屎山”。

接下来咱们就来盘点一下到底是一些什么原因将一个原本架构清晰的系统腐败沦丧为复杂“屎山”的,然后咱们作为后来人又该如何应对?

“屎山”特征

既然说到系统沦为“屎山”,那么什么样的系统会被定义成“屎山”呢?其实所谓的“屎山”即为非常复杂的系统,难以维护。John OusterhOut在A Philosophy of Software Design这本书中就已经提及了“复杂性就是使得软件难于理解和修改的因素”。

John OusterhOut将“屎山”(这里要说明一下,这哥们并没有说复杂系统叫“屎山”,哈哈,只是咱们都习惯这么叫了)归为三大类:

  • Change Amplification(变更放大)
  • Cognitive Load(认知负荷)
  • Unknown Unknowns(未知的未知)

上述几类特种总结有点抽象,咱们来具体化阐述一下。

变更放大

变更放大其实就是说明明一个相当简单的需求,却要动到很多地方的代码。

相信大家在日常开发中应该也会遇到这样的问题。老猫也经常遇到,例如明明从需求的理解来说,只要加一个固定的校验逻辑,这个事情就应该可以搞定了,结果发现,改一个地方的校验逻辑还不行,可能还要动到各个地方的校验逻辑。这种情况往往是由于开发在写代码过程中复用没有到位,或者本身流程问题导致的。软件可拓展性变得很差。

认知负荷

认知负荷说白了就是相关的开发人员在进行开发任务的时候,需要花费很多时间去学习所需要的知识(当然这里大部分指的是技术知识)才能完成一系列开发任务,于此同时,如果某个知识点没有掌握好,可能会导致未知的Bug。

打个比方,大部分的开发还是比较倾向于自己熟悉的编程语言或者是开发框架,以及中间件的。例如,前后端分离虽然好,DDD虽然好,但是对于简单的内部管理系统而言,明明一个mvc就能搞定的事情,非得搞成前后端分离,加上DDD设计分层。明明一个人天就能搞定的事情,非得搞成三人天,另外的维护者可能还得花时间去研究相关技术,这种盲目追求最新技术增加系统本身实现复杂度就是一种本末倒置的行为。

当然认知负荷有的时候可能也不一定是新技术带来的,也有可能是纯粹的技术实现烂,例如不恰当的接口设计、混乱的命名,还有“爱撒谎”的注释等等。

未知的未知

未知的未知是最要命的,例如,当我们从产品那边得到一个需求的时候,我们甚至不晓得为了完成这个需求我们到底需要修改哪些代码才能完成,当前开发甚至还不清楚相关的业务知识。

这种很多时候体现在咱们接手一个新项目的时候,尤其是项目比较复杂的情况下,并且此时的项目没有任何的技术文档,这种情况下我们往往是抓瞎的,非常被动,即使对代码调整完毕之后心里也还是会没底,涉及的一些业务场景甚至都没有理清楚。也不晓得调整完毕之后会不会出现新的问题。

“屎山”诱因

从技术侧聊聊,复杂系统发展的诱因,UML之父Grady Booch在《面向对象分析和设计》的观点是,软件的复杂性是一个固有属性,并不是偶然属性,软件的发展必然会伴随复杂性。

诱因有下面三个:

  1. 模糊性:模糊性产生了最直接的复杂度。
    老猫的理解,关于模糊性包含其实有两层,一个是需求模糊性,第二个技术模糊性。产品经理对实际的业务把控没有做到很精准,存在模糊性,导致系统本身的业务覆盖点经常发生变更,这种是导致系统复杂的罪魁祸首之一。第二技术侧的模糊性,技术侧的模糊性当然就包含研发人员本身对业务把控不到位,另外的在定义API以及方法命名变量命名的时候存在模糊,无法通过命名直接理解想要表达的意思。

  2. 依赖性:模糊产生复杂性,而依赖导致复杂的传递,不断外移的复杂性将导致最终系统无限腐化,质量失控,修复成本指数级增长。打个比方一个不合理的实现方法被我们认为是一套标准的实现方式,然后后面的很多业务代码为了方便都会去复用这段逻辑。但是这种不合理的实现方法还在不停的迭代,所以之后系统会发展成什么样子,大家可想而知了。

  3. 递增性:一个软件系统无论多么复杂,都是从第一行代码开始的。然后慢慢“生长”。随着业务发展,需求不断产生,功能逐渐丰富,软件系统随之演进,同时废弃而未被及时清除的代码也是日益膨胀。最终形成一个复杂的系统。
    这点相信理解起来还是比较简单的。

系统“腐烂”的真相

就像上面小猫和老A的对话那样,其实很多时候,系统的腐烂并并不是发生在最开始。

很多后端研发在接手新的系统之后,往往对其设计的理解其实是不够深入的,来了需求之后就是一顿“兵来将挡水来土掩”,可以说是一种战术性编程,或者说的难听些“应付式编程”。

这种编程的特点有下面这几种:

  1. 快。这类程序员为了快速解决产品需求,总是以腐化系统为代价去解决问题。经过他们之手维护的系统可拓展性差。
  2. 高产。这类程序员代码量极大,可能不择手段,完全不会考虑复用,很多时候解决问题就是cv大法。
  3. 坑。他们往往只是专注于功能堆砌却忽略设计原则和设计规范,有时候命名规范甚至都懒得遵循,成本放到未来,后人买单。咱们经常提到的倒霉的小猫就是经常买单的那位。

上述共同特点就是缺乏设计,完全聚焦于快速交付,注重短期价值不考虑未来发展。

那么为什么会这样的呢?可能会受到以下三点的影响:

  1. 研发人员本身的水平以及认知还有责任心。研发人员本身认知不够,意识不到系统其实是需要考虑拓展性的,这种往往也是没有办法的,另外一种是研发人员抱有侥幸心理,虽然意识到拓展性的问题以及设计问题,但是比较懒,本着“多一事不如少一事,反正我只是过客”的心态去做系统。这类往往在外包系统中体现更为放大。

  2. 互联网背景下,老板为了快速适应市场,会进行大量业务试错,这就会要求程序员快速开发。很多程序员想要好好设计一下系统,可是无奈妥协于项目经理的一而再再而三的问你上线时间。这种情况下,设计可能就成了一种奢侈。

  3. 考评体系不合理。老猫有个朋友,之前一天他和我们吐槽,他们目前领导需要拉出他们每天写代码的量去看看他们每天干了多少活。这种真的是滑天下之大稽,在这样的考评体系下面,程序员还会好好写代码么。当然这种往往是发生在领导屁都不懂研发的公司。这类领导也是老猫最最鄙视的。技术上明明屁都不懂,还要装x去指指点点。

“屎山”应对之道

上面聊了这么多,我们也大概知道了为什么我们的系统会逐渐沦为“屎山”,可能是在软件发展过程中的必然,其中也掺杂着各种人为因素以及非人为因素。
当然事情还是要去解决的。那么我们应当如何应对呢?

  • 寻找合适的架构

    当咱们接到一个复杂系统的时候,其实首先需要理清楚相关的架构,知道系统是如何进行模块拆分的,另外它们的协作关系和通信方式。具体操作,大家可以访问老猫之前写的
    系统梳理大法

  • 遵循设计原则
    组件层面,咱们的设计原则需要遵循复用/发布等同原则,共同闭包原则,共同复用原则,无依赖环原则,稳定依赖原则和稳定抽象原则。
    代码层面,可以参考老猫之前梳理的
    开发中需要遵循的设计原则

  • 避免破窗效应

    这里的“破窗效应”其实是出自David Thomas Andrew Hunt的著作《程序员修炼之道》,一扇破窗,只要一段时间不去修理,建筑中的居民就会潜移默化地产生一种被遗弃的感觉————当权者不关心这幢建筑。然后其他窗户也开始损坏,居民开始乱丢废物,墙上开始乱涂鸦,建筑开始出现严重结构性的损坏。

    聊到咱们软件系统侧其实也是一样的,在系统发展的过程中,只有在我们修复历史遗留的问题时,才是真正对其进行了维护。如果我们使用一些极端的手段保持古老陈腐的代码继续工作的时候,这其实是一种苟且。例如为了临时解决问题写hotfix接口等等。

    在我们开发的过程中,一旦系统有了设计缺陷,咱们其实应该及时优化,否则会形成不好的示范,更多的后来者会倾向于做出类似设计,从而加速系统腐化。

总结

上述就是老猫对系统沦为“屎山”的一些看法,另外的,希望大家比较再提“防御性编码”这类概念。这种思想就不应该是一个合格程序员提出的。老猫对这类还是比较抵触的。“难不成螺丝钉以为自己螺纹角度独特就不会被取代了?”,咱们把自己负责的东西尽量做到完美,是金子总能发光的,小伙伴们,你们觉得呢?