2024年11月

前言

分类树
查询功能,在各个业务系统中可以说随处可见,特别是在电商系统中。

但就是这样一个简单的分类树查询功能,我们却优化了
5
次。

到底是怎么回事呢?

背景

我们的网站使用了
SpringBoot
推荐的模板引擎:
Thymeleaf
,进行动态渲染。

它是一个XML/XHTML/HTML5模板引擎,可用于Web与非Web环境中的应用开发。

它提供了一个用于整合SpringMVC的可选模块,在应用开发中,我们可以使用Thymeleaf来完全代替JSP或其他模板引擎,如Velocity\FreeMarker等。

前端开发写好Thymeleaf的模板文件,调用后端接口获取数据,进行动态绑定,就能把想要的内容展示给用户。

由于当时这个是从0-1的新项目,为了开快速开发功能,我们第一版接口,直接从数据库中查询
分类
数据,组装成
分类树
,然后返回给前端。

通过这种方式,简化了数据流程,快速把整个页面功能调通了。

第1次优化

我们将该接口部署到dev环境,刚开始没啥问题。

随着开发人员添加的分类越来越多,很快就暴露出性能瓶颈。

我们不得不做优化了。

我们第一个想到的是:
加Redis缓存

流程图如下:

图片

于是暂时这样优化了一下:

  1. 用户访问接口获取分类树时,先从Redis中查询数据。
  2. 如果Redis中有数据,则直接数据。
  3. 如果Redis中没有数据,则再从数据库中查询数据,拼接成分类树返回。
  4. 将从数据库中查到的分类树的数据,保存到Redis中,设置过期时间5分钟。
  5. 将分类树返回给用户。

我们在Redis中定义一个了key,value是一个分类树的json格式转换成了字符串,使用简单的key/value形式保存数据。

经过这样优化之后,dev环境的联调和自测顺利完成了。

第2次优化

我们将这个功能部署到st环境了。

刚开始测试同学没有发现什么问题,但随着后面不断地深入测试,隔一段时间就出现一次首页访问很慢的情况。

于是,我们马上进行了第2次优化。

我们决定使用
Job
定期
异步
更新分类树到Redis中,在系统上线之前,会先生成一份数据。

当然为了保险起见,防止Redis在哪条突然挂了,之前分类树同步写入Redis的逻辑还是保留。

于是,流程图改成了这样:

图片

增加了一个job每隔5分钟执行一次,从数据库中查询分类数据,封装成分类树,更新到Redis缓存中。

其他的流程保持不变。

此外,Redis的过期时间之前设置的5分钟,现在要改成永久。

通过这次优化之后,st环境就没有再出现过分类树查询的性能问题了。

第3次优化

测试了一段时间之后,整个网站的功能快要上线了。

为了保险起见,我们需要对网站首页做一次压力测试。

果然测出问题了,网站首页最大的qps是100多,最后发现是每次都从Redis获取分类树导致的网站首页的性能瓶颈。

我们需要做第3次优化。

该怎么优化呢?

答:加内存缓存。

如果加了内存缓存,就需要考虑数据一致性问题。

内存缓存是保存在服务器节点上的,不同的服务器节点更新的频率可能有点差异,这样可能会导致数据的不一致性。

但分类本身是更新频率比较低的数据,对于用户来说不太敏感,即使在短时间内,用户看到的分类树有些差异,也不会对用户造成太大的影响。

因此,分类树这种业务场景,是可以使用内存缓存的。

于是,我们使用了Spring推荐的
caffine
作为内存缓存。

改造后的流程图如下:
图片

  1. 用户访问接口时改成先从本地缓存分类数查询数据。
  2. 如果本地缓存有,则直接返回。
  3. 如果本地缓存没有,则从Redis中查询数据。
  4. 如果Redis中有数据,则将数据更新到本地缓存中,然后返回数据。
  5. 如果Redis中也没有数据(说明Redis挂了),则从数据库中查询数据,更新到Redis中(万一Redis恢复了呢),然后更新到本地缓存中,返回返回数据。

需要注意的是,需要改本地缓存设置一个过期时间,这里设置的5分钟,不然的话,没办法获取新的数据。

这样优化之后,再次做网站首页的压力测试,qps提升到了500多,满足上线要求。

第4次优化

之后,这个功能顺利上线了。

使用了很长一段时间没有出现问题。

两年后的某一天,有用户反馈说,网站首页有点慢。

我们排查了一下原因发现,分类树的数据太多了,一次性返回了上万个分类。

原来在系统上线的这两年多的时间内,运营同学在系统后台增加了很多分类。

我们需要做第4次优化。

这时要如何优化呢?

限制分类树的数量?

答:也不太现实,目前这个业务场景就是有这么多分类,不能让用户选择不到他想要的分类吧?

这时我们想到最快的办法是开启
nginx

GZip
功能。

让数据在传输之前,先压缩一下,然后进行传输,在用户
浏览器
中,自动解压,将真实的分类树数据展示给用户。

之前调用接口返回的分类树有1MB的大小,优化之后,接口返回的分类树的大小是100Kb,一下子缩小了10倍。

这样简单的优化之后,性能提升了一些。

第5次优化

经过上面优化之后,用户很长一段时间都没有反馈性能问题。

