阅读视图

发现新文章,点击刷新页面。
🔲 ☆

拉个 JSON 居然要装 5 个第三方库?终于明白 Go 的标准库到底有多“霸道”

本文永久链接 – https://tonybai.com/2026/03/11/standard-library-is-part-of-the-go-success

大家好,我是Tony Bai。

在现代软件开发中,我们似乎已经患上了一种名为“依赖上瘾”的绝症。

新建一个项目,你敲下的第一行命令大概率不是写业务逻辑,而是 npm install、cargo add 或者 pip install。我们潜意识里已经默认:语言本身只提供最基础的砖块,稍微高级一点的功能(比如发起个网络请求、解析个 JSON),都必须去浩如烟海的开源社区里“淘金”。

但这种习以为常的生态繁荣,真的是一件好事吗?

近日,在 Reddit 的 r/golang 社区,一个题为《标准库是 Go 成功的一部分吗?》的帖子,像一颗深水炸弹,炸出了无数程序员对于“依赖地狱(Dependency Hell)”的疯狂吐槽。

发帖人分享了一个极其真实且让人啼笑皆非的日常小故事:

他想写一个微型应用,目的非常单纯——从家里的太阳能光伏电池 Web 服务器上抓取一个 JSON 文件,解析出来,然后把能源数据显示在屏幕上。

他首先用 Go 语言写了一版。极其丝滑,仅靠自带的标准库就搞定了网络请求和 JSON 解析,编译出一个干干净净的二进制文件,直接跑通。

几天后,他闲来无事,想测试一下其他编译型语言:

  • 他尝试了 D 语言,发现在不依赖第三方库的情况下,D 语言根本无法在三大主流操作系统上顺利完成“下载并解析 JSON”这个基础任务。
  • 他转头去折腾目前红得发紫的 Rust,结果发现,如果不借助 reqwest(处理 HTTP)和 serde(处理 JSON)这两个庞大的第三方 Crates,面对这个简单的需求,他同样寸步难行。
  • 一圈折腾下来,只有 Nim 勉强做到了原生支持。

这个看似不起眼的小实验,无意间撕开了现代软件工程一块遮羞布,也揭示了 Go 语言在后端开发中一个极其“霸道”、却常被新手低估的绝对优势:降维打击般的标准库(Standard Library)。

今天,我们就来深度剖析一下,为什么大量工程师越来越偏爱 Go 这种“零依赖”的极简哲学。

你以为你在写代码,其实你在做“库的选品”

在很多主打“生态繁荣”的编程语言中,标准库被视为一种“最小公集”。语言的设计者把高级特性推给社区,美其名曰“保持语言的核心轻量”。

这听起来很美好,但在实际的商业工程中,它带来了一个极其消耗心智的隐性成本:决策疲劳(Decision Fatigue)

想象一下,当你用 Node.js 或者 Rust 仅仅需要发起一个异步 HTTP 请求时,你需要经历怎样痛苦的内心戏?

  1. 打开包管理网站,搜索 “http client”。
  2. 面对排名前 5 的主流库,你开始像个电商买手一样比对:A 库的 Star 数最高但半年没更新了;B 库的 API 最优雅但是性能测试差点意思;C 库支持最新的异步模型但文档写得像天书。
  3. 你甚至还要去翻看它们的 GitHub Issues,看看有没有致命的内存泄漏。
  4. 纠结了一下午,终于选定了一个库,引入依赖,然后开始痛苦地学习它那套独创的 API 调用法则。

而在 Go 中,这一切内耗根本不存在。

正如 Reddit 帖子评论区一位资深 Gopher 一针见血指出的:

“Go 的成功不仅在于它轻量、简单、易学,还在于它自带了一个庞大且极其优秀的标准库。因此,在开始处理每个微小的子任务之前,你不需要去评估一堆第三方库。”

Go 的哲学是“开箱即用”。net/http 就在那里,encoding/json(以及json/v2) 就在那里。它直接消灭了你在技术选型上的无意义内耗,让你可以把 100% 的脑力,全部砸在能给公司赚钱的业务逻辑上。

不是所有的标准库,都敢叫“生产级”

看到这里,Python 开发者可能会不服气:“Python 也有非常丰富的标准库啊,我们叫 Batteries included(自带电池)!”

没错,Python 的标准库确实庞大,但问题在于:它好用吗?它能直接扛高并发吗?

Python 自带的 urllib API 设计得极其反人类,导致全网的 Python 教程都在教你第一时间去 pip install requests。

如果你提供的标准库只是一个“能跑就行”的玩具,开发者迟早还是要逃向第三方库的怀抱。其他语言的标准库,大多只敢称自己是“开发级(Dev-level)”的替代品。

但 Go 的标准库,是真正意义上的“生产级(Production-ready)”。

以 Go 的 net/http 为例。它不仅仅是能发个请求那么简单,它底层直接内置了工业级的连接池、自动支持 HTTP/2、拥有极其精细的超时控制,并且在骨子里完美契合了 Go 的 Goroutine 并发模型。

在这个世界上,有无数估值数十亿美元的独角兽公司,他们的高并发微服务底层,没有套 Nginx,没有套 Tomcat 或 Gunicorn,而是直接裸跑在 Go 标准库的 net/http.Server 之上! 这在其他语言的生态里,简直是不可想象的。

同样,Go 的 crypto 包也不是随便拼凑的开源算法,它是由谷歌著名的密码学家亲自操刀设计和维护的。它被全球安全界公认为是业界最安全、最难被开发者“误用”的密码学实现之一。

每一次引入第三方库,都是在给系统埋雷

在现代软件工程中,有一句极其沉重的话:“依赖即债务”

你想要一个香蕉,但开源社区给你的是一只拿着香蕉的大猩猩,以及大猩猩背后的一整片热带雨林。你敲下的每一个 npm install,都在把公司的核心系统暴露给未知的风险。

前几年的 Java Log4j 史诗级漏洞事件,以及三天两头上头条的 NPM 恶意投毒、删库跑路事件(比如著名的 left-pad 事件),给全行业上了血淋淋的一课。当你引入一个计算日期的第三方包时,它可能又间接依赖了 50 个你闻所未闻的子依赖,其中哪怕有一个包的作者被黑客盗了号,你的服务器底裤就被看穿了。

发帖的楼主深刻地探讨了这一点:

“保持项目没有外部依赖,让维护变得更加容易。开发者经常忘记,向项目中添加一个依赖,就增加了一份审查恶意代码的责任。”

Go 强大的标准库,为你提供了一道天然的“供应链安全护城河”。

像前面提到的“拉取光伏面板 JSON 并解析”这样的任务,在 Go 中是零外部依赖的。

零外部依赖,就意味着零第三方供应链风险。这种“自给自足”的底气,在如今极度苛求数据安全、合规性审计的企业级开发中,绝对是降维打击般的加分项。

被忽视的跨平台与 Unicode 魔法

除了宏观的网络和并发处理,Go 的标准库在极其底层、却又极其折磨人的领域,展现出了极其深厚的内功。

熟悉 C/C++ 的老兵一定懂得,在底层处理多语言编码(locales)和宽字符(wide chars)是一场怎样的噩梦。而 Go 的标准库原生且完美地接纳了 UTF-8。从 strings 包到 unicode/utf8,再到字符串底层极其优雅的字节切片(Byte Slice)设计,让多语言文本处理变得如同呼吸一般自然。

更不用提 Go 那近乎魔法的跨平台交叉编译

Go 的标准库(如 os、path/filepath)对底层操作系统的 API 差异进行了极致的抽象。作为开发者,你可以在一台舒舒服服的 Mac 上写代码,只需加一个环境变量 GOOS=linux,就能瞬间利用标准库编译出一个毫无平台依赖的静态二进制文件,直接扔到 Ubuntu 服务器上完美运行。

这种抽象能力,让一切第三方跨平台打包工具都显得极其多余。

Go 1 的承诺,十年前的代码今天依然能跑

最后,Go 的标准库之所以被几百万开发者绝对信任,离不开 Go 团队当年立下的一个近乎严苛的誓言:Go 1 兼容性保证(Go 1 Compatibility Guarantee)

这意味着什么?这意味着你在 2012 年基于 Go 1.0 标准库写下的一段处理 HTTP 的代码,在今天最新的 Go 1.26 编译器下,不仅能一字不改地编译通过,而且运行行为保持绝对一致!

在任何其他语言的开源生态中,很多曾经辉煌一时的第三方霸主库,都会因为作者的精力衰退、兴趣转移或资金断裂,最终走向被废弃(Deprecated)的命运。当你依赖的库停止维护时,你的整个项目组都要被迫进行痛苦的代码大重构。

开源世界充满了不确定性,而 Go 的标准库,背后站着的是谷歌顶级的工程团队,拥有与这门语言同等漫长的寿命周期。

这种确定性的安全感,是任何高星的第三方库都无法给予你的。

写在最后:最好的工具,就是让你感受不到它的存在

我们常说,Go 是一门为“大规模软件工程”而生的语言。

这种工程基因,不仅仅体现在它的极速编译和极简语法上,更深深地烙印在它那套“霸道”的标准库里。

它逼着你放下对“奇技淫巧”的追求,逼着你放弃花里胡哨的第三方依赖,回归到用最稳固的基石,构建最健壮的系统的正道上来。

当然,Go 的标准库并不完美,比如千呼万唤始出来的官方 UUID 至今仍让社区望眼欲穿。但在构建现代云原生应用、微服务 API 和数据网关时,它依然交出了一份近乎满分的答卷。

它告诉了所有高级架构师一个硬道理:最好的工具,是让你感受不到工具存在的工具;最强大的库,是让你根本不用去寻找库的库。


今日互动吐槽

你在平时的开发中,被哪个第三方库(依赖地狱)狠狠坑过?或者你觉得 Go 的标准库里,现在最缺哪个核心功能?

欢迎在评论区开喷吐槽!


认知跃迁:读懂底层骨架,才能驾驭“降维打击”

很多写了几年 CRUD 的朋友问我:“Tony 老师,既然 Go 的标准库这么牛,那我只要背熟标准库的 API 是不是就能进大厂了?”

大错特错。会调 API 只是技工,看懂底层设计才是架构师。

Go 语言“少即是多”的工程美学,其精髓并不在于它提供了什么函数,而在于它是如何用极简的代码,实现千万级并发与跨平台抽象的。比如 net/http 背后那精妙的 Goroutine 调度模型,比如 context 是如何控制全局超时的。

如果你渴望突破技术瓶颈,不再满足于做一个“只会调包的熟练工”,而是想从骨子里吃透 Go 的系统级设计思维——

我的全新极客时间专栏 《Go语言进阶课》正是为你量身打造。

在这 30+ 讲硬核内容中,我将带你剥开语法糖,深入标准库与并发模型的底层骨架,锻造你编写高可用、生产级微服务的顶级工程实践能力。

目标只有一个:助你完成从“Go 熟练工”到“能做架构决策的 Go 专家”的蜕变!

扫描下方二维码,加入专栏,让我们一起深挖这门语言背后的“降维打击”之力。


还在为“复制粘贴喂AI”而烦恼?我的新专栏 AI原生开发工作流实战 将带你:

  • 告别低效,重塑开发范式
  • 驾驭AI Agent(Claude Code),实现工作流自动化
  • 从“AI使用者”进化为规范驱动开发的“工作流指挥家”

扫描下方二维码,开启你的AI原生开发之旅。


原「Gopher部落」已重装升级为「Go & AI 精进营」知识星球,快来加入星球,开启你的技术跃迁之旅吧!

我们致力于打造一个高品质的 Go 语言深度学习AI 应用探索 平台。在这里,你将获得:

  • 体系化 Go 核心进阶内容: 深入「Go原理课」、「Go进阶课」、「Go避坑课」等独家深度专栏,夯实你的 Go 内功。
  • 前沿 Go+AI 实战赋能: 紧跟时代步伐,学习「Go+AI应用实战」、「Agent开发实战课」、「Agentic软件工程课」、「Claude Code开发工作流实战课」、「OpenClaw实战分享」等,掌握 AI 时代新技能。
  • 星主 Tony Bai 亲自答疑: 遇到难题?星主第一时间为你深度解析,扫清学习障碍。
  • 高活跃 Gopher 交流圈: 与众多优秀 Gopher 分享心得、讨论技术,碰撞思想火花。
  • 独家资源与内容首发: 技术文章、课程更新、精选资源,第一时间触达。

衷心希望「Go & AI 精进营」能成为你学习、进步、交流的港湾。让我们在此相聚,享受技术精进的快乐!欢迎你的加入!

img{512x368}


商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。如有需求,请扫描下方公众号二维码,与我私信联系。

© 2026, bigwhite. 版权所有.

🔲 ☆

西电信安协会招新系统 Golang 后端开发小记

大概是,今年 1 月份与协会的 CopperKoi 同学把协会的招新系统重新做了一版。CopperKoi 用 Vite 写前端,他和我用 Golang 写后端。后端用 gin 处理请求,用 gorm 对接数据库。文章到这里就结束了。

招新系统公测截图

接下来是废话时间。

CopperKoi 真的是一个很厉害的大佬。

吹,接着吹

迎新页面(一般每年由大一同学建设)

引自某个文档

大概就是这样,今年我们在使用的招新系统有一些功能需要改进,但我找不到代码仓库,协会服务器里的站点文件夹里赫然堆着 Flask 大代码,明显不是今年用的这个。又听学长说历来都有大一新生写招新页面的传统,就来了。

CopperKoi 此前跟我聊过前端。当我问他愿不愿意带飞我做一个招新页面时,他欣然答应了。此前写过一些关于流程和数据库的设计图,CopperKoi 也写了接口文档,开整!

Golang 写完之后可以直接部署成二进制文件,这一点比 Python 方便许多。虽然能做到这一点的语言很多,Python 也有 Pyinstaller,不过没用过。

对面试者的面试流程
对面试官的面试流程
数据库模型
很古早的流程和数据库设计

我们在开发的过程中有一些迷,这里记录一下。

前后端分离了怎么用 X-CSRF-Token 来防 csrf:好像光想着要放 csrf 了,但我们不是前后端分离了吗?😅后来 CopperKoi 想到前端把 JWT 放在 Authorization Header 不依赖 Cookies 就可以让 csrf 几乎不可能实现。

腾讯的 CodeBuddy 写代码是真的快。我刚开始边翻文档边写代码速度好慢,把文档交给 ai 没过多久路由就都写好了。目前公测没有报告出 ai 代码的问题(只有我灵机一动犯的错)。但是 ai 写代码和我一样有时会出现想当然的错误。

ai 的幻觉(幸好我略微熟悉代码)

密码怎么传输?我原本以为在后端 bcrypt 的基础上前端用 sha256 加密之后传输会安全一点,并且将这个设计补充到文档里。但当我问 Deepseek 时,它给出了不同的意见(聊天链接)。“sha256 碰撞都来了。”CopperKoi 经过一番研究之后采用了 Deepseek 的建议,传输明文密码,传输过程中的安全由 https 来保障。

没过几天,CopperKoi 的前端也写好了。测试的时候公告的置顶接口出现了一个很奇妙的问题。

接口原本用来绑定 body 的模型

接口通过 Params 获取要置顶 / 取消置顶的公告的 uuid,通过 ShouldBindJSON 函数将 body 绑定到模型上。Pinned 布尔值为真则置顶公告,为假则取消置顶。

但是我在测试的时候发现:置顶公告的功能可以跑通,但取消置顶都会报告“参数校验失败”的错误。欸,明明 body 的结构符合要求啊。然后,就出现了惊天大瓜。

看到这个回答我脑子都宕机了

六百六十六。我和 CopperKoi 都看呆了。当我们把这个发给学长时,他给我们分享了这个。

When I send a JSON request with the value true it works, but when I send the same request with false I get the following error.

In summary you need a pointer or custom type to know if the value exists due to Go’s static nature.

Bool binding required Bug · Issue #814 · gin-gonic/gin

然后学长跟我们介绍了一件事情。

对于一些可以 nil 的字段而言,静态检查十分重要。GORM 的默认主键自增是从 0 开始的,不是从 1 开始,而你初始化系统用的第一个账户大概率是 admin 账户,账户 id 大概率也是 0。如果因为某些 bug,鉴权中间件没绑上 id……所有人都是 admin 了,而且 go 不会有任何错误汇报。

寄。但我们主键用 uuid 好像把这个坑绕过去了。(不然高低也要踩一回)

给 CopperKoi 提建议的时候我可得劲啦!我给 CopperKoi 提了:将性别从 input 改为单选框、textarea 没有限制宽度可以拉出容器边框、简历显示不出来等等…我们改、测得差不多之后就把代码部署上去让协会里的人公测。

在公测之前,我灵机一动:要不把里面能提的东西都提到 env 里面吧,把参数硬写在代码里不太优雅。然后我看到了 JWT 的 secret…让程序从 env 里面获取 secret。我真是个甜菜!(操作没错,但我的环境变量是用 .env 设置的)

想当然地让程序从 env 里获取密钥

在公测的那天晚上,服务就被学长攻破了。

学长通过修改 JWT 越权访问面试官才能访问的接口

学长通过 gpt 发现的。

