聊一聊如何使用Crank给我们的类库做基准测试

背景

当我们写了一个类库提供给别人使用时,我们可能会对它做一些基准测试来测试一下它的性能指标,好比内存分配等。

在 .NET 的世界中,用 BenchmarkDotNet 来做这件事是非常不错的选择,我们只要写少量的代码就可以在本地运行基准测试然后得到结果。

这个在修改代码的时候,效果可能会更加明显,因为我们想知道我们的修改会不会使这段代码跑的更快,占用的资源更少。

作一个简单的假设,根据测试用例,代码变更之前,某方法在基准测试的分配的内存是 1M,修改之后变成 500K,那么我们可以认为这次的代码变更是有性能提升的,占用的资源更少了,当然这个得在单元测试通过的前提下。

试想一下,如果遇到下面的情况

  1. 想在多个不同配置的机器上面运行基准测试,好比 4c8g 的windows, 4c16g 的 linux

  2. Pull Request/Merge Request 做代码变更时,如何较好的做变更前后的基准测试比较

这个时候就会复杂一点了,要对一份代码在多个环境下面运行,做一些重复性的工作。

那么我们有没有办法让这个变得简单呢?答案是肯定的。

我们可以用 Crank 这个工具来完成这些内容。

什么是 Crank

Crank 是.NET团队用于运行基准测试的基础设施,包括(但不限于)TechEmpower Web Framework基准测试中的场景。Crank 第一次出现在公众的视野应该是在 .NET Conf 2021, @sebastienros 演讲的 Benchmarking ASP.NET Applications with .NET Crank。

Crank 是 client-server (C/S) 的架构,主要有一个控制器 (Controller) 和一个或多个代理 (Agent) 组成。其中控制器就是 client,负责发送指令;代理就是 server,负责执行 client 发送的指令,也就是执行具体的测试内容。

下面是它的架构图。

879c12bf68f5f7115faa2f18be6e62d2.png


可以看到,控制器和代理之间的交互是通过 HTTP 请求来驱动的。然后代理可以执行多个不同类型的作业类型。

我们这篇博客主要讲的是图中的 .NET project Job

先来看看官方仓库一个比较简单的入门示例。

入门示例

首先要安装 crank 相关的两个工具,一个是控制器,一个是代理。

dotnet tool update Microsoft.Crank.Controller --version "0.2.0-*" --global

dotnet tool update Microsoft.Crank.Agent --version "0.2.0-*" --global

然后运行官方仓库上面的 micro 示例,是一个 Md5 和 SHA 256 对比的例子。

public class Md5VsSha256
{
    [Params(100, 500)]
    public int N { get; set;}
    private readonly byte[] data;

    private readonly SHA256 sha256 = SHA256.Create();
    private readonly MD5 md5 = MD5.Create();

    public Md5VsSha256()
    {
        data = new byte[N];
        new Random(42).NextBytes(data);
    }

    [Benchmark]
    public byte[] Sha256() => sha256.ComputeHash(data);

    [Benchmark]
    public byte[] Md5() => md5.ComputeHash(data);
}

要注意的是 Main 方法,要用 BenchmarkSwitcher 来运行,因为 Crank 是用命令行来执行的,会附加一些参数,也就是代码中的 args。

public static void Main(string[] args)
{
    BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args);
}

然后是控制器要用到的配置文件,里面就是要执行的基准测试的内容,要告诉代理怎么执行。

f9163dc450e5bd4d2392d4cffa8b018b.png

下面先来启动代理,直接运行下面的命令即可。

crank-agent

会看到下面的输出:

[11:42:30 INF] Created temp directory 'C:\Users\catcherwong\AppData\Local\Temp\2\benchmarks-agent\benchmarks-server-8952\2mmqc00i.3b1'
[11:42:30 INF] Agent ready, waiting for jobs...

默认端口是 5010,可以通过 -u|--url 来指定其他的;如果运行代理的电脑已经安装好 SDK 了,可以指定 --dotnethome 避免因网络问题导致无法正常下载 SDK。

然后是通过控制器向代理发送指令。

crank --config C:\code\crank\samples\micro\micro.benchmarks.yml --scenario  Md5VsSha256 --profile local

上面的命令指定了我们上面的配置文件,同时还指定了 scenario 和 profile。因为配置文件中可以有多个 scenario 和 profile,所以在单次执行是需要指定具体的一个。

如果需要执行多个 scenario 则需要执行多次命令。

在执行命令后,代理里面就可以看到日志输出了:

96c5105a8b3285dafac154d1fec26b43.png

最开始的是收到作业请求,然后安装对应的 SDK。安装之后就会对指定的项目进行 release 发布。

744345c9c6d08489fd9e600a64ac06d0.png

