我们是如何测试人工智能的(三)数据构造与性能测试篇

我们是如何测试人工智能的(三)数据构造与性能测试篇

山治

前言

人工智能场景中的性能测试与我们在互联网中创建到的有很大的不同,因为它需要模拟更复杂的情况。当然它也有相似的地方,只不过今天我们主要介绍它们不同的地方。

产品分类

首先我们需要澄清一下, 从 AI 产品的类型来划分的话,我们可以分成两个大的类别:

  • 人工智能的业务类产品:AI 就是为了某个特定的业务服务的。它的形态可能就是一个模型。可以是用来做广告或者内容推荐的模型,也可以是用来做人脸识别,对话问答的模型。 测试人员通常的测试对象就是这个模型,或者是搭载这个模型的上层业务,比较少会涉及到其他的东西,行业中做这种测试的人数占了大多数。
  • 人工智能的平台类产品:随着 AI 能力进入到越来越多的业务中,大家发现高昂的人力和算力的成本都是非常大的负担, 所以业界期望有一种产品能够大幅度的降低 AI 的投入成本,希望能够通过一个平台产品让用户使用更简单的方法(比如 UI 上的操作)就可以进行数据的 ETL,特征的工程,模型的训练,上线,自学习等能力。 所以现在 AI 如此流行的大环境下, 越来越多这样的产品在业界中流行,几乎每个有点规模的 AI 公司都会有这样的产品。

在这个背景下,我们可以知道人工智能的平台类产品测试是更有难度的,它不是只对着一个模型,而是涉及到了 AI 的整个生命周期。 而我们第一个性能测试场景,就是针对模型训练的。

结构化数据模拟

在大数据与人工智能领域中的性能测试, 往往要涉及到数据模拟。 因为人工智能的基础是大数据, 只有海量的高质量数据才能支持人工智能。 所以我们在做性能测试的时候,也需要构造大量的数据来支撑模型的训练。 主要是为了衡量在标准数据下,算法需要多长时间才能把模型训练出来。 所以需要构建各种不同的标准数据。比如:

  • 不同的数据规模(行数,列数)
  • 不同的数据分布(数据倾斜)
  • 不同的数据分片,文件数量(模拟数据切片规模和海量小文件场景)
  • 不同的特征规模(高维特征,低维特征)

接下来要仔细的解释一下这几种数据场景:

  • 不同的数据规模:这个比较好理解,就不细说了, 我们需要评估在不同数据规模下算法的性能表现。 从而推测出一个资源和时间的推导公式。
  • 不同的数据分布:往往在特征工程和 ETL 中使用。这个是跟分布式计算的原理有关系的,分布式计算简而言之就是一个分而治之,比如用一个经典的计算词频的 DEMO 来解释。 当程序想要计算一个大文件中每个单词出现的次数的时候,需要把这些单词传输到独立的任务中。 比如把所有的 hello 传输到任务 A 中, 所有的 world 传输到任务 B 中。然后每个任务只统计一个单词出现的次数,最后再把结果进行汇总。 整个过程如下图,分布式计算追求的就是用多台机器的资源来加速计算过程,简而言之是任务被划分成多个子任务调度在多个机器里,而每个任务只负责其中一部分数据的计算。 在这样的原理之前我们会发现,如果 hello 这个单词出现的特别多, 占了整个数据量的 99%。 那么我们会发现就会有一个超大的子任务存在(99% 的数据传送到了一个子任务里),这样就破坏了分布式计算的初衷了。 变成了无限趋近于单机计算的形式。 所以一般要求我们的程序需要用一定的方法去处理数据倾斜的情况。

  • 不同的数据分片和文件数量:了解到分布式计算的原理后,我们也需要知道分布式存储的原理, 当一份数据过大后,也需要对这份数据进行切片并保存在不同的机器中,所以一份数据其实是有很多个数据分片的。而数据分片多了以后也会出问题, 因为按照分布式计算的原理,每个数据分片也都会有一个独立的子任务去处理。一个独立的子任务就是一个线程,所以如果数据分片太多了,就会有过多的线程在系统中争抢 CPU 资源,并且每个线程其实也处理不了多少数据(因为数量分片太多,每个分片就没多少数据了)这时候大多数的资源都用来维护线程之间的开销了。 而海量的文件数量也会给存储系统带来压力。
  • 不同的特征规模:通过之前的文章我们知道工程师需要先从数据中提取特征,然后把特征输入给模型进行训练的。所以除了原始数据的规模外, 重要的是经过特征工程后,有多少特征被提取了出来,特征的规模也直接影响了模型的性能和大小。 而特征也分围离散特征与连续特征。它们最大的区别在于算法在计算对应字段的时候如何处理,对于离散特征来说算法会将每一个唯一的值作为一个独立的特征,比如我们有用户表,其中职业这个字段中我们有 100 种职业,也就是说这张表不论有多少行,职业这个字段的取值就是这 100 个职业中的一个。 而对于算法来说每一个职业就是一个独立的特征。 老师是一个特征,学生是一个特征,程序员、环卫工人、产品经理、项目经理等等这些都是独立的特征。也就是说对于这张表,不论有多少行,算法针对职业这一字段提取出来的特征就是固定的这 100 个。而连续特征则完全不同,它是一个数字的数字,算法会把它当成一个独立的特征,也就是当算法把一个字段提取成为连续特征后,不管这张表有多少行,这个字段固定就只有 1 个特征被提出来。 所以我们做性能测试的时候, 场景描述通常都是:多少行,多少列,多少特征。
