查看原文
其他

GPU 技术在图片渲染中的应用

Off-Platform Ads Shopee技术团队 2023-04-18

点击关注公众号👆,探索更多Shopee技术实践

广告是互联网流量实现现金收益的一种常见方式。而用于制作广告创意,根据广告位的要求支持不同尺寸、不同投放环境的创意设计的广告素材系统则是整个广告系统中必不可少的一环。广告素材系统最典型的两个特征就是并发量高、计算量大。

本文介绍 Shopee Off-Platform Ads 团队将 GPU 技术应用到图片渲染场景的设计思路与实践经验。团队设计和开发的广告素材渲染系统(Creative Rendering System),通过通用的模板规则配置,充分利用 GPU 强大的并行计算能力,提供每日渲染亿级商品图片的能力,助力 Shopee 快速实现业务增长。

目录

1. GPU 背景知识
    1.1 GPU 与 CPU 通信
    1.2 GPU 程序数据处理过程
    1.3 GPU 软件:CUDA 软件栈
2. 业务背景
3. 图片渲染系统的设计
    3.1 素材渲染
    3.2 模板管理
    3.3 总体架构设计
4. 落地成果
5. 实践经验
    5.1 文字处理改进
    5.2 性能提升
    5.3 CUDA Alpha 合成累积误差问题
6. 总结

1. GPU 背景知识

GPU(Graphics Processing Unit,图形处理器)是由数亿个晶体管搭建的超大规模的集成电路。与 CPU(Central Processing Unit,中央处理器)不同的是,GPU 的大部分电路用作计算单元。下图中绿色为计算单元,黄色为控制单元,橙色为缓存。

GPU 物理架构示意图(摘自 NVIDIA_CUDA_Programming_Guide_1.1_chs.pdf)

相较于 CPU,GPU 的突出特点是其并行计算能力。并且在计算单元的物理设计上,CPU 一般只有单个 SIMD 组件,而 GPU 一般拥有多个 SIMD 组件。简单来说,SIMD 能够使用单指令处理向量化数据

GPU 设计的目标是提高数据处理的吞吐量,它采用了数量众多的计算单元和超长的流水线,但只有非常简单的控制逻辑和 Cache。因此,GPU 擅长处理类型高度统一的、相互无依赖的大规模数据和不需要被打断的纯净的计算场景,例如图片处理、矩阵运算、人工智能等。

1.1 GPU 与 CPU 通信

GPU 作为 CPU 的补充,是无法独立工作的。GPU/CPU 组成最常见的异构系统,CPU 所在位置称为 HOST(主机端),GPU 所在位置称为 DEVICE(设备端)。他们在物理上通过点对点的 PCIe 总线进行数据传输。

摘自 NVIDIA_CUDA_Programming_Guide_1.1_chs.pdf

1.2 GPU 程序数据处理过程

典型的 CPU/GPU 程序处理流程如下图:

1.3 GPU 软件:CUDA 软件栈

CUDA 从驱动层 API 到应用层 API 提供了一整套编程支持。驱动层 API 更加细微,更加灵活,但是要求编程人员对 GPU 硬件有比较深入的了解。而针对一些常见的应用场景,CUDA 已经封装好应用层 API 供开发者使用,相对简单高效。

GPU 最初就是从图形渲染业务发展起来的,对于广告图片渲染场景,CUDA 已经有成熟的应用层 API,因此我们选择基于 CUDA 的应用层 API 开发业务软件。

摘自 NVIDIA_CUDA_Programming_Guide_1.1_chs.pdf

同时,CUDA 提供了从命令行到可视化的程序性能分析工具,包括 nvprof、nvvp、Nsight 等。它们可以测量各个接口的时间占比、硬件的使用情况,并且建议性地指出程序可能存在的瓶颈等。下图为 nvvp 测量的各接口调用次数、调用时间等信息。

2. 业务背景

Shopee 的 Off-Platform Ads 站外广告业务是通过投放站外广告(将 Shopee 的商品投放到 Facebook、Google 等流量平台),帮助 Shopee 平台和入驻卖家实现业务的增长。

