整合营销服务商

电脑端+手机端+微信端=数据同步管理

免费咨询热线:

64 位字节长度的银行余额“对任何人来说都足够了吗”? 译文

640K 对任何人来说都应该足够了。
据说是比尔·盖茨在 1981 年左右说的。


最近,在 TigerBeetle,我们决定使用 128 位整数来存储所有财务金额和余额,不再使用 64 位整数。虽然有些人可能会认为 64 位整数(可以存储从 0 到 2^64 的整数)足以计算地球上的沙粒,但我们意识到,如果我们想充分存储各种交易,就需要超越这个限制。让我们来找出原因。

我们如何代表金钱?

为了表示数字(并能够用它们进行数学运算),计算机需要用二进制系统对数字进行编码,根据数字的范围和类型,需要一定数量的位(每个位可以是 0 或 1)。例如,从 -128 到 127 的整数(整数)仅用 8 位即可表示,但如果我们不需要负数,我们可以使用相同的位来表示从 0 到 255 的任何整数,这就是一个字节!数字越大,需要的位就越多,例如,最常见的是 16 位、32 位和 64 位数字。

您可能已经注意到,我们讨论的钱是整数,而不是十进制数或美分。小数的情况就变得更加复杂,可以使用浮点数进行编码。虽然二进制浮点数可能适合其他计算,但它们无法准确表达十进制数。当我们尝试将⅓以十进制表示为 0.33333… 时,我们人类会遇到同样的问题,而计算机必须将¹⁄₁₀ 以二进制表示!

>>> 1.0 / 10

0.10000000000000001

随着时间的流逝,“一分钱的零头”会累积成很多,浮点数对金融来说是一场灾难!

因此,在 TigerBeetle 中,我们不使用分数或小数,每个账本都表示为用户定义的最小整数因子的倍数。例如,您可以将美元表示为美分的倍数,然后 1.00 美元的交易可以描述为 100 美分。即使是非十进制货币系统也可以更好地表示为共同因子的倍数。

令人惊讶的是,我们也不使用负数(您可能遇到过仅存储单个正/负余额的软件分类账)。相反,我们保留两个单独的严格正整数金额:一个用于借方,另一个用于贷方。这不仅避免了处理负数的负担(例如溢出或下溢的无数特定于语言的环绕后果),但最重要的是通过显示与借方和贷方不断增加的余额相关的交易量来保留信息。当您需要获取净余额时,可以相应地减去两个余额,并将净额显示为单个正数或负数。

那么,为什么我们需要 128 位整数?

回到将 1.00 美元表示为 100 美分的示例。在这种情况下,64 位整数可以表示接近 184.5 千万亿美元。虽然这对很多人来说可能不是问题,但当需要表示小于一美分的数值时,64 位整数的上限就会受到限制。添加更多小数位会大大缩小这个范围。

出于同样的原因,数字货币是 128 位余额的另一个用例,其中最小金额可以表示为微美分 (10-6) 的数量级……甚至更小。虽然这是 TigerBeetle 支持的一个引人注目的用例,但我们发现各种其他应用程序也受益于 128 位余额。

让我们再思考一下 0.01 美元太大而无法代表某种东西的价值的情况。

例如,在许多国家,一加仑/升汽油的价格要求小数点后三位,股票市场已经要求以百分之一美分的增量定价,比如0.0001。

或者,在高频小额支付的经济中,也需要更高的精度和规模。坚持使用 64 位值会对现实世界的需求施加人为限制,或迫使应用程序在不同的账本中处理同一种货币的不同规模,通过将金额小心地分割到多个“美元”和“微美元”账户中,只是因为单个 64 位余额不足以覆盖代表数十亿美元交易的许多小额支付所需的整个精度和规模范围。

能够很好地(大规模地)计算的数据库的价值也不限于金钱。TigerBeetle 的设计目的不仅是计算金钱,还包括任何可以使用复式记账法建模的东西。例如,计算库存物品、API 调用频率,甚至千瓦时电量。而这些东西都不需要像金钱一样运作,也不需要受到相同的限制。

面向未来的会计。

关于金额和余额上限的另一件事是,虽然单笔交易金额似乎不太可能超过万亿或千万亿的数量级,但账户余额会随着时间的推移而累积。对于长期运行的系统,一个账户可能会在几年内交易如此多的金额,因此一次转账也必须能够将整个余额从一个账户转移到另一个账户。这是我们遇到的一个问题,因为我们考虑是否要转移到 128 位交易金额和/或仅 128 位账户余额。

