网易云音乐机器学习平台实践


title: 网易云音乐机器学习平台实践
description: 机器学习平台为算法相关工作者提供基础的开发调度环境,为机器学习各个系统提供集成与接入的能力,为各个机器学习相关子系统形成一套标准化流程提供保障。

图片来源:https://ml-ops.org/content/mlops-principles
作者: burness

机器学习平台基础架构

在网易云音乐内部,机器学习平台早期主要承担着包括音乐推荐、主站搜索、创新算法业务在内的核心业务,慢慢地也覆盖包括音视频、NLP等内容理解业务。机器学习平台基础架构如下,目前我按功能将其抽象为四层,本篇文章也会从这四个方面详细描述我们在各个抽象层的具体工作。

资源层:平台核心能力保障

主要为平台提供资源保障与成本优化的能力,资源保障覆盖了包括算力、存储、通信、租户等各个方面,而成本优化目前我们采用虚拟化,提供资源池的动态分配,另外考虑到某些业务突发性的大算力需求,资源层能够快速、有效地从其他团队、平台资源层调取到足够的资源提供给业务使用。本章以VK与阿里云ECI为例,简述云音乐机器学习平台在资源这块的工作:

Visual Kubelet资源

目前网易内部有很多资源属于不同的集群来管理,有很多的k8s集群, 杭研云计算团队开发kubeMiner网易跨kubernetes集群统一调度系统,能够动态接入其他闲置集群的资源使用。过去一段时间,云音乐机器学习平台和云计算合作,将大部分CPU分布式相关的如图计算、大规模离散和分布式训练,弹性调度至vk资源,在vk资源保障的同时,能够大大增加同时迭代模型的并行度,提升业务迭代效率;
平台使用VK资源可以简单地理解为外部集群被虚拟为k8s集群中的虚拟节点,并在外部集群变化时更新虚拟节点状态,这样就可以直接借助k8s自身的调度能力,支持Pod调度到外部集群的跨集群调度。

云音乐平台的CPU算力任务主要包括图计算、大规模离散和分布式训练三类,涉及到 tfjob, mpijob, paddlepaddle几种任务类型,任务副本之间均需要网络通信,包括跨集群执行Pod Exec,单副本规格大概在(4c~8c, 12G~20G)之间; 但是因资源算力不足,任务的副本数以及同时可运行的任务并行度很低,所以各个训练业务需忍受长时间的训练和任务执行等待。接入VK后,能够充分利用某些集群的闲置算力, 多副本、多任务并行地完成训练模型的迭代。

阿里云ECI;

CPU资源可以通过kubeMiner来跨集群调度,但是GPU这块,基本上整个集团都比较紧张,为了防止未来某些时候,需要一定的GPU资源没办法满足。机器学习平台支持了阿里云ECI的调度,以类似VK的方式在我们自己的机器学习平台上调度起对应的GPU资源,如下是云音乐机器学习平台调用阿里云ECI资源,在云音乐机器学习平台上,用户只需要在选择对应的阿里云ECI资源,即可完成对阿里云ECI的弹性调度,目前已有突发性业务在相关能力上使用:
截屏2021-12-21 下午3.29.22
截屏2021-12-21 下午3.30.34

底层基座层:基础能力赋能用户

底层基座层是利用资源层转换为基础的能力,比如 通过spark、hadoop支持大数据基础能力、通过flink支持实时数据处理能力,通过k8s+docker支持海量任务的资源调度能力,这其中我们主要讲下ceph在整个平台的使用以及我们在实践优化中的一些工作。

Ceph

Ceph作为业界所指的一套分布式存储,在机器学习平台的业务中很多使用,比如在开发任务与调度任务中提供同一套文件系统,方便打通开发与调度环境,多读读写能力方便在分布式任务中同用一套文件系统。当然, 从0到1的接入,很多时候是功能性的需求,开源的Ceph存在即满足目标。但是,当越来越多的用户开始使用时,覆盖各种各样的场景,会有不同的需求,这里我们总结Ceph在机器学习平台上的一些待优化点:

  • 数据安全性是机器学习平台重中之重功能,虽然CephFS支持多副本存储,但当出现误删等行为时,CephFS就无能为力了;为防止此类事故发生,要求CephFS有回收站功能;
  • 集群有大量存储服务器,如果这些服务器均采用纯机械盘,那么性能可能不太够,如果均采用纯SSD,那么成本可能会比较高,因此期望使用SSD做日志盘,机械盘做数据盘这一混部逻辑,并基于此做相应的性能优化;
  • 作为统一开发环境,便随着大量的代码编译、日志读写、样本下载,这要求CephFS既能有较高的吞吐量,又能快速处理大量小文件。

针对这些相关的问题,我们联合集团的数帆存储团队,在Ceph上做了比较多的优化:

改进一:设计并实现基于CephFS的防误删系统

当前CephFS原生系统是没有回收站这一功能的,这也就意味着一旦用户删除了文件,那么就再也无法找回该文件了。众所周知,数据是一个企业和团队最有价值的无形核心资产,有价值的数据一旦遭到损坏,对一个企业和团队来说很可能是灭顶之灾。2020年,某上市公司的数据遭员工删除,导致其股价大跌,市值蒸发几十亿港元,更严重的是,合作伙伴对其信任降到了冰点,其后续业绩也遭到了巨大打击。
因此,如何保障数据的可靠性是一个关键问题。但是,CephFS这一开源明星存储产品恰恰缺少了这一环。防误删功能作为数帆存储团队与云音乐共建项目中的重点被提上了日程。经过团队的攻坚,最终实现了回收站这一防误删功能。

新开发的回收站在CephFS中初始化了trashbin目录,并将用户的unlink/rmdir请求通过后端转换成了rename请求,rename的目的地就是trashbin目录。保证了业务使用习惯的一致性和无感。 回收站保持逾期定期清理的机制。恢复上,通过构建回收站内相关文件的目录树,然后rename回收站内的文件至目标位置来进行恢复。

改进二:混合存储系统的性能优化

通过长时间观察分析机器学习平台io状态,发现经常性存在短时间的压力突增情况。对于用户来说,其最关注的就是成本以及AI任务训练时长(存储IO时延敏感)。而目前:对于公司内外部用户,如果是追求性能的用户,数帆存储团队提供基于全闪存盘的存储系统;如果是追求成本的用户,数帆存储团队这边提供基于全机械盘的存储系统。这里我们提供一种兼具成本与性能的存储系统方案,

该架构也算是业界较常用的架构之一,但是有一个问题制约该混部架构的发展,即直接基于Ceph社区原生代码使用该架构,性能只比纯机械盘的集群好一倍不到。因此,数帆存储团队对Ceph代码进行了深度分析与改造,最终攻克了影响性能的两个关键瓶颈点:重耗时模块影响上下文以及重耗时模块在IO核心路径,如下图标红所示:

经过数帆存储团队的性能优化之后,该混部系统性能相较于社区原生版本有了显著提升,在资源充足的情况下,IO时延以及IOPS等性能指标有七八倍的提升,当资源不足且达到限流后,性能也有一倍以上的提升。

改进三:设计并实现了基于CephFS的全方位性能优化

CephFS作为基本的分布式存储,简单易用是优势,但是在很多场景下存在着性能问题:比如业务代码、数据管理、源码编译造成的卡顿、延迟过高;比如用户删除海量数据目录耗时非常久,有时候甚至要达到数天;比如因多用户分布式写模型导致的共享卡顿问题。这些问题严重影响着用户的使用体验。因此,数帆存储团队对性能问题进行了深入研究与改进,除了上面提到的在混合盘场景下的性能优化,我们在CephFS元数据访问以及大文件删除等多方面都进行了性能优化。

  1. 在大目录删除方面: 我们开发了大目录异步删除功能:用户在日常业务中,经常会遇到需要删除大目录情况。这些目录一般包含几千万个文件,总容量在数个TB级别。现在用户的常规方式是使用Linux下的rm -rf 命令,整个过程耗时非常久,有时甚至超过24小时,严重影响使用体验。因此,用户希望能提供一种快速删除指定目录的功能,且可以接受使用定制化接口。基于此,我们开发了大目录异步删除功能,这样使得大目录的删除对用户来说可以秒级完成;
  2. 在大文件IO方面:我们优化了大文件写性能,最终使得写带宽可以提升一倍以上,写延时可以下降一倍以上,具体性能指标如下;
  3. 在优化用户开发环境git和make编译等都很慢方面:用户在容器源码目录中使用git status非常慢,耗时数十秒以上,同时,使用make编译等操作也异常慢,基于该问题,杭州存储组对该问题进行了细致分析:通过strace跟踪简单的git status命令发现,流程中包含了大量的stat, lstat, fstat, getdents等元数据操作,单个syscall的请求时延一般在百us级别,但是数千个(对于Ceph源码项目,大概有4K个)请求叠加之后,造成了达到秒级的时延,用户感受明显。横向对比本地文件系统(xfs,ext4),通常每个syscall的请求时延要低一个数量级(十us级别),因此整体速度快很多。进一步分析发现,延时主要消耗在FUSE的内核模块与用户态交互上 ,即使在元数据全缓存的情况下,每个syscall耗时依然比内核态文件系统高了一个数量级。接下来数帆存储团队通过把用户态服务转化为内核服务后,性能得到了数十倍的提升,解决了用户卡顿的这一体验;
  4. 元数据请求时延方面:分析发现,用户的很多请求时延较高原因是open,stat等元数据请求时延较高,因此,基于该问题我们采用了多元数据节点的方案,最终使得元数据的平均访问时延可以下降一倍以上;

应用框架层:覆盖大部分机器学习业务的工具能力

应用框架层主要承担业务落地业务时,使用的框架能力,比如众所周知的TensorFlow框架、分布式训练任务能力、大规模图神经网络能力等等,本章将从TensorFlow资源迁移与大规模图神经网络两块工作讲述团队这块的工作:

TensorFlow与资源迁移

考虑到算力资源的不足, 在2021年,我们采购了一批新的算力,A100的机器, 也遇到了一些问题:

  1. 资源与社区:
    • A100等新显卡仅支持CUDA11,官方不支持CUDA10,而官方TensorFlow只有最新版本2.4以上版本支持CUDA11,而现在音乐用的比较多的TF1.X,源码编译无法解决跨版本问题,Nvidia社区仅贡献Nvidia-TensorFlow支持CUDA11;
    • TensorFlow版本间差异较大,TF1.X与TF2.X, TF2.4.0以下与TF2.4.0以上差异很大;
    • TensorFlow1.X的社区相关问题,如环境、性能,Google官方不予支持;
  2. 音乐内部机器学习基础架构:
    • RTRS目前仅支持TF1.14,目前针对TF1.X,Google不支持CUDA11,Nvidia官方出了Nvidia-TensorFlow1.15来支持,但是这种并不属于官方版本,内部代码更改太多,风险较大;
    • 针对目前各个业务组内维护的Java jni 模型推理的情况,如果需要使用新硬件进行模型训练,需要支持至少CUDA11的对应的TF版本(2.4以上);
    • 模型训练侧代码, 目前版本为TF1.12-TF1.14之间;

基于这样的背景, 我们完成机器学习平台TF2.6版本的全流程支持,从样本读写、模型训练、模型线上推理,全面支持TF2.6,具体的事项包括:

  1. 机器学习平台支持TF2.6以及Nvidia TF1.15两套框架来适配Cuda11;
  2. 考虑到单A100性能极强,在大部分业务的模型训练中无法充分发挥其性能。因而,我们选择将一张A100切分成更小的算力单元,需要详细了解的可以关注nvidia mig 介绍,可以大大提升平台整体的吞吐率;
  3. mig的好处,能够大大地提升平台整体的吞吐率,但是A100经过虚拟化之后,显卡实例的调度以及相关的监控也是平台比较复杂的工作;
  4. 离线训练升级到较高版本之后,推理框架也需要升级,保证兼容TF1.x与TF2.x的框架产生的模型;

通过完成上述事项, 在完成A100 MIG能力的支持之后, 整体从训练速度、推理改造后的数据来看,大大超出预期,离线任务我们使用新显卡1/3的算力可以在常规的任务老版本算力上平均有40%以上的训练速度提升,最高有170%以上的提升,而线上推理性能,通过适配2.6的TensorFlow版本,在保证完全兼容TF1.X的线上版本的同时,获得20%以上的推理性能提升。在A100切分实例上,我们目前提供2g-10gb、3g-20gb、4g-40gb三类显卡实例,覆盖平台日常的任务类型,其他指标如稳定性均大大超过老版本算力。

大规模图神经网络

随着从传统音乐工具软件到音乐内容社区的转变,云音乐依托音乐主站业务,衍生大量创新业务,如直播、播客、K歌等。创新业务既是机遇也为推荐算法同学带来了挑战:用户在创新业务中的行为稀疏,冷启动现象明显;即使是老业务也面临着如下问题:

  • 如何为新用户有效分发内容;
  • 将新内容有效分发给用户;

我们基于飞桨图学习框架PGL,使用全站用户行为数据构建用户的隐向量表征,刻画用户之间的隐性关系,提供个性化召回、相似挖掘、lookalike 等功能;在实践中,我们遇到了各种难点挑战:

  • 难点一:存在多种行为对象、行为类型,用户行为数据量大,近五亿节点(包含用户、歌曲、mlog、播客等),数百亿条边的数据规模;
  • 难点二:模型训练难,模型本身参数量巨大,需要大量算力资源来保障模型的训练;
  • 难点三:在企业界,落地像图神经网络这类技术时,需要综合考虑成本与收益,其中成本主要包括两个方面:架构改造成本与计算资源成本;

为解决这些难点,我们基于网易云音乐机器学习平台落地了以下具体的技术方案:

  1. GraphService提供类似于图数据库,基于海量的弱终端资源,提供巨图存储与采样的服务、通过巨图数据加载优化策略,满足不同规模模型以及不同采样方法;
  2. 通过k8s MPI-Operator实现了超大规模图存储与采样,是实现通用构图方案可用易用必要的基础组件;
  3. 整合k8s TF-Operator 与MPI-Operator解决模型分布式训练中的图存储、采样与分布式模型计算的问题;
  4. 通过k8s VK资源与cephfs实现计算存储资源弹性扩容
    训练过程会消耗大量计算存储资源,训练结束,这些资源就会闲置,通过cephfs实现存储资源动态扩缩容;通过virtual-kubelet等闲置计算资源引入机器学习平台,实现弹性扩容,按需计费,大大减少大规模分布式任务的训练成本;

功能层:化零为整与化整为零的艺术

功能层主要是机器学习平台做为一处机器学习基础设施,去支持整个机器学习过程的全生命周期,在云音乐,一个标准的机器学习流,主要包括四个部分:

  1. 数据样本服务;
  2. 特征算子开发与配置开发;
  3. 模型训练与离线评估;
  4. 模型服务开发与部署、持续更新;

而通过整合机器学习流中覆盖的各个部分的不同系统,端到端机器学习平台目的是为了更高效、方便的为算法开发以及相关的用户提供各种能力的支持。而在核心任务之外,机器学习平台也会抽离部分阶段的能力,为包括通过模型服务、模型共享等相关工作提供部分组件的支持;接下来会分别从端到端机器学习平台与ModelZoo两个项目来分享我们在这块的工作:

端到端机器学习平台:化零为整

端对端机器学习平台是通过机器学习平台,抽象出一套能够打通样本处理、特征存取、线上服务开发、代码/数据版本控制系统、线上服务系统推送、abtest系统标准化流程,抽象出相应地接口,为各个机器学习子系统集成至机器学习平台,复用包括容器化、系统互联、弹性资源、监控等核心能力。端对端机器学习平台目的的愿景是提供一种以模型为中心的机器学习开发范式,通过元数据中心,将整个生命周期的相关元数据关联至模型任务,以模型的视角去串联整个机器学习生命周期的各个阶段。为了达到这个目的,我们在以下几个方面完成相应的工作:

样本服务

数据样本收集与预处理,主要涉及大数据系统的对接,早期而言, 数据样本的开发并没有相关的系统支持, 业务同学自己写Spark、Flink任务,进行样本收集、清洗、预处理等过程。因而,联通系统,仅需要机器学习平台本身支持用户开发样本任务的联通,音乐内部业务上游主要使用两部分的数据开发平台:猛犸与自研的Pandora与Magina,在机器学习平台上,支持任务级别的依赖,同时考虑到其他任务的多样性,我们在每一个容器中,提供大数据框架的接入能力,支持Spark、Flink、Hadoop等基础框架。
而通过一段时间的迭代之后,我们通过约束标准的特征使用方式,基于网易云音乐基础的存储套件Datahub,提供一套标准的FeatureStore。在此基础上,标准化业务的样本生成逻辑,用户仅需修改少部分的样本生成模板中的逻辑,即可完成一个标准化的业务样本服务,为用户提供实时、离线的样本生成能力。

特征算子开发与配置开发

特征算子开发与配置开发,是一个标准的机器学习流程必须的过程,也是比较复杂的过程,是样本服务的前置逻辑。在云音乐, 线上推理框架,简称RTRS,抽象出专门的特征处理模块,提供给用户开发特征算子、使用特征算子生成的逻辑。

用户在原始数据处理时通过特征计算DSL语音配置已有算子或者自定义特征处理逻辑,编译成相应地feature_extractor包

<feature_extract_config cache="true" log_level="3" log_echo="false" version="2">
    <fea name="isfollowedaid" dataType="int64" default="0L" extractor="StringHit(``item_id, ``uLikeA.followed_anchors)"/>
    <fea name="rt_all_all_pv" dataType="int64" default="LongArray(0, 5)" extractor="RtFeature($all_all_pv.f, 2)"/>
    <fea name="anchor_all_impress_pv" dataType="int64" default="0" extractor="ReadIntVec($rt_all_all_pv, 0)"/>
    <fea name="anchor_all_click_pv" dataType="int64" default="0" extractor="ReadIntVec($rt_all_all_pv, 1)"/>
    <fea name="anchor_all_impress_pv_id" dataType="int64" default="0" extractor="Bucket(``anchor_all_impress_pv, ``bucket.all_impress_pv)"/>
    <fea name="anchor_all_ctr_pv" dataType="float" default="0.0" extractor="Smooth(``anchor_all_click_pv, ``anchor_all_impress_pv, 1.0, 1000.0, 100.0)"/>
    <fea name="user_hour" dataType="int64" extractor="Hour()" default="0L"/>
    <fea name="anchor_start_tags" dataType="int64" extractor="Long2ID(``live_anchor_index.start_tags,0L,``vocab.start_tags)" default="0L"/>
</feature_extract_config>

在线上服务或者样本服务里使用,提供给模型引擎与训练任务使用,具体详情可关注云音乐预估系统建设与实践这篇文章

模型服务开发与部署

目前网易云音乐线上的核心业务,主要使用模型服务框架是RTRS,RTRS底层基于C++开发的,而C++的相关应用开发,存在两个比较麻烦的地方:

  1. 开发环境: 总所周知,机器学习相关离线与线上操作系统不匹配,如何以一种比较优雅的方式提供用户模型开发同时也支持服务开发的能力? 网易云音乐机器学习平台底层基于K8S+docker,提供定制化的操作系统;
  2. 依赖库、框架的共享:在进行rtrs服务的开发时, 环境中需要集成一些公共的依赖,比如框架代码、第三方依赖库等等,通过机器学习提供的统一的分布式存储,只需要挂载指定的公共pvc,即可满足相关需求;

模型的部署可简单区分为两个过程:

  1. 首次模型的部署:首次模型的部分比较复杂,涉及到线上资源申请、环境安装配置等流程,并且在首次模型部署时,需要统一拉取RTRS服务框架,通过载入业务自定义逻辑so包以及模型、配置、数据文件,提供基础的模型服务能力;
  2. 模型、配置、数据的更新:在首次模型部署之后,由于时间漂移、特征漂移以及种种其他原因,我们会收集足够多的训练样本重新训练模型或者更新我们的配置、词典等数据文件,这个时候,我们通常不是重新发布模型推理服务,而且去动态更新模型、配置、包括词典在内的数据文件等等;

而机器学习平台通过标准化的模型推送组件,适配RTRS的模型部署以及线上服务的更新。

端对端机器学习平台的收益

减少用户参与,提升效率
端对端机器学习平台将核心业务的主要流程通过模型关联在一起,以模型为中心视角,能够有效地利用上下游的基本信息,比如在样本特征,可以通过复用样本服务中生成的特征schema的信息,减少在模型训练、模型推理时的特征输入部分的开发,能大大减少相关的开发工作,通过我们在某些业务的实验,能够将业务从0开发的过程花费的时间从周级别到天级别。

机器学习流程可视化与生命周期数据跟踪
端对端机器学习平台通过统一的元数据中心,将各个阶段的元数据统一管理,提供机器学习流程可视化能力:
并且通过各个阶段标准化的元数据接入, 能够有效踪机器学习过程各个阶段的生命周期数据以及资源使用情况,如样本使用特征、样本拼接任务的资源使用情况、模型最终上线的各个特征处理方式、模型训练的超参等等:

ModelZoo: 化整为零

业务背景

下图是对各个公司的机器学习业务模型上线占用时间的一个调查数据的说明,大部分的数据科学家、算法工程师在模型上线上花费过多的时间:

ModelZoo功能分层

符合我们在云音乐内部业务落地的认知,而除了前面我们讨论的端到端的标准化的核心业务的解决方案, 云音乐内部一些算法团队也会对其中的某些功能组件,有很强的需求,比如我们的通用模型服务,用户希望通过易用、高效地部署方式去构建可在实际场景中使用的通用模型,这个就是ModelZoo的由来,在这个之上,我们希望后续通用模型能在流程上打通再训练、微调,将能公开的已部署的模型,直接提供给有需求的业务方,Model的基础功能分层如下:

  • 资源层:资源层覆盖机器学习平台所有任务资源,包括GPU、VK资源、阿里云ECI资源;
  • 算法层:覆盖包括CV、NLP、以及其他有通用能力需要的能力模块如faiss分布式能力;
  • 交付层:主要包括SDK、接口两种交付方式,其中SDK模块用于提供给算法集成开发过程的场景使用,接口用于无算法集成的场景下使用,提供用户自定义模型接口构建、接口提供服务等核心功能;
  • 任务层:提供包括推理、微调、重训等核心功能,通过SDK功能、接口功能提供;

ModelZoo进展

ModelZoo到目前为止,我们的工作大概在这几方面:

  1. 通过K8S支持Serveless的能力,使用合适的镜像如TF Serving、TorchServe,即可对模型做通用的模型服务;
  2. 基于机器学习平台开,集成在模型部署组件中,提供组件部署通用模型推理的服务;
  3. 通过我们交付的组件,用户仅需要通过指定模型包(包括部署的一些基础元信息),来部署相应的服务。如果需要额外的前后处理,也支持在torchserve中自定义前后处理的逻辑;
  4. 在镜像层通过引入mkl编译的镜像、调整session线程数等核心参数,在高qps场景上,rt减少30%;
  5. 调研openvino、triton,目前由于业务已满足需求以及人力需求,暂无进一步投入,有相关经验的欢迎分享;

总结

以上就是网易云音乐机器学习平台的过去的一些工作,回顾一下,我们分别从“资源层”、“底层框架层”、“应用框架层”、“功能层”来分享相关的部分工作以及进展,机器学习平台因为覆盖的面很广泛,工作看起来比较杂乱,覆盖各种不同的技术栈,并且各项工作的挑战与目标都不一样,还是很有意思的。

本文发布自网易云音乐技术团队,文章未经授权禁止任何形式的转载。我们常年招收各类技术岗位,如果你准备换工作,又恰好喜欢云音乐,那就加入我们 staff.musicrecruit@service.netease.com

2022/05/24 posted in  机器学习平台

模型生产环境中的反馈与数据回流

前言

当机器学习模型部署在生产环境之后,它可能会在毫无预兆的情况下,业务指标快速下降,直到为时已晚。这就是为什么模型监控是机器学习模型生命周期中的关键步骤,也是MLOps的关键部分:

机器学习模型需要在两个方面进行监控:

  • 资源类监控:确保模型在生产环境中正确运行,具体包括:系统是否仍是监控? CPU、RAM、网络和磁盘空间是否符合预期?请求是否以预期的RT处理等
  • 指标类监控:随着时间的推移,模型是否依然有效。包括:模型是否仍适配新进的线上数据?指标是否与设计阶段一样好?
    资源类监控是DevOps中所涉及到的。而指标类监控更复杂,模型的指标表现通常依赖于其数据, 由于数据通常在不断变化,如果没有持续的新数据来训练模型,不再更新的模型无法学习到新的数据模式。模型指标类监控试图跟踪这种衰减,并且在适当的时候,使用更具代表性的新数据对模型进行再训练,阻止模型指标的衰减。

模型应该多久重新训练一次?

关于监控和再训练的其中一个关键问题是:模型应该多久再训练?这个问题取决于许多因素,包括:

  • 业务领域:网络安全、股票实时交易等领域的模型需要定期更新,以适应这些领域的即时变化。而如语音识别这类模型,通常更稳定,模式不会经常突然改变。但是,更稳定的模型需要更加强大:比如需要支持咳嗽场景下语音识别;
  • 再训练成本:需要考虑再训练的成本是否值得。例如,整个数据管道并重新训练模型需要一周的时间,但是仅能提升1%或者更低;
  • 模型指标:在某些情况下,模型指标在很少新的训练样本下很难有提升,是否重训练的还取决于收集足够的新训练样本;

在再训练频率方面,还需要考虑两个极限情况:

  • 上限:模型每年重训练一次, 但是要确保你的团队以及你的基础设施能够保证训练的时候,团队和基础设施能够有用;
  • 下限:以具有近乎实时反馈的模型为例,例如推荐引擎,用户在预测后几秒钟内点击产品,相关部署方案应该包括A/B Test,以确保模型按预期执行,考虑到这些验证属于统计验证,需要时间来处理收集所需的信息,计算相关的验证指标,另外即使是最简单的部署,该过程也可能允许一些人工验证或手动回滚的可能性。这些决定再训练的下限;

除上,还有一个规则之外的,就是”在线训练“:团队通过使用更自动化的ML管道,来构建能自我迭代的业务训练能力。虽然听起来很有吸引力,但这些算法的上线成本更高:模型设计者不仅要在测试数据集上测试模型的指标,还要在数据变化时验证模型的指标。 (后者是必需的,因为一旦部署了算法,就很难减轻不良学习的影响,而且当每次训练递归地依赖于前一次训练时,也不可能重现所有行为,因为你不可能让用户行为无法重复发生)。
没有标准的方法——类似于交叉验证来做模型验证,所以设计成本会更高。在线机器学习是挑战性很高的一个分支, 需要强大的基建与标准化的流程才能保证。

无论如何,一定频率的模型再训练绝对是必要的——这不是是否的问题,而是合适在训练的问题。好消息是,如果有可能在第一次模型训练时,收集到足够多的训练数据,那么大多数场景下,使用新的数据来进行再训练的解决方案是合适的。
因此,作为 MLOps和算法模型生命周期的一部分,机器学习各方参与者应该尽可能全面理解模型指标衰减。实际上,每个部署的模型都应该带有监控指标和相应的警告阈值,以尽快检测到业务性能下降。接下来的文章,用于在特定模型下,如何定义指标理解模型衰减。

理解模型退化

一旦机器学习模型在生产中得到训练和部署,有两种方法可以监控其性能下降:真实数据评估和输入漂移检测。了解这些方法背后的理论和局限性对于确定最佳策略至关重要。

真实数据评估

真实数据再训练依赖训练数据标签。例如,在欺诈检测模型中,训练数据标签是特定交易是否是欺诈性的。对于推荐引擎,则是客户是否点击或最终购买了推荐产品。

收集到新的训练数据标签后,下一步是根据训练样本数据评估模型的指标,并将其与训练阶段的模型指标进行比较,当差异超过阈值时,模型可以被认为是过时的,应该重新训练。

需要监控的指标通常有两种:

  • 统计类指标,如准确度、AUC、logloss 等。由于模型设计者可能已经选择了其中一个指标来选择最佳模型,因此它是监控的首选候选者。对于更复杂的模型,整体指标不够,可能需关注由子群体计算的指;
  • 业务指标,例如成本效益评估。 例如,信用评分业务已经制定了自己的特定指标;

统计类指标的优势是它与领域无关。为了获得最早的有意义的警告,甚至可以计算p值来评估观察到的下降不是由于随机波动引起。 而缺点是其指标下降可能在统计上显着而在业务层面没有任何明显的影响,或者更糟的是,再训练的成本与再部署相关的风险可能高于预期收益。 而业务指标更有用,它们通常具有商业价值,能够帮助业务人员在模型指标与再训练的成本上平衡。
如果可能的话,真实样本数据评估是最好的解决方案。 但是通常存在三个主要挑战:

  • 真实样本数据并不总是立即可用。对于某些业务场景,团队需要等待数月(或更长时间)才能获得真实的样本数据,如果模型指标训练下降,这可能意味着重大的经济损失;
  • 真实样本数据和样本标签是分离的。为了计算已部署模型在新的样本数据上的指标,需要样本数据与其对应的样本标签匹配。在生产环境中,这是一项具有挑战性的任务,这两条数据是在不同的系统中以不同的时间戳生成和存储的。对于低成本或短期模型,可能不值得自动收集真实数据与样本,并相互关联,这带来的成本提升,可能无法被模型指标带来的增益所cover;
  • 真实样本数据与标签仅部分可用。 在某些情况下,为所有算法场景过滤出可用的样本数据与标签,成本极大。比如,在推荐场景中, 这意味着选择要标记哪些样本是真实被用户观察过并且被用户选择或者未选择,这其中的困难将偏差引入系统;

无论如何, 进入模型再训练的真实样本子集需尽可能涵盖所有可能的情况,以便经过训练的模型无论后续样本如何都能做出符合预期的预测;

输入漂移检测

鉴于真实数据评估的成本以及局限性,一种更实用的方法是对样本输入进行漂移检测。
假设一种业务场景, 其目标是使用UCI葡萄酒质量数据集作为训练数据来预测波尔多葡萄酒的质量。
每种葡萄酒都有以下特征:类型、固定酸度、挥发性酸度、柠檬酸、残糖、氯化物、游离二氧化硫、总二氧化硫、密度、pH、硫酸盐和酒精度。为了简化建模问题,假设质量分数等于或大于 7 的葡萄酒是好酒。因此,目标是建立一个二元模型,从葡萄酒的属性中预测该标签。
为了演示数据漂移,我们明确地将原始数据集分成两部分:

  • wine_alcohol_above_11,其中包含所有酒精度为11% 及以上的葡萄酒;
  • wine_alcohol_below_11,包含所有酒精度低于11% 的葡萄酒;
    我们通过酒精度数超过11%来训练和评估我们的模型,低于11%数据将被视为部署模型的新的传入数据。
    这样的处理方式人为地制造了一个大问题:葡萄酒的质量不太可能独立于酒精度数。更糟糕的是,酒精含量可能与两个数据集中的其他特征有不同的相关性。所以,在超过11%的数据集上学到的模型能力(“如果残糖低且 pH 值高,那么葡萄酒好酒的概率就高”)可能在另一个数据集上完全是错误的。
    从数学上讲,不能假设每个数据集的样本来自相同的分布(即它们不是“相同分布的”)。而确保算法模型能取得预期效果的前提是:独立同分布,即要求训练与部署后接触的真实数据数据分布尽可能一致。而这里我们在第一个数据集上训练模型,然后在第二个数据集上部署它, 由此产生的偏移称为数据漂移。如果酒精水平是 ML 模型使用的特征之一(或者与模型使用的特征强相关),则称为特征漂移,而模型训练学习的模式本身不再成立时,则成为概念漂移

真实场景的漂移检测

如前文描述,为了能够及时做出反应,模型行为可仅基于输入数据的特征值进行监控,而无需等待真实样本数据与标签。
其正常逻辑是,如果数据分布(例如,均值、标准差、特征之间的相关性)在模型训练和验证阶段与真实部署场景所面对的数据之间存在差异,则表明模型的指标在这两种场景下是不可能相同的。

产生数据漂移的原因示例

数据漂移有两个常见的根本原因:

  • 样本选择偏差,其中训练样本不能代表总体样本。选择偏差通常源于数据收集管道本身,在葡萄酒示例中,酒精含量高于11%的原始数据集样本无法代表整个葡萄酒群体。请注意,这种原因在现实生活中说起来容易做起来难,因为有问题的通常是未知的、与场景相关、并且被检测的;
  • 非稳定环境,其中从数据源收集的训练数据不代表目标源。这通常发生在具有强烈季节性影响的时间相关任务。回到葡萄酒的例子:可以想象这样一种情况,原始数据集样本只包括特定年份的葡萄酒,这可能代表一个特别好的(或坏的)年份,在这些数据上训练的模型可能无法推广到其他年份;

输入漂移检测技术

在理解可能导致不同类型漂移的可能情况之后,接下来 的问题是:如何检测漂移? 本节介绍两种常见的方法,如何选择取决于对可解释性的需求:需要对结果进行解释的方法的情况应该更偏向单变量统计测试,而如果同时涉及多个特征的复杂漂移,或者如果数据科学家可以忍受黑盒,那么域分类器方法也可能是一个不错的选择:

单变量统计检验

此方法需要对来自每个特征的源分布和目标分布的数据进行统计。当这些评估存在严重偏差时,则说明存在较为严重的输入漂移。
假设检验在学术界进行了广泛研究,但基本方法依赖于这两个检验:

  • 对于连续特征,Kolmogorov-Smirnov检验是一种非参数假设检验,用于检查两个样本是否来自同一分布;
  • 对于离散类特征,卡方检验是一种实用的选择,用于检查目标数据中离散特征数据分布是否与源数据数据分布相匹配;
    通常会尝试使用p值来辅助进行漂移检测, p值的主要优点是它们有助于尽快检测漂移,主要缺点是没有量化效果的能力。因此,在真实场景下,有必要用具有业务意义的指标来补充p 值。

领域分类器

这类方法和最近比较火的对抗式的方法比较类似:训练模型,该模型试图区分原始数据集(输入特征和可选的预测目标)和开发数据集。换句话说,他们堆叠两个数据集并训练一个旨在预测数据来源的分类器。将模型的指标(例如其准确性)视为漂移水平的指标。如果该模型在其任务中是成功的,则具有较高的漂移分数,意味着可以区分训练时的数据和真实模型面对新数据,因此可以说新数据已经漂移。而为了获得更多信息,特别是要识别导致漂移的特征,可以使用该模型的特征重要性分数。

结果解释

领域分类器和单变量统计测试都指出了特征或标签对解释漂移的重要性。 归因于标签的漂移很重要,因为它通常直接影响业务。例如,考虑信用评分:如果整体评分较低,授予贷款的数量可能会较低,因此收入也会较低。)而归因于特征的漂移通常有助于减缓漂移的影响:

  • 根据此特征重新加权(例如,如果 60 岁以上的客户现在代表 60% 的用户,但在训练集中仅占 30%,则将其权重加倍并重新训练模型);
  • 删除该特征并在没有它的情况下训练一个新模型;
    在几乎所有场景下,即使检测到漂移,也不太可能存在自动操作。 如果部署再训练模型的成本很高,则仅当基于真实场景的指标下降或检测到显著漂移时,才会根据新的样本数据对模型进行再训练。

数据反馈

来自生产环境的数据需要流回模型原型环境进行进一步改进。
从下图可以看出,在监控和反馈中收集的数据被发送到模型开发阶段。相关系统分析模型是否按预期工作:如果是,则无需执行任何操作,如果模型的指标下降,则将自动或手动触发更新。 在实际场景中,要么使用新的样本数据重新训练模型,要么开发更强大的新模型。

无论哪种情况,数据监控与反馈系统的核心是能够捕捉即时的数据模式,并确保业务不会受到负面影响。 该系统的基础架构由三个主要组件组成,对构建强大MLOps能力至关重要:

  • 从多个生产服务器收集数据的日志系统;
  • 在不同模型版本之间进行版本控制、模型评估的模型评估系统;
  • 在线网环境中进行模型比较的在线系统,可以使用影子评分系统(冠军/挑战者)或A/B 测试;

日志系统

如今,随着生产基础设施变得越来越复杂,多场景、多版本、多数据源的模型同时部署在多个服务器上,有效的日志系统比以往任何时候都更加重要:
来自这些真实线网环境下的数据需要集中进行分析和监控,使得机器学习系统的持续改进成为可能,机器学习系统的事件日志是带有时间戳和以下信息的记录。

  • 模型元数据:型号和版本的标识;
  • 模型输入:新数据的特征值,可被用来判断新传入数据是否是模型所期望的,从而允许检测数据漂移;
  • 模型输出:模型的预测,连同后来收集的样本数据与标签,能够帮助计算模型在线网环境中的真实指标;
  • 系统动作:模型预测几乎不会是机器学习相关应用的最终产品,更常见的情况是系统会根据这个预测进行下一步操作。例如,在欺诈检测用例中,当模型给出高概率时,系统可以阻止交易或向银行发送警告。这类信息很重要,因为它会影响用户反应,从而间接影响反馈数据;
  • 模型解释: 在金融、医疗、自动驾驶等一些高度监管的领域,模型预测必须带有解释(即哪些特征对预测影响最大),此类信息通常使用Shapley等技术进行计算,并应记录下来以识别模型是否存在潜在问题(例如,偏差、过度拟合);

模型评估

一旦日志系统到位,它会定期从生产环境中获取数据以进行持续监控。直到有一天数据漂移警报被触发:传入的数据分布正在偏离训练数据分布,模型指标可能正在下降。
经过审查,算法同学通过重新训练模型来改进模型:对于几个经过训练的候选模型,下一步是将它们与部署的模型进行比较。 在实践中,这意味着在同一数据集上评估所有模型(候选模型集和部署模型)。如果其中一个候选模型的离线指标优于已部署的模型,则可以在生产环境中更新模型,通过冠军/挑战者或 A/B Test设置进行在线评估,这就是模型评估系统, 它允许算法同学进行以下操作:

  • 将多个新训练的模型版本与现有部署的版本进行比较;
  • 将模型与样本数据上的其他模型版本进行比较;
  • 随时间跟踪模型指标;

机器学习优化范式

构建机器学习相关应用程序是一个长期的迭代过程,从部署到生产、监控性能以及寻找改进系统如何解决目标问题的方法。迭代优化的方法有很多,包括:

  • 在新数据上重新训练相同的模型;
  • 向模型添加新特征;
  • 开发新算法模型;
    由于这些原因,机器学习场景随着时间不断变化。需要有一套成熟的机器学习优化的方法论来对机器学习业务进行优化, 这里称之为机器学习优化范式
    机器学习优化范式与以往我们解决其他软件任务的模式不同:让我们回到前面介绍的 wine 示例:部署三个月后,存在酒精含量较低的葡萄酒的新数据流入系统后,我们可以在新数据上重新训练我们的模型,从而使用相同的模型获得新的模型版本,也可能创建新的数据特征来添加到模型中,或者我们可能决定使用另一种机器学习模型(如深度学习模型)而不是 XGBoost。
    因此,该模型的机器学习优化范式
  • 线上模型不做任何修改;
  • 基于原始模型模板、新数据重新训练;
  • 使用新机器学习模型并且联合新数据进行训练;

模型评估存储系统

提醒一下,模型评估存储系统是集中管理模型生命周期相关的数据, 这些数据是允许被比较的。模型评估存储系统的两个主要任务是:

  • 随着时间的推移对按机器学习优化范式的优化行为进行版本控制:每个记录版本都必须附带其训练阶段的所有基本信息,包括:
    • 使用的所有特征;
    • 每个特征的预处理技术;
    • 使用的算法以及选择的超参数;
    • 训练数据集;
    • 用于评估训练模型的测试数据集;
    • 评估指标;
  • 比较机器学习优化范式重产生的不同版本之间的指标, 要决定部署哪个版本,必须在同样数据集上评估所有的这些版本(候选者和部署的模型)。
    选择要评估的数据集至关重要:如果有足够多的真实场景数据可以对模型指标进行可靠估计,因为它最接近我们在生产环境中数据;否则,我们可以使用部署模型的原始验证数据集,在数据没有漂移假设下,这让我们对候选模型与原始模型相比的指标有具体的了解。
    确定最佳候选模型后,工作尚未完成,在实际场景中,模型的离线和线网指标通常存在很大差异,因此,线网环境的测试至关重要, 线网环境的评估给出了关于候选模型在面对真实数据时的行为的最真实的反馈。

线网评估

从业务角度来看,生产模型的线网评估至关重要,而技术角度来看可能具有挑战性,在线评价主要有两种模式

  • 冠军/挑战者(也称为旁路测试),其中候选模型旁路部署并对相同的实时请求进行打分;
  • A/B测试,其中候选模型对线网部分实时请求进行打分,已部署的模型对其他请求进行评分
    这两种情况都需要真实数据反馈,因此,线网评估结果具有滞后性。

冠军/挑战者测试

冠军/挑战者(旁路测试)涉及将一个或多个候选模型(挑战者)部署到线网环境,候选模型接受与已部署模型(冠军)相同的请求。但是,它们不会向系统返回任何响应或预测结果。只需记录预测打分以供进一步分析。
冠军/挑战者测试被用来做两件事:

  • 验证候选模型的指标优于或至少与已部署模型一样好。因为这两个模型是在相同的数据上打分的,因此可以直接比较它们在线网环境的指标。当然,这也可以通过使用由冠军模型打分的新请求组成的数据集, 在候选模型来离线打分完成;
  • 测量候选模型如何处理线网实际负载(如rt、cpu使用率)。考虑到新模型可能有新的特征、新的数据预处理逻辑,甚至是新的模型结构,请求的预测时间不会和已部署模型一样,是否符合能上线的要求也是需要验证的;

A/B测试

A/B 测试(测试两个变体 A 和 B 的随机实验)是软件应用优化中广泛使用的技术:

  • 对于类似推荐场景的应用,模型给出了给定客户可能会点击的item列表。因此,如果商品没有出现,就不可能知道客户是否会点击。在这种情况下,进行A/B测试,其中一些客户会看到模型A的推荐,而一些客户会看到模型B的推荐;
  • 优化的目标仅与预测的指标间接相关:如广告引擎,它可以预测用户是否会点击广告,假设它是根据购买率来评估的,即用户是否购买了产品或服务,而每一次,不可能记录用户对两种不同模型的反应;
2022/04/04 posted in  mlops

mlops之监控与数据回流

12345678 90
但是机器学习模型部署到生产的创新之后模型会从开始的性能比较好快速的跌到性能比较差因此我们的模型系统需要进行一个完整的监控

2022/03/26 posted in  mlops

机器学习平台在云音乐的持续实践

0 刀耕火种的日子

19年3月,当时刚来云音乐,本来是在大规模机器学习上,去落地一些业务,但是发现,机器学习基础设施的暴力与原始,几乎把我一波送走:

  1. 若干台物理机登录, 每一个业务团队分配若干台物理机,基础环境、机器学习框架都需要业务团队自己负责;
  2. 没有开发调度的区别, 任务开发完成后,手动在环境里的任务crontab上去更改,调度起任务;
  3. 几乎没有任务监控的能力,很多时候我们去溯源一些运行的任务时,发现很多任务大半年没有在运行了,业务团队也不清楚;
  4. 对新人极其不友好, 没有统一培训新人的标准,同一个团队内也没办法建立好标准;
  5. 业务算法改造意愿不强,因为并没有特别好的替代品;
  6. 因为没有基建标准的缘故,各个团队去发布模型也不一致,有的团队,通过内部git进行发版来更新,也有拷贝到线上集群的指定路径,不可能管理起来,也没有办法保证模型服务更新成功的质量;

基于这些致命的问题, 我们判断,如果再不做改变,必然会影响后续业务的发展。但改变很多时候,在一些公司,或者在一些公司的某些个阶段本身就是一件很难的事情。

1 先干吧

尽管问题我们都清楚, 刀耕火种的日子应该早点摒弃,我们应该尽早脱离这种状态。但是云音乐是一个以业务为主导的公司, 很难提前投入相关的人力去做这样短期内没办法看到好处的工作。很多业务为主导的公司, 后者说前期在业务增长的公司, 在技术架构层面上,遇到问题时,会习惯性地先找出一些取巧的解法去规避他,直到无法规避时,才会从技术架构体系上去优化。所以, 老板的一句话就是“先干吧”,虽然对我们来说,并没有实际的支持, 人力也未到位,业务也不配合, 可能只有一句口号”技术体系建设“。 我们几个小伙伴却憋着气,想做一些有意思的东西。

2 团结一切可以团结的力量

老板一句”先干吧“, 团队几个小伙伴,憋着气想要做出改变当时境况的东西,但是”理想丰满
、现实骨干“。当时,团队连我在内只有3个人力, 还各个背景不同,有算法出身、做过一些分布式框架的我, 有负责模型服务开发的,有纯做后台开发的,就是没有系统化研究过机器学习平台的。
所幸,当时网易内部有一些相关机器学习团队,我们进行了亲切友好地交流(纯偷师)。其中,我印象最深地就是伏羲实验室赵增负责的丹炉,支撑了整个伏羲的机器学习业务,包括后来在游戏上深耕的超大规模强化学习的应用。 虽然,丹炉支撑的业务和云音乐的业务千差万别, 但是从架构体系来说,丹炉可以说是早期云音乐Goblin机器学习平台的老师。在我们一穷二白,完全没有上路的时候,给了我们方向:ML Infra base K8S。
然后,我们找了云计算的新勇,期望他们帮我们改造我们的资源管理、调度能力,给我们培训到底k8s是啥、kubeflow是啥、那些机器学习场景下的operator又是啥。 新勇是一个很nice的同事, 我印象中,当时由于GPU主机还是以物理机的形式, 我们可操作GPU主机好像只有三台。在这三台主机上,新勇团队帮我们搭起最简单的k8s集群,然后我们又攒了10多台大数据集群淘汰下来已经过保的CPU机器,找了王盼负责的存储团队,帮我们搭了一个ceph集群。
至此,我们终于第一次以容器化集群的形式,管理起来我们的存储与计算资源,可以开始开发了。

3 解决什么问题

在开始说要干机器学习平台之前,其实之前就有一个版本, 后来我们同学在公司内分享,称作为”石器时代“。大概就是一个平台,支持一些组件的拖拉,然后组件内通过java开发,构建DAG,完成相应的功能。从开发完成,发布到之后的几个月,用户量为0。后来我们开始干之前,前面也提到和各个机器学习团队有过深入地交流,我们摒弃了之前花式的拖曳与低代码逻辑, 确定了当时机器学习平台需要解决的核心问题:

  1. 开发模型过程中遇到的环境、数据链路、开发便利性问题;
  2. 如何将开发环境中完成的模型任务,高效、简单地调度起来,解决包括基础依赖、日志、重跑等核心问题;

针对问题1,以往物理机开发的模式极其暴力, 每个人在自己的目录上安装环境,或者通过anaconda的虚拟化环境来支持,但是在一些公共的工具体系,比如大数据环境上,很容易出现问题,而有了Docker之后,变得不再是问题。我们将大数据工具比如hadoop、spark环境,Python开发环境Anaconda、JupyterLab,常见的机器学习框架如TensorFlow、PyTorch,以及SSH这种基础的服务,打包到若干个基础镜像。虽说是基础镜像,但是有过机器学习镜像打包经历的人,应该知道是一个什么样的体验,随便一个包含上面我提到的环境的TensorFlow某个版本的基础镜像就到了10多GB。我记得当时至少在两周内, 我和军正的电脑一刻不停的打镜像,解决各种比如安装配置命令有错,没办法访问墙外的资源等等非技术问题,每天风扇呼呼响,打包一个push一个,然后删除,继续打包下一个,有的时候打包时间过长,就把电脑放到公司,等第二天来看。以至于后来,我们都强烈的在Goblin上增加一个commit的功能,去让用户把容器内部的变更固化到镜像中,减少他们来找我们打基础镜像的需求。
本地IDE远程开发
web vscode
JupyterLab

针对问题2,我们的选择是统一化存储、任务流、与容器化组件。其中统一化存储是基础,通过分配给用户对应的pvc卷,来打通任务开发与调度之间的gap,开发环境写好的代码文件,仅需要在调度环境中配置相同的pvc卷,即可调度、访问。容器化组件是没有任务附加逻辑的,他的功能仅仅是向资源池申请指定的资源,然后按配置好的镜像拉动启动的文件,最后,运行配置好的启动文件;而任务流支持和其他功能组件联动,比如对接外部系统的模型推送、多个容器化组件的编排。

容器化组件示意
任务流组件示意

4 把业务的机器全卷走

基础功能完成后,经过一段时间的测试, 评估基本上没有啥问题。 我们开始逐步安利业务老大们把任务迁移起来。但是节奏还不能太快。 这个时候,我们只有3个正式人力,3台GPU主机。
一步一步来, 但是现实依然残酷,没有一个业务团队配合,不管如何安利,如何保证。直到音视频实验室小伙伴来找我们借显卡,这里要提一个很有意思的历史背景:当时数据智能部创立之初,我还没有来时,就买了一批显卡,还蛮多的,160张,都归属数据智能部这边,音视频实验室没有,所以来找我们借显卡,然后我们就逐步诱惑他们要不要在我们机器学习平台上来先试试。就这样,音视频成了我们第一个小白鼠,上了我们的贼船。也帮助早期的机器学习平台填了比较多的坑。后来,一个团队迁移,释放出原来他们的GPU物理机,加入到Goblin集群,再迁移一个团队,再释放。保持这样的节奏,李宽、立益、军正、我, 在两个月时间里,迁移完业务团队所有的历史模型训练任务并和业务同学完成验证交付,并且把所有原先业务所有GPU主机都加入到Goblin集群。至此,GPU资源,全部被统一化收入到平台, 我们终于有了平台,去可以在后面尝试标准化地完成一些关于机器学习的工作;

5 时候到了,该报了

前面基本上的功能开发完成之后, 业务也开始迁移上去。但是挑战才刚刚开始, 虽然得益于之前音视频已经在Goblin做了相关的尝试,基础的功能,比如Local IDE 远程开发,基础任务调度,并没有太多的问题。但是任务量上去之后,底层K8S的资源调度能力出现了瓶颈。当时,由于我们团队在大规模上线五分钟、十五分钟级别增伤模型训练任务,整个机器学习平台每天将近4000多次任务调度,最高时有6000、7000。 尤其是在高频率模型更新上, 尽管采取了包括限制namespace、打上专属label等策略,进行资源的限制,也取得了一定的效果。但还是存在问题:经Goblin从调度开始,到pod真正拉起来, 有2-3分钟的延迟,这种级别的延迟在类似于小时更新、日更类别的模型,之前我们都是忽略的。但是在五分钟级别,考虑到模型运算时间有限, 我们没办法容忍,需要一定一定抠时耗:

  • 增加一种专门针对高频率更新的文件依赖策略,以往的hdfs上的文件依赖逻辑是文件存在后,且保证两分钟内数据无变化,则为文件已生成完成,而5分钟级别的数据流使用flink落地,可以直接通过文件名识别出数据是否生成(未完成时为.processingXXX),完成时则为正常名字;
  • 之前云计算同事,将K8S 集群的API Server从原先的容器上,没考虑到太多并发调度资源的场景,后续将API Server部署到并发处理能力更强的物理机上,能够大大减少API Server请求的时延;
  • 未规范使用K8S List接口, 随着历史任务越来越多,List会一次将所有历史任务都请求到, 当List频繁调用,会导致接口被卡住,影响整体调度性能,后续改为watch增量的查看任务,每次请求不会是全量数据;

这段时间, 在稳定性上,我们遇到了很多挑战。 起初,只有靠人力去抗,好多次在周末, 李宽、军正、我都在紧急帮忙解决线上问题,确实那段时间稳定性上的问题给业务体验很不好,很感谢业务的容忍以及反馈, 让我们在一个可接受的阶段内去逐步收敛线上遇到的问题。

6 ML Infra第一步:联通多个系统

单单是机器学习平台本身,定位在离线模型训练。再如何做天花板都很低, 作用很有限, 必须要兼容、联合现有的各个系统,让机器学习平台成为集散地的角色。

在云音乐,一个标准的机器学习流,主要包括四个部分:

  1. 数据样本收集与预处理;
  2. 特征算子开发与配置开发;
  3. 模型训练;
  4. 模型服务开发与部署、持续更新;

早期, 数据样本收集、预处理过程都有算法自己开发, 后面我们会详细介绍相关工作, 这里我们仅对当时情况做简单的阐述:

数据样本收集与预处理

数据样本收集与预处理,主要涉及大数据系统的对接,早期而言, 数据样本的开发并没有相关的系统支持, 业务同学自己写Spark、Flink任务,进行样本收集、清洗、预处理等过程。因而,联通系统,仅需要机器学习平台本身支持用户开发样本任务的联通,音乐内部业务上游主要使用两部分的数据开发平台:猛犸与自研的Pandora与Magina,在Goblin机器学习平台上,均支持任务级别的依赖,同时考虑到其他任务的多样性,比如容器化任务处理某些样本开发中使用到的附加数据,通过Hadoop 命令push到hdfs的,我们也支持文件级别的依赖,通过文件生成来驱动模型任务的调度;

特征算子开发与配置开发

特征算子开发与配置开发,是云音乐线上推理框架赋予的能力, 线上基本框架,简称RTRS, 其基础架构设计如下:
rtrs基础架构设计示意图
用户在原始数据处理时实现相应逻辑,打成相应的feature_extractor包,然后在线上服务调用相应的算子即可完成数据的转换,喂到模型中计算即可。

模型服务开发与部署

模型服务的开发在云音乐体系相对比较复杂,历史债也比较多, 这里仅阐述目前主要支持的框架RTRS,RTRS底层基于C++开发的,要想将其推广至云音乐全部业务落地, 存在两个比较麻烦的地方:

  1. 开发环境: 总所周知,机器学习相关离线系统,比较偏向于Ubuntu这类, 相关的技术教程、资源也很多, 而云音乐线上环境为centos,如何以一种比较优雅的方式提供用户模型开发同时也支持服务开发的能力? Goblin机器学习平台底层基于K8S+docker,我们只需要标准化一个centos的rtrs开发环境镜像即可;
  2. 依赖库、框架的共享:在进行rtrs服务的开发时, 环境中需要集成一些公共的依赖,比如框架代码、第三方依赖库等等,通过机器学习提供的统一的分布式存储,只需要挂载指定的公共pvc,即可满足相关需求;
    RTRS 服务开发环境

模型服务整体架构抽象为两个具体过程:1. 首次服务的部署;2.部署之后,周期性模型的更新:

针对模型服务的部署早期相对比较麻烦, 很多时候都是人工去支持,这块机器学习平台与框架团队参与的不多,不便阐述。
当部署完成之后,涉及到模型的更新, 模型更新其核心流程主要包括以下几个方面:

  1. 通知线上服务,模型已经训练完成,且经过一定流程检验,符合标准,请准备更新;
  2. 服务端接到通知后,按通知中夹带的模型相关信息如模型路径去载入模型, 载入完成后,伪造若干相关样本,进行推理计算,完成后若无问题,则将模型服务替换为更新模型提供的服务即可;

7 进化

当很多功能可以通过机器学习平台作为一个入口进行支持之后,自然而然地开始考虑各个子系统的更新迭代,并且在这个基础上通过平台保持统一标准,保证各个子系统的更新不影响到整体机器学习作业流的正常运行。因为各个子系统的复杂性,本文仅对团队负责的相关工作来扩展:

端对端平台共建

端对端最早雏形是机器学习平台上的任务流相关的一个形态,现在在机器学习平台上已经废弃掉了。早期我们的想法是通过机器学习平台,抽象出一套能够打通样本处理、服务开发、打通代码版本控制系统、线上服务系统推送、abtest系统的任务流,抽象出标准化的接口,来提供给各个系统相关同学接入机器学习平台,复用包括容器化、系统互联、弹性资源等核心优势。
我们的思路是优先从底层能力打造自定义Pipeline与自定义Stage,每一个核心逻辑可以通过自定义的Stage标准来接入到机器学习平台,比如样本服务、特征开发等等, 但是后续发现这样的设计虽然能够大大抽象化整个工作流, 增加整个系统的灵活性,但是早期落地重点可能并不是一套灵活的Pipelin和Stage自定义系统,而是快速接入用户背书。所以后面整个设计思路基本上从下到上改变成为从上到下,先暂时不考虑用户需求, 固化用户基于RTRS开发的基本流程,从样本服务、特征算子与配置开发、模型训练、模型推送等固化成基础的业务系统,然后通过打通各个业务系统,来达到标准化整个流程的目的。
工作流

自上而下与自下而上

自下到上的与自上到下是两种不同的开发范式, 站在复盘的角度上,其实蛮有意思的。有点类似于《笑傲江湖》里的华山派的气宗与剑宗。前者注重先修内功后修招式,后者更注重招式。前者更讲究架构设计,从底层思考兼容各种不同业务的架构,后者更注重实战,先解决好业务需求,最简化业务范式,抽象出平台。
其实不仅仅是开发模式上,自上而下与自下而上的想法在很多方向上都是成立的。当我们推进一件事情时,需要综合考虑当前情况,是更注重短期业务产出还是更长远架构的稳定性是决定采用那种模式的关键因素。

业务可解释性

平台本身能力的升级无非在于稳定性、成本、易用性等基础方面,而统一标准化入口,各个子系统的集成接入,却可以给我们带来更多增值服务。
数据理解能力,是我们平台可以期待的增值服务之一。在我们说服业务接入平台,标准化之后,我们总该给他们更多的原来做不到的能力。

流程可视化

数据理解能力

性能影响体验

从0到1的接入,很多时候是功能性的需求,很多工作存在即满足目标。但是,当越来越多的用户开始使用时,面对各种各样的场景,会有不同的需求, 如性能。

例如CephFS为机器学习平台提供了弹性的、可共享的、支持多读多写的存储系统,但开源CephFS在性能和安全性上还不能完全满足真实场景需求:

  • 数据安全性是机器学习平台重中之重功能,虽然CephFS支持多副本存储,但当出现误删等行为时,CephFS就无能为力了;为防止此类事故发生,要求CephFS有回收站功能;
  • 集群有大量存储服务器,如果这些服务器均采用纯机械盘,那么性能可能不太够,如果均采用纯SSD,那么成本可能会比较高,因此期望使用SSD做日志盘,机械盘做数据盘这一混部逻辑,并基于此做相应的性能优化;
  • 作为统一开发环境,便随着大量的代码编译、日志读写、样本下载,这要求CephFS既能有较高的吞吐量,又能快速处理大量小文件。

详细地性能优化实践见网易数帆存储团队与云音乐机器学习平台合作产出的机器学习平台统一化分布式存储 Ceph 的进阶优化

监控方案日益完备

之前我们的监控系统主要依赖轻舟服务提供,很多功能依赖轻舟提供给我们的能力。但是轻舟定位可能是更通用的云计算平台,针对于机器学习本身很多定制化需求无法得到快速响应。
随着越来越多的任务运行,以往轻舟的监控方案能力明显无法满足,我们与云计算新勇团队联合独立一套专属的监控方案,用来详尽地监控集群情况: Prometheus 检测到的异常发送至配置中定义的 Alertmanager,Alertmanager 再通过路由决策决定发送给哪些报警后端,所以集群采集的数据均在统一的数据存储上按标识保存,业务接入直接使用即可,无需关注。
整个模块主要包括三个部分:

  1. Cluster Monitoring Operator:管理报警消息、集群以及报警消息接收人之间的关系;
  2. Querier:负责跨集群查询监控数据,后端存储对接网易内部产品;
  3. AlertManager Webhook Server:接收 AlertManager 的报警信息,并根据接收人的配置将报警消息发送至对应的消息接收人,支持邮件、Popo 和短信的通知方式。

业务可根据需要接入自定义的报警后端还可以使用 Webhook 接口来进行开发。目前网易内部常用的报警后端包括邮件、短信、Popo、邮件、Stone、易信、电话,运维部的通知中心暴露了接入这些的通道的。
监控方案架构

而平台可以通过相应的接口,集成Grafana监控能力,并能够快速制定业务模块展示相关监控信息。

某个开发容器:

调度namespace监控:

某个GPU物理节点监控:

更多类型资源支持

A100 MIG

采购的一些A100机器,但是在我们内部的大部分推广搜场景,并不需要如此强大的算力,这里采用了Nvidia的MIG能力,将单张卡拆开成多个GPU实例,给到不同业务使用。

然后现实比较残酷,无力吐槽的是, A100不支持Cuda10,一家的旗舰产品竟然不能软件向下兼容, 而以往在集群上的任务都是基于Cuda10跑的,要想使用新版本的显卡,必须要兼容Cuda11,英伟达还稍微有点良心的是有一个Nvidia-TensorFlow1.15,不过总归不是官方。而Google的TensorFlow在这些上就更麻烦了,几乎每个版本,接口都有差异,大部分企业内部都是使用TF1.X中的某个版本作为稳定版本,各种接口的不适应,做过平台相关工作的人应该能理解这里的工作量。哎,对这类公司真的是无力吐槽,对社区太强硬了。真的希望在硬件、软件上都能和英伟达还有TensorFlow扳手腕的玩家,引入一些竞争,对社区用户更友好一些。

Visual Kuberlet资源

因为网易内部有很多资源属于不同的集群来管理,有很多的k8s集群, 杭研云计算团队开发kubeMiner网易跨kubernetes集群统一调度系统,能够动态接入其他闲置集群的资源使用。过去一年多,云音乐机器学习平台和云计算合作,将大部分CPU分布式相关的如图计算、大规模离散和分布式训练,迁移至vk资源,在vk资源保障的同时,能够大大增加同时迭代模型的并行度,提升业务迭代效率;

相关技术细节见: 降本增效黑科技 | kubeMiner 网易跨kubernetes集群统一调度系统

阿里云ECI;

CPU资源,我们可以通过kubeMiner来跨集群调度,但是GPU这块,基本上整个集团都比较紧张,为了防止未来某些时候,需要一定的GPU资源没办法满足。我们支持了阿里云ECI的调度,感谢集团云基建相关的合作,可以以上面VK类似的方式在我们自己的机器学习平台上调度起对应的GPU资源,如下是机器学习平台调用阿里云ECI资源,后续会集成在平台上。
截屏2021-12-21 下午3.29.22
截屏2021-12-21 下午3.30.34

8 不仅仅是平台

大规模机器学习

大规模图神经网络与隐性关系链共建

IP画像

9 未来

协助完成构建更全面的机器学习基础能力

协助推进线上服务新架构体系演进(cpu->gpu)

推进新架构体系演进(cpu->gpu)

底层能力梳理

当平台越来越多被第三方团队使用之后,我们有一个想法,能够有一套接口标准,能够将

2021/11/17 posted in  机器学习平台

为机器学习量身定做的ops工具:cml & dvc

动机

写这篇文章初衷是最近工作在做一些关于机器学习流程化的工作。现阶段,算法上的创新一天一个样,很多时候,算法同学会去试, 如何能搞提升较高的效率, 每个算法同学的手段都不同,回想计算机科学发展至今天,从野蛮生长到模块化、流程化、标准化。机器学习无外如是。而最近工作涉及到关于mlops相关的事情,其难度很大,不在乎技术本身,当中涉及到太多的以往的技术债。很难去设计本来mlops应该做的东西,另外在某个微信群里讨论,算法同学应该怎样增加工程能力
IMG_1454
其实个人认为算法相关的工程能力也不是去一定专注model service,写部署服务这些,个人认为凡是提升算法工程当中的效率,皆可以算作工程能力。基于这两个原因以及自己的职业规划,细了解了开源相关的工作,窃以为开源虽然有时候很难落地企业实际场景,但是其思想很纯粹, 值得我们去学习,这一系列的文章应该会比较多,今天先介绍cml 和dvc。

cml 第一次尝试

git clone https://github.com/burness/example_cml

定义一个train-my-model的workflow:

name: train-my-model
on: [push]
jobs:
  run:
    runs-on: [ubuntu-latest]
    container: docker://dvcorg/cml-py3:latest
    steps:
    - uses: actions/checkout@v2
    - name: cml_run
      env:
        repo_token: ${{ secrets.GITHUB_TOKEN }}
      run: |
        pip install -r requirements.txt
        python train.py

        cat metrics.txt >> report.md
        cml-publish confusion_matrix.png --md >> report.md
        cml-send-comment report.md

几个关键点:

  1. 定义一个train-my-model的工作流,该工作流在代码被push到仓库当中任一分支时触发;
  2. 启动dvcorg/cml-py3:latest 容器,使用action/checkout@v2,拉取项目源码(这个是在github专门提供的机器上完成的,github hosted runner和self hosted runner的差异);
  3. 从requirements.txt安装项目所需python包,运行train.py, 并将结果(metrics.txt, cufusion_matrix.png)重定向到report.md,然后将report转换为下图的评论;

修改代码当中模型某一参数后, 提交后,触发该工作流:

查看任意工作流的日志记录:


pip install -r requirements.txt

python train.py

dvc

dvc demo演示

第一节介绍了基于github actions,如何去做持续机器学习的工作流,算是一个简单的demo,接下来演示,当训练数据过大时,github无法使用时,应该如何处理,这里我们先做个演示,后续再来讲解。

先安装个dvc

pip install dvc

dvc init 有个报错
ERROR: unexpected error - 'PosixPath' object has no attribute 'read_text', 这里后面遇到experiments,又跳转会pip安装,并且卸载 pathlib即可: pip uninstall pathlib.
改用安装包

git clone https://github.com/burness/mlops_tutorial2

在google 云硬盘上创建一个目录, 复制folders之后的字段

dvc remote add -d mlops_toturial2 gdrive://1ybWU9o_A38z0VIzXpBzRMsoTdcrjKqC7

commit and push

运行代码get_data.py, 下载需要使用的数据文件

运行dvc add data.csv

commit and push:


dvc 推送数据到google drive:


由于是第一次登陆, dvc需要你将图上链接粘贴到浏览器,然后得到一个验证码,复制进来,然后就开始数据push:

名字有一个变化:

因为我在公司,可以看外网资料, 这边没有两台电脑,为了方便演示, 我从github上clone项目代码到另一个目录:

dvc pull:

dvc 功能详解

数据与模型版本管理:

如前面一小节里, 我们dvc add data.csv, 生成data.csv.dvc, 那么我们看下对应dvc里面的内容:

那么,我们现在对数据 做一些更改,删除两行数据后,重新dvc add:


git 提交, dvc push:

我们到google drive上看, 又多了一个版本:

到现在为止, 我们有那个dataset, 怎么转回到之前的那个数据集呢?先git checkout 某个版本,然后dvc checkout即可

我们的数据就回来了,更好地一种是通过branch 来管理,这里就不尝试了,大家可以试试。

数据、模型访问

dvc list https://github.com/burness/mlops_tutorial2可以直接列出,项目当中使用dvc保存的数据文件,而注意这些文件并没有保存在github上。

dvc get https://github.com/burness/mlops_tutorial2 data.csv,可以在任意路径来download数据:

其他命令,如dvc import可以具体去看相关帮助信息了解;

数据结合pipeline

前面演示了如何使用dvc管理你的dataset文件,当然model文件也可以同样管理,但是这还不够,接下来会演示dvc如何保存数据的pipelines,在演示之前,我们拿到演示需要的代码:

 wget https://code.dvc.org/get-started/code.zip
 unzip code.zip
 rm -f code.zip

安装数据处理所需的依赖:

pip install -r src/requirements.txt

运行命令:

dvc run -n prepare \
          -p prepare.seed,prepare.split \
          -d src/prepare.py -d data/data.xml \
          -o data/prepared \
          python src/prepare.py data/data.xml

将数据文件按对应参数,切分成train.tsv与test.tsv:


对应dvc.yaml里内容记录:

下一步,将prepare的数据进行特征处理:

dvc run -n featurize \
          -p featurize.max_features,featurize.ngrams \
          -d src/featurization.py -d data/prepared \
          -o data/features \
          python src/featurization.py data/prepared data/features

yaml文件如下:

继续增加训练过程:

dvc run -n train \
      -p train.seed,train.n_est,train.min_split \
      -d src/train.py -d data/features \
      -o model.pkl \
      python src/train.py data/features model.pkl

相关产出:

dvc.yaml文件如下:

接下来只需要执行dvc repro,即可重现dvc pipeline:

我们修改下params里面的参数split:0.20->0.15, n_est:50->100:

最后dvc dag:

pipeline与metric、parameter、plots结合

继续上面的演示来, 我们构建如上图的一个pipeline之后,接下来做模型的评估, 其中-M指定该文件为metric文件, --plots-no-cache 表明dvc不cache该文件:

dvc run -n evaluate \
      -d src/evaluate.py -d model.pkl -d data/features \
      -M scores.json \
      --plots-no-cache prc.json \
      --plots-no-cache roc.json \
      python src/evaluate.py model.pkl \
             data/features scores.json prc.json roc.json

dvc.yaml又增加evaluate过程, 其中evaluate生成对应的score.json, prc.json, roc.json:

查看score.json:

dvc plots modify prc.json -x recall -y precision
dvc plots modify roc.json -x fpr -y tpr

运行dvc plots show:

修改params.yaml中的max_features=1500, ngrams=2。
重新运行整个pipeline:

dvc repro

git保存&提交:

git add .
git commit -a -m "Create evaluation stage"

再次修改params.yaml中的max_features=200, ngrams=1, 运行整个pipeline:

dvc plots diff画出不同参数版本的效果差异:

dvc与experiments

在dvc中,使用experiments来构建不同实验组,来进行超参的调试:

dvc exp run --set-param featurize.max_features=3000 --set-param featurize.ngrams=2将max_features配置为3000, 并且运行整个pipeline:

对比实验:

缓存实验队列:

dvc exp run --queue -S train.min_split=8
dvc exp run --queue -S train.min_split=64
dvc exp run --queue -S train.min_split=2 -S train.n_est=100
dvc exp run --queue -S train.min_split=8 -S train.n_est=100
dvc exp run --queue -S train.min_split=64 -S train.n_est=100

开始运行, 其中并行任务为2, 这里遇到个小插曲,改用pip安装即可,写在pathlib即可:

dvc exp run --run-all --jobs 2

我们看到最好的auc 效果为79d541b, 实验id为exp-f6079, 我们将这个持久化,并且查看现在score.json:

dvc push 保存至远端

这里我push失败dvc exp push gitremote xx, 应该是和我项目git remote前后不一致相关,,dvc exp pull gitremote xxx ,小问题, 这里不影响 不做过多计较;

##总结
不知道大家有没有把文章看完,很长(后续应该还有cml和dvc其他方面的实践,比如self-hosted runner, 太长了就拆开)。不过归根到底,无非就是那么几块东西:

  1. 如何将你的算法代码、数据、配置、模型、实验管理起来;
  2. 将你所管理的信息,能够分享、复制、快速复现;
  3. 所有的流程都可以记录,可以版本管理;

文章上面相关的技术工作是iterative.ai/这家公司的成果。虽然无法直接拿到企业中使用,但是其中的思想以及设计理念值得学习。接下来的文章,我将参考他们的设计思想,试着去设计符合企业界的mlops。 有兴趣,欢迎讨论。

2021/07/21 posted in  mlops

子项目 里程碑 风险
Ironbaby 能力建设 (2020.07.06 - 2020.08.28)完善基础架构,并能在 Goblin 上完成部署与验证;Rank 和 Match 常用模型扩展完成;文档编写完成(2020.08.31 - 2020.09.21)提供更为丰富的序列模型、多模态、多目标模型支持;图模型和 Transformer 模型的深入探索,提供 Match 更为丰富的表征与召回能力 (2020.09.21 - 2020.10.30)完善项目文档,并进行总结
体系化建设支持 (2020.08.03 - 2020.09.01)完成 Ironbaby 与 Snapshot 和模型部署的标准化支持
搜索业务支持 (2020.07.06 - 2020.08.31)包括意图识别在内的召回侧模型支持完成,以改善搜索召回体验 依赖业务方的工作目标
直播业务支持 (2020.07.06 - 2020.xxxx) 依赖业务方的工作目标
2021/01/13

直播场景PGL落地

作为国民级的音乐APP, 网易云音乐很早之前就将定位从传统的音乐工具软件转换为音乐内容社区,连接好泛音乐产品与用户,做最懂用户的音乐APP。

而直播作为参与度较多的场景, 云音乐内部花费大量的功夫将合适的主播推荐给用户。

评论页直播推荐难点

###业务难点

如图,是评论页场景直播推荐,和经典的推荐架构,主要包括召回、粗排、精排,本次实践主要落地在召回,而在此场景,我们约到的核心问题在于阅读评论页的用户较大部分未产生过行为,因而采用传统的协同过滤类算法,由于数据极度稀疏,并不能产生较好的效果,为典型的用户冷启动问题。

###基建难点

目前云音乐的所有计算资源已完成容器化部署, 在成本考虑下, 不可能给各个业务团队,比如384G内存、8卡的计算资源来进行海量数据的图神经网络模型训练, 如何在有限的、分布式的资源调控策略下完成大规模的图神经网络的训练也是我们必须要考虑的

图神经网络如何协助落地

行为域知识迁移

所幸,并不是没有解决方法,新用户在直播场景下为新,但是在音乐、歌单、mlog,通常会有较多的行为,是否能考虑这部分的知识,来映射到直播域,提供足够的“行为”来推荐合适的主播呢?
这里我们考虑使用Graph的结构, 引入多种不同类型的实体,如歌曲、DJ、Query、RadioID等等,将用户与主播、用户与歌曲、Query与主播等等行为,统一建模在一张图中,通过经典的Graph Model如DeepWalk, Metapath2vec、GraphSAGE,学习足够强大的embedding表示,来建模各实体id,然后通过ANN召回,通过用户对歌曲、Query等等的行为迁移至主播域,召回合适的主播。

通用的分布式图神经网络解决方案

由于该场景整体数据规模较大,在经过某些规则裁剪之后, 整体数据规模约为百万级节点,亿级别边,而在很多开源方案中,这样的量级就已经开始成为瓶颈,或者使用极其昂贵的计算资源来解决, 这个对于我们现有的资源情况来说是不可能, 而在我们的规划中,这样的数据量级仅仅还只是开始,所以,我们在开始之初, 就考虑到数据规模的问题,相对于使用什么的模型,我们更关注其在工业界场景数据下是否能训练得出, 通过调研现有的开源方案,我们选择PGL作为我们基础的框架, PGL基于PaddlePaddle Fleet来支持Graph Learning中较为独特的Distributed Graph Storage、Distributed Sampling, 可以较为方便的通过上层python接口,来将graph中的存储比如Side Feature等存储在不同server上,也支持通用的分布式采样接口,将不同子图的采样分布式处理,在分布式的“瘦计算节点”上加速计算, 这对于我们是十分具有吸引力。

进展与未来规划

目前采用图计算的实验,在有效观看上提升将近6%, 在该场景下尤其是通过引入其他域数据上来解决新用户冷启动问题上,有明显提升,未来,在这块上,我们规划从以下三个方向上探索

  1. 模型上:目前我们已经完成包括DeepWalk(带权重)、MetaPath2Vec(带权重)、GraphSAGE直播场景的落地,后续打算引入更复杂的模型如attention 机制是否能协助解决不同实体类型对最终实体建模的贡献能力;
  2. 数据规模:通过不停挖掘更多行为数据、关联更多行为实体, 数据规模越来越大,从单机到分布式,PGL目前支持较为完好,后续我们打算减少数据阈值,引入更多合适的领域数据来探索是否能够进一步提升;
  3. 平台化能力输出:目前PGL的分布式方案尽管已在云音乐内部的机器学习平台上运行,但并未整合到提供平台化能力输出, 用户暂时无法自助运行分布式的PGL模型训练,也需要更改部分源码来适配分布式训练,后期我们打算通过制作组件以及对PGL进行封装,支持PGL训练代码无需修改代码来分布式训练;
2020/12/13 posted in  图计算

直播场景PGL落地

作为国民级的音乐APP, 网易云音乐很早之前就将定位从传统的音乐工具软件转换为音乐内容社区,连接好泛音乐产品与用户,做最懂用户的音乐APP。

而直播作为参与度较多的场景, 云音乐内部花费大量的功夫将合适的主播推荐给用户。

评论页直播推荐难点

###业务难点

如图,是评论页场景直播推荐,和经典的推荐架构,主要包括召回、粗排、精排,本次实践主要落地在召回,而在此场景,我们约到的核心问题在于阅读评论页的用户较大部分未产生过行为,因而采用传统的协同过滤类算法,由于数据极度稀疏,并不能产生较好的效果,为典型的用户冷启动问题。

###基建难点

目前云音乐的所有计算资源已完成容器化部署, 在成本考虑下, 不可能给各个业务团队,比如384G内存、8卡的计算资源来进行海量数据的图神经网络模型训练, 如何在有限的、分布式的资源调控策略下完成大规模的图神经网络的训练也是我们必须要考虑的

图神经网络如何协助落地

行为域知识迁移

所幸,并不是没有解决方法,新用户在直播场景下为新,但是在音乐、歌单、mlog,通常会有较多的行为,是否能考虑这部分的知识,来映射到直播域,提供足够的“行为”来推荐合适的主播呢?
这里我们考虑使用Graph的结构, 引入多种不同类型的实体,如歌曲、DJ、Query、RadioID等等,将用户与主播、用户与歌曲、Query与主播等等行为,统一建模在一张图中,通过经典的Graph Model如DeepWalk, Metapath2vec、GraphSAGE,学习足够强大的embedding表示,来建模各实体id,然后通过ANN召回,通过用户对歌曲、Query等等的行为迁移至主播域,召回合适的主播。

通用的分布式图神经网络解决方案

由于该场景整体数据规模较大,在经过某些规则裁剪之后, 整体数据规模约为百万级节点,亿级别边,而在很多开源方案中,这样的量级就已经开始成为瓶颈,或者使用极其昂贵的计算资源来解决, 这个对于我们现有的资源情况来说是不可能, 而在我们的规划中,这样的数据量级仅仅还只是开始,所以,我们在开始之初, 就考虑到数据规模的问题,相对于使用什么的模型,我们更关注其在工业界场景数据下是否能训练得出, 通过调研现有的开源方案,我们选择PGL作为我们基础的框架, PGL基于PaddlePaddle Fleet来支持Graph Learning中较为独特的Distributed Graph Storage、Distributed Sampling, 可以较为方便的通过上层python接口,来将graph中的存储比如Side Feature等存储在不同server上,也支持通用的分布式采样接口,将不同子图的采样分布式处理,在分布式的“瘦计算节点”上加速计算, 这对于我们是十分具有吸引力。

进展与未来规划

目前采用图计算的实验,在有效观看上提升将近6%, 在该场景下尤其是通过引入其他域数据上来解决新用户冷启动问题上,有明显提升,未来,在这块上,我们规划从以下三个方向上探索

  1. 模型上:目前我们已经完成包括DeepWalk(带权重)、MetaPath2Vec(带权重)、GraphSAGE直播场景的落地,后续打算引入更复杂的模型如attention 机制是否能协助解决不同实体类型对最终实体建模的贡献能力;
  2. 数据规模:通过不停挖掘更多行为数据、关联更多行为实体, 数据规模越来越大,从单机到分布式,PGL目前支持较为完好,后续我们打算减少数据阈值,引入更多合适的领域数据来探索是否能够进一步提升;
  3. 平台化能力输出:目前PGL的分布式方案尽管已在云音乐内部的机器学习平台上运行,但并未整合到提供平台化能力输出, 用户暂时无法自助运行分布式的PGL模型训练,也需要更改部分源码来适配分布式训练,后期我们打算通过制作组件以及对PGL进行封装,支持PGL训练代码无需修改代码来分布式训练;
2020/12/13 posted in  paddle

Graph Neural Networks

经典的机器学习框架主要支持包括Images和Text/Speech 这两种数据,主要设计用来解决sequence、grids问题, 而Graph 结构相对较为复杂:

  • 无法产生特定顺序的节点序列,因而很难转换为普通序列问题;
  • 图结构频繁变化,且通常需要建模多模态特征;

Basics of deep learning for graphs

假定存在图G,其中V表示节点集合,A是邻接矩阵,\(X \in R^{m*|V|}\)是节点的特征矩阵(a matrix of node features)。这里的节点特征,根据不同的网络有不同的定义——如在社交网络里面,节点特征就包括用户资料、用户年龄等;在生物网络里面,节点的特征就包括基因表达、基因序列等;如果节点没有特征,就可以用one-hot编码表示或者常数向量 1: [ 1 , 1 , … , 1 ] [1, 1, …, 1][1,1,…,1]来表示节点的特征。

Local Network neighborhoods

节点的邻居定义计算图, 能够有效地建模信息的传播;

如上图,根据节点邻居得到计算图, 然后根据计算图生成节点的向量表示,如下图,A的节点表示由其邻居节点{B,C,D},而这些节点又由其邻居节点决定,形成如下图的计算图,其中方形框即为其聚合邻居节点的策略,可采用average操作:


由此,可将图中任意节点,根据其邻居节点,生成对应的计算图。

Stacking multiple layers

上一小节示意图中,使用节点的最高至二阶邻居点,即邻居的邻居来生成某节点的计算图,也可以使用任意深度的模型来表示,即图神经网络中层的概念:

  • 任意节点在每一层均有对应的向量表示;
  • layer-0的向量表示即模型的输入特征\(x_{u}\);
  • layer-k的向量表示,即节点经过k层邻居之后的向量表示;
    因而,Graph Neural Network能够通过叠加layer建模更高阶信息。

Graph Neural Network整体架构到这里其实基本上就讲完了,而不同的上述各种图中的方形框内部的计算逻辑。

一个有效的方法即GCN的思路是, 在入口对接收到的message平均化, 多阶的信息由邻居的邻居的信息传播来进行建模, 和常见的深度学习模型没啥差别:

模型训练

模型训练基本方式和常规的深度学习方式基本一致, 构建神经网络结构,通过构造loss函数,使用梯度下降方法,对模型参数进行更新,以最小化loss函数值:

非监督方式

基于图神经网络的非监督方式相对于传统的深度学习方式更加流行,其通常利用graph结构信息(其实也可以拼接slide info,slide未提),保证在graph中有相似的节点,有相似的特征表示。常见的模型包括:random walk系列(node2vec, DeepWalk, Struc2vec), Graph Factorization, Node Proximity in the graph

有监督方式

有监督方式的loss函数,根据不同任务类型来定义,如在常见的节点分类任务,如互联网中判断某节点是否为薅羊毛党,其为二元分类问题,通常采用交叉熵来表征其loss函数:

模型设计回顾

定义邻居聚合函数与loss函数:

按邻居节点生成批计算图

生成图节点的向量表示


对于计算图中,同一layer中的节点采用共享的参数表示,其参数彼此共享,且针对新图或者图中的新出现节点,均同样适用。

Graph Convolutional Networks and GraphSAGE

基本介绍

如前文所述,在邻居节点聚合操作时,我们采用average操作,那么是否有更多通用的方式呢?GraphSAGE的思路是采用一个通用的aggregation函数来表示方形框的计算,并将其与本身的向量表示拼接起来,aggregation函数要保证可微分。

常见的Aggregation如下:

  • Mean: 

  • Pool:

  • LSTM:

回顾

如下图, 图神经网络通过建模邻居节点的方式来解决图节点向量化的难题,通过多层高阶graph layer来聚合不同阶邻居的信息,并构建Graph计算图,来训练得到节点向量化表示,其中GCN为最基础方式,其聚合函数采取平均策略,而GraphSAGE设计通用的邻居聚合函数,如mean、pool、LSTM等等。

关于GNN的一些论文:

Tutorials and overviews:
§ Relational inductive biases and graph networks (Battaglia et al., 2018)
§ Representation learning on graphs: Methods and applications (Hamilton et al., 2017)
Attention-based neighborhood aggregation:
§ Graph attention networks (Hoshen, 2017; Velickovic et al., 2018; Liu et al., 2018)
Embedding entire graphs:
§ Graph neural nets with edge embeddings (Battaglia et al., 2016; Gilmer et. al., 2017)
§ Embedding entire graphs (Duvenaud et al., 2015; Dai et al., 2016; Li et al., 2018) and graph pooling
(Ying et al., 2018, Zhang et al., 2018)
§ Graph generation and relational inference (You et al., 2018; Kipf et al., 2018)
§ How powerful are graph neural networks(Xu et al., 2017)
Embedding nodes:
§ Varying neighborhood: Jumping knowledge networks (Xu et al., 2018), GeniePath (Liu et al., 2018) § Position-aware GNN (You et al. 2019)
Spectral approaches to graph neural networks:
§ Spectral graph CNN & ChebNet (Bruna et al., 2015; Defferrard et al., 2016) § Geometric deep learning (Bronstein et al., 2017; Monti et al., 2017)
Other GNN techniques:
§ Pre-training Graph Neural Networks (Hu et al., 2019)
§ GNNExplainer: Generating Explanations for Graph Neural Networks (Ying et al., 2019)

Graph Attention Networks

GAT顾名思义,需要考虑邻居节点中,对最终结果的不同的影响,因而引入attention机制,attention在经典的深度学习网络中十分常见,如transformer的self-attention,

Attention Mechanism

如下图, \(e_{uv}\)可以通过两个节点之间的信息流得到, \(e_{vu}\)表示节点u的信息对节点v的重要性, 使用softmax进行归一化,以便在不同的节点间存在可比性,然后集成在模型聚合函数中来建模不同节点对当前节点信息的重要性。

其他另外过程和原先GCN、graphSAGE没什么差别,除了attention信息也会参与训练。

2020/12/12 posted in  图计算

graph representation learning

写在net embedding之前

一个标准的机器学习流程如下:

其中如何设计一套合理方式来高效地进行特征表示,是十分重要的,比如在cv与nlp任务中,我们会分别设计cnn模块与RNN模块来建模图像中像素点表征的信息、word表征的信息。


那么在graph中,如何进行特征表示呢 ? Graph的特征表示极为复杂,主要表现在以下三个方面:

  • 极其复杂的拓扑结构,很难简单地像图像中的感受野来提取有效信息;
  • 无特定的节点顺序;
  • 通常graph会是动态变化的, 且使用多模态特征;

今天我们就来了解下如何对graph进行特征学习;

Embedding Nodes

假设有图G,V表示节点集合,A为对应的邻接矩阵,embedding node是对图当中的节点进行有效地编码,保证相似的节点在编码也有类似的相似性,如下图:

节点的向量学习分为以下三个方面:

  1. 定义编码器, 即ENC(v),将图中的节点通过周边结构关系的学习,映射到低维的向量表示,如\(Z_{v}\),在常见的任务中,通常就是一个embedding矩阵,通过lookup拿到对应节点的向量,然后反向进行embedding向量的更新,这个即成为学习;
  2. 定义相似度函数sim(u,v),即如何定义两个节点相似度的大小;
  3. 通过数据的学习不断来优化编码器参数,保证\(sim(u,v) = Z_{v}^{T}Z_{u}\);

Random Walk

何谓"随机游走"?给定一个graph和一个起点,随机选择其一个邻居,并移动到这个邻居,然后继续随机选择这个点的邻居,产生随机的点序列,而点和点之间出现的概率即为\(Z_{v}^{T}Z_{u}\)

Random Walk有两个特点:

  • 极具表达性: 节点相似计算的定义、随机,且包含了局部(邻居)以及高阶邻居的信息;
  • 效率级高:训练时不需要穷举所有的节点对,只需要考虑随机游走中同时的对;

非监督特征学习

给定图G=(V, E), 学习一种映射\(z: u->R^{d}\),目标函数:

其中,\(N_{R}(u)\)表明以策略R得到的节点u的邻居:

优化

  1. 使用某种策略R,从图上的每个节点开始进行固定长度的较短的random walks;
  2. 对于每个节点u,得到其的邻居\(N_{R}(u)\);
  3. 根据上图中似然函数,对embedding进行优化;

这里,将目标函数,修改为以下,其中\(p(v|z_{u})\)可通过softmax得到:

但是一旦使用softmax,其复杂度变得极大,你必须计算graph当中所有的节点,其复杂度达到了\(O(|V|^2)\),和大家十分熟悉的word2vec,我们采用Negative Sampling来近似计算:

Negative Sampling

Negative Sampling仅仅随机选择k个负样本进行归一化操作,其中\(P_V\)是所有节点的随机分布,其中k属于超参,通常有以下经验:

  • k越大,预估有更强的鲁棒性;
  • k越大,负样本偏差会越大;
  • k通常使用5-20;

Node2vec

Node2vec解决的和random类似的额问题, Node2vec扩展节点u的邻居\(N_{R}(u)\)的定义,来丰富embedding的建模信息;

Biased Walk
DeepWalk选取随机游走序列中下一个节点的方式是均匀随机分布的,而node2vec通过引入两个参数p和q,其中p为返回到上一个节点的概率, q表示生成策略选择DFS或者BFS的概率, 将宽度优先搜索和深度优先搜索引入随机游走序列的生成过程。宽度优先搜索(BFS)注重临近的节点,并刻画了相对局部的一种网络表示,宽度优先中的节点一般会出现很多次,从而降低刻画中心节点的邻居节点的方差;深度优先搜索(BFS)反应了更高层面上的节点间的同质性。(即BFS能够探究图中的结构性质,而DFS则能够探究出内容上的相似性(相邻节点之间的相似性)。其中结构相似性不一定要相连接,甚至可能相距很远。

得到Walks之后, Node2vec算法和DeepWalk基本类似,整体流程如下:

  • 计算随机游走的概率;
  • 模型从每个节点u开始的步长为l的r次随机游走;
  • 利用优化方法优化目标函数;

如何使用节点的embedding?

训练好的节点向量可用于很多场景:

  • 比如专栏前面文章提到的聚类、社区检测;
  • 节点分类,比如根据节点embedding预测节点是否属于薅羊毛人群,新闻实体是否为fake news;
  • 关系预测,比如预测用于是否与对应实体可能产生连接关系,即是否可能产生行为关系;

总结

本章课程,主要介绍了在graph中做node embedding的基本概念,以及常见的方法如Random Walk、Node2vec,并简要介绍了embedding的用途,视频教程中还有基于KG的Translating Embedding的应用,因为讲的过于简单,仅仅介绍TranE的三元组关系,感觉并不能理清楚其实际落地细节,所以本文不做描述,这部分相关工作建议直接阅读TranE的相关文档,比如药物中蛋白质分子的应用的等等,其实本文将是接下来各种复杂GNN的入门课程,大概所有的深度学习模型其实都在学习一种有效地特征表示,如本章内容而言,仅是入门,还有很多有意思的工作,比如是否能建模graph的dynamic的信息,行为是动态更新的,dynamic在某些场景下更能表征节点的社交行为结构化信息,另外还有包括side information的引入,都是在graph下的embedding node下很重要的工作。

2020/12/12 posted in  图计算

泛模型管理与分发

泛模型文件即和算法模型相关的文件,如模型配置文件、模型文件、模型生效对应的动、静态库文件、头文件等等;考虑到边缘侧设备众多,且边缘侧设备由于场景的复杂性,比如不同表计、不同场景光线信息,包括模型版本、模型生效时间、模型对应框架、算法配置文件信息等,需要统一平台进行泛管理与分发,其中泛模型管理主要包括以下几方面:

  1. 泛模型注册,如模型落地场景、归属单位信息、生效时间、模型运行框架、容器镜像版本等等,这类信息由部署人员预先注册至智能电力管理平台,进入数据库,持久化保存,并展示至模型管理页面;
  2. 泛模型控制模块,用于配置是否运行模型、模型上传与下载、泛模型更新,控制模块用于收集相关信息,生成模块化任务,自动配置完成泛模型的相关控制逻辑;
  3. 快速排障模块, 考虑到边缘侧设备通常远离开发维护人员,并且由于本身设计成本考虑,其容错性远远低于传统中央式数据中心,因而需要额外考虑快速排障模块,通过统一化SDK,将监控agent集成至边缘侧设备启动应用,及时上报心跳,管理平台通过心跳及时判断设备是否出现故障,并通过自动化脚本重启相关设备,如遇到无法完成恢复,及时告警给相关责任人,完成线下修复;
  4. 监控模块, 边缘侧通过agent接口上报运行状态信息,如表计检测中的表计读数,也包括特征的上报日志,如心跳上报日志、模型状态更新日志等等;

基于统一平台的泛模型管理功能,能够快速拿到设备注册信息,如场景、归属单位等,而考虑到边缘设备远大于传统基于service的部署方式,更新模型必然需要“空中下发”,即通过模型管理平台,上载待下发的泛模型文件至统一存储平台后,平台根据待下发的场景或设备标签,生成泛模型下发配置信息,发送到指定场景或者设备标签下的边缘设备,边缘设备集成统一的agent服务,收到泛模型下发配置后,拉取对应泛模型文件,完成泛模型的更新与生效。

2020/12/12 posted in  机器学习平台

Spectral Clustering

Graph Partitioning

何谓graph partitioning, 如下图,给定无向图\(G(V,E)\), 将这些节点分为两个组:

逻辑很简单,但是难点在于:

  1. 如何定义一个尺度,来保证图的切分是合理的:
    1. 组内成员连接尽可能多;
    2. 组与组之间连接尽可能少;
  2. 如何高效地识别这些分区;

Criterion

Cut(A,B): 如下图,图当中,两个点分别在两个分组的边的数量;

Minimum-cut

最小化图分组间的连接(如果有权重,则考虑权重):

\[arg min_{A, B}\ Cut(A,B) \]

这样会存问题:

  • 仅仅考虑图当中分组的外部连接;
  • 未考虑图中分组的内部连接;
    因此,在下面图中,会出现,假如是minimum cut不是optimal cut

Conductance

与Minimum-cut逻辑不一样, Conductance不仅仅考虑分组间的连接, 也考虑了分割组内的“体积块”, 保证分割后得到的块更均衡,Conductance指标如下:

\[ \phi(A, B)=\frac{cut(A,B)}{min(vol(A), vol(B))} \]

其中\(vol(A)\)指分组块A内节点所有的权重度之和;
但是,得到最好的Conductance是一个np难题。

Spectral Graph Partitioning

假定A为无连接图G的链接矩阵表示,如(i,j)中存在边,则\(A_{ij}=1\),否则为0;
假定x是维度为n的向量\((x_1, ..., x_n)\),我们认为他是图当中每个节点的一种标签;
那么\(A*x\)的意义是, 如下图, \(y_i\)表示i的邻居节点与对应标签和:

令\(Ax=\lambda x\),可以得到特征值:\(\lambda_i\), 和对应的特征向量\(x_i\)。对于图G, spectral(谱)定义为对应特征值\({\lambda_1, \lambda_2, ..., \lambda_n}\),其\(\lambda_1 \leq \lambda_2 \leq ... \leq \lambda_n\) 对应的特征向量组\({x_i, x_2, ..., x_n}\);

** d-Regular Graph 举例 **

假定图当中每个节点的度均为\(d\),且G是连通的,即称为\(d-Regular Graph\)。
假定\(x = (1,1,...,1)\),那么\(Ax = (d, d, ..., d) = \lambda x\), 故会有对应的特征对:

\[x=(1,1,...,1), \lambda =d \]

且d是A最大的特征值(证明课程未讲)

d-Regular Graph on 2 Components

假定G有两个部分, 每个部分均为d-Regular Graph,
那么必然存在:

\(x^{'}=(1,...,1,0,...,0)^T\), \(A x^{'}=(d,...,d,0,...,0)^T\)
\(x^{''}=(0,...,0,1,...,1)^T\), \(A x^{'}=(0,...,0,d,...,d)^T\)

所以必然存在两个特征值\(\lambda_{n} = \lambda_{n-1}\), 推广起来,如果图G中两个部分互相连通,如下图, 则最大的特征值很近似:

推广, 这里有点没有太理解:

Matrix Representations

邻接矩阵A

  • 对称矩阵;
  • n个实数特征值;
  • 特征向量均为实数向量且正交:

度矩阵

  • 对角矩阵;
  • \(D=[d_{ii}]\), \(d_{ii}\) 表示节点i的度;

Laplacian matrix

Laplacian matrix 有以下特点:

  • 令x=(1,...,1)则\(L*x=0\), 故\(\lambda=\lambda_{1}=0\);
  • L的特征值均为非负实数;
  • L的特征向量均为实数向量,且正交;
  • 对于所有x,\(x^{T}Lx=\sum_{ij} L_{ij}x_{i}x_{j} \geq 0\);
  • L能够表示为\(L = N^{T} N\)

Find Optimal Cut

分组表示(A,B)为一个向量,其中

问题转换为寻找最小化各部分间连接:

相关证明间slide,这里老师没有做过多解读;

Spectral Clustering Algorithm

基础方法

如下图:主要包括三个步骤:

  • 预处理:构造图的表示, 包括Laplacian Matrix;
  • 矩阵分解:
    • 计算Laplacian Matrix的所有的特征值与特征向量;
    • 将节点使用特征向量表示(对应\(\lambda_2\)的特征向量\(x_2\));
  • 聚类, 将节点的特征表示,排序, 按大于0与小于来进行拆分:

以下是多个实例, 看起来使用\(\lambda_{2}\)对应的特征向量\(x_2\)来切分是比较合适的:


k-Way Spectral Clustering

如何将图切分为k个聚类呢?

  • 递归利用二分算法,将图进行划分。但是递归方法效率比较低,且比较不稳定;
  • 使用降维方法,将节点表示为低维度的向量表示,然后利用k-mean类似的方法对节点进行聚类;

那么如何选择合适的k呢,如下图,计算连续的特征值之间的差值,选择差异最大的即为应该选择的k?

Motif-Based SPectral Clustering

是否能够通过专有的pattern 来进行聚类呢?上一篇文章有提到motif, 如下图:

给定motif,是否能够得到相应地聚类结果:

答案当然是可以的, 而且也是复用前面的逻辑

Motif Conductance

和上文中, 按边来切分逻辑不通, conductance指标,应该表征为motif的相关指标,如下:

这里给出一个计算的例子, 如下图, 该出模式分子为切分经过的该模式数量, 分母为该模式覆盖的所有节点数量:

所以motif的谱聚类就变成了给定图G与Motif结构来找到\(\phi_{M}(S)最小的\), 很不幸, 找到最小化motif conductance也是一个np问题;
同样地,也专门提出了解决motif 谱聚类的方法:

  1. 给定图G和motif M;
  2. 按M和给定的G,生成新的权重图\(W_{(M)}\);
  3. 在新的图上应用spectral clustering方法;
  4. 输出对应的类簇;

大致过程如下图所示:


具体过程如下:

  1. 给定图G与motif M, 计算权重图\(W^(M)\):

  2. 应用谱聚类, 计算其Laplacian Matrix的特征值与特征向量,得到第二小的特征向量,:

  3. 按升序对第二小特征值的对应的特征向量进行排序(对应的节点ID需要保存以计算motif conductance), 以\(S_r = {x_1, ...,x_r}\)计算motif conductance值,选择最小地的值即为划分点, 如下图,1,2,3,4,5为一个类:

Summary

本章我们学习了谱聚类相关的工作, 首先,讲了关于表征切分图的指标cut(A,B)以及conductance,如何切分图以及为什么切分图是一个np难题,然后提出了利用谱聚类的方法来解决该问题,从而学习到了degree matrix, Laplacian matrix等概念; 而后提出是否有按motif来进行图聚类的方法, 并基于谱聚类的方法来解决来转换原图为带权重的图来解决;

2020/10/18 posted in  图计算

Community structure in networks

Granovetter's theory

马克·格兰诺维特(Mark Granovetter,1943年10月20日-),美国社会学家,斯坦福大学教授。格兰诺维特是论文被引用最多的学者之一,根据 Web of Science 的数据,社会学论文被引数排名第一和第三的文章皆出自格兰诺维特之手。格兰诺维特因为对社会网络和经济社会学的研究而成名。其最著名成就是1974年提出的弱连接理论:与自己频繁接触的亲朋好友之间是一种“强连接”,通过这种连接获取到的往往是同质性的信息;但社会上更为广泛的是一种并不深入的人际关系,这种弱关系能够使个体获得通过强关系无法获取到的信息,从而在工作和事业上、在信息的扩散上起到决定作用。


格兰诺维特的研究认为如果两个人之间有共同的朋友,那他们成为朋友的可能性较大。

格兰诺维特的研究也在真实的数据上得到了验证

Edge Overlap

简单解释下,Edge Overlap表示两个节点的邻居节点的重合程度(本身节点不在计算范围内),下图中右边部分右上图, \(N(i)=4, N(j)=4\), 去除本身i, j 所以\(N(i) \cup N(j) = 6\), \(N(i) \cap N(j) = 2\), 所以\(O_{ij}=1/3\)

Edge Overlap被用来当做节点间链接的强弱的一种度量,通过在实际数据集(欧洲通话网络数据集得到验证), 在实际数据中,具有高边重叠的边,确实是有着强连接关系的, 这里的强连接关系即用节点间通话次数来表示关系强弱;

社区内强连接,社区间弱连接

上面的研究表明,在图中确实会存在紧密连接的社群概念,社群内的链接基本是强连接, 而社群间的连接是弱连接,强链接偏向将信息流锁紧在社群内部,而边缘连接由于涉及到多个社群之间,在信息传播上更有优势

网络社群的基本概念

网络中的社群的基础定义:紧密连接的节点集合,这些节点间有较多的内部连接,而相对较少的外部连接;

Zachary空手道俱乐部一个在图这块入门级的数据集,用来展示图网络中基本的问题如节点分类、社区发现等等;

如下图, 仅靠图的结构化关系,可以比较合理地将俱乐部进行切分,即规划至各自的社群:

另一个例子是NCAA FootBall Network:

如何寻找网络中的社群?

Modularity Q用来衡量网络中社群划分的指标, 其基础含义如下:

\[Q \Rightarrow \sum_{s \in S}[(\#\ edges\ within\ group\ s) - (expected\ \#\ edges\ with\ in\ group\ s) ] \]

其中\((expected\ \#\ edges\ with\ in\ group\ s)\)需要构建null model(之前的学习笔记有提到过Configuration Model, 忘记了的小伙伴可以翻一下), 保证相同的degree distribution且连接概率为均匀随机, 假定两个节点i、j,其度分别为\(k_i, k_j\),那么节点间边的期望为\(p(i,j)= \frac{k_{i}k_{j}}{2m}\),所以这个图里面所有边的期望为:

\[E_{edge}=0.5 \sum_{i \in N} \sum_{j \in N} \frac{k_{i}k_{j}}{2m}= 0.5 * \frac{1}{2m}\sum_{i \in N}k_{i}(\sum_{j \in N} k_{j})=\frac{1}{4m}* 2m*2m = m \]

所以Modularity Q从:

\[Q \Rightarrow \sum_{s \in S}[(\#\ edges\ within\ group\ s) - (expected\ \#\ edges\ with\ in\ group\ s) ] \]

表示为:

\[Q(G, S) = \frac{1}{2m}\sum_{s \in S}\sum_{i \in s}\sum_{j \in s}(A_{ij}-\frac{k_{i}k_{j}}{2m}) = \frac{1}{2m}\sum_{ij}[A_{ij}-\frac{k_{i}k_{j}}{2m}]\delta(c_i, c_j) \]

其\(A_{ij}\ =\ 1\ if\ i->j\ 0\ otherwise, \delta(c_i, c_j)=1\ if\ c_i = c_j\ 0\ otherwise\)

Q值域为[-1, 1], 正值表示社群内的边多于期望, 当Q为0.3-0.7之间表明有显著的社群结构;

Louvain Algorithm

Louvain Algorithm是一个最大化Modularity Q的贪心算法,主要由两步构成:

过程1:Partitioning

  1. 初始化的时候给所有节点分配一个单独的社区;
  2. 针对每一个节点i,进行以下步骤:
    1. 计算改几点被划分到其邻居节点j所在的社群,并且计算该社群的modularity delta;
    2. 将节点i移入至最好modularity delta的社群;
  3. 迭代,直到modularity delta不再增加;

其中modularity delta \(\Delta Q\), 应该包括\(\Delta (i->C)\)(将节点i移入community C增加的Q值)与\(\Delta(D->i)\)(将节点i溢出Community D), 因为\(\\Delta(D->i)\), 在步骤1中都一样, 所以\(\Delta Q\)公式主要依赖于\(\Delta (i->C)\), 如下:

\[\Delta Q(i ->C)[\frac{\sum_{in} { k_{i, in}}}{2m} - (\frac{\sum_{tot}+k_i}{2m})^2] - [\frac{\sum_{in}}{2m} - (\frac{\sum_{tot}}{2m})^2 - (\frac{k_i}{2m})^2] \]

其中:
\(\sum_{in}\) 表示社群内节点之间的边权重和(社群内边);
\(\sum_{tot}\) 表示社群中节点所有的边的边权重和(社群内边与社群外边均考虑);
\(k_{i, in}\) 表示社群中节点i与其他节点的边权重和;
\(k_i\) 表示节点i连接的所有边的权重和;

过程2: Restructuring

经过第一步之后, 会出现很多超级节点,这些超级节点间所在的社群间如果有任意的边连接,那么我们要连接超级节点, 超级节点间的边的权重等于他们相应地社群间所有边权重的和;

具体的伪代码如图,逻辑还是相对比较简单的:

回顾与真实数据展示

每一个pass 包含:partitioning和restructuring 两个部分, 如下,迭代直到收敛:

真实数据集上的一个例子:

检测有重叠的社群算法:BigCLAM

何谓有重叠的社群?

无重叠的社群与有重叠的社群

很多实际数据集中,存在有重叠的社群:
facebook的Ego-Network

PPI

BigCLAM

BigCLAM主要步骤如下:

  1. 基于节点所属的社群,构造一个图生成模型,构造方法为AGM(Community Affiliation Graph Model);
  2. 根据我们实际的图G,假设它是用AGM生成的。寻找一个最优的AGM,能产生我们实际的图G, 通过这个AGM,确定每个节点所属的社区

Community Affiliation Graph Model

如下图, 左边部分主要包括节点V, 社群C, 从属关系,其中每一个社群的概率为\(p_c\):

  1. 社群c内的节点,彼此连接形成边的概率是\(p_c\);
  2. 对于从属与多个社群的节点,如果其在一个社群内没有连接, 其边在另外社群的连接也是可能存在的;

AGM可以生成多种不同的社群结构,如Non-overlapping, Overlapping, Nested:

Detecting Communities with AGM

使用AGM来做社群发现就转换成一个给定graph G, 如何反推AGM的各个参数F:Affiliation Graph M, 社群个数C,以及社群内连接概率\(p_c\)

给定G和F, 计算器似然函数:

\[P(G|F)=\prod_{(u,v) \in G}P(u, v)\prod_{(u,v) \not \in G}(1-p(u,v)) \]

上式中前一个连乘表示图当中所有的边的似然函数,后一个表示不在该graph边集合的似然。
考虑到每个节点属于各个社区的权重是不同的, 向量\(F_u\)表示节点u属于各个社群的权重,加上log转换, 所以log似然函数为:

\[l(F) = \sum_{(u,v) \in E} log(1-exp(-F_{u}F_{u}^{T})) - \sum_{(u,v) \not \in E}F_{u}F_{v}^T \]

这里和lr的梯度下降很类似,其实就是一样,只需要用梯度下降公司待进去自然就可以算出收敛是的F的参数;

有了F之后, 我们的节点从属社群关系就极其明显了。

总结

今天,课程上主要讲解的是graph中社群的概念,通过Granovetter's theory和真实数据的对比,验证了图当中的强弱连接,从而引出社群概念,然后介绍了一种简单的社群发现算法Louvain Algorithm,通过最大化Modularity Q来保证节点与社群的从属, 最后提供可重叠的社群发现,提出BigCLAM算法,通过最大化log似然函数优化AGM生成对应真实Graph,来得到AGM, 从而得到包括社群从属关系在内的各个参数,从而识别节点从属;

2020/10/06 posted in  图计算

Motifs and structural roles in networks

Subgraphs

何谓motif? 图中反复出现的相互连接的模式,有以下三个特点:

  • Pattern:小的能导出的子图;
  • Recurring:频繁出现;
  • Significant:模式的出现明显高于预期,如类似的random genderated networks中的模式;

Motif: Induced Subgraphs

Induced Subgraph如下图所示,图中红色框虽然也是3个节点构成的子图,但是该子图与待匹配的子图不匹配(连接不一致),而蓝色的三角框中的子图与待匹配的子图匹配, 匹配的意思是指必须是出现在待匹配子图里所有节点的边,如果不是待匹配节点之间的边,则不匹配;

Motifs: Recurrence

如下图,右侧图中出现了4个待匹配的motif,motif之间可以相互重叠;

Motif: Significance

如下图, 该motif在真实的网络中出现的频率要对类似的随机网络出现的频率要高的多, 我们成为其显著性明显;

显著性通常是和随机性网络做对比,通常使用\(Z_i\)来描述motif i的显著性, 其中\(N_{i}^{real}\)表示真实网络中motif i的数量, \(N_{i}^{rand}\)表示在随机网络中motif i的数量:

\[Z_i = \frac{(N_{i}^{real}-N_{avg\ i}^{rand})}{std(N_{i}^{rand})} \]

上面的计算会随着网络规模的不同而有数值的变化,而大的网络会倾向于有更大的Z-score, 归一化处理之后,使用Net significance profile来表示,motif i的SP计算公式如下:

\[SP_{i}= \frac{Z_{i}}{\sqrt{\sum_{j}{Z_{j}^{2}}}} \]

Configuration Model

配置一个和真实网络相同的度序列的随机图可以分为三步:

  1. 按节点的度序列生成Node spokes;
  2. 随机从nodes spokes中挑选两个连接起来;
  3. 根据源节点和目标节点,将步骤2中聚合起来,即形成和真实网络相同度序列的随机网络;

Alternative for Spokes: Switching

另一个产生于源图类似的图的方法就是随机做边交换,具体步骤如下:

  1. 从源图中随机找出边如,A->B, C->D,随机交换边的终点产生边A->D, C->B, 如果交换导致自己指向自己,则不交换;
  2. 重复1中,Q次, 当Q足够大时,即可生成随机图;

本节总结

经过上面的定义与解释之后,我们就可以定义如何检测一个motif:

  1. 在真实图中,统计induced subgraph的个数;
  2. 统计生成的随机网络中的induced subgraph的个数, 这里随机生成的网络,可以生成多个做对比;
  3. 计算Z-score, 那些高的Z-score就是我们需要的motif;

motif也有相应的变种, 如不同的频率概念、不同的显著性计算标准、null model的不同约束等等,但基本上都是万变不离其宗;

Graphlets: Node Feature Vectors

Graphlet是基础的由节点构成的基础子图单位,由两个节点开始,下图是2-5个节点的graphlet示例图:

那么,如何由graphlet来改造节点的特征呢?
之前我们提到的度,是指每个节点能够接触到的边数,这里我们扩展Graphlet degress vector,用来表示节点v能够接触到的graphlet的数量, 如下图,graphlet有a, b, c, d, 注意这里d和c画在一张图上, 这样我就可以用2, 1, 0, 2来表示节点V:

Graphlet degree vector的意义在与它提供了对于一个节点的本地网络拓扑的度量,这样可以比较两个节点的GDV来度量它们的相似度。由于Graphlet的数量随着节点的增加可以很快变得非常大,所以一般会选择2-5个节点的Graphlet来标识一个节点的GDV。

Finding Motifs and Graphlets

在一个图里识别出一种特定大小的motifs和graphlet,并计算它的数量是非常难的一个问题。识别是否是同构子图本身就是一个NP-hard的问题:计算量也会随着节点数的增加而呈指数增长,因此, 一般只识别节点数较小如3-8的motif或者graphlet。

Exact Subgraph Enumeration###

ESU从一个节点\(v\)开始,算法分为两个集合:\(V_{subgraph}\):表示当前构造的子图,\(V_{extension}\):表示能够扩展motif的候选节点, 将满足以下两个条件的节点\(u\)加入到\(V_{extension}\):

  1. \(u\)的节点id要大于v的id;
  2. \(u\)只能是新加入加点\(w\)的邻居,而不能是\(V_{subgraph}\)里的节点邻居;

伪代码逻辑如下:

如下图就很容易理解了, 从不同的点出发, 比如node 1, node 1的邻居只有3, 所以开始extendsion为3, node2, 其邻居只有3, 所以extension为3, node 3, 其邻居有1、2、4、5,为保证id要大于node 3, 所以extension为4、5, node 4, 邻居有3、5, 保证id大于4所以extension为5, 第二层将exetension加入到subgraph, 且此时exetension要是新加入节点,如最左边分支,新加入节点为3,exetension要讲node 3的邻居加入,且保证大于node1 , 即exetension变为2,4,5;左起第二个, 将exetension 中node 3加入, node3的邻居节点有1、2、4、5, 其中只有4、5大于原先subgraph中的2, 所有exetension为4、5, 以此类推即可,最终所有大小为3的子图即可遍历出来;

到目前为止, 我们就可以遍历出所有大小为的子图, 接下来我们只需要统计下这些图即可, 如下图所示, 需要判断是否为同构图,即拓扑结构完全一致:

Graph Isomorphism

如何判定两个图是同构?
如果图\(G\)和\(H\)是同构的,那么必定存在一个双向映射\(f: V_{(G}->H_{(H)}\)保证任意两个节点u和v在图G里面是相邻的,则\(f ( u )\) 和\(f ( v )\)在图H里也是相邻的, 检查图是否重构是一个NP难题

Structural roles in networks

Role: 角色, 是对节点在网络中功能的描述, 是有相同结构特征的点,相同角色的节点并不一定直接相连,而Group/Communities(社群), 是彼此相互密集连接的节点群;

视频中举了个例子,假定一个计算机系构建一个社交网络,其中:

  • 角色指: 教职、职员、学生;
  • 社群指: AILab、Info Lab、 Theory Lab等;

如果节点u和节点v和所有其他节点有相同的关系,则说明节点u和节点v在结构上等同, 如下图中u和v完全相同;

Discovering Structural Roles in Networks

为什么要研究图当中的role ?如下图:

RoIX: AutoMatic Discovery of nodes' structural roles in network

RoIX特点如下:

  • 非监督学习方法;
  • 无需先验知识;
  • 支持多种角色分类;
  • 按边数线性扩展;

RoIX过程如下图, 其中最重要的Recursive Feature Extraction.

Recursive Feature Extraction是基于图的结构详细,从某一节点出发,聚合该节点的特征,如有向图中,该特征未出度、入度、度等等,其次基于该node的邻居、包含该节点的可导出子图,这称之为Egonet,也会提取Egonet中节点的特征。以此类推,用这种方法提取到的特征,是指数级增长,后续会使用裁剪技术将部分特征裁剪掉;

最终,每一个节点会由如下图的向量表示, 然后采用non negative matrix factorization(KL离散度距离来评估似然度) 即可完成流程图中node * role matrix与role * feature matrix的生成:

2020/10/03 posted in  图计算

Properties of Networks and Random Graph Model

如何描述一个网络

Degree Distribution

P(k): 随机选择的节点, 度为k的的概率分布, 使用直方图来描述


其中\(N_k\)表示度为k的节点数, 比如上图中,度为1的节点数有6, 所有节点数为10, 所以\(P(6)=0.6\)

Path Length

Path: path是指每个节点连接下一个节点的序列,其中,一个path能够重复多次相同的边, 如下图: ACBDCDEG

Distance: 连接节点对最少数量的边,称为两个节点间的distance,如下图,其中\(h_{B,D}=2, h_{A,X}=\infty\), 若图中两节点无连接,或中间连接断开,则distance为无穷,在有向图中,distance的计算应该考虑两个节点间的方向,如下图\(h_{B,C}=1, h{C,B}=2\),不是对称的:

Diameter在graph中,所有节点对当中最长distance;
Average path length针对graph来说, average path length计算公式如下:

\[h_{average}=\frac{\sum_{i,j!=i h_{ij}}}{2E_{max}} \]

其中\(h_{ij}\)是node i到node j的distance, \(E_{max}\)是指图最多可存在的边数:\(\frac{n(n-1)}{2}\)

Cluster coefficient

cluster coefficient 对于无向图,用来描述节点i与他的邻居的链接情况, 其中节点i的度为\(k_{i}\),clustering coefficient计算公式如下:

\[C_{i}=\frac{2e_{i}}{k_{i}(k_{i}-1)} \]

如下图, 图的node i的cluster coefficient计算如下:\(C_{i}=\frac{2*6}{4*(4-1)}=1, C_{i}=\frac{(2*3)}{4*(4-1)}=0.5, C_{i}=\frac{2*0}{4*(4-1)}=0\)
Average clustering coefficient: \(C=\frac{1}{N}\sum_{i}^{N}C_{i}\)

\(K_{A}=1, e_{A}=0, C_{A}=0\)
\(k_{B}=2, e_{B}=1, C_{B}=1\)
\(k_{C}=4, e_{C}=2, C_{C}=1/3\)
\(k_{D}=4, e_{D}=2, C_{D}=1/3\)
\(k_{E}=3, e_{E}=0, C_{E}=0\)
\(k_{F}=1, e_{F}=0, C_{F}=0\)
\(k_{G}=1, e_{G}=0, C_{G}=0\)
\(k_{H}=2, e_{H}=1, C_{H}=1\)
avg. clustering: C= (1+1/3+1/3+1)/8=1/3

Connected components

Connectivity
图当中最大的可连接的component:能够通过path链接的任意两个几点的最大的集合;
如何找到图当中的connect components,从图中随机节点开始,按广度优先策略遍历,标记遍历过的节点,如果,所有的节点均被遍历,那么这个未connected component, 否则从未遍历的节点中随机开始,重复广度优先策略遍历;

描述实际中的图:MSN Messenger

msn一个月的相关的数据,如下:

Degree Distribution


x坐标log之后:

可见大部分的节点degress在个位数。

Clustering

将所有的节点的k与c绘制在如下图中,整个graph的avg culstering coefficient约为0.1140

Connected Components

Diameter

msn的graph中平均path length为6.6, 90% 的节点能够触及在8个链接后触及到另一节点;

图的核心属性如何使用?

这些graph的属性是意外的还是在我们本身预料之中?

PPI Network

Random Graph Model

Simplest Model of Graph

ER Random Graphs
两个变种:

  1. \(G_{np}\): n个节点的无向图,其中每一条边是概率为p的独立同分布;
  2. \(G_{nm}\): n个节点的无向图,其中m个边均匀随机生成;

需要说明的是,n, p 无法唯一地的决定graph,如下图,相同的n,p下, 我们有不同的图:

Degree Distribution of \(G_{np}\)

假定\(P{(k)}\)表示度为k在所有节点中的占比, 则

\[P_{(k)}= C_{n-1}^{k} p^{k}(1-p)^{n-1-k} \]

很明显的binomial distribution, 所以均值、方差为:

\[k_mean = p(n-1) \] \[\sigma^2 = p(1-p)(n-1) \]

标准差率为:\(\frac{\sigma}{k_mean} \approx \frac{1}{(n-1)^{0.5}}\),当图无限大的时候,则标准差为0, 所有的节点都为\(k_mean\)。

Clustering Coefficient of \(G_{np}\)

已知\(C_{i}=\frac{2e_{i}}{k_{i}(k_{i}-1)}\), 在\(G_{np}\),边为概率为p的独立同步分, 其中\(E[e_{i}] = p\frac{k_{i}(k_{i}-1)}{2}\), 故\(E[C_i] = \frac{pk_i(k_i - 1)}{k_i(k_i -1)} = p = \frac{k_{avg}}{n-1} \approx \frac{k_{avg}}{n}\)

Expansion

定义\(\alpha\): 如果一个graph的任意的子集S,子集中边的条数大于alpha乘以子集或者graph去除子集之后的节点数量, Expansion通常用来衡量图的lu'bang'xing:

\[\alpha = \mathop{\min}_{S \subseteqq}{\frac{\# edges\ leaving\ S} {min(|s|, |V \ S|)}} \]


这张ppt没理解清楚,

在\(G_{np}\)中,n*p为常数,所以avg deg k也为常数:

Connected Components

\(G_{np},其中n = 100000, k=p(n-1)=0.5...3\),Largest CC中节点占图中所有节点的比例

Random Graph Model vs. MSN

在Random Graph Model 和实际的MNS的4个核心属性对比:

真实网络和Random Graph类似吗 ?

  • Giant Connected component: yes
  • Average path length: yes
  • Clustering Coefficient: No
  • Degree Distribution: No

The Small-World Model--能同时保证high clustering且短path的图吗?

回顾下前面MSN network,clustering coef为0.11, 而\(G_{np}的clustering coef为8*10^{-8}\)。
另外一个例子, IMDB数据集、Electrical power grid, Network of nerons中:

其中h:average shortest path length, C: avg clustering coefficient, random,是保证相同avg degree,相同节点下的图的情况。

下图左边:高clustering coefficient: 朋友的朋友是我的朋友;

Small-World同时保证high cluster and low diameter;
如下图,从high clustering/high diameter, 到low clustering/low diameter, 增加随机性(p变大): 即随机的将一条边的另一个端点连接到任意较远的节点上,这样可以保持high clustering,low diameter;

下图中的p区域保证保证high clustering 和low path length:

Kronecker Graph Model: Generating large realistic graphs

递归的graph的生成: Self-similarity

Kronecker Produce是一种生成self-similar矩阵的方法:

Kronecker Product 定义如下:

举个例子:

  • 构建一个\(N_1 * N_1\)的初始概率矩阵;
  • 计算k阶Kronecker 矩阵;
  • 遍历k阶矩阵,按\(p_{uv}\)构建edge(u, v)链接

    如上图最后, 需要模拟\(n^2\)次,耗时太高, 是否有更高效方法(利用其递归结构)?

真实网络与Kronecker网络很相似, 右上角为其初始矩阵:

2020/10/02 posted in  图计算

[toc]

技术调研报告

需求场景

云音乐在边缘计算上的需求较为丰富,主要集中在两个点上:

  1. 边缘数据处理;
  2. 边缘模型推理;

前者主要目的是缓解基础数据处理在服务端压力, 并且在越来越严的监管压力下,进行某些数据的脱敏处理之后上传,如某些poi,在边缘端在客户端完成提取之后,脱敏上传,扫描更多用户信息,提取相关标签;
边缘模型推理,主要是指将模型部署下发到移动侧,完成推理,如检测直播中是否有人脸出现、评论是否涉黄涉恐等;
本报告接下来分为三个部分来描述业界类似的场景需求,以及相关的技术方案;

边缘数据处理###

业界需求

边缘数据处理

场景一

工业设定的边缘环境(例如海上石油勘探平台)往往缺乏
充足的计算,存储和网络资源有限,且设备生成数据量极大,全部传输不仅占用极大资源,且意义不大,所以业务开发边缘分析程序来进行关键数据的分析、脱敏等操作,之后将处理完成的数据传输至服务器端进行进一步挖掘;

场景二

自动驾驶侧,由于驾驶本身对反馈数据的时延要求极高,将数据传回网络侧进行计算处理,再反馈相应处理逻辑整体耗时随着网络的不稳定而变化极大,在边缘侧计算关键数据的计算,包括模型推理,进行及时的决策反馈;

场景三

欧盟GDPR法规,限制欧盟之外的任何公司, 针对于不符合在隐私数据上规范的公司处以最高罚款为其全球收入的4%或2000万欧,两者取高者。政策越来越严的规范, 数据不离开用户设备必然会成为后续的趋势,这对于推荐、搜索、广告等核心互联网业务会造成毁灭性的打击,边缘侧进行必要的数据处理,传回有价值的、脱敏、符合政策法规的数据,

####云音乐场景####

需求一: 辅助埋点完成脏、乱数据治理,数据生产侧完成数据质量保障

和团队@董有现讨论,目前埋点逻辑由相关产品收集, 反馈至客户端,客户端完成相关功能开发后,由数据团队进行数据质量检查,完成检查之后上线,数据埋点日志落存储,大数据同学再进行相关功能的开发,数据同学反馈会存在很多异常情况(本身数据质量:如数值为空情况、数据明显异常如播放点负数、), 是否能将脏、乱数据治理,直接落地在边缘侧处理, 脏、乱数据治理,埋点同学最熟悉,可以在此完成逻辑的闭环,如下图展示:
埋点改造

需求二:边缘侧进行数据聚合, 生成更详尽指标
单个指标通常应用到算法,会经过一些基本的聚合处理,比如按时间构造多尺度(天级别、周级别)播放时间、按多source聚合指标,此类指标仅依赖用户本身行为,可选择在边缘侧进行数据聚合,如下图:可以选择边缘侧进行时间聚合、多source聚合,在边缘侧可进行数据异常处理、这部分工作如何在大数据上去做,其实相对还是比较复杂的,因为埋点逻辑不可避免地会落地异常数据,其实在大数据侧很难完全处理掉这部分数据,因而不仅仅是效率问题,边缘侧的数据聚合能够有效地将客户端处理逻辑闭合,提供更高智能、更易用的数据,另一方面,在大数据环境下进行相关数据的聚合,整体资源消耗较大;
边缘数据处理-详尽数据

需求三:数据敏感,减少法律法规风险
目前国内外隐私相关法律法规如GDPR针对Google、Facebook天价罚款,《中华人民共和国民法典》对隐私权与个人信息保护越来越严,互联网公司尤其是移动侧数据采集,必然越来越严格。对整体数据应用会有极大地影响。移动侧后续在数据采集上,可能需要考虑关键数据的脱敏,同时保证尽可能少地影响算法效果;

###边缘模型推理###
####为什么云音乐需求边缘模型推理####

  1. 脱机可用性:这可能是最明显的论点。如果无论条件和连接性如何都需要应用程序可用,则必须将智能放置在本地设备中。由于远程蜂窝数据不稳定,DDoS攻击后服务中断或仅仅是因为你的设备正在地下室中使用,会导致连接中断!对于基于云的解决方案来说,这是一个巨大的挑战。但是,如果将智能放在本地设备上,则无需担心;
  2. 降低云服务成本:云服务非常方便(可扩展性,可用性),但是却代表着相当安规成本,随着越来越多的人使用解决方案,这种成本将会增加,尤其是AI类的推理应用, 这些成本将持续到产品的整个生命周期, 而边缘侧进行模型推理,利用了用户本身的算力来完成相关模型的推理计算,大大降低在类似场景下的成本付出;
  3. 降低连接成本:边缘侧完成模型就散,仅发送AI的计算结果,就地处理信息可以将带宽消耗除以100倍(对于视频则更多),尤其是对于视频类应用,通常计算集群和存储集群是分开的,完成相关的推理计算,需要频繁地拷贝,若在边缘侧完成模型的推理计算,需要传输的数据将从兆字节的视频将转换为几个字节,比如主播人脸是否存在,而不是将视频数据拷贝至相应地计算集群,完成模型推理,仅仅需要边缘侧完成计算即可;
  4. 处理机密信息:当可以在本地收集和处理关键信息时,无需将数据传至数据中心处理,但是依然能完成相关地逻辑需求,如通过判断直播场景下是否有人,而非将直播关键的数据实时传输到计算集群上,来进行相关计算,这个成本是极其昂贵的;
  5. 响应时间至关重要:在本地收集和处理数据很会缩短响应时间,从而改善用户体验,目前的手机侧通常拥有比较不错的算力,能应付一些模型的推理计算;
  6. 环保:中小型物联网设备每天将发送1MB或更少的数据,大约可以每天估算20g的二氧化碳,经过一年的计算,10,000台设备可产生多达73吨的二氧化碳!在本地进行处理可以将其缩小到730kg, 数据中心电力的消耗也极大,尤其是在进行密集型计算时,单个gpu设备可能达到250w的工作效率,而基于视频或图像的解决方案可能会产生更大的影响,未来在数据搜集上都可能缴税的政策锋线上,碳排放、电力消耗的缴税风险可能更大,其成本也不容忽视;
  7. 硬件优化到一定程度;

可行性分析

###边缘数据中心与数据处理:进一步贴近接入层###

学术界与行业相关工作
前两年在学术界有相关的文章,如Secure and Sustainable Load Balancing of
Edge Datacenters in Fog Computing
,提出Edge Data Center的概念;Potentials, Trends, and Prospects in Edge Technologies:
Fog, Cloudlet, Mobile Edge, and Micro Data Centers
也提到边缘侧数据中心,更近地贴近边缘侧完成部分数据的存储、计算,来提供移动侧内计算能力,减少传统数据中心的数据计算压力,提供更稳定时延服务;
业界包括华为、https://www.siemon.com/zh/home/applications/edge在5g上的布局也包括边缘侧完成部署计算,包括组件边缘数据中心,详情见[面向5g的边缘数据中心基础设施](https://e.huawei.com/cn/material/networkenergy/e7940fd56def4524aa1d0e4a8f835f99)、[边缘数据中心](https://www.siemon.com/zh/home/applications/edge)。
边缘数据中心,其目的在于减少边缘侧数据存储计算的时延,尤其是在占大量带宽的数据计算、视频分析等关键AI应用上,相关行业目前技术尤其是数据存储、计算,确实有向边缘侧靠的趋势。

云音乐实际情况
而针对云音乐目前的痛点,尤其是在数据处理时,集群压力极大,目前整体,移动端进行必要的部分数据存储以及部分指标的计算,是比较合理的

  1. 目前,边缘侧数据处理仅基于用户尺度做较为简单地数据聚合、数据加密、脱敏操作,其计算复杂性不高,理论上可以适合计算;
  2. 边缘侧完成的更高级指标的计算,能够被大数据工作流直接使用,比原先大数据侧进行数据去脏、去错再进行相关计算,理论上精确度更高;
  3. 边缘侧完成相关数据的计算前提在于其对数据质量、数据内容有极高的保障,并且有相应地去脏除错逻辑,在此背景下,更易于大数据埋点需求做到解耦合;
  4. 边缘侧目前cpu、gpu性能较为强劲,边缘侧已经能支持较为复杂的矩阵线性计算,如大kernel cnn,理论上简单地数据处理逻辑理论上不在话下;

###边缘模型推理###

标准化的开发流程
边缘模型推理目前是各大互联网公司着力落地的点,需求比较明显,这里就不详述,主要是将部分模型的推理部署在移动侧,减少由于网络传输带来的安全风险以及时延问题,目前整体的技术方案如下图大概分成以下几个部分,下面会详细描述:

边缘模型推理流程

  1. 离线特征处理:主要目的是生产训练样本,如评论是否涉黄、涉恐分类模型,需要收集评论数据,并标准为相关类别;
  2. 离线模型训练:使用包括TensorFlow、Paddle、Pytorch等在内的框架来训练相关离线模型;
  3. 离线模型评估:将训练好的模型在指定的验证集上进行模型评估,得到离线评估指标;
  4. 模型转换:将训练出来的模型转换为移动侧支持的模型,并附上指定SDK Demo;
  5. 模型验证:在指定SDK上验证转换生成的模型文件,开发对应的数据处理逻辑,完成模型功能、以及时延验证;
  6. 模型下发:验证完成之后,通过专有平台将模型批量下发给客户端;
  7. 模型更新:下发完成后,进行模型文件更新, 将新版本的模型文件以及对应逻辑的包更新;

####离线特征处理####
主要负责生产训练样本,包括特征的基本处理以及样本的标准,如在直播场景下,检测视频当中是否出现主播人脸,人脸检测在直播场景,需要提前标注一定量级的的人脸标注照片, 下图中红框、红点为关键点检测标注信息:

完成标注之后,通常我们会将标注数据处理成适合某些框架训练的数据格式,如TensorFlow下tfrecord。

####离线模型训练####

离线魔性训练,通常我们会用TensorFlow或者Pytorch构造合适的神经网络结构,如针对人脸检测比较出名的MTCNN,MTCNN网络复杂性设计的比较好, 可以在中端手机上部署,完成20~30FPS的检测速率:

####离线模型评估####
离线模型训练之后,我们会在一个合适的数据集上进行模型评估,待数据集上指标满足要求之后,再开始考虑上线,离线评估数据集通常是业务落地时面对的真实数据集,在这个数据上的评估指标,最接近真实数据,通常在满足指标要求,都会经过多轮的迭代,通过data augmentation等数据增广,或者直接补齐某些效果不好场景下的训练数据,来逐步提升模型在各类场景下的鲁棒性;

####模型转换####

模型转换目的是为了将适合服务端计算的模型文件转换为专门针对移动端进行算子优化的框架,如paddle lite提供将其他框架如TensorFlow、Caffe等转换为paddlepaddle的原生工具X2Paddle, 且在转换过程中支持包括量化、子图融合、Kernel优选的优化手段,优化之后的模型更轻量级、耗费资源更少、更适合在移动侧部署,目前比较流行的移动侧框架,网商的某个性能对比:

为了防止对业务造成影响,一般都是单线程使用,且关闭openmp防止帧间波动影响体验,整体结论:

paddle-lite>mnn>ncnn>tflite

另外的如apple本身的Core ML,因为仅支持ios。

####模型验证####

模型验证主要是进行移动端编译的验证,通过这个过程,框架侧会提供一个最简化的演示的sdk,支持build成移动侧的app,然后通过比如adb在手机侧进行推理,评估资源消耗、耗电以及模型推理耗时。
比如paddle lite的一个demo: https://github.com/PaddlePaddle/Paddle-Lite-Demo/tree/master/PaddleLite-android-demo/face_detection_demo

####模型框架集成####

模型验证完成之后,集成到客户端app中,这里需要考虑引入新的模型框架带来的app包体积增大以及app耗时增加的问题,如目前paddle-lite 目前整体包ARMV7只有800K,ARMV8下为1.3M

####模型/框架热更新####

模型部署成功之后,后续模型框架、模型文件更新是比较频繁的,不可能每次发版本来更新客户端的离线模型,主要包括两个部分:

  1. 模型文件的更新:模型推理框架无需更新,仅更新模型文件;
  2. 模型推理包的更新:需要更新推理框架以及模型文件;

挑战

###边缘数据处理###

  1. 相关开源项目比较少, 且活跃度不高:
    目前边缘数据处理开源的不多,并且活跃度不大,如下面的apacheEdgent,边缘计算相关的开源框架和产品主要集中在如何组装,采用类似于K8S编排设备的技术来完成边缘设备的编排,主要是针对有限网络下快速的组网、设备管理等工作,做数据处理的封装目前没看到太多的解决方案:

    1. apacheEdgent: https://edgent.incubator.apache.org/
    2. Akraino Edge Stack:https://www.lfedge.org/category/akraino-edge-stack/
    3. AWS IoT Greengrass: https://aws.amazon.com/cn/greengrass/
    4. Azure IoT Edge: https://github.com/Azure/iotedge
    5. baetyl: https://github.com/baetyl/baetyl
    6. Macchina.io: https://macchina.io/

    目前,在云音乐的需求中,边缘的主要针对于做用户维度的、时间维度的聚合操作,这里需要一些专门的算子,如map、reduce,用于专项处理埋点数据中的转换、聚合操作,这里需要联合客户端同学,可先完成相关功能的封装,建议开始按功能如按天聚合播放时间等case by case 开发,后续抽象出来,由数据组提供相应sdk;

  2. 脱敏加密数据上传涉及到的隐私保护计算,目前整体积累较差,相关的技术水平要求高:

    1. 如手机号、身份证这类数据,可在端侧进行脱敏处理,类似手机号、身份证无论在算法还是数据处理上,其实仅充当统一性表示ID, 进行脱敏处理不影响其落地应用,但是否有涉及到比如推送号码包类似应用需要,如有需要,在此类场景下需提供专门解码方法完成加密数据的解码;
    2. 如有数据保存至公有平台进行数据分析时,可采用同态加密技术,同态加密技术旨在分析可以在不拿到明文数据的前提下,进行数据分析等相关操作;
  3. 目前无法很好地评估,增加边缘侧数据处理带来的性能问题,如电量耗损、cpu占用过多影响体验、磁盘读写占用等等问题; // 使用闲时处理、闲时上传;

  4. 边缘侧数据处理和原先类似埋点开发过程中,需要保持一致性的地方,这一块如何保证;

  5. 原始埋点数据上,增加业务场景线上,两条路走;

###边缘模型推理###

边缘模型推理目前挑战点主要在于:

  1. 目前虽然移动侧模型推理较为流行,但是并不是所有的训练框架算子均支持移动侧部署,或已在移动侧深度优化,边缘侧部署需要和模型训练侧深度合作;
  2. 边缘模型将部分模型的app打包到本地,模型以及相关包均在用户侧,这其中可能涉及到模型的相关风险,在一些核心应用,例如身份验证这类场景下,涉及到相关的安防技术,如目前通过deepfake技术使一些人脸识别系统出错, 如何安全的访问模型请求也是现有的挑战;
  3. 模型定时更新涉及到大规模的模型下发,尤其在云音乐这种规模下的模型下发,这块我们不太熟悉,无法评估风险;
  4. 模型推理下放到边缘端计算,必然在资源上消耗较多,如果减少资源负载也是必须要要考虑的问题;
2020/09/18 posted in  mlops

推荐系统的禅与道

前言

先说说我的简历,毕业刚出来做推荐,在1号店,后来觉得做业务没意思,就各个公司浪了一圈,目前在做Machine Learning Infrastructure。 最近也在和好朋友聊过很多recsys相关的思考, 整理了下,分享给大家

推荐系统之禅

游江海、涉山川,寻师访道为参禅;自从认得曹溪路,了知生死不相关。行亦禅、坐亦禅,语默动静体安然;纵遇锋刀常坦坦,假饶毒药也闲闲。    --\《证道歌\》 永嘉玄觉禅师

修禅即体悟当下, 从个体的角度去感悟,从一个人的角度去了解推荐系统要做啥?

简单地百度下, 都不用Google,就有很多的答案。 其实提炼下没那么多的东西,无非就以下几个工作内容:

  1. 数据收集:理解业务,所有推荐系统的落地都离不开对业务的理解,业务的理解其实就是对数据的理解, 要搭建一个比较合适的推荐系统, 数据收集是第一步也是最基础的;
  2. ETL:Extract、Transform、Load, 工业级推荐系统尤其是移动互联网普及之后, 数据是海量的,不是所有的数据都是有用的, 如何从繁杂的数据中提取到有效的数据;
  3. 模型训练: 利用得到的训练样本,构建模型,得到符合指标的模型,也可能根本没有所谓的模型,可能是最简单的规则, 如仅仅是按热度的排行榜,也算是一种推荐系统;
  4. 评估:评估包括离线的评估与在线的评估, 评估即是如何说明你的工作有效;
  5. 上线:当评估阶段满足预期之后, 模型上线才开始,才真正地开始引入流量;
  6. 持续效果改进:上线之后, 一定是可能会不符合预期的,如果快速理解线上的bad case, 并针对性地解决;

数据收集

数据收集是一个没有信息熵的词, 在互联网中,数据收集这个词你去问不同的同学, 会得到不同的答案, 对于算法同学,数据收集是啥样的呢?

找对人
一定要找到数据的对接人, 公司大了, 数据的产生、使用都有一定的标准流程,流程越长,越难理解数据表中指标的意义,一定要找到合适的人, 他不仅能告诉你数据的意义,更多的时候可能会告诉你还有哪些有意义的数据, 无论做什么工作一定要是很靠谱的合作方,尤其是在这个流程你可能不太明白的情况下, 在我们公司, 数仓开发,是最复杂的功能,他们对接几乎所有的算法团队的数据需求,他们可能是公司中最了解数据的同学,但是他们中的大多数同学因为本身工作的特殊性,有的时候是小伙伴口中自嘲的”sql boy“, 他们很难去理解算法同学对数据真实的一些需求;因而,简单地沟通,其实很多因为彼此知识的不对等,造成提供的和需求的不一致,而且这种比想象的要多的多,和你的业务数据提供方交朋友,可能是最无需成本的沟通;

找哪样的数据
作为需求方, 算法同学一定要知道去拿哪些数据,推荐是一个向人推荐物的过程,用户的特征是首要的,完整的用户画像是极为重要的,了解你的用户是什么样的人,有什么样的属性,喜欢什么,什么时候最活跃等等;其次,了解你的物料,如果是电商,那就是你的商品,你的物料数据在哪儿? 这些数据有哪些维度?是怎样组织的,你需要怎么去做处理?我相信正常的团队,一定有专门作用户画像与物料特征的同学,去和这样的团队沟通,了解他们做了什么,如何去使用他们的数据,甚至是否合作,你也可以去做一些数据挖掘的活,去扩展他们团队没有考虑到的特征维度。

ETL

特征工程

模型训练

评估

上线

推荐系统之道

道即万物运行的轨迹,虽然每个个体都不同

2020/07/19 posted in  推荐系统

机器学习与容器化平台

愿景

算法同学爽、工程任务开心、技术快速复用,干完战,早点下班。总结了一下具体包括以下几个方面:

开发流程标准化

针对开发者,尤其是新人能够快速进入到开发工作中来, 前提是需要一套比较完善、合理的开发流程,将可能的开发工作流程集中在平台当中,在此基础上完成开发流程的标准化管控, 和军队培训战士一样, 我们从广大人民群众中,选择了最优秀的那一批同学,来到我们的团队,从内务教令、文化教育、长途越野跑、基础实战能力,到最后的兵王,一定是有一套特别完善的标准化流程,Goblin就想做这样的"军队"试验场,将人才的培养、项目开发上线,流程化、标准化,赋能给技术团队,让"新兵"得到最好的实践锻炼,"兵王"打好战;

能力接受与放大器

在大数据、算法团队中,其实很难评估开发人员本身的工作,最近在看一本书, 其中提到针对一个推荐系统产品,有4个关键元素需要注意: 1. UI和UE;2.数据;3.领域知识;4.算法,其权重是1>2>3>4,1和3是"颜值即正义"、"老天赏饭吃", 2、4是我们开发者需要关注的,而对数据、算法,最难的其实是目标的定制,如何评估一个数据任务、大数据产品、算法模型有价值,需求方说好就一定好?指标升了就一定好?这个不一定,而这也是数据相关从业人员很有挑战的方面,而作为内部平台,算法、工程人员前方打战,我们要做的是保证好后勤,提供(大数据能力)粮草、(模型开发环境)弹药,关键时刻还得赤膊上前一起干(通用模型能力);

技术可复用

小团队的技术有高有低,专业能力各有不同,数据积累也各有千秋, 如何集合各团队优势,将完成优势互补, 尤其是在互联网日新月异的场景需求下,能快速将以前产品技术复用到新场景上,是一项很关键的能力,支持创新需求的快速落地,是如Goblin这样内部平台的初衷,很多人管这叫中台, 但目前,我们觉得这个词太大,完成技术复用已经是我们到现在以及短期内比较宏远的目标,达到中台那样的恢弘,任重而道远。

算法与工程集散地

实践中,从工作来看,算法工程师和支持算法的工程师技术路线gap太大,大到可能达不到相互理解、相互信任,必须要有一个平台能够弥补这其中的gap,幸运地是,在我们实践中,这一类模式可能是很多花样,但是其内核是稳定不变的,模块化、标准化完成这些其实并不是复杂的工作;

基础知识介绍

到这里,说了很多非技术的事情,开始要写一些技术相关的了,毕竟这是篇正经的技术文章,这里我分享三个Goblin中使用的比较多的技术工作:容器化技术、Kubernetes、分布式存储;

容器化技术

概述

首先,我们来聊下容器化能解决什么问题?

软件开发环境问题
软件开发最复杂的就是开发环境的配置,无论是Python、Scala、C++还是其他的任何语言,在开发之前,需要准备各种运行环境、IDE、辅助工具,而在一个软件的交付上,开发和维护需要保证一摸一样的环境,否则就会经常出现"在我机器上是可以的, 你去xxxxxx";

软件架构越来越复杂
软件到现在越来越复杂,就以手机操作系统而言,不仅包括常用的工具APP,云端应用,还有AI功能的服务,越来越复杂的功能造成了多种技术架构必然是模块解耦、多样的技术栈、动态构建资源;

统一管理
所有功能、架构都需要统一的管理,才能有效地管控这些小恶魔,以至于不出乱子;

容器化技术能够很有效地解决上面问题的, 对于开发者来说, 容器是一个黑盒:

  • 你不需要关心容器怎么构建,你只需要知道有何功能;
  • 有易用的工具来对容器进行管理与编排;
  • 部署模块到容器,集装箱式组合;
  • 环境通过文件生成,可简单复用;

容器简史

参考文章: http://www.dockone.io/article/8832,描述的特别好

kubernetes

概述

Kubernetes是一个开源的,用于管理云平台中多个主机上的容器化的应用,Kubernetes的目标是让部署容器化的应用简单并且高效,Kubernetes提供了应用部署,规划,更新,维护的一种机制。在Google内部,容器技术已经应用了很多年,Borg系统运行管理着成千上万的容器应用,在它的支持下,无论是谷歌搜索、Gmail还是谷歌地图,可以轻而易举地从庞大的数据中心中获取技术资源来支撑服务运行。Borg提供了3大好处:

  1. 隐藏资源管理和错误处理,用户仅需要关注应用的开发。
  2. 服务高可用、高可靠。
  3. 可将负载运行在由成千上万的机器联合而成的集群中。

而作为Borg的开源版本, Kubernetes对计算资源进行了更高层次的抽象,通过将容器进行细致的组合,将最终的应用服务交给用户。Kubernetes在模型建立之初就考虑了容器跨机连接的要求,支持多种网络解决方案,同时在Service层次构建集群范围的SDN网络。其目的是将服务发现和负载均衡放置到容器可达的范围,这种透明的方式便利了各个服务间的通信,并为微服务架构的实践提供了平台基础。而在Pod层次上,作为Kubernetes可操作的最小对象,其特征更是对微服务架构的原生支持。

架构

节点

在这张系统架构图中,我们把服务分为运行在工作节点上的服务和组成集群级别控制板的服务。Kubernetes节点有运行应用容器必备的服务,而这些都是受Master的控制。每次个节点上当然都要运行Docker。Docker来负责所有具体的映像下载和容器运行。

Kubernetes主要由以下几个核心组件组成:

  • etcd保存了整个集群的状态;
  • apiserver提供了资源操作的唯一入口,并提供认证、授权、访问控制、API注册和发现等机制;
  • controller manager负责维护集群的状态,比如故障检测、自动扩展、滚动更新等;
  • scheduler负责资源的调度,按照预定的调度策略将Pod调度到相应的机器上;
  • kubelet负责维护容器的生命周期,同时也负责Volume(CVI)和网络(CNI)的管理;
  • Container runtime负责镜像管理以及Pod和容器的真正运行(CRI);
  • kube-proxy负责为Service提供cluster内部的服务发现和负载均衡;

除了核心组件,还有一些推荐的Add-ons:

  • kube-dns负责为整个集群提供DNS服务
  • Ingress Controller为服务提供外网入口
  • Heapster提供资源监控
  • Dashboard提供GUI
  • Federation提供跨可用区的集群
  • Fluentd-elasticsearch提供集群日志采集、存储与查询

分层架构
Kubernetes设计理念和功能其实就是一个类似Linux的分层架构,如下图所示:

  • 核心层:Kubernetes最核心的功能,对外提供API构建高层的应用,对内提供插件式应用执行环境
  • 应用层:部署(无状态应用、有状态应用、批处理任务、集群应用等)和路由(服务发现、DNS解析等)
  • 管理层:系统度量(如基础设施、容器和网络的度量),自动化(如自动扩展、动态Provision等)以及策略管理(RBAC、Quota、PSP、NetworkPolicy等)
  • 接口层:kubectl命令行工具、客户端SDK以及集群联邦
  • 生态系统:在接口层之上的庞大容器集群管理调度的生态系统,可以划分为两个范畴
    • Kubernetes外部:日志、监控、配置管理、CI、CD、Workflow、FaaS、OTS应用、ChatOps等
    • Kubernetes内部:CRI、CNI、CVI、镜像仓库、Cloud Provider、集群自身的配置和管理等

分布式存储

Kubernetes 特别高效地管理多个主机上的容器化应用的,但是在此之前,尤其在数据科学开发场景下,如何共享数据是一个特别严峻的话题,这里我们专门拿出来提下Ceph这一分布式文件系统,简而意之,提供类似于NAS能力,提供多个容器能同时访问的能力。

概述

Ceph 可以简单地定义为:

  • 可轻松扩展到数 PB 容量;
  • 高性能文件存储、访问能力;
  • 高可靠性;

架构

Ceph生态系统可以大致划分为四部分:客户端(数据用户)、元数据服务器(缓存和同步分布式元数据)、对象存储集群(将数据和元数据作为对象存储,执行其他关键职能)、集群监视器(执行监视功能)。

一个标准的流程:

  1. 客户使用元数据服务器,执行元数据操作(来确定数据位置)。
  2. 元数据服务器管理数据位置,以及在何处存储新数据。值得注意的是,元数据存储在一个存储集群(标为 “元数据 I/O”)。实际的文件 I/O 发生在客户和对象存储集群之间。这样一来,
  3. 打开、关闭、重命名由元数据服务器管理,读和写操作则直接由对象存储集群管理。
  4. 集群监控用于监控这一流程,监控元数据服务器、对象存储的文件IO等等;

组件

Ceph 客户端
Ceph 文件系统 — 或者至少是客户端接口 — 在 Linux 内核中实现。值得注意的是,在大多数文件系统中,所有的控制和智能在内核的文件系统源本身中执行。但是,在 Ceph 中,文件系统的智能分布在节点上,这简化了客户端接口,并为 Ceph 提供了大规模(甚至动态)扩展能力。

Ceph 元数据服务器
元数据服务器(cmds)的工作就是管理文件系统的名称空间。虽然元数据和数据两者都存储在对象存储集群,但两者分别管理,支持可扩展性。事实上,元数据在一个元数据服务器集群上被进一步拆分,元数据服务器能够自适应地复制和分配名称空间,避免出现热点。元数据服务器管理名称空间部分,可以(为冗余和性能)进行重叠。元数据服务器到名称空间的映射在 Ceph 中使用动态子树逻辑分区执行,它允许 Ceph 对变化的工作负载进行调整(在元数据服务器之间迁移名称空间)同时保留性能的位置。

Ceph 监视器
Ceph 包含实施集群映射管理的监视器,但是故障管理的一些要素是在对象存储本身中执行的。当对象存储设备发生故障或者新设备添加时,监视器就检测和维护一个有效的集群映射。这个功能按一种分布的方式执行,这种方式中映射升级可以和当前的流量通信。

Ceph 对象存储
传统的驱动是只响应来自启动者的命令的简单目标。但是对象存储设备是智能设备,它能作为目标和启动者,支持与其他对象存储设备的通信和合作。从存储角度来看,Ceph 对象存储设备执行从对象到块的映射(在客户端的文件系统层中常常执行的任务)。这个动作允许本地实体以最佳方式决定怎样存储一个对象。Ceph 的早期版本在一个名为 EBOFS 的本地存储器上实现一个自定义低级文件系统。这个系统实现一个到底层存储的非标准接口,这个底层存储已针对对象语义和其他特性(例如对磁盘提交的异步通知)调优。今天,B-tree 文件系统(BTRFS)可以被用于存储节点,它已经实现了部分必要功能(例如嵌入式完整性)。

ceph On K8s

PVC 的全称是:PersistentVolumeClaim(持久化卷声明),PVC 是用户存储的一种声明,PVC 和 Pod 比较类似,Pod 消耗的是节点,PVC 消耗的是 PV 资源,Pod 可以请求 CPU 和内存,而 PVC 可以请求特定的存储空间和访问模式。对于真正使用存储的用户不需要关心底层的存储实现细节,只需要直接使用 PVC 即可。ceph提供底层存储功能,cephfs方式支持k8s的pv的3种访问模式ReadWriteOnce,ReadOnlyMany ,ReadWriteMany

以下是goblin上对某个任务的k8s资源编排文件

apiVersion: apps/v1
kind: StatefulSet
metadata:
  creationTimestamp: '2020-04-09T03:44:26Z'
  labels:
    system/project-goblin: 'true'
    goblin/creator-email: duanshishi
    goblin/instance-id: '97'
    goblin-notebook: '97'
    statefulset: notebook-duanshishi-1586403865616-97
    login-password: 2cfa89d4-da8e-455c-98ef-2a0a0e1a3b04
    goblin/creator-group: '422'
    goblin/creator-id: '128'
    goblin/exec-mode: manual
    system/tenant: music-da
    goblin/type: development-environment
  name: notebook-duanshishi-1586403865616-97
  namespace: goblinlab
  selfLink: /apis/apps/v1/namespaces/goblinlab/statefulsets/notebook-duanshishi-1586403865616-97
  uid: 68fdd40f-7a14-11ea-b983-fa163e51ded8
spec:
  podManagementPolicy: OrderedReady
  replicas: 1
  revisionHistoryLimit: 10
  selector:
    matchLabels:
      system/app: notebook-duanshishi-1586403865616-97
      statefulset: notebook-duanshishi-1586403865616-97
  serviceName: ""
  template:
    metadata:
      labels:
        system/app: notebook-duanshishi-1586403865616-97
        system/project-goblin: 'true'
        statefulset: notebook-duanshishi-1586403865616-97
        system/tenant: music-da
    spec:
      containers:
        -
          env:
            -
              name: NOTEBOOK_TAG
              value: notebook-duanshishi-1586403865616-97
            -
              name: SPARK_DRIVER_PORT
              value: '22480'
            -
              name: SPARK_DRIVER_BLOCKMANAGER_PORT
              value: '22490'
            -
              name: SPARK_UI_PORT
              value: '22500'
            -
              name: HADOOP_USER
              value: duanshishi
            -
              name: PASSWORD
              value: 2cfa89d4-da8e-455c-98ef-2a0a0e1a3b04
            -
              name: ROOT_PASSWORD
              value: 2cfa89d4-da8e-455c-98ef-2a0a0e1a3b04
          image: 'music-harbor.k8s.cn-east-p1.internal/library/rtrs-dev-py37-hadoop:v1.5'
          imagePullPolicy: IfNotPresent
          name: notebook
          resources:
            requests:
              memory: 24Gi
              cpu: '10'
            limits:
              memory: 24Gi
              cpu: '10'
          terminationMessagePath: /dev/termination-log
          terminationMessagePolicy: File
          volumeMounts:
            -
              mountPath: /etc/localtime
              name: localtime
              readOnly: false
            -
              mountPath: /var/lib/lxcfs/
              mountPropagation: HostToContainer
              name: lxcfs-folder
              readOnly: false
            -
              mountPath: /proc/cpuinfo
              name: proc-cpuinfo
              readOnly: false
            -
              mountPath: /proc/diskstats
              name: proc-diskstats
              readOnly: false
            -
              mountPath: /proc/loadavg
              name: proc-loadavg
              readOnly: false
            -
              mountPath: /proc/meminfo
              name: proc-meminfo
              readOnly: false
            -
              mountPath: /proc/stat
              name: proc-stat
              readOnly: false
            -
              mountPath: /proc/uptime
              name: proc-uptime
              readOnly: false
            -
              mountPath: /sys/devices/system/cpu/online
              name: lxcfs-cpu
              readOnly: false
            -
              mountPath: /root
              name: develop-duanshishi
              readOnly: false
            -
              mountPath: /mnt/goblin-data
              name: goblin-data
              readOnly: false
            -
              mountPath: /root/demo
              name: notebook-demo
              readOnly: false
            -
              mountPath: /mnt/goblin-log
              name: goblin-log
              readOnly: false
            -
              mountPath: /mnt/goblin-cache
              name: goblin-cache
              readOnly: false
      dnsPolicy: ClusterFirst
      imagePullSecrets:
        -
          name: registrykey-myhub
      nodeSelector:
        system/namespace: netease.share
        system/tenant: netease.share
      restartPolicy: Always
      schedulerName: default-scheduler
      securityContext: {}
      terminationGracePeriodSeconds: 30
      volumes:
        -
          hostPath:
            path: /etc/localtime
            type: ""
          name: localtime
        -
          hostPath:
            path: /var/lib/lxcfs/
            type: DirectoryOrCreate
          name: lxcfs-folder
        -
          hostPath:
            path: /var/lib/lxcfs/lxcfs/proc/diskstats
            type: FileOrCreate
          name: proc-cpuinfo
        -
          hostPath:
            path: /var/lib/lxcfs/lxcfs/proc/diskstats
            type: FileOrCreate
          name: proc-diskstats
        -
          hostPath:
            path: /var/lib/lxcfs/lxcfs/proc/loadavg
            type: FileOrCreate
          name: proc-loadavg
        -
          hostPath:
            path: /var/lib/lxcfs/lxcfs/proc/meminfo
            type: FileOrCreate
          name: proc-meminfo
        -
          hostPath:
            path: /var/lib/lxcfs/lxcfs/proc/stat
            type: FileOrCreate
          name: proc-stat
        -
          hostPath:
            path: /var/lib/lxcfs/lxcfs/proc/uptime
            type: FileOrCreate
          name: proc-uptime
        -
          hostPath:
            path: /var/lib/lxcfs/lxcfs/sys/devices/system/cpu/online
            type: FileOrCreate
          name: lxcfs-cpu
        -
          cephfs:
            path: /pvc-volumes/kubernetes/kubernetes-dynamic-pvc-ca4d6207-1b24-11ea-ac15-0a580ab28a22
            secretRef:
              name: ceph-secret-admin-goblin
            user: admin
            monitors:
              - 10.194.174.173
          name: goblin-data
        -
          cephfs:
            path: /pvc-volumes/kubernetes/kubernetes-dynamic-pvc-9eba69ee-3123-11ea-a0ac-0a580ab2c805
            secretRef:
              name: ceph-secret-admin-goblin
            user: admin
            monitors:
              - 10.194.174.173
          name: notebook-demo
        -
          cephfs:
            path: /pvc-volumes/kubernetes/kubernetes-dynamic-pvc-c1e328db-1b24-11ea-ac15-0a580ab28a22
            secretRef:
              name: ceph-secret-admin-goblin
            user: admin
            monitors:
              - 10.194.174.173
          name: goblin-log
        -
          cephfs:
            path: /pvc-volumes/kubernetes/kubernetes-dynamic-pvc-cb7cadba-1b24-11ea-ac15-0a580ab28a22
            secretRef:
              name: ceph-secret-admin-goblin
            user: admin
            monitors:
              - 10.194.174.173
          name: goblin-cache
        -
          cephfs:
            path: /pvc-volumes/kubernetes/kubernetes-dynamic-pvc-d22931ef-3063-11ea-a0ac-0a580ab2c805
            secretRef:
              name: ceph-secret-admin-goblin
            user: admin
            monitors:
              - 10.194.174.173
          name: develop-duanshishi
  updateStrategy:
    type: RollingUpdate
    rollingUpdate:
      partition: 0
status:
  collisionCount: 0
  currentReplicas: 1
  currentRevision: notebook-duanshishi-1586403865616-97-6bf6f8bcd8
  observedGeneration: 1
  readyReplicas: 1
  replicas: 1
  updateRevision: notebook-duanshishi-1586403865616-97-6bf6f8bcd8
  updatedReplicas: 1

上面是我某个任务完成的yaml文件,在k8s中,yaml 可以告知任何资源的使用与配置如cpu、内存、gpu、对外端口等等,也包括分布式存储如ceph,上面文件中挂载了包括notebook-demo, goblin-log, goblin-cache, goblin-data, develop-duanshishi等多个pvc, pvc底层是cephfs, 且分配了专门的卷,来分享ceph的存储。

赋能

有了容器化、k8s、分布式存储之后,直观上,就有了把集群机器资源单独分配给需求用户的能力,且在不使用时弹性收回,每一个用户可以独立构建自己相关的开发环境,共享给任何团队的小伙伴,也可以将开发环境打包完成线上部署。下面我们从资源隔离与环境隔离、大数据开发、工程开发、机器学习等四个方面来聊一下Goblin的赋能。

资源隔离与环境隔离

name: notebook
    resources:
    requests:
        memory: 24Gi
        cpu: '10'
    limits:
        memory: 24Gi
        cpu: '10'
...
image: 'music-harbor.k8s.cn-east-p1.internal/library/rtrs-dev-py37-hadoop:v1.5'

还是从上面那个yaml文件来说明, k8s提供简单的资源分配能力,针对于我们使用的notebook应用, 我们申请10cpu,24G内存来运行开发环境,这个资源是系统级的隔离,Docker 资源利用率的优势,在于你几乎无法感受到容器化的消耗, 因此你几乎可以认为你就是在使用一个独占的24G, 10cpu的机器资源(几乎没有任何损耗),而容器镜像的选择让你几乎可以完全自定义自己的开发环境,你可以选择不同的操作系统、不同的c++编译环境、Python开发工具等等,所以的环境都可以是你定义的,而定义的十分简单,理论上你只需要把你想用的image push到我们容器集群的镜像库,如rtrs-dev-py37-hadoop:v1.5 就是我们的一个centos的基本的镜像,当然,在notebook上我们默认提供了notebook、python、spark、tensorflow、tensorboard的环境支持,后期自定义的容器镜像可以完全由用户定义, 目前我们使用的镜像库如下,后期goblin将会公开这部分能力,如果用户有自定义环境的高级需求,只需要提供相应dockerfile, goblin后台会自动编译成专属镜像:

大数据开发

环境配置

大数据开发能力是Goblin的基础, 目前我们所有的线上镜像都集成了基本的hadoop、Spark支持,打通了线上大数据集群,可以通过多种方式来访问线上数据,如Notebook pyspark、spark shell、python等等进行大数据开发,有了容器之后,这一切变得十分简单, 仅仅需要构造image时,配置好相关开发环境即可, 下面是我们某个镜像dockerfile的大数据相关的配置:

COPY cluster/hadoop-2.7.3 /app/hadoop 
COPY cluster/apache-hive-2.1.1-bin/ /app/hive
COPY cluster/spark-2.3.2-bin-ne-0.2.0 /app/spark
COPY cluster/spark-lib/*.jar /app/spark/jars/
COPY cluster/apache-hive-2.1.1-bin/conf/hive-site.xml /app/spark/conf/hive-site.xml
COPY krb5.conf /etc/
COPY keytab_init.sh /app/keytab_init.sh
COPY keytab_init.py /app/keytab_init.py
COPY jupyter /app/jupyter
COPY code-server /app
RUN chmod +x /app/keytab_init.sh

ENV SHELL=/bin/bash
ENV JUPYTER_ENABLE_LAB=yes
ENV HIVE_HOME=/app/hive
ENV HADOOP_HOME=/app/hadoop
ENV JAVA_HOME=/usr/lib/jvm/java-8-openjdk-amd64
ENV SPARK_HOME=/app/spark
ENV PATH=``JAVA_HOME/bin:``HIVE_HOME/bin:``HADOOP_HOME/bin:``SPARK_HOME/bin:$PATH
ENV PYTHONPATH=``SPARK_HOME/python:``SPARK_HOME/python/lib/py4j-0.10.7-src.zip:$PYTHONPATH

开发工具

基于大数据的开发工具,我们提供了jupyter lab, jupyter lab是一套比较强大的数据科学开发工具,是大家比较熟悉的jupyter notebook的下一代产品, 集成了包括文件浏览、多窗口支持、多内核支持等等相关功能,如下图, 一个比较简单的访问线上hive表的脚本:

当然你可以在jupyter lab中打开terminal,通过spark-shell、或者其他工具来访问:

工程开发

动机

goblin为方便工程开发,提供了ssh登录、jupyter lab以及vscode online几种使用基于定制化容器环境的开发模式, 业务同学虽然感觉相对于原有的物理机开发会更方便,但是依然给我们这边提了很多问题,其中比较多的一个问题是,是否能够更方便,直接本地连接,并且接入到IDE,当然是可以的,其实本人之前也是采用远程开发的模式,不过在Goblin之前,我选择在本地构建好专门的Docker 容器,通过ssh 链接入本地容器,接下来会和给为小伙伴演示,如何配置本地IDE和Goblin完成远程开发,为了方便,以及考虑成本的问题,本章以vscode为例, 其他相关的IDE如clion,我们测试下来也可以的。

本地环境安装

vscode 安装

vscode 安装比较简单, 参考https://code.visualstudio.com/上,按不同操作系统安装即可

相关插件以及开发环境安装

vscode 安装完成之后,根据开发任务,比如你使用python开发,安装好常用插件,以下是我的插件列表:

Remote SSH

Remote SSH 插件基本介绍

Visual Studio Code Remote - SSH
The Remote - SSH extension lets you use any remote machine with a SSH server as your development environment. This can greatly simplify development and troubleshooting in a wide variety of situations. You can:

1. Develop on the same operating system you deploy to or use larger, faster, or more specialized hardware than your local machine.
2. Quickly swap between different, remote development environments and safely make updates without worrying about impacting your local machine.
3. Access an existing development environment from multiple machines or locations.
4. Debug an application running somewhere else such as a customer site or in the cloud.

Remote SSH 是在本地做goblin远程开发的一个插件,以上是基本介绍;

goblin 环境

新建容器

goblin-实验室-实例管理页面,新建实例:

填入对应的配置信息,选择合适的镜像:

启动镜像后,ssh、vscode、TensorBoard,Jupyter信息如下,

修改本地ssh配置

在本地~/.ssh/config, 配置好容器的相关信息,如ip、port、alias等等

Remote SSH链接


代码开发

c++
在本地安装好c++插件之后, 目前编辑器使用的就是goblin docker容器内的环境,直接打开容器镜像内挂载的ceph 目录:

c++相关的环境也比较方便,比如支持提示、智能跳转、查看源文件等等功能;



开发完成后,调出命令行, g++ helloworld.cc -o hellworld,当然,如果你的项目靠makefile或者cmake也可以的, 小伙伴们自己发挥吧;

编译完成之后, 执行即可

python
业务同学更多的可能是python的开发:

  1. 切换目录至pytorch_study的目录, 本目录是一个:

直接运行发现包缺失问题, 因为我们的镜像tf1.14中没有继承pytorch, 问题不大,我们在命令行安装即可:


安装完成后, 我们也很方便的通过vscode 直接跳转到远端goblin docker下的文件进行代码的阅读:

模型开始训练

显卡占用查询,目前goblin docker有个bug,是无法查看显卡占用的线程,已在修复中:

其他IDE支持

我们团队自研的thanos 基于CMake来构建项目,目前也是基于Goblin Docker完成开发, 以下是基于clion上使用Goblin Docker完成编译的项目截图:

机器学习

Goblin提供的机器学习能力支持包括Jupyter Lab、python shell、大数据环境访问、远程开发等等能力,还支持GPU设备的分配、基于IronBaby的分布式能力等等

机器学习开发环境

Jupyter Lab 深度学习模型开发, 基于vscode远程开发的例子见上章pytorch_test的例子

模型训练调度

模型开发完成之后, 会定期更新训练数据完成模型训练,比如按天更新,针对模型训练调度的需求, Goblin提供了容器化调度组件


配置好,PVC、启动文件、启动参数、环境镜像,你可以快速将模型调度起来, 调度支持时间通配符, 你可以通过自定义你的python启动脚本完成各种自定义逻辑的调度策略;

模型部署

模型部署, 目前是基于Goblin上开发了模型发现服务,目前已经与线上精排系统打通, 用户可以通过简单地配置好模型目录地址、模型线上服务集群等基本信息,即可完成模型定时调度后的定时发布,该发布不仅包括模型,还包括模型场景使用到的词表、Embedding向量等等,因涉及到具体业务;目前我们也在和其他团队合作优化模型部署场景,这一块还有很多的工作来进行,后续应该会有专门工作介绍,这里就不详细描述了。

分布式机器学习

基于容器化的机器学习平台后, 分布式机器学习能力的扩展就变得十分容易,这里介绍我们团队的两个相关的工作:IronBaby、Thanos:
IronBaby
IronBaby是基于TensorFlow为底层框架的推荐系统工具包, IronBaby优势在于企业级的TensorFlow工程经验,对性能考虑比较全面,算法同学仅需要完成模型结构开发即可完成业务模型的开发与调试,目前已有若干场景使用IronBaby完成开发, 以下是某个demo实验场景中的一些配置:


模型训练、导出,仅需要几行即可实现:

基于Goblin的IronBaby分布式:

当然也可以在前面提到的Jupyter Lab notebook中尝试用单机多卡:

某个业务场景评测数据:
单机多卡(1 server 3 worker):

多机分布式加速比(Batch size 5120):几乎线性加速,且我们发现,目前发现在我们业务很多场景瓶颈不在于计算、而在于IO, 这里有不同经验的人欢迎在下面留言,在杭州的小伙伴也欢迎疫情后约出来一起喝咖啡;

Thanos
Thanos是基于parameter server自研的机器学习框架,目前主要针对实时化场景,之前的文章有过分享,这里就不详述了,和IronBaby一样,Thanos是基于KuberFlow的tf_operator完成快速的改造,来支持分布式训练与调度的;

思考

在这块有一些思考,尤其是项目不断迭代的过程中, 我发现其实蛮有意思的

  • 组件细还是粗: 首先是组件粗还是细,最开始,我们的思路参考了国内很多不同的云计算平台的机器学习平台,构建很多功能细致的组件,后来发现几乎没人使用,开发同学更偏向于将自己的业务逻辑代码化,而不是简单地拖拉,很多时候他们可能仅仅只需要一个自定义的容器化调度组件,其他的他们会自己来解决,比如特征id化等等,而云平台如PAI、Azure由于针对用户群体不同,更偏向把这部分逻辑也约束掉,但是在我们经验看来,过度细化的组件群基本上没有人来用, 拖拖拉拉的场景好像也并不合适;
  • 必须要有一些舍弃:平台的工作大且宽泛,必须要要有一些舍弃,一定是有特别多的事情可以去完成,但是必须抓住平台用户的核心需求, 比如早期,我们曾经尝试抽象化通用算法能力, 后来发现,至少在近期这块其实是并不需要的, 真正贴近用户需求才是最迫切的, 通过和其他团队同学合作,完成模型部署(精排系统)的接入;
  • 售后服务才是王道,才是开始:基本需求完成后, 我们发现和用户在一起才是开始, 用户会反馈各种各样的bug、各式各样的需求来促使我们去一步步修改、迭代我们原本的规划,很多时候,早期大而全的roadmap,并没有其必要性。为此我们通过维护专门的用户群,定期回访头部用户, 来迅速迭代我们的需求,以及要完成的工作;
  • 不足、不足还是不足: 还有太多的工作没有完成,有太多太多的问题,比如稳定性、易用性等等,这其中更多的可能不是技术本身的问题, 团队协作、用户需求可能是我们需要特别关注的, 期待后面能做的更好。

Reference

2020/04/06 posted in  机器学习平台

分布式一致性

拜占庭将军问题

拜占庭位于如今的土耳其的伊斯坦布尔,是东罗马帝国的首都。由于当时拜占庭罗马帝国国土辽阔,为了防御目的,因此每个军队都分隔很远,将军与将军之间只能靠信差传消息。在战争的时候,拜占庭军队内所有将军必需达成 一致的共识,决定是否有赢的机会才去攻打敌人的阵营。但是,在军队内有可能存有叛徒和敌军的间谍,左右将军们的决定又扰乱整体军队的秩序,在进行共识时,结果并不代表大多数人的意见。这时候,在已知有成员不可靠的情况下,其余忠诚的将军在不受叛徒或间谍的影响下如何达成一致的协议,拜占庭问题就此形成。拜占庭假设是对现实世界的模型化,由于硬件错误、网络拥塞或断开以及遭到恶意攻击,计算机和网络可能出现不可预料的行为。

疑问:

  1. 类似拜占庭将军这样的分布式一致性问题是否有解?
  2. 如果有解的话需要满足什么样的条件?
  3. 在特定前提条件的基础上,提出一种解法。

Raft 协议的易理解性描述

由Raft协议组织的集群中有三类角色:

  1. Leader;
  2. Follower;
  3. Candidate;


选出 Leader 后,Leader 通过定期向所有 Follower 发送心跳信息维持其统治。若 Follower 一段时间未收到 Leader 的心跳则认为 Leader 可能已经挂了再次发起选主过程。

** Leader 节点对一致性的影响 **

主节点可能在任意阶段挂掉, 如何保证数据一致性:

  1. 数据到达Leader节点前
  2. 数据到达 Leader 节点,但未复制到 Follower 节点
    这个阶段 Leader 挂掉,数据属于未提交状态,Client 不会收到 Ack 会认为超时失败可安全发起重试。Follower 节点上没有该数据,重新选主后 Client 重试重新提交可成功。原来的 Leader 节点恢复后作为 Follower 加入集群重新从当前任期的新 Leader 处同步数据,强制保持和 Leader 数据一致。
  3. 数据到达 Leader 节点,成功复制到 Follower 所有节点,但还未向 Leader 响应接收: 这个阶段 Leader 挂掉,虽然数据在 Follower 节点处于未提交状态(Uncommitted)但保持一致,重新选出 Leader 后可完成数据提交,此时 Client 由于不知到底提交成功没有,可重试提交。针对这种情况 Raft 要求 RPC 请求实现幂等性,也就是要实现内部去重机制。
  4. 数据到达 Leader 节点,成功复制到 Follower 部分节点,但还未向 Leader 响应接收
    这个阶段 Leader 挂掉,数据在 Follower 节点处于未提交状态(Uncommitted)且不一致,Raft 协议要求投票只能投给拥有最新数据的节点。所以拥有最新数据的节点会被选为 Leader 再强制同步数据到 Follower,数据不会丢失并最终一致。


5. 数据到达 Leader 节点,成功复制到 Follower 所有或多数节点,数据在 Leader 处于已提交状态,但在 Follower 处于未提交状态
这个阶段 Leader 挂掉,重新选出新 Leader 后的处理流程和阶段 3 一样。

6. 数据到达 Leader 节点,成功复制到 Follower 所有或多数节点,数据在所有节点都处于已提交状态,但还未响应 Client
这个阶段 Leader 挂掉,Cluster 内部数据其实已经是一致的,Client 重复重试基于幂等策略对一致性无影响。

  1. 网络分区导致的脑裂情况,出现双 Leader

网络分区将原先的 Leader 节点和 Follower 节点分隔开,Follower 收不到 Leader 的心跳将发起选举产生新的 Leader。这时就产生了双 Leader,原先的 Leader 独自在一个区,向它提交数据不可能复制到多数节点所以永远提交不成功。向新的 Leader 提交数据可以提交成功,网络恢复后旧的 Leader 发现集群中有更新任期(Term)的新 Leader 则自动降级为 Follower 并从新 Leader 处同步数据达成集群数据一致。

2020/04/05 posted in  分布式系统

raft 参考文章

分布式系统中,网络不可靠,主机的差异性(包块性能、时钟),主机的不可靠等特性,从而产生了分布式系统的一致性问题,我们要保障分布式中的主机以同样的顺序来执行指令,从而产生一致性的结果,使得整个分布式系统像一台主机。

一致性问题
分布式系统产生一致性问题的原因可总结如下:

网络不可靠
主机不可靠
主机之间的差异性(性能、时钟等)
首先,网络可能导致我们发送的数据或者指令以乱序的方式到达,也可能会丢失数据,其次主机时不可靠的,可能会出现宕机,重启等,主机之间的差异性,包括主机的性能和时钟,这些都会导致分布式系统难于实现一致性,FLP不可能理论指出无法彻底解决一致性问题,在CAP中我们只能选择两项,大多数的分布式系统都选择了最终一致性,即若一致性来保证可用性和分区容错性。
在实际的生产环境中,一致性算法需要具备以下属性:

安全性:即不管怎样都不能返回错误的结果;
可用性:主要大部门的机器正常,就仍然可以正常工作;
不依赖时间来确保一致,即系统是异步的;
一般情况下,运行时间由大多数的机器决定,不会因为有少部分慢的机器而影响总体效率
通俗来讲,一致性的问题可以分解为两个问题:
1、任何一次修改保证数据一致性
2、多次数据修改的一致性

弱一致性:不要求每次修改的内容在修改后多副本的内容是一致的,对问题1的解决比较宽松,更多解决问题2,该类算法追求每次修改的高度并发性,减少多副本之间修改的关联性,以获得更好的并发性能。例如最终一致性,无所谓每次用户修改后的多副本的一致性及格过,只要求在单调的时间方向上,数据最终保持一致,如此获得了修改极大的并发性能。
强一致性:强调单次修改后结果的一致,需要保证了对问题1和问题2要求的实现,牺牲了并发性能。
一致性算法有:两阶段提交算法、分布式锁服务、Paxos算法和Raft算法。
两阶段提交参见这里分布式事务
分布式锁服务参加这里分布式锁以及三种实现方式
下面主要介绍Paxos算法和Raft一致性算法。

Paxos算法
Paxos算法是一个会者简单,不会者觉得很难的算法,就连Lamport本文也不得不为Paxos先后做了三次解释。
查阅了很多资料,最后发现维基百科中对Paxos的解释最为准确和易懂,可见参考文件中。这里只是阐述算法的过程和原理,不再做深一步的证明和理解。
Paxos算法分为两个简单,分别是准备阶段(Prepare)和接受阶段(Accept),当Proposer接收到来自客户端的请求时,就会进入如下流程:

Paxos算法

只要Proposer经过多数派接受,该提案就会成为正式的决议。
Raft一致性算法
Raft是Paxos的变体,不过Raft简化了Paxos,任何时间内,只有leader能够发起提案,这就涉及到leader选举问题。
Raft算法中有一下三个角色:

1.Leader:负责 Client 交互 和 log 复制,同一时刻系统中最多存在一个;
2.Follower:被动响应请求 RPC,从不主动发起请求 RPC;
3.Candidate : 由Follower 向Leader转换的中间状态。
Leader选举过程:
在极简的思维下,一个最小的 Raft 民主集群需要三个参与者(如下图:A、B、C),这样才可能投出多数票。初始状态 ABC 都是 Follower,然后发起选举这时有三种可能情形发生。下图中前二种都能选出 Leader,第三种则表明本轮投票无效(Split Votes),每方都投给了自己,结果没有任何一方获得多数票。之后每个参与方随机休息一阵(Election Timeout)重新发起投票直到一方获得多数票。这里的关键就是随机 timeout,最先从 timeout 中恢复发起投票的一方向还在 timeout 中的另外两方请求投票,这时它们就只能投给对方了,很快达成一致。

Raft 协议强依赖 Leader 节点的可用性来确保集群数据的一致性。数据的流向只能从 Leader 节点向 Follower 节点转移。当 Client 向集群 Leader 节点提交数据后,Leader 节点接收到的数据处于未提交状态(Uncommitted),接着 Leader 节点会并发向所有 Follower 节点复制数据并等待接收响应,确保至少集群中超过半数节点已接收到数据后再向 Client 确认数据已接收。一旦向 Client 发出数据接收 Ack 响应后,表明此时数据状态进入已提交(Committed),Leader 节点再向 Follower 节点发通知告知该数据状态已提交。
Leader的选举问题

分布式一致性算法与共识算法总结

经典分布式一致性算法:
2 Phase commit protocol

3 phase commit protocol

Paxos: 唯一有效的一致性算法, 其他算法都改算法的某种程度的简化版

分布式一致性算法特点:
领域: 分布式数据库

目标: 其解决的问题是分布式系统如何就某个值(决议)达成一致。

只有一种算法: paxos

特点: 无拜占庭容错, n/2 +1,

主流的传统分布式一致性算法其实只有一个:Paxos。包括Raft在内的其他算法,都属于Paxos的变种,或特定假设场景下的Paxos算法。

传统分布式一致性算法和区块链共识机制的异同点
相同点

Append only

时间序列化

少数服从多数

分离覆盖(即长链覆盖短链区块,节点大数据量日志覆盖小数据量日志)
不同点

传统分布式一致性算法并不考虑拜占庭容错,只假设所有节点仅发生宕机、网络故障等非人为问题,没有考虑恶意节点。

传统分布式一致性算法面向数据库或文件,而区块链共识机制面向交易或价值传输。
详细介绍
经典的分布式一致性算法
Paxos算法
Paxos算法是莱斯利·兰伯特(Leslie Lamport)1990年提出的一种基于消息传递的一致性算法,其解决的问题是分布式系统如何就某个值(决议)达成一致。

从工程实践的意义上来说,通过Paxos可以实现多副本一致性、分布式锁、名字管理、序列号分配等。比如,在一个分布式数据库系统中,如果各节点的初始状态一致,每个节点执行相同的操作序列,那么他们最后得到的状态就是一致的。为保证每个节点执行相同的命令序列,需要在每一条指令上执行一个“一致性算法”以保证每个节点看到的指令一致。后续又增添多个改进版本的Paxos,形成了Paxos协议家族,但其共同点是不容易工程实现。

Lamport在2011年的论文Leaderless Byzanetine Paxos中表示,不清楚实践中是否有效,考虑Paxos本身实现的难度以及复杂程度,此方案工程角度不是最优,但是系统角度应该是最好的。

  1. Raft算法

Paxos协议的难以理解是出了名的,斯坦福大学的博士生Diego Ongaro把对其的研究作为了自己的博士课题。2014年秋天,他正式发表了博士论文CONSENSUS: BRIDGING THEORY AND PRACTICE,并给出了分布式一致性协议的一个实现算法,即Raft。

在论文正式发表前,Diego Ongaro还把与Raft相关的部分摘了出来,形成了一篇十多页的文章In Search of an Understandable Consensus Algorithm,即人们俗称的Raft论文。

Raft算法主要注重协议的落地性和可理解性,让分布式一致性协议可以较为简单地实现。Raft和Paxos一样,只要保证n/2+1节点正常就能够提供服务;同时,Raft更强调可理解性,使用了分而治之的思想把算法流程分为选举、日志复制、安全性三个子问题。

在一个由Raft协议组织的集群中有三类角色:Leader(领袖)、Follower(群众)、Candidate(候选人)。Raft开始时在集群中选举出Leader负责日志复制的管理,Leader接受来自客户端的事务请求(日志),并将它们复制给集群的其他节点,然后负责通知集群中其他节点提交日志,Leader负责保证其他节点与他的日志同步,当Leader宕掉后集群其他节点会发起选举选出新的Leader。

共识算法
当我们描述传统分布式一致性算法时,其实是基于一个假设——分布式系统中没有拜占庭节点(即除了宕机故障,没有恶意篡改数据和广播假消息的情况)。而当要解决拜占庭网络中的数据一致性问题时,则需要一种可以容错的算法,我们可以把这类算法统称为拜占庭容错的分布式一致性算法。而共识机制,就是在拜占庭容错的分布式一致性算法基础上,根据具体业务场景传输和同步数据的通信模型。

  1. 工作量证明机制(Proof of Work, POW)

POW依赖机器进行数学运算来获取记账权,资源消耗相比其他共识机制高、可监管性弱;同时,每次达成共识需要全网共同参与运算,性能效率比较低,容错性方面允许全网50%节点出错。第一个运用POW的是比特币系统,它能够使更长总账的产生具有计算性难度,平均每10分钟有一个节点找到一个区块

  1. 股权证明机制(Proof of Stake, POS)

股权证明机制已有很多不同变种,但基本概念是产生区块的难度应该与用户在网络里所占的股权成比例

  1. 授权股权证明机制(DPOS)

每个股东可以将其投票权授予一名代表,获票数最多的前100名代表按既定时间表轮流产生区块。所有代表将收到等同于一个平均水平的区块所含交易费的10%作为报酬,如果一个平均水平的区块含有100股作为交易费,则一名代表将获得1股作为报酬。

该模式每30秒便可产生一个新区块,在正常的网络条件下区块链分叉的可能性极小,即使发生也可以在几分钟内得到解决。

  1. 实用拜占庭协议(PBFT)

PBFT是一种基于消息传递的一致性算法,算法经过三个阶段达成一致性,这些阶段可能因为失败而重复进行。

假设节点总数为3f+1,f为拜占庭错误节点:

(1)当节点发现leader作恶时,通过算法选举其他的replica为leader;

(2)leader通过pre-prepare 消息把它选择的value广播给其他replica节点,其他replica节点如果接受则发送 prepare,如果失败则不发送;

(3)一旦2f个节点接受prepare消息,则节点发送commit消息;

(4)当2f+1个节点接受commit消息后,代表该value值被确定。

b6b2c783ac6f4c0687543a5fc82fa405_th.jpg.png
该算法主要应用在hyperledger fabric等联盟区块链或私有区块链场景中,容错率低、灵活性差,超过1/3的节点作恶就会导致系统崩溃,并且不可动态添加节点(部分论文讨论了动态节点的PBFT算法,但是理论和实践上都有比较强的假设条件)。

  1. GEAR共识协议(Group Estimate and Rotate)

该协议是唐盛(北京)物联技术有限公司自主研发的共识协议,通过轮转记账(rotate)、集体评估(group estimate)和齿轮共识路由(gear)三个子协议组成,结合区块链数据结构和点对点网络通信的特点,实现安全、高效、去中心化、应用场景灵活的数据同步共识。目前,该协议已经在“唐盛链”中得到应用。

协议的参与者包括轮转见证人(rotate witness)、一级集体评估人(voter)、二级集体评估人(valuer)。Voter作为接入共识网络的用户,既是系统的使用者也是一级集体评估人,按照其所持代币加权评估选举出轮转见证人,轮转见证人按照等概率轮流记账(产生区块)。二级集体评估人是在评估事件发生时由轮转见证人转化而来,通过加权平均的接近率抢夺一次记账机会

作者:millerix
链接:https://www.jianshu.com/p/9a9290fb0727
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

2020/03/28 posted in  分布式系统

分布式系统书籍

2020/03/28 posted in  分布式系统

容灾【备份】

原文链接https://juejin.im/post/5d2030616fb9a07eea3292f1

本文是Fault-Tolerant VM论文的阅读笔记。本文实现了一个容错虚拟机,在另外一个服务器上备份主虚拟机的运行。可以有两种实现思路:

备份全部状态的变化到备份虚拟机(包括CPU、内存、I/O设备)。但是,这样一来需要传输处理的数据内容将十分巨大。
将虚拟机视为确定状态机,这是本文的方法。只需要初始状态和确定的输入,以及记录一些不确定事件即可完成备份。

VMware已经在vSphere中实现了本文的虚拟机备份机制,能够在主虚拟机发生故障之后无缝启用备份虚拟机。目前这个技术只支持单核处理器,因为多核处理器指令访存也是不确定的事件。当然,本文实现的备份机制奏效的前提是故障在被外部发现之前都能被检测到。
基础的容错设计
一个容错的配置如下图所示,对于一台虚拟机(主虚拟机)而言,我们在其他物理服务器上运行一台备份虚拟机,两台主机处于虚拟锁步状态。它们会连接到同一个共享磁盘。其中,所有的输入(包括网络、鼠标、键盘等等)只会交给主虚拟机,然后通过日志信道发送给备份虚拟机。

确定回放实现
复制虚拟机的运行主要面临三个挑战:

正确地捕获所有的输入和必要的不确定事件来保证备份虚拟机的确定运行,
正确地将输入和不确定性应用到备份虚拟机
保证不降低性能

不过,VMware vSphere已经提供了VMware确定回放[2]功能。
容错协议
输出要求:如果备份虚拟机在故障之后替代了主虚拟机,备份虚拟机运行期间要保证外界得到的输出是完全一致的。
复制代码
只有满足了输出要求,外界才不会观测到故障的发生,而这个要求需要延迟外部输出直到备份虚拟机收到足够的信息来重放输出操作。一个必要的条件就是备份虚拟机需要收到输出操作之前的所有日志。备份虚拟机不能在输出操作之前上线,因为可能主虚拟机中会存在不确定事件取消了后续的输出。
输出规则:主虚拟机不会将输出发送给外界,直到收到了来自备份虚拟机收到产生输出的操作的日志的确认。
复制代码
容错协议如下图所示,异步事件、输入和输出操作发送给了备份虚拟机,主虚拟机只有在备份虚拟机确认收到输出操作后输出。

不过协议无法保证重复输出,因为备份虚拟机无从知晓主虚拟机在输出之前还是之后崩溃,另外故障发生时发送给主虚拟机的包也会丢失。不过好在,网络基础设施、操作系统、应用程序通常都能处理丢包或者重复的情况。
故障检测和响应
在备份虚拟机取代主虚拟机之前,需要应用全部的日志。主虚拟机和备份虚拟机主要通过心跳包和日志通信来判断对方是否故障。为了解决脑裂问题,两个虚拟机需要通过共享磁盘上得知对方是否故障。如果主虚拟机故障,那么备份虚拟机取代主虚拟机,并创建一个新的备份虚拟机;如果备份虚拟机故障,那么创建一个新的备份虚拟机。
容错实现实践

启动和重启容错虚拟机

在启动主虚拟机或者备份虚拟机故障后,需要创建一个和主虚拟机相同状态的备份虚拟机,并且不能打断主虚拟机的运行。具体通过VMware VMotion实现,VMotion将虚拟机复制到另外一个物理服务器上,将源虚拟机作为主虚拟机,将目标虚拟机作为备份虚拟机。
备份虚拟机通常位于集群中另外一台服务器上,由vSphere调度选择放置的服务器,这些服务器能够访问共享的磁盘。

管理日志信道:

日志信道可以通过一个大的缓冲来实现,必要的时间可以控制主虚拟机的运行速度来保证备份虚拟机能够赶上。

容错虚拟机的操作

虚拟机会有各种各样的控制操作,例如关机、修改资源分配,这些其实都可以通过特殊的控制操作日志来实现。
容错机制给VMotion带来了挑战,VMotion用来无缝迁移虚拟机,要求在切换的时候挂起所有磁盘I/O。主虚拟机可以挂起磁盘I/O,但是备份虚拟机重复主虚拟机的,需要通过日志信道请求主虚拟机挂起磁盘I/O。

磁盘I/O实现中的问题:

实现磁盘I/O会面临以下问题

磁盘操作是非阻塞的,所以可以并行写入,但是由此引入了不确定性。解决方案是强制磁盘操作串行进行。
磁盘操作(DMA)和应用程序会并行操作同一块内存,引发数据竞争。解决方案是使用额外缓冲,读取磁盘时先将数据读入额外缓冲,写入磁盘时先将数据复制到额外缓冲。
当主虚拟机故障,备份虚拟机替代时,磁盘I/O可能没有完成。解决方案是重新执行磁盘操作,因为前两个方案已经避免了数据竞争,因此磁盘操作是可重入的。

网络I/O实现中的问题:

vSphere实现了一些网络方面的优化,例如直接从网络缓冲区取走数据,而不通过陷阱,但是这将带来不确定,因此需要禁用这个优化。另外也做了以下优化:

通过批量操作降低虚拟机陷阱和中断次数。
降低传输数据包的延迟:将发送操作和接收操作注册到TCP协议栈之中,保证立即发送和接收日志。

设计中的选择
是否共享磁盘
主虚拟机和备份虚拟机其实可以采用独立的磁盘,磁盘是内部存储,所以不需要满足输出要求,但是两个磁盘需要在启动容错之初进行同步。不过,这是脑裂问题就不能通过磁盘解决了,需要通过第三方协调服务器。

在备份虚拟机中执行磁盘读取操作
备份虚拟机也可以考虑直接从磁盘读取数据而不需要通过日志信道。但是这会导致备份虚拟机变慢,因为需要等待磁盘读取操作,并且需要处理读取故障,以及推迟写入操作保证之前的读取操作被备份服务器成功执行。

2020/03/22 posted in  分布式系统

容灾【备份】

原文链接:http://blog.luoyuanhang.com/2017/05/20/ftvm-notes/

在分布式系统中,容错方法有很多种,常见的传统方法有:主/副服务器方法(当主服务器宕机之后,由副服务器来接管它的工作),这种方法通常需要机器之间的高带宽。

另外还有确定(deterministic)状态机方法:将另一台服务器初始化为和主服务器一样的状态,然后让它们都接受到同样的输入,这样它们的状态始终保持一致,但是这种方法对于非确定的(non-deterministic)操作并不适用。

本文中讨论的方法是使用虚拟机作为状态机,它具有以下优点:

操作全部被虚拟化
虚拟机本身就支持 non-deterministic 操作
虚拟机管理程序(Hypervision)能够记录所有在虚拟机上的操作,所以能够记录主服务器(Primary)所有操作,然后在副服务器(Backup)上进行演绎
基本设计方案

如图就是本文提到的容错系统的架构,一个 Primary,一个 Backup,Primary 和 Backup 之间通过 Logging Channel 进行通信,Primary 和 Backup 基本保持同步,Backup 稍稍落后,它们两个之间会通过 heartbeat 进行 fail 检测,并且它们使用共享磁盘(Shared Disk)。

确定(deterministic)操作的演绎
让两台机器初始状态相同,它们接受相同的输入,顺序相同,两台机器执行的任务的结果就会相同。

但是如果存在非确定的(non-deterministic)操作(比如中断事件、读取CPU时钟计数器的值操作就是非确定的),它会影响状态机的执行。

难点在于:

需要捕捉全部的输入和 non-deterministic 操作在保证 Backup 是deterministic 的
需要准确将全部输入和 non-deterministic 操作应用到 Backup 中
需要保证系统高效
设计方案为:将所有的 input 和 non-deterministic 操作写入到 log 中(file),对于 non-deterministic 操作还要记录和它相关的状态信息等,确保 non-deterministic 操作后Backup状态还是和 Primary 一致

FT(Fault-Tolerance)协议
FT 协议是应用于 logging channel 的协议,协议的基本要求为:

如果 Primary 宕机了,Backup 接替它的工作,Backup 之后向外界发出所有的 Output 要和 Primary 原本应当发送的一致。

为了保证以上的要求,设计如下系统:

Primary会在所有关于本次Output 的所有信息都发送给 Backup 之后(并且要确保 Backup 收到)才会把 output 发送给外界
Primary 只是推迟将 output 发送给外界,而不会暂停执行后边的任务
流程如图所示:

但是这种方法不能保证 output 只发出一次,如果 primary 宕机了,backup 不能判断它是在发送了 output 之前还是之后宕机的,因此 backup 会再发送一次 output。但是这个问题很容易解决,因为:

output 是通过网络进行发送的,例如 TCP 之类的网络协议能够检测重复的数据包
即使 output 被发送了2次其实也没关系。如果 output 是一个写操作,它会在同一个位置写入两次,结果不会发生变化;如果 output 是读取操作,读的内容会被放入 bounce buffer(为了消除 DMA 竞争),数据会在 IO 中断之后被送到
宕机检测
如何知道有机器宕机,在该系统中是十分重要的。该设计使用的是UDP heartbeat 机制来检测 Primary 与 Backup 之间的通信是否正常。

但是使用这种方法会存在裂脑问题(split-brain,Primary 和 Backup 同时宕机),该怎么解决呢?

该设计中使用了共享存储(Shared Storage),对它的操作是原子的,Primary 和 Backup不能同时进行一个操作(提供原子的 test-and-set 操作)

如果检测出 Primary 宕机,Backup 会成为 Primary,接替之前的工作,然后再寻找一个 Backup。

具体实现
启动/重启 Virtual Machine
如何启动一个和 Primary 状态一样的 Backup?

VMware Vmotion 操作能够将一台 VM 从一个 Server 完整的迁移到另一个 Server(只需要很短的中断),在该设计中的方法对 Vmotion 做了一点修改,不是进行迁移,而是直接克隆。

管理 Logging Channel

如图,该设计使用了一个大的 buffer,来保存 logging entries,Primary 把自己的 entry 存到 buffer 中,由 logging channel 发送给Backup 的 buffer,然后 Backup 从 buffer 读取命令执行。

如果 Backup 的 buffer 空了,没有命令执行了,Backup 会等待新的 entry
如果 Primary 的 buffer 满了,Primary 会等待,等 buffer 中有空余空间再继续执行
Disk I/O问题
disk 操作是并行的,同时对 disk 的同一位置进行操作会导致 non-deterministic

解决方案:检测 IO 竞争,使这些操作串行执行

Disk IO 使用 DMA(Direct Memory Access),同时访问内存同一位置的操作会导致 non-deterministic

解决方案:对 disk 操作的内存设置内存的页保护,但是这种方法代价太高;该设计中使用了 bounce buffer,它的大小和 disk 所操作的内存部分大小是一致的,read 操作直接将内容读入 buffer,当其他操作完成,写入内存,write 操作将写内容写入 buffer,之后再写入磁盘。

总结
Vmware 提出的这种 Primary/Backup 方法是分布式容错方法中非常重要的一部分,可以用在许多系统中,不仅仅是分布式存储(GFS 的容错方法),也可以用在分布式计算中,因为它是将所有的操作都记录下来,将它们重新在 Backup 上进行演绎,从而起到了备份的作用,能够做到容错(Fault-Tolerance)。

2020/03/22 posted in  分布式系统

ps-lite

ps-lite再一次研读源码

Postoffice

Postoffice是个单例类,是整个ps-lite的核心,相当于整个ps-lite的调控中心,包括对调起Van负责整个网络的拉起、通信、命令管理如增加节点、移除节点、恢复节点等等;整个集群基本信息的管理,比如worker、server数的获取、server端feature分布的获取、worker/server Rank与node id的互转、节点角色身份等等;

Van 和ZMQVan: 网络如何被构建, 如何通信

Van是ps-lite的一个基类, 实现了基础的公共函数,ZMQVan是基于zeromq的Van的实现,Van是整个Parameter Server的通信模块;在整个训练任务的生命周期中,有以下几点值得注意:

  1. 任务启动时,所有nodes,发送消息到scheduler,;
  2. 启动好scheduler后, worker与server会互相连接,注意worker之间、server之间不会连接;
  3. 框架运行过程中,通信中包括以下多种信息类型,如数据信息:worker向server更新梯度、心跳信息:worker/server向scheduler发送心跳、server和worker的连接、scheduler端的处理命令:如添加节点、恢复dead节点等等;
  4. Message中的Meta,如是否request、app_id、timestamp、nodes的ip、port、role等等在网络通信过程中会打包成protobuf,减少通信压力;
  5. 在节点挂掉(心跳时间内没回应)会恢复节点,这个过程中会将挂掉节点的id赋给恢复的节点;

Customer 消息如何被处理

Customer主要是request、response,比如新建一次request,会返回一个timestamp,这个timestamp会作为这次request的id,每次请求会自增1,相应的res也会自增1,调用wait时会保证 后续比如做Wait以此为ID识别,tracker_是Customer内用来记录request(使用request id)和对应的response的次数的一个map;recv_handle_绑定Customer接收到request后的处理函数(SimpleApp::Process);Customer会新拉起一个线程,用于在customer生命周期内,使用recv_handle_来处理接受的请求,这里是使用了一个线程安全队列,Accept()用于往队列中一直发送消息,对于Worker,比如KVWorker,recv_handle_保存拉取的msg中的数据,对于Server,需要使用set_request_handle来设置对应的处理函数,如KVServerDefaultHandle,使用std::unordered_map<Key, Val> store保存server的参数,当请求为push时,对store参数做更新,请求为pull时对参数进行拉取;

Message: 消息数据结构

Message封装包括Meta, Control, Node等消息, 其中data的部分,采用的SArray这个数据结构可以理解为一个零拷贝的vector,能兼容vector的数据结构,另外为了保证高效会对Message里的Meta,Control,Node使用protobuf来打包,这里有个疑问,为啥不会数据比如推送的梯度信息用protobuf打包呢?

PS如何构建网络

PS构建网络步骤如下:

  1. scheduler节点拉起;
  2. worker、server节点想scheduler发送请求,汇报ip、端口等等,scheduler分配node id给相应节点;
  3. 所有节点启动后,scheduler发送消息周知;
  4. 所有节点内部启动线程,一直发送心跳, scheduler会来处理相应的命令;

同步操作

ps-lite里面有两个涉及到等待同步的地方:

  1. Worker pull时是异步操作,通常调用Wait来调用Customer::WaitRequest()来保证customer里面的request和response两者相等,即保证Pull完成后再做其他操作;

  2. 另外在一个worker内,可以存在多个Customer,当第一个发送barrier后,scheduler接收到request请求,然后根据msg判断是request,然后,向barrier_group里的所有node,node接到后, Postoffice::Get()->Manage(*msg)将barrier_done_中的customer_id对应的bool置true,完成同步操作,这里貌似没有我们常说的asp、bsp、ssp,可以通过增加相应的Command来完成;

  3. 当构建节点连接时,也可以进行一个barrier;

  4. 更复杂的比如Asp,bsp,ssp可以通过发送新定Command来完成

    void Van::ProcessBarrierCommand(Message* msg) {
    auto& ctrl = msg->meta.control;
    if (msg->meta.request) {
    if (barrier_count_.empty()) {
    barrier_count_.resize(8, 0);
    }
    int group = ctrl.barrier_group;
    ++barrier_count_[group];
    PS_VLOG(1) << "Barrier count for " << group << " : " << barrier_count_[group];
    if (barrier_count_[group] ==
    static_cast(Postoffice::Get()->GetNodeIDs(group).size())) {
    barrier_count_[group] = 0;
    Message res;
    res.meta.request = false;
    res.meta.app_id = msg->meta.app_id;
    res.meta.customer_id = msg->meta.customer_id;
    res.meta.control.cmd = Control::BARRIER;
    for (int r : Postoffice::Get()->GetNodeIDs(group)) {
    int recver_id = r;
    if (shared_node_mapping_.find(r) == shared_node_mapping_.end()) {
    res.meta.recver = recver_id;
    res.meta.timestamp = timestamp_++;
    CHECK_GT(Send(res), 0);
    }
    }
    }
    } else {
    Postoffice::Get()->Manage(*msg);
    }
    }

SampleApp

一个基类,封装了基本的PS app的操作,KVWorker、KVServer集成SampleApp,完成相应逻辑: 如push、pull、key的切片等等;

2020/03/08 posted in  参数服务器

pytorch 环境安装及配置

基础的cuda 啥的就不详细描述了,建议用docker、建议用docker、建议用docker,把搞环境的时间花在跑代码上不香吗? 本文主要是来自于pytorch 官网,会稍微改下部分内容来作为学习笔记;

# install pytorch 1.4
!pip install -U torch torchvision -i https://mirrors.aliyun.com/pypi/simple
Looking in indexes: https://mirrors.aliyun.com/pypi/simple
Collecting torch
  Using cached https://mirrors.aliyun.com/pypi/packages/1a/3b/fa92ece1e58a6a48ec598bab327f39d69808133e5b2fb33002ca754e381e/torch-1.4.0-cp37-cp37m-manylinux1_x86_64.whl (753.4 MB)
Collecting torchvision
  Using cached https://mirrors.aliyun.com/pypi/packages/1c/32/cb0e4c43cd717da50258887b088471568990b5a749784c465a8a1962e021/torchvision-0.5.0-cp37-cp37m-manylinux1_x86_64.whl (4.0 MB)
Requirement already satisfied, skipping upgrade: six in /app/anaconda3/lib/python3.7/site-packages (from torchvision) (1.12.0)
Requirement already satisfied, skipping upgrade: pillow>=4.1.1 in /app/anaconda3/lib/python3.7/site-packages (from torchvision) (6.2.0)
Requirement already satisfied, skipping upgrade: numpy in /app/anaconda3/lib/python3.7/site-packages (from torchvision) (1.17.2)
Installing collected packages: torch, torchvision
Successfully installed torch-1.4.0 torchvision-0.5.0
# verification
from __future__ import print_function
import torch
x = torch.rand(5, 3)
print(x)
tensor([[0.3890, 0.3672, 0.2697],
        [0.1633, 0.1091, 0.9061],
        [0.0438, 0.5167, 0.5995],
        [0.0546, 0.0019, 0.8384],
        [0.5708, 0.0217, 0.3954]])
# check gpu device
import torch
torch.cuda.is_available()
True

what is pytorch

Tensors

from __future__ import print_function
import torch
x = torch.empty(5, 3)
print(x)
x = torch.rand(5, 3)
print(x)
x = torch.zeros(5, 3, dtype=torch.long)
print(x)
x = torch.tensor([5.5, 3])
print(x)
x = x.new_ones(5, 3, dtype=torch.double)      # new_* methods take in sizes
print(x)
x = torch.randn_like(x, dtype=torch.float)    # override dtype!
print(x)    
print(x.size())
tensor([[0., 0., 0.],
        [0., 0., 0.],
        [0., 0., 0.],
        [0., 0., 0.],
        [0., 0., 0.]])
tensor([[0.5029, 0.7441, 0.5813],
        [0.1014, 0.4897, 0.2367],
        [0.2384, 0.6276, 0.0321],
        [0.9223, 0.4334, 0.9809],
        [0.1237, 0.3212, 0.0656]])
tensor([[0, 0, 0],
        [0, 0, 0],
        [0, 0, 0],
        [0, 0, 0],
        [0, 0, 0]])
tensor([5.5000, 3.0000])
tensor([[1., 1., 1.],
        [1., 1., 1.],
        [1., 1., 1.],
        [1., 1., 1.],
        [1., 1., 1.]], dtype=torch.float64)
tensor([[ 0.5468, -0.4615, -0.0450],
        [ 0.5001, -0.9717, -0.6103],
        [-0.5345,  0.1126, -0.0836],
        [-0.5534,  0.5423, -1.1128],
        [-1.3799,  1.3353, -1.6969]])
torch.Size([5, 3])

Operaations

y = torch.rand(5, 3)
print(x+y)
tensor([[ 1.0743, -0.4365,  0.7751],
        [ 1.4214, -0.7803, -0.2535],
        [ 0.3591,  0.7957,  0.0637],
        [-0.3185,  0.5621, -0.9368],
        [-0.7098,  1.5445, -1.5394]])
print(torch.add(x, y))
tensor([[ 1.0743, -0.4365,  0.7751],
        [ 1.4214, -0.7803, -0.2535],
        [ 0.3591,  0.7957,  0.0637],
        [-0.3185,  0.5621, -0.9368],
        [-0.7098,  1.5445, -1.5394]])
result = torch.empty(5, 3)
torch.add(x, y, out=result)
print(result)
tensor([[ 1.0743, -0.4365,  0.7751],
        [ 1.4214, -0.7803, -0.2535],
        [ 0.3591,  0.7957,  0.0637],
        [-0.3185,  0.5621, -0.9368],
        [-0.7098,  1.5445, -1.5394]])
# Any operation that mutates a tensor in-place is post-fixed with an _. For example: x.copy_(y), x.t_(), will change x.
y.add_(x)
print(y)
print(x[:, 1])
tensor([[ 1.0743, -0.4365,  0.7751],
        [ 1.4214, -0.7803, -0.2535],
        [ 0.3591,  0.7957,  0.0637],
        [-0.3185,  0.5621, -0.9368],
        [-0.7098,  1.5445, -1.5394]])
tensor([-0.4615, -0.9717,  0.1126,  0.5423,  1.3353])
# Resizing: If you want to resize/reshape tensor, you can use torch.view:

x = torch.randn(4, 4)
y = x.view(16)
z = x.view(-1, 8)  # the size -1 is inferred from other dimensions
print(x.size(), y.size(), z.size())
torch.Size([4, 4]) torch.Size([16]) torch.Size([2, 8])
# If you have a one element tensor, use .item() to get the value as a Python number
x = torch.randn(1)
print(x)
print(x.item())
tensor([0.9993])
0.999320387840271

Numpy Bridge

# Converting a Torch Tensor to a NumPy Array
a = torch.ones(5)
print(a)
b = a.numpy()
print(b)
a.add_(1)
print(a)
print(b)
tensor([1., 1., 1., 1., 1.])
[1. 1. 1. 1. 1.]
tensor([2., 2., 2., 2., 2.])
[2. 2. 2. 2. 2.]
# Converting NumPy Array to Torch Tensor
import numpy as np
a = np.ones(5)
b = torch.from_numpy(a)
np.add(a, 1, out=a)
print(a)
print(b)
[2. 2. 2. 2. 2.]
tensor([2., 2., 2., 2., 2.], dtype=torch.float64)
## CUDA Tensors

if torch.cuda.is_available():
    device = torch.device("cuda")          # a CUDA device object
    y = torch.ones_like(x, device=device)  # directly create a tensor on GPU
    x = x.to(device)                       # or just use strings ``.to("cuda")``
    z = x + y
    print(z)
    print(z.to("cpu", torch.double))       # ``.to`` can also change dtype together!
tensor([1.9993], device='cuda:0')
tensor([1.9993], dtype=torch.float64)

AUTOGRAD: AUTOMATIC DIFFERENTIATION

Central to all neural networks in PyTorch is the autograd package. Let’s first briefly visit this, and we will then go to training our first neural network.
The autograd package provides automatic differentiation for all operations on Tensors. It is a define-by-run framework, which means that your backprop is defined by how your code is run, and that every single iteration can be different.

Tensor

torch.Tensor is the central class of the package. If you set its attribute .requires_grad as True, it starts to track all operations on it. When you finish your computation you can call .backward() and have all the gradients computed automatically. The gradient for this tensor will be accumulated into .grad attribute.

x = torch.ones(2, 2, requires_grad=True)
print(x)
y = x + 2
print(y)
print(y.grad_fn)
z = y * y * 3
out = z.mean()
print(z, out)
tensor([[1., 1.],
        [1., 1.]], requires_grad=True)
tensor([[3., 3.],
        [3., 3.]], grad_fn=<AddBackward0>)
<AddBackward0 object at 0x7feca614d6d0>
tensor([[27., 27.],
        [27., 27.]], grad_fn=<MulBackward0>) tensor(27., grad_fn=<MeanBackward0>)
a = torch.randn(2, 2)
a = ((a * 3) / (a - 1))
print(a.requires_grad)
a.requires_grad_(True)
print(a.requires_grad)
b = (a * a).sum()
print(b.grad_fn)
False
True
<SumBackward0 object at 0x7feca617ddd0>

Gradients

out.backward()
print(x.grad)
tensor([[4.5000, 4.5000],
        [4.5000, 4.5000]])
x = torch.randn(3, requires_grad=True)
y = x * 2
while y.data.norm() < 1000:
    y = y * 2
print(y)
tensor([-1138.8549,   484.4676,   417.7082], grad_fn=<MulBackward0>)
v = torch.tensor([0.1, 1.0, 0.0001], dtype=torch.float)
y.backward(v)

print(x.grad)
tensor([1.0240e+02, 1.0240e+03, 1.0240e-01])
# stop autograd from tracking history on Tensors with .requires_grad=True either by wrapping the code block in with torch.no_grad():
print(x.requires_grad)
print((x ** 2).requires_grad)

with torch.no_grad():
    print((x ** 2).requires_grad)
    
# Or by using .detach() to get a new Tensor with the same content but that does not require gradients:
print(x.requires_grad)
y = x.detach()
print(y.requires_grad)
print(x.eq(y).all())
True
True
False
True
False
tensor(True)

Neural networks

A typical training procedure for a neural network is as follows:

  • Define the neural network that has some learnable parameters (or weights)
  • Iterate over a dataset of inputs
  • Process input through the network
  • Compute the loss (how far is the output from being correct)
  • Propagate gradients back into the network’s parameters
  • Update the weights of the network, typically using a simple update rule: weight = weight - learning_rate * gradient
import torch
import torch.nn as nn
import torch.nn.functional as F


class Net(nn.Module):

    def __init__(self):
        super(Net, self).__init__()
        # 1 input image channel, 6 output channels, 3x3 square convolution
        # kernel
        self.conv1 = nn.Conv2d(1, 6, 3)
        self.conv2 = nn.Conv2d(6, 16, 3)
        # an affine operation: y = Wx + b
        self.fc1 = nn.Linear(16 * 6 * 6, 120)  # 6*6 from image dimension
        self.fc2 = nn.Linear(120, 84)
        self.fc3 = nn.Linear(84, 10)

    def forward(self, x):
        # Max pooling over a (2, 2) window
        x = F.max_pool2d(F.relu(self.conv1(x)), (2, 2))
        # If the size is a square you can only specify a single number
        x = F.max_pool2d(F.relu(self.conv2(x)), 2)
        x = x.view(-1, self.num_flat_features(x))
        x = F.relu(self.fc1(x))
        x = F.relu(self.fc2(x))
        x = self.fc3(x)
        return x

    def num_flat_features(self, x):
        size = x.size()[1:]  # all dimensions except the batch dimension
        num_features = 1
        for s in size:
            num_features *= s
        return num_features


net = Net()
print(net)
Net(
  (conv1): Conv2d(1, 6, kernel_size=(3, 3), stride=(1, 1))
  (conv2): Conv2d(6, 16, kernel_size=(3, 3), stride=(1, 1))
  (fc1): Linear(in_features=576, out_features=120, bias=True)
  (fc2): Linear(in_features=120, out_features=84, bias=True)
  (fc3): Linear(in_features=84, out_features=10, bias=True)
)
params = list(net.parameters())
print(len(params))
print(params[0].size())  # conv1's .weight
10
torch.Size([6, 1, 3, 3])
# feed a random input
input = torch.randn(1, 1, 32, 32)
out = net(input)
print(out)
tensor([[-0.0082, -0.0266,  0.0843,  0.0188,  0.1456, -0.1081, -0.0937,  0.0086,
         -0.0356,  0.0723]], grad_fn=<AddmmBackward>)
# Zero the gradient buffers of all parameters and backprops with random gradients:
net.zero_grad()
out.backward(torch.randn(1, 10))

Loss Function

output = net(input)
target = torch.randn(10)  # a dummy target, for example
target = target.view(1, -1)  # make it the same shape as output
criterion = nn.MSELoss()

loss = criterion(output, target)
print(loss)
tensor(1.0206, grad_fn=<MseLossBackward>)
print(loss.grad_fn)  # MSELoss
print(loss.grad_fn.next_functions[0][0])  # Linear
print(loss.grad_fn.next_functions[0][0].next_functions[0][0])  # ReLU
<MseLossBackward object at 0x7fec9c351350>
<AddmmBackward object at 0x7feca617d490>
<AccumulateGrad object at 0x7fec9c351350>

Backprop

net.zero_grad()     # zeroes the gradient buffers of all parameters

print('conv1.bias.grad before backward')
print(net.conv1.bias.grad)

loss.backward()

print('conv1.bias.grad after backward')
print(net.conv1.bias.grad)
conv1.bias.grad before backward
tensor([0., 0., 0., 0., 0., 0.])
conv1.bias.grad after backward
tensor([-0.0241, -0.0161, -0.0086, -0.0032,  0.0125,  0.0005])

Update the weights

# using sgd
learning_rate = 0.01
for f in net.parameters():
    f.data.sub_(f.grad.data * learning_rate)

# using custom optimizer in torch.optim
import torch.optim as optim

# create your optimizer
optimizer = optim.SGD(net.parameters(), lr=0.01)

# in your training loop:
optimizer.zero_grad()   # zero the gradient buffers
output = net(input)
loss = criterion(output, target)
loss.backward()
optimizer.step()    # Does the update

Training a classifier

What about data?

When you have to deal with image, text, audio or video data, you can use standard python packages that load data into a numpy array. Then you can convert this array into a torch.*Tensor.

  • For images, packages such as Pillow, OpenCV are usefu
  • For audio, packages such as scipy and librosa
  • For text, either raw Python or Cython based loading, or NLTK and SpaCy are useful

Training an image classifier

  • Load and normalizing the CIFAR10 training and test datasets using torchvision
  • Define a Convolutional Neural Network
  • Define a loss function
  • Train the network on the training data
  • Test the network on the test data

Loading and normalizing CIFAR10

import torch
import torchvision
import torchvision.transforms as transforms

transform = transforms.Compose(
    [transforms.ToTensor(),
     transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))])

trainset = torchvision.datasets.CIFAR10(root='./data', train=True,
                                        download=True, transform=transform)
trainloader = torch.utils.data.DataLoader(trainset, batch_size=4,
                                          shuffle=True, num_workers=2)

testset = torchvision.datasets.CIFAR10(root='./data', train=False,
                                       download=True, transform=transform)
testloader = torch.utils.data.DataLoader(testset, batch_size=4,
                                         shuffle=False, num_workers=2)

classes = ('plane', 'car', 'bird', 'cat',
           'deer', 'dog', 'frog', 'horse', 'ship', 'truck')
Files already downloaded and verified
Files already downloaded and verified
%matplotlib inline
import matplotlib.pyplot as plt
import numpy as np

# functions to show an image


def imshow(img):
    img = img / 2 + 0.5     # unnormalize
    npimg = img.numpy()
    plt.imshow(np.transpose(npimg, (1, 2, 0)))
    plt.show()


# get some random training images
dataiter = iter(trainloader)
images, labels = dataiter.next()

# show images
imshow(torchvision.utils.make_grid(images))
# print labels
print(' '.join('%5s' % classes[labels[j]] for j in range(4)))

output_49_0

  cat   cat  deer  ship

Define a Convolutional Neural Network

import torch.nn.functional as F
import torch.nn as nn


class Net(nn.Module):
    def __init__(self):
        super(Net, self).__init__()
        self.conv1 = nn.Conv2d(3, 6, 5)
        self.pool = nn.MaxPool2d(2, 2)
        self.conv2 = nn.Conv2d(6, 16, 5)
        self.fc1 = nn.Linear(16 * 5 * 5, 120)
        self.fc2 = nn.Linear(120, 84)
        self.fc3 = nn.Linear(84, 10)

    def forward(self, x):
        x = self.pool(F.relu(self.conv1(x)))
        x = self.pool(F.relu(self.conv2(x)))
        x = x.view(-1, 16 * 5 * 5)
        x = F.relu(self.fc1(x))
        x = F.relu(self.fc2(x))
        x = self.fc3(x)
        return x
    
net = Net()

Define a Loss function and optimizer

import torch.optim as optim

criterion = nn.CrossEntropyLoss()
optimizer = optim.SGD(net.parameters(), lr=0.001, momentum=0.9)

Train the network

for epoch in range(2):  # loop over the dataset multiple times
    running_loss = 0.0
    for i, data in enumerate(trainloader):
#         print(data)
        # get the inputs; data is a list of [inputs, labels]
        inputs, labels = data
#         print(inputs, labels)
        # zero the parameter gradients
        optimizer.zero_grad()

        # forward + backward + optimize
        outputs = net(inputs)
        loss = criterion(outputs, labels)
        loss.backward()
        optimizer.step()

        # print statistics
        running_loss += loss.item()
        if i % 2000 == 1999:    # print every 2000 mini-batches
            print('[%d, %5d] loss: %.3f' %
                  (epoch + 1, i + 1, running_loss / 2000))
            running_loss = 0.0

print('Finished Training')
[1,  2000] loss: 2.203
[1,  4000] loss: 1.875
[1,  6000] loss: 1.680
[1,  8000] loss: 1.563
[1, 10000] loss: 1.480
[1, 12000] loss: 1.474
[2,  2000] loss: 1.397
[2,  4000] loss: 1.365
[2,  6000] loss: 1.350
[2,  8000] loss: 1.321
[2, 10000] loss: 1.302
[2, 12000] loss: 1.300
Finished Training
PATH = './cifar_net.pth'
torch.save(net.state_dict(), PATH)
dataiter = iter(testloader)
images, labels = dataiter.next()

# print images
imshow(torchvision.utils.make_grid(images))
print(labels)
print('GroundTruth: ', ' '.join('%5s' % classes[labels[j]] for j in range(4)))

output_57_0

tensor([3, 8, 8, 0])
GroundTruth:    cat  ship  ship plane
net = Net()
net.load_state_dict(torch.load(PATH))

<All keys matched successfully>
outputs = net(images)
print(outputs)
_, predicted = torch.max(outputs, 1)
print(predicted)
print('Predicted: ', ' '.join('%5s' % classes[predicted[j]]
                              for j in range(4)))
tensor([[-8.1609e-01, -7.3227e-01,  1.9178e-01,  1.9166e+00, -9.6811e-01,
          8.3362e-01,  1.1127e+00, -1.4818e+00,  3.7150e-01, -7.1563e-01],
        [ 7.2351e+00,  4.0154e+00,  1.7224e-03, -2.3247e+00, -2.0117e+00,
         -4.3768e+00, -4.1172e+00, -4.6825e+00,  8.7625e+00,  2.0940e+00],
        [ 3.9245e+00,  1.3894e+00,  6.4428e-01, -1.0531e+00, -8.4998e-01,
         -2.2757e+00, -2.7469e+00, -2.2073e+00,  4.4428e+00,  6.6101e-01],
        [ 4.5622e+00, -6.9576e-02,  1.1598e+00, -8.8092e-01,  9.0635e-01,
         -2.1905e+00, -1.8022e+00, -2.2323e+00,  4.0340e+00, -7.8086e-01]],
       grad_fn=<AddmmBackward>)
tensor([3, 8, 8, 0])
Predicted:    cat  ship  ship plane
correct = 0
total = 0
with torch.no_grad():
    for data in testloader:
        images, labels = data
        outputs = net(images)
        _, predicted = torch.max(outputs.data, 1)
        total += labels.size(0)
        correct += (predicted == labels).sum().item()

print('Accuracy of the network on the 10000 test images: %d %%' % (
    100 * correct / total))
Accuracy of the network on the 10000 test images: 55 %
class_correct = list(0. for i in range(10))
class_total = list(0. for i in range(10))
with torch.no_grad():
    for data in testloader:
        images, labels = data
        outputs = net(images)
        _, predicted = torch.max(outputs, 1)
        c = (predicted == labels).squeeze()
        for i in range(4):
            label = labels[i]
            class_correct[label] += c[i].item()
            class_total[label] += 1


for i in range(10):
    print('Accuracy of %5s : %2d %%' % (
        classes[i], 100 * class_correct[i] / class_total[i]))
Accuracy of plane : 66 %
Accuracy of   car : 59 %
Accuracy of  bird : 36 %
Accuracy of   cat : 38 %
Accuracy of  deer : 56 %
Accuracy of   dog : 27 %
Accuracy of  frog : 73 %
Accuracy of horse : 59 %
Accuracy of  ship : 72 %
Accuracy of truck : 63 %

Training on GPU

device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")

# Assuming that we are on a CUDA machine, this should print a CUDA device:

print(device)
net.to(device)


for epoch in range(2):  # loop over the dataset multiple times
    running_loss = 0.0
    for i, data in enumerate(trainloader):
#         print(data)
        # get the inputs; data is a list of [inputs, labels]
        inputs, labels = data[0].to(device), data[1].to(device)
#         print(inputs, labels)
        # zero the parameter gradients
        optimizer.zero_grad()

        # forward + backward + optimize
        outputs = net(inputs)
        loss = criterion(outputs, labels)
        loss.backward()
        optimizer.step()

        # print statistics
        running_loss += loss.item()
        if i % 2000 == 1999:    # print every 2000 mini-batches
            print('[%d, %5d] loss: %.3f' %
                  (epoch + 1, i + 1, running_loss / 2000))
            running_loss = 0.0

print('Finished Training')
cuda:0
[1,  2000] loss: 1.195
[1,  4000] loss: 1.204
[1,  6000] loss: 1.204
[1,  8000] loss: 1.188
[1, 10000] loss: 1.207
[1, 12000] loss: 1.228
[2,  2000] loss: 1.191
[2,  4000] loss: 1.209
[2,  6000] loss: 1.202
[2,  8000] loss: 1.209
[2, 10000] loss: 1.214
[2, 12000] loss: 1.203
Finished Training
net = Net()
net.load_state_dict(torch.load(PATH))

correct = 0
total = 0
with torch.no_grad():
    for data in testloader:
        images, labels = data
        outputs = net(images)
        _, predicted = torch.max(outputs.data, 1)
        total += labels.size(0)
        correct += (predicted == labels).sum().item()

print('Accuracy of the network on the 10000 test images: %d %%' % (
    100 * correct / total))
Accuracy of the network on the 10000 test images: 55 %

Training on multi gpus

if torch.cuda.device_count() > 1:
  print("Let's use", torch.cuda.device_count(), "GPUs!")

net = nn.DataParallel(net)
net.to(device)
for epoch in range(4):  # loop over the dataset multiple times
    running_loss = 0.0
    for i, data in enumerate(trainloader):
#         print(data)
        # get the inputs; data is a list of [inputs, labels]
        inputs, labels = data[0].to(device), data[1].to(device)
#         print(inputs, labels)
        # zero the parameter gradients
        optimizer.zero_grad()

        # forward + backward + optimize
        outputs = net(inputs)
        loss = criterion(outputs, labels)
        loss.backward()
        optimizer.step()

        # print statistics
        running_loss += loss.item()
        if i % 2000 == 1999:    # print every 2000 mini-batches
            print('[%d, %5d] loss: %.3f' %
                  (epoch + 1, i + 1, running_loss / 2000))
            running_loss = 0.0

print('Finished Training')
Let's use 2 GPUs!
[1,  2000] loss: 1.202
[1,  4000] loss: 1.197
[1,  6000] loss: 1.202
[1,  8000] loss: 1.194
[1, 10000] loss: 1.211
[1, 12000] loss: 1.210
[2,  2000] loss: 1.208
[2,  4000] loss: 1.184
[2,  6000] loss: 1.215
[2,  8000] loss: 1.198
[2, 10000] loss: 1.201
[2, 12000] loss: 1.208
[3,  2000] loss: 1.206
[3,  4000] loss: 1.209
[3,  6000] loss: 1.198
[3,  8000] loss: 1.206
[3, 10000] loss: 1.209
[3, 12000] loss: 1.208
[4,  2000] loss: 1.203
[4,  4000] loss: 1.206
[4,  6000] loss: 1.203
[4,  8000] loss: 1.187
[4, 10000] loss: 1.220
[4, 12000] loss: 1.207
Finished Training

2_gpus

2020/02/04 posted in  pytorch

TensorFlow Sparse的一个优化

分布式TensorFlow在Sparse模型上的实践
前言
如果你去搜TensorFlow教程,或者分布式TensorFlow教程,可能会搜到很多mnist或者word2vec的模型,但是对于Sparse模型(LR, WDL等),研究的比较少,对于分布式训练的研究就更少了。最近刚刚基于分布式TensorFlow上线了一个LR的CTR模型,支持百亿级特征,训练速度也还不错,故写下一些个人的心得体会,如果有同学也在搞类似的模型欢迎一起讨论。

训练相关
集群setup
分布式TensorFlow中有三种角色: Master(Chief), PS和Worker. 其中Master负责训练的启动和停止,全局变量的初始化等;PS负责存储和更新所有训练的参数(变量); Worker负责每个mini batch的梯度计算。如果去网上找分布式TensorFlow的教程,一个普遍的做法是使用Worker 0作为Master, 例如这里官方给出的例子。我个人比较建议把master独立出来,不参与训练,只进行训练过程的管理, 这样有几个好处:

独立出Master用于训练的任务分配,状态管理,Evaluation, Checkpoint等,可以让代码更加清晰。
Master由于任务比worker多,往往消耗的资源如CPU/内存也和worker不一样。独立出来以后,在k8s上比较容易分配不同的资源。
一些训练框架如Kubeflow使用Master的状态来确定整个任务的状态。
训练样本的分配
在sparse模型中,采用分布式训练的原因一般有两种:

数据很多,希望用多个worker加速。
模型很大,希望用多个PS来加速。
前一个就涉及到训练样本怎么分配的问题。从目前网上公开的关于分布式TF训练的讨论,以及官方分布式的文档和教程中,很少涉及这个问题,因此最佳实践并不清楚。我目前的解决方法是引入一个zookeeper进行辅助。由master把任务写到zookeeper, 每个worker去读取自己应该训练哪些文件。基本流程如下:

master

file_list = list_dir(flags.input_dir)
for worker, worker_file in zip(workers, file_list):
self.zk.create(f'/jobs/{worker}', ','.join(worker_file).encode('utf8'), makepath=True)

start master session ...

worker

wait_for_jobs = Semaphore(0)
@self.zk.DataWatch(f"/jobs/{worker}")
def watch_jobs(jobs, _stat, _event):
if jobs:
data_paths = jobs.decode('utf8').split(',')
self.train_data_paths = data_paths
wait_for_jobs.release()

wait_for_jobs.acquire()

start worker session ...

引入zookeeper之后,文件的分配就变得非常灵活,并且后续也可以加入使用master监控worker状态等功能。

使用device_filter
启动分布式TF集群时,每个节点都会启动一个Server. 默认情况下,每个节点都会跟其他节点进行通信,然后再开始创建Session. 在集群节点多时,会带来两个问题:

由于每个节点两两通信,造成训练的启动会比较慢。
当某些worker挂掉重启(例如因为内存消耗过多),如果其他某些worker已经跑完结束了,重启后的worker就会卡住,一直等待其他的worker。此时会显示log: CreateSession still waiting for response from worker: /job:worker/replica:0/task:x.
解决这个问题的方法是使用device filter. 例如:

config = tf.ConfigProto(device_filters=["/job:ps", f"/job:{self.task_type}/task:{self.task_index}"])

with tf.train.MonitoredTrainingSession(
...,
config=config
):
...
这样,每个worker在启动时就只会和所有的PS进行通信,而不会和其他worker进行通信,既提高了启动速度,又提高了健壮性。

给session.run加上timeout
有时在一个batch比较大时,由于HDFS暂时不可用或者延时高等原因,有可能会导致session.run卡住。可以给它加一个timeout来提高程序的健壮性。

session.run(fetches, feed_dict, options=tf.RunOptions(timeout_in_ms=timeout))
优雅的停止训练
在很多分布式的例子里(例如官方的这个),都没有涉及到训练怎么停止的问题。通常的做法都是这样:

server = tf.train.Server(
cluster, job_name=FLAGS.job_name, task_index=FLAGS.task_index)
if FLAGS.job_name == "ps":
server.join()
这样的话,一旦开始训练,PS就会block, 直到训练结束也不会停止,只能暴力删除任务(顺便说一下,MXNet更粗暴,PS运行完import mxnet就block了)。前面我们已经引入了zookeeper, 所以可以相对比较优雅的解决这个问题。master监控worker的状态,当worker全部完成后,master设置自己的状态为完成。PS和worker检测到master完成后,就结束自己。

def join():
while True:
status = self.zk.get_async('/status/master').get(timeout=3)
if status == 'done':
break
time.sleep(10)

master

for worker in workers:
status = self.zk.get_async(f'/status/worker/{worker}').get(timeout=3)
update_all_workers_done()
if all_workers_done:
self.zk.create('/status/master', b'done', makepath=True)

ps

join()

worker

while has_more_data:
train()
self.zk.set('/status/worker/{worker}', b'done')
join()
Kubeflow的使用
Kubeflow是一个用于分布式TF训练提交任务的项目。它主要由两个部分组成,一是ksonnet,一是tf-operator。ksonnet主要用于方便编写k8s上的manifest, tf-operator是真正起作用的部分,会帮你设置好TF_CONFIG这个关键的环境变量,你只需要设置多少个PS, 多少个worker就可以了。个人建议用ksonnet生成一个skeleton, 把tf-operator的manifest打印出来(使用ks show -c default), 以后就可以跳过ksonnet直接用tf-operator了。因为k8s的manifest往往也需要修改很多东西,最后还是要用程序生成,没必要先生成ksonnet再生成yaml.

Estimator的局限性
一开始我使用的是TF的高级API Estimator, 参考这里的WDL模型. 后来发现Estimator有一些问题,没法满足目前的需要。

一是性能问题。我发现同样的linear model, 手写的模型是Estimator的性能的5-10倍。具体原因尚不清楚,有待进一步调查。

UPDATE: 性能问题的原因找到了,TL;DR: 在sparse场景使用Dataset API时, 先batch再map(parse_example)会更快。详情见: https://stackoverflow.com/questions/50781373/using-feed-dict-is-more-than-5x-faster-than-using-dataset-api

二灵活性问题。Estimator API目前支持分布式的接口是train_and_evaluate, 它的逻辑可以从源代码里看到:

training.py

def run_master(self):
...
self._start_distributed_training(saving_listeners=saving_listeners)

if not evaluator.is_final_export_triggered:
    logging.info('Training has already ended. But the last eval is skipped '
                'due to eval throttle_secs. Now evaluating the final '
                'checkpoint.')
    evaluator.evaluate_and_export()

estimator.py

def export_savedmodel(...):
...
with tf_session.Session(config=self._session_config) as session:
saver_for_restore = estimator_spec.scaffold.saver or saver.Saver(
sharded=True)
saver_for_restore.restore(session, checkpoint_path)
...
builder = ...
builder.save(as_text)
可以看到Estimator有两个问题,一是运行完才进行evaluate, 但在大型的sparse model里,训练一轮可能要几个小时,我们希望一边训练一边evaluate, 在训练过程中就能尽快的掌握效果的变化(例如master只拿到test set, worker只拿到training set, 这样一开始就能evaluate)。二是每次导出模型时,Estimator API会新建一个session, 加载最近的一个checkpoint, 然后再导出模型。但实际上,一个sparse模型在TF里耗的内存可能是100多G,double一下会非常浪费,而且导出checkpoint也极慢,也是需要避免的。基于这两个问题,我暂时没有使用Estimator来进行训练。

使用feature column(但不使用Estimator)
虽然不能用Estimator, 但是使用feature column还是完全没有问题的。不幸的是网上几乎没有人这么用,所以我把我的使用方法分享一下,这其实也是Estimator的源代码里的使用方式:

define feature columns as usual

feature_columns = ...
example = TFRecordDataset(...).make_one_shot_iterator().get_next()
parsed_example = tf.parse_example(example, tf.feature_column.make_parse_example_spec(feature_columns))
logits = tf.feature_column.linear_model(
features=parsed_example,
feature_columns=feature_columns,
cols_to_vars=cols_to_vars
)
parse_example会把TFRecord解析成一个feature dict, key是feature的名称,value是feature的值。tf.feature_column.linear_model这个API会自动生成一个线性模型,并把变量(weight)的reference, 存在cols_to_vars这个字典中。如果使用dnn的模型,可以参考tf.feature_column.input_layer这个API, 用法类似,会把feature dict转换成一个行向量。

模型导出
使用多PS时,常见的做法是使用tf.train.replica_device_setter来把不同的变量放在不同的PS节点上。有时一个变量也会很大(这在sparse模型里很常见),也需要拆分到不同的PS上。这时候就需要使用partitioner:

partitioner = tf.min_max_variable_partitioner(
max_partitions=variable_partitions,
min_slice_size=64 << 20)
with tf.variable_scope(
...,
partitioner = partitioner
):
# define model
由于TensorFlow不支持sparse weight(注意: 跟sparse tensor是两码事,这里指的是被训练的变量不能是sparse的), 所以在PS上存的变量还是dense的,比如你的模型里有100亿的参数,那PS上就得存100亿。但实际上对于sparse模型,大部分的weight其实是0,最终有效的weight可能只有0.1% - 1%. 所以可以做一个优化,只导出非0参数:

indices = tf.where(tf.not_equal(vars_concat, 0))
values = tf.gather(vars_concat, indices)
shape = tf.shape(vars_concat)
self.variables[column_name] = {
'indices': indices,
'values': values,
'shape': shape,
}
这样就把变量的非0下标,非0值和dense shape都放进了self.variables里,之后就只导出self.variables即可。

此外, 在多PS的时候,做checkpoint的时候一定要记得enable sharding:

saver = tf.train.Saver(sharded=True, allow_empty=True)
...
这样在master进行checkpoint的时候,每个PS会并发写checkpoint文件。否则,所有参数会被传到master上进行集中写checkpoint, 消耗内存巨大。

导出优化
前面提到的导出非0参数的方法仍有一个问题,就是会把参数都传到一台机器(master)上,计算出非0再保存。这一点可以由grafana上的监控验证:

可以看到,每隔一段时间进行模型导出时,master的网络IO就会达到一个峰值,是平时的好几倍。如果能把non_zero固定到PS上进行计算,则没有这个问题。代码如下:

for var in var_or_var_list:
var_shape = var.save_slice_info.var_shape
var_offset = var.save_slice_info.var_offset
with tf.device(var.device):
var
= tf.reshape(var, [-1])
var_indices = tf.where(tf.not_equal(var
, 0))
var_values = tf.gather(var_, var_indices)

indices.append(var_indices + var_offset[0])
values.append(var_values)

indices = tf.concat(indices, axis=0)
values = tf.concat(values, axis=0)
进行了如上修改之后,master的网络消耗变成下图:

参考Tensorboard, 可以看到gather操作确实被放到了PS上进行运算:

模型上线
我们目前在线上prediction时没有使用tf-serving, 而是将变量导出用Java服务进行prediction. 主要原因还是TF的PS不支持sparse存储,模型在单台机器存不下,没法使用serving. 在自己实现LR的prediction时,要注意验证线上线下的一致性,有一些常见的坑,比如没加bias, default_value没处理,等等。

使用TensorBoard来理解模型
在训练时用TensorBoard来打出Graph,对理解训练的细节很有帮助。例如,我们试图想象这么一个问题:在分布式训练中,参数的更新是如何进行的?以下有两种方案:

worker计算gradients, ps合并gradients并进行更新
worker计算gradients, 计算应该update成什么值,然后发给ps进行更新
到底哪个是正确的?我们看一下TensorBoard就知道了: TensorBoard 可以看到,FTRL的Operation是在PS上运行的。所以是第一种。

Metrics
在TensorFlow中有一系列metrics, 例如accuracy, AUC等等,用于评估训练的指标。需要注意的是TensorFlow中的这些metrics都是在求历史平均值,而不是当前batch。所以如果训练一开始就进行评估,使用TensorFlow的AUC和Sklearn的AUC,会发现TensorFlow的AUC会低一些,因为TensorFlow的AUC其实是考虑了历史的所有样本。正因如此Estimator的evaluate才会有一个step的参数,例如运行100个step,就会得到这100个batch上的metric.

性能相关
profiling
要进行性能优化,首先必须先做profiling。可以使用tf.train.ProfilerHook来进行.

hooks = [tf.train.ProfilerHook(save_secs=60, output_dir=profile_dir, show_memory=True, show_dataflow=True)]
之后会写出timeline文件,可以使用chrome输入chrome://tracing来打开。

我们来看一个例子: 上图中IteratorGetNext占用了非常长的时间。这其实是在从HDFS读取数据。我们加入prefetch后,profile变成下面这个样子: 可以看到,加入prefetch以后HDFS读取延时和训练时间在相当的数量级,延时有所好转。

另一个例子: 可以看到, RecvTensor占用了大部分时间,说明瓶颈在PS上。经调查发现是partition太多(64)个而PS只有8个,造成传输数据效率下降。调整partition数量后变成如下:

使用Grafana进行性能监控
可以使用Grafana和heapster来进行集群节点以及Pod的监控,查看CPU,内存,网络等性能指标。

apiVersion: extensions/v1beta1
kind: Deployment
metadata:
name: monitoring-grafana
namespace: kube-system
spec:
replicas: 1
template:
metadata:
labels:
task: monitoring
k8s-app: grafana
spec:
containers:
- name: grafana
image: k8s.gcr.io/heapster-grafana-amd64:v4.4.3
ports:
- containerPort: 3000
protocol: TCP
volumeMounts:
- mountPath: /etc/ssl/certs
name: ca-certificates
readOnly: true
- mountPath: /var
name: grafana-storage
env:
- name: INFLUXDB_HOST
value: monitoring-influxdb
- name: GF_SERVER_HTTP_PORT
value: "3000"
# The following env variables are required to make Grafana accessible via
# the kubernetes api-server proxy. On production clusters, we recommend
# removing these env variables, setup auth for grafana, and expose the grafana
# service using a LoadBalancer or a public IP.
- name: GF_AUTH_BASIC_ENABLED
value: "false"
- name: GF_AUTH_ANONYMOUS_ENABLED
value: "true"
- name: GF_AUTH_ANONYMOUS_ORG_ROLE
value: Admin
- name: GF_SERVER_ROOT_URL
# If you're only using the API Server proxy, set this value instead:
# value: /api/v1/namespaces/kube-system/services/monitoring-grafana/proxy
value: /
volumes:
- name: ca-certificates
hostPath:
path: /etc/ssl/certs
- name: grafana-storage
emptyDir: {}

apiVersion: v1
kind: Service
metadata:
labels:
# For use as a Cluster add-on (https://github.com/kubernetes/kubernetes/tree/master/cluster/addons)
# If you are NOT using this as an addon, you should comment out this line.
kubernetes.io/cluster-service: 'true'
kubernetes.io/name: monitoring-grafana
name: monitoring-grafana
namespace: kube-system
spec:

In a production setup, we recommend accessing Grafana through an external Loadbalancer

or through a public IP.

type: LoadBalancer

You could also use NodePort to expose the service at a randomly-generated port

type: NodePort

ports:

  • port: 80
    targetPort: 3000
    selector:
    k8s-app: grafana
    部署到k8s集群之后,就可以分节点/Pod查看性能指标了。

ps/worker数量的设置
PS和worker的数量需要如何设置?这个需要大量的实验,没有绝对的规律可循。一般来说sparse模型由于每条样本的特征少,所以PS不容易成为瓶颈,worker:PS可以设置的大一些。在我们的应用中,设置到1:8会达到比较好的性能。当PS太多时variable的分片也多,网络额外开销会大。当PS太少时worker拉参数会发生瓶颈。具体的实验可以参见下节。

加速比实验
实验方法:2.5亿样本,70亿特征(使用hash bucket后),提交任务稳定后(20min)记录数据。

worker PS CPU/worker (cores) CPU/PS (cores) total CPU usage (cores) memory/worker memory/PS total memory k example/s Network IO
8 8 1.5-2 0.7-0.8 19-23 2-2.5G 15G 150G 52 0.36 GB/s
16 8 1.3-2.6 1.0-1.4 40 2-2.5G 15G 170G 72 0.64 GB/s
32 8 1.8-2.3 1.5-2.0 70 2.2-3.0G 15G 216G 130 1.2 GB/s
64 8 1.5-2 4.0-6.4 150 2.2-3G 15G 288G 250 2.3 GB /s
64 16 1.7 2.9 169 2.2G 7.5G 300G 260 2.5 GB/s
128 16 1.5-2 3.9-5 278 2.2G 7.5G 469G 440 4.2 GB/s
128 32 1.5 2.5-3 342 2.2G 4-4.5G 489G 420 4.1 GB/s
160 20 1.2-1.5 3.0-4.0 360 2-2.5G 6-7G 572G 500 4.9 GB/s
160 32 1.2-1.5 1.0-1.4 388 2-2.5G 4-4.5G 598G 490 4.8 GB/s
结论:

worker消耗的内存基本是固定的;PS消耗的内存只跟特征规模有关;
worker消耗的CPU跟训练速度和数据读取速度有关; PS消耗的CPU和worker:PS的比例有关;
TensorFlow的线性scalability是不错的, 加速比大约可以到0.7-0.8;
最后加到160worker时性能提升达到瓶颈,这里的瓶颈在于HDFS的带宽(已经达到4.9GB/s)
在sparse模型中PS比较难以成为瓶颈,因为每条样本的特征不多,PS的带宽相对于HDFS的带宽可忽略不计
Future Work
文件分配优化
如果worker平均需要1个小时的时间,那么整个训练需要多少时间?如果把训练中的速度画一个图出来,会是类似于下面这样:

在训练过程中我发现,快worker的训练速度大概是慢worker的1.2-1.5倍。而整个训练的时间取决于最慢的那个worker。所以这里可以有一个优化,就是动态分配文件,让每个worker尽可能同时结束。借助之前提到的zookeeper, 做到这一点不会很难。

online learning
在online learning时,其实要解决的问题也是任务分配的问题。仍由master借助zookeeper进行任务分配,让每个worker去读指定的消息队列分区。

PS优化
可以考虑给TensorFlow增加Sparse Variable的功能,让PS上的参数能够动态增长,这样就能解决checkpoint, 模型导出慢等问题。当然,这样肯定也会减慢训练速度,因为变量的保存不再是数组,而会是hashmap.

样本压缩
从前面的实验看到,后面训练速度上不去主要是因为网络带宽瓶颈了,读样本没有这么快。我们可以看看TFRecord的实现:

message Example {
Features features = 1;
};

message Feature {
// Each feature can be exactly one kind.
oneof kind {
BytesList bytes_list = 1;
FloatList float_list = 2;
Int64List int64_list = 3;
}
};

message Features {
// Map from feature name to feature.
map<string, Feature> feature = 1;
};
可以看到,TFRecord里面其实是保存了key和value的,在sparse模型里,往往value较小,而key很大,这样就造成很大的浪费。可以考虑将key的长度进行压缩,减少单条样本的大小,提高样本读取速度从而加快训练速度。

2019/12/20 posted in  TensorFlow

基础知识背景

与其说TensorFlow是一套框架,更愿意说TensorFlow是一套语言,TensorFlow灵活性特别大,其本质在于构建一个计算图,计算图的各个节点按照计算逻辑,与资源配置情况分配到不同的计算设备上来进行计算,计算图整体的计算资源,比如计算设备CPU、GPU、线程池是框架本身来灵活配置的,而灵活性过大导致了TensorFlow无论在计算图、计算资源的适配上都需要花费额外的功夫才能保证模型训练效率,加上TensorFlow API的多种不同的魔性版本(比如CV场景,TensorFlow历史上支持多个完全不同的API封装,如Slim、Estimator、Keras等等)、以及文档上很多模棱两可的工作,在TensorFlow写好高效的模型即使是对一名资深的算法从业人员也是一件相对复杂的工作。

本文会在先通过支持多个业务的TensorFlow任务学习到知识来分享在很多业务场景下TensorFlow效果不高的原因,基于这样的背景,我们希望和算法同学分享下在云音乐推荐场景下,TensorFlow标准化的一些工作。

TensorFlow低效的几个原因分析

低效数据读取

数据读取部分一直是业务同学特别忽略的一个过程,通常业务数据,尤其是推荐、搜索场景下的业务数据,整体量差异很大,从数G到数T不止,如果预先拉取数据到本地进行存储,会额外增加耗时,TF本身提供了HDFS的数据访问方法,也提供了一套相应地高效地读取HDFS数据的方法,但是存在各种各样的问题:

  1. 读取数据接口API未能明显标识那些是python级别的接口函数、哪些是C++的接口函数,误用python数据读取接口时,由于python本身全局锁问题,导致性能极低;
  2. 对数据进行操作时,由于本身python在这方面的便利以及算法同学对numpy、scipy、pandas比较熟悉,容易引入python的操作,也会造成1中遇到的问题;
  3. 使用Dataset的API时,由于一般在推荐场景下数据存储在hdfs这类存储系统上,一般的读取接口没有做专项优化,未使用优化过的数据读取API;
  4. 未合理进行向量化操作,具体比如对dataset做map操作时,应在batch前还是batch后,batch后是向量化操作,而batch前是单个单个处理,函数调用次数前者远低于后者;
  5. 在配置环境来读取hdfs文件时,我们发现,hadoop环境会默认配置MALLOC_ARENA_MAX环境变量,这个变量控制malloc的内存池,embedding_lookup_sparse的uniqueOp在被hadoop限制MALLOC_ARENA_MAX=4后会受很大影响;

频繁移动你的数据

在机器学习系统中,要想程序跑的快,有两个原则:

  1. 尽量减少数据的移动;
  2. 数据离计算尽可能近;

在写TF时,由于对API的不熟悉,很容易会造成数据的移动,通常这样不会产生太大的问题,但是在某些场景下,会导致大量的数据拷贝,不同设备之间的拷贝不仅会占用设备间的数据通道,也会耗费大量的原本可以用来计算的资源。
比较常见的问题是Graph内外数据的拷贝,如下code:

import tensorflow as tf
ds = read_from_tfrecord_fun(path)
with tf.Session() as sess:
  data,label = sess.run(ds)
    
... # build your model
train_op = build_model()
with tf.Session() as sess:
  while ...:
    sess.run(train_op, feed_dict={"data": data, "label": label})

这部分代码相当于把整个模型的训练分为两个部分:第一个部分将数据从tfrecord文件中拿出,第二个部分将从tfrecord拿出的数据再feed进训练网络。
从功能上这个没有什么问题,但是仔细想想,这里涉及到graph到python内存的拷贝,然后从python内存拷贝到训练的graph中,并且由于python本身性能和GIL的影响,这里的耗时极大,并且这个拷贝是随着训练一致存在,假设你数据大小为100G,训练10个epoch,整个训练过程相当于不停地从graph内拷贝到python进程空间,然后从python进程空间拷贝到graph内,整个数据量为2*10*100G=2T,这部分的耗时相对于训练时间占很大比例。

资源非必需原因消耗

这一块有大概几个方面:
Graph自增节点
如下代码

def build_model(model_path):
    model_input = tf.placeholder('float32', [1, IMAGE_HEIGHT, IMAGE_WIDTH, COLOR_CHANNELS])
    ...... 
    return model_input,vec1
    
def get_ops(vec):
    ...
    return new_vec

with tf.Session() as sess:
    sess.run(tf.global_variables_initializer())
    img_input, vec1_out = build_model("path/to/model")
    for cur_img_path in imgs_path_list: 
        cur_img = load_image(cur_img_path)
        vec1_out = sess.run(vec1, feed_dict = {img_input:cur_img})      # 正向传播输出模型中的vec1
        new_vec = get_ops(vec1_out)       

在对图像列表循环操作时,一直在不停地调用get_ops来产生新的节点,graph一直在变大,通常这个过程不会影响代码本身的正确性,但是由于一直在不停地扩展graph,会造成计算机资源一直在扩展graph,本身模型的计算会被大大地阻碍。

cache()
在使用dataset读取数据时,提供一个cache()的api,用于缓存读到之后处理完成的数据,在第一个epoch处理完成之后就会缓存下来,若cache()中未指明filename,则会直接缓存到内存,相当于把数据集所有数据缓存到了内存,直至内存爆掉;

不合理的gpu_config
TensorFlow在调用gpu时,由于仅在memory fraction层面来做了相关的切分来区分某个gpu被各自任务占用,这里会有很多问题,比如某个人物memory比较小,但是会一直占用gpu的kernel来进行计算,另外还有目前一些对于gpu的virtualDevices也会造成任务的速率有很大的问题,如下为config.proto中关于gpu的配置相关内容。

message GPUOptions {
  double per_process_gpu_memory_fraction = 1;
  bool allow_growth = 4;
  string allocator_type = 2;
  int64 deferred_deletion_bytes = 3;
  string visible_device_list = 5;
  int32 polling_active_delay_usecs = 6;
  int32 polling_inactive_delay_msecs = 7;
  bool force_gpu_compatible = 8;
  message Experimental {
    message VirtualDevices {
            repeated float memory_limit_mb = 1;
    }
  repeated VirtualDevices virtual_devices = 1;
  bool use_unified_memory = 2;
  int32 num_dev_to_dev_copy_streams = 3;
  string collective_ring_order = 4;
  bool timestamped_allocator = 5;
  int32 kernel_tracker_max_interval = 7;
  int32 kernel_tracker_max_bytes = 8;
  int32 kernel_tracker_max_pending = 9;
  }
  Experimental experimental = 9;
}

使用TensorFlow做模型计算之外的事情

TensorFlow在宣传时,期望all in TensorFlow, ALL In XX是所有开源框架的目标,但是是不可能的,所有系统都是一步一步的tradeoff的过程,在一方面性能的优势,必然在设计上会有另一方面的劣势。TensorFlow也不例外,尽管TensorFlow设计了很多data preprocess的api,这些api尽管在功能实现上完成了某些machine learning场景下基本的data preprocess的功能,但是从整个系统架构上来看,TensorFlow来进行data preprocess的工作本身并没有太多优势,相反会由于种种原因会造成很多额外的资源损耗与问题:

数据无法可视化

TensorFlow Data Preprocess Pipeline因为直接在graph内,其处理无法可视化,就是一些字节流,无法可视化,也无法验证准确性,机器学习是一个try and minimize error,如果仅能训练处数据而看不到数据,无异于让瞎子指挥交通,这一点上目前TensorFlow Data Process Pipeline并没有特别好的解决方案,传统的大数据解决方案相对来说会更具可行性。

更多适合data preprocess工具

其实,在数据处理上,有更多适合在Spark、Flink上去做,有几个方面的原因:

  1. 底层机器选择上,Spark、Flink的集群架构被用来设计做data preprocess,无论在性能、数据质量、各种指标监控上都有很成熟的经验,而深度学习主机并不适合来做data preprocess的工作;
  2. 一部分data preprocess的工作,尤其是计算复杂的data transform的工作可以计算完成之后cache到数据库当中,线上计算时不需要额外计算,而是直接缓存,如分桶、ID化这些工作;
  3. 被缓存的数据是one-stage finish的,而集成在TF中,后续模型上线这部分耗时会一直在存在,优化空间极易碰到天花板;
  4. 在实时场景下,我们通常会利用各种大数据工具来完成比如实时数据的校验,通过snapshot收集实时推荐反馈后的行为来形成正负样本,这块目前有很多有效的工具来完成,而使用TensorFlow Data Pipeline目前看来没有特别好的解决方案;

超参设计不合理

这类问题不能称得上是问题,只是算法在设计的时候应该考虑系统本身能力来做相关的优化,比如在考虑模型计算时间太长时,应该尽力保证large batch,这样会减少模型update的次数,从而从数据移动、数据拷贝的角度上来减少相关操作,关于large batch的training,facebook有相关的文章Training ImageNet in 1 Hour - Facebook Research来证明其实通过修改参数以及增加warm up可以达到STOA的指标。
另外包括模型结构、embedding、优化器,这类操作在某些场景下,也会严重影响模型训练的数据。这里分享一个case,关于adam优化器与embedding的一个TensorFlow的issue,当模型中包括一个embedding层时,比如亿级别的feature embedding,理论上模型只需更新其中出现过的feature对应的embedding,但是TensorFlow原始的adam会更新亿级别feature embedding中所有的feature embedding,而不是更新出现过的id embedding,当然TF针对此场景有LazyAdamOptimizer,但是坦白来讲效率也不高。因此,超参的不合理也是影响TensorFlow低效训练的因素之一。

Ironbaby: TensorFlow Recommendation Framework

由于TensorFlow做推荐相关算法开发有以上很多零零碎碎的问题,我们组开发了一套基于TensorFlow的推荐框架:Ironbaby,Ironbaby通过封装基本的操作接口,来固化下来一些比较对计算系统比较友好的操作,一方面能够有效地提高算法的运行效率;另一方面,由于标准化基本操作之后,能够更简便的对系统进行二次开发,比如机器学习平台的容器化改造,比如更方便地对接精排系统提供上线服务等等。
Ironbaby主要从以下几个方面来简化我们模型的构造以及训练过程:

  1. Ironbaby支持Sparse、Dense、Sequence三类数据接口,其中Sparse为稀疏类特征,Dense为稠密特征,Sequence类为不定长但有先后顺序关系的数据,每一个接入Ironbaby的任务,需保证数据处理为这三类之一,Ironbaby支持这三类数据的有效读写与处理,支持包括本地磁盘、cephfs、hdfs、kafka的数据读取方案,也提供local script以及spark script脚本来完成转换,其中spark仅需要在保存tfrecord时,保证column的type即可;
  2. Ironbaby推荐使用配置文件来完成任务模型信息的配置,配置文件的方式能够有效地统一平台对于任务的管理方式,比如任务的定时调度、任务信息监控等等;
  3. 更抽象层的封装,TensorFlow本身有多套相关的api来完成这些工作,但是其文档组织太过于繁琐且复杂,Ironbaby目前专注于推荐场景,对一些经常使用的部分进行了比较好的封装,比如cross module,可以通过简单配置来完成不同cross模块的支持;
  4. 基于estimator + tfrecord的高效数据读取方案,既能保证高效地数据读取、模型训练(杜绝了graph 内外拷贝的风险),也能够通过saved model的方式一键部署在业务系统完成在线推理;
  5. Ironbaby目前未使用任何data preprocess的api,我们任务进入Ironbaby的数据为处理好的数据,Ironbaby只负责模型计算的部分,其他如数据预处理应该有其他的更有效的数据处理工具来完成;
  6. Ironbaby严格控制TensorFlow中不同版本各类api的使用,比如不会频繁引入contrib中的api,所有Ironbaby中使用的api均是测试成功,能够保证基础性能,Ironbaby能够有效缓冲TensorFlow频繁更新与业务代码历史包袱的矛盾;
  7. Ironbaby通过标准化我们的模型训练,也能从另一方面去方便标准化我们模型训练周边相关的服务,比如模型监控、业务数据的监控、在线推理数据监控等等;

最后一点可能是所有规范化SDK的作用,业务同学如果能够使用统一APi完成模型的构建,在需要工程、框架同学支持时,能够有效地减少团队间沟通成本,没有规范的模型代码,需要更多地时间去消费其与问题不相关的逻辑,日积月累,这样的沟通成本对于整个团队都是不可忽视的。

目前Ironbaby已支持的功能

  • 基础模型的封装:如DeepFM, Wide and Deep, XDeepFM, fibinet等模型已经实现,仅需要修改配置文件即可完成模型的训练、评估、离线预测、导出等功能;
  • 支持扩展自定义网络结构:用户仅需要继承BabyModel,然后重写build_model_fn即可;
  • 默认支持TensorBoard,能够有效地观测模型训练参数;
  • 支持模型运行的训练、评估、离线预测、导出成saved_model提供给精排在线推理;
  • 支持TensorFlow的parameter server分布式协议,仅需要配置文件配置好相关目录,后续会基于goblin打通k8s,完成资源的动态分配;

后言

任何对于Ironbaby感兴趣的想要尝试的同学欢迎联系我们团队,我们会提供持续的解决方案与相关能力的培训,目前已完成的文档,后续我们也会在我们gitlab项目主页上开放我们的roadmap,欢迎大家多多讨论

2019/12/20 posted in  TensorFlow

推荐系统灌水文之如何理解Item

Item 是数据系统中的『物』,理解『物』和借『人』是数据系统本身最核 心的两个方面,本章为方便描述,将以电商场景下的数据系统中对『物』的理解。

得到Item之前

这里仅以电商为例,仅表达在通常到算法同学手上之前,已经经过很多比较复杂的系统来进行抽象,一个好的数据工作人员也应该有一些基础的了解,比如针对供应链体系的电商化的数据体系。

SPU(Standard Product Unit)标准化产品单元,即以产品为一个单元, 如华为 mate 20 pro,就是一个 SPU,主要是描述产品,但是通常对一个用 户看到的产品会有其他的属性来描述,如内存大小:64g、128g 的价格明显不 一致,子型号等等,因此,SPU 不足以应用在数据系统中,而 SKU(Stock Keeping Unit)针对的是不同规格的商品,商品的所有不同规则的组合就是一个SKU,如下图所示:
spu_sku
而本章将从类目属性、基础属性、UGC,PGC 三个维度来讲述如何理解 电商中的 Item:SKU。

类目体系

类别也是描述 sku 的一部分信息,电商体系的商品类目〸分复杂,通常类目体系分为三级(以前在1号店工作经验,现在也应该是一致的),以京东页面为例:
jd_leimu

电商类目体系实际上是三层(或者更多)的树,叶子节点也就是第三级类目数量大概是 数千个(以前接触大概是 3000 多个,现在大厂的类别体系规模可能更大),每一个sku 类目信息有这三级构成。

类目树

jd_leimu_tree

上图是京东首页的家居-家具的一些子类目示意图,这些类目本身是特别有信息量的,而这部分信息由于是商家在商家商品时去选择的,其实还是比较有工作量的,另外只要人参与,就会出错,有的时候由于电商本身对一些类目 有补贴、优惠,商家会出于利益的目的,故意选错类目,这些噪声,对于理解 SKU 是有负向作用的,为减少工作量以及减少分类错误率,引入机器学习算 法对商品进行自动化分类是很有必要的。

分类信息

考虑到搜索、推荐等核心业务都调用商品的类目数据,为了降低类目错误 对核心业务的影响,可以使用机器学习方法设计模型通过商品标题来对商品进 行分类,即可定位为文本分类问题,且由于商品标题均不长,为短文本分类问 题。

传统文本分类方法

频次法: 顾名思义,〸分简单,记录每篇文章的次数分布,然后将分布输 入机器学习模型,训练一个合适的分类模型,对这类数据进行分类,需要指出 的时,在统计次数分布时,可合理提出假设,频次比较小的词对文章分类的影 响比较小,因此我们可合理地假设阈值,滤除频次小于阈值的词,减少特征空 间维度

TF-IDF:相对于频次法,有更进一步的考量,词出现的次数能从一定程 度反应文章的特点,即 TF,而 TF-IDF,增加了所谓的反文档频率,如果一 个词在某个类别上出现的次数多,而在全部文本上出现的次数相对比较少,我 们认为这个词有更强大的文档区分能力,TF-IDF 就是综合考虑了频次和反文 档频率两个因素。

互信息方法:也是一种基于统计的方法,计算文档中出现词和文档类别的 相关程度,即互信息

N-Gram:基于 N-Gram 的方法是把文章序列,通过大小为 N 的窗口, 形成一个个Group,然后对这些 Group 做统计,滤除出现频次较低的Group, 把这些 Group 组成特征空间,传入分类器,进行分类。

基于深度学习文本分类方法

这部分后续要详细扩展,现在先不卡在这儿了

TextCNN: 基于 cnn 的文 本分类方法,使用不同大小 filter 的 cnn 网络,filter 的大小即为词上下文长 度,如下图,红色为 2k,黄色为 3k,不同 filter 形成不同的 feature map,最后经过 maxpool,将所有的 channel 拼接到一起基于 cnn 的方法,设计不同 的上下文长度来建模上下文关系,但是很明显,丢失大部分的上下文关系。
textcnn

rcnn: rcnn 将每一个词表示为向量化,这个向量化过程会考虑上文与下 文关系,是一个双向过程,整个网络设计如下:
rcnn

以”A sunset stroll along the South Bank affords an array of stunning van- tage points” 为例:stroll 的表示包括 c l (stroll), preword2vec(stroll), c r (stroll), c l (stroll) 编码”A sunset” 的语义,而 c r (stroll) 向量化过程会考虑下文”along the South Bank affords an array of stunning vantage points” 的语义信息,每 一个词都如此处理,因此会避免普通 cnn 方法的上下文缺失的信息。LSTM 基于 CNN 的方法中第一种类似,直接暴力地在 embedding 之后加入 LSTM, 然后输出到一个 FC 进行分类,基于 LSTM 的方法,我觉得这也是一种特征 提取方式,可能比较偏向建模时序的特征;

C-LSTM C-LSTM 将 embedding 输出不直接接入 LSTM,而是接入到 cnn,通过 cnn 得到一些序列,然后把这些序列再接入 到 LSTM 来分类。

char-cnn char-cnn 不依赖分词工具,直接将字引入 cnn 中,尤其在短文本分类场景中,分词后,词出现频率极度稀疏,而字级别的 char-cnn 相对频率较高,效果更好。

Pretrain model based Bert 当然还有基于Bert这类模型来finetuning进行文本分类的模型,基于Transformer改造的超大的模型, 并且已经取得STOA效果,下篇文章会在某个真实数据集上详细对比一些经典模型;

基础属性

下图是京东的页面上,华为 mate20 pro 8g+128g 版本的,可以看出 SKU 是由一套自由的属性值组合,比如品牌:华为、内存:8g、CPU:海思等等, 在电商场景下每一个类别都有不同的属性,这些不同属性没有严格的层次机 构, 很难像常规的特征工程量化这部分信息, 且属性、属性值的体系随着 sku 的添加会一直动态扩展, 通常这部分特征会以 key-val 形式 ID 化保存。
sku_mate20pro
经过处理之后,mate 20pro,在数据系统中,基础属性部分的特征可以这样表示:
sku_attri_val_table
如此,一个sku 可以由这些属性 ID 与属性值 ID 表示,所有的信息就如此的 ID 化,当然有一些如机身重量这样的属性,其属性值可能为连续值,可以选择编码其ID为负数(简单举例,仅表达应具有区别度,实际不一定适用类似方法),并将数值编码到负数 ID 中,即说明该 feature 为连续值,后续可做离散化处理;

UGC、PGC

sku特征的第三类来源于 UGC 或者 PGC 的内容,如通过对电商标题、展示图像这类PGC信息的挖掘尝试的特征,以及通过用户评论、负反馈、各种不同行为如分享到社交网络、加车后删除等等。
jd_pg
jd_ug

2019/12/15 posted in  推荐系统

TensorFlow优化

这段时间,工作当中杂事比较多,有点像充当产品经理,去给算法业务的同学去安利我们最近完成的一些东西,感觉自己几乎没啥提升,希望这样的日子快点过去,公众号也落下了,主要原因是最近事比较多,又加上还迷上了抖音 什么东北酱,周末一躺就过去了。加上最近网上暴力被裁事情、明星猝死,突然发现中年危机可能就要在眼前,作为最老一批90年后,也马上要30了,但是觉得荒废下去好像也是蛮爽的。哎,迷茫而惆怅的中年危机恐怕是真的要到来了。回到正题,好久没有写过技术文章,最近又开始在读XDL的代码,这次我重点关注在底层,真的觉得质量很高,无愧是阿里妈妈吗的工作,但是无奈基础太差,又加上XDL除了代码开头的license生命几乎没有一行注释, 这里如果有XDL的小伙伴看到,希望把注释补一补,东西是不错,但是也要考虑下我们的水平吧。

本章文章是看到XDL代码之后,发现它自己写了自己的内存管理,这块我之前完全不懂,google一波才明白一些,而TensorFlow本身其实也有一些memory allocation的工作,本章会详细说明下BFC这块的工作。

TensorFlow Memory Manage

BFC是Best fit with coalescing的缩写,下面截图是TensorFlow源码config.proto,其中有关于内存、显存管理的注释文档,BFC是dlmalloc的一个简化版本,DLmalloc是啥,大家不明白没关系,直接理解为内存分配上一个特别硬核的工作,当前很多内存分配的算法很多受其影响。【memory allocate是操作系统下特别复杂的一项工作,如要系统了解,建议读读下OS经典教材memoey manage的几章】。

TensorFlow Memory Allocator

BFCAllocator

TensorFlow针对不同的设备,比如cpu下的mkl的allocator,cuda下的allocator,其实现都是有很大的差异的。本文不可能一一详述,仅针对bfc_allocator相关的逻辑进行描述,由于作者在这块经验较少,有任何发现疑问以及问题的请在评论区留言,大家来一起讨论

Allocator: Abstract interface for allocating and deallocating device memory

class Allocator {
 public:
  static constexpr size_t kAllocatorAlignment = 64;

  virtual ~Allocator();

  virtual string Name() = 0;

  virtual void* AllocateRaw(size_t alignment, size_t num_bytes) = 0;

  virtual void* AllocateRaw(size_t alignment, size_t num_bytes,
                            const AllocationAttributes& allocation_attr) {
    return AllocateRaw(alignment, num_bytes);
  }

  virtual void DeallocateRaw(void* ptr) = 0;

  virtual size_t RequestedSize(const void* ptr) const {
    CHECK(false) << "allocator doesn't track sizes";
    return size_t(0);
  }

  virtual size_t AllocatedSize(const void* ptr) const {
    return RequestedSize(ptr);
  }

  virtual int64 AllocationId(const void* ptr) const { return 0; }
  virtual size_t AllocatedSizeSlow(const void* ptr) const {
    if (TracksAllocationSizes()) {
      return AllocatedSize(ptr);
    }
    return 0;
  }

  virtual absl::optional<AllocatorStats> GetStats() { return absl::nullopt; }

  virtual void ClearStats() {}

  virtual void SetSafeFrontier(uint64 count) {}
};



struct AllocationAttributes {
  AllocationAttributes() = default;

  AllocationAttributes(bool no_retry_on_failure, bool allocation_will_be_logged,
                       std::function<uint64()>* freed_by_func)
      : no_retry_on_failure(no_retry_on_failure),
        allocation_will_be_logged(allocation_will_be_logged),
        freed_by_func(freed_by_func) {}

  bool no_retry_on_failure = false;
  bool allocation_will_be_logged = false;
  std::function<uint64()>* freed_by_func = nullptr;  // Not owned.

  TF_DISALLOW_COPY_AND_ASSIGN(AllocationAttributes);
};


struct AllocatorStats {
  int64 num_allocs;          // Number of allocations.
  int64 bytes_in_use;        // Number of bytes in use.
  int64 peak_bytes_in_use;   // The peak bytes in use.
  int64 largest_alloc_size;  // The largest single allocation seen.

  absl::optional<int64> bytes_limit;

  int64 bytes_reserved;       // Number of bytes reserved.
  int64 peak_bytes_reserved;  // The peak number of bytes reserved.
  absl::optional<int64> bytes_reservable_limit;

  AllocatorStats()
      : num_allocs(0),
        bytes_in_use(0),
        peak_bytes_in_use(0),
        largest_alloc_size(0),
        bytes_reserved(0),
        peak_bytes_reserved(0) {}

  string DebugString() const;
};

TensorFlow 内存分配与回收的抽象接口,封装Name, AllocateRaw, DellocateRaw, TracksAllocationSize, AllocatesOpaqueHandle, RequestedSize, AllocatedSize, AllocationId, AllocatedSizeSlow, GetStats, ClearStats, SetSafeFrontier

这些逻辑作为父类的纯虚接口,由子类去实现,BFCAllocator的详细接口信息如下:

在此之前,BFCAllocator下的两个比较重要的数据结构, Chunk和Bin,两者之间的关系如下图,看起来像一个个糖葫芦,第一个bin size为256<<1, 第二个为256<<2, 一次类推,TF内有21个bin,最后bin size 为256 << 21为512MB,每一个bin下面会接下若干个大于bin size的chunk,整个内存空间由以下的结构来组织,当分配内存大小指定时,系统会遍历bin,找到能够第一次满足chunk > bin_size,每一个bin下的chunk是有序的(Bin下的ChunkComparator)

Chunk

struct Chunk {
    size_t size = 0;
    size_t requested_size = 0;
    int64 allocation_id = -1;
    void* ptr = nullptr;  // pointer to granted subbuffer.
    ChunkHandle prev = kInvalidChunkHandle;
    ChunkHandle next = kInvalidChunkHandle;
    BinNum bin_num = kInvalidBinNum;

    // Optional count when this chunk was most recently made free.
    uint64 freed_at_count = 0;
    bool in_use() const { return allocation_id != -1; }
    string DebugString(BFCAllocator* a,
                       bool recurse) NO_THREAD_SAFETY_ANALYSIS {
      string dbg;
      strings::StrAppend(
          &dbg, "  Size: ", strings::HumanReadableNumBytes(size),
          " | Requested Size: ", strings::HumanReadableNumBytes(requested_size),
          " | in_use: ", in_use(), " | bin_num: ", bin_num);
      if (recurse && prev != BFCAllocator::kInvalidChunkHandle) {
        Chunk* p = a->ChunkFromHandle(prev);
        strings::StrAppend(&dbg, ", prev: ", p->DebugString(a, false));
      }
      if (recurse && next != BFCAllocator::kInvalidChunkHandle) {
        Chunk* n = a->ChunkFromHandle(next);
        strings::StrAppend(&dbg, ", next: ", n->DebugString(a, false));
      }
      return dbg;
    }
  };

Bin

struct Bin {
    // All chunks in this bin have >= bin_size memory.
    size_t bin_size = 0;

    class ChunkComparator {
     public:
      explicit ChunkComparator(BFCAllocator* allocator)
          : allocator_(allocator) {}
      bool operator()(const ChunkHandle ha,
                      const ChunkHandle hb) const NO_THREAD_SAFETY_ANALYSIS {
        const Chunk* a = allocator_->ChunkFromHandle(ha);
        const Chunk* b = allocator_->ChunkFromHandle(hb);
        if (a->size != b->size) {
          return a->size < b->size;
        }
        return a->ptr < b->ptr;
      }

     private:
      BFCAllocator* allocator_;  // The parent allocator
    };

    typedef std::set<ChunkHandle, ChunkComparator> FreeChunkSet;

    FreeChunkSet free_chunks;
    Bin(BFCAllocator* allocator, size_t bs)
        : bin_size(bs), free_chunks(ChunkComparator(allocator)) {}
  };

分配内存

  • rounded_bytes: 保证内存对齐;

  • BinNumForSize(rounded_bytes):找到对应的BinNum;

  • MergeTimestampedChunks: 如果timestamped_chunks_不为空, (required_bytes==0,这里还不是特别理解, 有理解清楚的可以在文章后面评论),则合并;

  • FindChunkPtr:

    • 找到第一个满足rounded_bytes的bin;
    • 从free_chunks中删除大于rounded_bytes的chunk, 从free_chunks移除;
    • 若chunk大小为rounded_bytes的两倍,或者chunk大小比rounded_bytes 大128mb以上, 会将chunk split成满足rounded_bytes和剩余大小的chunk, 然后将后者插入合适bin的free_chunks;
  • 如果FindChunkPtr没找到合适的chunk,则尝试Extend

    • 如果available_bytes<rounded_bytes,则返回false;
    • 如果当前curr_region_allocation_bytes_小于rounded_bytes,则curr_region_allocation_bytes翻倍,直到满足大于rounded_bytes;
    • 调用sub_allocator来分配内存块,分配bytes大小为min(rounded_bytes, curr_region_allocation_bytes), 若未能成功分配,则一直尝试分配0.9*bytes, 若最后也未分配成功,则extend失败;
    • 分配好内存块之后,创建对应的chunk,这个chunk里保存了,内存块地址等信息,并将chunk插入到对应bin;
  • 如果Extend也fail了, 则再次尝试MergeTimestampedChunks来是否满足round_bytes, (这里会聚合最近释放的内存块,直到满足rounded_bytes);

  • 若再次MergeTimestampedChunks之后还是无法分配合适的内存块,系统会再次尝试释放已经free的regions,然后尝试extend来满足是否能满足分配rounded_Bytes, 如还是fail,则返回空指针,Allocate 失败;

      void* BFCAllocator::AllocateRawInternal(size_t unused_alignment,
                                              size_t num_bytes,
                                              bool dump_log_on_failure,
                                              uint64 freed_before) {
        if (num_bytes == 0) {
          VLOG(2) << "tried to allocate 0 bytes";
          return nullptr;
        }
    
        size_t rounded_bytes = RoundedBytes(num_bytes);
        BinNum bin_num = BinNumForSize(rounded_bytes);
      
        mutex_lock l(lock_);
        if (!timestamped_chunks_.empty()) {
          MergeTimestampedChunks(0);
        }
        void* ptr = FindChunkPtr(bin_num, rounded_bytes, num_bytes, freed_before);
        if (ptr != nullptr) {
          return ptr;
        }
      
        if (Extend(unused_alignment, rounded_bytes)) {
          ptr = FindChunkPtr(bin_num, rounded_bytes, num_bytes, freed_before);
          if (ptr != nullptr) {
            return ptr;
          }
        }
      
        if ((freed_before == 0) && (!timestamped_chunks_.empty())) {
      
          if (MergeTimestampedChunks(rounded_bytes)) {
            ptr = FindChunkPtr(bin_num, rounded_bytes, num_bytes, freed_before);
            if (ptr != nullptr) {
              return ptr;
            }
          }
        }
    
        if (DeallocateFreeRegions(rounded_bytes) &&
            Extend(unused_alignment, rounded_bytes)) {
          ptr = FindChunkPtr(bin_num, rounded_bytes, num_bytes, freed_before);
          if (ptr != nullptr) {
            return ptr;
          }
        }
    

释放内存

  • 首先,判断ptr是否为空指针,如是,则不作后续操作;

  • region_manager_.get_handle(ptr) 找到对应chunkhandle, 然后将handle标记为free,具体有application_id赋值为-1, 若timing_counter_为true,则记录释放内存时间;

  • 调用InsertFreeChunkIntoBin,将已标记为free的chunk插入到合适的Bin中,释放内存完成;

      void BFCAllocator::DeallocateRawInternal(void* ptr) {
        if (ptr == nullptr) {
          VLOG(2) << "tried to deallocate nullptr";
          return;
        }
        mutex_lock l(lock_);
      
        // Find the chunk from the ptr.
        BFCAllocator::ChunkHandle h = region_manager_.get_handle(ptr);
        CHECK(h != kInvalidChunkHandle);
      
        MarkFree(h);
      
        // Consider coalescing it.
        if (timing_counter_) {
          InsertFreeChunkIntoBin(h);
          timestamped_chunks_.push_back(h);
        } else {
          InsertFreeChunkIntoBin(TryToCoalesce(h, false));
        }
      
        if (VLOG_IS_ON(4)) {
          LOG(INFO) << "F: " << RenderOccupancy();
        }
      }
    

读源码的方法

我发现看懂了一部分代码之后,再写出来就显得很简单,这个对于读者不是什么好事情,很多小伙伴很有心地去阅读某些框架的底层源码,但是很多时候理解不了,下面我分享给小伙伴几个方法:

  • 专项学习某些知识点,比如你看的源码部分是memory manager,你可以去找一些国内外教材、教学视频, 先理解清楚其中概念;
  • 至少完整看两遍代码:第一遍,不要嫌麻烦,将每个类的所有的成员函数、成员变量画出来,更给力的是讲类间的关系引用也画出来;第二遍,再逐个函数把逻辑理清楚;
  • 看完之后建议写篇文章来描述下,以后忘记了再稍微看看能搞清楚;

感兴趣的家庭作业

XDL代码里面有其他不同的allocator如buddy_allocator, slab_allocator, slab_buddy_allocator,如果有人感兴趣可以按照我前面提的方法来理解下这几块内容,大家一起学习、讨论。

2019/11/23 posted in  TensorFlow

GDG上海 2019.10.13分享内容准备

个人介绍:

段石石,五年互联网小兵,开源参与爱好者,TF、MXNet、PaddlePaddle、TFLearn contributor,知乎专栏作者,推荐算法出身,因腾讯工作期间参与无量核心开发,由算法转ML SYSTEM。目前就职于网易云音乐,负责云音乐整体机器学习框架的应用与研发,为云音乐提供大规模机器学习框架与基础算法应用能力,目前兴趣点在大规模机器学习框架,图网络等
autho

TensorFlow在云音乐上的一些实践

  • 网易云音乐基础业务介绍
  • 机器学习框架在云音乐基础介绍: TensorFlow、 thanos
  • 推荐搜索业务实践; (坑+TF2.0尝鲜)
  • NLP基础业务实践; (flink + tensorflow)
  • 算法平台赋能
2019/09/01 posted in  TensorFlow

Dive Into TensorFlow

前一段时间,一直在忙框架方面的工作,偶尔也会帮业务同学去优化优化使用TensorFlow的代码,也加上之前看了dmlc/relay,nnvm的代码,觉得蛮有意思,也想分别看下TensorFlow的Graph IR、PaddlePaddle的Graph IR,上周五,看代码看的正津津有味的时候,看到某个数据竞赛群里面讨论东西,不记得具体内容,大概说的是框架的代码实现, 有几位算法大佬说看底层源码比较麻烦,因为比较早从框架,这块代码通常都还能看,问题都不大,和群里小伙伴吹水了半天之后,感觉是可以写篇如何看TensorFlow或者其他框架底层源码的劝退文了。

利其器

首先,一定是要找个好工作来看源码,很多人推荐vs code、sublime,我试过vs code+bazel的,好像也不错,但是后面做c++适应了clion之后,除了资源要求比较多,还是蛮不错的,使用c++一般推荐使用cmake来看编译项目,但是TensorFlow是bazel的,无法直接支持,最开始,这边是自己写简单的cmake,能够实现简单的代码跳转,但是涉及到比如protobuf之类的编译过后产生的文件无法跳转,比较麻烦,不够纯粹,很早之前知道clion有bazel的组件,但是不知道为啥一直搞不通,上周找时间再试了试,发现竟然通了,使用之后,这才是看tf源码的真正方式:

首先,选择合适版本的bazel,千万不能太高,也不能太低,这里我拉的是TF2.0的代码,使用bazel 0.24.0刚刚好,切记千万别太高也比太低, 千万别太高也比太低,千万别太高也比太低

其次,clion上选择bazel的插件

第三步,./configure,然后按你的意图选择合适的编译配置

第四步,导入bazel项目:File=>



经过上面几步之后,接下来就要经过比较长时间的等待,clion会导入bazel项目,然后编译整个项目,这个耗时视你机器和网络而定(顺便提一句,最好保证比较畅通的访问github的网络,另外由于上面targets:all,会编译TensorFlow所有的项目,如果你知道是什么意思,可以自己修改,如果不知道的话我先不提了,默认就好,期间会有很多Error出现,放心,问题不大,因为会默认编译所有的模块)
经过上面之后,我们就可以愉快的看代码啦,连protobuf生成的文件都很开心的跳转啦

极简版c++入门

TensorFlow大部分人都知道,底层是c++写的,然后外面包了一层python的api,既然底层是c++写的,那么用c++也是可以用来训练模型的,大部分人应该都用过c++或者java去载入frozen的模型,然后做serving应用在业务系统上,应该很少人去使用c++来训练模型,既然我们这里要读代码,我们先尝试看看用c++写模型,文件路径如下图:

主要函数就那么几个:CreateGraphDef, ConcurrentSteps, ConcurrentSessions:

CreateGraphDef 构造计算图

GraphDef CreateGraphDef() {
  // TODO(jeff,opensource): This should really be a more interesting
  // computation.  Maybe turn this into an mnist model instead?
  Scope root = Scope::NewRootScope();
  using namespace ::tensorflow::ops;  // NOLINT(build/namespaces)

  // A = [3 2; -1 0].  Using Const<float> means the result will be a
  // float tensor even though the initializer has integers.
  auto a = Const<float>(root, {{3, 2}, {-1, 0}});

  // x = [1.0; 1.0]
  auto x = Const(root.WithOpName("x"), {{1.f}, {1.f}});

  // y = A * x
  auto y = MatMul(root.WithOpName("y"), a, x);

  // y2 = y.^2
  auto y2 = Square(root, y);

  // y2_sum = sum(y2).  Note that you can pass constants directly as
  // inputs.  Sum() will automatically create a Const node to hold the
  // 0 value.
  auto y2_sum = Sum(root, y2, 0);

  // y_norm = sqrt(y2_sum)
  auto y_norm = Sqrt(root, y2_sum);

  // y_normalized = y ./ y_norm
  Div(root.WithOpName("y_normalized"), y, y_norm);

  GraphDef def;
  TF_CHECK_OK(root.ToGraphDef(&def));

  return def;
}

定义graph 节点 root, 然后定义常数变量a (shape为2*2), x (shape为2* 1),然后 y = A * x, y2 = y.^2, y2_sum = sum(y2), y_norm = sqrt(y2_sum), y_normlized = y ./ y_norm。代码很简洁, 看起来一目了然,
然后是ConcurrentSteps

void ConcurrentSteps(const Options* opts, int session_index) {
  // Creates a session.
  SessionOptions options;
  std::unique_ptr<Session> session(NewSession(options));
  GraphDef def = CreateGraphDef();
  if (options.target.empty()) {
    graph::SetDefaultDevice(opts->use_gpu ? "/device:GPU:0" : "/cpu:0", &def);
  }

  TF_CHECK_OK(session->Create(def));

  // Spawn M threads for M concurrent steps.
  const int M = opts->num_concurrent_steps;
  std::unique_ptr<thread::ThreadPool> step_threads(
      new thread::ThreadPool(Env::Default(), "trainer", M));

  for (int step = 0; step < M; ++step) {
    step_threads->Schedule([&session, opts, session_index, step]() {
      // Randomly initialize the input.
      Tensor x(DT_FLOAT, TensorShape({2, 1}));
      auto x_flat = x.flat<float>();
      x_flat.setRandom();
      std::cout << "x_flat: " << x_flat << std::endl;
      Eigen::Tensor<float, 0, Eigen::RowMajor> inv_norm =
          x_flat.square().sum().sqrt().inverse();
      x_flat = x_flat * inv_norm();

      // Iterations.
      std::vector<Tensor> outputs;
      for (int iter = 0; iter < opts->num_iterations; ++iter) {
        outputs.clear();
        TF_CHECK_OK(
            session->Run({{"x", x}}, {"y:0", "y_normalized:0"}, {}, &outputs));
        CHECK_EQ(size_t{2}, outputs.size());

        const Tensor& y = outputs[0];
        const Tensor& y_norm = outputs[1];
        // Print out lambda, x, and y.
        std::printf("%06d/%06d %s\n", session_index, step,
                    DebugString(x, y).c_str());
        // Copies y_normalized to x.
        x = y_norm;
      }
    });
  }

  // Delete the threadpool, thus waiting for all threads to complete.
  step_threads.reset(nullptr);
  TF_CHECK_OK(session->Close());
}

新建一个session,然后设置10个线程来计算,来执行:

std::vector<Tensor> outputs;
      for (int iter = 0; iter < opts->num_iterations; ++iter) {
        outputs.clear();
        TF_CHECK_OK(
            session->Run({{"x", x}}, {"y:0", "y_normalized:0"}, {}, &outputs));
        CHECK_EQ(size_t{2}, outputs.size());

        const Tensor& y = outputs[0];
        const Tensor& y_norm = outputs[1];
        // Print out lambda, x, and y.
        std::printf("%06d/%06d %s\n", session_index, step,
                    DebugString(x, y).c_str());
        // Copies y_normalized to x.
        x = y_norm;
      }

每次计算之后,x=y_norm,这里的逻辑其实就是为了计算矩阵A的最大eigenvalue, 重复执行x = y/y_norm; y= A*x;
编译:

bazel build //tensorflow/cc:tutorials_example_trainer 

执行结果,前面不用太care是我打印的一些调试输出:

简单的分析

上面简单的c++入门实例之后,可以抽象出TensorFlow的逻辑:

  1. 构造graphdef,使用TensorFlow本身的Graph API,利用算子去构造一个逻辑计算的graph,可以试上述简单地计算eigenvalue,也可以是复杂的卷积网络,这里是涉及到Graph IR的东西,想要了解的话,我建议先看下nnvm和relay,才会有初步的概念;
  2. 用于构造graphdef的各种操作,比如上述将达到的Square、MatMul,这些操作可以是自己写的一些数学操作也可以是TensorFlow本身封装一些数学计算操作,可以是MKL的封装,也可以是cudnn的封装,当然也可以是非数学库,如TFRecord的读取;
  3. Session的构造,新建一个session,然后用于graph外与graph内部的数据交互:session->Run({{"x", x}}, {"y:0", "y_normalized:0"}, {}, &outputs));这里不停地把更新的x王graph里喂来计算y与y_normalized,然后将x更新为y_normalized;

GraphDef这一套,太过复杂,不适合演示如何看TF源码,建议大家先有一定的基础知识之后,再看,这里我们摘出一些算法同学感兴趣的,比如Square这个怎么在TF当中实现以及绑定到对应操作

  1. 代码中直接跳转到Square类,如下图;
  2. 很明显看到Square类的定义,其构造函数,接收一个scope还有一个input, 然后我们找下具体实现,如下图:
  3. 同目录下, math_ops.cc,看实现逻辑,我们是构造一个名为Square的op,然后往scope里更新,既然如此,肯定是预先有保存名为Square的op,接下来我们看下图:
    188061565535890_.pic_hd
  4. 这里讲functor::square注册到"Square"下,且为UnaryOp,这个我不知道怎么解释,相信用过eigen的人都知道,不知道的话去google下,很容易理解,且支持各种数据类型;
    188641565536482_.pic_hd
  5. 那么看起来,square的实现就在functor::square,我们再进去看看,集成base模板类,且看起来第二个模板参数为其实现的op,再跳转看看:
    188941565536836_.pi
    6.最后,我们到达了最终的实现逻辑:operator()和packetOp,也看到了最终的实现,是不是没有想象的那么难。
    188951565536836_.pi

更重要一点

看完了上面那些,基本上会知道怎么去看TensorFlow的一些基础的代码,如果你了解graph ir这套,可以更深入去理解下,这个过程中,如果对TensorFlow各个文件逻辑感兴趣,不妨去写写测试用例,TensorFlow很多源码文件都有对应的test用例,我们可以通过Build文件来查看,比如我想跑下client_session_test.cc这里的测试用例

我们看一下Build文件中

这里表明了对应的编译规则,然后我们只需要

bazel build //tensorflow/cc:client_client_session_test

然后运行相应的测试程序即可

更更重要的一点

上面把如何看TensorFlow代码的小经验教给各位,但是其实这个只是真正的开始,无论TensorFlow、MXNet、PaddlePaddle异或是TVM这些,单纯去看代码,很难理解深刻其中原理,需要去找相关行业的paper,以及找到行业的精英去请教,去学习。目前网上ml system的资料还是蛮多的,有点『乱花迷人眼』的感觉,也没有太多的课程来分享这块的工作,十分期望这些框架的官方分享这些框架的干货,之后我也会在学习中总结一些资料,有机会的话分享给大家。最后,这些东西确实是很复杂,作者在这块也是还是懵懵懂懂,希望能花时间把这些内在的东西搞清楚,真的还蛮有意思的。

2019/08/16 posted in  TensorFlow

机器学习工程实践

过去半年,我们团队在机器学习平台上做过一些工作,因为最近看到几篇关于机器学习算法与工程方面的的文章,觉得十分有道理,萌发了总结一下这块的一些工作的念头,我最近工作主要分为两块:1,机器学习框架的研发、机器学习平台的搭建;2,基础NLP能力的业务支持。本篇文章会总结下在机器学习框架这部分系统工作上的一些工作,主要也分为两部分:1,经典框架的支持;2,自研框架的工作;

经典框架的支持

这里经典框架其实就是TensorFlow,目前TensorFlow在我司场景上主要集中在两部分场景:

  1. 搜索、推荐、广告等比较传统业务场景,提供包括召回、粗排、精排等核心流程的算法支持;
  2. 新兴业务如直播、社交等业务基础的算法能力的支持,构建内容生态, 如各业务内容审核、曲库、歌单、直播体系建设等方面;

具体一个case

算法业务有一个场景,根据用户过去session内的若干次(限制为定长)的访问记录,预测下一个访问内容,业务同学设计了一个DNN来召回这部分内容,然后,在精排阶段去排序。但是问题在于召回的整体候选集特别大,大概为30万, 因此,这个DNN模型就有了如下的结构:

初看,没有任何问题,设计一个多层mlp,来训练召回模型,且保证输入限制为定长K,通过过去K次浏览记录来召回下一次可能的内容, 很合理,且在业务上效果挺不错的。从算法业务同学的视角里,这完全没有任何问题,相信很多小伙伴在业务初期都会有类似的尝试,但是问题是当候选集为30万大小,或者更大时,想想这时候会发生什么?(感谢之前在腾讯手机qq浏览器的经验,yuhao做歧义消解的时候讨论过这个问题)
每一轮的迭代,必须有两个过程forward、backward, forward主要逻辑是基于预测值,backward主要逻辑是根据预测值和对应标签信息,然后更新梯度信息,如此大的输出节点数,每一次forward会计算30万的softmax然后计算loss,通过bp更新梯度,这其中的耗时可想而知,,相信很多小伙伴看到这里会突然想到word2vec针对这块的优化:Negative Sampling和Hierarchical Softmax, 专门用来解决输出维度过大的情况;google也发表了On Using Very Large Target Vocabulary for Neural Machine Translation,用于在Very Large Target Vocabulary部分的loss的计算,TensorFlow官方也支持https://www.tensorflow.org/api_docs/python/tf/nn/sampled_softmax_loss,改造sampled_softmax_loss之后,速度提升将近30%。
另外在支持业务的时候,还发现一个很有意思的东西,业务同学因为要在训练过程中看到一些预测的结果是否符合预期, 因此在每次sess.run()的时候都塞进去predict的op。但是呢,训练过程本身又是使用的softmax_cross_entropy,这就造成了一次sess.run()其实跑了两轮softmax,之前没有考虑这样的细节,在某天和业务同学一起优化时,猛然看到,修改后,速度直接提升了一倍,也就是说上述所有的计算其实都是在softmax相关的计算,其实真实的模型的更新可能95%以上的计算都在softmax,加上本身使用TensorFlow灵活性确实够大,predict、train又计算了两次softmax,耗时可想而知。
基于上述两个点优化之后,速度整体提升明显,但是回到算法模块的设计上,DNN在如此大的候选集上真的合适吗,在我看来,其实设计是可以更好的,微软在2013年的的文章有提到DSSM的工作https://www.microsoft.com/en-us/research/wp-content/uploads/2016/02/cikm2013_DSSM_fullversion.pdf, 后来业界优化dssm支持lstm、cnn子模块,用于推荐系统的召回,相信会是更好的方法,不存在输出空间太大的问题。

上述类似的问题应该出现在很多团队中,尤其是在新兴业务中的快速落地,无可厚非,设计了一套业务数据十分好看的模型,除了耗时多一些、内存多了一些,但是呢,对工程同学呢,这个是无法忍受的。不需要的地方,一点点的算力、一点点的比特的浪费都不能让,这是工程同学、尤其是机器学习工程同学基本的坚持。

hdfs小文件读取的优化

另外一块关于TensorFlow的优化是读取hdfs数据时,小文件的影响,场景是这样的,业务同学收集好数据之后,转为tfrecord,存到hdfs,然后本地通过TFRecordDataset去读取hdfs文件,速度很慢,通过一些工具分析,主要耗时集中在数据拉取过程中,但是其他业务场景下也不会有问题,后来拿到数据看了下,因为复用了部分代码,在spark上转tfrecord的时候默认partition为5000,而本身该场景数据量比较小,分割为5000后,每个文件特别小,而TensorFlow在读取tfrecord时,遇到小文件时,效率会特别低,其实不仅仅是在hdfs上,在ceph上也是,笔者之前也遇到小文件造成的数据读取的耗时严重影响模型训练的问题。

分布式方案如何选择

当单机无法满足性能之后,自然而然选择了分布式方案,那么分布式方案如何选择呢,业界有两套比较成熟的方案:

  1. 基于parameter server的分布式方案,能够有效支持模型并行、数据并行;
  2. 基于ring allreduce的分布式方案,能够有效支持数据并行;
    两者之间差别在哪儿呢 ?
    回答这个问题之前,我们先做一个算术题:
    若一个场景,每200个batch耗时21秒左右,即一个batch约为0.1s,假设模型传参时间为一半,整体模型大小约为100M,如果仅做数据并行,也就是说每0.05s需要将整个模型通过网络传到另一台机器上,也就是要奖金2GB/s的带宽,换算成远远超过现在很多10Gb网卡的性能,而大家会存疑了,为啥每个batch 计算时间为啥仅有0.1s呢,这个可能吗,其实在推荐、广告这类场景下,这种情况极有可能,在推荐、广告这类场景下模型的特点在于embedding维度极大,但是本身消耗的算力比较小,耗时也很小,embedding仅仅是lookup 然后到较小维度的embedding向量,剩余的参数更新量极小。
    所以要保证此类模型的并行效率,ring allreduce这类分布式方案,并不可行,网络必将成为瓶颈,那么如何选择呢? parameter server目前看来是一套比较好的方案,模型并行,模型分布能够有效利用多个worker的网卡带宽,达到较高的加速效率。
    而ring allreduce适合那些 model_size/batch耗时 较小的场景,比如cv场景下cnn model,其加速比几乎可以达到线性:

当然也有很多手段来在推荐场景上也使用ring allreduce,比如并不是每个batch都更新所有的梯度的信息,可以选择性的去传输部分梯度,通过合理的策略选择,也能达到很好的加速效率,这里就不详细展开了。

线上模型部署优化

模型部署这块的工作,因为涉及到线上,在我们看来更加重要。由于业务系统大部分基于Java构建,而机器学习框架本身大部分采用c/c++实现,因此我们采用jni的方式来打通java业务系统到c++模型的调用,将包括spark lgb、tensorflow还有我们自研的框架,进行封装,业务只需要指定模型引擎、写好模型出入处理,即可快速上线,这块后续会有团队小伙专门文章介绍,这里只描述一点可能算不上优化的优化,就是在TensorFlow框架中引入SIMD的支持,起先由于缺少这块的经验,并没有想到SIMD对于性能的提升,但业务RT过高时,发现原先TensorFlow CPU的线上的编译按TensorFlow默认教程,少了AVX、SSE的支持, 在引入AVX、SSE之后,线上性能提升明显,A场景从40ms降到了20ms,B场景从70+ms降到了40ms,读者里面有部署没有引入SIMD的,可以快速尝试下,很香,命令如下:

bazel build -c opt --copt=-msse3 --copt=-msse4.1 --copt=-msse4.2 --copt=-mavx --copt=-mavx2 --copt=-mfma //tensorflow/tools/pip_package:build_pip_package

自研框架


如上图,是自研框架的一个逻辑抽象图,整体框架分为三个角色:scheduler、Server、Worker,通过计算与存储分离,合理编排任务,达到高性能的分布式机器学习框架,这里不详细描述这块的设计,后续感兴趣会有专门的文章来描述,这里仅讨论下在自研框架上的几道坎。

自研框架路上的几道坎

部署工作

项目之初,因为基于Parameter Server的自研框架,不像Spark、Hadoop有现成的作业提交系统,团队开发了一套简单的实验工具,用于支持框架的开发:具体是基于docker作为环境的配置以及隔离工具, 自研deploy工具,发布多节点训练任务,镜像内打通线上大数据环境,可以任务实验环境发布后直接拉取节点来训练模型,现阶段已有较好的任务发布、资源调度系统,相信随着后续迭代会更加的合理以及完全。

其实这个就是一个鸡生蛋、蛋生鸡的问题,有的人认为要自研框架,需要先考虑支持工作,如何提交、如何监控, 连部署工具、任务调度都没有,怎么做框架?这是个特别好的问题,基建无法满足的情况应该多多少少会出现在很多团队上,怎么办?基建无法满足,开发就没办法进行下去吗?当然不是,作为工程师,完全可以开发一个极简版本,支持你的项目开发,记住这时你的目的是框架开发而非业务支持,框架开发过程中自然会找到解决方案,以前老大经常和我们提项目之初不能过度设计,我觉得还要加上一条,项目之初要抓住关键需求,然后来扣,一个复杂的系统永远不是完美的,也不是一个团队可以支持的,要联合可以联合的团队一起成长、一起攻克。

资源瓶颈

不管何时,资源的瓶颈或许说资源的限制一定会存在,对于一个好的系统一定是不断磨合不同流程、不同模块之间的性能来达到的,自研框架过程中,我们学习到一些经验:
定制数据处理逻辑
分布式机器学习框架,尤其是大规模离散场景下,单batch的样本稀疏程度十分大, 有值特征通常不到万分之一,在一轮迭代中仅仅只更新很小一部分参数,如下图
如图中粉红圆圈

原则上,但数据reader去解析数据文件中的数据时,理论上一次遍历即可拿到所有数据,此处考虑到计算能力,采用生产-消费者模式,配置好合适的cache,用来保存待消费的数据序列。放入cache的数据文件分片单位,如支持4个part,即表明cache内数据条数为4*part内条数据,读取文件数据时,应用format_parser来解释训练数据格式,然后进入cache, cache内部分进行shuffle,切分batch,切分batch过程中会计算每一个batch的nnz、key_set,用于后面分配计算空间以及向server拉取参数,参数拉取完成够, 每一个batch喂给计算模块去计算,shuffle batch on the fly。

可能各位大佬看到这里觉得不太高效,为什么是分块的载入cache,为啥不直接使用流式处理呢 ? 流式处理是不是会更高效,因为这里考虑到shuffle这块的逻辑,流式上的shuffle设计会十分复杂,这里其实我们也考虑过,比如在cache上配置一个计时器,定时进行cache内数据的shuffle,理论上可以增加一定的shuffle逻辑,但其实也无法严格保证, 当然之前我们也考虑过直接在前面读取数据时,做全局的shuffle,类似于现在图像的读取逻辑,比如类似于lmdb的存储结构,其实质在于每个样本配置一个指针用于指定数据内存块,但是在推荐场景下,一般单个样本1k-1.5k大小,样本量十分大, 如果使用lmdb这套逻辑,理论上我可以通过指针序列进行全局的shuffle,快速定位到指针位置来取样本数据, 但是如此多的指针,本身的内存占用就变得很大了,不像图像,单个指针相对整个图像内存来说几乎忽略不计,我们在尝试之后,发现样本空间变得十分巨大, 拉取数据的增长远远超过我们的鳄鱼漆, 而在推荐场景下这个是我们没有采用的,而是采用分数据块读取,然后local shuffle的逻辑。

拒绝数据拷贝,减少内存压力
起初框架开发时,尽快我们考虑到性能问题,但多多稍稍还是没注意很多内存空间的拷贝以及不及时释放的问题,这块在单worker,或者worker数量较少的情况下,影响可忽略,但是当我们要将一台机器压到极致性能时,这块我们重新梳理了下,通过更改逻辑以及使用move操作去除 parser 等函数中不必要的数据拷(此处没有严格对比),预估能提升将近1/10的性能,尤其是训练样本数据块的拷贝,占用过多内存。

磁盘IO瓶颈
我们没有想到磁盘IO瓶颈来的如此快,反而一直担心网络IO, 后来查了下机器,就释然了,实验拿到的机器竟然是很老的机械磁盘(这里真的想吐槽规划这批机器的同事),磁盘速率极低,磁盘IO的等待远远超出预期,尤其是在第一个epoch从hdfs拉到本地缓存数据和读取数据块到内存时,磁盘IO被打满了。计算耗时在最严峻时,连整体耗时的五分之一都不到,磁盘IO成为了系统计算的瓶颈,减少了cache内存区大小也只不过减缓了这部分的压力,磁盘还是在大部分时间被打的满满的。

我们尝试过,编排数据读取部分平摊到整体任务计算的过程中,减少磁盘IO压力, 发现效果并不明显。最后我们通过将业务部分原始样本数据:大概480G的文本数据,通过Protobuf+gzip之后,压缩到差不多100G不到,单个文件大小从492M,转换后一个文件大小为 106M,相对降低了 78%。而读取单个文件的性能从原来的平均40s缩短至8s,相对减少了80%;,在数据读取部分进行反序列化,本以为反序列化会增加部分耗时,但发现在经过第一部分的优化之后,反序列化不增加额外耗时,且由于整体样本量减少到了1/5,磁盘IO完全不成问题了,也加上第一步的优化改造,整体的IO曲线很平稳且健康。
至此,磁盘IO等待符合预期,不再用磁盘IO瓶颈。

网络瓶颈,由于现在是比较简单的模型,暂时没有看到,本个季度应该会遇到,到时候再看。

特殊需求优化

考虑到部分业务,并没有实时化部署线上服务,需要预先离线计算结果,然后放到线上去做推荐,我们的分布式机器学习框架也做了一些离线的inference的优化,单台机器从30万/s的处理速度优化到170万/s的速度,用5台机器,200个cpu计算核70分钟完成370亿的样本的离线计算,整体内存占用仅180G。
具体优化包括以下几个方面:

1, 数据压缩,如前面提到采用protobuf+gzip后,提升明显;
2, 实现local_inference函数,因为此业务场景模型单机完全可以载入,去掉pull参数逻辑,直接从内存中拿到对应key,local inference时,每个worker载入全部参数;
3, 修改batch inference改为单条去查询,然后多线程计算结果,这里比较违反常识,理论上同事多个样本进行计算,向量化计算效率肯定更高,但是这里因为在local inference场景下,不像训练时,组成batch的matrix效率更高,local inference计算只有一个forward,计算耗时极小,整体耗时瓶颈并不在计算上,相反由于要组成一个batch的matrix增加的耗时要大于整体计算的耗时,而单个单个可以直接查询key来进行forward计算,且这里通过openmp,可以达到多线程加速的效果。

业务沟通

和业务交流沟通,永远是做底层同学最大的一道坎,彼此视角不同、技术方向不同、愿景也有差异,在暂不成熟的业务上,业务同学永远有1000种以上的方法去提升日活、留存、转化率,技术也许只是最后一个选择。
服务意识,是系统,尤其是像ml system这类并不是足够成熟的行业上必须要具备的,其实想想TensorFlow也就释然了,如此牛的一套东西,也还必须要全世界去pr,去培养用户使用机器学习的习惯。

未来规划

自研框架这套大概经历了四个多月的时间,也培养了两个比较给力的小伙伴,后续规划主要是向业务看齐,先满足业务,能预期的主要包括以下几个方面

实时化支持
改造业务离线模型,支持实时化,这套框架本身已经支持增量训练,更重要的改造是:1,利用现有大数据框架进行特征实时化;2,模型小时级训练(实时化其实也支持到位了,但目前业务需求不明显);3,模型校验机制:需要有一套合适的机器判断小时级更新的模型是否应该上线。

参数通信模块优化
前面提到网络目前还没看到瓶颈,但是在涉及到更复杂一些的模型,更大维度的参数空间时,网络必将成为瓶颈,目前业界在大规模分布式框架上有一些减缓网络带宽压力的措施:1,梯度裁剪;2,梯度压缩;3,混合精度训练;

其他框架兼容
由于计算算子目前在很多现有的机器学习框架支持已经够丰富了, 后续会考虑支持TensorFlow、Pytorch, 参考xdl、byteps这类框架,也会看看能否支持统一的模型部署格式如onnx, 目前团队正在调研这部分工作,相信今年会在这块有一定的突破。

代码结构优化
目前团队每周会进行code review,后续会进行几轮代码大范围重构,更加抽象一些逻辑,更加强调代码的复用:如增加register各类操作机制、更改layer到op层等等操作;

2019/08/03 posted in  机器学习平台

ps-lite再一次研读源码

Postoffice

Postoffice是个单例类,是整个ps-lite的核心,相当于整个ps-lite的调控中心,包括对调起Van负责整个网络的拉起、通信、命令管理如增加节点、移除节点、恢复节点等等;整个集群基本信息的管理,比如worker、server数的获取、server端feature分布的获取、worker/server Rank与node id的互转、节点角色身份等等;

Van 和ZMQVan: 网络如何被构建, 如何通信

Van是ps-lite的一个基类, 实现了基础的公共函数,ZMQVan是基于zeromq的Van的实现,Van是整个Parameter Server的通信模块;在整个训练任务的生命周期中,有以下几点值得注意:

  1. 任务启动时,所有nodes,发送消息到scheduler,;
  2. 启动好scheduler后, worker与server会互相连接,注意worker之间、server之间不会连接;
  3. 框架运行过程中,通信中包括以下多种信息类型,如数据信息:worker向server更新梯度、心跳信息:worker/server向scheduler发送心跳、server和worker的连接、scheduler端的处理命令:如添加节点、恢复dead节点等等;
  4. Message中的Meta,如是否request、app_id、timestamp、nodes的ip、port、role等等在网络通信过程中会打包成protobuf,减少通信压力;
  5. 在节点挂掉(心跳时间内没回应)会恢复节点,这个过程中会将挂掉节点的id赋给恢复的节点;

Customer 消息如何被处理

Customer主要是request、response,比如新建一次request,会返回一个timestamp,这个timestamp会作为这次request的id,每次请求会自增1,相应的res也会自增1,调用wait时会保证 后续比如做Wait以此为ID识别,tracker_是Customer内用来记录request(使用request id)和对应的response的次数的一个map;recv_handle_绑定Customer接收到request后的处理函数(SimpleApp::Process);Customer会新拉起一个线程,用于在customer生命周期内,使用recv_handle_来处理接受的请求,这里是使用了一个线程安全队列,Accept()用于往队列中一直发送消息,对于Worker,比如KVWorker,recv_handle_保存拉取的msg中的数据,对于Server,需要使用set_request_handle来设置对应的处理函数,如KVServerDefaultHandle,使用std::unordered_map<Key, Val> store保存server的参数,当请求为push时,对store参数做更新,请求为pull时对参数进行拉取;

Message: 消息数据结构

Message封装包括Meta, Control, Node等消息, 其中data的部分,采用的SArray这个数据结构可以理解为一个零拷贝的vector,能兼容vector的数据结构,另外为了保证高效会对Message里的Meta,Control,Node使用protobuf来打包,这里有个疑问,为啥不会数据比如推送的梯度信息用protobuf打包呢?

PS如何构建网络

PS构建网络步骤如下:

  1. scheduler节点拉起;
  2. worker、server节点想scheduler发送请求,汇报ip、端口等等,scheduler分配node id给相应节点;
  3. 所有节点启动后,scheduler发送消息周知;
  4. 所有节点内部启动线程,一直发送心跳, scheduler会来处理相应的命令;

同步操作

ps-lite里面有两个涉及到等待同步的地方:

  1. Worker pull时是异步操作,通常调用Wait来调用Customer::WaitRequest()来保证customer里面的request和response两者相等,即保证Pull完成后再做其他操作;

  2. 另外在一个worker内,可以存在多个Customer,当第一个发送barrier后,scheduler接收到request请求,然后根据msg判断是request,然后,向barrier_group里的所有node,node接到后, Postoffice::Get()->Manage(*msg)将barrier_done_中的customer_id对应的bool置true,完成同步操作,这里貌似没有我们常说的asp、bsp、ssp,可以通过增加相应的Command来完成;

  3. 当构建节点连接时,也可以进行一个barrier;

  4. 更复杂的比如Asp,bsp,ssp可以通过发送新定Command来完成

    void Van::ProcessBarrierCommand(Message* msg) {
    auto& ctrl = msg->meta.control;
    if (msg->meta.request) {
    if (barrier_count_.empty()) {
    barrier_count_.resize(8, 0);
    }
    int group = ctrl.barrier_group;
    ++barrier_count_[group];
    PS_VLOG(1) << "Barrier count for " << group << " : " << barrier_count_[group];
    if (barrier_count_[group] ==
    static_cast(Postoffice::Get()->GetNodeIDs(group).size())) {
    barrier_count_[group] = 0;
    Message res;
    res.meta.request = false;
    res.meta.app_id = msg->meta.app_id;
    res.meta.customer_id = msg->meta.customer_id;
    res.meta.control.cmd = Control::BARRIER;
    for (int r : Postoffice::Get()->GetNodeIDs(group)) {
    int recver_id = r;
    if (shared_node_mapping_.find(r) == shared_node_mapping_.end()) {
    res.meta.recver = recver_id;
    res.meta.timestamp = timestamp_++;
    CHECK_GT(Send(res), 0);
    }
    }
    }
    } else {
    Postoffice::Get()->Manage(*msg);
    }
    }

SampleApp

一个基类,封装了基本的PS app的操作,KVWorker、KVServer集成SampleApp,完成相应逻辑: 如push、pull、key的切片等等;

2019/03/26 posted in  参数服务器

Maybe Best Practice With Sparse Machine Learning In TensorFlow

TensorFlow现状及背景

在机器学习这块,Estimator本身的封装能够适应比较多的Dense的场景,而对于Sparse的场景无论是官方demo还是一些业界的大牛都分享的比较少,在很多场景,比如libfm、libffm、Xgboost都支持直接libsvm, field-libsvm的格式中读入数据,训练模型没有原始的实现,没法直接调包使用,得自己在TensorFlow的框架上构造,所幸Estimator本身的框架支持自定义的input_fn,和自定义的model_fn,笔者过去一段时间工作之余研究了下,并实现了基于libsvm的Sparse Logistic Regression和Sparse Factorization Machine, 打通了从数据读取、模型训练、到TensorFlow Serving的部署。

TensorFlow中的sparse_tensor实现

我们读下sparse_tensor的源码,sparse_tensor.py, 很容易看出来sparse_tensor在TensorFlow中是一个高层的封装,主要包括indices, values, shape三个部分,这里很有意思,后面我实践中遇到一个大坑,可以通过这里解决,这里我先卖个关子;

sparse representation的好处

常见的稀疏矩阵的表示有csc,csr,在很多矩阵计算的库当中有使用,比如python中大家使用比较多的scipy,TensorFlow底层计算模块eigen,都是用类似的方式来表示稀疏矩阵,举个例子比如某个商户有500万个商品,而用户产生行为的商品必定远远小于500万,如果都是用dense表示,那么保存单个用户行为的商品数据需要500万个指,而采用稀疏数据表示则保存所需要的空间只需要和你才产生行为的商品数量有关,如下图100个用户的在500w上的行为数据如果用dense表示需要大概3G的空间;

需要保存100*5000000个int,而使用csc_matrix,

row = np.array(range(100))
col = np.zeros(100)
data = np.ones(100)
csc_matrix((data, (row, col)), shape=(100, 5000000))

我们只需要保存3*NNZ(这里就是100)个int,然后加上一个shape信息,空间占用大大减少;
在内存中,我们通常使用csc来表示Sparse Matrix,而在样本保存中,通常使用libsvm格式来保存

1 1:1 2:1 3:1 4:1 5:1 6:1 7:1 8:0.301 9:0.602 10:1 11:1 12:1 13:1 14:1 15:1 16:1 17:1 18:1 19:1 20:1 21:1 22:1

以空格为sep,label为1, 后续为feature的表示,格式为feature_id: feature_val, 在TensorFlow中我们可以使用TextlineDataset自定义input_fn来解析文本,其他很多相关的技术文章都有提及,但是作为一个程序员总感觉不想走已经走过的路,而且TF官宣tfrecord的读写效率高, 考虑到效率问题,我这里使用TFRecordDataset来做数据的读取;

LibSVM To TFRecord

解析LibSVM feature_ids, 和feature_vals, 很简单没有啥好说的, 直接贴代码,想要深入了解的,可以去看看TF的example.proto, feature.proto, 就大概能了解Example和Feature的逻辑了,不用闷闷地只知道别人是这样写的。

import codecs
import tensorflow as tf
import logging


logger = logging.getLogger("TFRecSYS")
sh = logging.StreamHandler(stream=None)
logger.setLevel(logging.DEBUG)
fmt = "%(asctime)-15s %(levelname)s %(filename)s %(lineno)d %(process)d %(message)s"
datefmt = "%a %d %b %Y %H:%M:%S"
formatter = logging.Formatter(fmt, datefmt)
sh.setFormatter(formatter)
logger.addHandler(sh)

class LibSVM2TFRecord(object):
    def __init__(self, libsvm_filenames, tfrecord_filename, info_interval=10000, tfrecord_large_line_num = 10000000):
        self.libsvm_filenames = libsvm_filenames
        self.tfrecord_filename = tfrecord_filename
        self.info_interval = info_interval
        self.tfrecord_large_line_num = tfrecord_large_line_num

    def set_transform_files(self, libsvm_filenames, tfrecord_filename):
        self.libsvm_filenames = libsvm_filenames
        self.tfrecord_filename = tfrecord_filename

    def fit(self):
        logger.info(self.libsvm_filenames)
        writer = tf.python_io.TFRecordWriter(self.tfrecord_filename+".tfrecord")
        tfrecord_num = 1
        for libsvm_filename in self.libsvm_filenames:
            logger.info("Begin to process {0}".format(libsvm_filename))
            with codecs.open(libsvm_filename, mode='r', encoding='utf-8') as fread:
                line = fread.readline()
                line_num = 0
                while line:
                    line = fread.readline()
                    line_num += 1
                    if line_num % self.info_interval == 0:
                        logger.info("Processing the {0} line sample".format(line_num))
                    if line_num % self.tfrecord_large_line_num == 0:
                        writer.close()
                        tfrecord_file_component = self.tfrecord_filename.split(".")
                        self.tfrecord_filename = self.tfrecord_filename.split("_")[0]+"_%05d.tfrecord"%tfrecord_num
                        writer = tf.python_io.TFRecordWriter(self.tfrecord_filename)
                        tfrecord_num += 1
                        logger.info("Change the tfrecord file to {0}".format(self.tfrecord_filename))
                    feature_ids = []
                    vals = []
                    line_components = line.strip().split(" ")
                    try:
                        # label = 1.0 if line_components[0] == "+1" else 0.0
                        label = float(line_components[0])
                        features = line_components[1:]
                    except IndexError:
                        logger.info("Index Error, line: {0}".format(line))
                        continue
                    for feature in features:
                        feature_components = feature.split(":")
                        try:
                            feature_id = int(feature_components[0])
                            val = float(feature_components[1])                        
                        except IndexError:
                            logger.info("Index Error: , feature_components: {0}",format(feature))
                            continue
                        except ValueError:
                            logger.info("Value Error: feature_components[0]: {0}".format(feature_components[0]) )
                        feature_ids.append(feature_id)
                        vals.append(val)
                    tfrecord_feature = {
                        "label" : tf.train.Feature(float_list=tf.train.FloatList(value=[label])),
                        "feature_ids": tf.train.Feature(int64_list=tf.train.Int64List(value=feature_ids)),
                        "feature_vals": tf.train.Feature(float_list=tf.train.FloatList(value=vals))
                    }
                    example = tf.train.Example(features=tf.train.Features(feature=tfrecord_feature))
                    writer.write(example.SerializeToString())
                writer.close()
            logger.info("libsvm: {0} transform to tfrecord: {1} successfully".format(libsvm_filename, self.tfrecord_filename))

if __name__ == "__main__":
    libsvm_to_tfrecord = LibSVM2TFRecord(["../../data/kdd2010/kdda.libsvm"], "../../data/kdd2010/kdda")
    libsvm_to_tfrecord.fit()

转成tfrecord文件之后,通常比原始的文件要大一些,具体的格式的说明参考下https://cloud.tencent.com/developer/article/1088751 这篇文章比较详细地介绍了转tfrecord和解析tfrecord的用法,另外关于shuffle的buff size的问题,个人感觉问题并不大,在推荐场景下,数据条数多,其实内存消耗也不大,只是在运行前会有比较长载入解析的时间,另外一个问题是,大家应该都会提问的,为啥tfrecord会比自己写input_fn去接下文本文件最后来的快呢?
这里我只能浅层意义上去猜测,这部分代码没有拎出来读过,所以不做回复哈,有读过源码,了解比较深的同学可以解释下

TFRecord的解析

import tensorflow as tf

class LibSVMInputReader(object):
    def __init__(self, file_queue, batch_size, capacity, min_after_dequeue):
        self.file_queue = file_queue
        self.batch_size = batch_size
        self.capacity = capacity
        self.min_after_dequeue = min_after_dequeue

def read(self):
    reader = tf.TFRecordReader()
    _, serialized_example = reader.read(self.file_queue)
    shuffle_batch_example = tf.train.shuffle_batch([serialized_example], 
                            batch_size=self.batch_size, capacity=self.capacity,
                            min_after_dequeue=self.min_after_dequeue)
    features = tf.parse_example(shuffle_batch_example, features={
                    "label" : tf.FixedLenFeature([], tf.float32),
                    "feature_ids": tf.VarLenFeature(tf.int64),
                    "feature_vals": tf.VarLenFeature(tf.float32)
                })
    batch_label = features['label']
    batch_feature_ids = features['feature_ids']
    batch_feature_vals = features['feature_vals']
    return batch_label, batch_feature_ids, batch_feature_vals

个人读了一些解析tfrecord的几个格式的源码,现在还有点乱,大概现在貌似代码中有支持VarLenFeature, SparseFeature, FixedLenFeature, FixedLenSequenceFeature这几种,但是几个api的说明里面貌似对sparsefeature的支持有点磨砺两可,所以选择使用VarLenFeature上面的方式, 不知道这里SparseFeature是怎么玩的,有时间还得仔细看看。

然后,简单写个读取的demo:

import tensorflow as tf
from libsvm_input_reader import LibSVMInputReader
from sparse2train import Sparse2Train

filename_queue = tf.train.string_input_producer(["../../data/kdd2010/kdda_t.tfrecord"], num_epochs=1, shuffle=True)
lib_svm_input_reader = LibSVMInputReader(filename_queue, 2, 1000, 200)

init_op = tf.group(tf.initialize_all_variables(),tf.initialize_local_variables())
batch_label, batch_feature_ids, batch_feature_vals = lib_svm_input_reader.read()
with tf.Session() as sess:
    sess.run(init_op)
    coord = tf.train.Coordinator()
    threads = tf.train.start_queue_runners(sess=sess, coord=coord)
    try:
        step = 0
        while not coord.should_stop():
            step += 1
            print "step: {0}".format(step)
            batch_label_list, batch_feature_ids_list, batch_feature_vals_list = sess.run([batch_label, batch_feature_ids, batch_feature_vals])
            print(batch_feature_ids_list)
            # sparse_2_train_obj = Sparse2Train(batch_label_list, batch_feature_ids_list, batch_feature_vals_list)
            # batch_label_array, batch_feature_ids_array, batch_feature_vals_array = sparse_2_train_obj.fit()
            # print batch_feature_ids_array
            # print batch_feature_vals_array
    except tf.errors.OutOfRangeError:
        print "Done Training"
    finally:
        coord.request_stop()
        coord.join(threads)

大家可以动手跑跑看,仔细研究的话会发现一些比较有意思的东西,比如VarLenFeature出来的是一个SparseTensor,
这里我最开始是打算每次sess.run,然后转换为numpy.array, 然后再喂feed_dict到模型,但是觉得这样会很麻烦,速度会是瓶颈,如果能过直接使用这里的SparseTensor去做模型的计算,直接从tfrecord解析,应该会比较好,但是又会遇到另一个问题,后面再详细说明;这里简单提下,我这边就是直接拿到两个SparseTensor,直接去到模型,所以模型的设计会和常规的算法会有不同;

Sparse Model的高效实现

import tensorflow as tf

class SparseFactorizationMachine(object):
    def __init__(self, model_name="sparse_fm"):
        self.model_name = model_name

    def build(self, features, labels, mode, params):
        print("export features {0}".format(features))
        print(mode)
        if mode == tf.estimator.ModeKeys.PREDICT:
            sp_indexes = tf.SparseTensor(indices=features['DeserializeSparse:0'],
                         values=features['DeserializeSparse:1'],
                         dense_shape=features['DeserializeSparse:2'])
            sp_vals = tf.SparseTensor(indices=features['DeserializeSparse_1:0'],
                                      values=features['DeserializeSparse_1:1'],
                                      dense_shape=features['DeserializeSparse_1:2'])
        if mode == tf.estimator.ModeKeys.TRAIN or mode == tf.estimator.ModeKeys.EVAL:
            sp_indexes = features['feature_ids']
            sp_vals = features['feature_vals']
            print("sp: {0}, {1}".format(sp_indexes, sp_vals))
        batch_size = params["batch_size"]
        feature_max_num = params["feature_max_num"]
        optimizer_type = params["optimizer_type"]
        factor_vec_size = params["factor_size"]

        # first part
        bias = tf.get_variable(name="b", shape=[1], initializer=tf.glorot_normal_initializer())
        w_first_order = tf.get_variable(name='w_first_order', shape=[feature_max_num, 1], initializer=tf.glorot_normal_initializer())
        linear_part = tf.nn.embedding_lookup_sparse(w_first_order, sp_indexes, sp_vals, combiner="sum") + bias
        # second part
        w_second_order = tf.get_variable(name='w_second_order', shape=[feature_max_num, factor_vec_size], initializer=tf.glorot_normal_initializer())

        embedding = tf.nn.embedding_lookup_sparse(w_second_order, sp_indexes, sp_vals, combiner="sum")
        embedding_square = tf.nn.embedding_lookup_sparse(tf.square(w_second_order), sp_indexes, tf.square(sp_vals), combiner="sum")
        sum_square = tf.square(embedding)
        # square_sum = 
        second_part = 0.5*tf.reduce_sum(tf.subtract(sum_square, embedding_square), 1)
        y_hat = linear_part + tf.expand_dims(second_part, -1)
        # y_hat = linear_part
        predictions = tf.sigmoid(y_hat)
        print "y_hat: {0}, second_part: {1}, linear_part: {2}".format(y_hat, second_part, linear_part)
        pred = {"prob": predictions}
        export_outputs = {
            tf.saved_model.signature_constants.DEFAULT_SERVING_SIGNATURE_DEF_KEY: tf.estimator.export.PredictOutput(predictions)
        }
        if mode == tf.estimator.ModeKeys.PREDICT:
            return tf.estimator.EstimatorSpec(
                mode=mode, 
                predictions=predictions,
                export_outputs=export_outputs)
        loss = tf.reduce_mean(tf.nn.sigmoid_cross_entropy_with_logits(labels=labels, logits=tf.squeeze(y_hat)))
        if optimizer_type == "sgd":
            opt = tf.train.GradientDescentOptimizer(learning_rate=params['learning_rate'])
        elif optimizer_type == "ftrl":
            opt = tf.train.FtrlOptimizer(learning_rate=params['learning_rate'],)
        elif optimizer_type == "adam":
            opt = tf.train.AdamOptimizer(learning_rate=params['learning_rate'])
        elif optimizer_type == "momentum":
            opt = tf.train.MomentumOptimizer(learning_rate=params['learning_rate'], momentum=params['momentum'])
        train_step = opt.minimize(loss,global_step=tf.train.get_global_step())
        eval_metric_ops = {
            "auc" : tf.metrics.auc(labels, predictions)
        }

        if mode == tf.estimator.ModeKeys.TRAIN:
            return tf.estimator.EstimatorSpec(mode=mode, predictions=predictions, loss=loss, train_op=train_step)
        if mode == tf.estimator.ModeKeys.EVAL:
            return tf.estimator.EstimatorSpec(mode=mode, predictions=predictions, loss=loss, eval_metric_ops=eval_metric_ops)

这里讲个Factorization Machine的实现,会比Sparse Logistic Regression的实现要稍微复杂一点,首先,模型的算法实现,比较简单,随便搜下应该大概都知道Factorization Machine的算法原理,fm主要包括两个部分,一个是LogisticRegression的部分,包括bias和一阶特征,另外一部分是把每一维特征表示为一个指定大小的vector,去从样本中去学习对训练有效的交叉信息:

bias = tf.get_variable(name="b", shape=[1], initializer=tf.glorot_normal_initializer())
w_first_order = tf.get_variable(name='w_first_order', shape=[feature_max_num, 1], initializer=tf.glorot_normal_initializer())
linear_part = tf.nn.embedding_lookup_sparse(w_first_order, sp_indexes, sp_vals, combiner="sum") + bias
# second part
w_second_order = tf.get_variable(name='w_second_order', shape=[feature_max_num, factor_vec_size], initializer=tf.glorot_normal_initializer())

embedding = tf.nn.embedding_lookup_sparse(w_second_order, sp_indexes, sp_vals, combiner="sum")
embedding_square = tf.nn.embedding_lookup_sparse(tf.square(w_second_order), sp_indexes, tf.square(sp_vals), combiner="sum")
sum_square = tf.square(embedding)
# square_sum = 
second_part = 0.5*tf.reduce_sum(tf.subtract(sum_square, embedding_square), 1)
y_hat = linear_part + tf.expand_dims(second_part, -1)
# y_hat = linear_part
predictions = tf.sigmoid(y_hat)

这里和普通的fm唯一不同的是,我使用tf.nn.embedding_lookup_sparse 来计算WX,在海量特征维度的前提下,做全部的WX相乘是耗时,且没有必要的,我们只需要取出其中有值的部分来计算即可,比如kdd2010,两千万维的特征,但是计算WX其实就会考验系统的瓶颈,但是如果经过一个简单的tf.nn.embedding_lookup_sparse来替代WX,就会先lookup feature_id,对应的embedding的表示,然后乘以相应的weight,最后在每一个样本上进行一个combiner(sum)的操作,其实就是等同于WX,tf.nn.embedding_lookup_sparse(w_first_order, sp_indexes, sp_vals, combiner="sum"), 而在系统方面,由于计算只与NNZ(非零数)有关, 性能则完全没有任何压力。二阶的部分可以降低时间复杂度,相信应该了解FM的都知道,和的平方减去平方的和:

embedding_square = tf.nn.embedding_lookup_sparse(tf.square(w_second_order), sp_indexes, tf.square(sp_vals), combiner="sum")
sum_square = tf.square(embedding)
# square_sum = 
second_part = 0.5*tf.reduce_sum(tf.subtract(sum_square, embedding_square), 1)

由上面的实现,我们只需要把特征的sp_indexes, sp_val传出来就可以了, 但是因为这两者都是SparseTensor,笔者开始想到的不是上述的实现,而是使用tf.sparse.placeholder, 然后喂一个feed_dict,对应SparseTensorValue就可以了,确实是可以的,模型训练没有问题,模型export出来也没有问题(其实是有问题的, 我这里重写了Estimator的build_raw_serving_input_receiver_fn使其支持SparseTensor),但是在部署好TensorFlow Serving之后,我发现在客户端SparseTensorValue貌似不能组成一个TensorProto,tf.make_tensor_proto主要是把请求的值放进一个TensorProto,而TensorProto, https://github.com/tensorflow/tensorflow/blob/master/tensorflow/core/framework/tensor.proto,貌似不能直接支持SparseTensorValue去放进TensorProto,所以就无法在部署好TensorFlow Serving后去请求(部署会在后文详细描述,这里我也想过能不能改他们的代码,但是貌似涉及太底层的东西,有点hold不住),但是也是有办法的,前面文章提到SparseTensor,在TensorFlow中是高阶的api,他其实就是由3个Tensor组成,是否可以把SparseTensor本身的3个Tensor暴露出来,然后请求的时候去组这三个Tensor就可以啦,所以只需要找到TFRecord接下出来的sp_indexes, sp_vals就可以了

从这里很容易看到sp_indexes, sp_vals的TensorName,然后用占位符替代,然后用这些去组成sp_indexes,sp_vals



说明下,这里我使用的kdd2010的数据,特征维度是20216831,样本数量8407752,我是用我15年的macbook pro跑的, 使用的sgd, 收敛还是比较明显的, 大家有兴趣可以试试,按以往经验使用其他优化器如adam,ftrl会在这种特征规模比较大的条件下有比较好的提升,我这里就走通整个流程,另外机器也不忍心折腾;
到了这里,就训练出来了一个可用的Sparse FM的模型,接下来要导出模型,这里的导出模型是导出一个暴露了placeholder的模型,可以在TensorFlow Serving被载入,被请求,不是单纯的ckpt;

模型部署

feature_spec = {
            'DeserializeSparse:0': tf.placeholder(dtype=tf.int64, name='feature_ids/indices'),
            'DeserializeSparse:1': tf.placeholder(dtype=tf.int64, name='feature_ids/values'),
            'DeserializeSparse:2': tf.placeholder(dtype=tf.int64, name='feaurte_ids/shape'),
            'DeserializeSparse_1:0': tf.placeholder(dtype=tf.int64, name='feature_vals/indices'),
            'DeserializeSparse_1:1': tf.placeholder(dtype=tf.float32, name='feature_vals/values'),
            'DeserializeSparse_1:2': tf.placeholder(dtype=tf.int64, name='feature_vals/shape')
        }
serving_input_receiver_fn = tf.estimator.export.build_raw_serving_input_receiver_fn(feature_spec, is_sparse=False)
sparse_fm_model.export_savedmodel(servable_model_dir, serving_input_receiver_fn, as_text=True)

和前面构造模型的时候对应,只需要把DeserializeSparse的部分暴露出来即可

这里会以时间戳创建模型,保存成功后temp-1543117151会变为1543117151,接下来,就是要启动TensorFlow Serving载入模型:docker run -p 8500:8500 --mount type=bind,source=/Users/burness/work/tencent/TFRecSYS/TFRecSYS/runner/save_model,target=/models/ -e MODEL_NAME=sparse_fm -t tensorflow/serving,使用官方提供的docker镜像来部署环境很方便。

会先载入新的模型,然后unload旧模型,从命令行log信息可以看出gRPC接口为8500
剩下的,就下一个client,去请求

import grpc
import sys
sys.path.insert(0, "./")
from tensorflow_serving.apis import predict_pb2
from tensorflow_serving.apis import prediction_service_pb2_grpc
# from grpc.beta import implementations
import tensorflow as tf
from tensorflow.python.framework import dtypes
import time
import numpy as np
from sklearn import metrics

def get_sp_component(file_name):
    with open(file_name, "r") as fread:
        for line in fread.readlines():
            fea_ids = []
            fea_vals = []
            line_components = line.strip().split(" ")
            label = float(line_components[0])
            for part in line_components[1:]:
                part_components = part.split(":")
                fea_ids.append(int(part_components[0]))
                fea_vals.append(float(part_components[1]))
            yield (label, fea_ids, fea_vals)

def batch2sparse_component(fea_ids, fea_vals):
    feature_id_indices = []
    feature_id_values = []
    feature_vals_indices = []
    feature_vals_values = []
    for index, id in enumerate(fea_ids):
        feature_id_values += id
        for i in range(len(id)):
            feature_id_indices.append([index, i])
    for index, val in enumerate(fea_vals):
        feature_vals_values +=val
        for i in range(len(val)):
            feature_vals_indices.append([index, i])
    return np.array(feature_id_indices, dtype=np.int64), np.array(feature_id_values, dtype=np.int64), np.array(feature_vals_indices, dtype=np.int64), np.array(feature_vals_values, dtype=np.float32)    



if __name__ == '__main__':
    start_time = time.time()
    channel = grpc.insecure_channel("127.0.0.1:8500")
    stub = prediction_service_pb2_grpc.PredictionServiceStub(channel)
    request = predict_pb2.PredictRequest()
    request.model_spec.name = "sparse_fm"
    record_genertor = get_sp_component("../../data/kdd2010/kdda_t.libsvm")
    batch_size = 1000
    
    predictions = np.array([])
    labels = []
    while True:
        try:
            batch_label = []
            batch_fea_ids = []
            batch_fea_vals = []
            max_fea_size = 0
            for i in range(batch_size):
                label, fea_ids, fea_vals = next(record_genertor)
                # print label, fea_ids, fea_vals
                batch_label.append(label)
                batch_fea_ids.append(fea_ids)
                batch_fea_vals.append(fea_vals)
                if len(batch_fea_ids) > max_fea_size:
                    max_fea_size = len(batch_fea_ids)
            shape = np.array([batch_size, max_fea_size],dtype=np.int64 )
            batch_feature_id_indices, batch_feature_id_values,batch_feature_val_indices, batch_feature_val_values  = batch2sparse_component(batch_fea_ids, batch_fea_vals)
            # print(batch_feature_val_indices)
            request.inputs["DeserializeSparse:0"].CopyFrom(tf.contrib.util.make_tensor_proto(batch_feature_id_indices))
            request.inputs["DeserializeSparse:1"].CopyFrom(tf.contrib.util.make_tensor_proto(batch_feature_id_values))
            request.inputs["DeserializeSparse:2"].CopyFrom(tf.contrib.util.make_tensor_proto(shape))
            request.inputs["DeserializeSparse_1:0"].CopyFrom(tf.contrib.util.make_tensor_proto(batch_feature_val_indices))
            request.inputs["DeserializeSparse_1:1"].CopyFrom(tf.contrib.util.make_tensor_proto(batch_feature_val_values))
            request.inputs["DeserializeSparse_1:2"].CopyFrom(tf.contrib.util.make_tensor_proto(shape))
            response = stub.Predict(request, 10.0)
            results = {}
            for key in response.outputs:
                tensor_proto = response.outputs[key]
                nd_array = tf.contrib.util.make_ndarray(tensor_proto)
                results[key] = nd_array
            print("cost %ss to predict: " % (time.time() - start_time))
            # print(results["prob"])
            # print results
            
            predictions = np.append(predictions, results['output'])
            labels += batch_label
            # print(predictions)
            print(len(labels), len(predictions))

        except StopIteration:
            break
    fpr, tpr, thresholds = metrics.roc_curve(labels, predictions)
    print("auc: {0}",format(metrics.auc(fpr, tpr)))
    # 1:1 2:1 3:1 4:1 5:1 6:1 7:1 8:0.301 9:0.602 10:1 11:1 12:1 13:1 14:1 15:1 16:1 17:1 18:1 19:1 20:1 21:1 22:1
    # id_indices = np.array([[0,0],[0,1],[0,2],[0,3],[0,4],[0,5],[0,6],[0,7],[0,8],[0,9],[0,10],[0,11],[0,12],[0,13],[0,14],[0,15],[0,16],[0,17],[0,18],[0,19],[0,20],[0,21]], dtype=np.int64)
    # id_values = np.array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22], dtype=np.int64)
    # id_shape = np.array([1,22], dtype=np.int64)
    # val_indices = np.array([[0,0],[0,1],[0,2],[0,3],[0,4],[0,5],[0,6],[0,7],[0,8],[0,9],[0,10],[0,11],[0,12],[0,13],[0,14],[0,15],[0,16],[0,17],[0,18],[0,19],[0,20],[0,21]], dtype=np.int64)
    # val_values = np.array([1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.301, 0.602, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0], dtype=np.float32)
    # val_shape = np.array([1,22], dtype=np.int64)

    # request.inputs["DeserializeSparse:0"].CopyFrom(tf.contrib.util.make_tensor_proto(id_indices))
    # request.inputs["DeserializeSparse:1"].CopyFrom(tf.contrib.util.make_tensor_proto(id_values))
    # request.inputs["DeserializeSparse:2"].CopyFrom(tf.contrib.util.make_tensor_proto(id_shape))
    # request.inputs["DeserializeSparse_1:0"].CopyFrom(tf.contrib.util.make_tensor_proto(val_indices))
    # request.inputs["DeserializeSparse_1:1"].CopyFrom(tf.contrib.util.make_tensor_proto(val_values))
    # request.inputs["DeserializeSparse_1:2"].CopyFrom(tf.contrib.util.make_tensor_proto(val_shape))

    # response = stub.Predict(request, 10.0)
    # results = {}
    # for key in response.outputs:
    #     tensor_proto = response.outputs[key]
    #     nd_array = tf.contrib.util.make_ndarray(tensor_proto)
    #     results[key] = nd_array
    # print("cost %ss to predict: " % (time.time() - start_time))
    # # print(results["pro"])
    # print(results["output"])

开始用一个样本做测试打出pred的值,成功后,我将所有的测试样本去组batch去请求,然后计算下auc,对比下eval的时候的auc,差不多,那说明整体流程没啥问题,另外每1000个样本耗时大概270多ms,整体感觉还可以。

后续

基本到这里就差不多了,现在已经支持单个field的Logistic Regression和Factorization Machine,扩展性比较强,只需要重写算法的类,剩余的大部分都可以复用,接下来计划是支持multi-field的数据接入,会实现更高效的Sparse DeepFM, FNN, DIN, DIEN, 其实已经差不多了,现在正在弄可用性,希望能够通过配置文件直接串起整个流程。

2018/11/24 posted in  TensorFlow

深度解析为什么GAN能是Structured Learning的一种解决方案

Basic of GAN

Goodfellow微醉时与同学进行一次争论,Goodfellow在酒吧相处了GAN的技术:用一个模型对现实世界进行创造,再用另一个模型去分析结果并对图像的真伪进行识别。
300+ 相关GAN模型
Facebook的AI研究负责人杨立昆(Yann LeCun)将GAN称作“过去20年内在深度学习上最酷的想法”

Generator

Discriminator

The Relation between Generator And Discriminator

将Generator比喻成蝴蝶,Discriminator比喻成捕食者,蝴蝶为了不被捕食者捕杀而一步步进化成枯叶蝶,而捕食者由于食物减少也随之会学会更能判别枯叶蝶的能力:

步骤1: 使生成器G不更新,更新判别器D:

步骤2: 使判别器D不更新,更新生成器G:

伪代码:

In each train iteration:
    - Sample m examples {x1, x2, ..., xm} from database
    - Sample m noise samples {z1, z2, ..., zm} from a distribution
    - Obtaining generated data {G(z1), G(z2), ..., G(zm)}
    - Update discriminator parameter to maximize
        - Loss_{\theta_{d}} = 1/m * (\sum_{1}^{m} log(D(xi)) + \sum_{1}^{m} log(1-D(G(zi))))
        - \theta_d = \theta_d + learning_rate * \Delta(Loss_{\theta_{d}})
    - Sample m noise samples {z1, z2, ..., zm} from a distribution
    - Update generator parameter to maximize
        - Loss_{\theta_{g}} = 1/m * \sum_{1}^{m} log(D(G(zi)))
        - \theta_g = \theta_g - learning_rate * \Delta(Loss_{\theta_{g}})

GAN as structured learning

首先我们了解下什么叫做stuctured learning, 机器学习本质上是学习数据集到目标的映射函数 F:X->Y, 对比下机器学习下其他的场景,如回归、分类:

Regression: 输出为连续变量
Classification: 输出为类别(one-hot vector)
Structured Learning: 输出为序列、矩阵(图像)、树等等

Structured Learning的输出是彼此有前后依赖关系的, 比如一个好的系统输出一张生成的图像,图像有蓝天,天空中通常有鸟,但是不会有人(除非是超人),当我们把图像中每一个像素点看做一个components,我们知道这些components之间会有若干联系。

Structured Learning在实际的场景中很有用,比如Machine Translation、Speech Recognition、Chat-bot、Image Transform、Image to Text

Why Structured Learning Challenging

Structured Learning 主要有这几个方面的挑战:

  • One-shot/Zero-shot Learning: 在分类任务中,每一个类别有若干个examples,而在Structured Learning,假如我们把components的某一种组合即生成的结果看做一个类别,你会发现类别特别大,不可能有如此多的数据来覆盖,Structured Learning,生成的图像可能在训练数据集中完全没有出现。因此,Structured Learning需要机器更加"智能",需要学会创造,才能完成相应场景的任务;
  • Machine has to learn to do planning: 前面有提到生成图像有蓝天、天空中有鸟,这些components之间有依赖关系,所有的components才能合成一张有意义的图像,Structured Learning必须要有这样的能力,才能完成相应场景的任务;

GAN: A Solution from Structured Learning

传统的在Structure Learning上相关的工作,主要集中在两部分:

  • Bottom Up: 要产生一个完整的对象,如图像,需要从component一个一个分别产生,这种方法会失去大局观;
  • Top Down: 从整体考虑,生成多个对象,然后找到最好的对象;

而GAN中,Generator就属于Bottom Up的方法,Discriminator属于Top Down的方法,接下来两节,我会详细解释如何理解Generator为Bottom Up,Discriminator为Top Down的方法;

Generator As Bottom Up

假设我们想通过一个向量来生成一张图片,我们一般会如何做呢 ?
很容易,我们一般第一印象会想到Auto-encoder的技术

拿到图片,我们通过一个nn来encoding为一段vector, 然后过NN来decode这段vector,设置loss函数保证decode出来的图像与原始图像尽可能类似,这样我们把decode的部分拿出来,不就是一个Generator了吗 ?
那么Auto-encoder会有什么样的问题呢?

比如code a能生成1的图像,code b也能生成1的图像,比较右向,那么0.5a+0.5b呢 我们可能希望它也有相应的方向变化,但是Auto-encoder可能连1这张图像也无法生成;
如何解决?VAE也是我们在学习GAN经常会拿来对比的

NN Encoder在生成时,会生成对应维度下的方差然后经过如图的组合得到c1,c2,c3去decode相应的输出,这样即使encoder的code约束性更少的情况下也可以得到相应的图像。
说道这样,看起来Generator就可以做到很好的Structure Learning的问题,看到这里,可能会问,前面不是说Bottom Up的方法有缺失大局观的问题吗?如何理解呢?
是的,这里我们来聊下缺失大局观的问题(莫名想到酒神)。

在Auto-encoder中,我们来衡量G的准确性时,我们是拿原始图片和生成的图片,彼此像素值的差异,那么就会存在一个问题:图像对像素值差异越小,越能说明是某一类吗?

第一行中,像素差异的部分只有1个pixel,第二行差异部分有6个pixel,但是我们认为第二行的数据更像属于数字2,这里就是大局观的问题,Generator的方法,在衡量生成结果的时候会有很难设定考虑到大局的相似性函数。
当然,如果在无穷数据、无穷的计算资源下下,这类问题不存在,但是实际当中无法规避,通常的经验是使用Generator生成多个components组成的对象,例如图像时,需要更复杂的网络结构。

Can Discriminator generate

那我们能用Discriminator解决Structured Learning的问题吗?前面有提到Discriminator可以被视为Top Down的一种解决方法。
Discriminator相对Generator很容易去建模components之间的关系,比如

这两个数字,用卷积核,处理components之间的依赖关系,那么Discriminator如何来做呢?

原理很简单,我们只需要遍历所有的数据然后找到生成得分值最好的(实际当中怎么解呢?),即可解决Structured Learning的问题,这里先假设我们能够收集"所有数据"这部分不是问题,那么要想得到一个这样的工具,如何去训练呢?
我们需要得到好和不好的图像,比如在绘画场景下,我们需要得到画的好与画的不好的情况,这个其实就存在一个悖论了,如果我们能得到不好的图像,那么得到好的图像是不是也没有问题呢?怎么得到真实的不好的图像呢?这个是很有意思的

各种不同程度negative会直接影响Discriminator的评分,你很难去得到negative的样本。

而GAN为什么能说是一种比较好解决,同样我们从G和D两个方便来说

  • 针对Discriminator,我们能够利用G很好地解决负样本的问题,这个是Discriminator缺乏的能力,G可以进化地去产生更好的负样本,去保证Discriminator更精准;
  • 针对Generator,尽管还是每一个component每一个component地去生成对象,但是他会学到Discriminator的大局观。
2018/07/10 posted in  GAN