普通视图

发现新文章,点击刷新页面。
昨天以前微言 | wyanassert

记一次 scheme 解析参数问题

作者 wyanassert
2025年2月20日 11:28

最近做需求时候, 遇到一个后端联调问题, 需求是 后端A 把一个 json 转成 string 后放到 scheme 的payload参数中, json 如下, text1 是一个字符串,

1
2
3
4
{
"text1": "今天是个好日子",
"trackID1": 123456
}

转换为字符串payload后变成

1
{\"text1\":\"今天是个好日子\",\"trackID1\":123456}

scheme 大致如下:

1
scheme://test/ui/func?p={"hintPayload":"{\"text1\":\"今天是个好日子\",\"trackID1\":123456}","param2":"9"}

然后对 scheme 转义, 下发给客户端, 客户端在解析 scheme 后, 获取各个参数, 然后把payload 字符串给另一个业务后端B.
自测阶段没什么问题, 但是测试发现text1中有换行符以及"时候就不行了, 客户端问题就出在解析 scheme 时候, 会对参数做一次 string 转 json 处理, 这次处理失败了.

报错如下

1
2
3
parse json error(Error Domain=NSCocoaErrorDomain Code=3840 "Unescaped control 
character around line 1, column 42." UserInfo={NSDebugDescription=Unescaped
control character around line 1, column 42., NSJSONSerializationErrorIndex=42}),

意思是text1的第 42 个字符有问题, (注意这里是从 0 开始数的, 但是把有问题的字符串贴到编辑器, 编辑器大多是从 1 开始数的)

客户端 scheme 解析出错

找到有问题的字符是 %0A, 换行符, 嗯, easy, 让 后端A 把 %0A 换成 \\n 下发就好, 经过 scheme 反转义后会变成 \n
很快遇到第二个问题, " 也有问题, 行, 再让 后端A 换成\\"下发, 经过 scheme 反转义后变成\", 客户端终于能解析 scheme 了.

注: iOS 解析 %0A 以及 " 出错, Android 解析 " 出错

后台解析也出错

然后就遇到第三个问题, 当把从 scheme 里面解析出来的payload 给另一个业务 后端B 时候, 转json 就失败了,
第一个问题是 字符串里直接是 \n 后端解析不了,
第二个问题是 \"后端也解析不了
这里尝试客户端做下兼容, \n 换成 \\n, easy,,,
但是 \"处理就麻烦了, payload字符串如下,

1
{     \"text1\": \"今天是个好日子\"有引号\",      \"trackID1\": 123456 }

text1 中有\", 参数 text1/trackID1后面也有\", 直接对字符串处理肯定是不行的
那还得先把 payload 转成 json, 再对其中的值做 \"的转换, 最后再把 json 转成字符串给到后端B.
疯了吧

最后回想下上面,

  1. 后端A 做了两个字符串的处理,
  2. 客户端(iOS / Anrdoid) 两端都也要处理,
  3. 本不该客户端理解的 payload 字段客户端也得做一次 json 化, 然后处理其中的值,
  4. 还有其他字符有问题吗???

怎么看这个思路都有大问题

追溯源头

回到最开始的问题, 客户端居然在 scheme 中的参数解析出了 %0A, 百分号字符是 URL 转义的, 客户端在对 scheme 做了反转义之后, 根本不应该还有 %0A呀? 后端A 下发的 scheme 有问题, 问后端 A scheme 是怎么拼出来的, 答曰: 前端提供的, 直接替换了某些字段, 就转义然后给了客户端.
前端提供的 scheme 如下:

1
scheme://test/ui/func?p={"hintPayload":"{\"text1\":\"text1Value\",\"trackID1\":trackID1Value}","param2":"9"}

后端A 使用上面的 scheme 直接把 text1Value 以及trackID1Value 换成了需要的值, 然后转义下就给了客户端. 所以 text1Value 中的特殊字符根本没有被处理过, 直接一起被转义了, 导致了前面一串的问题.

解决

解决问题总是很无聊的, 后台对 scheme 的处理改为

  1. 使用需要的值, 构造了 json
  2. json 转为字符串
  3. 字符串放在了 scheme 后面的参数中
  4. 转义给了客户端

好像一开始就应该这么做吧? 这反应一个普遍问题, scheme 是前端和客户端的交互的协议, 后端一般不愿意去理解, 最后在实践中也的确没有去理解, 直接通过字符串替换的方式完成了需求, 最后导致了联调问题.

[转载] 微信 SQLite 数据库修复实践

作者 wyanassert
2025年1月22日 17:11

原文地址

1、前言

众所周知,微信在后台服务器不保存聊天记录,微信在移动客户端所有的聊天记录都存储在一个 SQLite 数据库中,一旦这个数据库损坏,将会丢失用户多年的聊天记录。而我们监控到现网的损坏率是0.02%,也就是每 1w 个用户就有 2 个会遇到数据库损坏。考虑到微信这么庞大的用户基数,这个损坏率就很严重了。更严重的是我们用的官方修复算法,修复成功率只有 30%。损坏率高,修复率低,这两个问题都需要我们着手解决。

2、SQLite 损坏原因及其优化

我们首先来看 SQLite 损坏的原因,SQLite官网上列出以下几点:

  • 文件错写
  • 文件锁 bug
  • 文件 sync 失败
  • 设备损坏
  • 内存覆盖
  • 操作系统 bug
  • SQLite bug

但是我们通过收集到的大量案例和日志,分析出实际上移动端数据库损坏的真正原因其实就3个:

  • 空间不足
  • 设备断电
  • 文件 sync 失败

我们需要针对这些原因一一进行优化。

2.1、优化空间占用

首先我们来优化微信的空间占用问题。在这之前微信的部分业务也做了空间清理,例如朋友圈会自动删除7天前缓存的图片。但是总的来说对文件空间的使用缺乏一个全局把控,全靠各个业务自觉。我们需要做得更积极主动,要让开发人员意识到用户的存储空间是宝贵的。我们采取以下措施:

  • 业务文件先申请后使用,如果某个文件没有申请就使用了,会被自动扫描出来并删除;
  • 每个业务文件都要申明有效期,是一天、一个星期、一个月还是永久存储;
  • 过期文件会被自动清理。

对于微信之外的空间占用,例如相册、视频、其他App的空间占用,微信本身是做不了什么事情的,我们可以提示用户进行空间清理:

2.2、优化文件 sync

2.2.1、synchronous = FULL

设置SQLite的文件同步机制为全同步,亦即要求每个事物的写操作是真的flush到文件里去。

2.2.1、fullfsync = 1

通过与苹果工程师的交流,我们发现在 iOS 平台下还有 fullfsync 这个选项,可以严格保证写入顺序跟提交顺序一致。设备开发商为了测评数据好看,往往会对提交的数据进行重排,再统一写入,亦即写入顺序跟App提交的顺序不一致。在某些情况下,例如断电,就可能导致写入文件不一致的情况,导致文件损坏。

2.3、优化效果

多管齐下之后,我们成功将损坏率降低了一半多;DB损坏还是无法完全避免,我们还是得提高修复成功率。

3、SQLite 修复逻辑优化

3.1、master 表

首先我们来看 SQLite 的架构。SQLite 使用 B+树 存储一个表,整个 SQLite 数据库就是这些 B+树 组成的森林。对于每个表的元数据(表名、根节点地址、表 scheme 等),都记录在一个叫 sql_master 的表中。这个 sql_master 表(下简称 master 表) 本身也是一个 B+树 存储的普通表。

3.2、官方修复算法率低下原因

官方修复算法是这样一个流程:从 master 表中读出一个个表的信息,根据根节点地址和创表语句来 select 出表里的数据,能 select 多少是多少,然后插入到一个新 DB 中。要注意的是 master 表他本身也是一个 B+树 形式的普通表,DB 第0页就是他的根节点。那么只要 master 表某个节点损坏,这个节点下面记录的表就都恢复不了。更坏的情况是 DB 第0页损坏,那么整个 master 表都读不出来,就导致整个DB都恢复失败。这就是官方修复算法成功率这么低的原因,太依赖 master 表了。

3.3、备份 master 表

那么最自然的想法,自然是另外备份一份 master 表了,也不需要用B+树,直接用数组序列化存储就好。我们只需要每隔一段时间轮询 master 表,看看最近有没有增删 table,有的话就全量备份。

3.3.1、备份时机

这里有个担忧,就是普通数据表的插入会不会导致表的根节点发生变化,也就是说 master 表会不会频繁变化,如果变化很频繁的话,我们就不能简单地进行轮询方案了。通过分析源码,我们发现 SQLite 里面 B+树 算法的实现是 向下分裂 的,也就是说当一个叶子页满了需要分裂时,原来的叶子页会成为内部节点,然后新申请两个页作为他的叶子页。这就保证了根节点一旦定下来,是再也不会变动的。实际的代码调试也证实了我们这个推论。所以说 master 表只会在新创建表或者删除一个表时才会发生变化,我们完全可以采用定时轮询方案。

3.3.2、备份文件有效性

接下来的难题是既然 DB 可以损坏,那么这个备份文件也会损坏,怎么办呢?我们采用了 双备份 的机制。具体来说就是会有新旧两个备份文件,每个文件头都加上 CRC 校验;每次备份时,从两个备份文件中选出一个进行覆盖。具体怎么选呢?优先选损坏那个备份文件,如果两个都有效,那么就选相对较旧的。这就保证了即使本次写入导致文件损坏,还有另外一份备份可以用。这个做法跟 Realm 标榜的 MVCC(多版本并发控制)的做法有异曲同工之妙,相当于确认新写入的文件有效之后,才使用新写入的文件,否则还是继续用旧的有效的文件。

前面提到 DB 损坏的一个常见场景是空间不足,这种情况下还要分配文件空间给备份文件也是会失败的。为了解决这个问题,我们采取 预先分配空间 的做法,初始值是 32K,大约可存 750 个表的元信息,后续则按照32K的倍数进行增长。

3.4、优化效果

通过备份 master 表,我们成功将修复成功率提高了一倍多。

4、其他

通过这些优化,我们提高了微信聊天记录存储的可靠性。这些优化实践,会同之前在并发性能方面的优化实践(微信iOS SQLite源码优化实践),将会合并到微信即将开源的 WCDB(WeChat Database)组件中。我们正在进行紧张的代码整理工作,争取在 2017 年年中开源 WCDB。

[转载] Matrix SQLiteLint -- SQLite 使用质量检测

作者 wyanassert
2025年1月22日 17:11

原文地址

前言

Matrix 是微信终端自研和正在使用的一套 APM(应用性能管理)系统。

SQLite 在移动端开发中广泛使用,其使用质量直接影响到产品的体验。微信是个重度使用 SQLite 的应用,相关的质量检测也是质量监控体系中不可忽视的一部分。  

常见的 SQLite 质量监控一般都是依赖上线后反馈的机制,比如耗时监控或者用户反馈。这种方式问题是:事后发现,负面影响已经发生

关注的只是没这么差。eg. 监控阈值为 500ms ,那么一条可优化为 20ms 而平均耗时只有 490ms 的 sql 就被忽略了。

能否在上线前就进行SQLite使用质量的监控?于是我们尝试开发了一个工具: SQLiteLint。虽然名带 “lint “,但并不是代码的静态检查,而是在 APP 运行时对 sql 语句、执行序列、表信息等进行分析检测。而和 “lint” 有点类似的是:在开发阶段就介入,并运用一些最佳实践的规则来检测,从而发现潜在的、可疑的 SQLite 使用问题。

本文会介绍 SQLiteLint 的思路,也算是 SQLite 使用经验的分享,希望对大家有所帮助。

简述

SQLiteLint 在 APP 运行时进行检测,而且大部分检测算法与数据量无关即不依赖线上的数据状态。只要你触发了某条 sql 语句的执行,SQLiteLint 就会帮助你 review 这条语句是否写得有问题。而这在开发、测试或者灰度阶段就可以进行。

检测流程十分简单:

descript

  1. 收集 APP 运行时的 sql 执行信息 包括执行语句、创建的表信息等。其中表相关信息可以通过 pragma 命令得到。对于执行语句,有两种情况:
        a)DB 框架提供了回调接口。比如微信使用的是 WCDB,很容易就可以通过MMDataBase.setSQLiteTrace 注册回调拿到这些信息。
        b)若使用 Android 默认的 DB 框架,SQLiteLint 提供了一种无侵入的获取到执行的sql语句及耗时等信息的方式。通过hook的技巧,向 SQLite3 C 层的   api sqlite3_profile 方法注册回调,也能拿到分析所需的信息,从而无需开发者额外的打点统计代码。
  2. 预处理 包括生成对应的 sql 语法树,生成不带实参的 sql ,判断是否 select* 语句等,为后面的分析做准备。预处理和后面的算法调度都在一个单独的处理线程。
  3. 调度具体检测算法执行 checker 就是各种检测算法,也支持扩展。并且检测算法都是以 C++ 实现,方便支持多平台。而调度的时机包括:最近未分析 sql 语句调度,抽样调度,初始化调度,每条 sql 语句调度。
  4. 发布问题 上报问题或者弹框提示。

可以看到重点在第 3 步,下面具体讨论下 SQLiteLint 目前所关注的质量问题检测。

检测问题简介

一、检测索引使用问题

索引的使用问题是数据库最常见的问题,也是最直接影响性能的问题。SQLiteLint 的分析主要基于 SQLite3 的 “explain query plan” ,即 sql 的查询计划。先简单说下查询计划的最常见的几个关键字:


SCAN TABLE: 全表扫描,遍历数据表查找结果集,复杂度 O(n)
SEARCH TABLE: 利用索引查找,一般除了 without rowid 表或覆盖索引等,会对索引树先一次 Binary Search 找到 rowid ,然后根据得到 rowid 去数据表做一次 Binary Search 得到目标结果集,复杂度为 O(logn)
USE TEMP B-TREE: 对结果集临时建树排序,额外需要空间和时间。比如有 Order By 关键字,就有可能出现这样查询计划,


通过分析查询计划,SQLiteLint 目前主要检查以下几个索引问题:

1. 未建索引导致的全表扫描(对应查询计划的 SCAN TABLE… )

虽然建立索引是最基本优化技巧,但实际开发中,很多同学因为意识不够或者需求太紧急,而疏漏了建立合适的索引,SQLiteLint 帮助提醒这种疏漏。问题虽小,解决也简单,但最普遍存在。

这里也顺带讨论下一般不适合建立索引的情况:写多读少以及表行数很小。但对于客户端而言,写多读少的表应该不常见。而表行数很小的情况,建索引是有可能导致查询更慢的(因为索引的载入需要的时间可能大过全表扫描了),但是这个差别是微乎其微的。所以这里认为一般情况下,客户端的查询还是尽量使用索引优化,如果确定预估表数量很小或者写多读少,也可以将这个表加到不检测的白名单。

解决这类问题,当然是建立对应的索引。

2. 索引未生效导致的全表扫描(对应查询计划的 SCAN TABLE… )

有些情况即便建立了索引,但依然可能不生效,而这种情况有时候是可以通过优化 sql 语句去用上索引的。举个例子:

descript

以上看到,即便已建立了索引,但实际没有使用索引来查询。

如对于这个 case ,可以把 like 变成不等式的比较:

descript

这里看到已经是使用索引来 SEARCH TABLE,避免了全表扫描。但值得注意的是并不是所有 like 的情况都可以这样优化,如 like '%lo'like '%lo%' ,不等式就做不到了。

再看个位操作导致索引不生效的例子:

descript

位操作是最常见的导致索引不生效的语句之一。但有些时候也是有些技巧的利用上索引的,假如这个
case 里 flag 的业务取值只有 0x1,0x2,0x4,0x8,那么这条语句就可以通过穷举值的方式等效:

descript

以上看到,把位操作转成 in 穷举就能利用索引了。

解决这类索引未生效导致的全表扫描的问题,需要结合实际业务好好优化sql语句,甚至使用一些比较trick的技巧。也有可能没办法优化,这时需要添加到白名单。

3. 不必要的临时建树排序(对应查询计划的 USE TEMP B-TREE… )。

比如sql语句中 order bydistinctgroup by 等就有可能引起对结果集临时额外建树排序,当然很多情况都是可以通过建立恰当的索引去优化的。举个例子:

descript

以上看到,即便id和mark都分别建立了索引,即便只需要一行结果,依然会引起重新建树排序(USE TEMP B-TREE FOR ORDER BY )。当然这个case非常简单,不过如果对 SQLite 的索引不熟悉或者开发时松懈了,确实很容易发生这样的问题。同样这个问题也很容易优化:

descript

这样就避免了重新建树排序,这对于数据量大的表查询,优化效果是立竿见影的好。

解决这类问题,一般就是建立合适的索引。

4. 不足够的索引组合

这个主要指已经建立了索引,但索引组合的列并没有覆盖足够 where 子句的条件式中的列。SQLiteLint 检测出这种问题,建议先关注该 sql 语句是否有性能问题,再决定是否建立一个更长的索引。举个例子:
descript

以上看到,确实是利用了索引 genderIndex 来查询,但看到where子句里还有一个 mark=60 的条件,所以还有一次遍历判断操作才能得到最终需要的结果集。尤其对于这个 case,gender 也就是性别,那么最多 3 种情况,这个时候单独的 gender 索引的优化效果的已经不明显了。而同样,优化也是很容易的:

descript

解决这类问题,一般就是建立一个更大的组合索引。

5. 怎么降低误报

现在看到 SQLiteLint 主要根据查询计划的某些关键字去发现这些问题,但SQLite支持的查询语法是非常复杂的,而对应的查询计划也是无穷变化的。所以对查询计划自动且正确的分析,不是一件容易的事。SQLiteLint 很大的功夫也在这件事情上

所以对查询计划自动且正确的分析,不是一件容易的事。SQLiteLint 很大的功夫也在这件事情上SQLiteLint 这里主要对输出的查询计划重新构建了一棵有一定的特点的分析树,并结合sql语句的语法树,依据一定的算法及规则进行分析检测。建分析树的过程会使用到每条查询计划前面如 “0|1|0” 的数字,这里不具体展开了。

举个例子:是不是所有带有 “SCAN TABLE“ 前缀的查询计划,都认为是需要优化的呢?明显不是。具体看个 case :

descript

这是一个联表查询,在 SQLite 的实现里一般就是嵌套循环。在这个语句中里,t3.id 列建了索引,并且在第二层循环中用上了,但第一层循环的 SCAN TABLE是无法优化的。比如尝试给t4的id列也建立索引:

descript

可以看出,依然无法避免 SCAN TABLE 。对于这种 SCAN TABLE 无法优化的情况,SQLiteLint 不应该误报。前面提到,会对查询计划组织成树的结构。比如对于这个 case,最后构建的查询计划分析树为:

descript

分析树,有个主要的特点:叶子节点有兄弟节点的是联表查询,其循环顺序对应从左往右,而无兄弟节点是单表查询。而最后的分析会落地到叶子节点的分析。遍历叶子节点时,有一条规则(不完整描述)是:

叶子节点有兄弟节点的,且是最左节点即第一层循环,且 where 子句中不含有相关常量条件表达式时,SCAN TABLE 不认为是质量问题。

这里有两个条件必须同时满足,SCAN TABLE 才不报问题:第一层循环 & 无相关常量表达式。第一层循环前面已经描述,这里再解释下后面一个条件。

descript

由上看到,当select子句中出现常量条件表达式 “t4.id=666” , 若 t3.id,t4.id 都建了索引,是可以优化成没有 SCAN TABLE

descript

而把 t4.id 的索引删除后,又出现了 SCAN TABLE 。而这种 SCAN TABLE 的情况,不满足规则里的的第二个条件,SQLiteLint 就会报出可以使用索引优化了。

这里介绍了一个较简单语句的查询计划的分析,当然还有更复杂的语句,还有子查询、组合等等,这里不展开讨论了。巨大的复杂性,无疑对准确率有很大的挑战,需要对分析规则不断地迭代完善。当前 SQLiteLint 的分析算法依然不足够严谨,还有很大的优化空间。

这里还有另一个思路去应对准确性的问题:对所有上报的问题,结合耗时、是否主线程、问题等级等信息,进行优先级排序。这个”曲线救国”来降低误报的策略也适用本文介绍的所有检测问题。

二、检测冗余索引问题

SQLiteLint 会在应用启动后对所有的表检测一次是否存在冗余索引,并建议保留最大那个索引组合。

先定义什么是冗余索引:如对于某个表,如果索引组合 index1,index2 是另一个索引组合 index3 的前缀,那么一般情况下 index3 可以替代掉 index1 和 index2 的作用,所以 index1,index2 就冗余了。而多余的索引就会有多余的插入消耗和空间消耗,一般就建议只保留索引 index3 。

看个例子:

descript

以上看到,如果已经有一个 length 和 type 的组合索引,就已经满足了单 length 列条件式的查询,没必要再为 length 再建一个索引。

三、检测 select * 问题

SQLiteLint这里通过扫描 sql 语法树,若发现 select * 子句,就会报问题,建议尽量避免使用 select * ,而是按需 select 对应的列。