最后,即使是最意想不到的事件(例如恶性通货膨胀)也可能将货币推向 64 位整数的上限,从而要求其放弃美分并去掉没有实际用途的零。

你的数据库模式能够经受住这种考验吗?

我们可能无法直观地了解 128 位整数有多大。不仅仅是 64 位的两倍;它实际上是 2^64 倍!从这个角度来看,如果我们以微美分的规模对账本进行编码,64 位整数不足以处理 100 万亿美元的账单。但是,使用 128 位整数,我们应该能够在一千年内每秒执行 100 万次相同值的转账,并且仍然不会达到账户余额限制。

  1.000e20  // one hundred trillion at micro-cent scale

x 1.000e6 // 1 million transfers per second

x 3.154e7 // the number of seconds in a year

x 1.000e3 // a thousand years

------------

= 3.154e36 // less than 2^128 ≈ 3.4e38

让我们做一些餐巾纸数学题吧!

BigInteger 带来了巨大的责任。

现代处理器架构(例如 x86-64 和 ARM64)可以处理涉及 64 位值的算术运算,但如果我们理解正确的话,它们并不总是具有用于本机 128 位计算的特定指令集。处理 128 位操作数时,任务可能必须分割为 CPU 可以执行的 64 位部分。因此,我们考虑了与 64 位整数可能的单指令执行相比,128 位算术是否可能要求更高。

下表比较了为 64 位和 128 位操作数生成的 x86_64 机器代码。别担心,您不需要是汇编专家就能明白这一点!只需注意,编译器可以将大多数操作优化为一系列简单的 CPU 指令,例如进位和和借位减法。这意味着使用 128 位金额的成本开销对 TigerBeetle 来说并不重要。

+-----------+--------------------+------------------------+

| Operation | 64-bit operands | 128-bit operands |

+-----------+--------------------+------------------------+

| a + b | mov rax, rdi | mov rax, rdi |

| | add rax, rdx | add rax, rdx |

| | ret | adc rsi, rcx |

| | | mov rdx, rsi |

| | | ret |

+-----------+--------------------+------------------------+

| a - b | mov rax, rdi | mov rax, rdi |

| | sub rax, rsi | sub rax, rdx |

| | ret | sbb rsi, rcx |

| | | mov rdx, rsi |

| | | ret |

+-----------+--------------------+------------------------+

| a * b | mov rax, rdi | mulx r8, rax, rdi |

| | imul rax, rsi | imul rsi, rdx |

| | ret | imul rcx, rdi |

| | | add rcx, rsi |

| | | add r8, rcx |

| | | mov rdx, r8 |

| | | ret |

+-----------+--------------------+------------------------+

| a / b | mov rax, rdi | psuh rax |

| | xor edx, edx | call __udivti3@PLT |

| | div rsi | pop rcx |

| | ret | ret |

+-----------+--------------------+------------------------+

| a == b | cmp rdi, rsi | xor rsi, rcx |

| | sete aj | xor rdi, rdx |

| | ret | or rdi, rsi |

| | | sete al |

| | | ret |

+-----------+--------------------+------------------------+

1. 为简单起见,此汇编代码省略了我们始终为 TigerBeetle 启用的检查算术边界检查和恐慌。2
. 128 位除法无法表示为 64 位指令序列,需要通过软件实现。

作为这一变化的一部分,我们还必须考虑所有客户端,因为 TigerBeetle 需要将其 API 公开给许多不同的编程语言,而这些语言并不总是支持 128 位整数。我们为其提供客户端的主流语言目前需要使用任意精度整数(又名BigInteger)来对 128 位整数进行数学运算。唯一的例外是 .Net,它最近在 .Net 7.0 中添加了对 Int128 和 U Int128 数据类型的支持(向 DotNet 团队致敬!)。

使用 BigInteger 会带来额外的开销,因为它们不是作为固定大小的 128 位值处理,而是作为可变长度的字节数组在堆中分配。此外,算术运算在运行时由软件模拟,这意味着它们无法充分利用如果编译器知道它要处理的数字类型,那么可能实现的优化。嘿,Java、Go,甚至C#,我在看着你。

为了减轻客户端的成本(当然,也为了忠于我们的TigerStyle),我们将所有 128 位值(例如 ID、金额等)存储并公开为一对堆栈分配的 64 位整数(JavaScript 除外,因为它也不支持 64 位数字)。尽管编程语言不了解这种原始类型,也无法对其进行算术运算,但我们提供了一组辅助函数,用于在每个生态系统中现有的惯用替代方案之间进行转换(例如 BigInteger、字节数组、UUID)。

