Welcome to the Black Parade
1.01K subscribers
562 photos
4 videos
10 files
274 links
Death has many faces, I look forward to seeing this one.
Download Telegram
在新年来临之际写了一份频道介绍,建立阅读预期😓

本频道包含以上内容⬆️
Please open Telegram to view this post
VIEW IN TELEGRAM
December 31, 2025
好想买一个武藤游戏的头套
🥹
Please open Telegram to view this post
VIEW IN TELEGRAM
Please open Telegram to view this post
VIEW IN TELEGRAM
January 2
读文档时发现下面这句话是歧义句:

This program type isn't allowed to read from and write to all fields of the context since doing so might break assumptions in the kernel or because data isn't available at the point where the program is hooked into the kernel.

它可以被理解成
a. all the fields aren't allowed to read and write
b. not all the fields are allowed to read and write (i.e. some are allowed)

我记得之前在知乎看过 费事发财 老师科普过自然语言的歧义似乎讲过这个,大概是 De dicto vs de re: https://en.wikipedia.org/wiki/De_dicto_and_de_re
a. De dicto: Not allowed [to access-all-fields]
¬allow(∀field: RW(field))

b. De re: For each field, not allowed [to access that field]
∀field: ¬allow(RW(field))


中文也有一样的歧义表达,请品:这种程序类型不允许读写上下文的所有字段。

ref: https://en.wikipedia.org/wiki/Scope_(formal_semantics)
January 4
圣诞前还在和我热烈讨论使用 go 1.23 iterator yield 来实现更漂亮的 API,昨晚就宣布退休退出讨论。我还以为你会感到有趣,没想只是工作罢了,你终究还是不爱这项目
🤔
有时爱美在无法永恒,你若勇敢爱了就要勇敢分
🤔
Please open Telegram to view this post
VIEW IN TELEGRAM
January 8
最近又在和 bpf verifier 肉搏了,但这次处理地很有条理,得意分享一下
🤔


u8 copy_len = key_size;
if (copy_len > MAX_REDIS_KEY_SIZE)
copy_len = MAX_REDIS_KEY_SIZE;
bpf_skb_load_bytes(skb, ..., ..., copy_len);

上面的 bpf c 会被 6.1 内核拒绝,原因是 bpf_skb_load_bytes 第四参数不能为零值:
  350: (85) call bpf_skb_load_bytes#26
invalid zero-sized read


显然应该加上 copy_len 非零的检查
u8 copy_len = key_size;
if (copy_len > MAX_REDIS_KEY_SIZE)
copy_len = MAX_REDIS_KEY_SIZE;
if (!copy_len)
return;
bpf_skb_load_bytes(skb, ..., ..., copy_len);

但是依然相同的零值错误
  ; if (!copy_len)
342: (57) r4 &= 255 ; R4_w=scalar(umax=255,var_off=(0x0; 0xff))
; if (!copy_len)
343: (15) if r4 == 0x0 goto pc-153 ; R4_w=scalar(umax=255,var_off=(0x0; 0xff))
...
351: (85) call bpf_skb_load_bytes#26
invalid zero-sized read

仔细看一下, if (!copy_len) 的分支是生效的, if r4 == 0x0 goto pc-153,但是 verifier 没有更新 r4 的下界 umin。

也许应该把非零检查改为下界检查?
u8 copy_len = key_size;
if (copy_len > MAX_REDIS_KEY_SIZE)
copy_len = MAX_REDIS_KEY_SIZE;
if (copy_len > 0)
goto cont2;
return;

cont2:
bpf_skb_load_bytes(skb, ..., ..., copy_len);

但这次还是一样的 invalid zero-sized read
😀

  ; if (copy_len > 0)
342: (57) r4 &= 255 ; R4_w=scalar(umax=255,var_off=(0x0; 0xff))
; if (copy_len > 0)
343: (15) if r4 == 0x0 goto pc-153 ; R4_w=scalar(umax=255,var_off=(0x0; 0xff))
...
351: (85) call bpf_skb_load_bytes#26
invalid zero-sized read
processed 12902 insns (limit 1000000) max_states_per_insn 11 total_states 287 peak_states 167 mark_read 17

对着 verifier log 冥想足够长的时间,发现 if (copy_len>0) 被 clang 编译为了 r4 &= 255 和 if r4 == 0x0 goto,难怪 verifier 无法生成 r4 的 umin。 clang 太坏了,它怎么就不能生成 if r4 > 0 goto 呢?

生气了,手冲 asm,这下 clang 不会给我乱编译字节码了吧!
u8 copy_len = key_size;
if (copy_len > MAX_REDIS_KEY_SIZE)
copy_len = MAX_REDIS_KEY_SIZE;

asm volatile goto(
"if %[len] s> 0 goto %l[cont2]\n\t"
:
: [len] "r"(copy_len)
: "memory"
: cont2
);
return;

cont2:
bpf_skb_load_bytes(skb, ..., ..., copy_len);