select * 是SQLite最常用的语句之一,也非常方便,为什么还认为是问题的呢?这里有必要辩驳一下:

对于 select * ,SQLite 底层依然存在一步把 * 展开成表的全部列。

select *也减少了可以使用覆盖索引的机会。覆盖索引指索引包含的列已经覆盖了 select
所需要的列,而使用上覆盖索引就可以减少一次数据表的查询。

对于 Android 平台而言,select * 就会投射所有的列,那么每行结果占据的内存就会相对更大,那么
CursorWindow(缓冲区)的容纳条数就变少,那么 SQLiteQuery.fillWindow 的次数就可能变多,这也有一定的性能影响。

基于以上原因,出于 SQLiteLint 目标最佳实践的原则,这里依然报问题。

四、检测 Autoincrement 问题

SQLiteLint 在应用启动后会检测一次所有表的创建语句,发现 AUTOINCREMENT 关键字,就会报问题,建议避免使用 Autoincrement 。

这里看下为什么要检测这个问题,下面引用 SQLite 的官方文档:

The AUTOINCREMENT keyword imposes extra CPU, memory, disk space, and
disk I/O overhead and should be avoided if not strictly needed. It is
usually not needed.

可以看出 Auto Increment 确实不是个好东西。

ps. 我这里补充说明一下 strictly needed 是什么是意思,也就是为什么它不必要。通常 AUTOINCREMENT 用于修饰 INTEGER PRIMARY KEY 列,后简称 IPK 列。而 IPK 列等同于 rowid
别名,本身也具有自增属性,但会复用删除的 rowid 号。比如当前有 4 行,最大的rowid是 4,这时把第 4 行删掉,再插入一行,新插入行的 rowid 取值是比当前最大的 rowid 加 1,也就 3+1=4 ,所以复用了 rowid 号 4。而如果加以 AUTOINCREMENT 修饰就是阻止了复用,在这个情况,rowid 号是 5。也就是说,AUTOINCREMENT 可以保证了历史自增的唯一性,但对于客户端应用有多少这样的场景呢?

五、检测建议使用 prepared statement

SQLiteLint 会以抽样的时机去检测这个问题,比如每 50 条执行语句,分析一次执行序列,如果发现连续执行次数超过一定阈值的相同的(当然实参可以不同)而未使用 prepared statement 的 sql 语句,就报问题,建议使用 prepared statement 优化。

如阈值是 3 ,那么连续执行下面的语句,就会报问题:

descript

使用 prepared statement 优化的好处有两个:

  1. 对于相同(实参不同)的 sql 语句多次执行,会有性能提升
  2. 如果参数是不可信或不可控输入,还防止了注入问题

六、检测建议使用 without rowid 特性

SQLiteLint 会在应用启动后检测一次所有表的创建语句,发现未使用 without rowid 技巧且根据表信息判断适合使用 without rowid 优化的表,就报问题,建议使用 without rowid 优化。

这是 SQLiteLint 的另一个思路,就是发现是否可以应用上一些 SQLite 的高级特性。

without rowid 在某些情况下可以同时带来空间以及时间上将近一半的优化。简单说下原理,如:

descript

对于这个含有 rowid 的表( rowid 是自动生成的),这时这里涉及到两次查询,一次在 name 的索引树上找到对应的 rowid ,一次是用这个 rowid 在数据树上查询到 mark 列。

而使用 without rowid 来建表:

descript

数据树构建是以 name 为 key ,mark 为 data 的,并且是以普通 B-tree 的方式存储。这样对于刚刚同样的查询,就需要只有一次数据树的查询就得到了 mark 列,所以算法复杂度上已经省了一个 O(logn)。另外又少维护了一个 name 的索引树,插入消耗和空间上也有了节省。

当然 withou rowid 不是处处适用的,不然肯定是默认属性了。SQLiteLint 判断如果同时满足以下两个条件,就建议使用 without rowid :

表含有 non-integer or composite (multi-column) PRIMARY KEY

表每行数据大小不大,一个比较好的标准是行数据大小小于二十分之一的page size 。ps.默认 page size SQLite 版本3.12.0以后(对应 Android O 以上)是 4096 bytes ,以前是 1024。而由于行数据大小业务相关,为了降低误报,SQLiteLint 使用更严格的判定标准:表不含有 BLOB 列且不含有非 PRIMARY KEY TEXT 列。

简单说下原因:

对于1,假如没有 PRIMARY KEY ,无法使用 without rowid 特性;假如有
INTEGER PRIMARY KEY ,前面也说过,这时也已经等同于 rowid 。

对于 2,小于 20 分之一 pagesize 是官方给出的建议。

这里说下我理解的原因。page 是 SQLite 一般的读写单位(实际上磁盘的读写 block 更关键,而磁盘的消耗更多在定位上,更多的page就有可能需要更多的定位)。without rowid 的表是以普通 B-Tree 存储的,而这时数据也存储在所有树结点上,那么假如数据比较大,一个 page 存储的结点变少,那么查找的过程就需要读更多的 page,从而查找的消耗更大。当然这是相对 rowid 表 B*-Tree 的存储来说的,因为这时数据都在叶子结点,搜索路径上的结点只有 KEY,那么一个page能存的结点就多了很多,查找磁盘消耗变小。这里注意的是,不要以纯内存的算法复杂度去考量这个问题。以上是推论不一定正确,欢迎指教。

引申一下,这也就是为什么 SQLite 的索引树以 B-Tree 组织,而 rowid 表树以 B*-Tree 组织,因为索引树每个结点的存主要是索引列和 rowid ,往往没这么大,相对 B*-Tree 优势就在于不用一直查找到叶子结点就能结束查找。与 without rowid 同样的限制,不建议用大 String 作为索引列,这当然也可以加入到 SQLiteLint 的检测。

小结

这里介绍了一个在开发、测试或者灰度阶段进行 SQLite
使用质量检测的工具,这个思路的好处是:

  1. 上线前发现问题
  2. 关注最佳实践

本文的较大篇幅其实是对 SQLite 最佳实践的讨论,因为 SQLiteLint 的思路就是对最佳实践的自动化检测。当然检查可以覆盖更广的范围,准确性也是挑战,这里还有很大的空间。

Matrix 将在不久之后开源出去,SQLiteLint 作为其中一部分也将随同一起开源。敬请期待。

hexo 标签大小写错误 导致 404 问题解决

作者 wyanassert
2025年1月22日 11:19

最近遇到了 hexo 标签打不开的问题, 同一个标签, 比如 Swift, 有的文章使用的是 swift, 有的文章又是 Swift, 我尝试把所有文章的小写单词都换成大写, 在本地预览无误, 但是推到服务器后发现还是打不开, 点击Swift标签, 打开的页面却是https://xxx/tags/swift/, 同时页面显示 404, 在服务器上查看 tag 下的目录, 也是 swift, 删掉该目录重新推一次, 仍旧无法解决.

这个问题一眼就是 Git的大小写问题了, 因为之前产生的第一个标签是小写, 后面文章里面都改大写后, 标签仍然是小写的, 推到服务器的目录也是小写, 所以第一步

1
2
3
4
5
6
// 1. cd 到博客hexo 目录
cd hexo
// 2. cd .deploy_git/ 目录
cd .deploy_git/
// 3. 打开配置文件, 这里是用 VSCode 打开的, 换成 vim 等都可以
code .git/config

然后找到 ignorecase 配置, 改成 false, 保存即可

第二步删掉历史数据

1
2
3
4
// 此时仍在 .deploy_git 目录下, 删掉目录下所有文件
git rm -rf *
// 提交
git commit -m 'clean all file'

第三步, 重新推送部署

1
2
3
4
5
6
// 回到 hexo 目录
cd ..
// 重新部署
hexo clean
hexo g
hexo d

再点击 Swift 标签,打开的就是 https://xxx/tags/Swift/ 网页, 可以正常访问了; 同时尝试访问原有的 https://xxx/tags/swift/ 也可以正常访问.

[转载] 探究 SwiftUI Preview 的工作原理

作者 wyanassert
2025年1月22日 10:41

原文地址

如果你爱 SwiftUI😍,那你很可能也恨 SwiftUI Preview😡。

之所以这么说,是因为大部分使用 SwiftUI 的开发者,都或多或少遇到过这样的一个界面:

除了崩溃,Xcode Preview 还经常会莫名其妙地卡死无法展示预览效果。

一般遇到这种情况,由于我们不了解 Preview 的工作原理,所以除了处理明显的项目编译错误外,对于其他疑难杂症,似乎只能通过清除缓存、重启 Xcode 这些方法来解决。

为了更好地了解这些问题的根因,这篇文章将探究 SwiftUI Preview 的工作原理。尽管无法完全杜绝 SwiftUI Preview 产生的问题,但至少能够帮助你看懂异常日志,希望能给你的日常开发过程带来一些启发。

TLDR:先说结论


  1. Xcode 16 中,Preview 的工作原理进行了较大调整。如果你对 Xcode 16 之前的 Preview 工作原理感兴趣,可以阅读构建稳定的预览视图 —— SwiftUI 预览的工作原理
  2. 从 Xcode 16 开始,正常的 Build and Run 流程与 Preview 共享构建产物,只是产物的执行流程不同,Preview 会使用 JIT 的方式运行产物
  3. Preview 有三种不同层次的重新构建操作,适用于源代码文件不同程度的修改。如果我们用 Small、Middle、Large 来指代这三种操作,它们的区别可以用下面的表格来表示:
重新构建程度 典型场景 重新构建范围 Preview 应用刷新方式
Small 修改方法中的字符串字面量 不重新构建 保留原应用进程,重新执行 Preview 宏中定义的方法
Middle 修改方法中的其他内容 只重新构建修改了方法的源代码文件 关闭原应用进程,重新开启一个新的应用实例,再执行 Preview 宏中定义的方法
Large 修改类或者结构体属性、修改全局变量 整个工程重新构建,等同于重新执行一次带缓存的 build and run 关闭原应用进程,重新开启一个新的应用实例,再执行 Preview 宏中定义的方法

接下来我们可以通过下面的内容来进一步了解这些细节。

怎么研究:从构建产物来一探究竟


为了研究 Preview 的工作原理,让我们先做一个假设:Preview 在工作过程中,一定会在 Xcode 的 DerivedData 文件夹中留下蛛丝马迹。因此,我们不妨将 DerivedData 加入 Git 管理,观察每一次操作会给 DerivedData 文件夹带来什么变化。

为了方便研究,我们创建了一个名为 SwiftUIPreviewSample 的工程,并将项目的 DerivedData 文件夹放到 .xcproject 同级目录以方便查看。你也可以通过查看每一个 commit diff 来了解不同修改对 DerivedData 的影响。

复习一下:一次 Build and Run 的应用是怎么运行起来的?


从 Xcode 16 开始,SwiftUI Preview 的工作机制就发生了一些变化,其中最主要的变化就是:Build and Run 和 Preview 共享了同样的构建产物,这是为了让 Preview 和 Build and Run 的编译产物可以复用,进而提高 Preview 本身的构建效率。

当我们点击 Play 之后,Xcode 就会构建整个项目,整个过程的中间产物和最终产物都存在于 ~/Library/Developer/Xcode/DerivedData/xxx/Build 文件夹下的 Build/Intermediates.noindexBuild/Products 文件夹下。

在最终的 .app 中,我们一般可以看到这样的内容:

1
2
3
4
XXX.app
|__ XXX
|__ __preview.dylib
|__ XXX.debug.dylib

根据 Apple 的这个 官方文档,我们可以得知,为了让 Preview 和 Build and Run 共享构建产物,Xcode 在项目中开启 ENABLE_DEBUG_DYLIB 的情况下,会将原本都放在 XXX.app/XXX 中的主要内容都拆分到 XXX.debug.dylib 这个动态库中,而原本的二进制文件就成为了一个仅仅起到跳板作用的 “空壳” 可执行文件。

为了验证这一点,你可以打开看看你的任意一个完整项目的二进制文件,仅仅从大小你就可以看出,随着代码的增多,增大的只有 XXX.debug.dylib 这个动态库。

而当我们将二进制启动之后,也可以通过 lsof -p $(pgrep -f "") 命令查看到,二进制在整个运行过程中,也确实读取了 debug.dylib 这个动态库。

1
2
3
4
5
lsof -p $(pgrep -f SwiftUIPreviewSample)
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
SwiftUIPr 77422 onee cwd DIR 1,18 416 315720871 /Users/onee/Library/Containers/spatial.onee.SwiftUIPreviewSample/Data
SwiftUIPr 77422 onee txt REG 1,18 57552 316066805 /Users/onee/Code/Playground/SwiftUIPreviewSample/Build/Products/Debug/SwiftUIPreviewSample.app/Contents/MacOS/SwiftUIPreviewSample
SwiftUIPr 77422 onee txt REG 1,18 290816 264469085 /Applications/Xcode.app/Contents/Developer/usr/lib/libBacktraceRecording.dylib

所以,在一次正常的 Build and Run 流程中,整个二进制的构建和执行结果会是下图所示的流程:

探究一下:一次 Preview 的应用是怎么运行起来的?


而当我们启用了 Preview 之后,整个应用的构建流程就会开始有一些变化。首先,在整个构建过程中,Xcode 将会针对使用了 Preview 宏的 Swift 代码源文件,生成一些特别的 .preview-thunk.swift 文件,在这个文件中会对原始的 Swift 文件做一些文件上的预处理。

Note

在计算机领域中,thunk 一般指的是一种用于解决不同代码段之间的接口问题的技术,一个典型例子就是将回调风格的异步函数转换为 async/await 风格的函数,我们就可以将这个过程称之为 thunkify。

例如,假如我们的源文件是是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import SwiftUI
let myText = "Hello, world!"
struct ContentView: View {
@State var item = Item(name: "Hello!")
@State var count = 0

var body: some View {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
Text("\(item.name) Foo, Bar, Baz")
}
.padding()
}
}
#Preview {
ContentView()
}

那么对应的 .preview-thunk.swift 文件就会是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import func SwiftUI.__designTimeFloat
import func SwiftUI.__designTimeString
import func SwiftUI.__designTimeInteger
import func SwiftUI.__designTimeBoolean
#sourceLocation(file: "/Users/onee/Code/Playground/SwiftUIPreviewSample/SwiftUIPreviewSample/ContentView.swift", line: 1)
// 1. 这里标记了源文件的位置
//
// SwiftUIPreviewSample
// Created by: onee on 2025/1/10
//
import SwiftUI
let myText = "Hello, world!"
struct ContentView: View {
@State var item = Item(name: "Hello!")
@State var count = 0

var body: some View {
VStack {
Image(systemName: __designTimeString("#2282_0", fallback: "globe"))
// 2. 这里使用了开头引入的私有函数
.imageScale(.large)
.foregroundStyle(.tint)
Text("\(item.name) Foo, Bar, Baz")
}
.padding()
}
}
#Preview {
ContentView()
}

Xcode 对原始的 Swift 文件主要做了两个地方的处理,首先是使用 #sourceLocation 标记了源文件的位置,方便做一些异常报错的优化;另外,在一些文字字面量的位置,使用 __designTimeString 替换原本的文字内容,方便在文字变化时直接修改。

除了额外生成这些 .preview-thunk.swift 文件,其他的构建流程和普通的 Build and Run 的构建流程基本一致。Xcode 会将整个项目完整构建,最终也会生成和普通的 Build and Run 一样的 .app 文件。并且,我们也可以在 Xcode 中的 Report Navigator 中查看到整个构建过程的详细信息。

Note

是的,这一点相当反直觉。我相信大部分人看到 Preview 宏的写法时,第一反应都会以为 Preview 只会编译单个文件。

和正常的 Build and Run 不同的是,Preview 运行起来的应用会使用 PreviewInjection.framework 和 XOJITExecutor.framework 这两个系统私有库(macOS 在 /System/Library/PrivateFrameworks 目录下就能找到)来 JIT 执行整个应用。这一点,我们可以通过故意在 Preview 中写一个会产生异常的代码来验证:

1
2
3
4
5
6
7
8
struct ContentView: View {
var body: some View {
// 在这里故意写一个数组越界的代码
let a = [1]
let b = a[2]
// other code
}
}

然后我们就可以从 Preview 的异常日志中看到这样的信息:

1
2
3
4
5
6
7
8
5   ???                        0x3400f0aa4 _$s20SwiftUIPreviewSample11ContentViewV4bodyQrvg$14$body
6 ??? 0x3400f1d30 _$s20SwiftUIPreviewSample11ContentViewV0A2UI0E0AadEP4body4BodyQzvgTW$14$body
# ...
70 XOJITExecutor 0x000007adc __xojit_executor_run_program_wrapper + 1832
71 XOJITExecutor 0x0000037cc ???
72 PreviewsInjection 0x000038098 ???
73 SwiftUIPreviewSample 0x000001958 __debug_blank_executor_main + 1056
74 dyld 0x000006274 start + 2840

其中 ”???” 就是 Xcode Preview 采用 JIT 方式执行的代码。由于没有调试信息,所以在最终的日志中我们也看不到具体名称。

相同的异常在 Build and Run 运行起来的应用中,会是这个样子:

1
2
3
4
5
6
7
5   HotReloadInspector.debug.dylib       0x10467656c ContentView.body.getter + 644 (ContentView.swift:74)
6 HotReloadInspector.debug.dylib 0x10467940c protocol witness for View.body.getter in conformance ContentView + 28
# ...
75 SwiftUI 0x1c1016f38 static App.main() + 224
76 HotReloadInspector.debug.dylib 0x10467d128 static HotReloadInspectorApp.$main() + 40
77 HotReloadInspector.debug.dylib 0x10467d2d8 __debug_main_executable_dylib_entry_point + 12 (HotReloadInspectorApp.swift:9)
78 dyld 0x191770274 start + 2840

与 Build and Run 不同,Preview 模式下运行的应用完全不会读取 .debug.dylib 这个动态库,而是读取 __preview.dylib 来完成后续所有的 JIT 执行。这一点也可以从 lsof -p $(pgrep -f "XXX") 命令的结果中看到:

1
2
3
4
5
lsof -p $(pgrep -f SwiftUIPreviewSample)
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
SwiftUIPr 42252 onee cwd DIR 1,18 704 2 /
SwiftUIPr 42252 onee txt REG 1,18 57040 316025086 /Users/onee/Library/Developer/Xcode/UserData/Previews/Simulator Devices/EA49B734-414B-4A25-B2F4-9D72D059EF9E/data/Containers/Bundle/Application/571D3078-50BF-4273-90CC-A5AF8D652500/SwiftUIPreviewSample.app/SwiftUIPreviewSample
SwiftUIPr 42252 onee txt REG 1,18 34896 316025089 /Users/onee/Library/Developer/Xcode/UserData/Previews/Simulator Devices/EA49B734-414B-4A25-B2F4-9D72D059EF9E/data/Containers/Bundle/Application/571D3078-50BF-4273-90CC-A5AF8D652500/SwiftUIPreviewSample.app/__preview.dylib #

另外,如果是 Preview SPM Package 中的代码,整体的执行流程会和 Preview 主工程中的代码有些许的不同,Xcode 会使用 XCPreviewAgent.app 这个应用来运行整个应用。这个应用就藏在 Xcode 中,你可以通过 find /Applications/Xcode.app/ -name "*Agent" 来看看他具体在哪里:

1
2
3
find /Applications/Xcode.app/ -name "*Agent"
/Applications/Xcode.app//Contents/Developer/Platforms/AppleTVOS.platform/Developer/Library/Xcode/Agents/XCPreviewAgent.app/XCPreviewAgent
# others...

这样,我们就可以用下面的流程图来表示 Preview 的整个运行流程:

Warning

尽管通过框架名称我们可以推断出来 Preview 使用了 JIT 来执行代码,不过在此次的探究中,我们并没有找到具体的 JIT 执行细节,例如异常日志中 ??? 所代表的二进制内容的具体位置,因此在目前版本的图中,??? 代表的二进制前面就先放了一个问号。

如果你对这方面有更深入的了解,欢迎在评论区留言或者直接 与我交流

到目前为止,我们已经知道一个 Preview 执行首次执行起来是什么样子了,如果我们修改了代码,Preview 会如何重新构建呢?

Preview 的三层重构建策略


正如我们文章一开始所说,Preivew 有三种不同的策略来重新构建应用。这里我们分别用具体的例子来进行说明。

Small

首先是改动最小的,修改方法中的字面量,例如 这个修改,我们将 ContentView 中的 Text 中的字面量从 Hello, world! 改为 Hello, world!, Foo, Bar, Baz

而在此之前,ContentView 对应的 thunk.swift 文件中,Text 对应的代码是这个样子的:

1
Text(__designTimeString("#25104_1", fallback: "Hello, world!")

从 commit 记录可以看出,在这种修改下,DerivedData 文件夹下的内容并没有发生任何变化。同时,通过对比 pgrep -f SwiftUIPreviewSample 的 PID 也可以得知,原有的 SwiftUIPreviewSample 进程并没有被销毁。

并且,如果我们使用 @State 存储了一些变量,Small 程度下的修改并不能保留这些变量的状态,例如我们将 count 从 0 改为 1,修改字面量后, count 的值会恢复为 0。

那我们就就可以合理推断,在这种策略下,Xcode 应该是通过直接读取源代码中的字面量内容,然后将新的字面量内容更新到已有的 SwiftUIPreviewSample 进程中,进一步通过重新执行 Preview 宏创建出来的一系列方法来实现整个视图的更新的。

当 Preview 重新执行时,__designTimeString() 会返回更新后的字符字面量的最新值,从而实现了视图上文字的更新。

Middle

接下来,如果我们修改了方法中其他非字面量的内容,例如将一个变量修改为带插值的字符串,如 这个修改

从 commit 记录可以看出,在这种情况下,Xcode 会重新生成 .preview-thunk.swift 文件以及对应的 .o 文件,但重新生成的范围仅限于做了修改的文件,.app 下的二进制文件和动态库并不会重新生成。

同时,通过 pgrep -f SwiftUIPreviewSample 的 PID 也可以得知,原有的 SwiftUIPreviewSample 进程已经被关闭,Xcode 重新生成了一个新的 SwiftUIPreviewSample 进程。

因此我们可以合理推断,在这种策略下,Xcode 只会重新编译做了方法内容修改的源文件,然后通过重新运行一次 SwiftUIPreviewSample 进程来实现整个视图的更新。

Large

最后,如果我们修改了类或结构体属性、修改全局变量、增加 @State 等,例如 这个修改

从 commit 记录可以看出,类似这样的修改也会更新 .app 下的二进制文件和动态库。当这样的修改出现时,我们可以从 Xcode 的 Report Navigator 中看到整个项目的重新编译日志:

同样,通过 pgrep -f SwiftUIPreviewSample 的 PID 也可以得知,原有的 SwiftUIPreviewSample 进程已经被关闭,Xcode 重新生成了一个新的 SwiftUIPreviewSample 进程。

因此我们可以合理推断,在这种策略下,Xcode 会重新编译整个项目,然后通过重新运行一次 SwiftUIPreviewSample 进程来实现整个视图的更新。

他山之石:对比一下 Flutter 的 Hot Reload


尽管在 Xcode 16 中 Preview 已经做出了一些优化,但如果对比其他框架的 Hot Reload 功能,例如 Flutter 的 Hot Reload,目前版本的 Preview 还有不少提升空间:

  1. 不支持断点调试。在这一点上,Flutter 的 Hot Reload 不但支持断点调试,当代码修改之后断点的位置也不会漂移,可以说体验非常好
  2. 视图上的状态会被重置。相比之下,Flutter 在大部分情况下的 Hot Reload 会保留视图的状态,这样在调试需要状态的 UI 时会方便很多
  3. 整体实现更加黑盒。Flutter 的文档详细探讨了 DartVM 支持 Hot Reload 的过程和原理,而 Apple 在这一点上相对欠缺

不过,也许 Xcode 16 的 Preview 机制改进只是一个开始,希望后续版本的 Xcode 能针对 Preview 有更大的优化。

参考

[转载] 手机能跑图生成和 LLM 大模型吗

作者 wyanassert
2025年1月21日 20:02

原文地址

💡 能,但还比较勉强。

在客户端上跑大模型,一定是未来的趋势。

  1. 上个时代 AI 的核心应用是推荐系统,推荐是依赖海量数据的,海量数据只有服务端上存在,在推荐这主场景下客户端 AI 上能做的事很少,发展得比较吃力。
  2. 生成式 AI 时代,最大的应用就是模型本身,只有训练时依赖海量数据,使用时并不依赖数据,那理论上只要客户端硬件资源足够,在客户端使用,跟在服务端使用,场景和效果是一致的。
  3. 生成式 AI 在端上跑模型,最大的优势是成本。成本是当前生成式 AI 应用除了效果以外第二大关键因素,在用户客户端上跑模型,对服务提供方来说就是 0 成本,0 成本使更多场景大规模免费应用成为可能。其他的优势还包括 隐私保护、实时性、离线可用

硬件条件


那当前手机设备硬件条件如何?我们可以通过一些指标对手机和服务端的算力差距有个大概认识。

显存: 一个模型能不能跑,取决于显存够不够,显存不够模型无法加载。

  1. 服务端一般用独立显卡,有独立显存。
  2. 手机通常使用系统级芯片 Soc(System on a Chip),无独立显卡,SoC 中包含了 CPU、GPU、基带等多个组件,使用统一内存架构允许 CPU 和 GPU 共享同一块内存,所以手机 GPU 显存跟手机内存是一个东西。

性能: 而模型跑得快不快,取决于芯片性能怎样。

  1. 芯片性能取决于很多因素,例如芯片架构、显存带宽,而算力是其中一个,通常用TOPS(万亿次每秒 Tera Operations Per Second)指标来衡量算力。TOPS 默认是针对 INT8 整型数的处理次数,另一个指标 TFLOPS 是针对 Float32 浮点数的处理次数。
  2. 在通用 GPU 以外,现代芯片会搭载专门处理 AI 运算的硬件加速器,NVIDIA 是 Tensor Core,手机 SoC 芯片是 NPU (Neural Processing Unit 神经网络处理单元),以下是 Tensor Core 和 NPU 的运算性能指标。
  3. 不同芯片性能,特别是涉及不同芯片架构设计的,应该以实测数据作为对比,但当前缺乏这类数据,先用 TOPS 指标看个大概。

我们看看当前常用的英伟达各种显卡芯片,以及移动端设备芯片这几个指标的情况:

芯片 TOPS(INT8) 显存 搭载设备
服务端芯片 H100 2000 80G /
A100 624 80G /
NVIDIA A30 330 24G /
NVIDIA A10 250 24G /
移动设备芯片 骁龙8 Gen3 45 16G 小米14/一加12/荣耀6/Redmi K70 Pro
Apple M4 38 24G(iPad) iPad Pro / MacBook Pro
Apple A17 Pro 35 8G iPhone 15 Pro / Max
天玑9300 20 12G/16G vivo X100 / OPPO Find X7
Apple A15 15 6G iPhone 13 Pro Max
Apple M1 11 16G/32G MacBook Pro

手机内存显存与系统共用,正常能提供给 APP 使用的内存只有1/2~2/3,所以可以认为对 APP 来说,手机设备的可用内存需要减半,否则有内存不足 APP 被系统 kill 的风险,像 iPhone 15 Pro 预计是4G,小米14等高端机是8G。

生图模型要求

那当前主流的生图模型,对硬件的要求是怎样?

显存

Stable Diffusion XL base 参数量 3.5B(35 亿),精度 Float16(16位bits,2个字节),换算下来参数总大小 6.5G,实际文件大小6.94G,在模型推理过程中,参数得加载到显存中,也就是显存至少6.9G,同时在模型推理过程过程中,也有一些中间值需要保留在显存中,所以正常需要8G – 12G显存支持。

实测在 Macbook 跑起来,占用了10.3G。极端情况下,通过显存调度之类的技术在 4G 显存也能勉强跑起来,但会性能较差或不稳定。

这个显存要求,在 iPhone 15 Pro 基本是不满足的,Android 高端机整体内存普遍较大,勉强可以支持

性能

我在 A10 卡和 M1 MacBook Pro 上分别实测了下,SDXL base 模型生成 1024×1024 的图,A10大概6.4秒,M1 大概 95 秒。如果只看 TOPS 指标,A10 220TOPS 是 M1 11TOPS 的20倍,实测跑下来 95秒/6.4秒 = 14.8倍,也就是 M1 与 A10 的实际差距没那么大。

真实性能受各种因素影响,每个芯片有各自的优化方案,单用 TOPS 指标难以衡量,但可以看个大概。如果只看 TOPS 倍率,内存完全足够的情况下,搭载骁龙 8 Gen3 的小米 14 生成同样的图预计需要 17.6s,官方宣传15s左右。

芯片 TOPS SDXL 生图耗时 设备
NVIDIA A10 220 6.4s(实测) 服务器
Apple M1 11 95s~140s(实测) MacBook Pro
骁龙8 Gen3 45 17.6s(预估) 小米14

量化

原 SDXL 模型硬件要求高,但如果可以牺牲部分效果,是有办法对原模型做压缩,让它可以跑在低内存手机的。

模型为了成本、速度考虑,一般会进行不同程度的量化。量化就是降低模型参数的精度,神经网络模型中的参数通常使用32位浮点数 Float32 表示,但 Float32(4个字节) 存储大计算量也大,进一步可以压缩映射到更低的数值表示,包括 Float16、Int8、Int4 甚至 Int2 都有应用,只是会带来不同程度的效果损失。

模型量化后,参数需要的存储空间降低,所需要的显存跟着降低,而因为数据量小了,计算量也相应减小,模型推理速度也会加快。

Draw things 这个应用,将 SDXL base 模型量化到 Int8 的精度,模型大小 2G,可以跑在 4G 内存的 iPhone 上(APP 最多只能使用 2G 内存,为此作者做了系列优化)。实测 SDXL base Int8 模型 在 iPhone 13 Pro Max(A15,6G)上,生成 1024*768 的图需要 180s,跟它硬件 TOPS 算力差得有点多,可以认为是推理架构上为了节省内存做的妥协。

LLM 大模型要求


那在 LLM 大模型上,情况怎样?

我们拿阿里通义千问qwen的模型大概看下它 7B 和 72B 在不同量化下的大小。qwen 最大模型是 72B,而 llama3 最大是 400B(还在训练中),可以预估 400B 模型会是接近1T的体量。

如果拿400B模型对标GPT4,72B 模型对标 GPT3.5+,可以看到目前可用的 LLM 模型推理成本和硬件要求是非常高的,比图生成高几十倍。

模型 参数量 量化 大小 生成 2048 token 所需显存
Qwen 1.8B Int4 1.88G 2.9G
Int8 2.49G
Float16 3.6G
7B Int4 5.86G 8.2G
Int8 9.13G
Float16 15.41G
72B Int4 41.65G 48G
Int8 111.86G
Float16 144.18G
Stable Diffusion XL base 3.5B Float16 6.94G

qwen 最小的 1.8B 模型,生成 2048 个 token 最低需要 2.9G 显存,当前高端机是可以跑起来的。但 1.8B 效果差很多,预计只能预训练做特定任务。7B 可用性高一些,可以看到 7B 模型就没多少手机能支持了,骁龙8 Gen3 宣传号称 7B 模型推理每秒执行 20 个token,未搜到相关实测。

Google 用于端侧的 Gemini Nano 有 1.8B、3.25B 两种参数量。苹果之前放出来的 OpenELM 模型有 0.27B ~ 3B 的参数量,最新 iOS18 的 AI 模型估计用的就是 OpenELM,限制了只有最新 iPhone 15 Pro 能跑。

iOS Android 都在往系统级集成端侧 LLM 大模型这个方向做,系统集成有更多的硬件资源调度权限,在当前资源条件下容易先做起来,APP 能用到的资源有限,目前很难跑起来。

所以手机跑 LLM 大模型,用最小的模型,在最高端的手机上理论可行,实际应用还要再等等。

端模型问题


除了硬件理论情况,端模型也有一些问题待解决:

  1. 对服务提供方,有技术保密问题: 在端上部署模型,模型、prompt、workflow 都是存储在本地,虽然可以做各种加密,但总能破解,如果服务方视这些为核心竞争力,那就难以以这种方案部署,更有可能的是端云协同的架构,部分运算放客户端,云端处理核心和保密部分。
  2. 对于手机用户:手机耗电、发热、耗时问题: 大量运算跑满 GPU 必然导致手机发热严重耗电高,在持续使用的场景下体验会比云端差,手机芯片跑起来速度也会不如云端快,手机端系统需要做好资源控制和平衡。
  3. 生态问题: 英伟达的CUDA、PyTorch 生态,相关工具链/社区,在端上都是需要重新建立的,当然只要有场景有诉求,这些可以补上,但需要时间。
  4. 场景和价格问题: 能运行大模型的手机,在未来几年价格还是高的,目前还没有比较好的理由让用户接受这个溢价,对用户来说,像生图、修图、LLM当前服务端能提供最好的,在端侧跑模型体验没提升,就没必要溢价买个高端机,高端机平民化速度就会慢。在没有 killer APP 的情况下, 需要靠手机厂商和系统强推了,例如 iOS 18 新Siri 只在最高端机可使用。

结论

图生成 硬件要求不算高,高端机已经摸到实际应用的门槛,预计再过一两年,硬件进一步提升,不追求效果极致的图生成应用场景,大部分会部署在客户端上。

LLM 硬件要求高,iOS/Android 系统级应用有条件接入,APP 基本还用不了。等系统应用被大众认知和接受,硬件普遍升级,才轮到 APP 端发挥。

当前过渡阶段,端云协同的方案会比较多,预计也会存在很长一段时间。例如图生成,可以将部分运算(比如 VAE 编解码)放到端上,主生成流程放云端。iOS 18 Siri 也会判断如果用户输入的是简单指令,就不请求服务端,直接端模型生成。

[转载] 带文字的 AI 图片生成是怎么做的?

作者 wyanassert
2025年1月21日 19:56

原文地址),如有侵权,请联系删除。

近期即梦上线了 AI 图片生成文字的能力,在生成海报、封面以及各种场景下渲染文字效果是非常不错的。最开始AI生成的图片中,涉及到文字的基本都是不能看的乱码,需要针对性训练优化才能做到生成清晰的文字并融入图片。那这里是怎么做优化的?对这个原理比较好奇,尝试通过几篇公开论文学习下相关实现思路原理。

大致思路:Recraft

目前生成文字(英文)最好的模型是 Recraft,官方有篇文章 《How To Create SOTA Image Generation with Text: Recraft’s ML Team Insights》介绍了模型训练的大体过程,挺适合简单了解大致思路的,简单复述下。

首先说明下为什么图片生成文字容易乱码?

  1. 一是数据量不足:图片生成模型是通过大量图片+图片描述去做训练,而大部分图片的描述是不怎么包含图上的文字的,比如拍一个街道建筑图,图上会有很多店面的名字文字,图片描述可能就是类似 城市/街道/红色招牌等描述,并没有把图上的所有文字放进去,模型只能在少部分相对简单的场景(比如图上只有几个字,图片描述中也有这几个字)中学习生成正确的文本,幻觉会比较严重。
  2. 二是文字的错误更容易被发现,相对于人物动作不协调、衣服花纹的差错,文字只要有一笔一划错误就很容易被人察觉识别为乱码,需要更精确的生成。

接下来看优化文字生成能力的大致流程:

第一步,准备数据。准备大量的包含文字的图片,包括海报、封面、广告、Logo等,对这些图片进行处理。处理包含两部分,一是用 OCR 模型识别图像上的文字位置和文字内容,二是用多模态模型识别这张图的内容,输出描述文本。得到了海量的 图片 – 文本布局和内容 – 图片描述 组合的数据。

第二步,使用数据训练模型,跟第一步是反着的过程。先训练一个布局模型,可以通过输入 prompt → 输出文本布局+内容。再把 prompt 和文本布局输入生图模型,最终生成带文字的图片。

大流程就是这样,再稍微把其中布局模型展开一下:

输入 prompt 输出 文字内容+布局,用的是一个大语言模型(LLM),定义了一个输出的文本格式,包含文本内容和这些文本的坐标。同时还会根据文本和坐标数据,用文字渲染工具画张图片出来。

这张渲染出来的文字布局图会作为生图时的参考,用类似ControlNet 的方式作用在生图过程中,最终生成图上的文字。

这是个大致流程,文中没有展开里面模型架构的一些细节,原文上表示思路基于 TextDiffuser2,但看起来思路上跟 GlyphControl、TextDiffuser、TextDiffuser2 都有关系。

各方案大的思路都差不多,基本都是分两步,生成文字布局信息,再作用在生图过程中,主要是模型架构不同,以及数据集质量不同。下面看看这些相关的论文和一些模型细节。

GlyphControl

先看看相对简单的 GlyphControl,23年11月的论文,基本就是一种 ControlNet,跟边缘轮廓、姿态等 ControlNet 没太大差异。ControlNet 的相关介绍可以看回这篇

  • 训练阶段: 找一批带文字的图片,用OCR 识别文字内容和位置,再渲染出一张白底黑字的图片,将图片描述和这张白底黑字图片一起进入 Glyph ControlNet 网络训练。这个白底黑字的图片就是参考图,跟边缘轮廓/姿态等其他 ControlNet 的参考图作用和流程都一样。
  • 推理阶段: 分两部分输入,生图的 Prompt 和白底黑字参考图,这张参考图看起来是要用户自己另外准备的,可以直接画一张白底黑字的图,或者描述文字内容、行信息、大小位置布局,用工具生成白底黑字参考图,再和 prompt 一起去生成相应的带图的文字。
  • 效果: 文字能较准确生成,但没有控制字体样式和文本颜色的能力,泛化性会比较差。布局和位置需要额外输入,产品化实用性低一些。

疑问: controlNet 23年2月出现,为什么11月才有人用于改进图片文字渲染,ControlNet作者自己不试试呢?

还有一篇更直接的,直接用 ControlNet 的边缘轮廓做文字生成,也不用自己训练,做了个评测: 《Typographic Text Generation with Off-the-Shelf Diffusion Model》

TextDiffuser

TextDiffuser 是23年10月的论文,跟上面 ControlNet 的思路有差异:

  1. 不用准备参考图,用一个模型从 prompt 中推断文字布局。
  2. 直接在生图扩散模型中训练,非 ControlNet 插件的形式。

流程

  1. 布局生成:先根据 prompt 生成逐个字母的文字形状 mask 图。用一个 transformer 模型(非LLM)理解输入的语义,识别出图上要画哪些文字,这些文字在画布上应该是在哪个位置,获得每一个字符在画布上的box位置,再用字体渲染库(如pillow)把这些文字渲染上去,生成这些字符的遮罩表示(Mask)。
  2. 图像生成:将上一步得到的字符遮罩输入扩散模型,参与引导扩散过程,使图片能在遮罩对应的位置生成对应的字符形状。

训练

  1. 数据:作者从各处收集了1000万张带有文字的图像-文本对,称为MARIO-10M,主要来源是开源的LAION-400M,从中筛选带文字的高质量的图,也对数据进行了处理,包括文本检测识别、字符级的位置数据、原有的图片描述文字等。
  2. 布局阶段:会使用这个数据集去做训练上面提到的 transformer 模型,输入是图片描述文字,输出是每个字符的 mask 遮罩。在数据集中,每张图片的描述、以及每张图片经过 OCR 识别处理后字符的遮罩位置都有,模型就能学习到对不同的图片描述,对应的最终的文本位置和形状应该是怎样的。
  3. 图片生成阶段:这个数据集也会在扩散模型的基础上去做进一步训练,在这过程中 U-Net 的参数是冻结的,猜测是避免核心生图能力被破坏?训练过程中只会修改扩散模型 U-Net 以外的其他模块参数,整个网络还是能学习拟合到数据集里 图片描述(prompt) + 字符遮罩数据 → 带文字图片 这里的对应关系。

这整个过程,就是为生图增加信息量,布局阶段渲染的每个字符的 mask 是很大的信息量来源,引导图片扩散方向不飘。

效果

相对未针对性训练的生图模型,能生成合理清晰的文字,在给定图像补充文字上效果也不错,也能做到控制文本颜色了,但字体多样性差一些。

TextDiffuser2


TextDiffuser 有个问题,它第一阶段产生的文字 mask 是用单一字体渲染的结果,用这个 mask 引导生图,结果是生成的结果字形的多样性比较差,生成的文字倾向于规整,手写或艺术字很难出现,GlyphControl也有同样的问题。另外 TextDiffuser 布局转换器对用户输入 prompt 的理解能力也有限。

TextDiffuser2 差异在于:

  1. 布局模型用大语言模型去替换。LLM 能表现出比较强的语义理解布局规划能力,用一个 LLM 去理解 prompt 转化为对应的布局格式,效果会更好。
  2. 生图阶段,对扩散模型中的语言模型(clip)和 U-Net 都做了训练。

训练

布局模型

  1. 使用 LLM vicuna-7b-v1.5 模型进行微调,训练用的还是前面的 MARIO-10M 数据集,拿这个数据集每张图对应的描述文字作为输入,用 OCR 把每张图片的内容和位置信息提取出来作为预期输出做训练。
  2. 这里自定义了布局的格式,一个关键词以一组坐标和字母组成,比如 [x25][y89][x108][y96][W][I][L][D],两个坐标表示方块左上右下两个点。每个字符单独标记,会比去做BPE分词标记效果好。
  3. LLM在学习了大量文字对应图片的构图后,可以从语义推理这些文字的构图应该是怎样的,同时 LLM 自身也能很好理解哪些词是关键字,哪些词应该在同一行。比如上图的 旷野之息邮票 a stamp of Breath of the Wild,LLM 可以学到图上的文本应该是 Breath of the Wild,而对于邮票比较好的布局是上下两行,有个关键字 Wild 突出,得出相应的布局数据。
  4. 根据论文描述,5000个数据量的训练效果是最好的,可能数据多了反而过拟合效果不好。