在一些电商促销节日到来之前,各大电商平台通常会在主流社交媒体投放线上广告,借助社媒的力量快速触达大量用户,以达到宣传品牌形象、提升购买率与利润空间的目的。

通常,在商品促销的广告中,都会包含带有促销信息的商品图片,它可以更有效地吸引用户,提高广告点击率。如下图,用户可以直接从商品图片中获取到商品样式价格促销信息。这种带有促销信息的图片,即通过图片渲染技术将商品原图与商品信息进行合成后所得

广告投放中的商品样图

3. 图片渲染系统的设计

针对上文中的图片渲染诉求,我们搭建了素材渲染系统(Creative Rendering System, CRS)。该系统主要包含素材图片渲染模板管理两个核心模块。

3.1 素材渲染

我们将商品样式、价格、促销活动等信息抽象成模板,提供给运营人员配置。运营人员配置好模板后,渲染服务会对商品原图按照配置的渲染模板进行合成计算处理,再将合成后的图片同步给外部广告平台。当广告展示给用户的时候,用户就可以看到渲染后的商品图片。

渲染服务示意图

3.2 模板管理

模板由一个或多个图层组成,图层是一个待渲染到商品原图上的图片,图层的类型有图片图层图形图层文字图层

其中,文字图层是即时生成的包含各个商品促销信息的图片。由于商品多种多样,运营人员可以使用商品信息作为每一个图层的渲染条件,只有满足渲染条件的图层才会被绘制到商品图片上。

模版编辑界面例图

3.3 总体架构设计

为了降低渲染系统的复杂度,提高代码的扩展性与可维护性,我们按照功能要素对系统进行了分层设计。主要分为四层:

  • 系统接入层。主要负责数据校验、系统流量控制、报表等。
  • 业务逻辑层。主要负责渲染数据的准备、模板的管理。
  • C/Go 通信层。主要负责 C 与 Go 不同语言间的数据传递。
  • 图像渲染层。主要负责图像数据的处理。

3.3.1 系统接入层

  • 参数校验主要负责对输入参数进行有效性校验,避免非法输入进入下游。
  • 并发控制主要负责对整个系统的并发量进行监控,避免短时大流量引起系统崩溃,起到保护后端的作用。
  • 报表主要负责对输出结果进行整理,包含输出数据结构化、对渲染结果进行分类数理统计等。

3.3.2 业务逻辑层

1)渲染数据准备

一次图片渲染需要数量不等的原始图片,而图片往往比较大,不方便将图片数据直接放在请求中,因此需要渲染数据准备层从第三方下载渲染所需的商品图片、渲染模版等。

2)渲染数据计算

商品广告需要将特定的商品信息渲染在商品图片上,展示给用户以达到特定的商业目的。而怎样将商品信息渲染到广告图片上就涉及到渲染数据计算,根据计算结果决定渲染模板中的图层信息,包括 Tag 填充、是否使用某一图层等。渲染数据计算输出最终唯一确定可执行的渲染数据传递给图像渲染层。渲染数据计算主要包括:

  • Tag 填充。例如商品价格是 9.9,对应填充 Tag 是 #price#,则在 Tag 填充时,需将文字 “#price#” 替换为 “9.9”。
  • 数值处理。例如将数值 “111111” 添加千分位,得到 “111,111”。
  • 渲染条件表达式计算。渲染条件表达式,包括条件本身的定义和条件之间的逻辑关系定义,如 price > 100 AND price <= 200,表示商品的 price 在 (100, 200] 区间内才满足图层的渲染条件。

渲染条件的定义

渲染条件包含 Tag 与运算条件两部分,渲染条件的结果有 True/False 两种。其中,Tag 取商品信息中的字段,运算条件指对 Tag(商品字段)中的值所作运算类型。

Tag运算条件备注
id=
is one of
= 与 is 相同含义;is one of 的类型,用英文符号的 “,” 区分,忽略前后的空格
availabilityis
contains
does not contain

Titlecontains
does not contain
字母均不区分大小写
price<  >  <=  >=  =小数精确到两位

条件之间的逻辑关系定义