造数工具

造数工具的重点是可以模拟出上面说这些不同的数据类型, 以及性能要符合预期。 毕竟我们在建模的时候动不动就要模拟几千万甚至几亿的,所以性能一定不能太差, 要是造个数据就要等好几天的也是无法接受的。 所以我们一般也决定使用分布式计算技术来造数, 而我这里选择的就是 spark。

image

  • 利用 spark 编写造数据工具
  • 利用 k8s/hadoop 或者 spark cluster 作为集群, 把任务调度到集群中分布到多个机器上加速计算。
  • 根据业务情况可以双写或者多写到多个存储系统中,并进行压缩和固化

模拟设备规模

在计算机视觉场景中,我们可能往往要面对大量的设备模拟工作。 因为在系统中我们的数据都是从各个摄像设备中采集到视频流,并进行解码抽帧等工作得到图片的。 而在这种情况下的性能测试,我们需模拟大量的设备来制造系统压力(上面的场景是模拟结构化数据来制造压力, 而这个就是模拟视频流来制造压力)。当然我们不能真的去架设海量的摄像头。所以我们不需要模拟设备,而是模拟视频流就可以了(设备和产品的通信一般都是通过视频流的,比如 rtsp 的实时视频流协议)。所以我们的解决方案是下面这个样子的:

  • ffmpeg 这个工具是常用的图像处理工具,我们可以从一个视频数据中提取数据并转成视频流
  • 我们建立一个流媒体服务器,把 ffmpeg 转成的视频流推送到这个流媒体服务器中。这样系统就可以跟流媒体服务器进行通信了。 这个流媒体服务器可以有很多种实现。 我们随便找一个开源的就可以了,比如 easydarwin。

假设我们需要模拟 1000 路摄像头,那就可以用 ffmpeg + easydarwin 生成 1000 个 rtsp 流地址并配置到被测的系统中。 当然模拟这么大规模的视频流数据,需要给流媒体服务器相当的资源才可以。

模拟节点规模

在上面的模拟设备规模的场景中,我们往往也需要面对边缘计算系统的情况。 因为我们的视频设备往往都假设在靠近用户的地方(原理中心集群),比如路边的摄像头,抓拍机等等。 由于我们要面对数据安全,网络带宽和性能等问题,不能把数据传回中心集群处理。所以需要用到边缘计算

  • 云:边缘计算是代替不了云计算的,它与云计算更像是相辅相成的关系。在边缘计算的业务中,仍然需要把部分服务部署在云端中心集群中来控制整体业务的策略。
  • 边:部署在每个地域的边缘机房,它们与终端设备距离最近并部署了绝大部分计算服务,接收到终端设备的请求后就开始就地计算并进行实时的反馈。
  • 端:终端设备的统称,这些设备可以是手机,可穿戴设备,工厂中的工控机或是路旁的摄像头。它们负责采集数据并传递给距离自己最近的边缘机房进行计算。