jwtSecret =[]byte(os.Getenv("secretKey")在包初始化阶段执行,而 env 是在 main.go 里godotenv.Load()才加载;结果 jwtSecret 可能为空字符串。攻击者可用空密钥自行签发 JWT,伪造 role=interviewer 直接接管所有受保护接口。

gpt如是说

把 secret 硬编码在代码里就把这个漏洞 fix 了。

CopperKoi/XDSEC-Recruitment-System:协会2026招新系统前端
Xiaozonglin/xdsec-join-2026: 西电信安协会2026招新系统后端

西电信安协会招新系统 Golang 后端开发小记最先出现在林林杂语

🔲 ☆

高德封杀个人开发者:5万门槛下的中国创新困局

一部复古电话机听筒搁置在桌上,旁边是一张被红色印章盖了“封锁”字样的中国地图,地图上散落着几个代表AI的小零件,羊皮纸,钢笔彩色手绘的统一风格。

被高德打电话,告知封闭了我的开发者账号,在中国创新越来越难了

大家好,欢迎收听老范讲故事的YouTube频道。我昨天接到了高德地图的电话,原因很简单,我去年申请过高德地图的MCP账号,可以通过这个MCP在AI agent里边去调用,比如路径啊,这周围有什么好吃的呀,调用这些信息。去年我印象里这个账号是有一定的免费额度,因为我自己做完演示,或者做完了一些测试以后就放着了,就没有再动它。

突如其来的商业化转型

昨天给我打电话了,说:“您好,您是谁谁谁吗?”我说是,因为我们这种账号都是个人实名的,是有身份证号码、有手机号、有名字的,所以他肯定是能够找到我。他就说了,说我们这一块准备都转成商业账号。

我说:“什么意思?我说我是个人账号啊,这个有问题吗?”

他说:“我们不,我们要把所有的个人账号全都封掉。”

我说我做了实名制,他说那也不行,我们准备把所有个人账号都封掉。

我说:“那这个商业账号是多少钱?如果我自己还可以按量使用,你比如说我用多少交多少钱,或者有一些比较低的套餐,10块钱20块钱一个月的这种,我说我还是愿意买的。虽然我用的不多,但是高德地图的那个MCP还是挺好用的。”

说我们现在没有这种套餐,我们转成商业账号以后,最低一年5万块

一道高耸入云的坚固城墙挡住了去路,城门上挂着一个巨大的金色价签写着“50,000”,一个渺小的背影站在城墙下仰望,羊皮纸,钢笔彩色手绘的统一风格。

当时把我问傻了。我说这5万,我说你当我是干嘛的?为什么要交这5万?我还威胁他们,我说我自己是YouTuber,我要录节目骂你们。他们表示了遗憾,但是也没有什么办法,说这个我们领导这么规定的,必须这么办。以后就没有任何个人账号了,所有人,你想去调高德地图的API就是5万了。

没有任何低价替代方案

我说那有什么别的办法没有?他说也有办法,现在可以跟人合租账号。我说那怎么个租法呢?

  • 他说10个人租一个账号,那么每个人5,000,这个也是可以的。
  • 说每一个账号大概是做可能是60个TOKEN还是多少个TOKEN,你可以拿这个TOKEN用来,他分给你6个……

我说这5,000对于我来说也有点夸张了,我说我就是拿来玩一下,不应该啊,搞成这样。我说那有没有别的路径可选?人家告诉我了,别费劲,百度地图也是5万,大家都是按照一个标准来的。

官方理由:防止“薅羊毛”

我说这,你们现在都是主张个人开发者搞这个一人公司的时候,你们整这种事干嘛呢?到底为啥?人家讲了,说去年确实是免费开放过,但是被薅羊毛了

因为中国人还是很聪明的,你一旦开了这种免费账号,他们就会开大量的这种账号,然后把每一个账号的额度都给你薅满。说我们被薅了很多羊毛,最后也没挣出钱来,我们不乐意了,我们准备搞这样的一个事情。

一群可怜的绵羊被巨大的金属机械臂彻底剪光了羊毛,背景是复杂的KPI数据报表,羊皮纸,钢笔彩色手绘的统一风格。

我说那也行,你给我出点低一点的套餐,或者按需使用的套餐,我也愿意买,对吧?我也没说惦记你薅你的羊毛,本身我用的量也很小。他说不行,他说我们现在有KPI。现在呃,高德把整个数字服务这一块分了一公司管,叫高德云图,我们定了KPI,必须把这钱挣出来。我说那我说你这个就杀鸡取卵的方式挣吗?

我就问他们,我说你们做这样的一政策,有人骂你没有?给我的回答是都在骂,但是没办法。所有原来的这些实名制上去的个人账号,他们这几天在挨个给人打电话,说:“您好,您还用不用了?您要用我们就给你开商务的,您要不用了我就给你关上。”我说你就给我关上吧,那怎么办呢?

深度分析:真实的封号原因

我的感觉呢,是他们并没有说出真实的原因来。这个像高德这样的一公司,背后是阿里,他们被阿里收购了,应该不缺这点钱啊。

  • 我们在阿里云上使用阿里云主机,你也是可以按需付费的;
  • 你使用阿里的这个通义千问的TOKEN,你也是可以按需付费的。

怎么就到高德地图这,就一定是一年5万起步?这个里头一定还有别的原因。而且现在不仅仅是高德,百度跟腾讯也都拉起了5万元的门槛,那么这个原因肯定就不是说被人薅羊毛了。

百度、腾讯他们的地图MCP最早也都是免费的,后面也出过一点点按量付费的套餐,最后不约而同都设定了5万一年的公对公打款门槛。你必须是公司给我,你个人给我打5万块钱都不行,这个是不允许的,一定是公司的。美团其实也有地图服务,只是美团的地图服务更加封闭,它就完完全全为自己来服务的。

个人开发者还有机会吗?

现在想要做地理位置信息相关的应用,个人开发者还有机会吗?还有一个东西可以用啊,叫Mapbox啊。这个东西还是开放的,每天应该有1万次的免费额度,其实对于我们来说足够使了。

但是它这个Mapbox应该是海外的一个服务,在国内调用是比较慢的,而且也不是很稳定。中国地图的更新和准确性上要稍微差一些,一线城市可能还准确啊,到二三线城市或者到农村,这个Mapbox就不那么靠谱了。

在海外肯定还是可以使用的。在中国使用这种服务获得地理信息呢,其实它有一定的灰色地带。在中国,不是谁都可以去提供地理信息的,这是不是倒行逆施呢?AI时代,各个开放平台,你不应该服务好我们这些个人开发者,服务好这个一人公司吗?你怎么整成这么个事?你哪怕你收贵点都行。

你像Twitter,也就是X平台以及Reddit平台,他们也还是允许你去访问的,只是贵一些,也是按量付费,只是很贵。你怎么就上来一定是企业付费,要求5万块钱门槛呢?这个一定是背后有原因的。

数据护城河与合规成本

现在在AI时代呢,虽然很多的平台公司,比如像OpenAI啊,像谷歌的Gemini啊,他们在努力的拉拢个人开发者,在努力的拉拢这种一人公司。但是很多的数据平台,核心价值是数据的,数据才是他们的唯一护城河

一座坚固的中世纪城堡矗立在地图中央,周围环绕着由二进制代码组成的深邃护城河,吊桥高高拉起,拒绝外部访问,羊皮纸,钢笔彩色手绘的统一风格。

X和Reddit都是在做数据封锁,虽然吃相搞得不至于像中国这些地图企业这么难看吧,但也确实是在做数据封锁。只有给数据添加了稀缺性,才能卖得出价格来啊。甭管是X还是Reddit,他们现在都在跟别人去签这种数据销售协议,每年要给钱的。X的数据就比较省事了,就直接内部循环掉了,给xAI就完了。Reddit的数据是给到谷歌跟OpenAI。前几天咱们讲的Stack Overflow,他们也是在向谷歌和OpenAI在卖这些数据。

合规警报:从无人机禁飞说起

那么我前面觉得高德的人,没说出真实的原因到底是什么呢?最近除了突然接到高德的电话之外,我还突然接到了警察的电话。当然不是说我们录YouTube的事情。

警察突然给我打电话说:“您有无人机吧?”

我说有啊。

“记着别飞啊。”

我说行行行,我知道,我不飞。

这个我的也接到了,所以我觉得这两个事应该是一起的:地理位置信息的合规成本突然上升了

无人机这个东西,原来我们还是可以在北京周边玩耍的,后来有重大的会议、重大的活动的时候,警察会给你打电话,说最近这几天别玩啊。我说没问题啊,放心吧。这个因为你只要是有警察电话过来的时候,大疆的无人机APP就直接禁止起飞了,就直接把这周围全划成禁飞区就不让玩了。

一架被铁链锁在地面的无人机,周围拉着黄色的警戒线,旁边站着一位制服人员拿着电话,背景是模糊的城市轮廓,羊皮纸,钢笔彩色手绘的统一风格。

现在是一整年,北京及其周边地区所有行政区划,包括河北的一部分,都不让飞了。而且呢,现在警察还在给你打电话说:“你好啊,这个不让飞了。”我就问人家,我说什么事?不知道,就告诉你不让飞了。我说那这个到什么时候让解开?说那不知道,这个反正我们就只管通知你不让飞了就完了。这个最近干的事情是很多的。

测绘法与国家安全

其实无人机飞起来也是会采集非常多的地理信息相关的内容的。中国是有测绘法的,不是谁都能进来这个进行测绘的。

  • 谷歌是开着他们的测绘小车,这个谷歌地球的小车全世界开,但是在中国就不行。谷歌地图在中国买的是——我记得是他好像买的百度的数据,还是买的四维图新的数据;
  • 而苹果,就是iPhone上面的苹果地图,买的就是高德的数据。

那么在中国你必须要有测绘资质,你才可以去测绘这件事情。在中国呢,地图数据并非普通的商业数据,而是被定义为涉及国家主权和安全的核心数据或者是重要数据。非法测绘这个事在中国绝对是一条红线啊。你说我飞起来测个绘什么的,因为大家注意无人机其实飞起来好多是做测绘的。

而且数据出境和流转的一些限制在中国也是非常严格的。你除了不能随便测绘之外,你还不能把这个数据拿到国外去使用。你想,甭管是高德也好,百度也好,他开个MCP服务,他没法去限制说这些人一定是在国内、一定是在国外。虽然我们可能做实名制的时候确保我们在国内了,拿中国手机号,拿中国身份证,我们做了实名制了,但是我们开发的应用很有可能在国外被人使用了。所以对于他们来说合规成本是非常非常高的。

为什么必须是企业公账?

所以个人实名制对于现在突然升高的合规成本来说就不够了。现在必须是企业,而且是要公账打款。你说我个人打不行,公账打款才可以信任。

因为你一个企业注册在这,你账户啊、什么房子呀、员工都在这里,他认为这些人还是会投鼠忌器的,不敢乱来。而且在中国使用地理位置信息,以前我们就可以随便用,现在必须要做场景合规。什么意思呢?就是企业账号在申请API的时候,通常需要提交具体的应用场景说明,比如说做物流配送啊、外卖调度。

但是你个人开发者的话,那应用场景千奇百怪,而且呢,很难以监控。AI agent更是具有不可预测性,它是个黑盒子,对吧?因此,基于最小特权原则,关闭个人通道是符合监管逻辑的避险行为。这就是我的账号之所以被关的一个原因。

中国创新的困境:成本与风险

在中国做创新已经越来越难了。现在你说我要开发个APP,要租云服务的机器,要去持有一个域名,成本都非常高。实名制,最好是公司打款,你在中间哪一步验证失败了,都是过不去的。现在所有的成本都在快速上升,而且挣钱又越来越难,所以大家干脆就不动了。

为什么那么多人说我干脆躺平了,我啥也不干了就完了?国内的主管部门又都是这种多一事不如少一事,你做完了以后,万一出点什么毛病,这不是给自己找麻烦吗?如果是不出毛病,大家你好我好;万一出点毛病,他是有可能会去丢乌纱帽的。所以他们尽可能让大家都坐在那不要动。

一个疲惫的创业者面对着堆积如山的文件和红色的封条,无奈地选择躺在地上休息,周围是静止不动的齿轮,羊皮纸,钢笔彩色手绘的统一风格。

大厂们这个也不希望沦为基础设施,所以还是希望用手头的数据去捆绑更多的应用场景。也就是美团、百度、高德、腾讯这些手里有地图服务的这些厂,说我们开放出去,你们都自己开发应用去了,你不用我的,这不行。我们最后还是要让你上我的APP里来,你要在我这边继续去使用我的服务,你不能直接开发个小应用把用户都吸走了。

AI时代:出海是唯一的出路?

AI时代还是去海外玩耍吧。在中国一直是重合规轻版权

  • 在中国,比如说我们上抖音、上小红书,你看上面这种违反版权的东西一堆一堆的,包括B站,B站就更没法说了。
  • 但是合规的成本极高,而且这些平台也都是把脑袋挂裤腰带上在干活。因为他有的时候出了问题,绝不是说罚点款就完事了,那是要拔网线的。

所以在中国合规的成本太高,而且有很大一块是灰色的。什么叫灰色?没有逻辑,也说不清楚,但是呃,你也不敢过去。这个就是“行不可知则威不可测”的这部分。

而在海外正好反过来,他们是轻合规重版权。你说我合规出问题了,他肯定也罚你,但是罚了最多你把我视频删了,或者你把我的账号关了,把我账号里的钱扣了,完了咱们可以接着干,再开一个账号,再继续干就完了。但是他版权很严格,你一旦侵权别人的内容了,那就肯定是拿不到收益了。

所以AIGC我们既然可以这么方便地去处理非结构化数据,可以这么方便地去生成内容,那我们一定要找一个重版权的地方。因为内容是我重新生成的,所以肯定是符合版权要求的。合规的成本千万不要那么高。

其实海外的合规成本,一方面是要比国内低,另外一方面就是更明确。你出了问题以后,你还有地去投诉,我还可以去跟人去讲道理。就算是真的出问题了,大不了赔点钱也就完事了呗,接着再开账号接着干呗。所以海外这一块还是要好很多的。

结论

大量中国的软件或者是互联网公司,一边在被合规成本所困扰,一边也以合规为借口巩固自己的地盘。所以你像高德这些人,并不光是说合规让我很痛苦,我这个个人用户都没了,他们乐着呢。你看我现在有合规借口了。刚才我也讲了,在中国合规有一大块是灰色地带,你是讲不清的。既然讲不清,那我再给他加点私货,没毛病吧?所以他们也通过这样的方式巩固自己的地盘。

AI无比强大,和国内的合规成本战斗,是一件非常非常不划算的事情。如果你说我想去做AI应用、AI创业,或者我想去做一人公司,在中国没有你的客户,还是出去。在中国可能唯一能干的,就是在小红书上卖卖课啊,割一割韭菜,可能还能干一干,其他的真的不划算。

一艘扬帆起航的小船驶离被迷雾和围墙笼罩的海岸,驶向阳光明媚、海阔天空的远方,船帆上印着AI的标志,羊皮纸,钢笔彩色手绘的统一风格。

好,这个故事今天就讲到这里,感谢大家收听。请帮忙点赞、点小铃铛,参加DISCORD讨论群,也欢迎有兴趣有能力的朋友加入我们的付费频道。再见。


背景图片:

Prompt:A GIS company office interior with a massive 3D city scale model in the center, many miniature courier figures in varied colored uniforms weaving through the model streets, display screens showing GIS maps, glass walls, modern desks, cinematic composition, rule of thirds, 35mm medium shot, eye-level, soft directional light with subtle rim light, a high-contrast watercolor scene, neon cyan rimlight, deep navy background, sharp subject separation, minimal palette (ink blue, neon cyan, gold accents), glossy reflections, extremely legible negative space for text –ar 16:9 –stylize 170 –chaos 5 –no text, watermark, logo, blurry, lowres, distorted, extra limbs, clutter, messy background –v 7.0 –p lh4so59

Prompt:A GIS company office interior, central massive 3D city scale model with micro streets and blocks, numerous miniature courier figures in varied colored uniforms moving through the diorama, realistic materials, desk clutter kept minimal, large GIS map displays, glass and steel architecture, 35mm medium shot, eye-level, rule of thirds, soft directional light with subtle rim light, a high-contrast watercolor scene, neon cyan rimlight, deep navy background, sharp subject separation, minimal palette (ink blue, neon cyan, gold accents), glossy reflections, fine surface micro-detail, crisp edges –ar 16:9 –stylize 120 –chaos 3 –no text, watermark, logo, blurry, lowres, distorted, extra limbs, cluttered mess, oversaturated –v 7.0 –p lh4so59

🔲 ☆

为什么 Go 社区强调避免不必要的抽象?—— 借用海德格尔哲学寻找“正确”的答案

本文永久链接 – https://tonybai.com/2026/01/16/go-community-the-right-kind-of-abstraction

大家好,我是Tony Bai。

“Go 的哲学强调避免不必要的抽象。”

这句话我们听过无数次。当你试图引入 ORM、泛型 Map/Reduce 、接口或者复杂的设计模式时,往往会收到这样的反馈。这句话本身没有错,但难点在于:到底什么是“不必要”的?

函数是抽象吗?汇编是抽象吗?如果不加定义地“避免抽象”,我们最终只能对着硅片大喊大叫。

在 GopherCon UK 2025 上,John Cinnamond 做了一场与众不同的演讲。他没有展示任何炫酷的并发模式,而是搬出了马丁·海德格尔(Martin Heidegger)和伊曼努尔·康德(Immanuel Kant),试图用哲学的视角,为我们解开关于 Go 抽象的终极困惑。

注:海德格尔与《存在与时间》

马丁·海德格尔(Martin Heidegger)是 20 世纪最重要的哲学家之一。他在 1927 年的巨著《存在与时间》(Being and Time) 中,深入探讨了人(此在)如何与世界互动。John Cinnamond 在演讲中引用的核心概念——“上手状态” (Ready-to-hand)“在手状态” (Present-at-hand),正是海德格尔用来描述我们与工具(如锤子)之间关系的术语。这套理论极好地解释了为什么优秀的工具(或代码抽象)应该是“透明”的,而糟糕的工具则会强行占据我们的注意力。

img{512x368}

我们都在使用的“必要”抽象

首先,让我们承认一个事实:编程本身就是建立在无数层抽象之上的。

  • 泛型:这是对类型的抽象。虽然 Go 曾长期拒绝它,但在技术上它是必要的,否则我们将充斥着重复代码。
  • 接口:这是对行为的抽象。io.Reader 让我们不必关心数据来自文件还是网络。
  • 函数:这是对指令序列的抽象。没有它,我们只能写长长的 main 函数。
  • 汇编语言:这是对机器码的抽象。

所以,当我们说“避免不必要的抽象”时,我们真正想表达的其实是——避免“不恰当” (Inappropriate) 的抽象

那么,如何判断一个抽象是否“恰当”?

何为抽象?—— 一场有目的的“细节隐藏”

在深入探讨“正确”的抽象之前,我们必须先回到最基本的定义。John Cinnamond 在演讲中给出了一个精炼而深刻的定义:

“抽象是一种表示 (Representation),但它是一种刻意移除被表示事物某些细节的表示。”

让我们拆解这个定义:

  1. 抽象是一种“表示”,而非事物本身
    它不是代码的实体,而是代码的地图或模型。例如,一辆模型汽车是真实汽车的表示,但 Gopher 吉祥物是地鼠的抽象——它刻意省略了真实地鼠的所有细节,只保留了核心特征。

  1. 抽象是“有目的的”细节移除
    这与仅仅是“不精确”或“粗糙”不同。抽象是有意为之的,它不试图精确描绘所有方面,而是只关注某个特定维度

  1. 抽象在编程中具有动态性
    • 不确定引用 (Indefinite Reference):一个抽象(如 io.Reader)通常可以指代许多不同的具体实现。
    • 开放引用 (Open Reference):抽象的内容或它所指代的事物可以随着时间而改变。

为什么要刻意移除细节?John 总结了几个核心动机:

  • 避免重复代码:将重复的逻辑提取到抽象中。
  • 统一不同的实现:允许以统一的方式处理本质上不同的数据结构(如所有实现了 Read 方法的类型)。
  • 推迟细节:隐藏那些当下不重要、或开发者不关心的细节(例如,你坐火车参会,不需要知道每节车厢的编号)。
  • 揭示领域概念:用抽象来更好地表达业务领域中的核心概念。
  • 驾驭复杂性:这是最核心的理由——没有抽象,我们无法在大脑中一次性处理所有细节,也就无法解决复杂的问题。

但请记住,并非所有抽象都是一样的。John 将它们分为三类:

  1. 基于“它是如何工作的” (How it works)
    这是为了代码复用而提取的抽象。例如,你发现两处代码都在做“检查用户是否是管理员”的逻辑,于是将其提取为一个函数。这种抽象关注的是内部机制。 (这类抽象通常比较脆弱,一旦实现细节变化,抽象可能就会失效。)

  2. 基于“它做了什么” (What it does)
    这是 Go 语言中接口(Interface)最典型的用法。例如 io.Reader,我们不关心它是文件还是网络连接,我们只关心它能“读取字节”。这是一种行为抽象。

  3. 基于“它是什么” (What it is)
    这是基于领域模型的抽象。例如一个 User 结构体,它代表了系统中的一个实体。这种抽象关注的是本质属性。

在现实中,好的抽象往往是这三者的混合体,但在设计时,明确你是在抽象“行为”还是“实现”,对于判断抽象的质量至关重要。

理解了抽象的本质,我们可能会觉得:既然抽象能驾驭复杂性,那是不是越多越好?

且慢。在急于评判一个抽象是否“恰当”之前,我们必须先意识到一个常被技术人员忽略的现实:抽象不仅存在于代码中,更存在于人与人的互动里。 这将我们引向了一个更现实的考量维度。

抽象的代价 —— 代码是写给人看的

John 提醒我们,软件开发本质上是一项社会活动 (Social Activity)

“除非你是为了自己写着玩,否则你的代码总是写给别人看的。团队是一个微型社会,它有自己的习俗、信仰和‘传说’(Lore)。”

引入一个新的抽象,本质上是在向这个微型社会引入一种新的文化或规则。这意味着:

  1. 你需要支付“社会成本”:如果这个抽象与团队现有的习惯(Lore)相悖——比如在一个从未用过函数式编程的 Go 团队里强推 Monad——你将遭遇巨大的阻力。
  2. 团队的保守性:成熟的团队往往趋于保守,改变既定习惯需要巨大的能量。你不能仅仅因为一个抽象在理论上很美就引入它,你必须证明它的收益足以覆盖它带来的社会摩擦成本
  3. 认知负担是共享的:一个抽象对你来说可能很清晰,但如果它让队友感到困惑,那就是在消耗团队的整体智力资源。

因此,当我们评判一个抽象是否“恰当”时,不能只看代码本身,还必须看它是否“合群”。这正是我们接下来要引入海德格尔哲学的现实基础。

锤子哲学 —— “上手状态” vs. “在手状态”

John 引用了海德格尔在《存在与时间》中的一个著名概念:Ready-to-hand (上手状态)Present-at-hand (在手状态)

  • 上手状态 (Ready-to-hand):当你熟练使用一把锤子钉钉子时,你的注意力完全在钉钉子这件事上,锤子本身在你意识中是“透明”的。你感觉不到它的存在,它只是你身体的延伸。
  • 在手状态 (Present-at-hand):当锤子突然坏了(比如锤头掉了),或者你拿到一把设计奇特的陌生工具时,你的注意力被迫从“钉钉子”转移到了“锤子”本身。你开始审视它的构造、重量和用法。

这对代码意味着什么?

  • 好的抽象是“上手状态”的:比如 for 循环。作为经验丰富的开发者,你使用它时是在思考“我要遍历数据”,而不是“这个循环语法是怎么编译的”。它透明、顺手,让你专注于解决问题。

  • 坏的抽象是“在手状态”的:比如一个复杂的、过度设计的 ORM 或者一个晦涩的 Monad 库。当你使用它时,你的思维被迫中断,你需要停下来思考:“这个函数到底在干什么?这个参数是什么意思?”

如果一个抽象让你频繁地从“解决业务问题”中抽离出来去思考“工具本身”,那么它很可能是一个坏的抽象

注:通过学习和实践,在手状态 (Present-at-hand)的抽象可以转换为 上手状态 (Ready-to-hand)的抽象。

真理的检验 —— “本质真理” vs. “巧合真理”

接着,John 又搬出了康德关于真理的分类,引导我们思考抽象的持久性

  • 分析真理 (Analytic Truth):由定义决定的真理。比如“所有单身汉都没结婚”。在代码中,这就像 unnecessary abstractions are unnecessary,虽然正确但没啥用。
  • 综合真理 (Synthetic Truth):由外部事实决定的真理。比如“外面在下雨”。它的真假取决于环境,随时可能变。
  • 本质真理 (Essential Truth):虽然不是由定义决定,但反映了世界的本质规律。比如“物质由原子构成”。

这对抽象意味着什么?

当你提取一个抽象时,问问自己:它代表的是代码的“本质真理”,还是仅仅是一个“巧合”?

举个例子:你有一段过滤商品的代码,可以按“价格”过滤,也可以按“库存”过滤。你提取了一个 Filter(Product) bool 的抽象。

  • 如果未来所有的过滤需求(如颜色、大小)都能用这个签名解决,那么你发现了一个本质真理。这个抽象是稳固的。
  • 但如果突然来了一个需求:“过滤掉重复的商品”,这个需求需要知道所有商品的状态,而不仅仅是单个商品。原本的 Filter(Product) bool 签名瞬间失效。

如果你提取的抽象仅仅是因为几段代码“长得像”(巧合),而不是因为它们“本质上是一回事”,那么当需求变更时,这个抽象就会崩塌,变成一种负担。

由此可见,好的抽象不是被创造出来的,而是被发现(Recognized)出来的。它们是对代码中某种本质结构的捕捉。

实战指南 —— 如何引入抽象?

最后,John 给出了一个评估抽象是否“恰当”的五步清单:

  1. 明确收益 (Benefit):你到底是为了解决重复、隐藏细节,还是仅仅因为觉得它“很酷”?
  2. 考虑社会成本 (Social Cost):编程是社会活动。这个抽象符合团队的习惯吗?引入它是否需要消耗大量的团队认知成本?(比如在 Go 里强推 Monad等函数式编程的范式)。
  3. 是否处于“上手状态” (Ready-to-hand):它能融入开发者的直觉吗?还是会成为注意力的绊脚石?
  4. 是否本质 (Essential):它是否捕捉到了问题的核心结构,能经得起未来的变化?
  5. 是否涌现 (Emergent):它是你从现有代码中“识别”出来的模式,还是你强加给代码的枷锁?

小结:保持怀疑,但别放弃好奇

Go 社区的“避免不必要的抽象”文化,本质上是对认知负担的防御。我们见过太多为了抽象而抽象的烂代码。但 John 提醒我们,不要因此走向另一个极端——恐惧抽象

正确且必要的抽象是强大的武器,它能让我们驾驭巨大的复杂性。只要我们能像海德格尔审视锤子那样审视我们的代码,区分“上手”与“在手”,区分“本质”与“巧合”,我们就能在 Go 的简约哲学中,找到属于自己的那条“正确”道路。

资料链接:https://www.youtube.com/watch?v=oP_-eHZSaqc


你的“锤子”顺手吗?

用海德格尔的视角审视代码,确实别有一番风味。在你现在的项目中,有哪些抽象是让你感觉“如臂使指”的(上手状态)?又有哪些抽象经常让你
“出戏”,迫使你不得不去研究它内部的构造(在手状态)?

欢迎在评论区分享你的“哲学思考”! 让我们一起寻找那个最本质的代码真理。

如果这篇文章带给你一次思维的“脑暴”,别忘了点个【赞】和【在看】,并转发给那些喜欢深究技术的伙伴!


还在为“复制粘贴喂AI”而烦恼?我的新专栏 AI原生开发工作流实战 将带你:

  • 告别低效,重塑开发范式
  • 驾驭AI Agent(Claude Code),实现工作流自动化
  • 从“AI使用者”进化为规范驱动开发的“工作流指挥家”

扫描下方二维码,开启你的AI原生开发之旅。


你的Go技能,是否也卡在了“熟练”到“精通”的瓶颈期?

  • 想写出更地道、更健壮的Go代码,却总在细节上踩坑?
  • 渴望提升软件设计能力,驾驭复杂Go项目却缺乏章法?
  • 想打造生产级的Go服务,却在工程化实践中屡屡受挫?

继《Go语言第一课》后,我的《Go语言进阶课》终于在极客时间与大家见面了!

我的全新极客时间专栏 《Tony Bai·Go语言进阶课》就是为这样的你量身打造!30+讲硬核内容,带你夯实语法认知,提升设计思维,锻造工程实践能力,更有实战项目串讲。

目标只有一个:助你完成从“Go熟练工”到“Go专家”的蜕变! 现在就加入,让你的Go技能再上一个新台阶!


商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。如有需求,请扫描下方公众号二维码,与我私信联系。

© 2026, bigwhite. 版权所有.

🔲 ☆

草梅 Auth 1.11.1 版本发布与 AI 辅助代码重构实践 | 2025 年第 49 周草梅周报

本文在 草梅友仁的博客 发布和更新,并在多个平台同步发布。如有更新,以博客上的版本为准。您也可以通过文末的 原文链接 查看最新版本。

前言

欢迎来到草梅周报!这是一个由草梅友仁基于 AI 整理的周报,旨在为您提供最新的博客更新、GitHub 动态、个人动态和其他周刊文章推荐等内容。


本周依旧在开发 草梅 Auth 中。

你也可以直接访问官网地址:https://auth.cmyr.dev/
Demo 站:https://auth-demo.cmyr.dev/
文档地址:https://auth-docs.cmyr.dev/

本周 草梅 Auth 发布了 1.11.1 版本。

主要是修复了一些问题,和对项目代码的一些重构,提高代码质量。

此外,也对 better-auth 版本更新后,导致无法通过邮箱登录草梅 Auth 的恶性 BUG 进行了修复。

详见: #267

如果想了解如何部署和使用项目,可以参考文档的内容,也欢迎补充文档缺失的内容。

如果你对草梅 Auth 感兴趣,欢迎参与开发和测试。


在这里我也简单提一下我是如何借助 AI 来重构草梅 Auth 的。

首先,在草梅 Auth 的开发过程中,为了追求进度,优先实现功能,所以在代码质量上不是很高,出现了大量的耦合代码、行数上千的单个代码文件、重复代码块、硬编码字符、测试覆盖率不高等问题。

所以,我做的第一步就是先让 AI(比如 Gemini 3 Pro)对整个代码库进行分析,生成一份代码重构方案。

参考:REFACTOR_PLAN.md

image-20251207195122796

在有了方案之后,下一步就是采用技术指标,对项目的重构效果进行评估。

首先是控制文件长度,这个比较简单,在 eslint 的配置中添加 max-lines 配置即可。

1
'max-lines': [1, { max: 800 }], // 强制文件的最大行数

然后是测试覆盖率,这个由 vitest 提供,通过执行 vitest run --coverage 命令即可查看当前测试覆盖率。

image-20251207200232960

最后是代码重复率,这个由 jscpd 提供,执行 jscpd . 查看当前代码中重复片段的数量。

image-20251207200513195

控制在 5%以下就还算不错。

在有了具体的技术指标后,后续代码重构也就有了数据支持,可以定量的评估重构效果。

GitHub Release

caomei-auth

v1.11.1 - 2025-12-06 20:40:12

摘要:
版本 1.11.1 摘要 (2025-12-06)

Bug 修复:

  • 优化管理员角色同步功能的数据源加载方式
  • 为 Twitter 登录添加所需 scopes
  • 调整 ESLint 规则,将最大行数限制改为 800 行

代码重构:

  • 邮件模板引擎重构,提取回退模板到独立模块
  • 邮件发送逻辑重构,引入依赖注入和限流机制
  • 优化手机功能启用逻辑,使用空值合并运算符处理环境变量
  • 导航系统改进,引入依赖注入机制优化登录跳转逻辑
  • 用户个人资料组件重构,包括对话框和管理员日志页面
  • 短信发送逻辑重构,增加依赖注入和限流机制,支持多渠道发送
  • TypeORM 适配器增强,支持关系处理和事务管理
  • 安全设置页面重构为组合式函数和组件化架构
  • User 和 Application 模块重构
  • 使用专门的 provider 对话框替换原有组件,简化提供者管理逻辑

最新 GitHub 加星仓库

  • CaoMeiYouRen starred CLIProxyAPI - 2025-12-07 18:17:46
    该项目使用 Go 语言开发,将多个主流 AI 模型(Gemini CLI、ChatGPT Codex、Claude Code、Qwen Code、iFlow)封装成兼容 OpenAI/Gemini/Claude/Codex 的 API 服务。主要特点包括:1)提供统一 API 接口访问不同 AI 模型;2)支持免费使用 Gemini 2.5 Pro、GPT 5、Claude 和 Qwen 等先进模型;3)在 GitHub 上获得 2202 个 star,显示其受欢迎程度;4)实现跨平台模型调用标准化。该项目简化了开发者集成多种 AI 服务的过程。
  • CaoMeiYouRen starred claude-code-proxy - 2025-12-07 18:16:37
    这是一个 Python 编写的 Claude API 到 OpenAI API 的代理工具,允许开发者通过 OpenAI API 格式访问 Claude 模型。项目在 GitHub 上获得了 1727 个星标,表明其受欢迎程度较高。该工具主要功能是将 OpenAI API 请求转换为 Claude API 兼容格式,便于开发者集成使用。
  • CaoMeiYouRen starred jscpd - 2025-12-07 18:07:39
    编程源代码的复制粘贴检测工具,主要使用 TypeScript 语言开发,在 GitHub 上获得 5100 颗星标。
  • CaoMeiYouRen starred tdesign - 2025-12-06 01:09:35
    企业设计系统
    主要语言:Vue
    GitHub 星标数:3673
  • CaoMeiYouRen starred DeepSeek-LLM - 2025-12-02 22:25:53
    DeepSeek LLM 是一款人工智能语言模型,主要编程语言为 Makefile,目前在 GitHub 上获得 6647 个星标。

其他博客或周刊推荐

阮一峰的网络日志

阿猫的博客

潮流周刊

二丫讲梵的学习周刊

总结

本周的更新和动态如上所示。感谢您的阅读!
您可以通过以下方式订阅草梅周报的更新:

往期回顾

本文作者:草梅友仁
本文地址: https://blog.cmyr.ltd/archives/2025-49-caomei-weekly-caomei-auth-1-11-1-release-and-ai-refactoring.html
版权声明:本文采用 CC BY-NC-SA 4.0 协议 进行分发,转载请注明出处!

🔲 ☆

条件分支预测器逆向工程(以 Apple M1 Firestorm 为例)

条件分支预测器逆向工程(以 Apple M1 Firestorm 为例)

背景

去年我完成了针对 Apple 和 Qualcomm 条件分支预测器(Conditional Branch Predictor)的逆向工程研究,相关论文已发表在 arXiv 上,并公开了源代码。考虑到许多读者对处理器逆向工程感兴趣,但可能因其复杂性而望而却步,本文将以 Apple M1 Firestorm 为例,详细介绍条件分支预测器的逆向工程方法,作为对原论文的补充说明。

背景知识

首先介绍一些背景知识。要逆向工程条件分支预测器,需要先了解其工作原理。条件分支预测器的基本思路是:

  • 条件分支的跳转行为(跳转或不跳转)通常是高度可预测的
  • 预测器的输入包括条件分支的地址,以及近期执行的若干条分支的历史记录;输出则是预测该条件分支是否跳转

为了在硬件上实现这一算法,处理器会维护一个预测表,表中每一项包含一个 2 位饱和计数器,用于预测跳转方向。查表时,系统会对条件分支地址以及近期执行的分支历史进行哈希运算,使用哈希结果作为索引读取表项,然后根据计数器的值来预测分支的跳转方向。

(图源:CMU ECE740 Computer Architecture: Branch Prediction)

目前主流处理器普遍采用 TAGE 预测器,它在上述基础查表方法的基础上进行了重要改进:

  1. 观察到不同分支的预测所需的历史长度各不相同:有些分支无需历史信息即可准确预测,有些依赖近期分支的跳转结果,而有些则需要更久远的历史信息;
  2. 分支历史越长,可能的路径组合就越多,导致预测器训练过程变慢,训练期间的预测错误率较高,因此希望尽快收敛;
  3. 为满足不同分支对历史长度的需求,TAGE 设计了多个预测表,每个表使用不同长度的分支历史。多个表同时进行预测,当多个表都提供预测结果时(仅在 tag 匹配时提供预测),选择使用最长历史长度的预测结果。

