普通视图

发现新文章,点击刷新页面。
昨天以前字节跳动技术团队

2024 抖音欢笑中国年(五):Wasm、WebGL 在互动技术中的创新应用

作者 ByteDanceTech
2024年4月16日 09:01

前言

随着 Web 前端技术的不断发展,越来越多的新兴技术方案被引入到 Web 开发中,其中 Wasm 和 WebGL 作为前端领域的两大利器,为开发者带来了更多的可能性。

本文将结合2024 年抖音欢笑中国年的部分项目,重点介绍如何利用 Wasm 和 WebGL 对目前流行的一些前端互动技术(比如 Lottie、渲染引擎、动画图片等)进行创新和实践,利用 Wasm 和 WebGL 等新技术方案的特性和优势提升业务性能和流畅度,给用户带来更好的体验。

Simple 渲染引擎

WebAssembly(Wasm)是一种可以在 Web 浏览器中运行,提供比 JavaScript 更高的性能,并且支持多种编程语言的全新的字节码格式;基于其高性能的优势,我们团队尝试将其应用到渲染场景中,推出了基于 Wasm + WebGL 的高性能、轻量化的 Simple 渲染引擎。

以期借助于 Wasm 的高性能计算,以 Simple 引擎为基础,保持轻量化的同时,解决目前前端动效和轻互动场景主流技术方案如 Lottie 动画、动画图片(序列帧、 Apng 、WebP)、JS 渲染引擎等存在的能力受限、资源体积大、性能较低等问题。

引擎架构

考虑到前端用户学习和使用成本,Simple 引擎使用 TypeScript 语言开发上层接口,主要是利用 TS 封装简单对象,同时做类型提示方便前端用户使用,另外还提供尽可能高性能的方式和 Wasm 进行交互;底层则使用 C/C++,主要处理计算工作,比如:矩阵计算、图形计算、动画计算、动态合批等;

Simple 引擎目前的渲染管线主要以 2D 为主,也分为两部分:JS 部分负责处理数据量少但是 GL 调用频繁的操作,Wasm 部分则相反负责处理数据量多但是 GL 调用少的操作,尽可能达到性能最优解。

整体架构如下:

0b7490e96201ec48415abe448e6f41a3.png

性能收益

受益于 Wasm 的计算性能优势,Simple 引擎相比主流的 JS 渲染引擎,比如:PixiJS 6.3、Cocos 2.4 在 Spine 动画、精灵旋转、精灵跳动、图形水平移动等基准性能测试场景中取得了不俗的表现:

41134740c43c0f8270a2ceab7ee62943.jpeg

以上测试数据来自 Android OPPO Find X1 抖音 跨端框架 V8 环境,可以看到基于 Wasm 的 Simple 引擎相比基于 JS 的引擎性能提升明显,计算任务越复杂,性能收益越大

计算任务复杂度:骨骼动画 > 图形计算 > 旋转变换 > 位移变换

测试代码

100 个 Spine 动画 Simple 和 Cocos 2.4 测试代码如下:

  • Cocos 2.4

875be47a74bc90af5cb69348e23a163c.png
  • Simple

2024 抖音欢笑中国年(四):渲染技术实践与探索

作者 ByteDanceTech
2024年4月12日 09:01

作者:陈瑞、欧阳浩铸、王武俊、倪梵云

前言

抖音在2024年春节期间推出了欢笑中国年系列活动,为用户带来了全新的体验和乐趣。而SAR Creator则为该项目研发工作提供了重要的技术支持。SAR Creator是一款基于 Typescript 的高性能、轻量化的互动解决方案,目前支持了浏览器和跨端框架平台,服务于字节内部的各种互动业务。

这些绚烂多彩的互动场景当然也离不开实时渲染技术的支持,因此本文将专门介绍春节活动招财神龙神龙探宝中SAR Creator渲染相关的业务实践经验以及技术探索和尝试。

春节—招财神龙

7345d622438ef00c6dc710ba6ca73298.gif

春节—神龙探宝

0773877fecef5af189e5529b1e164646.gif

比如抖音欢笑中国年系列文章《招财神龙互动技术揭秘》中就有提到,项目中“家”场景就是由2D元素以及不同材质支持的3D元素共同组成的。出于性能和美术效果的考虑,各3D模型使用的材质会有所不同,比如无光照Unlit材质、基于物理的PBR材质。对于阴影这种移动端性能消耗比较大的特性,不同物体的接收也会做特殊处理。这些材质的选择以及光照阴影的支持都是依托于SAR Creator材质库能力的支持。如下图所示即为SAR Creator Unlit材质(左图)和PBR材质(右图)的示例。

736c2f25060ad4c19b787a7ca48b685f.png
无光照Unlit材质示例

e98c392344d5de3ed7b7ad78ed12b931.png

基于物理的PBR材质示例

此外,SAR Creator支持使用ShaderGraph插件制作自定义材质,帮助用户制作更多可定制化的特效。神龙探宝项目中实现了多种基于ShaderGraph的特殊效果,包括:入场溶解特效、分区解冻特效、拖尾特效等。下图展示了利用ShaderGraph定制卡通风格水体的效果。

e54fa82a89251689459dbeb48f5d5f7e.gif

ShaderGraph卡通水体

除了上述春节活动中顺利落地的渲染效果外,我们还尝试做了很多效果提升的技术探索,比如后处理辉光效果、凹凸贴图等。希望可以更好的提升美术设计师的设计体验和最终的渲染效果。

55a5f3b786490a099e7cc180c1553988.gif

bloom辉光

3ae0d3723647a5cdb90b42ce4f0dbd88.gif

招财神龙渲染实践

“招财神龙”活动是2024年春节游戏化玩法之一,活动整体采用3D场景(龙在家场景)+ 2D场景(龙寻宝场景)结合的方式。在招财神龙的活动中,设计同学基于SAR Creator编辑器,进行场景搭建和效果还原工作;研发同学基于SAR Creator渲染能力,快速进行技术方案选型和实施。

2D&3D混合渲染

对于活动中的“龙”和“小女孩”元素,我们采用3D模型,提供更为逼真的体验感。而针对场景中的房子、炮仗等,我们使用2D贴片来呈现。通过调整相机的远近平面、fov等参数,展示出小女孩在炮仗前、龙在房子前、龙在炮仗后的视觉假象。

336d0293c792719c9641cb25a6a0f0da.png

34d8f2b847a414da1de5a98bc5183187.png

a21bb64ca35245440ec48320b81758ab.gif

材质库

SAR Creator提供了Unlit、PBR、Uber和NPR等多种材质的选择。

8a85431372592a5662db84565458ec2a.png

例如,这次“招财神龙”中的白天/黑夜场景场景,小女孩和龙的皮肤颜色等需要有不同的表现,就是基于材质的“颜色贴图”能力来实现的。

07f0149d3361217c6e15319eee486fb2.png

针对PBR材质来说,设计同学还基于SAR Creator提供的金属度、粗糙度来进行小女孩身体细节的调整。

ed71f2a0d9db0de3a1ceaf78ecd2604c.png

为了追求更佳的视觉体验,在小女孩的模型上,设计同学为不同的部位(身体、头发和衣服)赋予了不同的PBR材质的实例,再通过调整不同PBR材质的金属度、粗糙度属性,微调受光条件下,不同部位的表现细节。

这次活动,我们不仅使用了PBR材质,综合性能和实用性的角度,还使用了Sar Creator提供的Unlit材质。比如“龙”模型中的身体、胡须、眼睛等模块。Unlit材质是一种简单的、不受光源影响的材质,在技术选型时,为了平衡性能和效果,通常是活动开发的首选。

d7b7383b8145219e1421bdb58c85f4e4.png

光照阴影

除了上述所说的这些材质,为了实现场景中元素的真实性,设计同学借助SAR Creator提供的渲染能力,利用灯光、阴影来优化渲染场景。

SAR Creator提供了平行光的方向、颜色、强度等属性,使得设计同学可以调整出不同效果。

39976b076785d8074127a86f6af5905f.png

为了更好的光照效果,我们这次使用了两个平行光,利用PBR受光的特性,可以实现更贴近真实世界的效果。

d9b082ee9df047348c1f5b388821fc7a.png

只使用了环境光

44f2296144524ae50720137dc8e93e3a.png

使用了一个平行光(小女孩鞋子、脸、手部等部分都收到了光照影响)

9db27f1336e809dcff31fd9f287529d7.png

使用了两个平行光微调(小女孩背部收到了光照,更贴近真实效果)

只有光照,没有阴影的话,同样也不符合物理世界的客观规律。SAR Creator通过在光源上设置“投射阴影”,在需要显示阴影的物体上,设置“接受阴影”,即可快速的实现阴影的效果。

81d9ef000bbe49b1da1ec6aaca9ee2ed.pngf7817ec7b92b2c2ecce0037551b6b3f8.png

787cf895fa8694d31a1003447133d4c3.png

无阴影

d8e376ac6b1159d19890b34badf89739.png

有阴影

利用SAR Creator提供的ShadowMaterial这种自实现的材质,我们还能通过调整颜色、透明度等常用属性,快速调整出设计师想要的阴影效果。

8ebfacd7274e6f0220e782fcfd6e8e65.png

神龙探宝渲染实践

神龙探宝是2024年春节系列活动中的一个以2D场景为主的互动玩法,其尝试并成功落地了多种特效渲染技术。本章节主要有三个特效渲染技术点可分享给大家。分别是:入场溶解特效、分区解冻特效、拖尾特效。

入场溶解

实现入场溶解特效核心是采样一张溶解图(可低分辨率128x128),通过动画step.edge即可。该方案主要通过ShaderGraph可视化界面开发Shader帮助实现美术预期效果,具体节点实现实现如下图所示:

5ad9bb97d13e346c63d30a2bce591f0b.gif

5154513474f9f4c3e21492d7ba98a5aa.png

如想了解更多技术细节,各位同学可用WebGPU版ShaderGraph在线体验(https://deepkolos.github.io/shader-graph-wgsl/?graph=demoSummberDissolve)(PC Chrome113+),也欢迎内部同学直接体验SAR Creator。

地图分区解冻

地图分区解冻需要实现的效果是支持分区块单独控制其处于解冻/未解冻状态。表现效果会随着解冻状态的变化而变化。

如果按照传统前端实现估计需要7个区域小图+1张底图=8张图片去实现, 即需要消耗8个绘制指令(DrawCall)。虽然传统方式可以通过动态合批的方式优化绘制指令(DrawCall)为1个,但合批操作本身也有耗时,且每次资源替换+小图位置调整,会带来额外工作量。而利用ShaderGraph插件定义支持图片存储特定贴图IDMap的Shader可解决这些问题,只需一张JPG 一张PNG 即可。首先我们需要将区域信息存储在A通道,比如区域A = 0.9 区域B = 0.8 以此类推。

未点亮

caf77b7f7548674f540b340cb21cb5e5.png

已点亮

393e03faf9a1aea4f4b442a22828155f.png

解冻过程

4dd5cbffd0801726a243732e2c70589e.gif

然后在Shader中根据点亮前后纹理采样颜色值,混合计算出最终像素颜色值,以实现每个区域的解冻/未解冻状态变化。具体计算逻辑如下所示:

1641a5d6cb74f5dc92f07aeaa7aac780.png5e04238b882a497af44647829418a96b.png

如想了解更多技术细节,该例子也可用WebGPU版ShaderGraph在线体验(https://deepkolos.github.io/shader-graph-wgsl/?graph=demoCustomMap)(PC Chrome113+)。

然而在上述效果的实现基础上,设计同学提出了更高的渲染需求,要求冰冻区域沿边缘浸染已解冻区域,来避免硬边缘。由于项目时间节奏比较紧张,综合考虑时间成本和收益后,边缘浸染需求最终没有推进支持。通过简单的调研,该效果可能的一个解法是:增加7个2D光照,通过光照计算范围来实现冰冻浸染效果,但问题在于没法实现沿边缘浸染。各位如果有什么好的思路也可以分享下,也许可以通过ShaderGraph插件快速支持这种定制需求。

粒子拖尾+几何拖尾

设计效果

f0d6cb18492987003dace0e57cdd1ec2.gif

落地实现

30c220951971cc3ba028452bcff90173.gif

我们可以分析表格第三列中效果参考的构成,得出技术要点为: 几何拖尾+粒子拖尾+头部星光。头部星光较为简单,只需要一个Sprite/Plane+Tween增加下旋转动画即可。下面将主要介绍粒子拖尾和几何拖尾的技术实现。

de9545f53ca53b482c3a70ec345f3ec1.png

粒子拖尾

目前SAR Creator现有非常强大的粒子系统,可快速实现粒子效果,提效非常明显。

8c4ebad09160abc28f675aa887b2c05e.png

但完整的粒子系统在功能强大的同时包体积也相对较大,为了兼顾粒子效果的同时也避免包体积问题,需实现简易版拖尾粒子。通过ShaderGraph结合EmitOverDisatance,抽离出的粒子拖尾特效资源打包后只有7.66KB。下图左为: 简易版粒子拖尾+EmitOverDistance+ShaderGraph联动,下图中/右为: 粒子系统示例和ShaderGraph定制材质的参数设置。

a220a3ec0bed89eb28f8e48b7f468357.png

655e1e5c0ecb42cf5b20e5a612c8c758.gif

ed0746fcd21b5ee8ac38163ded5fbdcb.png

目前粒子拖尾已集成进SAR Creator以及ShaderGraph插件,方便用户更加直观的调试材质特效。

28c3ea21774df80cfeaa74646e0a7c9c.png

GPU Instancing是一个DrawCall绘制大量相同几何,姿态不同的技术。

所以简易版拖尾粒子本质上是一个GPU Instancing几何更新器

7e100ba8879beff159dc3adf9fd2f39a.png

3e24629dc3f7f3dee6ea31c0d0ca14e3.gif

48375ea3ba6c515e78458255a434fc92.png

0a974ff379ea89c01ac1b1ff0b0ee50d.png

大量粒子的位移动画通过Shader使用GPU并行算力完成,节约宝贵的CPU算力。

e9ca8bf996bcdb72c5e1c16385a2d79a.png

感兴趣代码实现,可查看👉开源实现(https://github.com/deepkolos/three-js-trail) or 线上Demo(https://deepkolos.github.io/three-js-trail/),或者SAR Creator中直接体验。

几何拖尾

几何拖尾本质上也是一个几何更新器,不过并非更新Instanced数据,而是几何本身数据Position+Index,使用下图可直观了解几何拖尾的关键逻辑。

939703f5cfebf697e35ffb5a843778ed.png

fae1e8371003b186fcf2e6aad74f7ebf.png

1144e0de44db55950dd7d1458dfe235a.png

如上图左所示为几何拖尾的几何部分实现,参考效果的游动效果则需要在Shader中增加UV动画+拖尾沿Brush重心缩小,所以几何拖尾同样支持ShaderGraph扩展材质。

7f525d7adc0ae085647588279ebb846d.png

72cbd4f7ccc3b66e561901fadbdde60b.gif

如感兴趣代码实现,可查看👉开源实现(https://github.com/deepkolos/three-js-trail) or 线上Demo(https://deepkolos.github.io/three-js-trail/),或者SAR Creator中直接体验。

ShaderGraph探索

ShaderGraph自定义Shader相较于研发编写定制材质而言主要优势在于更高的自由度。SAR Creator通过ShaderGraph插件可以边看中间结果,边理解特效的实现方式。同时帮助用户更好的调试渲染结果高度不可控的特效。此外,ShaderGraph插件实现思路和节点能力实现方式与Unity ShaderGraph基本一致,用户可以以极低成本的方式参考Unity已有特效并搬运到SAR Creator上。

比如抖音故障效果:

61c5350c9e01c526e9845c88f5570c2a.png

再或者卡通水体WebGPU版ShaderGraph在线预览(https://deepkolos.github.io/shader-graph-wgsl/?graph=demoCartoonWater):

4ff473dfb317bf788819070f44f39173.png

渲染技术探索

除了在项目实际落地的渲染技术外,我们也在春节项目中尝试探索渲染技术可能应用场景。下面我们将通过后处理篇和材质篇来进一步介绍其中的技术点。

后处理篇

比如在“招财神龙”的龙须上,我们希望能增加辉光bloom的效果。bloom是屏幕后处理效果中较为常用的一种,表现为高光物体带有泛光效果,通常会搭配HDR来得到更好的效果。

d175823fe1054da9bb5eee75c6c8dd8b.gif

在技术方案的实现上:针对此类特定区域的辉光,我们引入了亮度阈值。第一步,对原场景图进行筛选时,所有小于这个阈值的像素都会被筛掉,仅保留大于等于该亮度阈值的区域,即我们的龙须区域。第二步,对上一步操作的结果龙须区域进行模糊操作,达到光溢出的效果。最后,我们将处理过的图像和原图像进行叠加,就得到了最终的效果。

de591ea8cbfb447edbd0848c30176af5.png

bloom渲染流程示意图

bloom渲染流程中的第一步:过滤高亮区域,我们在shader的属性中加一个lumaThreshold,然后提取图片像素亮度进行step过滤,在片段着色器中代码示例如下:

uniform float lumaThreshold;

float luminance(vec3 color) {
    return dot(color,vec3( 0.299,0.587,0.114 ));
}

void main{
    vec4 texel = texture2D( tDiffuse,vUv );
    float luminosity = luminance(texel.xyz);
    float contribute = step( lumaThreshold,luminosity );
    gl_FragColor = texel * contribute;
}

bloom渲染流程中的第二步,图像模糊算法在后处理渲染领域中占据着重要的地位,后处理中所采用模糊算法的优劣,直接决定了后处理管线最终的渲染品质和消耗性能的多少。高品质后处理:十种图像模糊算法的总结与实现(https://zhuanlan.zhihu.com/p/125744132) 对十种模糊算法进行总结、对比和盘点,其中双重模糊(dual blur) 获得了高性价比评价,故SAR Creator中bloom的模糊算法采用双重模糊进行了实现。双重模糊的核心思路在于模糊的过程中进行了降采样和升采样,即对RT进行了降采样以及升采样。

83ed52badf1e76c55fd983ba8621509f.png

3bd657aa8317fef625f62189bf8e7860.png

部分代码示例如下:

// 新建renderTexture数组
for (let i = 0; i < downSampleNum; i++) {
    this.fboArr[i] = new RenderTexture(0,0,fboOptions);
    if (i !== downSampleNum - 1) {
    this.fboArr[(downSampleNum - 1) * 2 - i] = new RenderTexture(0,0,fboOptions);
    }
}
// 下采样
for (let i = 0; i < downSampleNum - 1; i++) {
      uniforms.tDiffuse.value = fboArr[i].texture;
      uniforms.halfPixel.value.set(1 / fboArr[i].width,1 / fboArr[i].height);
      fsQuad.render(renderer,fboArr[i + 1]);
}
// 上采样
const n = downSampleNum;
for (let i = downSampleNum - 1; i < (downSampleNum - 1) * 2; i++) {
    uniforms.tDiffuse.value = fboArr[i].texture;
    uniforms.downTexture.value = fboArr[2 * n - i - 3].texture;
    uniforms.halfPixel.value.set(1 / fboArr[i].width,1 / fboArr[i].height);
    fsQuad.render(renderer,fboArr[i + 1]);
}

此外,SAR Creator提供了多种blur kernel,设计师可以切换对比,调整出自己想要的光晕效果。

07a2665fb613140b4d3ad090d051d7dc.png

所有的模糊算法都是利用周遭像素值加权叠加计算得到结果的,权重则取决于距离。部分模糊算法的shader实现如下:

#if BLUR_KERNEL == 0 // --------------- Kawase ---------------
void blurKernel() {
    #if SAMPLE_PHASE == 0 // down
    vec4 sum = texture2D(tDiffuse,vUv) * 4.0
            + texture2D(tDiffuse,uv01.xy) + texture2D(tDiffuse,uv01.zw)
            + texture2D(tDiffuse,uv23.xy) + texture2D(tDiffuse,uv23.zw);
    gl_FragColor = sum * 0.125;
    #elif SAMPLE_PHASE == 1 // up
    vec4 sum = texture2D(tDiffuse,uv01.xy) + texture2D(tDiffuse,uv23.xy)
            + texture2D(tDiffuse,uv45.xy) + texture2D(tDiffuse,uv67.xy)
            + (texture2D(tDiffuse,uv01.zw) + texture2D(tDiffuse,uv23.zw)
            +  texture2D(tDiffuse,uv45.zw) + texture2D(tDiffuse,uv67.zw)) * 2.0;
    gl_FragColor = sum * 0.0833;
    #endif
}
#elif BLUR_KERNEL == 1 // --------------- Box4Tap ---------------
    void blurKernel() {
        vec4 sum = texture2D(tDiffuse,vUv + uvOffset.xy) + texture2D(tDiffuse,vUv + uvOffset.zy)
                + texture2D(tDiffuse,vUv + uvOffset.xw) + texture2D(tDiffuse,vUv + uvOffset.zw);
        gl_FragColor = sum * 0.25;
    }
#elif BLUR_KERNEL == 2 // --------------- Tent9Tap ---------------
    void blurKernel() {
        vec4 sum = texture2D(tDiffuse,vUv + uvOffset.xy)
                + texture2D(tDiffuse,vUv - uvOffset.xy) 
                + texture2D(tDiffuse,vUv + uvOffset.zy)
                + texture2D(tDiffuse,+ vUv - uvOffset.zy)
                + (texture2D(tDiffuse,vUv - uvOffset.wy) 
                +  texture2D(tDiffuse,vUv + uvOffset.zw) 
                +  texture2D(tDiffuse,vUv + uvOffset.xw) 
                +  texture2D(tDiffuse,vUv + uvOffset.wy)) * 2.0 
                + texture2D(tDiffuse,vUv) * 4.0; 
        gl_FragColor = sum * 0.0625;
    }

材质篇

互动活动场景中经常会出现3D地形,如果通过建模软件来生成三角形面片几何体的方式支持该需求的话,会存在三角面片数过高从而消耗性能过大的问题。此外,设计师很难得到所需要的凹凸起伏的建模,设计调整成本过高。一般而言,设计师会希望通过一张灰度图来表示相应区域的高低,从而通过控制平面中各个着色点在垂直方向上的偏移来表达起伏。

而支持这种实现的技术就是位移贴图(DisplacementMap)。如下图1、2所示展示了PBR材质修改位移贴图缩放指数(DisplacementScale)为0和2的区别。如下图3所示展示了控制高低的位移贴图。

50e0c138a5ed36f4ba5087844cb32c2e.png

472487c452d39e8d420c6a212ce176b7.png

ffb95da908bd480bfb1b185bd97ba795.png

位移贴图的实现方式是在材质顶点着色器VertexShader中基于原始顶点信息、结合位移贴图对顶点偏移的计算,得到实际展示的顶点位置,具体shader中定义和实现如下所示:

// 前置信息定义
#ifdef USE_DISPLACEMENTMAP
    vec2 displacementUV; // 位移贴图UV
    uniform mat3 displacementUVMatrix; // 位移贴图UV变换锯齿
    uniform sampler2D displacementMap;  // 位移贴图
    uniform float displacementScale; // 位移缩放比例
    uniform float displacementBias; // 位移偏移
#endif
// 顶点计算
#ifdef USE_DISPLACEMENTMAP
    positionV4.xyz += normalize( positionV4.xyz ) * ( texture2D( displacementMap,        displacementUV ).x * displacementScale + displacementBias );
#endif

此外,与位移贴图调整当前绘制点顶点不同,还可以通过调整当前绘制点法线计算的方式使得渲染结果展示出一种比较细致的凹凸感,即凹凸贴图(BumpMap)技术。如下图1、2所示展示了PBR材质修改凹凸贴图缩放指数(BumpMapScale)为0和100的区别。如下图3所示展示了控制表面粗糙感的凹凸贴图。

be424966f632df9903d20fdc260210f0.png

407c6235afb58159ceaa67d0cb35f0a6.png

dcb50fb7504ebbfae7a616798fd8c54f.png

凹凸贴图的实现方式是在材质片元着色器FragmentShader中基于原始顶点/法线信息、结合凹凸贴图,计算出调整后的法线结果,具体shader中定义和实现如下所示:

// 前置信息定义
#ifdef USE_BUMPMAP
    varying vec2 bumpUV; // 凹凸贴图UV
    uniform sampler2D bumpMap; // 凹凸贴图
    uniform float bumpScale; // 凹凸缩放比例
#endif
#ifdef  USE_BUMPMAP 
    vec2 dHdxy_fwd() {
        vec2 dSTdx = dFdx( bumpUV );
        vec2 dSTdy = dFdy( bumpUV );
        float Hll = bumpScale * texture2D( bumpMap,bumpUV ).x;
        float dBx = bumpScale * texture2D( bumpMap,bumpUV + dSTdx ).x - Hll;
        float dBy = bumpScale * texture2D( bumpMap,bumpUV + dSTdy ).x - Hll;
        return vec2( dBx,dBy );
    }
    vec3 perturbNormalArb( vec3 surf_pos,vec3 surf_norm,vec2 dHdxy,float faceDirection ) {
        vec3 vSigmaX = normalize( dFdx( surf_pos.xyz ) );
        vec3 vSigmaY = normalize( dFdy( surf_pos.xyz ) );
        vec3 vN = surf_norm;
        vec3 R1 = cross( vSigmaY,vN );
        vec3 R2 = cross( vN,vSigmaX );
        float fDet = dot( vSigmaX,R1 ) * faceDirection;
        vec3 vGrad = sign( fDet ) * ( dHdxy.x * R1 + dHdxy.y * R2 );
        return normalize( abs( fDet ) * surf_norm - vGrad );
    }
#endif
// 法线信息计算
#ifdef  USE_BUMPMAP 
    normal = perturbNormalArb( posWorld,normal,dHdxy_fwd(),faceDirection );
#endif

所以我们可以看到,渲染引擎除了提供通用的基础材质外,往往需要在原有材质基础上灵活迭代,根据用户使用场景和需求不断支持新特性。

未来展望

SAR Creator 材质库、ShaderGraph特效、后处理等渲染能力在24年春节活动中得到进一步完善和项目验证,为互动场景提供了不错的视觉效果。当然在业务落地过程中,我们也发现了一些不足之处。比如后处理可选效果还不够多、功能还不够完善,目前只初步支持bloom和fxaa后处理。比如美术工作流还不够高效,美术资产的制作和落地过程中可能存在卡点,需要多方沟通协调。后续我们会和美术设计师保持更紧密的沟通,更深入的了解用户需求,提供更多开箱即用的材质特性、后处理效果和ShaderGraph特效能力。

团队介绍

我们是抖音前端架构-互动体验技术团队,主要为字节跳动业务提供互动技术解决方案。技术产品包含面向互动 / 小游戏研发场景的 SAR Creator、高性能动效渲染引擎 Simple Engine、互动场景端能力套件 AnnieX 互动容器

在这些技术建设与业务落地上,和抖音前端-互动创作团队跨端框架团队、开放平台小游戏团队、用户增长-激励前端团队一同推进,不断探索字节跳动应用生态下的创新业务形态。

下期预告

下期主题是Wasm、WebGL 在互动技术中的创新应用,重点介绍如何利用 Wasm 和 WebGL 对目前流行的一些前端互动技术(比如:Lottie、渲染引擎、动画图片等)进行创新和实践,利用 Wasm 和 WebGL 等新技术方案的特性和优势提升业务性能和流畅度,给用户带来更好的体验,敬请期待。

往期回顾

2024 抖音欢笑中国年(一):招财神龙互动技术揭秘

2024 抖音欢笑中国年(二):AnnieX互动容器创新玩法解析

2024 抖音欢笑中国年(三):编辑器技巧与实践

2024 抖音欢笑中国年(三):编辑器技巧与实践

作者 ByteDanceTech
2024年4月8日 09:01

前言

本次春节活动中,我们大部分场景使用内部的 SAR Creator互动方案来实现。

SAR Creator 是一款基于 TypeScript 的高性能、轻量化的互动解决方案,目前支持了Web和字节内部跨端框架平台,服务于字节内部的各种互动业务,包括但不限于抖音春节、抖音直播礼物、抖音UG活动等。

c77ecd1f40b0c06260f89cf4784504e4.png

SAR Creator 编辑器支持了图形化界面,提供了各类完善的系统(光照、动画、脚本等)供用户快速便捷的搭建一个高质量的互动场景,集成了场景搭建、资源配置、预览调试等功能,并提供了打包构建等工程化插件,可配合业务定制高效2D、3D内容生产工作流。

本文将就本次春节活动中编辑器的工作流程、开发常用功能、以及配套的工程配置方案等进行介绍。

工作流程

目前编辑器的工作流程能极大的提升开发效率,在于研发和设计能够极好的解耦工作。对于设计同学来说,不仅能在研发同学在线编辑器上协作,也能独立制作通过Asset Package包进行资产的交付;而对于开发来说,编辑器能够可视化的进行大部分场景搭建以及资产配置,能直观的检查资产信息。

906f200aa59bd0fea91368bc9377b237.png

资产制作

由于设计同学本地没有开发环境,为了方便设计同学脱离研发单独使用SAR Creator制作美术资产,我们提供了SAR Creator Launcher工具供美术使用,使用 Sar Creator Launcher 可以管理 Sar Creator 编辑器的多个版本安装创建新项目快速启动项目查看编辑器版本的更新信息等功能。

5bdcf9795be11c1e902ef83dca3350c4.png

设计同学使用Launcher搭建一个项目之后,可以将他从PS、Blender、Spine制作的资源导入到SAR Creator当中进行组合到场景当中,并导出成预制体资源,例如春节活动以下场景就是由设计同学制作然后交付给研发使用。

42c8bedec92635dee467484046d9d986.png

动效预览

设计同学产出的资源可以直接导入到SAR Creator中进行播放预览以及调整,美术可以确保最终交付给研发的动画资源是符合预期的。

beb956a77fcc67b760305144d5b31a23.png

目前支持的动画有 lottie、spine、帧动画、模型动画、组合动画以及帧差动画。

资产导出

设计同学最终的导出产物一般为prefabs资源,因为设计和研发的项目不互通,需要一种更快捷的方式同步资源,设计同学可以在创建的预制体右键导出资源包,此功能会将预制体依赖的所有资源导出为一个asset压缩包,设计同学可以直接将此压缩包发送给研发同学导入

16caf2707223d84e81e608d2ff08b768.png

d146610c67261366b5d1083f75eaf9a8.png

研发同学直接在game目录上右键选择导入资源包,即可选择设计同学交付的文件将资源导入

810e15a6cbcd6cb3f83205499629451f.png

68ed939d7073af76af9e84fba21c0ea5.png

功能介绍

画布适配

画布适配是指调整互动区域的视觉输出以适应不同的屏幕尺寸和分辨率,这样可以确保互动区域在不同分辨率的设备上看起来运行都正常。

57a549f6b30d397d6513061effb77ecc.png

SAR Creator 提供了简单的解决方案,首先需要指定设计的宽度和高度,它们定义了游戏将以何种分辨率进行开发,通过设定这些值可以确保互动区域在所在目标设备上正确缩放并保持一致的视觉呈现。

除此之外除了为了进一步增强画布的适配功能,我们提供了根据特定需求的各种适配策略。这些策略包括:

  • FixedWidth(固定宽度):在此策略中,互动区域保持其设计宽度,高度根据纵横比自动调整。这确保互动区域始终占据屏幕的整个宽度,没有重要信息被裁剪掉。

  • FixedHeight(固定高度):与固定宽度策略类似,互动区域保持其设计高度,而宽度则根据纵横比调整。这可以确保互动区域始终垂直填充屏幕,并在各种设备上提供一致的体验。

  • Auto(自动适应):此策略将互动区域按比例缩放以适应可用屏幕空间的宽度和高度。取决于设备的纵横比,两侧或上下可能会出现一些空白区域,但整个互动区域内容都是可见的且无失真。

2D&3D混合开发

互动场景的编辑中,经常需要2D和3D元素相互结合才能创造出更好的交互体验,3D模型的移动经常能带来更逼真的效果,2D元素经常应用于背景图或者UI元素。

a1f0813a0a41728626e639d7cedc62e7.png

通过使用SAR Creator编辑器,可以很轻松的在同一场景中同时添加2D和3D对象。而且SAR Creator编辑器还允许自由调整这些对象之间的层级关系、渲染顺序以及视觉表现。此外,还可以为场景设置多种灯光和相机效果,进一步丰富场景表现力。

使用2D用来编辑器场景UI,对比使用前端技术编写UI界面逻辑,可视化拖拽编辑能大大提升开发速度,拿之前的一个项目举例:

  • 左下界面UI是使用前端技术方案代码编写界面,耗时3天。

  • 右下UI是使用SAR Creator 2D场景制作,耗时0.5天。

33d910c66f505a8e98a9dba26a4bd1d1.png

302a32eb4a2790c0432b075de532089a.png

Bundle分包

在项目开发过程中,资源管理和加载优化是至关重要的。随着互动内容变得越来越丰富,会出现资源体积庞大、加载速度缓慢等问题。为了解决这些问题,SAR Creator编辑器提供了Bundle分包能力,帮助开发者轻松地对游戏资源进行分组、管理和优化。

1692c2955f530f2565c1d5a24064b968.png

a0a90849f89e1c2da3a37bdf393fba84.png

21e1b28e24e87e265afe96ea0ec6ec41.png

Bundle分包使用起来非常简便,只需要选中文件夹,然后如左上图所示,勾选上配置为Bundle,就可以在构建的时候将这个文件夹内的资源拆分为独立的Bundle包,分组选项中有三种资源合并类型,功能介绍如下

  • 无:不做任何优化;

  • 合并依赖:将Bundle中相互依赖的资源二进制文件合并在一起,从而减少运行时加载请求次数;

  • 全部合并:将Bundle中所有资源二进制文件合并为一个,最大化减少请求数量,但可能会增加单个资源加载时间。

用户可以根据自己bundle包的资源情况合理选择分组方式。使用Bundle分包可以让开发者按需加载分包资源,无需项目初始化时就加载所有资源,这样不仅能减少项目首包的大小,还能提升游戏的启动速度和运行效率。

图片分级

图片资源的大小不仅影响着项目最终的包体大小,分辨率大的图片同样会占用设备更多的内存以及性能,而不同的设备的性能是不一样的,为了达到最好的显示效果又要满足用户设备的性能要求,SAR Creator编辑器对图片提供了分级功能,可以使不同性能的设备加载不同分辨率级别的图片,这在春节活动中得到了广泛使用。

0a7fccb3b4bba98a024d55e0f976b925.png

当勾选去掉禁用分级后,构建的时候会生成normal、medium、low三个级别的文件资源,其中medium、low的图片分辨率会低于原始图片,会根据用户手机的性能评分下发不同等级的图片资源,而无需研发同学关心下发的内容。

f3be30d8d1b6b1c0092012e2b9b08805.png

e3c8be38cd10a08b463efe68bc2ca33e.png

需要注意的是,在使用图片分级时,实际上是会修改图片的分辨率大小,而当我们使用spine或者序列帧这类动画文件时,图片资源为图集,如果降低了图片的分辨率,它们就无法在图集上获取正确的像素数据,导致动画播放异常,因此针对图集文件或者动画文件的图片资源,我们需要禁用分级。

压缩纹理

压缩纹理,是一种 GPU 能直接读取并显示的格式,使得图像无需在 CPU 侧解压成 bitmap 即可直接传入 GPU 进行渲染,节约大量的显存开销,而在移动端即内存开销。

在图片的检查器中,勾选上使用压缩纹理即可开启使用,压缩纹理在运行时会根据运行环境自动判断是否使用压缩纹理,使用时用户无需关注。

f8e8832c32ef505b8eeff49261484f60.png

4c69f4ee3e6b4fc1a9808b0910fa4ff3.png

启动图片上的压缩纹理配置后,可以配置选择etc、astc类型的压缩纹理,并通过排序决定运行时加载的优先级。同时可以配置压缩纹理的压缩质量、是否翻转等属性。

另外需要注意的是

  • 压缩纹理不能翻转纹理,即 flipY 表现始终为 false

  • 压缩纹理不能生成 mipmap,即 generateMipmaps 始终为 false

  • 为了适配不兼容压缩纹理的机型,图片资源仍然会被打进包体当中,所以包体会存在图片和图片的压缩纹理资源,包体体积会变得更大

透明像素扩边

在Canvas中绘制图片纹理时,图片边缘的像素会比较难处理,当我们选择边缘像素插值方式为linear时,虽然边缘会更光滑,但是由于插值方式的原因,图片的像素边缘会有一圈黑边,如下左图所示:

b4605170bc145ebfd8817242ad3a9706.jpeg

7865be46c5ae46a3d3aaedd97218aa2c.jpeg

因此,为了解决这个问题,SAR Creator编辑器提供了透明像素扩边的能力,我们可以通过图片配置勾选上透明像素扩边,将图片的边缘增加一圈纯白像素,使插值的像素可以不为黑色,如果扩边单个像素为白色不明显时,我们也可以选择扩全部像素,将透明像素图片的所有透明像素点填充为通过插值计算的带有颜色透明度的像素点,避免边缘渲染时产生黑边。

b87b8c2c6ebc881fee0937e0ac273048.png

841eeff679e2ede36f59935e836ef493.png

不过透明像素扩边同样存在着一些问题,经过扩边操作的图片因为会修改原先的图片文件,所以会导致图片体积增大,具体的增大情况要看最终的图片产物。

工程配置

配置文件

SAR Creator的配置文件主要分为两块:

  • SAR Creator项目的配置文件,其指定了项目的路径、入口文件等基础信息以及SAR Creator中预览的一些基础配置,方便运行项目的时候找到项目的位置。

  • SAR Creator项目构建插件的配置,因为SAR Creator可以适配各种平台,因此SAR Creator的构建流程通过插件的形式嵌入到不同平台的构建流程当中,并可以在插件中深度集成平台的相关能力。

SAR Creator项目配置文件通常叫做sar.config.js,这边以春节的摇签项目配置举例,项目的配置文件如下所示:

const path = require('path');
module.exports = {
  // 指定入口文件目录
  projectRoot: path.resolve(__dirname, 'game'),
  previewConfig: {
    previewDevtool: true,
    resolve: {
      alias: {
        // 别名,脚本中使用@lottery代替绝对路径,用来避免sar creator报错
        '@lottery': path.resolve(__dirname, './')
      }
    }
  }
};

插件通常在平台的配置文件中引入,因此插件的配置通常也在平台的配置文件中,此次春节活动是基于字节内的跨端框架平台开发,所以接下来会结合跨端框架平台做插件配置的功能介绍。

分包配置

跨端框架平台的项目会主动下发资源到用户手机,但是为了提高资源包下发的成功率,单包的限制通常在5M,因为如果我们的项目资源大于5M时,就需要进行拆包,在上面我们已经介绍过了Bundle分包,其能很好的将互动资源进行拆分并动态加载,但是一个Bundle包不一定有5M,因此为了让资源包收益最大化,一个资源包可以由几个Bundle包组成,这个拆分手动的话会很麻烦,因此我们通过工程配置在,构建完成后进行分包,配置信息如下:

const lotterySarConfig = {
  // ......
  /** 分包策略,构建才会生效 **/
  subPackages: {
    // 可以过滤掉一些包,比如测试包需要被剔除
    excludeBundles: ['test'],
    // 组合bundle包为gecko包的规则
    combineBundleRules: [
      {
        // 拆分到的gecko包名
        channelName: 'pitaya-lottery-game-2',
        // bundle包匹配符合条件的正则规则
        test: [/mascot/]
      },
      {
        channelName: 'pitaya-lottery-game',
        test: [/main-scene/, /resources/],
        preload: true,
        preloadPriority: 96
      }
    ]
  }
};

内联配置

在Bundle包中,会存在各种config或者json配置文件,文件数量较多时,每次访问都需要请求一个链接地址,为了减少请求次数,我们可以通过配置插件构建,将每个Bundle包中的json文件合并成一个二进制文件,减少请求的次数和资源的大小,并且为了防止单个二进制文件太大,我们仍可以限制合并的二进制的文件大小,以确保不影响请求资源下载的速度,配置如下:

const lotterySarConfig = {
  // ....
  ejectOptions: {
    // bundle的config文件不生成
    disableGenConfigWhenInline: true,
    // group分组配置信息
    groupConfig: {
      // 针对 bundle 下 group 的体积限制,优先级更高
      groupPackingSizeLimitBundleConfig: {
        // 名为mascot的 bundle 下单个group限制400k大小 
        mascot: 400,
        // 同上
        resources: 400
      }
    },
    // 内置bundle文件的config配置到entry.ts里
    inlineConfig: {
      'main-scene': true,
      resources: true,
      mascot: true
    }
  },
}

预加载配置

跨端框架平台支持资源的预加载,为了配合平台特性,我们还可以在构建生成游戏的时候就配置Bundle里的资源支持预加载,然后在构建完成后就可以生成预加载的配置文件,可以使用户在打开容器时就可以让容器提前加载好配置文件里的资源,使项目内加载到资源的时候,能更快的加载资源,其配置如下:

const lotterySarPreloadUrlList = [];
const lotterySarConfig = {
  // ......
  // 
  preload: {
    // 预加载资源前缀
    publicPath: `https://${CDN_DOMAIN}${GECKO_PREFIX}/pitaya-lottery-main/`,
    // 限制预加载资源个数
    limit: 300,
    // 限制预加载文件总体积, 默认就是3M
    sizeLimit: 3 * 1024, 
    // 配置文件输出目录
    outputDir: 'merge-js/lottery',
    // 过滤资源的规则
    asset: {
      rules: [
        {
          // 匹配规则
          test: /.*/,
          // 可以匹配重写该资源是否加入 preload.json,返回 { enableMemory, url, priority } 或 null
          rewrite: (item) => {
            if (
              item.url.endsWith('.png') ||
              item.url.endsWith('.jpg') ||
              item.url.endsWith('.jpeg') ||
              // 已经加入的不再次加入preload
              lotterySarPreloadUrlList.includes(item.url)
            ) {
              // 不写入预加载配置
              return;
            }
            lotterySarPreloadUrlList.push(item.url);
            item.enableMemory = true;
            // 写入预加载配置
            return item;
          }
        }
      ]
    }
  }
}

最后预加载的配置文件如下所示:

{
    // 跨端框架页面路径
    "***/index/template.js": {
        "image": [
            {
                // 是否开启内存缓存
                "enableMemory": true,
                // 资源加载路径
                "url": "***/resource/image/back.bcd0a82e.png",
                // 资源大小
                "size": 280
            },
            // ....
        ],
        "other": [
            {
                "enableMemory": true,
                "url": "***/main-scene/g_0.cc991.group",
                "priority": 96,
                "size": 143296,
                "assetBundleType": "group"
            },
            // ......
        ]
    }
}

依据跨端框架的页面路径,来预加载配置的URL链接以及优先级,能够在页面打开前就提前加载好资源内容。

开发实践

脚本编写

在资产平台右键我们可以创建默认脚本,脚本格式如下所示

a39feb4cb45bf606b323286cd4b93f21.png
// newscript.ts 脚本文件

import { Script, ScriptUtil } from '@sar-creator/toolkit/engine/core';

@ScriptUtil.Register('NewScript')
export default class NewScript extends Script {
  onStart() {
    const scene = this.world.getEntitiesByName('Scene')[0];
    if (scene) {
      // TODO:
    }
  }
}

SAR Creator 脚本的生命周期分为以下几个阶段:

  • onAwake:节点挂载时执行一次

  • onDestroy:节点销毁时执行

  • onPause:暂停时执行

  • onResume:恢复时执行

  • onStart:第一次帧动画开始前执行一次

  • onUpdate:每帧开始时执行

  • onLateUpdate:每帧结束时执行

我们常用的生命周期有onStartonUpdateonResume这个三个生命周期。

onStart是在节点首帧时激活一次,表明游戏已经可以开始运行,我们一般在此阶段动态加载资源,将获取到的资源添加到场景中,或者获取场景的某些节点实例。因为我们的项目是SAR Creator + 跨端框架组合,所以在跨端框架阶段初始化引擎的时候,我们会同步去获取服务端数据,当满足onStart触发以及数据获取成功两个条件时,我们一般会在脚本内依据数据初始化场景内容。

onUpdate会在节点的每帧渲染前触发一次,由requestAnimationFrame驱动,当我们需要变换场景中的元素的时候,就可以依靠每帧触发修改微小的变量,达到最终看起来连贯变换的样子,例如在摇签玩法中我们会依据陀螺仪的数据来确定签筒的状态,因此我们在此阶段每帧获取陀螺仪的数据,然后对其操作同步转换签筒的状态,让签筒有一种跟手的感觉。

onResume会在游戏暂停后重启时触发,一般来说当我们有些定时操作会在暂停阶段销毁,在恢复阶段触发,除此之外,当节点隐藏恢复时也会触发此阶段,当一个节点启动时播放动画,播放完成后隐藏,当恢复时需要重置动画状态并播放就可以在这个阶段进行。

预制体制作

当我们将层级面板中的节点拖拽到资产面板中就会自动生成预制体资产,预制体资产储存了这个节点的所有的数据以及依赖的资源信息。

aa49991b785f383f18cb353c4079af64.png

双击预制体资源,我们仍可以对预制体进行二次编辑等操作。

b1062bd3002fc95f22ce7c86b71cdfca.png

使用预制体资源可以帮我们可视化的预设大量的节点数据,我们可以通过直接加载制作的预制体,节省大量的实体修改参数配置等操作,从而提高我们的开发速度。

另外将场景中的非必要资源拆成预制体加载,可以有效提升主场景的加载速度,并渐进式加载其他装饰节点预制体到场景当中,并且可以依据用户手机性能,选择性加载不同复杂程度的预制体到场景当中。

例如在摇签玩法中,我们预设了不同的背景烟花以及签筒的预制体,使用时我们依据时机判断加载不同的烟花到场景当中,以及依据用户的手机性能,选择性加载要求更高性能的签筒还是性能要求更低的签筒。

c8683dfd43742295aef5fc4f47e83295.png

动态加载

在项目搭建中我们已经介绍了Bundle的配置方式,用户可以将文件夹配置成动态资源包,那么加载动态资源包我们提供了资源管理器assetManager挂载在world上,例如加载一个预制体资源如下所示:

const { 
  bundle, 
  error: bundleError
} = await this.world.assetManager.loadBundle('resources');

if(!bundle || errror) throw bundleError

const {
  asset,
  error: prefabError
} = await bundle.load<Entity>(`prefabs/fireworks/${prefabName}.prefab`);

 if (!asset?.get?.() || prefabError) throw prefabError;
 
 const entity = asset.get() as Entity

我们需要先使用loadBundle获取到这个Bundle包的实例,loadBundle支持传入Bundle的名称或者链接,拿到Bundle实例后,我们按照资源在bundle包中的路径加载,就可以获得到一个asset资产对象,asset调用get方法可以获取当前加载资产的实例,例如加载预制体获取到Entity实例。

另外当我们加载图片资源时,我们从资产面板可以看到图片的texture虚拟资产Texture对象,如果我们想要直接加载图片的Texture对象时,就可以在加载图片的路径中拼接上index.texture,例如下所示:

bf02cc73a1a56cebb3522554db65a6b8.png

const { 
  bundle, 
  error: bundleError
} = await this.world.assetManager.loadBundle('resources');

if(!bundle || errror) throw bundleError

const {
  asset,
  error: textureError
} = await bundle.load<Texture>(`texture/lamp/rect.png/index.texture`);

 if (!asset?.get?.() || textureError) throw textureError;
 
 const texture = asset.get() as Texture


fda486398ea9723832b83f1a2de896ea.png
const { 
  bundle, 
  error: bundleError
} = await this.world.assetManager.loadBundle('resources');

if(!bundle || errror) throw bundleError

const {
  asset,
  error: textureError
} = await bundle.load<Texture>(`texture/lamp/rect.png/index.texture`);

 if (!asset?.get?.() || textureError) throw textureError;
 
 const texture = asset.get() as Texture

未来展望

在过去的一年中,我们的SAR Creator产品经历了三个重要的大版本更新,分别是1.0、1.1、以及1.2,三个版本的迭代都是我们基于用户需求并不断优化用户体验推出来的阶段性版本。

在三个版本的迭代中,我们满足了研发同学的一些重要需求,例如脚本配置、bundle分包、性能优化等;增添了一些新功能,例如动画编辑器、VFX Particle、SAR Creator Launcher等;优化了SAR Creator的使用体验,例如多语言版本、材质属性分组、资产预览面板等。

基于过去一年的迭代,SAR Creator为春节的活动的开发带来了稳定的功能支持,在2024年中,SAR Creator将会持续优化引擎性能以及编辑器的易用性。

除此之外动画将是编辑器能力的核心,在2024年中我们将会不断扩展动画编辑器的能力,使用SAR Creator能够实现更加丰富的动画编辑功能,简单的动画元素将直接可以使用编辑器完成开发和预览。

团队介绍

我们是抖音前端架构-互动体验技术团队,主要为字节跳动业务提供互动技术解决方案。技术产品包含面向互动 / 小游戏研发场景的 SAR Creator、高性能动效渲染引擎 Simple Engine、互动场景端能力套件 AnnieX 互动容器

在这些技术建设与业务落地上,和抖音前端-互动创作团队跨端框架团队、开放平台小游戏团队、用户增长-激励前端团队一同推进,不断探索字节跳动应用生态下的创新业务形态。

下期预告

下期主题为渲染技术与实践,为了满足视觉和性能的两个要求,我们在渲染能力上提供了多种方法在适合的场景使用,最终达到了极为优秀的效果,敬请期待。

往期回顾

2024 抖音欢笑中国年(一):招财神龙互动技术揭秘

2024 抖音欢笑中国年(二):AnnieX互动容器创新玩法解析

2024 抖音欢笑中国年(二):AnnieX互动容器创新玩法解析

作者 ByteDanceTech
2024年4月2日 09:03

本文基于24年抖音春节活动业务背景,介绍了字节跨端容器AnnieX在游戏互动套件上的探索,致力于提升容器在游戏互动场景的优化能力。

业务背景

AnnieX作为字节一方游戏统一容器,服务字节内部电商、直播、UG等跨端场景业务。在字节一方游戏互动场景,有大量的一方游戏业务对容器有特定的流量、端能力和游戏优化的诉求。因此我们不断深入互动游戏业务特点,为字节游戏提供完善游戏端能力和流量运营能力,同时提供游戏互动场景极致优化能力,提升游戏业务的整体性能。

养羊赚钱

种树赚钱

6699998c85f35a3ff42ba6bd7d047359.gif

f27d8dc4f5f072ea8b2eab508dd42597.gif

萌宠旅行家

新周中/周末

outside_default.png

dc614a6f7bc65ab28dd6a16914224874.gif

今年是抖音春节活动的第7年,我们跨端容器已经具备丰富的端能力的同时,也提供了一些对于跨端容器常规的一些优化手段,例如:资源预加载、网络预请求等手段,并且在往年春节活动中取得了不错的优化效果。而今年,我们希望能够基于游戏场景,逐渐深挖游戏互动场景的特点,为春节等游戏场景去提供更加丰富和专业的游戏优化能力

春节-神龙寻宝

春节-守卫现金

db39de3191245c4c5a1b9dadd9a01fbb.gif

dc0939ac00ee03bba5eef54b2956f348.gif

春节-摇福签

春节-神龙探宝

6bc9d3e98087576784263418604812d0.gif

6dd28bb2b7f84752dcf654dda68d0e82.gif

互动套件介绍

因此我们基于24年抖音春节活动,在容器侧提出了互动套件的建设,希望为游戏互动场景提供优化解决方案。24年春节我们完成游戏引擎预热和游戏资产公共离线库的相关优化,在24年春节得到了不错的优化效果;同时我们完成了游戏资源(解)压缩的探索和更高效的物理引擎探索,希望能够在25年春节完成压缩和物理引擎的落地,从而丰富游戏互动玩法的同时,提升游戏的整体性能。

4ee1d79e00b4ecea43a48855e84c1732.png

资产离线公共库是一个重要的资源管理工具,管理了游戏互动业务所需要的游戏引擎资源。通过对游戏主包拆包和引擎资源的统一管理,资产离线公共库能够有效地帮助降低游戏包的大小,从而提高游戏的运行效率。此外,资产离线公共库还能够通过复用本地离线公共库的引擎版本,进一步降低游戏包下载的整体耗时和带宽成本。

引擎预热是一种提供了与游戏引擎优化相关的能力的技术。其原理是在游戏启动过程中,对游戏引擎进行预热,以便游戏逻辑执行过程中更快地加载游戏引擎包,从而提高游戏加载速度。通过提前准备游戏引擎,在游戏启动时,就可以更快地进入游戏世界,减少等待时间。这种技术可以为玩家带来更好的游戏体验。

资源的压缩与解压缩可以极大地提高资源利用率,有效降低资源包体积和网络加载时长。资源的压缩处理是在引擎编辑器的打包构建阶段做的资源预处理,而资源的解压缩则被放在了互动容器中,在游戏载入过程中实时进行处理。

物理引擎,目前前端游戏开发者主要依赖js或者wasm版本的物理引擎,在游戏性能和体验上存在较大的优化空间,因此我们探索在容器侧提供更高效的物理引擎方案,从而为游戏开发者和设计师提供更好的物理引擎解决方案,丰富游戏互动玩法体验。

本文主要介绍24年在春节场景落地的引擎预热和资产公共离线库相关的优化手段。同时介绍、探讨我们游戏资源(解)压缩方案的探索和在探索中遇到的问题

名词解释

67caebe43a6690c12be91312891e028b.png

引擎预热&资产离线公共库

业务痛点

8dbeae53f161b4d73400eb3bf2bb191f.png

基于游戏业务已有的数据进行分析,我们发现游戏互动场景有以下2个特点

  • 依赖游戏引擎:在游戏中,游戏互动场景的创建和运行需要依赖于游戏引擎的加载和初始化逻辑。这个过程会消耗大约200ms~300ms的时间,这可能会对游戏的性能产生一定的影响。因此,为了提高游戏的响应速度和流畅度,游戏开发者需要尽可能地优化引擎加载和初始化逻辑的执行效率。

  • 游戏加载耗时和用户异常流失率呈正比:启动性能对于游戏的用户体验至关重要,因为如果游戏启动没有优化好,用户需要等待很长时间才能看到游戏画面。这样很可能会导致许多用户在游戏加载完成之前就退出游戏。根据已有的游戏数据分析,游戏加载时间在1.5秒后,每0.5秒左右就会有4%左右的用户异常流失。因此,游戏开发者需要高度重视游戏启动性能的优化,以确保游戏能够快速启动,让用户尽快进入游戏世界。

因此我们针对游戏互动场景完成了引擎预热的方案,整体在24年春节场景落地,双端取得了不错的加载收益。

整体思路

051152242c6770dd7b0deff4adb7e147.png

在优化之前,在容器加载游戏主包完成之后,才会开始渲染游戏首屏页面和执行游戏业务逻辑。当游戏的业务逻辑在执行的过程中,必须等待容器完成游戏引擎资源的加载和初始化,这部分在线上存在一定的耗时。因此,为了解决这个问题,我们在容器侧和前端工具链完成了一系列改造,以提高游戏页面的加载速度。

具体地来说我们在容器打开游戏页面路由阶段,就完成了游戏引擎的预加载,并在跨端框架完成初始化之后,充分利用JS线程空闲的时间,完成游戏引擎的预热,并将预热好的实例挂载在全局对象中,这样当游戏页面真正执行的时候,就能直接通过全局对象中的获取到游戏引擎实例,从而完成后续的游戏业务逻辑。

技术细节

开发工具链改造

df77f7586ff0307e823a5d0e991a7d7c.png

前端工具链方面,在构建工具中接入speedy-split-chunks插件,在游戏打包的过程中,将游戏引擎包从游戏主包中拆分出去,并将引擎库发布到静态资源分发平台公共离线库下;封装split-engine-adapter组件,完成引擎预热的前端代码插桩,并在引擎预热时,提供兜底逻辑,保障业务的稳定性。在剔除游戏引擎包同时,并将剔除出来的引擎包和对应的meta文件部署到静态资源分发平台公共离线库,通过meta文件来维护离线公共库的游戏引擎资源版本,供多个游戏互动业务使用,从而降低带宽成本。

另一方面,我们的split-engine-adapter插件可以在请求加载引擎资源文件时,完成对requireModule函数的hook。在这个过程中,如果发现全局对象上不存在的引擎实例,那么就会通过requireModule请求拆包引擎包作为兜底措施。通过这种方式,可以避免因引擎实例不存在而导致的错误,从而提高系统的稳定性和可靠性。

预热游戏引擎

98acf2b35479dc75991c453a82acbddb.png

客户端通过前端拆包的meta文件,进行公共库离线库资源版本管理。当客户端页面schema中打开引擎预热的能力时,客户端会在页面路由时,根据meta查找对应的引擎版本,提前去预加载游戏引擎。由于离线公共库存在客户端本地,为了防止替换客户端本地引擎JS文件,整体加载过程中会对游戏引擎资源进行验签,从而达到避免JS注入攻击的风险。同时,在跨端容器创建的过程中,将游戏引擎文件在JS线程进行预热,最终将游戏引擎实例挂载到全局对象上,以确保游戏引擎的正常运行。

当前端业务逻辑执行时,可以通过全局对象访问游戏引擎实例。如果引擎预热失败,前端业务会通过兜底的逻辑,请求游戏引擎资源进行使用,从而提升整体方案的稳定性和可靠性。

业务收益

1280f78a84a3fbc7c61dbf4502089bd1.png

在游戏互动场景中,AnnieX互动容器通过提供引擎预热和依赖资产公共离线库托管游戏引擎的方案,实现了游戏引擎的快速加载和初始化,从而提升了游戏业务的整体性能。这一方案成功地帮助完成了24年抖音春节活动的落地,游戏主包大小降低了28.05%。通过复用公共离线包引擎资源,节约了数万元网络带宽的成本

959899d12382391bee70f2812471950c.png

在游戏加载耗时方面,双端在不同游戏的表现中存在200ms~500ms左右的优化幅度。由于iOS整体性能更佳,因此优化幅度整体上相较于Android用户优化幅度更大。在Android端中,PCT90有32.12%的优化幅度,PCT50有20.75%的优化幅度;在iOS端中,PCT90有47.24%,PCT50有33.89%的优化幅度

资源压缩

业务痛点

3D资源往往文件体积较大,在需要网络传输的场景,对这些资源的压缩通常能够有效提升资源的加载速度,节约网络流量传输成本。

目前,常用的3D资源文件格式主要有glTF、fbx、obj等,这些资源通常包含了3D资源的几何体、骨骼、动画、材质等等所有数据,因此围绕着24年抖音春节活动,我们开始了基于游戏3D资源的压缩和解压缩的探索和调研。

整体思路

这里主要介绍AnnieX互动容器对于几何体和动画数据的压缩算法及其具体实现方式,分别是:量化(Quantization)、稀疏访问器(Sparse Accessor)和网格优化(meshopt)。

e938f27fa19b005524e9de0effa43d24.png

技术细节

几何体的压缩与解压缩

在SAR Creator中,几何体(Geometry)除了包含了顶点的坐标(Position)、纹理坐标(UV)、法向量(Normal)、切向量(Tangent)、颜色(Color)等数据之外,还包含了组成三角形的顶点索引(Index)、骨骼(Skeleton)和混合变形(Blend Shape)等数据。

由于这些不同的数据有着不同的格式和特性,所以往往需要分别应用不同的压缩格式,用以在尽可能保证数据精度的同时尽量提高压缩效率。

这里介绍几种SAR Creator中使用到的压缩算法:量化、稀疏访问器和meshopt。

量化(Quantization)

量化是一种常用的简单且高效的资源压缩方式,通常用于顶点数据的压缩,是一种有损压缩

量化是将32位浮点值转换为16位或8位的整数进行存储的过程,可以达到50%甚至25%的压缩率

转换公式

a89ff5020f7f516ea55b5851e356d71c.png

详情

由上述转换说明易知,量化是一个有损的压缩,其压缩和解压缩均只需要对所有原始数据做当且仅当一次乘法/除法运算,算法复杂度为O(n),开销较低。

量化对原始浮点数的范围有一个前提要求,其范围必须在0到1之间(可转换到无符号整数)或-1到1之间(可转换到有符号整数)。当原始浮点数的值不在这个范围内的话,需要预先做一次归一化处理,使其落在需要的范围内,才能继续进行量化处理。

本质上,量化是建立了一个浮点数和整数的对应关系,整数的存储位数越大,对应的浮点数精度越高。理论上可以实现任意位数的整数跟浮点数的转换。

在SAR Creator中,因为不同的数据对于精度的要求不同,故在实际应用时对于不同类型的数据使用了不同位数的整数进行存储,具体如下:

  • position:14

  • uv:12

  • normal:10

  • tangent:12

  • color:8

  • blend shape:8

以上不同类型数据的存储位数的配置选择参考自业界经验,可以在尽量保证数据精度的同时提高压缩率。在对精度要求较高的情况下,可以对该配置进行调整优化,一般最大值不会超过16位。

稀疏访问器(Sparse Accessor)

这里的稀疏访问器来自于glTF规范中的稀疏访问器(Sparse Accessor),特别适合用来存储混合变形(BlendShape,以下简称BS)数据,因为BS数据中往往存在较多的“0”。

在glTF规范中,Sparse Accessor会把一个较大的vector2/3/4的数组(Buffer),拆分成3个部分:非0值数组(Values Array)、索引下标数组(Indices Array)和原始长度值(Count)。通过这样仅存储非0值及其下标的方式,结合一个原始长度,就可以很方便地还原出原始数据。

在SAR Creator中会基于glTF的Sparse Accessor的格式再进一步做优化调整,仅会存储非0的vector2/3/4的非0通道的数据,而不是glTF规范中必须是Vector2/3/4中所有通道的数据均为0才不去存储,这样可以进一步减少序列化后存储数据的文件体积大小。同时,由于SAR的AssetBundle格式对于数组存储采用了特殊的方式,所以这里还可以少存一个Int32的Count值。

转换格式

转换格式示意图如下:

42338287c41339a8cd6dd3c0e5bf1aa1.jpeg

由上图可知,稀疏访问器是无损压缩,且其压缩和解压缩的流程简单,算法复杂度为O(n),但实际的解压缩过程并不需要对所有的数据进行处理,只需要创建跟原先相同大小的buffer,按下标将所有的非零值填进去即可,故而开销很低

详情

另外,由于稀疏访问器仅在当BS数据的0值较多时,才会有较高的压缩效率,否则反而会降低压缩效率。SAR Creator默认会自动判断应用了稀疏访问器后是否会减少资源文件体积,来决定是否采用稀疏访问器,不需要手动开启。

附:真实业务(虚拟形象)中的glTF文件为例,仅采用Sparse Accessor能减少53%左右的文件体积,如果采用Sar的Asset Bundle中优化后的数据格式,可以减少78%左右的文件体积。

值得一提的是,稀疏访问器可以跟量化一起叠加同时应用在几何体数据上,两者互不影响,互不冲突,最终的精度损失仅为量化的损失。

网格优化(meshopt)

meshopt是glTF规范中的一个扩展:EXT_meshopt_compression,该扩展可以对glTF文件中的几何体和动画数据进行优化与压缩。在SAR Creator中,几乎全量移植了这个扩展的具体实现,使得SAR Creator的Asset Bundle中的几何体和动画数据同样支持了meshopt压缩。

由于meshopt压缩和解压缩的算法较为复杂,SAR Creator在实现时应用了wasm的方式(同时用asm.js做兜底),这里使用了开源项目meshoptimizer,它提供了对Mesh进行优化压缩相关算法的C/C++接口,也可以编译为wasm使用。

压缩流程

meshopt在压缩前需要先优化顶点和索引数据,用于提高压缩效率,同时也可以提高解压缩后的渲染效率(GPU缓存)。meshopt提出了三种不同的模式(mode:attribute、triangles、indices)的和三种不同的过滤器(filter:octahedral、quaternion、exponential),针对不同的数据类型应用不同的模式和过滤器,可以最大化压缩率。

对于几何体的meshopt压缩,SAR Creator实现时的具体流程如下:

  1. 优化索引顺序

    1. 优化索引顺序,可以最大限度地利用附近的顶点数据,从而尽量复用在GPU硬件中的缓存。

  2. 优化顶点顺序

    1. 顶点顺序应按照顶点在索引数据中出现的顺序进行顺序排列,以而可以最大化索引数据的压缩率。

需要注意的是:有时候更高的压缩率反而会降低渲染效率,需要取好平衡点。

  1. 压缩顶点数据

    1. 顶点数据需要先进行量化(quantized),才能进行后续的压缩处理;

    2. 量化后的顶点数据需要用attributes模式来进行二进制比特流存储;

    3. 对于normal/tangent的数据,使用octahedral的filter可以进一步提高压缩率,对于position或其他数据,可以使用exponential的filter进行处理;

  2. 压缩索引数据

    1. 索引数据用indices模式进行存储,也可以用triangles模式进行存储;

    2. 其中,triangles模式仅支持三角形列表的存储,indices模式可以支持其他拓扑类型的索引数据存储;

  3. 压缩BS数据

    1. BS数据可以完全当作顶点数据来进行处理,也需要先进行量化(quantized),之后再以attributes模式进行二进制比特流存储;

    2. BS数据因为是增量(relative/delta)的值,故也可以选用比顶点数据在量化后更小的比特位来进行存储;

解压缩流程

对于几何体的meshopt解压缩,则需要反过来进行,互动容器中实现的具体流程如下

  1. 解压缩顶点数据

    1. 按attributes模式进行解压缩;

    2. 对于采用了对应filter的数据,还需要再进行filter的解压缩;

    3. (可选)如果有必要,在进行反量化转换(de-quantized)处理;

  2. 解压缩索引数据

    1. 直接按压缩时采用的indices或triangles模式进行解压缩即可;

  3. 解压缩BS数据

    1. 同顶点数据的解压,不再赘述;

详情

对于几何体数据应用meshopt压缩,往往可以带来10%-20%的压缩率,可以极大减少资源体积。但由于其复杂度较高,需要使用wasm加速压缩和解压缩的过程

对于不支持wasm的环境,SAR Creator使用了asm.js作为兜底方案。实际测试结果显示,asm.js会比wasm慢2-4倍。在后续版本的SAR中,可能会直接使用C++的方案加速运行时对meshopt压缩后的资源进行解压缩,加速资源加载和解析的整个流程。

另:SAR Creator对于几何体数据的meshopt压缩实现,目前仍在feature分支,未经过全面测试,仅上线了对于动画数据的meshopt压缩。

动画的压缩与解压缩

动画(Animation)数据是由一系列的关键帧(keyframe)组成的。所以动画数据是由两部分组成的:关键帧的时间数组(times)和关键帧的值数组(values)。

网格优化(meshopt)

SAR Creator中对于动画(Animation)数据的压缩,也是移植自上述meshopt扩展。

压缩流程

对于动画数据的meshopt压缩,SAR Creator实现时的具体流程如下:

  1. 对动画数据进行重采样

    1. 对动画数据的压缩,最有收益的是进行重采样(resample),这样可以减少存储的关键帧数量,进而减少动画数据的文件存储体积;

    2. 具体实现使用开源库:keyframe-resample-wasm,它提供了WebAssembly版本的重采样方式,会比纯js的版本快一些,而且这个库的重采样过程是无损的。

  2. 压缩关键帧的times数据

    1. 如果times数据是均匀的,仅使用attribute模式而不使用任何过滤器已经能取得比较高的压缩率了;

    2. 为了保证最终的动画不变形,一般不建议对关键帧的times数据进行filter处理,也可以继续使用具有最大尾数位数(23)的exponential过滤器进行处理,从而保证最小的精度损失;

    3. 最后使用attributes模式进行存储;

  3. 压缩关键帧的values数据

    1. 旋转的数据可以使用16-bit进行量化处理,并应用quaternion过滤器进行处理;

    2. 位移和缩放数据可以应用exponential过滤器应用相同的指数进行处理;

    3. 最后使用attributes模式进行存储;

解压缩流程

对于动画数据的meshopt解压缩,互动容器中的实现具体流程如下:

  1. 解压缩关键帧的times数据

    1. 按attributes模式进行解压缩;

    2. 如果压缩时采用了exponential的过滤器(filter),还需要再进行exponential的过滤器解压缩;

  2. 解压缩关键帧的values数据

    1. 按attributes模式进行解压缩;

    2. 旋转的数据还需要用quaternion过滤器进行解压缩;

    3. 位移和缩放数据还需要用exponential过滤器进行压缩时相同指数的解压缩;

详情

对于动画数据的meshopt压缩,同样也是有损的,往往有40%左右的压缩率,同样采用了wasm的方式加速压缩和解压缩的过程。

附:真实业务(招财神龙)中的动画资源文件为例,有38.50%的压缩率,可以减少1.9MB的资源包体积。

另:招财神龙业务中,考虑中低端机在跨端框架下wasm的兼容性问题,开发时直接默认启用了asm.js的降级方案,但在后来的性能测试环节中发现meshopt解压缩的asm.js在iOS的JSC运行时下效率较低,会严重增加资源的解析耗时,最终上线时并没有启用动画数据的meshopt压缩。

未来规划

24年春节我们完成了引擎预热、资产公共离线库的落地,取得了不错的游戏优化效果。但在业务落地的过程中,我们也发现一些存在的问题,例如引擎预热逻辑命中率双端平均只达到81.33%,整体上还存在优化的空间;另一方面随着资产公共离线库的托管的引擎类型和版本越来越多,体积越来越大,我们需要加强对离线公共库的版本管理,识别和控制ROI整体较低的引擎和版本,从而更好的控制离线公共库的带宽成本。

而在资源压缩方面,我们完成了相关方案js和wasm版本的流程的调研和探索,以24年春节玩法招财神龙为例,我们取得了不错的资源体积优化效果,降低整体游戏包的大小。但在资源压缩方面,由于低端机和部分iOS手机上对于网格优化的解压缩效率低下,甚至部分机型适用wasm会有崩溃的情况存在,所以虽然完成了整体的技术方案落地,但考虑到稳定性并没有在线上启用。后续会进一步优化这一方面的兼容性问题,解压的性能问题上也会直接采用C++而非wasm的方案,将资源压缩方案完整在正式的游戏业务落地,同时覆盖更多的游戏业务场景。

另外,在3D游戏场景下,一般意义上的资源还包括图片、视频、字体、脚本以及特定编辑器导出的资源(如lottie、spine等),对于这些资源文件的压缩也有着明显的意义,我们将在后续进一步探索相关资产的压缩,从而优化整体游戏的资产大小。

团队介绍

我们是抖音前端架构-互动体验技术团队,主要为字节跳动业务提供互动技术解决方案。技术产品包含面向互动 / 小游戏研发场景的 SAR Creator、高性能动效渲染引擎 Simple Engine、互动场景端能力套件 AnnieX 互动容器

在这些技术建设与业务落地上,和抖音前端-互动创作团队跨端框架团队、开放平台小游戏团队、用户增长-激励前端团队一同推进,不断探索字节跳动应用生态下的创新业务形态。

下期预告

系列下期主题是编辑器技巧与实践。作为字节互动方案的主要工具,其提供各类完善的系统方便用户使用快速搭建互动场景,并提供丰富的配置能力,解决了预览调试到构建打包的一系列流程问题,可配合业务定制高效的内容生产工作流。敬请期待。

往期回顾

2024 抖音欢笑中国年(一):招财神龙互动技术揭秘

2024 抖音欢笑中国年(一):招财神龙互动技术揭秘

作者 ByteDanceTech
2024年3月27日 10:29

字节跳动旗下的抖音等 App 在 2024 年春节期间推出了欢笑中国年系列活动,在实现增长业务目标的同时,为用户带来了全新的体验和乐趣。「招财神龙」是其中的一个重要玩法。

前言

本次春节活动,使用到了字节内的主要前端、跨端、互动技术产品。主要涉及:

  • 跨端框架 提供了首屏直出的方案使其具有较短的首屏时间,能够大大提升业务加载成功率。跨端框架也提供了 Canvas 作为 SAR Creator 等渲染引擎的运行环境。

  • SAR Creator 是抖音前端架构自研的一款基于 TypeScript 的高性能互动解决方案。SAR Creator 提供面向设计和研发同学的工作流,内置常见 2D / 3D 渲染能力、动效、粒子、物理等效果支持。

活动中,主要支持了 5 个互动玩法:“招财神龙”、“神龙探宝”、“摇福签”、“保卫现金”和“红包雨”,如下所示。

eba000f3f707d798b41908560941d891.png

我们会通过系列文章,介绍春节玩法用到的互动技术。文章所说的互动技术指以图形 API(如:WebGL)为基础,结合前端工程化、图形渲染、引擎技术、交互能力和跨平台能力,面向前端技术栈的动效和游戏化技术,如下图所示。

5fd7aa0eb3a70ec24e5e2fc18d82409e.png

在活动开发中,前端 UI 如:滑动列表、页面布局,可以用成熟的前端框架(React)。需要图形绘制的地方,如:渲染 3D 模型,就要用到互动解决方案(SAR Creator)。互动所用到的图形绘制部分往往是页面中的一个区域,我们会把互动部分封装成一个 SDK,通过使用 SDK API 和前端进行通信。

本文作为系列开篇,主要从“招财神龙”玩法视角,分享团队前端互动玩法的相关开发经验。

活动玩法介绍

下面是招财神龙玩法示意,用户可以点击“去寻宝”按钮(称此时的场景为「家场景」),让神龙去寻宝(称此时的场景为「寻宝场景」),寻宝过程中神龙会遇到福袋和龙蛋。福袋自动掉落到宝箱中,而龙蛋需要用户点击。寻宝过程中,红色的主按钮上有倒计时,倒计时结束后寻宝结束,用户可以打开宝箱领取奖励。寻宝过程中,场景中会有一些可点击的发光建筑,用户点击它们,发光效果消失,可能触发任务。

在「家场景」,用户可以点击小女孩,与之产生轻互动,如下图所示。

3e696e16c4dbdca89cedcc7da11a49e0.gif

包含四个主题的「寻宝场景」,每次寻宝会随机一个主题,如下,从左到右分别是山川、雪乡、丹霞和江南。

9e23083dca6700f8ccd0361489524f75.png

招财神龙互动玩法实现

实现招财神龙的互动玩法,需要多个工种配合。首先产品提出玩法需求,描述整个场景的构成要素和玩法逻辑。然后设计同学根据产品的描述,产出设计草图,逐步细化,最终通过 DCC 软件(如:C4D)生成 3D 模型、视频、2D 贴图或动画等美术资源程序需要根据产品需求和设计产出实现互动玩法的代码逻辑。整个开发过程需要三方通力合作,尤其需要程序和设计同学的有效沟通,以确保设计方案可以用程序顺利实施。为了保障产品质量,还需要测试人员验收产品。整个开发过程大致如下图所示。开发过程是持续迭代的,比如:产品可能在开发中期提出新需求,就需要设计、程序和测试做出响应。

9eb13b36c4b6afc74f4edfb54bb61de1.png

这里以程序的视角描述招财神龙互动玩法的实现。如上文所述,招财神龙互动部分由「家场景」和「寻宝场景」构成,两个场景通过一个转场动画过渡。每个场景使用了不同的美术资源和互动技术。程序不直接消费 DCC 软件生成的美术资源,而是消费 SAR Creator 产出的资源包(即 bundle)。

  • bundle: 设计在 SAR Creator 编辑器中导入 DCC 软件的产物(如:3D 模型),通过二进制序列化生成的运行时消费用的资源包。

  • prefab: 一个 bundle 可以包含多个 prefab(预制体),一个 prefab 可以包含 3D 模型、2D 贴图、动画甚至脚本代码等元素。

SAR Creator 为 bundle 及 prefab 提供了序列化、反序列化和管理等功能

接下来让我们先了解一下招财神龙页面元素的构成。

招财神龙页面元素构成

招财神龙活动在抖音App及多端(抖极、头条、西瓜、番茄等)的任务页上线,为了让大家对整个招财神龙前端页面有个清晰的认识,这里我们以任务页为例,为大家讲解一下页面构成。

e91ed3ea7c945d18d3a13a86267bae54.png

如上图所示:

  • 任务页(图左):字节系 App(例如西瓜视频),大多会有一个长期在线的激励页面,如上面左图所示,用户可以通过完成任务获得现金、或者积分等虚拟货币奖励。

  • 互动区域(图右):如上面右图所示,互动区域即为场景区域,是页面主 KV (Key Visual) 中的核心区域,用 Canvas 承载,使用 SAR Creator 来渲染互动内容。

任务页在非活动期间,以日常的形态展示(各 App 独立迭代),而在活动阶段,则以统一的活动内容展示。这是怎么做到的呢?

03553d7b527dfaaef9d404a65cf4649a.png

如上图所示,我们把任务页抽象为收益区 + 主 KV + 任务专区。在有活动的时候,我们只需要替换主 KV 对应的内容就可以了。在实际开发中,活动的主 KV 则抽象为活动 SDK。在满足活动条件时,服务端下发活动内容字段,任务页动态渲染活动组件,完成活动内容的展示;在活动结束后,服务端移除活动内容字段,页面切换回日常形态。

任务页上开发互动内容,存在较大的性能挑战。任务页前端 UI 繁多,业务逻辑复杂,而互动的资源加载往往又是 CPU 密集型任务,所以往往在首次渲染页面时,造成页面和互动区域的 JS 线程繁忙,进而形成卡顿和渲染时间过长。同时由于任务页已经存在大量的前端 UI 和动画,留给互动部分可用的内存安全余量往往仅有 200-300 MB,稍有不慎就有可能导致 OOM。在任务页上既要完成视觉表现精美,又要保证性能良好,是一件非常有挑战的事情。

招财神龙前端与互动的交互

我们将前端的同学分为两部分,一部分负责处理活动的主逻辑,例如和服务端交互、处理业务元素(例如进度条、明信片等挂件、任务列表等),这一部分的工作角色,我们通常称之为“前端同学”,另一部分同学主要用来处理游戏相关的逻辑,聚焦在互动上,我们称之为“互动同学”。 他们相互协作,共同实现了招财神龙的活动玩法。二者的协作方式如下图所示。

9765d680ef1686bbf7e42e31a549b8d0.png

游戏初始化阶段,游戏加载完 SAR Creator 运行框架后,向前端同学“索要”本次初始化的服务端数据,用来判断该用户进入游戏后,应该展示的是「家场景」还是「寻宝场景」。用户完成相关任务后,主接口刷新。前端同学以事件通信的形式通知互动同学渲染当前场景并播放相关动效。互动同学也会监听主接口数据,更新互动模块专有的逻辑或效果。

「家场景」的实现

「家场景」是引导用户“唤醒神龙”、“去寻宝”以及“领取福袋”的核心场景,如下图所示。本章节会将介绍「家场景」的搭建过程,并分享「家场景」开发过程中有趣的实现。

cb19ae8dff1794e03296fa82410513e8.gif

整个「家场景」是由 3D 和 2D 元素混合构建的。3D 部分包括小女孩、龙、地面和雪堆。2D 元素主要有炮仗、房子以及神龙回家后小女孩头上的提示气泡,是用图片实现的。还有一些 2D 动画元素,比如房子后面一直循环播放的红包动画、龙沉睡时嘴角的“zzz”呼吸效果。

场景搭建

设计同学使用 SAR Creator 编辑器搭建「家场景」,包括 3D 模型/2D 精灵的摆放、灯光和相机参数的设置等。SAR Creator 编辑器提供了图形化界面,可以方便地调整场景元素的层级关系、位置、朝向、缩放比例以及材质参数等。「家场景」的 3D 模型使用透视相机渲染,而 2D 精灵等使用正交相机渲染。最终,SAR Creator 渲染出的场景画面还原了设计稿的效果。

SAR Creator 场景中所有元素,包括相机、灯光等,都以 entity(实体)的形式存在,entity 之间存在父子关系,形成一棵节点树,如下图左上角“层级”标签页下的内容。父节点 entity 的 Transform3D 组件的位置、旋转和缩放属性,会影响子节点的相同属性。Enity 上可以挂载自定义脚本,影响 enity 的行为逻辑。SAR Creator 提供了大量操纵 entity 的引擎能力

46b4d9e3e64f61a4b9a83b1d46f06c90.png

动画播放

为了呈现出精彩的效果、给用户带来尽可能好的视觉体验,我们设计了14个模型动画,并通过出色的逻辑串联,保证了动画播放流程的简洁高效。

export enum HomeAnimName {
  HomeSleep = 'home_sleep', // 沉睡
  HomeAwake = 'home_awake', // 苏醒
  HomeIdle = 'home_idle', // 待机
  HomeClick = 'home_click', // 点击效果1
  HomeClickA = 'home_click_a', // 点击效果2
  HomeClickB = 'home_click_b', // 点击效果3
  HomeHappy = 'home_happy', // 完成任务,开心状态
  HomeGoHome = 'home_gohome', // 龙回家
  HomeHoldBox = 'home_hold_box', // 宝箱状态
  HomeOpenBox = 'home_open_box', // 龙推宝箱
  HomeCloseBox = 'home_close_box', // 关闭宝箱
  HomeCloseBoxIdle = 'home_close_box_idle', // 关闭宝箱后的待机态
  HomeOpenBoxIdle = 'home_open_box_idle', // 开完宝箱后的待机态
  HomeGoOut = 'home_goout' // 龙去寻宝
}

我们使用了 SAR Creator 提供的动画播放能力:Animator 组件。获取到 3D 模型的 animator 组件,并调用它的crossFade函数,在第二个参数duration指定的时间内,从当前动画状态过渡到另一个动画状态,即下面代码中的第一个参数anim。调用animator.on('finished',cbFunc)可以自定义动画结束后的回调函数。

this._dragonAnimator.crossFade(anim as string, duration);
this._charAnimator.crossFade(anim as string, duration);
this._dragonAnimator.on('finished', () => this.onAnimEnd(anim, params));

设置动画的loopCount属性,可以指定该动画播放的次数。设置clampWhenFinished可以指定播放完该动画后,是否停留在最后一帧。

const setClip = () => {
    const loopCount = loop ? -1 : 1;
    
    const _dragonClip = this._animator.clips.find((i) => i.clip?.name === anim);
    if (_dragonClip) {
      _dragonClip.loopCount = loopCount;
      const action = this._dragonAnimator.getAction(anim);
      if (action) {
        action.clampWhenFinished = !loop;
      }
    }
}

基于上述的这些底层的Api,我们实现了一套AnimationGraph来帮助研发和设计同学更好地开发提效。

a451a8192d6e9eecc396644c7349dadf.png

对于设计同学使用来说,例如想实现一个龙睡觉状态到龙待机状态,我们可以将HomeAwakeHomeIdle动画拖入到graph中,并创建动画链路。

16da673cb46c1012d69596dc84260029.png

HomeAwake动画播完以后,会在HomeIdle动画进行loop播放。选中链路,可以对链路进行配置和预览

b761e6fa375425befc15a55bde8a5c4e.gif

对于研发同学,可以基于graph进行逻辑条件的配置。

dcb00f5e949c7233d4f73c4a5a34141d.gif

如上图所示,例如进入游戏后,用户可能是在“龙沉睡”或者“龙待机”的状态,我们通过在Graph的变量区建立代码运行的逻辑条件(支持Number和Boolean两种类型),可以自定义一个case变量,当case = 1,播放“龙沉睡”、当case = 2,播放“龙待机”。

在代码中,我们可以通过使用AnimationController.setValue(variableName,value)来触发动画执行。

const animationController = this.entity.getScript(AnimationController);
if(showAwake) {
    // 需要播沉睡
    animationController.setValue("case", 1)
}else if(showIdle){
    //需要播放idle
    animationController.setValue("case", 2)
}

再比如,在某一个时间,用户点击了“去寻宝”按钮,这时候通过设置animationController.setValue("showGoOut",true)即可触发龙去寻宝的动画。

我们还为动画播放提供了钩子函数,在动画播放的特定时间,触发自定义的逻辑回调。

onStateEnter 在进入状态时触发
onStateExit 在完全退出状态时触发
onStateUpdate 在状态更新时触发
// 获取动画控制器组件
const animationController = this.entity.getScript(AnimationController);

animationController.on('onStateEnter',(controller:AnimationController,state:AnimationState)=>{
    //在此处实现业务逻辑
});

坐标同步

在实现一些特殊效果时,为了保障效果的高度还原,我们使用了坐标同步。例如小女孩头上的提示气泡和龙嘴角的“zzz”呼吸特效,接下来以气泡为例介绍一下这一部分的实现。

b11d5ed888cbb38b53e8574ee1634f8f.png

若用常规的模式在 3D 场景中摆放一个 2D 的片,会导致小女孩动的时候,渲染出来的气泡会穿帮或者 z-fighting

f449ea0a22a8ea638bd1f54724fb821d.png

3D-2D 坐标同步的做法是将 Bubble 节点放在 UICanvas(SAR Creator 处理 2D 元素的节点)中,每一帧将小女孩模型里的骨骼变换节点在 3D 空间中的位置转化成 UICanvas 坐标系的坐标,再实时设置 Bubble 的位置属性。

164aac3e851999b618e0039bf48f2c7f.png

e9b8086b1eb528c7f26ded8c0abcea28.png

坐标同步代码如下👇

const TEMP_VEC3 = new Vector3();
export const threeD2UICanvas = (entity: Entity, camera: PerspectiveCamera) => {
  entity?.object?.getWorldPosition(TEMP_VEC3);
  const vec3 = camera?.project(TEMP_VEC3) || new Vector3();
  // 375 * 500 为画布大小
  const x = vec3.x * 375;
  const y = vec3.y * 500;
  return { x, y };
};

每一帧设置 UICanvas 画布中气泡节点的位置,最终实现小女孩在 3D 场景中动来动去,头上的气泡也会跟着一起移动。

class BubbleScript extends Script {
    // ECS 脚本每一帧的回调
    onUpdate() {
        if(NEED_SYNC_POS){
            const bubbleRootIn3D = CharGlb.getChildByName('girl_Root_for_bubble')
            const bubbleEntityIn2D = UICanvas.getChildByName('Bubble')
            // 3D场景下的相机
            const cameraIn3D = MainScene.getChildByName('MainCamera')
            // sync pos
            const pos = threeD2UICanvas(bubbleRootIn3D, cameraIn3D)
            bubbleEntityIn2D.position?.set(pos.x, pos.y)
        }
    }
}

「寻宝场景」的实现

「寻宝场景」是一个纯 2D 互动场景,是招财神龙玩法的重要环节。为了实现有趣、自然的互动效果,「寻宝场景」要处理许多复杂逻辑。为了让互动和前端在动效上衔接流畅,互动和前端会在必要时通信。

简化版的“寻宝”逻辑如下图所示,包括地形等美术资源的加载相机处理探测点检测福袋和龙蛋触发以及地形回收等逻辑。每次寻宝开始前,服务端提前下发“寻宝数据”,包括本次寻宝开始和结束的时间戳以及 timeline 信息,timeline 是一个“道具”触发列表,列表中每个元素包含一个道具 id、触发时间戳、道具类型和道具状态等信息。

0d22ed0f0b1728689fc58935cdae8650.png

「寻宝场景」的 timeline 数据结构伪代码如下面所示。其中"prop_type"是道具类型,可能是福袋或龙蛋。福袋不需要用户点击交互,寻宝结束后总是发放给用户。在视觉效果上神龙会撞上福袋。但龙蛋需要用户手动点击,若不点击就会错失对应奖励。"timestamp"是道具触发的时间戳。

/** 一次寻宝的信息 */
export interface TreasureHuntData {
  /** 当前状态 */
  treasure_hunt_status: TreasureHuntStatus;
  /** 时间轴开始时间 */
  start: Int64;
  /** 时间轴结束时间 */
  end: Int64;
  /** 时间轴信息 */
  timeline: Array<PropTriggerInfo>;
  current_time: Int64;
  // ...
}

export interface PropTriggerInfo {
  /** 道具的id */
  prop_id: string;
  /** 在时间轴上的时间戳 */
  timestamp: Int64;
  /** 类型 */
  prop_type: PropType;
  /** 道具领取状态,寻宝结束时才有 */
  propStatus?: PropStatus;
}

相机逻辑

「寻宝场景」使用一个正交相机渲染。「寻宝场景」的地形大部分时间保持不动,相机不停地往前移动。相机的逻辑比较简单,只是 x 轴不停地增加,其伪代码如下所示。

// deltaTime是上一帧到当前帧的时间间隔,_moveSpeed是相机移动速度
this._camEntity.position.x += deltaTime * this._moveSpeed;

相机移动速度可在 SAR Creator 中配置,如下图中红框中的 Speed 所示。

da9dd4d761ef85860c8c143f70024669.png

SAR Creator 提供了装饰器工具@ScriptUtil,用于把一个脚本及其字段暴露给编辑器。相机配置脚本 TravelCameraConfig.ts 挂在上图中 TravelCamera 节点下,其伪代码如下:

import { Script, ScriptUtil } from '@sar-creator/toolkit/engine/core';

// TravelCameraConfig是一个脚本,继承SAR Creator的Script类。
// 脚本类名前使用装饰器@ScriptUtil.Register(),可以在编辑器中挂到节点上
@ScriptUtil.Register('TravelCameraConfig')
export default class TravelCameraConfig extends Script {
  // 在脚本字段名前使用装饰器@ScriptUtil.Field(),可以在编辑器中编辑该字段
  @ScriptUtil.Field('float', { default: 0, params: { precision: 4 } })
  speed = 0;
  // ...
}

构建无限地形

每次寻宝的时间长度由服务端动态下发,最长为 20 分钟。每个主题的「寻宝场景」都有一个地形块队列每个地形块以 prefab 的形式提供。线上,每个主题的地形队列由两个地形块构成,这里我们记作 map_x_a.prefab 和 map_x_b.prefab,其中 x 是主题的索引。每个主题的地形块 prefab 由设计同学在 SAR Creator 中制作完成并以资源包的形式提供给研发同学,极大地减少了二者的工作耦合度,提升了开发效率。

「寻宝场景」一屏的设计分辨为 750x1000。每个地形块的宽度为 3750。这样两个地形块的宽度就是 10 屏,能提供足够多的细节差异、降低场景元素的重复感。下图是一个地形块 prefab 在 SAR Creator 中的样子,可以看出它在 3750x1000 的矩形外,还会多出一些视觉元素(如左右边界上的云),这些多出的视觉元素能够和另一个地形块上的视觉元素有机地融合。

104d29098430ed8628d38749b4c20703.png

为了让大家更容易理解,我们把山川主题的两个地形 prefab 都拖到 SAR Creator 中,如下图所示,它们总是可以无缝拼接的。

732f85f61db707845ea49da59134200d.png

在实际项目中,因为主题是随机指定的,所以这两个地形 prefab 是用代码动态加载的,而非直接拖到场景中。为了让用户更早地看到「寻宝场景」的视觉内容,我们同步加载第一个地形块 prefab , 异步加载第二个地形块 prefab。其伪代码如下所示。

async _loadTerrains(travelScene2D: Object2D): Promise<void> {
    const terrainNames = TerrainNamesByTheme[this._theme];
    // 加载当前主题第一块地形prefab
    const firstTerrainName = terrainNames[0];
    // 注_loadTerrain是异步的,返回promise。我们会在本函数返回前await此promise。
    this._firstTerrainPromise = this._loadTerrain(firstTerrainName!);

    // 异步加载其它地形prefab,其实对于线上的情况就只有第二块地形了。
    const terrainPromises = 
        terrainNames.filter((_, idx) => idx !== 0).map((i) => this._loadTerrain(i));

    void Promise.all(terrainPromises).then(async () => {
      // 注意要保证第一块地形已经加载好了,_tryCreateFirstTerrainBlock函数内部做判断,
      // 保证第一块地形块不被创建两次。
      await this._tryCreateFirstTerrainBlock(travelScene2D);
      
      let lastTerrainBlock = this._firstPrefabBlock;
      const terrainPos = this._terrainOffset.clone();
      for (const terrainPromise of terrainPromises) {
          if (lastTerrainBlock !== undefined) {
            const terrainEntity = await terrainPromise;
            terrainPos.x += lastTerrainBlock.getBlockSize();
            lastTerrainBlock = this._createTerrainBlock(travelScene2D, terrainEntity, false, terrainPos);
          }
      }
    });

    // 同步加载第一个地形block
    await this._tryCreateFirstTerrainBlock(travelScene2D);
  }

队首的地形块完全离开屏幕后把它移到队尾成为“新”的地形块。为了处理地形块边缘多出的部分视觉元素,延迟一屏让当前队首地形块消失,提前一屏让队列中第二个地形块可见,让用户看不到任何缝隙,伪代码如下所示。

_recycleTerrain(cameraPos: Vector3): void {
    const headRightX = this._terrainBlocks[0].getBlockRightPositionWorld();
    const terrainScreenWidth = this._terrainBlocks[0].getTerrainScreenWidth();
    const screenLeftEdge = cameraPos.x - this._halfScreenWidth!;
    const screenRightEdge = cameraPos.x + this._halfScreenWidth!;

    // 首队地图延迟一屏消失
    if (headRightX + terrainScreenWidth < screenLeftEdge) {
      // 队首地形右边界,离开屏幕左边缘
      this._terrainBlocks[0]?.setVisible(false);
      const headBlock = this._terrainBlocks.shift();
      if (headBlock) {
        const tailPos = this._terrainBlocks[this._terrainBlocks.length - 1].getPosition();
        headBlock.setPositionX(tailPos.x + this._terrainPrefabLength);
        this._terrainBlocks.push(headBlock);
        // 重置队首地形上的探测点
        headBlock.resetDetectors();
        // 通知prefab离开屏幕等
      }
    } else if (
      headRightX - terrainScreenWidth <= screenRightEdge &&
      !this._terrainBlocks[1].getVisible()
    ) {
      // 地图队列中第二个地图,提前一屏显示。队首地形右边界,离开屏幕右边缘
      this._terrainBlocks[1].setVisible(true);
      // 通知prefab进入屏幕等
    }
  }

地形上还有一些挂载点,程序根据当前机型的评分等,挂载不同类型的发光建筑。例如,高端机会挂载用 Spine 制作的发光建筑,而低端机挂载普通的精灵图。高端机能实现好的视觉 效 果,低端机减轻了 CPU 负担,保障程序运行流畅。下图红框中的节点就是发光建筑的挂载点。

15128630e05907a1a9c5e2b6111cfb39.png

探测点、神龙和福袋

「寻宝场景」中,神龙是最显眼的视觉元素,是用 Spine 制作的,但它并非一直在播放。神龙的行为和场景中一些被称为探测点的特殊节点有关。下图左边红框内有一个名为"commonDetector_common_2"的探测点,该探测点同层级还有一个福袋槽位"redpacket_2"节点,下图右边的福袋就是挂在"redpacket_2"节点上。下图右边的紫色方块是左边的探测点的可视化“符号”,只在开发阶段标识探测点位置,方便调试。一个地形块可能有 5 到 6 个探测点,大约一屏的宽度一个探测点

996889fa41ea193b0b4e3c3bb6c0bf63.png

探测点命名规则是程序和设计约定好的,第一个下划线“_”前面的部分是探测点类型,而后面部分神龙动画名称,如下图左边红、蓝框圈住的地方。探测点有多种类型,后面详述。

每个探测点上还挂有一个可配置脚本,如下图右下角红框圈住的区域,可以配置当前探测点触发时的神龙在 z 轴上的层级("Z value"),以及该探测点触发后对应的福袋槽位上挂的福袋多少秒后播放 “ 出现 ” 动画,即下图左下角的"Red Packet Appear Time"),多少秒后播放 “ 消失 ” 动画,即下图左下角的"Red Packet Hide Time"。

2e6d56a01f452835215cd5e1659b1550.png

在水平方向上,当一个探测点位于屏幕中心时,该探测点被触发。不能以 x 轴上探测点到屏幕中心的距离接近 0 来判断一个探测点到达了屏幕中心,这样有很大误差,因为相机移动速度很快。甚至有可能错过探测点的触发时机,比如下一帧本来要触发的,结果当前帧由于某种原因卡顿了一下,deltaTime 突然变大很多,导致下一帧探测点直接越过屏幕中心,且距离远大于零。

在实际项目中,每个探测点都有一个标志位,这里记作 isTriggered,当探测点在屏幕中心右边时,isTriggered 记为 false,当某一帧探测点突然在屏幕中心左边时,说明探测点刚刚越过了屏幕中心,探测点触发,设置其值为 true。在回收队首地形块时,其上所有探测点的 isTriggered 标志位都要重置为 false,因为此时该地形块会被移到场景的最右边,也在屏幕中心右边。

当一个探测点被触发时,程序播放对应的神龙动画,神龙在场景中游动。同时,程序根据探测点上配置的时间设置定时器,经过"Red Packet Appear Time"秒后,播 放 福袋的“出现”动画,出现动画结束后自动播福袋的待机动画,经过"Red Packet Hide Time"秒后,播放福袋的“消失”动画。此神龙头部恰好撞到福袋。这些时间的值是设计同学根据神龙和福袋的动画时长在 SAR Creator 中配置的。程序只需读取配置,实现代码逻辑。时间线上,探测点触发与神龙和福袋动画的关系如下图所示。

4d7113fd8ac8f9f977e503d8f6650bdf.png

设计同学在制作神龙的每个动画时,都是以对应主题的第一个地形块的左下角为参考点。程序运行时,需要在第一个地形块中,记录每个探测点和地形块左下角的偏移向量。在后面所有地形块中播放神龙动画前,都要重置神龙的位置。神龙的新位置是以探测点为基准,减去对应的偏移向量得到的。设计同学保证所有探测点都在第一个地形块中出现。

ee4befc91b9412d73c82cb96356aab61.png

每个主题的「寻宝场景」有多个类型的探测点,如上图所示。普通探测点达到屏幕中心时,播放对应的神龙动画,并且若福袋槽位上挂有福袋,会在配置的时间后,播放对应的福袋动画。两个特殊探测点是在普通探测点基础上添加了限制或功能。

  • 好友龙探测点:用户获得其他用户助力后去寻宝,好友龙探测点首先被触发,程序除了播放主龙动画外还播放好友龙动画,下图左边半透明的就是好友龙。

  • 接近好友龙探测点的探测点:以“nearFriendDetector_”开头的探测点是在位置上十分接近“好友龙探测点”的探测点,如下图右边红框中第二个探测点(紫色方块)。当有好友助力时,在第一个地形块首次出现时,程序不触发这类探测点,因为好友龙相关的动画只出现一次且不能被打断。其它情况,其行为和普通探测点一致。

6e5c02110a3a24ba740b1f7513c19456.png

寻宝过程中福袋其实有两种美术表达形式:

  • Spine 动画:神龙“撞”到的福袋,即上面所述的,是互动侧实现的,每个福袋是一个 Spine 动画,如下图左边红框里圈住的部分。

  • Lottie 动画 当神龙撞到 Spine 福袋后,Spine 福袋消失,同时屏幕中心出现一个大福袋,它是一个 Lottie 动画 ,并掉落到底部的宝箱中,如右图蓝框中圈中的福袋,这是前端同学实现的

06b17c2a05453be2c155d046a56ea684.png

「寻宝场景」的龙蛋视觉效果,是前端同学实现的。互动侧代码根据 timeline 数据检测到一个龙蛋触发时,就向前端发送消息,前端代码弹出一个龙蛋的 Lottie 动画,如本文开头的视频所示。

2D 场景实现“3D”效果

寻宝场景」是 2D 的,如何实现自然的“3D”效果呢?下图左边是丹霞主题,龙可以穿过石拱门,龙身一部分在拱门前,另一部分在拱门后;右边是山川主题,龙可以绕着山体一圈,龙尾在山后,而龙头在山前。这是设计同学通过在 SAR Creator 中设置 2D 元素的层级(z-轴)实现的

5c01a9eb83c514e9361e0a602156aa5f.png

以上图右边的“龙绕山”为例山顶其实有一小片是单独的精灵图,有单独的层级 ,与山体的层级不一样。当龙走到这里时,程序把龙的层级设置为一个恰好处于山顶和山体层级之间的值,这样就达到视觉效果了。下图右边是把山顶往左偏移后的效果,方便观察。

5bbfadfb843908b35ee59e38e2bc05f0.png

「寻宝场景」有四个主题的地形,但神龙只有一个,独立于地形之外。在不同主题和探测点处,神龙的层级值是不同的,这一点是通过在探测点上添加 Z Value 配置实现的,在上一小节的截图中展示过。每当一个探测点触发时,程序先读取配置的 Z Value,并把它赋值给神龙的 Entity,然后再播放神龙动画,就实现“龙绕山”的“3D”效果了。相关流程如下图所示。

48a30a02786170295cbbd839f165e653.png

相关伪代码:

// 播放每一个动画时,主龙的transform3D.position.z的值都有一个对应的配置,以实现渲染层级。
  newWorldPos.z = curDetector.ipZValue; // ipZValue是探测器上配置的Z Value。
  const ipAnimEventInfo = {
    isFriendDragon: curDetector.isFriendDector,
    animName: curDetector.ipAnimName,
    newIPPos: newWorldPos
  };
  // 播放神龙动画,playIPAnimation()内部重置神龙位置
  playIPAnimation(GameEvent.TRAVEL_IP_ANIMATE, ipAnimEventInfo);
}

正是为了实现这种“3D 效果” ,我们才引入探测点的概念,互动代码需要感知「寻宝场景」的环境信息,以播放对应的神龙动画。引入福袋槽位的概念是为了方便实现神龙头部撞上福袋的视觉效果。福袋槽位的位置是由设计同学精心设计好的,可以保证龙头恰好在对应的时间经过那里。福袋槽位的位置是固定的所以互动代码并不能完全遵循服务器下发的 timeline 中福袋的触发时间戳,而是尽量和它对齐,同时有一些自己的规则,比如:不能早于 timeline 中的时间触发福袋、优先把福袋放置在最近的可用福袋槽位上等。

场景管理策略

上文介绍了「家场景」和「寻宝场景」的实现。如何将这两个场景串起来,做好场景管理呢?

首先,我们需要确认游戏初始化完后应该加载哪个场景。引擎能力准备完毕后,互动向前端获取本次用户进入游戏的活动数据,判断进入游戏后是“寻宝中”还是“在家”的状态,根据状态,加载对应场景的资源,然后展示给用户。

如何实现两个场景的丝滑切换?例如,用户此刻在「家场景」,点击“去寻宝”,如下图所示。

777853085859a84bfc183f266bd045ce.png

龙转场

我们会播一个龙的转场动画,转场完,给用户展示出另一个场景。

首先,设计同学导出一份 spine 动画资源, 有 start、loop、end 三个动画,分别为龙从屏幕左下角起飞、龙身占满整个屏幕循环播放、龙身离开直到龙尾离开屏幕。

d10b2ca3f117cde430d8fa32212f59c6.png

在龙身 loop 动画的这一段时间内,进行另一个场景的加载和逻辑处理。

c5d3a9a4d98ed4eb6e76a787980641e1.png

相关代码如下:

interface TransferLifeCycle {
    onStart?: () => Promise<void>; // loop动画开始时机,此时用户完全看不到转场后面的内容
    onEnd?: () => void; // loop动画结束时机,此时用户能看到转场后面的一些内容
    onRemove?: () => void; // end动画结束时机。此时龙尾巴完全离开屏幕
    onError?: (e: Error) => void; // 转场出错
}

// 转场逻辑
class Transfer {
    _spine: Spine; // 转场的动画资源
    _transfer!: TransferLifeCycle; // 存转场的钩子函数
    _canEnd = false // 标记用户的start逻辑是否处理完毕
    
    startTransfer = async (params: TransferLifeCycle) => {
        this._transfer = { ...this._transfer, ...params }
        try {
            // 开始触发spine的start动画播放,交由spine的complete监听来处理每一个阶段的逻辑
            this._canEnd = false
            // 若未加载Spine,则加载spine资源,并播放其的'start'动画,略。
        } catch(e){
            this._transfer?.onError?.(e);
        }
    }
    // Spine资源加载完毕后,此回调函数被自动调用
    async onSpineAnimComplete(entry: any) {
        const animateName = entry.animation.name;
        // start动画播完 => 需要开始播loop动画,并处理onStart的逻辑
        try {
            if (animateName === 'start'){
                // 播放Spine的'loop'动画, 略。
                await this._transferParams.onStart?.()
                this._canEnd = true // 标记用户处理完了onStart逻辑
            } else if(animationName === 'loop'){
                if(this._canEnd) {
                    // 处理完了onStart逻辑。播放end动画,略。
                    this._transfer?.onEnd?.()
                }
            } else if (animationName === 'end'){
                this._transfer?.onRemove?.()
            }
        } catch (e){
            this._transfer?.onError?.(e)
        }
    }
}

“家”和“寻宝”两个场景的管理怎么做呢?主要使用了“预加载”、“缓存”和“销毁”三种手段。

预加载

为了做到场景加载的更快,对场景进行预加载,提升用户的体验。

游戏初始化后,若加载的是“家”场景,则充分利用加载完“家”到用户“点击寻宝”之间的这段时间,对“寻宝”场景进行预加载。

const isHome = mainData.isHome // 是否是家场景

const preloadTravel = () => {
    const { bundle } = assetManager.loadBundle('travel')
    bundle.load('Travel.prefab')
}

const preloadHome = () => {
    xxx
}

// 预加载另一个场景
const preload = () => {
    if(isHome){
        preloadTravel()
    }else{
        preloadHome()
    }
}

利用 bundle.preload( prefab )可以将 prefab 依赖到的资源提前 fetch 到本地 。

缓存和销毁

除了预加载资源,我们还适当地使用了缓存,用空间换时间,提升切换场景的速度。 

SAR Creator 提供了将子节点从父节点移除,但是不销毁其依赖的资源的能力。这是实现缓存逻辑的基础。

class SceneManager {
    homeRoot?: Entity; // 家场景
    travelRoot?: Entity; // 寻宝场景
    
    // 加载
    async loadHomeRoot() {
        // 若有缓存,这步就不会走,直接addChild即可
        if(!this.homeRoot){
            this.homeRoot = await bundle.load('HomeRoot.prefab')
        }
        
        // 加载缓存的或者第一次初始化出来的家场景
        if(this.homeRoot){
            scene.addChild(this.homeRoot)
        }
    }
    
    async loadTravelRoot() {
        if(!this.travelRoot){
            this.travelRoot = await bundle.load('TravelRoot.prefab')
        }
    }
    
    // 缓存
    dispose() {
        // 缓存
        if(USE_STORAGE){
            // 将节点从场景中移除,但保留其依赖的资源
            this.homeRoot?.parent?._deleteEntityFromChildren(this.homeRoot)
        }else{
            // 销毁
            entity.dispose()
        }
    }
}

所有机型无差别地缓存,风险很大。为此,我们对低端机采取资源销毁的逻辑。

使用entity.dispose方法实现销毁逻辑,它会递归该 entity 及所有子 entity 依赖的资源,释放其纹理、material、geometry 等。

对于使用缓存还是销毁,程序定义了如下数据结构:

export interface DowngradeIParams {
    // 静态获取
    enable: boolean,
    blackList: [],
    i32Forbidden: boolean, // 是否在32位包上禁用缓存能力
    deviceScoreHigh: 10, // 超过此评分算高端
    deviceScoreMid: 8, // 超过此评分算中端
    deviceLevel: ['high', 'mid', 'low'], // 缓存能力启用的机型
    
    // 动态获取
    memoryLimit: Infinity // 剩余内存超过这个数才启用
}

上面数据结构提供了全局开启/关闭(enable)、机型黑名单、32 包禁用、机型打分、动态内存等多个度量标准来帮助我们做缓存/销毁的判断,配置的数据走 settings(字节内部客户端配置动态下发平台)下发。

基于这些技术,每次场景切换时,我们根据当前的机型信息和实时内存数据来判断采用哪种策略,例如,剩余内存不够多时,加载“寻宝”场景,并销毁“家”场景的所有资源,以此来保障游戏稳定性

团队介绍

我们是抖音前端架构-互动体验技术团队,主要为字节跳动业务提供互动技术解决方案。技术产品包含面向互动 / 小游戏研发场景的 SAR Creator、高性能动效渲染引擎 Simple Engine、互动场景端能力套件 AnnieX 互动容器

在这些技术建设与业务落地上,和抖音前端-互动创作团队、跨端框架团队、开放平台小游戏团队、用户增长-激励前端团队一同推进,不断探索字节跳动应用生态下的创新业务形态。

下期预告

系列下期主题是 AnnieX 互动容器。作为字节互动解决方案的一环,结合客户端能力,为互动场景提供极致的性能体验,满足互动游戏多样化场景需求。敬请期待。

Monorepo 解决方案 — 基于 Bazel 的 Xcode 性能优化实践

作者 ByteDanceTech
2024年3月13日 11:00

背景介绍

书接上回《Monorepo 解决方案 — Bazel 在头条 iOS 的实践》,在头条工程切换至 Bazel 构建系统后,为了支持用户使用 Xcode 开发的习惯,我们使用了开源项目 Tulsi 作为生成工具,用于将 Bazel 工程转换为 Xcode 工程。但是在使用的过程中,我们发现了一些问题,其中影响较大的是,

  • Xcode 工程卡顿:对于头条这种大型项目来说,Xcode 卡顿一直是本地研发的痛点问题,在切换 Bazel 构建系统之后卡顿现象明显加剧。

  • Xcode 功能支持受限:Tulsi 支持的功能有限,很多功能都年久失修,并未持续适配 Bazel 与 rules 的更新。

在做了一些前期调研后,我们发现 rules_xcodeproj 提供了更好的解决方案。rules_xcodeproj 是一个由一系列 Bazel rules 组成的开源项目,使用它可以从一个 Bazel 工程生成对应的 Xcode 工程,实现在 Xcode 中写代码同时使用 Bazel 进行真正的构建任务,相比于 Tulsi,rules_xcodeproj 在以下几个方面有着更为明显的优势。

  • Xcode 工程更加流畅: 头条工程迁移到 rules_xcodeproj 后,工程首次冷启、二次启动和文件增删操作的时间有了明显缩短,工程卡顿情况也明显好转。

  • Xcode 功能支持度更高: rules_xcodeproj 对 Xcode 的支持更全面(包括单元测试、SwiftUI Previews),能够更好地满足我们的需求。

  • 社区更加活跃:随着 rules_xcodeproj 在 2023 年 2 月份发布正式版,Tulsi 项目也正式宣布停止维护,这意味着对于新版本的 Bazel,Tulsi 将不再提供适配和支持;同时 rules_xcodeproj 几乎每月都会更新一个中版本,对于后续适配 Bazel 更新的成本会更低。

  • 更符合 BitSky 的演进路线:由 Bazel 驱动的工程生成方式更符合 BitSky 的 Bazel Native 演进路线,可以完全在 Bazel 环境下生成工程。

因此,我们将 Xcode 生成工具从 Tulsi 迁移到了 rules_xcodeproj,并对 BitSky 工具链进行了适配。适配过程中,我们在修复 rules_xcodeproj 索引问题的同时,对工程结构进行了一些优化,进一步提升了工程流畅度。头条 iOS 工程的测试数据如下:

测试设备 MacBook Pro,芯片 M1 Pro,内存 32GB

ps. 本文介绍均基于 rules_xcodeproj 1.4.0 版本,build with bazel 模式

pps. 由于 Xcode 主线程的卡顿较难监测,此处用主动操作的执行时间衡量流畅度


Tulsi rules_xcodeproj
工程首次冷启 47s 16s
二次启动 33s 12s
文件操作 新增 20s
删除 23s
新增 8s
删除 6s

下面来看下我们具体做的适配工作。

适配过程

从 Tulsi 迁移到 rules_xcodeproj 后,我们发现 Xcode 卡顿有明显改善,仔细分析发现 Tulsi 工程的卡顿主要有两方面原因:

  1. 头条工程中组件数量多、依赖关系复杂:Tulsi 的索引方案需要 Xcode Target 之间保留这些依赖关系,但这些依赖关系并不会被 Xcode 构建消费,却增加了 Xcode 对工程进行解析和计算的成本。

图中 Pod X_A 与 Pod X_B 为 Pod X 分别在 App A 与 App B 下被引用的 Target

4ec246b5859f3a8f8259299f78a27790.png
  1. 全源码构建:我们在切换 Bazel 时还推进了工程的全源码构建,源码数量大幅增加,也给索引任务带来更大的压力。

接下来详细说明整个分析和适配过程,带大家更全面地了解我们结合 rules_xcodeproj 在 Xcode 背后做了什么。

在 Xcode 中开发时主要会用到三部分功能:构建、索引和调试。而 Xcode 并不能直接理解 Bazel 项目的工程文件(BUILD 和 WORKSPACE),所以我们需要通过工具(rules_xcodeproj 或 Tulsi)将 Bazel 的工程文件转换为 Xcode 可以理解的 .xcodeproj来支持这些功能正常工作。

各功能的适配点如下表所示。

ae4f2b66f0acc6128c5367818f006120.png

rules_xcodeproj 原生方案的调试模块基本能正常工作,而索引和构建两个模块中我们对原生方案的改造较大,因此本文主要对这两个模块进行展开介绍。

索引

前面提到支持 Xcode 索引功能需要提供各个源文件的编译参数,rules_xcodeproj 实现这一点的工作流程是:

  1. rules_xcodeproj 在 Bazel 的分析阶段获取到源码文件和编译参数;

  2. 用这些信息创建 Xcode Target 的 Compile Sources 和 Build Setting 产出工程文件 .xcodeproj

  3. Xcode 加载工程文件,获取源码文件对应的索引参数,调用 clang 或 swiftc 执行索引命令。

迁移过程中我们注意到 rules_xcodeproj 工程的索引在多 Target 共用源文件的场景存在语法高亮异常的问题,对比 Tulsi 工程的处理方式后我们得出两个结论:

  • 索引异常是由于 rules_xcodeproj 移除了所有 Library Target 间的依赖导致的;

  • Tulsi 工程中 Library Target 间的依赖关系正是导致 Tulsi 工程更为卡顿的关键原因。

后文会先分析依赖关系在索引中发挥的作用,以及 rules_xcodeproj 如何处理依赖关系移除带来的问题&

抖音 ANR 自动归因平台建设实践

作者 ByteDanceTech
2024年3月8日 15:00

背景介绍

本文在 2024 年初最新一期『抖音客户端基础技术大揭秘』技术沙龙活动中已做过专题分享,本次将内容重新整理文章进行分享。公众号后台回复技术沙龙可查看沙龙回放及 PPT~

抖音作为一个超大型的应用,我们在 ANR 问题治理上面临着很大的挑战。首先对于存量问题的优化,由于缺少有效的归因手段,一些长期的疑难问题一直难以突破解决,例如长期位于 Top 1 的 nativePollOnce 问题。同时我们在防劣化上也面临很大的压力,版本快速迭代引入的新增劣化,以及线上变更导致的激增劣化,都需要投入大量的人力去排查定位,无法在第一时间快速修复止损。

ANR 原理简介

既然我们要建设的是 ANR 归因平台,首先需要了解下什么是 ANR ?它是 Android 系统定义的一种“应用程序无响应”的异常问题,目的是为了监控发现应用程序是否存在交互响应慢或卡死的问题。从用户的视角来看,发生 ANR 时设备上会出现提示应用无响应的弹窗,甚至在一些机型上可能就直接闪退了。所以 ANR 与其他崩溃问题一样,是一种会对用户体验造成严重打断的异常问题。

接下来我们从系统的设计原理来看下为什么会发生 ANR ?以一种常见的广播超时引起的 ANR 为例,首先系统 AMS 服务会通过 IPC 方式将一个有序广播发送给应用进程,并在同时启动一个超时监控。应用进程在 Binder 线程接收到广播之后,会将其封装成一个消息 Message 加入到主线程的消息队列里等待执行。正常情况下,广播消息在应用内都会得到及时响应,然后通知系统 AMS 服务取消超时监控。但是在一些异常的情况下,如果在系统设置的超时到来之前,目标消息还没有调度执行完成的话,系统就会判定响应超时并触发 ANR。

de9c98e41446f6d2bb1962ea348f715f.png

归因方案现状

当前业界针对 ANR 问题的归因手段有哪些?第一种是传统归因方案:基于系统生成的 ANR Trace 和 ANR Info 来定位问题原因。这里的 ANR Trace 是在问题发生时系统通知应用自身 dump 采集的各个线程的堆栈以及状态信息,而 ANR Info 中则包括了 ANR 原因、系统以及应用进程的 CPU 使用率 和 IO 负载等信息。对于像下图中这类由于当前消息严重耗时或卡死引起的 ANR,这种系统原生的方案可以帮助我们快速的定位到问题堆栈。

但是它也存在一个明显的问题,从前面的原理分析可以知道,广播等系统组件消息在加入到主线程之后,是按照它在消息队列中的先后顺序来执行的,所以有可能是之前的历史消息存在严重耗时从而引起的问题。这种情况下在 ANR 实际发生时系统抓取的堆栈很有可能就已经错过了问题的现场,基于这样的数据进行归因得到的结果也是不准确的。

7cca25514d47ef655bed43ed0a6d6710.png

第二种是慢消息归因方案:通过监控主线程消息的执行情况,并结合耗时消息的采样抓栈来定位问题原因。这个方案解决了前面传统归因方案中存在的问题,提供了一种更细粒度的监控和归因能力,可以同时发现当前和历史的耗时消息,以及其中可能存在的耗时问题堆栈。

但这个方案也同样存在的一些不足之处, 因为对于像抖音这样的大型应用来说,ANR 通常是由于各种复杂的综合性因素导致的,包括子线程 / 子进程 CPU 抢占、应用 / 系统内存不足等也都会对主线程的执行效率造成影响,间接导致主线程整体都变慢了。在这种情况下,主线程的慢消息或堆栈可能并不是问题的根本原因,同样以此得到的归因结果也是不全面的。

91df5902d92809ebc46c35875fb6b0f7.png

所以总结一下目前的现状,现有的 ANR 归因方案存在以下几个痛点问题:首先是归因不准确,归因结果难以消费,不能真正解决问题。其次是归因能力少,对于复杂问题难以定位根本原因。最后是归因效率低,人工排查周期长。

建设思路

接下来重点介绍下 ANR 归因平台的建设思路,平台归因体系主要围绕以下三个方向进行建设:

  • 单点问题归因:首先需要对单个 ANR 问题实现精准的归因,这也是我们整个归因体系建设的重点和基础。

  • 聚合问题归因:其次是线上大数据的聚合问题归因,帮助我们聚焦 Top 重点问题。

  • 劣化问题归因:最后线上灰度以及全量版本劣化问题的自动归因,提升新增 / 激增问题的解决效率,目前正在建设中,本次分享就不展开介绍了。

单点归因思路

单点 ANR 问题的归因可以分为三个步骤,首先需要从原理出发明确 ANR 问题区间;接下来对问题进行粗归因,也就是一种定性的分析,比如说是主线程阻塞卡死、还是 CPU 抢占或是内存异常导致的问题;最后就是进一步进行细归因,也就是需要定位到具体的问题代码,能实际指导我们消费并解决问题。

问题区间

首先从 ANR 问题的原理出发,来分析一下如何大致确定 ANR 问题区间。我们同样还是以上面的广播消息超时引起的 ANR 为例,问题产生的时间顺序为:从系统 AMS 服务发送有序广播并启动超时监控开始,到应用进程将该广播消息加入到主线程消息队列,并按照队列中的先后顺序等待调度执行。当系统进程的超时时间结束前,对应的广播消息还没有执行完成并通知系统,系统就会判定响应超时并触发 ANR。这里我们可以以 ANR 实际发生时间为结束点,往前回溯对应的超时时间(这里不同的 ANR 原因会对应不同的超时时间设置),也就是后续需要诊断分析 ANR 问题区间范围。

9c6cde83c1538f7c30d5e0973a27bf28.png
粗归因

在明确 ANR 问题的时间范围后,我们需要从技术角度来拆解下,如何进行粗归因的定性分析。从上面的原理分析可以推导出,引起 ANR 的根本原因就是系统组件消息(包括 input 事件)在应用侧没有得到及时的执行。而我们知道这些关键目标消息都是在主线程中进行消费处理的,所以这里的关键点就是 ANR 区间内之前的这些消息为什么执行耗时 ?从系统的角度来看,所有代码逻辑的执行可以分为 On-CPUOff-CPU 两种情况:

  • On-CPU:对应 Running 状态,即当前任务正在占用 CPU 资源进行计算处理。已知 计算耗时 = 计算量 / 计算速度,这里计算速度会受到 CPU 硬件本身的限制,比如 CPU 核心频率以及当前运行在大核或小核上。另一个跟应用自身关系比较紧密的就是计算量,例如主线程在执行 CPU 密集型的操作,比如 JSON 序列化 / 反序列化,或是在处理大量的高频业务消息。

  • Off-CPU:包括 Runnable 和 Sleep 两种状态。Runnable 代表任务所需的资源已就绪,正在 CPU 运行队列上等待调度执行,这里除了受到系统本身的调度策略的影响之外,也跟当前同样已就绪并等待调度的任务数量有关。如果子线程或子进程有很多 CPU 耗时任务在等待执行的话,因为总的计算资源是有限的,互相之间频繁的抢占也会影响主线程的执行效率。Sleep 代表任务在阻塞等待资源,比如等待 Lock、IO、同步 Binder 以及内存 Block GC 等。通常情况下我们应该避免在主线程发生这类 Block 阻塞问题,同时这也是比较常见的一类解决卡顿或 ANR 的优化手段。

32e7779f51a9592be17dddbcc021c758.png

下面我们来看下第一种主线程异常消息直接引起的 ANR,它可能是由于当前消息严重耗时或卡死导致的,也可能是由于之前的一个或多个历史耗时消息引起的 ANR。

92ba695a1daabe0b8ee0703059d83d70.pngb0bfab73400a0b1781a6111d9cf9cca1.png

第二种就是后台任务 CPU 资源抢占引起的ANR,它会间接影响主线程的执行效率。从下图中可以看到在子线程 CPU 负载变高之后,主线程的整体性能开始下降变慢,这种情况下就会更容易发生 ANR。最典型的就是在冷启动的场景下,通过降级或打散 CPU 耗时的后台任务,我们已经验证可以有效的降低 ANR 率以及缩短启动首刷耗时。

618c885a6c57751e8d372281291f0ab6.png

最后一种内存等资源异常问题,例如虚拟机 Java 内存不足时,GC 线程就会开始变得活跃并进行频繁 GC,这同样也会抢占主线程 CPU 资源或 Block GC 等待,从而导致 ANR 问题的发生。对于抖音这样的视频类大型应用,线上内存问题导致的卡顿或 ANR 问题的占比较高,目前也在专项治理优化中。

59bfa2da9d6547b9a7eb487375d30b46.png

基于以上的演绎推理,总结一下我们的归因思路主要包括以下几类:第一,主线程本身的异常问题,例如存在严重的阻塞等待或者 CPU 繁忙等问题。第二,后台任务抢占 CPU 资源导致的异常问题。最后,内存 / IO 等系统资源不足导致的异常问题,目前正在探索中,本次分享就不展开介绍了。

细归因
主线程消息异常

首先来看主线程消息异常的归因思路:我们需要先对主线程消息进行监控,这里包括三种情况,已经执行完成正在执行中的消息以及消息队列中待执行的消息。对于已经或正在执行的消息,我们主要关注它们的耗时情况,通过分析系统源码我们可以知道,主线程消息队列里处理的消息一共包括三种:Java 消息、Native 消息和 Idle Handler。而对于待执行的消息,我们主要关注其中的数量,从而判断是否存在大量消息堆积的异常问题。

59244ecdfdc57366ddbc9042f2f7f571.jpeg

对于主线程异常消息的问题类型,第一类就是耗时消息,即在问题区间内存在一个或多个耗时大于阈值的慢消息。另一种是高频消息,也就是存在出现次数以及累计耗时超过一定阈值的高密度消息。这里通过对业务消息的 target、callback 和 what 等信息进行聚合分析,有时也可以协助定位到导致问题的业务方。

e22e2b5facfa7918a79c6955737a7413.pngd9de1058829dd9f8e18dfc35bdd5e3ff.png

但是仅仅找到异常消息并不能直接帮助我们解决问题,还需要对消息的耗时原因做进一步的归因分析,找到其中引起消息耗时的问题函数。接下来介绍一下线上 Trace 数据采集方案,这里我们采取类似 Matrix 方案,通过 ASM 字节码工具,在编译时对应用内的业务代码进行插桩,也就是在函数的入口和出口插入一行统计代码,来记录当前方法的运行耗时。为了降低采集时的性能损耗,将方法 begin / end 状态标识、当前方法 ID 以及时间戳的 Diff 相对值,合并使用一个 64 位的变量来记录。在 ANR 等异常问题发生时,再将 Ring Buffer 中记录的数据进行上报,在后端数据链路处理生成对应的 Trace 堆栈,并提供给后续的诊断算法进行自动化分析,以及 Perfetto 人工可视化分析的需求。

d65be46b15e83cbb049c64e3661b6dd2.png

但对于抖音这样的大型应用来说,插桩方案也会有一些弊端,当插桩的函数过多时,会对包体积以及性能产生负面的影响。为了尽可能降低监控工具对线上用户体验的影响,我们提出了精准插桩的方案!因为结合对于线上问题的分析诉求来看,我们重点关注的是上层执行的业务函数,比如页面生命周期、业务消息等入口方法,以及底层那些可能耗时的业务函数。所以我们基于静态代码分析的基础能力,分析提取出带有耗时特征的函数来进行插桩,例如下面表格中带有锁关键字的函数、存在 Native / IO 等调用的函数以及特别复杂的大方法等。在这个精准插桩的优化策略之下,大幅减少了约 90% 的插桩数量!

db8232416f8b87c1433f9b9b8c2526e5.png

在大幅精简插桩数量之后,我们在消费线上数据时又面临到一个问题,就是仅有插桩的堆栈信息太少,有时难以帮助我们实际定位到发生问题的代码。为了解决这一痛点问题,我们又设计了插桩和抓栈数据拟合的优化方案,其原理就是通过对耗时超过一定阈值的慢函数进行抓栈上报,然后在服务端再将插桩与抓栈数据根据时间点进行拟合,补齐其中缺失的业务和系统堆栈信息。如下图中所示,当插桩堆栈中连续两个节点能在抓栈数据中找到最小间距的数据并对齐时,抓栈数据中对应层之间的数据将会被补全到到插桩数据中,生成新的拟合堆栈数据,图中的 D 就是被补全的数据。

ec0c7c1fe5d4b38465354f8a9e595ab3.png6593bc56eae44ba9783b801b50b4f53b.png

在获取到线上问题发生时的详细 Trace 数据后,我们需要进一步找到其中引起耗时的问题函数,常见的问题函数类型包括以下两种:

  • 慢函数:是指函数的执行耗时超过一定的阈值;并且从实际可消费性的角度来看,我们预期是要能找到更靠近叶子节点的业务慢函数。所以需要根据实际调用堆栈的情况,进一步剔除底层的基础库或工具类方法,以及存在相同调用链路的亲缘父子节点,来找到最合适的慢函数问题。

  • 高频函数:对应就是单个虽然并不耗时,但是由于次数很多,累计执行耗时超过阈值的函数;根据我们以往的经验来看,对于高频函数的优化通常也能带来不错的收益。

e50b11152e8eb106392f5cb49cc497e0.png865e8e8a16ae41d94af230ea8b515999.png

当然仅仅知道函数慢也是不够的!我们结合一个线上实际的示例来看,通过 Trace 可以发现标记红框的这里有一个业务函数执行比较耗时,但是这里为什么会耗时呢?我们要如何进行优化呢?目前仅有的堆栈信息并不能满足我们的归因需求。之后通过分析业务代码并补齐了缺失的“关键”信息之后,我们就可以明确知道这里耗时的原因是由于锁竞争导致的,并且我们还进一步补充了当前持有锁的线程以及堆栈等重要信息。

9e25ffd17147f41f7dd0a9f08fa715ba.pngbc0980e68251d2d169e38715639624e7.png

为了更好的对慢函数的耗时进行归因,除了前面采集的 Trace 堆栈数据之外,我们还需要补充一些关键的上下文信息,这里就统称为精细化数据。比如上面提到的锁,还有函数的 CPU-Time、Binder 调用的名称、IO 读写的文件路径和大小、绘制渲染相关的 RenderNode,以及内存 Block GC 等相关信息。

dc5eb90197654ec706302d50334dc29c.png

所以回顾总结一下主线程消息异常的归因流程,首先需要明确当前 ANR 的问题区间,然后找到其中的异常消息(耗时消息或高频消息),进一步下钻找到其中引起耗时的问题函数(慢函数或高频函数),最后再结合精细化数据对其耗时原因进行归因。

a23da95b0ece6b60c9f28e8eba48e3d9.jpeg
后台任务异常

接下来再看下后台任务 CPU 异常的归因思路:首先我们需要明确是否存在后台任务对主线程 CPU 资源产生抢占的问题,这里可以结合主线程的非自愿上下文切换以及调度状态的信息,来观测主线程是否有较多的时间都花费在等待系统调度上。如果存在明显的异常情况,再结合系统和应用的 CPU 使用率信息,可以进一步先定位到是应用的子线程 / 子进程,或是关键系统进程(如 dex2oat 进程等),还是其他应用进程造成的 CPU 资源抢占。

对于应用自身造成的 CPU 抢占问题,我们需要进一步定位到具体的问题代码。所以我们在之前的 Trace 采集方案的基础上,进行了重大的升级改造,扩展支持了全线程的 Trace 数据采集。

66c033666c553602392257c6e38e24c8.png

单线程 Trace 多线程 Trace 说明
Flag 状态位 2 bit 2 bit 代表函数开始/结束:3(二进制0b11)= catch, // 预留 2(二进制0b10)= throw,// 预留 1(二进制0b01)= begin, 0(二进制0b00)= end
Method ID 20 bit 20 bit 代表插桩的方法 ID,最大支持 1048575 个函数
Thread ID - 15 bit 代表当前线程的 TID
Timestamp 42 bit 27 bit 代表当前函数执行时与基准时间的相对时间,多线程模式下最大支持 134,217,727 ms = 约 1.5 天

由于后台任务我们重点关注的是 CPU-Time 耗时,所以在采集函数的 Wall-Time 执行耗时之外,同时也支持函数粒度的 CPU-Time 耗时采集,并在后端进行处理关联。这里出于性能损耗上的考虑,我们会进一步精简控制同时需要采集 CPU 时间的插桩函数数量,例如仅对系统的生命周期方法,以及子线程的 Runnable、Callable 以及二方 / 三方的任务框架入口方法,以及少量的关键特征方法才开启,并且会设置最小的采样间隔时间。

对于 CPU-Time 的获取一般有两种方式:一种是通过定期读取 proc 文件系统下的文件来解析获取,这种方式如果想要精确到方法级别需要相当高的读取频率,这种高频率读取文件并解析的性能损耗很高,不适合在线上方法级别的 CPU-Time 采集;另一种则是通过 Android 提供的 SystemClock.currentThreadTimeMillis() 方法或者 Native 层的 clock_gettime(CLOCK_THREAD_CPUTIME_ID) 方法获取当前线程的 CPU-Time,这种方式比较适合采集方法级别的 CPU 耗时,在方法开始和结束时分别调用前述方法再计算差值即可,因此我们线上采集选择的也是这个方案。

同样回顾总结一下后台任务异常的归因流程,在 ANR 的问题区间内,首先需要明确是否存在对主线程执行效率产生明显影响的 CPU 资源抢占,如果是应用自身的问题,先找到应用内 CPU 负载较高的线程或进程,进一步定位到对应异常阶段里的后台任务代码,最后再结合精细化数据对其 CPU 耗时原因进行归因。

aea19f7cc3f2a838951bcdc195c25059.jpeg

聚合归因思路

基于以上对 ANR 单点问题进行诊断分析后产出的归因结论,我们可以进一步结合线上大数据进行聚合归因,从而帮助我们更好的聚焦到 Top 重点问题的优化上。

归因标签

首先是对归因标签的聚合分析,主要包括以下几类:

  • 粗归因标签:针对 ANR 问题定性的归因分类标签,包括主线程阻塞、高频消息、CPU 抢占、堆内存不足等。

  • 细归因标签:针对细归因定位到的问题代码的精细化归因标签,包括主线程锁、IO、Binder 或者 Block GC 阻塞耗时等。

  • 业务归因特征:发生 ANR 时用户所在场景页面等业务维度的特征标签,有时也可以辅助快速定位到问题相关的业务方。

如下图所示,通过对以上不同归因标签的多维聚合分析,可以帮助我们对线上 ANR 问题的特征分布有一个全局的了解和认知,同时也能指导我们在归因能力上下一步需要重点攻坚的方向。

650439e6f3958040b6690769e97164f7.png
异常问题

其次是对细归因产出的异常问题进行聚合分析, 目前主要包括主线程异常函数后台任务以及内存这三个维度。聚合后的问题列表支持渗透率、耗时均值、PCT 50 / 90 耗时以及场景等维度的统计数据,可以帮助我们识别出线上整体占比较高或耗时特别严重的这类问题。

bc747844606adf36f6825523782c46ca.png

在进入异常函数的归因详情页之后,可以查看当前问题函数在线上大数据聚合后的火焰图,其中 Caller 堆栈代表上层不同业务方的调用次数分布情况,而 Callee 堆栈则是所有子函数的耗时分布情况。

c56442d7da2cf31ff28712b26bd0845b.pnga1378ff79bd2ebc045f974911fe25507.png

最后,基于以上的归因标签、异常问题以及业务归因信息,平台会产出一个对 ANR 问题最终的归因结论以及对应的综合置信度评分。

a8c56a8251db26524238181d17809b47.png

落地效果

接下来再介绍一下平台目前的落地效果:首先这个案例是一个启动阶段的 ANR 问题,我们从主线程 Trace 中可以分析定位到主线程的耗时函数,并且通过细归因标签的结果,可以明确知道是一个锁耗时的问题。进一步结合锁的详情信息进行下钻分析,通过当前子线程持有锁的聚合堆栈,发现是由于某个后台任务的执行时机变更提前了,从而与主线程某个任务产生了锁竞争冲突,导致主线程长时间的阻塞等待引起了 ANR。

37c0ab15350f719210c2007b3fd6a0a8.png

36071a4666d0b71bbd0bc2310c66469c.png9fb4e85bf70535ff680505564e3068d2.png

第二个案例是一个主线程高频消息问题,从定位到的问题函数可以发现其调用的非常高频,平均在一次 ANR 里会出现了上千次!通过进一步分析发现是由于某个业务的逻辑 Bug,导致在特定场景下会发送大量的重复消息,导致主线程消息队列堵塞引起的 ANR 劣化。

4fc30a206949862a558741c90cfa3a33.png0a962f493e5812a263fceeec36f49fff.png

第三个案例是一个子线程高频任务问题,通过定位到的后台任务可以发现其在多个子线程的出现次数都非常高频,并且累计的 CPU 耗时也比较高。进一步分析发现也是某个业务的 Bug 问题,在特定场景下会向子线程发送大量的重复任务,并且由于这些异步任务内部还会给主线程 Handler 发送或删除消息,所以除了会抢占 CPU 资源之外,还会间接导致主线程消息队列在遍历取消息时会发生高频的锁竞争耗时,两个因素叠加之下引起的 ANR。

2ef17bba30395c10dc4ca662601903b8.pngdbab7ff4447ddbf529fbb876d45d1745.png

最后总结下平台过去一年的阶段性成果,总共累计发现了有效问题 88 个,修复并优化其中 56 个,同时协助抖音 / 抖极的大盘 ANR 率分别下降了 -13.06%-8.70% ,并取得了不错的业务收益。

总结展望

抖音 ANR 自动归因平台未来的规划主要包括以下三个方面:

  • 归因体系:持续打磨监控能力和归因算法,包括探索完善 Java / Native 内存、绘制渲染以及 Native Trace 等方向上的精细化归因能力。

  • 防劣化体系:持续优化线上劣化归因和消费流程,提升线上自动归因准确率,以及劣化问题的消费解决效率。

  • 专家系统:沉淀专家经验,并尝试结合大模型等新技术,通过对技术特征和业务特征进行精细化聚合分析,进一步提升问题发现和解决效率。

加入我们

抖音基础技术客户端团队是一个深度追求极致的团队,我们专注于 Android / iOS 的体验、稳定性、架构、编译构建、工程效率等方向的深耕,保障超大规模团队的研发效率和数亿用户的使用体验。目前北京、上海、深圳等地都有人才需要,欢迎有志之士与我们共同建设亿级用户全球化 APP!你可以进入字节跳动招聘官网查询「抖音基础技术客户端」相关职位,也可邮件联系 chenjiawei.kisson@bytedance.com 咨询相关信息或者直接发送简历内推!

CVPR 2024 | Modular Blind Video Quality Assessment:模块化无参视频质量评估

作者 ByteDanceTech
2024年3月5日 18:30

无参视频质量评估 (Blind Video Quality Assessment,BVQA) 在评估和改善各种视频平台并服务用户的观看体验方面发挥着关键作用。当前基于深度学习的模型主要以下采样/局部块采样的形式分析视频内容,而忽视了实际空域分辨率和时域帧率对视频质量的影响,随着高分辨率和高帧率视频投稿逐渐普及,特别是跨分辨率/帧率视频转码档位画质评估场景中,这种影响变得更加不可忽视。在本文中,我们提出了一种模块化 BVQA 模型,以及一种训练该模型以提高其模块化性的方法。我们的模型包括基础质量预测模块、空域矫正模块和时域矫正模块,分别显式地响应视频质量的视觉内容和失真、空域分辨率和时域帧率变化情况。我们用提出的模块化BVQA模型在专业生成的内容和用户生成的内容视频数据库上进行了大量实验。实验表明,我们的质量模型实现了优于当前方法或相近的性能。此外,模块化的模型为分析现有视频质量数据库的空间和时间复杂性提供了机会。最后,我们的 BVQA 模型可以轻量高效地添加其他与质量相关的视频属性,例如动态范围和色域作为额外的矫正模块。

背景

多年来,研究人员从心理物理学和感知研究中收集了大量证据,证明更高的空域分辨率和更高的帧速率对视频主观画质有积极的影响。具体而言,感知质量取决于视频内容,特别是空域和时域复杂性。针对这些主观发现,早期的知识驱动的BVQA模型直接将空域分辨率和帧速率参数作为压缩视频质量预测的输入的一部分。尽管这种方法非常简单,但这些视频属性参数与内容和失真无关,因此它们与感知的视频质量不太相关。

基于卷积神经网络(CNN)的数据驱动的 BVQA 方法面临的计算问题十分明显。它们几乎没有尝试评估全尺寸视频,主要原因是计算复杂度很高,尤其是在处理高分辨率和帧速率的视频时&#

CVPR 2024 | CAMixerSR 动态注意力分配的超分辨率加速框架

作者 ByteDanceTech
2024年3月5日 18:30

背景

随着相关技术和应用的发展,比如超高清屏幕、虚拟现实(VR)等沉浸式体验的增加,用户对超高分辨率图像和视频的需求变得越来越强烈。在这些场景中,图像的质量和清晰度对于提供最佳的用户体验至关重要。超高分辨率不仅能提供更清晰、更真实的视觉效果,还能在一定程度上增强人们的互动和沉浸感,在一些VR场景中我们需要8K甚至16K的才可以满足需求。然而要生成或者处理这些超高分辨率的内容,对算力的要求也是与日增长,对相关算法提出了挑战。

超分辨率是一个经典的计算机底层视觉问题,该问题要解决的是通过低分辨率的图像输入,获得高分辨率的图像输出。目前该领域的算法模型主要是有CNN以及Transformer两大类别,考虑到实际的应用场景,超分的一个细分领域方向是算法的轻量化。在上述提到的超高分辨率的场景,超分算法的算力消耗问题变得尤为关键。基于此,本文提出了一种名为CAMixerSR的超分框架,可以做到内容感知,通过对Conv和Self-Attention的分配做到计算量的大幅优化。

cc20e1c95f3a5a1640678039694da9e4.jpeg

论文地址:http://arxiv.org/abs/2402.19289

方法

b3af023b408f355f7748df17d9b23544.jpeg
Table 1 不同难易程度内容的超分算力与效果对比

2024 AI & 前端:回首展望,光芒未至,破晓之前!

作者 ByteDanceTech
2024年2月2日 15:56

前言

回望 2023 年,ChatGPT 的突然爆火,让 AI 无疑成为最为值得注目的新兴领域之一,我们也一起见证了生成式 AI 的寒武纪大爆发。这一年来,国内外的生成式 AI 、大模型和相关产品以令人眼花缭乱的速度更新迭代,新的创业浪潮风起云涌。在这 AI 浪潮下,也让我们有了新的开发思考,探索着在各个环节中“前端 & AI”的应用场景。勇于探索的前端开发者们已经开始挥舞着 AI 的“魔法棒”,譬如代码生成、辅助 CR、低代码、测试、业务提效等各类开发环节都被赋予了新的活力和可能性。

在经历长时间与复杂项目“搏斗” 的你,是否对不断重复的工作感到厌倦 ?

当你面对上万行的代码的 Code Review 时,是否也曾让你感到力不从心 ?

业务遇到发展瓶颈,是否你也会把希望寄托于 AI 相关应用为其注入活力?

当面对种种类似问题时,你是否也曾幻想过有这样一个神器帮你胜任琐碎的语法检验、代码梳理、模块测试甚至是 整体搭建呢 ?

AI 技术的不断成熟,让这些梦想开始变得触手可及。AI 工具如同瑞士军刀般,其功能与用途变得越来越多样,它们正在帮助我们更有效地解决问题、提高效率,将想象中的可能变为现实。在本次大盘点中,我们会聚焦近一年 AI 相关技术在前端领域的趋势与未来展望,将一一回答上述疑问,帮助读者挖掘从业者的新机会,迎接 AI 的挑战与机遇。

如果你对 AI & 前端感兴趣,请一定要阅读下去,相信我们的精心准备一定不会让你失望~

“勇敢实践 AI 的人,将最先享受世界”

AI & 代码生成

一切故事开始来自于 ChatGPT 的横空出世,让世界看到了 AI 写代码的能力。大语言模型能写算法题、能写游戏、能写网站,不仅写的又快又好,还能直接运行,AI 的表现让人惊讶,人们开始感叹:“程序员的职业恐将不复存在!”

不过只是在 ChatGPT 的对话框里和 AI 交流代码技术还是不够方便,于是,已经默默存在了一段时间的 AI 代码生成辅助工具们乘势而起,成为了爆火产品。代表有 Github Copilot,Tabnine,CodeWhisperer,Cursor,Codeium,Safurai 等,其中有自研模型工具,也有套用模型服务主打产品交互和 prompt 的工具。

a659755f4b212973d2256100e7d452a1.png12338a5c831558db0110543358bbf9b0.png947a4e1f4f434e6f6f601c390cb89b2b.png01ce16998e79d4a6d2ddbfbdb6cc2ec3.png

AI 代码生成可以细分为很多场景,比如 自然语言生成代码单元测试代码重构代码续写代码解释代码评审问答助手。AI 代码生成工具的出现颠覆了程序员的工作方式,影视作品中对着机器人说两句话就完成编程的时代仿佛就在眼前了。

生态现状及使用体验

但我们抛开博人眼球的新闻,AI代码生成效果如何?程序员真的要失业了吗?

以下是 AI 生成的代码在日常开发过程中的一些亲身体会:

  • 对于单元测试:AI 考虑了非常全面的分支条件,但是运行就报错,因为一些基础依赖、mock 方法、API 的使用不够准确;

  • 对于代码生成:第一次生成效果最好,在后续的讨论修改中逐渐变得上文不接下文,直到所有代码变得不可用 ;

  • 对于代码补全: Copilot 虽然很惊艳 但是在企业的业务开发过程中由于存在内部代码泄露风险,并不能随心所欲的使用;

  • 对于代码助手: 向它寻求帮助时,它编造不存在工具和 API 推荐给我;

总的来说,AI 代码生成这件事经过了一年时间的考验,仿佛变成了食之无味弃之可惜的鸡肋骨,程序员战战兢兢使用 AI 生成着代码,却惊喜地发现这东西一时半会还替代不了自己。

退而求其次,让 AI 打打下手吧,AI 生成的代码,修改一下,可以使用吗?

在日常开发中,AI 已被业界许多工具证明可以提高开发效率。但如果代码量快速膨胀,人类还改的过来吗?如果随着 AI 生产力进一步提升,当代码检查成为效率的瓶颈时,大量未经人类把关的 AI 代码直接用在生产环境,人类真的敢用吗?

AI & 代码重构的探索

为什么做重构?

在 AI 代码生成的众多细分场景中,代码重构是相对来说是一次生成代码量最大的场景之一,同时又因为有原代码逻辑可对照,它生成的代码的评判标准又相对明确。于是代码重构场景成为了很好的观察对象。

在没有AI 工具的时代,程序员们也在持续做代码重构,工程在迭代过程中逐渐腐坏,需要不同程度的重构去保证迭代效率。同时重构的项目总不会是刚写没多久就开始重构的,所以重构总是伴随着大量的、难以追溯的业务逻辑。在这个过程中,陈年逻辑是不是理解到位?再次实现是否能保证没有 bug?这些问题都折磨着每个做重构项目的程序员。

AI 随着时代的洪流登场了。大家都知道代码重构是麻烦又危险的操作,如果 AI 能代劳,既保持了代码的可维护性,又没有程序员因为实施重构而失去头发。在实践中,我们的业务刚好有代码重构的需求:陈年的 Vue 代码希望重构成React版本,目标通过重构使团队技术栈保持统一,同时完成一些针对老项目的性能优化动作。

58ea5f4a4887c57dd6c866e02158d3e6.png

重构前

1bfb2da70c979f433f029deaa7b5d99e.png

AI 重构后

AI 的表现如何?

考虑到 AI 已经被鼓吹可以替代人类,那么人类程序员能做到的代码重构效果,AI 也一定能做到,所以一开始在提示词中希望它在重构的过程中优化文件结构,移除冗余代码,同时保持原始逻辑不变。这是一个非常“普通且自信”的想法:既要、又要、还要。

于是沟通遇到的第一个问题出现了,即使将复杂的任务拆分,人类也很难用自然语言明确告知模型,怎么样算优化文件结构,什么样的逻辑是冗余逻辑,毕竟人类程序员还要经过反复的需求讨论才能逐渐确认需求的全部细节。于是一步到位的提示词方案被否定。

接下来对 AI 的要求是:文件对文件,逻辑对逻辑进行代码转换,不必做过程中的优化和调整。这样任务就简单了许多,降低了沟通成本,指令的下达变得明确而高效。

随着项目的推进,又遇到了类似的问题:对于混杂在多任务中的简单小任务,AI 无法做到,无论是明确定义、增加示例、反复强调、给予鼓励。 比如特定第三方组件库的使用参数,JSX 条件渲染的实现等。这时问题可能出现在这些小任务合集触碰到了 AI 能力的上限,除非针对特定任务精调,去提升它在任务中的表现。

去评判 AI 的表现,总的来说还是沟通和能力边界的问题。对于业务中使用的某个具体模型,它的能力边界不完全确定,由于模型内部逻辑原理的不可解释性,提示词工程师(Prompt Engineer,PE)们只能不断猜测、试验,而在这其中投入多少精力的度,是不确定的。另外考虑到 AI 的幻觉问题,如何评判“胜任”,如何确保 AI 每一次任务都“胜任”也是一个挑战。所以这充满不确定的沟通调试过程,影响了 AI 代码生成的效果,较低的代码产物质量让它们暂时无法被信任地直接用在生产环境。

总结

和人之间的信任一样,AI 的信任是逐渐建立的,从简单的任务到复杂的任务如果都能逐一胜任,那么人类对 AI 的信任就会增加。对于 AI 出现的失误,并不是不被允许,只是这些失误需要是可解释的,并且有规律可规避的。 对 AI 更进一步的期待是,其内部的运行逻辑是可解释的,其行为是可控的。

我们期待这一天的到来,期待AI 成为人类更加可信任的工作伙伴。

AI & 测试

你可能会觉得“啊,既然 AI代码生成问题那么多,那么退一步让 AI 来测试吧”,但是,实际上 AI 测试当前也有着数不尽的问题。本小节将会详细拆解这些问题来逐一分析。

AI 与测试相关的问题

41a5c8c63148e075d3b30076b22ae448.png

这里面涉及到很多很虚无缥缈的东西,你可能还会说“废话少说,直接告诉我最佳实践”。然而事实是,别说最佳实践,以至于截止到 2023 年,前端 + 大语言模型 + 测试的实践几乎为 0。如果你在 Github 搜索,仓库不超过 10 个,合计的 Star 数加起来 5 个都不到。

1148d1608f262cedcb7ad8152b631a70.png0a4dff8bdfb5ceaff155cb131d60d1c3.png

为什么?为什么感觉这么“前途无量”的场景连 Github 仓库都没几个?

这个问题很难用一句来解释清楚,但是我向你保证,看完本节,你一定会得到答案。

单元测试

先说实施起来最简单的单元测试。单元测试非常的直白,就是使用一段纯 JS 代码来测试另一段纯 JS 代码,比较结果,一样就成功不一样的就失败。这么简单的任务,强如 GPT-4,不会做不到吧?

能做到,但是不能完全做到。

其实在 AI + 单元测试,在 LLM 出现之前,业界就已经有很多很多的实践了,而在 LLM 后,又崛起了一批 LLM + 单元测试的试验品。但是我打赌读者一个也没听说过对不对?没听说过就对了,因为哪怕有了 LLM 的助力,效果依旧一言难尽,根本拿不出手。

接受率

在这里引入一个接受率的概念。简单来说就是 AI 生成了 100 个用例,哪些是可以直接使用的。假如有 80 个可以不经修改的直接使用,那么接受率就是 80 / 100 = 80%

但是这里有个非常尴尬事情——并不是说接受率 > 0% 那么就可以用,因为你依旧需要花精力在那些“不可用的用例”。

假如接受率是 40%,AI 生成了 100 个用例,你其实并不知道这 100 个用例中哪 40 个是正确的,而为了使用这 40 个正确的用例,你需要:

  1. 想办法将可用的用例挑出来,并逐一验证

  2. 将剩下不可用的用例一个一个改好

  3. 重新整体检查,手动填补用例遗漏

假如接受率真的是 40%,这个过程将会是地狱,将远远超越你自己写 100 个用例的时间。

好了,那么读者不妨在此猜测一下,那么在 LLM 之前和之后,AI 单元测试的接受率是多少?

单元测试的进一步分析

通过接受率小节,我们了解的接受率必须高到一定阈值才有意义,否则就是单纯的把“写用例”变成了“调用例+改用例”。据我们的估计,接受率至少要达到 80% 以上,否则就完全是在帮倒忙。

而目前LLM+ 单元测试 普遍的接受率仅为 50% 不到,而如果是用 NLP 来代替 LLM,接受率将低到 30% 以下:

7bb73eb41739a63f225dfefd31e11845.png

Pass@10[%] 代表 AI 拿到错误信息再次迭代,尝试了 10 次

这就好比有个实习生,写了 10 个页面,其中:

  1. 2 个页面看上去正常,实际上交互有各种问题和巧妙的 Bug

  2. 4 个页面运行正常,但是和上面这种页面混在一起了,你需要测试后才能找到他们

  3. 2 个页面打开就白屏,你简单修改几次它还是白屏,你也不知道具体为啥,需要慢慢调

  4. 2 个页面忘做了

这种实习生你要吗?因此,AI & 单元测试依旧要求程序员拥有极强的单测完善能力,它不能代替你来直接完成单元测试。

其他测试

单元测试包含了太多的细节和边界情况,那么我们把视野拉远,比如 E2E 测试,是不是会好一点呢?正好前端单测用的也不多。

别说,你还真别说,当我们聚焦于 E2E 而不是单测后,各种问题确实是迎刃......增加了~!

当你想使用 AI + E2E 后,第一件事就是——怎么让 AI 和浏览器交互。目前在这方面,业界没有任何的实践,所有的胶水代码都必须由你来开发。

目前 GPT 确实有检索和理解网页的能力,但是仅限于内容的理解。无法做到对于前端页面交互的理解,比如在选择框来选择某项

在单元测试中,我们可以很容易的“报错后直接发给 AI,让 AI 多试几次”,但是这个交互流程在 E2E 情景下是没有相关实践的,其中伴随很多的开发工作。

同样的,AI 对于一个网页的理解是非常有限的,他并不知道一个用户交互是错误的还是正确的,你可能还需要传递给 AI 一些背景资料,比如产品文档、测试用例、交互图、代码。这部分的序列化工作也是一个全新的领域,所有的胶水代码都必须由你来开发。

如果你不是在一个大型 IT 公司,开发胶水代码成本是难以接受的。它可能要花费一年的时间,并且过程中没有任何阶段性产出,且部分功能随时可能被新一代的 LLM 所代替。

总结

通过单元测试 和 E2E 测试的分析,相信读者已经明白了的当前 AI & 测试面临的主要挑战:

  1. 如何序列化让 AI 理解现状上下文

  2. 过低接受率导致的额外人工介入的成本

但是我们相信这些这些问题都是有解的。可能是更加聪明的 LLM 提高了接受率,可能是规范的合作方合作流程,比如测试用例写的足够可读,并能直接导出为表格发送给 LLM,也可能是某个公司大笔一挥,决定花一年来开发一个 AI E2E 测试框架。

现在,软件测试的世界在等待一位英雄,他手持 LLM 的利剑,彻底斩除软件开发的质量痛点。而那位英雄,会不会是正在阅读本文的你呢?

AI & 辅助CR

除了开发与测试,换个角度看问题,如果让 AI 来当我们的CR助手呢?

代码审查(CodeReview)是软件开发过程中的重要环节,Code Review 是指开发人员对彼此的代码进行审查和评估的过程。通过 Code Review,团队成员可以相互检查代码的正确性、可读性、可维护性以及是否符合编码规范等方面的问题。可以帮助开发者发现并修复代码中的错误和缺陷,提高代码质量 ,减少技术债务。

1470d1ec68fcfb1ebcdc81de17dcf877.png

传统的代码审查由人工完成,团队在 Code Review 上花费了大量时间,且容易出现疏漏。随着大型语言模型(LLM)的发展,LLM 在代码审查领域得到了越来越多的关注。LLM 可以通过对代码的语义理解,快速识别代码中的错误和缺陷,从而提高代码审查的效率和准确性,以下是 AI Code Review 的几个好处:

  • 自动化和效率:与传统的人工 Code Review 相比,AI Code Review 具有更高的自动化程度,可以大大提高审查的效率。

  • 捕捉更多问题:AI Code Review 工具可以检测出更多的代码问题,包括潜在的性能问题、代码重复、安全漏洞等,帮助开发人员更全面地改进代码。

  • 减少人为偏差:由于人为因素,传统的 Code Review 可能存在一些主观偏差。而 AI Code Review 可以基于大量的事实和规则进行评估,减少主观因素的影响,提供更客观的评价结果

  • 持续集成与部署:AI Code Review 工具可以与CI/CD相结合,实现自动的代码审查和反馈,帮助开发团队更好地管理代码质量。

AI 在 Code Review 的场景

LLM 在代码审查领域的应用主要有以下几个方面:

  • 补充CR信息:根据 CR 的 Diff 内容,生成 CR 的表述、标题、CR 总结 ,方便人工短时间了解代码变更关键信息。

  • 基本代码问题:变量名、函数名的语义化、可读性、可维护性、可扩展性等。通过分析代码的结构、语法和语义特征,工具可以自动判断代码的质量等级,为开发团队提供改进建议。

  • 代码性能问题:AI 代码评审工具可以分析代码的执行效率,发现潜在的性能瓶颈和优化点。例如,工具可以识别循环嵌套、冗余计算等问题,并提供优化建议,提高代码的执行效率。

  • 逻辑问题:比如边界问题、条件判断 、循环控制的合理性。

  • 代码安全问题:AI 代码评审工具可以用于识别潜在的安全漏洞和风险。例如,工具可以分析代码中的敏感数据处理、输入验证、权限控制、SQL 注入,XSS 等方面,发现可能存在的安全问题,并提供修复建议。

当前应用

当前业界有不少 AI Code Review 的能力应用,除了大家熟知的 Github Copilot ,开发者对其进行提问可以帮助完成 Code Review 的动作,我们再看一下还有哪些成熟的应用。

ChatGPT-CodeReview

通过调用 Chagpt 的 Open API 以 GIthub Action 的形式集成到 Github 里面,机器人会自动进行代码审查,审查信息将显示在 pr timeline / file changes 部分。在git push更新 PR 之后,CR bot 将重新审查更改的文件:

cde2224e20fc174ee883576027ef4fad.png
CodiumAI PR-Agent

CodiumAI PR-Agent:一款基于 Chatgpt 的开源工具,可帮助高效审查和处理拉取请求。它自动分析 pull request,并提供多种类型的命令:自动生成 PR 描述、可调整的反馈、问题解答、代码建议、更新 CHANGELOG.md 文件、查找相似问题、自动添加文档、生成自定义标签和自动分析 PR 变更。

49125538b4463dbc16ba00d6000b7738.png

4fdb689f9b807e7787eaf8faeed85c9f.gif

总结

如果你决定开始使用 AI 能力作为团队 CR 助手,请注意:

  • 由于 LLM 本身的 Token 上下文限制,当MR超出最大的 Context 上下文限制时,会造成信息丢失导致 Review 结果不全面。

  • 由于 LLM 回答的随机性,同一份代码多次 Review 可能有不同的结果,以及 Review 的结论会偏啰嗦 ,不容易抓出重点问题,所以在提示词里面可以多强调关注重点问题,比如 Prompt 加入 “作为一名代码 review 专家、对 Gitlab 的 MR 进行代码 review. 只关注代码性能、代码安全问题、严重的逻辑错误”

  • 如果要针对团队业务场景或基于团队研发规范进行 AI Review,可以将团队的业务背景信息规范等作为知识库对模型进行微调 ,对于担心代码泄漏的场景,还可以对开源的LLM进行私有化部署作为企业内的方案。

一言以蔽之,当前只是辅助人工 Review 帮助快速发现一些问题,减轻人工的简单重复的劳动,但无法完全取代人,最终还得由人作判断,但我们可以用节省下来的人力关注到更高优的事情上面。

AI & 低代码

前两年火爆的低代码概念,似乎和 AI 有不错的“缘分”,两者结合往往能达到一加一大于二的效果。

什么是低代码

低代码开发平台(英语:Low-Code Development Platform,简称 LCDP),是一种方便产生应用程序的平台软件,软件会让用户以图形化接口以及配置编写程序,而不是用传统的程序设计作法。此平台是针对某些种类的应用而设计开发的,例如数据库、业务过程、以及用户界面(例如网页应用程序)。这类平台一般可以产生完整且可运作的应用代码,但在一些特殊的情形下仍需要编写程序。

低代码概念最早出现在 1982 年《无程序员的应用程序开发》一书中,美国低代码产品的研究和实践过程较长,积累了较丰富的经验。中国最早出现低代码平台大概是在 2014 年,产品的形态从最初的数据库交付,到数据集结构搭建逐渐演变抽象出各种流程引擎、可视化界面等产品能力,应用能力也从 BPM 延伸到 ERP、CRM 等大型复杂应用,整个行业经历了 2017-2020 年的快速发展阶段,市场增速开始放缓,但相对于其他企业服务赛道仍处于高增长阶段,预计 2025 年中国低代码行业市场规模将达到 118.4 亿。

2bc480dd2ab86b76e95447481f28492a.png

存在问题

但低代码自出现以来,一直还是饱受质疑,核心的点在于通过低代码的可视化搭建大大降低了建设成本,且使用群体从开发人员逐步向业务人员渗透,但低代码本身是存在一定的的上手门槛,包括各类物料的用法、如何实现与后端接口通信、物料之间如何配置交互等等,这使得业务人员无法快速上手完成页面搭建,而开发人员需要学习特定搭建规则和限制,可能不如实际开发来的快。

在不同类型的低代码产品的相关的限制又会有所差异,从应用场景上可以划分通用型和垂直型

782921e72714c4a11965f3ffae63554d.png
  • 对于垂直型低代码产品,深耕特定行业或特定的场景,提供当前垂类更具有专业性的解决方案和搭建能力,在具体的搭建场景中专业性贴合更深,会存在复杂搭建场景,如公式、模型、数据等,门槛更高,搭建依赖的前置输入也要更多。

  • 而对于通用型,由于覆盖各行业各场景,沉淀了大量的行业模版和解决方案,虽然搭建门槛相对垂直型要更低,但内容覆盖面更广,大量的物料和模版不仅造成了用户选择压力,也提升了搭建的学习负担。

低代码产品本身的易用性有待进一步提升,包括底层技术框架和可视化界面的呈现,另外低代码相关的培训机制有待完善,培训不足导致用户对于低代码产品的接纳度低,用户本身对于繁重的产品使用学习的意愿不足,也会大大阻碍低代码产品的推广和应用。

如何赋能

随着 GPT 为代表大模型爆火,AIGC 也开始在各个行业渗透,借助大语言模型的能力精准识别用户需求,进而彻底改变当前低代码搭建工作流。通过自然语言交互替代原有选择、编辑、拖拽等操作,从而屏蔽了低代码平台本身的使用规则,这大大降低用户的学习上手成本,进而提升低代码的应用场景和搭建效率。未来 AI 能力将会成为低代码平台的标配,成为未来重要的发展方向。

当前阶段,各个低代码平台都在摩拳擦掌,跃跃欲试,很多平台已经逐步上线自己的 AI 融合能力,一些典型场景包括:

  • 智能创建页面

通过自然语言完成页面创建,主要应用在通用型低代码平台中,来避免大量物料和模版的学习选择成本,钉钉宜搭、织信、易鲸云都上线了这部分能力,比如在宜搭平台上线了通过简要描述门户的基本信息,系统将智能化地按需生成门户的能力。

03cca81fe28981d04554c1c387f7ec18.png

核心逻辑在于通过自然语言精准识别用户需求,匹配合适的物料、模版,完成内容生成,进一步组装形成 DSL 进行渲染。

  • 局部场景智能生成

聚焦低代码平台搭建中的局部场景,如公式编辑器、流程编排、智能表格、辅助编程等,通过自然语言来替换原有搭建方式,进而降低整体搭建复杂度,这么做的优势在于将自然语言聚焦特定局部场景的优势,也可以使得需求识别更加精准,下图是易鲸云通过自然语言自动化完成流程编辑的效果。

易鲸云AI生成流程演示

  • 智能问答

通过将低代码平台中沉淀的大量的文本、视频材料喂给大语言模型,来提升用户问题解决效率,节省运营成本。当然不仅仅是低代码,智能文档这一场景适用各类平台中。

总结

当然 AIGC 与低代码的融合还处于早期阶段,当前应用范围也主要还是在一些容错率较高的场景,通过大语言模型可能不能完全理解用户的真实需求,这需要庞大的上下文做支撑,虽然融合方向仍然有诸多问题亟需解决,但未来低代码平台必将会全面拥抱 AIGC,这也意味着我们需要重新去思考低代码平台的交互界面的设计,充分利用自然语言交互特点,提升用户体验。

另外数据安全风险也是在商业化产品中必须要考量的一点,因此多数企业都在尝试走自研大模型的路子,然而这并不是一件容易的事。它需要大量高质量数据的收集、清洗和标注,以及昂贵的计算资源来支持模型训练。这也意味着未来 AI 加持下的低代码平台的竞争成本会更高,会更加聚焦在有雄厚实力的头部企业。

除了 AIGC +低代码融合之外,还有一方观点认为既然 AIGC 能力越来越强,是否会完全替代低代码,直接生成原始应用?我们认为至少在可以预见很长范围内低代码不会被完全替换掉,就像上面几部分提到的,大模型通过语义直接生成代码并不能保证其完全的准确性,且代码可用性需要大量的人工校正。而低代码融合 AIGC 可以通过语义生成模型,加速需求分析工作进程,可以让 AI 更快的进入业务。

AI & 业务

说到业务,下面就用各大互联网企业中最为常见的审核业务作为例子来进行探讨:

思考

对于审核业务与 AI ,可能大家的第一反应就是,AI 是否能完全替代审核员对内容进行审核呢?

我们认为未来是可以的。 内容对于人类来说,无非也是来自声光电的刺激。而当这些声光电的刺激能被模型接受、理解、推理后,也就做到了和人类一样的事。而且,相比较人,它更容易管理,标准更统一,成本也会更低。

但现阶段,就像推测会被替代的所有工作类型一样,还有太多的问题要解决了。AI 会作为不同的工具形态,慢慢融入人们的工作;但距离完全替代,还有不小的距离。

所以业务上,我们也在思考,它能不能也辅助审核员,取得更好的审核质量和效率。也尝试了一些 demo 项目,想知道 AI 能做到什么地步,这样有助于我们探索业务场景上能做到的极限。

但即使我们只是想让他作为一个额外的信息来源,来辅助审核员来的判断,也存在非常多现阶段需要解决的问题:

  • 成本问题:无论是使用外部的 LLM ,支付接口调用的费用,还是自己训练并使用内部的模型,基于现在每日发布内容的巨大量级,预估这都是一笔巨大的开支。在目前 AI 辅助审核的前进方向不明确的情况下,成本能否打平收益,还是一个需要进一步深究的问题。

  • 合规问题:只能使用用户已经公开在平台发表可见的内容,公司内的数据,例如 Policy 、代码、培训材料等,都是不允许输入给外部大模型的。

  • 人为限制:外部给用户使用的大模型需要满足世俗的道德和法律体系,因此模型训练和输出过程中做了对齐,但审核业务就是为了能让大模型正确分析以及反馈有害内容中的风险细节, LLM 往往因为对齐,而拒绝回答,给审核场景的探索带来了不少阻碍。

  • 标准问题:各家大模型都有自己的 Policy ,特定业务中的 Policy 因为合规问题,无法输入给 LLM ,那 LLM 就无法给出符合我们审核标准的答案。

  • 多模态问题:面对一个以音视频为主的内容社区,很多的风险是在画面、音频中的,甚至有的还要靠连续的画面或者多个模态的信息结合起来判断,这些都大大限制了目前以文字输入为主的大模型的在内容社区审核中的使用场景。

其中,合规和标准、人为限制可以通过使用私有的开源大模型解决,但也需要持续进行效果的调优。

但依然也可以看到,在过去的2023年,大模型业界,很多问题被研究,很多的方案被提出,也有很多的场景逐步落地,曙光变成了朝霞,荒野也被勇敢探索的人踏出了小路。

当然,除了审核本身,对于其他平台业务来说,什么是未来平台产品的范式:AI 在其中解决什么问题,扮演着什么样的角色。随着业界方案成熟和持续的思考,也可以有计划地集成到我们的平台里。

可能性

23年,AI 技术上也在不断的发展,这给了业务更多思考空间,如何合理的在业务中使用这些技术。

在模型和基建上,能看到下面这些现象:

8d3e6c6c1e751aa71c317eb09ef84fc8.png

而在应用层,我们可以参考一些现在实用程度较高的方案,思考它们如何在业务中结合:

097b27e9895d0eabf567bd8e3135a8f6.png

d406c1de755144fdd37de938ee0c15fc.png

当前其实可用的开源方案有不少,例如:

  • https://github.com/logspace-ai/langflow

  • https://github.com/langchain-ai/langchain

  • https://github.com/lobehub/lobe-chat

  • https://github.com/e2b-dev/E2B

最后的最后

随着 2023 年的日历缓缓翻至尾页,我们站在新的起点上,回首这一年 AI 技术尤其是生成式 AI 的巨大飞跃,ChatGPT 的崛起不只标志着技术的飞速进步,更是我们共同见证的历史性时刻。在过去的一年里,AI 的变革触及了从代码生成到业务优化的各个方面,前端开发者们大胆拥抱这场变革,将 AI 的巨大潜力转化为实际的生产力。

然而,正如我们所经历的,AI 并非全能。它在代码生成、测试和重构等领域虽有所进步,却也显露出一些局限。不尽完美的接受率、结果的随机性,以及对复杂任务处理的不足,都提醒着我们:AI 当前只是辅助工具,而非全能替代者。它能帮助我们,但最终的创新和决策仍需人类来掌握。

面对 AI 在代码审查和低代码开发等领域的实践应用,我们看到了潜力与挑战的共存。

AI 技术的发展,不仅仅是技术层面的进步,更是对我们工作方式的深刻挑战和重塑。我们热切期待在新的一年中 AI 技术带来的更多创新与变革,同时也清楚地认识到,在与 AI 携手共进的道路上,还有许多未知和挑战等待我们去克服和探索。

展望 2024 年,我们满怀期待地看向在 AI 的助力下,不仅在技术上实现更多突破,还能在业务层面实现新的飞跃。

带着这一年的收获和经验,让我们共同迈向全新的一年。我们坚信,凭借对技术的持续探索和对业务的深入理解,我们能够在 AI 的支持下,开拓更宽广的业务领域,创造更加卓越的未来。

迎着 2024 年,我们满载信心地向前行!

一则小广告

欢迎加入我们!「Data-TnS-FE」部门为国际化产品的内容安全业务提供各类能力,其目标是从用户/业务视角出发,结合技术赋能于业务,打造更多普适便捷的业务中台、基础架构、解决方案等,为团队所有业务的运作提供稳定、高效、易用的能力支撑。

abe3d1b01a9e9ce7dfe4dfb56cdc84d6.png

a065a34ffa5ee7ee25581b7189e441c1.png

fbfdc6970aba5dca33d65bb6574355d8.png

参考链接

  • https://mp.weixin.qq.com/s/A0t4I6EqZZAVXibxhXZdAA

  • https://github.com/search?q=LLM+test+frontend&type=repositories&s=&o=desc

  • https://github.com/search?q=GPT+test+frontend&type=repositories&s=&o=desc

  • https://github.com/Codium-ai/pr-agent

  • https://www.fiverr.com/resources/guides/programming-tech/code-review-using-ai

  • https://github.com/anc95/ChatGPT-CodeReview

  • https://bytedance.larkoffice.com/wiki/RgFFwmbrtirYqCkTqVXcECf2nNg

  • https://bytedance.larkoffice.com/docx/CALQdFxsAoTpOSxWdpmcrWXgnqe

Kotlin 云端差分缓存技术

作者 ByteDanceTech
2024年1月30日 10:30

本文由字节跳动 Buildinfra 团队出品。

在我们的工程上线 Monorepo 全源码后,Kotlin 编译成了整个编译中最耗时的步骤,全源码过程中大量的 BuildCache Miss 导致我们的编译数据落后原来多仓二进制时代很多,且业界没有相关的解决方案。本篇文章我们来具体阐述下 BuildInfra 团队自研的解决方案 - Kotlin 云端差分方案的原理和技术实现。

一、Monorepo 中的噩梦

在 2022-2023 年,我们的头部业务开始慢慢地从原来的多仓二进制模式,迁移到全新 Monorepo 方案。

在多仓二进制时代,由于 Maven 的加持,大部分时候我们的都不需要直接编译代码,而是复用 Maven 的『缓存』。

在工程进入 Monorepo 时代之后,我们花了大量的精力建设 BuildCache,来试图抹平 Monorepo 与二进制的构建速度差异,但遗憾的是,尽管我们已经做了很多的努力,但由于 Gradle 脆弱的 BuildCache 设计,导致很多时候改动一行代码就会全局 miss,不得不重新编译一遍所有的源码。这很不环保(

  • 本地编译:首次编译/更新代码/切分支等场景,非常容易触发整个工程的重新编译,动辄 20min+。

  • CI 编译:从原来多仓二进制时代的 25min 劣化到了 40min+

并且,90 分位的构建劣化更是飙升到不可控的地步,它直接决定了本地开发、CI 合码的体验。

在可预见的将来,随着代码的快速增长、更多子仓的合入,仅靠 BuildCache 已经完全无法支撑大型 Monorepo 工程的开发。

二、分析

如果想优化 Kotlin 的全量编译,如果从计算机通用优化角度来看,不外乎以下几种方案:

  • 更高效的实现

  • 并发

  • 缓存

更高效的实现:语言编译是一个庞大且复杂的系统,涉及前后端编译,目前针对前后端编译流程进行优化实施起来难度较大,是一个长期且艰巨的任务。但它并不和其他优化方案有冲突。

并发:如果是相互之间没有复杂依赖关系的多 Module,那么 Gradle 已经保证了模块之间的并行度;只要你的项目依赖足够 flat,那么就可以充分利用计算机并发资源,在 CI 场景会获得非常大的提升,所以从工程角度可以进行依赖的优化来提升并行度。但这也不是一个通用的方案,非常依赖于业务方的改造。

缓存:Kotlin 目前使用的是 Gradle 的缓存,众所周知,Gradle 为了保证正确性,有着一套极其严格的 Cache key 生成策略,很多「可能」不影响 kotlin 编译正确性的变化(比如编译插件的 classpath)却会让整个 BuildCache 崩盘,全部 Task 全部 Cache Miss。

在 kotlin 中,不仅是 classpath 这些变动会导致 cache miss,kotlin 的 sourceset(即源码)也会作为 cache key 的计算输入。这意味着,只要代码里有一个字符不一样,就会导致 cache miss,整个模块就需要重新编译,那么这块是否有优化空间呢?

综合来看,从缓存角度入手,可以以比较低成本来实现较大的收益。

为此,我们提出了一套创新性的方案:

通过云端模糊匹配缓存来将全量编译转化为增量编译,来减少全量编译的耗时。

三、核心原理

我们通过改造 Kotlin Gradle Plugin 入手,当 Kotlin Task 由于各种原因不能命中 Build Cache 时,就会 fallback 到我们的『Kotlin 差分编译系统』。通过云端模糊匹配缓存来将全量编译转化为增量编译,来将本来动辄 10min+ 的全量编译转化成 10s 内的增量编译。

云端缓存模糊匹配

常规编译构建缓存方案如 Gradle Build Cache 采用的是 kv 一对一匹配。

对于 Kotlin 来说,即通过对源码文件、依赖 Jar 包,编译参数等信息算出一个缓存 key 之后,唯一匹配出一个缓存。当匹配到缓存后,缓存包的内容就是 Kotlin 的最终产物,然后就可以跳过 Kotlin Task,直接进入下一步。

由于精确匹配需要达成的条件较多,比如只要修改了一个 .k

字节跳动基础架构SRE-Copilot获得2023 CCF国际AIOps挑战赛冠军

作者 ByteDanceTech
2024年1月5日 10:31

近日,2023 CCF国际AIOps挑战赛决赛暨“大模型时代的AIOps”研讨会在北京成功举办,活动吸引了来自互联网、运营商、科研院所、高校、软硬件厂商等领域多名专家学者参与,为智能运维的前沿学术研究、落地生产实践打开了新思路。决赛中,从初赛两百多支队伍中脱颖而出的十支入围队伍分别展示了各自的方案,并进行了现场答辩,评审专家从选题方向、创新性、实用性、完整度和实验复现结果等多角度进行了综合评定,最终,来自字节跳动基础架构-SRE 团队的 SRE-Copilot战队,以SRE-Copilot:基于 LLM 的多场景智能运维”,获得本届大赛冠军

83ed35f441d587eb747a2e7fb2280c57.jpeg

CCF国际AIOps挑战赛由中国计算机学会(CCF)、清华大学和南开大学联合发起,旨在借助社区力量,运用人工智能算法解决各类运维难题。自2017年底首次举办,迄今为止已经成功举办六届,吸引了大量AIOps从业者和关注者,赛事规模和影响力不断扩大,是智能运维领域极具影响力的专业赛事。本届CCF国际AIOps挑战赛共有来自265支队伍的677名选手报名参赛,决赛现场有超300人线下参会,同时有近5万人次观看线上直播。

CCF国际AIOps挑战赛自创办以来,赛题覆盖了不同的运维场景、运维数据、故障来源、应用类型。本届大赛赛题全新升级,首次采用开放式赛题,基于建行云龙舟运维平台的稳定性工具和多维监控系统,由参赛选手自主确定需要解决的运维问题,并对主办方提供的交易、日志、调用链、监控指标等一种或多种模态数据进行故障检测、定位、根因分析、影响分析等。本次赛题不再局限于单个运维场景,而是模拟了企业运维团队面临的系统架构复杂、数据规模庞大、数据种类繁多等一系列需要解决的运维挑战,使AIOps生态里的所有产、学、研、用各方,都可以基于同样的数据,展开竞赛,并鼓励参赛选手探索大语言模型(LLM)在智能运维领域的应用。

e94c7e22f953c956281a239aba09f6cd.png

为拥抱这一变化,SRE-Copilot战队提出

字节跳动百万级Metrics Agent性能优化的探索与实践

作者 ByteDanceTech
2024年1月3日 11:43

背景

ff1542bf4b3f0c7f905f082cb6d19f2d.png

metricserver2 (以下简称Agent)是与字节内场时序数据库 ByteTSD 配套使用的用户指标打点 Agent,用于在物理机粒度收集用户的指标打点数据,在字节内几乎所有的服务节点上均有部署集成,装机量达到百万以上。此外Agent需要负责打点数据的解析、聚合、压缩、协议转换和发送,属于CPU和Mem密集的服务。两者结合,使得Agent在监控全链路服务成本中占比达到70%以上,对Agent进行性能优化,降本增效是刻不容缓的命题。本文将介绍我们在Agent性能优化上的探索和实践。

基本架构

d5379c1599552ec947812d9789aceed2.jpeg
  • Receiver 监听socket、UDP端口,接收SDK发出的metrics数据

  • Msg-Parser对数据包进行反序列化,丢掉不符合规范的打点,然后将数据点暂存在Storage中

  • Storage支持7种类型的metircs指标存储

  • Flusher在每个发送周期的整时刻,触发任务获取Storage的快照,并对其存储的metrics数据进行聚合,将聚合后的数据按照发送要求进行编码

  • Compress对编码的数据包进行压缩

  • Sender支持HTTP和TCP方式,将数据发给后端服务

我们将按照数据接收、数据处理、数据发送三个部分来分析Agent优化的性能热点。

数据接收

Case 1

Agent与用户SDK通信的时候,使用 msgpack 对数据进行序列化。它的数据格式与json类似,但在存储时对数字、多字节字符、数组等都做了优化,减少了无用的字符,下图是其与json的简单对比:

fbc81aef87e9e1f16a4729ce20b6320a.png

Agent在获得数据后,需要通过msgpack.unpack进行反序列化,然后把数据重新组织成 std::vector。这个过程中,有两步复制的操作,分别是:从上游数据反序列为 msgpack::object 和 msgpack::object 转换 std::vector。

{ // Process Function
    msgpack::unpacked msg;
    msgpack::unpack(&msg, buffer.data(), buffer.size());
    msgpack::object obj = msg.get();
    
    std::vector<std::vector<std::string>> vecs;
    if (obj.via.array.ptr[0].type == 5) {
        std::vector<std::string> vec;
        obj.convert(&vec);
        vecs.push_back(vec);
    } else if (obj.via.array.ptr[0].type == 6) {
        obj.convert(&vecs);
    } else {
        ++fail_count;
        return result;
    }
    
    // Some more process steps
}

但实际上,整个数据的处理都在处理函数中。这意味着传过来的数据在整个处理周期都是存在的,因此这两步复制可以视为额外的开销。

msgpack协议在对数据进行反序列化解析的时候,其内存管理的基本逻辑如下:

71d01434aeabf8c40f55d006a43e001a.png

为了避免复制 string,bin 这些类型的数据,msgpack 支持在解析的时候传入一个函数,用来决定这些类型的数据是否需要进行复制:

2a5407eba73c2db394fb29cff59591af.png

因此在第二步,对 msgpack::object 进行转换的时候,我们不再转换为 string,而是使用 string_view,可以优化掉 string 的复制和内存分配等:

// Define string_view convert struct.
template <>
struct msgpack::adaptor::convert<std::string_view> {
    msgpack::object const& operator()(msgpack::object const& o, std::string_view& v) const {
        switch (o.type) {
        case msgpack::type::BIN:
            v = std::string_view(o.via.bin.ptr, o.via.bin.size);
            break;
        case msgpack::type::STR:
            v = std::string_view(o.via.str.ptr, o.via.str.size);
            break;
        default:
            throw msgpack::type_error();
            break;
        }
        return o;
    }
};

static bool string_reference(msgpack::type::object_type type, std::size_t, void*) {
    return type == msgpack::type::STR;
}

{ 
    msgpack::unpacked msg;
    msgpack::unpack(msg, buffer.data(), buffer.size(), string_reference);
    msgpack::object obj = msg.get();

    std::vector<std::vector<std::string_view>> vecs;
    if (obj.via.array.ptr[0].type == msgpack::type::STR) {
        std::vector<std::string_view> vec;
        obj.convert(&vec);
        vecs.push_back(vec);
    } else if (obj.via.array.ptr[0].type == msgpack::type::ARRAY) {
        obj.convert(&vecs);
    } else {
        ++fail_count;
        return result;
    }
}

经过验证可以看到:零拷贝的时候,转换完的所有数据的内存地址都在原来的的 buffer 的内存地址范围内。而使用 string 进行复制的时候,内存地址和 buffer 的内存地址明显不同。

827d4cd5c210318ba6594862cd0e5411.png

Case 2

8893a1d4ffe4d5fc37d49945f1a40405.png

Agent在接收端通过系统调用完成数据接收后,会立刻将数据投递到异步的线程池内,进行数据的解析工作,以达到不阻塞接收端的效果。但我们在对线上数据进行分析时发现,用户产生的数据包大小是不固定的,并且存在大量的小包(比如一条打点数据)。这会导致异步线程池内的任务数量较多,平均每个任务的体积较小,线程池需要频繁的从队列获取新的任务,带来了处理性能的下降。

因此我们充分理解了msgpack的协议格式(https://github.com/msgpack/msgpack/blob/master/spec.md)后,在接收端将多个数据小包(一条打点数据)聚合成一个数据大包(多条打点数据),进行一次任务提交,提高了接收端的处理性能,降低了线程切换的开销。

static inline bool tryMerge(std::string& merge_buf, std::string& recv_buf, int msg_size, int merge_buf_cap) {
    uint16_t big_endian_len, host_endian_len, cur_msg_len;

    memcpy(&big_endian_len, (void*)&merge_buf[1], sizeof(big_endian_len));
    host_endian_len = ntohs(big_endian_len);
    cur_msg_len = recv_buf[0] & 0x0f;

    if((recv_buf[0] & 0xf0) != 0x90 || merge_buf.size() + msg_size > merge_buf_cap || host_endian_len + cur_msg_len > 0xffff) {
        // upper 4 digits are not 1001
        // or merge_buf cannot hold anymore data
        // or array 16 in the merge_buf cannot hold more objs (although not possible right now, but have to check)
        return false;
    }

    // start merging
    host_endian_len += cur_msg_len;
    merge_buf.append(++recv_buf.begin(), recv_buf.begin() + msg_size);

    // update elem cnt in array 16
    big_endian_len = htons(host_endian_len);

    memcpy((void*)&merge_buf[1], &big_endian_len, sizeof(big_endian_len));
    return true;
}

{ // receiver function 
    // array 16 with 0 member
    std::string merge_buf({(char)0xdc, (char)0x00, (char)0x00});
    
    for(int i = 0 ; i < 1024; ++i) {
        int r = recv(fd, const_cast<char *>(tmp_buffer_.data()), tmp_buffer_size_, 0);
        if (r > 0) {
            if(!tryMerge(merge_buf, tmp_buffer_, r, tmp_buffer_size_)) {
                // Submit Task
            }
        // Some other logics
    }
}

从关键的系统指标的角度看,在merge逻辑有收益时(接收QPS = 48k,75k,120k,150k),小包合并逻辑大大减少了上下文切换,执行指令数,icache/dcache miss,并且增加了IPC(instructions per cycle)见下表:

d0f82bbe22658ea9f37d61fc58a32234.jpeg

同时通过对前后火焰图的对比分析看,在合并数据包之后,原本用于调度线程池的cpu资源更多的消耗在了收包上,也解释了小包合并之后context switch减少的情况。

Case 3

用户在打点指标中的Tags,是拼接成字符串进行纯文本传递的,这样设计的主要目的是简化SDK和Agent之间的数据格式。但这种方式就要求Agent必须对字符串进行解析,将文本化的Tags反序列化出来,又由于在接收端收到的用户打点QPS很高,这也成为了Agent的性能热点。

早期Agent在实现这个解析操作时,采用了遍历字符串的方式,将字符串按|=分割成 key-value 对。在其成为性能瓶颈后,我们发现它很适合使用SIMD进行加速处理。

原版

inline bool is_tag_split(const char &c) {
    return c == '|' || c == ' ';
}

inline bool is_kv_split(const char &c) {
    return c == '=';
}

bool find_str_with_delimiters(const char *str, const std::size_t &cur_idx, const std::size_t &end_idx,
    const Process_State &state, std::size_t *str_end) {
    if (cur_idx >= end_idx) {
        return false;
    }
    std::size_t index = cur_idx;
    while (index < end_idx) {
        if (state == TAG_KEY) {
            if (is_kv_split(str[index])) {
                *str_end = index;
                return true;
            } else if (is_tag_split(str[index])) {
                return false;
            }
        } else {
            if (is_tag_split(str[index])) {
                *str_end = index;
                return true;
            }
        }
        index++;
    }
    if (state == TAG_VALUE) {
        *str_end = index;
        return true;
    }
    return false;
}

SIMD

#if defined(__SSE__)
static std::size_t find_key_simd(const char *str, std::size_t end, std::size_t idx) {
    if (idx >= end) { return 0; }
    
    for (; idx + 16 <= end; idx += 16) {
        __m128i v = _mm_loadu_si128((const __m128i*)(str + idx));
        __m128i is_tag = _mm_or_si128(_mm_cmpeq_epi8(v, _mm_set1_epi8('|')),
                                     _mm_cmpeq_epi8(v, _mm_set1_epi8(' ')));
        __m128i is_kv = _mm_cmpeq_epi8(v, _mm_set1_epi8('='));

        int tag_bits = _mm_movemask_epi8(is_tag);
        int kv_bits = _mm_movemask_epi8(is_kv);
        // has '|' or ' ' first
        bool has_tag_first = ((kv_bits - 1) & tag_bits) != 0;
        if (has_tag_first) { return 0; }
        if (kv_bits) { // found '='
            return idx + __builtin_ctz(kv_bits);
        }
    }

    for (; idx < end; ++idx) {
        if (is_kv_split(str[idx])) { return idx; } 
        else if (is_tag_split(str[idx])) { return 0; }
    }

    return 0;
}

static std::size_t find_value_simd(const char *str, std::size_t end, std::size_t idx) {
    if (idx >= end) { return 0; }
    
    for (; idx + 16 <= end; idx += 16) {
        __m128i v = _mm_loadu_si128((const __m128i*)(str + idx));
        __m128i is_tag = _mm_or_si128(_mm_cmpeq_epi8(v, _mm_set1_epi8('|')),
                                     _mm_cmpeq_epi8(v, _mm_set1_epi8(' ')));
        int tag_bits = _mm_movemask_epi8(is_tag);
        if (tag_bits) {
            return idx + __builtin_ctz(tag_bits);
        }
    }

    for (; idx < end; ++idx) {
        if (is_tag_split(str[idx])) { return idx; }
    }

    return idx;
}

构建的测试用例格式为 。text 则是测试例子里的 str_size,用来测试不同 str_size 下使用 simd 的收益。可以看到,在 str_size 较大时,simd 性能明显高于标量的实现。

str_size simd scalar
1 109 140
2 145 158
4 147 198
8 143 283
16 155 459
32 168 809
64 220 1589
128 289 3216
256 477 6297
512 883 12494
1024 1687 24410

数据处理

Case 1

Agent在数据聚合过程中,需要一个map来存储一个指标的所有序列,用于对一段时间内的打点值进行聚合计算,得到一个固定间隔的观测值。这个map的key是指标的tags,map的value是指标的值。我们通过采集火焰图发现,这个map的查找操作存在一定程度的热点。

6b562ca32e9d804422ef4d625b6671a7.png

下面是 _M_find_before_node 的实现:

1fe999c303fdcca5008006c167a985a4.png

这个函数作用是:算完 hash 后,在 hash 桶里找到匹配 key 的元素。这也意味着,即使命中了,hash 查找的时候也要进行一次 key 的比较操作。而在 Agent 里,这个 key 的比较操作定义为:

bool operator==(const TagSet &other) const {
        if (tags.size() != other.tags.size()) {
            return false;
        }
        for (size_t i = 0; i < tags.size(); ++i) {
            auto &left = tags[i];
            auto &right = other.tags[i];
            if (left.key_ != right.key_ || left.value_ != right.value_) {
                return false;
            }
        }
        return true;
    }

这里需要遍历整个 Tagset 的元素并比较他们是否相等。在查找较多的情况下,每次 hash 命中后都要进行这样一次操作是非常耗时的。可能导致时间开销增大的原因有:

  1. 每个 tag 的 key_ 和 value_ 是单独的内存(如果数据较短,stl 不会额外分配内存,这样的情况下就没有单独分配的内存了),存在着 cache miss 的开销,硬件预取效果也会变差;

  2. 需要频繁地调用 memcmp 函数;

  3. 按个比较每个 tag,分支较多。

2eb76915e976fc49e18356b5fbe609b9.png

因此,我们将 TagSet 的数据使用 string_view 表示,并将所有的 data 全部存放在同一块内存中。在 dictionary encode 的时候,再把 TagSet 转换成 string 的格式返回出去。

// TagView 
#include <functional>
#include <string>
#include <vector>

struct TagView {
    TagView() = default;
    TagView(std::string_view k, std::string_view v) : key_(k), value_(v) {}
    std::string_view key_;
    std::string_view value_;
};

struct TagViewSet {
    TagViewSet() = default;
    TagViewSet(const std::vector<TagView> &tgs, std::string&& buffer) : tags(tgs), 
        tags_buffer(std::move(buffer)) {}
    TagViewSet(std::vector<TagView> &&tgs, std::string&& buffer) { tags = std::move(tgs); }
    TagViewSet(const std::vector<TagView> &tgs, size_t buffer_assume_size) {
        tags.reserve(tgs.size());
        tags_buffer.reserve(buffer_assume_size);
        for (auto& tg : tgs) {
            tags_buffer += tg.key_;
            tags_buffer += tg.value_;
        }
        const char* start = tags_buffer.c_str();
        for (auto& tg : tgs) {
            std::string_view key(start, tg.key_.size());
            start += key.size();
            std::string_view value(start, tg.value_.size());
            start += value.size();
            tags.emplace_back(key, value);
        }
    }

    bool operator==(const TagViewSet &other) const {
        if (tags.size() != other.tags.size()) {
            return false;
        }
        // not compare every tag
        return tags_buffer == other.tags_buffer;
    }

    std::vector<TagView> tags;
    std::string tags_buffer;
};

struct TagViewSetPtrHash {
    inline std::size_t operator()(const TagViewSet *tgs) const {
        return std::hash<std::string>{}(tgs->tags_buffer);
    }
};

验证结果表明,当 Tagset 中 kv 的个数大于 2 的时候,新方法性能较好。

42fe08f4f794603b4bf88e223b2f012e.png

数据发送

Case 1

早期Agent使用zlib进行数据发送前的压缩,随着用户打点规模的增长,压缩逐步成为了Agent的性能热点。

因此我们通过构造满足线上用户数据特征的数据集,对常用的压缩库进行了测试:

zlib使用cloudflare

3878a7844f1e1cd3cec72a4fd9fbe622.jpeg

zlib使用1.2.11

ee51519f1dd494c825972e27ccd263e3.jpeg

通过测试结果我们可以看到,除bzip2外,其他压缩算法均在不同程度上优于zlib:

  • zlib的高性能分支,基于cloudflare优化 比 1.2.11的官方分支性能好,压缩CPU开销约为后者的37.5%

    • 采用SIMD指令加速计算

  • zstd能够在压缩率低于zlib的情况下,获得更低的cpu开销,因此如果希望获得比当前更好的压缩率,可以考虑zstd算法

  • 若不考虑压缩率的影响,追求极致低的cpu开销,那么snappy是更好的选择

结合业务场景考虑,我们最终执行短期使用 zlib-cloudflare 替换,长期使用 zstd 替换的优化方案。

结论

上述优化取得了非常好的效果,经过上线验证得出:

  • CPU峰值使用量降低了10.26%,平均使用量降低了6.27%

  • Mem峰值使用量降低了19.67%,平均使用量降低了19.81%

综合分析以上性能热点和优化方案,可以看到我们对Agent优化的主要考量点是:

  • 减少不必要的内存拷贝

  • 减少程序上下文的切换开销,提高缓存命中率

  • 使用SIMD指令来加速处理关键性的热点逻辑

除此之外,我们还在开展 PGO 和 clang thinLTO 的验证工作,借助编译器的能力来进一步优化Agent性能。

加入我们

本文作者赵杰裔,来自字节跳动 基础架构-云原生-可观测团队,我们提供日均数十PB级可观测性数据采集、存储和查询分析的引擎底座,致力于为业务、业务中台、基础架构建设完整统一的可观测性技术支撑能力。同时,我们也将逐步开展在火山引擎上构建可观测性的云产品,较大程度地输出多年技术沉淀。 如果你也想一起攻克技术难题,迎接更大的技术挑战,欢迎投递简历到 zhaojieyi@bytedance.com

最 Nice 的工作氛围和成长机会,福利与机遇多多,在上海、杭州和北京均有职位,欢迎加入字节跳动可观测团队 !

参考引用

  1. v2_0_cpp_unpacker:https://github.com/msgpack/msgpack-c/wiki/v2_0_cpp_unpacker#memory-management

  2. messagepack-specification:https://github.com/msgpack/msgpack/blob/master/spec.md

  3. Cloudflare fork of zlib with massive performance improvements:https://github.com/RJVB/zlib-cloudflare

  4. Intel® Intrinsics Guide:https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html

  5. Profile-guided optimization:https://en.wikipedia.org/wiki/Profile-guided_optimization

  6. ThinLTO:https://clang.llvm.org/docs/ThinLTO.html

字节电商双11 大促容量保障是如何做的?

作者 ByteDanceTech
2023年12月12日 18:02

前言

Rhino 简介

Rhino是字节自研全链路容量评估产品,致力于构建完整的全链路容量评估解决方案(覆盖:容量预估->资源准备->数据准备->容量验证->监控->分析->决策->处理反馈);围绕容量在稳定性、成本、效率 三方面提供业务全方位基础支撑。Rhino 目前已经成为字节各业务容量评估主流解决方案,并且历年来在业务大型活动稳定性保障中(抖音春节项目、电商618/双11大促等)均扮演了关键角色。

bba87d6c54e88d8673a102b6976651f9.png

Rhino 所解决的核心问题包括:

  • 如何降低字节各业务容量风险,内部变更、外部流量、活动、容灾、应急等场景容量风险?

  • 如何控制业务资源成本管控同时不拖累业务发展,尤其是例行抖音春节项目和电商业务大促活动?

  • 如何在资源有限情况下,保障业务持续运行,兼顾稳定性与成本?

容量评估产品体系

Rhino 发展历程

Rhino 是由之前全链路压测升级而来,自上线以来经历了3次大的产品变革,当前围绕容量评估进行容量保障生态体系建设。

4cfdf9527effe9dc25cf6de094ae57f3.png
Rhino 产品架构
ae12e30d295a893665ad1a8b99d37d9a.png

Rhino 整个产品体系是围绕容量验证和容量预估两个基础能力构建,协同为业务提供完整容量评估解决方案:

  • 以分布式压测为核心的容量验证,能够为业务提供高置信度评估支持;但随着各业务压测进入深水区,仿真度和安全性保障趋于完善,业务成本方面关注度更高;

  • 基于算法模型的容量预估,对于业务而言能够大幅降低容量评估成本,但缺点是置信度不如压测,这个也是我们要持续提升算法模型准确性的原因,没有置信度即使成本 0也没有价值。

其中,基于算法模型的容量预估自22年初开始开始在各头部业务和活动中实现规模化应用,容量模型基础能力建设是【Rhino】 、【电商服务架构】以及【基础架构各组件团队】深度合作完成的。

容量模型建设里程碑:

6cfe9046ded08329a827191e94200da6.png
Rhino 核心能力
  • 全链路容量评估自动化及多场景支持

    • 构建面向业务提供容量预估、资源准备、数据准备、容量验证、监控、分析、决策、处理、反馈完整容量评估流程;

    • 容量评估流程各环节交互高度自动化;

    • 提供多种场景容量评估支持,如大型活动、新服务上线、线上性能探测等 。

  • 超大流量、多场景施压引擎能力

    • 提供不同协议、多地区、数据类型、过亿 QPS 级别施压引擎支持 ;

    • 针对业务复杂场景,提供开放性支持,如灵活任务编排、流量调度、Plugin、云 IDE 支持等 。

  • 安全、可靠、准确容量评估支持

    • 提供多种协议的流量染色标记和不同泳道流量数据监控、追踪能力 ;

    • 构建完整的安全合规配套管控方案;

    • 多评估模型支持能力。

  • 定义容量评估标准,实现容量评估低成本、常态化

    • 容量评估配套基础能力横向打通,提供丰富数据构造、基础组件改造、容灾等基础能力支持 ;

    • 研发流程相结合,提供容量评估管控能力支持,实现容量评估同时覆盖左移、右移;

    • 容量评估标准化、流程化,如基础组件改造统一、数据构造标准化、业务评估模板定制化。

业务背景

活动背景及目标

基础信息:双11 历来都是电商业务年度重要的节点,技术侧整体目标对系统架构也一向有很高的要求。

活动策略

fde4b949b190dc8b800d9f593e19bba4.png

核心挑战

业务容量保障流程长

本次电商大促活动,Rhino 全链路容量保障方案从活动前、活动中、活动后三个活动时段,拆分 7 个阶段围绕:流量预估、压测、限流、资源、风险巡检等Topic 展开。

6f45c1bd4bf6bbf357524b4fba63a6c3.png
链路复杂、业务规模大

2023年双11 大促涉及链路覆盖上下游:上千个服务, 几十个子域场景 & 几百个核心接口全链路压测 & 入口千万级QPS发压。

  • 复杂场景下资源预估多组件覆盖技术挑战

    • 单一模型无法适用所有组件类型和业务场景,针对不用业务场景和组件需要构建不同模型;

    • 预估效果影响因素众多:除受算法模型影响之外,还跟不同类型组件设计架构、业务工程策略等因素有关。

  • 容量治理能力初次在电商活动中实践落地

    • 变化:由于双十一活动业务目标的提升,业务在容量治理方面提出了更高的要求;过去常态容量保障基础上增加了:核心服务极限容量探测、强弱依赖集群拆分验证、线上性能防劣化等容量治理诉求。

    • 挑战

      • 容量治理基建能力 —— 线上切流巡检,在稳定性、安全性上需要深度契合电商业务场景,不能引起线上切流资损或事故;

      • 容量治理在问题召回及问题分析方面,数据标准化缺失,需平衡对齐多方标准提升业务认可度。

容量保障方案介绍

活动前流量预估

流量预估架构
73b6dc567e681460a315cd9e46ec1695.png
详细说明

对业务所有接口进行逐个分析,将流量预估方案细化到单接口的维度上;在此过程中,我们主要考虑接口流量来源、接口流量的影响因素以及接口之间的关联性。接口流量预估主要可以分为两部分:

  • 大促基础流量:分为以下几种类型采用不同方式进行流量预估

    • 背景流量:流量不随大促活动变化,仅作为背景流量补充,因此可以直接取近期线上最高流量水位;

    • 模型预估接口:流量随电商直播间PCU、直播间进房量等业务指标变化而变化,而大促期间这2组指标数据量波动较大,这类接口受大促影响较大。我们对这类接口进行流量数据建模并沉淀出日常流量模型、大促流量模型作为后续流量预估基准;

    • 重保接口:业务方往往有强诉求,其流量目标比通用模型估出来的结果要高,对标历史大促的最高峰值流量,比如交易提下单、购物车、福袋抽奖等模块。

  • 高热直播间核心接口增量流量:这类接口容易受主播达人的口播内容、玩法策略影响产生尖刺流量,因此需要使用更精细的秒级数据进行建模。

流量预估模型
c81880fee160ec7119e70dd3d3faa8ba.png

活动前资源预估

目标

电商上下游依赖上千服务,人工估算难以准确推算整体资源部署情况;Rhino 资源预估能力借助拓扑流量估算、排队论等算法,自动化估算全链路资源缺口。

资源准备流程
0a924c742b4b513c3f3df773951b8aef.png
  • 业务目标明确: 在开始流量预估前,借助关注的往年及今年核心整体数据(例如 DAU、GMV、PCU),提供统一的参考基准;不同阶段会综合考虑多层面影响因素,例如:

    • 大促活动目标交易额预估:往届、本届大促成交量目标 GMV 及头部主播实时在线人数 PCU 变化等;

    • 子域链路目标流量预估:拆分至营销、直播间等子域链路目标流量;

    • 具体到接口流量的预估:业务逻辑、调用关系、流量增幅等。

  • 入口服务流量估算:业务目标及转化关系明确后,完成入口服务流量预估。

  • 资源预估入口导入: 流量预估完成后通过填写或导入的方式,开始活动资源预估

    • 参考历史时段:选择参考线上的 QPS、CPU 等指标日期,以及链路拓扑时间区间;

    • 预期 CPU 利用率:选择大促活动各组件目标资源水位;

    • 2e293e96000b95cf3bbc2444cfed5476.png

资源预估方案
d803c2edd34b7f697f31d7ba1ecedbd0.png
  • 业务冗余度注入:基础组件资源预估往往需要考虑更高维度指标(磁盘 IO、链接池、主从同步等);从业务评估灵活度考虑,流量冗余度参考计算的方式,能够弥补业务其他角度补充预估考虑。

  • 模型细分构建:综合各组件架构、部署特性、工程策略等因素进行容量模型建模,为各类基础组件提供资源预估数据,例如下面典型组件核心影响因素差异。


MySQL( RDS) Redis Abase ByteKV ByteGraph ...
系统架构 Proxy - Server Proxy - Server Proxy - Server Proxy - Server Proxy - Cache - Server ...
数据存储方式 单机 + 分片存储 分片存储 分片存储 分片存储 分片存储 ...
部署方式 物理机 + 同规格容器 + 不同规格容器 同规格容器 同规格容器 同规格容器 同规格容器 + 不同规格容器 ...
容灾方式 主从容灾部署 主从 + 分机房容灾部署 主从 + 分机房容灾部署 分布式部署 分布式部署 ...
存储资源依赖 磁盘 磁盘 磁盘 磁盘 依赖 ByteKV 存储 ...
资源申报方式 CPU + 存储分离申报 合并存储资源申报 按分片量申报 按分片量申报 按分片量申报 ...
  • 读写流量分离:基础组件主从实例流量与读写流量存在特殊分布关系,例如 Redis 主从实例流量架构:

b2304a5aae51827f344177c5dd8b49b9.png

Rhino 预估方案聚焦此架构特点,在流量预估阶段按照部署架构分离读写流量至对应实例。

  • 存储资源预估:Rhino 预估方案从资源瓶颈和申报口径两个视角切入评估存储资源

    • 存储资源增长趋势预测:预测存储资源剩余使用天数,辅助业务评估存储瓶颈;

    • 资源申报汇总:结合计算资源、存储资源,汇总资源申报建议数据。

注:业务完成资源预估后,即可根据预估数据进行资源填报和进行资源交付,从而完成资源准备工作。

活动前容量治理

容灾链路性能摸底
  • 目标:为了活动保障电商核心链路稳定性,业务会通过‘重保集群’拆分对链路进行隔离,达到容灾目的;拆分集群的性能有一致性要求,在这个过程中我们需要辅助业务对容灾链路进行性能摸底。

  • 实现方案

72b9fbfbe09741af5c1e65705490502b.png
  • 任务&环境创建:自动化的构建泳道环境创建,支持集群间业务真实流量切流,低成本实现单pod性能压测。

  • 服务接入完成后,业务需要确立服务性能摸底目标并调试任务运行,以保障泳道切流能够成功运行并达成业务目标,主要工作包含:

    • 切流负载目标:评估服务性能摸底 CPU 负载目标;

    • 持续时间:泳道切流运行持续时间,及步长;

    • 熔断配置:保障线上流量安全性,进行错误率、时延等任务熔断配置。

  • 确立性能摸底目标后,即可开始泳道切流任务,通过服务端监控观测性能差异,从而判断性能基本状态。

限流治理
  • 背景

    • 电商大促活动中,流量增长往往超出限流配置预期,易出现限流误命中造成流量损失;

    • 电商大促容量验证期间,限流误命中会导致容量验证目标无法达成,影响验证效率。

    • 过往电商链路由于流量异常增长等情况未配置有效限流造成事故占比较高,所以电商业务针对全链路服务进行通用限流建设,限流覆盖率达到 80%+;

    • 限流覆盖率增高,误限流导致的问题同样增多,影响场景如下:

  • 限流前置治理目的

    • 通过前置限流治理,提前暴露风险,并提高整体容量评估效率;

    • 为保护系统热点状态稳定性,针对业务核心接口进行限流全覆盖及L0&L1 核心链路覆盖;

    • 为保护基础组件稳定性,针对热点直播间、热点商品进行独立限流,并通过子域压测检验可用性。

  • 限流治理类型和方案

0b97f07ab296f5a7584666c68b876024.jpeg8b18f7bf95b10d6cba2a592761d3bbe8.jpeg

  • 限流风险排查

    • 结合Rhino 限流瓶颈预估组件,定期全链路扫描潜在限流风险;

    • 组织业务侧限流风险review,检验限流设置合理性,并在活动保障中取得正向价值;

    • 同时,针对异常报警数据建立了自动化追踪机制,持续协助业务侧高效排查风险案例。

活动前全链路压测

业务全链路压测实施要点

整体来讲,电商业务全链路压测包括2个阶段:子域压测、C端全链路压测。

  • 子域压测意义

8e2e25d098e7d2b15e8a37560a4b7818.png

全链路压测之前需要各个业务模块提前进行子域压测,重要性如下:

  • 流程效率:

    • 大促机器资源到位时间较晚,等全部资源到位之后再开始压测时间会来不及,因此可以在部分机器资源到位之后就开始做子域压测,先把子域容量压到预期;

    • 全链路压测的定位是整体容量验收,原则上需要各个子域确保自己没有容量问题了才能参加。避免全链路压测的时候因为某个子模块容量问题而反复回归与验证。

  • 压测仿真度:全链路压测无法保证100%仿真,流入到某些子域的流量可能不及预期,需要在子域压测中覆盖。

  • 覆盖场景:

    • B端场景覆盖:B端目前没有全链路压测,只能依靠子域压测来覆盖;

    • 极端场景覆盖:某些极端场景(比如秒杀场景、热点直播间场景等)在子域压测中覆盖,全链路压测更多的起到端到端容量评估和验收的作用;

    • 横向玩法场景覆盖:某些横向玩法和C端核心接口的链路耦合性不强,适合通过子域压测单独覆盖。

  • C 端全链路压测

3cd720d985df5441255e23e6a3888c5c.png

C端全链路压测定位是端到端容量评估和验收,因此在子域压测完成之后进行。

全链路压测技术支撑
  • Rhino 经过多年来字节各大活动容量保障支持,沉淀打磨下完整稳定容量验证平台化能力,自2022年以来验证环节中具体步骤不再需要 Rhino 过多介入;故为了保证全链路容量验证预期目标,Rhino 与业务方分工:

    • 业务侧:主导容量验证各环节,包含:链路梳理、数据构造、任务创建、调试、执行等;

    • Rhino:架构师角色参与关键方案评审,主要评估、协调验证环节风险点。

  • 容量验证方案

87ecc02f968835b1c277e56c339ec40b.png
  • 前置准备支持: 辅助服务改造、场景梳理、数据准备以及相关知识培训,为正式开展容量验证做准备

    • 服务改造:包括存储改造、登录鉴权改造、时间穿梭改造;

    • 场景梳理:梳理接口逻辑、调用下游情况、不同参数不同链路情况;

    • 数据模型构造:包括那些参数需要替换、替换方式确定、铺底数据如何构造。

  • 资源、上下游风险评估:保障容量验证流程、目标顺利完成,及上下游依赖方系统安全性:

    • 业务验证规模报备:峰值 QPS、使用场景、预期发压引擎资源、Mock 量级、改写数据量级等;

    • 发压引擎资源预估、部署(优化中):通过发压引擎模型,预估预期使用资源量;

    • 上下游风险评估:评估录制、改写、Mock、压测服务账号等上下游风险点,并同步依赖方。

  • 平台基建保障: 提供平台稳定性保障,保证活动顺利进行。

  • 全链路压测执行: 目前业界对全链路压测的实施成熟方案已经很多,并且在我们过去的技术文章中有过介绍(https://mp.weixin.qq.com/s/vofrpFGvnptj3MNAv1hQ-w),本文不再赘述。

活动中容量巡检

业务自建链路巡检
  • 电商系统庞大复杂,这么多指标,哪些才是重点?业务会构建自己的系统监控大盘。

    • 呈现核心业务指标、核心链路及关键系统指标、机房网络、基础组件监控;

    • 其中核心业务指标以L0链路为主体,汇总SRE及各域稳定性同学共同决策出电商及各域结果指标,作为大促时最关注的指标之一;

    • 同时监控与告警联动,当有指标异常时,会出现红色提示标识,并出现告警信息弹窗,以快速发现问题。

269de043e83bbfac627246d998a659ee.png
  • 只看重点还不够,如何看全、看细?子域监控大盘。

    • 提供子域监控能力,用于构建子域业务及系统链路,双十一前完成各个子域大屏接入;

    • 类似,子域大屏与告警打通,如有异常会进行染色及标注提示,辅助业务快速发现子域内的问题;

    • 同时与业务之前自建的Grafana等大盘结合互补,从电商整体的角度,看全看细。

基于模型多组件容量巡检
  • 电商大促活动依赖的下游上千个,根据核心链路的强弱梳理,电商强依赖组件有十几种类型、几百个部署集群,且容量评估维度较多,依赖人工评估+巡检耗时巨大,容易遗漏。那么如何进行容量保障?引入自动化巡检提升效率和精准度。

  • 由以下几个步骤来分阶段实现自动化:

d4a3cc4bfb11f819e8b6acd349159ac8.png

当前Rhino 提供的容量预估与巡检覆盖了主流在线计算相关组件,覆盖电商96%+核心链路服务,风险拦截准确率达到 90%以上。

活动后缩容

整体来讲,活动结束后会进行:容量调整和缩容验证。

6b02df09e2030320a522ccaf4661303e.png
容量调整
  • 容量建议:容量调整的输入,是基于活动前预估数据进行操作,通常无需重新计算;

  • 容量调整:需要进行计算资源和相关配置等项的恢复。

缩容验证
  • 目的

完成活动资源回收目标后,从电商链路稳定性角度考虑,需要进行一轮电商全链路压测进行验证,确保缩容后服务稳定性。

  • 基于调度的自动化验证方案

c32c65e6ab5d925d986e947c5375b361.png

本次电商双11 活动中,基于调度的全链路自动化压测方案投入使用,主要优势如下:

  • 通过生产流量的调度,使得流量仿真度及下游服务触达率可以得到更有效保障,最终取得效果:压测仿真度100%;

  • 切流操作步骤和压测执行过程中监控、分析等核心环节实现了高度自动化,异常检测分析和止损效率有可靠保障,最大限度的降低了整个压测活动的实施成本。

总结与展望

自2022年初以来,Rhino 成功完成了从压测 到容量产品转型,围绕业务全流程容量保障构建了基础能力支撑体系;这个期间,非常感谢公司内所有合作团队,尤其是基础架构各兄弟团队、中台团队对Rhino 的支撑,没有他们的支撑,整个容量评估体系难以完成的。

未来计划

  • 多模态容量模型持续探索

    • 继续围绕业务场景,构建基于:成本 - 流量 - 容量 模型分析能力,以及结合容量验证客观数据反馈,不断提升数据质量;

    • 同时推进模型精细化训练,例如:集群、接口、时间、单指标等维度模型训练粒度。

    • 持续探索基于模型容量评估解决方案(排队论、深度学习、LLM等),不断推进组件类型和业务场景低成本覆盖;

    • 容量模型预测、分析准确率持续提升:

  • 面向业务完整容量解决方案SOP 构建

    • 继续提高全链路容量评估流程完整度,实现:容量评估、决策、处理、反馈闭环能力建设,面向业务构建全流程容量保障SOP;

    • 细分业务场景SOP建设,例如容量基础能力在业务容量成本优化过程中提供系统解决方案。

  • 常态化容量评估能力建设

    • 常态运维能力建设:极致弹性运维、应急/自愈等核心能力强化;

    • 全链路容量评估常态化运行,实现服务形态全新升级。

招聘广告

Rhino 属于字节跳动架构体系下QE 团队,致力于建设字节跳动完整全链路容量评估生态体系和标准,提供覆盖容量预估、容量分析、容量验证、容量治理及容量运维等完整容量保障解决方案,以及低成本、安全、准确的容量评估支持,覆盖场景包括大型活动、技术升级验证、常态化容量评估等。

  1. 打造字节跳动全链路容量评估体系,负责平台架构设计和研发工作,持续保证系统高可用,为亿级DAU产品提供技术支撑;

  2. 持续优化容量模型,优化全链路容量评估活动实施成本,为业务方降本增效赋能;

  3. 研究软件工程学术前沿,并结合业务场景进行探索落地,引领公司和业界技术潮流。

职位要求:

  1. 本科及以上学历,计算机、软件工程及相关专业;

  2. 热爱计算机科学和互联网技术,精通 Go/Python/Java/C++ 等至少一门编程语言,有良好的编码能力和风格;

  3. 熟悉Linux,对容器化、云原生架构、网络协议、文件系统等相关领域有一定了解;

  4. 良好的后端架构设计能力,具备分布式、高并发开发经验;

  5. 对问题有清晰分析和全局思维,能提出具有创造性的解决思路和方案;责任心强,有良好的对外沟通和团队协作能力;

  6. 具有全链路压测、机器学习、存储组件研发经验者优先。

目前团队【后端开发工程师】和【算法工程师】紧缺,非常期待感兴趣的同学加入。

  • 微信联系:duguxiaolong11;

  • 简历投递邮箱:gaoyujun@bytedance.com(邮件标题:姓名 - 工作年限 - Rhino )。

  • 帮转【前端工程师】招聘:https://zjsms.com/i8jXQcAM/

西瓜视频RenderThread引起的闪退问题攻坚历程

作者 ByteDanceTech
2023年12月13日 17:45

背景

影响

西瓜之前存在过一类RenderThread闪退,从堆栈上看,全部都是系统so调用,给人的第一印象像是一个系统bug,无从下手。闪退集中在Android 5~6上,表现为打开直播间立即闪退。该问题在2022年占据Native Crash Top5,2023年更是上升到到Top1。因此有必要投入时间和精力再重新审视一下这个问题。在历经多周的源码分析和排查后,逐步明确了问题根因并修复,最终取得了显著的稳定性收益和业务收益。

接下来,我们将抽丝剥茧,一步步深入分析这个历史遗留问题,揭开它背后真正的原因。

基本信息

具体堆栈如下:

12a06dd4db3d8419cc772fea9c608541.png

堆栈都是系统的so调用,不能明确具体闪退业务场景,只能看出是RenderThread线程主动abort了。

根据abort message找到对应的abort代码,在CanvasContext::requireSurface时闪退了,代码如下:

ac6433511b6b3180aa910155f9dd0007.png

问题特征:

问题集中在Android 5.0~6.0,线程集中在RenderThread,无明显机型、厂商特征。

RenderThread简介

为了便于理解下面的分析过程,先对RenderThread简单的介绍。顺便看一下是怎么调用到CanvasContext::requireSurface的。

相关类图如下:

87b7ece3aec66a4aa298273ddb9df7df.png

相关源码:frameworks/base/libs/hwui/renderthread

RenderThread::threadLoop

RenderThread继承自Thread和Singleton,是一个单例模式的线程,通过RenderThread.getInstance()获取。和主线程很像,内部是一个通过for实现的无限循环,不断从TaskQueue里通过nextTask函数获取RenderTask并执行,RenderTask执行完后会按需调用requestVsync。核心代码在threadLoop函数中:

6a42fc716192a7714ce7f0a670287ee6.png

ThreadedRender

Java层通过ThreadedRender与RenderThread进行通信。当Window启用硬件加速时,ViewRootImpl会通过HardwareRenderer.create()创建一个ThreadedRender实例。ThreadedRender在创建时,会调用nCreateProxy在native层创建一个RenderProxy。ThreadedRender通过RenderProxy向RenderThread提交任务。

dee555bbfd540fee585c11c7f8fb7646.png

RenderProxy

RenderProxy在创建时,会同步创建一个CanvasContext,再通过RenderThread.getInstance()拿到RenderThread实例。RenderProxy通过CREATE_BRIDGE定义了许多Bridge函数,再通过SETUP_TASK把这些Bridge函数包装成RenderTask,再通过postAndWait提交给RenderThread调用。postAndWait之后,当前线程进入等待状态,当对应的task执行完毕之后唤醒当前线程。以RenderProxy::createTextureLayer为例:

b4256c051d590169b1115fcd06caee56.pngd5e9a71a89e2f1635a75fe6502140bf5.png

CanvasContext

RenderProxy把任务提交给RenderThread之后,执行的实际上是CanvasContext::createTextureLayer,就是在这里调用了requireSurface。

9f90f322cef5eb304ad5822656eb7a29.png

初步猜想

其他 App 相似问题修复

其他端App也曾有过'requireSurface() called but no surface set! '相关闪退。原因是:在Activity进行侧滑退出时,侧滑框架需要强制对下层Activity进行绘制生成Bitmap,再用这个Bitmap来实现Activity的切换效果。但由于下层Activity此前已处于不可见状态,可能有业务层主动释放了下层Activity中的TextureView,导致了no surface set的闪退。经过对西瓜的侧滑框架的源码分析,发现不会产生此类问题,因此西瓜的问题应该另有其因。

正面分析西瓜问题

问题的条件是mEglSurface == EGL_NO_SURFACE,看下mEglSurface赋值为EGL_NO_SURFACE的时机。

总共有两处:

第一处:CanvasContext::setSurface

这里总共两处mEglSurface赋值操作。一处直接赋值为EGL_NO_SURFACE,另一处为mEglManager.createSurface的返回值。而mEglManager.createSurface在返回前判断如果是EGL_NO_SURFACE会主动abort,显然createSurface的返回值一定不是EGL_NO_SURFACE。

void CanvasContext::setSurface(ANativeWindow* window) {
    if (mEglSurface != EGL_NO_SURFACE) {
        mEglSurface = EGL_NO_SURFACE;
    }
    if (window) {//不可能返回EGL_NO_SURFACE
        mEglSurface = mEglManager.createSurface(window);
    }
}

EGLSurface EglManager::createSurface(EGLNativeWindowType window) {
    EGLSurface surface = eglCreateWindowSurface(mEglDisplay, mEglConfig, window, nullptr);
    LOG_ALWAYS_FATAL_IF(surface == EGL_NO_SURFACE,"Failed to create EGLSurface for window %p, eglErr = %s",(void*) window, egl_error_str());
    return surface;
}

那么这里根据window是否为nullptr又可以分为两种情况:

  1. setSurface(nullptr)之后,mEglSurface 最终赋值为 EGL_NO_SURFACE,之后调用requireSurface发生abort。

  2. setSurface(window),第5行会先设置为EGL_NO_SURFACE,在第10行createSurface返回之前,此时在另外一个线程调用requireSurface也会发生abort。

第二处:初始值

初始值为EGL_NO_SURFACE。只有调用CanvasContext::setSurface时,mEglSurface 才会被赋值,在此之前,调用了requireSurface也会引发闪退。

class CanvasContext : public IFrameCallback {
    private:
    EGLSurface mEglSurface = EGL_NO_SURFACE;
}

总结下来,有三个时机调用requireSurface会导致闪退:

  1. 多线程并发,mEglSurface短暂为EGL_NO_SURFACE

  2. CanvasContext::setSurface(nullptr)之后,即mEglSurface被销毁

  3. CanvasContext::setSurface之前,即mEglSurface未初始化

深入分析

7.0+系统是如何避免这个问题的?

从多维信息看出,问题在6.0及以下版本发生。那么7.0上系统做了哪些优化,是如何规避我们上面三种可能的情况的?这些优化思路对我们解决问题能否提供帮助?

对比6.0和7.0代码之后,发现谷歌直接把requireSurface这个方法移除了!

逐个翻看6.0~7.0上RenderThread相关的commit,最终找到了这个commit(8afcc769)。这里确实是把requireSurface删除了,并在createTextureLayer中调用了一下 mEglManager.initialize()。而EglManager::initialize里的实现,是执行下EglManager的初始化,这里跟6.0基本一致。

f7771d130dcd9b1a4cbca0a84425585d.pngc1b49f99461b799cbbe37aba4ed5d4aa.png5d646cbe5cf5c010c2a932eb862ac47b.png

那6.0上的abort原来是可以直接删掉的吗?如果是这样,我们是不是可以有样学样,尝试hook requireSurface,把abort去掉,再主动调用一下mEglManager.initialize,从而达到这个commit相似的修复效果?

在云真机上找了个6.0的设备,把libhwui.so pull下来,通过 readelf -sW libhwui.so查看requireSurface的符号。发现,没有requireSurface的符号。解开so后发现,requireSurface被inline进了createTextureLayer:

7aa30c30708a687feb96b12e7756e5dd.png

再尝试一下hook requireSurface的上一层调用CanvasContext::createTextureLayer,发现也没有对应的符号,只有RenderProxy::createTextureLayer 的符号。如果采用7.0的修复方案,需要修改的指令非常多。而且,这个MR中还有其他改动,要不要一起hook?这些问题暂时还没搞清楚,花大力气去做的话风险太高。

c81a73615a8dd8321ec30e7e1bbd5b41.png

看起来,参考7.0修复方案操作难度大,并且不确定是否有效。我们先把它作为备选方案,另谋出路!

正面分析三种可能性

多线程问题?

CanvasContext的createTextureLayer和setSurface相关代码,都被RenderProxy转移到了RenderThread上执行,而RenderThread又是单例的,所以这里不存在多线程问题,因此可以直接排除线程并发问题

setSurface(null)导致?

前面分析过,setSurface(nullptr)也会导致EGL_NO_SURFACE。下面是ThreadedRender一个大概的调用时序图,把几个可能产生EGL_NO_SURFACE的setSurface调用做了标记,序号24就是的闪退函数requireSurface。时序图:

1f07fdaba6e940ceb83bb564f0dfcf1c.png

可以看出在CanvasContext中,setSurface的调用有:initialize、swapBuffers、makeCurrent、updateSurface、destory、~ConvasContext。对应的java层调用为ThreadedRender中的的initialize、draw、createTextureLayer、updateSurface、destory、finalize方法。排除一些不会出现异常的方法:

排除ThreadedRender.initialize

initialize时java层传过来的surface做了判断保护,可以确保surface不为nullptr,因此可以排除initialize,代码如下:

2ec574b41a9c2076bc05f2e5c68a9b65.png

排除ThreadedRender.draw

draw对应的问题是swapBuffers失败。发生在swapBuffers失败时,也就是eglSwapBuffers错误了。

170a268af064e522c308c656868ef713.png

系统有以下两种方式处置错误:

  1. EGL_BAD_SURFACE:打印失败日志。但翻看多个跟踪日志,均没有发现类似日志,暂时不关注。

  2. 其他EGL错误:直接abort掉,此时变成另外一个闪退,因此可以排除。

综上,对于swapBuffers失败这种情况可能存在,但未发现相关报错日志,暂时不作过多关注。相关代码如下:

759740d50a56aefa615bb592339f214a.png

排除ThreadedRender.finalize

ThreadedRender.finalize之后,会在native层通过delete 释放RenderProxy和ConvasContext,在~ConvasContext析构时调用setSurface(nullptr)。因此,如果之后再调用requireSurface,应该会发生SIGSEGV相关错误,不可能出现surface not set异常。因此,也可以排除掉。代码如下:

50e9ee00f0117ce8b283ef65307eb4f5.png4ae0c9af04b8af5eb159f7c18896fe1c.pngd9ce31a6e277e4ba9afdf5e8ffcd24f5.png

剩余情况

排除了intialize、swapBuffers、finalize之后,还剩下makeCurrent失败、updateSurface(nullptr)、destory都有可能产生问题,上游调用比较多追踪起来依然比较困难,暂时无法排除。

初始化前触发了requireSurface?

一般来说,setSurface的首次调用是在initialize中。那么,如果在initialize之前就调用了requireSurface,是不是就会出问题呢?从前面的分析可以看出,requireSurface的上游是java层的createTextureLayer,而createTextureLayer的调用处只有一个,在TextureView的getHardwareLayer中。

619380dac2a7ec4d346f15d086ba2261.png

getHardwareLayer是View的一个方法,默认返回null。从注释上也能看出,在6.0上只有TextureView用到了这个方法,调用处也只有移除在getBitmap中。在7.0上也是直接把getHardwareLayer从View中移除了,变成TextureView的一个方法。而getBitmap是个public方法,这里是可以被app调用到的。

8f97c4defe0942d5f2bd51a5ee2091c5.png8556bb2c64c7c54f25c609fde02f7956.png

闪退的前提条件:

  1. ThreadedRender.initialize还未调用

  2. ThreadedRender.destroy或者ThreadedRender.updateSurface(null)之后

在Java层触发requireSurface步骤如下:

  • TextureView.getBitmap => ThreadedRender.createTextureLayer => ConvasContext::requireSurface

最终定位

验证分析结论

通过前面的分析,找到了问题的前提条件,并发现了一条触发requireSurface的方式。那么,就可以结束纸上谈兵,通过实操来在本地复现这个闪退,来实锤前面的结论。

ThreadedRender.initialize还未调用

由于ThreadedRenderer不是公开api,需要通过反射来创建实例。拿到实例后不调用其intialize方法,直接反射调用createTextureLayer。代码如下:

5de947166067017105569e9957156adb.png

果然,复现了'requireSurface() called but no surface set!' 这个问题:

8a2380a1eccd3d8fceef0e90542ce02c.png

destroy或updateSurface(null)

通过反射创建ThreadedRender实例,先执行ThreadedRender.intialize,之后调用destroy或updateSurface清空surface,最后调用createTextureLayer,也成功复现了这个闪退。

业务场景定位

前面的复现,只是从技术层面确认了问题发生的几种可能,但还没有与业务场景关联起来。真实的问题是否在前面提到的这几种可能中间?如果在的话,那具体的调用点在哪,又该如何修复?

尝试在真实场景中复现

通过shaddow hook RenderProxy的Initialize、destroy、updateSurface、createTextureLayer等函数,在hook函数中打印一些日志。由于RenderProxy可能存在多个实例,需要在日志里加上RenderProxy实例的地址来方便追踪单个RenderProxy调用时序。

hook函数如下:

4b8260b8023abd9ecc49073c7f583aa4.png

尽管这个问题在android 6上闪退率比较高,但我用6.0测试机跑自动化测试,还是没有复现这个问题。

线上定位

问题不是必现的,排查进程在线下难以为继。而只有在真实的业务场景中复现,才能明确问题根因,找到最佳修复方案。因此,需要加把这些hook点上线进一步排查。上线无小事,线下可以小步快走,逐渐定位问题。但上线步子一定要稳,不能迈太大。但也不能太小,否则周期会拉的很长。所以,既要保证有足够的信息排查,也要尽可能的降低稳定性和性能影响。

明确业务堆栈

前面的hook方案只能确认真实业务场景是否也有调用时序问题,并不清楚具体的业务调用堆栈。

从业务上层代码到异常点,必然经过了ThreadedRender的Initialize、destroy、update、createTexture等方法,那么通过java hook把这些方法hook住,并打印堆栈应该就定位到业务代码。需要注意的是:

  1. 稳定性问题

Java hook 目前主流的方案中还没有能达到线上大规模使用的水平,只能小流量观察。

保障方案:系统版本限制在6.0,放量计划1%->5%->10%,小流量观察。

  1. 性能问题

由于这些api调用频率可能很高,也都在主线程,直接打印堆栈会影响性能。真正需要关注的就是异常前的几次调用,且有些case可以通过以下条件预判,其余的堆栈都不必要甚至是干扰信息。需要关注的堆栈如下:

  1. initialize:不需要堆栈。只要知道有没有调用过,打印一行日志即可。

  2. destroy:必须全部打印堆栈。发生在异常前,无法预判过滤。

  3. updateSurface:surface==null时打印堆栈。surface不为null不会导致异常。

  4. createTextureLayer:未初始化或者surface==null时打印堆栈。只有这两种可能会有异常。

总结:可以通过surface是否为null、initialize是否调用过这两个条件减少stackTrace。

在ThreadedRender中,surface都被透传给了native层,没有对应的Java引用,需要手动维护一个java 层的实例。初始化状态可以通过反射ThreadedRenderer.mInitialized拿到,不过既然已经hook intialize和destroy了,这里也选择手动维护一个初始化状态,毕竟可以减少一次反射调用。

public class ThreadedRenderer extends HardwareRenderer {
    private boolean mInitialized = false;
        
    @Override
    void updateSurface(Surface surface) throws OutOfResourcesException {
        updateEnabledState(surface);//透传给了Native层,Java层没有引用
        nUpdateSurface(mNativeProxy, surface);
    }
}

Java hook伪代码如下:

public static class ExtraInfo {
    private boolean isSurfaceNull = true;
    private boolean mInitialized = false;
}

static Map<Object, ExtraInfo> infoMap = new ConcurrentHashMap<>();

private static ExtraInfo extraInfo(Object instance) {
    ExtraInfo threadedRendererInfo = infoMap.get(instance);
    if (threadedRendererInfo == null) {
        threadedRendererInfo = new ExtraInfo();
        infoMap.put(instance, threadedRendererInfo);
    }
    return threadedRendererInfo;
}
public static boolean initializedHook(Object instance,Surface surface) {
    extraInfo(instance).mInitialized = true;
    return (Boolean) Hubble.callOrigin(initializeHookEntry, instance, surface);
}

public static void destroyHook(Object instance) {
    infoMap.remove(instance);
    Log.d("REPAIR", "destroy", new Throwable());
    Hubble.callOrigin(destroyHookEntry, instance);
}

public static void updateSurfaceHook(Object instance, Surface surface) {
    extraInfo(instance).isSurfaceNull = surface == null;
    if (surface == null) {
        Log.d("REPAIR", "updateSurface null ", new Throwable());
    }
    Hubble.callOrigin(destroyHookEntry, instance);
}

public static void createTextureLayerHook(Object instance) {
    ExtraInfo extraInfo = extraInfo(instance);
    if (extraInfo.mInitialized || extraInfo.isSurfaceNull) {
        Log.d("REPAIR", "createTextureLayer null ", new Throwable());
    }
    return Hubble.callOrigin(createTextureHookEntry, instance);
}

线上日志

上线后成功采集到了关键的Java调用堆栈!基本都集中在直播业务场景下,初始化前调用requireSurface。也有一些零星的destroy之后requireSurface的case,由于量级太小本文不做重点讨论。

ThreadedRender.Initialize之前

日志截图如下:

68f02ab211eadf3fd5d65c0f6582fd29.png

调用时序问题确认:

对于地址为0x814a0b0的RenderProxy实例,没有它intialize相关调用日志,只有一条createTextureLayer调用日志。可以明确,这个RenderProxy实例是在initialize之前调用createTextureLayer导致闪退!

Java 堆栈分析:

Log.d会对过长的堆栈进行截取,FrameLayout.onMeasure之前的都被截取了,不过对于排查问题,影响不大。

堆栈关键信息整理如下:

  1. 闪退时正在执行onMeasure

  2. 在com.bytedance.android.livesdk.chatroom.ui.LivePlayerWidget.loadSharedPlayer中调用了TextureView的getBitmap方法,再到ThreadedRender.creatTextureLayer,之后就发生了Native crash。

为什么没有intialize?

onMeasure会早于ThreadedRender.initialize执行吗?

ThreadedRender.initialize和performMeasure相关的代码都在performTraversals中,再次回到源码中去分析。从代码结构来看,initialize前后都有measure相关操作。initialize之前通过measureHierarchy调用了performMeasure,initialize之后是直接调用performMeasure。由于measureHierarchy外部包了许多判断条件,所以不能直接从代码行的上下关系,得出measure早于initialize的结论,但我们可以保持这个怀疑进一步验证。

这个方法过于巨大,移除无关代码后如下:

private void performTraversals() {
    if (mFirst) {
        mLayoutRequested = true;
    }
    
    boolean layoutRequested = mLayoutRequested && (!mStopped || mReportNextDraw);
    if (layoutRequested) {
        windowSizeMayChange |= measureHierarchy(host, lp, res, desiredWindowWidth, desiredWindowHeight);
    }

    if (mApplyInsetsRequested) {
        if (mLayoutRequested) {
            windowSizeMayChange |= measureHierarchy(host, lp, mView.getContext().getResources(), desiredWindowWidth, desiredWindowHeight);
        }
    }
    if (mFirst || windowShouldResize || insetsChanged || viewVisibilityChanged || params != null) {
        if (!hadSurface) {
            if (mSurface.isValid()) {
                if (mAttachInfo.mHardwareRenderer != null) {
                    hwInitialized = mAttachInfo.mHardwareRenderer.initialize(mSurface);
                }
            }
        }

        if (!mStopped || mReportNextDraw) {
            if (focusChangedDueToTouchMode || mWidth != host.getMeasuredWidth() || mHeight != host.getMeasuredHeight() || contentInsetsChanged) {
                performMeasure(childWidthMeasureSpec, childHeightMeasureSpec);
            }
        }
    }
}

private boolean measureHierarchy(final View host, final WindowManager.LayoutParams lp,
                                 final Resources res, final int desiredWindowWidth, final int desiredWindowHeight) {
    boolean goodMeasure = false;
    if (lp.width == ViewGroup.LayoutParams.WRAP_CONTENT) {
        if (baseSize != 0 && desiredWindowWidth > baseSize) {
            performMeasure(childWidthMeasureSpec, childHeightMeasureSpec);
            if ((host.getMeasuredWidthAndState()&View.MEASURED_STATE_TOO_SMALL) == 0) {
                goodMeasure = true;
            } else {
                performMeasure(childWidthMeasureSpec, childHeightMeasureSpec);
                if ((host.getMeasuredWidthAndState()&View.MEASURED_STATE_TOO_SMALL) == 0) {
                    goodMeasure = true;
                }
            }
        }
    }
    if (!goodMeasure) {
        performMeasure(childWidthMeasureSpec, childHeightMeasureSpec);
    }
    return windowSizeMayChange;
}

由于堆栈被裁剪了,无法确认异常是从哪个分支过来的。不过没关系,注意到当mFirst=true时,满足layoutRequested = true,会先调用执行measureHierarchy,可以在本地模拟mFirst=true这种情况,即可验证。

本地通过onMeasure复现

本地写个demo,在FrameLayout.onMeasure中立即调用TextureView.getBitmap,并通过反射查看mFirst的值,找个6.0的云真机验证一下。onMeasure会连续执行多次,只有第一次的mFirst为true,但没能复现问题,代码如下:

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    boolean first=reflectFirst();//反射获取mFirst
    activity.log("mFirst=" + first);
    Bitmap bitmap = mTextureView.getBitmap(mBitmap);
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}

再来看下mTextureView.getBitmap的实现,

public Bitmap getBitmap(Bitmap bitmap) {
        if (bitmap != null && isAvailable()) {
            if (mLayer == null && mUpdateSurface) {
                getHardwareLayer();
            }
        }
        return bitmap;
    }

    public boolean isAvailable() {
        return mSurface != null;
    }
    
    public void setSurfaceTexture(@NonNull SurfaceTexture surfaceTexture) {
        mSurface = surfaceTexture;
    }

可以看到,要想执行到ThreadedRender.createTextureLayer还需要满足以:isAvailable()为true,手动调用一下TextureView.setSurfaceTexture就可以满足。

根据猜想,再次编写代码终于复现成功! demo如下:

mTextureView.setSurfaceTexture(new SurfaceTexture(0));

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    boolean first=reflectFirst();
    activity.log("mFirst=" + first);;
    mTextureView.getBitmap(mBitmap);
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}

尝试只在mFisrt=false时执行getBitmap,再次运行,不崩了。可见异常的关键条件就是mFirst!

if (!first) { //没问题
    mTextureView.getBitmap(mBitmap);
 }

问题根因

梳理下整体流程。ViewRootImpl首次performTraversals时(mFirst=true),onMeasure会早于ThreadedRenderer.initialize。而业务方在onMeasure中又调用了TextureView.getBitmap,最终在native层会调用CanvasContext::requreSurface。由于还没有执行过CanvasContext::initialize,当前mEglSurface为EGL_NO_SURFACE,于是在Android5~6上触发了abort,发生surface not set的异常。

总结起来:在android6.0上,ViewRootImpl首次performTraversals时,如过在onMeasure中调用了TextureView.getBitmap,就可能会发生这个异常。

线上还存在一些零星的destroy之后requireSurface、swapBuffers失败后requireSurface的异常,由于排查思路大同小异,这里就不展开说了。

修复方案

通过字节码插桩全局替换TextureView.getBitmap方法,当ViewRootImpl.mFirst=true时,就返回默认值而不执行getBitmap原有逻辑,这样就不会调用到ThreadedRender.createTextureLayer。

但由于mFirst只能通过反射获取,这可能会影响performTraversals性能,有没有性能更好的方案?

通过代码分析,发现performTraversals会经历layout阶段,而layout之后View会增加一个PFLAG3_IS_LAID_OUT:

/**
* Flag indicating that the view has been through at least one layout since it
* was last attached to a window.
*/
static final int PFLAG3_IS_LAID_OUT = 0x4;

public void layout(int l, int t, int r, int b) {  
    mPrivateFlags3 |= PFLAG3_IS_LAID_OUT;
}


public boolean isLaidOut() {
    return (mPrivateFlags3 & PFLAG3_IS_LAID_OUT) == PFLAG3_IS_LAID_OUT;
}

因此,可以通过isLaidOut ()获取到这一个属性,达到和mFirst基本一致的效果。

最终方案:

插装替换全局TextureView.getBitmap调用,增加textureView.isLaidOut()判断。

public static boolean isGetBitmapSafe(TextureView textureView) {
    return Build.VERSION.SDK_INT > 23 || textureView.isLaidOut() || !AppSettings.inst().mFerretSettings.autoFixRequireSurface.enable();
}

@ReplaceMethodInvoke(targetClass = TextureView.class, methodName = "getBitmap", includeOverride = true)
public static Bitmap getBitmapHook(TextureView textureView) {
    return isGetBitmapSafe(textureView) ? textureView.getBitmap() : null;
}

@ReplaceMethodInvoke(targetClass = TextureView.class, methodName = "getBitmap", includeOverride = true)
public static Bitmap getBitmapHook(TextureView textureView, int width, int height) {
    return isGetBitmapSafe(textureView) ? textureView.getBitmap(width, height) : null;
}

@ReplaceMethodInvoke(targetClass = TextureView.class, methodName = "getBitmap", includeOverride = true)
public static Bitmap getBitmapHook(TextureView textureView, Bitmap bitmap) {
    return isGetBitmapSafe(textureView) ? textureView.getBitmap(bitmap) : bitmap;
}

修复效果

实验全量后requireSurface 相关crash明显下降,观察两周业务指标没有明显劣化,直播场景有正向收益,符合预期。全量后量级大幅下降,还剩下一小部分主要是老版本、以及一些少量的destroy、swapBuffer失败相关的问题。

业务收益:看播渗透显著提升;人均看播天数显著提升;

稳定性收益:Native Crash大幅下降

84e65b80902c4a424af26c17a6aef58b.pngff68340af3f8188ca69e6090cb1d42b6.png

后续思考

这个requireSurface问题发生在RenderThread,但造成问题的原因在主线程,因此如果能在RenderThread线程发生native crash时抓到主线程java堆栈,就可以定位到业务根因,也就不需要一系列自下而上地代码分析来寻找hook点了。

因此,后续有RenderThread线程异常时,应该把主线程堆栈上报上来,提高RenderThread问题的排查效率。

加入我们

我们是字节跳动西瓜视频客户端团队,专注于西瓜视频 App 的开发和基础技术建设,在客户端架构、性能、稳定性、编译构建、研发工具等方向都有投入。如果你也想一起攻克技术难题,迎接更大的技术挑战,欢迎点击阅读原文,或者投递简历到xiaolin.gan@bytedance.com。

最 Nice 的工作氛围和成长机会,福利与机遇多多,在上海和杭州均有职位,欢迎加入西瓜视频客户端团队 !

文章推荐

客户端架构设计的过程

Android D8编译器“bug”导致Crash的问题排查

Baseline Profile 安装时优化在西瓜视频的实践

使用火山引擎 APMPlus 解决抖音Top 1 Java 崩溃的通用优化方案

作者 ByteDanceTech
2023年11月29日 18:41

背景

近3个月,抖音 Android 版面临一个多次触发线上报警的崩溃问题,全量版本和灰度版本的异常数据激增,该问题不仅容易触发报警,更成为了 Java Top 1 崩溃问题,带来巨大困扰,急需攻坚解决。

本文展现了具体的分析过程、优化思路和解决方案,同时提供了已集成该方案的实用工具。

初步分析

多维特征

我们以某发版期间数据为例进行分析:

  • 机型方面:比较分散,有聚集部分samsung sm-s9180 占比 top1

  • 渠道方面:比较分散,华为、小米、三星占比 top3。

  • ROM方面:比较分散。

  • OS 版本方面:排除古老的 5.1.1 系统,只有 10 以上版本,12/13/10 占比 top3

  • App小版本方面,排除古老的版本,基本跟日活排序一致。

通过对多维特征分析,我们得出结论是:存在 OS 高版本和三星机型聚集特征。

aae682e22056224cc65d240e23e93f44.png

崩溃堆栈

从下图我们看到,崩溃类型 TransactionTooLargeException,崩溃现场在 BinderProxy.transactNative 方法附近,崩溃提示消息 data parcel size xxx bytes,崩溃时 Activity 在向 AMS 传递 stop 信息。初步分析这些信息,我们得出结论:

  • 这是由 Binder 传输数据量超过阈值触发的崩溃。

  • 排除其中无关的动态代理 ProxyInvocationHandler 的业务堆栈,其它都在系统堆栈中,无法直接定位到具体业务方解决。

60f73e51013dd202ff81985408d2d540.png

崩溃日志

除了崩溃堆栈,我们也要分析崩溃日志,这样往往能够从中获得一些有价值的线索。以下,我们分别从聚合 logcat 日志和单次 logcat 日志进行分析。通过 应用性能监控全链路版(APMPlus)App端监控 的 logcat 词云,看到聚合日志 top1 是 JavaBinder: !!! FAILED BINDER TRANSACTION !!! (parcel size = **。这个是系统 Binder 传输数据失败打印的日志。

e6766c8aa8c288022243aee5af3edaf6.png

我们从源码里面找到打印日志的地方,发现是在 Native 层 Binder.cpp 里面,接收到 Binder 驱动的错误码 FAILED_TRANSACTION 之后,先打印错误日志,然后通过 jniThrowException 函数从 JNI 层进入 Java 层抛出异常。

7b7ba11551843fdb6da3eb577320a2c7.png

通过对聚合 logcat 日志分析,我们得到结论:只有系统 Binder 传输失败日志,无业务相关日志,不确定是哪个业务不合理导致的崩溃。根据前面聚合 locat 日志关键字,我们翻看了几十个上报日志,发现在 JavaBinder: !!! FAILED BINDER TRANSACTION !!! (parcel size = **的后面紧接着出现 Tag 为 ActivityStopInfoBundle stats: 日志,其中一个如图所示。因此这时我们知道 Binder transaction 传输的 parcel 内容很大可能跟 Bundle 有关。

从打印的 Bundle stats:树形结构看,只打印了系统的 view 和 fragment 占用 size,没有 App 业务相关占用 size,无法细分是哪里不合理。另外在日志 JavaBinder: !!! FAILED BINDER TRANSACTION !!! (parcel size = ** 的前面我们终于发现了跟业务相关的 Activity 是 com.ss.android.ugc.aweme.detail.ui.DetailActivity。翻看了几十个上报日志,并不是每个日志都是这个 com.ss.android.ugc.aweme.detail.ui.DetailActivity ,还有 com.ss.android.ugc.aweme.shortvideo.ui.VideoPublishActivity等。

经过对单次 logcat 日志分析,能够从单次 logcat 日志中知道是哪个 Activity stop 时出错,但是目前分析不出这些处于 stop 状态的 Activity 占比情况。

47915aaeb06442fbadb0d19fee0ca3bf.png

综合聚合和单次 logcat 日志看,无明显 App 业务逻辑聚合,只有系统日志,无法简单定位到某个业务解决。

初步结论

经过前面的分析,我们得出初步结论:

  • 问题是 Activity stop 时给 AMS 传输 Bundle 超过 Binder 驱动限制的大小,触发了崩溃。

  • 调用栈和日志无明显业务聚合,无法简单确定是哪个业务引入的问题。

因此,需要进行深入分析,一方面要从监控归因着手排查其中不合理的因素,另一方面也要从底层通用解决方案尝试突破限制彻底解决问题。

深入分析

下面将主要从崩溃堆栈进行深入分析,翻了几十个崩溃堆栈,我们发现并不是只有 Activity stop 调用栈,还有 Activity start 调用栈,主要包含两类,一个是 activityStopped 关键字的崩溃,占比约 97.25%;另一个是 handleStartActivity 关键字的崩溃,占比约 2.75%,这些崩溃主要与三星兼容性问题有关。

因此,我们判断在有限的时间和精力下,先解决 top 的 activityStopped 关键字的崩溃,以下主要是对 activityStopped 分析。如图所示,我们接下来从底层向上层对核心逻辑崩溃堆栈进行逐步分析。

bf1c399f8542c9d836a609994d5a4d8b.png

Caused by: android.os.TransactionTooLargeException: data parcel size 541868 bytes

根据关键字 data parcel size 和相关源码分析,我们得出 data parcel size 附近的数据处理流程:

21ba548e930cf6cbbc512472bc5a0066.png
  1. JNI 层 BinderProxy_transact 函数先调用 IBinder->transact 函数进行 Binder 传输数据。

  2. IBinder->transact 函数的返回值 err 用于识别 Binder 传输数据错误码。

  3. 当 err != NO_ERROR && err != UNKNOWN_TRANSACTION 情况下,说明 Binder 传输数据出现异常。

  4. signalExceptionForError 函数对异常情况多种错误码进行分发处理。

  5. 当错误码 err = FAILED_TRANSACTION 时,说明是在传输的过程中失败,接下来主要根据 parcelSize 和 200*1024 的大小关系处理。

  6. 当 parcelSize > 200*1024 时,后续通过 jniThrowException 函数在 Java 层抛出 TransactionTooLargeException,并提示消息 "data parcel size %d bytes"

  7. 当 parcelSize <= 200*1024 时,后续通过 jniThrowException 函数在 Java 层抛出 DeadObjectException 或者 RuntimeException,并提示消息 "Transaction failed on small parcel; remote process probably died, but this could also be caused by running out of binder buffer space",意思是 Binder 传输小的数据量情况下也可能失败,原因有两个:第一是远程进程可能已经死亡,Binder 驱动找不到远程进程对应的 mmap 虚拟内存,也就无法完成 Binder 数据传输;第二是远程进程还存活,但是它的 Binder mmap 接收缓冲区已经被用完,无法分配空闲的 mmap 虚拟内存,也就无法完成 Binder 数据传输。

通过对错误消息分析,我们得出结论:

  • IBinder transact 函数返回 FAILED_TRANSACTION 错误码且 parcelSize > 200*1024 情况下会抛出 TransactionTooLargeException。

BinderProxy.transactNative 流程

前面我们在 signalExceptionForError 函数中读取这个 FAILED_TRANSACTION 错误码,接下来我们要知道其写入的地方在哪里,以便理顺数据处理流程。通过关键字查找,我们发现这个 FAILED_TRANSACTION 错误码只在 IPCThreadState::waitForResponse 函数中写入。对应有两种情况会写入:BR_FAILED_REPLY 回复失败,BR_FROZEN_REPLY 冻结回复。

这两个 cmd 对应的值是从 mIn.readInt32读取出来,也就是 Parcel 中读取出来的命令值。

e26291e9cffefd5fb7dc8c8c4b4fb7f9.png

我们依然循着错误码传递路径来到 Binder 驱动层。

BR_FAILED_REPLY 错误码在 binder 驱动中有 40 个引用,过于宽泛,没有唯一性,导致继续使用这个排查方向的 ROI 不高,因此暂停这个排查方向。

http://aospxref.com/kernel-android13-5.10-lts/more/drivers/android/binder.c?full=BR_FAILED_REPLY

fe67f356c99ffd3ec9b91b26e15e97ac.png

BR_FROZEN_REPLY 错误码在 binder 驱动中有 1 个引用,指进程/线程被冻结情况,主要用于区分进程死亡时 Binder 无法回复 BR_DEAD_REPLY。我们这个 Activity stop 场景用户大概率还在前台,App 和 System Server 不太可能出现进程被冻结,因此暂停这个排查方向。

http://aospxref.com/kernel-android13-5.10-lts/xref/drivers/android/binder.c#6298

3504266f1ef98758e246bed2b5421487.png

除了错误码之外,我们结合源码得出 BinderProxy.transactNative 执行流程如下:

1652a86d1757d35f62066e21e50a1983.png
  • Java 层 BinderProxy.transactNative 调用 Native 层 android_os_BinderProxy_transact。

  • 数据经过 IBinder::transact、BpBinder::transact 函数调用到达 IPCThreadState::transact。

  • IPCThreadState::writeTransactionData 先把待传输数据写入 Parcel 。

  • IPCThreadState::waitForResponse 负责和 Binder 驱动通讯进行数据发送和接收,并处理接收的命令。

  • IPCThreadState::talkWithDriver 负责和和 Binder 驱动通讯,关键是通过 ioctl 系统调用进入内核处理。

PendingTransactionActions$StopInfo.run 流程

前面我们分析了崩溃类型 TransactionTooLargeException、崩溃消息 data parcel size xx bytes、和堆栈最底层函数 BinderProxy.transactNative 数据处理流程之后,得出的结论是:该崩溃是Binder 传输数据超过 200 * 1024 时出现的崩溃。

由于 BinderProxy.transactNative 是底层通用函数,它的调用方有很多,我们需要快速确定这个崩溃场景最大的 Context 是在哪个里面,所以我们直接跳到分析主线程消息处理里面业务根消息PendingTransactionActions$StopInfo.run 的分析。

04d6b099302dbee15b685d21bad7f19a.png

通过下图源码,我们发现 App 进程通过 Binder IPC 通知 System Server 进程的 AMS Activity Stop 信息时,有个 catch RemoteException 异常捕获逻辑。

通过先打印 Bundle 统计数据,后进行异化逻辑处理,我们看到 targetSdk < 24 情况下,程序打印了一行日志 App sent too much data in instance state, so it was ignored,然后 return 返回,并没有执行 throw 异常。所以推测系统在这种情况下其实不崩溃。

88899e9716a37a0c3f4ac4b5cdc3947c.png

使用 App 监控统计分析到,6/7/10系统没有崩溃,这个和源码逻辑可以匹配上。在 targetSdk >= 24 之后,把捕获的异常重新通过 throw 关键字抛出,这种情况下会出现崩溃。经过回溯 StopInfo 中的 Bundle 来自Activity.onSaveInstanceState :

b3ab2acfaa164086e5a8c88df760f342.png

因此,我们得出崩溃场景是:

6ec0dd18a86028b35ee3519550ba462c.png
  • ActivityThread.handleStopActivity 过程中保存 callActivityOnSaveInstanceState 产生的 Bundle 数据。

  • 在主线程异步消息执行 StopInfo.run 告诉 AMS activityStopped 信息过程中,传递 Bundle 数据用于未来恢复 Activity 状态。

Activity.onRestoreInstanceState 流程

前面我们分析崩溃场景 Binder 传输数据和 Activity.onSaveInstance 的 Bundle 数据有关,接下来我们分析这个 Bundle 数据恢复处理流程。

7df709238a27333b9c88f8479e7cf03b.png
  • Activity 被销毁重建场景,在 ActivityThread.performLaunchActivity 函数中,Bundle 会跟随新 Activity 实例创建从 AMS 传递到 App 进程内。

  • 由于 ActivityThread.performLaunchActivity 中 Bundle 是从 AMS 进程序列化传递到 App 进程,所以其中的类需要在 App 进程进行反序列化,重新进程类加载和类初始化,所以 Bundle.setClassLoader 用于 App 进程内 Bundle 数据反序列化。

  • 接下来通过 Instrumentation 传递 Bundle 到 Activity.onCreate 函数,在这里是 Activity 接收到 Bundle 最早的地方,Activity 可以从这里开始访问被恢复的 Bundle 数据。

  • 在 ActivityThread.handleStartActivity 处理 start 时,也会通过 Activity.onRestoreInstanceState 函数用于恢复 Activity 状态。

TransactionTooLargeException 直接原因

经过前面分析,我们知道 Binder 传输数据量超限是 Activity.onSaveInstance 保存的 Bundle 过大导致。但是我们并不知道 Bundle 超过多大会崩溃,比如 Bundle 超过 200K、500K、1M 会崩溃吗?

为了分析崩溃执行过程,在 demo App 中 Activity.onSaveInstanceState 保存大数据,并使用 root 机器分析崩溃场景日志:

de4d2cf07b729585c1267d765d87004c.png
// 4692 是发送方 app 进程 pid,1838 是接收方 system server 进程 pid。接收方接收数据时,遇到分配 4198824 虚拟内存失败,没有足够的虚拟地址空间
4692  4692 E binder_alloc: 1838: binder_alloc_buf size 4198824 failed, no address space
4692  4692 E binder_alloc: allocated: 2240 (num: 8 largest: 2080), free: 1038144 (num: 4 largest: 1036672)
4692  4692 I binder  : 4692:4692 transaction failed 29201/-28, size 4198812-8 line 3317
4692  4692 E JavaBinder: !!! FAILED BINDER TRANSACTION !!!  (parcel size = 4198812)

结合源码,我们得出 BinderProxy.transactNative 执行流程是这样:

d05d50ef05c5638fcf51b97ad155804c.png
  • Java 层 BinderProxy.transactNative 调用 Native 层 android_os_BinderProxy_transact 。

  • 数据经过 IBinder::transact、BpBinder::transact 函数调用到达 IPCThreadState::transact 。

  • IPCThreadState::writeTransactionData 先把待传输数据写入 Parcel 。

  • IPCThreadState::waitForResponse 负责和 Binder 驱动通讯进行数据发送和接收,并处理接收的命令。

  • IPCThreadState::talkWithDriver 负责和和 Binder 驱动通讯,关键是通过 ioctl 系统调用进入内核 binder_ioctl 处理。

  • binder_thread_write 负责向目标进程写入 Binder 传输数据,binder_thread_read 负责读取其它进程发送给当前进程的 Binder 传输数据。

  • 进程间传输数据走到公用函数 binder_transaction,通过函数参数 int reply识别区分是否是发送数据还是接收数据。

  • 在往一个进程写入传输数据之前,需要通过 binder_alloc_new_buf_locked 函数先申请合适大小的 Binder 接收缓冲区,如果分配失败,则无法完成数据传输。

b480a20d4ab76db5087960bdb63f8dee.png

因此,TransactionTooLargeException 直接原因是接收方进程 Binder mmap 接收缓冲区无法分配足够大的空闲内存。

Binder 传输数据最大值

前面我们只传输了约 500K 大小的数据就遇到了 TransactionTooLargeException 问题。那么

  • 是否 Binder mmap 接收缓冲区大小就是小于 500K 呢?

  • 是否最大缓冲区其实超过 100M,只是被其它 Binder 调用分配用完了呢?

通过 binder 驱动底层源码分析,单进程 Binder mmap 分析最大值 4MB,但是还受到参数 vma 控制。

ecb04de448c0bb821511ae5283c5bdd0.png

而从整个 Binder 架构初始化流程分析,这个参数来自 ProcessState 里面,最大值是 1M - 8K。

这两个值里面取最小值作为 Binder 传输数据最大值。

ea4ee9bfc6807de49e2e8d46a361a418.png

因此,一个进程 Binder 接收缓冲区最大是 1M-8K。

问题总结

经过前面的深入分析,我们对这个问题得出更加清晰的结论:

  • 问题是在 App 进程向 system_server 进程发送 Activity.onSaveInstanceState 保存的 Bundle 数据时,驱动层在 system_server 的 Binder 接收缓冲区(大小上限 1M-8K)中分配不出用于接收 Bundle 大小的内存,导致 Binder 发送失败

  • Bundle 数据用于 Activity 页面销毁重建时恢复页面状态,其来源包含 framework 和 App 层保存的数据。当页面过于复杂或者很重场景下可能会出现超限问题

  • 从日志和调用栈无法定位/拆解出 Bundle 中什么数据过大导致的崩溃,需要有归因和解决工具

解决方案

优化思路

前面我们分析得知崩溃场景是 Activity stop 和 start 时和 AMS 传递的 Bundle 过大流程:

758e85ca2d7ec6882c9af7e9a70976bb.png
  • App 进程内通过 Activity.onSaveInstance 方法把数据放到 Bundle 中

  • 通过 Binder Driver 提供的接口 App 把 Bundle 写入 System Server 进程的 mmap 内存

  • 接下来 Activity 重建/启动时,System Server 再把保存的 Bundle 数据转发给 App 进程内 Activity

按照这个数据处理流程,一旦 Bundle 超过进程 Binder 接收缓冲区大小,就会触发 Binder 传输失败。

@Override
protected void onSaveInstanceState(Bundle outState) {
    super.onSaveInstanceState(outState);
    // 需要 hook 拦截处理 outState
}

@Override
protected void onRestoreInstanceState(Bundle savedInstanceState) {
    // 需要 hook 拦截处理 savedInstanceState
    super.onRestoreInstanceState(savedInstanceState);
}

因此,结合问题场景和现状,我们的优化思路是:

  • 由于目前没有太好的办法在运行时应用态直接修改 Binder 驱动层内核态的执行指令,因此主要从上层 App 应用态入手解决问题

  • 在 onSaveInstanceState 之后把 outState 缩小到小于底层 Binder 传输的阈值

  • 在 onRestoreInstanceState 之前把 savedInstanceState 扩大到原始大小给业务层使用

Hook 点

通过阅读源码和断点调试,我们发现一个合适的 hook 类 ActivityLifecycleCallbacks,其能满足我们的诉求:

  • onActivityPostSaveInstanceState 正好是在 onSaveInstanceState 之后执行,而且能拿到 outState 参数

5440bded9327a005ed747221e80e3080.png
  • onActivityPreCreated 正好是在 onRestoreInstanceState 之前执行,而且能拿到 savedInstanceState 参数

所以我们通过 Application.registerActivityLifecycleCallbacks 能够拿到合适的执行时机。

缩小 Binder 传输 Bundle

为了让 Binder 读取到的 Bundle 数据量小从而避免崩溃,我们需要思考小到什么程度?比如:

  • 完全不执行 onSaveInstanceState 函数,这样也就不会超过 Binder 阈值

  • onSaveInstanceState 中主动调用 clear 函数清理 outState 数据,这样也不会超过 Binder 阈值

  • 把 outState 数据替换为一个小的 KEY 数据,而且建立 KEY-outState 一一对应的 Map 结构,方便查找还原 Bundle

为了尽量不影响系统本身的 save 和 restore 逻辑,我们选择传输 KEY 数据缩小 Bundle 数据。其中依赖的 KEY 我们使用 UUID 生成方式。

public void onActivityPostSaveInstanceState(Activity activity, Bundle outState) 
    outState.clear();
    String uuid = UUID.randomUUID().toString();
    sKey2ContentMap.put(uuid, outState);
    outState.putString("TransactionTooLargeOptKey", uuid);
}

还原 Binder 传输 Bundle

前面我们有了 KEY 和 Map 结构,现在我们只需要根据 KEY 查找对应的 Bundle 数据进行还原即可:

public void onActivityPreCreated(Activity activity, Bundle savedInstanceState) {
    if (savedInstanceState != null) {
        Object uuid = savedInstanceState.get("TransactionTooLargeOptKey");
        Bundle bundle = sKey2ContentMap.get(uuid);
        if (bundle != null) {
            savedInstanceState.clear();
            savedInstanceState.putAll(bundle);
        }
    }
}

功能、性能和稳定性折中

有了整体稳定性优化数据处理流程之后,我们发现维护的 Map 结构需要一定的空间开销,可能包括内存或者外存。

一方面,假如我们需要跟系统 save 和 restore 行为保持一致,即冷启动之后还可以恢复 Activity 状态,那么需要把 Bundle 信息保存在外存。而外存一般只能保存文本/二进制数据,这就需要把 Bundle 进行转换成二进制,并且涉及到一些 io 操作,很可能影响性能。

另一方面,我们对抖音主要 Activity 进行销毁重建,发现 Activity 并不能很完美的支持恢复能力。

所以,基于以上两点,我们决定 Map 结构不涉及的 io,只做内存级别。

前面我们的流程中针对所有 Bundle 都进行了优化,可能它就没有超过 Binder 的阈值。因此,为了精准识别问题场景,我们希望对超过一定大小的 Bundle 才进行优化。所以我们使用 Parcel 对 Bundle 进行了序列化量化:

private static byte[] bundle2Bytes(Bundle outState) {
    Parcel parcel = Parcel.obtain();
    try {
        parcel.writeBundle(outState);
        return parcel.marshall();
    } catch (Throwable t) {
        t.printStackTrace();
    } finally {
        parcel.recycle();
    }
    return null;
}

把 Bundle 序列化为 byte[] 之后,我们限制大小超过 1024 Bytes 才进行优化。

假如用户不断触发 save 操作,那么很可能会挤爆 Map 内存。所以我们需要有个 Map 容量上限和清理逻辑。这里初步设置容量 64,超限之后清理掉最久不用的记录:

if (sKey2ContentMap.size() > 64) {
    Set<Map.Entry<String, byte[]>> entries = sKey2ContentMap.entrySet();
    List<String> removeKeys = new ArrayList<>();
    // 清理掉前 20%,留下后面 80%
    final int removeCount = (int) (64 * 0.2);
    for (Map.Entry<String, byte[]> entry : entries) {
        removeKeys.add(entry.getKey());
        if (removeKeys.size() >= removeCount) {
            break;
        }
    }
    for (String firstKey : removeKeys) {
        sKey2ContentMap.remove(firstKey);
    }
}

Demo 验证

在 demo App 中,我们尝试往 Bundle 中写入超过 Binder 传输阈值的 16 MB 数据,预期在 restore 中能读取到相同数据:

@Override
protected void onSaveInstanceState(Bundle outState) {
    super.onSaveInstanceState(outState);
    byte[] bytes = new byte[16 * 1024 * 1024]; // 16MB
    outState.putByteArray("key", bytes);
    System.out.println("onSaveInstanceState bytes.length = " + bytes.length);
}


@Override
protected void onRestoreInstanceState(Bundle savedInstanceState) {
    super.onRestoreInstanceState(savedInstanceState);
    byte[] bytes = savedInstanceState.getByteArray("key");
    System.out.println("onRestoreInstanceState bytes.length = " + bytes.length);
}

在机器中开启开发者选项中不保留活动选项,测试结果中两行日志 bytes 数组长度一致,说明方案可行。

05020c63fc64b7d1653aaeac6bbf3819.png

最终方案

经过前面层层分析和逐步优化,我们的最终方案是:

  • 启动之后通过 Application#registerActivityLifecycleCallbacks 注册 Hook 点函数,从而拿到 save 和 restore 执行时机和参数。

  • 在 ActivityLifecycleCallbacks#onActivityPostSaveInstanceState 时把 Bundle 序列化为二进制,当大小超过 1024 Bytes 时创建 UUID 并在内存记录 UUID 和 Bundle 映射关系,替换 Bundle 数据为 ID,从而减小 Binder 看到的数据量。

  • 在 ActivityLifecycleCallbacks#onActivityPreCreated 时从 Bundle 中读取出 UUID,并从内存 Map 中查找出映射的 Bundle,还原 ID 为 Bundle 数据。

3c999f78926ddc987270e3a7353cc9de.png

上线效果

单个 issue 修复效果:优化方案已经解决 97% 的问题。

剩余三星 startActivity 兼容性问题,已经不是 Top 问题。由于涉及启动 Activity 多进程数据共享问题,故此次未做优化

接入方式

目前该方案已经在抖音全量上线,并集成到火山引擎 APMPlus ,供各产品快速接入(由于不同产品的复杂度不同,建议接入时做好观察验证)。接入方式如下:

MonitorCrash.Config config = MonitorCrash.Config.app(APM_AID)
        .enableOptimizer(true);//打开稳定性优化开关
        .build();

只需要初始化时打开优化开关即可,欢迎各产品接入优化异常问题,该能力开箱即用,具体接入方式详见文档(https://www.volcengine.com/docs/6431/68852)。

总结和思考

  • 系统层:对于系统服务端 system_server 进程,其会接收众多 App 进程发送的 Binder 数据,Binder 传输数据量上限限制和 App 进程一样,可能是不太合理的。随着手机内存发展,这个数值在内存充足的机器上或存在优化空间,例如扩容到 2M 之后,传输失败率下降,App 使用时长增加。

  • 业务层:跨进程场景 Bundle 和 Intent 中不适合传递大数据,可以经过外存中转,通过维护外存 Map 和传递 Key 解决;另外 Bundle 中不建议放受到后台 Server 活动影响较大的序列化数据结构,这种情况平时没有问题,一到线上活动就容易崩溃,在活动上线前需要多加演练。

关于APMPlus

APMPlus 是火山引擎下的应用性能监控产品,通过先进的数据采集与监控技术,为企业提供全链路的应用性能监控服务,助力企业提升异常问题排查与解决的效率。基于海量数据的聚合分析,平台可帮助客户发现多类异常问题,并及时报警,做分配处理。目前,APMPlus 已服务了抖音、今日头条等多个大规模移动 App,以及作业帮、Keep、德邦等众多外部业务方。

45e5e6644baaf208c131a8729ef9195d.png

APMPlus APP端监控方案提供了 Java 崩溃、Native 崩溃、ANR 等不同的异常类别监控,以及启动、页面、卡顿等流畅性监控,还包括内存、CPU、电量、流量、磁盘等资源消耗问题的监控。此外,APMPlus 提供网络耗时和异常监控,拥有强大的单点分析和日志回捞能力。在数据采集方面,提供采样和开关配置,以满足客户对数据量和成本控制的需求,并支持实时报警能力。针对跨平台方案,其提供了 WebView 页面的监控。丰富的能力满足客户对 App 全面性能监控的诉求。

方案亮点

  • Java OOM 监控提供全流程自动分析能力,准确定位Java内存问题,泄漏链、泄漏大小、大对象一目了然。

  • ANR 使用基于信号的捕获方案,更节省系统资源,准确度高,提供现场消息调度图,高度还原现场主线程阻塞情况。

  • 真正解决 Native(C/C++)崩溃的现场还原能力,提供了最有价值的Tombstone,精细还原现场。完整展示崩溃线程的进程信息、信号信息、寄存器信息,还原崩溃现场汇编指令,详细的maps、fd和内存信息。

  • 高性能日志库,做到数据稳定性强、性能好,保障了现场业务信息的高度还原。

804db56a42a804657c26a60e5f2b154a.png46c44e25b6c29c6fd58c56e29810b924.png

扫码申请免费试用

👇 点击阅读原文,了解更多

用 Addon 增强 Node.js 和 Electron 应用的原生能力

作者 ByteDanceTech
2023年11月24日 12:02

前言

Node.js Addon 是 Node.js 中为 JavaScript 环境提供 C/C++ 交互能力的机制。其形态十分类似 Java 的 JNI,都是通过提供一套 C/C++ SDK,用于在 C/C++ 中创建函数方法、进行数据转换,以便 JavaScript / Java 等语言进行调用。这样编写的代码通常叫做 Bindings。

此外还有基于 C ABI Calling Convention (例如 stdcall / System-V 等标准) 直接进行跨语言调用的方案,例如 Rust FFI、Python 的 ctypes、Node.js 的 ffi 包等。这两者的差别在于 Rust 等原生语言是直接针对平台来将函数调用编译为机器码,而 ctypes 和 ffi 包则是基于 libffi 动态生成机器码来完成函数调用的。和 Node.js Addon 的差别则在于调用和类型转换的开销上。

本文将围绕 Node.js Addon 进行介绍,即创建一个 Bindings 来增强 Node.js 或 Electron 应用的原生能力,使其可以和系统进行交互,或者使用一些基于 C/C++ 编写的第三方库。

Node.js Electron 的关系

Electron 在主进程和渲染进程中都包含了完整的 Node.js 环境,因此本文既适用于 Node.js 程序,也适用于 Electron 程序。

Node.js Addon 的类型

在 Node.js 的 Addon,有三种类型:

dc401e6cb8bf2d8dfe1eb643b8420c0b.png

本文主要介绍 Node-API 的原理,以及以 node-addon-api 作为例子。

Node-API 基本原理

4467c6b1fe7336efa658380ee1797d0b.png

Node.js 本质上是一个动态链接库(即 Windows 下的 .dll 文件、MacOS 下的 .dylib 文件、Linux 下的 .so 文件),只不过在分发时会将文件的扩展名改为 .node

加载

Node.js Addon 通常通过 CommonJS 的 require 函数进行导入和初始化。require 在被 .node 扩展名路径作为参数进行调用的情况下,最终会利用 dlopen(Windows 下是 LoadLibrary)方法来动态加载这个以 .node 扩展名的动态链接库:

a2f5c61c11d1269b6e9c8f34504b56da.png

初始化

以 https://github.com/nodejs/node-addon-examples/blob/main/1_hello_world/napi/hello.c 作为参考:

static napi_value Init(napi_env env, napi_value exports) {
  napi_status status;
  napi_property_descriptor desc = DECLARE_NAPI_METHOD("hello", Method);
  status = napi_define_properties(env, exports, 1, &desc);
  assert(status == napi_ok);
  return exports;
}

NAPI_MODULE(NODE_GYP_MODULE_NAME, Init)

NAPI_MODULE 宏用来绑定一个 C 函数作为初始化函数。这个函数中可以用来给模块的 exports 对象添加所需要的功能。

例如上述的代码中,给 exports 添加了一个叫做 hello 的函数。这样一来,我们在 Node.js 中 require 这个模块之后,就能获得到一个包含 hello 函数的 exports 对象:

b562b57293da43c8b8238981b5410b97.png

调用

以 https://github.com/nodejs/node-addon-examples/blob/main/1_hello_world/napi/hello.c 作为参考:

static napi_value Method(napi_env env, napi_callback_info info) {
  napi_status status;
  napi_value world;
  status = napi_create_string_utf8(env, "world", 5, &world);
  assert(status == napi_ok);
  return world;
}

Method 本身是一个 C 函数,接受 napi_env 作为 JavaScript 的上下文信息。napi_callback_info 作为当前函数调用的信息,例如函数参数等。返回一个 napi_value 作为函数的返回结果。

从这个函数的例子中可以看到,在 C 中是可以获取到函数的调用参数,并且产生一个值作为函数的返回结果。稍后我们会以 node-addon-api 作为例子来具体介绍其编写方式。

cb989e00d6a2daa04ed0cd54335f397d.png

模块编写指南

本节介绍使用 C++ 配合 node-addon-api 开发模块时常见的一些模式和样板代码,仅供参考。

更多用法详见官方文档:https://github.com/nodejs/node-addon-api/blob/main/doc/hierarchy.md

模块初始化

使用 NODE_API_MODULE 宏绑定一个 C++ 函数进行模块初始化:

Napi::Object Init(Napi::Env env, Napi::Object exports) {
  exports.Set(Napi::String::New(env, "hello"),
              Napi::Function::New(env, Method));
  return exports;
}

NODE_API_MODULE(hello, Init)
  • 其中 Napi::Env 是对 napi_env 的封装,代表一个 JavaScript 上下文,大部分和 JavaScript 交互的场景都需要这个上下文,可以保存起来以供下次使用(但是不要跨线程使用)。

  • Napi::Object exports 则是这个模块的 exports 对象,可以把想要给 JavaScript 暴露的值和函数都设置到这个上面。

创建 JavaScript 函数

首先需要创建一个如下函数签名的 C++ 函数:

Napi::Value Add(const Napi::CallbackInfo& info) {
  Napi::Env env = info.Env();
  double arg0 = info[0].As<Napi::Number>().DoubleValue();
  double arg1 = info[1].As<Napi::Number>().DoubleValue();
  Napi::Number num = Napi::Number::New(env, arg0 + arg1);
  return num;
}

其中函数的返回值可以是任何派生自 Napi::Value 的类型,也可以是 Napi::Value 本身。

获取函数参数

通过 Napi::CallbackInfo& 来获取函数参数,例如 info[0] 代表第一个参数。

info[n] 会获取一个 Napi::Value 值,我们需要调用它的 As<T> 方法来转换为具体的值,我们才能将它继续转换为 C/C++ 可用的数据类型。例如,我们希望将函数的第一个参数转换为字符串,我们需要经过两个步骤:

  1. 将 Napi::Value 转换为 Napi::String:

Napi::String js_str = info[0].As<Napi::String>();
  1. 将 Napi::String 转换为 std::string

std::string cpp_str = js_str.Utf8Value();

其他数据类型例如 Napi::NumberNapi::Buffer<T> 均有类似的方法。

返回函数结果

我们可以直接创建一个 JavaScript 值并在 C++ 函数中返回。具体创建值的方法详见下一小节。

创建 JavaScript 值

我们可以利用各种实例化方法,来从 C/C++ 的数据类型中创建 JavaScript 的值,下面举几个常见的例子。

创建字符串
Napi::String::New(env, "字符串内容")
创建数字
Napi::Number::New(env, 123)
创建 Buffer

创建 Buffer 是一个有风险的操作。Node-API 提供了两种创建方式:

  • 提供一个指针和数据长度,创建一个数据的拷贝

    • ✅ 安全,首选这种方法

    • ✅ v8 会负责这个 Buffer 的垃圾回收

Napi::Buffer::Copy(napi_env env, const T* data, size_t length)
  • 直接基于指针和数据长度创建一个 External Buffer

    • ⚠️ 同一个指针(相同的内存地址)只能创建一个 Buffer,重复创建会引起错误

    • ⚠️ v8 / Node.js 不负责这个 Buffer 的内存管理

Napi::Buffer::New(napi_env env, const T* data, size_t length)

异步代码

异步函数

异步函数通常用于实现一些异步 IO 任务、事件,例如实现一个异步网络请求库的绑定。

异步函数通常有两种实现方式:回调 和 Promise。

同线程回调

同线程回调的使用场景比较少:

  • 使用了 libuv 来运行了一些异步任务,并且这个异步任务会在 libuv 主线程唤醒事件循环来返回结果,这时候可以比较安全地直接进行同线程回调。但是要求事先把 Napi::Env 保存在一个地方。

  • 实现一个函数的时候,在实现中直接同步调用一个 Napi::Function。

获取函数

通常我们会从函数调用的参数中获取到 Napi::Function,一般来说我们需要在当次调用就把这个函数给使用掉,避免后续被 v8 GC 回收。

持久化函数

如果我们确实需要在之后的其他时机去使用函数,我们需要将它通过 Napi::Persistent 持久化:

Napi::FunctionReference func_persist = Napi::Persistent(func);

使用时,可以作为一个正常的函数去使用。

调用函数

无论是 Napi::Function 还是 Napi::FunctionReference,我们都可以通过 Call 方法来调用:

Napi::Value ret_val = func_persist.Call({
  Napi::String::New(env, "Arg0")
});
跨线程回调

跨线程回调是比较常见使用场景,因为我们通常会想在另外一个线程调用 JavaScript 函数。

使用线程安全函数 (ThreadSafeFunction)

为了在其他线程中调用 JavaScript 函数,我们需要基于 Napi::Function 去创建一个 Napi::ThreadSafeFunction

Napi::ThreadSafeFunction tsfn = Napi::ThreadSafeFunction::New(
  env,                     // Napi::Env
  info[0].As<Function>(),  // JavaScript 函数
  "handler",               // 异步函数的名称,用于调试的识别
  0,                       // 队列最大大小,通常指定为 0 代表没有限制。如果队列已满则可能会导致调用时阻塞。
  1                        // 初始线程数量,通常指定为 1。实际上是作为内存管理使用。可参考这篇文档。
);

接着就可以把 tsfn 保存在任何位置,并且并不需要同时保存一份 Napi::Env

调用线程安全函数

调用线程函数有两种形式,一种是同步调用,另一种是异步调用。

同步调用

同步调用指的是如果我们限制了 ThreadSafeFunction 的队列大小,并对其进行了多次调用,从而创建了许多调用任务,则会导致队列已满,调用就会被阻塞,直到成功插入队列后返回结果。

这是进行一次同步调用的例子:

const char* value = "hello world";
napi_status status = tsfn.BlockingCall(value, [](Napi::Env env, Napi::Function callback, const char* value) {
  Napi::String arg0 = Napi::String::New(env, value);
  callback.Call({ arg0 });
});

这样一来就能顺利地在任意线程去调用 JavaScript 函数。

但是我们发现,实际上我们并不能同步地获取函数调用的返回结果。并且 Node-API 或者 node-addon-api 都没有提供这么一种机制。但是我们可以借助 libuv 的信号量来达到这个目的。

uv_sem_t sem;
uv_sem_init(&sem, 0);
const char* value = "hello world";
Napi::Value ret_val;
napi_status status = tsfn.BlockingCall(value, [&ret_val](Napi::Env env, Napi::Function callback, const char* value) {
  Napi::String arg0 = Napi::String::New(env, value);
  *ret_val = callback.Call({ arg0 });
  uv_sem_post(&sem);
});
uv_sem_wait(&sem);

// 直至 JavaScript 运行结束并返回结果,才会走到这里
// 这里就可以直接使用 ret_val 了

异步调用

异步调用则会在队列已满时直接返回错误状态而不进行函数调用。除此之外的使用方法同 “同步调用” 完全一致:

const char* value = "hello world";
napi_status status = tsfn.NonBlockingCall(value, [](Napi::Env env, Napi::Function callback, const char* value) {
  Napi::String arg0 = Napi::String::New(env, value);
  callback.Call({ arg0 });
});
Promise
C++ 中创建 Promise 给 JavaScript 使用

我们通常会需要在 C++ 中实现异步函数。除了直接用上面已经介绍的基于回调的方法之外,我们还可以直接在 C++ 中创建一个 Promise。

Promise 只支持同 线程 调用

由于 Promise 并未提供跨线程 Resolve 的方式,因此如果希望在其他线程对 Promise 进行 Resolve 操作,则需要结合 libuv 来实现。此方法比较繁琐,建议转而使用跨线程回调函数。如果读者感兴趣,后续本文可以补充相关内容。

我们可以直接创建一个 Promise,并在函数中返回:

Napi::Value YourFunction(const Napi::CallbackInfo& info) {
  Napi::Promise::Deferred deferred = Napi::Promise::Deferred::New(info.Env());

  // 我们可以把 env 和 Napi::Promise::Deferred 保存在任何地方。
  // deferred_ 会在 Resolve 或者 Reject 之后释放。
  env_ = info.Env();
  deferred_ = deferred;

  return deferred.Promise();
}

接着我们可以在其他地方调用 Napi::Promise::Deferred 来完成 Promise。注意,这里一定需要在主线程中调用:

// 返回成功结果
deferred_.Resolve(Napi::String::New(info.Env(), "OK"));
// 返回错误
deferred_.Reject(Napi::String::New(info.Env(), "Error"));
C++ 中使用来自 JavaScript 的 Promise

由于 Node-API 或者 node-addon-api 均没有提供使用 Promise 的封装,因此我们需要像在 JavaScript 中通过 .then 手动使用 Promise 的方式,在 C++ 中使用 Promise。

// 首先需要定义两个函数,用来接受 Promise 成功和失败
Napi::Value ThenCallback(const Napi::CallbackInfo &info) { 
  Napi::Value result = info[0];
  // result 是 Promise 的返回结果
  return info.Env().Undefined();
}
Napi::Value CatchCallback(const Napi::CallbackInfo &info) { 
  Napi::Value error = info[0];
  // error 是 Promise 的错误信息
  return info.Env().Undefined();
}

Napi::Promise promise = async_function.Call({}).As<Napi::Promise>()
Napi::Value then_func = promise.Get("then").As<Napi::Function>();
then_func.Call(promise, { Napi::Function::New(env, ThenCallback, "then_callback") });
Napi::Value catch_func = promise.Get("catch").As<Napi::Function>();
catch_func.Call(promise, { Napi::Function::New(env, CatchCallback, "catch_callback") });

显然这种使用方式是比较繁琐的,我们也可以通过一些办法使其可以将 C++ Lambda 作为回调函数来使用,但是本文暂时不涉及这部分内容。

异步任务

异步任务通常是利用 libuv 提供的线程池来运行一些 CPU 密集型的工作。而对于一些跨线程异步回调的 Bindings 实现则直接使用 ThreadSafeFunction 即可。

具体使用可以参考:https://github.com/nodejs/node-addon-api/blob/main/doc/async_worker.md

Node-API 的构建

基本构建配置

Node.js Addon 通常使用 node-gyp 构建,这是一个基于 Google 的 gyp 构建系统实现的构建工具。至于为何是 gyp,因为 Node.js 是基于 gyp 构建的。

我们来看一个 node-addon-api 项目的构建配置,以 bindings.gyp 命名:

{
  "targets": [
    {
      "target_name": "hello",
      "cflags!": [ "-fno-exceptions" ],
      "cflags_cc!": [ "-fno-exceptions" ],
      "sources": [ "hello.cc" ],
      "include_dirs": [
        "<!@(node -p "require('node-addon-api').include")"
      ],
      'defines': [ 'NAPI_DISABLE_CPP_EXCEPTIONS' ],
    }
  ]
}

具体配置可以参考官方使用文档:https://gyp.gsrc.io/docs/UserDocumentation.md

一些常识:

  • "sources" 中需要包含所有 C/C++ 代码文件,不需要包含头文件

  • "<!@(node -p "require('node-addon-api').include")" 在使用 Node-API 还是 node-addon-api 的情况下是不同的。

  • "target_name" 通常需要修改为你希望使用的扩展名称,它会影响编译产物的名称。

常用构建命令

  • node-gyp rebuild 重新构建,会清理掉已有的构建缓存,推荐每次都使用这个命令来构建产物,避免出现奇怪的问题

    • 可以添加 --arch <ARCH> 参数来指定构建的目标架构,例如希望构建一个 32 位的产物,则可以使用 --arch ia32 来构建。

  • node-gyp clean 清理构建缓存。如果希望使用 node-gyp build 来进行构建的话,需要善用 clean 功能。

实用构建配置

添加头文件目录
'include_dirs': [
  'win32/x64/include'
]
在 Windows 下进行动态链接 / 静态链接
'libraries': [
  'some_library.lib'
]
  • 对于动态链接,需要指定 .dll 对应的 .lib 文件,并在分发的时候将 .dll 放在 .node 相同的目录下。

  • 对于静态链接,则直接指定 .lib 文件即可。但是在 Node.js Addon 中进行静态链接是一个比较费劲的事情,因为通常涉及到对其他静态依赖的管理,需要谨慎选择此方案。

在 Windows 下设置 C++ 版本
'msvs_settings': {
  'VCCLCompilerTool': {
    'AdditionalOptions': [
      '/std:c++20'
    ]
  }
}
在 Windows MSVC 下构建支持代码文件中的 UTF-8 字符(中文注释等)

本质上是给 MSVC 的编译器添加一个 /utf-8 参数

'msvs_settings': {
  'VCCLCompilerTool': {
    "AdditionalOptions": [
      '/utf-8'
    ]
  }
}
在 MacOS 下进行动态链接 / 静态链接
'link_settings': {
  'libraries': [
    '-L<动态库或静态库所在的文件夹>',
    '-l<动态库名称>'
  ]
}
在 MacOS 下引入系统 Framework 依赖
'libraries': [
  '-framework MediaPlayer',
  '-framework Cocoa',
]
在 MacOS 下设置 C++ 版本
"cflags_cc": [
  "-std=c++20"
]
在 MacOS Xcode 的 Release 构建下生成 .dSYM 调试文件
'xcode_settings': {
  'DEBUG_INFORMATION_FORMAT': 'dwarf-with-dsym'
}
使 MacOS 下的 addon 能够使用同目录下的动态库 / Framework
'link_settings': {
  'libraries': [
    '-Wl,-rpath,@loader_path',
    ## 此外,还可以设置到任何相对于 .node 文件的其他目录下
    '-Wl,-rpath,@loader_path/../../darwin/arm64',
  ]
},

但是这也要求 .dylib 文件支持该功能,可以通过 otool -D <你的动态链接库位置>.dylib 的返回结果来检查:

<你的动态链接库>.dylib:
@rpath/<链接库名称>.dylib

如果文件名往前的开头是 @rpath,则意味着支持该功能。如果不是,则可以使用 install_name_tool 来修改动态链接库使其支持:

install_name_tool -id "@rpath/<链接库名称>.dylib" <你的动态链接库位置>.dylib
在 MacOS 下支持 Objective-C 和 C++ 混编
'xcode_settings': {
  'OTHER_CFLAGS': [
    '-ObjC++'
  ]
}

开发&分发&使用

项目文件组织

通常来说,我们可以用下面的文件夹结构来扁平地组织我们的 addon 文件:

.
├── node_modules                   ## npm 依赖
├── build                          ## 构建路径
│   ├── Release                   ## Release 产物路径
│       ├── myaddon.node          ## addon 产物
│       ├── myaddon.node.dSYM     ## addon 的符号文件
├── binding.gyp                    ## 构建配置
├── addon.cc                       ## Addon 的 C++ 源码
├── index.js                       ## Addon 的 JavaScript 源码
├── index.d.ts                     ## Addon 的 TypeScript 类型(下方会介绍)
└── package.json                   ## Addon 的 package.json 文件

当然我们也可以把 JavaScript 源码和 C++ 源码分别放入不同的文件夹,只需要修改对应的构建配置和 package.json 即可。

编写 index.js - 使用 bindings 包

一般来说我们会直接在 C++ 中实现大部分逻辑,JavaScript 文件只用来引入 .node 文件。由于 Node.js Addon 存在各种不同的方案、构建配置,因此 .node 文件产物的位置可能也会因此不同,所以我们需要借助一个第三方 npm 包来自动为我们寻找 .node 文件的位置:

https://github.com/TooTallNate/node-bindings

通过 bindings,我们的 index.js 仅需一行代码就能自动获取并导出 .node 模块:

module.exports = require('bindings')('binding.node')

同时保证 package.json 的 main 配置为我们的 index.js:

{
  // ...
  "main": "index.js"
  // ...
}

为 Addon 添加 TypeScript 类型

添加 TypeScript 类型,最简单的方式只需要创建一个 index.d.ts 文件,并在其中声明在 C++ 代码中创建的函数们即可:

export interface FooOptions {
  bar: string
}

export function foo(options: FooOptions)

并在 package.json 添加一行参数用于指向类型文件:

{
  // ...
  "types": "index.d.ts"
  // ...
}

大部分情况下,这个方法就可以给你的 Node.js Addon 声明类型。

分发形式

安装时编译

一种方式是在使用者进行 npm install 时,使用用户设备进行 Addon 的编译。这时候我们可以使用 install 钩子来实现,我们仅需在 package.json 文件中添加如下内容:

{
  // ...
  "scripts": {
    // ...
    "install": "prebuild-install || node-gyp rebuild --release"
    // ...
  }
  // ...
}

保险起见,确保 node-gyp 在你的 devDependencies 之中,这样就能在用户通过 npm 安装你的 Addon 时,自动编译当前系统架构所对应的产物。

预编译

如果希望更近一步,节约用户安装 Addon 的时间,或者是为了让用户无需具备编译环境即可安装 Addon,可以使用预编译方案。即在集成环境中提前编译常见的操作系统、架构对应的 .node 文件,并随着 npm 包进行分发,再通过 bindings 或者其他一些库来自动匹配寻找系统所需要的对应 .node 文件。

由于预编译方案涉及到更多的细节,本文不再做介绍,大家可以参考该项目:

https://github.com/mapbox/node-pre-gyp

火山引擎 ByteHouse 的增强型数据导入技术实践

作者 ByteDanceTech
2023年11月17日 12:01

作为企业数字化建设的必备要素,易用的数据引擎能帮助企业提升数据使用效率,更好提升数据应用价值,夯实数字化建设基础。

数据导入是衡量OLAP引擎性能及易用性的重要标准之一,高效的数据导入能力能够加速数据实时处理和分析的效率。作为一款OLAP引擎,火山引擎云原生数据仓库ByteHouse源于开源ClickHouse,在字节跳动多年打磨下,提供更丰富的能力和更强性能,能为用户带来极速分析体验,支撑实时数据分析和海量离线数据分析,具备便捷的弹性扩缩容能力,极致的分析性能和丰富的企业级特性。

随着ByteHouse内外部用户规模不断扩大, 越来越多用户对数据导入提出更高的要求,这也为ByteHouse的数据导入能力带来了更大的挑战。

本篇文章来源于ByteHouse产品专家在火山引擎数智平台(VeDI)主办的“数智化转型背景下的火山引擎大数据技术揭秘”线下Meeup的演讲,将从ByteHouse数据库架构演进、增强HaKafka引擎实现方案、增强Materialzed MySQL实现方案、案例实践和未来展望四个部分展开分享。

ByteHouse数据库的架构演进

作为一款分析型数据库,ByteHouse已经应用在互联网、金融、汽车领域,帮助企业实现人群洞察、行为分析、 IOT 风控等场景的实时分析。

ByteHouse的演进

  • 从2017年开始,字节内部的整体数据量不断上涨,为了支撑实时分析的业务,字节内部开始了对各种数据库的选型。经过多次实验,在实时分析版块,字节内部决定开始试水ClickHouse。

  • 2018年到2019年,字节内部的ClickHouse业务从单一业务,逐步发展到了多个不同业务,适用到更多的场景,包括BI 分析、A/B测试、模型预估等。

  • 在上述这些业务场景的不断实践之下,研发团队基于原生ClickHouse做了大量的优化,同时又开发了非常多的特性。

  • 2020年, ByteHouse正式在字节跳动内部立项,2021年通过火山引擎对外服务。

  • 截止2022年3月,ByteHouse在字节内部总节点数达到18000个,而单一集群的最大规模是2400个节点。

ByteHouse的架构

ByteHouse架构分为分布式架构和云原生架构两种。分布式架构的主要特点就是单集群可以支持 2000 多个节点的“大兵团”;通过分布式的并行计算体现的高性能,能够充分利用每个节点的计算和存储资源;云原生实现了存算分离,计算资源通过容器化进行弹性和秒级的扩容,这对业务是无感知的。

从分布式架构来看,ByteHouse具备MPP 1.0特点:

  • 存算一体:通过本地存储能够保证它极致的这种查询性能。

  • 自研的表引擎:包含 HaMergeTree和 HaUniqueMergeTree。

  • 在社区 RBO 优化器的基础上增强 RBO 加 CBO 的结合的查询优化,并基于 CBO 的分布式计划能够在集群模式下计算全局最优的查询计划。

  • 支持数据的冷热分存,同时兼顾性能和成本。

  • 增强关键的数据类型,从而优化查询性能。

  • 通过统一的管控面提供可视化的管理查询和运维,从内到外给用户提供优质的使用体验。

7fdc2254405d63ab983322e9386236db.png

但MPP 1.0存在资源隔离、扩容等痛点,由此演进到云原生架构,即MPP 2.0:其中存算分离通过结合 shared-everything 存储和 shared-nothing 计算层,避免了传统 MPP 架构中数据重新分配 (re-sharding) 的问题。

好处在于:

  • 更好地实现资源隔离。每个用户不同的计算都提交到不同的计算组,并进行计算资源和存储资源的扩容,再结合按量计费的计费策略可以降低用户使用成本。

  • 底层存储既支持HDFS,也支持 S3 对象存储,能够让 ByteHouse实现真正的云原生。

7798a70bdf5421f634e7c5c67e8e7bf0.png

ByteHouse技术优势

在增强型数据导入场景中,ByteHouse核心优势体现在自研表引擎:

  • 在社区版的基础上,ByteHouse对表引擎做了进一步增强,使其能够实现开源的ClickHouse所做不到的场景。

  • 高可用引擎,相比社区高可用引擎,可以支持表的数量更多,集群的规模更大,稳定性会更高。

  • 实时数据引擎,相比社区实时数据引擎,消费能力更强,支持 at least once 的语义,排除单点写入的性能故障。

  • Unique引擎,相比社区Unique引擎,ByteHouse没有更新延迟问题,能够实现真正实时的 upsert。

  • Bitmap 引擎,在特定的场景比如用户圈选圈群的场景中支持大量的交并补操作,能够使整体的性能提升 10 - 50 倍以上。

4cc9d467414bbf9c650ad5a2082700ca.png

这里具体再介绍一下ByteHouse自研引擎的优势——与导入密切相关的表引擎。

首先,ByteHouse 提供的HaMergeTree方案能够降低 ZK 负载,提升可承载的数据量级。

  • ClickHouse 社区版本: 社区提供的ReplicatedMergeTree表引擎让 ClickHouse 实现了从单机到集群的演进,通过ZK节点来同步并维护两个MergeTree之间的元数据和数据。痛点在于,在 TB 级的数据量级之下, ZK 重复地进行分发日志和数据交换等操作,极大地增加了ZK的压力,使ZK 成为整个集群的故障点。

  • ByteHouse 自研HaMergeTree: 将元数据的同步和数据的同步解耦,ZK只负责元数据的同步,而数据的同步是通过 LogExchange 来实现,在两个MergeTree之间进行对等拷贝。优势在于,降低了 ZK 的负载,即使是承载 PB 级的数据量,集群也能够平稳地运行。

其次, ByteHouse 提供的HaMergeTree方案能平衡读写性能。

  • ClickHouse 社区版本: 提供ReplacingMerge Tree实现了对唯一键的支持;使用Merge-on-read的实现逻辑,在不同批次的数据中包含着相同的 key ,需要在读时做合并,让相同的 key 返回最新的版本。痛点在于,数据存在延迟、滞后,降低读的性能。

  • ByteHouse 自研的HaUniqueMergeTree: 引入了 delete bitmap 的组件在数据插入时即标记删除,然后在数据查询时过滤掉标记删除的数据。优势在于,整体上平衡了读和写的性能,保障了读取时性能一致性。

增强HaKafka引擎实现方案

HaKafka 引擎架构介绍

社区版Kafka 优势 : 由于社区版ClickHouse是一个分布式结构,其数据分布在多个Shard上,Kafka引擎可以在多个Shard上去做并发的写入,而在同一个Shard内可以启动多线程做并发写入,并具备本地盘的极致的性能读写。

社区版 Kafka 不足 :

  • 在内外部业务的场景中,会经常遇到唯一键场景,由于社区版本的 Kafka的 high level 的消费模式(这种模式就决定无法预知数据被写入到哪一个Shard上),所以很难满足这一类场景。

  • 社区版的 Kafka 引擎没有高可用,即使ClickHouse是多副本的,在当一个节点发生宕机时,无法保证另一个节点继续消费。

HaKafka引擎架构(分布式架构)

保持社区版本两级并发两大的优化点:

  • 引入高可用,让备节点处于 stand-by 的状态,一旦主节点发生宕机,备节点立刻继续进行消费。

  • 升级为low-level的消费模式,当数据写入的时候,相同的 key 会写到相同的 partition 里面,保证在同一个Shard下支持的唯一键场景。

ByteHouse增强HaKafka引擎核心功能实现

  • 高可用(主备切换)

在备节点上起一个 stand by的consumer ,通过 ZK 来进行选组,选到的主节点进行消费,当主节点发生宕机或者无法进行服务时,在秒级之内切换到备节点上,让备节点继续消费。

假设现在 replica 1因为故障宕机了,无法继续进行消费,那么Z K能在秒级内把 replica 2 选为leader。replica 2 随即会立即启动相同数量的消费者,启动之后会继续从 replica 1 的消费位置开始继续进行消费。

b245807531972c6468dd87a11e0cddf0.png
  • 替换节点

随着集群规模的增大,节点数越来越多的情况下,不可避免地遇到节点故障,这个时候就需要替换节点。

对于分布式架构,替换节点一个重要的操作就是拷贝数据,在拷贝数据的时候意味着新的节点的数据是不全的,如图示,示意图 replica 1为新替换的节点,还处于数据拷贝的状态,即数据是不全,如果此时实施消费的 leader 起在了 replica 1,就意味着 最新的消费数据会写进 replica 1,但是它缺失一部分旧的数据。

而replica 2有旧的数据,它的最新数据还需要从replica 1进行拷贝,那这个时候下载之内没有一个副本上面的数据是完整的,所有的节点就不可能对外提供服务。

这时HaKafka会做强制限制,如果 replica 1是一个新节点,且还在拷贝数据的状态,那么就会强制把 leader 切换成 replica 2,由 replica 2 继续去消费最新的数据,replica 1保持继续拷贝数据,这样可以保证在节点替换的过程中至少有一个副本是能够正常提供服务。

3af3e3554840676b5da9fbd94d7b0e13.png
  • Memory table

不同于社区的Memory Table和底层存储绑定,ByteHouse的Memory Table是和Hakafka绑定的,主要使用在有百列或者千列的大宽表的场景。

对于ClickHouse来说,每一次导入的写的文件的数量和列数是成正比的。如果列很多,但是每批次写入的数据量不大,这时每一次写入就会造成很多的碎片,这对于 IO的消耗会比较高,写入的性能其实也会比较差。

针对这种情况,考虑使用Memory Table,让写不直接落盘,每一次写先写到Memory Table中,攒到一定的批次或者到达一定的时间之后再一次性刷盘。

当数据写入Memory Table之后,就可以对外提供查询服务了,因为 memory table 是跟 Kafka 绑定的,在同一个下的内是唯一的。当查询来了之后,只需要路由到对应的消费节点下 the Memory Table,就能保证了数据查询的一致性。

29468f9d8f44112388eefa97a847f2d1.png
  • 云原生架构增强

分布式架构的痛点在于:

1.节点故障: 字节的集群规模较大,每周/每天会遇到节点故障的问题,需要进行节点替换,是一个比较大的负担。

2.读写冲突问题: 随着集群的接入的业务越来越多,数据量越来越大的情况下,每一个节点同时承担着查询和写入的操作,之间会有冲突。

3.扩容成本: 唯一键的场景对数据分布要求非常严格,扩容在这种场景下很难支持,因为扩容之后partition的映射关系发生了变化。

云原生架构优点在于,存算分离、自动扩容、自动容错轻量级的扩缩容等,因为云原生支持事物,让我们可以将消费语义增强到exactly once。

67a419d263a389390064edd5d5a426b1.png
  • 在云原生架构下的 Kafka 引擎是如何通过事务来实现 exactly once:

    • 事务保证: 因为云原生架构有事务的支持,所以每一轮的消费都需要有事物来保证。因为 Catalog 的元信息和 Catalog 元信息的交互是在 Server 端进行的,所以第一步会通过 RPC 的请求向 Server 端请求创建消费事务,然后客户端创建正常,创建消费事务之后会把 transaction ID 给consumer, consumer 拿到这种全声音 ID 之后就可以开始正常地消费了。之后它就会从分配到的 partition 里面不停地消费数据,当消费到足够的数据量或者消费满足一定的时间时,它就会把消费的这数据转换为对应的 part 文件并dump到存储层。在 dump 之后,数据是不可见的,因为这个时候的 transaction 还没有提交,所以在第五步的时候,还是会通过一个 RPC 的 call 把刚才 dump 的元信息消费的 offseed 提交到 catalog 中。这一步是一个原子性的提交,也是我们的消费语义升级从 at least once 到 exactly once 的一个核心关键点

    • 容错保证: 因为manager 和它具体之间的任务是在不同的节点上的,所以需要有一定的这种容错机制。当前是让 manager 和 task 之间保持一种一个双向的心跳机制来保证,比如说manager每隔 10 秒钟会去探活一次,看看当前任务是否正常运行,如果没有在正常运行,它就会重新拉起一个新的task。而对于 task 来说,它每一次的消费都会有两次的 RPC call 和 Server 端做交互,这两次的 RPC 交互都会向 manager 去校验自身的有效性,如果校验到自己当前是一个失效的状态,它就会把自己 kill 掉,从而保证整个全局的唯一任务的运行。

    • b6c99b8a6d78a58d78f10fa4d10b4170.png

    • Memory Buffer : 与社区相似,Memory Buffer和底层的存储表绑定。因为都是写入底表的,不仅Kafka的导入可以用,Flink的导入也可以用。memory buffer 的使用场景是高频的小批量的导入场景,因为每一次导入都会写一个part,不停地写 part 会对集群产生压力。而 ClickHouse 的话,对 ClickHouse 来说 part 越多性能越差,所以使用 memory buffer 来缓存小批量的数据,到达一定批次之后再进行导入。首先需要有一个事务的保证,才能保证导入的完整性和一致性。另外它需要有WAL,因为首先把数据要先写到 WAL 中,数据写入到 WAL 中之后,就认为导入成功了,因为 WAL 本身也是一个持久化的存储,数据写入 WAL 之后,再将数据写入到 memory buffer。当数据写入了 memory buffer 之后就可以对外提供查询服务。

14373f20d52ff57d85e52bf2dfe9ec24.png

增强 Materialzed MySQL 实现方案

社区版 Materialzed MySQL 介绍

物化 MySQL 将MySQL的表映射到 ClickHouse 中, ClickHouse 服务会读取binlog,并执行 DDL 和 DML 的请求,实现了这种基于实现了基于 MySQL binlog 的实时 CDC 同步。它的架构很简单,不依赖于数据同步工具,使用自身的资源就能将整个 MySQL 的数据库同步到 ClickHouse中,并且时效性很好,因为实时同步的延时一般在秒级、毫秒级到秒级之间。

社区版本的这种物化MySQL 在很大程度上去解决了 MySQL 数据库到 ClickHouse 之间的这种实时同步。在实际业务、实际场景中,遇到不少问题

1.社区版本的物化MySQL,它是不支持同步到分布式表,也不支持跳过DDL,缺乏这些功能就很难将数据库的引擎应用到实际生产中。

2.社区版本的物化 MySQL 不支持在数据同步发生异常时进行辅助,发生异常的时候发起重新同步的命令,它没有同步的日志信息和没有同步的状态信息,缺少了这些信息会导致同步发生异常的时候,很难在短期内把这些任务重新启动。

基于这些问题和痛点, ByteHouse在社区版本的物化 MySQL 的基础之上做了一些功能增强易用性,降低了运维成本,让数据的同步更加稳定。

f33a4915316704180584a1dcc7cf5aec.png

ByteHouse的物化 MySQL 结合了HaUniqueMergeTree表引擎:结合这样的表引擎之后,它就能够实现数据的实时去重能力,同时它能够支持分布式的能力,我们通过底层的中间的参数优化,比如 include tables、 exclude tables、 SKIP DDL 等等能够允许用户自定义同步的表的同步范围。

通过下 model 这样的一个参数,能够支持分布式表的同步,然后通过 Rethink 参数的设置支持将额外增加的表启动独立的数据同步任务去进行 CDC 同步,在出现异常的时候,我们也支持跳过这种不支持的 DDL 语句。另外,可以通过系统日志的抓取和展示进行可视化的运维。

b55fa18ca669b3383c9667b84253d45d.png

ByteHouse增强Materialzed MySQL核心功能实现

  • 实时去重 / 分布式

社区版的物化 MySQL 使用的是ReplacingMergeTree,每一个同步任务都会将源端的 MySQL 数据库同步到 ClickHouse 的某一个节点上面,它不支持按照分片逻辑将数据分布到所有的节点,也无法利用 ClickHouse 整个集群的分布式计算和存储能力,所ByteHouse的物化MySQL 支持分布式地同步利用。我们利用HaUniqueMergeTree表引擎,将每张表同步到对应的分布式节点上,充分利用集群的这种分布式计算能力,同时通过表引擎的实时 upsert 能力来实现快速地去重。

2a53fab2322eb3f290bb8d7cbcc870b8.png
  • 异步 Resync

这里有三个对象, SYNC manager是用来管理主 SYNC 线程和 Resync 线程,然后 SYNC task 和 resync task 各自管理各自的任务。比如说一个 MySQL 的库有 100 张表,我们选了 50 张表进行同步,所以在同步进行过程中,当 think task 同步到 binlog 的 position 位置,比如到 1000 的时候,用户修改了配置之后,它增加了 30 张表。增加 30 张表的时候, SYNC manager 就会启动 Resync task 去同步另外 30 张表,那这个时候 SYNC task 是继续执行的;RESYNC task 会从 position 0 开始,它先做全量的同步,然后再做增量的同步。所以当到达某一个阶段,比如说 sync task 跑到了 position 1500 的时候, resync task 跑到了 position 1490 的时候,这时 SYNC manager 就会去判断两者的误差,这个 position 的误差在一定的阈值之内,在一定阈值之内之后,它会将 SYNC task 停止1秒钟,将 RESYNC task 合并到 SYNC task 中。合并成功之后,这 80 张表就都会通过SYNC task 继续同步,而 RESYNC task 这个任务就会被停止掉。这就是现在 RESYNC task 做了一个能力实现。

65376143e24d75201374df9a54d91781.png
  • 可视化运维

通过可视化的任务监控和任务启停异常的重启任务告警这些方式实现了物化 MySQL 的可视化易用性的极大提升。

a8c895e43093b20d3282ff01fb00aa91.png

案例实践与未来展望

案例一:短视频直播

该场景下的数据是批流一体写入,为了维护和管理抖音创作者的数据,并且面向这种业务运营和达人经营提供数据查询服务,需要将短视频和直播的实时数据和离线数据做融合,来构建 B端的数据分析。

  • 问题: 首先,创作者是唯一的,需要我们进行数据去重。第二,数据源是比较多样化的,所以它整个字段超过 4000 +,是典型的大宽表场景。第三,T+1的数据,T+1数据离线同步后,T+0数据要对它进行更新。第四,是对任何指标的实时查询需要秒级出结果,这是业务面临的问题。

  • 解决方案: 第一,我们采用了自研的 Unique表引擎来做实时的去重,并且能够让数据在写入时就可以实时去重、实时查询。第二,通过 Kafka 引擎的 memory table 来实现大宽表数据先缓存,到达了一定的批次之后再集中刷盘。通过对 Byte house 的优化方案有效地解决了碎片化、IO负载高的问题,能够支持 10 亿+创作数据实时写入和实时查询。

5c2d8acabca7418bdbac43760951f4f3.png

案例二:营销实时数据的监控

营销实时监控是对业务营销活动效果的实时查询和实时回收,希望通过这种实时回收来动态调整奖品的实时发放策略来做到最终的 IOR、ROI 的提升。这就要求数据实时写入、落盘延时要非常低,对数据处理的性能也有很高的要求。在数据传送上面需要保证数据传输的唯一性,以保证奖品不会重复发放,也不会丢失。

  • 解决方案: 我们在方案上首先采用自研的Kafka 引擎来支持流式数据的实时写入,实时写入便实时入库。通过 low-level 的这种消费来保证数据的有序分片,再通过增强的消费语义 exactly once 保证数据的精准一次传输。最后我们通过自研的Unique引擎来实现实时的这种 upsert 的语义,让数据实时写入、实时去重。通过 ByteHouse 方案的优化,营销业务的每一个节点的实时性能达到了 30 MB/s/node,分析性能也是在秒级的延时,让运营人员能根据不同用户群,实时发放奖励,并秒级地监控奖品发放的进展,从而调整奖品的发放策略。

69fca8fcca854a78dd6da68aba65dd28.png

案例三:游戏广告的数据分析

游戏广告数据分析是在广告业务中会做一些人群圈选、广告投放、效果反馈等投放策略,用户行为预测这些全流程的统计和监控来实现广告营销过程的数字化,提升整个广告游戏投放的 ROI 。

  • 问题: 业务数据和日志数据要求实时写入、实时去重,由于体量比较大,所以写入压力和查询压力都比较大。

  • 解决方案: 首先使用 Kafka 引擎来支持流式数据写入,通过 low level 消费模式保障数据的有序分片,再通过 Unique引擎来实现数据的唯一性,并且实时地去重。在业务数据方面,我们使用物化MySQL来保障业务数据从 MySQL 到 ByteHouse 之间能够实时同步。最后使用自研的查询优化器来优化查询性能,通过ByteHouse 的优化之后,广告效果分析从原来的小时级提升到了现在的秒级延时数据查询的性能,单线程同步 20+MB/s ,并且整个查询性能提升了 3 倍,用户的收益和体验得到了明显的改善。

5b3d38baa0c51dfeb775da6579205b7c.png

未来战略:全链路和一体化

  • 端到端。从语法转换、数据迁移,到数据校验,形成完整的全链路方案。

  • 一体化。通过DES的逻辑复制能力实现 TP / AP 的一体化,同时实现数据仓库和数据集市的一体化。

  • 资源隔离。支持用户使用共享资源池或者数据库引擎来进行数据的同步,也支持用户通过独享的资源池来进行高效数据同步。

  • 多引擎方案。除了基于 ClickHouse 引擎的基础能力,我们也会去探索更多的底层引擎能力来增强ByteHouse的数据同步。

打造企业级智能问答系统的秘密:如何使用云数据库 PostgreSQL 版实现向量检索

作者 ByteDanceTech
2023年11月15日 11:23

本文就如何利用火山引擎云数据库 PostgreSQL 版和大语言模型技术(Large Language Model,简称 LLM),实现企业级智能交互式问答系统进行介绍。通过本文,你将会了解交互式问答系统的原理,学习 PostgreSQL 的向量化存储和检索技术,以及大语言模型交互技术等。

背景

在大数据的浪潮下,众多企业建立了自己的知识库,以便于信息检索和知识查询。然而,随着知识库内容的膨胀,传统的信息检索方式变得低效,经常出现费时费力且结果不尽人意的情况。随着生成式人工智能(AI Generated Content,简称 AIGC)的出现,人们看到了一种更智能的实现方式,通过问答的方式,知识获取的效率、准确性和用户体验在多方面得到提升。

即便如此,对于特定垂直领域的企业,生成式人工智能的局限性也开始显现,例如大模型训练周期长、对某一领域专业知识掌握不足等,这常常会导致 AI“幻觉”问题的出现(即 AI 的“一本正经地胡说八道”)。为了解决这一难题,我们通常会采用以下两种方式:

  • Fine Tune 方法,“驯服”大语言模型。

    • 利用领域知识,对大语言模型进行监督微调(Supervised Fine Tune)和蒸馏(Distillation)。这种方式可塑性强,但需要大量的算力和人才资源,综合成本高。此外,企业还需要持续监控和更新模型,以确保与不断变化的领域知识保持同步。

  • Prompt Engineering 方法,改变“自己”。

    • 该方法基于向量数据库,补充足够的对话上下文和参考资料,完善与大语言模型进行交互的问答问题(Prompt),其本质是将大语言模型的推理归纳能力与向量化信息检索能力相结合,从而快速建立能够理解特定语境和逻辑的问答系统。该方法的实现成本相对较低。

接下来,本文针对 Prompt Engineering 方法,来演示将云数据库 PostgreSQL 版作为向量数据库的使用方法。

核心概念及原理

嵌入向量(Embedding Vectors)

向量 Embedding 是在自然语言处理和机器学习中广泛使用的概念。各种文本、图片或其他信号,均可通过一些算法转换为向量化的 Embedding。在向量空间中,相似的词语或信号距离更近,可以用这种性质来表示词语或信号之间的关系和相似性。例如,通过一定的向量化模型算法,将如下三句话,转换成二维向量(x,y),我们可通过坐标系来画出这些向量的位置,它们在二维坐标中的远近,就显示了其相似性,坐标位置越接近,其内容就越相似。如下图所示:

“今天天气真好,我们出去放风筝吧”
“今天天气真好,我们出去散散步吧”
“这么大的雨,我们还是在家呆着吧”
f0c827048f9329a4477c54e337cd9dd9.png

Prompt Engineering 过程原理

如上所说,使用者需要不断调整输入提示,从而获得相关领域的专业回答。输入模型的相关提示内容越接近问题本身,模型的输出越趋近于专业水平。通俗理解就是,模型能够利用所输入的提示信息,从中抽取出问题的答案,并总结出一份专业水准的回答。整个 Prompt Engineering 工作流程如下图所示:

c79b683824ac1b92bf324b935cd54b2e.jpeg

其大致可以分为两个阶段:向量构建阶段和问答阶段。在向量构建阶段,将企业知识库的所有文档,分割成内容大小适当的片段,然后通过 Embeddings 转换算法,例如 OpenAI 的模型 API(https://platform.openai.com/docs/guides/embeddings/what-are-embeddings),将其转换成 Embeddings 数据,存储于云数据库 PostgreSQL 版向量数据库中,详细流程如下图所示:

d2860632bbc52a861981ecec844d3f8c.png

在问答阶段,问答系统首先接收用户的提问,并将其转化为 Embedding 数据,然后通过与向量化的问题进行相似性检索,获取最相关的 TOP N 的知识单元。接着,通过 Prompt 模板将问题、最相关的 TOP N 知识单元、历史聊天记录整合成新的问题。大语言模型将理解并优化这个问题,然后返回相关结果。最后,系统将结果返回给提问者。流程如下图所示:

c5245476fdd03d97e4a9e93253ca074c.png

实现过程

接下来将介绍如何利用云数据库 PostgreSQL 版提供的 pg_vector 插件构建用于向量高效存储、检索的向量数据库。

前置条件

  • 已创建 ECS 实例,或者使用本地具备 Linux 环境的主机,作为访问数据库的客户端机器。

  • 请确保您具备 OpenAI Secret API Key,并且您的网络环境可以使用 OpenAI。

训练步骤

本文将以构建企业专属“数据库顾问”问答系统为例,演示整个构建过程。使用的知识库样例为https://www.postgresql.org/docs/15/index.html,脚本获取方式详见文末。

搭建的环境基于 Debian 9.13,以下方案仅供参考,环境不同依赖包安装有所差异。

以下过程包括两个主要脚本文件,构建知识库的 generate-embeddings.ts,问答脚本 queryGPT.py,建议组织项目目录如下所示:

.
├── package.json                              // ts依赖包
├── docs
│   ├── PostgreSQL15.mdx                      // 知识库文档
├── script
│   ├── generate-embeddings.ts                // 构建知识库
│   ├── queryGPT.py                           // 问答脚本

1. 学习阶段

1. 创建 PostgreSQL 实例

登录云数据库 PostgreSQL 版控制台(https://console.volcengine.com/db/rds-pg)创建实例,并创建数据库和账号。关于创建 PostgreSQL 实例、数据库、账号的详细信息,请参见云数据库 PostgreSQL 版快速入门(https://www.volcengine.com/docs/6438/79234)。

2. 创建插件

进入测试数据库,并创建 pg_vector 插件。

create extension if not exists vector;

创建对应的数据库表,其中表 doc_chunks 中的字段 embedding 即为表示知识片段的向量。

-- 记录文档信息
create table docs (
  id bigserial primary key,
  -- 父文档ID
  parent_doc bigint references docs,
  -- 文档路径
  path text not null unique,
  -- 文档校验值
  checksum text
);
-- 记录chunk信息
create table doc_chunks (
  id bigserial primary key,
  doc_id bigint not null references docs on delete cascade, -- 文档ID
  content text, -- chunk内容
  token_count int, -- chunk中的token数量
  embedding vector(1536), -- chunk转化成的embedding向量
  slug text, -- 为标题生成唯一标志
  heading text -- 标题
);
3. 构建向量知识库

在客户端机器上,将知识库文档内容,分割成内容大小适当的片段,通过 OpenAI 的 embedding 转化接口,转化成embedding 向量,并存储到数据库,参考脚本获取方式详见文末。

注意 该脚本只能处理 markdown 格式的文件。

安装 pnpm:

curl -fsSL https://get.pnpm.io/install.sh | sh -

安装 nodejs(参考https://github.com/nodesource/distributions):

sudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
NODE_MAJOR=16
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | sudo tee /etc/apt/sources.list.d/nodesource.list
sudo apt-get update
sudo apt-get install nodejs -y

安装 typescript 依赖,配置文件 package.json 获取方式详见文末:

pnpm run setup
pnpm install tsx

修改 generate-embeddings.ts,设置 OpenAI 的 key、PG 的连接串以及 markdown 文档目录:

#这里需要将user、passwd、127.0.0.1、5432 替换为实际数据库用户、密码、数据库地址、端口
const postgresql_url = 'pg://user:passwd@127.0.0.1:5432/database';
const openai_key = '-------------';
const SOURCE_DIR = path.join(__dirname, 'document path');

运行脚本,生成文档 embedding 向量并插入数据库:

pnpm tsx script/generate-embeddings.ts

运行过程:

af2a3f118e1775fe373b8ee77cfb7122.png

脚本运行后,我们查看下所构建的知识库。查询 docs 表:

3fef4ee682bee002a3e41ae5e8ddb41a.png

查询 docs_chunk 表,批量导入向量成功:

e7b12d787ed81eb6ca93f6dcc7ad0b76.png

2. 问答阶段

1. 创建相似度计算函数

为了方便应用使用,使用 PostgreSQL 的自定义函数功能,创建内置于数据库内的函数。应用只需调用 PostgreSQL,该函数便可在应用程序中获取向量匹配结果。示例中使用“内积”来计算向量的相似性。

create or replace function match_chunks(chunck_embedding vector(1536), threshold float, count int, min_length int)
returns table (id bigint, content text, similarity float)
language plpgsql
as $$
begin
  return query
  select
    doc_chunks.id,
    doc_chunks.content,
    (doc_chunks.embedding <#> chunck_embedding) * -1 as similarity
  from doc_chunks

  -- chunk内容大于设定的长度
  where length(doc_chunks.content) >= min_length

  -- The dot product is negative because of a Postgres limitation, so we negate it
  and (doc_chunks.embedding <#> chunck_embedding) * -1 > threshold
  order by doc_chunks.embedding <#> chunck_embedding
  
  limit count;
end;
$$;
2. 提问及回答

以下 Python 程序,可以接收提问者问题,并实现上述 Prompt Engineering 的“问答阶段”的功能,最终将具备“逻辑思考”+“深度领域知识”的解答,发送给提问者。

import os, psycopg2, openai

def query_handler(query = None):
    if query is None or query == "":
        print('请输入有效问题')
        return

    query = query.strip().replace('\n', ' ')
    embedding = None
    try:
        # 使用 GPT 将提问转化为 embedding 向量
        response = openai.Embedding.create(
            engine="text-embedding-ada-002",  # 固定为text-embedding-ada-002
            input=[query],
        )
        embedding = response.data[0].embedding
    except Exception as ex:
        print(ex)
        return

    content = ""
    con = None
    try:
        # 处理 postgres 配置,连接数据库
        # host:127.0.0.1,port:5432,user:test,password:test,database:test
        params = postgresql_url.split(',')
        database, user, password, host, port = "test", "test", "test", "127.0.0.1", "5432"
        for param in params:
            pair = param.split(':')
            if len(pair) != 2:
                print('POSTGRESQL_URL error: ' + postgresql_url)
                return
            k, v = pair[0].strip(), pair[1].strip()
            if k == 'database':
                database = v
            elif k == 'user':
                user = v
            elif k == 'password':
                password = v
            elif k == 'host':
                host = v
            elif k == 'port':
                port = v
        # connect postgres
        con = psycopg2.connect(database=database, user=user, password=password, host=host, port=port)
        cur = con.cursor()
        # 从数据库查询若干条最接近提问的 chunk
        sql = "select match_chunks('[" + ','.join([str(x) for x in embedding]) + "]', 0.78, 5, 50)"
        cur.execute(sql)
        rows = cur.fetchall()
        for row in rows:
            row = row[0][1:-2].split(',')[-2][1:-2].strip()
            content = content + row + "\n---\n"

    except Exception as ex:
        print(ex)
        return

    finally:
        if con is not None:
            con.close()

    try:
        # 组织提问和 chunk 内容,发送给 GPT
        prompt = '''Pretend you are GPT-4 model , Act an database expert.
        I will introduce a database scenario for which you will provide advice and related sql commands.
        Please only provide advice related to this scenario. Based on the specific scenario from the documentation,
        answer the question only using that information. Please note that if there are any updates to the database
        syntax or usage rules, the latest content shall prevail. If you are uncertain or the answer is not explicitly
        written in the documentation, please respond with "I'm sorry, I cannot assist with this.\n\n''' + "Context sections:\n" + \
        content.strip().replace('\n', ' ') + "\n\nQuestion:"""" + query.replace('\n', ' ') + """"\n\nAnswer:"

        print('\n正在处理,请稍后。。。\n')
        response = openai.ChatCompletion.create(
            engine="gpt_openapi",  # 固定为gpt_openapi
            messages=[
                {"role": "user", "content": prompt}
            ],
            model="gpt-35-turbo",
            temperature=0,
        )
        print('回答:')
        print(response['choices'][0]['message']['content'])

    except Exception as ex:
        print(ex)
        return

os.environ['OPENAI_KEY'] = '-----------------------'
os.environ['POSTGRESQL_URL'] = 'host:127.0.0.1,port:5432,user:test,password:test,database:test'
openai_key = os.getenv('OPENAI_KEY')
postgresql_url = os.getenv('POSTGRESQL_URL')
# openai config
openai.api_type = "azure"
openai.api_base = "https://example-endpoint.openai.azure.com"
openai.api_version = "2023-XX"
openai.api_key = openai_key

def main():
    if openai_key is None or postgresql_url is None:
        print('Missing environment variable OPENAI_KEY, POSTGRESQL_URL(host:127.XX.XX.XX,port:5432,user:XX,password:XX,database:XX)')
        return
    print('我是您的PostgreSQL AI助手,请输入您想查询的问题,例如:\n1、如何创建table?\n2、给我解释一下select语句?\n3、如何创建一个存储过程?')
    while True:
        query = input("\n输入您的问题:")
        query_handler(query)
        
if __name__ == "__main__":
    main()

先修改 90、91 行的 OpenAI 的 key 和 PG 的连接串,为实际 key 和连接地址:

os.environ['OPENAI_KEY'] = '-----------------------'
os.environ['POSTGRESQL_URL'] = 'host:127.0.0.1,port:5432,user:test,password:test,database:test'

然后修改 GPT 的参数:

openai.api_type = "azure"
openai.api_base = "https://example-endpoint.openai.azure.com"
openai.api_version = "2023-XX"

其次通过修改机器人自我介绍,以让提问者快速了解问答机器人的专业特长,这里的自我介绍,说明机器人是一个数据库专家的角色。

prompt = '''Pretend you are GPT-4 model , Act an database expert.
        I will introduce a database scenario for which you will provide advice and related sql commands.
        Please only provide advice related to this scenario. Based on the specific scenario from the documentation,
        answer the question only using that information. Please note that if there are any updates to the database
        syntax or usage rules, the latest content shall prevail. If you are uncertain or the answer is not explicitly
        written in the documentation, please respond with "I'm sorry, I cannot assist with this.\n\n''' + "Context sections:\n" + \
        content.strip().replace('\n', ' ') + "\n\nQuestion:"""" + query.replace('\n', ' ') + """"\n\nAnswer:"

最后安装脚本依赖:

pip install psycopg2-binary
pip install openai
pip install 'openai[datalib]'

测试过程:

197d1b6314fb879eeb4b439440a8f0df.jpeg

到此为止,您就获得了一个企业级专属智能问答系统。

方案优势

相较于其他向量数据库,借助火山引擎云数据库 PostgreSQL 版提供的 pg_vector 插件构建的向量数据库具有如下优势:

  • 使用便捷易上手: 无需专业 AI 专家介入,无需构建其他大规模复杂分布式集群,只需要一个数据库实例,便可构建专用向量数据库。使用接口兼容现有 SQL 语法,不需要定制化调度框架、终端。

  • 性价比高: 可使用已有数据库实例,不需要额外购买其他庞大的集群资源。

  • 数据实时更新可用: 向量数据可以在毫秒级实现新增、更新,并且依然具备事务属性,无需担心数据的错乱。

  • 支持高并发,扩展容易: 在向量化场景可支持数千 TPS;在性能出现瓶颈时,可以通过一键扩展只读节点,轻松实现整体吞吐的瞬间提升。

  • 支持向量维度高: pg_vector 还具备支持向量维度高的特点。最多可支持 16000 维向量,能够满足绝大部分向量化存储、使用场景。

关注「字节跳动数据库」公众号回复【脚本】即可获取参考代码。

点击下方阅读原文,了解云数据库PostgreSQL版。

抖音大型直播的画质优化实践

作者 ByteDanceTech
2023年11月3日 10:30

面临挑战

随着抖音内容生态的不断丰富,越来越多的大型赛事在抖音平台进行直播,世界杯/春晚/亚运会等各项赛事节目引来大量用户观看。卡塔尔世界杯期间,抖音提供的稳定高质直播画面为观众带来了完美的观赛体验,决赛的 PCU 高达 3700W+。不同赛事节目涉及链路众多,且不同赛事之间存在差异,如何保障各链路的画质稳定并进一步提升画质,是一个巨大的挑战。

如何应对挑战?

画质优化链路

大型赛事直播涉及链路较长,不同赛事链路存在一些差异,整体可简化为下图流程,现场信号经过演播室的制作传输给 CDN 再进一步分发到用户侧。从画质角度来看整个链路可分为画质检测与画质优化两个部分,对于 CDN 之前的链路以画质监测为主,以发现问题/定位问题/推动对应链路人员解决问题为目的。画质优化在 CDN 和客户端两侧进行,下面的内容主要介绍画质优化部分。

ac2703d341f0d10a18f86f1ad5f92833.png

随着赛事录制技术的提升,越来越多的大型赛事都用上了4K HDR录制标准,画质清晰度也不断提升,随之而来的是更大的带宽压力。同时为了兼容消费端不同的看播设备和不同的带宽条件,服务端需要转出多种不同分辨率不同码率的版本供看播端选择,为了保障用户在不同带宽与设备下都能取得最佳的画质体验,我们做了大量的优化工作。团队通过自研的自适应 ToneMapping,视频降噪,ROI编码,视频插帧,BAS采样,端上超分等算法有效地提升了赛事画质。

自适应ToneMapping: 目前大型赛事大都使用HDR(高动态范围)设备录制,团队对支持 HDR 看播的设备增加了 HDR 档位,同时提供了多种不同分辨率/帧率的档位。HDR 拍摄的片源拥有更广的色域,更大的动态范围。但对很多终端显示设备而言,并不支持 HDR 信号播放,所以通过 ToneMapping 算法将 HDR 信号转换为 SDR(标准动态范围)信号是十分必要的。

cd9c238f45d83813e0b41ebf9cf39312.png

相比 SDR 信号,HDR 信号拥有更广的色域和更大的动态范围,在转换到 SDR 信号的过程中不可避免会产生一些信息损失。常用的一些 ToneMapping 方法,不论是 Reinhard,Filmic 或者 Hable,其本质都是设计固定的映射曲线实现从 HDR信号 到 SDR信号的转换,同时尽量保持对 HDR 效果的还原。但直播赛事场景多变,且现场动态范围跨度极大,如世界杯比赛中场馆的灯光/草地/球员亮度差异明显,不同镜头跨度极大,而在亚运会游戏类比赛中的CG画面较为稳定,现有的ToneMaping算法无法在多变的场景中取得优秀稳定的效果,而手动调节每场比赛的转换参数也不现实。为了解决这一问题,团队提出了内容自适应 ToneMapping 算法,通过统计视频内容的实际光照情况动态地进行 ToneMapping,从而得到更优效果。

左: 内容自适应 ToneMapping,右: Hable 算法

66ea1fa307b302f1a34b79d2f0e3ccfd.png96f32696110b32e2a57353ed0afb3f9d.png

在主观众测中优化之后的内容自适应 ToneMapping 算法遥遥领先于现有的TonaMpaping算法结果(对照任务为团队自研结果)

c3e97eb66e04db405737d6c4df1a46d6.png

BAS 采样: BAS(Byte AI Scaling)算法是字节自研的一种基于深度学习的图片/视频下采样算法,近些年来,深度学习驱动的视频处理算法已经广泛应用于各类点播、直播服务中,涵盖抖音、西瓜视频等诸多业务线。在实际的流媒体传输链路中,依据用户实际网络延迟、终端性能等因素,源流将通过自适应码率(Adaptive Bit-Rate)策略传输到终端设备,优化用户实际体验。其中,视频流往往会被采样到多个标准分辨率,例如蓝光(1080p)、高清(720p)、标清(480p)等。随着音视频行业和摄影设备的发展,高分辨率的视频源占比日益增多,大部分视频需要在服务端进行降采样来配合自适应码率策略,因此降采样算法的优化也是提升QoE的关键。在过去的业界实践中,视频处理算法往往专注于提高分辨率(如超分算法)或者保持分辨率(如降噪算法)的处理范式,而几乎忽视了对降低分辨率方法的研究。不同于固定算子的bicubic等降采样算法,BAS算法基于深度学习使用高精度数据训练模型,缓解传统方法带来的频域混叠与频域截断问题,降低锯齿感、减少细节丢失。如下图所示,对于4K的超高清图源降采样到480p分辨率的任务,左图为BAS算法处理结果,右图为传统bicubic算法处理结果。可以明显看到,BAS算法处理结果中缓解了边缘锯齿(左下),消除了摩尔纹(右下),并且灯牌、观众席等方面的细节纹理更加清晰,视觉观感更好。

左图为BAS采样结果,右图为Bicubic采样结果

48be67076a2122369ead85a80a9cdcd6.png

在与bicubic算法的定量对比中,BAS基于PSNR指标取得了-20.32%的BD-Rate收益,意味着相同重建误差水平下可以节省20%以上码率,而同等码率下则可以提升画质水平。而对于更符合人眼感知特性的VMAF指标,BAS同样取得了-20.89%的BD-Rate收益。

fecf6b47a4401478dd6e0ffe571f4ec3.png

在常用的编码条件下,BAS算法在UGC视频上能做到在降低6.12%平均码率的同时,提升多项关键主客观画质指标,既可以降低一部分传输带宽,也可以带来画质上的提升,取得成本和体验上的双赢。

51db69ca13806d229abc620f8f421132.png

视频插帧: 抖音大型赛事实践中会遇到各种不同的录制标准,其中也存在1080P 25fps的录制标准,现在消费者已经习惯高帧率的流畅视频体验,对于低帧率的视频会明显感受到画面的流畅度降低,影响用户观看体验。针对低帧率场景,我们使用了智能插帧技术,通过对前后帧的内容进行光流估计,根据光流信息将前后帧像素都转换到中间帧,然后进行整合,生成中间帧,提升视频帧率,减少观看时的卡顿感。而针对电竞类对帧率要求较高的场景,我们做了以下的额外优化。

c526af172db6a3ba1adcd5148ad7e5d3.png

faster光流模块和faster修正模块使用partial conv代替普通卷积,能在保持效果的同时减少卷积运算;在计算光流时,采用内容自适应下采样去对输入进行下采样,用于计算光流、残差和遮挡掩码, 再将其上采回原分辨率,用于原始输入的warp和整合 ,由于光流模块和修正模块这两个运算较多的模块接收的是较小的分辨率,从而达到进一步减少计算量的效果;工程上,团队通过算子融合、半精度的方式,减少IO和浮点运算,相比工程化前加速1倍多。同时,通过多GPU部署的方式拓展了智能插帧的能力,使得在更高分辨率(4k)的场景下能实施部署。另一方面,电竞场景中,比如王者荣耀,每个英雄上面都会有选手的名字,而这些文字较小,文字会随着英雄的复杂运动而运动,也就是会导致出现小文字的复杂运动,智能插帧通常会在这些复杂运动的小文字上因为光流估计不够准确而导致插出来的帧文字的位置不够准确,导致伪像出现,我们在训练过程中加入更多的随意移动或者静止的较小文字,使得模型能够在训练过程中更多地注意处理小文字的复杂运动,从而达到更好的插帧效果,如下图所示,左边为优化后的插帧结果。

左边为优化后结果,右边为优化前结果

6721088f36d6af6e07df8f2ce11d7809.png

ROI 编码: 为了兼顾视频码率和主观画质,团队使用了基于 LSTM(长短期记忆网络)的时域 ROI 技术,通过人眼显著性区域检测和编码相结合的方式,让码率在画面上的分配更加合理。除了模型设计之外,ROI算法中另一大难点是saliency(显著性物体检测)数据的获取,通用的saliency数据集在大型赛事中的表现并不理想。针对这一问题,团队收集制作了自己的专用数据集,并且对一些大型赛事做了专用数据集,例如针对世界杯赛事,团队专门制作了足球场景的 saliency 数据集,通过眼动仪追踪球迷观看球赛时的关注区域得到足球比赛的专用 saliency 数据集,从而极大增加了模型的准确性。针对足球场景中显著性物体较多,显著性区域分散的特点,团队对检测模型进行了专门的优化,在保证检测速度的前提下,提高了模型的召回率和不同场景的鲁棒性,从而实现更优的主观质量。

注:红色框内表示 ROI 区域,左边为通用方案结果,右边为优化结果

5d335881db7db4d99aa0e5d828bf4de4.pngef8f28dad645bd8c76a40eea1bb71183.png

同时团队使用视频降噪算法,根据视频信息对其进行空域、时域噪声的去除,将带有噪声的视频处理成干净、没有噪声的视频。由于去除了视频的噪声,在提升视频质量的基础上同时降低了传输的码率。由于用户侧网速的限制,端上存在多个档位,当看播端网速较慢时,可能会切换到 480P/720P 等低分辨档位,此时会触发端上超分算法提升画面清晰度。超分辨率技术指的是,基于机器学习/深度学习方法,根据视频信息对其进行空域、时域建模重构出缺失的细节,将低分辨率的视频重建出高分辨率视频的技术。这样即使是在低分辨档位也能体验到更清晰的画质。

左:视频降噪前,右:视频降噪后

cafa26630eea4f92d8b1a39d2466e106.png

除此之外团队还提供大分辨率、高帧率、广色域,并使用色彩增强、自适应锐化等多种画质增强技术,呈现更加沉浸感的超高清画面。

左:未过端上超分,右:经过端上超分

bb068718232cc593222b65542a296bf4.png

抖音直播新一代BVC编码器正式亮相

作者 ByteDanceTech
2023年11月1日 10:30

面临挑战

在直播行业发展如火如荼的今天,用户对视频体验的要求也水涨船高。视频基础体验的关键要素包括清晰度、流畅度、低延迟等,而这些要素的“第一性原理”,就是视频本身的编码效率,也就是压缩率。视频编码是整个技术体系的基座,编码效率的显著提升,能够在同等码率下极大提高画质,从而改善用户体验。

视频编码效率的重要性不言而喻,但进一步地提升也并非易事,尤其在直播场景中,对编码速度、延迟、码率控制等方面都有很高的要求。如何在保证画质不变的情况下,显著提高压缩率,同时满足实时性、低延迟的要求,是一个持续的技术挑战。

如何完成挑战

新一代编码器的采用

抖音基于BVC编码器,曾在世界杯中给数亿观众带来了极致的视频体验。而本次亚运会中,火山引擎多媒体实验室自研的新一代BVC编码器首次得到抖音直播全链路支持并在直播中使用。BVC编码器曾经在业界编码器大赛MSU中斩获多项指标的第一,具有行业领先的编码和计算性能,并还在持续不断地优化迭代中。新一代BVC编码器相比上一代,能在画质不变的情况下,显著降低码率,提升用户体验,降低带宽成本。

新一代BVC编码器在直播场景的优化

作为新一代编码器,引入了大量新的编码工具和算法,在显著降低码率的同时,也具有相当高的计算复杂度。而在直播场景中使用最新BVC编码,首先需要对计算复杂度进行大幅度的优化,才能达到实时性和低延迟的要求。

更极致、简洁的工程架构

首先,新一代BVC编码器在直播场景下,对所有编码工具和算法进行了测试,按照性价比筛选出了在直播的编码速度要求下能够投入使用的工具和算法集合。而基于这个集合重新设计轻量级的架构,能最大化减少计算流程损耗。新的编码器架构对整个编码流程进行了重新梳理,去除原先复杂的情况耦合,为特殊工具单独设计流程,实现了编码流程的最简化。同时,对数据结构也进行了更极致的优化,显著减少了数据量,提升了访存效率。此外,还通过大量的计算结果缓存及复用的机制,减少了重复计算,以及设计了高效的数据交换机制,减少了数据拷贝。在计算模块的优化上,挖掘了更多的计算流程整理为SIMD实现,同时对原有的SIMD实现进行了进一步优化,从而减少指令数。基于直播场景的编码器架构优化,在算法基本不变的前提下,为新一代BVC编码器节省了超过30%的复杂度。

灵活、精细化的并行框架

为了在计算复杂度提高的情况下,仍然能实现实时编码,充分利用多核处理器的能力至关重要。新一代BVC编码器针对直播场景进行并行框架的重新设计。首先将前处理、预分析、编码等过程并行起来,并在任务调度上分配合适的优先级,从而最大降低编码前的等待。对于编码过程的线程等待,精确计算等待的条件,并将条件限制降低到最小,从而降低等待时延。此外,基于帧内和帧间并行编码的模型,根据编码时依赖关系准确分配每个编码任务的线程优先级权重,对线程进行灵活而精细化地调度。经过优化,新一代BVC编码器的CPU利用率提升50%以上。

上百个快速算法

除了工程架构之外,新一代BVC编码器还增加了大量的快速算法,从而达到高分辨率、高码率和高帧率下的实时编码。新一代BVC编码器重构了编码块划分的框架,根据周围块和历史划分信息,自适应决策划分深度的范围,大幅减少了无效的划分尝试,从而降低编码复杂度。在模式决策中,为每个模块设计大量精细的初选快速算法,从而大幅减少最终尝试的模式数量。此外,还对前处理和预分析模块也进行了大量的简化处理。新一代BVC编码器为直播场景增加的上百个快速算法,在压缩率的损失较小前提下将整体编码速度提高了2倍以上。

亚运会的针对性优化

除了编码器内核本身的优化之外,新一代BVC编码器还对亚运会进行了专项优化。亚运会除了传统的运动项目之外,还增加了关注度较高的电竞项目。而新一代BVC编码器也对运动、游戏这两种场景进行针对性的优化。研发团队进行了大量的测试,对不同视频分辨率和复杂度下进行了编码档位的适配,调整了数十个编码参数来控制不同编码算法在运动、游戏场景中的性价比,在获得压缩率提高的同时实现了编码加速。此外,还对码率控制进行了调优,减少了高运动复杂场景中画面模糊的情况。

优化成果

新一代BVC编码器在直播场景中实现了1080P 50FPS的实时编码,在画质不变的情况下,相比上一代编码器实现了20%左右的码率节省。实际效果如下:(对比展示,上面是上一代BVC编码视频,下面是新一代BVC编码视频)

868f1fcf6738aeb5fc4b8d9aea541a75.pnge71d24ff5b46804ce2bd58e45b530d65.png

和广泛应用的开源编码器x265(v3.5)对比,新一代BVC编码器也具有显著优势,下图展现了性能对比数据。可以看出,在编码设置对齐的情况下(帧结构、码控方式、lookahead长度等),新一代BVC编码器,对于亚运会中的运动和游戏视频内容,平均能实现40%以上的码率节省,同时编码速度更快。

c0920984bc63fd18120da34b4859501a.png

Go Metrics SDK Tag 校验性能优化实践

作者 ByteDanceTech
2023年10月30日 10:39

背景

Metrics SDK 是与字节内场时序数据库 ByteTSD 配套的用户指标打点 SDK,在字节内数十万服务中集成,应用广泛,因此 SDK 的性能优化是个重要和持续性的话题。本文主要以 Go Metrics SDK 为例,讲述对打点 API 的 hot-path 优化的实践。

用户在使用 SDK API 进行打点时,需要传入指标对应的 Tag:

tags := []m.T{{Name: "foo", Value: "a"}, {Name: "bar", Value: "b"}}
metric.WithTags(tags...).Emit(m.Incr(1))

SDK 内部需要对用户传入的 Tag Value 的合法性进行校验,IsValidTagValue,是 SDK 中对 Tag Value 进行字符合法性校验的 util 函数,在对内部一些用户的业务使用 pprof 拉取 profile 时,发现这两个函数的 CPU 消耗占整个打点 API 过程的10%~20%,由于该函数发生在打点 API 的 hot-path 上,因此有必要对其进行进一步优化。

4dd97db7f5036b7cda9ff59442d149df.png

分析

当前实现

我们先看一下 IsValidTagValue 函数内部的实现方式,是否有可优化的点。当前的实现,对于通过 API 传入的每一个Tag Value,会进行以下操作来判断其合法性:

  • 先判断是否是在 Letter、Number 的范围内,是则直接通过;

  • 存储所有允许的特殊字符白名单,遍历 Tag Value 对比其每个字符是否在白名单内。

var (
   // these runes are valid in tag values
   whiteListRunes = []rune{'_', '-', '.', '%', ':', ' ', '[', ']', ',', '%',
      '/', ':', ';', '<', '=', '>', '@', '~'}
)

func IsValidTagValue(s string) bool {
   if len(s) == 0 || len(s) > maxTagLen {
      return false
   }

   for i, r := range s {
      if r < minValidChar || r > maxValidChar {
         return false
      }

      if unicode.IsLetter(r) || unicode.IsNumber(r) || isRuneInWhiteList(r) {
         continue
      }
      return false
   }
   return true
}

该实现的时间复杂度简单分析如下:

对于由 Letter、Number 这样的合法字符构成的字符串(大部分场景),其时间复杂度是:

对于全由特殊字符构成的字符串,其时间复杂度是:

整个字符串的时间复杂度将介于 到之间

问题点

可以看到,从当前实现看,一个主要影响性能的点是白名单列表的循环遍历对比操作,我们需要考虑可能的优化方式来降低这个操作的时间复杂度。

优化

优化一:使用 Lookup Table,空间换时间

Metrics SDK 所有允许的合法的字符,实际上是 ASCII 的一个子集,也就是说其所有可能的字符最多只有128个,因此,我们可以通过空间换时间的方式,将对白名单的 O(n) 遍历操作转换为 O(1) 的查表操作:

  1. 提前对这128个字符建立一个包含128个成员的数组,在每一个 offset 上标记对应字符是否合法(合法则标记为1),这样就建立了一个快速的 lookup table

  2. 对于要校验的每一个字符,只要将其转化为数组 offset,直接取数组成员值判断是否为1即可

589d7ed31d2feb3c4f803bdfed340aca.png
image.png
table := [128]uint8{...}
// fill flags
for i := 0; i < 128; i++ {
   if unicode.IsNumber(rune(i)) || unicode.IsLetter(rune(i)) || isRuneInWhiteList(rune(i)) {
      table[i] = 1
   }
}

str := "hello"

for _, char := range []byte(str) {
    if r > maxValidChar {
       return false
    }
    if table[char] != 1 {
        return false
    }
}
return true
Benchmark
goos: linux
goarch: amd64
pkg: code.byted.org/gopkg/metrics_core/utils
cpu: Intel(R) Xeon(R) Platinum 8260 CPU @ 2.40GHz
BenchmarkLookupAlgoValid
BenchmarkLookupAlgoValid/baseline
BenchmarkLookupAlgoValid/baseline-8                   2839345               478.9 ns/op
BenchmarkLookupAlgoValid/lookup-arraytable
BenchmarkLookupAlgoValid/lookup-arraytable-8          6673456               167.8 ns/op

可以看到,速度提升60%

优化二:使用 SIMD,提升并行度

基于 Lookup Table 的校验方式,将字符串校验的时间复杂度稳定在了, 但有没有可能进一步减少对字符串每一个字符的遍历次数,比如一次校验16个字符?

我们知道,SIMD 指令是循环展开优化的常用思路,那么这里是否可以引入 SIMD 来进一步提升运算并行度和效率?

答案是肯定的,以 intel x86 架构为例,参考其 Intrinsics Guide,在不同的 SIMD 指令集上提供了多个可以实现在不同大小的 lookup table 中查找数据的指令,这些指令可以作为我们加速方案的基础:

7d2909a4170ad75b59103cf9a9cb1821.png

注:可以通过 cat /proc/cpuinfo 命令来查看机器支持的simd指令集

鉴于 vpermi2b 指令的支持目前不是很普遍的原因,我们考虑使用 pshufb 来实现一个 SIMD 版本,但我们的Lookup Table 需要调整下,因为:

  • 虽然我们基于 bitmap 实现的 Lookup Table 是 128 bits,刚好可以填充 128 bits 的寄存器

  • 但 pshufb 是按字节进行 lookup 的,128 bits 的寄存器支持16字节的 lookup

因此,我们需要将 bitmap lookup table 做一次升维,变成一个16*8 bits 的二维 lookup table,做两次递进的行、列 lookup 完成查找,基于该思路,可以实现一次校验16个字符,大大提升并行度。

整体方案

该方案主要参考这篇文章:SIMDized check which bytes are in a set(http://0x80.pl/articles/simd-byte-lookup.html)

构建 bitmap table

对于一个 ASCII 字符,我们用其低 4bits 作为 lookup table 的 row index,用高 3bits 作为 lookup table 的 column index,这样对128个 ASCII 字符建立如下的一个二维 bitmap table:

6595ef3693d28c50fe14deec06cdf7d0.png

Lookup 流程

我们先实现一个纯 go 语言版本的基于二维 bitmap lookup table 的方案,以便于理解其中的关键逻辑:

table := [16]uint8{}
// fill flags
for i := 0; i < 128; i++ {
   if unicode.IsNumber(rune(i)) || unicode.IsLetter(rune(i)) || isRuneInWhiteList(rune(i)) {
      lowerNibble := i & 0x0f
      upperNibble := i >> 4
      table[lowerNibble] |= 1 << upperNibble
   }
}

str := "hello"

for _, char := range []byte(str) {
    if r > maxValidChar {
       return false
    }
    lowerNibble := uint8(r) & 0x0f
    upperNibble := uint8(r) >> 4
    if table[lowerNibble]&(1<<upperNibble) == 0 {
       return false
    }
}
return true

如上代码示例,可以看到,判断某个字符合法的关键逻辑是:

  • 通过 table[lowerNibble] 获取table第 lowerNibble 行内容,然后再看其第 upperNibble 个 bit 位是否为0

而 SIMD 版本,即是将上述的每一步操作都使用对应的 SIMD 指令变成对16个字节的并行操作,SIMD 的关键操作流程以及和上述 go 代码的对应关系如下:

e4917f542fb5e1ddd7f57761f4f90d22.png
代码实现

在 go 语言中,想要使用 SIMD,需要写 plan9 汇编,而编写 plan9 通常有两种方式:

  • 手撕,可借助 avo 这样的工具

  • C code 转 plan9,可借助 goat、c2goasm 这样的工具

这里采用 C code 转 plan9 的方式,先写一个 C 版本:

注:由于 goat 工具限制,不能很好的支持 C 代码中的常量定义,因此以下示例通过函数参数定义用到的 sm、hm 常量

#include <tmmintrin.h>

// is_valid_string returns 1 if all chars is in table, returns 0 else.
void is_valid_string(char* table, char* strptr, long strlen, char* sm, char* hm, char* rt) {
    __m128i bitmap = _mm_loadu_si128((__m128i*)table);
    __m128i shift_mask = _mm_loadu_si128((__m128i*)sm);
    __m128i high_mask = _mm_loadu_si128((__m128i*)hm);

    size_t n = strlen/16;
    for (size_t i = 0; i < n; i++)
    {
        __m128i input = _mm_loadu_si128((__m128i*)strptr);
        __m128i rows = _mm_shuffle_epi8(bitmap, input);

        __m128i hi_nibbles = _mm_and_si128(_mm_srli_epi16(input, 4), high_mask);
        __m128i cols = _mm_shuffle_epi8(shift_mask, hi_nibbles);

        __m128i tmp = _mm_and_si128(rows, cols);
        __m128i result = _mm_cmpeq_epi8(tmp, cols);
        size_t mask = _mm_movemask_epi8(result);
        if (mask != 65535) {
            *rt = 0;
            return;
        }
        strptr = strptr + 16;
    }

    size_t left = strlen%16;
    for (size_t i = 0; i < left; i++)
    {
        size_t lower = strptr[i] & 0x0f;
        size_t higher = strptr[i] >> 4;
        if ((table[lower] & (1<<higher)) == 0) {
            *rt = 0;
            return;
        }
    }

    *rt = 1;
    return;
}

通过以下命令转为 plan9:

goat is_valid_string.c -03 -mssse3

生成的 plan9 代码如下:

//go:build !noasm && amd64
// AUTO-GENERATED BY GOAT -- DO NOT EDIT

TEXT ·_is_valid_string(SB), $0-48
   MOVQ table+0(FP), DI
   MOVQ strptr+8(FP), SI
   MOVQ strlen+16(FP), DX
   MOVQ sm+24(FP), CX
   MOVQ hm+32(FP), R8
   MOVQ rt+40(FP), R9
   WORD $0x8949; BYTE $0xd2     // movq   %rdx, %r10
   LONG $0x3ffac149             // sarq   $63, %r10
   LONG $0x3ceac149             // shrq   $60, %r10
   WORD $0x0149; BYTE $0xd2     // addq   %rdx, %r10
   LONG $0x0f428d48             // leaq   15(%rdx), %rax
   LONG $0x1ff88348             // cmpq   $31, %rax
   JB   LBB0_4
   LONG $0x076f0ff3             // movdqu (%rdi), %xmm0
   LONG $0x096f0ff3             // movdqu (%rcx), %xmm1
   LONG $0x6f0f41f3; BYTE $0x10 // movdqu (%r8), %xmm2
   WORD $0x894d; BYTE $0xd0     // movq   %r10, %r8
   LONG $0x04f8c149             // sarq   $4, %r8
   WORD $0xc031                 // xorl   %eax, %eax

LBB0_2:
   LONG $0x1e6f0ff3               // movdqu   (%rsi), %xmm3
   LONG $0xe06f0f66               // movdqa   %xmm0, %xmm4
   LONG $0x00380f66; BYTE $0xe3   // pshufb   %xmm3, %xmm4
   LONG $0xd3710f66; BYTE $0x04   // psrlw    $4, %xmm3
   LONG $0xdadb0f66               // pand %xmm2, %xmm3
   LONG $0xe96f0f66               // movdqa   %xmm1, %xmm5
   LONG $0x00380f66; BYTE $0xeb   // pshufb   %xmm3, %xmm5
   LONG $0xe5db0f66               // pand %xmm5, %xmm4
   LONG $0xe5740f66               // pcmpeqb  %xmm5, %xmm4
   LONG $0xccd70f66               // pmovmskb %xmm4, %ecx
   LONG $0xfffff981; WORD $0x0000 // cmpl $65535, %ecx
   JNE  LBB0_8
   LONG $0x10c68348               // addq $16, %rsi
   LONG $0x01c08348               // addq $1, %rax
   WORD $0x394c; BYTE $0xc0       // cmpq %r8, %rax
   JB   LBB0_2

LBB0_4:
   LONG $0xf0e28349         // andq   $-16, %r10
   WORD $0xb041; BYTE $0x01 // movb   $1, %r8b
   WORD $0x294c; BYTE $0xd2 // subq   %r10, %rdx
   JE   LBB0_9
   WORD $0xc031             // xorl   %eax, %eax

LBB0_7:
   LONG $0x1cbe0f4c; BYTE $0x06 // movsbq (%rsi,%rax), %r11
   WORD $0x8945; BYTE $0xda     // movl   %r11d, %r10d
   LONG $0x0fe28341             // andl   $15, %r10d
   LONG $0x04ebc141             // shrl   $4, %r11d
   LONG $0x0cbe0f42; BYTE $0x17 // movsbl (%rdi,%r10), %ecx
   LONG $0xd9a30f44             // btl    %r11d, %ecx
   JAE  LBB0_8
   LONG $0x01c08348             // addq   $1, %rax
   WORD $0x3948; BYTE $0xd0     // cmpq   %rdx, %rax
   JB   LBB0_7

LBB0_9:
   WORD $0x8845; BYTE $0x01 // movb   %r8b, (%r9)
   BYTE $0xc3               // retq

LBB0_8:
   WORD $0x3145; BYTE $0xc0 // xorl   %r8d, %r8d
   WORD $0x8845; BYTE $0x01 // movb   %r8b, (%r9)
   BYTE $0xc3               // retq

对应的 Go Wrapper 代码如下:

var (
        // these runes are valid in tag values
        whiteListRunes = []rune{'_', '-', '.', '%', ':', ' ', '[', ']', ',', '%',
                '/', ':', ';', '<', '=', '>', '@', '~'}

        rcBitTable [16]uint8
        smTable    [16]int8
        hmTable    [16]uint8
)

//go:noescape
func _is_valid_string(table unsafe.Pointer, str unsafe.Pointer, len int32, sm, hm unsafe.Pointer, rt unsafe.Pointer)

func init() {
        // build tables
        for i := 0; i < 128; i++ {
                if unicode.IsNumber(rune(i)) || unicode.IsLetter(rune(i)) || isRuneInWhiteList(rune(i)) {
                        lowerNibble := i & 0x0f
                        upperNibble := i >> 4
                        rcBitTable[lowerNibble] |= 1 << upperNibble
                }
        }

        smTable = [16]int8{1, 2, 4, 8, 16, 32, 64, -128, 1, 2, 4, 8, 16, 32, 64, -128}
        hmTable = [16]uint8{0x0f, 0x0f, 0x0f, 0x0f, 0x0f, 0x0f, 0x0f, 0x0f, 0x0f, 0x0f, 0x0f, 0x0f, 0x0f, 0x0f, 0x0f, 0x0f}
}

func IsValidTagValueLookup2dBitTableSIMD(s string) bool {
        l := len(s)
        if l == 0 || len(s) > maxTagLen {
                return false
        }
        sptr := unsafe.Pointer((*reflect.StringHeader)(unsafe.Pointer(&s)).Data)
        var rt byte
        _is_valid_string(unsafe.Pointer(&rcBitTable), sptr, int32(len(s)), unsafe.Pointer(&smTable), unsafe.Pointer(&hmTable), unsafe.Pointer(&rt))
        return rt != 0
}
Benchmark
  1. 先做一个通用的 benchmark,待校验的 string 长度从1 ~ 20不等:

goos: linux
goarch: amd64
pkg: code.byted.org/gopkg/metrics_core/utils
cpu: Intel(R) Xeon(R) Platinum 8260 CPU @ 2.40GHz
BenchmarkLookupAlgoValid
BenchmarkLookupAlgoValid/baseline
BenchmarkLookupAlgoValid/baseline-8                  2574217               510.5 ns/op
BenchmarkLookupAlgoValid/lookup-arraytable
BenchmarkLookupAlgoValid/lookup-arraytable-8         6347204               193.7 ns/op
BenchmarkLookupAlgoValid/lookup-2d-bittable-simd
BenchmarkLookupAlgoValid/lookup-2d-bittable-simd-8   6133671               185.2 ns/op

可以看到,SIMD 版本在平均水平上与 arraytable 相当

  1. 由于 SIMD 优势主要体现在长字符串时,因此,我们使用一组长度为20左右的 string,再次 benchmark:

goos: linux
goarch: amd64
pkg: code.byted.org/gopkg/metrics_core/utils
cpu: Intel(R) Xeon(R) Platinum 8260 CPU @ 2.40GHz
BenchmarkLookupAlgoValidLong
BenchmarkLookupAlgoValidLong/baseline
BenchmarkLookupAlgoValidLong/baseline-8                  3523198           356.4 ns/op
BenchmarkLookupAlgoValidLong/lookup-arraytable
BenchmarkLookupAlgoValidLong/lookup-arraytable-8         8434142           153.3 ns/op
BenchmarkLookupAlgoValidLong/lookup-2d-bittable-simd
BenchmarkLookupAlgoValidLong/lookup-2d-bittable-simd-8  13621970            87.29 ns/op

可以看到,在长 string 上 SIMD 版本表现出非常大的优势,相对于 arraytable 版本再次提升50%

结论

  • 通过 lookup table + SIMD 的方式优化,字符校验的整体性能可以提升2~4倍

  • 但由于在 Go 中 plan9 汇编无法内联,因此在待校验的字符串较短时不能体现其优势

Reference

  • https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#

  • http://0x80.pl/articles/simd-byte-lookup.html

  • https://fullyfaithful.eu/simd-byte-scan/

  • https://gorse.io/posts/avx512-in-golang.html#convert-assembly

  • http://0x80.pl/notesen/2016-04-03-avx512-base64.html

本文作者郭刚平,来自字节跳动 Dev Infra - APM - 观测数据引擎团队,我们提供日均数十PB级可观测性数据采集、存储和查询分析的引擎底座,致力于为业务、业务中台、基础架构建设完整统一的可观测性技术支撑能力。

云上智能驾驶三维重建最佳实践

作者 ByteDanceTech
2023年10月20日 17:02

智能驾驶技术的不断发展,正在改变着我们的出行方式和交通系统。作为其中的一个关键技术,三维重建在智能驾驶系统中起着重要的作用。除去车端本身的感知、重建算法,自动驾驶技术的落地与发展需要庞大的云端重建能力支撑,火山引擎多媒体实验室通过行业领先的自研三维重建技术,结合强大的云平台资源与能力,助力相关技术在云端大规模重建、自动标注、真实感仿真等场景的落地与应用。

本文重点介绍火山引擎多媒体实验室三维重建技术在动态、静态场景的以及结合先进光场重建技术的原理与实践,帮助大家能更好的了解和认识云上智能三维重建如何服务智能驾驶领域,助力行业发展。

一、技术挑战与难点

驾驶场景重建需要对道路环境做点云级别的三维重建,与传统的三维重建技术应用场景相比,驾驶场景重建技术有以下难点:

  1. 车辆运行过程中的环境因素复杂且不可控,不同天气、光照、车速、路况等均会对车载传感器采集到的数据造成影响,这对重建技术的鲁棒性带来了挑战。

  2. 道路场景中经常会出现特征退化和纹理缺失的情况,例如相机获取到视觉特征不丰富的图像信息,或者激光雷达获取到相似性较高的场景结构信息,同时,路面作为重建中的关键要素之一,色彩单一且缺少足够的纹理信息,这对重建技术提出了更高的要求。

  3. 车载传感器数量较多,常见的有相机、激光雷达、毫米波雷达、惯导、GPS定位系统、轮速计等等,如何将多传感器的数据融合起来得到更精确的重建结果,对重建技术提出了挑战。

  4. 道路中存在运动车辆、非机动车、行人等动态物体,会对传统重建算法带来挑战,如何剔除动态物体对静态场景重建带来干扰,同时对动态物体的位置、大小、速度进行估计,也是项目的难点之一。

二、驾驶场景重建技术介绍

自动驾驶领域的重建算法通常会采用激光雷达、相机为主,GPS、惯导为辅的技术路线。激光雷达可以直接获取高精度的测距信息,能够快速得到场景结构,通过预先进行的激光雷达-相机联合标定,相机获取到的图像能够为激光点云赋予色彩、语义等信息。同时,GPS和惯导可以进行辅助定位,减少重建过程中因为特征退化而出现的漂移现象。但是,由于多线激光雷达售价较高,通常用于工程车辆,而在量产车上很难得到规模化的使用。

对此,火山引擎多媒体实验室自研了一套纯视觉的驾驶场景重建技术,包括静态场景重建、动态物体重建和神经辐射场重建技术,能够区分场景中的动静态物体,还原出静态场景的稠密点云,并突出路面、指示牌、红绿灯等关键要素;能够对场景中运动物体的位置、大小、朝向和速度进行有效的估计,用于后续的4D标注;能够在静态场景重建的基础上,使用神经辐射场对场景进行重建和复现,实现自由视角的漫游,可用于场景编辑和仿真渲染。这套技术解决方案不依赖激光雷达,且能够达到分米级的相对误差,用最小的硬件成本实现接近激光雷达的重建效果。

2.1 静态场景重建技术:剔除动态干扰、还原静态场景

视觉重建技术以多视角几何作为基础的理论依据,要求待重建的场景或者物体具有帧间一致性,即在不同图像帧中处在静止状态,因此需要在重建过程中剔除动态物体。根据场景中的不同要素的重要性,稠密点云中需要去除无关紧要的点云,而保留一些关键要素点云,因此需要事先对图像进行语义分割。对此, 火山引擎 多媒体实验室结合AI技术与多视角几何基本原理,搭建了一套先进的鲁棒、精确完整视觉重建算法框架。重建过程包括三个关键步骤 :图像预处理、稀疏重建和稠密重建

28e2d17c01c3d57e46f9b26eb1d7c514.png
静态场景.png

车载相机拍摄过程中处在运动状态,由于曝光时间的存在,采集到的图像中会随着车速提高而出现严重的运动模糊现象。另外,出于节约带宽和存储空间考虑,传输过程中会对图像进行不可逆的有损压缩,造成画质的进一步降低。为此, 火山引擎多媒体实验室使用了端到端的神经网络对图像进行去模糊处理,能够在抑制运动模糊现象的同时对图像质量进行提升。去模糊前后的对比如下图所示。

3f78f60e02a3bafb0c6d2f46d15cdffa.png 去模糊前(左) 去模糊后(右)

为了区分出动态物体,火山引擎多媒体实验室使用了基于光流的动态物体识别技术,能够得到像素级别的动态物体掩膜。在之后的静态场景重建过程中,落在动态物区域上的特征点将被剔除,只有静态的场景和物体将得到保留。

ddb485c16f4b3df075c77750368faa88.png

光流(左) 运动物体(右)

稀疏重建过程中需要同时计算相机的位置、朝向和场景点云,常用的有SLAM算法(Simultaneous localization and mapping)和SFM算法(Structure from Motion,简称SfM)。在不要求实时性的情况下,SFM算法能够得到更高的重建精度。但是,传统的SFM算法通常将每个相机当作独立相机来进行处理,而车辆上通常会在前后左右不同方向布置多个相机,这些相机之间的相对位置其实是固定不变的(忽略车辆振动带来的细微变化)。如果忽视相机与相机之间的相对位置约束,计算出来的各相机位姿误差会比较大。另外,当遮挡比较严重时,个别相机的位姿会难以计算。对此,火山引擎多媒体实验室自研了基于相机组整体的SFM算法,能够利用相机之间的先验相对位姿约束,以相机组作为整体来计算位姿,同时使用了GPS加惯导的融合定位结果对相机组中心位置进行约束,可有效地提高位姿估计的成功率和准确率,并能改善不同相机之间的点云不一致现象,减少点云分层现象。

730357bea6c6e99317a8b5175a12a138.png

d44232f9af31ffbb9eee7a295da39ca7.png传统SFM(左) 相机组SFM(右)

由于地面色彩单一、纹理缺失,传统的视觉重建很难还原出完整的地面,但是地面上存在车道线、箭头、文字/标识等关键要素,因此火山引擎多媒体实验室采用了二次曲面来拟合地面,辅助进行地面区域的深度估计和点云融合。和平面拟合相比,二次曲面更贴合实际道路场景,因为实际的路面往往并不是一个理想平面。以下是分别用平面方程和二次曲面方程来拟合地面的效果对比。

72b17dd6cf54e6baee81d53904790270.png 平面方程(左) 二次曲面方程(右)

将激光点云视作真值,并将视觉重建结果与之叠加,可以直观地衡量重建点云的准确性。从下图中可以看到,重建点云和真值点云贴合度非常高,经过测量得到重建结果的相对误差在15cm左右。

b885e0f57948556a69fb3df530df5f6f.png 火山引擎多媒体实验室重建结果(彩色)与真值点云(白色)

以下是火山引擎多媒体实验室视觉重建算法和某主流商业重建软件的效果对比。可以看到,和商业软件相比,火山引擎多媒体实验室的自研算法重建效果更好、更完整,场景中的路牌、红绿灯、电线杆,以及路面上车道线、箭头等还原度非常高,而商业软件的重建点云非常稀疏,且路面大范围缺失。

3d6f62f3628cdbfea3ea20018af537d4.png某主流商业软件(左) 火山引擎多媒体实验室算法(右)

2.2 动态重建技术:

在图像上对物体进行3d标注十分困难,需要借助于点云,当车辆只有视觉传感器时,获取场景中目标物体的完整点云十分困难。特别是动态物体,无法使用传统的三维重建技术获取其稠密点云。为提供运动物体的表达,服务于4d标注,使用3d bounding box(以下简称3d bbox)对动态物体进行表示,通过自研动态重建算法获取每一时刻场景中动态物体的3d bbox姿态、大小、速度等,从而补全动态物体重建能力。

910026ffa515fa3962a2e671a2164fb4.png 动态重建pipeline

对车辆采集的每一帧图像,首先提取场景中的动态目标,生成3d bbox的初始提议,提供两种方式:使用2d目标检测,通过相机位姿估计对应的3d bbox;直接使用3d目标检测。两种方式针对不同数据可以灵活进行选择,2d检测泛化性好,3d检测可以获得更好的初值。同时,对图像动态区域内部的特征点进行提取。获取单帧图像初始3d bbox提议及特征点后,建立多帧间数据关联:通过自研多目标跟踪算法建立物体匹配,并通过特征匹配技术对图像特征进行匹配。获取匹配关系后,将有共视关系的图像帧创建为局部地图,构建优化问题求解全局一致的目标bbox估计。具体地,通过特征点的匹配以及动态三角化技术,恢复动态3d点;对车辆运动建模,联合优化物体、3d点、相机之间的观测,从而获得最优估计的动态物体3d bbox。

52fe189a9e23ccc10b54b1320912175b.png2d生成3d(左二) 3d目标检测示例

多目标跟踪算法示例

动态重建结果demo

2.3 NeRF 重建:真实感渲染、自由视角

使用神经网络进行隐式重建,利用可微渲染模型,从已有视图中学习如何渲染新视角下的图像,从而实现照片级逼真的图像渲染, 即神经辐射场(NeRF)技术。同时,隐式重建具有可编辑、查询连续空间的特性,可以用于自动驾驶场景中自动标注、仿真数据构建等任务。使用NeRF技术对场景进行重建是非常有价值的。

bc47e7ca5081b63d72977bc3b836e95c.png

火山引擎多媒体实验室融合神经辐射场技术与大场景建模技术。在具体实践中,首先针对数据进行处理,场景中的动态物体会使NeRF重建出现伪影,借助自研动静态分割、影子检测等算法,对场景中和几何不一致的区域进行提取,生成mask,同时利用视频inpainting算法,对剔除掉的区域进行修复。借助自研三维重建能力,对场景进行高精度的几何重建,包括相机参数估计以及稀疏、稠密点云生成。另外,对场景进行拆分以减小单次训练资源消耗,并可做分布式训练、维护。在神经辐射场训练过程中,针对室外无边界大场景,团队通过一些优化策略以提升该场景下的新视角生成效果,如通过在训练中同时优化位姿提高重建精度,基于哈希编码的层次化表达提升模型训练速度,借助外观编码提升不同时间采集场景的外观一致性等,借助mvs稠密深度信息提升几何精度等。团队同毫末智行合作,完成单路采集以及多路合并的NeRF重建,相关成果已在毫末AI Day发布。

732a74e86232db2ec56ab09804254435.png动态物/影子剔除,填补

单摄像头nerf重建

自由视角+几何对比

火山引擎实时、低延时拥塞控制算法的优化实践

作者 ByteDanceTech
2023年10月18日 11:59

摘要

火山引擎智能拥塞控制算法 VICC(Volcano Intelligent Congestion Control)是一种自适应的拥塞控制算法,旨在解决全球不同网络环境下,不同音视频应用对带宽利用率和延时的差异化要求。它结合了传统拥塞控制算法(如 GCC 和 BBR)的优点,并且能够根据不同的网络条件、业务偏好和码率特征进行自适应调整,包括自适应拥塞响应速度、自适应带宽探测幅度、自适应丢包检测策略、自适应抗抖动能力和自适应 Padding。通过这些自适应调整,VICC 算法能够提升各种复杂弱网下的带宽利用率,同时在满足不同延时的条件下,尽量提升带宽的稳定性,为用户提供更好的音视频体验。

1.  行业现状和挑战

实时音视频应用的网络传输面临诸多方面的挑战,其中包括:

  • 带宽利用率:为了提供高质量的音视频体验,需要充分利用网络带宽,这就要求网络传输算法具有高效的带宽探测能力。

  • 延迟和响应时间:实时音视频应用要求快速的响应时间和超低延迟,这就要求网络传输技术具有快速的传输速度和低延迟的特性。

  • 可靠性和稳定性:网络传输过程中可能会出现拥塞、丢包等问题,这会影响到音视频的质量和稳定性,因此要求网络传输技术具有可靠性和稳定性,能够保证数据的正确传输和恢复。

  • 公平性和资源分配:在多用户场景下,需要保证网络传输的公平性和资源分配的合理性,以避免某些用户获得过多的资源,导致其他用户的服务质量下降。

除了上述挑战,实时音视频传输还需要关注体验指标,如实时性、流畅性、清晰度、音画同步性等,这些指标对于提供高质量的音视频体验至关重要。

1.1 现网音视频卡顿归因

为了快速提升线上用户弱网相关的体验,火山引擎根据抖音集团真实用户的负反馈数据打磨研发了“音视频卡顿归因模型”,它可以对线上音视频卡顿的所有 case 进行自动归因和聚类,为弱网问题的优化和优先级给出有效指导。

根据模型对线上用户音视频卡顿反馈的归因和聚类,我们发现,当前引起线上卡顿问题的主要原因是上下行大小缓存问题。

447fa68b4307a44a9253d99c5f8d862d.png线上用户视频/音频卡顿归因类型占比

大小缓存的描述,可以参考:https://www.ietf.org/archive/id/draft-cardwell-iccrg-bbr-congestion-control-00.txt

大缓存:Deep buffers,at bottleneck links with deep buffers, congestion happens before packet loss.

小缓存:shallow buffers, in shallow buffers, packet loss happens before congestion.

1.2 RTC 主流拥塞控制算法分析

自 Google 开源 WebRTC 实时音视频框架以来,GCC 作为默认拥塞控制算法备受行业研究和关注,而 WebRTC 在演进过程中,也在不断演进集成 BBR、PCC 等拥塞控制算法,以期望进一步提升实时音视频的传输性能。

下文以 GCC 和 BBR 算法为例,我们来看一看当前主流拥塞控制算法的特性和不足。

1.2.1 GCC 算法

GCC 算法是专为实时音视频传输设计的拥塞控制算法,但随着网络环境日益复杂、音视频应用场景越来越丰富,GCC 算法难以提升上限以获得更好的音视频传输体验。

aa33f220669013cee233d09554b3417d.png GCC 算法带宽估计与触发拥塞延时示意图

GCC 算法的关键特征

GCC 算法的发送/接收码率的联动是非连续性的。在检测到拥塞之前,发送码率和接收码率是不联动的;检测到拥塞之后,发送码率和接收码率才开始联动。如果要降低非联动过程中的网络延迟,GCC 算法需要过降带宽排空非联动过程中网络中堆积的数据。另外,GCC 的网络探测速度也较慢。

GCC 算法存在的问题

GCC 算法的几个主要问题中,最核心的问题是带宽估计的准确性(带宽利用率)和拥塞检测的有效性(对网络的冲击)存在很难调和的矛盾,导致这个问题的主要原因是:

  • GCC 算法的带宽估计强依赖拥塞检测,即,只有在算法判断为网络拥塞的情况下,才能进行带宽估计;

  • GCC 算法的拥塞控制灵敏度受到带宽估计的影响很大, 意味着当实际发送码率越贴近真实带宽,拥塞检测受到的干扰越大

1.2.2 BBR 算法

BBR 算法是互联网通用的拥塞控制算法,其设计目标追求高带宽利用率、低拥塞延迟和丢包,BBR 在通用互联网领域具备良好的性能表现,但因其设计目标和算法特性,不适应于实时音视频传输场景。

b6a712160158c473c9bcdd4e8ab691d5.png BBR 算法带宽估计与触发拥塞延时示意图

BBR 算法的关键特征

和 GCC 算法不同,BBR 算法的发送/接收码率的联动是连续性的,它实时跟踪接收码率,同时根据拥塞检测的结果,来调整发送窗口(cwnd)的大小,并最终影响发送码率。

BBR 算法存在的问题

虽然 BBR 在带宽估计准确性上等能力要高于 GCC,但它也存在一些明显的问题:

  • 突发拥塞时收敛速度慢;

  • 当链路丢包高于一定阈值时,吞吐量断崖式下跌;

  • 抗抖动能力一般;

  • 反向链路丢包延时影响上行带宽估计;

  • 探测最小延迟剧烈降窗不适用实时音视频传输;

由于 GCC 和 BBR 等算法在实时音视频传输场景存在一些不足,火山引擎网络传输团队自研了 VICC 算法,旨在优化上述问题的同时,也为火山引擎实时音视频业务提供更加良好的用户体验。

2. 火山引擎智能拥塞控制算法 VICC 介绍

VICC 算法主要通过网络状态统计进行自适应带宽估计决策,并作出带宽评估动作,以提升各种复杂网络下拥塞控制的性能表现。在近一年的实验室和线上业务打磨过程中,我们深入分析了不同算法原理及现网痛点弱网问题,输出了 40+ 项最佳工作点及 9 篇技术专利,线上业务的各项指标,特别是视频卡顿率、首帧时长等也得到了显著的改善。

65b901756859df4e9d45637cb351ca12.png 火山引擎智能拥塞控制算法 VICC 架构图

2.1 网络状态统计

评估当前网络状态的重要指标之一是网络状态统计参数,而准确的基础网络状态参数是提高带宽估计准确性和带宽利用率的关键基石。VICC 算法提供了多种基础网络状态参数,部分基础网络状态统计参数如下:

6bb956e4d1cadf8c64d17f7bb91daa4f.png

2.2 自适应拥塞控制

VICC 算法结合了传统拥塞控制算法的优点,并且能够根据不同的网络条件、业务偏好和码率特征进行自适应调整,包括自适应拥塞响应速度、自适应抗干扰能力、自适应丢包检测、自适应带宽探测幅度、自适应拥塞排空等。

7f854b1bf504dc2f70510fe9eda49f31.png
2.2.1 自适应拥塞检测

VICC 继承并优化了 GCC 和 BBR 拥塞检测能力,通过对发送码率、接收码率及延迟参数的关系进行建模,观察延迟参数变化趋势及其关联性,以及对于延迟参数的容忍程度进行拥塞响应,从而快速排空网络拥塞。

和 GCC / BBR 相比,VICC 拥塞响应及收敛速度更快。

c40a2cce484a380cd62ab8fb3a26ec67.png GCC / BBR / VICC 拥塞响应及收敛速度比较(线上实测)
2.2.2 自适应抗干扰能力

拥塞响应越灵敏,意味着在网络抖动场景下容易误判,导致算法抗干扰能力下降。VICC 使用蚁穴算法来对抗网络抖动和乱序,通过接收码率和发送码率来度量网络透过率,并结合观察延迟参数变化趋势及关联性,提升自适应抗干扰能力。

VICC 可以对抗 2000ms 以内的延迟抖动幅度,抗抖动能力显著比 GCC 和 BBR 强。

de367724772a0a93bf75b324664a6a2a.png GCC / BBR / VICC 抗抖动能力比较(线上实测)

灵活可配置的拥塞检测灵敏度

在自适应拥塞检测的基础上,VICC 还会根据业务偏好,提供灵活可配置的拥塞检测灵敏度模式设置,以适用于不同业务场景的诉求,并做好拥塞响应灵敏和抗干扰能力强的 trade-off。

以火山引擎 RTC 典型应用场景为例,互娱、企业通讯等场景一般可以容忍 200-300ms 的延时,而远程车控、云游戏等场景只能容忍 50-100ms 的延时。VICC 提供多种模式来适应不同场景的延时需求,在延时容忍度较高的场景,VICC 可以通过延迟拥塞响应时间来获得更高的带宽估计的稳定性,在延时容忍度较低的场景,VICC 可以通过快速拥塞响应来降低网络拥塞延迟。

a0ea5d855cd7de440ad9952d3c2fa981.png 火山引擎 RTC 典型应用场景
2.2.3 自适应丢包检测能力

通过对发送码率、接收码率及丢包参数的关系进行建模,并微幅调整发送码率,检测接收码率和丢包参数之间的关联性,VICC 可以自适应检测出丢包为随机丢包和拥塞丢包。一旦识别出随机丢包后,VICC 可以准确地对随机丢包进行系数补偿,以达到不误降带宽的效果。

和 GCC / BBR 相比,VICC 随机丢包抗性可达到 70% 以上。

12278ab1b5b1c38fd4e347a0b029a055.png GCC / BBR / VICC 随机丢包抗性比较(线上实测)
2.2.4 复杂弱网处理能力

考虑实时音视频传输对于延迟的容忍,根据延迟参数的程度、自适应上探幅度及下探幅度,在保留竞争力的同时,VICC 避免了因为频繁探测引入的网络延迟堆积问题。同时,在检测到拥塞缓解后,VICC 通过对发送码率、接收码率及延迟参数的关系进行建模,迅速提升带宽上探幅度和调整时间窗口;在探测到带宽满足音视频传输体验后,再逐步放慢上探幅度和时间间隔。

当网络存在瓶颈带宽时,VICC 的带宽探测相对 GCC 和 BBR 更平稳。当瓶颈带宽发生变化时,VICC 可以快速跟踪实际瓶颈带宽。

cd7b0983b9d1757a635c8f30a1f75842.png GCC / BBR / VICC 带宽探测平稳度比较

2.3 自适应 Padding 策略

VICC 使用自适应 Padding 策略来解决带宽下溢叠加复杂弱网场景下的带宽估计,在精准探测网络带宽的同时,尽量避免网络冲击和带宽浪费。在决策需要发送 Padding 时,会先根据带宽估计值设定目标发送码率,并实时度量接收码率,同时动态调整目标码率。

11ba7574a654d469bab94564419e1959.png VICC 自适应 Padding 策略示意图

3. VICC 表现及收益

通过对拥塞响应速度、带宽探测幅度、丢包检测策略、抗抖动能力等一系列的“自适应调整”,VICC 算法能够提升各种复杂弱网下的带宽利用率,同时在满足不同延时的条件下,尽量提升带宽的稳定性,为用户提供更好的音视频体验。

3.1 算法表现

为了更直观地展示 VICC 算法对于用户音视频体验的提升效果,我们在不同类型的弱网环境下进行了音视频通话测试,通过比较对端画面的实时性和流畅性来比较 VICC 算法和市场上同类算法的拥塞控制能力。

在上行 70% 丢包网络环境下,使用了 VICC 算法的火山引擎 RTC 依然保持稳定传输,几乎没有表现出卡顿。

模拟上行 70% 丢包网络下的 VICC 和市场同类算法表现比较

当网络突发上行 300kbps 限速小缓存时,使用了 VICC 算法的火山引擎 RTC 出现了短暂的卡顿,但算法很快进行了对抗,迅速恢复稳定和流畅,对用户的体验影响较小。

模拟上行 300kbps 限速小缓存网络下的 VICC 和市场同类算法表现比较

3.2 线上收益

VICC 算法经过了字节内部质量专项评估实验室打磨和验证后,在火山引擎线上业务上也进行了充分的流量验证,在视频通话、屏幕共享等场景中,视频卡顿率和首帧指标得到了显著的改善,其中,视频通话卡顿率下降 27%、首帧延时下降 100ms+;屏幕共享卡顿率下降 15%,首帧延迟下降 200ms。 同时,使用自适应 Padding 策略后,现网上下行码率也得到了明显的改善,其中,上行Padding码率下降 90%,下行下降 70%。

08233e7d3a7a82d09b08c08aa291009a.png

VICC 算法上线后对视频卡顿、首帧和 Padding 码率指标的改善

4. 未来展望

在网络环境高度复杂的背景下,影响用户体验的因素众多,有时通用算法难以精准匹配所有场景的环境特点,因此,一些特定场景的用户体验难以做到极致,无法实现“个性化场景自适应”的目标。

未来,我们将根据线上问题归因聚类建模,对用户网络场景精准识别,提升网络场景识别算法的准度和范围,并以此为驱动,针对不同的弱网场景进行全链路的差异化优化,使算法在各类网络模型下的收敛达到最优,持续提升用户体验。

点击阅读原文了解火山引擎 RTC 更多信息。

veImageX 演进之路:Web 图片加载提速50%

作者 ByteDanceTech
2023年9月26日 16:30

背景说明

火山引擎veImageX演进之路主要介绍了veImageX在字节内部从2012年随着字节成长过程中逐步演进的过程,演进中包括V1、V2、V3版本并最终面向行业输出;整个演进过程中包括服务端、客户端、网络库、业务场景与优化等多个角度介绍在图像处理压缩、省成本与体验优化的经验与方案;

本篇文章重点介绍在web端演进和提供的能力,图片是 Web 站点中的重要元素,图片体积、格式、分辨率以及渲染方式对用户体验有着显著影响。火山引擎veImageX 为业务提供了灵活、高效的一站式图片解决方案和静态素材托管方案,涵盖了上传、存储、处理、分发、评估等图片生产和消费阶段的全部链路。

解决的问题

Web 场景下图片的应用非常广泛,从传统的图文到视频封面都有图片的身影,图片体验是用户体验中很重要的一环,常用于衡量站点性能的 LCP 和 CLS 指标都把图片列为最重要的元素之一。随着业务的发展,用户量增长的同时也带来了 CDN 带宽成本的快速提升,最主要的元素则是图片和视频。因此,方案从体验和成本出发,旨在为用户提升体验的同时降低带宽成本。

用户体验可视化

图片体验问题通常有以下几点:

  • 加载速度慢:图片体积、网络、CDN、处理耗时等因素均会影响加载耗时;

  • 加载失败率高:导致图片加载失败的因素很多,重点在于如何及时定位问题;

  • 渲染体验差:包括图片区域长时间空白、加载后导致页面抖动、出错后无兜底等场景;

开发者往往忽视了图片体验,也不了解图片对站点性能的影响,并且缺少可量化的数据来衡量站点的图片体验。参考 Lighthouse 性能优化指南,方案整合了图片压缩、图片懒加载、图片稳定性布局、错误兜底等能力,并集成了数据监控能力,可结合 火山引擎veImageX 控制台实时大盘数据查看,为业务提供数据上报、数据分析、数据追踪、数据告警等全链路支持。

带宽成本问题

以下问题通常会带来额外的带宽成本:

  • 图片压缩率低;

  • 图片原始分辨率和渲染分辨率不匹配;

  • 采用传统的 PNG、JPEG 等低压缩率格式;

  • 图片未进行懒加载;

除了图片压缩,方案支持了 WebP、AVIF 等高压缩率图片格式的自适应加载和图片分辨率的自适应加载,尽可能减小图片体积。同时集成了图片懒加载,避免不可见区域的图片加载,降低站点 CDN 成本,同时也提升站点整体加载速度。根据内部业务数据,图片传输带宽和图片加载耗时通常可降低 50% 以上。

方案架构

方案总体上可划分为图片加载和数据监控两个部分。

ec572bc4afd4957ccc79dc953d9c2664.png
方案架构.png

如图所示,图片加载部分支持分辨率、格式自适应以及懒加载、稳定性布局等特性,其中涉及到图片处理部分基于火山引擎veImageX 服务实现,如图片转码、缩放、压缩等。SDK 侧生成当前环境下最佳的图片格式和分辨率,从服务获取相应的图片 URL,借助云端处理能力在运行时动态生成所需的图片。

数据监控部分可分为加载耗时监控、图片详情监控、画质评估、大图监控、云控配置几部分,监控 SDK 收集相关数据,根据云端下发的配置上报数据,火山引擎veImageX 服务对数据做清洗后可在控制台侧查看数据大盘。

模块详细介绍

图片加载

图片格式自适应

常见的图片格式有 PNG、JPEG、GIF、WebP、AVIF、HEIC 等,其中 WebP、AVIF、HEIC 等高压缩率图片格式可显著减小图片体积。但由于不同浏览器对高压缩率格式的支持情况不同,因此在应用时需要考虑图片加载的环境。三种高压缩率格式在 Web 侧的兼容性如下:

  1. WebP

937183fd82c8f69e4789bc25f0d57ea9.png
  1. AVIF

0e5fdc74b67564bbd81a8ef777a9b77f.png
  1. HEIC

16e7d4125e051317cdcefef3bfaa23af.png

在 APP 端,对于不支持的图片格式可采用 SDK 软解的方式进行解码、渲染,Native 侧的性能可保证图片解码的耗时和流量的节省都能有不错的收益。在 Web 侧,由于浏览器性能限制,veImageX 内部性能测试表明,SDK 软解在图片整体耗时方面的收益并不明显,尤其是多图场景下,因此在 Web 侧更适合走格式自适应的方案,即根据浏览器的支持性加载相对最优的图片格式。

常见的做法是采用标签以实现格式的自适应,标签有相对不错的兼容性,支持包含零或多个元素和一个  元素来为不同的浏览器环境提供图片版本,浏览器会自上而下选择可以被渲染的图片,若没有匹配的,则选择  元素当中的图片作为兜底。加载 SDK 最初也采用了该方案,如下:

<picture>
  <source srcset="image1.webp" type="image/webp" />
  <img src="image1.jpg" decoding="async" loading="lazy"/>
</picture>

但由于浏览器版本众多,在实际应用中,可能会出现很多预期以外的情况,比如:

  • 会同时加载多个图片资源,造成带宽的浪费;

  • 并非完全支持 WebP 的所有特性,存在加载失败的场景;

  • 只支持 AVIF 静图格式,不支持动图;

  • ...

为了保证图片加载成功率,因此在实际应用中无法直接使用标签,加载 SDK 目前采用格式探测 +相结合的方式来解决该问题。同时,由于 HEIC 支持率太低,格式自适应目前只做了 WebP 和 AVIF 的自适应,同等质量下,WebP 相比 JPEG 可减少 30% 的图片体积,AVIF 则可在 WebP 的基础上再减少 20%;

图片分辨率自适应

分辨率自适应指的是客户端根据实际渲染的宽高获取相应分辨率的图片,从而减小图片体积。常见的做法是我们可以借助 HTML 中原生的 srcset 属性来定义图像集,以及每个图像应用的场景。由以下三部分组成:

  • 文件名

  • 空格

  • 图像描述符,有两种描述方式

    • 宽度描述符 w,描述图像的固有宽度,以像素为单位。比如 480w 表示当浏览器需要 480 像素宽的图像时应该使用的图像资源

    • 像素密度描述符 x,描述了显示器的像素密度和图片资源之间的对应关系,通过window.devicePixelRatio可查询显示器像素密度

sizes 则定义了一组媒体条件,比如:屏幕宽度。并且指明当媒体条件为真时最佳的图片尺寸。每个条件由以下三部分组成:

  • 一个媒体条件,比如max-width:480px,表示可视窗口的宽度不超过480像素时

  • 空格

  • 当媒体条件为真时,应该选用的图片大小

可以将标签和 srcset 属性相结合,实现格式和分辨率的自适应,如下:

<picture>
  <source   
      srcset="image1.webp 200w,
              image2.webp 600w"
      sizes="100vw"
      type="image/webp"
   />
     
   <img 
      srcset="image1.jpg 200w,
              image2.jpg 600w"
      sizes="100vw"
      decoding="async"
      loading="lazy"
   />
</picture>

然而在实际中又会面临一些问题,如:

  • 指定多个 srcset 会增加 HTML 文件大小,尤其是当中存在多个的场景;

  • 媒体查询条件只能是屏幕宽度和像素密度,不能准确反映真实的图片渲染情况;

  • srcset 配合 sizes 使用,理解成本相对较高;

  • ...

在实际应用中,某些情况下可以提前知道图片渲染大小或者图片所在区域的大小,结合方案内置的几种布局方式以及设备像素密度等信息,加载 SDK 内部可以分析并选择出当前模块渲染的最佳分辨率。

图片稳定性布局

Web 侧通常基于 CLS(Cumulative Layout Shift,累积布局偏移)指标用于衡量页面布局的视觉稳定性。当可见元素的位置在页面生命周期内发生了变化时,就会产生布局偏移。

导致布局偏移的因素有很多(如:动态插入元素、iframe加载),无尺寸的图片是影响 CLS 指标的重要因素之一。例如下面两个页面中,右侧指定了图片宽高的页面要比左侧没有指定图片宽高的页面稳定性更好。

6aaac940f0db16cd4dfc57328086b969.gifc851de30a7408cddcb5e0fa9af7bc0b9.gif

受 next/image 的启发,加载 SDK 内置了四种稳定性布局方式:intrinsic、responsive、fixed、fill,通过生成稳定的 dom 结构来提升视觉稳定性,减少业务开发量。效果如下:

07081ea04373379e164300cfbda837cd.gif1422c4c5c004c493251d5e76b7c19c00.gif

d738bee7a43a70327e0e9bf426241a23.gif65a868195f4dffb4196e2aa883fa803c.gif

  • intrinsic: 若指定宽度小于容器宽度,则根据指定宽高渲染图片;反之则图片宽度为容器宽,图片高度按照比例缩小;

  • responsive: 图片渲染宽度等于容器宽度,高度按比例缩放;

  • fixed: 根据指定宽高渲染图片;

  • fill: 图片缩放以填充容器,可传入 objectFit、objectPosition 属性表示不同的填充模式;

图片懒加载

对于图片懒加载最简单的做法是基于  的原生属性 loading="lazy",但在实际的应用中也发现了两个问题:

  • 该属性的兼容性不达标,多数浏览器不支持;

  • 在部分 Safari 浏览器上存在 bug,可能会导致图片加载被阻塞;

因此,SDK 内部基于 IntersectionObserver API 实现,该 API 相对更可控,且可以设置懒加载的距离、目标元素等属性。

数据监控

df95d3e3e3bb3c15b5663f2bb3a612e3.png
数据监控.png

数据监控的整体链路为:

  1. 监听全局的 Load 和 Error 事件,并筛选出属于图片的部分;

  2. 基于 PerformanceObserver 监听图片资源加载,该事件回调中可拿到图片加载耗时相关的指标,如 DNS、TCP、SSL、请求、下载各个阶段的耗时,并且可以基于该 API 监听 CSS 中图片资源的加载;

  3. 对于图片格式、状态码、画质打分等信息则依赖 Response Header,而拿到 Response Header 仅有 request 资源这一种方式,因此在资源加载后再去 request 本地缓存中的信息,同时为避免并发请求影响其他类型的 HTTP 请求,SDK 会根据采样率、当前请求量等信息在空闲时读取需要上报的图片的缓存;

  4. 整合所有原始数据,根据采样率上报至 veImageX 数据服务,由数据服务对原始数据做清洗;

  5. 经过后端服务处理后最终即可在 veImageX 质量监控大盘查看,具体支持的指标及维度如下图所示:

    1. 下行网络监控

    2. ce24bc03eb445c935ea2e2e7557ed034.png

    3. 客户状态监控

    4. 7cb8a0c466fe783a710ccc3f7fc44635.png

方案演进

方案致力于为 Web 场景提供极致的图片加载体验,同时在稳定性和场景覆盖上也在不断提升。

更低的错误率

上面提到在某些浏览器下会存在部分 WebP、AVIF 图片加载失败的场景,在监控到此类场景后加载 SDK 基于格式探测的方式最低成本的解决了此类问题,同时保证了性能。

例如:在 iOS 14.3 & 14.4 版本下的 Safari 浏览器加载部分的 WebP 失败,而标签并不会对 WebP 的支持性做检测,其对于传入的 WebP 格式是全盘接收的,且 SDK 也无法对所有传入的图片做检测,因此只能通过构造特定图片,在业务图片加载前对其进行检测从而规避该问题,如下:

const checkWebP = () => {
  const pro: Promise<boolean> = new Promise<boolean>((resolve) => {
    if(typeof window === 'undefined') resolve(false);
    if (window['__support_webp__'] !== undefined) {
      resolve(!!window['__support_webp__']);
    } else {
      const img = new Image();
      img.onload = () => {
        window['__support_webp__'] = true;
        resolve(true);
      };
      img.onerror = () => {
        window['__support_webp__'] = false;
        resolve(false);
      };
      img.src = 'error image';
    }
  });
  return pro;
};

更多的场景覆盖

目前方案支持了 React、Vue2、Vue3 以及小程序,为了保证体验的一致性、降低维护成本,加载 SDK 做了分层的设计,将核心的 Core 层抽离出来给到各个框架使用,并对各项能力做了插件化。

17dbe67c65b4aac9ae23ef27e2b02691.png
场景覆盖.png

小结

随着方案的迭代,我们也在尝试覆盖更多的业务场景,比如:加密图渲染、Hybrid HEIC 渲染等,火山引擎veImageX 希望给客户带来全面、稳定、流畅的图片体验,同时给业务带来极致的成本收益。

我们将如上能力封装成简单的webSDK,向行业输出,并可以免费获取和使用此SDK,更高级的能力也可以配合veImageX来使用;

webSDK接入地址:https://www.volcengine.com/docs/508/177943

自研多模态追踪算法 PICO 为「手柄小型化」找到新思路

作者 ByteDanceTech
2023年9月22日 15:34

作者:张韬、林泽一 、闻超 、赵洋

研发背景

作为头戴的追踪配件,VR手柄可以通过HMD(头戴显示设备)的inside-out光学追踪定位原理,计算出手柄的空间运动轨迹,同时结合6轴传感器实现6DoF空间定位。与此同时,结合手柄控制器的物理按键、马达反馈、摇杆等,用户还能获得逼真、细腻的触觉反馈,进一步增强虚拟现实人机交互的能力以及沉浸感,这也是目前无手柄方案所难以实现的。

目前主流VR手柄的追踪技术方案,包括光学追踪、自追踪和电磁追踪方案。

304e1fbcf7c4ce82b9a4e5c2e3981c1e.png

因精度高、功耗低、成本低,光学追踪是目前最主流的VR手柄追踪方式。为了保证IR灯(红外灯)不易受遮挡,通常手柄本体上都带有一个明显凸起的追踪光环。

但为了顺应VR设备小型化得趋势,提升用户携带的便利性,并提供更自然的交互方式,PICO取消了手柄上的追踪光环,选择在手柄本体有限区域内布置少量的IR灯。

Centaur多模态融合算法架构

更小的手柄、更少的IR灯,也意味着更频繁的遮挡。如何解决遮挡情况下的手柄追踪问题,则是PICO研发团队面临的关键课题

基于团队在光学追踪与裸手追踪方面的技术积累,PICO创新性地提出了一套基于神经网络的多模态手柄追踪架构,其融合了惯性测量单元(IMU)、光学传感器和手部图像信息。在手柄被遮挡的情况下,裸手追踪能够提供更加精准的观测,同时手柄又能为手部追踪提供准确预测,两者深度融合、相互辅助。

裸手追踪

由于手柄的遮挡,通常裸手视觉特征并不明显,这也常常会引发追踪失效。针对该难点,裸手算法团队创新性地提出Down-Top的端到端6DoF追踪算法,通过有效利用多目时序的全局上下文信息,一次性准确且稳定地预测手柄位姿信息,能够在手柄追踪失效时,及时提供鲁棒的6DoF位姿。

1. 模型背景

目前普遍的裸手追踪算法是基于Top-Down结构,即基于Detection模型检测出手部的bounding-box框,再利用bounding-box框将手部抠选出来,如下图所示:

1c84d8832eaaef584c465d4a7463e1b6.png

该结构可以获得更高的精度,但是在平举、自然垂下等特殊动作场景中,由于小手柄遮挡或离得较远等原因,手部放大之后模糊的地方较多,如下图所示:

43543e2609a93914c026228eb0b06e21.jpeg5f9a4e5ce87157e87b539883b77de11c.jpeg

这种情况下,Top-Down的结构就很难检测出手腕点的位置,从而导致解算失败和手柄失效。但Down-Top的结构,则可以帮助PICO从大图中的手臂、身体等信息,判断手腕点的位置。

2. 模型结构图
c0c93d1fb4ff9f197fa517af783406ef.png
3. 评测结果

从使用Top-Down模型结构和Down-Top模型结构在平举和垂下等场景中的实验结果中,可以发现,使用Down-Top方案,能够在精度相近的情况下,获得更高的检出率:36%->93%。

Top-Down

386583023554f664c21b89c5bcf44b08.png

Down-Top

a070ef6bbdb7b7717c69275a5d5228aa.png

融合算法

1. 全新挑战

传统光学追踪方案,依赖手柄上一个显著突出的物理结构(即追踪光环),来确保手柄在各种各样的握持角度和位置条件下,都有足够的LED灯点可以被定位摄像头观测到。多个LED光点在图像上的2D位置被确定后,则可以进行PNP解算,辅以手柄内高帧率的IMU,则可以获得精确的手柄高频定位结果,从而为用户提供准确、流畅的追踪体验。

d93dcf41d3d6f2c9e71df9b626b85e04.png

PICO 4 手柄

88b84b79ec1582205176abaad3f125d7.png

Quest 2手柄

但去掉光环后,追踪算法则面临较大挑战。由于LED只能够被稀疏的布置于手柄本体的几个区域,且更少的数量和更易被遮挡的情况,也导致摄像头经常性地只能观测到有限的红外灯,甚至是零个。此时算法仅依赖IMU的惯性递推解算,并不能长时间提供稳定可靠的定位信息。

PICO算法团队经过多轮探索预研后,创新提出了融合惯性测量单元(IMU)、光学传感器和手部图像信息的多模态融合方案。该方案基于手势识别和手柄光学追踪的互补性,完美地解决了上述的一系列挑战和难点。团队将其命名为Centaur多模态融合算法。

2. Centaur多模态融合算法的构成

Centaur多模态融合算法将视觉信息和惯性信息进行融合,进而得到手柄位姿及速度的最优估计,并提供给上层应用层。融合算法构成如下图所示:

5faf12ebb79c1cfdc2c659f33656ae4c.png

图中各模块的功能:

  • 多个Global-Shutter IR camra布置在头戴四周,正常曝光帧能够采集到人手的特征,低曝光帧则能够在抑制大部分环境光照干扰时获取到手柄中的LED位置。

  • 一个IMU模块布置在手柄内部,提供手柄运动时的加速度和角速率信息。

  • 3-DOF模块借助纯IMU数据估计手部的旋转信息。

  • 基于深度学习手势检测及追踪模块(AI-based hand detection & tracking),通过有效利用多目时序的全局上下文信息,准确预测手柄的位姿信息。

  • 光学定位模块(Led detection / matching & pose estimation),使用3-DOF提供的姿态和LED在手柄上的分布等先验信息,通过智能匹配机制确定图像光斑和led灯的匹配关系,得到手柄位姿的单帧估计值。

  • 多帧融合滤波器(Multi-State-ESKF),将得到的手部位姿、手柄IMU数据、LED光学估计位姿及LED匹配关系等信息进行融合计算,得到高精度、高帧率的手柄位置、旋转及速度信息,并更新给系统接口,供上层应用使用。

3. 追踪与融合

当算法首次运行,或处于3DOF状态时,由于没有连续追踪产生的时序先验信息,因此需要Bootstrap from scratch的初始化方案。在LED及手势两种信息的加持下,初始化算法相比传统光学定位也做了相应的升级,并运行LED初始化和手势初始化两种算法,最先解出正确初始状态的算法将使用手柄初始位姿及速度初始化融合滤波器,从而显著改善各种握姿下手柄初始化的速度和成功率。

而当算法初始化完成并进入追踪状态时,算法流程又如下图所示:

6e754686d6ae27cec37237d4445c3de2.png
  • Step 1. 当一个新的图像帧到来时,基于滑窗中的历史帧状态,利用IMU数据进行惯性递推解算,得到新图像帧的状态预测值。

  • Step 2. 基于预测的手柄位姿能在当前帧图像中得到手柄LED或手部特征的预测位置,下面具体分类描述:

    • 针对正常曝光帧: 采用上文所述的Down-Top的网络结构,直接得到手腕关节6DOF的位姿结果,使用“手柄-手腕”对齐关系转换成手柄位姿,添加为一个位姿观测,作为当前帧的约束。

    • 针对低曝光帧: 在区域中检测得到LED光斑的2D位置。基于最近邻匹配算法,将预测的2D点集与检测得到的2D点集进行匹配。使用PNP solver得到手柄位姿估计,将位姿结果和2D匹配结果都添加到观测factor,作为当前帧约束。

  • Step 3. 最终的融合算法采用了Multi-State ESKF方案,采取了松耦合/紧耦合结合的模式,对追踪效果有显著改善的同时节省计算量并保证稳定性。

4. Centuar多模态融合算法收益
  • 下图为仅有 3 颗 LED 灯时手柄静止状态下的追踪效果,多帧紧耦合比单帧松耦合的结果更加精确,追踪更加稳定,波动显著减小

    • 光学观测的抖动非常明显,±3sigma范围约为「x轴16mm,y轴4mm,z轴25mm」。实际动作是放在头戴正前下方,露出三颗红外灯并保持静止,因此深度方向上(xz)误差显著大于与深度正交方向(y)上的误差。

    • 松耦合eskf对光学观测抖动有抑制作用,三轴向抖动范围压到「x轴6mm,y轴2.5mm,z轴9mm左右」,但速度估计波动仍有10mm/s。

    • 多帧紧耦合的结果是最好的,轨迹明显更平滑,抖动范围约「x轴2mm,y轴1mm,z轴3mm左右」,速度抖动范围3mm/s左右,相比原Filter各项误差指标大约有3倍收益。

e3998d3e4073b26abed53b829063c70e.png
  • 当做翻手动作,彻底遮挡所有LED时,算法融合手势定位信息与IMU信息,能够保持手柄的追踪状态与追踪精度,在各种场景下均能切换自如,丝滑操作。

  • 为了验证追踪效果,PICO团队还进行了极客玩家的极限测试,在运动健身、音游等需要快速甩动手柄的场景下,PICO多模态融合算法,都能准确且稳定地追踪手部和手柄的位置、姿态。

494b7727c70fe1f0953b415f5044af53.png

PICO 无灯环小手柄

自研同步多相机系统

数据采集与自动标注

PICO数据实验室构建了多模态的同步相机系统,不仅能获得大量且高精度的数据信息,也为技术和产品的研发奠定了坚实基础。该系统硬件方面包括工业 RGB 相机阵列,结构光扫描仪,光学动捕相机系统,以及 VR 头戴,软件方面包括点云注册、时空间标定、手势手柄自动标注等,数据采集与自动标注流程包含采集前的准备和数据采集作业,其中数据采集作业又分为两个阶段。

baa527e152456830618cc9514984f05e.png

左:同步相机系统;右:带光球的 VR 头戴

  • 采集前的准备

我们采用结构光扫描仪获取手柄和 IR 光球表面的密集点云获得了光球到手柄模型的转换关系。我们还将光球绑定到了 tag 标定板上,通过观测标定板获得了包括 VR 头戴在内的传感器参数;对于各个传感器的时间线,我们采用两种方式来对齐:一是侵入式地共用外部时钟信号,二是通过快速舞动头戴设备,从而获得 VR 头戴轨迹和与其绑定的光球轨迹来进行时、空间对齐。

f2b17177a2ba49502be7aeae879f3df9.jpeg

采集前,结构光扫描及注册

ef7fc4db22a2576b3ba6181152d31044.png

阶段一,采集手和手柄空间关系

600d0e83bf689d159a29ef8014ea09a6.png

阶段二,手柄跟踪及手势标签

  • 数据采集作业

    • 第一步,以多视角的图像作为输入,使用自研的手部姿态标注算法获得关键点位置。在这一环节中,为保持数据的高精度,我们提出了基于解耦表示的手势姿态估计算法。我们构建了 2D 视觉空间和 3D 节点空间,并通过迭代的方式不断优化手部姿态。同时,为了解决数据标注冷启动时训练数据来源的问题,我们还设计了多视角自监督的框架。相关算法已发表于 ICCV2023 会议中

    • 第二步,在获得不同视角观测的手部姿态后,我们融合多视角信息。通过使用三角化方法,通过 RANSAC 获取多视角融合后的 3D 手部姿态。在此基础上,再结合每个手部关键点的置信度进行微调优化。

    • 第三步,以上一步获得的 3D 手部关键点为目标,综合考虑骨骼位置、运动速度、手部关节的旋转、手势和手柄之间的碰撞关系等多种约束,对前序的结果进行优化。至此,我们获得了手的关键点以及手和手柄的相对位置关系

    • 第一阶段:相机系统同步采集工业相机和 VR 头戴相机的图像,并同时采集光学动捕相机捕捉的标志点坐标。

  • 第二阶段,被采集者保持手相对手柄姿势不变,在不同场景中挥动手柄获得其轨迹。

通过光球与手柄之间、阶段一获得的手和手柄之间的空间关系,以及阶段二采集的光球轨迹,就能获得手势、手柄在采集空间中的轨迹。另一方面,通过光球与头戴之间的空间关系与阶段二跟踪获得的光球轨迹,就能将手势、手柄投影到头戴相机中获得数据标签了。

总结

PICO研发团队始终致力于为全球用户创造优质的XR技术和产品体验。手柄小型化设计是XR交互方案设计中的创新性和突破性进展,而PICO自研的Centaur多模态追踪算法,不仅让「手柄小型化」完成了技术突破并成功落地,也为后续的人机交互设计提供了新的思路和可能性。

如何利用播放器节省20%点播成本

作者 ByteDanceTech
2023年9月21日 21:57

点播成本节省的点其实涉及诸多部分,例如:CDN、转码、存储等,而利用播放器降本却是很多客户比较陌生的部分。火山引擎基于内部支撑抖音集团相关业务的实践,播放器恰恰是成本优化中最重要和最为依赖的部分。

火山引擎的视频团队做了份数据统计,在一个很经典的视频业务中,我们在2022年至2023年大约1年半的时间里,针对这个业务进行了33次成本优化点,其中13次是播放器主导的优化,其余的有12次也是需要播放器强配合的优化也就是说在这个业务里,75%的成本优化是直接或间接由播放器参与,可见客户端对成本优化的关键作用。

最终我们在很多实践中也发现通过播放器的优化可以为点播业务节省20%甚至更多的成本,本篇内容就将聚焦在播放器层面如何节省成本这一主题。

点播成本构成

在视频点播的成本构成中,有很明显的二八原则:

b78dbdebdaf020b6ec3c8aecd90da74c.png
  • CDN带宽成本占绝对的大头,80%都是带宽成本;

  • 其次是存储和转码成本,二者占不到20%;

  • 额外还有一些其他的周边的成本,比如日志处理的数据成本、AI处理的成本。

我们可以将成本的优化理解成“置换”,在点播的成本优化中,就存在2种“置换关系”:

第1种置换关系是“成本项之间的置换”,指的是「带宽-转码-存储」之间的置换。

4e5da48baee214e8a480888d5f9f25f0.png

上图是H.264升级到H.265编码格式的例子,265的压缩率相对比264要优20%-40%,所以带宽、存储上265是大幅度减少;但是265的计算复杂度要复杂很多,所以转码成本大幅度升高。

这个图不是一个等边三角形,带宽成本要远大于转码和存储成本,所以这个置换是非常划算的

第2种置换是“成本和体验的置换”,我们一般说是“跷跷板效应:

2d70e930fe68861a75be2127abe9de38.png

例如:

我们增大缓存时长,对应体验上「卡顿率」就会降低,但是成本会增加;

抖音小视频feed流场景,我们做预加载,这时候首屏感会更顺滑,但对应的成本是增加的;

降低码率,那么体验上感到清晰度变差了,而成本就是减少的;

跷跷板中间支点是技术,我们通常是希望固定体验、降低成本,依靠技术来支撑。

所以我们总在说降成本,那降的到底是什么呢?我们这里用一个很简单的乘法公式来表示:

296fd2b40797c4a2a283b4a6ffb8f5b1.png

在过去,“单价”是非常明显的因素,大家往往选择在采购环节尽量的压低单价;而“用量”上通常会被认为是无法改变的业务因素。

但“用量”实际上是包含2类,一类是正常用量,确实是比较难改变的业务因素,但另一类是“浪费”,是可以被优化的。

所以如何识别出浪费、降低浪费,是播放器降本的关键点

那么造成浪费的因素有哪些呢?

ae7a53bc1feb31b2871087a1e9700fc3.png

例如在视频播放过程中,会包括“已播放的数据”,和“未播放但已经缓存的数据”,如果用户中途离开播放,那其中“已缓存的数据”都是浪费了。

所以我们定义“浪费”是“已经缓存了、但不需要的字节数”。

从理想上来说,没有浪费是最好的;但往往业务中,浪费是非常大的,大于30%是很常见的。

常见的可能带来的浪费包括了:

•未播放离开

•向后拖拽

•切换档位

•清晰度溢出(举例:很小的手机屏幕播放4K的内容,肉眼感知不到清晰度的区别)

播放器的成本优化方法

针对上述的浪费我们进行了如下的具体优化方法:

1、缓存的浪费

cc0a24ee15b200aa814c5a913a903619.png

承接上图的播放器缓存示意图,如果用户播放过程中离开了,那么深灰色是浪费部分。很容易就想到我们减少深灰色的部分的大小,比如把播放水位降低1/3(也就是图中浅黄色的部分减少掉),不去缓存,那么浪费就明显的减少了。

这个就是静态水位的思路,通过减少缓存水位来减少浪费。

但是,静态水位是很难抉择的,水位大了浪费多,但是水位太小了,卡顿就会明显的增加。

这里有个马太效应,从原理上,缓存的本质是为了对抗网络的抖动的。 网络稳定好时,只需要很少的缓存就足够了,但是网络好时缓存会填充的很快,大部分时间都是饱和的。反之,波动大的网络,需要更多的水位,但总的上限也有限,无法提供有效的缓存。

为此我们实现了的动态水位算法,我们根据一些因素来动态的决策缓存水位的大小

•1)探测用户的网络速度和稳定性,对稳定性高、速度快的,我们减少缓存;对网络速度差、稳定性差的网络,就增大缓存,这样在网络抖动时就能够有更大的缓存空间使用;

•2)根据用户的播放行为,通过数据分析道,视频观看的前期,用户离开的比例会更高,观看的后期,离开的比例就会降低, 所以前期的缓存水位小一些,后期的缓存水位大一些;

3)还有一些其他的因素,但目的是在每次播放时决策出一个尽量合理的缓存水位,来平衡卡顿和浪费;

决定了缓存水位大小之后,还有个细节点就是range请求。

6d96015d3f83dcc1bc6df16a59b630b6.png

Range是http协议的一个请求头,默认是“0-请求” ,表示请求完整文件。

左侧的图示意,如果是单独发一个“0-请求”,那么CDN服务端就会持续的返回整个文件,如果在中途断开,从服务端视角来说,这些数据已经发送过去了,无论客户端是否需要,都已经计费了,就构成了浪费。

在上图,我们分成3段来发range请求,中途断开时,是可以停止掉最后一段,那么浪费就大幅度减少了。

同样,静态的range是很难抉择的,range拆分的太细会引起卡顿的提升;range过大了成本节省的效果又不够了。

这里我们引入目标水位的概念,就是刚刚讲的动态水位算法所决策出来的水位大小。

播放器Range请求的应遵循两个原则:1. 将当前视频尽快缓存到目标水位。2. 控制Range拆分的大小,避免太小的Range拆分。

1ab36645c54e654f9ad8f05c82ae3566.png

上图是动态水位算法+动态range拆分的效果示意图:

横轴代表时间线。 纵轴上图是视频下载的大小,蓝色块代表一个range请求;下图是缓存的大小,橙色的折线表示缓存随着视频文件下载和播放时间的波动情况,横着的虚线是目标水位。

我们从左到右,分析下目标水位和range的关系:

• 看第1条竖着的红线,决策出来第一条目标水位1,是启播水位,启播时的range会略大于后面的2个range;

• 第2条竖着的红线,是判断出一次水位提升,有可能是检测到网络波动,会提高目标水位到水位2,同时做一次略大的range请求来达到目标水位;

• 第3条竖着的红线,是再次提升目标水位,到水位3,有可能是因为观看时长增加到阈值,判断离开概率较小,所以保持高水位;

•后续的播放,在目标水位3随着时间波动,range大小也会稳定些。

从最终效果上看,在任意一个时间点离开,都能够保障相对合理的浪费。

我们在不同业务上实践了很多次动态水位+动态range的AB实验,在体验指标持平或更优的前提下,带宽降低8%;

2、预加载的浪费

在类似于抖音这种feed流下滑的场景,会提前加载好下面的视频,能够使滑动更顺畅,我们 叫“零首帧”效果,里面作用最大的就是预加载。

一般的预加载是固定几个视频,每个视频固定的大小。为了得到更好的预加载效果,会尽量多、尽量大的做预加载,也就构成了浪费。

91ba4a783c31daf078817d415b501f38.png

我们做的“精准预加载策略”,在“时机、大小、个数”上做精细化的优化:

1) 时机上,对预加载也进行切片,这样可以区分出来一部分是紧急的, 其他是不紧急的。比如图里,标记P0的是要最优先下载的,然后可以做预加载,预加载标记P1的部分,然后是当前视频的缓存水位,之后可以选择是否要预加载P3的部分。

•2)大小上,每个视频也会结合视频的长度、头大小、码率等因素计算出来需要预加载的大小

3)个数上:按照feed list中的优先级依次预加载后续N个视频(动态计算),也会结合用户本身的行为(比如快速滑动)来动态决策。

•我们在不同业务上进行AB实验,都能够验证这策略可以有效的提升预加载利用率、降低对应流量成本

3、清晰度的浪费

现在的主干场景是在移动端看视频,大家都会有启播选档的策略,就是在播放启动时,决定所需要的清晰度,一般是跟随网速、码率来决策的。

97af700ce2050a162514fa0872b84948.png

经常大家面临的场景是,在竖屏里播放横屏视频时,实际上在很窄的一个空间里进行播放, 这个时候,如果依然使用完整的清晰度,那么肉眼是看不出来的清晰的。而且,通常情况下小窗播放时用户的主要关注度也并不是画面清晰度,所以就产生了实际上的清晰度浪费。

我们对应的解决策略叫 “窄屏低清” ,就是识别出来显示区域很窄时,播放低清晰度的视频(比如360P),当需要横屏时,再快速的切换为正常的清晰度。这里如果是mp4格式播放,需要转码也做些配合,支持mp4的帧对齐和平滑切换。

在很多应用中都是很常见的,也有常见的小窗播放,多个业务的AB实验都能有3%以上的成本收益;

另外清晰度上还有个很棒的能力,是客户端超分。随着客户端超分能力的优化,现在很大一部分机型在客户端向上超分一个档位是完全没问题的,耗电可以忽略。

对应节省成本的策略是“降档超分”,就是分发的清晰度向下降一档,然后再通过客户端超分降主观清晰度补回来。在国内当前的机型条件下,大部分业务能够有6~8%左右的成本收益

4、异常流量的浪费

我们根据「播放器日志是否可以识别」、「是否是正常流量」把流量分成了4类。

e5f0486dc9dff981b55e67c39b4efecf.png

在非常多的业务中会发现第三种情况:流量有异常浪费,比如有部分视频码率过高,可能是没转码,或者转码模版用错了。我们开始时会认为“这些都是很明显的失误,业务层小心点不就行了么? ”,但后来我们做成了单独的异常流量分析模块。我们跟业务尝试分析原因,发现业务总是复杂的:

  • 比如业务场景很复杂,包括短视频、长视频、主页视频、广告视频等等;

  • 研发的迭代也通常会带来些历史问题;

  • 并不是所有的人员都需要持续的感知成本,只要有一个环节漏掉了,那么就可能会造成很大浪费。

这里还有个问题点,如果是体验问题或者bug,总会有用户保障,来及时发现。但成本问题,用户基本是无法发现的,发现时就比较晚了。

我们是通过端到端的日志分析来发现和避免这些浪费的。原理很简单:

1)在客户端对日志染色,

2)cdn日志里记录的,区分是否是播放器产生的、是否是我们点播的域名。

3)对两头的日志进行比对和分析;

不仅如此,这里还有个副产物,是通过这些日志分析,识别到业务真实是被盗链了,然后做盗链的治理。

数据挖掘成本优化空间

以上是火山引擎是实际业务服务过程中探索出的优化方案,但优化是不是有上限的,优化到什么水平可以达到成本和体验的平衡,更多的能力是通过数据能力持续的挖掘出来的。

先从结果上来看,我们成本优化后通常会有2个报告:

1)AB实验报告:里面会分析对QoE的体验影响多少,对成本优化的影响多少,比如人均播放时长增加多少,成本降低多少。做成本的AB实验,依赖一个工具“客户端成本指标”。

2)价值回溯文档:用于核算真实收益有多少,一般发生在完整上量之后,比如1个月或2个月后。关键结果叫“万分钟播放成本”,这个对应的依赖的工具是“成本评估公式”。

客户端成本指标

ed934ca2952f6b9957af543727f9501a.png

这张图从左往右是视频点播的数据流向。想要建设好成本埋点,有2个难点:

1、成本拟合。因为真实的计费数据是左侧CDN的计费日志,在右侧的客户端侧实际上是没有成本数据的,所以我们需要把数据缓存层的对成本的埋点尽量的拟合,使之尽量的对应到CDN的计费日志。这个过程是非常艰难的,我们通过了大量的离线校验。

2、提升可解释率。业务动作比较复杂(播放、预加载、拖拽、重播等等),举个例子,重复播放,播放层是记录2遍播放时长的,但是因为有缓存,真实的网络请求只有1遍。我们想要两份数据尽量对齐、可解释,就需要涵盖住尽量所有的业务场景。

我们当前达到了“可解释率达到95%”,也就是说比如服务端CDN产生了100Gbps的带宽,客户端的日志能够拟合解释清楚95%。

虽然还不到100%,但日常来做成本优化、成本归因已经足够了。

下图是成本指标进入AB实验后的结果

093376221864505a3d9888f976688812.png

核心指标

3c190fda2f79bf683b97c72ea9293d40.png

归因指标

成本数据进入AB实验有什么用呢?

1、快速判断客户端的成本变化结果。大部分成本优化的能力都是伴随着策略的,不同策略有不同的结果置换关系,我们需要通过实验来确定效果。假设没有客户端的成本数据的话,我们就需要用不同的CDN域名来实验,这是很低效的,并且域名带宽的波动也会引起成本的波动。而在客户端成本指标进入了AB实验之后,大部分场景都直接看报表数字就可以了;

2、机制上可以防蜕化。 业务的产品经理、分析师等角色也日常会关注到实验数据的,当成本数据也进入实验后,这些角色也可以关注到成本的变化,这样就能够防退化了。举例:版本升级时,只要经历了AB实验,就很难有成本退化的问题。

成本评估公式

“成本评估公式” ,本质是一种单位成本的衡量方法。

b2c0f78ded13b2e7a6a8f2f41c5d29b0.png

我们叫“万分钟播放成本”,分子是点播的IT成本,分母是点播视频消费时长。

从技术侧来看,分子是“CDN、存储、转码等各种成本的加和”,分子是播放的时长。

这个公式很简单,但为什么要这么做呢?

涉及到成本优化,就会跟采购、财务团队打交道,采购、财务看到的都是每月的账单,业务用量每个月都在上下波动,导致账单每个月也都在波动。万分钟播放成本是单位成本,就可以刨除掉业务用量的影响因素,来衡量成本是否真的优化了。

我们来拆解其中的万分钟CDN成本:

f86b87202a99adf0f583b8412b8acf4a.png

万分钟CDN成本的影响因子会涉及到价格、码率、浪费率、带宽流量比。

举一个真实的例子:

有个客户反馈成本增加了,但是客户自己的业务用量在波动,不太好判断是什么情况。我们拆解分析万分钟CDN成本的具体影响因子,就发现了万分钟CDN成本确实是涨了11%,主因是“码率”涨了8%,“浪费率”增加了5%。

总结和展望

建标准

在服务业务的过程中,大家经常会面临一个问题, 还能再降多少?极限是多少?

这些问题是很难回答的,因为每个业务的场景都不同,举例缓存浪费中,每个业务的客户中断离开的模型可能都不一样,那么建设统一的标准就很难了;

火山引擎目前通过3种方式来建设标准:

1)通过排名获取标杆:将类似场景的业务进行排名,对齐当前技术做的最好的,可以作为一种标准;

2)离线的实验来模拟:我们做了成本的自动化测试平台,设计测试case,测试出来不同的参数的成本结果是多少,最后总结分析出来极限是多少;

3)通过“理论公式”来推算“标准” :举例通过“视频播放时长、中途离开比例”的关系,然后推算出理论的优化空间有多少;

做顾问

面对的业务越来越多,降本的能力也越来越多时,就会遇到效率问题:功能这么多,应该用哪些?每个业务的场景也不一样,那么策略参数应该怎么配置呢?

b5fc7788c4323eb1c39a1ced68716bbb.png

万分钟播放成本分析和策略推荐

解决方法是做顾问:上图是我们的一个万分钟CDN成本与理想万分钟成本的一个差异分析表,我们给计算出了对应的差异,然后再给出可以补足差异的策略或功能推荐。

当然,这个表只是一个总结概览,更多的内容我们会整理成“顾问服务报告”,把各个点的差异、业务分析、解决方法与业务逐一的讨论分析。

万分钟播放成本是一个非常简单、容易落地、价值很大的工具,大家计算下万分钟播放成本,如有调优的诉求,非常欢迎来与火山引擎交流。火山引擎视频点播https://www.volcengine.com/product/vod。

火山引擎 ByteHouse:ClickHouse 如何保证海量数据一致性

作者 ByteDanceTech
2023年9月15日 09:30

背景

ClickHouse是一个开源的OLAP引擎,不仅被全球开发者广泛使用,在字节各个应用场景中也可以看到它的身影。基于高性能、分布式特点,ClickHouse可以满足大规模数据的分析和查询需求,因此字节研发团队以开源ClickHouse为基础,推出火山引擎云原生数据仓库ByteHouse。

在日常工作中,研发人员经常会遇到业务链路过长,导致流程稳定性和数据一致性难保障的问题,这在分布式、跨服务的场景中更为明显。本篇文章提出针对这一问题的解决思路:在火山引擎ByteHouse中构建轻量级流程引擎,来解决数据一致性问题。

使用轻量级流程引擎可以帮我们使用统一的标准来解决复杂业务链路的编排问题,不仅提高业务代码的可读性和复用性,还能更专注业务核心逻辑的开发,让整体流程更加标准化、规范化。

总结来说,使用流程引擎有以下优势:

  • 轻量级,接入方便,内存操作,性能有保障

  • 易维护,流程配置与业务分离,支持热更新

  • 易扩展,丰富的执行策略及算子支持

大体思路

8239a84c9f65c08769b3712e63e7e732.jpeg

上图为ByteHouse企业版管理平台功能架构图。从该功能架构图可以看出,ByteHouse核心能力都是依赖ClickHouse集群,对于集群节点多、数据计算量大的业务场景,容易出现节点状态不一致的问题,因此保证ClickHouse集群间的状态一致性是我们的核心诉求。

2a12dd1533910355d5d72c795f55e077.png

为了保证数据一致性,ByteHouse提供了以下能力:

  1. event engine: 事件处理中心

  2. workflow engine:轻量级流程引擎

  3. 对账系统

保障数据一致性最简单的方式是通过状态机来监听流程执行过程:

  • 首先,将所有的任务请求下发到event engine,由event engine将任务分发对应的handler执行,统一管理所有下发任务的生命周期,并提供异步重试、回滚补偿等功能。流量汇总到event engine以后,会让服务后续的业务扩展更加便捷。

  • 其次,对于比较复杂的任务请求,我们可以下发到workflow engine执行,由workflow生成实例,并编排任务队列,管理流程执行实例的生命周期,统一失败回滚,失败重试。

  • 最后,对于服务不可用等特殊场景产生的脏数据,由对账服务兜底。

a9545ed459ab02347bcc763c6706d474.png

架构设计

在流程监控的架构设计中,主要包含以下:

  • 流程管理层:主要负责流程配置的解析初始化,并完成编排策略的工作

  • 策略behavior层:编排执行节点,并下发执行任务到执行器

  • 执行器:管理执行节点执行

  • 执行节点:负责业务具体实现

9bd0d275b3798d11010380ca0522d35c.png

实现方案

执行节点

906e89e2b700dbddea015a094e395ddd.png

流程引擎的核心为“责任链”,按照责任链上的节点顺序依次执行所有任务,所以我们需要的三个基本单元分别为:

  • request:入参

  • processlist:流程执行节点list

  • response:出参

在研发工作中,我们时常会遇到以下问题:

  • 如果同时出现了一个问题,node1、node2、node3之间的数据交互如何实现?

  • 如果node1入参、出参与node2,node3不一样该如何处理?

  • 参数类型不同的node又该如何统一调度?

最简单的处理办法,是让node使用相同的上下文信息,将整个执行node模版化。我们让所有的执行节点node实现相同的接口Delegation,统一使用相同的上下文executionContext作为执行方法的入参。

对于流程中的request和response,我们可以放入executionContext中,让每个执行节点都可以通过上下文操作response。

// Delegation -
type Delegation interface {
   Execute(ctx context.Context, executionContext ExecutionContextInterface) apperror.AppError
   TryExecute(ctx context.Context, executionContext ExecutionContextInterface) apperror.AppError
   ConfirmExecute(ctx context.Context, executionContext ExecutionContextInterface) apperror.AppError
   CancelExecute(ctx context.Context, executionContext ExecutionContextInterface) apperror.AppError

   Code() string
   Type() value.DelegationType
}

执行策略

如果确定好了最小的执行节点,我们需要考虑到,业务场景并不会永远顺序执行node,再返回结果,流程执行过程中跳转、循环、并发执行都是比较常见的操作。考虑不同业务场景复用性,我们在执行节点之上加了一层执行策略,用策略behaivor来重新编排触发执行节点的任务。

  • 下图将流程分成了behavior1和behavior2,分别对应不同的策略。

  • 简单的策略举例:按顺序执行、并发执行、循环执行、条件跳转执行等。

  • 我们可以根据自身业务实际需要定制,后续会有实例介绍。

6e88ba463f800f06de5705821e4faaaf.png
// ActivityBehavior -
type ActivityBehavior interface {
   Enter(ctx context.Context, executionContext ExecutionContextInterface, pvmActivity PvmActivity) apperror.AppError
   Execute(ctx context.Context, executionContext ExecutionContextInterface, pvmActivity PvmActivity) apperror.AppError
   Leave(ctx context.Context, executionContext ExecutionContextInterface, pvmActivity PvmActivity) apperror.AppError
   Code() value.ActivityBehaviorCode
}

策略behavior提供有Enter,Execute,Leave三个接口,Enter负责生成执行节点任务instance,Execute负责编排并触发执行任务instance操作,Leave负责跳转到下一个behavior。

可以看出来策略behaivor的跳转方式类似于链表,不断执行next方法,所以编码过程中需要注意不要出现死循环,小心stackoverflow。

Executor

执行器Executor的主要作用是串联执行策略和执行节点,策略behavior将执行的命令下发给Executor,由Executor对执行节点的触发操作。这里会根据执行节点的type,映射到三种执行节点的执行方式,包含tcc,执行一次,重试多次。

// DelegationExecutor -
type DelegationExecutor interface {
   execute(ctx context.Context, executionContext ExecutionContextInterface) apperror.AppError
   postExecute(ctx context.Context, executionContext ExecutionContextInterface) apperror.AppError
}

func (de *DefaultDelegationExecutor) execute(ctx context.Context, executionContext ExecutionContextInterface) apperror.AppError {
   delegationCode := executionContext.GetExecutionInstance().GetDelegationCode()
   if len(delegationCode) == 0 || de.DelegationMap[delegationCode] == nil {
      logger.Info(ctx, "DefaultDelegationExecutor delegation code not found,use default delegation", zap.String("delegationCode", delegationCode))

      delegationCode = string(value.DefaultDelegation)
      executionContext.GetExecutionInstance().SetDelegationCode(delegationCode)
   }

   return de.dumpExecute(ctx, executionContext, delegationCode)
}

func (de *DefaultDelegationExecutor) dumpExecute(ctx context.Context, executionContext ExecutionContextInterface, delegationCode string) apperror.AppError {
   FireEvent(ctx, executionContext, value.ExecutionStart)

   var err apperror.AppError
   delegation := de.DelegationMap[delegationCode]
   switch delegation.Type() {
   case value.TccDelegation:
      err = tccExecute(ctx, executionContext, delegation)
   case value.SingleDelegation:
      err = singleExecute(ctx, executionContext, delegation)
   case value.RetryDelegation:
      err = retryExecute(ctx, executionContext, delegation)
   }

   if err != nil {
      logger.Error(ctx, "delegation.Execute_err", zap.Error(err))

      return apperror.Trace(err)
   }

   FireEvent(ctx, executionContext, value.ExecutionEnd)

   return nil
}

ExecutionContext

ExecutionContext上下文是用来记录了流程执行的所有细节,包含以下:

  • ProcessEngineConfigurationInterface: 流程定义信息

  • ExecutionInstanceInterface: 执行节点实例

  • ActivityInstanceInterface: 执行策略实例

  • ProcessInstanceInterface: 流程实例

  • request:入参

  • response:返回值

为了保证整个流程执行的稳定性,这里除了response之外,所以其他的实例参数都不建议开放写接口,response可以用来存储流程实例执行过程中会产生的变量信息。

对于整个流程的定义ProcessEngineConfiguration,我们可以选择最简单的方式,即在数据库里,将配置信息映射成json字符串。当然也可以选择读取配置文件,只要能满足读取方便,数据不丢即可。

// ExecutionContextInterface -
type ExecutionContextInterface interface {
   GetProcessEngineConfiguration() ProcessEngineConfigurationInterface
   SetProcessEngineConfiguration(processEngineConfiguration ProcessEngineConfigurationInterface)
   GetExecutionInstance() instance.ExecutionInstanceInterface
   SetExecutionInstance(executionInstance instance.ExecutionInstanceInterface)
   GetActivityInstance() instance.ActivityInstanceInterface
   SetActivityInstance(activityInstance instance.ActivityInstanceInterface)
   GetProcessInstance() instance.ProcessInstanceInterface
   SetProcessInstance(processInstance instance.ProcessInstanceInterface)
   SetNeedPause(needPause bool)
   IsNeedPause() bool

   SetActivityIndex(activityIndex int)
   GetActivityIndex() int
   SetActivityBehaviorCode(activityBehaviorCode value.ActivityBehaviorCode)
   GetActivityBehaviorCode() value.ActivityBehaviorCode
   SetBizUniqueKey(bizUniqueKey string)
   GetBizUniqueKey() string

   GetRequest() map[string]interface{}
   SetRequest(request map[string]interface{})
   GetResponse() map[string]string
   SetResponse(response map[string]string)
   AtomicAddResponse(key string, value string)
}

Listener

监听器的主要作用是用来监听流程执行中的重要参数信息。从上述executor接口可以看到fireEvent,它的作用是发送消息event,让listener监听到对应的event类型,完成一些定制化的行为。

类似于面向切面编程,我们可以在执行节点的前后增加定制化的逻辑,如打日志、监听节点执行时间,持久化流程中产生的response信息、增加链路追踪等。

API

ac5943dc65c2d11784eeedd7c8e8d7cd.png

最后,我们将上述的内容拼接串联起来,主要提供三个接口:

  • Start: 启动流程

  • Signal: 暂停或是异常退出后,继续执行流程

  • Abort: 强制中断流程

process start(){
    //1.get and create ProcessEngineConfigurationInterface 解析流程定义
    
    //2.create processInstance 创建流程实例
    
    //3.create ExecutionContext 创建执行上下文
    
    //4. lockstrategy trylock 
    
    //5. invoke process start 
    processinstance.start()
    //6. persist processInstance and return

    //7. lockstrategy unlock 
}

processinstance start(){
    // get behavior
    
    // behavior enter
    behavior.Enter(ctx, executionContext)
    //behavior execute
    behavior.Execute(ctx, executionContext)
    //behavior leave
    behavior.Leave(ctx, executionContext)
}

相比于start,signal需要读取执行的细节信息,找到之前失败的执行节点位置,并加载到上下文中,再继续执行。

对于失败节点信息的持久化有两种方式:第一,可以选择在流程执行结束持久化;第二,可以通过listener在每个执行节点结束持久化。具体根据实际业务场景对于性能、数据一致性的要求做出抉择。

并发场景考虑

  1. behavior策略中肯定会出现定制、并发、处理多个执行节点到场景的问题,如果同时修改必定会造成数据错乱。简单的方法推荐使用带锁的容器存储,可以被修改的信息(response),此处使用的是github.com/bytedance/gopkg包里面封装的skipmap。

  2. lockstrategy可以自己定义最适配业务场景的,最简单的方案是redis锁,同时也考虑到系统异常退出后的恢复问题。可以参考redis官网解决特殊情况下的锁异常解决方案:https://redis.io/commands/setnx/

后续的工作

轻量级流程引擎的基本功能到此已经实现,后续的扩展优化可以围绕以下方向进行:

  1. 界面化展示,可以将链路执行情况展示出来

  2. 策略behavior维度扩展,适配各种业务场景

  3. 增加子流程的维度,可以复用原先的执行逻辑

Demo示例

以下为简单的processconfiguration的配置信息,此处使用DefaultBehavior,即同步顺序执行策略。

{
    "ProcessContentList":[
        {
            "Behavior":"DefaultBehavior",
            "DelegationList":[
                {
                    "Code":"sample1"
                },
                {
                    "Code":"sample2"
                },
                {
                    "Code":"sample3"
                }
            ]
        },
        {
            "Behavior":"DefaultBehavior",
            "DelegationList":[
                {
                    "Code":"sample4"
                },
                {
                    "Code":"sample5"
                }
            ]
        }
    ]
}
d20944d7dad46418584d4c190f459dd4.png

在listener里面加入日志,这样可以追溯出整个流程的执行流程,以便更好的监控整个流程的运行状态。

实际使用

以ClickHouse集群缩容为例:

c0162f657ec4f7f4b8b97a337361eae9.png
{
    "ProcessContentList":[
        // 查询所有需要重分布的table
        {
            "Behavior":"DefaultBehavior",// 顺序执行
            "DelegationList":[
                {
                    "Code":"hor_reshard_table_loop" 
                }
            ]
        },
        // 遍历所有table进行数据的重分布 
        {
            "LoopKey":"reshard_table_loop_key",
            "Behavior":"NonBlockLoopBehavior",// 非阻塞循环处理
            "DelegationList":[
                {
                    "Code":"hor_reshard_table"
                }
            ]
        },
        // 进行删除节点操作
        {
            "Behavior":"DefaultBehavior",
            "DelegationList":[
                {
                    "Code":"hor_start_remove_node"
                },
                {
                    "Code":"hor_prepare_node_vcloud",
                    "PostCode":"hor_rollback_remove_node_vcloud"// 统一失败回滚处理
                },
                {
                    "Code":"hor_update_config_vcloud",
                    "PostCode":"hor_rollback_remove_node_vcloud"
                },
                {
                    "Code":"hor_set_cluster_running",
                    "PostCode":"hor_rollback_remove_node_vcloud"
                },
                {
                    "Code":"hor_release_node"
                },
                {
                    "Code":"hor_callback_bill"
                }
            ]
        }
    ]
}

总结

一个流程引擎适配所有的业务场景几乎是不可能,除非接受复杂的方案设计,而第三方流程引擎对于日常的业务开发显得太笨重。轻量级流程引擎则会简化接入方式,减少了过多http请求带来的性能损耗,更加灵活多变,追述问题也变得简单。

在ByteHouse中加入流程引擎的能力,能以较小的代价给业务更多重试的可能性,而不需要反复回滚,特别对于耗时很长的任务,能带来更好用户使用体验。除此之外,流程引擎还能将业务流程模版化,增加接口服务的复用性,使得业务代码的可读性、扩展性得到提升,方便后期维护。

火山引擎云原生数据仓库ByteHouse是火山引擎旗下的一款云原生数据仓库,为用户提供极速分析体验,能够支撑实时数据分析和海量数据离线分析,同时还具备便捷的弹性扩缩容能力,极致分析性能和丰富的企业级特性,助力客户数字化转型。

抖音集团都在用的画质评估工具,确定不试试吗?

作者 ByteDanceTech
2023年9月7日 10:45

导读

本文从抖音集团内部画质评估体系的建设历程着笔,主要分享了画质评测对于业务的重要性主要应用场景内部产品的一些典型实践案例。通过分享业务视角遇到的一些问题和我们的解决思路,希望能抛砖引玉,为遇到类似困扰的伙伴们提供有价值的参考。

画质评估体系建设历程

为何评测画质如此重要?

我们通过线上业务大量实验发现,图片画质优劣对点击率、 停留时长等消费类指标有正相关影响,间接影响用户收益指标。因此,建设一套行之有效的画质评估体系,保障用户的画质体验是非常有必要性的。

94416f632b56a8b471c68c9e0e711d29.png

直观来讲,画质提升能够为带来更好的观感体验,但QoE综合体验也需要考虑其他方面如用户设备、网络状况、观看环境等多方面因素,不计成本地提升画质是否能持续为用户带来QoE的收益需要在业务场景中通过严谨的实验方案来验证效果的。

在低质图像打压和基于画质的推荐优化等多项业务中的数据分析积累沉淀,我们获取画质评分与用户主观体验之间的明确关系,数据统计显示用户对不同画质内容的敏感程度有着不同趋势,在中档画质分区间持续提升画质,用户的QoE体验也会显著提升,但当画质低于或者高于某个阈值时,用户对于画质将变得不再敏感,提升/降低画质对用户的影响均会降低。

439a56faa08f3bb5bee5bd695154f3c8.png期望中的画质甜点关系,中段区间的画质提升会持续带来QoE收益 af64044113c9ab065a83c9d377c7f971.jpeg实际业务场景中,分析画质与用户平均观看时长的关系,中高画质可以带来持续的看播收益

下图具体描述了两类典型应用场景下,画质评估体系在业务实践中发挥的主要价值:

81977a0c1c68af73f32549f879baae4f.png

我们为何自研画质评估体系?

图像服务的最终用户是人类,图像质量评价致力于成为可衡量图像的人眼感知质量需求的客观计算方法

行业现状
  • 主观质量评估:最准确,但费时费力费钱,难以批量应用。例如专家评测、众包测试等。

  • 客观评估算法:省时省力可大规模应用,但无论全参/无参考算法与主观评测均存在一定GAP,在UGC场景,差距会更加明显。

业界常用的有参画质评估算法,主要包括PSNR、SSIM、VMAF等3种:

fc19789cc9991cef60812fea80248893.png

痛点
  • 难以量化画质增强效果:行业通用指标( PSNR、SSIM、VMAF等)均为有参考画质指标, 主要适用于压缩失真的画质评估,难以量化评估画质增强效果。

  • 不适合 UGC 场景的评分:行业通用指标适用场景存在一定局限性,其训练数据集主要为PGC内容,在UGC场景的泛化效果较差。

  • 评估维度有限:UGC场景下,图片内容复杂且画质影响因素多样,需要更多维度评估指标用于画质分析和指导优化。

我们如何建设画质评估体系?

根据点播、直播和图片等不同形态业务需求,火山引擎多媒体实验室自研的VQScore 画质体系提供配套最优的全链路画质打分能力,提供异步或实时画质打分数据,为后续转码、增强、推荐策略和大盘监控提供能力支持。

具体画质分析打分能力分为两个部分:

  1. 内容分析理解:主要包含ROI检测、CG内容检测、人脸检测、内容分类等基础分类和检测的能力,为后续画质打分和增强转码提供细分的维度拆解能力和关键内容识别能力,实现精细准确的端到端自适应增强转码组合能力

  2. 画质打分能力:主要包含通用清晰度打分算法、美学指标、高阶色彩指标、人像画质等评估指标,噪声、块效应、过曝、脏镜头、模糊和伪高清等细分归因指标,以及超分质量、锐化质量和增强组合评估等前处理画质提升能力评估指标,通用+归因+增强多个维度组合,为不同的业务场景的画质优化需求提供集监控、分析、策略推荐等全方位画质打分能力

ad7d0b07cf71c8098ba503ad7d84f2e1.png

通用的画质清晰度评估算法基于多样化多业务场景主观标注样本、开源数据集和多样化失真合成数据集,驱动的轻量transformer-based深度学习的方案,在UGC视频/图像场景提供更稳定准确的客观清晰度预测能力。

在多种业务场景下,根据点播、直播和图片不同形态业务需求,支持最高4K分辨率内不同投稿内容的源画质分析,结合业务属性维度提供深入细化的画质维度分析,为自适应转码提供编码优化对比和不同时间尺度的画质监控,为AB实验和版本迭代等业务流程提供有效的QoE维度数据,同时也可以为多分辨率/码率档位播放下发提供画质与QoS网络、设备等因素组合组合的自适应播放分发优化能力。

7547fab8039abad7719415abee94a734.png

抖音画质评估体系有哪些优势?

适用范围广泛
  • 高质量且规模庞大的训练数据集,覆盖PGC和UGC内容,适用范围广泛(特别针对UGC场景)。

  • 算法模型历经亿级DAU产品持续打磨优化,泛化能力强。

评估维度多元

包含主观清晰度、大众美学质量等2类综合指标和噪声、亮度等十余类细分指标,支持更多维度、更细粒度地分析画质问题,便于业务有针对性地进行优化和调整策略。

多业务线上验证收益显著

历经抖音、头条、番茄小说等数十个大体量业务线上验证,评估效果可靠,能有效支持业务进行画质体验提升,进而带来用户消费指标提升,收益显著。

算法能力业内领先

画质评估体系涉及的算法模型已申请多项专利。eg. 一种检测伪高清视频的方法,一种基于多任务孪生神经网络的高阶视频色彩质量评价模型,一种三明治视频自适应播放方法等。

在ICME 2021的「压缩UGC视频质量评估」比赛中,火山引擎-多媒体实验室凭借自研的VQScore算法斩获无参考视频质量评价(NR-VQA)MOS赛道第一名。(详细介绍

该比赛主要针对 UGC 源视频画质和 H.264/AVC 压缩失真对视频主观画质的影响的研究

画质评估主要应用在哪些场景?

以瘦身计划和体重秤之间的关系做个简单类比,画质评估体系作为一套相对客观且行之有效的评测工具,在帮助产品了解业务画质现状、了解行业和市场现状、监测线上画质变化和支持提升用户体验等方面都有非常广泛的应用。

1. 了解业务画质现状

业务团队可以借助veImageX提供的画质评估工具,通过离线测评和在线评估等手段高效完成业务产品的画质摸底;同时,画质评估体系包含丰富的评测维度(例如噪声强度、色彩质量、块效应检测、过曝光检测等),数十项细分评测指标可高效帮助业务团队完成低质图像归因分析,快速锁定问题所在。

2. 了解行业/市场现状

借助画质评估工具,可以帮助业务团队对市场主流产品或同类业务进行画质评测,以便制定合理的画质提升目标;同时,综合用户主观评测和客观指标的对应关系,高效帮助业务团队确定适合自身业务的画质评估标准。

3. 监测线上画质变化

对于一款关注用户画质体验的产品来说,线上画质监测工具必不可少。而veImageX提供端到端的画质指标监测工具,可帮助业务团队长期高效监测线上画质变化;通过前后数据对比分析,帮助业务有效验证画质优化举措的效果;同时,线上低质问题告警也可帮助业务团队及时发现问题,保障线上用户浏览体验。

4. 支持提升用户体验

借助画质评估体系提供的评测结果,业务团队可以通过对低质图片进行搜索/推荐降权等方式打压低质内容,或借助画质增强能力提升画质,有效提升用户的浏览体验,进而带来点击率、人均阅读/消费时长、用户留存等业务指标正向提升。

典型案例实践分享

目前,由火山引擎veImageX提供的画质评估工具已服务于抖音、头条、西瓜、番茄小说、懂车帝等数十条业务线,在保障用户的画质体验方面发挥着重要作用。接下来,我们选取了几个典型案例为大家简要分享我们的实践经验。

某短视频/社区平台

需求背景

某短视频/社区平台的主要用户分布在多个国家和地区,发布内容覆盖多个细分垂类。业务团队收到部分用户反馈关注到不同国家和内容垂类间的画质存在一定差异,影响了用户的浏览体验,从而设立专项进行问题解决。

实践方案

业务团队首先使用画质评估工具对全地区的图片画质进行了离线摸底分析,发现部分国家间、某些重点垂类间的图片画质有较大差异,故使用自适应增强模型,针对性进行画质提升的同时尽可能节省码率。

整体收益

优化后,该平台各地区间、重点垂类间的画质基本拉齐且均达到【良好】及以上水平,图片大小显著降低,人均停留时长、人均互动、人均阅读时长、人均session次数等消费指标均显著正向

番茄小说

需求背景

相比于网文,漫画的书封更加精美,信息量也更多,因此在产品形态上,番茄小说频道采用了大屏的展现形式。然而,在漫画功能上线后,业务团队发现,有部分漫画的原始书封比较模糊,严重影响用户浏览体验。如下图所示:

02dffaaebbab7fd5bdaa8a289ab99c20.png

为了提升这部分图片的画质,业务团队想到了通过画质评估筛查低质图片,使用画质增强能力搭建自动化处理流程,针对性处理低质图片,得到高清图,以提升整体观感。

实践方案

业务团队使用veImageX画质评估工具,针对出版物(如小说封面、插图、电子书书封、有声播放器封面等)漫画(漫画封面、横图等) 等场景进行离线画质测评,对不同分辨率图片进行画质摸底。根据对低质原因的分析和增强算法对主观画质提升的收益大小综合评估,明确差异化的处理方案。最终业务团队选择搭建自动化处理流程,根据评估结果对不同画质等级的图片进行如自适应增强、超分等优化处理,针对性提升用户的画质浏览体验。

低质图片优化前后对比如下:

7849294b6c2200b33ce2bb4d4c3c93c4.png

整体收益

番茄小说团队借助veImageX画质评估和画质增强能力,有的放矢的提升画质,有效提升了用户画质体验点击率、人均阅读/消费时长、留存等用户消费指标。

b1604c6b604f7d94d7dec1fb6f4b3b80.png

今日头条

需求背景

头条小视频频道主要以双列展示为主,而双列流频道展现形式又以封面图为主。综合线上实验结果和实践经验发现,封面图的画质质量不仅会影响用户浏览体验,也会影响点击转化率和用户留存等业务指标,如何有效识别封面模糊的内容并进行打压调控成为一项较为棘手的工作。

实践方案

借助画质评估工具,业务团队对封面图进行画质打分,高效识别出低质封面(blockiness≥ X且vqscore< Y)并实行打压调控策略;同时将vqscore纳入推荐模型的参考指标,给优质内容提供更多优先曝光机会。

整体收益

业务团队通过对低质封面图进行打压调控,人工评估封面优质率提升约3倍封面低质率降低了约36.7%模糊封面图占比降低了约51.4%人均阅读数、 停留时长 、点击转化率等业务指标也得到显著提升。(数据来自业务AB实验)

幸福里VR

需求背景

幸福里房产VR能力在建设初期,因素材供给来源多样且渠道纷杂,质量良莠不齐,频繁收到线上用户反馈;图像质量把控主要依靠人工审核、定期抽检和线上反馈,不仅耗费人力且评估主观,对全景图缺乏有区分度的数据指标量化衡量图像质量和行业领先水平的差距,导致业务团队难以高效定位画质问题并针对性的改善和评估优化效果。

实践方案

通过对线上样本数据进行离线画质摸底并综合算法专家建议,业务团队最终选定清晰度 VQScore )、噪声(Noise)、亮度(Brightness)、过曝光(Overexporsure) 等四项指标作为全景图量化评估指标。评估发现精装 、 简装 、毛坯等三种装修类型存在显著画质差异,关键差异与环境光线、灯光照明等因素有较高关联,业务团队针对性进行迭代优化并监测画质指标变化,显著提升了VR看房效果。

整体收益

业务团队通过画质评估工具,定位具体的画质问题,针对性进行迭代优化以缩小和行业领先水平的差距;同时借助veImageX 提供的VR画质增强能力,显著提升全景图画质阶段性实现用户0客诉弥补了前端采集设备质量参差等问题。

写在最后

本文简要介绍了抖音集团内部对画质评估体系的业务思考、建设历程、应用场景和部分实践经验。由于篇幅所限,本文对探索历程、具体实现等细节内容有所省略,但仍希望能给业内同仁们一点启发或者参考借鉴。

火山引擎veImageX已全面集成上述画质评估体系,综合素材托管、压缩、智能处理和分发能力提供一整套图像解决方案支持全行业使用。目前画质评估和画质增强等多种能力均在特惠促销中产品新用户首购低至“1元” ,欢迎大家购买使用。复制链接跳转活动会场( https://t.zijieimg.com/iejjTSuv/)或扫描下方二维码了解更多~

6f7313fe7f1d431182987865f9ac2f82.png

61fdaf9354cf75166de4c12ff07b576b.png

最后,非常欢迎有类似需求或经验的伙伴来一起探讨交流,期待和大家共同进步!

火山引擎云搜索服务升级云原生新架构,提供数十亿级分布式向量数据库能力...

作者 ByteDanceTech
2023年7月13日 12:02

动手点关注

a5c6bf9b54eac430cb3d2604b4ee42fb.gif

干货不迷路

‍从互联网发展伊始,搜索技术就绽放出了惊人的社会和经济价值。随着信息社会快速发展,数据呈爆炸式增长,搜索技术通过数据收集与处理,满足信息共享与快速检索的需求。

云搜索服务 ESCloud 是火山引擎提供的完全托管在线分布式搜索服务,兼容 Elasticsearch、Kibana 等软件及常用开源插件。可以提供结构化、非结构化文本的多条件检索、统计、报表,帮助实现一键部署、弹性扩缩、简化运维,快速构建日志分析、信息检索分析等实际业务。

而伴随着 Serverless 的兴起和大势所向,火山引擎云搜索服务升级云原生新架构

云搜索服务云原生版‍

14de9a2f16504e6aea17398b5248e6e6.png

k-NN,大模型时代下的原生向量搜索和数据库

随着推荐、音视频等新兴领域应用的涌现和对大模型场景的需求,引入多模态搜索来满足更加复杂的搜索需求势在必行。我们在全文检索的基础上增加向量搜索能力来实现对非结构化数据的分析和检索

在向量搜索的场景下,使用机器学习模型生成向量来表示数据对象(文本、图像、音视频等);向量距离来代表对象间的相似性。常用的向量库使用 ANN 算法在极短时间内完成海量向量的检索。

k-NN 可以作为向量数据库来使用,通过引入先进的向量算法库来构建向量索引,还会将构建好的向量索引持久化到磁盘,索引更加稳定。结合 ESCloud 产品的倒排索引,可以将向量检索和全文检索的能力融合,实现更加强大的混合搜索(Hybrid Search)能力。在 ESCloud 的集群基础上,k-NN 向量数据库可以提供大规模分布式能力,为用户带来可扩展数量级的向量搜索。

cff15302191f18ef7a1870e429585d5a.png

场景案例

基于 k-NN 的业务场景主要有以下六大类,目前在字节跳动内部复杂的业务场景中均有所运用:

  • 多模态搜索:包括图片搜索,语义搜索,音视频相似性检索等;

  • 智能推荐:视频推荐,广告投放推荐,关系推荐,商品推荐等;

  • 智能问答:基于 Transformer 的 FAQ,LLM 的领域知识问答,LangChain 集合的生成式QA;

  • 数据消重:视频、音频、图片的审核消重,各类素材版权检测;

  • 安全风控:欺诈检测,扫黑检测,危险评估,异常检测;

  • 其他应用:数据挖掘,数据分析,搜索重排序, 文本搜图。

以文案相似度识别方案为例。

3b35bde3cb2d1e2eeafd9d62e35b2d15.png

在用户推送文案的场景下,为保证用户体验,需要确保推送文案不会有重复内容,因此对每个推送的内容都会进行相似度识别并消重。每个文案通过 BERT 模型生成 Embedding,在云搜索中检索一次。如果相似度低于阈值,判定为新的文案,会写入 k-NN 向量数据库中,逐渐完善成一个文案库;如果相似度高于阈值,则判定为重复文案,减少推送量。


云搜索服务 ESCloud 兼容 Elasticsearch、Kibana 等软件及常用开源插件,提供结构化、非结构化文本的多条件检索、统计、报表,可以实现一键部署、弹性扩缩、简化运维,快速构建日志分析、信息检索分析等业务能力。

8c7cacc7ee245b7aa27b6216fdd88da5.png

扫码了解更多产品详情

063d1149a57ba51be745d77e3c40bad8.png 点击「阅读原文」了解更多产品详情

VLDB 2023 | CDSBen: 字节跳动 veDB 数据库存储系统性能测试模型

作者 ByteDanceTech
2023年9月2日 10:02

背景

随着业务爆炸式增长与云原生技术的日渐成熟,大量云原生分布式数据库产品如雨后春笋般涌现,其中一部分主打 OLTP 场景的分布式数据库强调的是从计算-存储分离架构获得弹性收益;对于业界各种计算-存储分离架构的数据库而言,怎么用真实的端到端数据库 workload 去 benchmark 其底层存储系统一直存在以下难题:

  • 对于数据库专用存储系统,不存在如 fio 一样的“事实标准” benchmark 模型

  • 数据库专用存储系统实现与经典存储差别较大。若仍然将其当作经典存储来设计 benchmark,没有考虑数据库特点,会导致端到端存在“脱节”现象

为了解决上述问题,我们希望为字节跳动 veDB 底层的专用存储系统设计出契合数据库特点的 benchmark 模型。本次我们提出了 CDSBen 模型,利用 机器学习 方法,根据真实的数据库 端到端 事务 pattern 预测出对存储层的 IO pattern,从而对存储系统实现真实、精准的 benchmark。 相关文章 CDSBen: Benchmarking the Performance of Storage Services in Cloud-native Database System at ByteDance 发表于 VLDB 2023。

veDB 简介

veDB 是字节跳动基于计算-存储分离架构实现的,服务于 OLTP 场景的云原生分布式数据库,其目标是:

  • 高弹性 计算层 & 存储层解耦,可独立按需扩缩容,解决单机扩展性问题。

  • 高性价比 通过一系列深度的计算/存储内核优化,最终提升性能降低成本。

  • 高易用性 完全兼容 MySQL、PG 等开源数据库引擎,降低学习/使用开销。

  • 高可靠/高可用 计算/存储支持多副本、跨数据中心部署、快速 PITR 等能力,提升系统可用性 & 可靠性。

veDB 的系统架构如下所示:

b5c72a6f0245ccc6205e20653871d704.png

从上图可知,veDB 整体分为三层:

  • 接入层: 提供鉴权、流控、读写路由等功能。

  • 计算层: 完全兼容 MySQL、PG 等开源数据库引擎,支持 DML、DDL、事务。

  • 存储层: 为数据库设计的专用分布式存储系统,可以插件的形式支持不同数据库引擎。

veDB 存储层 Benchmark 存在的问题与挑战

veDB 的底层存储是专门为数据库设计的专用分布式存储系统,称为 veDB DBStore。在veDB DBStore 内部,又分为专门持久化 WAL 的日志存储模块和管理多版本数据 page 的 PageStore 模块。在此架构下,如果我们要对 veDB DBStore 进行 benchmark,之前我们常用的两种方式是

  • 采用数据库端到端的 benchmark 模型(如 TPC系列 & sysbench 系列),调整各种参数进行压测。

  • 采用改造后的 YCSB/类 fio 工具,调整各种参数进行压测。

然而,以上的两种方式对于 veDB Store 来说存在以下挑战

  • 端到端 benchmark 模型(TPC/sysbench)本质都是并发执行 SQL statement,而存储层无法直接处理 SQL,所以用 TPC/sysbench 就不能脱离计算层来单独对存储层进行压测。

  • 经改造的 ycsb/类 fio 工具可以单独对存储层进行压测,其对于经典存储系统而言具有普适性,但是经典存储的 IO pattern 又跟数据库的事务场景没太大关联,跟数据库“脱节”。

基于这些问题与挑战,我们针对 veDB Store 里管理多版本数据 page 的 PageStore 设计了 CDSBen 模型。我们尝试将端到端的数据库事务执行 pattern 跟存储系统的 IO pattern 匹配起来,从而在脱离计算层的情况下,能够以最真实的、端到端的 pattern 对 PageStore 进行 benchmark。

CDSBen 方案

Learning-based 模型

CDSBen 包括两个学习模型,一个是 IOPS 序列预测模型,基于循环神经网络;另一个是联合分布预测模型,基于随机森林,这个模型主要被用于预测读写请求的目标地址(PageStore segment ID)和写入数据量的联合分布。

CDSBen 工作流程如下:

  • 选择一个/多个真实业务场景,从 veDB 的计算层和存储层的 running log 中提取 workload 特征,并用所提取的特征训练 CDSBen 模型。

  • 用户向 CDSBen 输入计算层的 workload 特征,CDSBen 会预测存储层对应的 workload 特征。

  • CDSBen 使用经过改造的 YCSB 生成具体的读写请求并直接在 veDB DBStore上运行,从而进行基准测试。

接下来,我们介绍 CDSBen 的具体细节,主要包括特征提取 ,模型设计和负荷生成三个部分。

特征提取指从计算层和存储层的 running log 中分别提取特征,这些特征会被分别用于 IOPS 预测模型和联合分布预测模型的输入和输出。

  • 计算层的 workload 由多种事务类型混合构成,我们统计每一种事务的 TPS 作为计算层 workload 的特征向量,比如 veDB_OSS 业务包含四种单 SQL statement 的事务,分别为 SELECT、INSERT、UPDATE和DELETE;TPC-C 包含五种长事务,分别为 Line-Order、Payment、Order-Status、Delivery 和 Stock-Level。我们统计每一种事务的 TPS 作为计算层 workload 的特征向量。

  • 存储层的 workload 由对 PageStore segment 的读写请求构成,针对读请求,我们关注该请求的时间戳和目标地址(Segment ID);针对写请求,我们关注时间戳,目标地址和写入数据量。我们从存储引擎的日志中得到每一个读写请求的具体信息,通过每个请求的时间戳,我们可以得出存储层 workload 的 IOPS 序列,其包含的信息可以同时描述存储层 workload 的强度和波动程度。我们也关注读写请求的目标地址和写入数据量的联合分布。

为了简化数据处理,我们将所有读请求看作写入数据量为0的写请求。我们使用一个二维数组表示读写请求在目标地址和写入数据量上的分布情况,如数组中的第 i 列第 j 个表示对于目标地址 i,写入数据量为 j 的读写请求的比例。

在特征提取完成之后,我们使用收集到的数据训练这两个模型。这两个模型的输入是计算层 workload 的特征向量,输出分别是 IOPS 序列和联合分布。在训练完成之后,我们输入所想要模拟的计算层 workload 的特征向量,CDSBen 会预测对应的存储层 workload 的特征向量。然后我们使用YCSB,基于预测出的存储层 workload 的特征向量,随机生成出读写请求并在存储层运行,进行性能测试。

需要注意的是,因为CDSBen在特征提取的过程中只关注计算层的TPS,而不关注 workload 内容本身(跑了什么SQL statement),因此针对每一种 workload,CDSBen 的模型必须被重新训练一次。但是在实践中我们发现,训练模型的开销较小(取决于模型本身的复杂度),这种开销是可以接受的。

CDSBen 的优势主要是准确,灵活,和易用。准确可直接参考论文中的实验结果或本文下面4.2节的部分截图。灵活和易用在于 CDSBen 可以像 YCSB 一样直接在存储层上运行,不需要像 TPC/sysbench 一样部署计算层,同时CDSBen 可以让用户在只输入想要模拟的计算层负荷的特征向量这一简单输入的情况下,生成贴近真实情况的读写请求,且有能力回答 what-if question( TPS 变化和事务比例变化)。

在实验中我们发现,与 YCSB 相比, CDSBen 生成的读写请求测量出的性能明显更贴近真实业务流量下的性能

模型效果

f16abbec59862737aa0a8b6fa2e9b37e.png

上图是模型生成的 IOPS 曲线 & 真实线上业务 IOPS 曲线的对比图。在验证过程中,我们采样了线上一个代号 SYNC 的业务,图中的黑线是真实的业务对 veDB DBStore 造成的 IOPS 曲线,蓝线是 CDSBen 模型预测的 IOPS 曲线。从图中可以看出两条曲线的重合度很高,且两条曲线的一些数学特征匹配度很高(真实 IOPS 的区间平均值是1046、预测 IOPS 的区间平均值是999)。

总结

在没有 CDSBen 模型的阶段,如果想 benchmark 底层的存储系统(veDB DBStore)的性能,我们无法在脱离计算层的情况下,用真实的业务 workload 对存储层进行压测。

CDSBen 的作用,就是帮我们在事务 pattern(SQL statement)和存储 IO pattern(segment 读写)之间搭起了转换的桥梁,让我们可以脱离计算层,用较真实的业务 workload 对 veDB DBStore 进行测试。其收益在于:

  • 帮助数据库研发工程师在开发阶段对底层存储系统进行精准调优,保证 veDB 每个版本端到端上线时性能的稳定性,持续给用户提供高性能的数据库服务。

  • 精准地 benchmark 存储系统在真实 workload 下的(极限)性能,是我们在分布式数据库存储层实现精准流控的前提条件,无论哪种业务场景都能平滑、稳定地服务大量 veDB 实例的真实业务流量。

Interspeech 2023 | 火山引擎流媒体音频技术之语音增强和AI音频编码

作者 ByteDanceTech
2023年9月1日 11:01

背景介绍

为了应对处理各类复杂音视频通信场景,如多设备、多人、多噪音场景,流媒体通信技术渐渐成为人们生活中不可或缺的技术。为达到更好的主观体验,使用户听得清、听得真,流媒体音频技术方案融合了传统机器学习和基于AI的语音增强方案,利用深度神经网络技术方案,在语音降噪、回声消除、干扰人声消除和音频编解码等方向,为实时通信中的音频质量保驾护航。

作为语音信号处理研究领域的旗舰国际会议,Interspeech一直代表着声学领域技术最前沿的研究方向,Interspeech 2023 收录了多篇和音频信号语音增强算法相关的文章,其中,火山引擎流媒体音频团队共有 4 篇研究论文被大会接收,论文方向包括语音增强、基于AI编解码 、回声消除、无监督自适应语音增强

值得一提的是,在无监督自适应语音增强领域,字节跳动与西工大联合团队在今年的CHiME (Computational Hearing in Multisource Environments) 挑战赛子任务无监督域自适应对话语音增强(Unsupervised domain adaptation for conversational speech enhancement, UDASE) 获得了冠军(https://www.chimechallenge.org/current/task2/results)。CHiME挑战赛是由法国计算机科学与自动化研究所、英国谢菲尔德大学、美国三菱电子研究实验室等知名研究机构所于2011年发起的一项重要国际赛事,重点围绕语音研究领域极具挑战的远场语音处理相关任务,今年已举办到第七届。历届CHiME比赛的参赛队伍包括英国剑桥大学、美国卡内基梅隆大学、约翰霍普金斯大学、日本NTT、日立中央研究院等国际著名高校和研究机构,以及清华大学、中国科学院大学、中科院声学所、西工大、科大讯飞等国内顶尖院校和研究所。

本文将介绍这 4 篇论文解决的核心场景问题和技术方案,分享火山引擎流媒体音频团队在语音增强,基于AI编码器,回声消除和无监督自适应语音增强领域的思考与实践。

基于可学习梳状滤波器的轻量级语音谐波增强方法

论文地址:https://www.isca-speech.org/archive/interspeech_2023/le23_interspeech.html

背景

受限于时延和计算资源,实时音视频通信场景下的语音增强,通常使用基于滤波器组的输入特征。通过梅尔和ERB等滤波器组,原始频谱被压缩至维度更低的子带域。在子带域上,基于深度学习的语音增强模型的输出是子带的语音增益,该增益代表了目标语音能量的占比。然而,由于频谱细节丢失,在压缩的子带域上增强的音频是模糊的,通常需要后处理以增强谐波。RNNoise和PercepNet等使用梳状滤波器增强谐波,但由于基频估计以及梳状滤波增益计算和模型解耦,它们无法被端到端优化;DeepFilterNet使用一个时频域滤波器抑制谐波间噪声,但并没有显式利用语音的基频信息。针对上述问题,团队提出了一种基于可学习梳状滤波器的语音谐波增强方法,该方法融合了基频估计和梳状滤波,且梳状滤波的增益可以被端到端优化。实验显示,该方法可以在和现有方法相当的计算量下实现更好的谐波增强。

模型框架结构

基频估计器(F0 Estimator)

为了降低基频估计难度并使得整个链路可以端到端运行,将待估计的目标基频范围离散化为N个离散基频,并使用分类器估计。添加了1维代表非浊音帧,最终模型输出为N+1维的概率。和CREPE一致,团队使用高斯平滑的特征作为训练目标,并使用Binary Cross Entropy作为损失函数:

ccf651f2dea93e461c6c63dab70308de.png7b5994b3b7a443e2f75f7aa8b0735b67.png
可学习梳状滤波器(Learnable Comb Filter)

对上述每一个离散基频,团队均使用类似PercepNet的FIR滤波器进行梳状滤波,其可以表示为一个受调制的脉冲串:

cb8fe5838bb459c0149b51d0ffc750aa.png

在训练时使用二维卷积层(Conv2D)同时计算所有离散基频的滤波结果,该二维卷积的权重可以表示为下图矩阵,该矩阵有N+1维,每一维均使用上述滤波器初始化:

70f106c24a62b810069a0b00fff81752.png

通过目标基频的独热标签和二维卷积的输出相乘得到每一帧基频对应的滤波结果:

94b1b08fe7ca1090a2c81614690fa5e9.pngc2f19a1991b7ddffee645cb7ce462ee9.png

谐波增强后的音频将和原始音频加权相加,并和子带增益相乘得到最后的输出:

35d09757603b7883c470a04372599cf2.png

在推断时,每一帧仅需要计算一个基频的滤波结果,因此该方法的计算消耗较低。

模型结构
5c91676ca45f86cd2ac3cf5ca4c69d41.png

团队使用双路卷积循环神经网络(Dual-Path Convolutional Recurrent Network, DPCRN)作为语音增强模型主干,并添加了基频估计器。其中Encoder和Decoder使用深度可分离卷积组成对称结构,Decoder有两个并行支路分别输出子带增益G和加权系数R。基频估计器的输入是DPRNN模块的输出和线性频谱。该模型的计算量约为300 M MACs,其中梳状滤波计算量约为0.53M MACs。

模型训练

在实验中,使用VCTK-DEMAND和DNS4挑战赛数据集进行训练,并使用语音增强和基频估计的损失函数进行多任务学习。

a425f1c6649edf04bfa461663327cf5a.png270a54f05c16b7704df70ae7bb79ac4e.png

实验结果

流媒体音频团队将所提出的可学习梳状滤波模型和使用PercepNet的梳状滤波以及DeepFilterNet的滤波算法的模型进行对比,它们分别被称作DPCRN-CF、DPCRN-PN和DPCRN-DF。在VCTK测试集上,本文提出的方法相对现有方法均显示出优势。

e05339f73242220f2787b9d0718c83c0.jpeg

同时团队对基频估计和可学习的滤波器进行了消融实验。实验结果显示,相对于使用基于信号处理的基频估计算法和滤波器权重,端到端学习得到的结果更优。

73f2db6b918b71e1593cd6659310671f.jpeg

基于Intra-BRNN 和GB-RVQ 的端到端神经网络音频编码器

论文地址:https://www.isca-speech.org/archive/pdfs/interspeech_2023/xu23_interspeech.pdf

背景

近年来,许多神经网络模型被用于低码率语音编码任务,然而一些端到端模型未能充分利用帧内相关信息,且引入的量化器有较大量化误差导致编码后音频质量偏低。为了提高端到端神经网络音频编码器质量,流媒体音频团队提出了一种端到端的神经语音编解码器,即CBRC(Convolutional and Bidirectional Recurrent neural Codec)。CBRC使用1D-CNN(一维卷积) 和Intra-BRNN(帧内双向循环神经网络) 的交错结构以更有效地利用帧内相关性。此外,团队在CBRC中使用分组和集束搜索策略的残差矢量量化器(Group-wise and Beam-search Residual Vector Quantizer,GB-RVQ)来减少量化噪声。CBRC以20ms帧长编码16kHz音频,没有额外的系统延迟,适用于实时通信场景。实验结果表明,码率为3kbps的 CBRC编码语音质量优于12kbps的Opus。

模型框架结构

5dfe1651aac3e343de02f93f55d9d4d8.png CBRC总体结构
Encoder和Decoder网络结构

Encoder采用4个级联的CBRNBlocks来提取音频特征,每个CBRNBlock由三个提取特征的ResidualUnit和控制下采样率的一维卷积构成。Encoder中特征每经过一次下采样则特征通道数翻倍。在ResidualUnit中由残差卷积模块和残差双向循环网络构成,其中卷积层采用因果卷积,而Intra-BRNN中双向GRU结构只处理20ms帧内音频特征。Decoder网络为Encoder的镜像结构,使用一维转置卷积进行上采样。1D-CNN和Intra-BRNN的交错结构使Encoder和Decoder充分利用20ms音频帧内相关性而不引入额外的延时。

2da413b03d5779b983383aa6eba54ed7.png CBRNBlock结构
分组和集束搜索残差矢量量化器 GB-RVQ

CBRC使用残差矢量量化器(Residual Vector Quantizer,RVQ)将编码网络输出特征量化压缩到指定比特率。RVQ以多层矢量量化器(Vector Quantizer,VQ)级联来压缩特征,每层VQ对前一层VQ量化残差进行量化,可显著降低同等比特率下单层VQ的码本参数量。团队在CBRC中提出了两种更优的量化器结构,即分组残差矢量量化器 (Group-wise RVQ) 和集束搜索残差矢量量化器(Beam-search RVQ)。

分组残差矢量量化器 Group-wise RVQ 集束搜索残差矢量量化器 Beam-search RVQ
345baab8b0fc1d0946095374bbe92456.png a7c77a4e483fc733f9b67586736d079b.png

Group-wise RVQ将Encoder输出进行分组,同时使用分组的RVQ对分组后特征进行独立量化,随后分组量化输出拼接输入Decoder。Group-wise RVQ以分组量化方式降低了量化器的码本参数量和计算复杂度,同时降低了CBRC端到端训练难度进而提升了CBRC编码音频质量。

团队将Beam-search RVQ引入到神经音频编码器端到端训练中,使用Beam-search算法选择RVQ中量化路径误差最小的码本组合,以降低量化器的量化误差。原RVQ算法在每层VQ量化中选择误差最小的码本为输出,但每层VQ量化最优的码本组合后不一定是全局最优码本组合。团队使用Beam-search RVQ,在每层VQ中以量化路径误差最小准则保留k个最优的量化路径,实现在更大的量化搜索空间中选择更优的码本组合,降低量化误差。



Beam-search RVQ算法简要过程:

1、每层VQ输入前层VQ的个候选量化路径,得到个候选量化路径。

2、从个候选量化路径中选择个量化路径误差最小的个量化路径作为当前VQ层输出。

3、在最后一层VQ中选择量化路径误差最小的路径作为量化器的输出。
a9bb79ffe3c30b34463da9d4f44e2041.png

模型训练

在实验中,使用LibriTTS数据集中245小时的16kHz语音进行训练,将语音幅度乘以随机增益后输入模型。训练中损失函数由频谱重建多尺度损失,判别器对抗损失和特征损失,VQ量化损失和感知损失构成。

468d93b5c201a300516ac474e540e6ef.png

实验结果

主客观得分

为了评估CBRC编码语音质量,构建了10条多语种音频对比集,在该对比集上与其他音频编解码器进行了对比。为了降低计算复杂的影响,团队设计了轻量化的CBRC-lite,其计算复杂度略高于Lyra-V2。由主观听感比较结果可知,CBRC在3kbps上语音质量超过了12kbps的Opus,同样超过了3.2kbps的Lyra-V2,这表明所提出方法的有效性。https://bytedance.feishu.cn/docx/OqtjdQNhZoAbNoxMuntcErcInmb中提供了CBRC编码后音频样音。

客观分 主观听感得分
cb37a8eaefc39370c037f812f13e48c9.jpeg 33e770f16787db0fb6f8481560c4d4aa.jpeg
消融实验

团队设计了针对Intra-BRNN、Group-wise RVQ 和 Beam-search RVQ的消融实验。实验结果表明在Encoder和Decoder使用Intra-BRNN均可明显提升语音质量。此外,团队统计了RVQ中码本使用频次并计算熵解码以对比不同网络结构下码本使用率。相比于全卷积结构,使用Intra-BRNN的CBRC将潜在编码比特率从4.94kbps提升到5.13kbps。同样,在 CBRC中使用Group-wise RVQ 和 Beam-search RVQ均能显著提升编码语音质量,且相比于神经网络本身的计算复杂度, GB-RVQ带来的复杂度增加几乎可忽略。

8b4b278506978c6cdc6282b88ef6f5eb.jpeg321f3e9c9d60459e23446521edc8cb01.jpeg

样音

原始音频

CBRC 3kbps

CBRC-lite 3kbps

基于两阶段渐进式神经网络的回声消除方法

论文地址:https://www.isca-speech.org/archive/pdfs/interspeech_2023/chen23e_interspeech.pdf

背景

在免提通信系统中,声学回声是令人烦恼的背景干扰。当远端信号从扬声器播放出来,然后由近端麦克风记录时,就会出现回声。回声消除 (AEC) 旨在抑制麦克风拾取的不需要的回声。在现实世界中,有很多非常需要消除回声的应用,例如实时通信、智能教室 、车载免提系统等等。

最近,采用深度学习 (DL) 方法的数据驱动 AEC 模型已被证明更加稳健和强大 。这些方法将 AEC 表述为一个监督学习问题,其中输入信号和近端目标信号之间的映射函数通过深度神经网络 (DNN) 进行学习。然而,真实的回声路径极其复杂,这对 DNN 的建模能力提出了更高的要求。为了减轻网络的建模负担,大多数现有的基于 DL 的 AEC 方法采用一个前置的线性回声消除(LAEC) 模块来抑制大部分回声的线性分量。但是,LAEC 模块有两个缺点:1)不合适的 LAEC 可能会导致近端语音的一些失真,以及 2)LAEC 收敛过程使线性回声抑制性能不稳定。由于 LAEC 是自优化的,因此 LAEC 的缺点会给后续的神经网络带来额外的学习负担。

为了避免 LAEC 的影响并保持更好的近端语音质量,本文探索了一种新的基于端到端 DL 的两阶段处理模式,并提出了一种由粗粒度 (coarse-stage) 和细粒度 (fine-stage) 组成的两阶段级联神经网络(TSPNN) 用于回声消除任务。大量的实验结果表明,所提出的两阶段回声消除方法能够达到优于其他主流方法的性能。

模型框架结构

如下图所示,TSPNN 主要由三个部分组成:时延补偿模块 (TDC)、粗粒度处理模块 (coarse-stage) 和细粒度处理模块 (fine-stage)。TDC 负责对输入的远端参考信号 (ref) 和近端麦克风信号 (mic) 进行对齐,有利于后续模型收敛。coarse-stage 负责将大部分的回声 (echo) 和噪声 (noise) 从 mic 中去除,极大减轻后续 fine-stage 阶段模型学习负担。同时,coarse-stage 结合了语音活跃度检测 (VAD) 任务进行多任务学习,强化模型对近端语音的感知能力,减轻对近端语音的损伤。fine-stage 负责进一步消除残余回声和噪声,并结合邻居频点信息来较好地重构出近端目标信号。

c158c89e4b40be0c262299f3de3a2ec1.png

为了避免独立优化每个阶段的模型而导致的次优解,本文采用级联优化的形式来同时优化 coarse-stage 和 fine-stage,同时松弛对 coarse-stage 的约束,避免对近端语音造成损伤。此外,为了让模型能够具有感知近端语音的能力,本发明引入了 VAD 任务进行多任务学习,在损失函数中加入 VAD 的 Loss。最终损失函数为:

其中 分别表示目标近端信号复数谱、coarse-stage 和 fine-stage 估计的近端信号复数谱;分别表示coarse-stage估计的近端语音活跃状态、近端语音活跃检测标签; 为一个控制标量,主要用于调节训练阶段对不同阶段的关注程度。本发明限制 来松弛对 coarse-stage 的约束,有效避免 coarse-stage 对近端的损伤。

实验结果

实验数据

火山引擎流媒体音频团队所提两阶段回声消除系统还与其他方法做了比较,实验结果表明,所提能够达到优于其他主流方法的效果。

fbf5703d0ddd84f375721e49f3f567b3.jpeg
具体例子
  1. 实验结果 Github 链接:https://github.com/enhancer12/TSPNN

  2. 双讲场景效果表现:

32138b74ce2f8189fa07e2727e21c2bb.jpeg

CHiME-7 无监督域自适应语音增强(UDASE)挑战赛冠军方案

论文地址:https://www.chimechallenge.org/current/task2/documents/Zhang_NB.pdf

背景:

近年来,随着神经网络和数据驱动的深度学习技术的发展,语音增强技术的研究逐渐转向基于深度学习的方法,越来越多基于深度神经网络的语音增强模型被提出。然而这些模型大多基于有监督学习,都需要大量的配对数据进行训练。然而在实际场景中,无法同时收录到嘈杂场景的语音和与之配对的不受干扰的干净语音标签,通常采用数据仿真的形式,单独采集干净语音与各种各样的噪声,将其按照一定信噪比混合得到带噪音频。这导致了训练场景与实际应用场景的不匹配,模型性能在实际应用中有所下降。

为了更好的解决以上域不匹配问题,利用真实场景中大量无标签数据,无监督、自监督语音增强技术被提出。CHiME挑战赛赛道2旨在利用未标记的数据来克服在人工生成的标记数据上训练的语音增强模型因训练数据与实际应用场景的不匹配导致的性能下降问题,研究的重点在于如何借助目标域的无标签数据和集外的有标签数据来提升目标域的增强结果。

模型框架结构:

dfbfbbd71866997c085113711ce8e852.png

无监督域自适应语音增强系统流程图

如上图所示,所提框架是一个教师学生网络。首先在域内数据上使用语音活动检测、UNA-GAN、仿真房间冲击响应、动态加噪等技术生成最接近目标域的有标签数据集,在该域外有标签数据集上预训练教师降噪网络Uformer+。接着在域内无标签数据上借助该框架更新学生网络,即利用预训练的教师网络从带噪音频中估计干净语音和噪声作为伪标签,将他们打乱顺序重新混合作为学生网络输入的训练数据,利用伪标签有监督的训练学生网络。使用预训练的MetricGAN判别器估计学生网络生成的干净语音质量评分,并与最高分计算损失,以指导学生网络生成更高质量的干净音频。每训练一定步长后以一定权重将学生网络的参数更新到教师网络中,以获取更高质量的监督学习伪标签,如此重复。

Ufomer+网络

Uformer+是在Uformer网络基础上加入MetricGAN改进得到的。Uformer是一个基于 Unet 结构的复数实数双路径conformer网络,它具有两条并行的分支,幅度谱分支和复数谱分支,网络结构如下图所示。幅度分支用于进行主要的噪声抑制功能,能够有效抑制大部分噪声。复数分支作为辅助,用于补偿语谱细节和相位偏差等损失。MetricGAN的主要思想是使用神经网络模拟不可微的语音质量评价指标,使其可以被用于网络训练中,以减少训练和实际应用时评价指标不一致带来的误差。这里团队使用感知语音质量评价(PESQ)作为MetricGAN网络估计的目标。

14d404af4c0cc82a6d322d1fa45c2653.png

Uformer网络结构图

RemixIT-G框架

RemixIT-G是一个教师学生网络,首先在域外有标签数据上预训练教师Uformer+模型,使用该预训练教师模型解码域内带噪音频,估计噪声和语音。接下来在同一批次内打乱估计的噪声和语音的顺序,重新将噪声和语音按打乱后的顺序混合成为带噪音频,作为训练学生网络的输入。由教师网络估计的噪声和语音作为伪标签。学生网络解码重混合的带噪音频,估计噪声和语音,与伪标签计算损失,更新学生网络参数。学生网络估计的语音被送入预训练的MetricGAN判别器中预测PESQ,并与PESQ最大值计算损失,更新学生网络参数。

所有训练数据完成一轮迭代后根据如下公式更新教师网络的参数:,其中为训练第K轮教师网络的参数, 为第K轮学生网络的参数。即将学生网络的参数以一定权重与教师网络相加。

数据扩充方法 UNA-GAN
ec301fcfeb51575b63f2203979a3eadb.png UNA-GAN结构图

无监督噪声自适应数据扩充网络UNA-GAN是一种基于生成对抗网络的带噪音频生成模型。其目的是在无法获取独立的噪声数据的情况下,只使用域内带噪音频,直接将干净语音转化为带有域内噪声的带噪音频。生成器输入干净语音,输出仿真的带噪音频。判别器输入生成的带噪音频或真实的域内带噪音频,判断输入的音频来自真实场景还是仿真生成。判别器主要根据背景噪声的分布来区分来源,在这个过程中,人类语音被视为无效信息。通过执行以上对抗训练的过程,生成器试图将域内噪声直接添加在输入的干净音频上,以迷惑判别器;判别器试图尽力区分带噪音频的来源。为了避免生成器添加过多噪声,覆盖掉输入音频中的人类语音,使用了对比学习。在生成的带噪音频、和输入的干净语音对应位置采样256个块。相同位置的块的配对被视为正样例,不同位置的块的配对被视为负样例。使用正负样例计算交叉熵损失。

实验结果

结果表明所提出的Uformer+相比基线Sudo rm-rf具有更强的性能,数据扩充方法UNA-GAN也具有生成域内带噪音频的能力。域适应框架RemixIT基线在SI-SDR上取得了较大提升,但在DNS-MOS上指标较差。团队提出的改进RemixIT-G同时在两个指标上都取得了有效提升,并在竞赛盲测集上取得了最高的主观测听MOS打分。最终测听结果如下图所示。

185e11a0cb9eec3dd014dede77d1936c.png

总结与展望

上述介绍了火山引擎流媒体音频团队基于深度学习在特定说话人降噪,AI编码器,回声消除和无监督自适应语音增强方向做出的一些方案及效果,未来场景依然面临着多个方向的挑战,如怎么样在各类终端上部署运行轻量低复杂度模型及多设备效果鲁棒性,这些挑战点也将会是流媒体音频团队后续重点的研究方向。

加入我们

火山引擎流媒体团队,致力于提供全球互联网范围内高质量、低延时的实时音视频通信能力,帮助开发者快速构建语音通话、视频通话、互动直播、转推直播等丰富场景功能,目前已覆盖互娱、教育、会议、游戏、汽车、金融、IoT 等丰富实时音视频互动场景,服务数亿用户。

音频开发工程师和音频资深算法工程师热招中,欢迎同学们加入!

7de62e15f6db2d2e9b1912f915c18365.jpeg

扫描二维码 or 点击阅读原文了解更多职位信息~

火山引擎ByteHouse:一套方案,让OLAP引擎在精准投放场景更高效

作者 ByteDanceTech
2023年8月18日 12:14

由于流量红利逐渐消退,越来越多的广告企业和从业者开始探索精细化营销的新路径,取代以往的全流量、粗放式的广告轰炸。精细化营销意味着要在数以亿计的人群中优选出那些最具潜力的目标受众,这无疑对提供基础引擎支持的数据仓库能力,提出了极大的技术挑战。

本篇内容将聚焦字节跳动OLAP引擎技术和落地经验,以字节跳动内部场景为例,具体拆解广告业务的实现逻辑和业务效果。

广告精准投放场景

广告投放过程一般包含数据收集->数据整合->人群圈选->广告投放->反馈分析等关键流程,人群圈选是广告精准投放的关键步骤,它帮助确定广告目标受众,辅助投放平台根据不同受众和广告目标优化投放策略,提升广告收益;

人群预估

人群预估主要是根据一定的圈选条件,确认命中的用户数目。在广告精准投放过程中,广告主需要知道当前选定的人群组合中大概会有多少人,用于辅助判断投放情况进而确定投放预算,通常要求计算时间不能超过 5 秒。

d5a948bdf43c7422a40a25fae9be3773.png

广告投放

53012791fc8ca678a81171a832a20444.png

广告精准投放过程中遇到的问题与痛点:

1. 数据预估: 广告主需要对选定的人群组合进行预估,以便判断投放情况并确定投放预算。但人群包数据量多,基数大。平台的用户数上亿,仅抖音的 DAU 就几亿,抖音、头条对应的人群包在亿级别,早期的预估版本采用ElasticSearch,但由于数据过于庞大,只能采用1/10抽样存储,导致10%的误差,业务难以接受。

2. 查询性能: 广告主可以设定一个非常复杂的圈选条件,导致计算复杂(单次计算可能包含几百上千个人群包),Hive和ES等方案在处理大数据量时,查询速度会变得非常慢,如果需要查询某个广告主的所有用户,需要扫描整个用户库,而这个过程可能需要几分钟甚至几个小时,无法满足实时性要求。

3. 存储空间大: Hive和ES等方案需要额外的索引结构,导致存储空间变大,从而增加了存储成本。例如,如果需要对用户属性进行索引,就需要额外的存储空间来存储索引数据。

4. 不支持高并发: Hive和ES等方案在处理高并发请求时,容易出现性能问题,无法支持高效的广告投放。例如,如果同时有多个广告主需要查询用户信息,就可能会出现查询阻塞或响应延迟等问题。

5. 数据查询效率: 采用ClickHouse支持预估,但随着数据量的增长,ClickHouse在当前存储引擎的支持下也难以保证查询时间。这导致了数据查询效率的问题,影响了用户体验。

ByteHouse BitEngine方案

方案简介

新查询引擎
  • 基于高性能、分布式特点,ClickHouse可以满足大规模数据的分析和查询需求,因此研发团队以开源ClickHouse为基础,研发出火山引擎云原生数据仓库ByteHouse,并在其中定制一套处理模型——BitEngine,用于解决集合的交并补计算在实时分析场景中的性能提升问题。

  • 针对广告人群预估业务开发的新查询引擎,基于ByteHouse提供的MergeTree Family系列引擎,添加了新的bitmap64类型和一系列的相关聚合函数。BitEngine提供的bitmap64类型适合存储和计算大量的用户ID之间的关系;在广告人群预估业务中,bitmap64类型用于存储人群包数据,然后将人群包之间的交并补计算转化为bitmap之间的交并补,从而达到远超普通查询的性能指标。

实现步骤

创建一个bitmap64类型,可以将用户ID直接存储在bitmap中,提供一系列交并补的聚合计算,并且还希望可以充分利用多核CPU的并行计算能力,由此我们设计了BitEngine。示例如下

CREATE TABLE cdp.tag_uids_map (
tags String,
uids BitMap64 BitEngineEncode
)ENGINE = HaMergeTree('/clickhouse/xxxx/{shard}', '{replica}')
ORDER BY tag

tag_uids_map存储格式如下

tag uids
A {10001,20001,30001,40001,50001,60001,70001,80001,90001}
B {10001,20001,20002,20003,20004,20005,20006,20007,20008}

要查询 A&B 的结果 SQL 为

SELECT bitmapCount('A&B') FROM tag_uids_map

BitEngine实现逻辑

核心思想
  1. 对数据做分区划分和编码,保证每个区间的数据之间不存在交集,然后使用roaring bitmap保存数据;

  2. 计算时每个分区的数据可以独立的做聚合计算,充分利用机器的并行能力,每个分区内部的聚合计算就是多个bitmap之间的交并补,利用roaring bitmap高效的交并补计算降低CPU和内存的使用;

  3. 通过字典将编码的结果反解回来,数据编码是为了让数据的分布尽可能稠密,roaring bitmap在存储和计算的时候就可以获得更好的性能。

业务应用
业务关键要素
  1. 人群包:广告主自定义规则计算出来的人群数据,标签是dmp团队根据市场需求定义的人群数据。

  2. 标签ID:每天定时根据产出规则更新一次,人群ID是自增的,每天根据广告主需求进行新建计算。

统一编码
  1. 为了对标签数据和人群数据的uid统一编码,编码服务先将标签数据中的uid和人群数据中的uid提取出来进行统一编码,将全量uid均匀hash到一万个桶中,桶编号为i[0<=i<=9999],uid在每个桶内由1开始顺序编码,每个桶的范围为i*2^40 - (i+1)*2^40。

  2. uid数据每天都在增加,因此需要支持增量编码, 编码服务每天会先获取增量uid,hash后顺序放置到每个桶中。

数据存储
  1. 完成编码后,会先把字典数据统一写入hive表中,便于字典的各种使用场景。

  2. 在数据经过分区和编码之后,ClickHouse可以以多种数据导入格式将数据以bitmap64类型存入磁盘。

数据计算

BitEngine如何充分利用计算机的并行能力完成每个分区多个bitmap之间的交并补计算?

存在问题:

假设存在四个bitmap,分别为a,b,c,d;则(a | c) & (b | d)不一定等于(a & b) | (c & d)。

人群包

人群包A = [10001, 20001,30001,40001,50001],人群包B = [10001, 20001,20002,20003,20004]

期望结果

通过BitEngine计算A&B = [10001, 20001]

设计方案

  • 人群包按照一定的规则划分为多个区间,任意两个区间之间的人群包没有交集

  • 一个计算线程只读取同一个区间的人群包进行计算,得到一个中间结果

  • 最终的中间结果只需要简单的进行bitmap or计算即可

对于这个设计,BitEngine需要保证数据的读取和计算是严格按照区间进行。BitEngine在数据读取时会为每一个文件构建一个读任务,由一个线程调度模块完成整个任务的调度和读取,这个线程调度模块的调度原则是:

  • 不同分区的文件不会交叉读取(ClickHouse的文件读取粒度小于文件粒度,会存在多个线程先后读一个文件的情况,一个分区也可能由多个文件组成),即一个线程只会读A_1,B_1,不会在这之间读取A_2或者B_2。

  • 一个分区读取完成后,可以立即触发聚合计算,执行bitmap之间的计算逻辑,获得中间结果。即A_1,B_1 读取完成后,可以立即计算A_1 & B_1。

  • 线程计算完中间结果后,可以继续读其他文件

BitEngine完成所有中间结果的计算后,会按照结果的输出要求做一次数据合并:

  • 如果需要计算的结果是bitmap的基数的时候,BitEngine直接将各个中间结果的基数相加

  • 如果计算结果需要的是bitmap,BitEngine直接将所有的bitmap合并起来,这里合并指的是bitmap or计算

业务效果

广告业务效果
  1. 数据存储空间缩小了 3 倍+

  2. 导入时间缩小了 3 倍+

  3. 查询 avg/pct99/max 都下降明显,pct99 从 5 s 降低到 2 s

  4. CPU 使用下降明显,PageCache 节省 100 G+

  5. 查询误差从10% 下降到 0%

BitEngine上线前后查询耗时监控
33617116a2b2dbcdd656190d9c6840b1.png
BitEngine上线后CPU负载对比
75fdaf9542fd4a73624f7b2325b1475a.png
PageCache 使用情况(lower is better)
537289b26e05e112485fe63f9a4a12f6.png

案例总结

BitEngine上线使用后,经过大量调优,在广告人群预估业务上取得了良好收益。目前,BitEngine已经集成在火山引擎云原生数据仓库ByteHouse中对外输出。火山引擎ByteHouse主要为用户提供极速分析体验,能够支撑实时数据分析和海量数据离线分析,具备便捷的弹性扩缩容能力,极致分析性能和丰富的企业级特性,目前已经与中国地震台网中心、海王集团、莉莉丝游戏、极客邦科技等诸多行业企业达成合作,深度助力各个行业数字化转型。未来,BitEngine将继续增强功能以支撑广告业务场景,包括:引擎集成数据编码,使编码对用户透明;提供细粒度的缓存以缓存部分重复表达式的计算结果;优化表达式解析等。

广告案例|10亿数据、查询<10s,论基于OLAP搭建广告系统的正确姿势

作者 ByteDanceTech
2023年8月11日 15:30

由于流量红利逐渐消退,越来越多的广告企业和从业者开始探索精细化营销的新路径,取代以往的全流量、粗放式的广告轰炸。精细化营销意味着要在数以亿计的人群中优选出那些最具潜力的目标受众,这无疑对提供基础引擎支持的数据仓库能力,提出了极大的技术挑战。

背景

人群圈选分析是客户画像平台(CDP)中的核心功能。分析师利用各种标签组合,挑选出最合适的人群,进而进行广告推送,达到精准投放的效果。同时由于人群查询在不同标签组合下的结果集大小不同,在一次广告投放中,分析师需要经过多次的逻辑调整,以获得"最好"的人群包。在这种高频的操作下,画像平台通常会遇到两方面的问题:

  • 第一,由于此类查询分析是临时性的,各种标签组合数巨大,离线预计算无法满足此类灵活性。

  • 第二,由于此类查询是实时场景,查询性能变得非常关键, 通常一次查询在分钟级,耗时较长,无法满足分析师需求。

这篇文章中,我们将会分享人群圈选查询在实时分析OLAP场景下的解决思路,同时介绍如何利用ByteHouse来加速此类查询。从数据表现上看,在10亿级用户测试数据下,ByteHouse的人群查询P99小于10s,展现了优异的性能。

场景模型

一个支持人群圈选的数据架构大致如下:

a50d561e686d9cf25ddcd212b379ae21.png

用户的注册信息通过用户流进入数据湖,同时用户的行为信息通过事件流进入数据湖。之后通过标签生产任务,我们为每个用户打上标签。

由于即时查询的实时性和灵活性,转化好的数据通常会写入OLAP引擎,例如ByteHouse,以提供灵活且实时的SQL查询。用户在分析时,一般会从画像平台应用界面去可视化构建标签逻辑,再由平台应用将这些逻辑转化成SQL,发给ByteHouse进行处理。

从数据模型上看, 数据仓库或者数据湖里存储的格式多数以id-tag为主,例如:

user_id sex age tags
10001 F 20 []
10002 M 22 [tag_1,tag_2]
10003 F 23 [tag_1]
10004 M 24 [tag_2]
10005 F 25 [tag_1,tag_2]

在人群分析中,以下以tag为主的模式会更合适,例如:

tags active_users
tag_1 [10002,10003,10005]
tag_2 [10002,10005]

数据是通常是基于用户作为主体存储,这种情况导致用户数量非常多,同时存在很多不必要字段。那么当用户通过组合标签(tag) 过滤人群时,几乎所有的行都需要被扫描, 使得性能开销随着标签和用户的增长越来越大。

当数据以标签作为主体时,有两个比较大的改动:

  • 其一,只有跟人群相关的维度会被保留,其他信息例如sex,age等会被移除。

  • 其二,active_users以数组(array)的形式存放所有的用户id, 这种操作带来的一个重要的收益是减少了行数,同时减少了数据大小。

在这种模型下, 根据tag组合选取用户就会变成集合的交并补操作,性能对比第一种模型会有显著提升。

ByteHouse Bitmap类型

第二种存储模型可以用如下ByteHouse SQL建表:

CREATE TABLE id_tags (
    tags            String,
    active_users    Array<UInt64>
) Engine = CnchMergeTree() order by tags

人群圈选查询,例如找到同时满足tag_1和tag_2的人群的数量,可以用如下SQL完成:

WITH (SELECT active_users as tag_1
        FROM id_tags
        WHERE tags = 'tag_1') as tag_1_user,
WITH(SELECT active_users as tag_2
        FROM id_tags
        WHERE tags = 'tag_2') as tag_2_user,
SELECT length(arrayIntersect(tag_1_user, tag_2_user))

虽然该模型可以简化部分操作,但是每个tag的选取需要有一个子查询(with 部分)。这种方式对于表的扫描有大量浪费,而且跟标签的数量线性相关。

为了解决这个问题,ByteHouse内置BitMap类型,可以直接用位(bit)来表示一个tag是否能存在。

沿用以上例子, 在利用BitMap后,建表语句改为:

CREATE TABLE id_tags (
    tags            String,
    active_users    BitMap64
) Engine = CnchMergeTree() order by tags

此处注意,我们只是将active_users的类型由Array改成 BitMap64,其余的部分没有变动。

对于同样的“找到同时满足tag_1和tag_2的人群的数量”的查询,用以下查询:

SELECT bitmapCount('tag_1&tag_2')
FROM tag_uids_map

我们用bit代替了原始的数组,使得该查询可以被优化到在一次表扫描中完成。

基于字节跳动内部线上场景,我们观测到上述的查询优化在多标签场景下,能有10~50倍的性能提升。

数据导入

写入数据进入bitmap表跟普通表没有显著差异。例如,小批量insert的方式可以用如下方式:

INSERT INTO TABLE id_tags values ('tag_1', [2,4,6]),('tag_2', [1,3,5])

因为id_tags中active_users定义为BitMap64的类型, 数组值[1,3,5], [2,4,6]会被自动转化为BitMap64。之后的计算和存储都会是BitMap64类型。

大批量文件导入时,我们可以利用ByteHouse提供的导入服务,目前离线(TOS, LASFS)以及实时(Kafka)等导入模式均已支持BitMap数据导入。流式写入(如Flink直写)可以通过JDBC接口用insert的方式写入。

相关函数

ByteHouse除了支持BitMap类型的数据进行交并补操作,也内置了大量的列函数,例如bitmapColumnAnd用来接收一个bitmap列,对该列所有bitmap做and运算;以及bitmapColumnCardinality用来返回一个列中所有bitmap的元素个数。详情可以参考官方文档。

BitEngine原理介绍

BitMap结构解析

假设一个用户ID用32位unsigned integer表示, 那么使用常规bit存储的方式需要2^32 bits ~ 512MB 的空间。如果需要为每个标签对应512MB空间,在标签量增长时,存储量会变得巨大。实际上,很少有业务会遇到2^32 大约40亿用户,因此实际场景中用户ID的分布是很稀疏的。

我们可以基于这个特性,利用Roaring bitmap来进一步压缩这个空间。如下图所示:

57ba51ac41ce7effe4ed27178a8a70d2.png

在32位的Roaring bitmap中,前16位用于分桶,该取值范围内没有数据则bucket不会被创建,后16位存在对应的container中。Container有两种类型:

  • Array container: 数据量较少的时候(一般少于8K容量),更省空间

  • Bitmap container 适合存储稠密数据、占用空间小

在计算的时候只要对某些bucket中的值进行计算即可。扩展到64位的roaringbitmap的时候,我们可以通过一个map<uint32_t, Roaring>来支持,前32位作为map的key,后32位用roaringbitmap存储。

字典优化

在大部分场景中,以上的roaring bitmap已经有很好的性能。但是在字节的实际场景中,我们发现由于user_id 不是连续生成的,array container的数量占比会很高。对两个稀疏人群的交并补操作就变成了对两个有序数组的计算,这种计算对比单纯的位计算,在性能上还是有明显的差异。

因此在ByteHouse中,我们通过字典方式,对数据进行编码,让数据更加集中。

开启字典优化的方式如下:

CREATE TABLE id_tags (
    tags            String,
    active_users    BitMap64 BitEngineEncode
) Engine = CnchMergeTree() order by tags

本质上字典服务是个onto映射, 可以通过key 查找value, 也可以通过value反查key, 其中key原始值,value时编码值。开启编码之后,ByteHouse会依赖一个字典文件。在默认情况下,ByteHouse会在内部维护一个字典文件。

当底表更新时,内部字典文件也会随之异步更新。ByteHouse同时也支持用户维护外部字典,这里不做展开。

总结

人群分析是画像平台的基础功能,本文介绍了如何利用ByteHouse内置的BitMap类型来支持实时的画像查询分析。目前ByteHouse云数仓以及企业版均已登陆火山引擎。未来,火山引擎将通过 ByteHouse 来为客户持续提供字节跳动和外部最佳实践,构建交互式大数据分析平台,以应对复杂多变的业务需求和高速增长的数据场景。

超低延时直播技术的前世今生

作者 ByteDanceTech
2023年8月9日 16:00

作者:李晨光、匡建鑫、陈鉴平

卷首语:

据中国互联网络信息中心发布的《中国互联网络发展状况统计报告》显示,截止到 2022 年 6 月我国网络直播用户规模达到了 7.16 亿,占网民整体的 68.1% 。最主要原因是 2020 年度疫情期间导致居家办公和休闲娱乐的人数呈现激增,新媒体互动直播成为了广大网民最重要的休闲娱乐方式之一。

随着直播产业链的不断扩展完备升级,相关产业链各个环节分工逐渐明确且各环节参与人数逐步增多;为了满足不同的就业需求,引发相关就业人数提升,通过直播形式赋能传统产业升级转型,并与高新技术融合创新,优化传统行业商业模式,如直播带货、新媒体广告传媒转型等。

丰富的传统文化、新闻、竞技体育、法律、知识共享等内容,通过移动端互动直播的形式得以更加高效的展现传播,既让优质的直播内容可以实现爆发式传播扩散,又可以让用户有更多的机会感受,学习甚至主动参与直播互动,实现内容供给侧和需求传播的多方共赢。

可以说,超低延时直播技术正在走上一条全新的发展之路。InfoQ将联合火山引擎视频直播团队推出《超低延时直播技术演进之路》系列,带您探索超低延时直播技术的演进历程,揭示背后的挑战和突破,以及对未来直播行业的影响。

今天这篇文章我们来讲一下超低延时直播技术的前世今生~

网络基础设施升级、音视频传输技术迭代、WebRTC 开源等因素,驱动音视频服务时延逐渐降低, 使超低延时直播技术成为炙手可热的研究方向。实时音视频业务在消费互联网领域蓬勃发展, 并逐渐向产业互联网领域加速渗透。经历了行业第一轮的红利爆发期,我国实时音视频行业的场景效能逐渐深化,步入到理性增长阶段。

延时的指标选择很大程度上取决于用户与内容制作方的交互耦合程度,场景丰富多样。

0f366549af3434a87828dd12f86568de.png

在这些极端场景下,延时在用户侧希望越小越好,接近于实时通信的低延迟模式可以最大化地激发用户的参与感,无缝地与内容生产方产生互动效应,调动用户所见即所得的积极性。比如在主播秀场的PK、送礼、工会冲榜、打赏的活动关键环节,竞争双方的储值大户都希望实时地观察到自身主播在礼物刷榜后的反应,为后台运营决策团队或者后续活动策略提供第一时间的信息反馈。

下图体现了从技术/产品/运营的三方角度来综合思考低延时直播技术的作用;从外部-内部综合因素考虑技术的变迁对整个生态正向循环的影响。

7ca7c70cc8277c0b88a349a492385117.png

(一)传统标准直播技术的局限性

1. RTMP 协议的延迟问题

RTMP 协议是最传统的直播协议,主播端采用 RTMP 协议推送 H.264/5 和 AAC 编码的视音频数据到云厂商 CDN 服务器进行转封装分发,端到端延迟一般控制在 3 到 7 秒。问题是 RTMP 的可扩展性存在缺陷,同时对于延迟的进一步下探存在一定的技术困难。RTMP 协议情况下:为了满足延时降低必然压缩播放器的下载缓冲区,这样会引发显著的卡顿问题,使得播放的观感产生不舒适的感受(延时下探至 2 秒以下)。

762d84c1ec642dcd47b3a1152791a0fa.png

2. 传统直播技术在实时互动场景中的不足

  • 视频延时和弹幕交互的延时存在显著差异,问题聊天内容互动与视频传输图像节奏不匹配;

118f966f00ed6720071111089fbb2ef8.png
  • 观众与主播互动形式单一,是单向内容传导无法做到双向(在 RTC 技术引入之前无法显著解决)。

  • 单向传导的局限第一个方面表现在:观众端拉流传输无法做到根据网络情况自适应调节。用户只能以固定的码率进行流媒体传输无法做到动态感知,在网络情况实时变化的场景(比如弱网,移动基站切换等)固定单向码率传输有较大概率造成丢帧卡顿等因素影响观播体验;另一方面在网络条件更好时,固定码率传输无法动态提升视频传输码率(更高的画质带来更加舒适的体验)

  • 在直播和连麦场景共存的互动直播场景下,主播采用传统RTMP推流在遇到连麦PK场景时,会产生推流/本地连麦合流/服务器连麦合流的切换问题,这种场景变换的切换会使得观众端产生瞬间的卡顿问题;如果采用基于webRTC直播技术的超低延时直播方案,这种推流--连麦逻辑的合流切换问题可以得到比较友好的解决(只需要改变服务器转发-订阅流通道的分发逻辑,不涉及推流媒体数据流的旁路调度切换)。

3. 超低延时直播与标准直播的区别

  • 超低延时直播是近年来新兴起的一类应用。如电商直播、赛事直播等场景,兼具高并发与低延时的特性,传统直播 3-20s 的时延难以满足其需求,但对实时互动的要求又不及视频会议等典型的实时音视频应用,无需将时延降低至 400ms 以下。为此,超低延时直播融合了传统直播与实时音视频的技术架构,通过取长补短的方式实现了介于二者之间的端到端时延。尽管针对超低延时直播厂商尚无一套标准的技术路径,但大体可以归纳为拉流协议、网络架构和推流协议三个方面的改造, 在实际应用过程中,厂商会平衡成本及性能指标等因素,在不同的协议和网络架构之间进行选择。

  • 传输层协议的****差异 (基于 UDP 协议的可靠性优化,为弱网对抗策略提供依据)

    • 传统直播 FLV/RTMP 等采用的是 TCP 协议(或者 QUIC 协议)TCP 是牺牲传输实时性来换取数据完整性的可靠传输协议。弱网环境下,其在数据传输前的“三次 握手”连接会带来较大延时。而 UDP 作为不可靠的传输协议,其最大的优点为高实时性,但不保证数据的到达和排序。实时音视频 产品(如 RTM ****超低延时直播 )往往采用 UDP 协议,并在此之上进行协议层与算法层的优化,来提高传输的可靠性与逻辑性。

  • UDP 协议的优化:

    • UDP 协议往往和 RTP/RTCP 协议一起在实际应用中出现。RTP 负责数据传输,其协议头中的序列号、 端口类型、时间戳等字段,可为数据包的分组、组装、排序提供逻辑依据;RTCP 作为 RTP 的控制协议,负责对 RTP 的传输质量进行统计反馈,并为弱网对抗策略提供控制参数。

    • 8525653d43d44f22ce8f9f31c32d953f.png

(二)超低延时直播技术的演进历程

  • 基于业务场景发展的直播技术演进过程(延迟主线

  • RTM 协议本身的演进历程

    • a=extmap:18 "http://www.webrtc.org/experiments/rtp-hdrext/decoding-timestamp"
      a=extmap:19 "uri:webrtc:rtc:rtp-hdrext:video:CompositionTime"
      a=extmap:21 "uri:webrtc:rtc:rtp-hdrext:video:frame-seq-range"
      a=extmap:22 "uri:webrtc:rtc:rtp-hdrext:video:frame-type"
      a=extmap:23 "uri:webrtc:rtc:rtp-hdrext:video:reference-frame-timestamp"
      a=extmap:27 "uri:webrtc:rtc:rtp-hdrext:audio:aac-config"
    • a=extmap:18 "http://www.webrtc.org/experiments/rtp-hdrext/decoding-timestamp"

    • a=extmap:19 "uri:webrtc:rtc:rtp-hdrext:video:CompositionTime"

    • a=extmap:21 uri:webrtc:rtc:rtp-hdrext:video:frame-seq-range

    • a=extmap:22 uri:webrtc:rtc:rtp-hdrext:video:frame-type

    • a=extmap:23 uri:webrtc:rtc:rtp-hdrext:video:reference-frame-timestamp

    • a=extmap:27 uri:webrtc:rtc:rtp-hdrext:audio:aac-config

    • RTP 使用 RTP 私有扩展头携带 DTS/CTS 值,每一帧 RTP 数据包通过 RFC5285-Header-Extension 扩展头携带该帧的 DTS 值,每一帧首个 RTP 包和 VPS/SPS/PPS 包通过 RFC5285-Header-Extension 扩展头携带该帧的 CTS 值,通过 PTS = DTS + CTS 计算当前帧的时间戳。用于启播快速音画同步和播放器播控逻辑精准音画同步

    • 扩展头携带帧的起始/结束序号:如果首帧的前几个包丢失,那么可根据起始序号快速发起重传加快首帧;如果当前帧的后几个包丢失,那么可根据该帧的结束序号快速发起重传,降低延时,减少卡顿

    • 扩展头携带帧的类型:如果携带并解析了正确的帧类型,客户端可以不用解析 metadata ;同时在弱网情形,客户端可以跳过 B 帧直接解码 P 帧,加速出帧并减少潜在卡顿

    • 扩展头携带 P 帧的参考帧信息:如果发生弱网情形,那么客户端可以依照扩展头指定的参考帧关系及其对应时间戳,跳过 B 帧****解码 ,减少卡顿发生

    • 为了加速信令交互的速度,CDN 可以在某些条件下不去查询媒体信息,直接向客户端返回支持的音视频能力;此时 SDP 的媒体描述中将不包含有具体的音视频配置详细信息。在音频层面,此时AnswerSDP 中不包含 aac 解码所需的头信息;此时我们需要采取 RTP 扩展头模式携带 AAC-Config 供客户端在 RTP 收包时刻自行解析处理完成解码动作,作用是减少信令交互时间,提升拉流成功率

    • miniSDP 信令标准实现部分(抖音)

    • CDN 信令异步回源

    • RTP 携带扩展头组成部分

1. WebRTC 协议在直播播放器的移植

  • RTM 低延时直播基于 WebRTC 技术衍生,基于 WebRTC 标准构建点到点传输一般有如下几个步骤:

    • 通信双方要进行媒体协商,会话详细规范即 SDP(Session Description Protocol) 交互;

    • 随后进行交互式网络地址协商(查询对端真实 IP 地址)准备构建媒体传输通道;

    • 当上述条件准备完毕即进入最终的 Peer to Peer 点对点媒体数据传输。

43fe1b6b95e71b1b394bba7bd2edebf6.png
  • 信令部分客户端-服务器单独开发,利用了 SDP 标准报文模式;媒体传输部分采用开源的 WebRTC 框架和字节自研的实时音视频媒体引擎进行媒体传输。

2. RTC ****信令 协议的改造升级( MiniSDP 压缩 协议)

https://github.com/zhzane/mini_sdp

5c0406c7d09c9c144014970003db2a31.png
  • 标准 SDP 比较冗长( 5-10KB 左右),不利于快速高效传输。在直播场景下,会尤其影响首帧时间。MiniSDP 对标准 SDP 文本协议进行高效能压缩,将原生 SDP 转换成更小的二进制格式,使其能够通过一个 UDP 包来传输。

  • 降低信令交互时间,提高网络传输效能,降低直播拉流首帧渲染时间,提高拉流秒开率/成功率等 QoS 统计指标。

播放协议 RTM-HTTP信令 RTM-MiniSDP信令 FLV
首帧时间(预览) 600ms 510ms 350ms
拉流成功率(预览) 97.50% 98.00% 98.70%

3. CDN RTM ****信令 异步 回源优化

  • 降低 RTM 信令交互时间,降低 RTM 拉流首帧渲染时间。

  • 原来的流程在服务端缓存不命中时需要等待回源拿到数据,才能返回带有 AacConfig 信息的 AnswerSDP。客户端收到 AnswerSDP 后发送 STUN,而服务端只能在收到 STUN 才能开始下发数据。(如下图左);当异步回源情况下:服务端不再等待回源结果直接返回 AnswerSDP,之后回源和WebRTC 建连流程同步进行。等到 WebRTC 建连成功且回源拿到数据立即下发 RTP 数据。(如下图右)

216cb9daea39bbd4a650fdc31a0b1fca.jpeg

4. 视频渲染卡顿的优化(百秒卡顿平均降低4秒)

  • 改善人均看播时长,改变 RTC 引擎的组帧/解码策略;禁止 RTC 在低延时模式下的丢帧,改善直播的视频渲染卡顿。

实验组 视频渲染百秒卡顿(直播间场景)
RTM默认JitterBuffer策略 8.3s
RTM改进的JitterBuffer非丢帧策略 3.6s
  • 传统的 RTC 场景优先保时延,全链路会触发各种丢帧(包括但不限于解码模块,网络模块),FLV 直播场景会优先保证观播体验(不丢帧,良好的音画同步效果)。RTM 要想减少卡顿,取得 qoe 的收益,播控策略需进行定制化, 定制逻辑修改点:

    • 确保不会由于软解的解码耗时或者硬解的 dequeuinputbuffer 等其它 api 操作阻塞 jitterbuffer ,内核层有一层强制的音画同步逻辑,可以确保音视频的播放体验;

    • 同时上层在监控网络模块和解码模块的缓存长度,有相应的兜底逻辑:

  1. 判断硬解确实解不过来,dec_cache_frames 过多,上报错误,会降级到软解;

  2. jitterbuffer 异常,缓存的 frame_list 过多,触发播放器异常逻辑,上报错误,重新拉流。

b1f3810cead6ca977544143f0c088c6b.png

5. RTM 播控逻辑的优化

  • 改善移动端看播渗透,RTC 统一内核方案天生存在缺陷( MediaCodec 硬件解码器初始化耗时久);将 RTM 视频解码模块从 RTC 内核中迁移至 TTMP 播放内核,复用了 FLV 的视频解码模块( MediaCodec 避免重新初始化);显著的降低了安卓平台的首帧渲染时间,提升了拉流的成功率。

  • RTC 内核通用逻辑

1663a17f34275a5863c2ce42ea67d69a.png
  • 改进的 RTM 内核播控逻辑

e0e5bd3d814ec74157c5e1f4cd8b6698.png

以上为超低延时直播技术演进之路《进化篇》的所有内容,第二篇《实战篇》我们将聚焦于超低延时直播技术如何大规模落地实践,请大家持续关注~

字节跳动基于 Hudi 的机器学习应用场景

作者 ByteDanceTech
2023年7月20日 12:06

动手点关注

8990619a0ef08fce27e1caf0af0aebac.gif

干货不迷路

本文为 Apache Hudi 技术社区分享会第十期嘉宾分享文章,主要介绍火山引擎 LAS 团队自研的多场景样本离线存储技术,用于处理机器学习系统的离线数据流。同时,还会为大家揭秘流批一体样本生成的过程,分享对 Hudi 内核所做出的优化和改造,探索其在数据处理领域的实际应用和效果。文末更有专属彩蛋,新人优惠购福利,等着你来解锁!

本篇文章提纲如下:

  • 业务场景

  • 离线样本存储与迭代

  • 流批一体的样本生成

  • 功能与优化

1. 业务场景

为了让大家更容易理解接下来要讲的基于数据湖的样本存储和样本生成问题,文章先给大家简单介绍一些相关的基础概念。首先是机器学习系统的离线数据流架构,机器学习系统和其他线上服务系统类似,其中和样本有关的角色也比较集中。如下图所示,整个离线数据流架构分为流式和批式两种类型,其中的样本数据由两部分构成,分别是特征和标签。

9c14a5c22ac16ab55c3e4f211cb37a6d.png

在流式架构中,特征由在线预估服务在 serving 时 dump 对应的快照并发送到消息队列中。标签则来自实时行为采集服务,通过日志上报等方法采集得到。在线样本生成服务消费两个数据流,通过关联得到完整的样本,并发送到下游的流式训练服务中进行模型训练,完成样本数据的消费。

批式架构是流式架构的补充,批式架构在订阅流式数据的同时,还会加入批式的特征或者批式生成的标签。比如风控反作弊或者广告类的业务,会有批式生产的数据,并使用批式的样本生成模块生成样本,进而被模型训练组件消费。

流式和批式数据流架构中,还有元数据服务,元数据服务记录了特征的相关元数据,流式批式数据流都会访问元数据服务获取 meta 信息。因此,我们对于批式的特征存储有若干种特定的访问 pattern。

读方面有以下读数据 pattern:大范围的按天批式读取,关注吞吐指标;秒级的点查;高效的谓词下推查询能力;存在基于主键/外建的 join。

在写方面需支持以下能力:基于主键的 upsert;针对部分 cell 的插入与更新;针对行/列/cell 的删除;基于外键的 upsert。

在这样的背景下,我们了解 Hudi 在机器学习离线数据流中的若干应用场景。

2.离线样本存储与迭代

我们希望设计的样本离线存储方案能够适用于多种场景,主要包含以下三类情况。

第一,模型的重新训练,回放流式训练的过程,迭代/纠偏模型等等。

第二,样本的数据迭代,增加修改或者删除对应的特征/标签,并重新训练模型。

第三,样本的 OLAP 查询,用于日常 debug 等。

为了能够支持以上的场景的样本存储与迭代,我们提出的存储方案整体架构设计如下。在逻辑建模上,构建样本存储和构建特定 pattern 的 Hive 表非常类似,样本包含主键、分区键、内部元数据列等功能性 column,然后包含若干特征列和若干标签列。在物理架构上,通过流式和批式生产/采集的特征数据和标签数据通过多个作业混合 upsert 的方式写入 Hudi,更新位于 KV 存储的索引信息,并将实际的数据写入 HDFS 中。由于 Hudi 基于主键/外键 upsert 的特性,数据会被自然地拼接在一起,形成完整的包含特征和标签的样本数据,供消费使用。

3ab709299c2633fef66d4c251f98f7ed.png

在对离线特征进行调研时,我们需要面临以下挑战:基于 HDFS 这种不可变的文件存储,如何实现低成本低读写放大的数据修改。在没有使用数据湖之前,用户做离线特征调研之前需要复制样本,修改并另存一份。其中消耗了巨大的计算和存储资源,伴随样本量的增大,这样的方案将消耗数个 EB 的存储,使得迭代变得不可能。

我们基于 Hudi 实现了 ColumnFamily 的能力。这个方案受到了经典 BigTable 存储 Apache HBase 的启发,将 IO pattern 不同的数据使用不同的文件进行存储,以减少不必要的读写放大。原理是将同一个 FileGroup 的不同列数据存储在不同的文件中,在读时进行合并。这种方法会将新增列的数据单独进行文件存储,发生修改或者新增成本很低。

ec571f93a1e52df9352f37a293b7c9e2.png

我们通过为调研特征列赋予单独的 CF 的方式来减少读写放大,其他列复用线上的特征所在的 CF。这样资源的使用量只会和新增特征相关。这种方式极大得减少了迭代所需的存储使用,并且不会引入任何 shuffle 操作。

ea485d8784d52adcd357a5c4062b28e8.png

上文介绍了离线样本的存储与迭代方案,接下来我们进一步为大家介绍在线样本生成时的流批一体生成方案,讨论其如何降低在线存储的使用成本。

3. 流批一体的样本生成

在线样本生成服务中,我们使用 KV 或者 BigTable 类存储来满足样本拼接的需求,比如 RocksDB 等。这类存储点查性能好,延迟低,但是存储成本也较高。如果在数据有明显的冷热分层的情况下,这类存储本身并不能很好的满足这样的存储需求。Hudi 是一个具有 KV 语义的离线存储,存储成本较低,我们将冷数据存在 Hudi 上的方式来降低在线存储的使用成本,并通过统一的读写接口来屏蔽差异。这一架构也受到了目前市面的多种 HSAP 系统的启发。

e23a67d52b3a121a12c5853d85ca5174.png

为了能够让 Hudi 支持更好的点查,我们复用了写时的 HBase 索引。点查请求会先访问 HBase 索引找到数据所在文件,然后根据文件进行点查。整体端到端的延迟可以做到秒级。适合存储数据量大,qps 较低的场景。

e9343f38b291051777af002496ca4865.png

4. 功能与优化

在使用 Hudi 满足诸多业务需求的过程中,我们也对其内核做了一些改造,以更好得服务我们的业务场景。

4.1 Local Sort

我们支持了单文件内的主键排序。排序是较为常见的查询性能优化手段。通过对主键的排序,享受以下收益

  • CF 在读时,多 CF 合并使用 Sort Merge 的方式,内存使用更低。

  • Compaction 时支持 Sort Merge。不会触发 spill,内存使用低。我们之前使用 SSD 队列来做 Compaction 以保证性能,现在可以使用一些廉价的资源(比如无盘的潮汐资源)来进行 Compaction。

  • 在流批一体的样本生成中,由于主键是排好序的,我们点查时基于主键的谓词下推效果非常好。提升了点查性能。

4.2 Bulkload 并发写

并发写一直是 Hudi 的比较大的挑战。我们的业务场景中会发生行级别/列级别的写冲突,这种冲突无法通过乐观锁来避免。基于机器学习对于数据冲突的解决需求,我们之前就支持了 MVCC 的冲突解决方式。更进一步得,为了能够让 Hudi 支持并发读写,我们参考 HBase 支持了 Bulkload 的功能来解决并发写需求。所有写数据都会写成功,并由数据内部的 mvcc 来决定数据冲突。

我们首先将数据文件生成到一个临时缓冲区,每个缓冲区对应一个 commit 请求,多个写临时缓冲区的请求可以并发进行。当数据完整写入临时缓冲区之后,我们有一个常驻的任务会接受数据 load 的请求,将数据从缓冲区中通过文件移动的方式 load 进 Hudi,并生成对应的 commit 信息。多个 load 请求是线性进行的,由 Hudi Timeline 的表锁保证,但是每个 load 请求中只涉及文件的移动,所以 load 请求执行时间是秒级,这样就实现了大吞吐的数据多并发写和最终一致性。

8256caab2a49c4b11426bd08b277e713.png

4.3 Compaction Service

关于 Compaction,Hudi 社区提供了若干 Compaction 的开箱即用的策略。但是业务侧的需求非常灵活多变,无法归类到一种开箱即用的策略上。因此我们提供了 Compaction Service 这样的组件用来处理用户的 Compaction 请求,允许用户主动触发一次 Compaction,并可指定 Compaction 的数据范围,资源使用等等。用户也可以选择按照时间周期性触发 Compaction,以达到自动化数据生效的效果。

在底层我们针对 Compaction 的业务场景做了冷热队列分层,根据不同的 SLA 的 Compaction 任务,会选择对应的队列资源来执行。用来降低 Compaction 的整体成本。比如每天天级别的数据生效是一个高保障的 Compaction 任务,会有独占队列来执行。但是进行历史数据的单次修复触发的 Compaction,对执行时间不敏感,会被调度到低优先级队列以较低成本完成。

针对数据湖的样本存储与生成问题,我们搭建了适用于多种场景的存储方案架构,实现了批流一体的样本生成,并且通过对 Hudi 内核进行一定的改造,实现更加满足实际业务需求的功能设计。

以上就是字节跳动在 Hudi 的实践,目前均已通过火山引擎 湖仓一体分析服务 LAS 产品对外服务,欢迎对这方面有需求、感兴趣的用户都可以积极地来体验一下我们的 LAS 湖仓一体分析服务 。

湖仓一体分析服务 LAS(Lakehouse Analytics Service)是面向湖仓一体架构的 Serverless 数据处理分析服务,提供字节跳动最佳实践的一站式 EB 级海量数据存储计算和交互分析能力,兼容 Spark、Presto 生态,帮助企业轻松构建智能实时湖仓。新人优惠来袭!赠送给所有新人用户的专属福利来啦, LAS 数据中台 新人特惠 1 元秒杀活动最新上线!更有超多叠加优惠等你来抢! 感谢大家一直以来对我们的支持与厚爱,我们会一如既往地为您带来更好的内容。

(点击文末“阅读原文”,可顺滑体验)

10e5dbce0e00e65135f88efa1b1774b6.png

字节跳动开源 Kelemetry:面向 Kubernetes 控制面的全局追踪系统

作者 ByteDanceTech
2023年7月5日 12:03

动手点关注

82d6655c911b10587150615f4455aef3.gif

干货不迷路

Kelemetry是字节跳动开发的用于Kubernetes控制平面的追踪系统,它从全局视角串联起多个 Kubernetes 组件的行为,追踪单个 Kubernetes 对象的完整生命周期以及不同对象之间的相互影响。通过可视化 K8s 系统内的事件链路,它使得 Kubernetes 系统更容易观测、更容易理解、更容易 Debug。

db574c8c5b7af282a66ccf03918e7286.png

背景

在传统的分布式追踪中,“追踪”通常对应于用户请求期间的内部调用。特别是,当用户请求到达时,追踪会从根跨度开始,然后每个内部RPC调用会启动一个新的子跨度。由于父跨度的持续时间通常是其子跨度的超集,追踪可以直观地以树形或火焰图的形式观察,其中层次结构表示组件之间的依赖关系。

与传统的RPC系统相反,Kubernetes API是异步和声明式的。为了执行操作,组件会更新apiserver上对象的规范(期望状态),然后其他组件会不断尝试自我纠正以达到期望的状态。例如,当我们将ReplicaSet从3个副本扩展到5个副本时,我们会将spec.replicas字段更新为5,rs controller会观察到此更改,并不断创建新的pod对象,直到总数达到5个。当kubelet观察到其管理的节点创建了一个pod时,它会在其节点上生成与pod中的规范匹配的容器。

在此过程中,我们从未直接调用过rs controller,rs controller也从未直接调用过kubelet。这意味着我们无法观察到组件之间的直接因果关系。如果在过程中删除了原始的3个pod中的一个,副本集控制器将与两个新的pod一起创建一个不同的pod,我们无法将此创建与ReplicaSet的扩展或pod的删除关联起来。因此,由于“追踪”或“跨度”的定义模糊不清,传统的基于跨度的分布式追踪模型在Kubernetes中几乎不适用。

过去,各个组件一直在实现自己的内部追踪,通常每个“reconcile”对应一个追踪(例如,kubelet追踪只追踪处理单个pod创建/更新的同步操作)。然而,没有单一的追踪能够解释整个流程,这导致了可观察性的孤立岛,因为只有观察多个reconcile才能理解许多面向用户的行为;例如,扩展ReplicaSet的过程只能通过观察副本集控制器处理ReplicaSet更新或pod就绪更新的多个reconcile来推断。

为解决可观察性数据孤岛的问题,Kelemetry以组件无关、非侵入性的方式,收集并连接来自不同组件的信号,并以追踪的形式展示相关数据。

设计

将对象作为跨度

为了连接不同组件的可观察性数据,Kelemetry采用了一种不同的方法,受到kspan项目的启发,与将单个操作作为根跨度的尝试不同,这里为对象本身创建一个跨度,而每个在对象上发生的事件都是一个子跨度。此外,各个对象通过它们的拥有关系连接在一起,使得子对象的跨度成为父对象的子跨度。因此,我们得到了两个维度:树形层次结构表示对象层次结构和事件范围,而时间线表示事件顺序,通常与因果关系一致。

例如,当我们创建一个单pod部署时,deployment controller、rs controller和kubelet之间的交互可以使用审计日志和事件的数据在单个追踪中显示:

32c6a1c96a7ea96904e77c05e3ca5141.png

追踪通常用于追踪持续几秒钟的短暂请求,所以追踪存储实现可能不支持具有长生命周期或包含太多跨度的追踪;包含过多跨度的追踪可能导致某些存储后端的性能问题。因此,我们通过将每个事件分到其所属的半小时时间段中,将每个追踪的持续时间限制为30分钟。例如,发生在12:56的事件将被分组到12:30-13:00的对象跨度中。

我们使用分布式KV存储来存储(集群、资源类型、命名空间、名称、字段、半小时时间戳)到相应对象创建的追踪/跨度ID的映射,以确保每个对象只创建一个追踪。

审计日志收集

Kelemetry的主要数据源之一是apiserver的审计日志。审计日志提供了关于每个控制器操作的丰富信息,包括发起操作的客户端、涉及的对象、从接收请求到完成的准确持续时间等。在Kubernetes架构中,每个对象的更改会触发其相关的控制器进行协调,并导致后续对象的更改,因此观察与对象更改相关的审计日志有助于理解一系列事件中控制器之间的交互。

Kubernetes apiserver的审计日志以两种不同的方式暴露:日志文件和webhook。一些云提供商实现了自己的审计日志收集方式,而在社区中配置审计日志收集的与厂商无关的方法进展甚微。为了简化自助提供的集群的部署过程,Kelemetry提供了一个审计webhook,用于接收原生的审计信息,也暴露了插件API以实现从特定厂商的消息队列中消费审计日志。

Event 收集

当Kubernetes控制器处理对象时,它们会发出与对象关联的“event”。当用户运行kubectl describe命令时,这些event会显示出来,通常提供了控制器处理过程的更友好的描述。例如,当调度器无法调度一个pod时,它会发出一个FailToSchedulePod事件,其中包含详细的消息:

0/4022 nodes are available to run pod xxxxx: 1072 Insufficient memory, 1819 Insufficient cpu, 1930 node(s) didn't match node selector, 71 node(s) had taint {xxxxx}, that the pod didn't tolerate.

由于event针对用于kubectl describe命令优化,它们并不保留每个原始事件,而是存储了最后一次记录事件的时间戳和次数。另一方面,Kelemetry使用Kubernetes中的对象列表观察API检索事件,而该API仅公开event对象的最新版本。为了避免重复事件,Kelemetry使用了几种启发式方法来“猜测”是否应将event报告为一个跨度:

  • 持久化处理的最后一个event的时间戳,并在重启后忽略该时间戳之前的事件。虽然事件的接收顺序不一定有保证(由于客户端时钟偏差、控制器 — apiserver — etcd往返的不一致延迟等原因),但这种延迟相对较小,可以消除由于控制器重启导致的大多数重复。

  • 验证event的resourceVersion是否发生了变化,避免由于重列导致的重复event。

将对象状态与审计日志关联

在研究审计日志进行故障排除时,我们最想知道的是“此请求改变了什么”,而不是“谁发起了此请求”,尤其是当各个组件的语义不清楚时。Kelemetry运行一个控制器来监视对象的创建、更新和删除事件,并在接收到审计事件时将其与审计跨度关联起来。当Kubernetes对象被更新时,它的resourceVersion字段会更新为一个新的唯一值。这个值可以用来关联更新对应的审计日志。Kelemetry把对象每个resourceVersion的diff和快照缓存在分布式KV存储中,以便稍后从审计消费者中链接,从而使每个审计日志跨度包含控制器更改的字段。

追踪resourceVersion还有助于识别控制器之间的409冲突。当客户端传递UPDATE请求的resourceVersion过旧,且其他请求是将resourceVersion更改时,就会发生冲突请求。Kelemetry能够将具有相同旧资源版本的多个审计日志组合在一起,以显示与其后续冲突相关的审计请求作为相关的子跨度。

为了确保无缝可用性,该控制器使用多主选举机制,允许控制器的多个副本同时监视同一集群,以确保在控制器重新启动时不会丢失任何事件。

43cf545bb5e20d6e40bc5e7183fc4412.png

前端追踪转换

在传统的追踪中,跨度总是在同一个进程(通常是同一个函数)中开始和结束。因此,OTLP 等追踪协议不支持在跨度完成后对其进行修改。不幸的是,Kelemetry 不是这种情况,因为对象不是运行中的函数,并且没有专门用于启动或停止其跨度的进程。相反,Kelemetry 在创建后立即确定对象跨度,并将其他数据写入子跨度, 是以每个审计日志和事件都是一个子跨度而不是对象跨度上的日志。

然而,由于审计日志的结束时间/持续时间通常没有什么价值,因此追踪视图非常丑陋且空间效率低下:

035d8207a18c39d3401e81227e35e1ee.png

为了提高用户体验,Kelemetry 拦截在 Jaeger 查询前端和存储后端之间,将存储后端结果返回给查询前端之前,对存储后端结果执行自定义转换流水线。

Kelemetry 目前支持 4 种转换流水线:

  • tree:服务名/操作名等字段名简化后的原始trace树

  • timeline:修剪所有嵌套的伪跨度,将所有事件跨度放在根跨度下,有效地提供审计日志

  • tracing:非对象跨度被展平为相关对象的跨度日志

dfddb67056d8a417c7e70a8042678284.png

  • 分组:在追踪管道输出之上,为每个数据源(审计/事件)创建一个新的伪跨度。当多个组件将它们的跨度发送到 Kelemetry 时,组件所有者可以专注于自己组件的日志并轻松地交叉检查其他组件的日志。

用户可以在追踪搜索时通过设置“service name”来选择转换流水线。中间存储插件为每个追踪搜索结果生成一个新的“CacheID”,并将其与实际 TraceID 和转换管道一起存储到缓存 KV 中。当用户查看时,他们传递CacheID,CacheID 由中间存储插件转换为实际TraceID,并执行与 CacheID 关联的转换管道。

bd6943cdde8241d8ec13b5396044f478.png

突破时长限制

如上所述,追踪不能无限增长,因为它可能会导致某些存储后端出现问题。相反,我们每 30 分钟开始一个新的追踪。这会导致用户体验混乱,因为在 12:28 开始滚动的部署追踪会在 12:30 突然终止,用户必须在 12:30 手动跳转到下一个追踪才能继续查看追踪 . 为了避免这种认知开销,Kelemetry 存储插件在搜索追踪时识别具有相同对象标签的跨度,并将它们与相同的缓存 ID 以及用户指定的搜索时间范围一起存储。在渲染 span 时,所有相关的轨迹都合并在一起,具有相同对象标签的对象 span 被删除重复,它们的子对象被合并。轨迹搜索时间范围成为轨迹的剪切范围,将对象组的完整故事显示为单个轨迹。

多集群支持

可以部署 Kelemetry 来监视来自多个集群的事件。在字节跳动,Kelemetry 每天创建 80 亿个跨度(不包括伪跨度)(使用多 raft 缓存后端而不是 etcd)。对象可以链接到来自不同集群的父对象,以启用对跨集群组件的追踪。

未来增强

采用自定义追踪源

为了真正连接K8S生态系统中的所有观测点,审计和事件并不足够全面。Kelemetry将从现有组件收集追踪,并将其集成到Kelemetry追踪系统中,以提供对整个系统的统一和专业化视图。

批量分析

通过Kelemetry的聚合追踪,回答诸如“从部署升级到首次拉取镜像的进展需要多长时间”等问题变得更加容易,但我们仍然缺乏在大规模上聚合这些指标以提供整体性能洞察的能力。通过每隔半小时分析Kelemetry的追踪输出,我们可以识别一系列跨度中的模式,并将其关联为不同的场景。

使用案例

1. replicaset controller 異常

用户报告,一个 deployment 不断创建新的 Pod。我们可以通过deployment名称快速查找其 Kelemetry 追踪,分析replicaset与其创建的 Pod 之间的关系。

16f519e34595a374a78f72537bdd5b07.png

从追踪可见,几个关键点:

  • Replicaset-controller 发出 SuccessfulCreate 事件,表示 Pod 创建请求成功返回,并在replicaset reconcile中得到了replicaset controller的确认。

  • 没有replicaset状态更新事件,这意味着replicaset controller中的 Pod reconcile未能更新replicaset状态或未观察到这些 Pod。

此外,查看其中一个 Pod 的追踪:

f2bdc9a6c1153051e6f939f2ba174d8e.png

  • Replicaset controller 在 Pod 创建后再也没有与该 Pod 进行交互,甚至没有失败的更新请求。

因此,我们可以得出结论,replicaset controller中的 Pod 缓存很可能与 apiserver 上的实际 Pod 存储不一致,我们应该考虑 pod informer 的性能或一致性问题。如果没有 Kelemetry,定位此问题将涉及查看多个 apiserver 实例的各个 Pod 的审计日志。

2.浮动的 minReadySeconds

用户发现deployment的滚动更新非常缓慢,从14:00到18:00花费了几个小时。如不使用Kelemetry,通过使用 kubectl 查找对象,发现 minReadySeconds 字段设置为 10,所以长时间的滚动更新时间是不符合预期的。kube-controller-manager 的日志显示,在一个小时后 Pod 才变为 Ready 状态

8fd7a702d72a5d9b3ebcf3c8ac7594d0.png

进一步查看 kube-controller-manager 的日志后发现,在某个时刻 minReadySeconds 的值为 3600。

38b86d86b46dcca560642c250ed94de0.png

使用 Kelemetry 进行调试,我们可以直接通过deployment名称查找追踪,并发现federation组件增加了 minReadySeconds 的值。

1f38c5e788da0a62493607295fc234b3.png

后来,deployment controller将该值恢复为 10。

8857464ea4276d4fac2fa5e2bbed1b03.png

因此,我们可以得出结论,问题是由用户在滚动更新过程中临时注入的较大 minReadySeconds 值引起的。通过检视对象 diff ,可以轻松识别由非预期中间状态引起的问题。

尝试Kelemetry

Kelemetry已在GitHub上开源:https://github.com/kubewharf/kelemetry

按照 docs/QUICK_START.md 快速入门指南试试Kelemetry如何与您的组件进行交互,或者如果您不想设置一个集群,可以查看从GitHub CI流水线构建的在线预览:https://kubewharf.io/kelemetry/trace-deployment/

加入我们

火山引擎云原生团队火山引擎云原生团队主要负责火山引擎公有云及私有化场景中 PaaS 类产品体系的构建,结合字节跳动多年的云原生技术栈经验和最佳实践沉淀,帮助企业加速数字化转型和创新。产品包括容器服务、镜像仓库、分布式云原生平台、函数服务、服务网格、持续交付、可观测服务等。

4c29c2817339b503f173bca28e404356.png

重要升级!btrace 2.0 技术原理大揭秘

作者 ByteDanceTech
2023年6月26日 12:01

动手点关注

749629b0c67f3bdb159c4ae167b7df95.gif

干货不迷路

项目 GitHub 地址:https://github.com/bytedance/btrace

背景介绍

在一年多前,我们对外正式开源了 btrace(AKA RheaTrace),它是基于 Systrace 的高性能 Trace 工具,目前字节跳动已经有接近 10+ 产品团队使用 btrace 做日常性能优化工作。在这一年期间,我们收到很多社区以及公司内部反馈,包括使用体验、性能体验、监控数据等上都收到众多反馈,我们汇总了大家反馈的内容,主要包括以下三类:

  • 使用体验:Windows 有着大量用户群体,但 btrace 1.0 未支持;桌面脚本依赖 Systrace 和 Python 2.7 环境,导致环境搭建十分复杂,此外手机端还依赖外部存储访问权限,在初次使用时很容易导致打断。同时产物体积庞大,网页打开速度很慢。

  • 性能体验:大型应用插桩数量达到百万级别,性能损耗接近 100%,对性能优化工作产生一定困扰。

  • 监控数据:在 Trace 分析过程中,有些信息是缺失的,并不知道耗时原因,比如目前 Trace 中仅包含 synchronized 锁信息,缺少 ReentrantLock 等其他锁信息,同时渲染监控只有部分系统关键路径信息,缺少业务层信息。

同时,随着 Android 系统的不断发展,Google 逐渐废弃了 Systrace 工具,并开始大力推广 Perfetto 工具。此外,由于系统的 sdcard 权限限制变得更加严格,btrace 在高版本 Android 系统中已经出现兼容性问题。

在此背景下,我们决定大幅改造 btrace,解决用户反馈最多、最集中的问题,同时适应 Google 发布的新特性,并修复兼容性问题,以便更好地满足开发者的需求,目前 btrace 2.0 在使用体验、性能体验、监控数据等方面均做出大量改进,重点改进如下。

  • 使用体验: 支持 Windows 啦!此外将脚本实现从 Python 切至 Java 并去除各种权限要求,因脚本工具可用性问题引起的用户使用打断次数几乎降为 0,同时还将 Trace 产物切至 PB 协议,产物体积减小 70%,网页打开速度提升 7 倍!

  • 性能体验: 通过大规模改造方法 Trace 逻辑,将 App 方法 Trace 底层结构由字符串切换为整数,实现内存占用减少 80%,存储改为 mmap 方式、优化无锁队列逻辑、提供精准插桩策略等,全插桩场景下性能损耗进一步降低至 15%!

  • 监控数据: 新增 4 项数据监控能力,重磅推出渲染详情采集能力!同时还新增 Binder、线程启动、Wait/Notify/Park/Unpark 等详情数据!

接下来,我们将详细介绍上述三个改进方向的具体机制与实现原理,以帮助您深入了解 btrace 2.0 的重要升级。

原理揭秘

Perfetto 简介

Perfetto 和 Systrace 都是用于 Android 系统的性能分析和调试的工具,但它们有所不同:

Systrace:是 Android SDK 中的一个工具,可用于捕获和分析不同系统进程的时序事件,并提供了用于分析系统性能瓶颈的图形界面。Systrace 能够捕获的事件包括 CPU、内存、网络、磁盘I/O、渲染等等。Systrace 的工作原理是在内核和用户空间捕获和解析时序事件,并将其记录到 HTML 文件中,开发者可以使用 Chrome 浏览器来分析这些事件。Systrace 能够很好地帮助开发者找出系统瓶颈,但它在性能方面的表现并不理想,尤其是在处理大量数据时。

Perfetto:是一个全新、低开销的 Trace 采集工具,旨在优化 Systrace 的性能表现。Perfetto 的目标是提供比 Systrace 更快、更细粒度的 Trace 采集,并支持与其他跨平台工具集成。Perfetto 采用二进制格式记录 Trace 数据,并使用基于 ProtoBuf 的数据交换格式进行数据导出,可与 Grafana、SQLite、BigQuery 等其他分析和可视化工具集成。Perfetto 采集的数据种类非常广泛,包括 CPU 使用情况、网络字节流、触摸输入、渲染等等。与 Systrace 相比,Perfetto 在性能和可定制性方面更为出色。

因此,可以看出 Perfetto 是 Systrace 的一种更为先进和优秀的替代工具,它提供了更强大的数据采集和分析功能,更好的性能以及更好的可定制性,为开发人员提供更全面和深入的性能分析和调试工具。

整体流程

首先我们了解下 btrace 采集的整体流程:

6181b3396419a2bc8a3cfeca64613169.png

整个流程分为以下三个阶段:

App 编译时: 在应用程序编译阶段,我们提供了两种插桩模式:方法数字标识插桩和方法字符串标识插桩。方法 数字标识插桩适用于只需要记录方法名称的场景,而方法字符串标识插桩可以同时记录方法参数的值。此外,我们还支持精准插桩引擎,自动识别可疑耗时代码并进行插桩。

App 运行时: 在应用程序运行期间,主要工作是采集应用的 apptrace 信息,对于方法数字标识类型的信息,通过 mmap 无锁队列方式采集;对于字符串标识类型的信息,直接通过系统函数写入 atrace,同时代理 atrace 写入逻辑,将其替换为 LFRB 高性能写入方案。

桌面脚本: 桌面脚本主要用于控制应用程序的运行和开启/关闭 Trace 采集功能。此外,桌面脚本还负责对采集到的 apptrace 与 atrace 数据进行编码,并将它们与 ftrace 进行合并。

技术揭秘

1. 使用体验

使用体验问题在用户反馈中最多,分析下来基本是存储权限、Systrace 环境、Python 环境、Trace 产物体积过大、Perffetto 网页打开过慢等问题,这些体验问题我们完成了针对性的优化:

权限优化

为进行数据处理,桌面脚本需要访问到 App 数据。在 App 层面,最方便的方式是将数据存储到公共 SDCard 中。但从 Android Q 开始,Google 收紧对外置存储完全访问权限。尽管 requestLegacyExternalStorage 可以临时解决这个问题,但从长远来看,SDCard 将无法完全访问。

为解决此问题,我们搭建 Http Server 来通过端口对外访问数据,但访问该 Server 仍需要确定服务地址,为此,我们使用 adb forward 功能,它可以建立一个转发,将 PC 端数据转发到手机端口,并且可以获取从手机端口返回的数据。这样,我们就可以使用 localhost 访问数据。

以上解决了脚本读取 App 数据的问题,我们还面临 App 读取脚本参数问题,比如 maxAppTraceBufferSize、 mainThreadOnly,在 btrace 1.0 支持运行时通过 push 配置文件到指定目录进行动态调整,但这也需要 SDCard 访问权限,为彻底去除权限依赖,我们需要引入新方案。

首先想到的是 adb forward 反向方案:adb reverse,它可以将手机端口数据转发给 PC,实现了从手机到 PC 的访问,同样我们可以在脚本启动 HttpServer 来实现数据接收。但是,因为是网络请求意味着 App 读取参数只能在子线程进行,会有一定的不便,尤其在需要参数实时生效时。

我们又研究了新方案,在桌面脚本通过 adb setprop 给手机设置参数,App 通过 __system_property_get 来读取参数,只要是参数 property 名称以 debug. 开头,就无需任何权限。

// 桌面脚本设置参数
Adb.call("shell", "setprop", "debug.rhea.startWhenAppLaunch", "1");

// 手机运行时读取参数
static jboolean JNI_startWhenAppLaunch(JNIEnv *env, jobject thiz) {
    char value[PROP_VALUE_MAX];
    __system_property_get("debug.rhea.startWhenAppLaunch", value);
    return value[0] == '1';
}
环境优化

btrace 1.0 基于 Systrace 开发,对 Python 2.7 有强依赖,而 Python 2.7 已被官方废弃,同时大多数 Android 工程师对 Python 不太熟悉,浪费了大量时间解决环境问题。对此,我们计划将 Systrace 切换到 Perfetto ,并选择 Android 工程师更熟悉的 Java 语言重写脚本,用户只需有可用的 Java 和 adb 环境,即可轻松使用 btrace 2.0。

产物优化

btrace 1.0 产物是基于 Systrace 的 HTML 文本数据,常常遇到文本内容太大、加载速度过慢、甚至需要单独搭建服务来支持 Trace 显示的问题。Perfetto 是 Google 新推出的性能分析平台,支持多种数据格式解析,Systrace 格式是其中一种,同时 Perfetto 还支持 Protocol Buffer 格式,pb 是一种轻量级、高效的数据序列化格式,用于结构化数据存储和传输。Perfetto 使用 pb 作为其事件记录格式,保证记录系统事件数据的同时,保持数据的高效性和可伸缩性。pb 因为其结构化数据存储可以实现更小体积占用与更快解析速度。因此,btrace 2.0 也将数据格式由 HTML 切换到 pb,在减小产物文件体积的同时,还大幅提升 Trace 在网页上的加载速度。

我们先简单介绍下 Perfetto 的 pb 数据格式,然后再介绍如何将采集到的 apptrace 与 atrace 编码为 pb 格式,以及如何将其与系统 ftrace 进行融合。

Perfetto pb 是由一系列 TracePacket 组成,官方文档可以参考:https://perfetto.dev/docs/reference/trace-packet-proto,这里将介绍 btrace 使用到的一种 TracePacket:FtraceEventBundle:

FtraceEventBundle 是 Android 用于收集系统 Trace 数据的一种机制。它由大量 FtraceEvent 组成,可以被用来记录各种系统行为,如调度、中断、内存管理和文件系统等。btrace 主要利用其中 PrintFtraceEvent 来记录方法 Trace 信息,具体使用方式可以参考下面简单示例:

int threadId = 10011;
FtraceEventBundle.Builder bundle = FtraceEventBundle.newBuilder()
        .addEvent(
                FtraceEvent.newBuilder()
                        .setPid(threadId) // 线程内核 pid,就是 tid
                        .setTimestamp(System.nanoTime())
                        .setPrint(
                                Ftrace.PrintFtraceEvent.newBuilder()
                                        // buf 格式是 B|$pid|$msg\n 这里 pid 是实际
                                        // 进程 ID,`\n` 是必须项
                                        .setBuf("B|10010|someEvent\n"))) 
        .addEvent(
                FtraceEvent.newBuilder()
                        .setPid(threadId)
                        .setTimestamp(System.nanoTime() + TimeUnit.SECONDS.toNanos(2))
                        .setPrint(
                                Ftrace.PrintFtraceEvent.newBuilder()
                                        .setBuf("E|10010|\n")))
        .setCpu(0);
Trace trace = Trace.newBuilder()
        .addPacket(
                TracePacketOuterClass.TracePacket.newBuilder()
                        .setFtraceEvents(bundle)).build();
try (FileOutputStream out = new FileOutputStream("demo.pb")) {
    trace.writeTo(out);
}

上面示例将得到下面这个 Trace:

03980e358f94f3d342b5b3206bafeaf7.png

下面再介绍如何将运行时采集到的 apptrace 信息转换成 pb 格式的,这部分操作在桌面脚本进行。

首先脚本通过 adb http 方式获取到手机上 mmap 映射文件,然后再解析文件内容:

// 读取 mapping,我们将 mapping 内置到了 apk 的 assets 目录
Map<Integer, String> mapping = Mapping.get();
// 开始解码并保存解码后的结果
List<Frame> result = new ArrayList<>();
byte[] bytes = FileUtils.readFileToByteArray(traceFile);
ByteBuffer buffer = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN);
while (buffer.hasRemaining()) {
    long a = buffer.getLong();
    long b = buffer.getLong();
    // 分别解析出 startTime / duration / tid / methodId
    long startTime = a >>> 19;
    long dur0 = a & 0x7FFFF;
    long dur1 = (b >>> 38) & 0x3FFFFFF;
    long dur = (dur0 << 26) + dur1;
    int tid = (int) ((b >>> 23) & 0x7FFF);
    int mid = (int) (b & 0x7FFFFF);
    // 记录相应的开始与结束 Trace
    result.add(new Frame(Frame.B, startTime, dur, pid, tid, mid, mapping));
    result.add(new Frame(Frame.E, startTime, dur, pid, tid, mid, mapping));
}
// 排序
result.sort(Comparator.comparingLong(frame -> frame.time));

之后再利用上文介绍的 FtraceEventBundle 对 List 进行编码即可,这里不再展开。atrace 的处理方式也是类似的,也不再阐述。

再介绍下如何将采集到的 apptrace、atrace 与系统 ftrace 进行合并。

前文介绍过,Perfetto pb 是由一系列 TracePacket 组成,一般而言,我们只要将业务采集到的 Trace 分别封装成 TracePacket,然后加入到系统 TracePacket 集合中就完成了 Trace 的合并。

Trace.Builder systemTrace = Trace.parseFrom(systraceStream).toBuilder();
FtraceEventBundle.Builder bundle = ...;
for (int i = 0; i < events.size(); i++) {
    bundle.addEvent(events.get(i).toEvent());
}
systemTrace.addPacket(TracePacket.newBuilder().setFtraceEvents(bundle).build());

然而,这里有一个前提,就是业务采集的 apptrace / atrace 时间戳与系统 frace 时间戳一致。实际上,根据实际测试的结果,不同设备 ftrace 时间戳可能会采用不同时间,可能是 BOOTTIME,也可能是 MONOTONIC TIME。这导致业务层无论使用哪种时间戳都只能兼容部分设备。为解决此问题,我们在开始记录 trace 信息时,先记录一份 BOOTTIME 和 MONOTONIC TIME 初始时间,之后再记录时间戳时,都统一使用 MONOTONIC 时间。

最后在脚本中解析 ftrace 时间戳进行判断,如果与 MONOTONIC 接近,就采用 MONOTONIC;如果与 BOOTTIME 接近,就采用 BOOTTIME。虽然我们没有单独记录每个函数的 BOOTTIME,但是可以通过 MONOTONIC 与初始时间差异折算。

if (Math.abs(systemFtraceTime - monotonicTime) < Math.abs(systemFtraceTime - bootTime)) {
    Log.d("System is monotonic time.");
} else {
    long diff = bootTime - monotonicTime;
    Log.d("System is BootTime. time diff is " + diff);
    for (Event e: events) {
        e.time += diff;
    }
}

2. 性能体验

运行时优化

btrace 1.0 在插桩上是严格依赖 Systrace 模式,通过在方法开始与结束插入 Trace.beginSection 与 endSection。但是 beginSection 参数是字符串,百万级别方法插桩会造成数百万字符串额外内存占用,对内存造成巨大压力,并且也导致数据 IO 持久化压力巨大。此外,字符串数据大小不固定,只能通过有锁或者 LFRB 方式来记录数据,无法做到数据高效并发写入,只能将数据缓存在 buffer 中,但是过小 buffer 容易导致数据丢失,过大则会造成内存浪费。

btrace 2.0 版本通过将方法 ID 数字化,将方法执行信息记录到一个 mmap 映射文件中。由于方法 ID 大小是固定的,可以使用 atomic 原子操作计算存储数据位置,从而实现无锁并发写入,同时方法数字存储占用内存更小,IO 持久化压力也更小。

同时,我们发现 Trace.beginSection 和 endSection 方式,需要记录每个方法的开始与结束时间、线程 ID 与方法 ID,这里面的线程 ID 和方法 ID 会重复记录,也导致了内存浪费。于是专门进行了优化,在一条记录中同时记录开始时间、方法耗时、线程 ID 和方法 ID 信息,合计占用 2 个 long,可以充分利用内存。

具体插桩逻辑可以参考伪代码:

// 业务代码
public void appLogic() {
    long begin = nativeTraceBegin();
    // 业务逻辑
    nativeTraceEnd(begin, 10010);
}

// 插桩逻辑
long nativeTraceBegin() {
    return nanoTime();
}

void nativeTraceEnd(long begin, int mid) {
    long dur = nanoTime() - begin;
    int tid = gettid();
    write(begin, dur, tid, mid);
}

方法耗时数据记录格式示例:

b5b5cd1cf6ded5c9f0bf43a59ab8fc33.png

方法插桩在 Java 层,但是数据采集基于 mmap 方式在 native 层实现。这会导致高频 JNI 调用,当一个非 JNI 方法调用常规 JNI 方法,以及从常规 JNI 方法返回时,需要做线程状态切换,线程状态切换就会涉及到 GC 锁操作,会有较大性能开销。

熟悉 Android 系统的同学可能了解系统专门为高频 JNI 调用做的性能优化,通过 @CriticalNative 注解与 @FastNative 注解方式来实现,@FastNative 可以使原生方法的性能提升高达 2 倍,@CriticalNative 则可以提升高达 4 倍。

我们也参考系统的方式,给方法添加 @CriticalNative 注解实现方法调用加速。但是 @CriticalNative 注解是隐藏 API无法直接使用,可以通过构建一个定义 CriticalNative 注解的 jar 包,在项目中通过 compileOnly 方式依赖,来达到使用 @CriticalNative 注解的目的。相关注解定义参考自源码:

// ref: https://cs.android.com/android/platform/superproject/+/master:libcore/dalvik/src/main/java/dalvik/annotation/optimization/CriticalNative.java;l=26?q=criticalnative&sq=
@Retention(RetentionPolicy.CLASS)  // Save memory, don't instantiate as an object at runtime.
@Target(ElementType.METHOD)
public @interface CriticalNative {}

具体使用规则可以参考下面代码:

// Java 方法定义,必须是 static,不能用 synchronized,参数类型必须是基本类型
@CriticalNative
public static long nativeTraceBegin();

// Critical JNI 方法,不再需要声明 JNIEnv 与 jclass 参数
static jlong Binary_nativeTraceBegin() {
    ...
}

// 动态绑定
JNINativeMethod t = {"nativeTraceBegin", "(I)J",  (void *) JNI_CriticalTraceBegin};
env->RegisterNatives(clazz, &t, 1);

@CriticalNative/@FastNative 是 8.0 及以后才支持的特性,对于 8.0 以前的设备,也可以通过在方法签名中加入!方式来开启 FastNative:

// Fast JNI 方法,和普通 JNI 方法一样需要 JNIEnv 参数与 jclass 参数
static jlong Binary_nativeTraceBegin(JNIEnv *, jclass) {
    ...
}

// 动态绑定
JNINativeMethod t = {"nativeTraceBegin", "!(I)J",  (void *) Binary_nativeTraceBegin};
env->RegisterNatives(clazz, &t, 1);

以上方法 ID 数字化采集优化的相关内容,前文产物优化专题已经介绍具体的数据解码和 mapping 映射的方案,这里不再赘述。

虽然方法数字 ID 采集具有性能与内存的优势,但它也有一些限制,因为它只能记录在编译阶段准备的通过 ID 映射的内容,无法记录 App 运行时动态生成的内容。因此,除了方法 ID 的存储外,我们还支持字符串类型的数据存储,主要用于记录 btrace 细粒度监控数据和方法参数值。这一方案与 btrace 1.0 中的 LFRB 方案相似,这里就不再详细阐述。由于大部分 trace 数据都是方法 ID,已经被 mmap 分担了压力,因此 LFRB 的压力相比 btrace 1.0 小得多,我们可以适当减小 buffer 大小。

精准插桩

另一个性能优化是插桩优化。随着应用中方法数量越来越多,插桩方法数量也随之增多,久而久之插桩对应用性能损耗也会越大。btrace 1.0 通过提供 traceFilterFilePath 配置让用户来选择对哪些方法插桩,哪些不插桩,灵活配置的同时也把最终性能与插桩权衡的困扰转移给了用户。

在 2.0 中,我们希望建立一套智能规则,可以精准识别用户关心的高耗时方法,同时将不耗时方法精准的排除在插桩规则以外,可以实现智能精准插桩体验。

Android App 项目源码最终会编译为字节码,虽然 Android 虚拟机支持 200 多条字节码指令,但可能导致性能瓶颈的指令往往是比较少且易于枚举的,如 IO 读取、synchronized 字节码、反射、Gson 解析等函数调用等。我们在编译过程中将调用相关指令的方法视为疑似耗时方法,而剩余的非耗时函数则不进行插桩,因为它们不会导致性能问题,从而大大缩小了插桩范围。

32fca203ecd87cd12e9794b119e89ec5.png

以上述耗时特征为基础,我们设计了一条精细化插桩方案,方便用户可以根据具体的情况选择需要的插桩方法。支持的配置方案如下所示:

# 对锁相关的方法插桩
-tracesynchronize

# 对Native方法的调用点插桩
-tracenative

# 对Aidl方法插桩
-traceaidl

# 对包含循环的方法插桩
-traceloop

# 关闭默认耗时方法的调用插桩
-disabledefaultpreciseinject

# 开启大方法插桩,方法调用数超过40
-tracelargemethod 40

# 该方法的调用方需要进行插桩
-traceclassmethods rhea.sample.android.app.PreciseInjectTest {
   test
}

# 被该注解修饰的方法需要被插桩
-tracemethodannotation org.greenrobot.eventbus.Subscribe

# 该Class的所有方法均会被插桩
-traceclass io.reactivex.internal.observers.LambdaObserver

# 该方法的参数信息会在Trace中保留
-allowclassmethodswithparametervalues rhea.sample.android.app.RheaApplication {
   printApplicationName(*java.lang.String);
}

经过我们的精细化插桩后,抖音插桩量减少 94%,在保留较完整的 Trace 数据的同时,性能有了显著的提升。

总之,我们通过权衡耗时函数插桩的优点和缺点,这样可以帮助我们尽可能的获取到足够的耗时函数信息,同时避免过度插桩导致不必要的性能损耗。

3. 监控数据

监控数据是 Trace 的核心,关系到 Trace 能否给用户带来实际价值,除了常规方法执行 Trace 以外,本次 2.0 还带来了渲染监控、Binder 监控、阻塞监控、线程创建监控等四大能力,下面将介绍相关背景与实现原理。

渲染监控

Android 系统提供提供 RenderThread 关键执行逻辑的跟踪埋点,但其提供的信息不够充分,无法直观分析是具体影响渲染问题的业务代码,下图是 atrace 中渲染线程 Trace 示例:

23afa78d5c18ab86a0835e6f47c6cc27.png

为此,我们针对这部分信息进行更精细化拓展展示,新增记录渲染关键 View 节点,下图是优化后效果:

311188066b8dcee54cc5390548ebe720.png

渲染监控核心原理如下图:

420ffc75b1ebb3a21431c14da848ebf2.png

  • 代理 LayoutInflater 获取到 inflate 时 View 所属的布局信息,再通过 View 的 RenderNode 与 native 层 RenderNode关系,将 View 所属布局信息绑定到 RenderNode 的 name 字段上。

  • Hook 渲染阶段的关键节点,比如 SyncFrameState 阶段的 RenderNode::prepareTreeImpl 方法和 RenderPipeline 阶段的 RenderNodeDrawable::forceDraw 方法,将 RenderNode 所属 View 的布局信息记录到 Trace 中。

Binder 监控

Binder 是 Android 跨进程通信的一个非常重要手段,然而我们在做性能分析时,会偶尔发现 Binder 过程比较耗时,虽然 Android 系统 atrace 提供 Binder 耗时监控信息,但其并未提供是何种类型的 Binder 调用,如下图。

b1a0aabe6e399f3d50feee1956b31afa.png

btrace 的 Binder 增强目标是将 Binder 调用的接口名称与方法名称进行解析与展示,实现效果如下:

4baba7fc82b46608de8e8da9fc7e2fd7.png

核心原理通过 plt hook IPCThreadState::transact 记录 binder 调用的 code 与 Parcel& data 参数中的 interfaceName.

status_t IPCThreadState::transact(int32_t handle, uint32_t code, const Parcel& data,
                                  Parcel* reply, uint32_t flags);

但是 Parcel 结构是非公开,很难从 data 中解析出 interfaceName 信息,于是转变思路,通过 hook Parcel::writeInterfaceToken 来记录 interfaceName 与 Parcel 关联信息,随后再在 IPCThreadState::transact 中通过查询获取 interfaceName.

status_t Parcel::writeInterfaceToken(const char* interface) {
    // 记录 this Parcel 与 interface 名称的关联
}

status_t IPCThreadState::transact(int32_t handle, uint32_t code, const Parcel& data,
                                  Parcel* reply, uint32_t flags) {
    // 查询 Parcel data 对应的 interface
    // 记录 Trace
    RHEA_ATRACE("binder transact[%s:%d]", name.c_str(), code);
}

这就记录了以下信息,包含了 interfaceName 与 code:

binder transact[android.content.pm.IPackageManager:5]

此外,还需要将 code 解析到对应 Binder 调用方法。在 AIDL 中,interfaceName$Stub 类静态字段中记录了每个 code 与对应 Binder 调用名称。可以在抓取 trace 结束时,通过反射获取 code 与名称映射关系,将他保存到 Trace 产物中。

#android.os.IHintManager
TRANSACTION_createHintSession:1
TRANSACTION_getHintSessionPreferredRate:2
#miui.security.ISecurityManager
TRANSACTION_activityResume:27
TRANSACTION_addAccessControlPass:6

最后通过桌面脚本进行处理,将运行时记录的 Trace 中 code 进行替换,替换为真实的方法名称即可。

阻塞监控

锁监控是性能监控中非常重要的一个监控环节,在 Android 系统 atrace 中提供 synchronized 锁冲突 Trace 信息,比如通过下图可以得知主线程在获取锁时与 16105 线程发生锁冲突,这给优化线程阻塞提供了重要的信息输入。

1e3c27f68ac890f5ac170646f3099c2d.png

de096ea09091243f16d60276f20831ee.png

但是线程阻塞原因不只有锁冲突,还包含 wait/park 等原因导致的线程等待,比如 ReentrantLock 的底层实现是利用 park 和 unpark。btrace 阻塞监控就是提供这部分阻塞信息,下面是 wait/notify 关联示例,通过检索锁 obj 信息可以得知当前线程 wait 匹配的 notify 的位置:

cf427063094cc45f6a51ebd1482f5868.png

9317d46b58c0a96599eb1be8eb22ddfa.png

wait 与 park 等待原理类似,这里以大家更熟悉的 wait/notify 组合进行说明。

wait/notify 都是 Object 直接定义的方法,本质上都是 JNI 方法,可以通过 JNI hook 方式记录他们的调用。

public final native void wait(long timeoutMillis, int nanos) throws InterruptedException;
public final native void notify();

在对应的 hook 方法中,通过 Trace 记录他们的执行与对应的 this(也就是锁对象)的 identityHashCode,这样可以通过 identityHashCode 建立起映射关系。

static void Object_waitJI(JNIEnv *env, jobject java_this, jlong ms, jint ns) {
    ATRACE_FORMAT("Object#wait(obj:0x%x, timeout:%d)", Object_identityHashCodeNative(env, nullptr, java_this), ms);
    Origin_waitJI(env, java_this, ms, ns);
}

static void Object_notify(JNIEnv *env, jobject java_this) {
    ATRACE_FORMAT("Object#notify(obj:0x%x)", Object_identityHashCodeNative(env, nullptr, java_this));
    Origin_notify(env, java_this);
}
线程创建监控

在分析 Trace 时可能会遇到一些异常的线程,这时候往往需要分析线程在什么地方被创建,但是在传统 Trace 中缺少这部分信息。于是 btrace 加入了线程创建监控数据,核心原理是对 pthread_create 进行代理,记录线程创建的同时,还记录被创建线程的 tid。但是在 pthread_create 调用完成时是无法得知被创建线程 ID 的,通过分析系统源码,发现 pthread_t 本质上是一个 pthread_internal_t 指针,而 pthread_internal_t 则记录着被创建线程的 ID.

// https://cs.android.com/android/platform/superproject/+/master:bionic/libc/bionic/pthread_internal.h
struct pthread_internal_t {
    struct pthread_internal_t *next;
    struct pthread_internal_t *prev;
    pid_t tid;
};

int pthread_create_proxy(pthread_t *thread, const pthread_attr_t *attr,
                         void *(*start_routine)(void *), void *arg) {
    BYTEHOOK_STACK_SCOPE();
    int ret = BYTEHOOK_CALL_PREV(pthread_create_proxy,
                                 thread, attr, start_routine, arg);
    if (ret == 0) {
        ATRACE_FORMAT("pthread_create tid=%lu", ((pthread_internal_t *) *thread)->tid);
    }
    return ret;
}

最终实现效果如图,比如发现 Thread 16125 是新创建的线程,需要分析其创建位置,替换为线程池实现。

3dfeca51df31e5dc4f7f7a6486a1eba2.png

只需要检索 pthread_create tid=16125就能找到对应的创建堆栈。

eb6042b73f9594f6d162ef579a58b14d.png

总结展望

以上介绍了 btrace 2.0 的主要优化点,更多优化还需要在日常使用中去体会。2.0 不是终点,是新征程的起点,我们还将围绕下面几点持续优化,将 btrace 优化到极致:

使用体验: 深入优化使用体验,比如支持不定长时间 Trace 采集,优化采集耗时。

性能体验: 持续探索性能优化,正面与侧面优化双结合,提供更加极致性能体验。

监控数据: 在 Java 与 ART 虚拟机基础之上,建设包括内存、C/C++、JavaScript 等更多更全的监控能力。

使用场景: 提供线上场景接入与使用方案,帮助解决线上疑难问题。

生态建设: 围绕 btrace 2.0 建设完善生态,通过性能诊断与性能防劣化,自动发现存量与增量性能问题。

最后,欢迎大家深入讨论与交流,一起协作构建极致 btrace 工具!

加入我们

抖音 Android 基础技术团队是一个深度追求极致的团队,我们专注于性能、架构、包大小、稳定性、基础库、编译构建等方向的深耕,保障超大规模团队的研发效率和数亿用户的使用体验。目前北京、上海、深圳都有人才需要,欢迎有志之士与我们共同建设亿级用户全球化 APP!

你可以进入字节跳动招聘官网查询「抖音基础技术 Android」相关职位,也可以邮件联系:chenjiawei.kisson@bytedance.com 咨询相关信息或者直接发送简历内推!

fac8f6440be89aa90b805ae5345c1eee.png 点击「阅读原文」欢迎 star!

❌
❌