但有一天公司同事在排查Redis中大key的时候,揪出了分类树。之前的分类树使用key/value的结构保存数据的。

我们不得不做第5次优化。

为了优化在Redis中存储数据的大小,我们首先需要对数据进行瘦身。

只保存需要用到的字段。

例如:

@AllArgsConstructor
@Data
public class Category {

    private Long id;
    private String name;
    private Long parentId;
    private Date inDate;
    private Long inUserId;
    private String inUserName;
    private List<Category> children;
}

像这个分类对象中inDate、inUserId和inUserName字段是可以不用保存的。

修改自动名称。

例如:

@AllArgsConstructor
@Data
public class Category {
    /**
     * 分类编号
     */
    @JsonProperty("i")
    private Long id;

    /**
     * 分类层级
     */
    @JsonProperty("l")
    private Integer level;

    /**
     * 分类名称
     */
    @JsonProperty("n")
    private String name;

    /**
     * 父分类编号
     */
    @JsonProperty("p")
    private Long parentId;

    /**
     * 子分类列表
     */
    @JsonProperty("c")
    private List<Category> children;
}

由于在一万多条数据中,每条数据的字段名称是固定的,他们的重复率太高了。

由此,可以在json序列化时,改成一个简短的名称,以便于返回更少的数据大小。

这还不够,需要对存储的数据做压缩。

之前在Redis中保存的key/value,其中的value是json格式的字符串。

其实
RedisTemplate
支持,value保存
byte数组

先将json字符串数据用
GZip
工具类压缩成byte数组,然后保存到Redis中。

再获取数据时,将byte数组转换成json字符串,然后再转换成分类树。

这样优化之后,保存到Redis中的分类树的数据大小,一下子减少了10倍,Redis的大key问题被解决了。

最后说一句(求关注,别白嫖我)

如果这篇文章对您有所帮助,或者有所启发的话,帮忙扫描下发二维码关注一下,您的支持是我坚持写作最大的动力。
求一键三连:点赞、转发、在看。
关注公众号:【苏三说技术】,在公众号中回复:进大厂,可以免费获取我最近整理的10万字的面试宝典,好多小伙伴靠这个宝典拿到了多家大厂的offer。

解析

shared pool

图解:
library cache里面,暂时可以认为存储着:
1、SQL以及对应的执行计划(所占空间比较小);
2、存储过程、函数、触发器、包,它们编译后的对象(所占空间往往比较大,特别是包所占的比较大)

对于shared pool管理和研究的时候,row cache一般不会出现问题,所以一般情况我们都不研究row cache,会出问题的经常是library cache,所以我们经常研究它

如果要执行包里面的一个存储过程的时候,oracle就会把整个包头(包规范)的部分调到library cache里面去,这时候就可能造成比较大的一个对象突然被调到library cache里去,这时候有可能会报4013错误;所以写包的时候,尽量不要把过多的存储过程或者函数放到一个包里面去

在shared pool里,有时候可能造成比较大的一个对象突然被调到library cache里去,这时候有可能会报4013错误,所以为shared pool专门设了一个,一个比较大的对象突然被调到library cache里去的空间:reserved空间,用来存储突然来的大对象的空间,占shared pool空间大小的5%,

假设library cache的大小为5G,缓存着大量的SQL以及SQL的执行计划,现在,要去执行一个SQL,就在library cache里面的这些大量的SQL以及SQL的执行计划里面找,这就有一个问题,该怎么找呢?假设library cache里面有100万行,然后一个一个去对比,就要找100万次,这显然就不合适;

管理SQL以及SQL的执行计划

链(chain)
那么library cache里面是如何组织和管理SQL以及SQL的执行计划的,以便于很方便的找到要执行的SQL?

图解:
用链把一个一个的SQL链起来,假设library cache有500M大小,用4个链来管理SQL;对于SQL1来说:SQL1经过hash以后,得到hash值,然后计算:hash值/4的余数(0、1、2、3......):如果余数 = 0,就把SQL1挂在0号链上;如果SQL1在library cache里面,server process就认为SQL1一定在0号链上,然后在0号链上找,不需要在另外的链上找;另外假设SQL2,经过hash,然后计算余数,假设余数 = 1,就在1号链上找,最终没有找到,它就不找了,因为也不可能在其他的链上,然后SQL2就要产生硬解析了

链的特点:
1、链的一种访问方式:只能遍历,不能随机访问(找到链的头部就可以一个一个的找,一直找到链的尾部)
2、一种链一种作用(比如SQL经过hash,然后挂在链上的,找的时候也是SQL经过hash然后在链上找)

library cache里面的链:就是SQL经过hash值的方式挂起来的(当初怎么组织的,找的时候就怎么找)

library cache的大小,会影响链的数量(library cache多大,链的数量是多少,这个是oracle自己去调整、适应的;我们也可以调整相关的参数来调整链的数量)

一个链可以认为是一个bucket(桶)

Hash(其实就是一个函数)

例如对于SQL:select name from t where id = :x;
oracle会把SQL语句里面的每一个字母,转换成一个ASCII码值,每一个字母对应着一个编码,

一个SQL --> 一堆的文本字母 --> 一堆的数字