(图源:Half&Half: Demystifying Intel's Directional Branch Predictors for Fast, Secure Partitioned Execution

因此,要逆向工程处理器的条件分支预测器,需要完成以下工作:

  1. 确定分支历史的记录方式:通常涉及分支地址和目的地址,通过一系列移位和异或操作,将结果存储在寄存器中;
  2. 确定 TAGE 算法的具体实现:包括表的数量、每个表的大小、索引方式以及使用的分支历史长度。

分支历史的逆向

第一步是逆向工程处理器记录分支历史的方式。传统教科书方法使用一个寄存器,每当遇到条件分支时记录其跳转方向(跳转记为 1,不跳转记为 0),每个分支占用 1 bit。然而,现代处理器(包括 Intel、Apple、Qualcomm、ARM 和部分 AMD)普遍采用 Path History Register 方法。这种方法设计一个长度为 \(n\) 的寄存器 \(\mathrm{PHR}\),每当遇到跳转分支(包括条件分支和无条件分支)时,将寄存器左移,然后将当前跳转分支的地址和目的地址通过哈希函数映射,将哈希结果异或到移位寄存器中。用数学公式表示为:

\(\mathrm{PHR}_{\mathrm{new}} = (\mathrm{PHR}_{\mathrm{old}} \ll \mathrm{shamt}) \oplus \mathrm{footprint}\)

其中 \(\mathrm{footprint}\) 是通过分支地址和目的地址计算得到的哈希值。接下来的任务是确定 \(\mathrm{PHR}\) 的位宽、每次左移的位数,以及 \(\mathrm{footprint}\) 的计算方法。

历史长度

首先分析这个更新公式:它将最近的 \(\lceil n / \mathrm{shamt} \rceil\) 条跳转分支的信息压缩存储在 \(n\) 位的 \(\mathrm{PHR}\) 寄存器中。随着移位操作的累积,更早的分支历史信息对 \(\mathrm{PHR}\) 的贡献最终会变为零。

第一个实验的目标是确定 \(\mathrm{PHR}\) 能够记录多少条最近分支的历史。具体方法是构建一个分支历史序列:

  1. 第一个条件分支:以 50% 的概率随机跳转或不跳转;
  2. 中间插入若干条无条件分支;
  3. 最后一个条件分支:跳转方向与第一个条件分支相同。

接下来分析两种情况:

  1. 如果在预测最后一个条件分支时,分支历史 \(\mathrm{PHR}\) 仍然包含第一个条件分支的信息,预测器应该能够准确预测最后一个条件分支的方向;
  2. 如果中间的无条件分支数量足够多,使得第一个条件分支的跳转信息对预测最后一个条件分支时的 \(\mathrm{PHR}\) 没有影响,预测器只能以 50% 的概率进行正确预测。

通过构造上述程序,调整中间无条件分支的数量,并使用性能计数器统计分支预测错误率,可以找到一个临界点。当无条件分支数量超过这个阈值时,第二个条件分支的错误预测率会从 0% 上升到 50%。这个临界点对应 \(\mathrm{PHR}\) 能够记录的分支历史数量,即 \(\lceil n / \mathrm{shamt} \rceil\)

经过测试

# 第一列:第二步插入的无条件分支数量加一 # 第二列到第四列:分支预测错误概率的 min/avg/max # 第五列:每次循环的周期数 size,min,avg,max,cycles 97,0.00,0.00,0.01,216.87 98,0.00,0.00,0.01,221.02 99,0.00,0.00,0.01,225.18 100,0.00,0.00,0.01,229.17 101,0.45,0.50,0.53,331.97 102,0.47,0.50,0.54,336.27 103,0.46,0.50,0.54,339.85 

测试结果表明阈值为 100:在 Apple M1 Firestorm 上,最多可以记录最近 100 条分支的历史信息。

分支预测错误率是怎么测量的?

处理器内置了性能计数器,会记录分支预测错误次数。在 Linux 上,可以用 perf 子系统来读取;在 macOS 上,可以用 kpep 私有 API 来获取。我开源的代码中对这些 API 进行了封装,可以实现跨平台的性能计数器读取。更进一步,我们还逆向了 Qualcomm Oryon 的针对条件分支的预测错误次数的隐藏性能计数器,用于后续的实验。

分支地址 B 的贡献

接下来需要推测 \(\mathrm{footprint}\) 的计算方法,即分支地址和目的地址如何参与 \(\mathrm{PHR}\) 的更新过程。约定分支地址记为 \(B\)(Branch 的首字母),目的地址记为 \(T\)(Target 的首字母),用 \(B[i]\) 表示分支地址从低到高第 \(i\) 位(下标从 0 开始)的值,\(T[i]\) 同理。假设 \(\mathrm{footprint}\) 的每一位都由若干个 \(B[i]\)\(T[i]\) 通过异或运算得到。

分支指令本身占用了多个字节,那么分支地址指的是哪一个字节的地址呢?

经过测试,AMD64 架构下,分支地址用的是分支指令最后一个字节的地址,而 ARM64 架构下,分支地址用的是分支指令第一个字节的地址。这大概是因为 AMD64 架构下分支指令是变长的,并且可以跨越页的边界;ARM64 则是定长的,并且不会跨越页的边界。

设计以下程序来推测某个 \(B[i]\) 如何参与 \(\mathrm{footprint}\) 的计算:

  1. 根据上面的分析,Apple M1 Firestorm 最多可以记录最近 100 条分支的历史信息,为了让 \(\mathrm{PHR}\) 进入一个稳定的初始值,执行 100 个无条件分支;
  2. 设计两条分支指令,第一条是条件分支,按 50% 的概率跳或不跳;第二条是无条件分支;这两条分支的分支地址只在 \(B[i]\) 上不同,其余的位都相同,目的地址相同;
  3. 执行若干条无条件分支,目的是把 \(B[i]\)\(\mathrm{PHR}\) 的贡献向前移动;
  4. 执行一条条件分支指令,其跳转方向与第二步中条件分支的方向一致。

对应的代码如下:

// step 1. // 100 jumps forward goto jump_0; jump_0: goto jump_1; // ... jump_98: goto jump_99; jump_99:  // step 2. int d = rand(); // the follow two branches differ in B[i] // first conditional branch, 50% taken or not taken if (d % 2 == 0) goto target; // second unconditional branch else goto target; target:  // step 3. // variable number of jumps forward goto varjump_0; varjump_0: goto varjump_1; // ... varjump_k: goto last;  // step 4. // conditional branch last: if (d % 2 == 0) goto end; end: 

第二步中条件分支跳转与否,会影响分支历史中 \(B[i]\) 一个位的变化,它会经过哈希函数,影响 \(\mathrm{footprint}\),进而异或到 \(\mathrm{PHR}\) 中。通过调整第三步执行的无条件分支个数,可以把 \(B[i]\)\(\mathrm{PHR}\) 的影响左移到不同的位置。如果 \(B[i]\)\(\mathrm{PHR}\) 造成了影响,就可以正确预测最后一条条件分支指令的方向。当左移次数足够多时,\(B[i]\)\(\mathrm{PHR}\) 的贡献会变为零,此时对最后一条条件分支指令的方向预测只有 50% 的正确率。在 Apple M1 Firestorm 上测试,得到如下结果:

横坐标 Dummy branches 指的是上面第三步插入的无条件分支的个数,纵坐标 Branch toggle bit 代表修改的是具体哪一个 \(B[i]\),颜色对应分支预测的错误率,浅色部分对应最后一条分支只能正确预测 50%,深色部分对应最后一条分支总是可以正确预测。

从这个图可以得到什么信息呢?首先观察 \(B[2]\) 对应的这一行,可以看到它确实参与到了 \(\mathrm{PHR}\) 的计算中,但是仅仅经过 28 次移位,这个贡献就被移出了 \(\mathrm{PHR}\),为了保留在 \(\mathrm{PHR}\) 内,最多移动 27 次。类似地,在移出 \(\mathrm{PHR}\) 之前,\(B[3]\) 最多移动 26 次,\(B[4]\) 最多移动 25 次,\(B[5]\) 最多移动 24 次。

但实际上,这些 \(B\) 是同时进入 \(\mathrm{PHR}\) 的:这暗示它们对应 \(\mathrm{footprint}\) 的不同位置。如果某个 \(B[i]\) 出现在 \(\mathrm{footprint}\) 更高位的地方,它也会进入 \(\mathrm{PHR}\) 更高位,经过更少的移位次数就会被移出 \(\mathrm{PHR}\);反之,如果 \(B[i]\) 出现在 \(\mathrm{footprint}\) 更低位的地方,它能够在 \(\mathrm{PHR}\) 中停留更长的时间。

根据上面的实验,可见 \(B[5], B[4], B[3], B[2]\) 参与到了 \(\mathrm{footprint}\) 计算中,而 \(B\) 的其他位则没有。但比较奇怪的是,\(\mathrm{PHR}\) 理应可以记录最近 100 条分支的信息,但实际上只观察到了 28。所以一定还有其他的信息。

目的地址 T 的贡献

刚刚测试了 \(B\),接下来测试 \(T\) 各位对 \(\mathrm{PHR}\) 的贡献,方法类似:

  1. 为了让 \(\mathrm{PHR}\) 进入一个稳定的初始值,执行 100 个无条件分支;
  2. 设计一个间接分支,根据随机数,随机跳转到两个不同的目的地址,这两个目的地址只在 \(T[i]\) 上不同,其余的位都相同,分支地址相同;
  3. 执行若干条无条件分支,目的是把 \(T[i]\)\(\mathrm{PHR}\) 的贡献向前移动;
  4. 执行一条条件分支指令,其跳转方向取决于第二步中间接分支所使用的随机数。

对应的代码如下:

// step 1. // 100 jumps forward goto jump_0; jump_0: goto jump_1; // ... jump_98: goto jump_99; jump_99:  // step 2. int d = rand(); // indirect branch // the follow two targets differ in T[i] auto targets[2] = {target0, target1}; goto targets[d % 2]; target0: // add many nops target1:  // step 3. // variable number of jumps forward goto varjump_0; varjump_0: goto varjump_1; // ... varjump_k: goto last;  // step 4. // conditional branch last: if (d % 2 == 0) goto end; end: 

在 Apple M1 Firestorm 上测试,得到如下结果:

为了测试 T[31],岂不是要插入很多个 NOP,一方面二进制很大,其次还要执行很长时间?

是的,所以这里在测试的时候,采用的是类似 JIT 的方法,通过 mmap MAP_FIXED 在内存中特定位置分配并写入代码,避免了用汇编器生成一个巨大的 ELF。同时,为了避免执行大量的 NOP,考虑到前面已经发现 \(B[6]\) 或更高的位没有参与到 \(\mathrm{PHR}\) 计算中,所以可以添加额外的一组无条件分支来跳过大量的 NOP,它们的目的地址相同,分支地址低位相同,因此对 PHR 不会产生影响。对应的代码如下:

// step 1. // 100 jumps forward goto jump_0; jump_0: goto jump_1; // ... jump_98: goto jump_99; jump_99:  // step 2. int d = rand(); // indirect branch // the follow two targets differ in T[i] auto targets[2] = {target0, target1}; goto targets[d % 2]; target0: // skip over nops, while keeping B[5:2]=0 goto target2; // add many nops target1: goto target2;  target2:  // step 3. // variable number of jumps forward goto varjump_0; varjump_0: goto varjump_1; // ... varjump_k: goto last;  // step 4. // conditional branch last: if (d % 2 == 0) goto end; end: 

由此我们终于找到了分支历史最长记录 100 条分支的来源:\(T[2]\) 会经过 \(\mathrm{footprint}\) 被异或到 \(\mathrm{PHR}\) 的最低位,然后每次执行一个跳转分支左移一次,直到移动 100 次才被移出 \(\mathrm{PHR}\)。类似地,\(T[3]\) 只需要 99 次就能移出 \(\mathrm{PHR}\),说明 \(T[3]\) 被异或到了 \(\mathrm{PHR}[1]\)。依此类推,可以知道涉及 \(T\)\(\mathrm{footprint} = T[31:2]\),其中 \(T[31:2]\) 代表一个 30 位的数,每一位从高到低分别对应 \(T[31], T[30], \cdots, T[2]\)

小结

那么问题来了,前面测试 \(B\) 的时候,移位次数那么少,明显少于 \(T\) 的移位次数。这有两种可能:

  1. 硬件上只有一个 \(\mathrm{PHR}\) 寄存器,\(T[31:2]\) 被异或到 \(\mathrm{PHR}\) 的低位,而 \(B[5:2]\) 被异或到 \(\mathrm{PHR}\) 的中间位置;
  2. 硬件上有两个 \(\mathrm{PHR}\) 寄存器,其中一个是 100 位,它的 \(\mathrm{footprint} = T[31:2]\),记为 \(\mathrm{PHRT}\);另一个是 28 位,它的 \(\mathrm{footprint} = B[5:2]\),记为 \(\mathrm{PHRB}\)

经过后续的测试,基本确认硬件实现的是第二种。用数学公式表达:

\(\mathrm{PHRT}_{\mathrm{new}} = (\mathrm{PHRT}_{\mathrm{old}} \ll 1) \oplus \mathrm{T}[31:2]\)

\(\mathrm{PHRB}_{\mathrm{new}} = (\mathrm{PHRB}_{\mathrm{old}} \ll 1) \oplus \mathrm{B}[5:2]\)

有意思的是,在我的论文发表后不久,Apple 公开的专利 Managing table accesses for tagged geometric length (TAGE) load value prediction 中就出现了相关表述,证明了逆向结果的正确性。

按照这个方法,我还逆向工程了 Apple、Qualcomm、ARM 和 Intel 的多代处理器的分支历史记录方法,并进行了公开,供感兴趣的读者阅读,也欢迎读者将测试代码移植到更多处理器上,并贡献逆向工程的结果。

TAGE 表的逆向

接下来,我们将目光转向 TAGE 表的逆向工程。TAGE 表与缓存结构类似,也是一个多路组相连的结构,通过 index 访问若干路,然后对每一路进行 tag 匹配,匹配正确的那一路提供预测。TAGE 在预测时,输入是历史寄存器,即上面逆向得到的 \(\mathrm{PHRT}\)\(\mathrm{PHRB}\),以及分支地址,目前这两个输入都是可控的。为了避免多个表同时提供预测,首先逆向工程使用分支历史最长的表的参数:它的容量是多少,index 如何计算,tag 如何计算,以及几路组相连。

如何确保使用分支历史最长的表提供预测呢?其实还是利用分支历史的特性,将随机数注入到 \(PHRT\) 中,例如前面的间接分支,让两个目的地址只在 \(T[2]\) 上不同:

// add some unconditional jumps to reset phr to some constant value // 100 jumps forward goto jump_0; jump_0: goto jump_1; // ... jump_98: goto jump_99; jump_99:  // inject int d = rand(); // indirect branch // the follow two targets differ in T[2] auto targets[2] = {target0, target1}; goto targets[d % 2]; target0: // add nop here target1:  // add some unconditional jumps to shift the injected bit left goto varjump_0; varjump_0: goto varjump_1; // ... varjump_k: goto last; last: 

根据前面的分析,\(T[2]\) 会被异或到 \(\mathrm{PHRT}\) 的最低位上,每执行一次无条件分支,就左移一位。因此,通过若干个无条件分支,可以把 d % 2 这个随机数注入到 \(\mathrm{PHRT}\) 的任意一位上。之后我们还会很多次地进行这种随机数的注入。

把随机数注入到 \(\mathrm{PHRT}\) 高位以后,再预测一个根据随机数跳转或不跳转的分支,就可以保证它只能由使用分支历史最长的表来进行预测。

逆向工程 PC 输入

首先,我们希望推断 PC 如何参与到 index 或 tag 计算中。通常,TAGE 只会采用一部分 PC 位参与 index 或 tag 计算。换句话说,如果两个分支在 PC 上不同的部分没有参与 index 或 tag 计算,那么 TAGE 无法区分这两条分支。如果这两个分支跳转方向相反,并且用相同的 PHR 进行预测,那么一定会出现错误的预测。思路如下:

  1. 用 100 个无条件分支,保证 PHR 变成一个确定的值;
  2. 注入随机数 d % 2 到 PHRT,并移动到高位(例如 \(PHRT[99]\)),使用前面所述的方法;
  3. 执行两个条件分支,它们在分支地址上只有一位 \(PC[i]\) 不同,它们的跳转条件相反,当第一个条件分支不跳转的时候,会执行第二个条件分支,它总是会跳转。

对应代码类似于:

// step 1. inject phrt int d = rand(); inject_phrt(d % 2, 99);  // step 2. a pair of conditional branches with different direction // their PC differs in one bit if (d % 2 == 0) goto end; if (d % 2 == 1) goto end;  end: 

经过测试,PC 的输入是 \(PC[18:2]\),其余的没有。

逆向工程相连度和 index 函数的 PC 输入

接下来是比较复杂的一步,同时逆向工程表的相连度和 index 函数的 PC 输入。这是因为这两部分是紧密耦合的:只有知道相连度,才能知道预测出来的分支数对应几个 set;但不知道 index 函数,又无法控制分支被分配到几个 set 中。首先,为了避免 PHR 的干扰,还是只注入一个随机数到 \(PHRT[99]\) 上(事实上,\(PHRT[99]\) 不是随便选择的,而是需要在 index 函数中,但通过测试可以找到满足要求的位)。其次,构造一系列分支,它们的地址满足:第 i 条分支(i 从 0 开始)的分支地址是 \(i2^k\),其中 \(k\) 是接下来要遍历的参数。当 \(k=3\) 时,分支会被放到 0x0, 0x8, 0x10, 0x18, 0x20 等地址,涉及的 PC 位数随着分支数的增加而增加。接下来,我们分类讨论:

  • 假如涉及的 PC 位都在 tag 中,没有出现在 index 中:那么这些分支都会被映射到同一个 set 内,一旦分支数量超出相连度,就会出现预测错误。
  • 假如涉及的 PC 位有一部分出现在 index 中:那么每有一个 PC 位出现在 index 中,这些分支可以被分配到的 set 数量就翻倍,直到这些 set 都满了以后,才会出现预测错误。
  • 假如涉及的 PC 位有一部分超出 PC 输入的范围(如前面逆向工程得到的 \(PC[18:2]\)):那么超出输入的部分地址会被忽略,使得 set 内出现冲突。

实验结果如下图:

纵坐标就是上面的 \(k\),横坐标是测试的条件分支数,颜色表示预测的错误率。当颜色从深色变浅,就说明出现了预测错误。观察:

  • \(PC[3]\) 的情况下,只能预测 4 个分支,而 \(PC[4]\)\(PC[5]\) 可以预测 8 个分支,暗示了四路组相连,然后 \(PC[4]\)\(PC[5]\) 对应到了两个 set,所以能够正确预测 8 个分支。
  • \(PC[6]\) 的情况下,可以预测 16 个分支,对应 4 个 set;后续 \(PC[7]\)\(PC[8]\) 又可以预测 8 个分支,对应 2 个 set;意味着 \(PC[6]\) 在 index 中,给 \(PC[4]\)\(PC[5]\) 提供了两倍的 set;\(PC[9]\) 在 index 中,给 \(PC[6]\)\(PC[7]\)\(PC[8]\) 提供了两倍的 set。
  • 后续更高的 PC 位,没有受到 index 函数的影响,因此都是 4,直到最后超出 PC 输入范围。

这就说明它是四路组相连,PC[6] 和 PC[9] 参与到了 index 函数中。

下面给读者一个小练习,下面是在 Qualcomm Oryon 上测得的结果,可以看到噪声比较大,你能推断出它是几路组相连,有哪些 PC 参与到了 index 计算吗?

揭晓答案

四路组相连,\(PC[6]\)\(PC[7]\) 参与到了 index 函数。

那么,这种测试是怎么构造的呢?即需要用相同的 PHR 去预测 \(PC=i2^k\) 的多条分支。思路比较复杂:

  1. 首先执行一条间接分支,目的地址是 \(i2^{k-1}\),那么它对 PHRT 的贡献是 \(\mathrm{PHRT}_1 = (\mathrm{PHRT}_0 \ll 1) \oplus (i2^{k-3})\)
  2. 接下来,在 \(i2^{k-1}\) 的位置,再执行一条直接分支,目的地址是 \(i2^k\),那么它对 PHRT 的贡献是 \(\mathrm{PHRT}_2 = (\mathrm{PHRT}_1 \ll 1) \oplus (i2^{k-2}) = (((\mathrm{PHRT}_0 \ll 1) \oplus (i2^{k-3})) \ll 1) \oplus (i2^{k-2}) = \mathrm{PHRT}_0 \ll 2\)

可见经过两步以后,PHRT 是保持不变的。针对 PHRB,只要 \(i2^{k-1}\) 没有涉及 \(PC[5:2]\),就能保证相同。那么如果 \(k\) 足够小,也有办法:

  1. 首先执行一条间接分支,目的地址是 \(i2^{k-1}\)
  2. 接下来执行大量的 NOP,使得 \(B\) 的低位等于 0,然后再执行一条间接分支,目的地址是 \(i2^k\)

因此我们总是可以通过两次分支,实现用相同的 PHR 预测不同 PC 上的多条分支。

逆向工程 tag 函数

接下来,进行 tag 函数的逆向工程。为了逆向工程 tag 函数,我们希望找到两个位在 tag 函数中有异或关系,那么如果这两个位同时设为 0,或者同时设为 1,其异或结果都等于 0,使得计算出来的 tag 函数相同,如果此时 index 还相同,那么预测器就无法区分这两种情况。

为了利用这一点,生成两个 0 到 1 的随机数 \(k\)\(l\),分别把它们注入到 PC、PHRB 或者 PHRT 中,去预测一个条件分支,其跳转与否取决于 \(k\) 的值(论文中有个小 typo)。如果 \(k\)\(l\) 在 tag 函数中有异或关系,那么预测器总会预测错误。

实验结果大致如下,横纵坐标表示注入哪一个位,颜色代表预测错误率,深色意味着预测错误,也就是找到了一组异或关系:

其中有一些异或关系,因为对应的位在 index 中出现的缘故,导致没有显现出来。根据已知的异或关系外推,可以得到如下的 tag 计算公式:

  • PC[7] xor PHRT[0,12,...,96] xor PHRB[8,21]
  • PC[8] xor PHRT[1,13,...,97] xor PHRB[9,22]
  • PC[9] xor PHRT[2,14,...,98] xor PHRB[10,23,24]
  • PC[10] xor PHRT[3,15,...,87,99] xor PHRB[11,12,25]
  • PC[11] xor PHRT[4,16,...,88] xor PHRB[0,13,26]
  • PC[12] xor PHRT[5,17,...,89] xor PHRB[1,14,27]
  • PC[13] xor PHRT[6,18,...,90] xor PHRB[2,15]
  • PC[14] xor PHRT[7,19,...,91] xor PHRB[3,16]
  • PC[15] xor PHRT[8,20,...,92] xor PHRB[4,17]
  • PC[16] xor PHRT[9,21,...,93] xor PHRB[5,18]
  • PC[17] xor PHRT[10,22,...,94] xor PHRB[6,19]
  • PC[18] xor PHRT[11,23,...,95] xor PHRB[7,20]
  • PC[2:5]: 单独出现,不和其他位异或

那么,这里是怎么实现针对 PC、PHRT 和 PHRB 的注入的呢?针对 PHRT 的注入前面已经提到过,只需要一个间接分支:

// target differs in T[2] auto targets[2] = {target0, target1}; goto targets[d % 2]; target0: // add nop target1: 

PHRB 的注入就比较复杂了,例如要注入 \(B[2]=k\),我们需要进行三次分支的跳转:

  1. 第一次跳转,\(B\) 相同,\(T[2]=k\)
  2. 第二次跳转,\(B[2]=k\)\(T[3]=k\)
  3. 第三次跳转,\(B[2]=B[3]=k\), \(T\) 相同。

经过计算,可以发现前两次跳转对 PHRT 的抵消,第二次跳转的 \(B[2]\) 与第三次跳转的 \(B[3]\) 抵消,最后相当于只有最后一次跳转的 \(B[2]=k\) 对 PHRB 产生了贡献。

最复杂的是 PC 的注入,这次需要分情况讨论:

第一种情况是,要注入的 PC 位比较高,具体来说,是 \(PC[7]\) 或者更高的位数,此时我们可以很容易地避免引入对 PHRB 的贡献,因为它只考虑 \(B[5:2]\)

  1. 第一次跳转,\(B\) 相同,\(T[6]=k\)
  2. 第二次跳转,\(B[6]=k\) 但对 PHRB 没有贡献,\(T[7]=k\)

那么两次跳转完以后,PHRB 不变,PHRT 的贡献被抵消掉,同时实现了 \(PC[7]=k\) 的注入。

第二种情况是,要注入的正好是 \(PC[6]\),继续用上面的方法会发现 PHRB 无法抵消,这时候,需要引入第三次跳转:

  1. 第一次跳转,\(B\) 相同,\(T[3]=k\)
  2. 第二次跳转,\(B[3]=k\)\(T[5]=T[4]=k\)
  3. 第三次跳转,\(B[4]=k\)\(T[6]=k\)

验算可以发现,PHRB 和 PHRT 的贡献全都被抵消,成功注入 \(PC[6]=k\)

最后来看要注入的 PC 更低的情况,例如要注入 \(PC[3]=k\),还是用三次跳转:

  1. 第一次跳转,\(B\) 相同,\(T[2]=k\)
  2. 第二次跳转,\(B[2]=k\)\(T[2]=T[3]=k\)
  3. 第三次跳转,\(B[3]=k\)\(T[3]=k\)

这样就成功注入 \(PC[3]=k\)

那么 PC 的注入就完成了,只有 \(PC[2]\) 没有找到合适的方法来注入。

逆向工程 index 函数

相连度和 tag 函数已知,接下来,让我们逆向工程最后的 index 函数。逆向工程的思路如下:

  1. 通过前面的逆向工程,发现 \(PC[5:2]\) 是独立出现在 tag 函数中,并且没有出现在 index 中,所以我们可以构造出多个分支,它们的 tag 不同;
  2. 进一步,构造两组条件分支,每组都有四个条件分支,因为是四路组相连,如果这两组分别映射到两个 set 中,就可以正确预测;反之,如果被映射到同一个 set 中,就会预测错误;
  3. 和之前类似,向 PC、PHRB 或 PHRT 注入两个随机数 \(k\)\(l\),然后预测两组共八个条件分支,这些条件分支的跳转方向都是 k xor l
  4. 如果 \(k\)\(l\) 注入的位同时出现在 index 函数中,但是没有异或关系,那么这八个条件分支可以正确地被预测;
  5. 如果 \(k\)\(l\) 注入的位同时出现在 index 函数中,并且有异或关系,那么这八个条件分支会被映射到同一个 set 内,最多只能正确预测其中四个分支;
  6. 如果 \(k\)\(l\) 注入的位至少出现一个在 tag 函数中,那么一个分支会在同一个 set 内占用两项,导致最多只能正确预测其中两个分支。

注入 \(PC[9]\)\(PHRT[i]\)实验结果如下:

结合上面的讨论,可以知道:

  • PC[9] 和 PHRT[38] 和 PHRT[88] 有异或关系
  • PHRT[2]、PHRT[12]、PHRT[17]、PHRT[22]、PHRT[27]、PHRT[33]、PHRT[43]、PHRT[53]、PHRT[58]、PHRT[63]、PHRT[68]、PHRT[73]、PHRT[78]、PHRT[83]、PHRT[93] 在 index 中,但是和 PC[9] 没有异或关系

实际上还有 PHRT[7] 和 PHRT[48] 也在 index 中,但实际上测试的时候为了保证历史最长的表提供预测,还额外注入了 PHRT[99],它与 PHRT[7] 和 PHRT[48] 有异或关系,所以在上图没有显现出来。

用类似的方法,去测试 PHRT 与 PHRT、PHRT 与 PHRB、PHRB 与 PHRB 之间的异或关系,就可以找到最终的 index 函数:

  • PHRT[2] xor PHRT[43] xor PHRT[93]
  • PHRT[7] xor PHRT[48] xor PHRT[99]
  • PHRT[12] xor PHRT[63] xor PHRB[5]
  • PHRT[17] xor PHRT[68] xor PHRB[10]
  • PHRT[22] xor PHRT[73] xor PHRB[15]
  • PHRT[27] xor PHRT[78] xor PHRB[20]
  • PHRT[33] xor PHRT[83] xor PHRB[25]
  • PHRT[38] xor PHRT[88] xor PC[9]
  • PHRT[53] xor PHRT[58] xor PHRB[0]
  • PC[6]

至此使用分支历史最长的表的逆向就完成了。接下来讨论一下,如何逆向工程分支历史更短的表。

逆向工程使用分支历史更短的 TAGE 表

前面提到,TAGE 在预测时,会选取提供预测的多个表中使用历史最长的那个表。那么,如果要测试使用历史第二长的表,应该怎么办呢?我们尝试了以下方法:

  • 在使用历史更长的表里,预先插入一些表项,再去测试历史较短的表的情况,由于 TAGE 会利用它的 useful 计数器来进行新表项的分配,当历史更长的表里的表项的 useful 不为零,可以防止它被覆盖成新的内容,逼迫 TAGE 用历史更短的表来进行预测;
  • 把多个表当成一个整体来考虑,比如在测试能够正常预测的分支数量的时候,得到的是多个表叠加的结果,再减去已知数量,可以得到历史比较短的表的信息;
  • 在超出要测试的表的历史部分,注入大量随机数,例如要测试的一个表,只用到了历史的低 57 位,那就在更高的部分注入大量的随机数,那么历史最长的表能够提供预测的概率会非常小,从而逼迫 TAGE 用当前被测试的表来做预测。

通过这些方法,我们成功地逆向出了 Firestorm 的剩下的 TAGE 表的信息:

有兴趣的读者可以试着自己复现一下,看看能不能得到对应的实验结果,然后从结果中分析出硬件的参数。有意思的是,我们逆向出来 Qualcomm Oryon 的分支预测器的大小(6 个表一共 80KB 的空间),与官方在 Hot Chips 上公开的是一致的。

总结

我们通过一系列方法,实现了对 Apple M1 Firestorm 的条件分支预测器的逆向,并且成功地把它应用到了设计基本一样的 Qualcomm Oryon 处理器上,为后续的研究提供了基础。

引用文献

🔲 ☆

PhpStorm 2025.2.2 官方版

PHPStorm是JetBrains公司开发的一个轻量级且便捷的PHP IDE,其旨在提供用户效率,可深刻理解用户的编码,提供智能代码补全,快速导航以及即时错误检查。 下载方式 安装版 https://download.jetbrains.com/webide/PhpStorm-2025. …
🔲 ☆

WebStorm 2025.2.2 官方正版

WebStorm是jetbrains公司旗下一款JavaScript 开发工具。已经被广大中国JS开发者誉为“Web前端开发神器”、“最强大的HTML5编辑器”、“最智能的JavaScript IDE”等。与IntelliJ IDEA同源,继承了IntelliJ IDEA强大的JS部分的功能。 下 …
🔲 ⭐

“这代码迟早出事!”——复盘线上问题:六个让你头痛的Go编码坏味道

本文永久链接 – https://tonybai.com/2025/05/31/six-smells-in-go

大家好,我是Tony Bai。

在日常的代码审查 (Code Review) 和线上问题复盘中,我经常会遇到一些看似不起眼,却可能埋下巨大隐患的 Go 代码问题。这些“编码坏味道”轻则导致逻辑混乱、性能下降,重则引发数据不一致、系统崩溃,甚至让团队成员在深夜被告警声惊醒,苦不堪言。

今天,我就结合自己团队中的一些“血淋淋”的经验,和大家聊聊那些曾让我(或许也曾让你)头痛不已的 Go 编码坏味道。希望通过这次复盘,我们都能从中吸取教训,写出更健壮、更优雅、更经得起考验的 Go 代码。

坏味道一:异步时序的“迷魂阵”——“我明明更新了,它怎么还是旧的?”

在高并发场景下,为了提升性能,我们经常会使用 goroutine 进行异步操作。但如果对并发操作的原子性和顺序性缺乏正确理解,就很容易掉进异步时序的陷阱。

典型场景:先异步通知,后更新状态

想象一下,我们有一个订单处理系统,当用户支付成功后,需要先异步发送一个通知给营销系统(比如发优惠券),然后再更新订单数据库的状态为“已支付”。

package main

import (
    "fmt"
    "sync"
    "time"
)

type Order struct {
    ID     string
    Status string // "pending", "paid", "notified"
}

func updateOrderStatusInDB(order *Order, status string) {
    fmt.Printf("数据库:订单 %s 状态更新为 %s\n", order.ID, status)
    order.Status = status // 模拟数据库更新
}

func asyncSendNotification(order *Order) {
    fmt.Printf("营销系统:收到订单 %s 通知,当前状态:%s。准备发送优惠券...\n", order.ID, order.Status)
    // 模拟耗时操作
    time.Sleep(50 * time.Millisecond)
    fmt.Printf("营销系统:订单 %s 优惠券已发送 (基于状态:%s)\n", order.ID, order.Status)
}

func main() {
    order := &Order{ID: "123", Status: "pending"}
    var wg sync.WaitGroup

    fmt.Printf("主流程:订单 %s 支付成功,准备处理...\n", order.ID)

    // 坏味道:先启动异步通知,再更新数据库状态
    wg.Add(1)
    go func(o *Order) { // 注意这里传递了指针
        defer wg.Done()
        asyncSendNotification(o)
    }(order) // goroutine 捕获的是 order 指针

    // 模拟主流程的其他操作,或者数据库更新前的延时
    time.Sleep(500 * time.Millisecond) 

    updateOrderStatusInDB(order, "paid") // 更新数据库状态

    wg.Wait()
    fmt.Printf("主流程:订单 %s 处理完毕,最终状态:%s\n", order.ID, order.Status)
}

该示例的可能输出:

主流程:订单 123 支付成功,准备处理...
营销系统:收到订单 123 通知,当前状态:pending。准备发送优惠券...
营销系统:订单 123 优惠券已发送 (基于状态:pending)
数据库:订单 123 状态更新为 paid
主流程:订单 123 处理完毕,最终状态:paid

我们看到营销系统拿到的优惠券居然是基于“pending”状态。

问题分析:

在上面的代码中,asyncSendNotification goroutine 和 updateOrderStatusInDB 是并发执行的。由于 asyncSendNotification 启动在先,并且捕获的是 order 指针,它很可能在 updateOrderStatusInDB 将订单状态更新为 “paid” 之前 就读取了 order.Status。这就导致营销系统基于一个过时的状态(”pending”)发送了通知或优惠券,引发业务逻辑错误。

避坑指南:

  1. 确保关键操作的同步性或顺序性: 对于有严格先后顺序要求的操作,不要轻易异步化。如果必须异步,确保依赖的操作完成后再执行。
  2. 使用同步原语: 利用 sync.WaitGroup、channel 等确保操作的正确顺序。例如,可以先更新数据库,再启动异步通知。
  3. 传递值而非指针(如果适用): 如果异步操作仅需快照数据,考虑传递值的副本,而不是指针。但在很多场景下,我们确实需要操作同一个对象。
  4. 在异步回调中重新获取最新状态: 如果异步回调依赖最新状态,应在回调函数内部重新从可靠数据源(如数据库)获取,而不是依赖启动时捕获的状态。

修正示例思路:

// ... (Order, updateOrderStatusInDB, asyncSendNotification 定义不变) ...
func main() {
    order := &Order{ID: "123", Status: "pending"}
    var wg sync.WaitGroup

    fmt.Printf("主流程:订单 %s 支付成功,准备处理...\n", order.ID)

    updateOrderStatusInDB(order, "paid") // 先更新数据库状态

    // 再启动异步通知
    wg.Add(1)
    go func(o Order) { // 传递结构体副本,或者在异步函数内部重新获取
        defer wg.Done()
        // 实际场景中,如果 asyncSendNotification 依赖的是更新后的状态,
        // 它应该有能力从某个地方(比如参数,或者内部重新查询)获取到 "paid" 这个状态。
        // 这里简化为直接使用传入时的状态,但强调其应为 "paid"。
        // 或者,更好的方式是 asyncSendNotification 接受一个 status 参数。
        clonedOrderForNotification := o // 假设我们传递的是更新后的状态的副本
        asyncSendNotification(&clonedOrderForNotification)
    }(*order) // 传递 order 的副本,此时 order.Status 已经是 "paid"

    wg.Wait()
    fmt.Printf("主流程:订单 %s 处理完毕,最终状态:%s\n", order.ID, order.Status)
}

坏味道二:指针与闭包的“爱恨情仇”——“我以为它没变,结果它却跑了!”

闭包是 Go 语言中一个强大的特性,它能够捕获其词法作用域内的变量。然而,当闭包捕获的是指针,并且这个指针指向的数据在 goroutine 启动后可能被外部修改,或者指针本身被重新赋值时,就可能导致并发问题和难以预料的行为。虽然 Go 1.22+ 通过实验性的 GOEXPERIMENT=loopvar 改变了 for 循环变量的捕获语义,解决了经典的循环变量闭包陷阱,但指针与闭包结合时对共享可变状态的考量依然重要。

典型场景:闭包捕获指针,外部修改指针或其指向内容

我们来看一个不涉及循环变量,但同样能体现指针与闭包问题的场景:

package main

import (
    "fmt"
    "sync"
    "time"
)

type Config struct {
    Version string
    Timeout time.Duration
}

func watchConfig(cfg *Config, wg *sync.WaitGroup) {
    defer wg.Done()
    // 这个 goroutine 期望在其生命周期内使用 cfg 指向的配置
    // 但如果外部在它执行期间修改了 cfg 指向的内容,或者 cfg 本身被重新赋值,
    // 那么这个 goroutine 看到的内容就可能不是启动时的那个了。
    fmt.Printf("Watcher: 开始监控配置 (Version: %s, Timeout: %v)\n", cfg.Version, cfg.Timeout)
    time.Sleep(100 * time.Millisecond) // 模拟监控工作
    fmt.Printf("Watcher: 监控结束,使用的配置 (Version: %s, Timeout: %v)\n", cfg.Version, cfg.Timeout)
}

func main() {
    currentConfig := &Config{Version: "v1.0", Timeout: 5 * time.Second}
    var wg sync.WaitGroup

    fmt.Printf("主流程:初始配置 (Version: %s, Timeout: %v)\n", currentConfig.Version, currentConfig.Timeout)

    // 启动一个 watcher goroutine,它捕获了 currentConfig 指针
    wg.Add(1)
    go watchConfig(currentConfig, &wg) // currentConfig 指针被传递

    // 主流程在 watcher goroutine 执行期间,修改了 currentConfig 指向的内容
    time.Sleep(10 * time.Millisecond) // 确保 watcher goroutine 已经启动并打印了初始配置
    fmt.Println("主流程:检测到配置更新,准备在线修改...")
    currentConfig.Version = "v2.0" // 直接修改了指针指向的内存内容
    currentConfig.Timeout = 10 * time.Second
    fmt.Printf("主流程:配置已修改为 (Version: %s, Timeout: %v)\n", currentConfig.Version, currentConfig.Timeout)

    // 或者更极端的情况,主流程让 currentConfig 指向了一个全新的 Config 对象
    // time.Sleep(10 * time.Millisecond)
    // fmt.Println("主流程:检测到配置需要完全替换...")
    // currentConfig = &Config{Version: "v3.0", Timeout: 15 * time.Second} // currentConfig 指向了新的内存地址
    // fmt.Printf("主流程:配置已替换为 (Version: %s, Timeout: %v)\n", currentConfig.Version, currentConfig.Timeout)
    // 注意:如果 currentConfig 被重新赋值指向新对象,原 watchConfig goroutine 仍然持有旧对象的指针。
    // 但如果原意是让 watchConfig 感知到“最新的配置”,那么这种方式是错误的。

    wg.Wait()
    fmt.Println("主流程:所有处理完毕。")

    fmt.Println("\n--- 更安全的做法:传递副本或不可变快照 ---")
    // 更安全的做法:如果 goroutine 需要的是启动时刻的配置快照
    stableConfig := &Config{Version: "v1.0-stable", Timeout: 5 * time.Second}
    configSnapshot := *stableConfig // 创建一个副本

    wg.Add(1)
    go func(cfgSnapshot Config, wg *sync.WaitGroup) { // 传递的是 Config 值的副本
        defer wg.Done()
        fmt.Printf("SafeWatcher: 开始监控配置 (Version: %s, Timeout: %v)\n", cfgSnapshot.Version, cfgSnapshot.Timeout)
        time.Sleep(100 * time.Millisecond)
        // 即使外部修改了 stableConfig,cfgSnapshot 依然是启动时的值
        fmt.Printf("SafeWatcher: 监控结束,使用的配置 (Version: %s, Timeout: %v)\n", cfgSnapshot.Version, cfgSnapshot.Timeout)
    }(configSnapshot, &wg)

    time.Sleep(10 * time.Millisecond)
    stableConfig.Version = "v2.0-stable" // 修改原始配置
    stableConfig.Timeout = 10 * time.Second
    fmt.Printf("主流程:stableConfig 已修改为 (Version: %s, Timeout: %v)\n", stableConfig.Version, stableConfig.Timeout)

    wg.Wait()
    fmt.Println("主流程:所有安全处理完毕。")
}

问题分析:

在第一个示例中,watchConfig goroutine 通过闭包(函数参数也是一种闭包形式)捕获了 currentConfig 指针。这意味着 watchConfig 内部对 cfg 的访问,实际上是访问 main goroutine 中 currentConfig 指针所指向的那块内存。

  • 当外部修改指针指向的内容时: 如代码中 currentConfig.Version = “v2.0″,watchConfig goroutine 在后续访问 cfg.Version 时,会看到这个被修改后的新值,这可能不是它启动时期望的行为。
  • 当外部修改指针本身时 (注释掉的极端情况): 如果 currentConfig = &Config{Version: “v3.0″, …},那么 watchConfig 捕获的 cfg 仍然指向原始的 Config 对象(即 “v1.0″ 那个)。如果此时的业务逻辑期望 watchConfig 使用“最新的配置对象”,那么这种捕获指针的方式就会导致错误。

这些问题的根源在于对共享可变状态的并发访问缺乏控制,以及对指针生命周期和闭包捕获机制的理解不够深入。

避坑指南:

  1. 明确 goroutine 需要的数据快照还是共享状态:

    • 如果 goroutine 只需要启动时刻的数据快照,并且不希望受外部修改影响,那么应该传递值的副本给 goroutine(或者在闭包内部创建副本)。如第二个示例中的 configSnapshot。
    • 如果 goroutine 需要与外部共享并感知状态变化,那么必须使用同步机制(如 mutex、channel、atomic 操作)来保护对共享状态的访问,确保数据一致性和避免竞态条件。
  2. 谨慎捕获指针,特别是那些可能在 goroutine 执行期间被修改的指针:

    • 如果捕获了指针,要清楚地知道这个指针的生命周期,以及它指向的数据是否会被其他 goroutine 修改。
    • 如果指针指向的数据是可变的,并且多个 goroutine 会并发读写,必须加锁保护
  3. 考虑数据的不可变性: 如果可能,尽量使用不可变的数据结构。将不可变的数据传递给 goroutine 是最安全的并发方式之一。

  4. 对于经典的 for 循环启动 goroutine 捕获循环变量的问题:

    • Go 1.22+ (启用 GOEXPERIMENT=loopvar) 或未来版本: 语言层面已经解决了每次迭代共享同一个循环变量的问题,每次迭代会创建新的变量实例。此时,直接在闭包中捕获循环变量是安全的。
    • Go 1.21 及更早版本 (或未启用 loopvar 实验特性): 仍然需要通过函数参数传递的方式来确保每个 goroutine 捕获到正确的循环变量值。例如:
for i, v := range values {
    valCopy := v // 如果 v 是复杂类型,可能需要更深的拷贝
    indexCopy := i
    go func() {
        // 使用 valCopy 和 indexCopy
    }()
}
// 或者更推荐的方式:
for i, v := range values {
    go func(idx int, valType ValueType) { // ValueType 是 v 的类型
        // 使用 idx 和 valType
    }(i, v)
}

虽然 Go 语言在 for 循环变量捕获方面做出了改进,但指针与闭包结合时对共享状态和生命周期的审慎思考,仍然是编写健壮并发程序的关键。

坏味道三:错误处理的哲学——“是Bug就让它崩!”真的好吗?

Go 语言通过返回 error 值来处理可预期的错误,而 panic 则用于表示真正意外的、程序无法继续正常运行的严重错误,通常由运行时错误(如数组越界、空指针解引用)或显式调用 panic() 引发。当 panic 发生且未被 recover 时,程序会崩溃并打印堆栈信息。

一种常见的观点是:“如果是 Bug,就应该让它尽快崩溃 (Fail Fast)”,以便问题能被及时发现和修复。这种观点在很多情况下是合理的。然而,在某些 mission-critical(关键任务)系统中,例如金融交易系统、空中交通管制系统、重要的基础设施服务等,一次意外的宕机重启可能导致不可估量的损失或严重后果。在这些场景下,即使因为一个未捕获的 Bug 导致了 panic,我们也可能期望系统能有一定的“韧性”,而不是轻易“放弃治疗”。

典型场景:一个关键服务在处理请求时因 Bug 发生 Panic

package main

import (
    "fmt"
    "net/http"
    "runtime/debug"
    "time"
)

// 模拟一个关键数据处理器
type CriticalDataProcessor struct {
    // 假设有一些内部状态
    activeConnections int
    lastProcessedID   string
}

// 处理数据的方法,这里故意引入一个可能导致 panic 的 bug
func (p *CriticalDataProcessor) Process(dataID string, payload map[string]interface{}) error {
    fmt.Printf("Processor: 开始处理数据 %s\n", dataID)
    p.activeConnections++
    defer func() { p.activeConnections-- }() // 确保连接数正确管理

    // 模拟一些复杂逻辑
    time.Sleep(50 * time.Millisecond)

    // !!!潜在的 Bug !!!
    // 假设 payload 中 "user" 字段应该是一个结构体指针,但有时可能是 nil
    // 或者,某个深层嵌套的访问可能导致空指针解引用
    // 为了演示,我们简单模拟一个 nil map 访问导致的 panic
    var userDetails map[string]string
    // userDetails = payload["user"].(map[string]string) // 这本身也可能 panic 如果类型断言失败
    // 为了稳定复现 panic,我们直接让 userDetails 为 nil
    if dataID == "buggy-data-001" { // 特定条件下触发 bug
        fmt.Printf("Processor: 触发 Bug,尝试访问 nil map '%s'\n", userDetails["name"]) // 这里会 panic
    }

    p.lastProcessedID = dataID
    fmt.Printf("Processor: 数据 %s 处理成功\n", dataID)
    return nil
}

// HTTP Handler - 版本1: 不做任何 recover
func handleRequestVersion1(processor *CriticalDataProcessor) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        dataID := r.URL.Query().Get("id")
        if dataID == "" {
            http.Error(w, "缺少 id 参数", http.StatusBadRequest)
            return
        }

        // 模拟从请求中获取 payload
        payload := make(map[string]interface{})
        // if dataID == "buggy-data-001" {
        //  // payload["user"] 可能是 nil 或错误类型,导致 Process 方法 panic
        // }

        err := processor.Process(dataID, payload) // 如果 Process 发生 panic,整个 HTTP server goroutine 会崩溃
        if err != nil {
            http.Error(w, fmt.Sprintf("处理失败: %v", err), http.StatusInternalServerError)
            return
        }
        fmt.Fprintf(w, "请求 %s 处理成功\n", dataID)
    }
}