生图模型

  1. 直接在扩散模型中训练,图上的 M2 是扩散模型里的 clip 文本模型,布局内容和文本 prompt 会一起输入,U-Net 也参与了训练,继续在用 MARIO-10M 数据集做训练。为什么这种方式训练效果好,文中没怎么提到。

效果

TextDiffuser2 的多样性会好一些,字体形态多样。

总结

还有一些其他方案,例如 GlyphDrawAnyText等,大原理差不多,不展开多说了。最后,用 notion AI 总结下本篇文章:

AI 图片生成文字主要有以下几种方案:

  1. GlyphControl: 通过白底黑字的参考图来控制生成文字的位置和内容,实现简单但泛化性较差。
  2. TextDiffuser: 采用两阶段方案 – 先用 transformer 模型生成文字布局 mask,再用扩散模型生成最终图像。但生成的字体样式比较单一。
  3. TextDiffuser2: 改进了 TextDiffuser,用大语言模型替代布局生成,并对扩散模型进行更全面的训练,使生成的文字样式更加丰富多样。

这些方案的核心思路都是:

  • 准备大量包含文字的图片数据集(如广告、海报等)
  • 设计两阶段架构:先生成文字布局,再生成最终图像
  • 通过不同的技术手段(如 ControlNet、LLM等)来提升生成效果

目前 TextDiffuser2 的效果最好,既保证了文字的准确性,又能生成多样化的字体样式。Recraft 借鉴了 TextDiffuser2 和 GlyphControl。

[转载] 客户端大模型进展怎样了?

作者 wyanassert
2025年1月21日 19:43

原文地址

近期苹果发布的新品,无论是 iPhone 还是 Mac,都一改之前挤牙膏的风格,在最低配机器上都加大了内存,目的很明确,就是支撑 iPhone 和 Mac 上的端 AI 大模型。过去一年,AI手机、AI电脑的概念也一度在炒,在之前写的文章也说过,在客户端上跑大模型,一定是未来趋势。那目前端上大模型情况怎样?

应用近况

总的来说,各家陆续出了不少小模型,相关工具链也能支持它们在客户端上跑起来,但可用的应用几乎没见到。

不少手机厂商都号称接入了端模型,但实际上没搜到相关具体应用,Apple Intelligence 还在路上,演示的能力似乎大多是云端模型,不确定本地小模型能做的事。Google Pixel 8 也没有接入Gemini nano,小米14上没有MiLM,小爱完全靠云端模型,OPPO find7 号称端侧模型用于生成通话摘要等一系列能力,但似乎得联网,不确定端模型在上面起到的作用有多大,真正能离线用的也只有图片消除功能。

为什么雷声大雨点小?

  1. 完全体 LLM 近一年的应用场景也有限,端上也就更少了,当前阶段业界精力还是主要投入在研发最好的模型上,很难顾得上端的优化。
  2. 现在的硬件和模型优化程度还不允许 LLM 在端上有作为。端设备基本都对体积和功耗敏感,这两者都限制了硬件能提供的最大性能,7B的模型硬件支持不好,3B的效果不好。

我在 Macbook pro M1 上试跑了下,感受是:3B级别的小模型基本不可用,7B/8B级别的模型速度太慢,资源占用也太大:

  1. llama3.2 3B模型,大小2G,推理速度 62 token/s,翻译/总结/简单的指令理解,都有很大偏差,基本不可用。3B 这个级别或更小的模型,目前看起来需要针对特定任务做微调才能有作用,通用能力不太行。
  2. llama 3.1 8B模型,大小15G,推理速度约 8 token/s,基本问答/翻译/总结可用,但速度太慢,资源要求太高。(这篇文章估算了推理速度,与实测差不多)

LLM 端推理引擎

客户端 LLM 应用还没到时候,但不妨碍大家对这个方向的投入热情,相关的工具链有比较大的进展。

这块工具链的核心是推理引擎,LLM 的训练和推理一般都用 PyTorch,它在GPU适配/加速/生态上都是最好的,但在客户端跑模型,有一些其他诉求:

  1. 在 CPU 上推理的能力,以及能适配多种 GPU 加速
  2. 量化技术,需要更小的模型、更低的资源消耗
  3. 可以轻量编译部署到多种客户端环境

所以需要另一种推理引擎,目前用得最多的是 llama.cpp。

llama.cpp 是 C++ 开发的 LLM 推理引擎,最开始只用于 meta 的 Llama 模型推理,后来扩展到更多模型,包括 Mistral / Gemma / Phi / QWen 等基本所有开源的 LLM,也包括基于 LLM 的多模态模型 llava。llama.cpp 是个人开源项目,基于同个作者的 ggml,在它基础上加了相关大模型推理的功能,token 化 / 缓存管理等。

llama.cpp 可以跑在基本所有主流操作系统上,Android、iOS、Linux、Windows、macOS,甚至 WebAssembly上也提供支持,支持各种 GPU / CPU / NPU 推理。

基于 llama.cpp,上层包装了很多应用,可以方便地在桌面端和移动端跑各种 LLM 模型,桌面端上使用最多的是 ollama,近期 LMStudio 也很不错,移动端上可以用 pocketPal

ollama 基于 llama.cpp,提供本地模型服务

上述这些都是包装了模型下载管理和聊天的壳,目前比较少见到基于 llama.cpp 包装更上层垂类场景的应用。有些些 Mac AI 应用会同时提供线上 GPT 接口以及本地 ollama 接口,LLM 处理可以在本地进行,例如做音频视频转文字和总结的 MemoAI,这也可能是后续 Mac/PC 本地 AI 应用的标配。

除了llama.cpp,还有类似的mlc-llm,也是全平台和多种 GPU 支持。还有专为苹果芯片优化的LM Studio MLX,不多介绍了。

LLM 以外

在实际应用中,端 LLM 还没能用起来,但一些厂商为了推 AI 手机 / AI 设备的概念,经常会包装进一些其他的 AI 能力,比如图片消除能力、语音唤醒识别能力。目前端 AI 真正能在实际场景中应用得好的,也还是这些多媒体图片/语音处理类的小模型,跟 LLM 无关。

常见的图片处理比如 杂物擦除、图片超清、背景去除等,都有很多小模型,转换为 ONNX 或其他推理引擎支持的格式就可以在端上跑。

ONNX 是一种标准开放的模型格式,PyTorch / TensorFlow 等各大深度学习框架训练的模型都可以转为 ONNX 格式,然后用统一的 ONNX Runtime 推理引擎部署在多种硬件和操作系统上,目前大多数端上推理引擎也都支持 ONNX 格式做推理,腾讯的 ncnn/TNN,阿里的MNN,小米的 mace 等都支持 ONNX 格式。

各种模型格式转为 ONNX,跑在各式各样的设备上

理论上只要模型不大,对硬件运算要求没有特别高,转化为 ONNX 格式后在端上都能很好地使用,很多特定的多媒体能力很符合这个条件,例如杂物擦除MI-GAN,只有590万个参数,直接跑在浏览器上 / APP 上都没问题,效果也不差。还有其他很多基于 GAN 的模型,图片超清Real-ESRGAN,老照片修复 GFPGAN 等,运算要求都不高,跑在端上没什么问题。IOPaint 这个项目可以看到比较多类似的模型。

IOPaint 本地运行各类图片编辑模型

如果不考虑多平台部署,把模型转为平台自带推理引擎支持的格式,是能更大程度优化性能的,例如可以将模型转为 CoreML 格式跑在 iOS/Mac 上,但相对比较少,大家更倾向于跨平台的方案。iOS 上比较有名的端生图 APP DrawThings 就是将 Stable Diffusion 转为 CoreML 格式并量化后跑在端上。也有把 SD 转为 ONNX 格式去端上跑的,但还没看到比较好的应用。

一些遐想

端模型的应用,从硬件上分两种:

AI 硬件

  1. 有些场景可以不受设备大小限制、甚至续航功率限制,可以做得比较大,车机系统是一种,这是最好最大的应用场景,端上大模型 AI 应用会最先产生在这个领域,FSD也可以认为是端 AI 的一种。
  2. 还有一些可能得 AI 教育硬件,陪伴的玩偶等,本身也足够塞个大运算量芯片和大电池。一些刚需的硬件,比如导盲眼镜,也可以是连着口袋里一个不小的计算设备,这些算是后续可能的端上大模型的应用场景。
  3. 但除了车载系统以外,其他 AI 硬件要采用这种方式,发展会比较难。技术体验是一回事,还有商业模式的问题。
  4. 这些设备是自带硬件端上跑,还是云端跑,其实就是买断制和订阅制的区别。在端上跑需要用户一次性付出较高的硬件成本,但后续没有其他额外的成本。云端跑初期用户付出的硬件成本低,甚至厂家也愿意赔钱卖机器,但后期是可以用订阅服务制长期收费。从这角度看,用户和商家基本都会选择订阅制,对双方都更友好。所以端大模型要在 AI 硬件上流行起来,还比较难,除非是有些场景对隐私和实时性要求就是很高。

手机电脑

另一种就是利用已有设备,不需要用户额外花钱买硬件,那就还是回到设备大小、续航功耗、发热、机型覆盖等限制,有些场景为了省成本可以先用起来,PC / Mac 陆续可以有一些应用场景,例如上面提到的连接 ollama 的 MemoAI,浏览器上的 AI 搜索也非常适合端上 LLM 去做,但可能这几年会一直处于小场景尝试的阶段,要到主流的程度还早得很,也可能一直不会是主流,手机更是了。

[转载] 谁在用 AI 图片生成

作者 wyanassert
2025年1月21日 19:35

原文地址

谁在用 AI 图片生成

===========

AIGC 图片生成的技术,基本是22年开始爆发,Midjourney 2022年7月推出,Stable Diffusion 2022年8月推出,至今两年发展迅速,已经广泛在很多场景应用,但这个市场上是谁在用图片生成,用来做什么,一直以来在我认知里都有些模糊,这篇文章做下相关调研。

线上线下所有用到图片的地方,都有 AI 图片生成的应用空间,而 AI 图片生成的能力,也会创造出新的领域和行业,就目前能看到的已经在应用的场景,归归类可以分为:生产力工具、大众娱乐、探索创作。

ToB:生产力工具


把 AI 图片生成能力作为实际工作中的生产力工具,用在各领域的内容生产,替换原来的工作流,效率有量级上的提升,同时也有因为 AI 图生成带来的新的领域,例如自媒体。

这里的用户大部分是设计师,全球设计师 9000w,包含建筑设计、室内设计、工业设计、服装设计、产品设计、平面设计等,Adobe 付费订阅人数2650w(2022年),是非常大的市场。

电商

电商有大量的市场,为了展示、介绍、美化不同种类的商品,对图片有巨大的诉求,是AI图片(以及视频)最好的应用场景。

  1. 模特图: 模特换衣、模特生成、在线试衣,专门服务服饰品类的工具,全球电商服饰品类市场规模六千亿美元,这让它对应的工具需求也足够大,能搜到的有几十家公司专门在做,例如BotikaVModel.AI摹小仙千面AI模特ZMO.ailinkfox,美图秀秀/醒图等也有相关工具。入门门槛低,但效果的调优是wu’zhi’jing的,不同角度/动作/不同衣服穿上后的自然度等都需要不断调优。
    17374579707866

  2. 商品图: 上传商品图,AI 可以帮你生成商品在不同环境下的宣传图,免去摆拍。相对于直接抠图→套模板,AI生成质量高,可定制程度也高,可以创造符合商品的各种背景,商品能更好融入对应背景、环境的光线阴影、颜色、高保真,这里的效果调优也是无止尽。同样有非常多公司在做,photoecom灵动AIPicCopilot。综合性的图片工具大多也会加入这个功能,比如 photoroom
    17374581018978

  3. 其他长尾: 电商很庞大,除了上述两个类,整个上下游各个品类还有不少细小长尾的 AI 图片生成需求,例如 T恤定制、衣服花纹生成、款式生成、站外营销图等。

  4. 从发展趋势看,电商平台如果自身有余力,都会去做这样的工具,嵌入到自己平台内,整个工作流更顺,像淘宝千牛自己就做了。但竞争是无止境的,所有商家都用平台提供的工具,质量品质同质化后,就会有个性化或追求更好效果的诉求,外部工具一直会有机会。

素材

素材应该是需求第二大的领域,活动图、海报、封面插图(文章/播客/杂志)、PPT,日常工作很多场景会用到,以前是搜图片找素材拼接,但如果是商用场景,一不小心有侵权的风险,素材是需要付费的,AI 图生成目前没有这个问题,而中国的版权图片市场规模在2020年是34亿,在高速复合增长。素材生成的诉求很泛,不太依赖可控生成,应该大部分都用图生成质量最好的 Midjourney,海报生成因为涉及文字,ideogram.ai 有较大的优势。

ideogram海报/营销素材/壁纸

自媒体

AI 图片生成的能力会被一些自媒体创作者用于创作有趣的内容,带来流量,进而接商单。例如影视/动漫 IP 二创、自制IP形象(宠物打工、宠物时装秀等)、扩图玩梗、表情包等,会不断有各种有趣的玩法持续出现。
高质量图/扩图,玩梗/玩法/自制IP

其他

  1. 游戏设计: 首当其冲是游戏原画,AI 图片生成出来的质量,跟外包原画师已经没有太大差异,或者质量更好,去年就传出游戏公司大规模砍原画外包的新闻。同时游戏内容本身需要大量的角色、场景设计,对于质量要求不高的 2D 游戏,AI图生成已经可以很好满足需求。
    角色生成/游戏原画

  2. 建筑设计: 借助 SD ControlNet 的能力,很容易做到建筑线稿设计图转绘为效果图,渲染不同风格,也不需要有多少微调的工作,各工作室自己可以部署。对于建筑灵感,直接用 Midjourney 看起来也是足够。

  3. 漫画/绘本故事:核心是模型角色保持的能力。儿童绘本故事门槛很低,网上也有大量应用的教程,大众对质量的要求也没那么高,这是 AI 图生成目前擅长的。漫画门槛高一些,核心是故事、分镜的质量,生图所占的比例其实不高,所以如果用 AI 大规模生产,质量堪忧,但也有一些精品,比如这个。针对漫画有一些独立的产品和模型,例如dashtoonComic Factorycomicsmakerllamagen等。

  4. 动画/短剧: 同样借助角色保持能力,生成图片后转成视频形式去消费,这也是后续内容制作的趋势。目前还没看到大规模成熟的应用,短剧类 midreal 相对小众,月活几万的级别。小说转动画视频有不少产品在尝试 剪映的故事成片、极虎漫剪漫剪猫等,规模比较小,但作为生产力工具,付费率是挺高的,做出来的内容有一定消费价值。

ToC:大众娱乐


图片特效

大众用户日常社交对图片是刚需,AI 图片生成在这个领域的应用是最广泛和成熟的,跑出很多爆款产品,Top 的是 Remini(23年MAU 8000w+,收入6643万美元),其他也有非常多产品冒出,AIMirror/FaceAPP/Lensa/Prisma等。

这个领域不断会有爆品出现,理论上不会一家独大,每个产品都有机会,逻辑是:出效果爆款→社交媒体传播全网引爆→大量用户使用&付费→热点几周后消退,用户少量留存,大量流失→找下一个爆款→找到进入下一个循环,找不到产品逐渐消亡。典型的持续活下来的产品是Remini,消亡的是妙鸭。

具体应用上,姑且分为 AI 写真和特效。

  1. AI写真:人像 P 图是刚需,AI写真算是这个刚需的分支,火过很多产品,国内的妙鸭,海外Remini,还有一大波专门做这块的垂类产品 PhotoAI星绘等。妙鸭虽然火一波以后销声匿迹,但这个需求是长期可持续的,photoAI 是独立开发者的产品,月流水已经到17万美元。主要用于各社交软件头像、linkedin商务照等。
  2. 特效:比如风格化的黏土风格、盲盒公仔、迪斯尼风等,还有其他例如换发型、换性别、变老变年轻、扩图等特效。
    Remini 众多特效星绘 AI 写真ailabtools 换性别、年龄

新场景

另一类 ToC 的应用,是把 AI 图片生成能力作为全新产品的一部分嵌入,跟产品形态有较强的绑定。

  1. 陪伴类产品:纯 LLM 文字陪伴发展下去肯定是结合图片生成/视频生成,让人更沉浸式,可以衍生抽卡、剧情图、虚拟女友形象等。产品非常多,MiniMax 的 星野/Talkiecandy.aidreamgf.ai 等,AI 陪伴还在爆发增长期,AI 生图在这个领域有很大应用空间。

  2. 教育类产品:DoDoboo 将儿童涂鸦实时转为绘画作品,激发儿童创造力。是一个尝试性的应用场景,没有很成功,但 AI 教育是万亿级别市场,儿童教育领域本身注重创造力想象力的培养,AI 图片生成就是想象力的呈现,是有机会创造或融入更多教育产品。

  3. NSFW:成人产品,比较特殊,市场自然是巨大的,待分析。

Talkie / DoDoboo

探索创作

====

除了上述 ToB 和 ToC 两类非常明确的应用场景外,AI图生成还衍生出另一波探索型用户。他们不是为工作,无商业目的,单纯喜欢玩 AI 创作,他们可能不会画画,AI 让他们可以不需要学习绘画技能,就能创作出好的作品,这对有创作欲的人有很强的吸引力。

Midjourney 付费用户中,只有 32% 的用户目的是工作或实际需求,68%的用户是为了娱乐。一方面因为 Midjourney 可控性不足,导致很难在真实生产环境使用,较少覆盖上述 ToB/ToC 的那部分用户,另一方面也能看出,纯粹探索 AI 玩图片生成的人群规模也不小,24 年 Q2 Midjourney 月活 600万+,24 年预计收入预计超过 3 亿美元。

图片生成技术,跟摄影技术有点像:

  1. 没有摄影时,只能通过超高的绘画技术记录现实画面,门槛很高,摄影技术让人人拥有记录现实的能力,只需要按个按钮。
  2. 而没有图片生成技术时,也只能通过绘画技术记录和创作现实没有的画面,把心中想象的创意具象化,图片生成技术让人人拥有创作的能力,只需要输入文字。
  3. 除此以外,还有一些相似点:
    1. 人人能用,但专业才能用得好:AIGC跟相机一样只是技术,日常拍照人人能拍,要拍出好的照片,不是人人能做到,即使摄影看起来只是按下快门,调下参数。图片生成随便输入 prompt 人人能创作图片,但要创作出好的作品,也不会是人人都能做到,即使看起来只需要输入文字。
    2. 大众需要,商业也需要:摄影可以记录生活,这是大众需要的,也可以杂质配图、做商业广告等,这是商业需要的。图片生成也一样。
    3. 新的艺术形式:摄影单独是一种艺术形式,相信 AI 图片生成也会带来独有的新的艺术形式,只是目前还未成型,摄影从诞生到成为一种艺术形式,也花了60年。跟画画与摄影不同的是,AI 图片生成创作,是有双向交互的,它不是定死的画笔或相机,创作过程中,AI 创作出来的内容会牵引下一步创作动作,不是一步到位,也不是忠实呈现自己脑里所想、呈现现实世界已有的东西,AI 不仅是工具,作品是人与 AI 的共创,有可能是新的艺术形式。

但跟摄影不同的是,图片生成技术,也许无法像拍照一样普及率那么高,摄像头记录美好生活是高频刚需,但创作不是,纯 AI 创作最终还是属于少部分创作者,就像能称为摄影师的只是少部分人。AI 技术进步是赋予了不会画画但有创意的一波人更强的能力,就像抖音最终赋予的也是少部分创作者展示他们才华的能力一样。

创作无法普及到大众,但创作出来的内容是能普及的,内容消费是大众刚需,至于这波创作者能否创作出跟摄像头相媲美的另一个维度的内容,支撑起一个 AI 内容消费社区,有待探索。

最后

生产工具、大众娱乐、探索创作,这三类图片生成的应用,差距还是比较大的。

  1. 生产工具,需要深入到场景做微调,不断优化效果、深入工作流。
  2. 大众娱乐,需要的是制造爆款的能力。
  3. 探索创作,需要有最好的基础模型能力,以及做好社区运营。

目前看起来没有一个产品能大面积覆盖这几个场景,未来会不会有?只要团队能满足这些条件,能造出一个超级应用满足所有图生成的诉求,大众认知上是没问题的,像上个时代的 Photoshop。

[转载] 什么是多模态大模型

作者 wyanassert
2025年1月21日 17:26

原文地址

是什么


  1. 在机器学习领域,”模态”被用来描述不同类型的数据形式,如文本、图像、视频、音频等。
  2. 最开始以 ChatGPT 为代表的大语言模型,都是只支持文本这个单一模态。
  3. 可以同时处理文本、图像、音频等多种形式的数据输入输出的大模型,就是多模态大模型。

特点:端到端