发布成功后就会执行 BenchmarkDotNet 相关的内容。

394f7f2b0b46bf1054436bcd2ccc0dfc.png

运行完成后会输出结果,最后清理这次基准测试的内容。

代理执行完成后,可以在控制器侧看到对应的结果:

0ef0a2f7ed06d1ac9b2a65d612131453.png

一般来说,我们会把控制器得到的结果保存在 JSON 文件里面,便于后续作对比或者要出趋势图。

这里可以加上 --json 文件名.json

crank --config C:\code\crank\samples\micro\micro.benchmarks.yml --scenario  Md5VsSha256 --profile local --json base.json

运行多次,将结果存在不同的 JSON 文件里,尤其代码变更前后的结果。

crank --config C:\code\crank\samples\micro\micro.benchmarks.yml --scenario  Md5VsSha256 --profile local --json head.json

最后是把这两个结果做一个对比,就可以比较清楚的看到代码变更是否有带来提升。

crank compare base.json head.json

037980eb06d75e27ac004a994eaef469.png

上面提到的还是在本地执行,如果要在不同的机器上面执行要怎么配置呢?

我们要做的是在配置文件中的 profiles 节点增加机器的代理地址即可。

下面是简单的示例:

7c7b71f9f63745de750ff5602916e604.png

这个时候,如果指定 --profile remote-win 就是在 192.168.1.100 这台服务器执行基准测试,如果是 --profile remote-lin 就是在 192.168.1.102

这样就可以很轻松的在不同的机器上面执行基准测试了。

Crank 还有一个比较有用的功能是可以针对 Pull Request 进行基准测试,这对一些需要基准测试的开源项目来说是十分有帮助的。

接下来老黄就着重讲讲这一块。

Pull Request

正常来说,代码变更的肯定是某个小模块,比较少出现多个模块同时更新的情况,如果是有,估计也会被打回拆分!

所以我们不会选择运行所有模块的基准测试,而是运行变更的那个模块的基准测试。

思路上就是有人提交 PR 后,由项目组成员在 PR 上面进行评论来触发基准测试的执行,非项目组成员的话不能触发执行。

下面就用这个 Crank 提供的 Pull Request Bot 来完成后面的演示。

要想用这个 Bot 需要先执行下面的安装命令:

dotnet tool update Microsoft.Crank.PullRequestBot --version "0.2.0-*" --global

安装后会得到一个  crank-pr 的文件,然后执行 crank-pr 的命令就可以了。

85460234af6aaa88dc556ea1240f15bd.png

可以看到它提供了很多配置选项。

下面是一个简单的例子

crank-pr \
  --benchmarks lib-dosomething \
  --components lib \
  --config ./benchmark/pr-benchmark.yml\
  --profiles local \
  --pull-request 1 \
  --repository "https://github.com/catcherwong/library_with_crank" \
  --access-token "${{ secrets.GITHUB_TOKEN }}" \
  --publish-results true

这个命令是什么意思呢?

它会对 catcherwong/library_with_crank 这个仓库的 Id 为 1 的 Pull Request 进行两次基准测试,一次是主分支的代码,一次是 PR 合并后的代码;基准测试的内容由 benchmarks,components 和 profiles 三个选项共同决定;最后两个基准测试的结果对比会在 PR 的评论上面。

其中  catcherwong/library_with_crank 是老黄提前准备好的示例仓库。

下面来看看 pr-benchmark.yml 的具体内容

6c11727d038657b4fbf65d2fa86cc5d4.png

基本上可以说是把 crank 的参数拆分了到了不同的配置选项上面去了,运行的时候就是把这些进行组合。

再来看看 library.benchmark.yml

823126e2c64ddc4601fbe5a5950be236.png

和前面入门的例子有点不一样,我们在 scenarios 节点 里面加了一个 variables,这个和 jobs 里面定义的 variables 和 arguments 是相对应的。

如果指定 --scenario dosomething,那么最后得到的 arguments 就是

--job short --filter *DoSomething* --memory

后面就是来看看效果了。

05a9eca3015c41809c55bec1dd3031c6.png

这里省略了评论内容的解析,也就是评论什么内容的时候会触发执行,因为这一块不是重点,有兴趣可以看 workflow 的脚本即可。

具体的执行过程可以参考

https://github.com/catcherwong/library_with_crank/actions/runs/4598397510/jobs/8122376959

当然,如果条件允许的话,也可以用自己的服务器资源来跑基准测试,不用 Github Action 提供的资源。

这样的好处是相对稳定,可以自己根据场景指定不同配置的服务器。不过对一些没那么复杂类库,用 Github Action 的资源也是无伤大雅的。

下面这个截图就是在提交到外部服务器上面执行的。