我们的 API 旨在实现非侵入式,让每个应用程序都可以自由选择使用 BigInteger 或通过任何最合理的第三方数值库处理 128 位值。我们希望尽可能提供出色的高性能低级原语,同时尽量减少“修饰”,同时又不剥夺更高层用户的自由。

结论

TigerBeetle 专为新时代而设计,在这个时代,金融交易更加精确、更加频繁。新时代已经开始,日常生活中充满了这样的例子:64 位余额“应该足够了!”这种情况不会持续太久。128 位……甚至更远!



作者:Rafael Batiati

出处:https://tigerbeetle.com/blog/2023-09-19-64-bit-bank-balances-ought-to-be-enough-for-anybody

一个类开始

我们从一个简单类开始说起:

package example.classLifecicle;public class SimpleClass {	public static void main(String[] args) {
SimpleClass ins = new SimpleClass();
}
}

这是一段平凡得不能再平凡的Java代码,稍微有点编程语言入门知识的人都能理解它表达的意思:

  1. 创建一个名为SimpleClass的类;

  2. 定义一个入口main方法;

  3. 在main方法中创建一个SimpleClass类实例;

  4. 退出。

什么是Java bytecode

那么这一段代码是怎么在机器(JVM)里运行的呢?在向下介绍之前先说清几个概念。

首先,Java语言和JVM完全可以看成2个完全不相干的体系。虽然JVM全称叫Java Virtual Machine,最开始也是为了能够实现Java的设计思想而制定开发的。但是时至今日他完全独立于Java语言成为一套生命力更为强悍的体系工具。他有整套规范,根据这个规范它有上百个应用实现,其中包括我们最熟悉的hotspot、jrockit等。还有一些知名的变种版本——harmony和android dalvik,严格意义上变种版本并不能叫java虚拟机,因为其并未按照jvm规范开发,但是从设计思想、API上看又有大量的相似之处。

其次,JVM并不能理解Java语言,他所理解的是称之为Java bytecode的"语言"。Java bytecode从形式上来说是面向过程的,目前包含130多个指令,他更像可以直接用于CPU计算的一组指令集。所以无论什么语言,最后只要按照规范编译成java bytecode(以下简称为"字节码")都可以在JVM上运行。这也是scala、groovy、kotlin等各具特色的语言虽然在语法规则上不一致,但是最终都可以在JVM上平稳运行的原因。

Java bytecode的规范和存储形式

前面代码保存成 .java 文件然后用下面的命令编译过后就可以生成.class字节码了:

$ javac SimpleClass.java #SimpleClass.class

字节码是直接使用2进制的方式存储的,每一段数据都定义了具体的作用。下面是SimpleClass.class 的16进制数据(使用vim + xxd打开):

Java

一个 .class 文件的字节码分为10个部分:

0~4字节:文件头,用于表示这是一个Java bytecode文件,值固定为0xCAFEBABE。

2+2字节:编译器的版本信息。

2+n字节:常量池信息。

2字节:入口权限标记。

2字节:类符号名称。

2字节:父类符号名称。

2+n字节:接口。

2+n字节:域(成员变量)。

2+n字节:方法。

2+n字节:属性。

每个部分的前2个字节都是该部分的标识位。

本篇的目的是说明字节码的作用以及JVM如何使用字节码运转的,想要详细了解2进制意义的请看这里:http://www.jianshu.com/p/252f381a6bc4。

反汇编及字节码解析

我们可以使用 javap 命令将字节码反汇编成我们容易阅读的格式化了的指令集编码:

$ javap -p SimpleClass.class #查看类和成员$ javap -s SimpleClass.class #查看方法签名$ javap -c SimpleClass.class #反汇编字节码$ javap -v SimpleClass.class #返汇编查看所有信息

javap 还有很多的参数,可以使用 javap --help 来了解。下面是使用javap -v 命令输出的内容,输出了常量池信息、方法签名、方法描述、堆栈数量、本地内存等信息:

public class example.classLifecicle.SimpleClass
 flags: ACC_PUBLIC, ACC_SUPER