hash值与SQL的几种对应关系:
1、如果SQL1和SQL2完全相同,那么它们的hash值一定相等
2、如果SQL1和SQL2不相同,那么它们的hash值一定不相等
3、如果SQL1和SQL2的hash值相等,但是SQL1和SQL2不一定相同

所以比较两个SQL时:
1、如果两个SQL的hash值不相等,那么两个SQL就不相;
2、如果两个SQL的hash值相等,那么还要比较两个SQL,一个字母一个字母的去比较

free空间

free里面的chunk是如何管理的?
也是使用链来管理的

从free空间里面找空闲的chunk(内存块),怎么找?
根据大小来找;比如现在我们需要一个4k大小的,就在free里面找一个4k的chunk,不行就找比4k大一点点的(比如:5k、8k、12k),然后找5k的,所以free里面是通过一种以free chunk的大小的链把一个一个的chunk挂在上面的


图解:
有三个链:2k、8k、16k,现在需要一个9k的,就在8k的链上找,找到一个12k的,可以,就用一下,用了9k还剩下3k,又挂到2k的链上。

游标(cursor)

一个SQL以及SQL对应的执行计划,叫做一个cursor,在library cache里面

父游标(parent cursor)

父游标说的是:SQL文本;同一个SQL可能对应多个执行计划(因为访问的用户不同,表的名字一样(都是t表),但是表的内容不一样;或者因为绑定参数的值的不同(同一个值,一个表里有1000万行,另外一个表里只有10行(这里表的名字相同)),执行计划也可能不同)

子游标(children cursor)

子游标就是:执行计划;子游标的个数不定,根据实际情况而定(比如10个、100个不确定)

version count(版本数量)
例如一个父游标有10个子游标,那么它的版本数量就是10

latch:锁(内存锁、闩锁),用来保护链的


图解:
现在有一个问题:oracle有好多上的server process,;假设server process1执行SQL1,server process2执行SQL2,然后server process1执行SQL1的时候要解析,在library cache里面没找到,SQL1就要发生硬解析;执行SQL2的时候,同样也如此,SQL2也需要硬解析;同时解析;
在解析的时候,就需要在free里面找一个free chunk,把它写进去;假设解析SQL1需要一个,9k的free chunk,解析SQL2,需要一个10k的free chunk,所以都需要在8k的链上找,这就有一个并发的问题:假设SQL1和SQL2找到相邻的两个free chunk;SQL1就要把它找到的free chunk2拿下来,把free chunk1指向free chunk3;SQL2把找到的free chunk3拿下来,然后把free chunk2指向free chunk4;这时候,free chunk2和free chunk3都没了,链就断了,所以对于这种情况链就需要并发保护,使用锁(latch)来进行保护;
latch(对于0号链申请的一个内存结构),用来保护0号链的,现在server process1读这个latch(里面有没有写相关信息),发现里面是空的,server process1就以S的方式写上,server process1以S的方式来访问0号链;
latch里面是空的,说明没有进程来对这个链进行保护修改;然后server process2也要来访问0号链,发现有一个进程在以S的方式访问,server process2想加一个X方式,但是server process1以S的方式持有着latch,server process2也想持有着latch,但是S和X不兼容,所以server process2就不能持有着latch,这时候server process2就发生一次latch misses(latch丢失);
假设server process1在cpu1上工作,server process2在cpu2上工作(有两个cpu),这时候,还有一个server process3在等着cpu空出来再进去,现在对于server process3来说两种选择:
1、server process2退出来(latch丢失),再进入cpu2,server process3工作一段时间以后退出来,server process2再进去,继续执行之前未执行完的操作,这个过程叫做:context switch(CS);
2、server process1持有latch的时间非常短,很短的时间内就执行完了,这时候,server process2不出来,它执行一个for循环,占用着cpu2,等server process1执行完S之后释放了latch就进去,这时候server process3就可以使用cpu1了;
再有假设,有四个cpu,server process2不出来,占用着cpu2,这时候server process3也持有着latch,server process2又再一次latch丢失了,然后server process4又持有着latch,server process2空转cpu,server process2又再一次latch丢失,server process2多次latch丢失以后,server process2就转为sleep状态,然后就退出cpu了,其他的server process就可以占用这个cpu了

因此,如果数据库出现严重的latch征用,就会表现出cpu很繁忙
sleep状态,说明出现了多次latch misses(latch丢失)

当server process1想访问0号链的时候,有两种访问方式:
1、S(共享锁)的方式(读链,在链上找大小)
2、X(排他锁)的方式(修改链,就是往链上挂东西和往链上摘东西)

S方式和X方式的关系

例如:1、SQL1:S 2、SQL1:S 3、SQL1:X
SQL2:S SQL2:X SQL2:S
1、SQL1和SQL2可以同时进行(S、S可以兼容)
2、SQL1和SQL2不能同时进行,要等SQL1找完了,SQL2再去修改(S、X不兼容)
3、SQL1和SQL2不能同时进行,要等SQL1修改完了,SQL2再去找(S、X不兼容)

latch的另外一种情况:
多个链用一个latch来保护,就很有可能出现latch丢失,所以我们可以通过调整参数来让多个链让多个latch来保护

latch的种类(绝大部分):
1、父latch:library cache latch
2、子latch:每一个链上latch(library cache latch里面一个一个的latch)