a98df6c491684a35227720e3e7ab45e0.png

如果仓库不是在 Github,是在自建 Gitlab 或者其他的,就可以根据这个思路来自定义流水线从而去完成这些基准测试的操作。

总结

Crank 还是一个挺不错的工具,可以结合 BenchmarkDotNet 来做类库的基准测试,也可以结合 wrk/wrk2/bombardier/h2load 等压测工具进行 api/grpc 框架和应用的测试。

这里只介绍了其中一个小块的内容,还有挺多内容可以挖掘一下的。

最后是本文的示例代码:

https://github.com/catcherwong/library_with_crank

参考资料

  • https://github.com/dotnet/crank

  • https://github.com/sebastienros/aspnetcore

  • https://github.com/martincostello/api

  • https://github.com/aspnet/Benchmarks/blob/main/scenarios/efcore.benchmarks.yml

好的,下面给出一个使用 Crank-Nicolson 格式求解二维热传导方程的例子。热传导方程的一般形式为: $$\frac{\partial u}{\partial t} = \nabla \cdot (\kappa \nabla u) + f$$ 其中,$u$ 表示温度,$\kappa$ 表示热传导系数,$f$ 表示源项。在这里,我们假设 $\kappa$ 为常数,$f=0$,并考虑一个矩形区域 $[0,L_x] \times [0,L_y]$ 上的热传导问题。那么,我们可以将上述方程离散化为以下差分方程: $$\frac{u_{i,j}^{n+1} - u_{i,j}^n}{\Delta t} = \frac{\kappa}{2} \left( \frac{u_{i+1,j}^{n+1} - 2u_{i,j}^{n+1} + u_{i-1,j}^{n+1}}{\Delta x^2} + \frac{u_{i,j+1}^{n+1} - 2u_{i,j}^{n+1} + u_{i,j-1}^{n+1}}{\Delta y^2} + \frac{u_{i+1,j+1}^n - u_{i+1,j-1}^n - u_{i-1,j+1}^n + u_{i-1,j-1}^n}{4 \Delta x \Delta y} \right)$$ 其中,$u_{i,j}^n$ 表示在时刻 $n$,位置 $(i,j)$ 处的温度,$\Delta t$、$\Delta x$ 和 $\Delta y$ 分别表示时间、$x$ 和 $y$ 的步长。 我们可以将上式写成以下形式: $$- \frac{\kappa \Delta t}{2 \Delta x^2} u_{i-1,j}^{n+1} + \left(1 + \frac{\kappa \Delta t}{\Delta x^2} + \frac{\kappa \Delta t}{\Delta y^2} \right) u_{i,j}^{n+1} - \frac{\kappa \Delta t}{2 \Delta x^2} u_{i+1,j}^{n+1} = \frac{\kappa \Delta t}{2 \Delta x^2} u_{i-1,j}^{n} + \left(1 - \frac{\kappa \Delta t}{\Delta x^2} - \frac{\kappa \Delta t}{\Delta y^2} \right) u_{i,j}^{n} + \frac{\kappa \Delta t}{2 \Delta x^2} u_{i+1,j}^{n} + \frac{\kappa \Delta t}{2 \Delta y^2} (u_{i,j+1}^n + u_{i,j-1}^n) + \frac{\kappa \Delta t}{4 \Delta x \Delta y} (u_{i+1,j+1}^n - u_{i+1,j-1}^n - u_{i-1,j+1}^n + u_{i-1,j-1}^n)$$ 我们可以使用迭代法来求解上式,具体来说,我们可以使用以下代码: ```python import numpy as np # 参数设置 T = 1.0 Lx = 1.0 Ly = 1.0 Nt = 100 Nx = 50 Ny = 50 kappa = 1.0 # 网格设置 dt = T / Nt dx = Lx / Nx dy = Ly / Ny x = np.linspace(0, Lx, Nx+1) y = np.linspace(0, Ly, Ny+1) t = np.linspace(0, T, Nt+1) u = np.zeros((Nt+1, Nx+1, Ny+1)) # 初始条件和边界条件 u[0, :, :] = np.sin(np.pi * x[:, np.newaxis]) * np.sin(np.pi * y[np.newaxis, :]) u[:, 0, :] = 0 u[:, Nx, :] = 0 u[:, :, 0] = 0 u[:, :, Ny] = 0 # 迭代求解 rx = kappa * dt / (2 * dx ** 2) ry = kappa * dt / (2 * dy ** 2) rxy = kappa * dt / (4 * dx * dy) for n in range(Nt): A = np.zeros((Nx-1, Ny-1, Nx-1, Ny-1)) b = np.zeros((Nx-1, Ny-1)) for i in range(1, Nx): for j in range(1, Ny): if i == 1: A[i-1, j-1, 0, :] = 0 A[i-1, j-1, 1, 0] = 1 + rx + ry A[i-1, j-1, 1, 1:] = -rx / 2 A[i-1, j-1, 2:, :] = 0 b[i-1, j-1] = (1 - rx - ry) * u[n, i, j] + rx / 2 * u[n, i+1, j] + ry / 2 * (u[n, i, j+1] + u[n, i, j-1]) + rxy * (u[n, i+1, j+1] - u[n, i+1, j-1] - u[n, i-1, j+1] + u[n, i-1, j-1]) elif i == Nx-1: A[i-1, j-1, -1, :] = 0 A[i-1, j-1, -2, -1] = 1 + rx + ry A[i-1, j-1, -2, :-1] = -rx / 2 A[i-1, j-1, :-2, :] = 0 b[i-1, j-1] = (1 - rx - ry) * u[n, i, j] + rx / 2 * u[n, i-1, j] + ry / 2 * (u[n, i, j+1] + u[n, i, j-1]) + rxy * (u[n, i+1, j+1] - u[n, i+1, j-1] - u[n, i-1, j+1] + u[n, i-1, j-1]) elif j == 1: A[i-1, j-1, :, 0] = 0 A[i-1, j-1, :, 1] = -ry / 2 A[i-1, j-1, :, 2] = 1 + rx + ry A[i-1, j-1, :, 3:] = -ry / 2 b[i-1, j-1] = (1 - rx - ry) * u[n, i, j] + rx / 2 * (u[n, i+1, j] + u[n, i-1, j]) + ry / 2 * u[n, i, j+1] + rxy * (u[n, i+1, j+1] - u[n, i+1, j-1] - u[n, i-1, j+1] + u[n, i-1, j-1]) elif j == Ny-1: A[i-1, j-1, :, -1] = 0 A[i-1, j-1, :, -2] = -ry / 2 A[i-1, j-1, :, -3] = 1 + rx + ry A[i-1, j-1, :, :-3] = -ry / 2 b[i-1, j-1] = (1 - rx - ry) * u[n, i, j] + rx / 2 * (u[n, i+1, j] + u[n, i-1, j]) + ry / 2 * u[n, i, j-1] + rxy * (u[n, i+1, j+1] - u[n, i+1, j-1] - u[n, i-1, j+1] + u[n, i-1, j-1]) else: A[i-1, j-1, :, :] = [[-ry/2, -rx/2, 0, -ry/2, rxy, 0, 0, 0], [-rx/2, 1+rx+ry, -rx/2, 0, -rxy, rxy, 0, 0], [0, -rx/2, -ry/2, 0, 0, -rxy, ry/2, 0], [-ry/2, 0, 0, -ry/2, 0, 0, -rx/2, rxy], [rxy, -rxy, 0, 0, 1+2*rxy, 0, -rxy, 0], [0, rxy, -rxy, 0, 0, -ry/2, 0, ry/2], [0, 0, ry/2, -rx/2, -rxy, 0, -rx/2, -ry/2], [0, 0, 0, rxy, 0, ry/2, -ry/2, -ry/2]] b[i-1, j-1] = (1 - rx - ry) * u[n, i, j] + rx / 2 * (u[n, i+1, j] + u[n, i-1, j]) + ry / 2 * (u[n, i, j+1] + u[n, i, j-1]) + rxy * (u[n, i+1, j+1] - u[n, i+1, j-1] - u[n, i-1, j+1] + u[n, i-1, j-1]) A = A.reshape(((Nx-1)*(Ny-1), (Nx-1)*(Ny-1))) b = b.reshape(((Nx-1)*(Ny-1),)) u[n+1, 1:Nx, 1:Ny] = np.linalg.solve(A, b).reshape((Nx-1, Ny-1)) # 绘制图像 import matplotlib.pyplot as plt from mpl_toolkits.mplot3d import Axes3D fig = plt.figure() ax = fig.add_subplot(111, projection='3d') X, Y = np.meshgrid(x, y) ax.plot_surface(X, Y, u[-1, :, :]) ax.set_xlabel('x') ax.set_ylabel('y') ax.set_zlabel('u') plt.show() ``` 在上述代码中,我们使用了 $50 \times 50$ 的网格来进行求解,时间步长为 $0.01$,空间步长为 $0.02$。我们使用 $\Delta t / \Delta x^2 = \Delta t / \Delta y^2 = 0.5$ 的 Crank-Nicolson 格式来进行求解。初始条件为 $u(x, y, 0) = \sin(\pi x) \sin(\pi y)$,边界条件为 $u(x, 0, t) = u(x, L_y, t) = u(0, y, t) = u(L_x, y, t) = 0$。 最终
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值