由于业务场景中,一次渲染实际使用的条件个数比较少,通常为 1 个,为了便于运营人员理解,我们选择了按顺序读取条件的方式,即 “AND” 和 “OR” 表示条件之间的关系。其中 “AND” 和 “OR” 具有相同的优先级,条件之间的执行顺序为从左到右依次进行。如 true OR false AND false == ((true OR false) AND false) => false。

渲染条件表达式计算——语法树

渲染条件可能包含多个 “AND”/“OR” 关系,为了使上述逻辑关系的计算更加通用,我们设计了计算渲染条件的语法树。

语法树规则设计:

  • 节点分为叶子节点和非叶子节点(其中根节点较特殊,既可以为叶子节点,也可以为非叶子节点);
  • 叶子节点表示具体的条件;
  • 非叶子节点表示 “AND” 和 “OR” 逻辑的运算分支;
  • 语法树的构造逻辑不同,如 “AND” 和 ”OR” 的优先级不同和优先级相同,二者所生成的语法树所表达含义也不同;
  • 语法树的解析逻辑相同,即逻辑运算表达式的计算算法一致,通过计算各节点的结果并最终计算得到总的表达式结果。

例如:用逻辑运算表达式 A and B and C or D and F or E,举例表示 “AND” 和 “OR” 优先级相同生成的语法树。

“AND”优先级等于“OR”的语法树
(((((A and B) and C) or D) and F) or E

3)模板管理

相对于商品数量来讲,模板的数量是很少的,这也就意味着大量的商品会使用同样的模板。因此,需要针对模板建立缓存机制,提升响应速度。

虽然模板数量相对于商品数量来说是很少的,但是也在 10k 的数量级,模板无法全量保存在单机内存中。由于业务逻辑层和图像渲染层都有相应的模板信息,模板参与的流程链路较长,为了保证并发安全性,我们采用延迟删除的策略管理模板缓存。

当模板在一段时间没有被使用后,我们就将该模板添加到待删除队列,在待删除队列内的模板处于临界可用状态,之后的渲染任务无法使用,之前的渲染任务可以正常使用。为了保证并发安全,模板在待删除队列里待够指定时间后,才会真正被删除。

3.3.3 C/Go 通信层

1)Golang/C++ 混合编程基础知识

由于团队采用 Golang 作为主开发语言,业务相关的存量代码为 Golang,而 GPU 的软件栈大部分是 C/C++ 的,C/C++ 的软件生态更加健全。因此,在设计基于 GPU 的图片渲染时需要考虑 Golang/C++ 混合开发,即 Golang 与 C/C++ 如何通信。

由于 Golang “不要通过共享内存来通信,而应该通过通信来共享内存”的设计理念,它并不直接提供内存共享的开发方式,使用 Syscall 接口可以实现内存共享,但极易出错,代码可读性差。幸运的是,Golang 提供了一个叫 CGO 的工具来支持对 C 语言函数级的调用,这就解决了 Golang 与 C/C++ 的通信问题。

如果在 Golang 代码中出现了 import "C" 语句,则表示使用了 CGO 特性,紧跟在这行语句前面的注释是一种特殊语法,里面包含的是正常的 C 语言代码。当确保 CGO 启用的情况下,还可以在当前目录中包含 C/C++ 对应的源文件。举个简单的例子:

package main

/*
#include <stdio.h>

void printint(int v) {
    printf("printint: %d\n", v);
}
*/

import "C"

func main() {
    v := 42
    C.printint(C.int(v))
}

这个例子展示了 CGO 的基本使用方法。开头的注释中写了要调用的 C 函数和相关的头文件,头文件被 include 之后,里面的所有的 C 语言元素都会被加入到 “C” 这个虚拟的包中。需要注意的是,import "C" 导入语句需要单独一行,不能与其他包一同 import。

向 C 函数传递参数也很简单,就直接转化成对应 C 语言类型传递就可以。如上例中 C.int(v) 用于将一个 Golang 中的 int 类型值强制类型转换为 C 语言中的 int 类型值,然后调用 C 语言定义的 printint 函数进行打印。