Constant pool: #1 = Methodref #4.#13 // java/lang/Object."<init>":()V
 #2 = Class #14 // example/classLifecicle/SimpleClass
 #3 = Methodref #2.#13 // example/classLifecicle/SimpleClass."<init>":()V
 #4 = Class #15 // java/lang/Object
 #5 = Utf8 <init>
 #6 = Utf8 ()V
 #7 = Utf8 Code
 #8 = Utf8 LineNumberTable
 #9 = Utf8 main
 #10 = Utf8 ([Ljava/lang/String;)V
 #11 = Utf8 SourceFile
 #12 = Utf8 SimpleClass.java
 #13 = NameAndType #5:#6 // "<init>":()V
 #14 = Utf8 example/classLifecicle/SimpleClass
 #15 = Utf8 java/lang/Object{
 public example.classLifecicle.SimpleClass();
 descriptor: ()V
 flags: ACC_PUBLIC
 Code:
 stack=1, locals=1, args_size=1
 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V
 4: return
 LineNumberTable:
 line 3: 0
 public static void main(java.lang.String[]);
 descriptor: ([Ljava/lang/String;)V
 flags: ACC_PUBLIC, ACC_STATIC
 Code:
 stack=2, locals=2, args_size=1
 0: new #2 // class example/classLifecicle/SimpleClass
 3: dup 4: invokespecial #3 // Method "<init>":()V
 7: astore_1 8: return
 LineNumberTable:
 line 5: 0
 line 6: 8}

下面是关于字节码格式的描述:

public class example.classLifecicle.SimpleClass

这一段表示这个类的符号。

flags: ACC_PUBLIC, ACC_SUPER

该类的标记。例如是否是public类等等,实际上就是将一些Java关键字转译成对应的Java bytecode。

Constant pool:

constant pool: 之后的内容一直到 { 符号,都是我们所说的"常量池"。在对java类进行编译之后就会产生这个常量池。通常我们所说的类加载,就是加载器将字节码描述的常量信息转换成实际存储在运行时常量池中的一些内存数据(当然每个方法中的指令集也会随之加载到方法指向的某个内存空间中)。

"#1"可以理解为常量的ID。可以把常量池看作一个Table,每一个ID都指向一个常量,而在使用时都直接用"#1"这样的ID来引用常量。

常量池中的包含了运行这个类中方法所有需要用到的所有常量信息,Methodref、Class、Utf8、NameAndType等表示常量的类型,后面跟随的参数表示这个常量的引用位置或者数值。

{}:

常量池之后的{}之间是方法。每一个方法分为符号(名称)、标记、描述以及指令集。descriptor:描述。flags:入口权限标记。Code:指令集。

Code中,stack表示这一段指令集堆栈的最大深度, locals表示本地存储的最大个数, args_size表述传入参数的个数。

字节码如何驱动机器运行

在往下说之前,先说下JVM方法区的内容。方法区顾名思义就是存储各种方法的地方。但是从实际应用来看,以Hotspot为例——方法区在实现时通常分为class常量池、运行常量池。在大部分书籍中,运行时常量池被描述为包括类、方法的所有描述信息以及常量数据(详情请看这里的介绍)。

对于机器来说并不存在什么类的感念的。到了硬件层面,他所能了解的内容就是:1)我要计算什么(cpu),2)我要存储什么(缓存、主存、磁盘等,我们统称内存)?

按照分层模型来说JVM只是一个应用进程,是不可能直接和机器打交道的(这话也不是绝对的,有些虚拟机还真直接当作操作系统在特有硬件设备上用)。在JVM到硬件之间还隔着一层操作系统,在本地运行时是直接调用操作系统接口的(windows和linux都是C/C++)。不过为了JVM虚拟机更高效,字节码设计为更接近机器逻辑行为的方式来运行。不然也没必要弄一个字节码来转译Java语言,像nodejs用的V8引擎那样实时编译Javascript不是更直接?这也是过去C/C++唾弃Java效率低下,到了如今Java反而去吐槽其他解释型编译环境跑得慢的原因(不过这也不见得100%正确。比如某些情况下Java在JVM上处理JSON不见得比JavaScript在nodejs上快,而且写起代码来也挺费劲的)。

我们回到硬件计算和存储的问题。CPU的计算过程实质上就是操作系统的线程不断给CPU传递指令集。线程就像传送带一样,把一系列指令排好队然后一个一个交给CPU去处理。每一个指令告诉CPU干一件事,而干事的前后总得有个依据(输入)和结果(输出),这就是各种缓存、内存、磁盘的作用——提供依据、保存结果。JVM线程和操作系统线程是映射关系(mapping),而JVM的堆(heap)和非堆(Non-heap)就是一个内存管理的模型。所以我们跳出分层的概念,将字节码理解为直接在驱动cpu和内存运行的汇编码更容易理解。