latch的工作方式:
1、sp1:S方式持有着latch
sp2:X方式,出现latch misses,空转着cpu,多次latch misses以后,变为sleep状态

2、sp1:S方式持有着latch 0
sp2:X方式,也想持有着latch 0,出现latch misses;但是sp2从latch 0上的链上能找到想要的东西,从latch 1上的链上也能找到想要的东西,一样;这时候,sp2就跑到latch 1去找东西了

但是绝大多数都是以第一种工作方式(willing to waiter)工作:愿意等,或者必须等的

我们中高端的windows笔记本上都可以看到Dolby音效,TV电视上也有支持Dolby显示选项。

杜比主要有几类:Dolby全景声(也叫Atmos)、Dolby视界(Vision)、杜比影院(Dolby Cinema)

作为OEM厂商,如何获取杜比授权呢?可以看下Dolby官网的申请流程:
如何获取杜比授权许可 - Dolby Professional

上面是官方合同的流程了,没啥特别的,只是起步阶段。下面我讲下了解到的杜比合作流程:

先是合同,与杜比签合同时,Dolby全景声、Dolby视界分别需要支付2.5万美金押金,押金后面可以退回的

需要说明的是,签合同只能是以一级母公司(如果有的话)来签,提供各种证明啥的

签完合同,后面就是杜比认证流程,也可以叫联调阶段。杜比会安排专人对接提供驱动和软件,适配后寄产品到台湾实验室调参数,整个认证测试大概半个月。这个阶段是不需要费用的

后面就是集成设备,杜比会给驱动以及管理App,OEM厂商集成到母盘生产。如果需要试用的话,Windows Store里也可以安装Dolby Access、Dolby Audio

杜比音效
杜比音效 - Dolby Professional - Dolby Professional
,在OEM端集成到PC上,需要安装驱动。比如这台联想天启,音频处理器这里有DolbyAPO SWC Device音频处理驱动:

windows系统有默认的windows sonic,如果没有可能是注册了“扬声器保护”。杜比Atoms音效可以通过windows函数注入算法,与windows sonic结合混音输出。

再说说真正的使用费用,杜比会统一设备生产数量(线上或者其它渠道),单方向告知每年需要缴纳多少专利费用也叫版税,大概1-2美金一台。

注意有坑:因为杜比basic原因(下面会讲),一旦与杜比签合同,公司之前所有windows设备也要补缴专利费,这可不是小数字

啥叫杜比basic,这里
杜比Windows许可计划常见问题 - Dolby Professional
有介绍,结合网上其它文章,总结如下:

Dolby Basic是杜比与Windows合作,从Win10开始默认内置的杜比音效(Dolby Audio),它能满足用户对个人视频及优质娱乐的优化期望。通过在 Windows 中提供对杜比音频 (Dolby Audio) 的完全支持,Microsoft 在 Windows 应用程序中为用户提供一致、兼容和高质量的音频/视频体验。

Win10内置杜比Audio音效,Windows版本包含 AC-3 编解码器,Edge浏览器也支持杜比Audio
Dolby Digital Plus - Dolby Professional
,也可以见杜比官网新闻:
微软采用杜比音效提升Windows 10的娱乐体验 | 杜⽐新闻中⼼

AC-3 是一种支持多声道(“环绕声”)音频的音频编解码器,它也被称为杜比数字“Dolby Digital”。

原本,杜比 Windows Basic 如果公司每年生产超过 10万 台设备,只需向杜比支付版税。每年制造不到 10万台设备的 OEM 可以免费获得杜比技术。

而上面申请Dolby全景声、视界,就有坑了,要补杜比Basic的费用。

针对这个问题最近有个好消息,
Win11 24H2不再内置杜比音效,不预装杜比音频解码器

Media Player 中的编解码器 - Microsoft 支持

24H2系统自带的播放器将无法直接播放采用 AC-3 格式的音频文件。所以最近有很多反馈24H2不支持AC-3杜比音效的问题:

win11 24h2 移除了ac-3编解码支持(已找到解决方法) - 电脑讨论(新) - Chiphell - 分享与交流用户体验

[笔记本]如何在 Windows 11 24H2 之后安装杜比音频解码器 |官方支持 |华硕全球

新版杜比(Dolby)的安装及设置-联想知识库

在 Windows 11 版本 24H2 中恢复 Microsoft Dolby Digital 解码器/编码器 MFT 支持-远景论坛-微软极客社区

大家可以从Store安装解码器扩展解决:
Dolby Digital Plus decoder for PC OEMs - Windows官方下载 | 微软应用商店 | Microsoft Store

24H2之前的版本不受影响。另外杜比最新的音频编解码格式是AC-4

默认不支持杜比音效,对OEM厂商是好事,只需要关注杜比认证,出了多少台安装杜比软件就缴纳多少版税。

参考资料:

“杜比音效”究竟是什么? - 知乎
杜比

Dolby Vision、 Dolby Atmos和 DolbyAudio是什么?有什么区别? - 知乎

常见问题 - Dolby Professional

Windows 11 24H2:不再支持 AC-3 音频编码器,及应对措施 - 系统极客

关于杜比全景声的一些概念,终于搞懂了!

Windows 上的空间音效和“杜比音效”_哔哩哔哩_bilibili

技术背景