一个模型能同时理解和处理多种模态的数据输入。

  1. 非端到端的例子:
    1. 在 ChatGPT 上,可以调用 DALL-E 生成图片,但实际流程是 prompt → GPT4模型 → 生成细节提示词 →DALL-E模型 → 生成高质量细节图像,只是一个能力串联,并不是一个多模态大模型。
    2. 在豆包或其他一些LLM APP上,支持语音输入→文字和语音输出,实际流程是 语音→ASR模型转文字→LLM→文字→tts模型转语音,并不是端到端 语音→LLM→语音。
  2. 端到端的例子:
    1. GPT4o 的实时语音对话,流程是 语音→ GPT4o模型→语音。延迟低、语气/音色/停顿/语义都能综合理解到。
    2. claude3.5 支持按要求识别图片,流程是 图片+prompt → claude模型→文本。能很好结合 prompt 按要求输出对图片的识别。
  3. 端到端的好处:
    1. 模型能直接从原始的数据中学习不同模态之间的关联和映射关系,发现隐藏在数据中的复杂跨模态模式,可以 scale up 达到涌现,没有中间折损,可以做到低延时。

原理:基于大语言模型


  1. 多模态大模型以大语言模型为基础模型,复用已预训练好的模型理解能力,在上面增加其他模态的能力,对齐多个模态的特征让原大语言模型能理解。GPT4o 就是在 GPT4 基础上增加音频/图片的特征能力,它在文本上的理解能力还是跟 GPT4 差不多。
  2. 模型通用的基本构造(参考这篇文章):
    1. 编码模块,将图片/视频/音频等模态编码为特征 token,一般还伴随一些压缩的处理。

    2. 投影层(Projector),让不同模态的特征 token 语义对齐,这是模型重点要训练的部分。

    3. LLM,多个模态的特征都在基础 LLM 大模型上做处理理解,通常 LLM 本身也要在新的模态训练过程中做相应微调,适配新的模态。

    4. 若支持多模态输出,也同样有模态对应的投影层和解码层。

      1

当前模型能力


把多模态大模型能力拆分成输入理解、输出生成的话:

  1. 当前主要在发展输入理解部分,较多大模型支持了图片理解、视频理解能力。
  2. 输出生成上,主流的还是各模态各自在发展阶段,如图片生成模型、视频生成模型、音乐生成模型,都是独立单任务模型。GPT4o、gemini 支持了音频的端到端理解和生成,其他大模型基本还只支持文本生成。
  3. 有一些新的模型在尝试大统一,输入输出都支持 文本、图片、音频、视频多种模态,如腾讯刚出的 VITAAnyGPTUnified-IO,都处于起步阶段,看起来综合效果还没很好。

图片理解

通往多模态的第一步,基本都是在LLM上加入图像识别能力,已成为目前大模型标配,这是最自然最广泛的需求,难度也不高。

现状:大部分模型 文心一言,豆包,GPT4o,claude、Gemini 等都支持,开源的 Qwen-VLLLaVAYi-VLMiniCPM-V 等也非常多。

能力:大模型加持的图像识别,各项能力都能胜任,包括OCR、图片物体理解、逻辑理解、文档图表理解、隐喻理解等。

效果:能力比较全面,但也相对平庸,相对垂直领域专门优化的图片识别模型,效果有差距。例如各大模型在OCR能力上的评测,相对最好的OCR垂直模型有差距,更垂直的像植物识别这种,跟PictureThis 这类专门优化过的差距会更大。对图片理解上,结合大模型能力效果会比较好(评测)。图片识别评测维度非常多,有各种维度的评测标准,从个人实际观感上综合识别效果最好的是claude 3.5

原理

Yi-VL 为例,其他模型差不太多,都是在 LLM 基础上增加图像编码处理然后端到端训练 :

2

  1. 图中的Large Language Model是基础模型,Yi-34B-Chat或Yi-6B-Chat。
  2. Vision Transformer(ViT)模块用于图像编码,用CLIP模型。
  3. Projection 模块处理图像特征,训练后的这一层让图像特征跟文本特征空间对齐,包含 layer normalizations 和 Multilayer Perceptron(MLP)。
  4. 火焰标志表示训练,雪花标志标识冻结不训练。训练分了3步,用了不同的 图片-文本 数据对,最后一步 LLM 也参与训练了。
  5. LLaVA/MiniCPM-V也是类似的结构和训练过程,训练最后一步都会微调到LLM基模参数。

应用

  1. 图片搜索、语义搜索、物体识别、人脸识别这些垂类小模型已经能做好。
  2. 给图片配诗、给图片配音、拍照搜题+解题、阅卷、验证图识别等,这些用结合LLM的大模型,门槛会降低,效果也会有优化。
  3. 截屏识别自动化,试卷阅卷,这种场景结合 LLM 才能做好

视频理解

现状:部分主流大模型支持通过把视频抽帧为一系列静态图进入模型分析,本质上是图片理解能力,能做到一定程度的内容理解,GPT4o 基本是这样,一些支持图片识别的大模型稍加调整也能支持这种方式。少部分模型能识别视频和对应的音频,如Gemini、阿里开源的 VideoLLaMA2。有比较多的开源模型在做各种方式的尝试,更好识别视频帧之间的时间逻辑关系、跟音频/文字模态做更好的整合理解。

效果:有个项目 Video-MME 专门分析各大模型视频识别理解能力,测了多个模型在各种理解任务上的表现,包括时间/空间关系的感知和逻辑推理、文字/物体感知、信息总结等,视频类型包括电影、体育、vlog等,能结合整个视频里的信息做理解。各模型在2分钟以内的短视频上理解能力已经不错,中长视频会差比较多,Gemini、GPT4o和效果最好的,开源的模型差距还比较大。

原理

视频理解的主流方法是使用图像编码器从视频中提取帧,对其进行编码,然后用压缩模块压缩视频编码信息,再将其输入到 LLM 中,与文本输入进行联合理解。

也有很多模型在尝试各种方案,如智谱 CogVLM2 加入时间定位、时间戳的数据,让模型能感知视频对应时间。有些模型尝试改造 LLM,不让视觉特征与文本混合,在 LLM 内部增加独立的 transformer 模块处理,如 mPLUG-Owl3

VideoLLaMA2 为例看下大致原理, 综合支持了视频和音频输入,视频和音频分别编码:

  1. 视频按帧编码为特征,经过STC Connector 处理,Spatial Convolution 处理视频帧特征,提取空间信息,Spatial – Temporal Downsampling 降低视频数据维度,再经过投影层与其他模态特征对齐,一起进入大模型。音频也是一样的流程。
  2. 训练分成多个步骤,视频、音频分别单独训练,最后再联合视频音频一起训练,每个步骤有对应的数据集,看起来只有最后一步联合训练,LLM基模的参数才会参与训练。

(题外话,名字叫 VideoLLaMA2,实际上跟Llama没关系,LLM基模用的是Mistral)

3

应用

基于类似的原理,可以自行训练在垂类表现更好的视频模型,例如:

  1. 视频配文案
  2. 视频内容总结、解读
  3. 视频内容搜索(以自然语言搜索长视频特定内容出现位置)
  4. 影视解读(影视时长过长,当前大模型 context 能力还不具备)

音频理解&输出

能力:GPT4o 和 Gemini 都支持了音频理解和输出,能很好理解音频里的语气、语调、节奏、风格等信息,细微的喘息、叹气声都能很好识别和生成,实时性也能做到很高。

原理

目前 GPT4o 和 gemini 相关公开的具体实现细节较少,最基本的原理跟上述应该差不多,语音编码为token→投影层对齐其他模态→输出预测语音token→解码为语音。可以看看 AnyGPT 的实现:

4

应用

最主要的应用是拟人真实程度高的实时语音对话,从GPT4o的演示看,这点对体验影响很大,即使智能能力进步不大,真实性和实时带来的 AGI 感受也是很强。

语音转录、会议记录总结等,虽然已经有很多 ASR 模型能做到转文字,但整个音频的内容、多人对话、语气情绪都能输入大模型,结合大模型理解能力,预计能做到更好的效果。

其他

端到端生成图片 Gemini 号称支持,但没找到相应资料,视频生成单模型都还在摸索,结合 LLM 还早。多模态大模型整体处于发展阶段,各模态的理解和生成还没到很高的水平,整体进展没预期快,但以当前的能力,针对垂直场景做一些训练,是能够较低门槛做出一些之前做不到或做不好的应用了,例如视频配旁白。

[转载] Transformer 里的 Q K V 是什么

作者 wyanassert
2025年1月21日 16:19

原文地址

Transformer 作为新 AI 时代的基石,有必要深入了解下。网上对 Transformer 的教学文章/视频非常多,很多讲得很好,像 3Blue1Brown 的讲解视频,以及这篇文章。整个详细过程原理写不来,本文主要记录一下其中我觉得比较容易混淆的 Attention 模块运算过程,主要是里面的 Q K V 的概念/运算过程/作用。

这是 Transformer 架构图,左边是 encoder,右边是 decoder,实际 LLM 大模型是只由右边 decoder 构成,这里面大部分是常用的 Feed Forward(前馈网络)/ Add(残差连接)/ Norm(层归一化),核心还是 Multi-Head Attention 模块,我们来具体看看 Multi-Head Attention 模块里做了什么。

输入

假设一个字是一个 token,输入是”我有一个玩”(用于推测下一个字”具“),5 个字,每个字用一个向量表示,每个向量假设是 9 维(GPT3 是 12288 维),也就是用 9 个数值表示这个字,那每个词顺序排下来,就组成了 5 行 9 列的输入矩阵,称他为 X,每一行代表一个词。

2

6每一个圈圈代表一个数值。”我“字由蓝色的9个数值表示,“有”字是绿色的9个数值。这 9 个数值组成一个 9 维向量,这里每个字对应的向量值是一开始定好的,至于怎么定的不细说,可以看看相关文章。

这个输入矩阵经过 Multi-Head Attention 模块运算,输出另一个同宽高的矩阵,接下来详细看看这个运算过程。

3

权重矩阵 & Multi-Head Attention

Multi-Head Attention 是由多个 Self Attention 模块拼接而成,如果它只有一个 head,就是一个 Self Attension 模块。

Self Attention

Self Attention 模块里,会包含 Wq Wk Wv 三个参数权重矩阵,模型训练过程就是不断调整 Wq Wk Wv 里的数值。

这几个权重矩阵的行和列数,需要满足:

  1. 行数:输入矩阵 X 会与它们进行相乘,所以行数需要与输入词向量的维度匹配,也就是 9。
  2. 列数:Transformer 中整个 Attention 模块的输入数据和输出数据维度应该是一致的,才能多层重复叠加,从矩阵相乘特性知道,这些权重矩阵的列数也应该对齐词向量的维度,还是 9。

所以如果这里是单个 Self Attention,Wq Wk Wv 就是行数和列数都是与词向量维度一致的矩阵,也就是 9×9。

Multi-Head Attention

但这里希望模型能捕获到单词间的多种不同注意力,所以会把它拆出来再拼接。假设把它拆成 3 个 head,那就是能捕获到 3 种单词之间不同的关系。这里拆出来的 3 个 head 就是 3 个 Self Attention 模块,每个模块有自己的 Wq Wk Wv 矩阵,行列数是 9 x 3。这里每个 Self Attention 独自进行注意力运算后,再组合拼接。

4

这里文字描述得比较绕,见后续运算过程和结果的图示比较清晰。

Attention 运算过程

先来看这里每个 Self Attention 模块的运算过程。

这里输入向量分别与 Wq Wk Wv 相乘,得到新的矩阵 Q K V,Q(query) K(key) V(value) 名字已经对应了它的含义,看完它的运算过程后,再来补充下对它含义的理解。

可以认为这里 Q K V 这几个新的矩阵,每一行仍然是表示一个单词 token 向量,只是换了种表示 (矩阵的乘法特性,例如第一行里的每一个数据都是由原矩阵第一行与 W 矩阵运算得来,与其他行无关)。

下图是 Q 矩阵的运算过程,K V 的过程一样,只是 W 权重矩阵的值不同,略过。

5

接着要做的是,计算每一个单词对于其他单词的 Attention 系数,这是一个两两可重复排列组合。上面 5 个单词,每个单词都 K 矩阵里的自己以及其他所有单词逐一计算出一个值,生成一个 5 x 5 的矩阵。这个矩阵的计算方式就是 Q*KT(K的转置矩阵),由矩阵乘法特性可以看出,这样算出来的矩阵,就是单词之间的关系值,比如第一行第五列数值,就是“我”和“玩”之间的注意力关系值。下图用颜色表示这个过程。

6

相乘后对这个矩阵进行 softmax (在这之前还会除以 √dk 向量维度,可以先忽略),每一行的和都为1,这里的矩阵第 i 行的数据表示的是第 i 个单词与其他单词的关系,这里归一化后,数值可以表示理解为,从全文范围上,每个单词对这第 i 个单词的重要程度比例。

最后这里的 Attention 系数矩阵,与矩阵 V 相乘,得到的是新的结合了每个单词之间 Attention 信息的矩阵。输出的矩阵中每一行还是表示一个单词,但这个单词向量经过这里注意力运算后,每个单词向量都集合了上下文每个单词的注意力信息。

7

单独拆除这里的第一行看看它的意义,单词”我“跟每一个字的注意力权重,再乘以每个字在 V 矩阵里的向量表示,结果再相加,组成最后的结果。比如这里第一个字”我“跟第三个字”一“的权重是0.1,那”一“的向量值对运算后最后表示”我“这个字的向量结果影响很小,如果是 0 就是没有影响。

8

上述整个过程,可以用这个数学公式表示:

9

Multi-Head Attention 模块里每个 Self Attention 模块都做同样的运算(但里面的 Wq Wk Wv 权重不同,数值结果不同),拼接起来,形成最终的结果,这个结果矩阵里,每一行每个字的表示,都已经集合了与其他所有字的注意力关系信息。

10

整个过程实际上还有个掩码的机制,按上述运算,这里输出的每个单词向量都包含了上下文所有的信息,通过掩码机制,会变成每个单词只包含单词所在前面位置的信息,比如第二行“有”只包含了“我”和“有”的信息,没有后面”一“”个“”玩“的信息。这里不继续展开了。

这里每一行包含了前面所有单词的注意力信息,也就可以通过这里的表示预测下一个单词,所以从这个矩阵最后一行“玩”的向量数值,就可以用于预测对应下一个单词是什么。

整个 Multi-Head Attention 的运算过程大致是这样了。实际模型如 GPT3,单词向量维度是12288,上下文长度2048(每个 token 都要跟2048个token计算注意力),每个 Multi-Head Attention 分成 96 个 head,同时有 96 层叠加,也就是 96 个 Multi-Head Attention,运算量是巨大的。

Q K V 的作用

Q 可以理解为原输入的词数据,拿着这个数据找谁跟我有关系。K 是被找的数据,用于计算输入的每个词之间的关系。Q 和 K 是为了算出 Attention 关系系数,知道每个 K 的数据跟 Q 是什么关系。

如果 Q 和 K 是同个输入变换来的,那就是自注意力,如果是不同输入变换来,那就是交叉注意力,比如 Stable Diffusion 里 Unet 的交叉注意力模块中,Q 是文字 prompt,K 和 V 是图片信息,Q 与 K 计算的是文字与图片信息的 Attention 关系系数。

K 和 V 是同个数据源,这个数据源,从 Q 和 K 的运算知道每个 Q 与数据源的关系系数,再与数据源做运算就是把这个关系数据作用到源数据上,源数据去做相应偏移,也就是可以在 Q 的作用下对源数据做相应推测。

感想

为什么这样一个算法架构,能衍生出智能,而且这个架构能扩展到多模态,语音、图像、视频基于它都有非常好的效果?我个人理解,最核心有两个点:

  1. 上下文信息充足
  2. 并行计算能力强

其他算法架构如果能充分融入上下文信息,规模大了也能有智能,只是 Transformer 可并行运算的特性,让目前的计算机算力可以触摸到涌现的那个点。

[转载] 使用火山引擎 APMPlus 优化 iOS 内存性能的全套指南

作者 wyanassert
2025年1月20日 21:29

原文地址

前言

本文面向 iOS 研发,不会涉及复杂的底层原理,而是直接告诉 iOS 研发答案,即怎么做,只需要花半小时阅读本文,就可以在开发需求的时候,知道如何更好利用内存来提升用户体验,同时避免稳定性相关问题给业务带来负向的用户体验;同时本文作者的初心是希望这篇文章能成为研发同学的一个”字典”,可以在一些特定场景或者感觉可能会踩内存坑的时候翻阅,快速找到最佳的编码规范。

为什么需要合理使用内存资源

在编程中有一个经常使用到优化技巧:空间换时间。对于 iOS 研发人员来说,平时的开发工作中同样也会遇到空间换时间,并且一般还是拿内存空间换时间(少部分还涉及到磁盘空间,本文只讨论内存),例如提前下载、解码一张图片,在需要时候直接展示在屏幕上,避免临时解码不能给用户带来极致的用户体验。

内存资源对于我们设计良好的策略以提升用户体验很有帮助,我们应当尽量充分利用手机内存资源,为用户带来最优的用户体验。同时我们也需要注意到,不合理的,无节制的使用内存资源,是不能给用户带来最佳体验的,因为设备资源有限,使用了太多内存,首先会带来的是性能问题,如果超过了限制,系统就会
Kill 我们的应用
。下表是各种内存大小的手机,最多可用的内存大小:

descript

部分研发人员可能在这里会有误解,认为自己的业务逻辑使用更多内存,肯定对自己业务体验是没有负向的。但是可用内存变少后,系统为了缓解内存压力,系统层面进行内存压缩、数据重加载等,应用整体性能下降,会出现卡顿甚至卡死等体验问题;同时增加了设备能耗,造成设备发热;极端情况系统则会
kill 应用。这是一个多输局面,所以我们需要知道如何合理的利用内存,保证最佳的用户体验。

了解 OOM 崩溃

不合理使用内存,带来最严重后果就是 OOM 崩溃,下面简单介绍 OOM
最基本两个概念:

什么是 OOM 崩溃

OOM 其实是 Out Of Memory 的简称,指的是在 iOS 设备上当前应用因为内存占用过高而被操作系统强制终止,在用户侧的感知就是 App 一瞬间的闪退,与普通的 Crash 没有明显差异。

OOM 的监控原理

由于我们不能直接监控到 OOM 崩溃,所以业界目前监控方式都是排除法,简单来说,我们排除所有已知的原因,那剩下的未知异常退出,就认为是 OOM 崩溃。

Jetsam 日志

当我们在调试阶段遇到这种崩溃的时候,从设备设置->隐私->分析与改进中是找不到普通类型的崩溃日志,只能够找到 Jetsam 开头的日志,这种形式的日志其实就是 OOM 崩溃之后系统生成的一种专门反映内存异常问题的日志。

descript

上图是截取一份 Jetsam 日志中最关键的一部分。关键信息解读:

  • pageSize:指的是当前设备物理内存页的大小:16KB。
  • states:当前应用的运行状态,对于 Inhouse 这个应用而言是正在前台运行的状态,这类崩溃我们称之为 FOOM(ForegroundOut Of Memory);与此相对应的也有应用程序在后台发生的 OOM 崩溃,这类崩溃我们称之为 BOOM(Background Out Of Memory)。
  • rpages:是 resident pages 的缩写,表明进程当前占用的内存页数量,该进程占用内存=内存页数量*16KB。

通过 APMPlus 内存监控发现异常问题解决方案

1.Block 里面混用 weakSelf 和 self

为了避免混用 weakSelf 和 self,推荐使用 weakify 和 strongify。

1
2
3
4
5
6
7
8
9
10
11
12
13
14

__weak typeof(self) weakSelf = self;
[self doSomethingWithCompletion:^(){
[weakSelf foo];
[self bar];//一般是添加的新代码,不知道前面有weakSelf
}];


@weakify(self);
[self doSomethingWithCompletion:^(){
@strongify(self);
[self foo];
[self bar];
}];

2.嵌套的 Block,每个 Block 都需要注意循环引用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

@weakify(self);
[self doSomethingWithCompletion:^(){
@strongify(self);
[self fooWithCompletion:^(){
//嵌套block,如果存在循环引用,也需要用weak来解耦
[self foo];//self refers to the original self pointer
}];
[self bar];
}];


@weakify(self);
[self doSomethingWithCompletion:^(){
@strongify(self);
[self fooWithCompletion:^(){
@strongify(self);
[self foo];
}];
[self bar];
}];

3.Block 里使用了 Super

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

[self doSomethingWithCompletion:^(){
[super foo];//super 是个编译器指令,也会引用到self,所以需要分析是否存在循环引用环;
//并且这里没法用Weakself来解耦
}];


@weakify(self);
[self doSomethingWithCompletion:^(){
@strongify(self);
[self xxMethod];
}];

用方法包一层,间接调用super
- (void)xxMethod {
[super foo];
}

4.Block 里使用的外面对象都需要分析是否存在循环引用

1
2
3
4
5
6
7
8
9
10
11
12
13
14


//不能认为Block里没有引用self,就不需要分析是否有引用环
[v bk_whenTapped:^{
v.backgroundColor = [UIColor redColor];
}];
//v -> tap_block -> v 导致循环引用


@weakify(v);
[v bk_whenTapped:^{
@strongify(v);
v.backgroundColor = [UIColor redColor];
}];

5.Block 里面使用了宏,而宏定义里隐式引用了 self

