wenmo8 发布的文章

前一篇:《全面解释人工智能LLM模型的真实工作原理(一)》

序言:
在上一篇文章中,我们从原理上构建了一个识别“叶子”和“花朵”的神经网络,并详细讲解了它的工作过程。这包括对输入数字逐个与权重相乘后求和,加上偏置值,最后通过非线性处理和统计分布计算来得出输出。这些操作使用了简单的数学运算(乘法、加法和非线性处理)。本节的重点是解答神经网络的权重和偏置值是如何得到的以及最关键的概念:如何让神经网络输出chatGPT一样的句子。为了让神经网络学到合适的权重和偏置,我们需要提供大量的学习数据(如大量的“叶子”和“花朵”图片),让网络在学习过程中调整每个神经元的权重和偏置值,最终实现正确分类。(
请动一下您的小手,订阅作者!

如何训练这个神经网络(模型)?

在上例中,我们为了测试,给模型预设了合适的权重和偏置,这样才能得到准确的输出。但在实际应用中,权重和偏置值是如何获得的呢?获得合适的‘权重’和‘偏置’这个过程就称为“训练模型”或“训练神经网络”,也可以理解为“人工智能的自我学习”;没错,这个过程就是“训练AI”。人类需要做的就是为模型提供优质数据来进行训练。

假设我们收集了一些数据,包括各种类型的“叶子”和“花朵”。然后,我们用工具将它们的颜色和体积转换成数字,给每个数据样本贴上“叶子”或“花朵”的标签(给数据取名字就称为“标注数据”),最终这些数据组成了我们的“训练数据集”。

训练神经网络的工作原理如下:

  1. 初始化权重

首先,从随机数开始,将神经元的每个参数/权重设为一个随机数。(启动训练程序时,计算机内存中未初始化的都是随机数,一般无须特别设定)

  1. 输入数据并获得初始输出

我们给神经网络输入“叶子”的数据表示(如 R=32,G=107,B=56,Vol=11.2),期望输出层第一个神经元的值大于第二个神经元的值,表示识别出“叶子”。假如预期“叶子”神经元的值是0.8,代表“花”的神经元值是0.2。

  1. 计算损失

因为初始权重是随机的,实际输出往往和预期有差异。比如,两个神经元的初始输出分别是0.6和0.4。我们可以通过求差并将差值平方相加计算损失:(0.8 - 0.6)² + (0.2 - 0.4)² = 0.04 + 0.04 = 0.08。理想情况下,我们希望损失接近于零,也就是“最小化损失”。

  1. 计算梯度并更新权重

计算每个权重对损失的影响(称为梯度),看向哪个方向调整才能减少损失。梯度指示了每个参数的变化方向——权重会朝损失减少的方向略微调整一点。这个过程称为“梯度下降”。

  1. 重复迭代

持续重复这些步骤,通过不断更新权重,使得损失逐步减少,最终得到一组“训练好的”权重或参数。这就是神经网络的训练过程,称为“梯度下降”。

补充说明

• 多个训练样本

训练中通常会使用多个样本。微调权重以最小化某个样本的损失可能会导致其他样本的损失增大。为了解决这个问题,通常会计算所有样本的平均损失,并基于平均损失的梯度来更新权重。每次完整的样本循环称为“一个 epoch”,多个 epoch 的训练可以帮助逐步找到更优的权重。

• 自动计算梯度

实际上,无需手动微调权重来计算梯度,数学公式可以直接推导出每个参数的最佳调整方向。例如,如果上一步权重为 0.17,且神经元的输出希望增大,那么将权重调整为 0.18 可能更有效。

在实践中,训练深度网络是一个复杂的过程,训练中可能会遇到梯度失控的情况,例如梯度值趋于零或趋向无穷大,这分别称为“梯度消失”和“梯度爆炸”问题。虽然上述的损失定义有效,但在实际应用中,通常会使用更适合特定任务的损失函数来提高训练效果。

这些原理怎样帮助神经网络生成语言?

请记住,神经网络只能接收输入一组数字,基于训练好的参数进行数学运算,最后输出另一组数字。关键在于如何解释这些数字,并通过训练来自动调整参数。如果我们能够把两组数字解释为“叶子/花朵”或“一小时后是晴天或雨天”,同样也可以将它们解释为“句子的下一个字符”。

但是,英语字母远不止两个,所以我们需要将输出层的神经元数量扩展,例如扩展到26个以上的神经元(再加上一些符号,如空格、句号等)。每个神经元对应一个字母或符号,然后我们在输出层中找出数值最大的神经元,并将其对应的字符作为输出字符。现在我们就有了一个可以接收输入并输出字符的网络。

如果我们给神经网络输入“Humpty Dumpt”这个字符串,然后让它输出一个字符,并将其解释为“网络预测到的下一个字符”,我们可以通过训练,确保网络在收到这样的字符串“Humpty Dumpt”输入时输出字母“y”,从而达到我们想要的结果“Humpty Dumpty”。

不过,这里有一个问题:如何将字符串输入到网络中?毕竟,神经网络只接受数字!通常实践中我们可以通过“one-hot编码”或其他编码方法将字符串转换成数值数组,使其可以被神经网络理解和处理。

这里我们用一个最简单的解决方案来编码:直接为每个字符分配一个数字。例如,a=1,b=2,依此类推。现在我们可以输入“humpty dumpt”并训练网络输出“y”。网络的工作过程如下:

先在神经网络的输入层输入一串句子(字符串),它将会在输出层预测下一个字符。这样的方法可以帮助我们构建完整的句子。例如,当我们预测出“y”后,可以将这个“y”添加到前面输入的字符串尾部,并再次送回神经网络的输入层,让它预测下一个字符。如果训练得当,网络会预测出一个空格;如此循环下去,最终生成出完整的句子:“Humpty Dumpty sat on a wall”。这样,我们就得到了一个生成式 AI(人工智能语言模型),神经网络现在可以生成人类的自然语言了!

当然,在真实应用中例如chatGPT,我们不会使用这种简单的字符编号方法。在后文中,我们会介绍一种更合理的编码方式。如果你迫不及待,可以查看附录中的“编码”部分。

细心的读者可能会注意到,我们无法直接输入“Humpty Dumpty”,因为如图所示,输入层只有12个神经元,对应于“humpty dumpt”中的每个字符(包括空格),并没有多余的神经元留给字母‘y’输入了。那么,如何在下一步中加入“y”呢?如果在输入层加上第13个神经元,就需要重新调整整个网络,这显然不太现实。解决方案很简单:我们可以将最早的字符“h”剔除,保留最近的12个字符输入。例如,我们输入“umpty dumpty”,网络会预测出一个空格;然后我们输入“mpty dumpty ”,网络会输出“s”,如此循环下去,过程如下所示:

这种方法有个问题,即当我们输入“ sat on the wal”时,会丢失之前的许多信息。那么,现代顶尖神经网络是如何解决的呢?原理基本相似。神经网络的输入的长度是固定的(取决于输入层的大小),这种长度称为“上下文长度”,即网络用来预测后续内容的参考范围。现代网络的上下文长度可以很长(通常达到几万甚至几十万个字符。例如,ChatGPT的4o模型支持12.8万个字符,Claude则支持25.6万个字符。这意味着它们在输入层中使用了超过10万个神经元来接收用户的输入。试想一下,上千亿参数意味着有多少神经元在参与运算?),这对提升效果非常有帮助。尽管某些方法允许输入无限长度的序列,但固定上下文长度较大的模型在性能上已经优于这些方法。

细心的读者可能还会注意到,我们在输入和输出端对同一个字母的解释方式不同!例如,输入“h”时我们用数字8表示它,但在输出层,我们并不直接要求模型输出数字8来代表“h”,而是生成26个数值,并选择其中最大值对应的字母作为输出。如果第8个数值最大,我们将其解释为“h”。为什么不在两端使用相同的表示方式呢?事实上,这是为了构建更有效的模型——不同的输入和输出解释方式为模型性能的提升提供了更多可能。实践表明,这种不同的表示方式对语言生成更有效。实际上,我们在输入端的数字表示方式也并非最佳,稍后会介绍更优的方法。

本节是搞明白chatGPT输出人类自然语言句子的核心原理,希望感兴趣的朋友如果没有搞明白,多读几篇或者在评论区留言与作者交流,我会毫无遗漏的回答所有的评论。

未完待续…

最近,在使用
Rust
时遇到了
Reborrow
的概念,记录下来以备以后参考。

1. 起因

起因准备对数据进行
Min-Max
标准化处理,也就是将一系列数据映射到一个新的范围。

首先,需要遍历数据,找出其中的
最大值

最小值
,然后通过公式改变原始数据集的值。

Min-Max
公式:标准化后的值 = (原始值 - 最小值) / (最大值 - 最小值)

简化后的代码如下:

fn main() {
    let mut values = vec![10.5, 22.3, 103.5, 45.75];
    let v = &mut values;
    println!("原始数据: {:#?}", v);

    let mut max = f64::MIN;
    let mut min = f64::MAX;

    for n in v {
        if *n > max {
            max = *n;
        }
        if *n < min {
            min = *n;
        }
    }

    println!("max is {}", max);
    println!("min is {}", min);

    println!("开始Min-Max标准化处理...");
    for n in v {
        *n = (*n - min) / (max - min);
    }

    println!("处理后数据: {:#?}", values);
}

运行时有如下错误:

error[E0382]: use of moved value: `v`                                                                     
   --> src/main.rs:22:14
    |
3   |     let v = &mut values;
    |         - move occurs because `v` has type `&mut Vec<f64>`, which does not implement the `Copy` trai
t
...
9   |     for n in v {
    |              - `v` moved due to this implicit call to `.into_iter()`
...
22  |     for n in v {
    |              ^ value used here after move
    |

大概是
第9行
遍历
v
的找出最大值和最小值时候,
可变借用v
的使用权已经转移了,

所以在
第22行

再次遍历v
去修改值的时候,出现错误。

这里,因为
Vector
没有实现
Copy Trait
,所以它的可变借用在第一次遍历时,由于隐式的调用了
.into_iter()
,所有权发生了转移。

如果想多次遍历
Vector
,可以使用它的不可变借用,比如定义
let v = &values;

那么,就可以多次遍历
v
,因为不可变借用都实现了
Copy Trait

但是,我第二次遍历
v
的时候,还需要修改其中的值,所以必须定义为可变借用
let v = &mut values;

通过查询资料,发现
Reborrow
的机制可以实现上面的需求。

2. Reborrow概念

借用(
Borrow
)是
Rust
中的一个重要概念,它是允许代码访问某个值而不获取其所有权的一种机制。


Reborrow
则是指在一个已存在的借用基础上创建一个新的借用,

这个新的借用可以是不可变的,也可以是可变的(前提是原始借用是可变的,并且没有其他借用存在)。

总的来说,
Reborrow
通过在已存在的借用上创建新的借用,从而扩展引用的生命周期并在更广泛的作用域内安全地访问值。

3. 解决方法

下面通过实践来检验对
Reborrow
概念的理解。

回到第一节中遇到的问题,解决方式就是在第一次遍历
v
时(
第9行
),不要把所有权转移出去,

这样,第二次遍历
v

第22行
)的时候,就不会报出
"value used here after move"
的错误。

根据
Reborrow
的机制,我们在
第9行
可以
Reborrow
可变借用
v
,这样转移出去的是被再次借用的
v
,而不是
v
本身。

改变方法很简单,
第9行
改为
for n in &*v {
即可,也就是先
还原v
(
*v
),然后
Reborrow
(
&*v
)。

修改后再次运行代码:

$  cargo run

原始数据: [
    10.5,
    22.3,
    103.5,
    45.75,
]
max is 103.5
min is 10.5
开始Min-Max标准化处理...
处理后数据: [
    0.0,
    0.12688172043010754,
    1.0,
    0.3790322580645161,
]

values
中的数据可以正常转换了。

注意,这里是将
v
Reborrow成一个不可变借用
&*v
,因为我第一次遍历时不需要改变
v

如果想
v
Reborrow成一个可变借用,可以写成:
&mut *v

buck电路

buck电路是直流的降压电路,我们下面给大家讲下,如何把12V的直流电压降压成5V的直流电压

1、buck电路拓扑:12V----->5V

2、降压原理

a、开关闭合,电流走向

电源的正极---->开关---->电感----->(电容和负载)----->电源负极

这里由于二极管是单相导通的,所以此时二极管是没有电流通过的

大家要注意,此时的电流也会给电容充电

这里最重要是电感,由于电感上的电流是不能突变的,所有流经电感的电流是慢慢增大,通过欧姆定律:电压=电阻 * 电流,所以负载电压是慢慢增大的。

当负载的电压超过5V的时候,此时我们会断开开关

b、开关断开,电流分析

断开开关,流过电感的电流会突然变小,由于电感的特性,流经电感的电流是不能突变的,所以此时电感上是 左负右正。在电感上的电压从左正右负变为左负右正的瞬间,电路中是没有电流的,所以此时的一瞬间,给负载供电的电容放电给负载供电

电感的正极---->(负载,电容)---->二极管---->电感的负极

电感放电的瞬间,电流是很大的,虽然电感的磁能转换电能,磁能越来越小,电能也越来越小,电流也越来越小,通过欧姆定律:电压=电阻 * 电流,所以负载电压是慢慢减小的,当减少到一定的程度,我们再次闭合开关。闭合的瞬间,电感的极性会变化,这个变化的一瞬间,又是由电容给负载供电

所以整个周期负载的电压变化是下面这样的,有效值大概就是在5V左右

我们是怎么控制输出的电压是5V的,这里就需要引入另外一个概念,占空比

占空比=输出电压/输入电压

占空比=5/12,也就是5/12的时间开关是闭合的,7/12的时间是开关的断开。这里的时间是很短的,以微妙为单位,用普通的开关是不行的,我们一般用MOS管,IGBT等代替

boost电路

1、boost电流拓扑:5V---->12V

boost电路是直流的升压压电路,我们下面给大家讲下,如何把5V的直流电压升压成12V的直流电压

2、升压原理

a、开关闭合,电流走向

开关闭合后,相当于短路,所以右半部分相当于短路状态,此时电流流过电感,形成左正右负的电源,电能转换为磁能

b、开关断开,电流走线

开关断开,由于阻抗大于开关闭合的时候,所以流过电感的电流会越来越小,电感为了阻止电流变小,会形成左负右正的电压,此时就和电源串联,使得最终输出的电压是大于5V的,这里就是boost电流升压的核心

c、开关再次闭合,给负载供电的电容放电,虽然电容的放电,电压会越来越小,当小于12V的时候,立刻断开

d、最终实现升压,最终的输出电压和什么有关系呢?又是占空比:D

Vout=Vin/(1-D),可以得出,如果要升压到12V,则占空比D=7/12,所以开关闭合的时间站7/12,开关断开的时间占5/12

最终实现输出的电压在12V上下波动,有效值就是12V

大家好,我是 V 哥。今天的文章来聊一聊HarmonyOS NEXT应用上架。当你开发、调试完HarmonyOS应用/元服务,就可以前往AppGallery Connect申请上架,华为审核通过后,用户即可在华为应用市场获取您的HarmonyOS应用/元服务。

HarmonyOS会通过数字证书与Profile文件等签名信息来保证应用的完整性,需要上架的HarmonyOS应用/元服务都必须通过签名校验,所以上架前,您需要先完成签名操作。

1.生成密钥和证书请求文件

  1. 打开DevEco Studio,菜单选择“Build > Generate Key and CSR”。
  2. Key Store File可以点击“Choose Existing”选择已有的密钥库文件(存储有密钥的.p12文件),跳转至步骤4继续配置;如果没有密钥库文件,点击“New”,跳转至步骤3进行创建。
  3. 在“Create Key Store”界面,填写密钥库信息后,点击“OK”。
  • Key Store File:设置密钥库文件存储路径,并填写p12文件名。
  • Password:设置密钥库密码,必须由大写字母、小写字母、数字和特殊符号中的两种以上字符的组合,长度至少为8位。请记住该密码,后续签名配置需要使用。
  • Confirm Password:再次输入密钥库密码。
  1. 在“Generate Key and CSR”界面继续填写密钥信息后,点击“Next”。
  • Alias:密钥的别名信息,用于标识密钥名称。请记住该别名,后续签名配置需要使用。
  • Password:密钥对应的密码,与密钥库密码保持一致,无需手动输入。
  • Validity:证书有效期,建议设置为25年及以上,覆盖元服务的完整生命周期。
  • Certificate:输入证书基本信息,如组织、城市或地区、国家码等。
  1. 在“Generate Key and CSR”界面设置CSR文件存储路径和CSR文件名,点击“Finish”。
  2. CSR文件创建成功后,将在存储路径下获取生成密钥库文件(.p12)和证书请求文件(.csr)。

2.申请发布证书

  1. 登录
    AppGallery Connect
    ,选择“用户与访问”。
  2. 左侧导航栏选择“证书管理”,进入“证书管理”页面,点击“新增证书”。

  1. 在弹出“新增证书”界面填写相关信息后,点击“提交”。

  1. 证书申请成功后,“证书管理”页面展示生成的证书内容。
  • 点击“下载”将生成的证书保存至本地。
  • 每个帐号最多申请1个发布证书,如果证书已过期或者无需使用,点击“废除”即可删除证书。

3.申请发布Profile

  1. 登录
    AppGallery Connect
    ,选择“我的项目”。
  2. 找到对应项目,点击项目卡片中需要发布的元服务。
  3. 导航选择“HarmonyOS应用 > HAP Provision Profile管理”,进入“管理HAP Provision Profile”页面,点击“添加”。

  1. 在“HarmonyAppProvision信息”界面填写相关信息,点击“提交”。

  1. 申请成功,即可在“管理HAP Provision Profile”页面查看Profile信息。点击“下载”,将文件下载到本地。

4.配置签名信息

  1. 打开DevEco Studio,菜单选择“File > Project Structure”,进入“Project Structure”界面。
  2. 导航选择“Project”,点击“Signing Configs”页签,填写相关信息后,点击“OK”。
    • Store File:密钥库文件,选择生成密钥和证书请求文件时生成的.p12文件。
    • Store Password:密钥库密码,需要与生成密钥和证书请求文件时设置的密钥库密码保持一致。
    • Key alias:密钥的别名信息,需要与生成密钥和证书请求文件时设置的别名保持一致。
    • Key password:密钥的密码,需要与生成密钥和证书请求文件时设置的密码保持一致。
    • Sign alg:固定设置为“SHA256withECDSA”。
    • Profile file:选择申请发布Profile时下载的.p7b文件。
    • Certpath file:选择申请发布Profile时下载的.cer文件。

5.编译打包

  1. 打开DevEco Studio,菜单选择“Build > Build Hap(s)/APP(s) > Build APP(s)”。
  2. 等待编译构建签名的HarmonyOS应用/元服务,编译完成后,可在工程目录build > outputs > default目录下获取用于上架的软件包。

6.上架HarmonyOS应用/元服务

  1. 登录
    AppGallery Connect
    ,选择“我的应用”。

  2. 在应用列表首页中点击“HarmonyOS”页签。

  3. 点击待发布的应用/元服务,在左侧导航栏选择“应用信息”菜单。

  4. 填写应用的基本信息,如语言,应用名称,应用介绍等,上传应用图标,所有配置完成后点击“保存”。

  5. 填写版本信息,如发布国家或地区、上传软件包、提交资质材料等,所有配置完成后点击右上角“提交审核”。等待审核结果就可以了。

在早期阶段,vivo AI 计算平台使用 GlusterFS 作为底层存储基座。随着数据规模的扩大和多种业务场景的接入,开始出现性能、维护等问题。为此,vivo 转而采用了自研的轩辕文件系统,该系统是基于 JuiceFS 开源版本开发的一款分布式文件存储方案。

本文将介绍 vivo 轩辕文件系统在 JuiceFS 基础之上开发的新特性。以及 vivo 针对一些关键场景,如样本数据读取速度慢和检查点写入环节的优化措施。此外,文章还将介绍 vivo 的技术规划包括 FUSE、 元数据引擎及 RDMA 通信等方面,希望能为在大规模 AI 场景使用 JuiceFS 的用户提供参考与启发。01 计算平台引入轩辕文件存储的背景

01 计算平台引入轩辕文件存储的背景

最初,vivo 的 AI 计算平台 使用 GlusterFS ,并由该团队自行维护。在使用过程中,团队遇到了一些问题。一是处理小文件时速度变得非常缓慢;二是当需要对 GlusterFS 进行机器扩容和数据平衡时,对业务产生了较大的影响。

随后,由于早期集群容量已满且未进行扩容,计算团队选择搭建了新的集群。然而,这导致了多个集群需要维护,从而增加了管理的复杂度。此外,作为平台方,他们在存储方面的投入人力有限,因此难以进行新特性开发。

他们了解到我们互联网部门正在研发文件存储解决方案,经过深入交流和测试。最终,他们决定将其数据存储迁移至我们的轩辕文件存储系统。

轩辕文件系统基于 JuiceFS 开源版,进行了二次开发,支持多种标准访问协议,包括 POSIX、HDFS 以及 Windows 上的 CIFS 协议。此外,我们还提供了文件恢复功能,该功能参考了商用解决方案,能够按照原路径进行数据恢复。

同时,我们的系统支持客户端热升级,这一功能在开源版本中也已经实现。另外,我们还支持用户名权限管理,默认使用本地 uid/gid 进行鉴权。在此基础上,我们还参考 JuiceFS 企业版实现了用户名鉴权功能。

下图是轩辕文件系统的架构图,与 JuiceFS 类似。在底层基座方面,我们使用 TikV 存储元数据,而数据则存储在我们自研的对象存储系统中。
特别值得一提的是,在 Windows 场景下,我们在 Samba 中开发了一个插件,该插件直接调用 JuiceFS API,从而为用户提供了一个在 Windows 上访问我们文件存储的通道

目前的 AI 计算平台存储流程如下:首先获取原始数据并通过一个包含 4 万个批处理任务的系统进行处理,生成样本库。这些样本库随后在 GPU 上训练,产生模型文件,这些模型文件被传输至在线系统用于推理。原始数据及处理后的样本库直接存储在轩辕文件系统中,由于其兼容 HDFS API,Spark 可以直接处理这些数据。模型文件也保存在轩辕中,并通过其提供的CSI插件,使在线推理系统能直接挂载并读取这些文件。

02 存储性能优化

训练阶段涉及存储的主要有两个重要方面:样本读和训练过程中的检查点( checkpoint) 保存。

环节1:加速样本读

为了提升样本加载的速度,我们开发了一个分布式读缓存层。在训练模型前,我们借助JuiceFS 提供的 warm up 功能,优先将本次训练所需的数据预加载至读缓存层。通过这种方式,训练数据可以直接从读缓存层获取,而无需从对象存储系统中拉取。通常情况下,直接从对象存储中读取数据需要花费十几至几十毫秒,但通过读缓存层则可将读取时间缩短至 10 毫秒以内,从而进显著提高了数据加载到 GPU的 速度。

环节2:检查点 (Checkpoint) 写入

在检查点写入方面,我们参考了
百度的方案
。具体而言,检查点数据首先被写入一个临时缓存区域(我们称之为“协管”区域,但此处可能指的是某种形式的中间缓存或暂存区),然后再逐步刷新到对象存储中。在这个过程中,我们也采用了单副本模式,因为检查点本身就是每隔一段时间保存的,即使某个时间段的检查点丢失,对整体训练的影响也是有限的。当然,我们也制定了一些策略来确保关键数据的安全性,并非所有数据都会进入这个中间缓存区域。通常,只有检查点文件和训练阶段的日志文件会被写入。如果训练中断,检查点文件可以从这个中间缓存区域中读取。

此外,当数据被写入并刷新到对象存储中时,我们并不会立即从检查点缓存中清除这些数据。因为训练过程中随时可能中断,如果此时检查点缓存中的数据被清除,而需要从对象存储中重新拉取,将会耗费较长时间。因此,我们设置了一个 TTL(生存时间)机制。例如,如果检查点数据每小时刷新一次到对象存储中,我们可以将 TTL 设置为 1.5 小时。这样,即使训练中断,我们也能确保检查点缓存中有一个最新的备份可供使用。

在开发写缓存的过程中,我们遇到了一个挑战。由于我们的客户端与写缓存之间的通信采用 gRPC 协议,该协议在数据反序列化时会重新申请内存以存储解析后的数据。在特定时间段内,如果写操作非常集中(例如在几十秒内),会导致大量的内存申请和释放。由于我们使用的是 Go 语言开发,其垃圾回收(GC)机制在这种情况下表现较慢,可能会导致写缓存的内存耗尽。

为了解决这个问题,我们调研了其他数据反序列化的方案。最终,我们采用了 Facebook 的 flatterbuffer 方案。与 gRPC 的 Pb 反序列化不同,flatterbuffer 在反序列化后可以直接使用数据,无需额外的解析步骤。通过这种方式,我们减少了内存的使用,与 Pb 相比,内存节省达到了 50%。同时,我们也对写性能进行了测试,发现使用 flatterbuffer 后,写性能提升了20%

环节3:在线推理,模型加载流量大

在用户进行在线推理时,我们注意到模型下载产生的流量极大,有时甚至会占满对象存储网关的带宽。深入分析这个场景后,我们发现存在众多实例,每个实例都会独立地将完整模型加载到内存中,并且这些实例几乎是同时开始加载模型的,这一行为造成了巨大的流量压力。

为解决此问题,我们借鉴了商业解决方案,采用了在 Pod 中实施逻辑分组的方法。在这种策略下,每个分组仅从底层存储读取一份完整模型,而分组内的各个节点则读取模型的部分文件,并通过节点间的数据共享(类似于 P2P 方式)来减少总体流量需求。这种方法显著降低了对底层对象存储带宽的占用,有效缓解了流量压力。

03 技术规划

libc 调用绕过 FUSE 内核,提升读写性能 下面这份图表来源于 ACM 期刊中的一篇论文。文中指出,在使用 FUSE 挂载时,请求的处理流程会先从用户态转移到内核态,然后再返回用户态。在这个流程中,上下文切换所带来的消耗是相当巨大的。

柱状图较高的部分代表原生的 FUSE,而柱状图较低的部分则代表经过优化的方案。

  • 小文件场景:原生的 FUSE 相较于优化方案,其上下文次数切换的数量差距达到了 1000 倍;
  • 大文件场景:原生的 FUSE 与优化方案之间的上下文次数切换的数量差距约为 100 倍;
  • 混合负载场景:同样显示出了巨大的上下文次数切换的数量差异。

在论文中提到,链路消耗的主要来源是上下文切换。因此,我们计划在 FUSE 这一层进行优化,主要针对元数据和小文件场景。目前,我们正在进行方案选型工作。

自研元数据引擎,文件语义下沉

我们还计划开发一个自己的元数据引擎。当前,我们使用的元数据引擎是基于 TiKV 的,但 TiKV 并不具备文件语义,所有的文件语义都是在客户端实现的。这给我们的特性开发工作带来了极大的不便。

同时,当多个节点同时写入一个 key 时,事务冲突也会非常频繁。近期,我们还遇到了进程会突然卡住的问题,持续时间从几分钟到十几分钟不等。这个问题一直未能得到解决。

另外,TiKV PD 组件为主节点 Active 模式,请求上 10 万后,时延上升明显,PD 节点(112核)CPU 使用率接近饱和。因此,我们正在尝试一些方案来降低主节点的 CPU 利用率,以观察是否能改善耗时问题。我们参考了一些论文,如
百度的 CFS 论文
,将所有的元数据操作尽量变成单机事务,以减少分布式事务的开销。

缓存层实现 RDMA

通信关于我们机房的 GPU 节点,它们目前使用的是 RDMA 网络。与缓存层的通信仍然使用 TCP 协议。我们有规划开发一个基于 RDMA 的通信方式,以实现客户端与缓存之间的低延迟、低 CPU 消耗的通信。

通过观察客户端的火焰图,我们发现 RPC 通信的耗时仍然非常明显。虽然写缓存的处理数据只需要一两毫秒,但客户端将数据上传到整个链路的耗时可能达到五六毫秒,甚至十毫秒。在客户端 CPU 非常繁忙的情况下,这个时间可能会达到二三十毫秒。而 RDMA 本身并不怎么消耗 CPU,内存消耗也比较少,因此我们认为这是一个值得尝试的解决方案。