// HTTP Handler - 版本2: 在每个请求处理的 goroutine 顶层 recover
func handleRequestVersion2(processor *CriticalDataProcessor) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if err := recover(); err != nil {
                fmt.Fprintf(os.Stderr, "!!!!!!!!!!!!!! PANIC 捕获 !!!!!!!!!!!!!!\n")
                fmt.Fprintf(os.Stderr, "错误: %v\n", err)
                fmt.Fprintf(os.Stderr, "堆栈信息:\n%s\n", debug.Stack())
                fmt.Fprintf(os.Stderr, "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n")

                // 向客户端返回一个通用的服务器错误
                http.Error(w, "服务器内部错误,请稍后重试", http.StatusInternalServerError)

                // 可以在这里记录更详细的错误到日志系统、发送告警等
                // 例如:log.Errorf("Panic recovered: %v, Stack: %s", err, debug.Stack())
                // metrics.Increment("panic_recovered_total")

                // 重要:根据系统的 mission-critical 程度和业务逻辑,
                // 这里可能还需要做一些清理工作,或者尝试让系统保持在一种“安全降级”的状态。
                // 但要注意,recover 后的状态可能是不确定的,需要非常谨慎。
            }
        }()

        dataID := r.URL.Query().Get("id")
        if dataID == "" {
            http.Error(w, "缺少 id 参数", http.StatusBadRequest)
            return
        }
        payload := make(map[string]interface{})

        err := processor.Process(dataID, payload)
        if err != nil {
            // 正常错误处理
            http.Error(w, fmt.Sprintf("处理失败: %v", err), http.StatusInternalServerError)
            return
        }
        fmt.Fprintf(w, "请求 %s 处理成功\n", dataID)
    }
}

func main() {
    processor := &CriticalDataProcessor{}

    // mux1 使用 Version1 handler (不 recover)
    // mux2 使用 Version2 handler (recover)

    // 启动 HTTP 服务器 (这里为了演示,只启动一个,实际中会选择一个)
    // 你可以注释掉一个,运行另一个来观察效果

    // http.HandleFunc("/v1/process", handleRequestVersion1(processor))
    // fmt.Println("V1 Server (不 recover) 启动在 :8080/v1/process")
    // go http.ListenAndServe(":8080", nil)

    http.DefaultServeMux.HandleFunc("/v2/process", handleRequestVersion2(processor))
    fmt.Println("V2 Server (recover) 启动在 :8081/v2/process")
    go http.ListenAndServe(":8081", nil)

    fmt.Println("\n请在浏览器或使用 curl 测试:")
    fmt.Println("  正常请求: curl 'http://localhost:8081/v2/process?id=normal-data-002'")
    fmt.Println("  触发Bug的请求: curl 'http://localhost:8081/v2/process?id=buggy-data-001'")
    fmt.Println("  (如果启动V1服务,触发Bug的请求会导致服务崩溃)")

    select {} // 阻塞 main goroutine,保持服务器运行
}

问题分析:

  • 不 Recover (handleRequestVersion1): 当 processor.Process 方法因为 Bug(例如访问 nil map userDetails["name"])而发生 panic 时,如果这个 panic 没有在当前 goroutine 的调用栈中被 recover,它会一直向上传播。对于由 net/http 包为每个请求创建的 goroutine,如果 panic 未被处理,将导致该 goroutine 崩溃。在某些情况下(取决于 Go 版本和 HTTP server 实现的细节),这可能导致整个 HTTP 服务器进程终止,或者至少是该连接的处理异常中断,影响服务可用性。
  • Recover (handleRequestVersion2): 通过在每个请求处理的 goroutine 顶层使用 defer func() { recover() }(),我们可以捕获这个由 Bug 引发的 panic。捕获后,我们可以:
    • 记录详细的错误信息和堆栈跟踪,便于事后分析和修复 Bug。
    • 向当前请求的客户端返回一个通用的错误响应(例如 HTTP 500),而不是让连接直接断开或无响应。
    • 关键在于: 阻止了单个请求处理中的 Bug 导致的 panic 扩散到导致整个服务不可用的地步。服务本身仍然可以继续处理其他正常的请求。

“是Bug就让它崩!”的观点在很多开发和测试环境中是值得提倡的,因为它能让我们更快地发现和定位问题。然而,在线上,特别是对于 mission-critical 系统:

  • 可用性是第一要务: 一次意外的全面宕机,可能比单个请求处理失败带来的损失大得多。
  • 数据一致性风险: 如果 panic 发生在关键数据操作的中间状态,直接崩溃可能导致数据不一致或损坏。recover 之后虽然也需要谨慎处理状态,但至少给了我们一个尝试回滚或记录问题的机会。
  • 用户体验: 对用户而言,遇到一个“服务器内部错误”然后重试,通常比整个服务长时间无法访问要好一些。

避坑与决策指南:

  1. 在关键服务的请求处理入口或 goroutine 顶层设置 recover 机制: 这是构建健壮服务的推荐做法。
    • recover 应该与 defer 配合使用。
    • 在 recover 逻辑中,务必记录详细的错误信息、堆栈跟踪,并考虑集成到告警系统。
  2. recover 之后做什么?——视情况而定,但要极其谨慎:
    • 对于单个请求处理 goroutine: 通常的做法是记录错误,向当前客户端返回错误响应,然后让该 goroutine 正常结束。避免让这个 panic 影响其他请求。
    • 对于核心的、管理全局状态的 goroutine: 如果发生 panic,表明系统可能处于一种非常不稳定的状态。recover 后,可能需要执行一些清理操作,尝试将系统恢复到一个已知的安全状态,或者进行优雅关闭并重启。绝对不应该假装什么都没发生,继续使用可能已损坏的状态。
    • “苟活”的度: “苟活”不代表对 Bug 视而不见。recover 的目的是保障服务的整体可用性,同时为我们争取定位和修复 Bug 的时间。捕获到的 panic 必须被视为高优先级事件进行处理。
  3. 库代码应极度克制 panic: 库不应该替应用程序做“是否崩溃”的决策。
  4. 测试,测试,再测试: 通过充分的单元测试、集成测试和压力测试,尽可能在上线前发现和消除潜在的 Bug,减少线上发生 panic 的概率。可以使用 Go 的 race detector 来检测并发代码中的竞态条件。
  5. 不要滥用 panic/recover 作为正常的错误处理机制: panic/recover 主要用于处理不可预料的、灾难性的运行时错误或程序缺陷,而不是替代 error 返回值来处理业务逻辑中的预期错误。

“是Bug就让它崩!”在开发阶段有助于快速发现问题,但在生产环境,特别是 mission-critical 系统中,“有控制地恢复,详细记录,并保障整体服务可用性” 往往是更明智的选择。这并不意味着容忍 Bug,而是采用一种更成熟、更负责任的方式来应对突发状况,确保系统在面对未知错误时仍能表现出足够的韧性。

坏味道四:http.Client 的“一次性”误区——“每次都新建,省心又省事?”

Go 标准库的 net/http 包提供了强大的 HTTP客户端功能。但有些开发者(尤其是初学者)在使用 http.Client 时,会为每一个 HTTP 请求都创建一个新的 http.Client 实例。

典型场景:函数内部频繁创建 http.Client

package main

import (
    "fmt"
    "io/ioutil"
    "net/http"
    "time"
)

// 坏味道:每次调用都创建一个新的 http.Client
func fetchDataFromAPI(url string) (string, error) {
    client := &http.Client{ // 每次都新建 Client
        Timeout: 10 * time.Second,
    }
    resp, err := client.Get(url)
    if err != nil {
        return "", err
    }
    defer resp.Body.Close()

    body, err := ioutil.ReadAll(resp.Body)
    if err != nil {
        return "", err
    }
    return string(body), nil
}

// 正确的方式:复用 http.Client
var sharedClient = &http.Client{ // 全局或适当范围复用的 Client
    Timeout: 10 * time.Second,
    // 可以配置 Transport 以控制连接池等
    // Transport: &http.Transport{
    //  MaxIdleConns:        100,
    //  MaxIdleConnsPerHost: 10,
    //  IdleConnTimeout:     90 * time.Second,
    // },
}

func fetchDataFromAPIReusable(url string) (string, error) {
    resp, err := sharedClient.Get(url) // 复用 Client
    if err != nil {
        return "", err
    }
    defer resp.Body.Close()

    body, err := ioutil.ReadAll(resp.Body)
    if err != nil {
        return "", err
    }
    return string(body), nil
}

func main() {
    // 模拟多次调用
    // 如果使用 fetchDataFromAPI,每次都会创建新的 TCP 连接
    // _,_ = fetchDataFromAPI("https://www.example.com")
    // _,_ = fetchDataFromAPI("https://www.example.com")

    // 使用 fetchDataFromAPIReusable,会复用连接
    data, err := fetchDataFromAPIReusable("https://httpbin.org/get")
    if err != nil {
        fmt.Printf("请求错误: %v\n", err)
        return
    }
    fmt.Printf("获取到数据 (部分): %s...\n", data[:50])

    data, err = fetchDataFromAPIReusable("https://httpbin.org/get")
    if err != nil {
        fmt.Printf("请求错误: %v\n", err)
        return
    }
    fmt.Printf("再次获取到数据 (部分): %s...\n", data[:50])
}

问题分析:

http.Client 的零值或通过 &http.Client{} 创建的实例,其内部的 Transport 字段(通常是 *http.Transport)会维护一个 TCP 连接池,并处理 HTTP keep-alive 等机制以复用连接。如果为每个请求都创建一个新的 http.Client,那么每次请求都会经历完整的 TCP 连接建立过程(三次握手),并在请求结束后关闭连接。

危害:

  1. 性能下降: 频繁的 TCP 连接建立和关闭开销巨大。
  2. 资源消耗增加: 短时间内大量创建连接可能导致客户端耗尽可用端口,或者服务器端累积大量 TIME_WAIT 状态的连接,最终影响整个系统的吞吐量和稳定性。

避坑指南:

  1. 复用 http.Client 实例: 这是官方推荐的最佳实践。可以在全局范围创建一个 http.Client 实例(如 http.DefaultClient,或者一个自定义配置的实例),并在所有需要发起 HTTP 请求的地方复用它。
  2. http.Client 是并发安全的: 你可以放心地在多个 goroutine 中共享和使用同一个 http.Client 实例。
  3. 自定义 Transport: 如果需要更细致地控制连接池大小、超时时间、TLS 配置等,可以创建一个自定义的 http.Transport 并将其赋给 http.Client 的 Transport 字段。

坏味道五:API 设计的“文档缺失”——“这参数啥意思?猜猜看!”

良好的 API 设计是软件质量的基石,而清晰、准确的文档则是 API 可用性的关键。然而,在实际项目中,我们常常会遇到一些 API,其参数、返回值、错误码、甚至行为语义都缺乏明确的文档说明,导致用户(调用方)在集成时只能靠“猜”或者阅读源码,极易产生误用。

典型场景:一个“凭感觉”调用的服务发现 API

假设我们有一个类似 Nacos Naming 的服务发现客户端,其 GetInstance API 的文档非常简略,或者干脆没有文档,只暴露了函数签名:

package main

import (
    "errors"
    "fmt"
    "math/rand"
    "time"
)