1
2
3
4
5
6
7
8
9
10
11
12
13
14


RACSignal *signal3 = [anotherSignal flattenMap:^(NSArrayController *arrayController) {
// Avoids a retain cycle because of RACObserve implicitly referencing self.
return RACObserve(arrayController, items);
}];


@weakify(self);
RACSignal *signal3 = [anotherSignal flattenMap:^(NSArrayController *arrayController) {
// Avoids a retain cycle because of RACObserve implicitly referencing self.
@strongify(self);
return RACObserve(arrayController, items);
}];

6.避免循环导致的 AutoreleasePool 内存堆积

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28


for (...) { //如果循环长度不确定,就需要用autoreleasepool包起来
@autoreleasepool {
...
// -[NSString stringWithFormat:] is a typical method that returns an autorelase object.
NSString *foo = [NSString stringWithFormat:@"bar %d", i];
...
}
}

或使用enumerateObjectsUsingBlock: / enumerateKeysAndObjectsUsingBlock: 代替for循环遍历

//array is an NSArray
[array enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) {
...
// -[NSString stringWithFormat:] is a typical method that returns an autorelase object.
NSString *foo = [NSString stringWithFormat:@"bar %d", i];
...
}];

//dictionary is an NSDictionary
[dictionary enumerateKeysAndObjectsUsingBlock:^(id _Nonnull key, id _Nonnull obj, BOOL * _Nonnull stop) {
...
// -[NSString stringWithFormat:] is a typical method that returns an autorelase object.
NSString *foo = [NSString stringWithFormat:@"bar %d", i];
...
}];

7.避免串行队列导致的 AutoreleasePool 内存堆积

对于串行队列,如果应用性能遇到问题,或异步到队列任务太多,都会导致串行队列 AutoreleasePool 里的对象不能及时释放,强烈建议使用 xxx_dispatch_async_autorelease 来代替 dispatch_async。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

dispatch_async(queue, ^{
//stuff you want to do
...
});

✅ Highly recommended
在xxxMacros.h封装一层
NS_INLINE void xxx_dispatch_async_autorelease(dispatch_queue_t _Nonnull queue, dispatch_block_t _Nonnull block)
{
dispatch_async(queue, ^{
@autoreleasepool {
block();
}
});
}

#import <xxxMacros.h>
使用xxx_dispatch_async_autorelease替换dispatch_async
xxx_dispatch_async_autorelease(queue, ^{
//stuff you want to do
...
});

8.注意 KVOController 造成的循环引用

KVOController 会持有 observee,所以当 observe self 的时候,就需要判断是否会造成循环引用。原因参考:https://www.jianshu.com/p/22c5024cc3c0。

1
2
3
4
5
6
7
8
9
10
11
12
13


[self.KVOController observe:self keyPath:@"foo" options:NSKeyValueObservingOptionNew block:^(id _Nullable observer, id _Nonnull object, NSDictionary<NSString *, id> *_Nonnull change) {
...
}];
//self(observer) -> self.KVOController -> self(observee) ,造成循环引用


- (void)setFoo:(FooClass *)foo
{
_foo = foo;
//do your own stuff
}

9.Runtime 相关函数导致的内存泄漏

例如 class_copyPropertyList 函数:

descript

这部分函数非常多,Xcode
在每个函数注释里都标注了需要释放,下面只列出函数,具体可看函数注释。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

// 建议:调用runtime相关函数,一定要看下函数注释!
class_copyPropertyList
method_copyReturnType
class_copyMethodList
property_copyAttributeList
objc_copyClassList
class_copyIvarList
class_copyProtocolList
method_copyArgumentType
property_copyAttributeList
objc_copyProtocolList
protocol_copyMethodDescriptionList
protocol_copyPropertyList2
protocol_copyProtocolList
objc_copyImageNames

10.RACSubject 内存泄漏

使用 RACSubject,如果进行了 map 操作,那么一定要发送完成信号,不然会发生内存泄漏。

1
2
3
4
5
6
7
8
9

RACSubject *subject = [RACSubject subject];
[[subject map:^id(NSNumber *value) {
return @([value integerValue] * 3);
}] subscribeNext:^(id x) {
NSLog(@"next = %@", x);
}];
[subject sendNext:@1];
[subject sendCompleted]; //✅一定要发送完成信号,不然会内存泄漏(或调用了sendError:函数)

11.dispatch_after 延迟执行导致的内存泄漏

虽然 dispatch_after 不是循环引用,但是也会造成 self 在 1000s 后才释放,一般情况下不会使用 dispatch_after delay 1000s,但是在复杂的业务场景中可能存在复杂的 dispatch_after 嵌套等情况。解决办法是使用 weakify(self), 如果 self 已经释放就直接进行 return。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

NSTimeInterval longTime = 1000.f;
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(longTime * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
[self doSomething];
});


NSTimeInterval longTime = 1000.f;
@weakify(self);
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(longTime * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
@strongify(self);
if (!self) {
return;
}
[self doSomething];
});

12.其他延迟执行导致的内存泄露

1
2
3
4
5
6
7
8
9
10
11
12
13
14

- (void)viewDidLoad {
NSTimeInterval longTime = 1000.f;
[super viewDidLoad];
[self performSelector:@selector(xxMethod) withObject:nil afterDelay:longTime];
}
当执行[self performSelector:@selector(xxMethod) withObject:nil afterDelay:longTime];代码的时候会对self进行一个捕获,当前self的引用计数进行+1直到延迟方法执行后才会进行-1操作。


方案一:提前主动取消调用
[NSObject cancelPreviousPerformRequestsWithTarget:self selector:@selector(xxMethod) object:nil];


方案二:使用定时器,并且不持有self方式来调用

13.dispatch_group_enter 和 dispatch_group_leave 不匹配导致的内存泄漏

1
2
3
4
5
6
7
8
9
10
11
dispatch_group_t group = dispatch_group_create();
dispatch_group_enter(group);
[self fetchXXMethod:^(UIImage *image) {
...
//如果这里的dispatch_group_leave得不到调用,就会出现内存泄漏
//dispatch_group_leave(group);
}];

dispatch_group_notify(group, dispatch_get_main_queue(), ^{
xxx
});

14.不要忘记释放 Core Foundation 对象

1
2
3
4
5
6
7
8
9
10
11

如果Core Foundation是从函数名里有“create”或“copy”的函数创建得到的,你有责任释放这个对象

CFStringRef str = CFStringCreateWithCString(NULL, "Hello World", kCFStringEncodingASCII);
...
CFRelease(str); //不再需要后,需要手动释放


CGImageRef imageRef = CGImageCreateWithImageInRect(self.CGImage, rect);
UIImage *image = [UIImage imageWithCGImage:imageRef scale:self.scale orientation:self.imageOrientation];
CGImageRelease(imageRef); //不再需要后,需要手动释放

15.使用 NSCache 去代替 NSDictionary / NSMutableDictionary 缓存对象**

NSCache 是苹果官方提供的缓存类,使用该类有如下优点:

  1. NSCache 是一个类似 NSDictionary 一个可变的集合。
  2. 提供了可设置缓存的数目与内存大小限制的方式。
  3. 保证了处理的数据的线程安全性。
  4. 缓存使用的 key 不需要是实现 NSCopying 的类。
  5. 当内存警告时内部自动清理部分缓存数据。

具体使用可参考developer.apple.com/nscache

16.单例对象不要持有大内存

单例对象不会被释放,如果持有了例如图片等大内存会导致应用内存水位在整个生命周期都会升高。

17.Model 对象强持有 image

如果 model 强持有 image,会导致应用在内存紧张的时候不能及时释放内存;model 只需要强持有 url,model 对应的 view,可以强持有 image,在 view 展示时候,持有的 image 不会释放,当 view 被释放了,image 会被 XXWebimage(图片库一般都有缓存管理) 管理,在内存充足时候,这些 image 都会被缓存起来,只有应用快 OOM 时候,缓存才可能被释放。这种策略既保证了用户体验,也避免了 OOM 崩溃。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

@interface xxDataModel : NSObject
@property (nonatomic, strong, nullable) UIImage *image;
...
@end

// Highly Recommended
@interface xxDataModel : NSObject
@property (nonatomic, strong, readonly, nullable) NSURL *imageURL;
...
@end

// OK (if applicable)
@interface xxDataModel : NSObject
@property (nonatomic, weak, readonly, nullable) UIImage *wkimage;
...
@end

18.Swift 的 func allocate函数,申请的内存需要释放

分配内存时需要记住在分配完成后释放内存。

descript

19.getifaddrs 和 freeifaddrs 函数需要配套使用

getifaddrs() 返回的数据是动态分配的,当不再需要时应使用 freeifaddrs()
进行释放。

1
2
3
4
struct ifaddrs *addrs;
int retval = getifaddrs(&addrs);
// do something
freeifaddrs(addrs); //don't forget to free memory

20.异常导致内存泄漏

其实异常不止导致内存泄漏,还会导致各种资源泄漏,甚至导致死锁发生,由于通常情况下异常本身发生的概率很低,所以除非在该路径有大内存泄漏需要特别注意,一般情况下不需要特别关注。反而是加解锁等操作需要注意,因为一般在加解锁操作中发生异常很容易造成死锁发生。

解决方案:
RAII(Resource Acquisition Is Initialization)是 C++ 之父 Bjarne
Stroustrup 在设计 C++ 异常时,为解决资源管理的异常安全性提出的一种技术:使用局部对象来管理资源。这里的资源指:内存、锁、网络套接字、fd、数据库句柄等,简而言之是任何需要释放的计算机资源。

RAII 要求资源的有效期与持有资源的对象生命周期严格绑定:即对象的构造函数完成资源的分配(获取),同时对象的析构函数完成资源的释放。那么这样后,就只需要正确管理对象的生命周期,就不会出现资源管理问题(特别是异常安全性可以得到保证)。

21.方法命名违反 ARC 约定

参考

1
2
3
4
5
6
7
8
9
10
11
12
ethods in the alloc, copy, init, mutableCopy, 
and new families are implicitly marked __attribute__((ns_returns_retained)).
then the caller expects to take ownership of a +1 retain count.

- (EMProduct *)newProduct {
...
}

NSObject *obj = [NSObject performSelector:@selector(newXXMethod)];


如果不是预期引用计数+1,函数名中不要包含alloc, copy, init, mutableCopy, new 这些字符串。

当然,除了上述 21 条规则外,还有很多内部 SDK 使用的编码规范,例如图片、网络等 SDK,都是容易导致内存问题的 SDK,这些规则就不在这里列举了。

APMPlus iOS 内存监控相关功能

OOM 崩溃

通过在崩溃趋势中筛选 OOM 崩溃,或直接在内存优化模块下打开 OOM 趋势,可查询 OOM 崩溃相关的指标以及具体的 Issue。

descript

Memory Graph

接入指南:https://www.volcengine.com/docs/6431/1175759#%E5%86%85%E5%AD%98%E4%BC%98%E5%8C%96

 在 OOM 崩溃中过滤有无 Memory Graph 文件 ,如果有的话点击进入 Issue 详情后可以跳转至单设备内存详情进行分析。

descript

descript

或者直接点击菜单单设备内存详情,查看上报的所有 MemoryGraph 文件,点击查看详情进入详情页分析内存。

descript

Memory Graph 分析方法参考:https://www.volcengine.com/docs/6431/68858#%E6%9C%80%E4%BD%B3%E5%AE%9E%E8%B7%B5

关于 APMPlus

APMPlus

是火山引擎下的应用性能监控产品,通过先进的数据采集与监控技术,为企业提供全链路的应用性能监控服务,助力企业提升异常问题排查与解决的效率。基于海量数据的聚合分析,平台可帮助客户发现多类异常问题,并及时报警做分配处理。目前,APMPlus
已服务了抖音、今日头条等多个大规模移动
App,以及教育、健身、图书、物流等多个行业的外部客户。

descript

APMPlus APP 端监控中的 iOS 方案提供了崩溃、卡死、OOM 崩溃、Extension
崩溃等不同的异常类别监控
,以及启动、页面、卡顿等流畅性监控,还包括内存、CPU、磁盘、MetricKit
等资源消耗问题的监控。此外,APMPlus 提供的网络耗时和异常监控,拥有强大的单点分析和日志回捞能力。在数据采集方面,提供灵活的采样和开关配置,以满足客户对数据量和成本控制的需求,只按事件量收费,不限制用户数。针对跨平台方案,其提供了 WebView 页面的监控。丰富的能力满足客户对 App 全面性能监控的诉求。

iOS 方案亮点

  • MemoryGraph提供应用内存全景,准确定位内存问题,引用关系泄漏对象、大对象一目了然。
  • 强大的工具箱助力解决线上疑难崩溃问题野指针归因让 OC 野指针无处遁形、GWPAsan 通过记录内存分配和释放堆栈协助高效分析内存踩踏、Coredump 还原崩溃现场数据为崩溃分析提供全面的上下文相关信息。
  • 高性能日志库,做到数据稳定性强、性能好,保障了现场业务信息的高度还原。
  • 结合系统的 MetricKit数据,磁盘、CPU、流量等数据全面收集,真正做到监控无死角。

descript

[转载] 告别构建错误, iOS 开发架构难题全面解析, 避免 CPU 架构陷阱

作者 wyanassert
2025年1月15日 16:15

原文地址

前言

如果你经常开发 iOS 中的第三方框架,那么你可能会遇到以下错误:

1
"Could not find module *** for target 'x86_64-apple-ios-simulator'."

或者:

1
"building for iOS Simulator, but linking in dylib built for iOS, file, '.../Frameworks/xxx.framework/xxx' for architecture arm64."

要解决这个问题,我们需要了解 CPU 架构和 Xcode 构建设置的一些知识,今天我们就来聊聊这个。

理解 CPU 架构

每个 CPU 都有一组可以执行的指令。这些指令主要分为两种类型:

CISC(复杂指令集计算)

  • 复杂且强大的指令。
  • 每条指令执行多个任务。
  • 例如:x86 处理器(由 Intel 和 AMD 使用)。

RISC(精简指令集计算)

  • 简单且快速的指令。
  • 每条指令执行一个任务。
  • 例如:ARM 处理器(由 Qualcomm、MediaTek 和苹果的 M1 芯片使用)。

什么是 32 位和 64 位

32 位:一次可以处理 32 位数据。
64 位:一次可以处理 64 位数据,允许更多的计算能力和内存使用。

常见架构

x86:Intel 和 AMD 使用的 32 位架构。

x86_64:x86 的 64 位版本,更强大,能处理更多数据。

ARM:Qualcomm 和 MediaTek 使用的 32 位架构。

ARM64:ARM 的 64 位版本,更强大,苹果的 M1 芯片使用。

主要的制造商

Intel 和 AMD:制造 x86 和 x86_64 处理器。

Qualcomm 和 MediaTek:制造 ARM 和 ARM64 处理器。

Apple:在 Apple Silicon 系列(M1、M1、M2、M3、M4 等)Mac 中使用
ARM64 处理器。

向 M 系列过渡与 Rosetta 的作用

M 系列处理器的引入,始于 M1,标志着苹果及其生态系统的重大转变。由于 M 系列基于 ARM,现有为 x86 构建的软件无法在这些新芯片上原生运行。为弥补这一差距,苹果推出了 Rosetta,它是一种兼容层,主要作用是允许 x86 软件在 M 系列处理器上运行。

M1 MacBooks 推出后,Xcode 最初就是使用 Rosetta 支持 x86 应用程序。

虽然这让开发者可以继续无缝工作,但 Rosetta 只是 Apple Silicon 过渡期的临时解决方案。随着 Xcode 12 的推出,苹果使 Xcode 能够在 ARM 上原生运行,全力支持 M 系列 MacBooks,而不再依赖于 Rosetta。

iOS 14 之前的模拟器仅限于 x86,并通过 Rosetta 在 M 系列 Mac 上运行。自 iOS 14 起,模拟器更新支持 ARM 和 x86,这意味着虽然模拟器可以在 M 系列 Mac 上原生运行,但未为 ARM 优化的应用程序仍会通过 Rosetta 运行。这种双重架构支持确保了过渡期间的兼容性和性能。

要检查你的应用程序正在使用哪种架构,你可以使用活动监视器。在活动监视器中,有一个名为”Kind”的列,显示应用程序是运行在 Intel(x86)还是 Apple(ARM)架构下。这一功能仅在 M 系列 Mac 上可用。

descript

Apple 物理设备架构

  • arm64:也称为 AArch64,现代 64 位 iOS 设备(iPhone 5S及更新机型),包括 A7、A8、A9、A10 和 A11 芯片的设备。
  • arm64e:较新的 64 位 iOS 设备,带有 A12仿生芯片及更新版本(例如,iPhone XS、XR、11、12、13 等)。
  • armv7:较旧的 32 位 iOS 设备(iPhone 3GS、4、4S)。
  • armv7s:略新的 32 位设备(iPhone 5、5C)。

Apple 模拟器架构

  • x86_64:基于 Intel 的 Mac 上的模拟器。
  • i386:用于较旧 iOS 版本的 32 位模拟器(主要是遗留支持)。
  • arm64:Apple Silicon(M1、M2)Mac 上的模拟器。

在了解了 CPU 架构基础知识、列出设备和模拟器架构,并讨论了 M
系列的历史和 Rosetta
的作用后,为解决之前提到的错误,我们需要知道如何找出我们集成的框架所支持的架构。

此外,我们需要调整构建设置,如 EXCLUDED_ARCHS 和
ONLY_ACTIVE_ARCH。接下来聊聊这些。

确定支持的架构

要确定 .xcframework 支持的架构,我们可以在终端中使用 lipo -info 命令。通过检查 .xcframework 中的目录,我们可以识别出支持哪些架构。以下是使用 lipo -info ~/Downloads/Bugly.framework/Bugly 查看 Bugly 2.6.0 版本的示例:

descript

根据命令输出可以看出 Bugly 2.6.0 版本支持 armv7、i386、x86_64 和 arm64 架构。

理解 EXCLUDED_ARCHS 和 ONLY_ACTIVE_ARCH

EXCLUDED_ARCHS

  • 定义:Xcode 中的一个构建设置,用于指定在构建目标时要排除的架构。
  • 用途:排除某些架构以避免兼容性问题或不必要的构建。
  • 示例:EXCLUDED_ARCHS[sdk=iphonesimulator*] = arm64 在为 iOS 模拟器构建时排除 arm64 架构,以确保兼容基于 Intel 的 Mac 上的 x86_64 模拟器。

ONLY_ACTIVE_ARCH

  • 定义:一个构建设置,决定 Xcode 是仅构建活动架构还是所有指定架构。
  • 用途:通过仅构建活动架构来加快开发过程中的构建速度。
  • 示例:ONLY_ACTIVE_ARCH = YES 仅构建活动架构,在开发过程中减少构建时间。通常在 Debug 配置中设置为 YES,在 Release 配置中设置为 NO,以确保最终构建支持所有所需架构。

说白了一个是黑名单,一个是白名单。

之前提到的错误

错误消息”Could not find module *** for target ‘x86_64-apple-ios-simulator’.”通常表示我们尝试使用的框架或模块不可用于我们目标的架构,它需要原生运行而不是使用 Rosetta,但我们试图导入仅为 x86_64 构建的框架或可测试应用:

descript

解决方法

我们可以实施一种变通方法来让测试目标运行,但这取决于使用的机器:

Apple Silicon M 系列:在 EXCLUDED_ARCHS 中排除 arm64。

descript

基于 Intel 的:在 Debug 模式下使用”仅构建活动架构”,这将允许项目成功运行。

descript

更好的解决方案

如果你可以控制框架的构建,建议打包的时候支持缺失的架构,特别是 arm64 模拟架构。

在分发的时候,优先使用 ios-arm64_i386_x86_64-simulator 或 ios-arm64_x86_64-simulator,更旧的模拟器一般就不用支持了。

如果你无法控制框架的构建,也无法联系开发者,你需要使用 EXCLUDED_ARCHS 排除缺失的架构。然而,这种方法可能限制你只能在物理设备上运行,特别是对于 Apple M 系列。此外,它要求你在使用此框架的所有依赖项中排除缺失的架构。例如,如果你使用的核心框架依赖于缺少架构的库,则核心框架和导入核心的项目都必须排除相同的架构。因此,在解决此类错误时要谨慎和耐心。

[转载] 理解 Stable Diffusion UNet 网络

作者 wyanassert
2025年1月3日 17:09

原文地址

前面的学习中,我们把 SD UNet 网络当成黑盒,不太影响对图片生成大致原理的理解,但在继续学 SD 的过程中,发现 ControlNet、T2I-Adapter、IPAdapter 等这些技术,都是在原 SD 网络模型上以各种方式对网络做修改叠加,要理解这些技术,还是得先了解下 SD UNet 网络结构的一些细节,不然看得很费劲。

SD 模型构成


从之前的学习我们知道,Stable Diffusion 模型里包含了三个组件:CLIP、VAE、UNet,这三个组件的参数和大小分布(来源):

组件 参数个数 文件大小 占比
CLIP 123,060,480 492 MB 12%
VAE 83,653,863 335 MB 8%
UNet 859,520,964 3.44 GB 80%
Total 1,066,235,307 4.27 GB 100%