在Jax的JIT即时编译中,会追踪每一个Tensor的Shape变化。如果在计算的过程中出现了一些动态Shape的Tensor(Shape大小跟输入的数据有关),那么就无法使用Jax的JIT进行编译优化。最常见的就是
numpy.where
这种操作,因为这个操作返回的是符合判定条件的Index序号,而不同输入对应的输出Index长度一般是不一致的,因此在Jax的JIT中无法对该操作进行编译。当然,需要特别说明的是,
numpy.where
这个操作有两种用法,一种是
numpy.where(condition, 1, 0)
直接给输入打上Mask。另一种用法是
numpy.where(condition)
,这种用法返回的就是一个Index序列,也就是我们需要讨论的应用场景。

普通模式

我们考虑一个比较简单的Toy Model用于测试:

\[E=\sum_{|r_i-r_j|\leq \epsilon}q_iq_j
\]

在不采用即时编译的场景下,Jax的代码可以这么写:

import os
os.environ['XLA_PYTHON_CLIENT_PREALLOCATE']='false'

import numpy as np
np.random.seed(0)
import jax
from jax import numpy as jnp

def func(r, q, cutoff=0.2):
    dis = jnp.abs(r[:, None] - r[None])
    maski, maskj = jnp.where(dis<=cutoff)
    qi = q[maski]
    qj = q[maskj]
    return jnp.sum(qi*qj)

N = 100
r = jnp.array(np.random.random(N), jnp.float32)
q = jnp.array(np.random.random(N), jnp.float32)

print (func(r, q))
# 1035.7422

那么我们先记住这个输出的结果,因为采用的随机种子是一致的,一会儿可以直接跟JIT的输出结果进行对比。

JIT模式

Jax的JIT模式的使用方法常见的就是三种,一种是在函数头顶加一个
装饰器
,一种是在函数引用的时候使用
jax.jit(function)
来调用,最后一种是配合
partial
偏函数
来使用,都不是很复杂。那么这里先用装饰器的形式演示一下Jax中即时编译的用法:

import os
os.environ['XLA_PYTHON_CLIENT_PREALLOCATE']='false'

import numpy as np
np.random.seed(0)
import jax
from jax import numpy as jnp

@jax.jit
def func(r, q, cutoff=0.2):
    dis = jnp.abs(r[:, None] - r[None])
    maski, maskj = jnp.where(dis<=cutoff)
    qi = q[maski]
    qj = q[maskj]
    return jnp.sum(qi*qj)

N = 100
r = jnp.array(np.random.random(N), jnp.float32)
q = jnp.array(np.random.random(N), jnp.float32)

print (func(r, q))

正如前面所说,因为
numpy.where
对应的输出是一个动态的Shape,那么在编译阶段就会报错。报错信息如下:

Traceback (most recent call last):
  File "/home/dechin/projects/gitee/dechin/tests/jax_mask.py", line 21, in <module>
    print (func(r, q))
  File "/home/dechin/anaconda3/envs/jax/lib/python3.10/site-packages/jax/_src/traceback_util.py", line 162, in reraise_with_filtered_traceback
    return fun(*args, **kwargs)
  File "/home/dechin/anaconda3/envs/jax/lib/python3.10/site-packages/jax/_src/api.py", line 622, in cache_miss
    execute = dispatch._xla_call_impl_lazy(fun_, *tracers, **params)
  File "/home/dechin/anaconda3/envs/jax/lib/python3.10/site-packages/jax/_src/dispatch.py", line 236, in _xla_call_impl_lazy
    return xla_callable(fun, device, backend, name, donated_invars, keep_unused,
  File "/home/dechin/anaconda3/envs/jax/lib/python3.10/site-packages/jax/linear_util.py", line 303, in memoized_fun
    ans = call(fun, *args)
  File "/home/dechin/anaconda3/envs/jax/lib/python3.10/site-packages/jax/_src/dispatch.py", line 359, in _xla_callable_uncached
    return lower_xla_callable(fun, device, backend, name, donated_invars, False,
  File "/home/dechin/anaconda3/envs/jax/lib/python3.10/site-packages/jax/_src/profiler.py", line 314, in wrapper
    return func(*args, **kwargs)
  File "/home/dechin/anaconda3/envs/jax/lib/python3.10/site-packages/jax/_src/dispatch.py", line 445, in lower_xla_callable
    jaxpr, out_type, consts = pe.trace_to_jaxpr_final2(
  File "/home/dechin/anaconda3/envs/jax/lib/python3.10/site-packages/jax/_src/profiler.py", line 314, in wrapper
    return func(*args, **kwargs)
  File "/home/dechin/anaconda3/envs/jax/lib/python3.10/site-packages/jax/interpreters/partial_eval.py", line 2077, in trace_to_jaxpr_final2
    jaxpr, out_type, consts = trace_to_subjaxpr_dynamic2(fun, main, debug_info)
  File "/home/dechin/anaconda3/envs/jax/lib/python3.10/site-packages/jax/interpreters/partial_eval.py", line 2027, in trace_to_subjaxpr_dynamic2
    ans = fun.call_wrapped(*in_tracers_)
  File "/home/dechin/anaconda3/envs/jax/lib/python3.10/site-packages/jax/linear_util.py", line 167, in call_wrapped
    ans = self.f(*args, **dict(self.params, **kwargs))
  File "/home/dechin/projects/gitee/dechin/tests/jax_mask.py", line 12, in func
    maski, maskj = jnp.where(dis<=cutoff)
  File "/home/dechin/anaconda3/envs/jax/lib/python3.10/site-packages/jax/_src/numpy/lax_numpy.py", line 1077, in where
    return nonzero(condition, size=size, fill_value=fill_value)
  File "/home/dechin/anaconda3/envs/jax/lib/python3.10/site-packages/jax/_src/numpy/lax_numpy.py", line 1332, in nonzero
    size = core.concrete_or_error(operator.index, size,
  File "/home/dechin/anaconda3/envs/jax/lib/python3.10/site-packages/jax/core.py", line 1278, in concrete_or_error
    raise ConcretizationTypeError(val, context)
jax._src.traceback_util.UnfilteredStackTrace: jax._src.errors.ConcretizationTypeError: Abstract tracer value encountered where concrete value is expected: Traced<ShapedArray(int32[])>with<DynamicJaxprTrace(level=0/1)>
The size argument of jnp.nonzero must be statically specified to use jnp.nonzero within JAX transformations.
The error occurred while tracing the function func at /home/dechin/projects/gitee/dechin/tests/jax_mask.py:9 for jit. This concrete value was not available in Python because it depends on the value of the argument 'r'.

See https://jax.readthedocs.io/en/latest/errors.html#jax.errors.ConcretizationTypeError

The stack trace below excludes JAX-internal frames.
The preceding is the original exception that occurred, unmodified.

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

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/home/dechin/projects/gitee/dechin/tests/jax_mask.py", line 21, in <module>
    print (func(r, q))
  File "/home/dechin/projects/gitee/dechin/tests/jax_mask.py", line 12, in func
    maski, maskj = jnp.where(dis<=cutoff)
  File "/home/dechin/anaconda3/envs/jax/lib/python3.10/site-packages/jax/_src/numpy/lax_numpy.py", line 1077, in where
    return nonzero(condition, size=size, fill_value=fill_value)
  File "/home/dechin/anaconda3/envs/jax/lib/python3.10/site-packages/jax/_src/numpy/lax_numpy.py", line 1332, in nonzero
    size = core.concrete_or_error(operator.index, size,
jax._src.errors.ConcretizationTypeError: Abstract tracer value encountered where concrete value is expected: Traced<ShapedArray(int32[])>with<DynamicJaxprTrace(level=0/1)>
The size argument of jnp.nonzero must be statically specified to use jnp.nonzero within JAX transformations.
The error occurred while tracing the function func at /home/dechin/projects/gitee/dechin/tests/jax_mask.py:9 for jit. This concrete value was not available in Python because it depends on the value of the argument 'r'.

See https://jax.readthedocs.io/en/latest/errors.html#jax.errors.ConcretizationTypeError

想避免这个报错,要么就是对该函数不做编译(牺牲性能),要么就是自己写一个CUDA算子(增加工作量),再就是我们这里用到的NonZero定长输出的方法(预置条件)。

NonZero的使用

使用Jax的NonZero函数时,也有一点需要注意:虽然NonZero可以做到固定长度的输出,但是这个固定的长度本身也是一个名为
size
的传入参数。也就是说,NonZero的输出Shape也是要取决于输入参数的。Jax开发时也考虑到了这一点,所以在编译时提供了一个功能可以设置静态参量:
static_argnames
,例如我们的案例中,将
size
这个名称的传参设置为静态参量,这样就可以使用Jax的即时编译了:

import os
os.environ['XLA_PYTHON_CLIENT_PREALLOCATE']='false'

import numpy as np
np.random.seed(0)
import jax
from jax import numpy as jnp
from functools import partial

@partial(jax.jit, static_argnames='size')
def func(r, q, cutoff=0.2, size=5000):
    if q.shape[0] != r.shape[0]+1:
        raise ValueError("The q.shape[0] should be equal to r.shape[0]+1")

    dis = jnp.abs(r[:, None] - r[None])
    maski, maskj = jnp.nonzero(jnp.where(dis<=cutoff, 1, 0), size=size, fill_value=-1)
    qi = q[maski]
    qj = q[maskj]
    return jnp.sum(qi*qj)

N = 100
r = jnp.array(np.random.random(N), jnp.float32)
q = jnp.array(np.random.random(N), jnp.float32)
pader = jnp.array([0.], jnp.float32)
q = jnp.append(q, pader)

print (func(r, q))
# 1035.7422

可以看到,函数用Jax的JIT成功编译,并且输出结果跟前面未编译时候是一致的。当然,这里还用到了一个小技巧,就是NonZero函数输出结果时,不到长度的输出结果会被自动Pad到给定的长度,这里Pad的值使用的是我们给出的
fill_value
。因为NonZero输出的也是索引,这样我们可以把Pad的这些索引设置为
-1
,然后在构建参数
\(q\)
的时候事先在末尾
append
一个0,这样就可以确保计算的输出结果直接就是正确的。

总结概要

在使用Jax的过程中,有时候会遇到函数输出是一个动态的Shape,这种情况下我们很难利用到Jax的即时编译的功能,不能使得性能最大化。这也是使用Tensor数据结构来计算的一个特点,有好有坏。本文介绍了Jax的另外一个函数NonZero,可以使得我们能够编译那些动态Shape输出的函数。

版权声明

本文首发链接为:
https://www.cnblogs.com/dechinphy/p/nonzero.html

作者ID:DechinPhy

更多原著文章:
https://www.cnblogs.com/dechinphy/

请博主喝咖啡:
https://www.cnblogs.com/dechinphy/gallery/image/379634.html

前言

watch这个API大家应该都不陌生,在Vue3版本中给watch增加不少有用的功能,比如
deep选项支持传入数字

pause、resume、stop方法

once选项

onCleanup函数
。这些功能大家平时都不怎么用得上,但是在一些特定的场景中,他们能够起大作用,这篇文章欧阳就来带你盘点一下这些功能。

关注公众号:【前端欧阳】,给自己一个进阶vue的机会

deep
支持传入数字

deep
选项大家应该比较熟悉,常见的值为
true
或者
false
,表示是否深度监听
watch
传入的对象。

在Vue3.5版本中对
deep
选项进行了增强,不光支持布尔值,而且还支持传入数字,数字表示需要监听的层数。

比如下面这个例子:

const obj1 = ref({
  a: {
    b: 1,
    c: {
      d: 2,
      e: {
        f: 3,
      },
    },
  },
});

watch(
  obj1,
  () => {
    console.log("监听到obj1变化");
  },
  {
    deep: 3,
  }
);

function changeDeep3Obj() {
  obj1.value.a.c.d = 20;	// 能够触发watch回调
}

function changeDeep4Obj() {
  obj1.value.a.c.e.f = 30;	// 不能触发watch回调
}

在上面的例子
watch

deep
选项值是3,表明监听到对象的第3层。

changeDeep3Obj
函数中就是修改对象的第3层的
d
属性,所以能够触发
watch
的回调。


changeDeep4Obj
函数是修改对象的第4层的
f
属性,所以不能触发
watch
的回调。

他的实现也很简单,我们来看一下deep相关的源码:

function watch(source, cb, options) {
  // ...省略
  if (cb && deep) {
    const depth = deep === true ? Infinity : deep
    getter = () => traverse(baseGetter(), depth)
  }
  // ...省略
}

这里的
depth
就表示watch监听一个对象的深度。

如果
deep
选项的值为true,那么就将
depth
设置为正无穷
Infinity
,说明需要监听到对象的最深处。

如果
deep
选项的值为false,或者没有传入
deep
,那么就表明只需要监听对象的最外层。

如果
deep
选项的值为number类型数字,那么就把这个数字赋给
depth
,表明需要监听到对象的具体某一层。

pause、resume、stop方法

这三个方法也是Vue3.5版本中引入的,通过解构
watch
函数的返回值就可以直接拿到
pause

resume

stop
这三个方法。

我们来看一下源码,其实很简单:

function watch(source, cb, options) {
  // ...省略
  watchHandle.pause = effect.pause.bind(effect)
  watchHandle.resume = effect.resume.bind(effect)
  watchHandle.stop = watchHandle
  return watchHandle
}

watch返回了一个名为
watchHandle
的对象,对象上面有
pause、resume、stop
这三个方法,所以我们可以通过解构
watch
函数的返回值拿到这三个方法。

pause
方法的作用是“暂停”watch回调的触发,也就是说在暂停期间不管watch监听的响应式变量如何改变,他的回调函数都不会触发。

有“暂停”,那么肯定就有“恢复”。

resume
方法的作用是恢复watch回调的触发,此时会主动执行一次watch的回调。后面watch监听的响应式变量改变时,他的回调函数也会触发。

来看个demo,代码如下:

<template>
  <button @click="count++">count++</button>
  <button @click="runner.pause()">暂停</button>
  <button @click="runner.resume()">恢复</button>
  <button @click="runner.stop()">停止</button>
</template>

<script setup lang="ts">
import { watch, ref } from "vue";

const count = ref(0);
const runner = watch(count, () => {
  console.log(count.value);
});
</script>

点击“count++”按钮会导致
watch
回调中的console执行。

但是当我们点击了“暂停”按钮后,此时我们再怎么点击“count++”按钮都不会触发
watch
的回调。

点击
恢复
按钮后会立即触发一次
watch
回调的执行,后面点击“count++”按钮也同样会触发
watch
的回调。

我们来看看
pause

resume
方法的源码,很简单,代码如下:

class ReactiveEffect {
  pause(): void {
    this.flags |= EffectFlags.PAUSED
  }

  resume(): void {
    if (this.flags & EffectFlags.PAUSED) {
      this.flags &= ~EffectFlags.PAUSED
      if (pausedQueueEffects.has(this)) {
        pausedQueueEffects.delete(this)
        this.trigger()
      }
    }
  }

  trigger(): void {
    if (this.flags & EffectFlags.PAUSED) {
      pausedQueueEffects.add(this)
    } else if (this.scheduler) {
      this.scheduler()
    } else {
      this.runIfDirty()
    }
  }
}


pause

resume
方法中通过修改
flags
属性的值,来切换是不是“暂停状态”。

在执行
trigger
方法依赖触发时,就会先去读取
flags
属性判断当前是不是“暂停状态”,如果是那么就不去执行watch的回调。

从上面的代码可以看到这三个方法是在
ReactiveEffect
类上面的,这个
ReactiveEffect
类是Vue的一个底层类,
watch

watchEffect

watchPosEffect

watchSyncEffect
都是基于这个类实现的,所以他们自然也支持
pause

resume

stop
这三个方法。

最后就是
stop
方法了,当你确定后面都不再想要触发watch的回调了,那么就调用这个
stop
方法。代码如下:

const watchHandle: WatchHandle = () => {
  effect.stop()
  if (scope && scope.active) {
    remove(scope.effects, effect)
  }
}

watchHandle.stop = watchHandle

响应式变量
count
收集的订阅者集合中有这个watch回调,所以当
count
的值改变后会触发watch回调。这里的
stop
方法中主要是依靠双向链表将这个watch回调从响应式变量
count
的订阅者集合中给remove掉,所以执行stop方法后无论
count
变量的值如何改变,watch回调也不会再执行了。(PS:如果你看不懂这段话,建议你去看看我的上一篇
Vue3.5双向链表
文章,看完后你就懂了)

once选项

如果你只想让你的watch回调只执行一次,那么可以试试这个
once
选项,这个是在Vue3.4版本中新加的。

看个demo:

<template>
  <button @click="count++">count++</button>
</template>

<script setup lang="ts">
import { watch, ref } from "vue";

const count = ref(0);
watch(
  count,
  () => {
    console.log("once", count.value);
  },
  {
    once: true,
  }
);
</script>

由于使用了
once
选项,所以只有第一次点击“count++”按钮才会触发watch的回调。后面再怎么点击按钮都不会触发watch回调。

我们来看看
once
选项的源码,很简单,代码如下:

function watch(source, cb, options) {
  const watchHandle: WatchHandle = () => {
    effect.stop()
    if (scope && scope.active) {
      remove(scope.effects, effect)
    }
  }

  if (once && cb) {
    const _cb = cb
    cb = (...args) => {
      _cb(...args)
      watchHandle()
    }
  }

  // ...省略
  watchHandle.pause = effect.pause.bind(effect)
  watchHandle.resume = effect.resume.bind(effect)
  watchHandle.stop = watchHandle
  return watchHandle
}

先看中间的代码
if (once && cb)
,这句话的意思是如果
once
选项的值为true,并且也传入了watch回调。那么就封装一层新的
cb
回调函数,在新的回调函数中还是会执行用户传入的watch回调。然后再去执行一个
watchHandle
函数,这个
watchHandle
是不是觉得有点眼熟?

前面讲的
stop
方法其实就是在执行这个
watchHandle
,执行完这个
watchHandle
函数后watch就不再监听
count
变量了,所以后续不管
count
变量怎么修改,watch的回调也不会再触发。

onCleanup函数

有的情况我们需要watch监听一个变量,然后去发起http请求。如果变量改变的很快就会出现第一个请求还没回来,第二个请求就已经发起了。在一些极端情况下还会出现第一个请求的响应比第二个请求的响应还要慢,此时第一个请求的返回值就会覆盖第二个请求的返回值。实际上我们期待最终拿到的是第二个请求的返回值。

这种情况我们就可以使用
onCleanup函数
,他是作为watch回调的第三个参数暴露给我们的。看个例子:

watch(id, async (newId, oldId, onCleanup) => {
  const { response, cancel } = myFetch(newId)
  // 当 `id` 变化时,`cancel` 将被调用,
  // 取消之前的未完成的请求
  onCleanup(cancel)
  data.value = await response
})

watch回调的前两个参数大家都很熟悉:新的id值和旧的id值。第三个参数就是
onCleanup
函数,在watch回调触发之前调用,所以我们可以使用他来cancel掉上一次的请求。

onCleanup
函数的注册也很简单,代码如下:

let boundCleanup

boundCleanup = fn => onWatcherCleanup(fn, false, effect)

function watch(source, cb, options) {
  // ...省略
  const job = (immediateFirstRun?: boolean) => {
    const args = [
      newValue,
      oldValue,
      boundCleanup,
    ]
    cb(...args)
    oldValue = newValue
  }
  // ...省略
}

执行watch回调实际就是在执行这个
job
函数,在
job
函数中执行watch回调时传入了三个参数。分别是
newValue

oldValue

boundCleanup
。前两个参数大家都很熟悉,第三个参数
boundCleanup
是一个函数:
fn => onWatcherCleanup(fn, false, effect)

这个
onWatcherCleanup
大家熟悉不?这也是Vue暴露出来的一个API,注册一个清理函数,在当前侦听器即将重新运行时执行。关于
onWatcherCleanup
之前欧阳写过一篇文章专门讲了如何使用:
使用Vue3.5的onWatcherCleanup封装自动cancel的fetch函数

总结

这篇文章盘点了Vue3 watch新增的一些新功能:
deep选项支持传入数字

pause、resume、stop方法

once选项

onCleanup函数
。这些功能大家平时可能用不上,但是还是要知道有这些功能,因为有的情况下这些功能能够派上大用场。

关注公众号:【前端欧阳】,给自己一个进阶vue的机会

另外欧阳写了一本开源电子书
vue3编译原理揭秘
,看完这本书可以让你对vue编译的认知有质的提升。这本书初、中级前端能看懂,完全免费,只求一个star。