它的业务流程可能是下面这个样子的:

我喜欢把这种架构简称为云边端,分别代表了在边缘计算中这 3 种不同的角色。在计算机视觉场景中,客户的设备可能存在于园区/工厂/学校/社区的摄像头或其他照相设备。这些设备更靠近终端的人而非中心集群,它们收集到图像数据后再经过一系列的处理(解码,抽帧,过滤等)再传输到边缘机房部署的模型服务中进行识别,比如识别人体的行为(打架,离岗,跌倒,野泳等),人体的属性(安全帽,手套,安全带等),又或者识别烟雾火灾人脸对比等等。

完成这样的架构是非常困难的,从物理设施角度看,通常终端设备分布在非常多的地域中,企业需要在这些地域架设大量的机房。而从软件角度看,产品的架构要能够完成所有边缘节点的统一管理,完成机器的区域划分,服务的统一下发以及网络的分发策略等等。 为了支持这种部署架构,人们寻求了很多的解决方案,尤其对当前最主流的云平台技术–K8S 中开发相关功能的呼声尤其强烈。 目前国内比较主流的边缘计算项目有 superedge、OpenYurt 和 KubeEdge,他们都是基于 k8s 扩展而来。 事实上人工智能产品需要的调度需求会更多,比如对于 GPU、FPGA、TPU 的调度,对于大型存储设备的调度,对于网络流量的调度等等。

所以我们除了要模拟大规模路数的视频流之外, 还需要模拟大规模的节点数量来验证集群的性能是否达标。

如果按照传统的思路,测试团队需要申请大量的虚拟机来组建 K8S 集群,但这无疑是成本非常高的选择,因为在实际项目中我们往往需要测试数百甚至数千台机器规模的集群。所以换一种思路,看一下 K8S 的集群架构:

K8S 集群中每台节点中都会启动一个名为 kubelet 和 kubeproxy 的进程。kubelet 负责与 docker/containerD 通信,维护容器状态并周期性的向 APIServer(K8S 集群中的主要服务)上报节点和容器状态,而 kubeproxy 则负责容器网络规则维护。 而 APIServer 则会负责把用户创建容器的请求以及 kubelet 上报的容器和节点状态存储在 ETCD 中。可以看出来 API Server 是维护 K8S 集群状态最关键的服务,一旦 API Server 出现问题整个集群都会陷入异常状态。而通过上述两点的描述也可以知道随着集群内节点和 Pod 的增多,API Server 承受的压力也会越来越大。除此之外在商业产品中往往会由于各种需要自研许多组件与 API Server 进行交互,这些组件都会给 API Server 带来不小的压力。所以 API Server 的性能数据往往是测试中最关键的指标之一。业界也会推荐在 K8S 集群中的 etcd 要使用高性能固态硬盘进行部署,因为 etcd 的性能也是影响 API Server 最主要的因素。同时还需要注意的是除了 API Server 以外 K8S 还有一些其他组件也是随着 Pod 数量增长而增加压力的(比如调度器和用户自研 operator)。

所以随着节点和容器的增多,对集群的主要压力在于 etcd 和 apiserver 这些组件。只要我们模拟真实的 kubelet 来上报容器状态给 APIServer 增加压力,那其实是不是有一堆真实的节点和容器并不重要。我们构建一些虚假的 kubelet 就可以达到目标了。

kubemark 是一款针对 k8s 的性能测试工具,它能够使用少量的资源模拟出一个大规模 K8S 集群。为了演示这个工具需要一个待测试的 K8S 集群 A 以及一个装载 Kubemark 服务的 K8S 集群 B(也称 Kubemark 集群),当然也可以使用同一个集群装载 Kubemark,只不过在实践过程中我们习惯将待测试集群和 Kubemark 集群区别开来。这时在 B 集群中部署 Kubemark 的 Hollow Pod,这些 Pod 会主动向 A 集群注册并成为该 K8S 集群中的虚拟节点。也就是说在 B 集群中启动的 Hollow Pod 模拟了 K8S 的 kubelet 和 kube-proxy 并与 A 集群的 API Server 进行交互,让 A 集群误以为这些 Hollow Pod 是真实的节点。这些虚拟节点会完全模拟真实节点的行为,比如它会定时向 API Server 上报节点和 Pod 的状态,也会在用户发起创建 Pod 的请求时进行响应,只不过它不会真的去启动容器而已。也就是说虚拟节点除了不会真的启动容器外,其他的大部分功能都与真实节点差不多。这样设计的目的是为了能够模拟真实的节点对 API Server 等组件造成的压力。这样测试人员就可以避免耗费巨额的成本去搭建真实的集群就可以评估 K8S 的性能了。我们可以认为 kubemark 就像是一个复杂的 mock 服务一样,不必启动容器,但可模拟 kubelet 的行为。 如图:

大家感兴趣的可以去搜一下 kubemark 的文档看看它如何使用。

海量小文件的构建

spark 虽然可以控制数据的分片数量, 但它无法构建非结构化数据(图片,视频,音频)也无法构建过于庞大的文件数量(比如数亿个文件)。 所以我们需要另外一种方法来构建这种量级的数据。通常模拟这样的数据是为了做计算机视觉的模型训练的性能测试,或者测试存储系统本身的性能。 构造这样的数据我们要面临的问题:

IO:不管是生成图片,还是文件都会消耗网络和磁盘 io, 数亿张图片对于 IO 的考验是比较大的
CPU:如果按照传统的思路,为了提升造数性能, 会开很多个线程来并发生成图片,计算元数据和数据交互。 但线程开的太多 CPU 的上下文切换也很损耗性能。 尤其我们使用的是 ssd 磁盘,多线程的模式可能是无法最大化利用磁盘 IO 的。

所以我们的主要问题在于如何充分的利用 IO,要尽量的打到 IO 瓶颈。后面经过讨论, 最后的方案是使用 golang 语言, 用协程 + 异步 IO 来进行造数:

  • 首先 golang 语言的 IO 库都是使用 netpoll 进行优化过的,netpoll 底层用的就是 epoll。 这种异步 IO 技术能保证用更少的线程处理更多的文件。 关于 epoll 为什么性能好可以参考这篇文章:EPOLL原理详解(图文并茂) - Big_Chuan - 博客园 也可以去查一下同步 IO 和异步 IO 的区别。 我大概总结一下就是, 传统的多线程 + 同步 IO 模型是开多个线程抗压力, 每个线程同一时间只处理一个 IO。 一旦 IO 触发开始读写文件线程就会处于阻塞状态, 这个线程就会从 CPU 队列中移除也就是切换线程。 等 IO 操作结束了再把线程放到 CPU 队列里让线程继续执行下面的操作,而异步 IO 是如果一个线程遇到了 IO 操作,它不会进入阻塞状态, 而是继续处理其他的事, 等到那个 IO 操作结束了再通知程序(通过系统中断),调用回调函数处理后面的事情。 这样就保证了异步 IO 的机制下可以用更少的线程处理更多的 IO 操作。 这是为什么异步 IO 性能更好的原因,也是为什么异步 IO 能最大化利用磁盘性能。 比如在这个造图片的场景里, 我在内存中造好图片后,开始写入文件系统, 如果是同步 IO 那这时候就要阻塞了,直到文件写入完毕线程才会继续处理, 但因为我用的异步 IO, 调用玩函数让内存中的数据写入到文件就不管了, 直接开始造下一张图片, 什么时候 IO 结束通知我我在回过头来处理。 所以是可以用更少的线程来完成更多的 IO 操作也就是异步 IO 能很容易的把磁盘性能打满。 我自己测试的时候再自己的笔记本上造 100k 的图片, 大概是 1s 就能造 1W 张图片
  • 其次 golang 的 GMP 模型本身就很高效, 编写异步程序也非常简单。 我也就花了一上午就把脚本写完了。 最后利用 k8s 集群把造数任务调度到集群中, 充分利用分布式计算的优势, 在多台机器上启动多个造数任务共同完成。

原谅我懒了, 上面这个方案的架构图我实在是不想画了, 大家见谅。

尾声

今天就先这样吧, 我了解到的比较典型的,并且比较有技术含量的数据构建场景就这些了。