需要注意的是,Golang 是强类型语言,所以 CGO 中传递的参数类型必须与声明的类型完全一致,而且传递前必须用 “C” 中的转化函数转换成对应的 C 类型,不能直接传入 Golang 中类型的变量。同时,通过虚拟的 C 包导入的 C 语言符号并不需要是大写字母开头,它们不受 Golang 的导出规则约束。类型对比如下表所示。

C 语言类型CGO 类型Go 语言类型
charC.charbyte
signed charC.scharint8
unsigned charC.ucharuint8
shortC.shortint16
unsigned shortC.ushortuint16
intC.intint32
unsigned intC.uintuint32
longC.longint32
long long intC.longlongint64
unsigned long long intC.ulonglonguint64
floatC.floatfloat32
doubleC.doublefloat64
size_tC.size_tuint

CGO 将当前包引用的 C 语言符号都放到了虚拟的 C 包中,同时当前包依赖的其他 Golang 包内部可能也通过 CGO 引入了相似的虚拟 C 包,但是不同的 Golang 包引入的虚拟的 C 包之间的类型是不能通用的。

例如下图,Golang-B 包引用了 Golang-A 包和 C 语言 Q 包,同时 Golang-A 包自身又引用了 C 语言-P 包,Golang-B 包不能使用 C 语言-P 包中的 C 语言类型。

2)Golang/C++ 通信层设计实现

C/Go 通信层负责 C/Go 数据传递。

  • 首先,业务逻辑层的 Go 协程将渲染数据放到待渲染的 Go channel 里;
  • 其次,专门的数据搬运 Go 协程从 Go channel 中取出数据,并转换成 C++ 的数据结构,放到 C++ 的渲染数据队列里;
  • 再次,C++ 线程从 C++ 渲染任务队列里读取数据,渲染图片,将渲染的结果放到 C++ 渲染结果队列;
  • 然后,专门的 Go 协程定时从 C++ 渲染结果队列里取出数据,转换成 Go 数据结构,放入到与业务逻辑层 Go 协程一一对应的的 Go channel 里;
  • 最后,业务逻辑层的 Go 协程将结果返回给上层调用。

3.3.4 图像渲染层

图像渲染层负责渲染任务的执行,它根据商品原图、商品参数和模板信息等输入,渲染加工后输出为带有广告内容的图片。该部分主要涉及图片这种数据关联性较弱的矩阵数据,我们选择了并行计算更加高效的 GPU。由于图像渲染层代码与业务逻辑深度解耦,我们将它封装成独立的图像渲染引擎。

渲染引擎的架构设计

图像渲染引擎主要包括模板管理、渲染数据管理和渲染线程组三部分。

  • 模版管理:负责管理模板缓存,对外提供模板添加与删除接口。每个模板包含若干个图层。图层分为图片图层、几何图层、文字图层三类。图片图层为用户上传的图片,主要操作有裁剪、缩放、旋转等;几何图层为圆形、矩形、箭头、直线等特定的几何图片,主要操作有缩放、旋转等;文字图层为一组特定的文字参数,主要操作有文字图片生成、旋转等。
  • 渲染数据管理:使用两个并发安全的队列分别管理渲染任务的输入与输出。
  • 渲染线程组:为了充分利用 GPU 并发能力,使用一组线程各自执行不同的渲染任务。渲染主要流程均使用 GPU 完成,如图像编解码、图像通道变换、图像旋转缩放、图像 Alpha 合成等。具体流程如下:

4. 落地成果

4.1 高性能支持广告业务场景

Shopee 运营的商品数量比较大,每日投放广告的商品量级也比较大,每次广告投放都伴随着图片渲染,GPU 渲染系统高性能地支撑了 Shopee 亿级的图片渲染业务。并且,当前的 GPU 渲染系统的渲染能力还有一定的冗余,可以支撑业务未来一段时间的业务增长。

4.2 节省成本

图片渲染需要对图片进行逐像素的操作,消耗相当大的计算资源。我们知道 GPU 的运算速度肯定是比 CPU 要强,但是在图片渲染的场景中,GPU 比 CPU 究竟强多少?经压测发现,GPU 在广告渲染场景的性能大约是 CPU 的 10 倍,如下表所示。

