diff --git a/README.md b/README.md index a1d8479..f6d3575 100644 --- a/README.md +++ b/README.md @@ -1184,6 +1184,10 @@ type Reflux interface { ## 性能 +> **想看内部是怎么工作的?** [docs/flow.md](docs/flow.md) 用两张流程图讲清了 +> `Get`/`Set` 如何把路径逐段分派到 unsafe 快路径或 reflect 回退, +> 以及每条分支各自的代价。下面只讲结论。 + ### 实现方式 热路径不再逐次走 `reflect` 的按名字段查找,而是: @@ -1231,11 +1235,25 @@ type Reflux interface { | 场景 | 旧版本 | 新版本 | 变化 | |---|---|---|---:| -| `Get("Meta","k")` map 键 | 175.6 ns / 10 allocs | 140.9 ns / 7 allocs | 1.25x | +| `Get("Meta","k")` struct 字段 → map 键 | 175.6 ns / 10 allocs | 140.9 ns / 7 allocs | 1.25x | +| **纯 map 路径** `Get("leaf")`(根就是 map) | 120.7 ns / 8 allocs | 161.7 ns / 8 allocs | **0.75x** | +| **纯 map 路径** `Get("b","c","leaf")` 三层 | 314.4 ns / 20 allocs | 439.5 ns / 20 allocs | **0.72x** | | `New(指针)` 构造 | 16.9 ns / 1 alloc | 25.4 ns / 1 alloc | **0.67x** | -- **map 只快 1.25 倍**: map 没有稳定的内存布局可以做偏移量运算,这条路径完全走 - reflect,而且取出来的值必须拷一份(map 元素不可寻址)。这是设计上的取舍。 +- **map 路径不但没提速,纯 map 场景反而更慢**。map 没有稳定的内存布局可以做 + 偏移量运算,这条路径完全走 reflect;而且新实现的内部表示是"地址 + 类型描述符", + 而 map 元素**不可寻址**,所以每经过一跳 map 都要 `reflect.New` 拷贝一份到堆上 + 才能拿到地址 —— 旧实现直接持有 `reflect.Value`,不需要这次拷贝。 + 层级越深,多出来的拷贝越多。 + + 上面第一行的 1.25x 之所以是正的,是因为第一跳 `Meta` 是 **struct 字段**, + 走了快路径,把第二跳 map 的损失盖过去了。**根是 map、或路径深处全是 map 时, + 收益是负的。** + + **选型建议**: 如果你的数据以 `map[string]any` 为主(比如把数据库查询结果直接 + 存成 map),那么这次重写对你没有收益,反而略有退化 —— 收益全部集中在 + **struct 字段访问**上。 + - **`New` 慢了约 8 ns**: 构造时要查一次类型描述符缓存。这是一次性成本, 换来之后每次 `Get`/`Set` 省下 50-100 ns —— 只要构造后至少访问一次就是净赚。 diff --git a/docs/flow-get.svg b/docs/flow-get.svg new file mode 100644 index 0000000..7709d05 --- /dev/null +++ b/docs/flow-get.svg @@ -0,0 +1,139 @@ + + + + + + + + + + + + + + + + + + + + New(&v) + rfx{ td, ptr, writable } · 24 字节,栈上 + + + + + + Get(path...) + + + + + + WALK 循环 · 全程栈上,零分配 + + + + pathIter.next() + 按 "." 切子串,不新建切片 + + + 每段 seg + + + + normalize() + Ptr → loadPtr 逐层解引用 · Interface → 拆包 + + + + + + step(seg) · 按 td.Kind + + + + + + + + + + + + + + + Struct + lookupField 查偏移 + fieldAt(ptr, off) + + + Slice + parseIndex + sliceElemAt + + + Array + parseIndex + arrayElemAt + + + Map + tryMapFieldValue + boxCopy · 堆拷贝 + + + 其它 / 未导出 + 拒绝, ok = false + → 无效值 + + + + + + + + 还有下一段 → 继续循环 + + + + + 路径走完 + + + + boxed() → &rfx{} + 唯一一次堆分配 · 24 字节 + + + + R 接口 + + + + + Get[T] 泛型直取 + 跳过装箱,直接写进调用方变量 · 0 分配 + diff --git a/docs/flow-set.svg b/docs/flow-set.svg new file mode 100644 index 0000000..a0f85ed --- /dev/null +++ b/docs/flow-set.svg @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + Set(key, val) + splitLastStr → 父路径 + 末段 + + + + + + walk(父路径) + + + + + + 父级存在?且 writable? + unsafe 路径没有 CanSet(),必须自己判 + + + + 否 · 典型是路径穿过 map + + + 整个交回 refx.Set() + map 的取出-修改-写回 SetMapIndex 在那边 + + + + + + + setField · 按 Kind + + + Struct / Slice / Array + + + + ASSIGNFIELD · 四级递降,能多快就多快 + + + ① storeFast + 类型完全一致的 string/int/bool/float64 + 按 *T 直接写内存 · 0 分配 + + + ② 类型精确匹配 + rv.Type() == td.rtype + valueAt().Set(rv),不绕道 + + + ③ 标量目标 + assignReflect + spf13/cast 转换 + 溢出截断等边角行为与旧实现对齐 + + + ④ 复合目标 → refx + 指针 / 切片 / 结构体 / map / interface + 语义琐碎,重写必然出偏差 + + + + + + miss + miss + + + + 命中即返回 + diff --git a/docs/flow.md b/docs/flow.md new file mode 100644 index 0000000..139b84f --- /dev/null +++ b/docs/flow.md @@ -0,0 +1,131 @@ +# 内部流程:路径是怎么被分派的 + +`Get` 和 `Set` 都不直接操作数据,而是把路径拆成段,逐段按当前值的 `Kind` 分派。 +**分派到哪个分支,决定了这一跳走 unsafe 偏移量寻址还是退回 reflect, +也决定了它要不要分配内存。** + +图例: + +| 标记 | 含义 | +|---|---| +| 青色 | unsafe 快路径 —— 偏移量寻址,零分配 | +| 琥珀色 | reflect 回退 —— 有堆拷贝 | +| 红色 | 拒绝 —— 返回无效值或 panic | + +--- + +## 一、取值:`Get(path...)` + +![Get 的分派流程](flow-get.svg) + +主干是 `walk` 的循环:每取出一段路径,先 `normalize` 把指针和 interface 剥掉, +再由 `step` 按 `Kind` 分派。整个循环在栈上进行,只有最后返回给调用方时才装箱一次。 + +### 各步骤 + +| 步骤 | 函数 | 说明 | +|---|---|---| +| 构造 | `New(&v)` | 得到 `rfx{td, ptr, writable}`,24 字节,栈上传递 | +| 取段 | `pathIter.next()` | 按 `.` 切子串,**不新建切片** —— 旧实现的 `expandPath` 每次要分配 4 次 | +| 解引用 | `normalize()` | `Ptr` 走 `loadPtr` 逐层解;`Interface` 退回 reflect 拆包 | +| 分派 | `step(seg)` | 按 `td.Kind` 进入下面五个分支之一 | +| 装箱 | `boxed()` | 唯一一次堆分配,24 字节 | + +### 五个分支 + +| 分支 | 做法 | 代价 | +|---|---|---| +| **Struct** | `lookupField` 查缓存好的偏移量 → `fieldAt(ptr, off)` | 零分配 | +| **Slice** | `parseIndex` → `sliceElemAt` | 零分配 | +| **Array** | `parseIndex` → `arrayElemAt` | 零分配 | +| **Map** | `tryMapFieldValue` → `boxCopy` | **每跳一次堆拷贝** | +| 其它 / 未导出字段 | 拒绝,`ok = false` | 返回无效值 | + +**map 是唯一在循环内部就要堆拷贝的分支。** map 元素不可寻址,而 `rfx` 的内部表示 +需要一个地址,所以每经过一跳 map 都要 `reflect.New` 复制一份。层级越深,拷贝越多 —— +这是纯 map 路径反而比旧实现慢的直接原因(见下面的实测)。 + +未导出字段必须显式拒绝:`reflect.NewAt` 造出来的 Value **不带只读标记**, +放行会让调用方绕过 Go 的导出规则直接读写私有字段。这是 unsafe 路径上最关键的一条防线。 + +--- + +## 二、写入:`Set(key, val)` + +![Set 的分派流程](flow-set.svg) + +写入比取值多两道关:**目标必须可写**,**类型必须对得上**。 +两者任何一个不满足,就把整个操作交回基于 reflect 的旧实现 `refx`。 + +### 为什么不自己实现复合类型的赋值 + +`assignField` 的四级递降是有意的,越往下越通用、也越慢: + +| 级别 | 条件 | 做法 | +|---|---|---| +| ① | 类型完全一致的 `string`/`int`/`bool`/`float64` | 按 `*T` 直接写内存,**0 分配** | +| ② | `rv.Type() == td.rtype` | `valueAt().Set(rv)`,不绕道 | +| ③ | 标量目标 | `assignReflect` + `spf13/cast` 转换 | +| ④ | 复合目标(指针/切片/结构体/map/interface) | **整个交回 `refx`** | + +第 ④ 级不能自己实现。`refx.setValue` 对这些类型有一整套规则: + +- 指针字段是"设置指针**指向的值**"而不是替换指针 +- `[]any` 会逐元素转成目标切片的元素类型 +- `map` 可以按字段名填充进 `struct` + +重新实现一遍必然出偏差 —— 这不是假设,是实际踩过的坑: +第一版自己实现时,`Set("Email", "x")`(`Email` 是 `*string`)会 panic, +而旧实现是正常的。 + +### 父级不可写的典型情况 + +`walk` 定位到父级后如果 `!parent.writable`,整个 `Set` 交回 `refx`。 +最常见的触发是**路径穿过了 map**:map 元素不可寻址,取到的是副本, +直接写副本不会反映到原 map 上。`refx` 有完整的"取出-修改-写回 `SetMapIndex`"逻辑, +复用它而不是重写。 + +--- + +## 三、每条分支的实际代价 + +同样一次 `Get`,走哪条分支决定了它是 63 ns 还是 440 ns。 + +| 分支 | 路径 | 旧实现 | 当前 | 变化 | +|---|---|---|---|---| +| Struct | 三层嵌套字段 | 199.1 ns / 9 allocs | 63.4 ns / 2 allocs | **3.14×** | +| Slice | `Get("Tags","1")` | 132.6 ns / 7 allocs | 47.5 ns / 2 allocs | **2.79×** | +| `Get[T]` | 泛型直取,跳过装箱 | — | 29.8 ns / **0 allocs** | **5.08×** | +| Map | struct 字段 → map 键 | 175.6 ns / 10 allocs | 140.9 ns / 7 allocs | 1.25× | +| Map | **纯 map,三层嵌套** | 314.4 ns / 20 allocs | 439.5 ns / 20 allocs | **0.72×** | + +三点说明: + +1. **纯 map 路径是负收益。** 根就是 map、或路径深处全是 map 时,当前实现比旧版慢约 28%。 + 原因在第一张图里直接看得到:每跳一次 map 都要 `boxCopy`。 + **收益全部集中在 struct 字段访问上** —— 如果你的数据以 `map[string]any` 为主, + 这次重写对你没有收益。 + +2. **那个 1.25× 容易看错。** 它的第一跳 `Meta` 是 **struct 字段**,走了快路径, + 把第二跳 map 的损失盖了过去 —— 不能当成"map 变快了"来读。 + +3. **剩下的 2 次分配是 API 形状的下限。** 一次是返回的 `R` 包装对象(24 字节), + 一次是可变参数切片经接口调用时的逃逸。想彻底避开,用 `Get[T]`。 + +--- + +## 对应源码 + +| 图中节点 | 位置 | +|---|---| +| `walk` / `step` / `normalize` / `fromReflect` | `rfx.go` | +| `Set` / `setField` / `assignField` / `storeFast` | `rfx.go` | +| `pathIter` | `path.go` | +| 类型布局缓存 `rfxDescriptorOf` | `rfx_typedesc.go` | +| `fieldAt` / `sliceElemAt` / `boxCopy` 等全部 unsafe 操作 | `unsafeptr.go` | +| `refx` 慢路径实现 | `rfx_reflect.go` | + +改动这几个函数时记得同步这两张图。SVG 源文件就在本目录下, +是手写的(没有用绘图工具),直接编辑坐标即可。 + +实测环境:Apple M4 Pro / darwin-arm64 / go1.25.5,数字取多轮中位数。