最后,我们回到方法区(Method Area)这个规范概念。CPU只关心一堆指令,而JVM中所有的指令都是放置在方法区中的。JVM的首要任务是把这些指令有序的组织起来,按照编程好的逻辑将指令一个一个交给CPU去运行。而CPU都是靠线程来组织指令运算的,所以JVM中每个线程都有一个线程栈,通过他将指令组织起来一个一个的交给CPU去运算——这就是计数器(Counter Register,用以指示当前应该执行什么字节码指令)、线程栈(Stacks,线程的运算模型——先进后出) 和 栈帧(Stacks Frame,方法执行的本地变量) 的概念。所以无论多复杂的设计,方法区可以简单的理解为:有序的将指令集组织起来,并在使用的时候可以通过某些方法找到对应的指令集合

解析常量池

Java

先看 SimpleClass 字节码中常量池中的一些数据,上图中每一个方框表示一个常量。方框中第一行的 #1 表示当前常量的ID,第二行 Methodref 表示这个这个常量的类型,第三行 #4,#13 表示常量的值。

我们从 #1 开始跟着每个常量的值向下延伸可以展开一根以 Utf8 类型作为叶节点的树,每一个叶节点都是一个值。所有的方法我们都可以通过树的方式展开得到下面的查询字段:

class = java/lang/Object //属于哪个类method = "<init>" //方法名称params = NaN //参数return = V //返回类型

所有的方法都会以 package.class.name:(params)return 的形式存储在方法区中,通过上面的参数很快可以定位到方法,例如 java.lang.Object."<init>":()V,这里"<init>"是构造方法专用的名称。

解析方法中的指令集

方法除了用于定位的标识符外就是指令集,下面解析main方法的指令集:

0: new #2 // class example/classLifecicle/SimpleClass3: dup4: invokespecial #3 // Method "<init>":()V7: astore_18: return

1))new 表示新建一个ID为#2的对象即SimpleClass(#2->#15="example/classLifecicle/SimpleClass")。此时JVM会在堆上创建一个能放置SimpleClass类的空间并将引用地址返回写到栈顶。这里仅仅完成在堆中分配空间,没执行初始化。

2)dup表示复制栈顶数据。此时栈中有2个指向同一内存区域的SimpleClass引用。

3)invokespecial #3表示执行#3的方法。通过解析常量池#3就是SimpleClass的构造方法。此后会将SimpleClass构造方法中的指令压入栈中执行。

4)接下来来是SimpleClass的构造方法部分: a)aload_0 表示将本地内存的第一个数据压入栈顶,本地内存的第一个数据就是this。b)invokespecial #1 表示执行 Object 的构造方法。c)退出方法。这样就完成了实例的构造过程。

5)完成上述步骤后,线程栈上还剩下一个指向SimpleClass实例的引用,astore_1 表示将引用存入本地缓存第二个位置。

6)return -> 退出 main 方法。

方法区结构

那么在方法区中所有的类是如何组织存放的呢?

我们用一个关系型数据库常的结构就可以解释他。在数据库中我们常用的对象有3个——表、字段、数据。每一个类对应的字节码我们都可以看成会生成2张数据库表——常量池表、方法表。通过字节码的解析,在内存中产生了如下结构的表:

常量池表:example.classLifecicle.SimpleClass_Constant

idtypevalue
#1Methodref#4,#13
…………
#4Class#15
#15Utf8java/lang/Object

方法表:example.classLifecicle.SimpleClass_Method

nameparamsreturnflagcode
<init> NaNVstatic,public……
……………………

然后在运行过程中当计数器遇到 invokespecial #3 这样的指令时就会根据指令后面的ID去本类的常量表中查询并组装数据。当组装出 class = java/lang/Object、method = "<init>"、params = NaN、return = V这样的数据后,就会去名为java.lang.Object的表中根据 method、params、return 字段的数据查询对应的code,找到后为该code创建一个本地内存,随后线程计数器逐个执行code中的指令。

Java

这里仅仅用关系型数据库表的概念来解释方法区中如何将指令执行和字节码对应起来,真正的JVM运行方式比这复杂得多。不过这样很容易理解方法区到底是怎么一回事。

者 | 王一鹏

如果说,在以音视频为载体传输信息、进行交互的技术领域,始终飘着一朵“乌云”,那么这朵“乌云”的名字,很可能既不是低延时,也不是高可靠,而是不断变化的应用场景。

从 Web 2.0 到移动端基础设施全面建成,我们完成了文字信息的全面数字化;而从 2016 “直播元年”至今,图像、语音信息的全面数字化则仍在推进中。最简单的例证是,对于早期的流媒体直播而言,1080P 是完全可接受的高清直播;但对于今天的流媒体而言,在冬奥会这样的直播场景下,8k 可能是个刚性需求,相比于 1080P,像素数量增长 16 倍。