// 假设这是 Nacos Naming 客户端的一个简化接口
type NamingClient interface {
    // GetInstance 获取服务实例。
    // 关键问题:
    // 1. serviceName 需要包含 namespace/group 信息吗?格式是什么?
    // 2. clusters 是可选的吗?如果提供多个,是随机选一个还是有特定策略?
    // 3. healthyOnly 如果为 true,是否会过滤掉不健康的实例?如果不健康实例是唯一选择呢?
    // 4. 返回的 instance 是什么结构?如果找不到实例,是返回 nil, error 还是空对象?
    // 5. error 可能有哪些类型?调用方需要如何区分处理?
    // 6. 这个调用是阻塞的吗?超时机制是怎样的?
    // 7. 是否有本地缓存机制?缓存刷新策略是?
    GetInstance(serviceName string, clusters []string, healthyOnly bool) (instance interface{}, err error)
}

// 一个非常简化的模拟实现 (坏味道的 API 设计,文档缺失)
type MockNamingClient struct{}

func (c *MockNamingClient) GetInstance(serviceName string, clusters []string, healthyOnly bool) (interface{}, error) {
    fmt.Printf("尝试获取服务: %s, 集群: %v, 只获取健康实例: %t\n", serviceName, clusters, healthyOnly)

    // 模拟一些内部逻辑和不确定性
    if serviceName == "" {
        return nil, errors.New("服务名不能为空 (错误码: Naming-1001)") // 文档里有这个错误码说明吗?
    }

    // 假设我们内部有一些实例数据
    instances := map[string][]string{
        "OrderService":   {"10.0.0.1:8080", "10.0.0.2:8080"},
        "PaymentService": {"10.0.1.1:9090"},
    }

    // 模拟集群选择逻辑 (文档缺失,用户只能猜)
    selectedCluster := ""
    if len(clusters) > 0 {
        selectedCluster = clusters[rand.Intn(len(clusters))] // 随机选一个?
        fmt.Printf("选择了集群: %s\n", selectedCluster)
    }

    // 模拟健康检查和实例返回 (文档缺失)
    if healthyOnly && rand.Float32() < 0.3 { // 30% 概率找不到健康实例
        return nil, fmt.Errorf("在集群 %s 中未找到 %s 的健康实例 (错误码: Naming-2003)", selectedCluster, serviceName)
    }

    if insts, ok := instances[serviceName]; ok && len(insts) > 0 {
        return insts[rand.Intn(len(insts))], nil // 返回一个实例地址
    }

    return nil, fmt.Errorf("服务 %s 未找到 (错误码: Naming-4004)", serviceName)
}

func main() {
    client := &MockNamingClient{}

    // 用户A的调用 (基于猜测)
    fmt.Println("用户A 调用:")
    instA, errA := client.GetInstance("OrderService", []string{"clusterA", "clusterB"}, true)
    if errA != nil {
        fmt.Printf("用户A 获取实例失败: %v\n", errA)
    } else {
        fmt.Printf("用户A 获取到实例: %v\n", instA)
    }

    fmt.Println("\n用户B 的调用 (换一种猜测):")
    // 用户B 可能不知道 serviceName 需要什么格式,或者 clusters 参数的意义
    instB, errB := client.GetInstance("com.example.PaymentService", nil, false) // serviceName 格式?clusters 为 nil 会怎样?
    if errB != nil {
        fmt.Printf("用户B 获取实例失败: %v\n", errB)
    } else {
        fmt.Printf("用户B 获取到实例: %v\n", instB)
    }
}

问题分析:

当 API 的设计者没有提供清晰、详尽的文档来说明每个参数的含义、取值范围、默认行为、边界条件、错误类型以及API的整体行为和副作用时,API 的使用者就只能依赖猜测、尝试,甚至阅读源码(如果开源的话)来理解如何正确调用。

危害:

  1. 极易误用: 用户可能以 API 设计者未预期的方式调用接口,导致程序行为不符合预期,甚至引发错误。
  2. 集成成本高: 理解和调试一个文档不清晰的 API 非常耗时。
  3. 脆弱的依赖: 当 API 的内部实现或未明确定义的行为发生变化时,依赖这些隐性行为的调用方代码很可能会中断。
  4. 难以排查问题: 出现问题时,很难判断是调用方使用不当,还是 API 本身的缺陷。

避坑指南 (针对 API 设计者):

  1. 编写清晰、准确、详尽的文档是 API 设计不可或缺的一部分! 这不仅仅是注释,可能还包括独立的 API 参考手册、用户指南和最佳实践。
  2. 参数和返回值要有明确的语义: 名称应自解释,复杂类型应有结构和字段说明。
    • 例如,serviceName 是否需要包含命名空间或分组信息?格式是什么?
    • clusters 参数是可选的吗?如果提供多个,选择策略是什么?是轮询、随机还是有特定优先级?
    • healthyOnly 的确切行为是什么?如果没有健康的实例,是返回错误还是有其他回退逻辑?
  3. 明确约定边界条件和错误情况:
    • 哪些参数是必需的,哪些是可选的?可选参数的默认值是什么?
    • 对于无效输入,API 会如何响应?返回哪些具体的错误码或错误信息?(例如,示例中的 Naming-1001, Naming-2003, Naming-4004 是否有统一的文档说明其含义和建议处理方式?)
    • API 调用可能产生的副作用是什么?
  4. 提供清晰的调用示例: 针对常见的用例,提供可运行的代码示例。
  5. 考虑 API 的易用性和健壮性:
    • 是否需要版本化?
    • 是否需要幂等性保证?
    • 认证和授权机制是否清晰?
    • 超时和重试策略是怎样的?
  6. 将 API 的使用者视为首要客户: 站在使用者的角度思考,他们需要哪些信息才能轻松、正确地使用你的 API。

对于 API 的使用者: 当遇到文档不清晰的 API 时,除了“猜测”,更积极的做法是向 API 提供方寻求澄清,或者在有条件的情况下,参与到 API 文档的改进和完善中。

在之前《API设计的“Go境界”:Go团队设计MCP SDK过程中的取舍与思考》一文中,我们了见识了Go团队的API设计艺术,大家可以认知阅读和参考。

坏味道六:匿名函数类型签名的“笨拙感”——“这函数参数看着眼花缭乱!”

Go 语言的函数是一等公民,可以作为参数传递,也可以作为返回值。这为编写高阶函数和实现某些设计模式提供了极大的灵活性。然而,当匿名函数的类型签名(特别是嵌套或包含多个复杂函数类型参数时)直接写在函数定义中时,代码的可读性会大大降低,显得冗余和笨拙。

典型场景:复杂的函数签名

package main

import (
    "errors"
    "fmt"
    "strings"
)

// 坏味道:函数签名中直接嵌入复杂的匿名函数类型
func processData(
    data []string,
    filterFunc func(string) bool, // 参数1:一个过滤函数
    transformFunc func(string) (string, error), // 参数2:一个转换函数
    aggregatorFunc func([]string) string, // 参数3:一个聚合函数
) (string, error) {
    var filteredData []string
    for _, d := range data {
        if filterFunc(d) {
            transformed, err := transformFunc(d)
            if err != nil {
                // 注意:这里为了简化,直接返回了第一个遇到的错误
                // 实际应用中可能需要更复杂的错误处理逻辑,比如收集所有错误
                return "", fmt.Errorf("转换 '%s' 失败: %w", d, err)
            }
            filteredData = append(filteredData, transformed)
        }
    }
    if len(filteredData) == 0 {
        return "", errors.New("没有数据需要聚合")
    }
    return aggregatorFunc(filteredData), nil
}

// 使用 type 定义函数类型别名,代码更清晰
type StringFilter func(string) bool
type StringTransformer func(string) (string, error)
type StringAggregator func([]string) string

func processDataWithTypeAlias(
    data []string,
    filter StringFilter,
    transform StringTransformer,
    aggregate StringAggregator,
) (string, error) {
    // 函数体与 processData 相同
    var filteredData []string
    for _, d := range data {
        if filter(d) {
            transformed, err := transform(d)
            if err != nil {
                return "", fmt.Errorf("转换 '%s' 失败: %w", d, err)
            }
            filteredData = append(filteredData, transformed)
        }
    }
    if len(filteredData) == 0 {
        return "", errors.New("没有数据需要聚合")
    }
    return aggregate(filteredData), nil
}

func main() {
    sampleData := []string{"  apple  ", "Banana", "  CHERRY  ", "date"}

    // 使用原始的 processData,函数调用时也可能显得冗长
    result, err := processData(
        sampleData,
        func(s string) bool { return len(strings.TrimSpace(s)) > 0 },
        func(s string) (string, error) {
            trimmed := strings.TrimSpace(s)
            if strings.ToLower(trimmed) == "banana" { // 假设banana是不允许的
                return "", errors.New("包含非法水果banana")
            }
            return strings.ToUpper(trimmed), nil
        },
        func(s []string) string { return strings.Join(s, ", ") },
    )

    if err != nil {
        fmt.Printf("处理错误 (原始方式): %v\n", err)
    } else {
        fmt.Printf("处理结果 (原始方式): %s\n", result)
    }

    // 使用 processDataWithTypeAlias,定义和调用都更清晰
    filter := func(s string) bool { return len(strings.TrimSpace(s)) > 0 }
    transformer := func(s string) (string, error) {
        trimmed := strings.TrimSpace(s)
        if strings.ToLower(trimmed) == "banana" {
            return "", errors.New("包含非法水果banana")
        }
        return strings.ToUpper(trimmed), nil
    }
    aggregator := func(s []string) string { return strings.Join(s, ", ") }

    resultTyped, errTyped := processDataWithTypeAlias(sampleData, filter, transformer, aggregator)
    if errTyped != nil {
        fmt.Printf("处理错误 (类型别名方式): %v\n", errTyped)
    } else {
        fmt.Printf("处理结果 (类型别名方式): %s\n", resultTyped)
    }
}

问题分析:

Go 语言的类型系统是强类型且显式的。函数类型本身也是一种类型。当我们将一个函数类型(特别是具有多个参数和返回值的复杂函数类型)直接作为另一个函数的参数类型或返回值类型时,会导致函数签名变得非常长,难以阅读和理解。这与 Go 追求简洁和可读性的哲学在观感上有所冲突。

避坑指南:

  1. 使用 type 关键字定义函数类型别名: 这是解决此类问题的最推荐、最地道也是最常见的方法。通过为复杂的函数签名定义一个有意义的类型名称,可以极大地提高代码的可读性和可维护性。如示例中的 StringFilter, StringTransformer, StringAggregator。
  2. 何时可以不使用类型别名:
    • 当函数签名非常简单(例如 func() 或 func(int) int)且该函数类型只在局部、极少数地方使用时,直接写出可能问题不大。
    • 但一旦函数签名变复杂,或者该函数类型需要在多个地方使用(作为不同函数的参数或返回值,或者作为结构体字段类型),就应该毫不犹豫地使用类型别名。
  3. 理解背后的设计考量: Go 语言强调类型的明确性。虽然直接写出函数类型显得“笨拙”,但也保证了类型信息在代码中的完全显露,避免了某些动态语言中因类型不明确可能导致的困惑。类型别名则是在这种明确性的基础上,提供了提升可读性的手段。