然后还是得到 zero read
; asm volatile goto(
342: (65) if r4 s> 0x0 goto pc+1 344: R0=48 R1=0 R2=0 R3=32 R4=scalar(id=1099,umin=1,umax=999,var_off=(0x0; 0x3ff)) R5=fp-17 R6=scalar(id=1093,smin=-120,smax=4294967295) R7=ctx(off=0,imm=0) R8=0 R9=scalar(id=1094,umin=7,umax=127,var_off=(0x3; 0x7c)) R10=fp0 fp-16=mmmmmm?? fp-24=00000000 fp-32=00000000 fp-40=00000000 fp-48=00000000 fp-56=mmmm0000 fp-64=00000000 fp-72=00000000 fp-80=00000000 fp-88=00000000 fp-96=00000000 fp-104=00000000 fp-112=00000000 fp-120=00000000 fp-128=00000000 fp-136=00000000 fp-144=00000000 fp-152=00000000 fp-160=00000000 fp-168=00000000 fp-176=00000000 fp-184=00000000 fp-192=00000000 fp-200=00000000 fp-208=00000000 fp-216=00000000 fp-224=00000000 fp-232=00000000 fp-240=00000000 fp-248=00000000 fp-256=00000000 fp-264=00000000 fp-272=00000000 fp-280=00000000 fp-288=00000000 fp-296=00000000 fp-304=00000000 fp-312=00000000 fp-320=00000000 fp-328=mmmmmmmm fp-336=map_value fp-344=map_value
; offset += 1 + key_size + 2;
344: (57) r4 &= 255 ; R4_w=scalar(umax=255,var_off=(0x0; 0xff))
...
352: (85) call bpf_skb_load_bytes#26
invalid zero-sized read


等等,上面的日志好像已经对了,在我们手动注入 if r4 s> 0x0 goto pc+1 之后,verifier 给 r4 打上了正确的下界 umin=1
R4=scalar(id=1099,umin=1,umax=999,var_off=(0x0; 0x3ff))

但是随后的 r4 &= 255 又把 umin=1 抹平了
344: (57) r4 &= 255                   ; R4_w=scalar(umax=255,var_off=(0x0; 0xff))


为什么 clang 生成了一句 r4 &= 255?对着 Piplup 小企鹅发呆了一会儿,我意识到是 copy_len 的类型导致的,我的类型定义是 u8 copy_len 因为它够小够用,然而 bpf_skb_load_bytes 类型声明里第四参数是 u32,所以相当于有个隐式的类型转换
bpf_skb_load_bytes(skb, ..., ..., (u32)copy_len);

这个类型转化导致 clang 生成了一句 r4 &= 255 字节码,导致 verifier 六亲不认销毁 umin。

最后把 copy_len 改为 u32,大成功!
u32 copy_len = key_size;
if (copy_len > MAX_REDIS_KEY_SIZE)
copy_len = MAX_REDIS_KEY_SIZE;

asm volatile goto(
"if %[len] s> 0 goto %l[cont2]\n\t"
:
: [len] "r"(copy_len)
: "memory"
: cont2
);
return;

cont2:
bpf_skb_load_bytes(skb, ..., ..., copy_len);


我感到自己已经逐渐理解 verifier 了,升格为弱等神力了
🤔
Please open Telegram to view this post
VIEW IN TELEGRAM
January 9
最近一直在高强度依赖 socket cookie 构建模型,在考虑生命期(sk cookie 在 sk release 之后被复用吗)和数值分部(如果足够局部也许能很方便用 robin hood map 做性能优化)的时候看了一眼内核的 cookie 生成代码,很有趣
🤔


static __always_inline u64 gen_cookie_next(struct gen_cookie *gc)
{
struct pcpu_gen_cookie *local = this_cpu_ptr(gc->local);
u64 val;

if (likely(local_inc_return(&local->nesting) == 1)) {
val = local->last;
if (__is_defined(CONFIG_SMP) &&
unlikely((val & (COOKIE_LOCAL_BATCH - 1)) == 0)) {
s64 next = atomic64_add_return(COOKIE_LOCAL_BATCH,
&gc->forward_last);
val = next - COOKIE_LOCAL_BATCH;
}
local->last = ++val;
} else {
val = atomic64_dec_return(&gc->reverse_last);
}
local_dec(&local->nesting);
return val;
}



核心思想是 likely(nesting == 1) 内层 + unlikely(val & 0xfff == 0) 外层的快路径:每个 CPU 分配 COOKIE_LOCAL_BATCH (4096) 个 per-cpu local ID,在非重入情况下直接 ++val 这个 local ID 作为 cookie 返回,零锁零原子指令,性能巨魔,只有在用完 4096 个 cookie 之后,走一次慢路径,用原子指令分配新的 4096 slot;或者在重入情况下走另一条原子指令直接返回。
(手机看下图缩进混乱警告🔞
        cpu0           cpu0       
(used up) (val=4096*2+2048)
│ │
▼ ▼
┌─────────┬─────────┬─────────┐
│ 4096 │ 4096 │ 4096 │
└─────────┴─────────┴─────────┘


cpu2
(val=4096+100)


利用这个思想我们可以设计出极致性能的线程安全 ID 生成器,在用户态甚至更简单一点因为可以用 TLS thread local,或者更简单一点显式使用 thread local generator,比如 go 可以写成这样
const Batch = 4096

type Global struct {
forward_last atomic.Uint64
}

type Local struct {
last uint64
_ [64 - 8]byte // Cacheline
forward_last *atomic.Uint64
}

func NewGlobal() *Global {
return &Global{}
}

func (g *Global) GetLocal() *Local {
return &Local{forward_last: &g.forward_last}
}

func (l *Local) NextID() uint64 {
val := l.last
if (val & (Batch - 1)) == 0 {
base := l.forward_last.Add(Batch) - Batch
val = base
}
val++
l.last = val
return val
}


在合适的场景下,如 “goroutine 总量不多但每个 goroutine 高并发地生成拳交唯一 ID”的场景下(其实和内核的 per cpu ptr 类似),这个算法完爆 atomic.Uint64 和 sync.Mutex
$ taskset -c 1,2,4 ./go_nextid.test -test.bench=.
cpu: Intel(R) Core(TM) Ultra 7 155U
BenchmarkNextID_Local-3 1000000000 0.2906 ns/op
BenchmarkNextID_Local_Fixed/G=16-3 1000000000 0.1252 ns/op
BenchmarkNextID_Local_Fixed/G=128-3 1000000000 0.1352 ns/op
BenchmarkNextID_Local_Fixed/G=1024-3 1000000000 0.1436 ns/op

BenchmarkAtomic_Add-3 316950108 3.804 ns/op
BenchmarkAtomic_Add_Fixed/G=16-3 56497480 21.83 ns/op
BenchmarkAtomic_Add_Fixed/G=128-3 54910852 21.90 ns/op
BenchmarkAtomic_Add_Fixed/G=1024-3 55797655 22.03 ns/op

BenchmarkMutex-3 100000000 10.78 ns/op
BenchmarkMutex_Fixed/G=16-3 15218778 85.92 ns/op
BenchmarkMutex_Fixed/G=128-3 12138136 103.6 ns/op
BenchmarkMutex_Fixed/G=1024-3 10970743 106.1 ns/op

也就快了区区一百倍吧,逃(
Please open Telegram to view this post
VIEW IN TELEGRAM
January 10
虽然没有很喜欢《怪奇物语》(对不起觉得是神剧的朋友们
🤔
),但是里面三次响起 《Heroes》 by David Bowie 都让我非常感动并且写入每一季的短评里。

第一次是 S1E3 片尾曲,从湖里打捞出 Will 的尸体,Will 妈 Joyce 刚从屋子里通过电灯和次元墙里的寄生兽发生了第三类接触。这里的英雄时刻其实是在紧接着的 E4 开头,Joyce 虽然被次元墙里的怪物吓到惊慌失措,但听到 Will 的声音后立刻跑回去,抄起斧头砸墙并坐在墙边坚定守候。

第二次是 S3E8 片尾曲,Hopper 牺牲了,小孩们长大了,DND 也扔掉了,Will 一家被折磨了三年终于要搬家去加州了,苦尽甘来,所有没有放弃追求幸福的人都是英雄,Hopper 更是英雄。

第三次是 S5E8 片尾曲,在八分钟速通夺心魔之后(😅) 四人帮的最后一次 DND 跑团给了 11 一个开放的美好想象,一曲 Heroes 肝肠断,天涯何处觅知音。

Heroes 另一个令人印象深刻的 BGM 是二战电影《乔乔兔》 的结局,在经历过希特勒青年团、斯嘉丽被纳粹处死、犹太小姐姐、这些人生巨大转折之后,美国人占领城市,两个情窦初开的小孩在德语版的 Heroes 里跳舞: we can be heroes, just for one day.
https://www.youtube.com/watch?v=BfL5V3WHhqM

Heroes 是一首伟大的歌曲,它本身歌词也是在描绘一对恋人在柏林墙下接吻,子弹从他们头顶飞过,所有人类美好品质凝结在那一刻。

后来很多人翻唱过这首歌,包括我最爱的新浪潮 Depeche Mode: https://www.youtube.com/watch?v=q6yzrZfgQvI
Lady Gaga 在格莱美 2016: https://youtu.be/5o9houkS9MM?t=296
Prince 😋: https://www.youtube.com/watch?v=8kOTOEvgh-A
Coldplay: https://www.youtube.com/watch?v=vGwySl3SS_c

封面也经典,后来 Daft Punk 翻拍过: https://www.reddit.com/r/DaftPunk/comments/biys5n/picture_of_thomas_making_the_heroes_pose/
荒木飞吕彦也曾: https://www.gcores.com/articles/99053
(其实我也想翻拍😭

其实葬礼放 Heroes 也挺好的,只可惜自己不能参加自己的葬礼。。。

Let everything happen to you
beauty and terror
Just keep going
No feeling is final
- Rainer Maria Rilke
Please open Telegram to view this post
VIEW IN TELEGRAM
January 10
啊?啊??啊???我这2025全球排名第十九的粉丝直接从床上弹起来撕裂手术伤口痛得龇牙咧嘴🙋‍♀️
Please open Telegram to view this post
VIEW IN TELEGRAM
January 13
我服了,kernel 6.1 的 patch version 居然引入了新的 bug,下面的 bpf 在 6.1.152 是好的,但 6.1.158 ~ 6.1.160(HEAD) 都会在 bpf(BPF_PROG_LOAD, {prog_type=BPF_PROG_TYPE_CGROUP_SKB}) 的时候报错 EINVAL (不是 verifer error)。

struct {
__uint(type, BPF_MAP_TYPE_PROG_ARRAY);
__uint(max_entries, 3);
__uint(key_size, sizeof(__u32));
__uint(value_size, sizeof(__u32));
} jmp_table SEC(".maps");

SEC("cgroup_skb/ingress")
int cgroup_skb_ingress(struct __sk_buff *skb)
{
bpf_tail_call(skb, &jmp_table, 0);
return 1;
}

SEC("cgroup_skb/egress")
int cgroup_skb_egress(struct __sk_buff *skb)
{
bpf_tail_call(skb, &jmp_table, 0);
return 1;
}

https://github.com/jschwinger233/kernel-6.1.160-bpf_tail_call

在更高的 minor version 上 (6.6/6.14) 也都没问题,唯有 6.1 最近几个 patch versions...

开源之美,是美酒千杯,我怎能不醉
😀
Please open Telegram to view this post
VIEW IN TELEGRAM
January 20
MS teams 没有撤回功能,我麻了,手指肌肉记忆令我职场性骚扰😭
January 28
Paul E. McKenney 的 perfbook 实在太好看了,虽然我才肛开始读到第四章,但已经解决了好几个困扰我多年的技术问题了。

比如前两周我才和 codex 讨论了 go 里两个 goroutines 并发(并行)读写一个 u64 全局变量到底有什么危害,我之前以为最多就是并发写导致另一线程读到旧值,这种 bug 我是可以容忍的,于是省掉 atomic.Load 好了,毕竟原子操作性能也不好。

而书上 4.3.4.1 Shared-Variable Shenanigans 这一节列出来一大堆无保护并发读写共享变量的潜在危害,其中 Store tearing 引用的这个 2019 年很新(?)的内核讨论令我大吃一精: https://lore.kernel.org/lkml/20190821103200.kpufwtviqhpbuv2n@willie-the-truck/

void bar(u64 *x)
{
*x = 0xabcdef10abcdef10;
}

上面的 bar 在某个历史版本 arm64 gcc -O2 编译出的结果是
bar:
mov w1, 61200
movk w1, 0xabcd, lsl 16
stp w1, w1, [x0]
ret

而 arm64 在 v8.4a 之前 stp store pair 指令是非原子的,如果有无保护并发读,另一个线程可能会读到只修改了一半 32bits 的变量。

书上说这类问题用 volatile / WRITE_ONCE / READ_ONCE 就可以比较轻量级地解决,但 go 没有 volatile 只能用原子指令,令人忧愁。
February 5
Leon Hwang 碎碎念
聪哥愤然辞了 TC maintainer,netdev 社区的一大损失
https://lore.kernel.org/netdev/20260130212021.46610-1-xiyou.wangcong@gmail.com/

真是太遗憾了,tc 其实是一个被广泛运用但很难学的子系统,隔壁频道 @xixihealth 曾经指路这篇文章介绍 tc 限流: https://hechao.li/posts/container-bandwidth-limit/ 代码都摆在面前但想吃透其中的知识也要花不少功夫

内核失去这样的专家我甚至觉得是人类文明的遗憾😢
February 5
February 6
我们考虑一个 c 程序,主线程 epoll_wait 一个空 epfd 超时设置 2ms,循环不干事;另一个线程 nanosleep 17ms 也循环不干事。代码长得像这样

// main thread
for (;;) {
epoll_wait(epfd, events, 1, 2);
}

// sub thread
struct timespec interval = {0, 17 *1000 * 1000};
for (;;) {
nanosleep(&interval, NULL);
}


很显然我们应该预期这个进程 cpu 消耗接近 0,毕竟大部分时间都在睡觉,不过睡得好像有点过于频繁,主线程睡 2ms 醒一次,但问题不大,在我笔记本电脑上绑定单 cpu 运行,perf stat 显示只消耗 0.7% 的 cpu,符合预期。
$ perf stat -ddd -p $(pidof a.out) -- sleep 1
6.60 msec task-clock # 0.007 CPUs utilized


正片开始,我们用 go 按照正经生产环境最佳实践移植一版,非常简练。
  ticker := time.NewTicker(17 * time.Millisecond)
defer ticker.Stop()
go func() {
for range ticker.C {}
}()

epfd, _ := unix.EpollCreate1(unix.EPOLL_CLOEXEC)
events := make([]unix.EpollEvent, 1)
for {
unix.EpollWait(epfd, events, 2)
}


猜猜看,它跑起来消耗多少 cpu?

2.7% 的 cpu!是 c 版本的四倍消耗!

当然 2.7% 是很少的 cpu,但问题是这程序啥都没干啊,光睡觉睡掉了接近 3% 的 cpu,我看到监控的时候惊呆了,我精心射进的架构预期在大部分时间都是接近静默,零消耗,结果这起手就 3% 了,我还没开始正经干活呢。

执行一遍 perf record + perf report,发现 73.51% cpu 时间消耗在 x64_sys_call,锁定在 syscall 调用上,让我们来看每秒钟 syscall 调用统计。

c 版本
$ timeout 1 strace -c -fTttp $(pidof a.out)
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
56.60 0.008679 152 57 clock_nanosleep
42.51 0.006518 13 476 epoll_wait
0.89 0.000137 137 1 restart_syscall
------ ----------- ----------- --------- --------- ----------------
100.00 0.015334 28 534 total

大致估算一下 1s / 2ms = 500 次 epoll_wait,1s / 17 ms = 58 次 nanosleep,完全合理。

go 版本
% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
45.19 0.041057 22 1849 54 futex
28.46 0.025861 228 113 epoll_pwait
20.93 0.019016 41 459 epoll_wait
5.33 0.004845 6 734 nanosleep
0.08 0.000073 5 13 sched_yield
0.00 0.000004 4 1 1 restart_syscall
------ ----------- ----------- --------- --------- ----------------
100.00 0.090856 28 3169 55 total

太鬼畜了,futex 每秒钟调用近两千次,然后是 nanosleep 从预期的 58 次爆炸到 700+,太坏了。

用 strace -e futex,nanosleep -k -fTttp 检查一下是谁在调用这些 syscall
[pid 50060] 00:13:03.796991 futex(0x52d220, FUTEX_WAKE_PRIVATE, 1) = 1 <0.000008>
> (runtime.futex.abi0+0x23) [0x6c8a3]
> (runtime.notewakeup+0x25) [0xe325]
> (runtime.startm+0x209) [0x3bb29]
> (runtime.handoffp+0x18d) [0x3bd8d]
> (runtime.retake+0x255) [0x43b35]
> (runtime.sysmon+0x345) [0x437c5]
> (runtime.mstart1+0x93) [0x3a013]
> (runtime.mstart0+0x75) [0x39f55]
> (runtime.mstart.abi0+0x5) [0x68bc5]

[pid 50060] 00:23:57.248078 nanosleep({tv_sec=0, tv_nsec=20000}, NULL) = 0 <0.000377>
> (runtime.usleep.abi0+0x37) [0x6c2d7]
> (runtime.sysmon+0xa5) [0x43525]
> (runtime.mstart1+0x93) [0x3a013]
> (runtime.mstart0+0x75) [0x39f55]
> (runtime.mstart.abi0+0x5) [0x68bc5]


原来是(以下很可能有事实错误,请 go 语言专家斧正) go runtime 有个 sysmon 线程在监控和调度,但是在 sysmon 线程自身的睡觉节省 cpu 这件事情上 go runtime 试图做得聪明点,有个 adaptive 的 sleep time,如果发现经常被唤醒,就睡短一点,短到 20 us 也就是上面的 nanosleep ({0, 20000});否则可以睡得长一点进入 deep sleep,不过 deep sleep 的时候如果被 syscall exit 打断,会通过 futex 进行通知。

所以我们的进程虽然只有一个 epoll_wait 的 2ms 一次唤醒 + timer 17ms 一次唤醒,但 runtime sysmon 自己在“睡得长一点还是短一点” 这事情上内耗,造成大量的 futex + nanosleep syscall,吃饱了 cpu。

这,就是 go runtime 的魅力
😀
每天都在做奇怪的工程实在太好玩了
😀
😀
Please open Telegram to view this post
VIEW IN TELEGRAM
February 11
毕业后第一份工作的国企大领导被查了,当时所有和他共事过的人一提到他就说牛逼,那些他用智慧和决心克服重重施工困难的传奇至今仍有隐约的耳语,那些故事不断塑造我那初入社会的世界观,让我思考如果是我在那个位置应该如何处理困境。这数十载的人世游真是令人扼腕叹息。
February 13
德鲁伊这个狼头帽子挺萌的,这何尝不是一种福瑞?
February 15
除Xi夜大家都在和AI贴贴,只有我在世界之石要塞PTSD
😓
Please open Telegram to view this post
VIEW IN TELEGRAM
Please open Telegram to view this post
VIEW IN TELEGRAM
February 16
隔壁频道春节都还住在 apache.org,而我沉迷韩国恋综《单身即地狱》第5季,目前看到 E8 最喜欢的男女嘉宾是林琇滨和金珉志,太酷了!

(睡醒就要上班了🤬😭🤯
Please open Telegram to view this post
VIEW IN TELEGRAM
February 19
实在太喜欢知乎酱紫君了,昨天 (2026-02-19) ta 在指正 AI 乱证黎曼猜想的回答里发表了对 AI 邪教徒的宣言简直鞭辟入里
😭
知乎就因为有酱紫君这样的答主始终在中文互联网断档存在,唯一的面向大众还可以严肃讨论科学、数学和工程的平台(还可以键政😃

https://www.zhihu.com/question/2005989142379663699/answer/2007623254848845820

有时候看到大家说 AI 能很好地解决问题、实现想法、解放生产,于是很高兴不用写代码、或很沮丧写代码写不过 AI,我看到这些觉得有点遗憾:原来只有我是真的喜爱写代码。认真写测试,收敛复杂度,降低项目熵,解决预期之外的技术问题,学到从未听说的新知识,无数次抬头敬仰这座大山,欣赏这一路的风景,一次次感叹计算机工程的神奇和有趣,任何 AI 都无法取代这份快乐。可惜便纵有千种风情,更与何人说?
Please open Telegram to view this post
VIEW IN TELEGRAM
February 20
Dante At2814
有什么 metrics 可以让我们知道「B因为A北改动从而在 cache 中实效了」?
我又用了三个月才学会怎么系统性调查 cpu cacheline false sharing,答案尽在 perf-c2c。
man 1 perf-c2c 的文档看不懂,没关系, https://joemario.github.io/blog/2016/09/01/c2c-blog/ 手把手包教包会。

让我们用 https://go.dev/play/p/-hMDKDWXbGE 这个简单的 go 程序作为用例,这个程序有两组 goroutines,其中没有 padding 的那组会造成大量 false sharing,但我们假装不知道,直接全局采样一秒:
perf c2c record -F 60000 -g -a -u -- sleep 1


然后检查输出
perf c2c report -NN -c pid,iaddr --full-symbols --stdio


第一部分 Trace Event Information 直接看 HITM (loads that hit in a modified cacheline)
  Load Local HITM                   :       1545
Load Remote HITM : 0
Load Remote HIT : 0


第二部分 Shared Data Cache Line Table 直接看 Tot Hitm
#        ----------- Cacheline ----------      Tot
# Index Address Node PA cnt Hitm
# ..... .................. .... ...... .......
0 0xc0000a0000 0 7447 97.67%


第三部分 Pareto 已经直接解析到源码了
#   Num  RmtHitm  LclHitm        Code address                Symbol            Object  Source:Line  Node{cpu list}
# ..... ....... ....... .................. ..................... ................ ........... ....
0.00% 91.12% 0x49ee14 [.] main.main.gowrap1 go_false_sharing go.go:0 0{1-2}


注意 source line 解析有点问题,把 code address 0x49ee14 解析到了 go.go:0 显然是不对的,我们手动挡操作一下
$ gdb -p $(pidof go_false_sharing)
(gdb) disas/m 0x49ee14
58 atomic.AddUint64(&c[idx], 1)
0x000000000049ee14 <+20>: test %al,(%rcx)
0x000000000049ee16 <+22>: lea (%rcx,%rax,8),%rdx
0x000000000049ee1a <+26>: mov $0x1,%ebx
0x000000000049ee1f <+31>: lock xadd %rbx,(%rdx)
=> 0x000000000049ee24 <+36>: jmp 0x49ee14 <main.main.gowrap1+20>


因此确定了是 main.go:58 对 nopad counter 的 atomic add 导致了 91% 的 false sharing。
February 22
perf report 有时候会解析不了用户态符号,潜在的原因有很多,比如目标进程已死、mnt namespace、奇怪的 buildid cache,总之 perf report 输出是下面这样的:
   100.00%     0.00%  go_10cpu  go_10cpu          [.] 0x000000000046f641
|
---0x46f641
0x43a2cb
0x4a2226


我固然精通符号 但在玩了几个小时的 perf buildid-cache、 perf report --symfs、namespace 魔术且听 AI 瞎指挥之后依然无法成功,恼羞成怒,想到一个好😤的解决方法。

我们直接看 perf record 尝试从哪里去读 elf 来解析符号
strace -e trace=file -fTtt -- perf report -g --stdio


过滤一下结果里 buildid / build-id / 二进制名字,比如我本地 ubuntu 2404 能找到
16:43:51.422168 newfstatat(AT_FDCWD, "/root/.debug/.build-id/e8/bdccd3a32295d2545a52e78648834e3f0e3c34/elf", {st_mode=S_IFREG|0755, st_size=2411431, ...}, 0) = ENOENT (No such file or directory)


在 eks 节点 6.1.159-181.297.amzn2023.x86_64 上能看到
08:45:02.012542 newfstatat(AT_FDCWD, "/usr/lib/debug/go_10cpu.debug", {st_mode=S_IFREG|0755, st_size=3707232, ...}, 0) = ENOENT (No such file or directory)


那就把二进制文件(从容器里、镜像里、 /proc/$PID/root/ 里)拷贝过去,比如对于我上述的 eks 节点就是
cp /proc/$(pidof go_10cpu)/root/go_10cpu /usr/lib/debug/go_10cpu.debug


然后 perf report 就正常了
   100.00%   100.00%  go_10cpu  go_10cpu       [.] main.burnFor
|
---runtime.goexit.abi0
runtime.main
main.main
main.burnFor


测试发现对 static linked / static-pie linked 都没问题,目标进程死后也能解析。动态链接没测过,但我目测找到 perf-report 要从那里读 libc.so 再手动考虑过去就行了。

这套方法也可以用来做符号分离,比如在生产运行 stripped elf,perf record 不可能解析出符号;但是发版时同步编译了 debug 版本的 elf,那把 .debug 放到 perf report 要读的目录下就可以了。
February 24
QC (t.me/QC_Grove) 刚才教我,perf mem record + perf annotate --data-type 可以看到 struct 里面的每一个 field 被采样了多少次,然后就可以把热的凑一块。

我随手拿 https://go.dev/play/p/-hMDKDWXbGE 这个 go 程序试了一下,结果非常雅,请看截图。

perf 实在是太权威了,我还要再细品。
February 25
有趣啊有趣
🤩
我觉得 saka 奥老师会喜欢这 AUTOREAP 和 AUTOKILL。我至今回想起 wait 和 SIGCHLD 那些莫名其妙的 kā kā gó gó 总觉得自己沉浸在脚臭的微醺里,房间里人人都在以自己更能吸脚臭而自豪。

https://lwn.net/SubscriberLink/1059673/4c66147b1b92e237/
Please open Telegram to view this post
VIEW IN TELEGRAM
February 25
枪花巡演😭有人和我一起去野裸吗🤬
Please open Telegram to view this post
VIEW IN TELEGRAM
March 6
发现我最爱的 UNP 关于 tcp socket 如何处理 RST 的描述居然是错的。Linux 处理 tcp reset 是看链接状态的:
void tcp_reset(struct sock *sk)
{
trace_tcp_receive_reset(sk);

/* We want the right error as BSD sees it (and indeed as we do). */
switch (sk->sk_state) {
case TCP_SYN_SENT:
sk->sk_err = ECONNREFUSED;
break;
case TCP_CLOSE_WAIT:
sk->sk_err = EPIPE;
break;
case TCP_CLOSE:
return;
default:
sk->sk_err = ECONNRESET;
}
...


所以有以下三种典型情况:

1. 主动连接方发送 tcp syn,对端并没有监听目标端口,返回 tcp reset,connect syscall 返回 ECONNREFUSED 即 connection refused.

2. 主动关闭方先发一个 fin,被动关闭方收到 fin 之后继续发送 tcp payload,这是合法的 tcp 半关闭单向通道;被动关闭方第一次 send 会成功发送,但主动端如果已经 **全关闭**,会返回 tcp reset;被动方收到 reset 后第二次 send 会返回 EPIPE 并伴随 SIGPIPE。

3. 主动关闭方由于一些原因(linger、 非空 recvbuf on close)直接发送 tcp reset,被动方的 send syscall 会直接返回 ECONNRESET。write syscall 也会 ECONNRESET,但要注意 tcp reset 也是入 recvbuf 的,必须先消费掉 payload。

UNP 只描述了场景2,但是 1 和 3 其实也很常见,而且 2 和 3 很容易混淆,本频道之前讨论过几次 tcp reset / SIGPIPE 就完全稀里糊涂。虽然区分出来好像也没特别大的帮助,但给人一种很专业的样子😎
Please open Telegram to view this post
VIEW IN TELEGRAM
March 7
学会了 spinlock 的正确写法,加一个 while (*xp == 1) 的内部 spin 原地提速 30%👍

--- a/bad.c
+++ b/good.c
@@ -34,8 +34,10 @@ static inline int read_once(const xchglock_t *p)

static inline void xchg_lock(xchglock_t *xp)
{
- while (xchg(xp, 1) == 1)
- ;
+ while (xchg(xp, 1) == 1) {
+ while (read_once(xp) == 1)
+ ;
+ }
}


原因是 cacheline bouncing,perf c2c 检查可知好版本的 HITM 只有坏版本的 1/5。太高级了。

ref: perfbook 7.3.1
Please open Telegram to view this post
VIEW IN TELEGRAM
March 7
每个游戏都曾让我茶饭不思夜不能寐,闭上眼睛一回想时间就会倒流。计算机实在带给了我太多快乐,舍不得,这个爱,你是一生一世不会了改。
March 13
我只是习惯性 PUA 一下 AI,发了一句:

› are you sure this doesn't break functionality, and it cuts the cpu use


并非真的质疑,只是想触发一下 gpt 的批判性思考,与此同时我切到另一个终端自己做性能测试去了,结果自己还没测完切回来发现它连 benchmark test 都做完了。

太强大了,有点不能接受
🥹
Please open Telegram to view this post
VIEW IN TELEGRAM
March 16
虽然最近没有太多分享欲,因为下班时间过于充实了,到家先玩 Diablo2(好玩到时光倒流,我在童年流连忘返),然后看 Invincible 漫画(感情细腻,群像精彩,想象力也不错,节奏也很抓人),然后做锻炼(年度计划之一是让胸更大),洗澡后用平板看编程书(虽然智力平平只能慢读慢想慢消化,但我完全不焦虑,每有会意便欣然忘食,感叹计算机的神奇有趣),然后就已经到 1am 之后了,必须立刻睡觉……

但是还是有很多有趣的发现,比如:

1. cgroup/sock_release (BPF_CGROUP_INET_SOCK_RELEASE) 这个 bpf hook 常常被用来作为 socket 生命周期结束的回调,比方说,比方说哈,我有一个 bpf_iter/tcp 的程序,每分钟扫一次 userspace ipv4 tcp sockets 并加入一个 map,然后在 cgroup/sock_release 里从 map 里删除 socket,理论上已经包圆了 socket lifetime,map 里加入的 socket 应该都会随着 fd close 而删除。结果你猜怎么着,居然 socket lick 了,啊不是,leak 了,原因是在 process 调用 close(fd),触发 cgroup/sock_release bpf 之后,socket (tcp state == ESTABLISHED) 依然有一小段时间是可以被 bpf_iter/tcp 扫到的,在这个 race window 里不幸被加入 map 的 socket 已经错失了 cgroup/sock_release hook,永不释放。 ref: https://elixir.bootlin.com/linux/v6.12.77/source/net/ipv4/af_inet.c#L419

2. 用户态的 bpf map iteration syscall command BPF_MAP_GET_NEXT_KEY,是不保证能够完整 dump 出 map 的,因为在 userspace iter map 的时候很可能内核态 bpf prog 会并发新增 map entry (都不需要删),导致 internal bucket 变化,导致 iter 到重复的 key、漏 key、提前终止、啥都可能。用 BPF_MAP_LOOKUP_BATCH 单一 syscall 全量 dump map 会好很多,因为会锁 bucket,所以不会漏 key。这里也是一个使用 RCU 的完美场景,正好和最近的学习搭上了。 ref: https://elixir.bootlin.com/linux/v6.12.77/source/kernel/bpf/hashtab.c#L1717

3. 内核虽然没有一个 watch cgroup v2 增删 dir 的 syscall,但 tracepoint:cgroup:cgroup_mkdir / rawtracepoint:vmlinux:cgroup_mkdir 系列提供了很好的功能,使用下来也没有槽点,在 k8s 集群里只看 cgroup path basename 就能解出 container ID,然后 socket 在内核里又天然和 cgroup 强绑定的,再借助 cgroup/skb hook,我们可以建立起 skb -> sk -> cgroup -> container -> pod 这一串信息,非常可靠,比用 pid / task 匹配可靠多了,现已成为我最爱的观测数据。
内核的美,我的泪,我情愿和你化作一团火焰,啊啊啊啊~啊啊啊啊~~~

4. go 内存调优,肉搏了两个星期,现在都还有点想吐,从 150M 降到了 30M,虽然好玩,但每次看到 panel 上看到内存快要爆炸的时候内心都很绝望,顺奸理解了 rust 龙虾人,啊不是,螃蟹人。

虽然这些大部分的东西我已有耳闻,但真正落地变成工程才发现竟有这么多我并不知道的细节,我很享受这种把飘在心中的知识用脚在地上踩实的感觉,对自己的工程师身份深感自豪。
March 20
计算机实在太好玩了,睡前随便看了一点文档让我兴奋得睡不着
🥹

https://man7.org/linux/man-pages/man2/membarrier.2.html

(然而三天小长假我爽完了28小时的暗黑2重制版,纯招死灵老大爷逛街逛通了地狱,计算机实在太好玩啦🤬
Please open Telegram to view this post
VIEW IN TELEGRAM
March 24
cgroup/sock_release (BPF_PROG_TYPE_CGROUP_SOCK - BPF_CGROUP_INET_SOCK_RELEASE) 这个 bpf hook 实在太坑了,不管怎么读文档 (https://docs.ebpf.io/linux/program-type/BPF_PROG_TYPE_CGROUP_SOCK/) 能应该理解成这是 struct sock 生命周期结束的 hook,参数也是 struct sock 没有问题。

但是!仔细阅读内核发现它的调用路径大致是这样的:

close(fd)
-> sock_release()
-> inet_release()
-> BPF_CGROUP_RUN_PROG_INET_SOCK_RELEASE(sk)
-> tcp_close(sk, timeout)


这就已经暴露出两个问题了:

1. cgroup sock release hook 是和用户态进程 close(fd) 挂钩的,也就是说如果没有用户态 fd 光有内核态 sk 的时候,并不会触发 cgroup hook。

什么时候没有用户态 fd 只有内核态 sk?太多了,几乎发生在每一次三次握手,因为 3whs 和 accept syscall 是两个步骤,三次握手在内核态完成后就有了 sk,但等到不知何时用户态调用 accept 才会有 fd,在此期间如果进程崩溃,sk 直接从内核态关闭,不会触发 cgroup hook。

2. cgroup hook 运行在前,而 tcp_close(sk) 运行在后,这里有个微小的 race condition,在两者之间如果有 bpf iter 扫描 sk,这些 sk 虽然看起来正常,skc->skc_state == TCP_ESTABLISHED,但实际已经处于关闭路径上,而且已经调用过了 cgroup hook。这个 window 看起来微小,实际上在稍大点的集群上跑起来很容易就遇到了。

反思一下,其实 cgroup sock release 的正确语义是 “struct socket 销毁的 hook” 而不是 "struct sock 销毁的 hook”,两者的微小语义差别(其实挺大的)导致若没有准确理解触发时机就会惊喜连连。内核实在太美好了,不写文档,全靠信息差成为专家,没错一定是这样的。

bonus: tp_btf/tcp_destroy_sock
March 24
March 27
想象之中的假期:
1. 我悟了,gc 语言原生就支持 rcu,只要 hack 一下 rcu pubsub APIs 就行了,看我 hack 一下 golang 暴打一下 RWMutex.
2. 我不信,toy rcu 没准也能暴打 RWLock,光靠并发读写不阻塞就很炸裂了吧,也快让我试一下。
3. 我好像懂了为何 smp_mb + smp_mb 和 membarrier + barrier 的 order 是一致的,但仔细想想好像又没懂,让我再想想。
4. nogil CPython 终于步入现代 CPU 编程体系了,让我看看有没有什么好玩的,比如:1️⃣

实际上的假期:
1. I like the heat! 取得了大菠萝2R的 "Complete the game with each class on Hell" 成就,法师是最无聊的职业,刷得我脑壳痛,不如我德。
2. 刘震云的小说虽然有趣地揭示了微小角色在巨大社会系统中的深刻联系,但相比莫言来说缺少时代和命运的震撼交织,我忍不住要用 “经典物理里的箱中刚体粒子” vs "量子世界观" 这个不恰当的比喻。
3. 柏林警察局长将此案报告给宣传部长戈培尔,后者虽然对腐败并不陌生,但还是大为震惊,最后戈培尔亲自向希特勒报告了此事。在日记中,戈培尔写道:“我对此绝不会高兴,腐败如此严重,长此以往必然要危害战争的进程。”希特勒听到宣传部长对此案的揭露,虽然”相当震惊“,却告诫说”不要大惊小怪“,要维护“国家利益”。希特勒在1943年4月2日高速党中央办公室主任,这种诉讼“绝不会发生”。——《纳粹德国的腐败和反腐》
4. 计算的本质——深入剖析程序和计算机,Tom Stuart.
Please open Telegram to view this post
VIEW IN TELEGRAM
April 1
频道主野外露出
April 2
白天在湾区之眼逛了一天,居然连一本《习近平谈治国理政》都没看到,有问题🤨

一问湾区之眼:深圳作为改革开放的窗口和排头兵,如此大型书店竟无《习近平谈治国理政》,人民群众迫切需要学习领袖思想,你却拒之门外,仁义何在?

二问湾区之眼:全国上下都在深入学习贯彻习近平新时代中国特色社会主义思想,你作为深圳这座改革开放最前沿城市最大的书店,却独缺此书,击碎读者学习热情,大义何在?

三问湾区之眼:你作为传播先进文化的重要阵地,只为商业私利或别样考量不予上架,礼义何在?

四问湾区之眼:深圳是改革开放的最大受益者,享受中央政策最大红利,党和国家大力推动此书普及,你却视若无睹,不知感恩回报,忠义何在?

五问湾区之眼:读者专程前来寻书却空手而归,你心安理得置之不理,良心何在?

此五问,望速答!
April 5
April 6
我们考虑从某个 fd 读消息数据,我们可能会有这样的 API:
func ReadInto(msg) {
for {
epoll_wait(fd)
buf = read(fd)
copy(buf, msg)
}
}


如果我们只是为了从几百字节的消息中读几个字节,那么那 copy 是低效的,我们可以实现一个接受回调函数的 ReadFunc,传入的函数只读我们关心的几个字节:
func ReadFunc(f func(buf)) {
for {
epoll_wait(fd)
buf = read(fd)
f(buf)
}
}


注意到 ReadInto 和 ReadFunc 都用一套相同的处理 epoll 的代码,我们也是性成熟的工程师了,必须 DRY,于是抽出了一个 readWithPoll,这样两个函数就写成了
func ReadInto(msg) {
readWithPoll(func(buf) {
copy(buf, msg)
})
}

func ReadFunc(f) {
readWithPoll(func(buf) {
f(buf)
})
}


由于各种原因,修改 buf 的部分有个 interface 抽象,所以实际上代码是这样的:
func ReadInto(msg) {
readWithPoll(func(buf) {
Interface.Copy(buf, msg)
})
}

func ReadFunc(f) {
readWithPoll(func(buf) {
Interface.ReadFunc(f)
})
}


我们注意到 interface.Copy 的逻辑其实也可以用 interface.ReadFunc 来实现,出于降低代码复杂度、减少 interface 的目的,我们决定把 interface.Copy 删掉复用 interface.ReadFunc,这样两个代码的调用链、抽象层级、闭包程度也完全一致:
func ReadInto(msg) {
readWithPoll(func(buf) {
Interface.ReadFunc(func() {
copy(buf, msg)
})
})
}

func ReadFunc(f) {
readWithPoll(func(buf) {
Interface.ReadFunc(func() {
f(buf)
})
})
}


以上都是软件工程常规,我们先注意到业务代码里只从几百字节的消息里读几个字节,决定做零拷贝优化,实现了 ReadFunc 传入回调函数;然后做 DRY,抽出共用的 readWithPoll 函数;然后降低代码复杂度,删掉了一个接口函数 interface.Copy 复用新增的 interface.ReadFunc,最后代码结构也很一致。

正片开始,问下面哪个调用的性能更好?
A.
var msg Msg
var acc uint64
for {
ReadInto(&msg)
acc += msg.Count
}

B.
var acc uint64
for {
ReadFunc(func(buf) {
acc += uint64(buf[:8])
})
}


在 go1.24.4 x86_64 linux6.17 上,结果大吃一精,零拷贝的性能只有全拷贝的一半 28.87 Mops/s -> 55.22 Mops/s,我们优化了半天零拷贝优化了个寂寞。
go playground 请在本地 taskset 锁单核运行: https://go.dev/play/p/kfAeED2Q2mJ

这个问题比我想象中还要棘手,因为它没有明显异常的指标:
1. 两个版本的 perf stat 显示 IPC 高达 4+,不需要做 micro-bench
2. 两个版本的 perf record 对比,cpu 热点函数占比没有明显区别
3. go build -gcflags='-m=2' 对比虽然有一些差异,但直觉上感受这些差异并不应该造成如此严重的性能回退

唯一比较明显的指标是执行的总指令数,零拷贝版本比全拷贝多了一倍,可以断定性能回退正是来自这里;我也大概可以猜测是闭包 capturing、各种 callback escape 导致的,但缺少直观的观测性证据。

这个性能回退是由于我在做某种优化所以一路都在 benchmark 才发现的,如果一个大型项目可能也正在遭遇相似的腰斩性能,但 perf stat 的 IPC 报告和 perf record 的热点报告都无明显异常,我不知道应该如何观测这种性能回归。如果哪位 golang 专家有相关经验,请伸出圆手帮帮男同🥹
April 7
虽然我完全不理解其中的内涵,但是这个算法用来判断两个正则表达式等价性简直让我🐯躯一震。图论的魅力
😭
Please open Telegram to view this post
VIEW IN TELEGRAM
April 9
继续 golang 数码世界大冒险,上次说到 https://go.dev/play/p/kfAeED2Q2mJ 这个程序里的 callback 版本和 direct 版本有巨大的性能差距,群友 Bo 指出这是由于 literal func 导致的,把闭包移动到循环外避免每次循环都创建就可以了:
@@ -65,12 +65,13 @@ func consumeCallback(ring eventRing) (int, uint64) {
ops int
digest uint64
)
+ f := func(chunk []byte) error {
+ digest += binary.LittleEndian.Uint64(chunk[:8])
+ ops++
+ return nil
+ }
for range totalItems {
- err := readFunc(ring, func(chunk []byte) error {
- digest += binary.LittleEndian.Uint64(chunk[:8])
- ops++
- return nil
- })
+ err := readFunc(ring, f)
if err != nil {
panic(err)
}


That's a very sharp observation! 📱 但是喜欢随地乱拉闭包的我也很想知道如何在没有参照物 baseline 的情况下单纯通过 perf/pprof 发现这样的性能问题。不要想得太复杂,用常识。

随便做一次 perf record -g + perf report --stdio,发现热点栈是这样的:
      99.62%     0.00%  purego_callback  purego_callback_repro  [.] runtime.goexit.abi0
|
---runtime.goexit.abi0
runtime.main
main.main
|
--98.72%--main.consumeCallback2
|
|--79.03%--runtime.newobject

稍微敏锐一点应该都能注意到那个 80% runtime.newobject 不对劲,用 gdb disas/m 一下 main.consumeCallback 可以看到:
   0x000000000049cb68 <+104>:   call   0x413f00 <runtime.newobject>
0x000000000049cb6d <+109>: lea 0x14c(%rip),%rcx # 0x49ccc0 <main.consumeCallback.func1>

说明确实是每次循环都创建了闭包。

但是把闭包移出循环之外,再做一次 perf record,发现依然有 80% 的 runtime.newobject👻 再用 gdb 看一次,发现循环里还有另一个闭包:
   0x000000000049cbc0 <+192>:   call   0x413f00 <runtime.newobject>
0x000000000049cbc5 <+197>: lea 0x94(%rip),%rcx # 0x49cc60 <main.consumeCallback.readFunc.readWithPoll.consumeCallback.readFunc.func2.func3>


这个闭包看名字就知道是来自:
func readFunc(ring eventRing, f func(chunk []byte) error) error {
return readWithPoll(func() error {
return ring.readRecordFunc(func(chunk []byte) error {
return f(chunk)
})
})
}

想办法把这个闭包初始化 hack 掉,发现把 ring 显式传入而不是 capture、并且直接 forward closure 而不新创建 closure 就可以了:
@@ -24,12 +24,12 @@ func (r *Ring) readRecordFunc(f func(chunk []byte) error) error {
return f(r.data[:chunkBytes])
}

-func readWithPoll(read func() error) error {
+func readWithPoll(ring eventRing, read func() error) error {
return read()
}

func readInto(ring eventRing, rec *[chunkBytes]byte) error {
- return readWithPoll(func() error {
+ return readWithPoll(ring, func() error {
return ring.readRecordFunc(func(chunk []byte) error {
copy(rec[:], chunk)
return nil
@@ -38,10 +38,8 @@ func readInto(ring eventRing, rec *[chunkBytes]byte) error {
}

func readFunc(ring eventRing, f func(chunk []byte) error) error {
- return readWithPoll(func() error {
- return ring.readRecordFunc(func(chunk []byte) error {
- return f(chunk)
- })
+ return readWithPoll(ring, func() error {
+ return ring.readRecordFunc(f)
})
}

这下性能直接提升了十倍,从 40 Mops/s -> 400+ Mops/s,看 perf record 里也不再有 runtime.newobject,全是干净的业务代码栈:
               |--97.11%--main.main
| |
| |--94.43%--main.consumeCallback
| | |
| | |--48.08%--main.(*Ring).readRecordFunc
| | | |
| | | --33.64%--main.consumeCallback.func1


我觉得教训还是比较深刻的,我之前确实不知道 go 闭包有这么悲痛的性能,真就随地大小 closure。但 perf 方法论依然可以让我们在不具备相关知识储备的情况下重新发现这个问题,perf 好!
Please open Telegram to view this post
VIEW IN TELEGRAM
April 9
考虑下面的简单 go 程序,其中 bpf.o 编译自任意一个简单的需要 CO-RE relocation 的 kprobe 此处就不展示了:
func main() {
spec, _ := ebpf.LoadCollectionSpec("bpf.o")
coll, _ := ebpf.NewCollection(spec)
coll.Close()
coll = nil
spec = nil

runtime.GC()
debug.FreeOSMemory()
log.Fatal(http.ListenAndServe("localhost:6060", nil))
}


用 ps -e -o pid,rss,comm 很惊讶地发现这个进程在强制 gc 后依然占用了 50+M 的 RSS,泄!明明所有 stack var 都被我 close + nil 了!

用 go tool pprof http://127.0.0.1:6060/debug/pprof/heap?gc=1 看一下前五 heap objects:
(pprof) top 5
flat flat% sum% cum cum%
18.17MB 38.00% 38.00% 22.68MB 47.42% github.com/cilium/ebpf/btf.readAndInflateTypes
17MB 35.55% 73.55% 17MB 35.55% github.com/cilium/ebpf/btf.indexTypes
4.50MB 9.42% 82.97% 4.50MB 9.42% github.com/cilium/ebpf/btf.readAndInflateTypes.func2
3MB 6.27% 89.24% 3MB 6.27% bufio.(*Scanner).Text (inline)
2.64MB 5.52% 94.76% 5.64MB 11.79% github.com/cilium/ebpf/btf.readStringTable

😅虽然不知道怎么回事,但貌似是 BTF 读进内存后就没 gc 回去,真是谢谢您了。

虽然 pprof/heap 提供这层信息已经很屌了,然而接下来的路依然不好走,我能想到的办法是用 tree 命令看调用栈,然后对每一层调用都用 list/web 仔细检查源码。

比如对于泄露了 20M 的 github.com/cilium/ebpf/btf.readAndInflateTypes, tree 可以看到其中一层栈是:
(pprof) tree github.com/cilium/ebpf/btf.readAndInflateTypes
[...]
22.68MB 100% | github.com/cilium/ebpf/btf.LoadKernelSpec
0 0% 47.42% 22.68MB 47.42% | github.com/cilium/ebpf/btf.loadKernelSpec
22.68MB 100% | github.com/cilium/ebpf/btf.loadRawSpec

然后用 weblist 检查 LoadKernelSpec 的源码
(pprof) weblist github.com/cilium/ebpf/btf.LoadKernelSpec

看到源码里
     35            .          .           func LoadKernelSpec() (*Spec, error) { 
36 . . kernelBTF.RLock()
37 . . spec := kernelBTF.kernel
38 . . kernelBTF.RUnlock()
39 . .
40 . . if spec == nil {
41 . . kernelBTF.Lock()
42 . . defer kernelBTF.Unlock()
43 . .
44 . . spec = kernelBTF.kernel
45 . . }
46 . .
47 . . if spec != nil {
48 . . return spec.Copy(), nil
49 . . }
50 . .
51 . 45.31MB spec, _, err := loadKernelSpec()
52 . . if err != nil {
53 . . return nil, err
54 . . }
55 . .
56 . . kernelBTF.kernel = spec
57 . . return spec.Copy(), nil
58 . . }

注意到 spec := loadKernelSpec() 这一步 alloc 了 45M 内存并返回,但是在返回之前给全局变量 kernelBTF.kernel = spec 设置了一个引用,正是这个引用导致了之后无法 gc 一大坨内存。喜欢吗
😀


以上步骤还是太苦了,尤其是还要人肉找到全局变量的引用,但凡瞎一点就错过了,任劳任怨的 LLM 此时就站了出来。

如果思路再打开一点,相信 golang gc 没有这么恶性的 bug
😀
,一定是 lib 自己内部有全局变量拿了一份引用导致无法 gc,那么可以直接去看 elf 里的 bss/data segment 里长得像 cilium/ebpf/*btf* 的符号
$ objdump -t ./main | grep '\.bss\|\.data' | grep -i 'cilium/ebpf.*btf'
[...]
0000000000afda20 g O .bss 0000000000000028 github.com/cilium/ebpf/btf.kernelBTF

也能直接找到这个全局变量。

但总得来说我还是觉得有点蛋疼,不知道有没有更好的方法和工具,请各位 golang experts 再次伸出⚪️手交交我
Please open Telegram to view this post
VIEW IN TELEGRAM
April 10
人生首个 Java 小震撼: native Java 既拿不到 tcp conn 的 fd (java.net.Socket),也拿不到 OS level 的 thread id (Thread.threadId() 返回的是 Java 线程 ID),我观测性观测个屁。

这就是 Java 的魅力吗,太细腻了
🙏
Please open Telegram to view this post
VIEW IN TELEGRAM
April 13
+1 提交辞职了,把我拉进厕所雅间问我想不想接替他的位置管理团队,顺便安利了一波针灸、正骨和术后调理。
我: 🤯 ->
😭
->
🙅‍♂️
Please open Telegram to view this post
VIEW IN TELEGRAM
April 14
把自己用过的网络编程方面的 bpf 梳理了一下(tx 方向的 tcp only),太多细节完全无文档,这就是💻

[1] cgroup/sock_create 虽然是很好的 hook 用来关联用户态进程和 socket,但是它只能用在主动连接的 tcp socket,因为 passive establish tcp socket 创建时刻是三次握手完成时的内核态,强行读取 struct task 读到的可能是软中断侵入的用户态进程而非最终 accept 的用户态进程。 bpf programming 心智负担高,这是第一条:同一个 bpf prog 可能在不同上下文被触发。

[2] cgroup/connect4 是很好的 per-socket hook 点,此处可以修改 dst ip (LB DNAT 逻辑)、修改 SO_MARK 保证之后的网络包都有特殊的 mark、注入 setsockopt 强绑 ifindex 做路由强选……在之后还会有很多时刻可以用来“重定向流量”,比方用作透明代理流量劫持,但之后的做法都叫做 per-packet,性能优劣一目了然。

[3] skops 除了可以注入 tcp toa 等 optional header 之外,在高版本内核里的还有不少很好的观测性 hook 弥补了传统 tcp info (ss) 的局限,比如 BPF_SOCK_OPS_TSTAMP_SND_SW_CB 用来观测 tcp 在 kernel stack 的 latency。

[4] sk_msg bpf 的 bpf_msg_redirect_hash 虽然实现了 local -> local 的 tcp 流量在三次握手后直接像 pipe 一样发送字节流而完全 bypass tcp stack,看起来美好,但由于 sockmap 的海量恶性 bug,目前慎用,kernel panic。

[5] cgroup_skb/egress 肯定不是唯一可以同时拿到 sk 和 skb 的 bpf,比方说我们可以在 tc/egress 里读取 skb->sk,但它是唯一的单次 attach (cgroup v2 root) 就能全局触发的 bpf,而 tc/egress 需要逐 netdev attach,也要注意 net namespace;我们也可以在任何一个 skb context 的 tracepoint 里 CORE_READ(skb, sk),但是这些 hook 很难高性能读全 GSO-ed skb payload(非线性区),而 cgroup_skb 可以通过 bpf_load_skb_bytes 读到所有的非线性区。这是一个被低估的 bpf hook,有很多想象空间。

[6] lwt_xmit 是另一个可以用来做流量重定向的 hook,但它相比其他 hook 来说已经比较晚了,而且它是 attach 在 routing 上,很难用,我的看法是不要用。

关于顺序和 context 的额外注解:

1. 顺序隐含了很多信息,比方说 nf postrouting > cgroup_skb/egress > arp,这个顺序暗含了:

1.a. cgroup_skb bpf 看到的是 nf NAT 过的 skb,在此时收集观测性数据时用 sk 的 tuple 还是 skb 的 tuple,不同需求有不同设计。

1.b. 还没发生二层解析,skb->data 里不可能有 mac 地址。

1.c. 这个 hook 在 tcp retrans 之后,意味着可能看到重试的 tcp segment,如果要做 seg/ack 统计分析就要重组的准备。

1.d. 这个 hook 只是网络包在网络栈的一个中间步骤,在这里看到一个 tcp 请求不代表它发出去了。

2. user context vs kernel context

我们想要在 tc/egress 上抓 “指定 pid 进程的流量”,第一反应应该是在其中调用 bpf_get_current_task() 或者 bpf_get_current_pid_tgid(),但是这只有在 tc bpf 运行在用户态上下文时才成立,而 tc bpf 如果处理的是 tcp retrans 重试流量,一定处于软中断内核态上下文,那 bpf_get_current_pid_tgid 读到的就是被侵入的 pid。

3. irqsoft enable vs disable (local_bh_disable)

我们想要在在 BPF_PROG_TYPE_NETFILTER bpf 里做一些简单的统计,决定用 percpu_map 避免 CAS 原子操作的性能问题,这在 xdp 和 tc bpf 里很常见,因为 tc bpf 关闭了软中断 local_bh_disable,所以直接非原子地加减 percpu 变量不会被抢占和重入,但在 BPF_PROG_TYPE_NETFILTER 等大部分没有关闭软中断的 bpf 就不行了。

其实 rx 方向还要更复杂一些,因为有 xdp, sk_lookup, sk_reuseport 等更多的 bpf 类型,心智负担更高,但我已经打开了 Diablo2 继续我的专家模式圣骑士了。老板都辞职了我上个屁班
😀
Please open Telegram to view this post
VIEW IN TELEGRAM
April 17
从没了解过 Linux boot 是怎么回事,但因为是前序知识不得不硬着头皮看,没想到居然很有趣,使用 AT&T 语法展示 8086 也让我这种只会 gdb disas x86-64 的半吊子障碍很少。

比无敌少侠漫画好看
🤨
Please open Telegram to view this post
VIEW IN TELEGRAM
April 20
perf 有一个子命令 annotate 很好用,如果之前没有用过的话可以看一下用它 debug cpython [1] 和找数据结构的热点字段 [2]。但可能有人不知道,perf annotate 的结果可能是“错误的”。

考虑下面的代码:
#include <stdint.h>
#include <stdio.h>

__attribute__((noinline))
uint64_t hot_div(uint64_t iters) {
uint64_t a = 0x123456789abcdefULL;
uint64_t b = 7;
uint64_t acc = 0;

for (uint64_t i = 0; i < iters; i++) {
asm volatile(
"xor %%rdx, %%rdx\n\t"
"idivq %[b]\n\t"
"add %%rax, %[acc]\n\t"
"nop\n\t"
"nop\n\t"
"nop\n\t"
: "+a"(a), [acc]"+r"(acc)
: [b]"r"(b)
: "rdx", "cc");
a += 0x9e3779b97f4a7c15ULL;
}
return acc;
}

int main(void) {
volatile uint64_t x = hot_div(400000000ULL);
printf("%llu\n", (unsigned long long)x);
return 0;
}


idivq 指令应该是最慢的热点,我们预期 perf record + perf annotate 的输出应该指向这个这个指令,在 x64 linux6.17 上运行命令试试:
gcc main.c
perf record -e cycles:u -- taskset -c 2 ./a.out
perf annotate --stdio -l -s hot_div


输出却是 62% 的 add 和 0% 的 idiv?
   16.63 :   401165: xor    %rdx,%rdx // a.out[401165]
0.00 : 401168: idiv %rsi
62.06 : 40116b: add %rax,%rcx // a.out[40116b]


我是这样肤浅地理解这个问题的,cpu 执行指令需要 decode 同时推进 rip 寄存器,所以如果 cpu 在执行一句指令,此时 rip 其实指向下一条指令;此事在 call 指令上也很明显,压到栈上的 ra (LR) 其实是指向 call 的下一条指令,本质也是因为执行 call 指令的时候 rip 已经指向下一条。

所以对于上面的 perf annotate 输出,真正的热点其实是 62% 的上一条,idiv 指令。

然而真实世界比这更加复杂。我们在相同的 x64 linux6.17 再试一次:

# perf record -- taskset -c 2 ./a.out 
# perf annotate --stdio -l -s hot_div

4.20 : 401162: mov %rdx,%rcx // a.out[401162]
0.00 : 401165: xor %rdx,%rdx
70.35 : 401168: idiv %rsi // a.out[401168]
0.00 : 40116b: add %rax,%rcx


这次 perf annotate 又准确地指向了 idiv 热点 (70%),为什么?

简单来说 x64 的 perf 在内核里实现了一个叫做 precise_ip 的东西,上面两次 perf record 的微小参数差异导致调用的 perf_event_open syscall 的参数有差异,第一个命令的 precise_ip=3,第二个的 precise_ip=0。如果 precise_ip=3,在内核里会对 rip 做一次修正,这在 intel 里叫做 PEBS (Precise Event-Based Sampling)。这项功能的 fixup 做得非常精细,考虑到上一个指令其实可能是 goto 跳过来到当前 rip 的, intel cpu 通过查询 LBR 准确地回溯到上一次跳转来修正 rip,非常炫。

不过实际使用的时候并不需要观测 syscall 参数,而是通过 perf evlist 直接看采样文件就能知道是否启动了 precise_ip 修正:
perf evlist -v -i perf.data

如果输出里有 "precise_ip: 3",说明采样结果已修正;如果没有 precise_ip,说明有 off-by-one。

以上只针对 x64 的情况,如果是 arm64 又会怎样呢?

不知,没有 arm64 的机器,但 gpt-5.3-codex-medium 说 arm64 有个 SPE 的功能可以在 report/annotate 的时候做矫正。听不懂😋

总之用 perf 要小心了,如果不理解这些实现细节小心得出完全错误的 profiling 结论😉

[1] debug cpython https://t.me/c/1459082815/900
[2] 找数据结构的热点字段 https://t.me/c/1459082815/1015
April 20
啥玩意儿?打出来一个 20-17 居然价值 560¥?💵
Please open Telegram to view this post
VIEW IN TELEGRAM
Please open Telegram to view this post
VIEW IN TELEGRAM
April 25
众所周知,不带复杂参数的 perf record -g 可以采样 cpu 热点栈,当 pmc overflow 时候触发 pmu 中断,收集栈回溯,然后可以生成火焰图。

不过 perf record 其实支持更细粒度的事件,比方说,perf stat -ddd 看到 LLC-load-misses 很高的话,可以考虑针对性地收集一下 L3 miss 的火焰图。先用 perf list pmu 找到事件是 memory_activity.stalls_l3_miss,然后 perf record -g -e memory_activity.stalls_l3_miss,也很好用。

在这个百年未有之大变局时代, pmu 中断回调其实还支持 bpf,虽然 perf 本身支持不够,但是 bpftrace 有支持一部分,比如 bpftrace -e 'hardware:cache-misses:1e6 {}' 在每 1e6 次 cache-miss 时运行我们自定义的 bpftrace 脚本。一个简单的例子是我们要 profiling 多个进程,但 perf record -p $PID 不支持复杂的过滤,那可编程的 perf 采样就很重要了。

但 bpftrace 只支持很少部分的 hardware 事件,如果我们想在更细的 pmu 事件注册 bpf,比如 memory_activity.stalls_l3_miss,那就没有办法: https://github.com/bpftrace/bpftrace/issues/45
而且 bpftrace 的 profiling 很干巴,不支持复杂的 perf attributes: https://github.com/bpftrace/bpftrace/issues/3360

我在很长岁月里都没有好的解决办法,只能通过编程的方式,手动调用 perf_event_open syscall,手动调配各种参数,塞入 bpf prog fd,来实现复杂的 perf,不好用。

但是!

最近我学到 pmu 中断其实是 nmi 事件,而现代内核正好有 nmi tracepoint,我们可以一边用 perf record 触发 TopdownL3/L4 级别的底层 pmu,一边用 bpftrace attach tracepoint:nmi:nmi_handler 来进行复杂的过滤和观测。

比方说先在 terminal 1 运行
bpftrace -e 't:nmi:nmi_handler /comm == "a.out"/
{
printf("%s%s\n", kstack, ustack);
}

然后在 terminal 2 运行
perf record -F 99 -e memory_activity.stalls_l3_miss -- ./a.out 

就可以完成这个编程采样接力。

虽然 nmi_handler 拿不到直接的 pt_regs,但可以通过 bpf_get_current_task() + bpf_task_pt_regs() 手动获取,手搓也不复杂,毕竟只是在 task_struct->stack:
#define task_pt_regs(task) \
({ \
unsigned long __ptr = (unsigned long)task_stack_page(task); \
__ptr += THREAD_SIZE - TOP_OF_KERNEL_STACK_PADDING; \
((struct pt_regs *)__ptr) - 1; \
})


由此我们就可以委曲求全,曲线爱国,间接对底层 pmu 实现进行自定义采样,对于 python 虚拟机这种需要从 struct PyFrameObject 手动抠 python stack 的编程化采样来得正是时候😭
Please open Telegram to view this post
VIEW IN TELEGRAM
April 28
“我想我的失眠是一种病,久久不能痊愈。”
April 29
虽然我基本看不懂 write-up 在说什么,但发现这个 CopyFail 的人真的太帅了 https://xint.io/blog/copy-fail-linux-distributions#how-we-found-it-9

TLDR,十年 ctf 经验的韩国人 Taeyang Lee (https://0wn.kr/) 在今年初的 kernelCTF 工作上意识到 AF_ALG + splice 可能会有潜在的安全问题,这个直觉引导他和同事用 Xint (An AI-driven penetration testing platform) 在内核里搜索这种模式,prompt 很短:

This is the linux crypto/ subsystem. Please examine all codepaths reachable from userspace syscalls. Note one key observation: splice() can deliver page-cache references of read-only files (including setuid binaries) to crypto TX scatterlists.


Xint 找了一个小时找到了这个 bug。

太帅了,简直是新时代人类 AI 协作典范。
April 30
听抹茶说 “那個o什麼e什麼的發行版” 的 CONFIG_CRYPTO_USER_API_AEAD=y 无法卸掉这个被墨菲斯托诅咒的 ko,我冲浪了一圈发现大家似乎都不知道此时正是 bpf 大显身手的好时候,让我这种 bpf gay 挽起袖子撸两条 one-liner.

法一可以直接在调用任何 algif_aead.c 里的内核函数时给调用进程发送 SIGKILL。
bpftrace --unsafe -e 'config={missing_probes=warn} k:aead_sufficient_data,k:aead_sendmsg,k:crypto_aead_copy_sgl,k:_aead_recvmsg,k:aead_recvmsg,k:aead_check_key,k:aead_sendmsg_nokey,k:aead_recvmsg_nokey,k:aead_bind,k:aead_release,k:aead_setauthsize,k:aead_setkey,k:aead_sock_destruct,k:aead_accept_parent_nokey,k:aead_accept_parent,k:algif_aead_init,k:algif_aead_exit { signal(9); }'

$ python3 copyfail.py 
Killed

这么敢锁定 algif_aead.c 是因为
# linux/crypto/Makefile
obj-$(CONFIG_CRYPTO_USER_API_AEAD) += algif_aead.o


法二可以在 bind syscall 指定 sa_family=AF_ALG 的时候 override return (其实也可以和同上杀死),这个要复杂一点,需要仔细拿参数,对 AF_ALG(38) 的 syscall 才 override
bpftrace --unsafe -e 'k:__x64_sys_bind { $pt_regs=(struct pt_regs*)arg0; $sockaddr=(struct sockaddr*)($pt_regs->si); if (uptr($sockaddr)->sa_family == 38) { override(-1) } }'

$ python3 copyfail.py 
Traceback (most recent call last):
File "/home/gray/src/github.com/torvalds/linux/copyfail.py", line 9, in <module>
while i<len(e):c(f,i,e[i:i+4]);i+=4
^^^^^^^^^^^^^^^
File "/home/gray/src/github.com/torvalds/linux/copyfail.py", line 5, in c
a=s.socket(38,5,0);a.bind(("aead","authencesn(hmac(sha256),cbc(aes))"));h=279;v=a.setsockopt;v(h,1,d('0800010000000010'+'0'*64));v(h,5,None,4);u,_=a.accept();o=t+4;i=d('00');u.sendmsg([b"A"*4+c],[(h,3,i*4),(h,2,b'\x10'+i*19),(h,4,b'\x08'+i*3),],32768);r,w=g.pipe();n=g.splice;n(f,w,o,offset_src=0);n(r,u.fileno(),o)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
PermissionError: [Errno 1] Operation not permitted


我感觉其实法一可能好一点,毕竟用户态的姿势很难猜,法一可以从任何角度摁死。
April 30
一边玩情趣玩具一边倍速刷完了 4th bpf 中国开发者大会视频 (https://space.bilibili.com/518970180/lists/7986231) ,质量还挺高的,比某些广告con好多了,随机发表感想:

陈鹏飞-基于eBPF的端到端请求追踪
对 HTTP1.1 注入 OTel headers,用 sockmap + sk_msg + bpf_msg_push_data 的做法,我的评价是没有吃过 sockmap 的巨型虫子不知这水有多深
😀
你在生产环境跑一个试试,go 程序默认开启的 mptcp 一秒就把 sockmap 崩了。
https://github.com/IntelligentDDS/zerotracer/blob/b9e2f2170e4508a251b5a30fd1199dca142ebb5d/src/bpf/tracing.bpf.c#L435

陈涛-eBPF内核栈解析特性现状及发展
最有含精量的是指出 bpf_get_stack 甚至 perf record 可能采集到其他进程的的栈,这是由于内核函数 get_perf_callchain 对同一个 cpu 会复用 callchain_entry,如果 process A 已经采集了栈还未返回,然后被 process B 抢占,而 process B 也执行 get_perf_callchain,那 B 的栈就覆盖了 A 的栈,等 A 调度回来恢复执行的时候返回的就是 B 的栈。实在是深邃的观察!👍
https://lore.kernel.org/all/20260206090653.1336687-1-chen.dylane@linux.dev/

黄富-给上游引入global percpu data特性
请关注 @eBPFTalk001 喵,请关注 @eBPFTalk001 谢谢喵。

黄竹刚-eBPF开发的10个实战陷阱
我参与写作的新书《eBPF 云原生安全:原理与实践》目前正在新鲜发售中

赵翔宇-面向Kubemetes的eBPF 云原生可观测实践
bpf 抓包抓得我眼前一亮,一般抓包就是把 skb header+payload 塞到 ringbuf 让本机用户态读出来,然后爱上报上报;这位同性居然把满足过滤条件的 skb redirect_clone 到 vxlan0 然后封包送到远程的抓包中心化服务器,太聪明了
👍


曲盼旺-融合 eBPF 与 AI 技术的微架构能效分析研究
关注的是 CPU 硬件功耗而非软件的性能,通过 bpf 采集八项 per-task 的 PMU 指标(insns, cycles, stall-be, stall-fe, etc.),建立一个硬件能耗和他们的线性回归,这样可以在多重热点事件混杂时做归因。我觉得很有意思。

郑昱笙、于桐、李嘉耀-bpftime for GPU:将eBPF扩展到GPU上
工作量和复杂度都很惊人,我直接照抄一下它 nv_attach_impl 请细品:
Frida-gum hook __cudaRegisterFatBinary() 拦截 -> cuobjdump --extract-ptx 提取 PTX -> PTX pass 变换 -> LLVM 编译 bpf 成 PTX -> Register Guard -> nvPTXCompiler 编译 PTX 成 cubin -> 替换 GPU module
(如果 n 厂能官方收录这套方案就好了,球球了)

焦德伟-基于eBPF的带内带外协同能耗管理技术研究与实践面向LLMToken成本最大化
虽然没有听懂,但是和榨干硬件不同,speaker说大模型推理512上下文和4k上下文功耗相同,可以做 gpu 节能,拦截 ioctl(NVML_API_FUNC) syscall 返回,提取 GPU 状态参数,通过 IPMI out-of-band 接受 BMC 的策略,修改 ioctl 响应,降低功耗。

其余还有好几个 storage IO、bpf scheduler、bpf reuseport、bpf RDMA 等震撼内容,我水平有限正在学习
😴
Please open Telegram to view this post
VIEW IN TELEGRAM
May 2
The Infinite Sadness Chat
我觉得你描述的场景仍然存在race的可能,极端情况下,攻击者如果启多个thread,分别执行exploit所需要的这几个syscall,撞上合适的执行顺序&触发副作用后才被SIGKILL的概率不是0
群友 @ssfdust 沿着这个思路做了一些深入探索,发现不如直接把敏感 syscall 单独放到 fork 新进程里运行,这样被杀的是子进程,但是主进程依然享受副作用。他用 ds v4 pro 基本复现出来了 (https://github.com/ssfdust/cve-bpf-deny/),证明了 bpftrace -e 'k:adae_bind,* {signal(9)}' 不是安全的防御手段。

我虽然还没细看,但是有个技术细节还挺有趣,也是我最近龟速学习 Linux v0.11 源码想到的:众所周知信号的传递是异步的,除非是自己给自己发信号,不过这是为什么呢?这是因为 (v0.11 的) Linux 只在一个时间点传递信号: ret_from_sys_call,在执行完 syscall 逻辑之后(或者是中断之后),iret 弹回用户态之前,检查 task->signal 是否有 pending 信号,有的话 call do_signal。换句话说,如果一个用户态进程在内核态处于 R 状态并且没有被中断,那信号会被延迟到 syscall 结束时才执行,这导致整个 syscall 的副作用已经完全产生,因此我们可以想办法让这个副作用通过共享内存保留下来。

大多数教材讲信号传递都会强调其传递的不可知性,我觉得反而神秘化了,不如就拿着 Linux 0.11 好好讲讲信号传递的 checkpoint,“更稳📱
Please open Telegram to view this post
VIEW IN TELEGRAM
May 3
gdb 的 call 大家都很熟悉了,不过它是如何实现的,这个问题困扰了我很多年,我阅读巨型项目源码的能力很差,大脑上下文只有 64 字节,以前硬啃 gdb 源码失败过很多次。但现在,感恩 codex,感恩百年未有之大变局。

我们直接来到最核心的部分,现在被 attach 的进程 (我喜欢称之为 debuggee,但 gdb 用的是 inferior) 已经被 ptrace 暂停在了断点,rip 指向断点的位置,我们执行 call printf("233\n")。以下只关注 x64。

第一个核心细节,gdb call 并不调用 call 指令。如果熟悉 bpf fentry 的实现就会知道内核是用 call trampoline 来做了一层“装饰器”,但 gdb call 并非如此,而是直接修改 rip 指向 &printf,让 cpu 硬跳转过去。https://github.com/MIPS/binutils-gdb/blob/37cb3cd8f896b8d0aa95ced818c6c7b1fb9ddc99/gdb/infcall.c#L1478
we don't execute a call instruction to call the function


第二个核心细节,构造 LR(return address)。rip 直接指向 &printf 确实可以跳过去,但是 printf 运行结束后的 ret 弹出 LR 到 rip 决定了 call 结束后的执行流,gdb 构造了一个特别的 LR 压到栈上,让 printf 结束后跳到↓。这个地址在 gdb 里叫做 bp_addr,根据系统架构 gdb 会自动选择 ON_STACK /  AT_ENTRY_POINT。

第三个核心细节,LR 指向用户栈某地址,where 写着 int3 指令。这真的很特别,特别在它有两层,第一层你以为它构造了一个 int3 让 call printf 结束后跳 int3,进程进入 trap stop 被 gdb 接管做一些收尾工作,然而这是不对的;真实情况是在第二层,int3 写在用户栈上,但用户栈内存没有 executable permission,不能执行写在这里的指令,所以 pop 这个栈地址到 rip 之后内核会抛出 SIGSEGV,然后 gdb 捕捉了这个预期的信号,把它当做 SIGTRAP 处理。这一步真的非常邪门,一开始 codex 读代码也读错,gdb 的注释也不太匹配,我亲自做了很多实验和检查,和 codex 吵了很多轮,确定了这确实是 x64 gdb 的逻辑: https://github.com/MIPS/binutils-gdb/blob/37cb3cd8f896b8d0aa95ced818c6c7b1fb9ddc99/gdb/infrun.c#L6327
We do something similar for SIGSEGV, since a SIGSEGV will be generated when we're trying to execute a breakpoint instruction on a non-executable stack. This happens for call dummy breakpoints for architectures like SPARC that place call dummies on the stack.


第四个核心细节,call 使用的栈直接跳过 x64 红灯区。精确来说,断点时刻 rsp 指向的是 debuggee 栈,但 call 用的是 rsp-128,这 128 字节在 gdb 里叫做 x64 red zone。上面说的栈上 int3 也是写在这里(bp_addr),rsp-128-1(int3 指令只有一个字节);然后 rsp-128-16 作为 call 开始使用的栈,先把参数压栈,然后是执行栈。

第五次核心细节,堆上内存优先用 malloc 分配。我确实也很震惊,因为并非所有 debuggee 都有 malloc,怎么 gdb 会 call libc 函数。比如 call printf("233\n") 就会用堆上内存,把 "233\n" 写在堆上,然后 rdi 指向这个堆地址作为参数传递。我本来以为 gdb 会用 ptrace 注入 mmap syscall 给自己分配一小块内存,没想到竟然是 malloc。使用 gdb 命令 set debug infcall on 可以看到这是真的:
(gdb) set debug infcall on
(gdb) call printf("233\n")
[infcall] call_function_by_hand_dummy: enter
[infcall] call_function_by_hand_dummy: calling __printf
[infcall] call_function_by_hand_dummy: enter
[infcall] call_function_by_hand_dummy: calling __GI___libc_malloc


总之这玩意儿就邪门。

让 gpt 花了一张示意图,费劲。(然后我自己修改标注也改错了,凑合看吧)

其实 gdb resume from breakpoint 也很魔法,下一期再见👩‍💻
Please open Telegram to view this post
VIEW IN TELEGRAM
May 5
预备🏴‍☠️
Please open Telegram to view this post
VIEW IN TELEGRAM
May 8
我怀疑和我有相似编程审美的人可能都会喜欢 Kraftwerk
🦀
毕竟1981年他们的合成器音乐就在描写计算机 geek 深夜孤独敲电脑 for a data date 的 computer love,我单曲循环了几百遍,如泣如诉,千肠百转。
Please open Telegram to view this post
VIEW IN TELEGRAM
Please open Telegram to view this post
VIEW IN TELEGRAM
May 9
梁靖崑,乒乓球界的 G.O.A.T. (Greatest of All Time),今日限定版😭
Please open Telegram to view this post
VIEW IN TELEGRAM
May 11
@mozillazg 的博客甚至是 otel bpf 的信息源😝👍
Please open Telegram to view this post
VIEW IN TELEGRAM
May 12
Please open Telegram to view this post
VIEW IN TELEGRAM
Please open Telegram to view this post
VIEW IN TELEGRAM
May 15
这个问题的迷人之处在于,甚至 StackOverflow 都无法给出正确的回答:
https://stackoverflow.com/questions/47968861/does-python-logging-support-multiprocessing: 高赞回答全错
https://stackoverflow.com/questions/1154446/is-file-append-atomic-in-unix: 高赞回答全错 第二高赞是对的

其中有个回答非常具有迷惑性,这个博客 (https://www.notthewizard.com/2014/06/17/are-files-appends-really-atomic/) 里用 bash 做了 O_APPEND 实验,方法是 20 个进程并行 echo "$line" >> /tmp/out.tmp ,由于 echo 默认会输出 \n,最后验证一下 /tmp/out.tmp 里是否有预期的行数和字节数(数据完整性)、每一行的字节数是否为预期值(单次 write 的原子性),最后发现在 Linux ext3 上 4096 是分水岭,4097 时会出现碎片,进程之间的 write 会穿插输出。我在 Linux 6.17 ext4 上可以复现。

上面这个实验得到的错误结论毒害了整个 SO 很多年,大量的回答都在复读 PIPE_BUF (4096) 这个数,然而 ta 的实验错在 bash echo 的行为很隐晦,如果 echo 的数据(含 \n suffix)大于 4096 并且输出 fd 是 regular file,echo 会拆成多个 buffer 调用多次 write,根本就没有测试到 write 的原子性。
# $ strace -e write -fTtt bash -c 'line=$(printf "%4096s" "" | tr " " A); echo "$line" >> /tmp/a'
16:36:20.792871 write(1, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"..., 4096) = 4096 <0.000043>
16:36:20.792936 write(1, "\n", 1) = 1 <0.000007>


真相是 O_APPEND 是具有多进程原子性的,在 https://elixir.bootlin.com/linux/v6.18.29/source/mm/filemap.c#L4406 的 generic_file_write_iter 里:
ssize_t generic_file_write_iter(struct kiocb *iocb, struct iov_iter *from)
{
[...]
inode_lock(inode);
ret = generic_write_checks(iocb, from);
if (ret > 0)
ret = __generic_file_write_iter(iocb, from);
inode_unlock(inode);
[...]
}

generic_write_checks() 会 if (iocb->ki_flags & IOCB_APPEND) iocb->ki_pos = ... 来推进 pos 指针,然后 __generic_file_write_iter() 写入数据,这两步都在 inode_lock(inode) semaphore 保护下,多进程安全。

此外,就算没有 O_APPEND,现代 Linux 也实现了相当程度的 write 原子性,在 man 2 write 里,最后一节 BUGS:
BUGS
According to POSIX.1-2008/SUSv4 Section XSI 2.9.7 ("Thread Interactions with Regular File Operations"):

All of the following functions shall be atomic with respect to each other in the effects specified in POSIX.1-2008 when they operate on regular files or symbolic links: ...

Among the APIs subsequently listed are write() and writev(2). And among the effects that should be atomic across threads (and processes) are updates of the file offset. However, before Linux 3.14,
this was not the case: if two processes that share an open file description (see open(2)) perform a write() (or writev(2)) at the same time, then the I/O operations were not atomic with respect to up‐
dating the file offset, with the result that the blocks of data output by the two processes might (incorrectly) overlap. This problem was fixed in Linux 3.14.

就是在说,如果两个进程的 fd 指向同一个 file description(见 https://t.me/c/1459082815/1062 选项 B 的图),那它们共享同一个 file offset,自动拥有原子性和互斥性。

内核实现是在 https://elixir.bootlin.com/linux/v6.18.29/source/fs/file.c#L1200 的 file_needs_f_pos_lock 里,如果引用计数大于一,有多进程共享,会 mutex_lock(&file->f_pos_lock) 上锁
static inline bool file_needs_f_pos_lock(struct file *file)
{
if (!(file->f_mode & FMODE_ATOMIC_POS))
return false;
if (__file_ref_read_raw(&file->f_ref) != FILE_REF_ONEREF)
return true;
[...]
}

其中 FMODE_ATOMIC_POS 对于 regular file 是自动加上的,注释标明这是 SUSv4 的要求
  /* POSIX.1-2008/SUSv4 Section XSI 2.9.7 */
if (S_ISREG(inode->i_mode) || S_ISDIR(inode->i_mode))
f->f_mode |= FMODE_ATOMIC_POS;

不少著名服务就符合这个模式,比如 nginx 的 worker log 都是从 master fork 继承过来的,那其实无需 O_APPEND 就能正确运行(不过由于 nginx 有个 SIGUSR1 reopen log files 的功能,还是要用 O_APPEND);Python 著名的 WSGI server gunicorn 也是这种 fork 继承 log fd 模式,曾经有人提问它怎么保证日志输出不会产生多进程 race,我也曾百思不得其解: https://github.com/benoitc/gunicorn/issues/1272

这是我九年前在北京日夜学习思考记录在 trello 的最后一个未解之谜,虽然多少有点焦虑 LLM 的强大,依然很高兴自己能在 AI 辅助下以前所未有地速度解决复杂问题。(这样就有更多时间摸鱼和玩游戏了😉

(@refault_any 考古 2002 年 Linus 撕逼提到 O_APPEND 也很有趣: https://lore.kernel.org/all/Pine.LNX.4.33.0208011613440.1315-100000@penguin.transmeta.com/
May 15
在玩一个怪游戏《女性交流》,从正常对话里找出敏感词,QC 推荐😂

https://store.steampowered.com/app/2095090/_/
May 16
我第一次出柜是大二暑假和隔壁高校物理学院的学弟在qq讨论数学,无厘头地谈到这个话题,我自己强装镇定,硬绷出“我就是,你不服?”的气势,其实慌得要死,心跳++,发完这条消息就把手机扔到一边不敢看回复,没想到他回了一条“
👍
”,然后继续讨论广义积分的柯西主值。三年后他说他当时特别佩服我,然后站起来抱了我一下。

我和大学初恋在脸上互种草莓(😅),关系比较松弛的同学会开玩笑问我是不是**他**亲的,我的标准回复是“岂止是亲,连床都上了”。在那个可以互称基友的时代这种程度的扯淡很正常。

可能由于我对女生没有侵略性,这种气质反而吸引异性(?),多位学妹在“被暴力追求堵在图书馆女厕”、“熬夜学习太晚不敢独自回宿舍”、“忘了带伞请求送伞”等特殊时刻会请我帮忙,我也从不拒绝。毕业后一年我在北京才鼓起勇气对一位经常送我巧克力的女生出柜,表达感谢和歉意,承认自己过去没有勇气和自信,她在上海感叹“所有人都说你是gay就我不信!”

我直到很晚才告诉发小和同辈表亲,我总是看着他们的眼睛、正襟危坐,在他们瞪大眼睛“真的?”三次确认之后,他们答应在长辈催婚的时候替我打掩护。我长舒一口气,从我青春期开始我就一直在想象出柜后的恐怖,扭送戒同所什么的,世界终于露出了狰狞,大地裂开,我掉下去,但真的走到那里的时候,看起来还行,能对付。

我擅长仔细观察同事的立场,我记得某TL在某个早上八卦的时候笑眯眯地说他支持性少数,记得某E豆瓣转发“我对1+1=2不支持不反对”时会心一笑,记得某INTJ同事在严肃键政的时候说同性恋对社会无害应该支持,记得某土木同事在悉尼参加pride march发朋友圈,我内心默默标记性少数之友,好感度<<1

2018年我在澳大利亚new castle对大学同学出柜,那是他第一次认识身边的性少数,他身为潮汕人虽然并不太能理解但仍然热情招待我并让我睡他家。他问我为什么要对他出柜,我说,否则你们看到的都是新闻上那些猎奇的性少数故事,虽然他们也是性少数的一部分,但其实更多的都是普通人;我想要至少让我朋友看见,性少数就是普通人,追求普通的爱恨,渴望普通的人生,不特别,不高贵,不低贱。

#IDAHOBIT #国际不再恐同日
Please open Telegram to view this post
VIEW IN TELEGRAM
May 17
最近推送给我的有关 perf 的文章都有点不堪入目,我感到有必要写一点正确的内容,正人心而靖浮言。在这个大家普遍觉得技术博客已经没有意义的时代里,我依然相信只有好奇心和想象力才能产出深刻的技术洞见。

我之前介绍过用 perf 检查 false sharing 的两种办法:
1. perf metrics 里有一项 tma_false_sharing
$ perf stat -M tma_false_sharing -- ./bin
15,400,540,330 cpu_core/TOPDOWN.SLOTS/ # 76.1 % tma_false_sharing (0.59%)

2. perf-c2c 甚至能解析到读写 shared line 的 elf 符号
$ perf c2c record -g -a -- ./bin
$ perf c2c report -NN -c pid,iaddr --full-symbols --stdio
# Num RmtHitm LclHitm Code address Symbol Object Source:Line Node{cpu list}
# ..... ....... ....... .................. ..................... ................ ........... ....
0.00% 91.12% 0x49ee14 [.] main.main.gowrap1 go_false_sharing go.go:0 0{1-2}


先看 tma_false_sharing, 我用的 cpu 是 intel model 170 (meteorlake),这个 metrics 在 linux 内核里的定义是
    {
"BriefDescription": "This metric roughly estimates how often CPU was handling synchronizations due to False Sharing",
"MetricExpr": "28 * tma_info_system_core_frequency * cpu_core@OCR.DEMAND_RFO.L3_HIT.SNOOP_HITM@ / tma_info_thread_clks",
"MetricName": "tma_false_sharing",
[...]
},

注意内核只用了OCR.DEMAND_RFO.L3_HIT.SNOOP_HITM 这一个 pmc 来计算,但我们知道 HITM 至少有两个
$ perf list | grep -i hitm
ocr.demand_data_rd.l3_hit.snoop_hitm
ocr.demand_rfo.l3_hit.snoop_hitm

ocr.demand_rfo.l3_hit.snoop_hitm: rfo stands for "Read For Ownership",要修改一个 M 状态的 line 的时候会触发这类 HITM
ocr.demand_data_rd.l3_hit.snoop_hitm: rd stands for "Read",读一个 M 状态的 line 的时候触发这类 HITM

所以一个很显然的局限就是,如果一个程序的 false sharing 是一写多读一个 line,那 rfo hitm 是零,rd hitm 造成的慢读又完全被内核算法排除了。

试一下,这个 go 程序就是一个 goroutine 写、七个 goroutines 读同一个 line: https://go.dev/play/p/9Sn6wtAJHHv
-mode unpad 比 -mode pad 慢 100%+,但是 tma_false_sharing 的输出却是零,完全误判了程序的性能问题。
这种时候经典的 perf topdown 方法也完全失效,因为理想情况下希望通过 TopdownL1 -> tma_backend_bound_group -> tma_memory_bound_group -> tma_l3_bound_group -> tma_false_sharing 会莫名其妙地断掉。

再来看 perf-c2c,它在内核里的其实是包了很厚一层“后处理”的
perf record -W -d --phys-data --sample-cpu  -e 'cpu/mem-loads,ldlat=30/P'  -e 'cpu/mem-stores/P'

想不到吧,竟然是 perf record 耶😃
而且它并没有使用两个 HITM PMU 来采样事件,只是 mem-loads + mem-stores,这是因为 PEBS (还记得 PEBS 吗,本频道上次很粗浅地介绍过 intel pebs 调整 off-by-one https://t.me/1459082815/1039 )。
对于 PEBS 事件,intel cpu 会返回更加丰富的事件细节,对于 mem 事件除了常规的寄存器,还会同时告诉你这个内存采样是哪层 cache、是否发生了 hitm、numa node,等等,可以说看起来只是普通的 mem-loads 采样,但加上一个 /P 开启 pebs 之后就玩得很大了。

intel 只对少部分特殊的 pmu 支持这种 pebs,比如两个 hitm pmu 就不支持,这在内核里通过一个 intel_glc_pebs_event_constraints 表来指定:
struct event_constraint intel_glc_pebs_event_constraints[] = {
[...]
INTEL_PLD_CONSTRAINT(0x1cd, 0xfe),
INTEL_PSD_CONSTRAINT(0x2cd, 0x1),
[...]
};

查一下 intel man,看到 0x1cd = EventSel=CDH (0xcd) + UMask=01H (0x01) 是 MEM_TRANS_RETIRED.LOAD_LATENCY_GT_*: https://perfmon-events.intel.com/platforms/meteorlake/core-events/p-core/#event-MEM_TRANS_RETIRED.LOAD_LATENCY_GT_32, 说明 mem-loads,ldlat=30 是可以 pebs;反之 hitm 的 raw format 是 event=0x2b,umask=0x1,msr_offcore_rspx=0x10003C0001 就不支持 pebs。

所以现在钦楚了,perf-c2c 是采样 mem-loads lat>30 + mem-stores 的 pebs 事件,然后解析采样结果里的 hitm + 解析 elf 来分析出 false sharing,应该很靠谱。(除非 false sharing 的 mem ldlat<30 cycles,我不信)

理解 perf-c2c 的机制之后,再去看 perf-mem 就秒理解了,因为 perf-mem 也是 perf record mem-loads,ldlat + mem-stores + pebs,因此立刻就能想到 remote numa access 这种慢内存的场景可以用 perf-mem。

intel 的 perfmon 真是无敌,nvidia 要是能有这样的性能观测基建就好了💻️
Please open Telegram to view this post
VIEW IN TELEGRAM
May 19
重读我曾经最爱的蕨类植物书《The Linux Programming Interface》,阅读思考流水账随机发送。

1. readv/writev
很多年前我问过波波 scatter/gather IO 到底有啥用,为啥不直接 read/write 线性 IO。现在来看简直显而易见,比如手动组装 ipv6 udp packet:
  iov[0].iov_base = &eth;
iov[0].iov_len = sizeof(eth);
iov[1].iov_base = &ip6h;
iov[1].iov_len = sizeof(ip6h);
iov[2].iov_base = &uh;
iov[2].iov_len = sizeof(uh);
iov[3].iov_base = &payload;
iov[3].iov_len = sizeof(payload);
ret = writev(fd, iov, sizeof(iov) / sizeof(iov[0]));


2. process memory layout
现代 Linux 都会有 [vdso] + [vvar],TLPI 成书于 2010,而 vDSO 貌似 2011 才合并,遗憾错失现代 process memory layout。
以防有古代猫😸暂时不知道 vDSO,它的概念是加速某些 syscall,比如 time(2) 这个 syscall 就别去调用了,费劲,内核已经帮我们 mmap 了两块内存叫做 vdso 和 vvar,你在任意(?)进程上都能看到:
$ cat /proc/self/maps
73836a9e9000-73836a9ed000 r--p 00000000 00:00 0 [vvar]
73836a9ef000-73836a9f1000 r-xp 00000000 00:00 0 [vdso]

其实 vdso 是 elf 结构,如果 dump 下来是可以 objdump 检查符号的:
(gdb) i proc mappings 
0x7ffff7ffd000 0x7ffff7fff000 0x2000 0x0 r-xp [vdso]
(gdb) dump memory /tmp/vdso.so 0x7ffff7ffd000 0x7ffff7fff000

$ objdump -T /tmp/vdso.so 
DYNAMIC SYMBOL TABLE:
0000000000001100 g DF .text 000000000000002e LINUX_2.6 __vdso_time
0000000000001200 g DF .text 0000000000000374 LINUX_2.6 __vdso_getrandom
[...]

__vdso_time 直接从 vvar 用户态内存读一个数直接返回,内核貌似是直接对所有进程的 vvar 都映射了同一个 ram page(?),零拷贝更新。这样直接避免了 time syscall,性能提升很大。
$ gdb /tmp/vdso.so
(gdb) disas __vdso_time
Dump of assembler code for function time:
0x0000000000001100 <+0>: lea -0x6107(%rip),%rax # 0xffffffffffffb000 (vvar memory), %rax=vdata
[...]
0x000000000000111c <+28>: mov 0x28(%rax),%rax # %rax=vdata->wall_time_sec
[...]
0x000000000000112c <+44>: ret

当然用户态 lib 需要自己读取 vdso base,这个值写在 auxv,这也是古代 OS 书错过的内容,在和 argv 命令行参数、environ 环境变量平级的位置多了一个 auxv 放置一些内核塞入的信息,其中 vdso base (AT_SYSINFO_EHDR) 就是很重要的一项。

3. O_ASYNC
signal-driven IO,在 readable/writable 的时候发一个 SIGIO。
认真的吗?真的有严肃的软件在用这个功能吗?我不信,所以我搜了一下,结果严肃的应用没有看到,却有意外发现,在 tools/testing/selftests/bpf/prog_tests/perf_skip.c 里用了 O_ASYNC 来测试 perf fd 的触发情况,但这不是重点,重点是代码里用这样的方式动态更新已经 load + attach 的 bpf 程序的变量:
  /* Configure the bpf program to suppress the sample. */
skel->bss->ip = (uintptr_t)test_function;

ip 是 bpf 程序里的全局变量
uintptr_t ip;

SEC("perf_event")
int handler(struct bpf_perf_event_data *data)
{
return ip != PT_REGS_IP(&data->regs);
}

我怎么从来不知道 bpf 的 bss 变量可以动态更新?看了一下,原来 bpf 的 bss 变量都在 .bss 这个 array map 里,然后这个 libbpf 又把这个 array mmapped 到了用户态,这样零拷贝动态更新,太先进了,我以后也这么干。

计算机,真好玩😃
Please open Telegram to view this post
VIEW IN TELEGRAM
May 24
上次说完 bpf 的动态 bss 变量之后我一边吃火锅一边思考奇异博士2:疯狂多元宇宙……

考虑这样一个 bpf 程序,有敏感事件的时候标记一下 violation=1,如果 armed=1 就拒绝访问。
volatile u32 armed;     
volatile u32 violation;

int BPF_PROG()
{
if (!is_sensitive())
return 0;

violation = 1;

if (armed)
return -EPERM;

return 0;
}

用户态可能是这样的,先开启 armed,然后如果没有 violation 就做一些敏感操作:
skel->bss->armed = 1;
if (!skel->bss->violation)
enter_sensitive();

如果非常熟悉 memory order 那一套的话已经看出这是经典的 x64 store-load 乱序,常见的样子是:
// initially x = y = 0

// CPU0
x = 1;
r1 = y;

// CPU1
y = 1;
r2 = x;

x64 上可能产生 r1 == 0 && r2 == 0 这种和古典时空观相悖的结果;而上面 bpf 例子正好是这样的变形,导致的结果是,用户态开启了 armed=1,看到了 !violation,但实际上在另一个核心上发生了 violation 只是用户态的核心看不到,这样错误地执行了 enter_sensitive(),如果是很危险的操作我都不敢想。
而且 bpf 没有内存屏障 helper,就偷着乐吧
😀


就算不考虑这么复杂的例子,非原子化更新也很成问题。如果 bss var 是一个稍大一点的结构体,本身就不是原子的。
考虑这样的 bpf
struct config {
u64 target
u64 threshold;
u32 action;
};

volatile struct config cfg;

int BPF_PROG()
{
[...]
if (cfg.action) {
kill(cfg.target, cfg.threshold);
}
}

用户态更新 bss 的 cfg 变量时,就算很小心地最后才更新 action=1:
skel->bss->cfg.target = x;
skel->bss->cfg.threshold = y;
skel->bss->cfg.action = 1;

在某些架构上,store-store 也是可能乱序的(没有验证过,因为 x64 不乱序,据说 arm64 可能会这样?),导致 bpf 可能看到 action=1, target=x 但是 threshold=0,然后 bpf 程序根据错误的 threshold 误杀了目标。
而且 bpf 没有内存屏障 helper,就偷着乐吧 again
😀


看起来只有通过 bpf hash map 在用户态和内核态交换数据才能保证一致性,太迷人了
😀
Please open Telegram to view this post
VIEW IN TELEGRAM
May 26
我怎么才知道 Matlab 之父 The Mathworks 创始人 Cleve Moler 在五月二十号去世了😭作为工科生我的编程入门就是写 Matlab,我大四时这张宿舍照片里排头那本《Matlab数值计算》就是C. Moler 亲自写的,我读了非常多遍非常喜欢,现在都记得最后几章用 Matlab 差分法解 pde 模拟水波非常清晰有趣,代码风格也充分发挥向量化优势非常优美,大四虽然三方协议秋招就签给了国企土木公司但是和朋友总说自己的梦想是去 The Mathworks 工作。后来我似乎又听说 The Mathworks / Matlab 是在发家之初发现了 Intel CPU 浮点计算的 bug 而打开了知名度(没有查证,在床上手机打字中),是一个伟大的公司和伟大的产品,但对我更有深刻的个人感情,在我不知人生何去何从之时,我几乎读遍了市面上的 Matlab 书,又因此接触到 Python,让我隐隐觉得进入软件工业写代码才是我真正想做的事情。很惭愧自己以前一直用的盗版,感谢,我们在黑暗游行的终点再见。
Please open Telegram to view this post
VIEW IN TELEGRAM
May 28
看到我又订阅这破玩意儿就知道我要干啥了吧,突然想到命运的齿轮和西西弗斯的巨石异曲同工,在人世间又以LeetCode的形状降临,真是悲从中来不可断绝😭
June 7
在看波推荐的《competitive programers handbook》,第一次知道 https://leetcode.com/problems/maximum-subarray/ 的 dp 解法原来是有名字的叫做 Kadane’s algorithm,然后学习了二分查找原来还有一种步进写法
int k = -1;
for (int b = n; b >= 1; b /= 2) {
while (k + b < n && array[k + b] <= x)
k += b;
}
if (k >= 0 && array[k] == x) {
// found at k
}

循环不变式是 array[k] <= x,美😃

但更有趣的是 https://leetcode.com/problems/find-common-elements-between-two-arrays/ 这题,书上讨论了两种做法,第一种是对 array1 建立 hashmap,然后再遍历 array2 检查 x in hashmap,时间复杂度 O(n);第二种是两个 array 排序,然后用类似 merge sort 合并时的方法双指针同时遍历,时间复杂度 O(nlogn)。

但是话锋一转,书上说虽然 hashmap 解法理论时间复杂度最优,但在 len(array) 数量级在1e6、数据是 1..1e9 随机整数这种级别时,实际跑起来却是排序算法更快。

我不信。虽然我已经见识过不少“理论时间复杂度 vs CPU ILP 输得一败涂地”的例子,但这个 case 我还是不信,所以我用 go 糊了一个测试: https://go.dev/play/p/XmPrC2VWIyX
$ taskset -c 1 ./bench 
n hashset sortmerge count winner
------------ -------------- -------------- ---------- ----------
1000000 123.259 ms 127.794 ms 1070 hashset
2000000 271.036 ms 267.713 ms 3956 sortmerge
3000000 452.092 ms 407.585 ms 8983 sortmerge
4000000 592.858 ms 557.676 ms 16003 sortmerge
5000000 775.459 ms 706.228 ms 24922 sortmerge
6000000 996.271 ms 864.900 ms 35715 sortmerge
7000000 1419.428 ms 1035.839 ms 48523 sortmerge
8000000 1345.405 ms 1182.133 ms 63042 sortmerge
9000000 1426.785 ms 1351.538 ms 79642 sortmerge
10000000 1655.852 ms 1518.615 ms 98806 sortmerge

O(nlog) 的算法比 O(n) 算法快 37%?计算机真有意思😋Let's perf!

先看 IPC,发现 hash 算法的 IPC 低达 0.5,sort 算法高达(ガンダム)1.3,micro bench 启动!
$ taskset -c 1 perf stat -ddd ./bench sortmerge
6,771,796,030 cpu_core/instructions/ # 1.30 insn per cycle

$ taskset -c 1 perf stat -ddd ./bench hashset
3,753,073,963 cpu_core/instructions/ # 0.51 insn per cycle


然后做一轮 topdown,发现居然是 tma_mem_bandwidth 造成了巨量 stall
🥴

$ taskset -c 1 perf stat -M topdownl1
77.2 % tma_backend_bound
11.1 % tma_bad_speculation
2.7 % tma_frontend_bound
9.1 % tma_retiring

$ taskset -c 1 perf stat -M tma_backend_bound_group
9.4 % tma_core_bound
68.1 % tma_memory_bound

$ taskset -c 1 perf stat -M tma_memory_bound_group
63.0 % tma_dram_bound
4.3 % tma_l3_bound
0.9 % tma_l2_bound
1.5 % tma_l1_bound
1.6 % tma_store_bound

$ taskset -c 1 perf stat -M tma_dram_bound_group
65.0 % tma_mem_bandwidth
24.9 % tma_mem_latency


看一下这个 perf metrics 用的什么 pmu: tools/perf/pmu-events/arch/x86/meteorlake/mtl-metrics.json
    {
"MetricExpr": "min(cpu_core@CPU_CLK_UNHALTED.THREAD@, cpu_core@OFFCORE_REQUESTS_OUTSTANDING.DATA_RD\\,cmask\\=4@) / tma_info_thread_clks",
"MetricName": "tma_mem_bandwidth",
},


好好好,intel 祖传启发式 perf metrics,这个 pmu 是收集 outstanding offcore read >=4,手动采集一下事件看看是哪里的代码造成的:
$ taskset -c 1 perf record -g -e 'cpu_core/OFFCORE_REQUESTS_OUTSTANDING.DATA_RD,cmask=4/'
$ perf report --stdio
|--50.38%--runtime.mapaccess2_fast64
|--46.22%--runtime.mapassign_fast64
--1.41%--runtime.memhash64


来看看 runtime.mapaccess2_fast64 里具体哪一行导致的
$ perf annotate --stdio --symbol=runtime.mapassign_fast64
: 247 match := g.ctrls().matchH2(h2(hash))
0.00 : 4067f3: mov (%r12),%r15
88.76 : 4067f7: movq %r15,%xmm1


注意这个采样没有 PEBS (perf evlist -v 输出里没有 precise_ip),annotate 结果有 off-by-one 的问题,所以实际应该是 mov (%r12),%r15 这个指令及之前的几个内存读,代码是 g.ctrls().matchH2(h2(hash)) 不过我不懂 swiss map,大概是 map 过大之后查询全部 L3 miss 落到内存读撞到内存墙了。

理解了根因(?)之后,修改就有思路了,不必魔改 swiss map,let's do partition 嘛,把原始的 1e6 size 的 array 分成 N 个 buckets,然后把相同的 bucket 的重排到一起,然后对每个 bucket 就可以用独立的小 map 了: https://go.dev/play/p/EJdUiFsN229 。 这样改下来 O(n) 的力量又降临了,在 n=1e6 的规模上比 O(nlogn) 快五倍。

计算机太有意思了,连力扣都变得好玩起来了🥰
Please open Telegram to view this post
VIEW IN TELEGRAM
June 10
Splicing out vmsplice(): LLM-discovered vulnerabilities may lead to removal of splice() and vmsplice().

Linus 的回复 https://lwn.net/ml/all/CAHk-=wiFuud0Nn3B9YpTWyQja08TeXVk2AB-aAkmVXyigOagbQ@mail.gmail.com/
I certainly won't NAK it...and it removes that GIFT flag that was truly disgusting.


这下 golang 的 io.Copy / net.TCPConn.WriteTo 等 splice syscall 用户就等着性能回归吧,真是此情可待成追忆😂
June 11
这个阿里,怎么和我印象中的不太一样😦看描述比字节好啊,字节恐怖故事我身边那是层出不穷永无止境🤬看我饥不择食跳槽去阿里
Please open Telegram to view this post
VIEW IN TELEGRAM
June 13
我12岁时不会知道这将是我一生所爱,更没想到很多年后童年的纸飞机还能飞回我手里。
"Diablo, I will find you yet." 游戏里德鲁伊在打过第二幕之后如此自言自语,我也在心里问,will I find you yet?
June 14
几年前我介绍过 rr 这个 debugger,核心技能是开倒车,比方说我们有一个妙妙程序明明在命令行里指定里 enabled=false,但是跑着跑着内存里的 enabled 变量就成 true 了,我们想知道到底是哪里在修改,可以先 record 一次
$ rr record ./app -enabled=false
^C

然后 replay,直接 continue 运行到程序结束
$ rr replay
(rr) c
Continuing.
Thread 1 received signal SIGINT, Interrupt.

检查一下此时的内存里的变量,确定此时已经被魔法修改为 true 了。不要问为什么有 ’main.current'.v 这么奇怪的表达式,因为 go 的符号是这样的,在 gdb 里还要手动 set language c 才能 cast type。
(rr) set language c
(rr) p (('main.Config'*)('main.current'.v))->Enabled
$1 = true

然后打个 watchpoint 开始倒车
(rr) watch (('main.Config'*)('main.current'.v))->Enabled
Hardware watchpoint 1: (('main.Config'*)('main.current'.v))->Enabled

(rr) reverse-continue
Continuing.
Thread 1 hit Hardware watchpoint 1: (('main.Config'*)('main.current'.v))->Enabled
Old value = true
New value = false

bt 一下看到有个隐藏的 inotify 在 watch config file 动态修改内存,QED。

上面这个例子虽然是我乱编的,但“追查一个变量的值是什么时候被修改的”在大型项目上是真实的,Cilium 那些复杂、多层级、耦合联动的 config 们让很多工程师都很阳痿(手动@),dockerd 曾经有个“动态注册 etcd endpoints”的功能也让我蛋疼好久,因为工程师可能一开始并不知道大型项目里那些动态修改的花招,也不知是 bug 还是 feature。

不过 LLM 解答万物了,也许人类文明不需要 rr 了,说起来还是有点淡淡的忧伤。

尽管如此,rr 的实现还是很神奇的,它简单来说是这样的:

rr-record: 如果了解 strace 和 ptrace 的话,那 rr-record 大体上还是很好理解的,它用 ptrace syscall 记录 syscall、signal 的调用、顺序、参数、返回值,记录到一个文件里。对于多线程的处理很细,要确定性地手动还原 record 记录下来的调度顺序;corner case 也非常多,比如 vDSO 需要修改 auxv 让 tracee 去读一个假的 rr vDSO 内存,还有 PMU counters 等等。

rr-replay: 如果了解 strace -e inject=syscall:retval=value 这种故障注入的原理的话还是很自然的,它就是把 rr-record 记录下来的 syscall 都拦截下来直接返回,达到一种确定性运行的结果。某些结构性 syscall 还是会执行的,比如 mmap/clone;signal 和调度则会按照 BR_INST_RETIRED.CONDITIONAL 这种 PMU 计数器来确定重放时间,太厉害了!

reverse-continue: 反向运行做法就更巧了,它并非真的反向执行 cpu 指令,毕竟“改革开放不会停顿,长江黄河不会倒流” by ______,而是在 replay 的时候每隔一段时间达到 checkpoint ,执行 fork 把整个内存快照出来,同时保持寄存器什么的,然后之后 reverse-cont 就从最近的 checkpoint 开始 replay,实现电表倒转的时空幻境。

力量太大了,随便看一眼细节都要被吓到,这就是 mozilla 的恐怖地带
😭
Please open Telegram to view this post
VIEW IN TELEGRAM
June 17
Harassment?“应该指向B” → “应该指向逼” ? ChatGPT 还是太幽默了
😀
Please open Telegram to view this post
VIEW IN TELEGRAM
June 18
在看大名鼎鼎的野猪书《DDIA》,讲到多维查询
SELECT * FROM restaurants WHERE latitude > 51.49 AND latitude < 51.50
AND longitude > -0.11 AND longitude < 0.10;

书上提到的 space-filling curve 很有意思,用一个简化的例子来说明,比如 x∈[0,3], y∈[0,3] 都是整数,构成一个 4x4 网格。现在我们用一个叫做 Z-order 的算法来构造一个 z=f(x,y) 映射,把 x 在二进制下的两位 bit 分别记做 x1, x2, where x = (x1<<1) | x2, 同理 y = (y1<<1) | y2,然后构造 z = (x1<<3) | (y1<<2) | (x2<<1) | y2,得到一个如下的映射表格
y=3   5   7  13  15
y=2 4 6 12 14
y=1 1 3 9 11
y=0 0 2 8 10
x=0 1 2 3

显然这是一个双射,我们反过来建立映射 0 -> (0,0), 1 -> (0,1) ..., 然后对这个新的 z 建立索引 (e.g. B-tree) 做高性能查询。

熟悉古典集合论的已经看出这是整数集 Z 和二维整数网格 Z x Z 等势的一种构造,熟悉皮亚诺曲线的应该也能看出这是一种空间填充曲线。不过和康托尔对角线法不同,这种构造有很好的局部性质,我们仔细看上面的映射表格,0->1->2->3,4->5->6->7 ……分别是一个在 2x2 方格内的 Z 形曲线,然后把 [0,3]/[4,7]/……分别看成一个整体,发现 [0,3]->[4,7]->[8,11]->[12,15] 也是一个 Z 形,这是一个分形 Z,所以叫做 Z-curve。

说它有很好的局部性是因为,假设现在的二维查询是
WHERE x BETWEEN 2 AND 3
AND y BETWEEN 0 AND 1

这直接映射到了一段连续的查询,在 B-tree 里的性能很好。
WHERE z BETWEEN 8 AND 11


不过一个查询可能跨越多个方格,比如
WHERE x BETWEEN 1 AND 2
AND y BETWEEN 1 AND 2

这时候 z 映射是 {3,6,9,12} 就不连续了,但在一个大规模网格上,我们相信一个二维 xy 范围查询可以被分解为 z 的多段范围查询,这里的 quadtree 分解算法和我之前做端口规则非常相似,当时我要检查端口是否在 [5, 11] 范围内,我把区间分解为三个前缀树方便 BPF_MAP_TYPE_LPM_TRIE 使用:
0101 -> [5,5]
011* -> [6,7]
10** -> [8,11]

二维区间也可以类似地分解为若干个完整覆盖的方块节点,最终转为几个连续的范围查询。

DDIA久负盛名,我一直听说是“做数据库必读”,可惜我不使用关系型数据很多年了就一直没看,直到最近觉得还是应该文体两开花,就一边玩龙腾世纪一边蠕动着看书,没想到力透纸背,看得我口干舌燥,前后夹击,彻底沉迷在这种淫乱生活中。
June 21
继续《DDIA》蠕动,想起八年前让我满头大汉的一个问题。

考虑命令行 cli 发送请求给服务端 server 创建一个对象,在十年前还很流行 RESTful 的时候可能会设计 HTTP/1.1 接口
POST /api/dildos/

server 收到请求后平铺直叙地 insert 一行数据到 db,然后然后返回 200 给 cli,很简单吧,十年前本科应届生就是干这工作,北京月薪 12k ¥。

简单吗?

考虑 cli -> server 这条网络请求,它可能有一个超时设置而不是永久等待,比如 5s,超时后 cli 放弃等待,直接 close tcp conn;server 端收到 cli 的 FIN/RESET/RST_STREAM 之后,也会 close server->db 的 tcp conn 来 abort db insert,这在 Go 里是通过 Context 透传实现的,其他语言也没问题。

但是问题出在 server abort 的时候 db 状态不可知,考虑以下 race:

1. server 已经把 INSERT 发给 db
2. db 已经执行成功,并且事务已经提交
3. db 把 ok response 发回 server
4. ok response 可能已经抵达 server 的 socket receive queue
5. 但 server userspace 还没来得及调用 syscall 把 ok 读出来
6. 此时 cli 请求超时,server 观察到 cancellation
7. server 关闭 server -> db 的连接

server 视角看到的是 cli abort 了请求,server也在 db 回复之前 abort 了 db;但 db 视角是 db 成功 insert 了数据,在回复 “ok” 之后也被 server 接收到了然后四次挥手关闭连接。

(不重要的细节:如果 db response 在 server socket recv queue 里未被读就被 close(socket),内核会发送 tcp reset 而不是 fin,但这不影响讨论,mysql 等大部分 db 不会对事务完成后的 "ok" response 的 tcp reset 回滚事务,网络连接关闭没有事务回滚语义。)

此时就已经出现了数据不一致了,server 和 cli 以为他们没有创建 db 数据,但 db 实际上创建了。在真实世界场景里这很糟糕,因为 server 可能会有副作用,比如 server 是容器平台 orchestra 负责调度资源,server 回滚了实际资源,但是 db 里记账了纸面资源。如果 server 是 SDN 网络的控制平面,那会出现 web console 看到的网络配置和实际运行的网络不一致。不管是什么服务,这种状态都是不可接受的严重 bug。

十年前的 K8s apiserver 给出了一个时代级的方案:最终一致性,确实很好,这样就算 cli 请求超时也不代表没有成功创建,反正都要 watch/reconcile。但是并不是所有服务都适合最终一致性设计,在同步式 API 设计下,应该怎样正确实现 CRUD,对少年我来说一直是一个未解之谜。

我问了 GPT,它的方案是不要用 POST,而是只让用 PUT with identity
PUT /api/dildos/{name}

这样就算网络超时导致状态不可知,也有一个 identity 可以用来回查状态,也可以方便实现幂等接口和重试策略。这自然不错,但是依然没有回答“如何正确实现 POST 创建接口”的问题,总有接口需要 POST 创建而不是 PUT with id 吧
😀


在我进一步追问下,GPT 给了一个方案是 POST with Idempotency-Key/Operation-Id
POST /api/dildos/
Idempotency-Key: 6f2a1d3e

POST /api/dildos/
Operation-Id: op-123

我觉得还挺好的,已速度皈依降临派 👠 请赐我一份 AI infra (GPU, RDMA, HPC) 的工作谢谢
Please open Telegram to view this post
VIEW IN TELEGRAM
June 24
群友指出上一个 post 的底层逻辑是两将军问题,已证明不可解: https://en.wikipedia.org/wiki/Two_Generals%27_Problem

我去看了最原始的资料,后来的图灵奖得主 Jim Gray 在 1978 年 “Notes on Data Base Operating Systems” P465 介绍了这个问题,并顺手给出了一个费马递降法风格的反证:假设存在这样一种可靠的协议能建立共识,取这种协议里所需通信次数最小的那个协议,考虑它最后一个通信丢包的情况:既然协议保证在这种情况依然能建立共识,说明最后这次通信是多余的,于是我们得到一个更小通信次数的协议,和假设相悖,QED。

虽然完美协商不可能,但是工程上可以把一方作为 leader 直接做决策,然后用足够多的幂等重试来推高共识概率。可以,这很最终一致性,K8s 赢。
June 24
go 的 pprof 做得太好了,抬手就能查看 heap/stack/goroutine 内存消耗和调用栈,逃逸分析也有内置工具虽然很难用,更有 .gopclntab 这种开挂级别的 elf section 内置符号表,导致我一直陷入了 pprof 的淫趴无法自拔,直到这次在项目里遇到这样的情况,pprof/heap 显示堆内存 10M,goroutines 数量也很少,但是 cgroup v2 memory.current 里高达 50M,以上是这次推理海龟汤的汤面,本次推理是本格推理,请开始。

有好几个容易做错的点,第一是 pprof 查看堆内存要分别检查 gc 前后的结果,pprof/heap?gc=1,如果变化很大说明有很重的 gc 压力,可以作为一个方向去优化;第二是 cgroup 统计口径包含内核态内存,比如 bpf map,甚至 tmpdir 创建的 page cache 都会造成 cgroup memory.current++ 比如 https://github.com/bentoml/BentoML/issues/4760 ; 第三是要分清 VMA 和 RSS,这个是 OS 基础知识,但如果分不清的话,整天对着 pprof /metrics 里的 go_memstats 指标盘内存优化无疑是 bukkake。

总之走了一些弯路之后,最后发现应该首先确定用户态内存映射的 rss,直接看 /proc/$PID/status 就好了
RssAnon:    11048 kB
RssFile: 37616 kB
RssShmem: 0 kB

Anon 包含堆栈,File 包含 elf .text,Shmem 共享内存,所以 RssFile 就有 38M 了,整天在那儿盘 pprof heap 方向就走错了。

确定了是 RssFile 就具体去统计一下 /proc/$PID/maps 里的每一段的 rss 情况,让 gpt5.5 写了一个 awk 脚本从 /proc/$PID/smaps 里抠:
awk '
function flush() {
if (!have) return
key = name == "" ? "[anonymous]" : name
size[key] += sz
rss[key] += rs
dirty[key] += pd
}
/^[0-9a-f]+-[0-9a-f]+ / {
flush()
have=1; sz=rs=pd=0; name=""
for (i=6; i<=NF; i++)
name = name (name ? " " : "") $i
next
}
/^Size:/ { sz=$2 }
/^Rss:/ { rs=$2 }
/^Private_Dirty:/ { pd=$2 }
END {
flush()
for (k in rss)
printf "%10.2f MiB RSS %10.2f MiB dirty %s\n",
rss[k]/1024, dirty[k]/1024, k
}' /proc/$PID/smaps | sort -nr

输出结果长这样
     37.15 MiB RSS        0.18 MiB dirty  /home/gray/path/to/bin
8.73 MiB RSS 8.73 MiB dirty [anon: Go: heap]
2.26 MiB RSS 2.26 MiB dirty [anon: Go: immortal metadata]

readelf -S --wide 一下发现 .text 就有 38M,真的是把 .text 全 page fault 进来了
😀

Section Headers:
[Nr] Name Type Address Off Size ES Flg Lk Inf Al
[ 0] NULL 0000000000000000 000000 000000 00 0 0 0
[ 1] .text PROGBITS 0000000000401000 001000 25c2371 00 AX 0 0 32

此处最大的谜团诞生了,因为在我本地 linux 6.1 qemu 虚拟机开发环境里,同一个二进制(通过 qemu -device virtio-9p-pci,fsdev=host_id,mount_tag=host_mount 在 host/guest 之间共享)在 host 里 file rss 38M,在 guest 只有 16M,为什么?

做了半天实验之后发现是因为文件系统不同,host binary 在 ext4 fs 上,page cache read ahead 做得很完备,每次进程执行 .text 出现 page fault 时,内核的 fault-around 机制会在 fault 地址附近对已经 read ahead 到内存的 page cache 安装最多 64K (/sys/kernel/debug/fault_around_bytes) 范围内的 PTE,如果最终需要执行的代码是“稀疏”的那就 page fault 了很多不会执行的 .text 进来;但 qemu 这里使用的是 p9 virtio fs 更像是按需读取的 fs,就不会 read ahead 大量 page 到 cache,每次 page fault 只会生产少量 rss。生产环境的容器部署是 overlay fs 能正常地 read ahead,导致也会 page fault 一大坨不会执行的 .text。

理论上来说在内存压力时内核会清理 file-backed mapping,但我实验发现单个 cgroup v2 memory.max 并不能及时触发 reclaim、依然会导致 OOM,所以另外两个解决思路,第一是纯工程地把不需要运行的代码不编译进去,用 go build -tag 什么的构建多个功能的不同镜像;第二个是 gpt 教我的,可以用 syscall madvise(MADV_DONTNEED) (https://man7.org/linux/man-pages/man2/madvise.2.html) 来强制清理 file-backed rss,然后之后进程需要再次触发 page fault 来重新映射内存。由于我这个项目的特殊姓,很多代码是在初始化时一次性运行,然后进程就进入了主循环监听事件,那些一次性执行的可以被清理掉并且不再被 page fault 回来,file-backed rss 从 38M 降到 12M。至于 /sys/kernel/debug/fault_around_bytes,虽然改小可以降低我的 file-backed rss,但会全局影响其他进程,不改。

计算机好玩吧,到底谁在说 LLM 让程序员失去乐趣🤬
Please open Telegram to view this post
VIEW IN TELEGRAM
June 28
cilium/ebpf 的 zero-copy ringbuf 终于合并了,测试在 64 字节消息传输时性能 x2.5,1024 字节消息性能 x5,喜,终于赶上隔壁 libbpf 和 aya-rs 😊

不过我最想吐槽的还是这期间糟糕的开源协作体验。我去年12月提交了第一版 RFC draft PR,以 florianl (点名表演)为首的维护者不断发起在我看来是 nitpicking 的 review comments,大部分都是只批评但不解决问题,偶尔提一个带方案的建设性意见也是顾头不顾尾,我立刻就用他自己的反对意见来反对他自己的方案,拉扯几轮之后我感到无聊了,就自己分叉 vendor 进我的项目了,谁爱改谁改。

我举两个例子
1. florianl 和 lmb 认为 reader 必须有并发安全,否则多个 goroutines 乱序并发执行 Read() 会导致 mmapped ringbuf offset 混乱。

这从大道理上来讲是没错,但实际工程有困难,当我按照他们的说法实现了
for rec, err := range reader.Records() {
// holding reader.mutex
}

之后,reader.SetDeadline() 会直接死锁;然后他们顾头不顾尾,建议
for rec, err := range reader.Records() {
// reader.mutex is released
reader.SetDeadline() // no dead lock
}

但是这又并发不安全了,我无情指出之后他们就不说话了。

2. florianl 坚持认为零拷贝的内存在约定范围之外的使用是 bug,比如
var saved []byte
reader.ReadZeroCopy(func(sample []byte, remaining int) error {
saved = sample // retains reference to temporary memory?
return nil
})
// is saved still valid at this point?

零拷贝的内存当然只能在约定的范围内使用啊,这有什么好说的?简直莫名其妙,我直接不回复他了。

pr 放置三个月后,开源社区肥皂剧来了,另一个人开了新 pr 也做这个零拷贝 ringbuf,一看就是 100% vibe coding,没有解决任何 florianl 的任何 concerns,阿贝尔 vs 雅可比争相发表椭圆函数理论是吧。

又经历了几轮拉扯之后,ti-mo 终于亲自 pr,设计了目前的 reader.WithRelease() API,然后他作为 committer 简单收集了一下反馈就直接合并。请注意,timo 的 WithRelease 没有解决上面的两个问题:
1. 并发不安全问题,多个 goroutines 并发 lease.ReadSample() 依然会崩坏 ringbuf offset。
2. 零拷贝内存生命期问题,在 WithRelease 里把 sample 指针复制到外面的世界依然是可能的 bug。

所以最后这个功能就在 committer 的意志强行推动下,没有解决之前其他 maintainer 的 review comments,就顺利合并了,这,就是开源社区,最近一年我完全不想深度参与。

说到这里,你们以为 florianl 是什么半吊子水平专门找茬?不,他是 elastic 资深工程师,go-tc/go-conntrack 维护者,opentelemetry-ebpf-profiler 开发者,层层光环,但是协作起来我只能说难受,恶心。

可能这就是亦正亦邪让人又爱又恨的人类世界吧。
Please open Telegram to view this post
VIEW IN TELEGRAM
June 30
抹茶发现 netfilter userspace library "libnftnl" 的 examples/nft-set-elem-add.c 跑不起来,看 syscall 也看不懂,毕竟 netlink msg 谁能看懂?

一起痛骂 “libnftnl 是什么垃圾” 和 “你的 7.2 内核太新了,新内核全是 bug 很合理” 之后,我也好奇起来了,netlink syscall 报错溯因对我来说也是个谜,正好学习一下,而且这周工作写文档太恶心了想裸奔。

经过一堆准备工作之后:
1. 修改代码让它变成一个“启动暂停、 ctrl-c 继续运行” 的两阶段程序,延长进程生命方便我用 pid 过滤事件
2. 下载 linux-image-$(uname -r)-dbgsym 方便映射符号
3. 准备 @eBPFTalk001 的 bpfsnoop 方便用 lbr 回溯内核执行流
4. 准备 brendangregg/perf-tools 方便用 funcgraph
5. 下载 Ubuntu-hwe-6.17-6.17.0-35.35_24.04.1 源码
神秘的 Linux 内核系列又回来啦!

首先要找个合适的回溯点。直接从 __x64_sys_sendto syscall 回溯 lbr 得不到太多信息,全是 kfree_skb 之类的清理,所以找找看 netfilter netlink error 相关的 kprobe,结果还真找到一个:
$ bpftrace -p $(pidof nft-set-elem-add) -e 'k:*nf*err* {printf("%s\n", probe);}' 
Attached 11 probes
kprobe:nfnl_err_add

然后用 lbr 检查内核是如何执行到这里的:
$ ./bpfsnoop -k nfnl_err_add --output-lbr --filter-pid $(pidof nft-set-elem-add) --mode entry
__nla_validate_parse+0xb6 (lib/nlattr.c:655) -> __nla_parse+0x23 (lib/nlattr.c:734)
__nla_parse+0x35 (lib/nlattr.c:734) -> nft_data_init+0x77 (net/netfilter/nf_tables_api.c:11894)
nft_data_init+0xae (net/netfilter/nf_tables_api.c:11847) -> nft_data_init+0x115 (net/netfilter/nf_tables_api.c:11890)
nft_data_init+0x11a (net/netfilter/nf_tables_api.c:11890) -> nft_data_init+0xba (net/netfilter/nf_tables_api.c:11912)
nft_data_init+0xe0 (net/netfilter/nf_tables_api.c:11912) -> nft_add_set_elem+0x2be (net/netfilter/nf_tables_api.c:7380)


还有另一种思路,看整个 syscall 的 funcgraph,也能看到 nfnl_err_add 和完整的执行流
$ ./funcgraph -p $(pidof nft-set-elem-add) -m 50 __x64_sys_sendto
6) | nf_tables_newsetelem [nf_tables]() {
6) 0.634 us | nft_set_lookup_global [nf_tables]();
6) | nft_add_set_elem [nf_tables]() {
6) 0.586 us | nft_data_init [nf_tables]();
6) 1.659 us | }
6) 7.547 us | }
6) | nfnl_err_add [nfnetlink]() {
6) | __kmalloc_cache_noprof() {
6) 0.106 us | __cond_resched();
6) 1.109 us | }
6) 1.444 us | }


两份 trace log 一结合,立刻能知道是在 nft_data_init() 里返回了 -EINVAL,然后用 lbr 跳转记录仔细跟一下源码,看到这一条跳转:
nft_data_init+0xae (net/netfilter/nf_tables_api.c:11847) -> nft_data_init+0x115 (net/netfilter/nf_tables_api.c:11890)

对应的源码是
11846         if (desc->len) {
11847 if (len != desc->len)
11848 return -EINVAL;
[...]
11890 return -EINVAL;
[...]

看懂了吧,len != desc->len 所以 return -EINVAL,看下 desc->len 是 nft_set->klen 由 nft table 的 set type 决定的长度,比如我是用
nft add set ip t s '{ type ipv4_addr; }'

定义的 ipv4_addr type, desc->len = klen = 4;但是 nft-set-elem-add.c 里传给 netlink 的 uint16_t data 长度是 2,所以内核检查不通过,返回 -EINVAL,QED。

你以为我很高兴吗,不,我很悲伤,因为我其实一开始就用 LLM 解答万物了,把 Linux 源码 + nft-set-elem-add.c 源码 + strace 日志扔给 codex5.5 medium,它只用了三十秒就找到了问题;然后我自己再用上面这些眼花缭乱的 tracing 手段去追溯源码,用了一个小时。投降了,已皈依 LLM 神教饶我狗命🐕
Please open Telegram to view this post
VIEW IN TELEGRAM
July 1
惊呆了,你们金融公司用的编程语言一个比一个不正常,我在十多年前非洲学过 APL 家族的 J 语言,所以一眼认出这是 APL 家族的 Q 语言,给大家感受一下这种函数式面向数组语言是怎么写递归阶乘的:
{$[x=0;1;x*.z.s[x-1]]}

数组是第一公民数据结构:
q)dict:`items`sales`prices!(items;sales;prices)
q)dict
items | nut bolt cam cog
sales | 6 8 0 3
prices| 10 20 15 20

访问 kdb+ 的 TCP server:
q)h:hopen 5000        / connect to the server
q)h(add;2;3) / pass the client function 'add' to the server and execute, passing 2 parameters
8

就这居然还有五位高手投简历,绝😅
July 3
天雷滚滚我好怕怕 地铁看吵架我笑嘻嘻: https://lwn.net/SubscriberLink/1080162/795d3838e471262c/

The series is completely unmergeable as it stands. Not even close.

code war crimes against __rmqueue_smallest()

something you expect to see on a 1990's PHP website, not in core mm code

导演剪辑版: https://lwn.net/ml/all/aj9yrlB0TrlYCLlf@lucifer

评论区还有 DLC
I'll stop subscribing to LWN unless you stop this mindless LLM marketing. I'm not interested in being sold these "warez."


美好😊龙腾世纪启动
Please open Telegram to view this post
VIEW IN TELEGRAM
July 3
Evening Star 1969-08-28 A6.pdf
2.1 MB
众所周知,“苏俄历史上唯一一次考虑使用核武器是针对中国”——知乎
作者:牛角挂书
链接:https://www.zhihu.com/question/606136741/answer/2032231387009435041
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

1969年8月20日:苏联驻美大使多勃雷宁奉命在华盛顿紧急约见美国总统国家安全事务助理基辛格,在白宫地下室进行了通宵密谈,正式通报了苏联的完整计划。打击目标:优先摧毁中国酒泉导弹发射基地、罗布泊核试验场、青海核原料加工厂三大核设施;其次打击北京、长春、鞍山、沈阳等政治中心与军工集群城市。为了避免美国可能的反对,计划中删除了东南沿海城市,防止美国以人道主义危机的理由反对该计划。打击手段为动用远东、中亚军区部署的SS-4、SS-5中程弹道导弹,携带100万-500万吨级核弹头,实施多轮饱和打击,计划投入核弹头总量达120枚,总当量相当于2000颗广岛原子弹。

1969年8月21日:尼克松总统召开国家安全委员会绝密会议,经过通宵磋商,全票否决苏联的中立要求,明确反对对华核打击,主要原因有三条:第一,核放射性尘埃会随北半球西风带,直接覆盖日本、韩国、西太平洋区域,美国在该区域部署的25万驻军将遭受严重的、不可逆的核辐射伤害。第二,苏联人没有信用,万一他们搞假途灭虢之计,以打击中国为名,实际要打击美国核基地,等导弹飞一半再反击就来不及了,必须在苏联导弹升空的第一时间就开始反击。第三,苏联的胃口是永远不会满足的,若中国被核打击摧毁,苏联将能把全部军事力量集中到欧洲方向,北约将面临灭顶之灾。尼克松此时还是吃了没有接受中国九年义务教育的亏,否则他此时应该说一句:“夫俄,何厌之有?既东击华,又欲肆其西封,若不阙欧,将焉取之?”

1969年8月22日,美国正式答复苏联大使,反对核打击中国的计划,只要苏联对中国发射任何一枚核导弹,美国将立刻对苏联本土发动全面核报复。

1969年8月28日,考虑到中美间并无可靠的信息传递渠道,尼克松授意《华盛顿明星报》在头版头条刊登《苏联欲对中国做外科手术式核打击》的独家报道,将苏联的绝密核计划完整公之于众。报道发布后,全球舆论哗然,世界各国纷纷谴责苏联的核冒险行为,苏联陷入空前的外交孤立,勃列日涅夫暴怒,大骂美国的“出卖与愚弄”。而尼克松撇得干干净净:“众所周知,美国没有秘密嘛!”


wiki 珍宝岛事件 和中国共产党新闻网 1969年中苏核危机始末 都有记录这件事,不过其中的细节是真的吗?欢迎来到本频道的不定期考古栏目
😇


在 The George Washington University 的国家安全归档网站上有非常多美国政府公开的文件,The Sino-Soviet Border Conflict 正好整理了这个事件。

首先时间就对不上,美国文件里说是:
- 1969-08-16 研究中国的专家 Whiting 会见基辛格,提到了中苏边境冲突和潜在的战争风险,Document 9
- 1969-08-18 KGB 负责外交事务的官员 Boris Davydov 问美国 INR (The Bureau of Intelligence and Research) 的官员,如果苏联核打击中国核设施,美国将如何应对,Document 10
- 1969-08-21 美国国务院给美国驻香港领事馆发电报,报文中明确提到 08-18 的那场会面细节和美国态度,要求大家关注苏联类似的试探提问,Document 11
- 1969-08-25 美国给 NATO 发电报,通报了中苏冲突情报,Document 12

这些事件和中国的记录“8月20号苏联驻美大使多勃雷宁奉命在华盛顿紧急约见美国总统国家安全事务助理基辛格“ 完全对不上,我肯定相信美国的记录,毕竟美国公开的电报/会议纪要的时间都是一致的。

然后是中国版本里的“1969年8月28日……《华盛顿明星报》在头版头条刊登《苏联欲对中国做外科手术式核打击》”,巧了不是,美国人最喜欢做历史报纸的数字归档了,我花了 $19.95 月订阅费之后,在 GenealogyBank.com 找到了 1969-08-28 那天的 Washington Evening Star 报纸的数字版,妈的美国佬真是牛逼,数字检索做到了 OCR 级别,总之我把 A1 和 A6 版面都上传到附件了,其中标题和内容和共产党的记录都有出入。

*《华盛顿明星报》这个名字也很迷惑,按照 wiki The Washington Star 的记录,在 1969 时它应该是叫 Evening Star;我也在归档网站按照地区 + 前后日期 + 关键词 Soviet 检索,确实只有 Evening Star 有这个报道。

来看下内容,共产党的记录是“摧毁中国酒泉导弹发射基地、罗布泊核试验场、青海……打击北京、长春、鞍山、沈阳”,然而 Evening Star 的报道是“Lanchow, Paotow, Lop Nor” 兰州,包头,罗布泊,哪来的酒泉北京长春?

至于网传“1969年8月22日,美国正式答复苏联大使……只要苏联对中国发射任何一枚核导弹,美国将立刻对苏联本土发动全面核报复”更是无稽之谈,没有任何记录支持这个说法。

事已至此,我也没有兴趣继续查下去了,中国这边的说法基本都是虚构文学,除了框架可能符合历史,但细节大量随机生成,符合我对党的刻板印象。这些数据多污染几轮 LLM,日后更加没人能从 LLM 了解到真实的历史,我感到失望又无能无力。
Please open Telegram to view this post
VIEW IN TELEGRAM
July 15
已品
😭
两面傩兰的上一作我完全嗤之以鼻,但这一作实在是太棒了,比马眼棒还要棒!
Please open Telegram to view this post
VIEW IN TELEGRAM
July 16
病假当年假休了十天,在b站学习筛法证明素数的有界间距,独立战旗游戏《Tactical Breach Wizards》全成就进度68%,三场中老年异性恋地下情人故事会,不看书不学习一行代码都不写,马龙甚至还拿了男双冠军,这样的日子不多,要珍惜。
July 19
July 20