而且,今天的流媒体业务,对视频流的要求不仅停留在分辨率上,也表现在帧率上。以阿里文娱 2019 年底推出的“帧享”解决方案为例,它将画面帧率推至 120 FPS,同时对动态渲染的要求也很高。过往人们总说,帧率超过 24 FPS,人眼就无法识别,因此高帧率没有实际意义。但高帧率是否能提升观看效果,与每帧信息量密切相关,近几年游戏开发技术的进步,以及以李安为代表的一众电影导演,已经彻底打破这一误解。

对于 RTC 来说,问题情境和对应的软件架构又截然不同。早期大家看赛事直播,20s 的延迟完全可以接受。但在 RTC 场景下,人与人的即时互动让使用者对延迟的忍耐度急剧降低,从 WebRTC 方案到自研传输协议,相关尝试从未停止。

当我们以为,所谓的场景问题,终于可以被抽象为有限的几个技术问题,并将延迟压入 100ms 以内,可靠性提升至 99.99%,新的场景又出现了。全景直播、VR 全球直播,云游戏……其中又以云游戏最为典型——云游戏简直是过去那些音视频场景性能要求的集大成者:有的游戏要求延时低至 50ms 以内;有的要求 FPS 60 以上;分辨率不消说,肯定是越高越好。同时云游戏场景夹杂着大量的动态渲染任务,无一不在消耗着服务器资源,增大着全链路的传输延时。

那么,如果从云游戏场景的性能要求出发,进而扩展至整个超视频时代的架构体系,该以怎样的思路来进行架构设计呢?只关注软件,可能不太行的通;硬件成为必须纳入考虑的一环。

以软件为中心并非最佳选择

要解释这个问题,必须重新回顾下常规的云游戏技术架构。下图主要参考自英特尔音视频白皮书、华为云游戏白皮书,并做了相应调整,基本与当前环境下,大部分云游戏架构的设计相符。

InfoQ 版权所有

在这一架构内,至云游戏终端前,所有服务都在云端、公共网络上完成,保证用户无需下载游戏或是为了玩游戏购置高性能终端。游戏玩家的终端,主要负责对网络包进行处理、对渲染后的游戏画面进行解码、显示,并相应地输入指令,回传给服务器。

而在服务器端,链路相对复杂。云游戏管理平台是服务的起点,上下两条链路,都是云游戏的周边技术服务,与业务场景强相关,包括云游戏的直播录制、游戏日志 / 记录存储等。前者对时延忍耐度较高,可以走正常的流媒体服务体系,使用 CDN 分发音视频内容;后者属于正常的游戏服务器设计范畴,正常提供服务即可。

关键在于中间一层,也就是云游戏容器集群。这一部分要实现的设计基础目标是保障 1s 至少完成 24 张游戏画面(24 帧)的计算、动态渲染和编码传输,部分高要求场景需要帧率达到 60 FPS,同时保证时延尽可能得低。

这部分的技术挑战非常大,以至于若仅以软件为中心思考,很难做出真正突破。从相关指标的演进历史来看,仅仅在 4 年前,移动端游戏本地渲染的基础目标还是 30 FPS,如今虽然能实现 60 FPS 甚至更高,但讨论的场景也从本地渲染切换成了云端渲染。在软件上,除非出现学术层面的突破,否则很难保证性能始终保持这样跨度的飞跃。

此外,渲染本来就是严重倚仗硬件的工作,渲染速度和质量的提升,主要依赖于 GPU 工艺、性能以及配套软件的提升。

3D 游戏渲染画面

而更为复杂的游戏性能以及整体时延的控制,则对整个处理、传输链路提出了要求。仅以时延为例,它要求在编码、计算、渲染、传输等任何一个环节的处理时间都控制在较低范围内。同样是在 3 - 4 年前,有业界专家分享,他们对 RPG 类云游戏的传输时延容忍度是 1000 ms,但事实证明,玩家并不能忍受长达 1s 的输入延迟。反观今日,无论是通过公有云 + GA 方案,还是通过自建实时传输网络方案,即便是传输普通音视频流的 RTC 服务也只能保证延时 100ms 以内,而云游戏的计算量和带宽需求数倍于普通音视频服务。

以上仅仅是冰山一角。对于架构设计而言,除了高性能、高可用、可扩展性三类设计目标外,成本也是必须要考虑的平衡点——需要 1000 台服务器的架构,和需要 100 台服务器的架构,压根不是一个概念。2010 年前后,云游戏基本不存在 C 端商业化可能,虽然整体时延和性能指标可以满足当时的要求,但代价是一台服务器只能服务一个玩家,单个玩家服务成本上万。云游戏“元老” Onlive 公司的失败,在当时非常能说明问题。