为了更好地简化匿名函数,Go团队也提出了关于引入轻量级匿名函数语法的提案(Issue #21498),该提案一直是社区讨论的焦点,它旨在提供一种更简洁的方式来定义匿名函数,尤其是当函数类型可以从上下文推断时,从而减少样板代码,提升代码的可读性和编写效率。

小结:于细微处见真章,持续打磨代码品质

今天我们复盘的这六个 Go 编码“坏味道”——异步时序混乱、指针闭包陷阱、不当的错误处理、http.Client 误用、文档缺失的 API 以及冗长的函数签名——可能只是我们日常开发中遇到问题的冰山一角。

它们中的每一个,看似都是细节问题,但“千里之堤,溃于蚁穴”。正是这些细节的累积,最终决定了我们软件产品的质量、系统的稳定性和团队的开发效率。

识别并规避这些“坏味道”,需要我们:

  • 深入理解 Go 语言的特性和设计哲学。
  • 培养严谨的工程思维和对细节的关注。
  • 重视代码审查,从他人的错误和经验中学习。
  • 持续学习,不断反思和总结自己的编码实践。

希望今天的分享能给大家带来一些启发。让我们一起努力,写出更少“坑”、更高质量的 Go 代码!


聊一聊,也帮个忙:

  • 在你日常的 Go 开发或 Code Review 中,还遇到过哪些让你印象深刻的“编码坏味道”?
  • 对于今天提到的这些问题,你是否有自己独特的解决技巧或更深刻的理解?
  • 你认为在团队中推广良好的编码规范和实践,最有效的方法是什么?

欢迎在评论区留下你的经验、思考和问题。如果你觉得这篇文章对你有帮助,也请转发给你身边的 Gopher 朋友们,让我们一起在 Go 的道路上精进!

想与我进行更深入的 Go 语言、编码实践与 AI 技术交流吗? 欢迎加入我的“Go & AI 精进营”知识星球

img{512x368}

我们星球见!


商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。如有需求,请扫描下方公众号二维码,与我私信联系。

© 2025, bigwhite. 版权所有.

🔲 ⭐

Apple M1 (Firestorm & Icestorm) 微架构评测

Apple M1 (Firestorm & Icestorm) 微架构评测

背景

虽然 Apple M1 已经是 2020 年的处理器,但它对苹果自研芯片来说是一个里程碑,考虑到 X Elite 处理器的 Oryon 微架构和 Apple M1 性能核 Firestorm 微架构的相似性,还是测试一下这个 Firestorm + Icestorm 微架构在各个方面的表现。Apple A14 采用了和 Apple M1 一样的微架构。

官方信息

Apple M1 的官方信息乏善可陈,关于微架构的信息几乎为零,但能从操作系统汇报的硬件信息中找到一些内容。

现有评测

网上已经有较多针对 Apple M1 微架构的评测和分析,建议阅读:

下面分各个模块分别记录官方提供的信息,以及实测的结果。读者可以对照已有的第三方评测理解。官方信息与实测结果一致的数据会加粗。

Benchmark

Apple Firestorm/Icestorm 的性能测试结果见 SPEC

环境准备

Apple M1 预装的是 macOS,macOS 的绑核只能绑到 P 或者 E,不能具体到某一个核上;在 macOS 上可以读取 PMU,需要使用 kpep 的私有框架,代码可以在这里找到。

如果想更方便地进行测试,建议安装 Asahi Linux 的各种发行版,此时可以在 Linux 下自由地绑核,也可以用标准的方式使用 PMU。

前端

取指带宽

Firestorm

为了测试实际的 Fetch 宽度,参考 如何测量真正的取指带宽(I-fetch width) - JamesAslan 构造了测试。

其原理是当 Fetch 要跨页的时候,由于两个相邻页可能映射到不同的物理地址,如果要支持单周期跨页取指,需要查询两次 ITLB,或者 ITLB 需要把相邻两个页的映射存在一起。这个场景一般比较少,处理器很少会针对这种特殊情况做优化,但也不是没有。经过测试,把循环放在两个页的边界上,发现 Firestorm 微架构遇到跨页的取指时确实会拆成两个周期来进行。

在此基础上,构造一个循环,循环的第一条指令放在第一个页的最后四个字节,其余指令放第二个页上,那么每次循环的取指时间,就是一个周期(读取第一个页内的指令)加上第二个页内指令需要 Fetch 的周期数,多的这一个周期就足以把 Fetch 宽度从后端限制中区分开,实验结果如下:

图中蓝线(cross-page)表示的就是上面所述的第一条指令放一个页,其余指令放第二个页的情况,横坐标是第二个页内的指令数,那么一次循环的指令数等于横坐标 +1。纵坐标是运行很多次循环的总 cycle 数除以循环次数,也就是平均每次循环耗费的周期数。可以看到每 16 条指令会多一个周期,因此 Firestorm 的前端取指宽度确实是 16 条指令。

为了确认这个瓶颈是由取指造成的,又构造了一组实验,把循环的所有指令都放到一个页中,这个时候 Fetch 不再成为瓶颈(图中 aligned),两个曲线的对比可以明确地得出上述结论。

随着指令数进一步增加,最终瓶颈在每周期执行的 NOP 指令数,因此两条线重合。

Icestorm

用相同的方式测试 Icestorm,结果如下:

可以看到每 8 条指令会多一个周期,意味着 Icestorm 的前端取指宽度为 8 条指令。

L1 ICache

官方信息:通过 sysctl 可以看到,Firestorm 具有 192KB L1 ICache,Icestorm 具有 128KB L1 ICache:

hw.perflevel0.l1icachesize: 196608hw.perflevel1.l1icachesize: 131072

Firestorm

为了测试 L1 ICache 容量,构造一个具有巨大指令 footprint 的循环,由大量的 nop 和最后的分支指令组成。观察在不同 footprint 大小下 Firestorm 的 IPC:

可以看到 footprint 在 192 KB 之前时可以达到 8 IPC,之后则快速降到 2.22 IPC,这里的 192 KB 就对应了 Firestorm 的 L1 ICache 的容量。虽然 Fetch 可以每周期 16 条指令,也就是一条 64B 的缓存行,由于后端的限制,只能观察到 8 的 IPC。

Icestorm

用相同的方式测试 Icestorm,结果如下:

可以看到 footprint 在 128 KB 之前时可以达到 4 IPC,之后则快速降到 2.10 IPC,这里的 128 KB 就对应了 Icestorm 的 L1 ICache 的容量。虽然 Fetch 可以每周期 8 条指令,由于后端的限制,只能观察到 4 的 IPC。

BTB

Firestorm

构造大量的无条件分支指令(B 指令),BTB 需要记录这些指令的目的地址,那么如果分支数量超过了 BTB 的容量,性能会出现明显下降。当把大量 B 指令紧密放置,也就是每 4 字节一条 B 指令时:

可见在 1024 个分支之内可以达到 1 的 CPI,超过 1024 个分支,出现了 3 CPI 的平台,一直延续到 49152 个分支。超出 BTB 容量以后,分支预测时,无法从 BTB 中得到哪些指令是分支指令的信息,只能等到取指甚至译码后才能后知后觉地发现这是一条分支指令,这样就出现了性能损失,出现了 3 CPI 的情况。第二个拐点 49152,对应的是指令 footprint 超出 L1 ICache 的情况:L1 ICache 是 192KB,按照每 4 字节一个 B 指令计算,最多可以存放 49152 条 B 指令。

降低分支指令的密度,在 B 指令之间插入 NOP 指令,使得每 8 个字节有一条 B 指令,得到如下结果:

可以看到 CPI=1 的拐点前移到 1024 个分支,同时 CPI=3 的平台的拐点也前移到了 24576。拐点的前移,意味着 BTB 采用了组相连的结构,当 B 指令的 PC 的部分低位总是为 0 时,组相连的 Index 可能无法取到所有的 Set,导致表现出来的 BTB 容量只有部分 Set,例如此处容量减半,说明只有一半的 Set 被用到了。

如果进一步降低 B 指令的密度,使得它的低若干位都等于 0,最终 CPI=1 的拐点定格在 2 条分支,此时分支的间距大于或等于 2048B;CPI=3 的拐点定格在 6 条分支,此时分支的间距大于或等于 32KB。根据这个信息,可以认为 Firestorm 的 BTB 是 512 Set 2 Way 的结构,Index 是 PC[10:2];同时也侧面佐证了 192KB L1 ICache 是 512 Set 6 Way,Index 是 PC[14:6]。

Icestorm

用相同的方式测试 Icestorm,首先用 4B 的间距:

可以看到 1024 的拐点,1024 之前是 1 IPC,之后增加到 3 IPC。比较奇怪的是,没有看到第二个拐点,第二个拐点在 8B 的间距下显现:

第一个拐点前移到 512,第二个拐点出现在 16384,而 Icestorm 的 L1 ICache 容量是 128KB,8B 间距下正好可以保存 16384 个分支。

用 16B 间距测试:

第一个拐点前移到 256,然后出现了一个 2 CPI 的新平台,2 CPI 的平台的拐点出现在 2048,第三个拐点出现在 8192,对应 L1 ICache 容量。

用 32B 间距测试:

第一个拐点在 1024,第二个拐点出现在 4096,对应 L1 ICache 容量,没有观察到 2 CPI。

用 64B 间距测试:

第一个拐点在 512,第二个拐点出现在 2048,对应 L1 ICache 容量。

Icestorm 的 BTB 测试结果并不像 Firestorm 那样有规律,根据这个现象,给出一些猜测:

  1. 可能只有一级 BTB,但它的 Index 函数进行了一些 Hash 而非直接取 PC 某几位,使得随着分支的间距增大,CPI=1 的拐点并非单调递减;但这无法解释为何 16B 间距时会出现 2 CPI 的平台
  2. 可能有两级 BTB,它们并非简单地级联,而是通过不同的组织方式,在不同的区间内发挥作用

针对 4B 间距没有出现 CPI>3 的情况,给出一些猜测:

  1. 测试规模不够大,把分支数量继续增大,才能出现 CPI>3 的情况
  2. 指令预取器在工作,当 footprint 大于 128KB L1 ICache 时,能提前把指令取进来

L1 ITLB

Firestorm

构造一系列的 B 指令,使得 B 指令分布在不同的 page 上,使得 ITLB 成为瓶颈,在 Firestorm 上进行测试:

从 1 Cycle 到 3 Cycle 的增加是由于 L1 BTB 的冲突缺失,之后在 192 个页时从 3 Cycle 快速增加到 13 Cycle,则对应了 192 项的 L1 ITLB 容量。

Icestorm

在 Icestorm 上重复实验:

只有一个拐点,在 128 个页时,性能从 1 Cycle 下降到 8 Cycle,意味 L1 ITLB 容量是 128 项。

Decode

从前面的测试来看,Firestorm 最大观察到 8 IPC,Icestorm 最大观察到 4 IPC,那么 Decode 宽度也至少是这么多,暂时也不能排除有更大的 Decode 宽度。

Return Stack

Firestorm

构造不同深度的调用链,测试每次调用花费的平均时间,在 Firestorm 上得到下面的图:

可以看到调用链深度为 50 时性能突然变差,因此 Firestorm 的 Return Stack 深度为 50。

Icestorm

在 Icestorm 上测试:

可以看到调用链深度为 32 时性能突然变差,因此 Icestorm 的 Return Stack 深度为 32。

Conditional Branch Predictor

参考 Dissecting Conditional Branch Predictors of Apple Firestorm and Qualcomm Oryon for Software Optimization and Architectural Analysis 论文的方法,可以测出 Firestorm 的分支预测器采用的历史更新方式为:

  1. 使用 100 位的 Path History Register for Target(PHRT) 以及 28 位的 Path History Register for Branch(PHRB),每次执行 taken branch 时更新
  2. 更新方式为:PHRTnew = (PHRTold << 1) xor T[31:2], PHRBnew = (PHRBold << 1) xor B[5:2],其中 B 代表分支指令的地址,T 代表分支跳转的目的地址

Icestorm 的分支预测器采用的历史更新方式为:

  1. 使用 60 位的 Path History Register for Target(PHRT) 以及 16 位的 Path History Register for Branch(PHRB),每次执行 taken branch 时更新
  2. 更新方式为:PHRTnew = (PHRTold << 1) xor T[47:2], PHRBnew = (PHRBold << 1) xor B[5:2],其中 B 代表分支指令的地址,T 代表分支跳转的目的地址

各厂商处理器的 PHR 更新规则见 jiegec/cpu

后端

物理寄存器堆

Firestorm

为了测试物理寄存器堆的大小,一般会用两个依赖链很长的操作放在开头和结尾,中间填入若干个无关的指令,并且用这些指令来耗费物理寄存器堆。Firestorm 测试结果见下图:

  • 32b/64b int:测试 speculative 32/64 位整数寄存器的数量,拐点在 362
  • 32b fp:测试 speculative 32 位浮点寄存器的数量,拐点在 382
  • flags:测试 speculative NZCV 寄存器的数量,拐点在 123

Icestorm

Icestorm 测试结果如下:

  • 32b/64b int:测试 speculative 32/64 位整数寄存器的数量,拐点在 78
  • 32b fp:测试 speculative 32 位浮点寄存器的数量,拐点在 382
  • flags:测试 speculative NZCV 寄存器的数量,拐点在 75

注意这里测试的都是能够用于预测执行的寄存器数量,实际的物理寄存器堆还需要保存架构寄存器。但具体保存多少个架构寄存器不确定,但至少 32 个整数通用寄存器和浮点寄存器是一定有的,但可能还有一些额外的需要重命名的状态也要算进来。

Load Store Unit + L1 DCache

L1 DCache 容量

官方信息:通过 sysctl 可以看到,Firestorm 具有 128KB L1 DCache,Icestorm 具有 64KB L1 DCache:

hw.perflevel0.l1dcachesize: 131072hw.perflevel1.l1dcachesize: 65536

构造不同大小 footprint 的 pointer chasing 链,测试不同 footprint 下每条 load 指令耗费的时间,Firestorm 上的结果:

可以看到 128KB 出现了明显的拐点,对应的就是 128KB 的 L1 DCache 容量。L1 DCache 范围内延迟是 3 cycle,之后提升到 16 cycle。

Icestorm 上的结果:

可以看到 64KB 出现了明显的拐点,对应的就是 64KB 的 L1 DCache 容量。L1 DCache 范围内延迟是 3 cycle,之后提升到 14 cycle。

L1 DTLB 容量

Firestorm

用类似的方法测试 L1 DTLB 容量,只不过这次 pointer chasing 链的指针分布在不同的 page 上,使得 DTLB 成为瓶颈,在 Firestorm 上:

从 160 个页开始性能下降,到 250 个页时性能稳定在 9 CPI,认为 Firestorm 的 L1 DTLB 有 160 项。9 CPI 包括了 L1 DTLB miss L2 TLB hit 带来的额外延迟。

如果每两个页放一个指针,则拐点前移到 80;每四个页放一个指针,拐点变成 40;每八个页放一个指针,拐点变成 20;每 16 个页一个指针,拐点是 10;每 32 个页一个指针,拐点变成 5;每 64 个页一个指针,拐点依然是 5。说明 Firestorm 的 L1 DTLB 是 5 路组相连,32 个 Set,Index 是 VA[18:14],注意页表大小是 16KB。

Icestorm

Icestorm:

从 128 个页开始性能下降,到 160 个页时性能稳定在 10 CPI,认为 Icestorm 的 L1 DTLB 有 128 项。10 CPI 包括了 L1 DTLB miss L2 TLB hit 带来的额外延迟。

如果每两个页放一个指针,则拐点前移到 64;每四个页放一个指针,拐点变成 32;每八个页放一个指针,拐点变成 16;每 16 个页一个指针,拐点是 8;每 32 个页一个指针,拐点变成 4;每 64 个页一个指针,拐点依然是 4。说明 Icestorm 的 L1 DTLB 是 4 路组相连,32 个 Set,Index 是 VA[18:14]。

Load/Store 带宽

Firestorm

针对 Load Store 带宽,实测 Firestorm 每个周期可以完成:

  • 3x 128b Load + 1x 128b Store
  • 2x 128b Load + 2x 128b Store
  • 1x 128b Load + 2x 128b Store
  • 2x 128b Store

如果把每条指令的访存位宽从 128b 改成 256b,读写带宽不变,指令吞吐减半。也就是说最大的读带宽是 48B/cyc,最大的写带宽是 32B/cyc,二者不能同时达到。

Icestorm

实测 Icestorm 每个周期可以完成:

  • 2x 128b Load
  • 1x 128b Load + 1x 128b Store
  • 1x 128b Store

如果把每条指令的访存位宽从 128b 改成 256b,读写带宽不变,指令吞吐减半。也就是说最大的读带宽是 32B/cyc,最大的写带宽是 16B/cyc,二者不能同时达到。

Memory Dependency Predictor

为了预测执行 Load,需要保证 Load 和之前的 Store 访问的内存没有 Overlap,那么就需要有一个预测器来预测 Load 和 Store 之前在内存上的依赖。参考 Store-to-Load Forwarding and Memory Disambiguation in x86 Processors 的方法,构造两个指令模式,分别在地址和数据上有依赖:

  • 数据依赖,地址无依赖:str x3, [x1]ldr x3, [x2]
  • 地址依赖,数据无依赖:str x2, [x1]ldr x1, [x2]

初始化时,x1x2 指向同一个地址,重复如上的指令模式,观察到多少条 ldr 指令时会出现性能下降,在 Firestorm 上测试:

数据依赖没有明显的阈值,但从 84 开始出现了一个小的增长,且斜率不为零;地址依赖的阈值是 70。

Icestorm:

数据依赖和地址依赖的阈值都是 13。

Store to Load Forwarding

Firestorm

经过实际测试,Firestorm 上如下的情况可以成功转发,对地址 x 的 Store 转发到对地址 y 的 Load 成功时 y-x 的取值范围:

Store\Load8b Load16b Load32b Load64b Load
8b Store{0}[-1,0][-3,0][-7,0]
16b Store[0,1][-1,1][-3,1][-7,1]
32b Store[0,3][-1,3][-3,3][-7,3]
64b Store[0,7][-1,7][-3,7][-7,7]

从上表可以看到,所有 Store 和 Load Overlap 的情况,无论地址偏移,都能成功转发。甚至在 Load 或 Store 跨越 64B 缓存行边界时,也可以成功转发,代价是多一个周期。

一个 Load 需要转发两个、四个甚至八个 Store 的数据时,如果数据跨越缓存行,则不能转发,但其他情况下,无论地址偏移,都可以转发,只是比从单个 Store 转发需要多耗费 1-4 个周期。

成功转发时 7.5 cycle,跨缓存行且转发失败时 28+ cycle。

小结:Apple Firestorm 的 Store to Load Forwarding:

  • 1 ld + 1 st: 支持
  • 1 ld + 2 st: 支持,要求不跨越 64B 边界
  • 1 ld + 4 st: 支持,要求不跨越 64B 边界
  • 1 ld + 8 st: 支持,要求不跨越 64B 边界
Icestorm

在 Icestorm 上,如果 Load 和 Store 访问范围出现重叠,则需要 10 Cycle,无论是和几个 Store 重叠,也无论是否跨缓存行。

Load to use latency

Firestorm

实测 Firestorm 的 Load to use latency 针对 pointer chasing 场景做了优化,在下列的场景下可以达到 3 cycle:

  • ldr x0, [x0]: load 结果转发到基地址,无偏移
  • ldr x0, [x0, 8]:load 结果转发到基地址,有立即数偏移
  • ldr x0, [x0, x1]:load 结果转发到基地址,有寄存器偏移
  • ldp x0, x1, [x0]:load pair 的第一个目的寄存器转发到基地址,无偏移

如果访存跨越了 8B 边界,则退化到 4 cycle。

在下列场景下 Load to use latency 则是 4 cycle:

  • load 的目的寄存器作为 alu 的源寄存器(下称 load to alu latency)
  • ldr x0, [sp, x0, lsl #3]:load 结果转发到 index
  • ldp x1, x0, [x0]:load pair 的第二个目的寄存器转发到基地址,无偏移
Icestorm

实测 Icestorm 的 Load to use latency 针对 pointer chasing 场景做了优化,在下列的场景下可以达到 3 cycle:

  • ldr x0, [x0]: load 结果转发到基地址,无偏移
  • ldr x0, [x0, 8]:load 结果转发到基地址,有立即数偏移
  • ldr x0, [x0, x1]:load 结果转发到基地址,有寄存器偏移
  • ldp x0, x1, [x0]:load pair 的第一个目的寄存器转发到基地址,无偏移

如果访存跨越了 8B/16B/32B 边界,依然是 3 cycle;跨越了 64B 边界则退化到 7 cycle。

在下列场景下 Load to use latency 则是 4 cycle:

  • load 的目的寄存器作为 alu 的源寄存器(下称 load to alu latency)
  • ldr x0, [sp, x0, lsl #3]:load 结果转发到 index
  • ldp x1, x0, [x0]:load pair 的第二个目的寄存器转发到基地址,无偏移

Virtual Address UTag/Way-Predictor

Linear Address UTag/Way-Predictor 是 AMD 的叫法,但使用相同的测试方法,也可以在 Apple M1 上观察到类似的现象,猜想它也用了类似的基于虚拟地址的 UTag/Way Predictor 方案,并测出来它的 UTag 也有 8 bit,Firestorm 和 Icestorm 都是相同的:

  • VA[14] xor VA[22] xor VA[30] xor VA[38] xor VA[46]
  • VA[15] xor VA[23] xor VA[31] xor VA[39] xor VA[47]
  • VA[16] xor VA[24] xor VA[32] xor VA[40]
  • VA[17] xor VA[25] xor VA[33] xor VA[41]
  • VA[18] xor VA[26] xor VA[34] xor VA[42]
  • VA[19] xor VA[27] xor VA[35] xor VA[43]
  • VA[20] xor VA[28] xor VA[36] xor VA[44]
  • VA[21] xor VA[29] xor VA[37] xor VA[45]

一共有 8 bit,由 VA[47:14] 折叠而来。

执行单元

想要测试有多少个执行单元,每个执行单元可以运行哪些指令,首先要测试各类指令在无依赖情况下的的 IPC,通过 IPC 来推断有多少个能够执行这类指令的执行单元;但由于一个执行单元可能可以执行多类指令,于是进一步需要观察在混合不同类的指令时的 IPC,从而推断出完整的结果。

Firestorm

在 Firestorm 上测试如下各类指令的延迟和每周期吞吐:

指令延迟吞吐
asimd int add24
asimd aesd/aese34
asimd aesimc/aesmc24
asimd fabs24
asimd fadd34
asimd fdiv 64b101
asimd fdiv 32b81
asimd fmax24
asimd fmin24
asimd fmla44
asimd fmul44
asimd fneg24
asimd frecpe31
asimd frsqrte31
asimd fsqrt 64b130.5
asimd fsqrt 32b100.5
fp cvtf2i (fcvtzs)-2
fp cvti2f (scvtf)-3
fp fabs24
fp fadd34
fp fdiv 64b101
fp fdiv 32b81
fp fjcvtzs-1
fp fmax24
fp fmin24
fp fmov f2i-2
fp fmov i2f-3
fp fmul44
fp fneg24
fp frecpe31
fp frecpx31
fp frsqrte31
fp fsqrt 64b130.5
fp fsqrt 32b100.5
int add14.6
int addi16
int bfm11
int crc31
int csel13
int madd (addend)11
int madd (others)31
int mrs nzcv-2
int mul32
int nop-8
int sbfm14.7
int sdiv70.5
int smull32
int ubfm14.7
int udiv70.5
not taken branch-2
taken branch-1
mem asimd load-3
mem asimd store-2
mem int load-3
mem int store-2

从上面的结果可以初步得到的信息:

  1. 标量浮点和 ASIMD 吞吐最大都是 4,意味着有 4 个浮点/ASIMD 执行单元,但并非完全对称,例如 fdiv/frecpe/frecpx/frsqrte/fsqrt/fjcvtzs 由于吞吐不超过 1,大概率只能在一个执行单元内执行。但这些指令是不是都只能在同一个执行单元内执行,还需要进一步的测试
  2. 浮点和整数之间的 move 或 convert 指令,fmov i2f/cvti2f 吞吐是 3,fmov f2i/cvtf2i 吞吐是 2,那么这些指令是在哪个执行单元里实现的,是否需要同时占用整数执行单元和浮点执行单元,需要进一步测试
  3. 整数方面,根据吞吐,推断出如下几类指令对应的执行单元数量:
    1. ALU: 6
    2. CSEL: 3
    3. Mul/Br/MRS NZCV: 2
    4. CRC/BFM/MAdd/Div: 1
  4. 虽然 Br 的吞吐可以达到 2,但是每周期只能有一个 taken branch;目前一些架构可以做到每周期超过一个 taken branch,此时 Br 的吞吐一般会给到 3
  5. 访存方面,每周期最多 3 Load 或者 2 Store

首先来看浮点和 ASIMD 单元,根据上面的信息,认为至少有 4 个执行单元,每个执行单元都可以做这些操作:asimd int add/aes/fabs/fadd/fmax/fmin/fmla/fmul/fneg,下面把这些指令称为 basic fp/asimd ops + aes。接下来要判断,fmov f2i/fmov i2f/fdiv/frecpe/frecpx/frsqrte/fsqrt 由哪些执行单元负责执行,方法是把这些指令混合起来测试吞吐(此处的吞吐不代表 CPI,而是每周能够执行多少次指令组合,例如用 2 条指令的组合测试,那么吞吐等于 CPI 除以 2):

指令吞吐
fp fdiv + fp frecpe0.5
fp fdiv + fp frecpx0.5
fp fdiv + fp frsqrte0.5
fp fdiv + fp fsqrt0.33=1/3
fp fdiv + fmov f2i1
fp fdiv + 2x fmov f2i0.67=1/1.50
fp fdiv + 3x fmov i2f1
fp fdiv + 4x fmov i2f0.75=1/1.33
fmov i2f + 4x fp fadd1
fmov f2i + 4x fp fadd0.67=1/1.50

根据以上测试结果,可以得到如下的推论:

  1. fp fdiv/frecpe/frecpx/frsqrte 混合的时候,吞吐只有一半,IPC 不变,说明这些指令在同一个执行单元中,混合并不能带来更高的 IPC
  2. fp fdiv 和 fp fsqrt 混合时,吞吐下降到 0.33 一个不太整的数字,猜测是因为它们属于同一个执行单元内的不同流水线,抢占寄存器堆写口
  3. fp fdiv + fmov f2i 的时候吞吐是 1,而 fdiv + 2x fmov f2i 时吞吐下降到 0.67,IPC 维持在 2,说明有两个执行单元,都可以执行 fmov f2i,但只有其中一个可以执行 fp fdiv,导致 fdiv + 2x fmov f2i 的时候会抢执行单元
  4. fp fdiv + 3x fmov i2f 的时候吞吐是 1,而 fdiv + 4x fmov i2f 时吞吐下降到 0.75,此时每周期还是执行 3 条 fmov i2f 指令,意味着 fdiv 没有抢占 fmov i2f 的执行单元,它们用的执行单元是独立的
  5. fmov i2f + 4x fp fadd 的时候吞吐是 1,说明 fmov i2f 没有抢占 fp fadd 的执行单元

推断这四个执行单元支持的操作:

  1. basic fp/asimd ops + aes + fdiv + frecpe + frecpx + frsqrte + fsqrt + fmov f2i + cvtf2i
  2. basic fp/asimd ops + aes + fmov f2i + cvtf2i
  3. basic fp/asimd ops + aes
  4. basic fp/asimd ops + aes

当然还有很多指令没有测,不过原理是一样的。

访存部分,前面已经在测 LSU 的时候测过了,每周期 Load + Store 不超过 4 个,其中 Load 不超过 3 个,Store 不超过 2 个。虽然从 IPC 的角度来看 LSU 的 Load/Store Pipe 未必准确,比如可能它发射和提交的带宽是不同的,但先暂时简化为如下的执行单元:

  1. load + store
  2. load
  3. load
  4. store

最后是整数部分。从 addi 的指令来看,有 6 个 ALU,能够执行基本的整数指令(add/ubfm/sbfm 的吞吐有时候测出来 4.6-4.7,有时候测出来 6,怀疑是进入了什么低功耗模式)。但其他很多指令可能只有一部分执行单元可以执行:bfm/crc/csel/madd/mrs nzcv/mul/div/branch/fmov i2f。为了测试这些指令使用的执行单元是否重合,进行一系列的混合指令测试,吞吐的定义和上面相同:

指令吞吐
3x int csel + 3x fmov i2f1
3x int csel + 2x fmov f2i0.75=1/1.33
3x int csel + int bfm1
3x int csel + int crc1
3x int csel + int madd1
3x int csel + int mul1
3x int csel + int sdiv0.5
3x int csel + mrs nzcv0.75=1/1.33
3x int csel + not taken branch0.75=1/1.33
mrs nzcv + not taken branch1
mrs nzcv + 2x not taken branch0.67=1/1.50
2x fmov f2i + 2x not taken branch1
2x fmov f2i + 2x int mul1
int madd + 2x int mul0.67=1/1.50
int madd + int sdiv0.5
int madd + int crc0.5

根据上述结果分析:

  1. 吞吐与不混合时相同,代表混合的指令对应的执行单元不重合
  2. 3x int csel + 2x fmov f2i 的 IPC 等于 4,意味着有四个执行单元,其中有三个可以执行 int csel,两个可以执行 fmov f2i,也就意味着其中有一个执行单元可以执行 int csel 和 fmov f2i,即有这样的四个执行单元:
    1. alu + csel
    2. alu + csel
    3. alu + csel + fmov f2i
    4. alu + fmov f2i
  3. 3x int csel + mrs nzcv/not taken branch 的 IPC 等于 3,说明它们的执行单元是重合的;又因为 2x fmov f2i + 2x not taken branch 的吞吐是 1,说明它们的执行单元不重合,那么上述四个执行单元只能是:
    1. alu + csel + branch
    2. alu + csel + branch
    3. alu + csel + fmov f2i
    4. alu + fmov f2i
  4. mrs nzcv + 2x not taken branch 的 IPC 等于 2,说明它们的执行单元是重合的,那么上述四个执行单元是:
    1. alu + csel + branch + mrs nzcv
    2. alu + csel + branch + mrs nzcv
    3. alu + csel + fmov f2i
    4. alu + fmov f2i
  5. csel 和 mul 不重合,f2i 和 mul 也不重合,说明 mul 在剩下的两个执行单元内:
    1. alu + csel + branch + mrs nzcv
    2. alu + csel + branch + mrs nzcv
    3. alu + csel + fmov f2i
    4. alu + fmov f2i
    5. alu + mul
    6. alu + mul
  6. madd 和 mul 重合,madd 和 crc 重合,那么:
    1. alu + csel + branch + mrs nzcv
    2. alu + csel + branch + mrs nzcv
    3. alu + csel + fmov f2i
    4. alu + fmov f2i
    5. alu + mul + madd + crc
    6. alu + mul

得到初步的结果:

  1. alu + csel + branch + mrs nzcv
  2. alu + csel + branch + mrs nzcv
  3. alu + csel + fmov f2i
  4. alu + fmov f2i
  5. alu + mul + madd + crc
  6. alu + mul

还有很多其他的指令没有测试,不过方法是类似的。从上面的结果里,可以看到一些值得一提的点:

  1. fmov f2i 同时占用了浮点执行单元和整数执行单元,这主要是为了复用寄存器堆读写口:fmov f2i 需要读浮点寄存器堆,又需要写整数寄存器堆,那就在浮点侧读寄存器,在整数侧写寄存器。
  2. fmov i2f 既不在浮点,也不在整数,那只能在访存了:而正好访存执行单元需要读整数,写整数或浮点,那就可以复用它的寄存器堆写口来实现 fmov i2f 的功能。
  3. 可见整数/浮点/访存执行单元并不是完全隔离的,例如一些微架构,整数和浮点是直接放在一起的。

小结:Firestorm 的执行单元如下:

  1. alu + csel + branch + mrs nzcv
  2. alu + csel + branch + mrs nzcv
  3. alu + csel + fmov f2i
  4. alu + fmov f2i
  5. alu + mul + madd + crc
  6. alu + mul
  7. load + store
  8. load
  9. load
  10. store
  11. basic fp/asimd ops + aes + fdiv + frecpe + frecpx + frsqrte + fsqrt + fmov f2i + cvtf2i
  12. basic fp/asimd ops + aes + fmov f2i + cvtf2i
  13. basic fp/asimd ops + aes
  14. basic fp/asimd ops + aes

Icestorm

接下来用类似的方法测试 Icestorm:

指令延迟吞吐
asimd int add22
asimd aesd/aese32
asimd aesimc/aesmc22
asimd fabs22
asimd fadd32
asimd fdiv 64b110.5
asimd fdiv 32b90.5
asimd fmax22
asimd fmin22
asimd fmla42
asimd fmul42
asimd fneg22
asimd frecpe40.5
asimd frsqrte40.5
asimd fsqrt 64b150.5
asimd fsqrt 32b120.5
fp cvtf2i (fcvtzs)-1
fp cvti2f (scvtf)-2
fp fabs22
fp fadd32
fp fdiv 64b101
fp fdiv 32b81
fp fjcvtzs-0.5
fp fmax22
fp fmin22
fp fmov f2i-1
fp fmov i2f-2
fp fmul42
fp fneg22
fp frecpe31
fp frecpx31
fp frsqrte31
fp fsqrt 64b130.5
fp fsqrt 32b100.5
int add13
int addi13
int bfm11
int crc31
int csel13
int madd (addend)11
int madd (others)31
int mrs nzcv-3
int mul31
int nop-4
int sbfm13
int sdiv70.125=1/8
int smull31
int ubfm13
int udiv70.125=1/8
not taken branch-2
taken branch-1
mem asimd load-2
mem asimd store-1
mem int load-2
mem int store-1

从上面的结果可以初步得到的信息:

  1. 标量浮点和 ASIMD 吞吐最大都是 2,意味着有 2 个浮点/ASIMD 执行单元,但并非完全对称,例如 fdiv/frecpe/frecpx/frsqrte/fsqrt/fjcvtzs 由于吞吐不超过 1,大概率只能在一个执行单元内执行。但这些指令是不是都只能在同一个执行单元内执行,还需要进一步的测试
  2. 整数方面,根据吞吐,推断出如下几类指令对应的执行单元数量:
    1. ALU/CSEL/MRS NZCV/SBFM/UBFM: 3
    2. Br: 2
    3. Mul/CRC/BFM/MAdd/Div: 1
  3. 虽然 Br 的吞吐可以达到 2,但是每周期只能有一个 taken branch
  4. 访存方面,每周期最多 2 Load 或者 1 Store

还是先看浮点,基本指令 add/aes/fabs/fadd/fmax/fmin/fmla/fmul/fneg 都能做到 2 的吞吐,也就是这两个执行单元都能执行这些基本指令。接下来测其余指令的混合吞吐(吞吐定义见上):

指令吞吐
fp fdiv + fp frecpe0.5
fp fdiv + fp frecpx0.5
fp fdiv + fp frsqrte0.5
fp fdiv + fp fsqrt0.31=1/3.25
fp fdiv + fmov f2i0.5
fp fdiv + 2x fmov i2f1
fp fdiv + 3x fmov i2f0.67=1/1.50

可见 fdiv/frecpe/frecpx/frsqrte/fsqrt/fmov f2i 都在同一个执行单元内:

  1. basic fp/asimd ops + aes + fdiv + frecpe + frecpx + frsqrte + fsqrt + fmov f2i
  2. basic fp/asimd ops + aes

还有很多指令没有测,不过原理是一样的。访存在前面测 LSU 的时候已经测过了:

  1. load + store
  2. load

最后是整数部分。从 addi 的指令来看,有 3 个 ALU,能够执行基本的整数指令。但其他很多指令可能只有一部分执行单元可以执行:bfm/crc/csel/madd/mul/div/branch。为了测试这些指令使用的执行单元是否重合,进行一系列的混合指令测试,吞吐的定义和上面相同:

指令吞吐
int madd + int mul0.5
int madd + int crc0.5
int madd + 2x not taken branch1

由此可见,madd/mul/crc 是一个执行单元,和 branch 的两个执行单元不重合,因此整数侧的执行单元有:

  1. alu + csel + mrs nzcv + branch
  2. alu + csel + mrs nzcv + branch
  3. alu + csel + mrs nzcv + madd + mul + crc

小结:Icestorm 的执行单元如下:

  1. alu + csel + mrs nzcv + branch
  2. alu + csel + mrs nzcv + branch
  3. alu + csel + mrs nzcv + madd + mul + crc
  4. load + store
  5. load
  6. basic fp/asimd ops + aes + fdiv + frecpe + frecpx + frsqrte + fsqrt + fmov f2i
  7. basic fp/asimd ops + aes

Scheduler

为了测试 Scheduler 的大小和组织方式(分布式还是集中式),测试方法是:首先用长延迟的操作堵住 ROB,接着用若干条依赖长延迟操作的指令堵住 Scheduler,当指令塞不进去的时候,就说明 Scheduler 满了。更进一步,由于现在很多处理器会引入 Non Scheduling Queue,里面的指令不会直接调度进执行单元,也不检查它依赖的操作数是否已经准备好,此时为了区分可调度部分和不可调度部分,在依赖长延迟操作的指令后面,添加若干条不依赖长延迟操作的指令,这样测出来的就是可调度部分的深度。

Firestorm

在 Firestorm 上测试,结果如下:

指令可调度 + 不可调度可调度
ld5848
st5843
alu158134
fp156144
crc4028
idiv4028
bfm4028
fjcvtzs4236
fmov f2i8472
csel7664
mrs nzcv6250

首先看浮点:

  1. 可调度部分 fp 是 144,fmov f2i 是 72,fjcvtzs 是 36,有明显的 4:2:1 的关系
  2. fp/fmov f2i/fjcvtzs 吞吐刚好也是 4:2:1 的关系
  3. 因此四个执行单元前面各有一个独立的 36 entry 的 Scheduler
  4. 不可调度部分,156-144=12,84-72=12,42-36=6,猜测有两个 Non Scheduling Queue,每个 Non Scheduling Queue 6 entry,分别对应两个 Scheduler

下面是访存部分,load 和 store 总数一样但 Scheduler 差了 5,不确定是测试误差还是什么问题,暂且考虑为一个统一的 Scheduler 和同一个 Non Scheduling Queue。

最后是整数部分,由于有 6 个整数执行单元,情况会比较复杂:

  1. 可调度部分 alu 一共是 134,其中 csel 是 64,crc/idiv/bfm 都是 28,mrs nzcv 是 50,结合六个整数执行单元,可以得到这六个执行单元对应的 Scheduler 大小关系:
    1. alu + csel + branch + mrs nzcv: x entries
    2. alu + csel + branch + mrs nzcv: 50-x entries
    3. alu + csel + fmov f2i: 14 entries
    4. alu + fmov f2i: y entries
    5. alu + mul + madd + crc: 28 entries
    6. alu + mul: 42-y entries
  2. alu 不可调度部分是 158-134=24,crc/idiv/bfm/csel/mrs nzcv 不可调度部分都是 12,考虑到 csel 对应前三个执行单元,并且和 mrs nzcv 一样多,说明前三个执行单元共享一个 12 entry 的 Non Scheduling Queue;剩下三个执行单元共享剩下的 12 entry 的 Non Scheduling Queue
  3. 最后只差 x 和 y 的取值没有求出来,可以通过进一步测试来更加精确地求出

Icestorm

在 Icestorm 上测试,结果如下:

指令可调度 + 不可调度可调度
ld2410
st244
alu3628
crc168
idiv168
bfm168
csel3224
mrs nzcv3224

访存部分,load 和 store 总数一样但 Scheduler 差了 6,不确定是测试误差还是什么问题,暂且考虑为一个统一的 Scheduler 和同一个 Non Scheduling Queue。

整数部分,由于有 3 个整数执行单元,情况会比较复杂:

  1. 可调度部分 alu 一共是 28(多出来的 4 个不确定是什么原因),其中 csel/mrs nzcv 是 24,crc/idiv/bfm 都是 8,结合 3 个整数执行单元,可以得到这 3 个执行单元对应的 Scheduler 大小关系:
    1. alu + csel + mrs nzcv + branch: x entries
    2. alu + csel + mrs nzcv + branch: 16-x entries
    3. alu + csel + mrs nzcv + madd + mul + crc: 8 entries
  2. alu 不可调度部分是 36-28=8,crc/idiv/bfm/csel/mrs nzcv 不可调度部分都是 8,应该是 3 个整数执行单元共享一个 8 entry 的 Non Scheduling Queue

Reorder Buffer

Firestorm

Firestorm 的 ROB 采用的是比较特别的方案,它的 entry 地位不是等同的,而是若干个 entry 组合在一起,成为一个 group,每个 group 有若干个 entry,一个 group 对 group 内指令的类型和数量有要求,这就导致用传统方法测 Firestorm 的 ROB,可能会测到特别巨大的数:2200+,也可能测到比较小的数:320+,这就和指令类型有关系。为什么对指令的类型和位置有要求呢,这是为了方便处理有副作用的指令。很多指令是没有副作用的,也不会抛异常,这些指令可以比较随意地放置;但是对于有副作用的指令,retire 时是需要特殊处理的,因此一个合理的设计就是,让这些指令只能放在 group 的开头。

经过测试,发现 Firestorm 上 pointer chasing 的延迟波动比较大,目测是 prefetcher 做了针对性的优化,因此用 fsqrt chain 来做延迟,Firestorm 上一条双精度 fsqrt 的延迟是 13 个周期。构造一个循环,循环包括 M 个串行的 sqrt 和 N 个 nop,如果没有触碰到 ROB 的瓶颈,那么当 N 比较小的时候,瓶颈在串行 fsqrt 上,每次循环的周期数应该为 M*13;当 N 比较大的时候,瓶颈在每个周期执行 8 个 NOP 上(NOP 被 eliminate 了,不用进 ALU,可以打满 8 的发射宽度),每次循环的周期数应该为 N/8 再加上一个常数。

但当 N 很大的时候,可能会撞上 ROB 大小的限制。下面给出了不同的 M 取值情况下,可以保证循环周期数在 M*13 左右的最大的 N 取值:

  • M=21, N <= 2109, cycle=273.74, M*13=273
  • M=22, N <= 2205, cycle=286.75, M*13=286
  • M=23, N <= 2269, cycle=299.76, M*13=299
  • M=24, N <= 2277, cycle=312.77, M*13=312
  • M=25, N <= 2275, cycle=325.77, M*13=325
  • M=26, N <= 2274, cycle=338.77, M*13=338

可以看到 N 最大在 2277 附近就不能再增加了,说明遇到了 ROB 的瓶颈,预计 ROB 的所有 group 的 entry 个数加起来大概是 2277 左右。

而如果把填充的指令改成 load/store,inflight 的 load 和 store 最多都是 325 个,并且这和 load/store queue 大小无关,而 load/store 又是有副作用的,很可能是因为它们只能在 ROB 每个 group 里只能放一条,于是看起来 ROB 的容量比 2277 小了很多,只表现出 325。按照这个猜想,对二者进行除法,发现商和 7 十分接近,这大概率意味着 Firestorm 的 ROB 有 325 左右个 group,每个 group 内有 7 个 entry,每个 entry 可以放一条指令(uop)。测试里开头的 20 个 sqrt 也要占用 ROB,实际的 ROB group 数量可能比 325 略多。

结论:Firestorm 的 ROB 有大约 330 个 group,每个 group 最多保存 7 个 uop。

Icestorm

首先用 NOP 指令测试 Icestorm 的 ROB 大小:

可以看到拐点是 413 条指令。改成用 load/store 指令,可以测到拐点在 57 左右,根据上面的分析,认为 group 数量在 57 左右,每个 group 最多保存 7 个 uop。由于 413 / 7 = 59,认为有 59 个 group。

结论:Icestorm 的 ROB 有 59 个 group,每个 group 最多保存 7 个 uop。

L2 Cache

官方信息:通过 sysctl 可以看到,4 个 Firestorm 核心共享一个 12MB L2 Cache,4 个 Icestorm 核心共享一个 4MB L2 Cache:

hw.perflevel0.l2cachesize: 12582912hw.perflevel0.cpusperl2: 4hw.perflevel1.l2cachesize: 4194304hw.perflevel1.cpusperl2: 4

L2 TLB

Firestorm

从苹果提供的性能计数器来看,L1 TLB 分为指令和数据,而 L2 TLB 是 Unified,不区分指令和数据。沿用之前测试 L1 DTLB 的方法,把规模扩大到 L2 Unified TLB 的范围,就可以测出来 L2 Unified TLB 的容量,下面是 Firestorm 上的测试结果:

可以看到拐点是 3072 个 Page,说明 Firestorm 的 L2 TLB 容量是 3072 项。

把指针的跨度增大:

  • 如果每 32 个页一个指针,L2 TLB 拐点前移到 96,L2 TLB 缺失时 CPI 为 36.5
  • 如果每 64 个页一个指针,L2 TLB 拐点前移到 48,L2 TLB 缺失时 CPI 为 36.5
  • 如果每 128 个页一个指针,L2 TLB 拐点前移到 24,L2 TLB 缺失时 CPI 为 36.5
  • 如果每 256 个页一个指针,L2 TLB 拐点前移到 12,L2 TLB 缺失时 CPI 为 35
  • 如果每 512 个页一个指针,L2 TLB 拐点依然在 12,L2 TLB 缺失时 CPI 为 35
  • 观察到命中 L1 DTLB 时 CPI 是 3,命中 L2 TLB 时 CPI 是 9,L2 TLB 缺失时 CPI 是 35-36.5,此时缓存缺失率为 0

认为 Firestorm 的 L2 TLB 是 12 Way,256 Set,Index 位是 VA[21:14]。

Icestorm

在 Icestorm 上测试:

可以看到拐点是 1024 个 Page,说明 Icestorm 的 L2 TLB 容量是 1024 项。

把指针的跨度增大:

  • 如果每 32 个页一个指针,L2 TLB 拐点前移到 32,L2 TLB 缺失时 CPI 为 33
  • 如果每 64 个页一个指针,L2 TLB 拐点前移到 16,L2 TLB 缺失时 CPI 为 32
  • 如果每 128 个页一个指针,L2 TLB 拐点前移到 8,L2 TLB 缺失时 CPI 为 32
  • 如果每 256 个页一个指针,L2 TLB 拐点前移到 4 和 L1 DTLB 拐点重合,一旦 L1 DTLB 缺失,L2 TLB 也缺失,L2 TLB 缺失时 CPI 为 32
  • 观察到命中 L1 DTLB 时 CPI 是 3,命中 L2 TLB 时 CPI 是 10,L2 TLB 缺失时 CPI 是 32-33,此时缓存缺失率为 0

由于 Icestorm 的 L1 DTLB 就是 4 Way,不确定 Icestorm 的 L2 TLB 组相连是 1/2/4 Way 的哪一种,假如是 4 Way,那么 Icestorm 的 L2 TLB 是 4 Way,256 Set,Index 位是 VA[21:14]。

🔲 ☆

CSS animation-composition可以让动画效果累加

by zhangxinxu from https://www.zhangxinxu.com/wordpress/?p=11689
本文可全文转载,但需要保留原作者、出处以及文中链接,AI抓取保留原文地址,任何网站均可摘要聚合,商用请联系授权。

一、前言

CSS animation-composition属性其实出来有一段时间了,2年多,见下面的附图,之前有见人分享过,最近有人在上一篇文章评论区提过,才想起了他,决定写篇文章简单记录下。

animation-composition兼容性

这个属性可以设置CSS动画效果再执行的时候,相关的CSS属性值是替换、直接相加还是计算累积。

二、animation-composition语法和案例

其语法如下所示:

/* 三个动画值 */
animation-composition: replace;
animation-composition: add;
animation-composition: accumulate;

/* 多个动画 */
animation-composition: replace, add;
animation-composition: add, accumulate;
animation-composition: replace, add, accumulate;

这里出现了3个值replace, add, accumulate就是animation-composition支持的几个关键字值,其含义分别如下:

replace
这是默认值。动画也就是@keyframes{}声明的CSS属性会替换默认设置的CSS属性值。
add
属性值直接相加。
accumulate
属性值计算叠加。accumulateadd比较类似,很多同学会搞不清楚,下面会有案例示意。

add和accumulate的区别

  • add是纯粹的属性值相加。
  • accumulate则是累加计算后的值。

在大绝多数情况下,addaccumulate两个值的表现是没有区别的,只有当CSS属性值相连和叠加的渲染效果不一样的时候才会有区别。

所以:

1. 普通CSS属性两者无区别

<canvas id="ball1" class="ball"></canvas>
<canvas id="ball2" class="ball"></canvas>
<p>
  <button 
    onclick="ball1.classList.toggle('active');ball2.classList.toggle('active')"
  >运动</button>
</p>
.ball {
  width: 120px; height: 120px;
  background: deepskyblue;
  border-radius: 50%;
  position: relative;
  left: 100px;
}
#ball1.active {
  animation: 2s move infinite;
  animation-composition: add;
}
#ball2.active {
  animation: 2s move infinite;
  animation-composition: accumulate;
}
@keyframes move {
  from { left: 50px }
  to { left: 150px }
}

此时,两个球的运动轨迹是一模一样的(动画都是从150px位置~250px)。实际效果如下所示(点击“运动”按钮):


2. transform属性没区别

transform属性支持多值相加,但是值前后相连和直接合并计算的渲染效果是一样的,例如,还是上面的例子,我们将位移改为transform属性实现,会看到并无区别:

.ball {
  /* 相同代码略 */
  transform: translateX(100px);
}
#ball1.active {
  animation: 2s move;
  animation-composition: add;
}
#ball2.active {
  animation: 2s move;
  animation-composition: accumulate;
}