服务器核心规格每秒渲染图片
GPU 服务器6 张 T4 卡4680
CPU 服务器64 核453.6

综合考虑在达到相同处理能力时所需要的 GPU 和 CPU 的机器成本,我们最终计算得到 GPU 方案的成本是 CPU 方案的 50% 左右,相当于会节省一半的成本。因此,GPU 方案是一种性价比更高的选择。

同时,GPU 所占用的机位仅为 CPU 的机器所占用的机位的 10%,为比较拥挤的机房资源也节省了很大的空间。

5. 实践经验

在应用 GPU 进行图片渲染的过程中,我们也积累了一些经验,总结如下。

5.1 文字处理改进

5.1.1 连字规则处理

问题描述

某些渲染后的图片,存在渲染的文字与图片上实际看到的文字不一致的情况。例如渲染文字为 ff0ff0 – -> 12,实际结果却如下图,为 ff0ff0 – -> 1212,多出来最后两个字符“12”。

问题分析

opencv-freetype 依赖的 HarfBuzz 库提供了一个强大的文本整形功能——OpenType 机制,可以定制字体整形规则,连字是其中规则之一。这些规则可以通过变量 hb_shape 来启用/关闭,OpenCV 默认开启该连字规则。

上图案例中,由于两个连续的 f 被连字规则整形为一个字符,导致字符串整体少两个字符,为了使字符个数一致,渲染引擎自动从末尾重复补充了两个字符。

解决方案

修改 OpenCV 源码,关闭量 hb_shape 字体整形规则。

5.1.2 文字截断处理

问题描述

某些渲染后的图片存在文字截断的现象,如下图蓝框中的字符“0”。

问题分析

使用不同字体测试,发现偏移的程度与字体相关。

我们使用的是支持 utf8 编码的 opencv_freetype 接口来渲染文字,查看 freetype 官网发现:

Advance width or advanceX

The horizontal distance the pen position must be incremented (for left-to-right writing) or decremented (for right-to-left writing) by after each glyph is rendered when processing text. It is always positive for horizontal layouts, and null for vertical ones.

Glyph width

The glyph's horizontal extent. For unscaled font coordinates, it is bbox.xMax-bbox.xMin. For scaled glyphs, its computation requests specific care, described in the grid-fitting chapter below.

  • advanceX:指水平方向上,字符实际占据的宽度,包含部分字体周围的空白区域。
  • width:指水平方向上,字符有像素体现的宽度,可能小于、大于、等于 advanceX。

下图补充 advanceX < width 的情况,在此情况下,字体会偏左。(advanceX < width 的原因是两个字符之间有重叠)。

总结,opencv_freetype 的 getTextSize 接口仅提供获取一段文字总宽度的能力,这里的总宽度是指:从文字最左侧有像素体现到文字最右侧有像素体现的最大距离。我们默认文字渲染是从有像素体现的位置开始,而实际文字渲染是按照 advanceX 进行排布的。当 advanceX > width 时,文字会超出渲染框的右边界;当 advanceX < width 时, 文字会超出渲染框的左边界。

解决方案

修改 OpenCV 源码,增加获取 advanceX 的接口。在业务渲染时,使用 advanceX 对文字渲染的水平起始位置进行补偿。

5.2 性能提升

5.2.1 改进 CPU 内存分配方式

使用操作系统 API malloc() 在主机上分配的内存是分页内存,与之对应的,使用 GPU API cudaMallocHost() 在主机上分配的内存是页锁内存。页锁内存的重要属性是主机的操作系统不会对这块内存进行分页和交换操作,确保该内存始终驻留在物理内存中。

由于 GPU 与 CPU 之间的内存拷贝是通过 DMA 实现的,CPU 只在内存拷贝完成后才会收到拷贝完成通知。GPU 为了防止拷贝时内存被系统交换出去,当它发现主机内存是分页内存时,会后台申请一个同等大小的页锁内存,先将分页内存的内容拷贝到页锁内存,再从页锁内存拷贝到 GPU。如下图所示。

图源 NIVIDA