而到了 2020 年,行业硬件的整体性能提升后,一台服务器可支持 20 - 50 路并发,性能提升了几十倍。

那么,如果我们将硬件变成架构设计的核心考虑要素,会是什么样的呢?大致如下图所示(为了不让图示过于复杂,我们只保留了云游戏核心服务链路,以作代表)。

InfoQ 版权所有

可以看到,仅在云服务器部分,就有大量的硬件和配套软件需要参与进来,要关注的性能点也相对复杂。而这仅仅是云游戏一个应用场景下的音视频架构,当我们将场景抽象并扩展,最终覆盖到整个超视频时代的时候,以下这张来自英特尔技术团队的架构图,可能更加符合实际。英特尔将音视频体系架构在软件和硬件层面分别进行了展示:一部分叫做 Infrastructure(基础设施层),如图一所示;另一部分则称其为 Infrastructure Readiness (基础设施就绪),指的是基础设施就绪后,建立在其上的工作负载,如图二所示。两张图的首尾有一定重合,表示其头尾相接。

图一:基础设施层

图二:基础设施就绪后的工作负载

可以看到,基础设施层主要包括硬件、配套云服务、云原生中间件以及各类开源基础软件。而在工作负载层面,是大量的软件工作,包括核心的框架、SDK 以及开源软件贡献(UpStream)。这也是为什么英特尔以硬件闻名,却维持着超过一万人的软件研发团队。

拆解软硬一体的音视频架构方案

基础设施层

在基础设施层,我们的首要关注对象就是硬件,尤其是对于音视频服务来说,硬件提升对业务带来的增益相当直接。

但相比于十年前,当前的硬件产品家族的复杂度和丰富度都直线上升,其核心原因无外乎多变的场景带来了新的计算需求,靠 CPU 吃遍天下的日子已经一去不复返了。以前面展示的英特尔硬件矩阵为例,在音视频场景下,我们主要关注 CPU、GPU、IPU,受限于文章篇幅,网卡一类的其他硬件不在重点讨论范围内。