@keyframes move {
  from { transform: translateX(50px); }
  to { transform: translateX(150px); }
}


原因很简单,add关键字生效的时候,动画的计算表现其实是这样的:

from {
  transform: translateX(100px) translateX(50px);
}
to {
  transform: translateX(100px) translateX(150px);
}

accumulate关键字的计算表现则是(直接累加计算):

from {
  transform: translateX(150px);
}
to {
  transform: translateX(250px);
}

transform连续位移和一次性位移的效果是一样的,因此,大家看不出两者的区别。

但如果是filter属性的某些值,那就会有所区别。

3. filter属性的模糊函数的区别可见

我们先看静态效果,比方说有张图片,我们应用下面两段CSS代码的效果是不一样的:

img {
  filter: blur(2px) blur(2px);
}
img {
  filter: blur(4px);
}

前者还是2px模糊,后者是4px。

我们可以看一下下面的实际渲染效果(实时渲染):

张含韵
张含韵

所以,当我们的动画是模糊变化的时候,大家就可以看到addaccumulate两个值的区别了。

对此,我专门做了个演示页面,您可以狠狠地点击这里:add和accumulate渲染效果区别演示demo

鼠标悬停目标图形,就可以看到不同的模糊动画效果了。

例如,悬停右图(移动端是下图),明显可以感觉到要更加模糊一点:

更加模糊的图片示意

相关测试代码如下所示:

<img src="1.jpg" class="img1">
<img src="1.jpg" class="img2">
img:hover {
  filter: blur(2px);
  animation: 1s pulse both;
}
.img1 {
    animation-composition: add;
}
.img2 {
    animation-composition: accumulate;
}

@keyframes pulse {
  0% {
    filter: blur(0px);
  }
  100% {
    filter: blur(2px);
  }
}

三、实际应用场景和结语

animation-composition属性最具代表性的应用场景当属transform动画了。

例如一个水平居中的toast在出现的时候需要有个轻微的上移动画,此时就不要担心translateX()函数和translateY()函数冲突的问题了。

<div id="toast" class="toast">操作成功!</div>
.toast {
  position: fixed;
  left: 50%;
  transform: translateX(-50%);
  background: green;
  padding: 4px 8px;
  color: #fff;
  animation-composition: add;
  display: none;
}
.toast.active {
  display: block;
  animation: tinyUp 350ms both;
}
@keyframes tinyUp {
  0% { transform: translateY(20px); }
  100% { transform: translateY(0px); }
}

实时渲染效果如下,点击按钮体验:

操作成功!

——

好,以上就是本文的全部内容,相信大家对animation-composition属性的理解一定更加全面了,感谢阅读,欢迎分享。

🦺👔👕👖🧣

本文为原创文章,会经常更新知识点以及修正一些错误,因此转载请保留原出处,方便溯源,避免陈旧错误知识的误导,同时有更好的阅读体验。
本文地址:https://www.zhangxinxu.com/wordpress/?p=11689

(本篇完)

🔲 ☆

Apple M1 (Firestorm & Icestorm) 微架构评测

Apple M1 (Firestorm & Icestorm) 微架构评测

背景

虽然 Apple M1 已经是 2020 年的处理器,但它对苹果自研芯片来说是一个里程碑,考虑到 X Elite 处理器的 Oryon 微架构和 Apple M1 性能核 Firestorm 微架构的相似性,还是测试一下这个 Firestorm + Icestorm 微架构在各个方面的表现。Apple A14 采用了和 Apple M1 一样的微架构。

官方信息

Apple M1 的官方信息乏善可陈,关于微架构的信息几乎为零,但能从操作系统汇报的硬件信息中找到一些内容。

现有评测

网上已经有较多针对 Apple M1 微架构的评测和分析,建议阅读:

下面分各个模块分别记录官方提供的信息,以及实测的结果。读者可以对照已有的第三方评测理解。官方信息与实测结果一致的数据会加粗。

Benchmark

Apple Firestorm 的性能测试结果见 SPEC。Apple Icestorm 尚未进行性能测试。

环境准备

Apple M1 预装的是 macOS,macOS 的绑核只能绑到 P 或者 E,不能具体到某一个核上;在 macOS 上可以读取 PMU,需要使用 kpep 的私有框架,代码可以在这里找到。

如果想更方便地进行测试,建议安装 Asahi Linux 的各种发行版,此时可以在 Linux 下自由地绑核,也可以用标准的方式使用 PMU。

前端

取指

为了测试实际的 Fetch 宽度,参考 如何测量真正的取指带宽(I-fetch width) - JamesAslan 构造了测试。其原理是当 Fetch 要跨页的时候,由于两个相邻页可能映射到不同的物理地址,如果要支持单周期跨页取指,需要查询两次 ITLB,或者 ITLB 需要把相邻两个页的映射存在一起。这个场景一般比较少,处理器很少会针对这种特殊情况做优化,但也不是没有。经过测试,把循环放在两个页的边界上,发现 Firestorm 微架构遇到跨页的取指时确实会拆成两个周期来进行。在此基础上,构造一个循环,循环的第一条指令放在第一个页的最后四个字节,其余指令放第二个页上,那么每次循环的取指时间,就是一个周期(读取第一个页内的指令)加上第二个页内指令需要 Fetch 的周期数,多的这一个周期就足以把 Fetch 宽度从后端限制中区分开,实验结果如下:

图中蓝线(cross-page)表示的就是上面所述的第一条指令放一个页,其余指令放第二个页的情况,横坐标是第二个页内的指令数,那么一次循环的指令数等于横坐标 +1。纵坐标是运行很多次循环的总 cycle 数除以循环次数,也就是平均每次循环耗费的周期数。可以看到每 16 条指令会多一个周期,因此 Firestorm 的前端取指宽度确实是 16 条指令。为了确认这个瓶颈是由取指造成的,又构造了一组实验,把循环的所有指令都放到一个页中,这个时候 Fetch 不再成为瓶颈(图中 aligned),两个曲线的对比可以明确地得出上述结论。

用相同的方式测试 Icestorm,结果如下:

可以看到每 8 条指令会多一个周期,意味着 Icestorm 的前端取指宽度为 8 条指令。

L1 ICache

官方信息:通过 sysctl 可以看到,Firestorm 具有 192KB L1 ICache,Icestorm 具有 128KB L1 ICache:

hw.perflevel0.l1icachesize: 196608hw.perflevel1.l1icachesize: 131072

为了测试 L1 ICache 容量,构造一个具有巨大指令 footprint 的循环,由大量的 nop 和最后的分支指令组成。观察在不同 footprint 大小下 Firestorm 的 IPC:

可以看到 footprint 在 192 KB 之前时可以达到 8 IPC,之后则快速降到 2.22 IPC,这里的 192 KB 就对应了 Firestorm 的 L1 ICache 的容量。虽然 Fetch 可以每周期 16 条指令,也就是一条 64B 的缓存行,由于后端的限制,只能观察到 8 的 IPC。

用相同的方式测试 Icestorm,结果如下:

可以看到 footprint 在 128 KB 之前时可以达到 4 IPC,之后则快速降到 2.10 IPC,这里的 128 KB 就对应了 Icestorm 的 L1 ICache 的容量。虽然 Fetch 可以每周期 8 条指令,由于后端的限制,只能观察到 4 的 IPC。

BTB

Firestorm

构造大量的无条件分支指令(B 指令),BTB 需要记录这些指令的目的地址,那么如果分支数量超过了 BTB 的容量,性能会出现明显下降。当把大量 B 指令紧密放置,也就是每 4 字节一条 B 指令时:

可见在 1024 个分支之内可以达到 1 的 CPI,超过 1024 个分支,出现了 3 CPI 的平台,一直延续到 49152 个分支。超出 BTB 容量以后,分支预测时,无法从 BTB 中得到哪些指令是分支指令的信息,只能等到取指甚至译码后才能后知后觉地发现这是一条分支指令,这样就出现了性能损失,出现了 3 CPI 的情况。第二个拐点 49152,对应的是指令 footprint 超出 L1 ICache 的情况:L1 ICache 是 192KB,按照每 4 字节一个 B 指令计算,最多可以存放 49152 条 B 指令。

降低分支指令的密度,在 B 指令之间插入 NOP 指令,使得每 8 个字节有一条 B 指令,得到如下结果:

可以看到 CPI=1 的拐点前移到 1024 个分支,同时 CPI=3 的平台的拐点也前移到了 24576。拐点的前移,意味着 BTB 采用了组相连的结构,当 B 指令的 PC 的部分低位总是为 0 时,组相连的 Index 可能无法取到所有的 Set,导致表现出来的 BTB 容量只有部分 Set,例如此处容量减半,说明只有一半的 Set 被用到了。

如果进一步降低 B 指令的密度,使得它的低若干位都等于 0,最终 CPI=1 的拐点定格在 2 条分支,此时分支的间距大于或等于 2048B;CPI=3 的拐点定格在 6 条分支,此时分支的间距大于或等于 32KB。根据这个信息,可以认为 Firestorm 的 BTB 是 512 Set 2 Way 的结构,Index 是 PC[10:2];同时也侧面佐证了 192KB L1 ICache 是 512 Set 6 Way,Index 是 PC[14:6]。

Icestorm

用相同的方式测试 Icestorm,首先用 4B 的间距:

可以看到 1024 的拐点,1024 之前是 1 IPC,之后增加到 3 IPC。比较奇怪的是,没有看到第二个拐点,第二个拐点在 8B 的间距下显现:

第一个拐点前移到 512,第二个拐点出现在 16384,而 Icestorm 的 L1 ICache 容量是 128KB,8B 间距下正好可以保存 16384 个分支。

用 16B 间距测试:

第一个拐点前移到 256,然后出现了一个 2 CPI 的新平台,2 CPI 的平台的拐点出现在 2048,第三个拐点出现在 8192,对应 L1 ICache 容量。

用 32B 间距测试:

第一个拐点在 1024,第二个拐点出现在 4096,对应 L1 ICache 容量,没有观察到 2 CPI。

用 64B 间距测试:

第一个拐点在 512,第二个拐点出现在 2048,对应 L1 ICache 容量。

Icestorm 的 BTB 测试结果并不像 Firestorm 那样有规律,根据这个现象,给出一些猜测:

  1. 可能只有一级 BTB,但它的 Index 函数进行了一些 Hash 而非直接取 PC 某几位,使得随着分支的间距增大,CPI=1 的拐点并非单调递减;但这无法解释为何 16B 间距时会出现 2 CPI 的平台
  2. 可能有两级 BTB,它们并非简单地级联,而是通过不同的组织方式,在不同的区间内发挥作用

针对 4B 间距没有出现 CPI>3 的情况,给出一些猜测:

  1. 测试规模不够大,把分支数量继续增大,才能出现 CPI>3 的情况
  2. 指令预取器在工作,当 footprint 大于 128KB L1 ICache 时,能提前把指令取进来

L1 ITLB

构造一系列的 B 指令,使得 B 指令分布在不同的 page 上,使得 ITLB 成为瓶颈,在 Firestorm 上进行测试:

从 1 Cycle 到 3 Cycle 的增加是由于 L1 BTB 的冲突缺失,之后在 192 个页时从 3 Cycle 快速增加到 13 Cycle,则对应了 192 项的 L1 ITLB 容量。

在 Icestorm 上重复实验:

只有一个拐点,在 128 个页时,性能从 1 Cycle 下降到 8 Cycle,意味 L1 ITLB 容量是 128 项。

Decode

从前面的测试来看,Firestorm 最大观察到 8 IPC,Icestorm 最大观察到 4 IPC,那么 Decode 宽度也至少是这么多,暂时也不能排除有更大的 Decode 宽度。

Return Stack

构造不同深度的调用链,测试每次调用花费的平均时间,在 Firestorm 上得到下面的图:

可以看到调用链深度为 50 时性能突然变差,因此 Firestorm 的 Return Stack 深度为 50。在 Icestorm 上测试:

可以看到调用链深度为 32 时性能突然变差,因此 Icestorm 的 Return Stack 深度为 32。

Conditional Branch Predictor

参考 Dissecting Conditional Branch Predictors of Apple Firestorm and Qualcomm Oryon for Software Optimization and Architectural Analysis 论文的方法,可以测出 Firestorm 的分支预测器采用的历史更新方式为:

  1. 使用 100 位的 Path History Register for Target(PHRT) 以及 28 位的 Path History Register for Branch(PHRB),每次执行 taken branch 时更新
  2. 更新方式为:PHRTnew = (PHRTold << 1) xor T[31:2], PHRBnew = (PHRBold << 1) xor B[5:2],其中 B 代表分支指令的地址,T 代表分支跳转的目的地址

Icestorm 的分支预测器采用的历史更新方式为:

  1. 使用 60 位的 Path History Register for Target(PHRT) 以及 16 位的 Path History Register for Branch(PHRB),每次执行 taken branch 时更新
  2. 更新方式为:PHRTnew = (PHRTold << 1) xor T[47:2], PHRBnew = (PHRBold << 1) xor B[5:2],其中 B 代表分支指令的地址,T 代表分支跳转的目的地址

各厂商处理器的 PHR 更新规则见 jiegec/cpu

后端

物理寄存器堆

为了测试物理寄存器堆的大小,一般会用两个依赖链很长的操作放在开头和结尾,中间填入若干个无关的指令,并且用这些指令来耗费物理寄存器堆。Firestorm 测试结果见下图:

  • 32b/64b int:测试 speculative 32/64 位整数寄存器的数量,拐点在 362
  • 32b fp:测试 speculative 32 位浮点寄存器的数量,拐点在 382
  • flags:测试 speculative NZCV 寄存器的数量,拐点在 123

Icestorm 测试结果如下:

  • 32b/64b int:测试 speculative 32/64 位整数寄存器的数量,拐点在 78
  • 32b fp:测试 speculative 32 位浮点寄存器的数量,拐点在 382
  • flags:测试 speculative NZCV 寄存器的数量,拐点在 75

注意这里测试的都是能够用于预测执行的寄存器数量,实际的物理寄存器堆还需要保存架构寄存器。但具体保存多少个架构寄存器不确定,但至少 32 个整数通用寄存器和浮点寄存器是一定有的,但可能还有一些额外的需要重命名的状态也要算进来。

Load Store Unit + L1 DCache

L1 DCache 容量

官方信息:通过 sysctl 可以看到,Firestorm 具有 128KB L1 DCache,Icestorm 具有 64KB L1 DCache:

hw.perflevel0.l1dcachesize: 131072hw.perflevel1.l1dcachesize: 65536

构造不同大小 footprint 的 pointer chasing 链,测试不同 footprint 下每条 load 指令耗费的时间,Firestorm 上的结果:

可以看到 128KB 出现了明显的拐点,对应的就是 128KB 的 L1 DCache 容量。L1 DCache 范围内延迟是 3 cycle,之后提升到 16 cycle。

Icestorm 上的结果:

可以看到 64KB 出现了明显的拐点,对应的就是 64KB 的 L1 DCache 容量。L1 DCache 范围内延迟是 3 cycle,之后提升到 14 cycle。

L1 DTLB 容量

用类似的方法测试 L1 DTLB 容量,只不过这次 pointer chasing 链的指针分布在不同的 page 上,使得 DTLB 成为瓶颈,在 Firestorm 上:

从 160 个页开始性能下降,到 250 个页时性能稳定在 9 CPI,认为 Firestorm 的 L1 DTLB 有 160 项。9 CPI 包括了 L1 DTLB miss L2 TLB hit 带来的额外延迟。

如果每两个页放一个指针,则拐点前移到 80;每四个页放一个指针,拐点变成 40;每八个页放一个指针,拐点变成 20;每 16 个页一个指针,拐点是 10;每 32 个页一个指针,拐点变成 5;每 64 个页一个指针,拐点依然是 5。说明 Firestorm 的 L1 DTLB 是 5 路组相连,32 个 Set,Index 是 VA[18:14],注意页表大小是 16KB。

Icestorm:

从 128 个页开始性能下降,到 160 个页时性能稳定在 10 CPI,认为 Icestorm 的 L1 DTLB 有 128 项。10 CPI 包括了 L1 DTLB miss L2 TLB hit 带来的额外延迟。

如果每两个页放一个指针,则拐点前移到 64;每四个页放一个指针,拐点变成 32;每八个页放一个指针,拐点变成 16;每 16 个页一个指针,拐点是 8;每 32 个页一个指针,拐点变成 4;每 64 个页一个指针,拐点依然是 4。说明 Icestorm 的 L1 DTLB 是 4 路组相连,32 个 Set,Index 是 VA[18:14]。

Load/Store 带宽

针对 Load Store 带宽,实测 Firestorm 每个周期可以完成:

  • 3x 128b Load + 1x 128b Store
  • 2x 128b Load + 2x 128b Store
  • 1x 128b Load + 2x 128b Store
  • 2x 128b Store

如果把每条指令的访存位宽从 128b 改成 256b,读写带宽不变,指令吞吐减半。也就是说最大的读带宽是 48B/cyc,最大的写带宽是 32B/cyc,二者不能同时达到。

实测 Icestorm 每个周期可以完成:

  • 2x 128b Load
  • 1x 128b Load + 1x 128b Store
  • 1x 128b Store

如果把每条指令的访存位宽从 128b 改成 256b,读写带宽不变,指令吞吐减半。也就是说最大的读带宽是 32B/cyc,最大的写带宽是 16B/cyc,二者不能同时达到。

Memory Dependency Predictor

为了预测执行 Load,需要保证 Load 和之前的 Store 访问的内存没有 Overlap,那么就需要有一个预测器来预测 Load 和 Store 之前在内存上的依赖。参考 Store-to-Load Forwarding and Memory Disambiguation in x86 Processors 的方法,构造两个指令模式,分别在地址和数据上有依赖:

  • 数据依赖,地址无依赖:str x3, [x1]ldr x3, [x2]
  • 地址依赖,数据无依赖:str x2, [x1]ldr x1, [x2]

初始化时,x1x2 指向同一个地址,重复如上的指令模式,观察到多少条 ldr 指令时会出现性能下降,在 Firestorm 上测试:

数据依赖没有明显的阈值,但从 84 开始出现了一个小的增长,且斜率不为零;地址依赖的阈值是 70。

Icestorm:

数据依赖和地址依赖的阈值都是 13。

Store to Load Forwarding

Firestorm

经过实际测试,Firestorm 上如下的情况可以成功转发,对地址 x 的 Store 转发到对地址 y 的 Load 成功时 y-x 的取值范围:

Store\Load8b Load16b Load32b Load64b Load
8b Store{0}[-1,0][-3,0][-7,0]
16b Store[0,1][-1,1][-3,1][-7,1]
32b Store[0,3][-1,3][-3,3][-7,3]
64b Store[0,7][-1,7][-3,7][-7,7]

从上表可以看到,所有 Store 和 Load Overlap 的情况,无论地址偏移,都能成功转发。甚至在 Load 或 Store 跨越 64B 缓存行边界时,也可以成功转发,代价是多一个周期。

一个 Load 需要转发两个、四个甚至八个 Store 的数据时,如果数据跨越缓存行,则不能转发,但其他情况下,无论地址偏移,都可以转发,只是比从单个 Store 转发需要多耗费 1-4 个周期。

成功转发时 7.5 cycle,跨缓存行且转发失败时 28+ cycle。

小结:Apple Firestorm 的 Store to Load Forwarding:

  • 1 ld + 1 st: 支持
  • 1 ld + 2 st: 支持,要求不跨越 64B 边界
  • 1 ld + 4 st: 支持,要求不跨越 64B 边界
  • 1 ld + 8 st: 支持,要求不跨越 64B 边界
Icestorm

在 Icestorm 上,如果 Load 和 Store 访问范围出现重叠,则需要 10 Cycle,无论是和几个 Store 重叠,也无论是否跨缓存行。

Load to use latency

Firestorm

实测 Firestorm 的 Load to use latency 针对 pointer chasing 场景做了优化,在下列的场景下可以达到 3 cycle:

  • ldr x0, [x0]: load 结果转发到基地址,无偏移
  • ldr x0, [x0, 8]:load 结果转发到基地址,有立即数偏移
  • ldr x0, [x0, x1]:load 结果转发到基地址,有寄存器偏移
  • ldp x0, x1, [x0]:load pair 的第一个目的寄存器转发到基地址,无偏移

如果访存跨越了 8B 边界,则退化到 4 cycle。