整个生图的核心网络就是 UNet。UNet 最初是用于医学图像分割任务而提出来的,但它的特性展现了在图像其他领域的应用潜力,后续经过扩散模型的改进,很好应用在了图像生成上,所以 Stable Diffusion 的 UNet 实际上在原 UNet 网络架构上做了一些改造。

基础 UNet 网络


我们先来看看原 UNet 网络架构:

1

  1. 左边输入图片,经过整个网络处理,右边输出同尺寸图片。(原 UNet 网络用于医学图像识别分割,所以图上右边标的输出是一张同尺寸分割图。SD 这里的输出是降噪图)
  2. 左边下采样(也可以称为编码器),右边上采样(也可以称为解码器),一张图片经过一层层下采样计算,尺寸逐渐减小(图中的网络是减小到32×32),再经过右边层层上采样,恢复到原尺寸。那这里下采样和上采样的作用是什么?
  3. 下采样,是使用某种计算方式让更小的数据表示整张图片,这更小的数据代表了对这张图片高纬度的描述,而不是像素级细致的描述。
    1. 越小的数据对图片的表示和描述越宏观,有利于捕捉图片的语义特征。
    2. 例如一张猫在屋子前玩耍的地图,原图能看清所有细节,但因为细节太多,模型想要知道图里有猫和屋子,得把每个像素组合运算才行,但下采样到最小,最宏观的猫和屋子就容易识别。
  4. 上采样,是让图片的宏观小尺寸表示恢复成原图片尺寸。
    1. 比如对于图片分割(把图片上的物体分割出来),我们在下采样后的小数据量的高维表示里识别了图片的主体、边缘,最后还是要转回在原尺寸图片上表示,不然识别了也没用。
    2. 那不断下采样过程中肯定把图片细节都丢失了,再上采样,怎么可能还原图片细节?那就要说到跳跃连接(skip connection)了。
  5. 跳跃连接,也就是并不是顺着网络的方向连接,而是跳过原网络方向,跳着连接传输信息。说得有点拗口,看图很容易理解,就是图上中间的几条灰色箭头。
    1. 原网络连接方向是图片输入→下采样各节点→上采样各节点→输出图片这个链路,就是图中U字型的路径。
    2. 在这个路径之外,左边的下采样的每一层,都额外连接到右边上采样对应的层上面,将两个网络进行拼接。
    3. 上采样每一层,都在拼接了左边下采样对应层的数据后,再一起作为下一层上采样的输入。
    4. 为什么这样做,很容易理解,左边的每一层网络都保留了图片不同程度的细节,右边的每一层因为是上采样过来的,只有宏观信息,没有图片细节,那把左边图片细节信息拼接过去,右边这个网络宏观特征和微观细节都具备了,每一层都有不同程度的对图片的宏观语义理解和微观细节,就能做各种事情了,包括图片分割、语义生成图片。

UNet 网络大致思路是这样,这里面具体的卷积运算和公式,不看应该不影响对整体思路和作用的理解。

Stable Diffusion UNet 结构


最初的 DDPM(去噪扩散概率模型),和后来改良的 LDM(潜在扩散模型),对 UNet 网络逐步做了一些改造,以适合扩散模型图生成的过程,SD 是基于 LDM 实现的。

最后 SD 里的 UNet,整体结构流程跟上述一致,改造大部分是在上采样和下采样的每一层的实现里,最大的改造是引入了 ResnetBlock(残差模块)和 Transformer 模块。ResnetBlock 提升网络表达能力(原 UNet 是简单卷积模块),而 Transformer 模块的交叉注意力机制,将文本提示(prompt)的嵌入与图像特征进行融合,实现基于文本条件的图像生成。

SD UNet 每个模块的组成如图(图片来源):

2

左边下采样每层由2个残差模块和2个Transformer模块连接组成,右边上采样是各3个,中间层是2个残差模块和1个Transformer模块。(高维的d4和u1没有接入Transformer模块,原因不明,可能是试过加入后效果不佳,在高维这里加入 Prompt 交叉注意机制,文字权重太大?)

细分模块结构

里面每一块具体的结构这篇文章画得很详细,摘录学习一下。我们拿其中一个下采样模块看看:

3

两个残差模块,两个Transformer模块。这图表示了 SD 生图的三个输入:input(噪声图)、prompt_embdding(文字 Prompt)、time_embdding(步数)在这几个模块的流转和处理。这里每一个小模块处理完后,输出的可以近似认为都是一个预测的噪声图的数据表示。

残差模块的输入输出 噪声图+步数 → 预测噪声图,Transformer 模块的输入输出是 噪声图+ Prompt → 预测噪声图。

Transformer 模块

再细看一下 Transformer 模块,Transformer 模块由下图所示好几个部分组成,最主要的是 自注意力模块(SelfAttention)和交叉注意力模块(CrossAttention):

4

展开看看这两个模块:

5

自注意力模块,Transformer 结构里的 QKV 输入都是图片特征(上一层的处理结果,就是降噪图的特征),这样做可以让模型获得包含整个输入图像的感受野,捕捉图片特征中不同位置之间的关系, 全局感受力是 Transformer 架构的特点。

交叉注意力模块,它的作用是融合不同模态的输入,在这里就是融合噪声图和文本特征,Q的输入是图片特征,KV的输入是文字 prompt_embedding,让图片特征可以关注到文字输入,根据注意力权重调整图片的生成方向。文字 prompt 在整个Transformer模块中只作用在交叉注意力这部分里。

Transformer 的机制原理、QKV的含义,是另一个比较大的话题,可以先看看网上其他相关讲解,比如这篇,后续再细拆深入。

回顾

关键几个模块的组成了解了,再回到整个UNet的构成:

6

现在通过这些结构图,可以大致看到 UNet 网络里的整体处理流程,以及关键模块的作用,经过这些模块的逐个叠加,组合成一个个采样模块,再组合成 UNet 网络架构,完成整个生图运算。

这里面还有很多需要深入学习的点,当前先了解到这个维度,已经可以帮助大致理解后续 ControlNet 等网络的机制原理。

参考资料


本文转自 https://blog.cnbang.net/tech/3823/,如有侵权,请联系删除。

[转载] Stable Diffusion 图片生成原理简述

作者 wyanassert
2025年1月3日 16:40

原文地址

最近关注和学习 AI 比较多,包括 AIGC 和 LLM 大模型,现在 AI 的原理介绍和入门教程已经非常多了,但还是想自己写一下,主要是遵从费曼学习法,分享是最好的学习,帮助自己整理思路。

本文介绍这一轮图片生成热潮的集大成者 Stable Diffusion 涉及的一些图片生成基本原理,这里全篇不会有数学公式,因为大部分公式我也不懂,但应该不会太影响理解基本原理和思路,有理解错误的地方欢迎指正。

扩散模型


在看图片生成这个逆天能力的时候,很好奇它是怎么做到的。要完全理解这里面的算法细节门槛挺高,但要了解基础原理概念还是简单的。

目前市面上文字生成图片基本上都基于 Diffusion 扩散模型,Stable Diffusion 自然也是,它最基本的原理是:根据文字指示,把一张随机生成的全是噪点的图片,一步步去掉噪点生成跟文字描述匹配的图片。

具体是怎样做到的?这里可以分步看两个问题:

  1. 怎么从一张随机噪点的图生成一张正常的图
  2. 怎么控制这个生成的图跟输入的 prompt 文字关联上

先看第一个问题,从随机噪点图生成一张正常图片,通过训练和组合 UNet 模型可以做到。

单步训练-生成

UNet 是个深度学习网络模型,模型细节不说,个人可以非常粗略地理解为,这个 UNet 模型里面的参数,记录了训练的图片的内容,但它不是精确存储,而是有一些映射和数学运算,做到可以识别提取图片特征,模糊地记忆图片的关键信息,混合存储。

这个模型训练出来后,如果你是用一张图片玩命训练它,那它最终恢复出来的就是这张图片(maybe?)。如果你用1万张图片训练这个模型,那它恢复出来的会是这一万张图片内容随机组合的一张图片。

1

下面稍微再展开看下训练过程和生成过程:

  1. 选一张正常图片A,随机生成一个噪声X,给A加上这个噪声。A+X=A1。
  2. 把A1输入到模型,我们希望做到的是,模型能在只有输入A1,没有输入噪声X的情况下,能自己推理知道噪声X是什么,如果能做到,那就可以通过A1 – X = A,把更清晰的图给反解出来了,也就是输入一张有噪点的图,输出一张去了噪点的图。
  3. 怎样做到?在训练过程中,我们是有A1和X这两个数据的,模型自己生成另一个噪声Y,跟噪声X对比,然后通过不断调节自己的参数,让自己生成的这个噪声接近X就行了。
  4. 这样模型记录了相关参数,下次拿A1过来,它就能推算出近似于上面加的噪声X,然后做A1 – X = A去噪,得到更清晰的图片A。

2

总的来说,我们训练的这个模型,它的能力就是,给一个图片,它能预测出来这张图片上是加了多少噪声,这样就可以让这张图减掉这些噪声,得到更清晰一点的图。

多步扩散

上面是简化的一步,只是把图片从稍微加了点噪声中还原出来,并不是直接从一个没有信息量充满噪声的图片中直接还原蹦出一张图来。要实现从纯噪声图生成一张图片,需要重复很多步上述步骤,所以它还有一个Time step的参数参与在训练过程中。

Time step 就是噪声强度,很好理解,表示的是加多少次噪声。回到上面的训练过程继续:

  1. 上面的训练,A 是完全没加过噪声的图片,A1是加了一次噪声X的图片,time step 就是1表示加了一次噪声,A1 和 t=1 输入到模型,训练出能推算 A1->A的能力

  2. 下一步针对A1再加一个噪声X2,A1+X2=A2,time step就是2,把加了两次噪声的图片A2和t=2输入到模型,训练出能推算A2->A1的能力

  3. 循环N次,加上多张图片重复这个步骤,模型的参数就学会了这里每一个步骤加的噪声数据。

    1. 实际训练过程中,t会是一个随机数,经过海量图片多轮训练,会覆盖所有t的值。下图epoch[回合],表示一轮一轮的训练,每次用不同的图片,不同的time step做训练

    3

  4. 最后就能从这个链路里把一个满是噪声(比如加了1000次噪声)的A1000逐渐去噪生成出清晰的图片A。A1000->A999 … ->A2->A1->A:
    4

训练和生成过程简化后就是这样。如果训练集是一张图片反复训练,我猜测输入一张加满噪声的图,最终会把这张图片还原出来。如果训练集是海量的图片,那这里还原出来的图片,如果不加其他控制,就会是这些海量图片随机的组合结果。

控制生成

接下来到下一个问题,就是怎样控制它去噪过程,让它生成的图片跟我们输入的文案描述匹配。

概念上很简单,就是在训练和生成过程中,把图片的描述文字也加进去。我们事先准备提供给模型训练的数据,除了图片本身,还需要包含对这张图片的文本描述,这样模型才能学到文本和图片内容的关系。

按刚才训练过程的示意图,实际上模型是三个输入,加了噪声的图片A1、图片对应的文本描述text,噪声强度time step,在这三者的作用下共同推理出对应的噪声。

5

有text的训练输入后,后续在通过噪声生成图片的过程中,根据用户输入的prompt文本,就可以引导去噪的走向。

如果比较具象地想象这个过程,比如训练集有20张图片,5张的图片描述是girl,其他15张没有girl。训练过程中,girl这个词就跟这5张图片关联记录在网络参数里,在训练后使用这个模型时,输入的文本有girl,那去噪的过程的每一步就大概率会定位到这5张图片训练时的数据,会有更大概率去噪的过程走向这5张图片,而不是之前的随机走向。

到这里,扩散模型最基本的原理就差不多了。

重要概念


上述整个过程比较简化,过程中有几个重要的问题和概念还没提到,这里逐个说明。

Latent Space & VAE

上述的扩散模型的训练和使用,有个很明显要解决的问题,就是图片太大了,如果图片用像素数据表示,现在 iPhone 拍的一张照片有最小有 500w 像素,即使做常见的图片压缩(JPG/PNG),也有几百K的数据大小,如果用原图按上面的流程跑下来,计算量巨大,显然我们要针对图片做降维(或者理解为压缩),把一张图片的数据量降低,再进行后续的训练和使用。所以需要一个降维模型做这个事,这个模型需要满足:

  1. 有 encode 和 decode,需要能从 encode 和处理后的低维数据 decode 成高清图片。
  2. 要压缩得尽量小,方便低成本做上述海量的计算。
  3. 信息要能保留得足够多。
  4. 信息要有语义,不然训练过程中不同图片的信息无法交叉融合。

VAE (Variational Autoencoders 变分自编码器)能做到这些,VAE 提供了 encoder 和 decoder,一张图片经过 VAE encode,可以压缩成仅有 64x64x4 的矩阵,这里经过 encode 后的数据空间,就称为隐空间(Latent Space),在这个空间里进行上述扩散模型的训练和生成流程,成本就非常低,这也是目前Stable Diffusion能跑在我们普通电脑上的原因。最后在隐空间里生成的图片数据,经过 VAE decoder,就能转换成高清图。

那 VAE 为什么能做到这样?一张图片转成 64x64x4 这么小的数据量,为什么能保存图片的信息?通俗理解是 VAE 把图片内容转成了语义概率表示,相当于变成了这张图片的描述,比如描述这张图片有猫的概率、有猫爪子的概率,猫在左边的概率,绿色的概率,类似这样,更深入就不了解了,这篇文章 有讲解到一些,也只了解到这里了。

CLIP

VAE 解决图片编码问题,再来看看文本的编码。在控制生成里,文本实际上是怎样参与到模型训练和生成的过程?如果文本只是随便编码进入模型,模型可能只认得一些特定字符,不认识语义,也就在后续的图片生成中没法比较好地通过自由的prompt文案控制。

SD 使用 OpenAI 训练的 CLIP 模型,把文本转为对应的向量,为什么用它,因为 CLIP 模型本身是一个文本到图片的映射模型,它对文本转出来的向量,更贴近图片的特征空间。

稍微展开说下原理:

  1. 有N张图片、以及它们对应的 N 个对图片的描述文本
  2. 对图像进行编码,得到I,下图中,I1/I2/…/IN 表示从第 1 到 N 张图片的编码表示。
  3. 对文本片段进行编码,得到T,下图中,T1/T2..TN 表示从第 1 到 N 张图片对应的文本描述。
  4. 模型的任务是训练 TextEncoder 和 ImageEncoder的 参数:
    1. 让图中蓝色部分Ti和Ii相似度变高,它们原本就是一一对应的文本-图片,属于正样本。(数学上是计算余弦相似度,越大表示相似度越高,最大化这个值)
    2. 让白色部分相似度最低,它们的文本和图片是没有关系的,属于负样本。(数学上是余弦相似度值最小化)6

这样训练后,最终使用这个模型时,TextEncoder出来的向量表示,就跟图片内容有很强的关系。比如下图第四行,猫的文字描述通过 TextEncode 出来的值,跟猫的图片的ImageEncode出来的值,相似度更高,跟其图片的encode相似度就低。

7

PS. 更细节的 CLIP 怎么跟 SD 生成过程结合,还没弄得很清楚,实际上SD 没有用 CLIP 里的 Image encoder,扩展模型训练过程中是用别的 Image Encoder,那就并没有用到文本和实际图片的对应映射关系,但可能CLIP出来的文本编码,语义和表现形式上已经是图片的模式,比如文字 cat,它能跟图片空间里猫所表示的形态(形状/位置)、视觉(眼睛/颜色/形状)、语义(宠物/动物)能比较接近地对应上,也能存储到相关信息,所以跟其他图片编码结合,也能起到很大作用?

采样器

编码和数据量的问题解决了,还有个问题没提到,就是上面流程里的步数太长了,最开始提出的扩散模型训练方案 DDPM(Denoising Diffusion Probabilistic Models 去噪扩散概率模型),正常需要 1000 步降噪过程(称为采样),才能生成一张不错的图片,即使上述隐空间已经降低了计算量,但生成一张图要1000步,还是达不到大规模可用。

为什么 DDPM 一定要这么多次采样?这篇文章说得比较清楚,不能直接减小步数(每次噪声加得很少,避免一步就破坏掉原图),不能跳步降噪(每一步状态都依赖前一步,号称马尔科夫性质)。

随后很快有人提出 DDIM(Denoising Diffusion Implicit Models 去噪扩散隐式模型) 方案,训练时还是 DDPM 一步步来,但生成时是可以做到跳步,同时还能让在步数变少的情况下保持生成的稳定。DDIM不是从头开始逐步去噪,而是选择几个关键的时间点,只在进行这些时间点上去噪操作,并且中间的步骤,比如从降噪100次的图片,下一步直接生成降噪90次的图片,跳10步,生成速度就快了10倍。为什么它能做到跳步,具体原因都是数学公式,就不展开了(还没全看懂),可以看回这篇文章

Stable Diffusion 的 WebUI 上有很多采样器,Eular a,Karras,DPM 等,在去噪过程中通过不同的方法,有不同的多样化程度、图像质量、速度、收敛性的区别。

Stable Diffusion


最后总结说下 Stable Diffusion。上面整个过程和概念,是一个个解决问题的方法,把它们组合起来,逐渐建立起基于扩散模型生成图片的方法大厦,谁都可以用这些公开的理论方法建一套自己的生图模型。

Stable Diffusion 就在这些基础上做一些改进,建立一套稳定的框架、训练出基础模型,开源让所有人可以用,整个 SD 就是多种能力的组合,这些能力可以分别不断升级替换,模型本身还有很多方式去做更强的控制干预(controlNet / LORA等),使得它可定制性可玩性很强,生态越来越繁荣。

最后让我们用一个图串起整个流程和讲解到的概念。

  1. Part1 是用 CLIP 模型对文本做编码。
  2. Part2 在模型训练过程中,图片经过 AutoencoderKL(VAE编码器的实现)生成隐空间下图片的表示,随机生成一个 noise 噪声,加到这个图片里,同时把通过 CLIP 模型编码的图片对应描述文本加入进来,一起进入 UNet 模型,训练预测加了多少 noise 噪声的能力。
  3. Part3 在模型推理过程中,输入一个完全随机噪点,在隐空间里通过不同的采样器,结合prompt 文本输入(图上没表示出来,文本数据会参与到降噪过程),在 UNet 模型里迭代多步做降噪预测,生成隐空间里的图片,再通过 VAE AutoencoderKL 解码出图片。

8

了解整个基础流程和概念后,现在看 Stable Diffusion 论文中的这张架构图,应该也大致能理解是什么意思了。

9

参考资料


本文转自 http://blog.cnbang.net/tech/3766/,如有侵权,请联系删除。

iOS 归档方法野指针崩溃修复记录 archiveRootObject: toFile:

作者 wyanassert
2024年12月26日 19:20

最近线上遇到一个归档[NSKeyedArchiver archiveRootObject: toFile:] 崩溃的问题, 修复历程颇为曲折, 直到最后也没找到具体的问题, 在此记录下具体的排查方向吧.

崩溃几率大概千万分之一级别, 崩溃堆栈如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

SIGSEGV:SEGV_ACCERR

0 libobjc.A.dylib _objc_release_x0 + 8

1 CoreFoundation _cow_cleanup + 164

2 CoreFoundation -[__NSDictionaryM dealloc] + 148

3 CoreFoundation ___RELEASE_OBJECTS_IN_THE_ARRAY__ + 116

4 CoreFoundation -[__NSArrayM dealloc] + 148

5 Foundation -[NSKeyedArchiver dealloc] + 268

6 Foundation +[NSKeyedArchiver archivedDataWithRootObject:requiringSecureCoding:error:] + 120

7 业务代码

崩溃代码的目的是想把一个字典存到磁盘上做缓存, 内容如下:

1
2
3
4
5
6
7
8
9
{
"key1" : {
"item1" : {"property1" : "value1", "property2" : "value2", ... "property9" : "value9",},
"item2" : {"property1" : "value1", "property2" : "value2", ... "property9" : "value9",},
...
},
"key2" : "seq",
"key3" : ["url1", "url2", ...]
}

第一开始以为只是一个简单的野指针问题, item 在多线程环境下产生的野指针, 就简单做了一个深拷贝, 然后再从深拷贝之后的 item 转化成 json :{"property1" : "value1", "property2" : "value2", ... "property9" : "value9",}, 用新的 json 插入到大字典中, 最后存储, 然后上线后, 崩溃数据一点都没有变.

此时意识到问题不简单, 又继续做了如下修改

  1. 检查了字典中的每一个对象, 确认涉及对象创建和释放的部分使用都是发生在一个串行队列中
  2. [NSKeyedArchiver archiveRootObject: toFile:] 被标注为弃用, 换成新的[NSKeyedArchiver archivedDataWithRootObject:requiringSecureCoding:error:], 需要注意的是, 新老 api 的产物有小概率不兼容
  3. 使用 @try @catch 包住出问题代码
  4. 发现存在多次连续保存的调用, 限制频次为 10 秒触发一次
  5. 只有数据改变时触发归档
  6. 使用 atomic 修饰涉及的属性
  7. 归档前, 打印字典