在 CPU 方面,英特尔已更新至强® 第三代可扩展处理器,相比第二代内存带宽提升 1.60 倍,内存容量提升 2.66 倍,采用 PCIe Gen 4,PCI Express 通道数量至多增加 1.33 倍。其中,英特尔® 至强® Platinum 8380 处理器可以达到 8 通道、 40 个内核,主频 2.30 GHz,英特尔支持冬奥会转播 8k 转播时,CPU 侧的主要方案即是 Platinum 8380。这里贴一张详细参数列表供你参考(https://www.intel.cn/content/www/cn/zh/products/sku/212287/intel-xeon-platinum-8380-processor-60m-cache-2-30-ghz/specifications.html):

英特尔 CPU 另外一个值得关注的特点,在于其配套软件层面,主要是 AVX-512 指令集。AVX-512 指令集发布于 2013 年,属于扩展指令集。老的指令集只支持一条指令操作一个数据,但随着场景需求的变化,单指令多数据操作成为必选项,AVX 系列逐渐成为主流。目前,AVX-512 指令集的主要使用意义在于使程序可同时执行 32 次双精度、64 次单精度浮点运算,或操作八个 64 位和十六个 32 位整数。理论上可以使浮点性能翻倍,整数计算性能增加约 33%,且目前只在 Skylake、 Ice Lake 等三代 CPU 上提供支持,因此也较为独特。

在视频编解码、 转码等流程中,因为应用程序需要执行大规模的整型和浮点计算,所以对 AVX-512 指令集的使用也相当关键。

而 GPU 方案在云游戏场景中,通常更加引人瞩目,英特尔® 服务器 GPU 是基于英特尔 Xe 架构的数据中心的第一款独立显卡处理单元。英特尔® 服务器 GPU 基于 23W 独立片上系统(SoC)设计,有 96 个独立执行单元、128 位宽流水线、8G 低功耗内存。

所谓片上系统 SoC,英文全称是 System on Chip,也就是系统级芯片,SoC 包括但不仅限于 CPU、GPU。就在今年,前 Mac 系统架构团队负责人、苹果 M1 芯片的“功臣” Jeff Wilcox 宣布离开苹果,担任英特尔院士(Intel Fellow)、设计工程事业群(Design Engineering Group)CTO,并负责客户端 SoC 架构设计,也在行业内引起了众多关注。

当然,只有 GPU 硬件本身是不够的,英特尔® Media SDK 几乎是搭配 GPU 的必选项。英特尔® Media SDK 提供的是高性能软件开发工具、库和基础设施,以便基于英特尔® 架构的硬件基础设施上创建、开发、调试、测试和部署企业级媒体解决方案。

其构成可参考下图:

IPU 是为了分担 CPU 工作负载而诞生的专用芯片,2021 年 6 月,英特尔数据平台事业部首席技术官 Guido Appenzeller 表示:“IPU 是一种全新的技术类别,是英特尔云战略的重要支柱之一。它扩展了我们的智能网卡功能,旨在应对当下复杂的数据中心,并提升效率。”

具体落地在音视频场景里,IPU 要负责处理编码后的音视频流的传输,从而解放 CPU 去更多关注业务逻辑。所以,CPU + GPU + IPU 的组合,不仅是在关注不同场景下的需求满足问题,实际上也在关注架构成本问题。

工作负载层

从基础设施过渡到工作负载,实际上有一张架构图,更详细的展示了相关技术栈的构成:

在这张架构图中,横向是从源码流输入到分发的整个流程,期间包含了编码、分析等处理动作;而纵向则展示了要服务于这条音视频处理流程,需要搭配的硬件和软件体系。

OneAPI 作为异构算力编程模型,是桥接基础设施和上层负载的关键一层,这不必多言。而到了负载层,软件则分成了蓝色和紫色两个色块。蓝色代表直接开源软件,紫色则代表经过英特尔深度优化,再回馈(Upstream)给开源社区的开源软件。

在蓝色部分,OpenVino 是个很有意思的工具套件,它围绕深度学习推理做了大量的性能优化,并且可以兼容 TensorFlow、Caffe、MXNet 和 Kaldi 等深度学习模型训练框架。当音视频体系需要加入 AI 技术栈以服务超分辨率等关键需求时,OpenVino 会起到关键作用。

紫色部分的 x.264/x.265 是一个典型。作为音视频行业最主流的编码标准,英特尔使其开源的主要贡献者,而且 AVX-512 指令集也专门围绕 x.264/x.265 做了优化和性能测试。

另一个值得关注的核心是编码器,它横跨了蓝色区域和紫色区域,既有行业通用的 ffmpeg,也有英特尔自研的 SVT,二者同样引人关注。

关于编解码器的选型思考

在流媒体时代,著名开源多媒体框架 ffmpeg 是业界在做编解码处理时,绝对的参考对象。说白了,很多编解码器就是 ffmpeg 的深度定制版本。到了 RTC 时代,出于更加严苛的及时交互需求,自研编解码器尽管难度颇高,但也在研发能力过硬的企业中形成了不小的趋势。

可归根结底,在推进以上工作时,软件始终是思考的出发点,从业者们多少有些忽略对硬件的适配。

SVT 的全称是 Scalable Video Technology ,是开源项目 Open Visual Cloud 的重要组成部分,针对英特尔多个 CPU 进行了高度优化,因此在英特尔硬件体系上,性能表现非常突出。SVT 设计最朴素的初衷,是针对现代 CPU 的多个核进行利用率方面的提升,比如依仗硬件上的多核设计并行对多个帧同时处理,或对一张图像分块进而并行处理,大大加快处理速度,避免多核 CPU 空转。

更为人所熟知的可能是后来这个叫做 SVT-AV1 的开源项目(GitHub 地址:https://github.com/AOMediaCodec/SVT-AV1),AV1 开源视频编码,由英特尔、谷歌、亚马逊、思科、苹果、微软等共同研发,目的是提供相比 H.265 更高效的压缩率,降低数据存储和网络传输的成本。

而就在今年上半年,英特尔发布了其用于 CPU 的开源编解码器 SVT-AV1 的 1.0 版,相比 0.8 版本,性能上有着巨大提升。

结束语

归根结底,尽管“摩尔定律”还在继续,但当下已过了靠吃“硬件红利”就能搞定新应用场景的“甜蜜期”。

今天,我们需要了解的是以 CPU 、GPU、加速器和 FPGA 等硬件为核心的复合架构,也被称之为由标量、矢量、矩阵、空间组成的 SVMS 架构。这一概念由英特尔率先提出,并迅速成为业内最主要的硬件架构策略。

位于硬件之上的开发者工具也存在同样的趋势,英特尔的 oneAPI 就是一个典型作品。只是对于开发者工具来说,目前最主要的工作不是性能提升,而是生态和整合。

从硬件到基础软件,再到开发者工具,整个基础设施层呈现高度复杂化的架构演进趋势,既是对架构师工作的严峻挑战,也给了所有架构师更大的发挥空间。对于架构师来说,如何为自己的企业算清楚成本,在追求高性能、高可用的同时,将硬件一并纳入考虑并高度重视,才是重中之重。