在下列场景下 Load to use latency 则是 4 cycle:

  • load 的目的寄存器作为 alu 的源寄存器(下称 load to alu latency)
  • ldr x0, [sp, x0, lsl #3]:load 结果转发到 index
  • ldp x1, x0, [x0]:load pair 的第二个目的寄存器转发到基地址,无偏移
Icestorm

实测 Icestorm 的 Load to use latency 针对 pointer chasing 场景做了优化,在下列的场景下可以达到 3 cycle:

  • ldr x0, [x0]: load 结果转发到基地址,无偏移
  • ldr x0, [x0, 8]:load 结果转发到基地址,有立即数偏移
  • ldr x0, [x0, x1]:load 结果转发到基地址,有寄存器偏移
  • ldp x0, x1, [x0]:load pair 的第一个目的寄存器转发到基地址,无偏移

如果访存跨越了 8B/16B/32B 边界,依然是 3 cycle;跨越了 64B 边界则退化到 7 cycle。

在下列场景下 Load to use latency 则是 4 cycle:

  • load 的目的寄存器作为 alu 的源寄存器(下称 load to alu latency)
  • ldr x0, [sp, x0, lsl #3]:load 结果转发到 index
  • ldp x1, x0, [x0]:load pair 的第二个目的寄存器转发到基地址,无偏移

Virtual Address UTag/Way-Predictor

Linear Address UTag/Way-Predictor 是 AMD 的叫法,但使用相同的测试方法,也可以在 Apple M1 上观察到类似的现象,猜想它也用了类似的基于虚拟地址的 UTag/Way Predictor 方案,并测出来它的 UTag 也有 8 bit,Firestorm 和 Icestorm 都是相同的:

  • VA[14] xor VA[22] xor VA[30] xor VA[38] xor VA[46]
  • VA[15] xor VA[23] xor VA[31] xor VA[39] xor VA[47]
  • VA[16] xor VA[24] xor VA[32] xor VA[40]
  • VA[17] xor VA[25] xor VA[33] xor VA[41]
  • VA[18] xor VA[26] xor VA[34] xor VA[42]
  • VA[19] xor VA[27] xor VA[35] xor VA[43]
  • VA[20] xor VA[28] xor VA[36] xor VA[44]
  • VA[21] xor VA[29] xor VA[37] xor VA[45]

一共有 8 bit,由 VA[47:14] 折叠而来。

执行单元

想要测试有多少个执行单元,每个执行单元可以运行哪些指令,首先要测试各类指令在无依赖情况下的的 IPC,通过 IPC 来推断有多少个能够执行这类指令的执行单元;但由于一个执行单元可能可以执行多类指令,于是进一步需要观察在混合不同类的指令时的 IPC,从而推断出完整的结果。

Firestorm

在 Firestorm 上测试如下各类指令的延迟和每周期吞吐:

指令延迟吞吐
asimd int add24
asimd aesd/aese34
asimd aesimc/aesmc24
asimd fabs24
asimd fadd34
asimd fdiv 64b101
asimd fdiv 32b81
asimd fmax24
asimd fmin24
asimd fmla44
asimd fmul44
asimd fneg24
asimd frecpe31
asimd frsqrte31
asimd fsqrt 64b130.5
asimd fsqrt 32b100.5
fp cvtf2i (fcvtzs)-2
fp cvti2f (scvtf)-3
fp fabs24
fp fadd34
fp fdiv 64b101
fp fdiv 32b81
fp fjcvtzs-1
fp fmax24
fp fmin24
fp fmov f2i-2
fp fmov i2f-3
fp fmul44
fp fneg24
fp frecpe31
fp frecpx31
fp frsqrte31
fp fsqrt 64b130.5
fp fsqrt 32b100.5
int add14.6
int addi16
int bfm11
int crc31
int csel13
int madd (addend)11
int madd (others)31
int mrs nzcv-2
int mul32
int nop-8
int sbfm14.7
int sdiv70.5
int smull32
int ubfm14.7
int udiv70.5
not taken branch-2
taken branch-1
mem asimd load-3
mem asimd store-2
mem int load-3
mem int store-2

从上面的结果可以初步得到的信息:

  1. 标量浮点和 ASIMD 吞吐最大都是 4,意味着有 4 个浮点/ASIMD 执行单元,但并非完全对称,例如 fdiv/frecpe/frecpx/frsqrte/fsqrt/fjcvtzs 由于吞吐不超过 1,大概率只能在一个执行单元内执行。但这些指令是不是都只能在同一个执行单元内执行,还需要进一步的测试
  2. 浮点和整数之间的 move 或 convert 指令,fmov i2f/cvti2f 吞吐是 3,fmov f2i/cvtf2i 吞吐是 2,那么这些指令是在哪个执行单元里实现的,是否需要同时占用整数执行单元和浮点执行单元,需要进一步测试
  3. 整数方面,根据吞吐,推断出如下几类指令对应的执行单元数量:
    1. ALU: 6
    2. CSEL: 3
    3. Mul/Br/MRS NZCV: 2
    4. CRC/BFM/MAdd/Div: 1
  4. 虽然 Br 的吞吐可以达到 2,但是每周期只能有一个 taken branch;目前一些架构可以做到每周期超过一个 taken branch,此时 Br 的吞吐一般会给到 3
  5. 访存方面,每周期最多 3 Load 或者 2 Store

首先来看浮点和 ASIMD 单元,根据上面的信息,认为至少有 4 个执行单元,每个执行单元都可以做这些操作:asimd int add/aes/fabs/fadd/fmax/fmin/fmla/fmul/fneg,下面把这些指令称为 basic fp/asimd ops + aes。接下来要判断,fmov f2i/fmov i2f/fdiv/frecpe/frecpx/frsqrte/fsqrt 由哪些执行单元负责执行,方法是把这些指令混合起来测试吞吐(此处的吞吐不代表 CPI,而是每周能够执行多少次指令组合,例如用 2 条指令的组合测试,那么吞吐等于 CPI 除以 2):

指令吞吐
fp fdiv + fp frecpe0.5
fp fdiv + fp frecpx0.5
fp fdiv + fp frsqrte0.5
fp fdiv + fp fsqrt0.32=3/3.12
fp fdiv + fmov f2i1
fp fdiv + 2x fmov f2i0.67=1/1.50
fp fdiv + 3x fmov i2f1
fp fdiv + 4x fmov i2f0.75=1/1.33
fmov i2f + 4x fp fadd1
fmov f2i + 4x fp fadd0.67=1/1.50

根据以上测试结果,可以得到如下的推论:

  1. fp fdiv/frecpe/frecpx/frsqrte 混合的时候,吞吐只有一半,IPC 不变,说明这些指令在同一个执行单元中,混合并不能带来更高的 IPC
  2. fp fdiv 和 fp fsqrt 混合时,吞吐下降到 0.32 一个不太整的数字,猜测是因为它们属于同一个执行单元内的不同流水线,抢占寄存器堆写口
  3. fp fdiv + fmov f2i 的时候吞吐是 1,而 fdiv + 2x fmov f2i 时吞吐下降到 0.67,IPC 维持在 2,说明有两个执行单元,都可以执行 fmov f2i,但只有其中一个可以执行 fp fdiv,导致 fdiv + 2x fmov f2i 的时候会抢执行单元
  4. fp fdiv + 3x fmov i2f 的时候吞吐是 1,而 fdiv + 4x fmov i2f 时吞吐下降到 0.75,此时每周期还是执行 3 条 fmov i2f 指令,意味着 fdiv 没有抢占 fmov i2f 的执行单元,它们用的执行单元是独立的
  5. fmov i2f + 4x fp fadd 的时候吞吐是 1,说明 fmov i2f 没有抢占 fp fadd 的执行单元

推断这四个执行单元支持的操作:

  1. basic fp/asimd ops + aes + fdiv + frecpe + frecpx + frsqrte + fsqrt + fmov f2i + cvtf2i
  2. basic fp/asimd ops + aes + fmov f2i + cvtf2i
  3. basic fp/asimd ops + aes
  4. basic fp/asimd ops + aes

当然还有很多指令没有测,不过原理是一样的。

访存部分,前面已经在测 LSU 的时候测过了,每周期 Load + Store 不超过 4 个,其中 Load 不超过 3 个,Store 不超过 2 个。虽然从 IPC 的角度来看 LSU 的 Load/Store Pipe 未必准确,比如可能它发射和提交的带宽是不同的,但先暂时简化为如下的执行单元:

  1. load + store
  2. load
  3. load
  4. store

最后是整数部分。从 addi 的指令来看,有 6 个 ALU,能够执行基本的整数指令(add/ubfm/sbfm 的吞吐有时候测出来 4.6-4.7,有时候测出来 6,怀疑是进入了什么低功耗模式)。但其他很多指令可能只有一部分执行单元可以执行:bfm/crc/csel/madd/mrs nzcv/mul/div/branch/fmov i2f。为了测试这些指令使用的执行单元是否重合,进行一系列的混合指令测试,吞吐的定义和上面相同:

指令吞吐
3x int csel + 3x fmov i2f1
3x int csel + 2x fmov f2i0.75=1/1.33
3x int csel + int bfm1
3x int csel + int crc1
3x int csel + int madd1
3x int csel + int mul1
3x int csel + int sdiv0.5
3x int csel + mrs nzcv0.75=1/1.33
3x int csel + not taken branch0.75=1/1.33
mrs nzcv + not taken branch1
mrs nzcv + 2x not taken branch0.67=1/1.50
2x fmov f2i + 2x not taken branch1
2x fmov f2i + 2x int mul1
int madd + 2x int mul0.67=1/1.50
int madd + int sdiv0.5
int madd + int crc0.5

根据上述结果分析:

  1. 吞吐与不混合时相同,代表混合的指令对应的执行单元不重合
  2. 3x int csel + 2x fmov f2i 的 IPC 等于 4,意味着有四个执行单元,其中有三个可以执行 int csel,两个可以执行 fmov f2i,也就意味着其中有一个执行单元可以执行 int csel 和 fmov f2i,即有这样的四个执行单元:
    1. alu + csel
    2. alu + csel
    3. alu + csel + fmov f2i
    4. alu + fmov f2i
  3. 3x int csel + mrs nzcv/not taken branch 的 IPC 等于 3,说明它们的执行单元是重合的;又因为 2x fmov f2i + 2x not taken branch 的吞吐是 1,说明它们的执行单元不重合,那么上述四个执行单元只能是:
    1. alu + csel + branch
    2. alu + csel + branch
    3. alu + csel + fmov f2i
    4. alu + fmov f2i
  4. mrs nzcv + 2x not taken branch 的 IPC 等于 2,说明它们的执行单元是重合的,那么上述四个执行单元是:
    1. alu + csel + branch + mrs nzcv
    2. alu + csel + branch + mrs nzcv
    3. alu + csel + fmov f2i
    4. alu + fmov f2i
  5. csel 和 mul 不重合,f2i 和 mul 也不重合,说明 mul 在剩下的两个执行单元内:
    1. alu + csel + branch + mrs nzcv
    2. alu + csel + branch + mrs nzcv
    3. alu + csel + fmov f2i
    4. alu + fmov f2i
    5. alu + mul
    6. alu + mul
  6. madd 和 mul 重合,madd 和 crc 重合,那么:
    1. alu + csel + branch + mrs nzcv
    2. alu + csel + branch + mrs nzcv
    3. alu + csel + fmov f2i
    4. alu + fmov f2i
    5. alu + mul + madd + crc
    6. alu + mul

得到初步的结果:

  1. alu + csel + branch + mrs nzcv
  2. alu + csel + branch + mrs nzcv
  3. alu + csel + fmov f2i
  4. alu + fmov f2i
  5. alu + mul + madd + crc
  6. alu + mul

还有很多其他的指令没有测试,不过方法是类似的。从上面的结果里,可以看到一些值得一提的点:

  1. fmov f2i 同时占用了两个浮点执行单元和两个整数执行单元,这主要是为了复用寄存器堆读写口:fmov f2i 需要读浮点寄存器堆,又需要写整数寄存器堆,那就在浮点侧读寄存器,在整数侧写寄存器。
  2. fmov i2f 既不在浮点,也不在整数,那只能在访存了:而正好访存执行单元需要读整数,写整数或浮点,那就可以复用它的寄存器堆写口来实现 fmov i2f 的功能。
  3. 可见整数/浮点/访存执行单元并不是完全隔离的,例如一些微架构,整数和浮点是直接放在一起的。

小结:Firestorm 的执行单元如下:

  1. alu + csel + branch + mrs nzcv
  2. alu + csel + branch + mrs nzcv
  3. alu + csel + fmov f2i
  4. alu + fmov f2i
  5. alu + mul + madd + crc
  6. alu + mul
  7. load + store
  8. load
  9. load
  10. store
  11. basic fp/asimd ops + aes + fdiv + frecpe + frecpx + frsqrte + fsqrt + fmov f2i + cvtf2i
  12. basic fp/asimd ops + aes + fmov f2i + cvtf2i
  13. basic fp/asimd ops + aes
  14. basic fp/asimd ops + aes

Icestorm

接下来用类似的方法测试 Icestorm:

指令延迟吞吐
asimd int add22
asimd aesd/aese32
asimd aesimc/aesmc22
asimd fabs22
asimd fadd32
asimd fdiv 64b110.5
asimd fdiv 32b90.5
asimd fmax22
asimd fmin22
asimd fmla42
asimd fmul42
asimd fneg22
asimd frecpe40.5
asimd frsqrte40.5
asimd fsqrt 64b150.5
asimd fsqrt 32b120.5
fp cvtf2i (fcvtzs)-1
fp cvti2f (scvtf)-2
fp fabs22
fp fadd32
fp fdiv 64b101
fp fdiv 32b81
fp fjcvtzs-0.5
fp fmax22
fp fmin22
fp fmov f2i-1
fp fmov i2f-2
fp fmul42
fp fneg22
fp frecpe31
fp frecpx31
fp frsqrte31
fp fsqrt 64b130.5
fp fsqrt 32b100.5
int add13
int addi13
int bfm11
int crc31
int csel13
int madd (addend)11
int madd (others)31
int mrs nzcv-3
int mul31
int nop-4
int sbfm13
int sdiv70.125=1/8
int smull31
int ubfm13
int udiv70.125=1/8
not taken branch-2
taken branch-1
mem asimd load-2
mem asimd store-1
mem int load-2
mem int store-1

从上面的结果可以初步得到的信息:

  1. 标量浮点和 ASIMD 吞吐最大都是 2,意味着有 2 个浮点/ASIMD 执行单元,但并非完全对称,例如 fdiv/frecpe/frecpx/frsqrte/fsqrt/fjcvtzs 由于吞吐不超过 1,大概率只能在一个执行单元内执行。但这些指令是不是都只能在同一个执行单元内执行,还需要进一步的测试
  2. 整数方面,根据吞吐,推断出如下几类指令对应的执行单元数量:
    1. ALU/CSEL/MRS NZCV/SBFM/UBFM: 3
    2. Br: 2
    3. Mul/CRC/BFM/MAdd/Div: 1
  3. 虽然 Br 的吞吐可以达到 2,但是每周期只能有一个 taken branch
  4. 访存方面,每周期最多 2 Load 或者 1 Store

还是先看浮点,基本指令 add/aes/fabs/fadd/fmax/fmin/fmla/fmul/fneg 都能做到 2 的吞吐,也就是这两个指定单元都能执行这些基本指令。接下来测其余指令的混合吞吐(吞吐定义见上):

指令吞吐
fp fdiv + fp frecpe0.5
fp fdiv + fp frecpx0.5
fp fdiv + fp frsqrte0.5
fp fdiv + fp fsqrt0.31=3/3.25
fp fdiv + fmov f2i0.5
fp fdiv + 2x fmov i2f1
fp fdiv + 3x fmov i2f0.67/1/1.50

可见 fdiv/frecpe/frecpx/frsqrte/fsqrt/fmov f2i 都在同一个执行单元内:

  1. basic fp/asimd ops + aes + fdiv + frecpe + frecpx + frsqrte + fsqrt + fmov f2i
  2. basic fp/asimd ops + aes

还有很多指令没有测,不过原理是一样的。访存在前面测 LSU 的时候已经测过了:

  1. load + store
  2. load

最后是整数部分。从 addi 的指令来看,有 3 个 ALU,能够执行基本的整数指令。但其他很多指令可能只有一部分执行单元可以执行:bfm/crc/csel/madd/mul/div/branch。为了测试这些指令使用的执行单元是否重合,进行一系列的混合指令测试,吞吐的定义和上面相同:

指令吞吐
int madd + int mul0.5
int madd + int crc0.5
int madd + 2x not taken branch1

由此可见,madd/mul/crc 是一个执行单元,和 branch 的两个执行单元不重合,因此整数侧的执行单元有:

  1. alu + csel + mrs nzcv + branch
  2. alu + csel + mrs nzcv + branch
  3. alu + csel + mrs nzcv + madd + mul + crc

小结:Icestorm 的执行单元如下:

  1. alu + csel + mrs nzcv + branch
  2. alu + csel + mrs nzcv + branch
  3. alu + csel + mrs nzcv + madd + mul + crc
  4. load + store
  5. load
  6. basic fp/asimd ops + aes + fdiv + frecpe + frecpx + frsqrte + fsqrt + fmov f2i
  7. basic fp/asimd ops + aes

Scheduler

为了测试 Scheduler 的大小和组织方式(分布式还是集中式),测试方法是:首先用长延迟的操作堵住 ROB,接着用若干条依赖长延迟操作的指令堵住 Scheduler,当指令塞不进去的时候,就说明 Scheduler 满了。更进一步,由于现在很多处理器会引入 Non Scheduling Queue,里面的指令不会直接调度进执行单元,也不检查它依赖的操作数是否已经准备好,此时为了区分可调度部分和不可调度部分,在依赖长延迟操作的指令后面,添加若干条不依赖长延迟操作的指令,这样测出来的就是可调度部分的深度。

Firestorm

在 Firestorm 上测试,结果如下:

指令可调度 + 不可调度可调度
ld5848
st5843
alu158134
fp156144
crc4028
idiv4028
bfm4028
fjcvtzs4236
fmov f2i8472
csel7664
mrs nzcv6250

首先看浮点:

  1. 可调度部分 fp 是 144,fmov f2i 是 72,fjcvtzs 是 36,有明显的 4:2:1 的关系
  2. fp/fmov f2i/fjcvtzs 吞吐刚好也是 4:2:1 的关系
  3. 因此四个执行单元前面各有一个独立的 36 entry 的 Scheduler
  4. 不可调度部分,156-144=12,84-72=12,42-36=6,猜测有两个 Non Scheduling Queue,每个 Non Scheduling Queue 6 entry,分别对应两个 Scheduler

下面是访存部分,load 和 store 总数一样但 Scheduler 差了 5,不确定是测试误差还是什么问题,暂且考虑为一个统一的 Scheduler 和同一个 Non Scheduling Queue。

最后是整数部分,由于有 6 个整数执行单元,情况会比较复杂:

  1. 可调度部分 alu 一共是 134,其中 csel 是 64,crc/idiv/bfm 都是 28,mrs nzcv 是 50,结合六个整数执行单元,可以得到这六个执行单元对应的 Scheduler 大小关系:
    1. alu + csel + branch + mrs nzcv: x entries
    2. alu + csel + branch + mrs nzcv: 50-x entries
    3. alu + csel + fmov f2i: 14 entries
    4. alu + fmov f2i: y entries
    5. alu + mul + madd + crc: 28 entries
    6. alu + mul: 42-y entries
  2. alu 不可调度部分是 158-134=24,crc/idiv/bfm/csel/mrs nzcv 不可调度部分都是 12,考虑到 csel 对应前三个执行单元,并且和 mrs nzcv 一样多,说明前三个执行单元共享一个 12 entry 的 Non Scheduling Queue;剩下三个执行单元共享剩下的 12 entry 的 Non Scheduling Queue
  3. 最后只差 x 和 y 的取值没有求出来,可以通过进一步测试来更加精确地求出

Reorder Buffer

Firestorm

Firestorm 的 ROB 采用的是比较特别的方案,它的 entry 地位不是等同的,而是若干个 entry 组合在一起,成为一个 group,每个 group 有若干个 entry,一个 group 对 group 内指令的类型和数量有要求,这就导致用传统方法测 Firestorm 的 ROB,可能会测到特别巨大的数:2200+,也可能测到比较小的数:320+,这就和指令类型有关系。为什么对指令的类型和位置有要求呢,这是为了方便处理有副作用的指令。很多指令是没有副作用的,也不会抛异常,这些指令可以比较随意地放置;但是对于有副作用的指令,retire 时是需要特殊处理的,因此一个合理的设计就是,让这些指令只能放在 group 的开头。

经过测试,发现 Firestorm 上 pointer chasing 的延迟波动比较大,目测是 prefetcher 做了针对性的优化,因此用 fsqrt chain 来做延迟,Firestorm 上一条双精度 fsqrt 的延迟是 13 个周期。构造一个循环,循环包括 M 个串行的 sqrt 和 N 个 nop,如果没有触碰到 ROB 的瓶颈,那么当 N 比较小的时候,瓶颈在串行 fsqrt 上,每次循环的周期数应该为 M*13;当 N 比较大的时候,瓶颈在每个周期执行 8 个 NOP 上(NOP 被 eliminate 了,不用进 ALU,可以打满 8 的发射宽度),每次循环的周期数应该为 N/8 再加上一个常数。

但当 N 很大的时候,可能会撞上 ROB 大小的限制。下面给出了不同的 M 取值情况下,可以保证循环周期数在 M*13 左右的最大的 N 取值:

  • M=21, N <= 2109, cycle=273.74, M*13=273
  • M=22, N <= 2205, cycle=286.75, M*13=286
  • M=23, N <= 2269, cycle=299.76, M*13=299
  • M=24, N <= 2277, cycle=312.77, M*13=312
  • M=25, N <= 2275, cycle=325.77, M*13=325
  • M=26, N <= 2274, cycle=338.77, M*13=338

可以看到 N 最大在 2277 附近就不能再增加了,说明遇到了 ROB 的瓶颈,预计 ROB 的所有 group 的 entry 个数加起来大概是 2277 左右。

而如果把填充的指令改成 load/store,inflight 的 load 和 store 最多都是 325 个,并且这和 load/store queue 大小无关,而 load/store 又是有副作用的,很可能是因为它们只能在 ROB 每个 group 里只能放一条,于是看起来 ROB 的容量比 2277 小了很多,只表现出 325。按照这个猜想,对二者进行除法,发现商和 7 十分接近,这大概率意味着 Firestorm 的 ROB 有 325 左右个 group,每个 group 内有 7 个 entry,每个 entry 可以放一条指令(uop)。测试里开头的 20 个 sqrt 也要占用 ROB,实际的 ROB group 数量可能比 325 略多。

结论:Firestorm 的 ROB 有大约 330 个 group,每个 group 最多保存 7 个 uop。

L2 Cache

官方信息:通过 sysctl 可以看到,4 个 Firestorm 核心共享一个 12MB L2 Cache,4 个 Icestorm 核心共享一个 4MB L2 Cache:

hw.perflevel0.l2cachesize: 12582912hw.perflevel0.cpusperl2: 4hw.perflevel1.l2cachesize: 4194304hw.perflevel1.cpusperl2: 4

L2 TLB

Firestorm

从苹果提供的性能计数器来看,L1 TLB 分为指令和数据,而 L2 TLB 是 Unified,不区分指令和数据。沿用之前测试 L1 DTLB 的方法,把规模扩大到 L2 Unified TLB 的范围,就可以测出来 L2 Unified TLB 的容量,下面是 Firestorm 上的测试结果:

可以看到拐点是 3072 个 Page,说明 Firestorm 的 L2 TLB 容量是 3072 项。

把指针的跨度增大:

  • 如果每 32 个页一个指针,L2 TLB 拐点前移到 96,L2 TLB 缺失时 CPI 为 36.5
  • 如果每 64 个页一个指针,L2 TLB 拐点前移到 48,L2 TLB 缺失时 CPI 为 36.5
  • 如果每 128 个页一个指针,L2 TLB 拐点前移到 24,L2 TLB 缺失时 CPI 为 36.5
  • 如果每 256 个页一个指针,L2 TLB 拐点前移到 12,L2 TLB 缺失时 CPI 为 35
  • 如果每 512 个页一个指针,L2 TLB 拐点依然在 12,L2 TLB 缺失时 CPI 为 35
  • 观察到命中 L1 DTLB 时 CPI 是 3,命中 L2 TLB 时 CPI 是 9,L2 TLB 缺失时 CPI 是 35-36.5,此时缓存缺失率为 0

认为 Firestorm 的 L2 TLB 是 12 Way,256 Set,Index 位是 VA[21:14]。

Icestorm

在 Icestorm 上测试:

可以看到拐点是 1024 个 Page,说明 Icestorm 的 L2 TLB 容量是 1024 项。

把指针的跨度增大:

  • 如果每 32 个页一个指针,L2 TLB 拐点前移到 32,L2 TLB 缺失时 CPI 为 33
  • 如果每 64 个页一个指针,L2 TLB 拐点前移到 16,L2 TLB 缺失时 CPI 为 32
  • 如果每 128 个页一个指针,L2 TLB 拐点前移到 8,L2 TLB 缺失时 CPI 为 32
  • 如果每 256 个页一个指针,L2 TLB 拐点前移到 4 和 L1 DTLB 拐点重合,一旦 L1 DTLB 缺失,L2 TLB 也缺失,L2 TLB 缺失时 CPI 为 32
  • 观察到命中 L1 DTLB 时 CPI 是 3,命中 L2 TLB 时 CPI 是 10,L2 TLB 缺失时 CPI 是 32-33,此时缓存缺失率为 0

由于 Icestorm 的 L1 DTLB 就是 4 Way,不确定 Icestorm 的 L2 TLB 组相连是 1/2/4 Way 的哪一种,假如是 4 Way,那么 Icestorm 的 L2 TLB 是 4 Way,256 Set,Index 位是 VA[21:14]。

🔲 ⭐

Terraform多Region的格式

Terraform 在基础设施的管理是是非常方便的。但是面对一个中型项目,管理文件的复杂度也会大幅提升。

特别是针对一个多regoin的项目,需要按regoin来进行拆分和管理,来获得更直观的资源展示。

这篇文章记录一下现在使用的项目结构,以便后续的复用

项目结构

➜  my-server tree .
.
├── eu-central-1
│   ├── ec2.tf
│   ├── lbs.tf
│   ├── main.tf
│   ├── security.tf
│   └── vars.tf
├── main.tf
├── set_env.sh
├── terraform.tfstate.d
│   ├── terraform.tfstate
│   └── terraform.tfstate.backup
└── us-east-1
    ├── ec2.tf
    ├── main.tf
    └── security.tf

项目结构如上面的目录树,每一个区域都拆分到了单独的文件夹中,对于多region的中小型项目,可以清晰的拆分各个区域的资源。

配置文件

在maintf 中,来设置 provider。通过 module 来引入,对应的模块的文件夹。

# Main.tf

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "5.63.1"
    }
  }
  backend "local" {
    path = "./terraform.tfstate.d/terraform.tfstate"
  }
}

module "us_east" {
  source = "./us-east-1"
}

module "eu_central" {
  source = "./eu-central-1"
}

在子文件夹中,就可以直接在main 里面设置provider 配置,这里设置的是region区域。

# us-east-1 main.tf
provider "aws" {
  region = "us-east-1"
}
# eu-central-1 main.tf
provider "aws" {
  region = "eu-central-1"
}

通过上面的模块引入之后,就可以使用,标准的方式来预定义一些变量了 。

variable "aws_vpc_id" {
    type = string
    description = "AWS VPC ID"
    default = "vpc-xxxx"
}

locals {
  vpc_info = {
    id = "vpc-0936ab715c6cee712"
    sub_private_a = "subnet-xxxx111"
    sub_private_b = "subnet-xxxx222"
  }
}


variable "public_subnets" {
  description = "List of Availability Zones"
  type        = list(string)
  default     = ["subnet-xxxxcccc", "subnet-xxxxdddd"]
}

以上记录一下,对于多region 的场景下如何来设计项目的目录结构来进行合理的划分和隔离。

这样避免了单个层级,堆叠了多区域/多文件 带来的管理混乱的问题。

🔲 ⭐

基于Terraform在AWS ECS中构建Jenkins持续集成体系

  之前我们旧的 Jenkins 集群跑在 AWS EC2 上,近期由于大量新增 Job 以及大量构建任务的并行,导致集群资源吃紧,不得不新增更多的 Slave 来应对。为了降低成本,同时获得更好的资源弹性,Alliot 打算基于 Terraform 在 AWS ECS 中构建新的 Jenkins 持续集成体系。容器提供了更细的资源粒度,拥有更好的资源弹性和资源利用率。

🔲 ☆

Terraform启用provider缓存

  我们在开始一个 Terraform 工程项目的时候,首先要做的就是 terraform init, 这个动作会在当前工作目录下创建 .terraform 目录,并联网下载项目所需要的 provider 到该目录下。 即便是多个项目使用的是同样的 provider,但每个工程项目仍然都有自己单独的 .terraform, 这不仅仅会浪费磁盘空间,还会花费很多不必要的时间去等待联网下载 provider,好在 Terraform 官方为我们提供了缓存目录。

🔲 ⭐

AWS ECS使用EBS作为Volume

  在 基于Terraform在AWS ECS中构建Jenkins持续集成体系 一文中, Alliot 采用了 EFS 作为 Jenkins 容器的数据卷,直接挂载了 /var/jenkins_home 目录。
正如评论区提到的, 我们在使用 bursting 模式的 EFS 时,遇到了 IO 性能的问题, 虽然 master + slave 架构的 Jenkins 将构建任务分发到了 slave 节点,减少了 master 节点的压力,但是在启动构建任务时, master 节点依然会有大量的 IO 操作, 这个时候会导致 bursting 模式下的 EFS 瞬间打光 Credit 从而导致整个 master 挂掉。当然,我们可以使用 Provisoning 模式缓解性能问题,但其价格又非常贵,性价比不高。
  好在从今年(2024)的一月开始, AWS ECS 的 Fargate 支持使用 EBS 卷作为 Volume 了。 目前官网的文档还比较分散,这里小记一些需要注意的点。

🔲 ☆

Personalized arXiv Recommendation Service

Surrendering to FOMO on important AI "breakthroughs", I subscribed to Arxiv mailing list. Like all of the subscribers, I can't read all the submissions in the daily email. To be completely honest, I simply archive the email from my inbox everyday without even opening the email at all. There is a slight chance that I try to browse through titles and abstracts one by one. But I wouldn't be able to read more than 10 abstracts. There are simply too many to read through.
🔲 ⭐

C# 单例应用

using System.Reflection;
using System.Threading;

public enum MutexScope
{
    Local,
    Global
}

public static class SingletonApp
{
    private static Mutex? mutex;

    public static bool IsNew { get; private set; }

    public static bool Check()
    {
        return Check(MutexScope.Local);
    }

    public static bool Check(MutexScope scope)
    {
        return Check($"{scope}\\{Assembly.GetEntryAssembly()?.GetName().Name}");
    }

    public static bool Check(string name)
    {
        bool createdNew;
        mutex = new(true, name, out createdNew);
        IsNew = createdNew;
        return createdNew;
    }

    public static void Cleanup()
    {
        mutex?.Close();
    }
}
❌