因此,应尽量使用 cudaMallocHost() 替代 malloc(),减少一次隐式的内存拷贝。如下为 CUDA 官方的测试数据,数据传输性能提升与主机 CPU 的性能相关,主机 CPU 性能越低,隐式拷贝耗时越大,因而,性能提升幅度越大。

5.2.2 引入内存池减少内存分配

由于图片原始 RGB 数据所占内存较大,每个渲染任务都要占用很大的 GPU 显存(几个 MB),且渲染任务的生命周期较短(几个 ms)。如果频繁的申请/释放 GPU 显存会产生较大的性能开销,因此使用内存池管理 GPU 显存是一个性能优化的方向。

OpenCV 提供 GPU 显存池的接口,但它要求显存的申请/释放必须满足先进后出的规则,该规则刚好与栈的性质一致,因此我们使用一个栈来管理从显存池里申请内存的指针。每次从显存池里申请新内存时,先判断栈顶是否有元素可以释放,如果有元素可释放的话,就释放栈顶元素,再循环判断栈顶元素是否可释放,直到栈顶没有元素可释放时,再将新申请内存的指针入栈。

以下是优化前后的处理性能对比:


渲染时间渲染图片数量QPS
优化前30s19572652
优化后30s28413947

5.2.3 避免 C 结构和 C++ 结构转换

CGO 仅支持 C 语言与 Golang 的通信。因此需要对 C++ 封装一层 C 接口提供给 CGO 调用。在设计 C++ 暴露给 Go 的数据结构时,尽量不使用 string、vector 等 c++ 独有的数据结构,避免多一次 C++ 与 C 数据结构转换的开销。

5.3 CUDA Alpha 合成累积误差问题

我们将文字渲染成图片,称之为前景图片,前景图片中文字的透明度为完全不透明,背景透明度为完全透明,即图中左下角度图片。另一张更大的图片,我们称之为背景图片,即下图中整个蓝色背景。

理论上,将前景图片 Aphla 合成到背景图片上,只会在背景图片上多出前景图片的文字。然而实际如下图所示,多出了红框中的深蓝色,与预期不符。

图片 Alpha 合成我们业务软件调用的 OpenCV 接口 cv::cuda::aplhaComp(),它实际使用的是 CUDA nppiAlphaComp_8u_AC4R() 接口,参考 Npp 官网,如果将该接口换成 nppiAlphaComp_16u_AC4R(),则与预期完全一致。

因此,我们初步判断 nppiAlphaComp_8u_AC4R() 接口存在 bug,并将该 bug 反馈给 NVIDIA,NVIDIA 确认该问题是由接口内部 Alpha 归一化错误所导致,已在 CUDA 11.5 中修复。

6. 总结

基于 Shopee 广告投放业务中的图片渲染场景,针对该场景并发量高、计算量大的特点,本文详细阐述了 GPU 方案的架构设计与细节实现,并分享了该渲染系统在业务场景中的落地成果与实践经验。

当前,渲染系统在满负荷条件下,GPU 使用率仍有一定的提升空间,还没有完全发挥出它的性能。我们仍需要进一步分析渲染系统的瓶颈所在,追求整体系统的极致性能。

后续我们还计划对更丰富的业务场景提供支持,例如视频素材的处理等。

本文作者

Qiang、Dora、Dahe、Lun,后端工程师,来自 Shopee Off-Platform Ads 团队。

技术编辑

Yin,来自 Shopee Off-Platform Ads 团队,Shopee 技术委员会 BE 通道委员。

加入我们

Shopee Off-Platform Ads 团队致力于构建平台级的营销引擎,通过站外广告投放、个性化推送、CPS 联盟等方式全方位触达用户,实现对用户需求的智能发现和满足。同时立足于智能 CRM 系统、价值用户挖掘(DMP)等技术领域,不断沉淀和优化营销领域的一系列系统和工具,提升研发效能,有力推动 Shopee 业务的增长。

目前团队在北京、深圳均有岗位持续招聘,涵盖后端、算法、大数据等。感兴趣的同学可以在 Shopee 社招官网查看岗位信息,亦可将简历发送至 vicky.zeng@shopee.com 进行咨询(备注来自 Shopee 技术博客)。

👇点击阅读原文,加入 Shopee

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存