以上操作 只有第5/6两个修改大概减少了 70% 的野指针崩溃数量, 第 8 点可以确定传入的数据没问题, 但是仍然崩溃, 说明是 NSKeyedArchiver 内部存在问题.

后续跟进思路, 打算弃用NSKeyedArchiver, 继续预研其他持久化工具

  • MMKV: 微信团队基于 mmap 内存映射的 key-value 组件, 但是也存在一些文件损坏的情况, 需要做好文件备份以及恢复
  • Sqlite : 存在数据库损坏, 且无法恢复
  • YYDiskCache : 在实践中发现用户主动杀死 app 时, YYDiskCache 监听退出通知, 然后释放缓存时候超时, 触发 MetricKit 上报问题, 并且它的磁盘缓存还是基于 NSKeyedArchiver 以及 Sqlite 实现的.

[转载] 【WWDC21 10158】VideoToolbox 视频编码基础及其低延时新特性

作者 wyanassert
2024年11月13日 23:38

原文地址

本文基于 Session 10158 梳理。随着直播互动性增强,对直播延时的要求也越来越高,高延时会严重影响用户体验。本 Session 介绍的 VideoToolbox 低延时编码从编码角度来降低延时,给我们提供了降低延时的新思路。

VideoToolbox 编解码基础

VideoToolbox 简介

VideoToolbox 是苹果提供的一个直接访问硬编解码器的底层框架,可以用来编码、解码和像素格式转换。这些功能都以 session 的形式提供。如果你的 App 中不需要直接访问硬编解码器,那不需要使用 VideoToolbox,可以使用其他框架例如 AVFoundation。

上图为 Apple 视频编解码框架图,我们主要关注 AVFoundation 和 VideoToolbox。

框架 编码 解码 iOS 编解码类型
AVFoundation 直接编码为文件 解码后直接渲染播放 硬编解码
VideoToolbox 编码为 CMBlockBuffer 解码为 CVPixelBuffer,需要自己处理渲染 硬编解码

VideoToolbox 常用数据结构

  • CVPixelBufferPool:CVPixelBuffer 缓冲池,用于管理一组可重用的 CVPixelBuffer

  • CVPixelBuffer:未编码的原始数据

  • CMBlockBuffer:编码后的数据

  • CMFormatDescription:编解码格式信息,包括以下信息:

    • Width / Height
    • Format Type—(kCMPixelFormat_32BGRA, kCMVideoCodecType_H264,……)
    • Extensions—(Pixel Aspect Ratio, Color Space,……)
  • CMTime:时间信息,表示时间点或者段,value / timeSacle

  • CMSampleBuffer:编码、解码数据容器,详细结构见下图:

H.264 码流格式

H.264 流是由一系列的 NAL Units(简称 NALU)组成,如下图所示:

NALU 可能包括:

  • 视频帧或者视频帧的一个分片(slice)
  • H.264 参数集:SPS(序列参数集)和 PPS(图像参数集)

Annex-B 和 AVCC

根据 NALU 的分隔符不同,可以把 H.264 流分为 Annex-B 格式和 AVCC 格式。

格式 特点 常用于格式 常用场景 备注
Annex-B 使用 3~4 字节 start code 0x00000001/0x000001 分割,SPS/PPS 为普通 NALU ts 流媒体 每个 I 帧前都需要添加 SPS/PPS 信息
AVCC 使用 4 字节大端序 NALU 长度进行分割,在 extradata 中封装 SPS/PPS mp4/flv/mkv 本地文件 只要在文件头添加 SPS/PPS 信息

特别需要注意的是:iOS 平台的 VideoToolbox 硬编解码接口只支持 AVCC 的 H.264 数据,而 Android 的 MediaCodec 硬编解码接口只支持 Annex-B 格式的 H.264 数据。
因此对于 iOS 而言,编码后得到 AVCC 格式数据,在推流前需要转为流媒体对应的 Annex-B 格式。拉流解码则刚好相反。

NALU 结构

NALU header

如下图所示,header 占一个字节,分为 3 个部分:

forbidden_zero_bit
禁止位,初始为 0,当网络发现 NALU 有错误时可设置该比特为 1,以便接收方纠错或丢掉该单元。
nal_ref_idc
nal 重要性指示,标志该 NALU 的重要性,值越大,越重要,解码器在解码处理不过来的时候,可以丢掉重要性为 0 的 NALU。
nal_unit_type
NALU 类型,NALU 第 5 个字节(前四个字节为 start code 0x00000001) & 00011111(十六进制为 0x1F),即 int type = (frame[4] & 0x1F)。
常用类型:

  • 5:IDR(I 帧)
  • 7:SPS
  • 8:PPS

编码流程

重要的参数、属性:

  • CMVideoCodecType:编码类型,kCMVideoCodecType_H264 对应 H.264
  • kVTCompressionPropertyKey_ProfileLevel:指定编码比特流的配置文件和级别。直播一般使用 baseline,可减少由于 b 帧带来的延时
  • kVTCompressionPropertyKey_RealTime:是否实时编码
  • kVTCompressionPropertyKey_AverageBitRate:平均码率
  • kVTCompressionPropertyKey_AllowTemporalCompression:是否开启帧间压缩
  • kVTCompressionPropertyKey_MaxKeyFrameInterval:设置 GOP 大小,每隔 X 帧有一个关键帧
  • kVTCompressionPropertyKey_MaxKeyFrameIntervalDuration:关键帧之间的 duration,每 Y 秒有一个关键帧。这两个属性可以同时设置,满足其中一个即可。

解码流程

重要的参数、属性

  • CMVideoFormatDescription:输入视频格式信息,可通过 SPS/PPS 创建。
  • destinationPixBufferAttrs:解码输出属性,是 CFDictionary,有如下 key:
    • kCVPixelBufferPixelFormatTypeKey:像素格式
    • kCVPixelBufferWidthKey/kCVPixelBufferHeightKey:宽高
    • kCVPixelBufferOpenGLCompatibilityKey:它允许在 OpenGL 的上下文中直接绘制解码后的图像,而不是从总线和 CPU 之间复制数据。这有时候被称为零拷贝通道,因为在绘制过程中没有解码的图像被拷贝。
    • kCVPixelBufferIOSurfacePropertiesKey:使用 IOSurface 来创建 CVPixelBuffer 时,需要为此 key 赋值。赋值为一个空字典表示使用默认的 IOSurface 选项。
  • VTDecompressionOutputCallback:解码回调,每次完成解码或者丢帧时都会调用,而且回调会阻塞解码器直到回调 return,因此要避免耗时操作。此外,解码器会以 dts 顺序返回视频帧,B 帧的 pts 和 dts 不一致,需要开发者进行视频帧重排序。

常见问题

VideoToolbox session 后台失效

App 切到后台时,iOS 的 VideoToolbox session 会失效,切回前台后原 session 也不能继续使用,需重新创建 VideoToolBox 实例。

VideoToolbox 低延时编码

概述

低延时编码对于许多视频应用非常重要,尤其是实时视频通信应用。本 session 将介绍 VideoToolbox 中一种新的编码模式,以实现低延时编码,这种新模式的目标是针对实时视频应用优化现有的编码器 pipeline。注意,此模式支持的视频编解码器类型为 H.264,将在 iOS 15.0+macOS 12.0+ 上引入此功能。

实时视频应用的目标

  1. 延时:我们需要最大限度地减少通信中的端到端延时,提升交流体验。
  2. 兼容性:我们需要通过让视频应用能够与更多设备进行通信来增强兼容性。
  3. 编码效率:当多人视频时,编码器 pipeline 应该是高效的。
  4. 视频质量:视频应用需要以最佳视觉质量呈现视频。
  5. 容错能力:我们需要一种可靠的机制来从网络丢失引入的错误中恢复通信。

低延时视频编码将在以上这些方面进行优化,对应的功能为:

  1. VideoToolbox 低延时编码基础功能
  2. 新的 profile
  3. 时间可扩展性(temporal scalability)
  4. 最大帧量化参数(max frame quantization parameter)
  5. 长期参考(long-term reference)

低延时编码基础功能

低延时编码是什么?

下图为 Apple 平台上视频编码 pipeline 的简图。

VideoToolbox 将 CVImagebuffer 作为输入,它要求视频编码器执行压缩算法,例如 H.264 以减少原始数据的大小。输出的压缩数据封装在 CMSampleBuffer 中,可以通过网络传输进行视频通信。从上图中我们可以注意到,端到端延时可能受两个因素影响:编码时间网络传输时间

为了最大限度地减少编码时间,低延时编码模式去除帧重新排序,遵循一进一出模式。其实相当于把 kVTCompressionPropertyKey_AllowFrameReordering 属性设置为 false:禁用 B 帧,去除因编码 B 帧带来的延时。此外,该模式下的码率控制器对网络变化的适应速度也更快,因此也最大限度地减少了网络拥塞造成的延时。通过这两个优化,我们已经可以看到与默认模式相比有明显的性能提升。对于 720p@30 的视频,低延时编码可以减少高达 100 毫秒的延时。这种节省对于视频会议至关重要。

低延时编码怎么使用?

只需要在 VTCompressionSessionCreate 的入参 encoderSpecification 中设置 EnableLowLatencyRateControl,其他配置和平常的编码流程一样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let encoderSpecification: [NSString: NSObject] = [
kVTVideoEncoderSpecification_EnableLowLatencyRateControl: kCFBooleanTrue
]

var compressionSession: VTCompressionSession?
VTCompressionSessionCreate(
allocator: kCFAllocatorDefault,
width: width,
height: height,
codecType: kCMVideoCodecType_H264,
encoderSpecification: encoderSpecification as CFDictionary?,
imageBufferAttributes: nil,
compressedDataAllocator: nil,
outputCallback: videoEncodeCallback,
refcon: nil,
compressionSessionOut: &compressionSession
)

低延时编码其他功能

  1. 新的 profile:新增 2 个 profile 来增强兼容性
  2. 时间可扩展性(temporal scalability):在视频会议中非常有用,通过降低帧率来满足低带宽网络环境
  3. 最大帧量化参数(max frame quantization parameter ):可以对图像质量进行细粒度控制
  4. 长期参考(long-term reference):提高容错能力

新的 profile

实际应用时需要其他端的解码器也支持

Profile 定义了一组解码器能够支持的编码算法。为了与接收方通信,编码后的码流应符合解码器支持的特定 profile。在 Video Toolbox 中,我们支持一系列 profile,例如 baseline profile、main profile 和 high profile。该系列添加了两个新 profile:constrained baseline profile(CBP) 和 constrained high profile(CHP)。CBP 主要用于低码率应用,而 CHP 具有更先进的算法以获得更好的压缩比

1
2
3
4
5
6
7
8
9
10
11
VTSessionSetProperty(
compressionSession,
key: kVTCompressionPropertyKey_ProfileLevel,
value: kVTProfileLevel_H264_ConstrainedBaseline_AutoLevel // value CBP
)

VTSessionSetProperty(
compressionSession,
key: kVTCompressionPropertyKey_ProfileLevel,
value: kVTProfileLevel_H264_ConstrainedHigh_AutoLevel // value CHP
)

时间可扩展性(temporal scalability)

使用该特性可以提高多方视频通话的效率。
下图为一个简单的三方视频会议场景:在此模型中,接收方 A 的带宽较低为 600 kbps,而接收方 B 的带宽较高为 1,000 kbps。通常,发送方需要对编码输出两路码流,以满足每个接收方的下行带宽(ps:实际现在一般是主播推一路流,cdn 进行转码),这可能不是最佳的方案。

当使用该特性时,编码会更高效,模型如下图:发送方只需要编码输出一路码流,然后根据接收方进行分层。

实现原理

下图为一组视频帧,其中每一帧都使用前一帧作为参考帧。可以将一半的帧放入另一层,更改参考帧,以便只有原始层中的帧用于参考帧。原始层称为 base layer,新构建的层称为 enhancement layer。enhancement layer 可以作为 base layer 的补充,以提高帧率。对于接收方 A,我们可以发送 base layer 帧,因为基础层本身已经是可解码的。更重要的是,由于 base layer 仅包含一半的帧,因此传输的码率将很低。接收方 B 可以享受更流畅的视频,因为它有足够的带宽来接全部视频帧。


收益

  1. 通过降低帧率来满足低带宽网络环境:base layer 可以用 60% 的码率来达到 50% 的帧率。

  2. 增强容错能力:enhancement layer 中的帧不用于预测,因此对这些帧没有依赖性。这意味着如果在网络传输过程中丢失了一个或多个增强层帧,其他帧不会受到影响。

API

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// 创建并开启低延时编码器
VTCompressionSessionCreate(...)

// 设置 base layer 比例
VTSessionSetProperty(
compressionSession,
key: kVTCompressionPropertyKey_BaseLayerFrameRateFraction,
value: Float(0.5) as CFTypeRef
)

// 设置 base layer 码率占比
VTSessionSetProperty(
compressionSession,
key: kVTCompressionPropertyKey_AverageBitRate, // 目标码率
value: Int(bitrate) as CFTypeRef
)
// 默认是0.6,取值范围:[0.6, 0.8]
VTSessionSetProperty(
compressionSession,
key: kVTCompressionPropertyKey_BaseLayerBitRateFraction,
value: Float(baseLayerBitrateFrac) as CFTypeRef
)


// 编码回调中查看某一帧是 base layer 还是 enhancement layer
let attachArray = CMSampleBufferGetSampleAttachmentsArray(
sampleBuffer,
createIfNecessary: true
)
let isBaseLayer = CFDictionaryGetValue(
CFArrayGetValueAtIndex(attachArray, 0),
kCMSampleAttachmentKey_IsDependedOnByOthers
)
```

### 最大帧量化参数(max frame quantization parameter, max frame QP)

Frame QP 用于调节图像质量和码率可以使用小 QP 来生成高质量的图像在这种情况下,图像数据会很大另一方面,可以使用大 QP 来生成低质量但数据量小的图像
在低延时模式下,编码器使用图像复杂度输入帧率视频运动等因素调整 QP,以在当前码率约束下产生最佳视频质量`所以苹果鼓励依靠编码器的默认行为来调整帧 QP`
但是在某些客户端对视频质量有特定要求的情况下,可以设置编码器使用的最大 QP,编码器将始终选择小于此限制的 QP,因此客户端可以对图像质量进行细粒度控制
值得一提的是,即使指定了最大 QP,常规码率控制仍然有效如果编码器达到最大 QP 上限但码率已达到设置的目标码率,它将开始丢弃帧以保持目标码率
使用此功能的一个例子是通过较差的网络传输录屏内容可以通过牺牲帧率来发送清晰的屏幕图像

#### API

```swift
// 创建并开启低延时编码器
VTCompressionSessionCreate(...)
// maxFrameQP [1,51]
VTSessionSetProperty(
compressionSession,
key: kVTCompressionPropertyKey_MaxAllowedFrameQP,
value: maxFrameQP as CFTypeRef
)

长期参考(long-term reference,LTR)

LTR 可用于错误恢复。下图显示了 pipeline 中的编码器、发送方和接收方。假设视频通信的网络状况不佳。由于传输错误,可能会发生帧丢失。当接收方检测到帧丢失时,它可以请求刷新。如果编码器收到请求,通常它会编码一个关键帧(I 帧)以用于刷新。但关键帧通常相当大。大的关键帧需要更长的时间才能到达接收方。由于网络条件已经很差,关键帧可能会加剧网络拥塞问题。

实现原理

那么,我们可以使用 P 帧而不是关键帧进行刷新吗?答案是肯定的,如果我们有帧 ack 机制。原理如下图所示:

  1. 首先,我们需要确定需要 ack 的帧。我们称这些帧为长期参考帧或 LTR 帧。这是编码器的决定,编码后的 LTR 帧附加信息中有 AcknowledgementToken,用来标记该 LRT 帧。
  2. 当发送方传输 LTR 帧时,还需要接收方 ack。如果接收方成功接收到 LTR 帧,则需要返回 AcknowledgementToken。
  3. 一旦发送方收到 ack,并在编码时把收到的 AcknowledgementTokens 发送给编码器,编码器就知道对方收到了哪些 LTR 帧,就可以用这些 LTR 帧来生成 P 帧。由于一次可以收到多个 ack,需要使用一个数组来存储这些 AcknowledgementTokens。
  4. 当编码器收到接收方刷新请求时,由于编码器有一堆已确认的 LTR 帧,它可以从中选取一帧作为参考帧进行编码得到一个 P 帧,以这种方式编码的帧称为 LTR-P。与关键帧相比,LTR-P 大小通常要小得多,因此更容易传输。如果没有确认的 LTR 可用,编码器将生成一个关键帧。


LTR API

帧 ack 需要由应用层处理。它可以通过 RTP 协议中的 RPSI 消息等机制来完成。这里只关注编码器和发送方在这个过程中是如何通信的。

API

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 创建并开启低延时编码器
VTCompressionSessionCreate(...)
// 开启 LTR 功能
VTSessionSetProperty(
compressionSession,
key: kVTCompressionPropertyKey_EnableLTR,
value: kCFBooleanTrue
)

// 设置编码属性,传递 tokens
let frameProperties: [NSString: AnyObject] = [
// 强制生成 LTR-P 帧
kVTEncodeFrameOptionKey_ForceLTRRefresh: kCFBooleanTrue,
// 编码时传递 tokens 给编码器
kVTEncodeFrameOptionKey_AcknowledgedLTRTokens: [token1, token2,...., tokenN] as NSObject
]

var flags: VTEncodeInfoFlags = []
VTCompressionSessionEncodeFrame(
compressionSession,
imageBuffer: imageBuffer,
presentationTimeStamp: presentationTimeStamp,
duration: duration,
frameProperties: frameProperties as CFDictionary?,
sourceFrameRefcon: nil,
infoFlagsOut: &flags
)


// 编码回调中,在 LTR 帧附加信息中获取 ack token
let attachArray = CMSampleBufferGetSampleAttachmentsArray(
sampleBuffer,
createIfNecessary: true
)
let token = CFDictionaryGetValue(
CFArrayGetValueAtIndex(attachArray, 0),
kVTSampleAttachmentKey_RequireLTRAcknowledgementToken
)

业界降低延时的方法


优化播放器缓冲区配置

下图为直播全链路延时分布图,这里重点关注解码渲染时播放器的数据缓冲区,为了抗卡顿确保流畅播放,播放器必须要缓冲媒体数据。缓冲区引入的延时在全链路比重占据较大,因此优化缓冲区配置是一个降低延时的常用手段。

选择合适的协议

目前国内外主流的音视频直播协议多种多样,国内使用比较多的是 rtmp 推流 flv 拉流方案,国外使用比较多的有 hls/dash 等。可以根据不同的直播场景和不同的延时要求选择合适的协议,具体如下:

协议 传输方式 封装格式 延时 数据分段
http-flv http flv 3~7sec 连续流
rtmp tcp flv-tag 2~4sec 连续流
hls http 文件 ts 8~15sec 切片文件
dash(cmaf) http 文件 mp4/webm 3~10sec 切片文件
quic udp flv-tag 3~10sec 连续流
rts udp rtp 0.6~1.2sec 连续流
srt udp ts < 1s 连续流
  • RTMP(Real Time Messaging Protocol)是基于 TCP 的,由 Adobe 公司为 Flash 播放器和服务器之间音频、视频传输开发的开放协议。
  • HLS(HTTP Live Streaming)是基于 HTTP 应用层的以 Apple 公司主导开发的音视频传输协议。
  • HTTP FLV 则是将 RTMP 封装在 HTTP 协议之上的,可以更好的穿透防火墙等。
  • CMAF (通用媒体应用格式 Common Media Application Format) 是利用的 ISOBMFF,fMP4 容器,同 HLS 类似,将视频流分段进行传输。
  • QUIC(Quick UDP Internet Connection)是谷歌公司制定的一种基于 UDP 协议的低时延传输协议;它将很多可靠性的验证策略从系统层转移到应用层来做,更适合现代流媒体传输的拥塞控制策略。iOS 15+/macOS 12+ 的 Network 框架已经支持了 HTTP3/QUIC,并可以将自有协议转换至 QUIC 之上。更多细节请参考 Accelerate networking with HTTP/3 and QUIC
  • RTS (Real Time Streaming via WebRTC)是基于谷歌 webRTC 的一种实时音视频传输技术,底层构建于 UDP 之上,浏览器通用兼容标准。
  • SRT(Secure Reliable Transport)是一种能够在复杂网络环境下实时、准确地传输数据流的网络传输技术,它在传输层使用 UDP 协议,具备 UDP 速度快、开销低的传输特性,支持点对点传输,无需中间进行服务器中转。SRT 的更多信息请查阅 Secure Reliable Transport (SRT) Protocol

总结

本 session 介绍了 VideoToolbox 中新引入的低延时编码模式,从编码角度来降低直播延时。但是该模式只支持 H.264 编码,而且实际应用时需要考虑到多端兼容和应用层协议的支持,更多是苹果在 H.264 低延时编码方面的一些探索。

❌
❌