You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@apisix.apache.org by "Yilialinn (via GitHub)" <gi...@apache.org> on 2026/08/28 02:42:11 UTC
[PR] blog: add APISIX throughput regression investigation (en + zh) [apisix-website]
Yilialinn opened a new pull request, #2115:
URL: https://github.com/apache/apisix-website/pull/2115
## Summary
- add synchronized English and Simplified Chinese versions of an APISIX throughput-regression investigation
- preserve the five-step hierarchy from CPU saturation and flame-graph analysis through LuaJIT events and paired A/B tests
- document the Global Rule filtering fix from apache/apisix#13779 and its APISIX 3.18.0 release context
## Validation
- `yarn lint:frontmatter`
- targeted `yarn remark` on both new posts
- CI-version `markdownlint-cli@0.25.0` on both new posts
- repository pre-commit hook (`lint-staged` and `remark`)
- `git diff --check`
- full site build not run locally: 44 GiB free is below this Mac's 45 GiB safety threshold
## Draft blockers
- [ ] add the original full and zoomed flame-graph images to both locales and replace the TODO placeholders
- [ ] confirm final author credits
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@apisix.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] blog: add APISIX throughput regression investigation (en + zh) [apisix-website]
Posted by "AlinsRan (via GitHub)" <gi...@apache.org>.
AlinsRan commented on code in PR #2115:
URL: https://github.com/apache/apisix-website/pull/2115#discussion_r3879889440
##########
blog/zh/blog/2026/08/28/debugging-apisix-throughput-regression.md:
##########
@@ -0,0 +1,234 @@
+---
+title: "火焰图没撒谎,但没直接告诉我们瓶颈:一次 APISIX 吞吐回退定位"
+authors:
+ - name: "Xin Rong"
+ title: "Author"
+ url: "https://github.com/AlinsRan"
+ image_url: "https://github.com/AlinsRan.png"
+ - name: "Yilia Lin"
+ title: "Technical Writer"
+ url: "https://github.com/Yilialinn"
+ image_url: "https://github.com/Yilialinn.png"
+keywords:
+ - Apache APISIX
+ - 火焰图
+ - LuaJIT
+ - 性能优化
+ - 吞吐回退
+description: "火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。"
+tags: [Ecosystem]
+---
+
+火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。
+
+<!--truncate-->
+
+一次吞吐回退中,火焰图最宽的路径并不是最终瓶颈。真正值得追查的两个位置,在 Lua 采样里只有 3.0% 和 3.9%。如果按热点排序,它们根本进不了第一轮优化名单。
+
+这次排查最有价值的不是某个补丁,而是一条证据链:先确认 CPU 确实是瓶颈;再横向看火焰图宽度,建立候选;然后沿调用栈纵向追,看重复调用和公共路径如何放大成本;当火焰图解释不了端到端差距时,继续查 LuaJIT 编译与中断事件;最后用每请求调用次数和配对 A/B 实验定量。
+
+> 数据范围:下文数据来自同一受控环境中的一个 APISIX 内部定制构建,其中加载了 100 余个插件。吞吐统一归一化,只用于说明定位方法与因果链,不代表 Apache APISIX OSS 在其他硬件、配置或负载下的通用表现。
+
+## 1. 先确认 CPU 瓶颈,而不是上来就看火焰图
+
+火焰图显示的是采样期间 CPU 在执行什么。只有目标 worker 的 CPU 接近饱和、吞吐受这个核限制时,火焰图的宽度才有资格解释性能回退。
Review Comment:
`有资格` reads as if the flame graph were a person. Matches the wording already used in the en table ("Can the flame graph explain this regression?").
````suggestion
火焰图显示的是采样期间 CPU 在执行什么。只有目标 worker 的 CPU 接近饱和、吞吐受这个核限制时,火焰图的宽度才能解释性能回退。
````
##########
blog/zh/blog/2026/08/28/debugging-apisix-throughput-regression.md:
##########
@@ -0,0 +1,234 @@
+---
+title: "火焰图没撒谎,但没直接告诉我们瓶颈:一次 APISIX 吞吐回退定位"
+authors:
+ - name: "Xin Rong"
+ title: "Author"
+ url: "https://github.com/AlinsRan"
+ image_url: "https://github.com/AlinsRan.png"
+ - name: "Yilia Lin"
+ title: "Technical Writer"
+ url: "https://github.com/Yilialinn"
+ image_url: "https://github.com/Yilialinn.png"
+keywords:
+ - Apache APISIX
+ - 火焰图
+ - LuaJIT
+ - 性能优化
+ - 吞吐回退
+description: "火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。"
+tags: [Ecosystem]
+---
+
+火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。
+
+<!--truncate-->
+
+一次吞吐回退中,火焰图最宽的路径并不是最终瓶颈。真正值得追查的两个位置,在 Lua 采样里只有 3.0% 和 3.9%。如果按热点排序,它们根本进不了第一轮优化名单。
+
+这次排查最有价值的不是某个补丁,而是一条证据链:先确认 CPU 确实是瓶颈;再横向看火焰图宽度,建立候选;然后沿调用栈纵向追,看重复调用和公共路径如何放大成本;当火焰图解释不了端到端差距时,继续查 LuaJIT 编译与中断事件;最后用每请求调用次数和配对 A/B 实验定量。
+
+> 数据范围:下文数据来自同一受控环境中的一个 APISIX 内部定制构建,其中加载了 100 余个插件。吞吐统一归一化,只用于说明定位方法与因果链,不代表 Apache APISIX OSS 在其他硬件、配置或负载下的通用表现。
+
+## 1. 先确认 CPU 瓶颈,而不是上来就看火焰图
+
+火焰图显示的是采样期间 CPU 在执行什么。只有目标 worker 的 CPU 接近饱和、吞吐受这个核限制时,火焰图的宽度才有资格解释性能回退。
+
+我们在采样前固定了这些条件:
+
+- APISIX 单 worker 并绑定独占物理核;
+- 上游服务和压测端使用其他核,避免 CPU 争抢;
+- 请求模型、配置、响应内容保持一致;
+- 吞吐回退可稳定复现,错误率和响应结果没有偏移;
+- 目标 worker 持续接近饱和,上游、网络、压测端仍有余量。
+
+如果不满足这些前提,首先应该查连接、网络、上游或压测端,而不是在 on-CPU 火焰图里找答案。
+
+## 2. 横向看宽度,而不是排名
+
+我们使用 eBPF 以 500 Hz 同时采集 C 和 Lua 调用栈。下面是排查中的真实 Lua on-CPU 火焰图。
+
+
+
+*搜索 `run_global_rules` 后命中 3 处,但这些位置在整张图里并不醒目。原始采样共 2,446 个样本;截图已移除进程 PID。*
+
+横向看火焰图,看的是宽度,不是 x 轴顺序。宽度越大,采样落在该路径上的次数越多。初步候选位置按 Lua self time 排序:
+
+| 候选位置 | Lua self time | 初判 |
+|---|---:|---|
+| Prometheus exporter | 25.9% | 最值得先看 |
+| `ctx.var` 元方法 | 15.1% | 第二候选 |
+| 自定义日志旁路 | 3.9% | 很容易被忽略 |
+| `run_global_rules` | 3.0% | 很容易被忽略 |
+
+这里的 self time 指:在带 Lua 上下文的样本中,直接落在这个位置上的比例,不是总 CPU 占比。本次采样约 19.6% 的样本缺失 Lua 上下文,所以这些百分比只用于选候选,不能直接预测吞吐收益。
+
+这个排名有四个盲区:
+
+1. LuaJIT 解释执行时,不同的 Lua 代码可能折叠到共享的 `lj_BC_*`、`lj_vm_*` 符号里。
+2. 已编译的 JIT trace 不一定能被常规栈回溯完整展开,因此可观察到的 JIT 样本只是下界。
+3. 表查找、内存分配、GC 等成本会记在 C 符号上,而不是触发的 Lua 行上。
+4. 一个调用可能改变调用方的 JIT 状态,使成本看起来落在调用方函数的头上。
+
+`run_global_rules` 还有一个特征:它没有形成一根大柱子,而是散在多个请求阶段的调用栈里。单处不宽,不等于合起来不贵。
+
+## 3. 纵向沿栈看,3% 如何被公共路径放大
+
+选中某个 `run_global_rules` 方块后,纵向看调用栈:底部是请求阶段入口,向上经过 `common_phase`、Global Rule 插件筛选、调度,再到具体插件执行。
+
+
+
+*所选栈会被重新铺满画布,因此图中的宽度已经归一化,不能再当作它占总 CPU 的比例。*
+
+纵向高度本身不代表耗时。它的作用是看清成本从哪里进入、被谁调用、为什么反复出现。
+
+修复前,`run_global_rules()` 会在请求的多个阶段调用 `_M.filter()`。`_M.filter()` 遍历所有已加载插件,再逐个判断该插件是否被 Global Rule 配置:
+
+```lua
+-- 简化示意,非 APISIX 完整实现
+for _, plugin_obj in ipairs(local_plugins) do
+ local name = plugin_obj.name
+ local plugin_conf = user_plugin_conf[name]
+ if type(plugin_conf) ~= "table" then
+ goto continue
+ end
+ -- 处理已配置插件
+ ::continue::
+end
+```
+
+两个放大器都在这里:
+
+1. 单次过滤成本随已加载插件数量的增长。
Review Comment:
`随…的增长` leaves the sentence without a predicate.
````suggestion
1. 单次过滤的成本随已加载插件数量增长。
````
##########
blog/zh/blog/2026/08/28/debugging-apisix-throughput-regression.md:
##########
@@ -0,0 +1,234 @@
+---
+title: "火焰图没撒谎,但没直接告诉我们瓶颈:一次 APISIX 吞吐回退定位"
+authors:
+ - name: "Xin Rong"
+ title: "Author"
+ url: "https://github.com/AlinsRan"
+ image_url: "https://github.com/AlinsRan.png"
+ - name: "Yilia Lin"
+ title: "Technical Writer"
+ url: "https://github.com/Yilialinn"
+ image_url: "https://github.com/Yilialinn.png"
+keywords:
+ - Apache APISIX
+ - 火焰图
+ - LuaJIT
+ - 性能优化
+ - 吞吐回退
+description: "火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。"
+tags: [Ecosystem]
+---
+
+火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。
+
+<!--truncate-->
+
+一次吞吐回退中,火焰图最宽的路径并不是最终瓶颈。真正值得追查的两个位置,在 Lua 采样里只有 3.0% 和 3.9%。如果按热点排序,它们根本进不了第一轮优化名单。
+
+这次排查最有价值的不是某个补丁,而是一条证据链:先确认 CPU 确实是瓶颈;再横向看火焰图宽度,建立候选;然后沿调用栈纵向追,看重复调用和公共路径如何放大成本;当火焰图解释不了端到端差距时,继续查 LuaJIT 编译与中断事件;最后用每请求调用次数和配对 A/B 实验定量。
+
+> 数据范围:下文数据来自同一受控环境中的一个 APISIX 内部定制构建,其中加载了 100 余个插件。吞吐统一归一化,只用于说明定位方法与因果链,不代表 Apache APISIX OSS 在其他硬件、配置或负载下的通用表现。
+
+## 1. 先确认 CPU 瓶颈,而不是上来就看火焰图
+
+火焰图显示的是采样期间 CPU 在执行什么。只有目标 worker 的 CPU 接近饱和、吞吐受这个核限制时,火焰图的宽度才有资格解释性能回退。
+
+我们在采样前固定了这些条件:
+
+- APISIX 单 worker 并绑定独占物理核;
+- 上游服务和压测端使用其他核,避免 CPU 争抢;
+- 请求模型、配置、响应内容保持一致;
+- 吞吐回退可稳定复现,错误率和响应结果没有偏移;
+- 目标 worker 持续接近饱和,上游、网络、压测端仍有余量。
+
+如果不满足这些前提,首先应该查连接、网络、上游或压测端,而不是在 on-CPU 火焰图里找答案。
+
+## 2. 横向看宽度,而不是排名
+
+我们使用 eBPF 以 500 Hz 同时采集 C 和 Lua 调用栈。下面是排查中的真实 Lua on-CPU 火焰图。
+
+
+
+*搜索 `run_global_rules` 后命中 3 处,但这些位置在整张图里并不醒目。原始采样共 2,446 个样本;截图已移除进程 PID。*
+
+横向看火焰图,看的是宽度,不是 x 轴顺序。宽度越大,采样落在该路径上的次数越多。初步候选位置按 Lua self time 排序:
+
+| 候选位置 | Lua self time | 初判 |
+|---|---:|---|
+| Prometheus exporter | 25.9% | 最值得先看 |
+| `ctx.var` 元方法 | 15.1% | 第二候选 |
+| 自定义日志旁路 | 3.9% | 很容易被忽略 |
+| `run_global_rules` | 3.0% | 很容易被忽略 |
+
+这里的 self time 指:在带 Lua 上下文的样本中,直接落在这个位置上的比例,不是总 CPU 占比。本次采样约 19.6% 的样本缺失 Lua 上下文,所以这些百分比只用于选候选,不能直接预测吞吐收益。
+
+这个排名有四个盲区:
+
+1. LuaJIT 解释执行时,不同的 Lua 代码可能折叠到共享的 `lj_BC_*`、`lj_vm_*` 符号里。
+2. 已编译的 JIT trace 不一定能被常规栈回溯完整展开,因此可观察到的 JIT 样本只是下界。
+3. 表查找、内存分配、GC 等成本会记在 C 符号上,而不是触发的 Lua 行上。
+4. 一个调用可能改变调用方的 JIT 状态,使成本看起来落在调用方函数的头上。
+
+`run_global_rules` 还有一个特征:它没有形成一根大柱子,而是散在多个请求阶段的调用栈里。单处不宽,不等于合起来不贵。
+
+## 3. 纵向沿栈看,3% 如何被公共路径放大
+
+选中某个 `run_global_rules` 方块后,纵向看调用栈:底部是请求阶段入口,向上经过 `common_phase`、Global Rule 插件筛选、调度,再到具体插件执行。
+
+
+
+*所选栈会被重新铺满画布,因此图中的宽度已经归一化,不能再当作它占总 CPU 的比例。*
+
+纵向高度本身不代表耗时。它的作用是看清成本从哪里进入、被谁调用、为什么反复出现。
+
+修复前,`run_global_rules()` 会在请求的多个阶段调用 `_M.filter()`。`_M.filter()` 遍历所有已加载插件,再逐个判断该插件是否被 Global Rule 配置:
+
+```lua
+-- 简化示意,非 APISIX 完整实现
+for _, plugin_obj in ipairs(local_plugins) do
+ local name = plugin_obj.name
+ local plugin_conf = user_plugin_conf[name]
+ if type(plugin_conf) ~= "table" then
+ goto continue
+ end
+ -- 处理已配置插件
+ ::continue::
+end
+```
+
+两个放大器都在这里:
+
+1. 单次过滤成本随已加载插件数量的增长。
+2. 相同过滤结果跨请求阶段重复生成;`body_filter` 与 `delayed_body_filter` 还可能按响应块多次进入。
+
+本次测试配置中,一个请求实际触发了 **9 次过滤**。也就是说,“从 100+ 插件中找出已配置的少量插件”这件事,被同一个请求重复了 9 次。
+
+这解释了为什么 Lua 层 3.0% 会低估整条路径成本:
+
+| 实际工作 | 火焰图常见归属 |
+|---|---|
+| 循环本身 | `plugin.lua` 对应位置 |
+| 大量表查找 | `lj_BC_TGETS` |
+| 临时表分配 | `lj_alloc_malloc` |
+| 临时对象回收 | `gc_sweep` |
+| 未编译解释器派发 | `lj_vm_*` / `lj_BC_*` |
+
+横向看,它只是 3% 的候选;纵向看,才会发现它位于一条被反复进入的公共路径。优化方向也很直接:同一请求内,如果 Global Rule 和匹配路由没有变化,就复用已筛选的插件集合,而不是每个阶段重新生成。
+
+## 4. 火焰图解释不了差距时,查 LuaJIT 编译事件
+
+第二条路径来自自定义观测组件。它和日志旁路不属于 APISIX OSS,但揭示的问题对 APISIX 插件和 OpenResty 扩展有参考价值:一次看似很轻的调用,既可能产生直接成本,也可能改变调用方之后以解释器还是机器码运行。
+
+### 4.1 解释器派发异常活跃
+
+把 C 层样本按运行时类型重新分类,最值得注意的不是某行 Lua,而是解释器与 JIT 的关系:
+
+| 运行类别 | 每请求 CPU 时间 | 占本次样本 |
+|---|---:|---:|
+| 解释器派发:`lj_BC_*` / `lj_vm_*` | 5.86 μs | 29.6% |
+| 可观察到的 JIT trace 执行 | 2.55 μs | 12.9% |
+
+JIT trace 难以完整 unwind,12.9% 只能看作下界。但在同一环境中,解释器派发如此活跃,已经构成强信号:某些高频路径可能没有稳定地跑在机器码里。
+
+这不等于 trace 越多越好。初始化代码不需要追求编译,不同 trace 的成本差异也很大。只有在 CPU 饱和、路径高频、解释器派发异常时,编译结果才值得深挖。
+
+### 4.2 `jit.v` 发出警报,也制造了第一次误判
+
+开启 `jit.v` 后,一次短时压测输出中看到 417 次 trace 编译成功、493 次 abort。按位置聚合后,一批路径的 abort 次数整齐停在 11。
+
+但这是编译事件数,不是唯一函数数、覆盖率或 CPU 时间。`jit.v` 有几个边界:
Review Comment:
`唯一函数数` is a literal rendering of "unique function count" and doesn't read as Chinese.
````suggestion
但这是编译事件数,不是去重后的函数个数、覆盖率或 CPU 时间。`jit.v` 有几个边界:
````
##########
blog/zh/blog/2026/08/28/debugging-apisix-throughput-regression.md:
##########
@@ -0,0 +1,234 @@
+---
+title: "火焰图没撒谎,但没直接告诉我们瓶颈:一次 APISIX 吞吐回退定位"
+authors:
+ - name: "Xin Rong"
+ title: "Author"
+ url: "https://github.com/AlinsRan"
+ image_url: "https://github.com/AlinsRan.png"
+ - name: "Yilia Lin"
+ title: "Technical Writer"
+ url: "https://github.com/Yilialinn"
+ image_url: "https://github.com/Yilialinn.png"
+keywords:
+ - Apache APISIX
+ - 火焰图
+ - LuaJIT
+ - 性能优化
+ - 吞吐回退
+description: "火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。"
+tags: [Ecosystem]
+---
+
+火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。
+
+<!--truncate-->
+
+一次吞吐回退中,火焰图最宽的路径并不是最终瓶颈。真正值得追查的两个位置,在 Lua 采样里只有 3.0% 和 3.9%。如果按热点排序,它们根本进不了第一轮优化名单。
+
+这次排查最有价值的不是某个补丁,而是一条证据链:先确认 CPU 确实是瓶颈;再横向看火焰图宽度,建立候选;然后沿调用栈纵向追,看重复调用和公共路径如何放大成本;当火焰图解释不了端到端差距时,继续查 LuaJIT 编译与中断事件;最后用每请求调用次数和配对 A/B 实验定量。
+
+> 数据范围:下文数据来自同一受控环境中的一个 APISIX 内部定制构建,其中加载了 100 余个插件。吞吐统一归一化,只用于说明定位方法与因果链,不代表 Apache APISIX OSS 在其他硬件、配置或负载下的通用表现。
+
+## 1. 先确认 CPU 瓶颈,而不是上来就看火焰图
+
+火焰图显示的是采样期间 CPU 在执行什么。只有目标 worker 的 CPU 接近饱和、吞吐受这个核限制时,火焰图的宽度才有资格解释性能回退。
+
+我们在采样前固定了这些条件:
+
+- APISIX 单 worker 并绑定独占物理核;
+- 上游服务和压测端使用其他核,避免 CPU 争抢;
+- 请求模型、配置、响应内容保持一致;
+- 吞吐回退可稳定复现,错误率和响应结果没有偏移;
+- 目标 worker 持续接近饱和,上游、网络、压测端仍有余量。
+
+如果不满足这些前提,首先应该查连接、网络、上游或压测端,而不是在 on-CPU 火焰图里找答案。
+
+## 2. 横向看宽度,而不是排名
+
+我们使用 eBPF 以 500 Hz 同时采集 C 和 Lua 调用栈。下面是排查中的真实 Lua on-CPU 火焰图。
+
+
+
+*搜索 `run_global_rules` 后命中 3 处,但这些位置在整张图里并不醒目。原始采样共 2,446 个样本;截图已移除进程 PID。*
+
+横向看火焰图,看的是宽度,不是 x 轴顺序。宽度越大,采样落在该路径上的次数越多。初步候选位置按 Lua self time 排序:
+
+| 候选位置 | Lua self time | 初判 |
+|---|---:|---|
+| Prometheus exporter | 25.9% | 最值得先看 |
+| `ctx.var` 元方法 | 15.1% | 第二候选 |
+| 自定义日志旁路 | 3.9% | 很容易被忽略 |
+| `run_global_rules` | 3.0% | 很容易被忽略 |
+
+这里的 self time 指:在带 Lua 上下文的样本中,直接落在这个位置上的比例,不是总 CPU 占比。本次采样约 19.6% 的样本缺失 Lua 上下文,所以这些百分比只用于选候选,不能直接预测吞吐收益。
+
+这个排名有四个盲区:
+
+1. LuaJIT 解释执行时,不同的 Lua 代码可能折叠到共享的 `lj_BC_*`、`lj_vm_*` 符号里。
+2. 已编译的 JIT trace 不一定能被常规栈回溯完整展开,因此可观察到的 JIT 样本只是下界。
+3. 表查找、内存分配、GC 等成本会记在 C 符号上,而不是触发的 Lua 行上。
+4. 一个调用可能改变调用方的 JIT 状态,使成本看起来落在调用方函数的头上。
+
+`run_global_rules` 还有一个特征:它没有形成一根大柱子,而是散在多个请求阶段的调用栈里。单处不宽,不等于合起来不贵。
+
+## 3. 纵向沿栈看,3% 如何被公共路径放大
+
+选中某个 `run_global_rules` 方块后,纵向看调用栈:底部是请求阶段入口,向上经过 `common_phase`、Global Rule 插件筛选、调度,再到具体插件执行。
+
+
+
+*所选栈会被重新铺满画布,因此图中的宽度已经归一化,不能再当作它占总 CPU 的比例。*
+
+纵向高度本身不代表耗时。它的作用是看清成本从哪里进入、被谁调用、为什么反复出现。
+
+修复前,`run_global_rules()` 会在请求的多个阶段调用 `_M.filter()`。`_M.filter()` 遍历所有已加载插件,再逐个判断该插件是否被 Global Rule 配置:
+
+```lua
+-- 简化示意,非 APISIX 完整实现
+for _, plugin_obj in ipairs(local_plugins) do
+ local name = plugin_obj.name
+ local plugin_conf = user_plugin_conf[name]
+ if type(plugin_conf) ~= "table" then
+ goto continue
+ end
+ -- 处理已配置插件
+ ::continue::
+end
+```
+
+两个放大器都在这里:
+
+1. 单次过滤成本随已加载插件数量的增长。
+2. 相同过滤结果跨请求阶段重复生成;`body_filter` 与 `delayed_body_filter` 还可能按响应块多次进入。
+
+本次测试配置中,一个请求实际触发了 **9 次过滤**。也就是说,“从 100+ 插件中找出已配置的少量插件”这件事,被同一个请求重复了 9 次。
+
+这解释了为什么 Lua 层 3.0% 会低估整条路径成本:
+
+| 实际工作 | 火焰图常见归属 |
+|---|---|
+| 循环本身 | `plugin.lua` 对应位置 |
+| 大量表查找 | `lj_BC_TGETS` |
+| 临时表分配 | `lj_alloc_malloc` |
+| 临时对象回收 | `gc_sweep` |
+| 未编译解释器派发 | `lj_vm_*` / `lj_BC_*` |
+
+横向看,它只是 3% 的候选;纵向看,才会发现它位于一条被反复进入的公共路径。优化方向也很直接:同一请求内,如果 Global Rule 和匹配路由没有变化,就复用已筛选的插件集合,而不是每个阶段重新生成。
+
+## 4. 火焰图解释不了差距时,查 LuaJIT 编译事件
+
+第二条路径来自自定义观测组件。它和日志旁路不属于 APISIX OSS,但揭示的问题对 APISIX 插件和 OpenResty 扩展有参考价值:一次看似很轻的调用,既可能产生直接成本,也可能改变调用方之后以解释器还是机器码运行。
+
+### 4.1 解释器派发异常活跃
+
+把 C 层样本按运行时类型重新分类,最值得注意的不是某行 Lua,而是解释器与 JIT 的关系:
+
+| 运行类别 | 每请求 CPU 时间 | 占本次样本 |
+|---|---:|---:|
+| 解释器派发:`lj_BC_*` / `lj_vm_*` | 5.86 μs | 29.6% |
+| 可观察到的 JIT trace 执行 | 2.55 μs | 12.9% |
+
+JIT trace 难以完整 unwind,12.9% 只能看作下界。但在同一环境中,解释器派发如此活跃,已经构成强信号:某些高频路径可能没有稳定地跑在机器码里。
+
+这不等于 trace 越多越好。初始化代码不需要追求编译,不同 trace 的成本差异也很大。只有在 CPU 饱和、路径高频、解释器派发异常时,编译结果才值得深挖。
+
+### 4.2 `jit.v` 发出警报,也制造了第一次误判
+
+开启 `jit.v` 后,一次短时压测输出中看到 417 次 trace 编译成功、493 次 abort。按位置聚合后,一批路径的 abort 次数整齐停在 11。
+
+但这是编译事件数,不是唯一函数数、覆盖率或 CPU 时间。`jit.v` 有几个边界:
+
+- abort 行显示的是 trace 中断位置,而惩罚记在 trace 起点,两者可能不是同一行。
+- 某函数从未成为 trace 起点,不代表它没有被编译,函数体可能已被内联进父 trace。
+- 文本日志很难对功能开启/关闭两组结果做稳定的集合差。
+
+我们一开始也把“起点出现次数为 0”误读成“整个函数都在解释执行”。后来用 `jit.dump` 字节码模式复核,才发现多个函数入口没有成为 root trace,但函数体已多次进入其他 trace。真正反复失败的是阶段入口和编排函数。
+
+> 看不到 trace 起点,只能证明这里没有成为 root trace 锚点;不能证明整个函数没有进入机器码。
+
+### 4.3 把 `start`、`stop`、`abort` 归回同一起点
+
+要回答“哪段代码没有编译、为什么”,需要让 LuaJIT 自己交事件流。核心做法是按 trace id 保存 `start` 的位置,再把后续 `stop` 或 `abort` 归回同一个起点:
+
+```lua
+-- 简化示意,实际回调参数和解析逻辑更复杂
+local trace_start = {}
+
+jit.attach(function(what, trace_id, func, pc, err_code)
+ if what == "start" then
+ trace_start[trace_id] = locate(func, pc)
+ elseif what == "stop" then
+ record_compiled(trace_start[trace_id])
+ trace_start[trace_id] = nil
+ elseif what == "abort" then
+ record_abort(trace_start[trace_id], err_code)
+ trace_start[trace_id] = nil
+ end
+end, "trace")
+```
+
+实际探针还要用 `jit.util.funcinfo` 解析源位置,用 `jit.vmdef.traceerr` 还原 abort 原因。这样就能比较开启/关闭自定义组件时:哪些起点进入 compiled 集合,哪些反复 abort,以及是否发生 trace flush。
Review Comment:
`比较开启/关闭自定义组件时:` breaks off before the object of 比较.
````suggestion
实际探针还要用 `jit.util.funcinfo` 解析源位置,用 `jit.vmdef.traceerr` 还原 abort 原因。这样就能比较开启和关闭自定义组件时的差异:哪些起点进入 compiled 集合,哪些反复 abort,以及是否发生 trace flush。
````
##########
blog/zh/blog/2026/08/28/debugging-apisix-throughput-regression.md:
##########
@@ -0,0 +1,234 @@
+---
+title: "火焰图没撒谎,但没直接告诉我们瓶颈:一次 APISIX 吞吐回退定位"
+authors:
+ - name: "Xin Rong"
+ title: "Author"
+ url: "https://github.com/AlinsRan"
+ image_url: "https://github.com/AlinsRan.png"
+ - name: "Yilia Lin"
+ title: "Technical Writer"
+ url: "https://github.com/Yilialinn"
+ image_url: "https://github.com/Yilialinn.png"
+keywords:
+ - Apache APISIX
+ - 火焰图
+ - LuaJIT
+ - 性能优化
+ - 吞吐回退
+description: "火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。"
+tags: [Ecosystem]
+---
+
+火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。
+
+<!--truncate-->
+
+一次吞吐回退中,火焰图最宽的路径并不是最终瓶颈。真正值得追查的两个位置,在 Lua 采样里只有 3.0% 和 3.9%。如果按热点排序,它们根本进不了第一轮优化名单。
+
+这次排查最有价值的不是某个补丁,而是一条证据链:先确认 CPU 确实是瓶颈;再横向看火焰图宽度,建立候选;然后沿调用栈纵向追,看重复调用和公共路径如何放大成本;当火焰图解释不了端到端差距时,继续查 LuaJIT 编译与中断事件;最后用每请求调用次数和配对 A/B 实验定量。
+
+> 数据范围:下文数据来自同一受控环境中的一个 APISIX 内部定制构建,其中加载了 100 余个插件。吞吐统一归一化,只用于说明定位方法与因果链,不代表 Apache APISIX OSS 在其他硬件、配置或负载下的通用表现。
+
+## 1. 先确认 CPU 瓶颈,而不是上来就看火焰图
+
+火焰图显示的是采样期间 CPU 在执行什么。只有目标 worker 的 CPU 接近饱和、吞吐受这个核限制时,火焰图的宽度才有资格解释性能回退。
+
+我们在采样前固定了这些条件:
+
+- APISIX 单 worker 并绑定独占物理核;
+- 上游服务和压测端使用其他核,避免 CPU 争抢;
+- 请求模型、配置、响应内容保持一致;
+- 吞吐回退可稳定复现,错误率和响应结果没有偏移;
+- 目标 worker 持续接近饱和,上游、网络、压测端仍有余量。
+
+如果不满足这些前提,首先应该查连接、网络、上游或压测端,而不是在 on-CPU 火焰图里找答案。
+
+## 2. 横向看宽度,而不是排名
+
+我们使用 eBPF 以 500 Hz 同时采集 C 和 Lua 调用栈。下面是排查中的真实 Lua on-CPU 火焰图。
+
+
+
+*搜索 `run_global_rules` 后命中 3 处,但这些位置在整张图里并不醒目。原始采样共 2,446 个样本;截图已移除进程 PID。*
+
+横向看火焰图,看的是宽度,不是 x 轴顺序。宽度越大,采样落在该路径上的次数越多。初步候选位置按 Lua self time 排序:
+
+| 候选位置 | Lua self time | 初判 |
+|---|---:|---|
+| Prometheus exporter | 25.9% | 最值得先看 |
+| `ctx.var` 元方法 | 15.1% | 第二候选 |
+| 自定义日志旁路 | 3.9% | 很容易被忽略 |
+| `run_global_rules` | 3.0% | 很容易被忽略 |
+
+这里的 self time 指:在带 Lua 上下文的样本中,直接落在这个位置上的比例,不是总 CPU 占比。本次采样约 19.6% 的样本缺失 Lua 上下文,所以这些百分比只用于选候选,不能直接预测吞吐收益。
+
+这个排名有四个盲区:
+
+1. LuaJIT 解释执行时,不同的 Lua 代码可能折叠到共享的 `lj_BC_*`、`lj_vm_*` 符号里。
+2. 已编译的 JIT trace 不一定能被常规栈回溯完整展开,因此可观察到的 JIT 样本只是下界。
+3. 表查找、内存分配、GC 等成本会记在 C 符号上,而不是触发的 Lua 行上。
+4. 一个调用可能改变调用方的 JIT 状态,使成本看起来落在调用方函数的头上。
+
+`run_global_rules` 还有一个特征:它没有形成一根大柱子,而是散在多个请求阶段的调用栈里。单处不宽,不等于合起来不贵。
+
+## 3. 纵向沿栈看,3% 如何被公共路径放大
+
+选中某个 `run_global_rules` 方块后,纵向看调用栈:底部是请求阶段入口,向上经过 `common_phase`、Global Rule 插件筛选、调度,再到具体插件执行。
+
+
+
+*所选栈会被重新铺满画布,因此图中的宽度已经归一化,不能再当作它占总 CPU 的比例。*
+
+纵向高度本身不代表耗时。它的作用是看清成本从哪里进入、被谁调用、为什么反复出现。
+
+修复前,`run_global_rules()` 会在请求的多个阶段调用 `_M.filter()`。`_M.filter()` 遍历所有已加载插件,再逐个判断该插件是否被 Global Rule 配置:
+
+```lua
+-- 简化示意,非 APISIX 完整实现
+for _, plugin_obj in ipairs(local_plugins) do
+ local name = plugin_obj.name
+ local plugin_conf = user_plugin_conf[name]
+ if type(plugin_conf) ~= "table" then
+ goto continue
+ end
+ -- 处理已配置插件
+ ::continue::
+end
+```
+
+两个放大器都在这里:
+
+1. 单次过滤成本随已加载插件数量的增长。
+2. 相同过滤结果跨请求阶段重复生成;`body_filter` 与 `delayed_body_filter` 还可能按响应块多次进入。
+
+本次测试配置中,一个请求实际触发了 **9 次过滤**。也就是说,“从 100+ 插件中找出已配置的少量插件”这件事,被同一个请求重复了 9 次。
+
+这解释了为什么 Lua 层 3.0% 会低估整条路径成本:
+
+| 实际工作 | 火焰图常见归属 |
+|---|---|
+| 循环本身 | `plugin.lua` 对应位置 |
+| 大量表查找 | `lj_BC_TGETS` |
+| 临时表分配 | `lj_alloc_malloc` |
+| 临时对象回收 | `gc_sweep` |
+| 未编译解释器派发 | `lj_vm_*` / `lj_BC_*` |
+
+横向看,它只是 3% 的候选;纵向看,才会发现它位于一条被反复进入的公共路径。优化方向也很直接:同一请求内,如果 Global Rule 和匹配路由没有变化,就复用已筛选的插件集合,而不是每个阶段重新生成。
+
+## 4. 火焰图解释不了差距时,查 LuaJIT 编译事件
+
+第二条路径来自自定义观测组件。它和日志旁路不属于 APISIX OSS,但揭示的问题对 APISIX 插件和 OpenResty 扩展有参考价值:一次看似很轻的调用,既可能产生直接成本,也可能改变调用方之后以解释器还是机器码运行。
Review Comment:
`改变调用方之后以解释器还是机器码运行` is missing 是, which makes the clause hard to parse.
````suggestion
第二条路径来自自定义观测组件。它和日志旁路不属于 APISIX OSS,但揭示的问题对 APISIX 插件和 OpenResty 扩展有参考价值:一次看似很轻的调用,既可能产生直接成本,也可能改变调用方之后是以解释器还是以机器码运行。
````
##########
blog/zh/blog/2026/08/28/debugging-apisix-throughput-regression.md:
##########
@@ -0,0 +1,234 @@
+---
+title: "火焰图没撒谎,但没直接告诉我们瓶颈:一次 APISIX 吞吐回退定位"
+authors:
+ - name: "Xin Rong"
+ title: "Author"
+ url: "https://github.com/AlinsRan"
+ image_url: "https://github.com/AlinsRan.png"
+ - name: "Yilia Lin"
+ title: "Technical Writer"
+ url: "https://github.com/Yilialinn"
+ image_url: "https://github.com/Yilialinn.png"
+keywords:
+ - Apache APISIX
+ - 火焰图
+ - LuaJIT
+ - 性能优化
+ - 吞吐回退
+description: "火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。"
+tags: [Ecosystem]
+---
+
+火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。
+
+<!--truncate-->
+
+一次吞吐回退中,火焰图最宽的路径并不是最终瓶颈。真正值得追查的两个位置,在 Lua 采样里只有 3.0% 和 3.9%。如果按热点排序,它们根本进不了第一轮优化名单。
+
+这次排查最有价值的不是某个补丁,而是一条证据链:先确认 CPU 确实是瓶颈;再横向看火焰图宽度,建立候选;然后沿调用栈纵向追,看重复调用和公共路径如何放大成本;当火焰图解释不了端到端差距时,继续查 LuaJIT 编译与中断事件;最后用每请求调用次数和配对 A/B 实验定量。
+
+> 数据范围:下文数据来自同一受控环境中的一个 APISIX 内部定制构建,其中加载了 100 余个插件。吞吐统一归一化,只用于说明定位方法与因果链,不代表 Apache APISIX OSS 在其他硬件、配置或负载下的通用表现。
+
+## 1. 先确认 CPU 瓶颈,而不是上来就看火焰图
+
+火焰图显示的是采样期间 CPU 在执行什么。只有目标 worker 的 CPU 接近饱和、吞吐受这个核限制时,火焰图的宽度才有资格解释性能回退。
+
+我们在采样前固定了这些条件:
+
+- APISIX 单 worker 并绑定独占物理核;
+- 上游服务和压测端使用其他核,避免 CPU 争抢;
+- 请求模型、配置、响应内容保持一致;
+- 吞吐回退可稳定复现,错误率和响应结果没有偏移;
+- 目标 worker 持续接近饱和,上游、网络、压测端仍有余量。
+
+如果不满足这些前提,首先应该查连接、网络、上游或压测端,而不是在 on-CPU 火焰图里找答案。
+
+## 2. 横向看宽度,而不是排名
+
+我们使用 eBPF 以 500 Hz 同时采集 C 和 Lua 调用栈。下面是排查中的真实 Lua on-CPU 火焰图。
+
+
+
+*搜索 `run_global_rules` 后命中 3 处,但这些位置在整张图里并不醒目。原始采样共 2,446 个样本;截图已移除进程 PID。*
+
+横向看火焰图,看的是宽度,不是 x 轴顺序。宽度越大,采样落在该路径上的次数越多。初步候选位置按 Lua self time 排序:
+
+| 候选位置 | Lua self time | 初判 |
+|---|---:|---|
+| Prometheus exporter | 25.9% | 最值得先看 |
+| `ctx.var` 元方法 | 15.1% | 第二候选 |
+| 自定义日志旁路 | 3.9% | 很容易被忽略 |
+| `run_global_rules` | 3.0% | 很容易被忽略 |
+
+这里的 self time 指:在带 Lua 上下文的样本中,直接落在这个位置上的比例,不是总 CPU 占比。本次采样约 19.6% 的样本缺失 Lua 上下文,所以这些百分比只用于选候选,不能直接预测吞吐收益。
+
+这个排名有四个盲区:
+
+1. LuaJIT 解释执行时,不同的 Lua 代码可能折叠到共享的 `lj_BC_*`、`lj_vm_*` 符号里。
+2. 已编译的 JIT trace 不一定能被常规栈回溯完整展开,因此可观察到的 JIT 样本只是下界。
+3. 表查找、内存分配、GC 等成本会记在 C 符号上,而不是触发的 Lua 行上。
+4. 一个调用可能改变调用方的 JIT 状态,使成本看起来落在调用方函数的头上。
+
+`run_global_rules` 还有一个特征:它没有形成一根大柱子,而是散在多个请求阶段的调用栈里。单处不宽,不等于合起来不贵。
+
+## 3. 纵向沿栈看,3% 如何被公共路径放大
+
+选中某个 `run_global_rules` 方块后,纵向看调用栈:底部是请求阶段入口,向上经过 `common_phase`、Global Rule 插件筛选、调度,再到具体插件执行。
+
+
+
+*所选栈会被重新铺满画布,因此图中的宽度已经归一化,不能再当作它占总 CPU 的比例。*
+
+纵向高度本身不代表耗时。它的作用是看清成本从哪里进入、被谁调用、为什么反复出现。
+
+修复前,`run_global_rules()` 会在请求的多个阶段调用 `_M.filter()`。`_M.filter()` 遍历所有已加载插件,再逐个判断该插件是否被 Global Rule 配置:
+
+```lua
+-- 简化示意,非 APISIX 完整实现
+for _, plugin_obj in ipairs(local_plugins) do
+ local name = plugin_obj.name
+ local plugin_conf = user_plugin_conf[name]
+ if type(plugin_conf) ~= "table" then
+ goto continue
+ end
+ -- 处理已配置插件
+ ::continue::
+end
+```
+
+两个放大器都在这里:
+
+1. 单次过滤成本随已加载插件数量的增长。
+2. 相同过滤结果跨请求阶段重复生成;`body_filter` 与 `delayed_body_filter` 还可能按响应块多次进入。
+
+本次测试配置中,一个请求实际触发了 **9 次过滤**。也就是说,“从 100+ 插件中找出已配置的少量插件”这件事,被同一个请求重复了 9 次。
Review Comment:
The passive `被同一个请求重复了 9 次` is awkward here; the request is the actor.
````suggestion
本次测试配置中,一个请求实际触发了 **9 次过滤**。也就是说,“从 100+ 插件中找出已配置的少量插件”这件事,同一个请求重复做了 9 次。
````
##########
blog/zh/blog/2026/08/28/debugging-apisix-throughput-regression.md:
##########
@@ -0,0 +1,234 @@
+---
+title: "火焰图没撒谎,但没直接告诉我们瓶颈:一次 APISIX 吞吐回退定位"
+authors:
+ - name: "Xin Rong"
+ title: "Author"
+ url: "https://github.com/AlinsRan"
+ image_url: "https://github.com/AlinsRan.png"
+ - name: "Yilia Lin"
+ title: "Technical Writer"
+ url: "https://github.com/Yilialinn"
+ image_url: "https://github.com/Yilialinn.png"
+keywords:
+ - Apache APISIX
+ - 火焰图
+ - LuaJIT
+ - 性能优化
+ - 吞吐回退
+description: "火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。"
+tags: [Ecosystem]
+---
+
+火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。
+
+<!--truncate-->
+
+一次吞吐回退中,火焰图最宽的路径并不是最终瓶颈。真正值得追查的两个位置,在 Lua 采样里只有 3.0% 和 3.9%。如果按热点排序,它们根本进不了第一轮优化名单。
+
+这次排查最有价值的不是某个补丁,而是一条证据链:先确认 CPU 确实是瓶颈;再横向看火焰图宽度,建立候选;然后沿调用栈纵向追,看重复调用和公共路径如何放大成本;当火焰图解释不了端到端差距时,继续查 LuaJIT 编译与中断事件;最后用每请求调用次数和配对 A/B 实验定量。
+
+> 数据范围:下文数据来自同一受控环境中的一个 APISIX 内部定制构建,其中加载了 100 余个插件。吞吐统一归一化,只用于说明定位方法与因果链,不代表 Apache APISIX OSS 在其他硬件、配置或负载下的通用表现。
+
+## 1. 先确认 CPU 瓶颈,而不是上来就看火焰图
+
+火焰图显示的是采样期间 CPU 在执行什么。只有目标 worker 的 CPU 接近饱和、吞吐受这个核限制时,火焰图的宽度才有资格解释性能回退。
+
+我们在采样前固定了这些条件:
+
+- APISIX 单 worker 并绑定独占物理核;
+- 上游服务和压测端使用其他核,避免 CPU 争抢;
+- 请求模型、配置、响应内容保持一致;
+- 吞吐回退可稳定复现,错误率和响应结果没有偏移;
+- 目标 worker 持续接近饱和,上游、网络、压测端仍有余量。
+
+如果不满足这些前提,首先应该查连接、网络、上游或压测端,而不是在 on-CPU 火焰图里找答案。
+
+## 2. 横向看宽度,而不是排名
+
+我们使用 eBPF 以 500 Hz 同时采集 C 和 Lua 调用栈。下面是排查中的真实 Lua on-CPU 火焰图。
+
+
+
+*搜索 `run_global_rules` 后命中 3 处,但这些位置在整张图里并不醒目。原始采样共 2,446 个样本;截图已移除进程 PID。*
+
+横向看火焰图,看的是宽度,不是 x 轴顺序。宽度越大,采样落在该路径上的次数越多。初步候选位置按 Lua self time 排序:
+
+| 候选位置 | Lua self time | 初判 |
+|---|---:|---|
+| Prometheus exporter | 25.9% | 最值得先看 |
+| `ctx.var` 元方法 | 15.1% | 第二候选 |
+| 自定义日志旁路 | 3.9% | 很容易被忽略 |
+| `run_global_rules` | 3.0% | 很容易被忽略 |
+
+这里的 self time 指:在带 Lua 上下文的样本中,直接落在这个位置上的比例,不是总 CPU 占比。本次采样约 19.6% 的样本缺失 Lua 上下文,所以这些百分比只用于选候选,不能直接预测吞吐收益。
+
+这个排名有四个盲区:
+
+1. LuaJIT 解释执行时,不同的 Lua 代码可能折叠到共享的 `lj_BC_*`、`lj_vm_*` 符号里。
+2. 已编译的 JIT trace 不一定能被常规栈回溯完整展开,因此可观察到的 JIT 样本只是下界。
+3. 表查找、内存分配、GC 等成本会记在 C 符号上,而不是触发的 Lua 行上。
+4. 一个调用可能改变调用方的 JIT 状态,使成本看起来落在调用方函数的头上。
+
+`run_global_rules` 还有一个特征:它没有形成一根大柱子,而是散在多个请求阶段的调用栈里。单处不宽,不等于合起来不贵。
+
+## 3. 纵向沿栈看,3% 如何被公共路径放大
+
+选中某个 `run_global_rules` 方块后,纵向看调用栈:底部是请求阶段入口,向上经过 `common_phase`、Global Rule 插件筛选、调度,再到具体插件执行。
+
+
+
+*所选栈会被重新铺满画布,因此图中的宽度已经归一化,不能再当作它占总 CPU 的比例。*
+
+纵向高度本身不代表耗时。它的作用是看清成本从哪里进入、被谁调用、为什么反复出现。
+
+修复前,`run_global_rules()` 会在请求的多个阶段调用 `_M.filter()`。`_M.filter()` 遍历所有已加载插件,再逐个判断该插件是否被 Global Rule 配置:
+
+```lua
+-- 简化示意,非 APISIX 完整实现
+for _, plugin_obj in ipairs(local_plugins) do
+ local name = plugin_obj.name
+ local plugin_conf = user_plugin_conf[name]
+ if type(plugin_conf) ~= "table" then
+ goto continue
+ end
+ -- 处理已配置插件
+ ::continue::
+end
+```
+
+两个放大器都在这里:
+
+1. 单次过滤成本随已加载插件数量的增长。
+2. 相同过滤结果跨请求阶段重复生成;`body_filter` 与 `delayed_body_filter` 还可能按响应块多次进入。
+
+本次测试配置中,一个请求实际触发了 **9 次过滤**。也就是说,“从 100+ 插件中找出已配置的少量插件”这件事,被同一个请求重复了 9 次。
+
+这解释了为什么 Lua 层 3.0% 会低估整条路径成本:
+
+| 实际工作 | 火焰图常见归属 |
+|---|---|
+| 循环本身 | `plugin.lua` 对应位置 |
+| 大量表查找 | `lj_BC_TGETS` |
+| 临时表分配 | `lj_alloc_malloc` |
+| 临时对象回收 | `gc_sweep` |
+| 未编译解释器派发 | `lj_vm_*` / `lj_BC_*` |
+
+横向看,它只是 3% 的候选;纵向看,才会发现它位于一条被反复进入的公共路径。优化方向也很直接:同一请求内,如果 Global Rule 和匹配路由没有变化,就复用已筛选的插件集合,而不是每个阶段重新生成。
+
+## 4. 火焰图解释不了差距时,查 LuaJIT 编译事件
+
+第二条路径来自自定义观测组件。它和日志旁路不属于 APISIX OSS,但揭示的问题对 APISIX 插件和 OpenResty 扩展有参考价值:一次看似很轻的调用,既可能产生直接成本,也可能改变调用方之后以解释器还是机器码运行。
+
+### 4.1 解释器派发异常活跃
+
+把 C 层样本按运行时类型重新分类,最值得注意的不是某行 Lua,而是解释器与 JIT 的关系:
+
+| 运行类别 | 每请求 CPU 时间 | 占本次样本 |
+|---|---:|---:|
+| 解释器派发:`lj_BC_*` / `lj_vm_*` | 5.86 μs | 29.6% |
+| 可观察到的 JIT trace 执行 | 2.55 μs | 12.9% |
+
+JIT trace 难以完整 unwind,12.9% 只能看作下界。但在同一环境中,解释器派发如此活跃,已经构成强信号:某些高频路径可能没有稳定地跑在机器码里。
+
+这不等于 trace 越多越好。初始化代码不需要追求编译,不同 trace 的成本差异也很大。只有在 CPU 饱和、路径高频、解释器派发异常时,编译结果才值得深挖。
+
+### 4.2 `jit.v` 发出警报,也制造了第一次误判
+
+开启 `jit.v` 后,一次短时压测输出中看到 417 次 trace 编译成功、493 次 abort。按位置聚合后,一批路径的 abort 次数整齐停在 11。
+
+但这是编译事件数,不是唯一函数数、覆盖率或 CPU 时间。`jit.v` 有几个边界:
+
+- abort 行显示的是 trace 中断位置,而惩罚记在 trace 起点,两者可能不是同一行。
+- 某函数从未成为 trace 起点,不代表它没有被编译,函数体可能已被内联进父 trace。
+- 文本日志很难对功能开启/关闭两组结果做稳定的集合差。
+
+我们一开始也把“起点出现次数为 0”误读成“整个函数都在解释执行”。后来用 `jit.dump` 字节码模式复核,才发现多个函数入口没有成为 root trace,但函数体已多次进入其他 trace。真正反复失败的是阶段入口和编排函数。
+
+> 看不到 trace 起点,只能证明这里没有成为 root trace 锚点;不能证明整个函数没有进入机器码。
+
+### 4.3 把 `start`、`stop`、`abort` 归回同一起点
+
+要回答“哪段代码没有编译、为什么”,需要让 LuaJIT 自己交事件流。核心做法是按 trace id 保存 `start` 的位置,再把后续 `stop` 或 `abort` 归回同一个起点:
+
+```lua
+-- 简化示意,实际回调参数和解析逻辑更复杂
+local trace_start = {}
+
+jit.attach(function(what, trace_id, func, pc, err_code)
+ if what == "start" then
+ trace_start[trace_id] = locate(func, pc)
+ elseif what == "stop" then
+ record_compiled(trace_start[trace_id])
+ trace_start[trace_id] = nil
+ elseif what == "abort" then
+ record_abort(trace_start[trace_id], err_code)
+ trace_start[trace_id] = nil
+ end
+end, "trace")
+```
+
+实际探针还要用 `jit.util.funcinfo` 解析源位置,用 `jit.vmdef.traceerr` 还原 abort 原因。这样就能比较开启/关闭自定义组件时:哪些起点进入 compiled 集合,哪些反复 abort,以及是否发生 trace flush。
+
+在本次 LuaJIT 构建中,失败惩罚从 72 开始逐次翻倍:第 10 次是 36,864,第 11 次达到 73,728,超过 60,000 上限。多个起点恰好 abort ×11 后不再增长;它们没有进入 compiled 集合,采集期间也没有 trace flush。两组存活 trace 数均低于缓存上限,因此排除了 trace 缓存被挤爆的可能。这些证据共同说明:对应的 trace 起点已被放弃继续编译。
+
+仍然不能扩大结论:被放弃的是 trace 起点,不一定是整个函数体;其他部分仍可能被内联进别的 trace。
+
+按调用来源聚合后,受影响的阶段入口和编排函数都经过同一条自定义日志旁路:日志级别不足时虽不输出,但仍会检查请求阶段、调用栈和请求上下文。新增调用本身没有形成大柱子,却改变了调用方的 JIT 结果,使部分成本看起来落在正常请求函数头上。
+
+```lua
+-- 自定义扩展的简化示意,不是 APISIX OSS 实现
+if log_level_is_suppressed then
+ check_debug_capture(...)
+ check_request_buffer(...)
+ return
+end
+```
+
+JIT 数据在这里承担两个职责:发现火焰图无法正确归属的成本,验证功能开关确实改变了高频路径的执行状态。但它不能直接告诉我们吞吐损失多大;真正的方向也不是“强迫所有函数编译”,而是让关闭状态下本来不该发生的工作根本不执行。
+
+还有一个容易让结论失真的陷阱:探针必须装在被观测模块加载之前。如果模块在 `require` 时保存了函数引用,之后替换原函数,计数器只能看到少量没被快照走的调用。本次探针注入晚时测到 1 次/请求,前移到 `require("apisix")` 之前后,才确认真实值是 5 次/请求。
+
+## 5. 配对 A/B 收口:实验回答“值多少钱”
+
+火焰图给位置,调用栈给放大链,LuaJIT 事件解释错位成本。最终还要靠配对实验定量。
+
+以 Global Rule 路径为例,我们保留相同的调度和配置,只让 Prometheus 业务函数进入后立即返回,用来区分“插件业务逻辑慢”和“进入插件前的公共路径慢”。为避免把定制环境绝对 RPS 当成 APISIX OSS benchmark,关闭 Prometheus 的吞吐被归一化为 100:
+
+| 内部 A/B 场景 | 相对吞吐指数 | 相对关闭 Prometheus |
+|---|---:|---:|
+| 关闭 Prometheus | 100.0 | 基准 |
+| 保留插件与调度,业务函数立即返回 | 77.2 | -22.8% |
+| 完整 Prometheus Global Rule | 56.9 | -43.1% |
+
+即使插件业务代码被短路,差距仍然显著。这个实验不能把成本定位到某一行,但足以证明损耗不只来自指标计算,进入插件前的公共路径本身就值得追查。再结合每请求 9 次过滤、纵向调用栈和 C 层成本分布,放大链才真正闭合。
+
+自定义观测组件也用同样方法:保持负载与配置一致,分别采集开启和关闭下的编译事件、每请求调用次数、吞吐和响应正确性。JIT 事件解释“为什么图上没有足够宽的新柱子”,端到端 A/B 回答“这条路径到底值多少钱”。
+
+整个排查可以压缩成五步:
+
+| 步骤 | 核心问题 | 证据 |
+|---|---|---|
+| 1. 确认 CPU 前提 | 火焰图是否有资格解释回退? | worker 饱和、绑核、上下游余量、稳定复现 |
Review Comment:
Same `有资格` as L34.
````suggestion
| 1. 确认 CPU 前提 | 火焰图能否解释这次回退? | worker 饱和、绑核、上下游余量、稳定复现 |
````
##########
blog/zh/blog/2026/08/28/debugging-apisix-throughput-regression.md:
##########
@@ -0,0 +1,234 @@
+---
+title: "火焰图没撒谎,但没直接告诉我们瓶颈:一次 APISIX 吞吐回退定位"
+authors:
+ - name: "Xin Rong"
+ title: "Author"
+ url: "https://github.com/AlinsRan"
+ image_url: "https://github.com/AlinsRan.png"
+ - name: "Yilia Lin"
+ title: "Technical Writer"
+ url: "https://github.com/Yilialinn"
+ image_url: "https://github.com/Yilialinn.png"
+keywords:
+ - Apache APISIX
+ - 火焰图
+ - LuaJIT
+ - 性能优化
+ - 吞吐回退
+description: "火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。"
+tags: [Ecosystem]
+---
+
+火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。
+
+<!--truncate-->
+
+一次吞吐回退中,火焰图最宽的路径并不是最终瓶颈。真正值得追查的两个位置,在 Lua 采样里只有 3.0% 和 3.9%。如果按热点排序,它们根本进不了第一轮优化名单。
+
+这次排查最有价值的不是某个补丁,而是一条证据链:先确认 CPU 确实是瓶颈;再横向看火焰图宽度,建立候选;然后沿调用栈纵向追,看重复调用和公共路径如何放大成本;当火焰图解释不了端到端差距时,继续查 LuaJIT 编译与中断事件;最后用每请求调用次数和配对 A/B 实验定量。
+
+> 数据范围:下文数据来自同一受控环境中的一个 APISIX 内部定制构建,其中加载了 100 余个插件。吞吐统一归一化,只用于说明定位方法与因果链,不代表 Apache APISIX OSS 在其他硬件、配置或负载下的通用表现。
+
+## 1. 先确认 CPU 瓶颈,而不是上来就看火焰图
+
+火焰图显示的是采样期间 CPU 在执行什么。只有目标 worker 的 CPU 接近饱和、吞吐受这个核限制时,火焰图的宽度才有资格解释性能回退。
+
+我们在采样前固定了这些条件:
+
+- APISIX 单 worker 并绑定独占物理核;
+- 上游服务和压测端使用其他核,避免 CPU 争抢;
+- 请求模型、配置、响应内容保持一致;
+- 吞吐回退可稳定复现,错误率和响应结果没有偏移;
+- 目标 worker 持续接近饱和,上游、网络、压测端仍有余量。
+
+如果不满足这些前提,首先应该查连接、网络、上游或压测端,而不是在 on-CPU 火焰图里找答案。
+
+## 2. 横向看宽度,而不是排名
+
+我们使用 eBPF 以 500 Hz 同时采集 C 和 Lua 调用栈。下面是排查中的真实 Lua on-CPU 火焰图。
+
+
+
+*搜索 `run_global_rules` 后命中 3 处,但这些位置在整张图里并不醒目。原始采样共 2,446 个样本;截图已移除进程 PID。*
+
+横向看火焰图,看的是宽度,不是 x 轴顺序。宽度越大,采样落在该路径上的次数越多。初步候选位置按 Lua self time 排序:
+
+| 候选位置 | Lua self time | 初判 |
+|---|---:|---|
+| Prometheus exporter | 25.9% | 最值得先看 |
+| `ctx.var` 元方法 | 15.1% | 第二候选 |
+| 自定义日志旁路 | 3.9% | 很容易被忽略 |
+| `run_global_rules` | 3.0% | 很容易被忽略 |
+
+这里的 self time 指:在带 Lua 上下文的样本中,直接落在这个位置上的比例,不是总 CPU 占比。本次采样约 19.6% 的样本缺失 Lua 上下文,所以这些百分比只用于选候选,不能直接预测吞吐收益。
+
+这个排名有四个盲区:
+
+1. LuaJIT 解释执行时,不同的 Lua 代码可能折叠到共享的 `lj_BC_*`、`lj_vm_*` 符号里。
+2. 已编译的 JIT trace 不一定能被常规栈回溯完整展开,因此可观察到的 JIT 样本只是下界。
+3. 表查找、内存分配、GC 等成本会记在 C 符号上,而不是触发的 Lua 行上。
+4. 一个调用可能改变调用方的 JIT 状态,使成本看起来落在调用方函数的头上。
+
+`run_global_rules` 还有一个特征:它没有形成一根大柱子,而是散在多个请求阶段的调用栈里。单处不宽,不等于合起来不贵。
+
+## 3. 纵向沿栈看,3% 如何被公共路径放大
+
+选中某个 `run_global_rules` 方块后,纵向看调用栈:底部是请求阶段入口,向上经过 `common_phase`、Global Rule 插件筛选、调度,再到具体插件执行。
+
+
+
+*所选栈会被重新铺满画布,因此图中的宽度已经归一化,不能再当作它占总 CPU 的比例。*
+
+纵向高度本身不代表耗时。它的作用是看清成本从哪里进入、被谁调用、为什么反复出现。
+
+修复前,`run_global_rules()` 会在请求的多个阶段调用 `_M.filter()`。`_M.filter()` 遍历所有已加载插件,再逐个判断该插件是否被 Global Rule 配置:
+
+```lua
+-- 简化示意,非 APISIX 完整实现
+for _, plugin_obj in ipairs(local_plugins) do
+ local name = plugin_obj.name
+ local plugin_conf = user_plugin_conf[name]
+ if type(plugin_conf) ~= "table" then
+ goto continue
+ end
+ -- 处理已配置插件
+ ::continue::
+end
+```
+
+两个放大器都在这里:
+
+1. 单次过滤成本随已加载插件数量的增长。
+2. 相同过滤结果跨请求阶段重复生成;`body_filter` 与 `delayed_body_filter` 还可能按响应块多次进入。
+
+本次测试配置中,一个请求实际触发了 **9 次过滤**。也就是说,“从 100+ 插件中找出已配置的少量插件”这件事,被同一个请求重复了 9 次。
+
+这解释了为什么 Lua 层 3.0% 会低估整条路径成本:
+
+| 实际工作 | 火焰图常见归属 |
+|---|---|
+| 循环本身 | `plugin.lua` 对应位置 |
+| 大量表查找 | `lj_BC_TGETS` |
+| 临时表分配 | `lj_alloc_malloc` |
+| 临时对象回收 | `gc_sweep` |
+| 未编译解释器派发 | `lj_vm_*` / `lj_BC_*` |
+
+横向看,它只是 3% 的候选;纵向看,才会发现它位于一条被反复进入的公共路径。优化方向也很直接:同一请求内,如果 Global Rule 和匹配路由没有变化,就复用已筛选的插件集合,而不是每个阶段重新生成。
+
+## 4. 火焰图解释不了差距时,查 LuaJIT 编译事件
+
+第二条路径来自自定义观测组件。它和日志旁路不属于 APISIX OSS,但揭示的问题对 APISIX 插件和 OpenResty 扩展有参考价值:一次看似很轻的调用,既可能产生直接成本,也可能改变调用方之后以解释器还是机器码运行。
+
+### 4.1 解释器派发异常活跃
+
+把 C 层样本按运行时类型重新分类,最值得注意的不是某行 Lua,而是解释器与 JIT 的关系:
+
+| 运行类别 | 每请求 CPU 时间 | 占本次样本 |
+|---|---:|---:|
+| 解释器派发:`lj_BC_*` / `lj_vm_*` | 5.86 μs | 29.6% |
+| 可观察到的 JIT trace 执行 | 2.55 μs | 12.9% |
+
+JIT trace 难以完整 unwind,12.9% 只能看作下界。但在同一环境中,解释器派发如此活跃,已经构成强信号:某些高频路径可能没有稳定地跑在机器码里。
+
+这不等于 trace 越多越好。初始化代码不需要追求编译,不同 trace 的成本差异也很大。只有在 CPU 饱和、路径高频、解释器派发异常时,编译结果才值得深挖。
+
+### 4.2 `jit.v` 发出警报,也制造了第一次误判
+
+开启 `jit.v` 后,一次短时压测输出中看到 417 次 trace 编译成功、493 次 abort。按位置聚合后,一批路径的 abort 次数整齐停在 11。
+
+但这是编译事件数,不是唯一函数数、覆盖率或 CPU 时间。`jit.v` 有几个边界:
+
+- abort 行显示的是 trace 中断位置,而惩罚记在 trace 起点,两者可能不是同一行。
+- 某函数从未成为 trace 起点,不代表它没有被编译,函数体可能已被内联进父 trace。
+- 文本日志很难对功能开启/关闭两组结果做稳定的集合差。
Review Comment:
`做稳定的集合差` is a literal rendering of "stable set difference".
````suggestion
- 文本日志很难在功能开启和关闭两组结果之间做稳定的集合比较。
````
##########
blog/zh/blog/2026/08/28/debugging-apisix-throughput-regression.md:
##########
@@ -0,0 +1,234 @@
+---
+title: "火焰图没撒谎,但没直接告诉我们瓶颈:一次 APISIX 吞吐回退定位"
+authors:
+ - name: "Xin Rong"
+ title: "Author"
+ url: "https://github.com/AlinsRan"
+ image_url: "https://github.com/AlinsRan.png"
+ - name: "Yilia Lin"
+ title: "Technical Writer"
+ url: "https://github.com/Yilialinn"
+ image_url: "https://github.com/Yilialinn.png"
+keywords:
+ - Apache APISIX
+ - 火焰图
+ - LuaJIT
+ - 性能优化
+ - 吞吐回退
+description: "火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。"
+tags: [Ecosystem]
+---
+
+火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。
+
+<!--truncate-->
+
+一次吞吐回退中,火焰图最宽的路径并不是最终瓶颈。真正值得追查的两个位置,在 Lua 采样里只有 3.0% 和 3.9%。如果按热点排序,它们根本进不了第一轮优化名单。
+
+这次排查最有价值的不是某个补丁,而是一条证据链:先确认 CPU 确实是瓶颈;再横向看火焰图宽度,建立候选;然后沿调用栈纵向追,看重复调用和公共路径如何放大成本;当火焰图解释不了端到端差距时,继续查 LuaJIT 编译与中断事件;最后用每请求调用次数和配对 A/B 实验定量。
+
+> 数据范围:下文数据来自同一受控环境中的一个 APISIX 内部定制构建,其中加载了 100 余个插件。吞吐统一归一化,只用于说明定位方法与因果链,不代表 Apache APISIX OSS 在其他硬件、配置或负载下的通用表现。
+
+## 1. 先确认 CPU 瓶颈,而不是上来就看火焰图
+
+火焰图显示的是采样期间 CPU 在执行什么。只有目标 worker 的 CPU 接近饱和、吞吐受这个核限制时,火焰图的宽度才有资格解释性能回退。
+
+我们在采样前固定了这些条件:
+
+- APISIX 单 worker 并绑定独占物理核;
+- 上游服务和压测端使用其他核,避免 CPU 争抢;
+- 请求模型、配置、响应内容保持一致;
+- 吞吐回退可稳定复现,错误率和响应结果没有偏移;
+- 目标 worker 持续接近饱和,上游、网络、压测端仍有余量。
+
+如果不满足这些前提,首先应该查连接、网络、上游或压测端,而不是在 on-CPU 火焰图里找答案。
+
+## 2. 横向看宽度,而不是排名
+
+我们使用 eBPF 以 500 Hz 同时采集 C 和 Lua 调用栈。下面是排查中的真实 Lua on-CPU 火焰图。
+
+
+
+*搜索 `run_global_rules` 后命中 3 处,但这些位置在整张图里并不醒目。原始采样共 2,446 个样本;截图已移除进程 PID。*
+
+横向看火焰图,看的是宽度,不是 x 轴顺序。宽度越大,采样落在该路径上的次数越多。初步候选位置按 Lua self time 排序:
+
+| 候选位置 | Lua self time | 初判 |
+|---|---:|---|
+| Prometheus exporter | 25.9% | 最值得先看 |
+| `ctx.var` 元方法 | 15.1% | 第二候选 |
+| 自定义日志旁路 | 3.9% | 很容易被忽略 |
+| `run_global_rules` | 3.0% | 很容易被忽略 |
+
+这里的 self time 指:在带 Lua 上下文的样本中,直接落在这个位置上的比例,不是总 CPU 占比。本次采样约 19.6% 的样本缺失 Lua 上下文,所以这些百分比只用于选候选,不能直接预测吞吐收益。
+
+这个排名有四个盲区:
+
+1. LuaJIT 解释执行时,不同的 Lua 代码可能折叠到共享的 `lj_BC_*`、`lj_vm_*` 符号里。
+2. 已编译的 JIT trace 不一定能被常规栈回溯完整展开,因此可观察到的 JIT 样本只是下界。
+3. 表查找、内存分配、GC 等成本会记在 C 符号上,而不是触发的 Lua 行上。
+4. 一个调用可能改变调用方的 JIT 状态,使成本看起来落在调用方函数的头上。
+
+`run_global_rules` 还有一个特征:它没有形成一根大柱子,而是散在多个请求阶段的调用栈里。单处不宽,不等于合起来不贵。
+
+## 3. 纵向沿栈看,3% 如何被公共路径放大
+
+选中某个 `run_global_rules` 方块后,纵向看调用栈:底部是请求阶段入口,向上经过 `common_phase`、Global Rule 插件筛选、调度,再到具体插件执行。
+
+
+
+*所选栈会被重新铺满画布,因此图中的宽度已经归一化,不能再当作它占总 CPU 的比例。*
+
+纵向高度本身不代表耗时。它的作用是看清成本从哪里进入、被谁调用、为什么反复出现。
+
+修复前,`run_global_rules()` 会在请求的多个阶段调用 `_M.filter()`。`_M.filter()` 遍历所有已加载插件,再逐个判断该插件是否被 Global Rule 配置:
+
+```lua
+-- 简化示意,非 APISIX 完整实现
+for _, plugin_obj in ipairs(local_plugins) do
+ local name = plugin_obj.name
+ local plugin_conf = user_plugin_conf[name]
+ if type(plugin_conf) ~= "table" then
+ goto continue
+ end
+ -- 处理已配置插件
+ ::continue::
+end
+```
+
+两个放大器都在这里:
+
+1. 单次过滤成本随已加载插件数量的增长。
+2. 相同过滤结果跨请求阶段重复生成;`body_filter` 与 `delayed_body_filter` 还可能按响应块多次进入。
+
+本次测试配置中,一个请求实际触发了 **9 次过滤**。也就是说,“从 100+ 插件中找出已配置的少量插件”这件事,被同一个请求重复了 9 次。
+
+这解释了为什么 Lua 层 3.0% 会低估整条路径成本:
+
+| 实际工作 | 火焰图常见归属 |
+|---|---|
+| 循环本身 | `plugin.lua` 对应位置 |
+| 大量表查找 | `lj_BC_TGETS` |
+| 临时表分配 | `lj_alloc_malloc` |
+| 临时对象回收 | `gc_sweep` |
+| 未编译解释器派发 | `lj_vm_*` / `lj_BC_*` |
+
+横向看,它只是 3% 的候选;纵向看,才会发现它位于一条被反复进入的公共路径。优化方向也很直接:同一请求内,如果 Global Rule 和匹配路由没有变化,就复用已筛选的插件集合,而不是每个阶段重新生成。
+
+## 4. 火焰图解释不了差距时,查 LuaJIT 编译事件
+
+第二条路径来自自定义观测组件。它和日志旁路不属于 APISIX OSS,但揭示的问题对 APISIX 插件和 OpenResty 扩展有参考价值:一次看似很轻的调用,既可能产生直接成本,也可能改变调用方之后以解释器还是机器码运行。
+
+### 4.1 解释器派发异常活跃
+
+把 C 层样本按运行时类型重新分类,最值得注意的不是某行 Lua,而是解释器与 JIT 的关系:
+
+| 运行类别 | 每请求 CPU 时间 | 占本次样本 |
+|---|---:|---:|
+| 解释器派发:`lj_BC_*` / `lj_vm_*` | 5.86 μs | 29.6% |
+| 可观察到的 JIT trace 执行 | 2.55 μs | 12.9% |
+
+JIT trace 难以完整 unwind,12.9% 只能看作下界。但在同一环境中,解释器派发如此活跃,已经构成强信号:某些高频路径可能没有稳定地跑在机器码里。
+
+这不等于 trace 越多越好。初始化代码不需要追求编译,不同 trace 的成本差异也很大。只有在 CPU 饱和、路径高频、解释器派发异常时,编译结果才值得深挖。
+
+### 4.2 `jit.v` 发出警报,也制造了第一次误判
+
+开启 `jit.v` 后,一次短时压测输出中看到 417 次 trace 编译成功、493 次 abort。按位置聚合后,一批路径的 abort 次数整齐停在 11。
+
+但这是编译事件数,不是唯一函数数、覆盖率或 CPU 时间。`jit.v` 有几个边界:
+
+- abort 行显示的是 trace 中断位置,而惩罚记在 trace 起点,两者可能不是同一行。
+- 某函数从未成为 trace 起点,不代表它没有被编译,函数体可能已被内联进父 trace。
+- 文本日志很难对功能开启/关闭两组结果做稳定的集合差。
+
+我们一开始也把“起点出现次数为 0”误读成“整个函数都在解释执行”。后来用 `jit.dump` 字节码模式复核,才发现多个函数入口没有成为 root trace,但函数体已多次进入其他 trace。真正反复失败的是阶段入口和编排函数。
+
+> 看不到 trace 起点,只能证明这里没有成为 root trace 锚点;不能证明整个函数没有进入机器码。
+
+### 4.3 把 `start`、`stop`、`abort` 归回同一起点
+
+要回答“哪段代码没有编译、为什么”,需要让 LuaJIT 自己交事件流。核心做法是按 trace id 保存 `start` 的位置,再把后续 `stop` 或 `abort` 归回同一个起点:
+
+```lua
+-- 简化示意,实际回调参数和解析逻辑更复杂
+local trace_start = {}
+
+jit.attach(function(what, trace_id, func, pc, err_code)
+ if what == "start" then
+ trace_start[trace_id] = locate(func, pc)
+ elseif what == "stop" then
+ record_compiled(trace_start[trace_id])
+ trace_start[trace_id] = nil
+ elseif what == "abort" then
+ record_abort(trace_start[trace_id], err_code)
+ trace_start[trace_id] = nil
+ end
+end, "trace")
+```
+
+实际探针还要用 `jit.util.funcinfo` 解析源位置,用 `jit.vmdef.traceerr` 还原 abort 原因。这样就能比较开启/关闭自定义组件时:哪些起点进入 compiled 集合,哪些反复 abort,以及是否发生 trace flush。
+
+在本次 LuaJIT 构建中,失败惩罚从 72 开始逐次翻倍:第 10 次是 36,864,第 11 次达到 73,728,超过 60,000 上限。多个起点恰好 abort ×11 后不再增长;它们没有进入 compiled 集合,采集期间也没有 trace flush。两组存活 trace 数均低于缓存上限,因此排除了 trace 缓存被挤爆的可能。这些证据共同说明:对应的 trace 起点已被放弃继续编译。
+
+仍然不能扩大结论:被放弃的是 trace 起点,不一定是整个函数体;其他部分仍可能被内联进别的 trace。
+
+按调用来源聚合后,受影响的阶段入口和编排函数都经过同一条自定义日志旁路:日志级别不足时虽不输出,但仍会检查请求阶段、调用栈和请求上下文。新增调用本身没有形成大柱子,却改变了调用方的 JIT 结果,使部分成本看起来落在正常请求函数头上。
+
+```lua
+-- 自定义扩展的简化示意,不是 APISIX OSS 实现
+if log_level_is_suppressed then
+ check_debug_capture(...)
+ check_request_buffer(...)
+ return
+end
+```
+
+JIT 数据在这里承担两个职责:发现火焰图无法正确归属的成本,验证功能开关确实改变了高频路径的执行状态。但它不能直接告诉我们吞吐损失多大;真正的方向也不是“强迫所有函数编译”,而是让关闭状态下本来不该发生的工作根本不执行。
+
+还有一个容易让结论失真的陷阱:探针必须装在被观测模块加载之前。如果模块在 `require` 时保存了函数引用,之后替换原函数,计数器只能看到少量没被快照走的调用。本次探针注入晚时测到 1 次/请求,前移到 `require("apisix")` 之前后,才确认真实值是 5 次/请求。
Review Comment:
`本次探针注入晚时` and `之前后` in the same sentence are hard to read.
````suggestion
还有一个容易让结论失真的陷阱:探针必须装在被观测模块加载之前。如果模块在 `require` 时保存了函数引用,之后替换原函数,计数器只能看到少量没被快照走的调用。本次探针注入较晚时测到 1 次/请求;把它前移到 `require("apisix")` 之前,才确认真实值是 5 次/请求。
````
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@apisix.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] blog: add APISIX throughput regression investigation (en + zh) [apisix-website]
Posted by "AlinsRan (via GitHub)" <gi...@apache.org>.
AlinsRan commented on code in PR #2115:
URL: https://github.com/apache/apisix-website/pull/2115#discussion_r3879714146
##########
blog/zh/blog/2026/08/28/debugging-apisix-throughput-regression.md:
##########
@@ -0,0 +1,234 @@
+---
+title: "火焰图没撒谎,但没直接告诉我们瓶颈:一次 APISIX 吞吐回退定位"
Review Comment:
Title: the hook plus a restatement of the topic is a generated-copy tell. A plain descriptive title works better and matches the rest of the site.
````suggestion
title: "一次 APISIX 吞吐回退排查:问题不在最宽的那根柱子上"
````
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@apisix.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] blog: add APISIX throughput regression investigation (en + zh) [apisix-website]
Posted by "AlinsRan (via GitHub)" <gi...@apache.org>.
AlinsRan commented on code in PR #2115:
URL: https://github.com/apache/apisix-website/pull/2115#discussion_r3879714196
##########
blog/zh/blog/2026/08/28/debugging-apisix-throughput-regression.md:
##########
@@ -0,0 +1,234 @@
+---
+title: "火焰图没撒谎,但没直接告诉我们瓶颈:一次 APISIX 吞吐回退定位"
+authors:
+ - name: "Xin Rong"
+ title: "Author"
+ url: "https://github.com/AlinsRan"
+ image_url: "https://github.com/AlinsRan.png"
+ - name: "Yilia Lin"
+ title: "Technical Writer"
+ url: "https://github.com/Yilialinn"
+ image_url: "https://github.com/Yilialinn.png"
+keywords:
+ - Apache APISIX
+ - 火焰图
+ - LuaJIT
+ - 性能优化
+ - 吞吐回退
+description: "火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。"
+tags: [Ecosystem]
+---
+
+火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。
+
+<!--truncate-->
+
+一次吞吐回退中,火焰图最宽的路径并不是最终瓶颈。真正值得追查的两个位置,在 Lua 采样里只有 3.0% 和 3.9%。如果按热点排序,它们根本进不了第一轮优化名单。
+
+这次排查最有价值的不是某个补丁,而是一条证据链:先确认 CPU 确实是瓶颈;再横向看火焰图宽度,建立候选;然后沿调用栈纵向追,看重复调用和公共路径如何放大成本;当火焰图解释不了端到端差距时,继续查 LuaJIT 编译与中断事件;最后用每请求调用次数和配对 A/B 实验定量。
+
+> 数据范围:下文数据来自同一受控环境中的一个 APISIX 内部定制构建,其中加载了 100 余个插件。吞吐统一归一化,只用于说明定位方法与因果链,不代表 Apache APISIX OSS 在其他硬件、配置或负载下的通用表现。
+
+## 1. 先确认 CPU 瓶颈,而不是上来就看火焰图
+
+火焰图显示的是采样期间 CPU 在执行什么。只有目标 worker 的 CPU 接近饱和、吞吐受这个核限制时,火焰图的宽度才有资格解释性能回退。
+
+我们在采样前固定了这些条件:
+
+- APISIX 单 worker 并绑定独占物理核;
+- 上游服务和压测端使用其他核,避免 CPU 争抢;
+- 请求模型、配置、响应内容保持一致;
+- 吞吐回退可稳定复现,错误率和响应结果没有偏移;
+- 目标 worker 持续接近饱和,上游、网络、压测端仍有余量。
+
+如果不满足这些前提,首先应该查连接、网络、上游或压测端,而不是在 on-CPU 火焰图里找答案。
+
+## 2. 横向看宽度,而不是排名
+
+我们使用 eBPF 以 500 Hz 同时采集 C 和 Lua 调用栈。下面是排查中的真实 Lua on-CPU 火焰图。
+
+
+
+*搜索 `run_global_rules` 后命中 3 处,但这些位置在整张图里并不醒目。原始采样共 2,446 个样本;截图已移除进程 PID。*
+
+横向看火焰图,看的是宽度,不是 x 轴顺序。宽度越大,采样落在该路径上的次数越多。初步候选位置按 Lua self time 排序:
+
+| 候选位置 | Lua self time | 初判 |
+|---|---:|---|
+| Prometheus exporter | 25.9% | 最值得先看 |
+| `ctx.var` 元方法 | 15.1% | 第二候选 |
+| 自定义日志旁路 | 3.9% | 很容易被忽略 |
+| `run_global_rules` | 3.0% | 很容易被忽略 |
+
+这里的 self time 指:在带 Lua 上下文的样本中,直接落在这个位置上的比例,不是总 CPU 占比。本次采样约 19.6% 的样本缺失 Lua 上下文,所以这些百分比只用于选候选,不能直接预测吞吐收益。
+
+这个排名有四个盲区:
+
+1. LuaJIT 解释执行时,不同的 Lua 代码可能折叠到共享的 `lj_BC_*`、`lj_vm_*` 符号里。
+2. 已编译的 JIT trace 不一定能被常规栈回溯完整展开,因此可观察到的 JIT 样本只是下界。
+3. 表查找、内存分配、GC 等成本会记在 C 符号上,而不是触发的 Lua 行上。
+4. 一个调用可能改变调用方的 JIT 状态,使成本看起来落在调用方函数的头上。
+
+`run_global_rules` 还有一个特征:它没有形成一根大柱子,而是散在多个请求阶段的调用栈里。单处不宽,不等于合起来不贵。
+
+## 3. 纵向沿栈看,3% 如何被公共路径放大
+
+选中某个 `run_global_rules` 方块后,纵向看调用栈:底部是请求阶段入口,向上经过 `common_phase`、Global Rule 插件筛选、调度,再到具体插件执行。
+
+
+
+*所选栈会被重新铺满画布,因此图中的宽度已经归一化,不能再当作它占总 CPU 的比例。*
+
+纵向高度本身不代表耗时。它的作用是看清成本从哪里进入、被谁调用、为什么反复出现。
+
+修复前,`run_global_rules()` 会在请求的多个阶段调用 `_M.filter()`。`_M.filter()` 遍历所有已加载插件,再逐个判断该插件是否被 Global Rule 配置:
+
+```lua
+-- 简化示意,非 APISIX 完整实现
+for _, plugin_obj in ipairs(local_plugins) do
+ local name = plugin_obj.name
+ local plugin_conf = user_plugin_conf[name]
+ if type(plugin_conf) ~= "table" then
+ goto continue
+ end
+ -- 处理已配置插件
+ ::continue::
+end
+```
+
+两个放大器都在这里:
+
+1. 单次过滤成本随已加载插件数量的增长。
+2. 相同过滤结果跨请求阶段重复生成;`body_filter` 与 `delayed_body_filter` 还可能按响应块多次进入。
+
+本次测试配置中,一个请求实际触发了 **9 次过滤**。也就是说,“从 100+ 插件中找出已配置的少量插件”这件事,被同一个请求重复了 9 次。
+
+这解释了为什么 Lua 层 3.0% 会低估整条路径成本:
+
+| 实际工作 | 火焰图常见归属 |
+|---|---|
+| 循环本身 | `plugin.lua` 对应位置 |
+| 大量表查找 | `lj_BC_TGETS` |
+| 临时表分配 | `lj_alloc_malloc` |
+| 临时对象回收 | `gc_sweep` |
+| 未编译解释器派发 | `lj_vm_*` / `lj_BC_*` |
+
+横向看,它只是 3% 的候选;纵向看,才会发现它位于一条被反复进入的公共路径。优化方向也很直接:同一请求内,如果 Global Rule 和匹配路由没有变化,就复用已筛选的插件集合,而不是每个阶段重新生成。
+
+## 4. 火焰图解释不了差距时,查 LuaJIT 编译事件
+
+第二条路径来自自定义观测组件。它和日志旁路不属于 APISIX OSS,但揭示的问题对 APISIX 插件和 OpenResty 扩展有参考价值:一次看似很轻的调用,既可能产生直接成本,也可能改变调用方之后以解释器还是机器码运行。
+
+### 4.1 解释器派发异常活跃
+
+把 C 层样本按运行时类型重新分类,最值得注意的不是某行 Lua,而是解释器与 JIT 的关系:
+
+| 运行类别 | 每请求 CPU 时间 | 占本次样本 |
+|---|---:|---:|
+| 解释器派发:`lj_BC_*` / `lj_vm_*` | 5.86 μs | 29.6% |
+| 可观察到的 JIT trace 执行 | 2.55 μs | 12.9% |
+
+JIT trace 难以完整 unwind,12.9% 只能看作下界。但在同一环境中,解释器派发如此活跃,已经构成强信号:某些高频路径可能没有稳定地跑在机器码里。
+
+这不等于 trace 越多越好。初始化代码不需要追求编译,不同 trace 的成本差异也很大。只有在 CPU 饱和、路径高频、解释器派发异常时,编译结果才值得深挖。
+
+### 4.2 `jit.v` 发出警报,也制造了第一次误判
+
+开启 `jit.v` 后,一次短时压测输出中看到 417 次 trace 编译成功、493 次 abort。按位置聚合后,一批路径的 abort 次数整齐停在 11。
+
+但这是编译事件数,不是唯一函数数、覆盖率或 CPU 时间。`jit.v` 有几个边界:
+
+- abort 行显示的是 trace 中断位置,而惩罚记在 trace 起点,两者可能不是同一行。
+- 某函数从未成为 trace 起点,不代表它没有被编译,函数体可能已被内联进父 trace。
+- 文本日志很难对功能开启/关闭两组结果做稳定的集合差。
+
+我们一开始也把“起点出现次数为 0”误读成“整个函数都在解释执行”。后来用 `jit.dump` 字节码模式复核,才发现多个函数入口没有成为 root trace,但函数体已多次进入其他 trace。真正反复失败的是阶段入口和编排函数。
+
+> 看不到 trace 起点,只能证明这里没有成为 root trace 锚点;不能证明整个函数没有进入机器码。
+
+### 4.3 把 `start`、`stop`、`abort` 归回同一起点
+
+要回答“哪段代码没有编译、为什么”,需要让 LuaJIT 自己交事件流。核心做法是按 trace id 保存 `start` 的位置,再把后续 `stop` 或 `abort` 归回同一个起点:
+
+```lua
+-- 简化示意,实际回调参数和解析逻辑更复杂
+local trace_start = {}
+
+jit.attach(function(what, trace_id, func, pc, err_code)
+ if what == "start" then
+ trace_start[trace_id] = locate(func, pc)
+ elseif what == "stop" then
+ record_compiled(trace_start[trace_id])
+ trace_start[trace_id] = nil
+ elseif what == "abort" then
+ record_abort(trace_start[trace_id], err_code)
+ trace_start[trace_id] = nil
+ end
+end, "trace")
+```
+
+实际探针还要用 `jit.util.funcinfo` 解析源位置,用 `jit.vmdef.traceerr` 还原 abort 原因。这样就能比较开启/关闭自定义组件时:哪些起点进入 compiled 集合,哪些反复 abort,以及是否发生 trace flush。
+
+在本次 LuaJIT 构建中,失败惩罚从 72 开始逐次翻倍:第 10 次是 36,864,第 11 次达到 73,728,超过 60,000 上限。多个起点恰好 abort ×11 后不再增长;它们没有进入 compiled 集合,采集期间也没有 trace flush。两组存活 trace 数均低于缓存上限,因此排除了 trace 缓存被挤爆的可能。这些证据共同说明:对应的 trace 起点已被放弃继续编译。
+
+仍然不能扩大结论:被放弃的是 trace 起点,不一定是整个函数体;其他部分仍可能被内联进别的 trace。
+
+按调用来源聚合后,受影响的阶段入口和编排函数都经过同一条自定义日志旁路:日志级别不足时虽不输出,但仍会检查请求阶段、调用栈和请求上下文。新增调用本身没有形成大柱子,却改变了调用方的 JIT 结果,使部分成本看起来落在正常请求函数头上。
Review Comment:
Long clause chains joined by semicolons read as translated spec text. Split into sentences; no data or conclusion changed.
````suggestion
真正的探针还要用 `jit.util.funcinfo` 解析源码位置,用 `jit.vmdef.traceerr` 还原 abort 的原因。有了这份数据,才能把组件开和关两种状态摆在一起比:哪些起点进了 compiled 集合,哪些在反复 abort,中间有没有发生 trace flush。
我们用的这个 LuaJIT 构建里,失败惩罚从 72 开始,每失败一次翻一倍:第 10 次是 36,864,第 11 次到 73,728,越过了 60,000 的上限。那批起点恰好都停在 abort ×11,之后再没进过 compiled 集合,采集期间也没有 trace flush,两组的存活 trace 数都在缓存上限以下,trace 缓存被挤爆这个可能性也排除了。几条凑在一起,结论就比较清楚:LuaJIT 放弃了这些 trace 起点。
但结论到此为止:被放弃的是 trace 起点,不一定是整个函数体,函数的其他部分照样可能被内联进别的 trace。
按调用来源再聚合一次,受影响的阶段入口和编排函数全都经过同一条自定义日志旁路。这条路径在日志级别不够、根本不会输出的时候,依然要去检查请求阶段、调用栈和请求上下文。它自己没在图上留下一根像样的柱子,却把调用方的 JIT 结果改掉了,于是一部分开销看起来落在了正常请求函数的头上。
````
##########
blog/zh/blog/2026/08/28/debugging-apisix-throughput-regression.md:
##########
@@ -0,0 +1,234 @@
+---
+title: "火焰图没撒谎,但没直接告诉我们瓶颈:一次 APISIX 吞吐回退定位"
+authors:
+ - name: "Xin Rong"
+ title: "Author"
+ url: "https://github.com/AlinsRan"
+ image_url: "https://github.com/AlinsRan.png"
+ - name: "Yilia Lin"
+ title: "Technical Writer"
+ url: "https://github.com/Yilialinn"
+ image_url: "https://github.com/Yilialinn.png"
+keywords:
+ - Apache APISIX
+ - 火焰图
+ - LuaJIT
+ - 性能优化
+ - 吞吐回退
+description: "火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。"
+tags: [Ecosystem]
+---
+
+火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。
+
+<!--truncate-->
+
+一次吞吐回退中,火焰图最宽的路径并不是最终瓶颈。真正值得追查的两个位置,在 Lua 采样里只有 3.0% 和 3.9%。如果按热点排序,它们根本进不了第一轮优化名单。
+
+这次排查最有价值的不是某个补丁,而是一条证据链:先确认 CPU 确实是瓶颈;再横向看火焰图宽度,建立候选;然后沿调用栈纵向追,看重复调用和公共路径如何放大成本;当火焰图解释不了端到端差距时,继续查 LuaJIT 编译与中断事件;最后用每请求调用次数和配对 A/B 实验定量。
+
+> 数据范围:下文数据来自同一受控环境中的一个 APISIX 内部定制构建,其中加载了 100 余个插件。吞吐统一归一化,只用于说明定位方法与因果链,不代表 Apache APISIX OSS 在其他硬件、配置或负载下的通用表现。
+
+## 1. 先确认 CPU 瓶颈,而不是上来就看火焰图
+
+火焰图显示的是采样期间 CPU 在执行什么。只有目标 worker 的 CPU 接近饱和、吞吐受这个核限制时,火焰图的宽度才有资格解释性能回退。
+
+我们在采样前固定了这些条件:
+
+- APISIX 单 worker 并绑定独占物理核;
+- 上游服务和压测端使用其他核,避免 CPU 争抢;
+- 请求模型、配置、响应内容保持一致;
+- 吞吐回退可稳定复现,错误率和响应结果没有偏移;
+- 目标 worker 持续接近饱和,上游、网络、压测端仍有余量。
+
+如果不满足这些前提,首先应该查连接、网络、上游或压测端,而不是在 on-CPU 火焰图里找答案。
+
+## 2. 横向看宽度,而不是排名
+
+我们使用 eBPF 以 500 Hz 同时采集 C 和 Lua 调用栈。下面是排查中的真实 Lua on-CPU 火焰图。
+
+
+
+*搜索 `run_global_rules` 后命中 3 处,但这些位置在整张图里并不醒目。原始采样共 2,446 个样本;截图已移除进程 PID。*
+
+横向看火焰图,看的是宽度,不是 x 轴顺序。宽度越大,采样落在该路径上的次数越多。初步候选位置按 Lua self time 排序:
+
+| 候选位置 | Lua self time | 初判 |
+|---|---:|---|
+| Prometheus exporter | 25.9% | 最值得先看 |
+| `ctx.var` 元方法 | 15.1% | 第二候选 |
+| 自定义日志旁路 | 3.9% | 很容易被忽略 |
+| `run_global_rules` | 3.0% | 很容易被忽略 |
+
+这里的 self time 指:在带 Lua 上下文的样本中,直接落在这个位置上的比例,不是总 CPU 占比。本次采样约 19.6% 的样本缺失 Lua 上下文,所以这些百分比只用于选候选,不能直接预测吞吐收益。
+
+这个排名有四个盲区:
+
+1. LuaJIT 解释执行时,不同的 Lua 代码可能折叠到共享的 `lj_BC_*`、`lj_vm_*` 符号里。
+2. 已编译的 JIT trace 不一定能被常规栈回溯完整展开,因此可观察到的 JIT 样本只是下界。
+3. 表查找、内存分配、GC 等成本会记在 C 符号上,而不是触发的 Lua 行上。
+4. 一个调用可能改变调用方的 JIT 状态,使成本看起来落在调用方函数的头上。
+
+`run_global_rules` 还有一个特征:它没有形成一根大柱子,而是散在多个请求阶段的调用栈里。单处不宽,不等于合起来不贵。
+
+## 3. 纵向沿栈看,3% 如何被公共路径放大
+
+选中某个 `run_global_rules` 方块后,纵向看调用栈:底部是请求阶段入口,向上经过 `common_phase`、Global Rule 插件筛选、调度,再到具体插件执行。
+
+
+
+*所选栈会被重新铺满画布,因此图中的宽度已经归一化,不能再当作它占总 CPU 的比例。*
+
+纵向高度本身不代表耗时。它的作用是看清成本从哪里进入、被谁调用、为什么反复出现。
+
+修复前,`run_global_rules()` 会在请求的多个阶段调用 `_M.filter()`。`_M.filter()` 遍历所有已加载插件,再逐个判断该插件是否被 Global Rule 配置:
+
+```lua
+-- 简化示意,非 APISIX 完整实现
+for _, plugin_obj in ipairs(local_plugins) do
+ local name = plugin_obj.name
+ local plugin_conf = user_plugin_conf[name]
+ if type(plugin_conf) ~= "table" then
+ goto continue
+ end
+ -- 处理已配置插件
+ ::continue::
+end
+```
+
+两个放大器都在这里:
+
+1. 单次过滤成本随已加载插件数量的增长。
+2. 相同过滤结果跨请求阶段重复生成;`body_filter` 与 `delayed_body_filter` 还可能按响应块多次进入。
+
+本次测试配置中,一个请求实际触发了 **9 次过滤**。也就是说,“从 100+ 插件中找出已配置的少量插件”这件事,被同一个请求重复了 9 次。
+
+这解释了为什么 Lua 层 3.0% 会低估整条路径成本:
+
+| 实际工作 | 火焰图常见归属 |
+|---|---|
+| 循环本身 | `plugin.lua` 对应位置 |
+| 大量表查找 | `lj_BC_TGETS` |
+| 临时表分配 | `lj_alloc_malloc` |
+| 临时对象回收 | `gc_sweep` |
+| 未编译解释器派发 | `lj_vm_*` / `lj_BC_*` |
+
+横向看,它只是 3% 的候选;纵向看,才会发现它位于一条被反复进入的公共路径。优化方向也很直接:同一请求内,如果 Global Rule 和匹配路由没有变化,就复用已筛选的插件集合,而不是每个阶段重新生成。
+
+## 4. 火焰图解释不了差距时,查 LuaJIT 编译事件
+
+第二条路径来自自定义观测组件。它和日志旁路不属于 APISIX OSS,但揭示的问题对 APISIX 插件和 OpenResty 扩展有参考价值:一次看似很轻的调用,既可能产生直接成本,也可能改变调用方之后以解释器还是机器码运行。
+
+### 4.1 解释器派发异常活跃
+
+把 C 层样本按运行时类型重新分类,最值得注意的不是某行 Lua,而是解释器与 JIT 的关系:
+
+| 运行类别 | 每请求 CPU 时间 | 占本次样本 |
+|---|---:|---:|
+| 解释器派发:`lj_BC_*` / `lj_vm_*` | 5.86 μs | 29.6% |
+| 可观察到的 JIT trace 执行 | 2.55 μs | 12.9% |
+
+JIT trace 难以完整 unwind,12.9% 只能看作下界。但在同一环境中,解释器派发如此活跃,已经构成强信号:某些高频路径可能没有稳定地跑在机器码里。
+
+这不等于 trace 越多越好。初始化代码不需要追求编译,不同 trace 的成本差异也很大。只有在 CPU 饱和、路径高频、解释器派发异常时,编译结果才值得深挖。
+
+### 4.2 `jit.v` 发出警报,也制造了第一次误判
+
+开启 `jit.v` 后,一次短时压测输出中看到 417 次 trace 编译成功、493 次 abort。按位置聚合后,一批路径的 abort 次数整齐停在 11。
+
+但这是编译事件数,不是唯一函数数、覆盖率或 CPU 时间。`jit.v` 有几个边界:
+
+- abort 行显示的是 trace 中断位置,而惩罚记在 trace 起点,两者可能不是同一行。
+- 某函数从未成为 trace 起点,不代表它没有被编译,函数体可能已被内联进父 trace。
+- 文本日志很难对功能开启/关闭两组结果做稳定的集合差。
+
+我们一开始也把“起点出现次数为 0”误读成“整个函数都在解释执行”。后来用 `jit.dump` 字节码模式复核,才发现多个函数入口没有成为 root trace,但函数体已多次进入其他 trace。真正反复失败的是阶段入口和编排函数。
+
+> 看不到 trace 起点,只能证明这里没有成为 root trace 锚点;不能证明整个函数没有进入机器码。
+
+### 4.3 把 `start`、`stop`、`abort` 归回同一起点
+
+要回答“哪段代码没有编译、为什么”,需要让 LuaJIT 自己交事件流。核心做法是按 trace id 保存 `start` 的位置,再把后续 `stop` 或 `abort` 归回同一个起点:
Review Comment:
De-jargoning only. Keeps the misdiagnosis in 4.2 — it is the most readable part of the article — but phrases it as something that happened rather than a lesson being delivered.
````suggestion
JIT trace 的栈不一定能完整 unwind,12.9% 只能算下界。但在同一个环境里,解释器派发能占到这个量,本身就是个够强的信号:有些高频路径大概没稳定跑在机器码里。
这不是说 trace 越多越好。初始化的代码本来就不必追求编译,不同 trace 之间的开销差别也很大。只有在 CPU 已经饱和、路径确实高频、解释器派发又反常的时候,编译结果才值得往下挖。
### 4.2 `jit.v` 报了警,也把我们带沟里过一次
打开 `jit.v` 跑一轮短压测,输出里有 417 次 trace 编译成功、493 次 abort。按位置聚合之后,一批路径的 abort 次数整整齐齐停在 11。
但这些是编译事件的次数,不是函数个数,也不是覆盖率或者 CPU 时间。`jit.v` 本身还有几个边界:
- abort 行显示的是 trace 中断的位置,而惩罚记在 trace 起点上,两者可能不是同一行。
- 某个函数从没当过 trace 起点,不代表它没被编译,函数体可能已经内联进父 trace 了。
- 文本日志很难对功能开和关两组结果做稳定的集合差。
我们一开始就把“起点出现次数为 0”读成了“整个函数都在解释执行”。后来用 `jit.dump` 的字节码模式复核才发现不是这么回事:好几个函数入口确实没成为 root trace,但函数体已经多次进过别的 trace。真正在反复失败的是阶段入口和编排函数。
> 看不到 trace 起点,只能说明这里没当上 root trace 的锚点,不能说明整个函数没进过机器码。
### 4.3 把 `start`、`stop`、`abort` 归回同一个起点
想知道到底哪段代码没编译成、为什么没编译成,得让 LuaJIT 自己把事件流交出来。思路不复杂:按 trace id 把 `start` 的位置记下来,后面的 `stop` 或 `abort` 再归回这个起点。
````
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@apisix.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] blog: add APISIX throughput regression investigation (en + zh) [apisix-website]
Posted by "AlinsRan (via GitHub)" <gi...@apache.org>.
AlinsRan commented on code in PR #2115:
URL: https://github.com/apache/apisix-website/pull/2115#discussion_r3879714202
##########
blog/zh/blog/2026/08/28/debugging-apisix-throughput-regression.md:
##########
@@ -0,0 +1,234 @@
+---
+title: "火焰图没撒谎,但没直接告诉我们瓶颈:一次 APISIX 吞吐回退定位"
+authors:
+ - name: "Xin Rong"
+ title: "Author"
+ url: "https://github.com/AlinsRan"
+ image_url: "https://github.com/AlinsRan.png"
+ - name: "Yilia Lin"
+ title: "Technical Writer"
+ url: "https://github.com/Yilialinn"
+ image_url: "https://github.com/Yilialinn.png"
+keywords:
+ - Apache APISIX
+ - 火焰图
+ - LuaJIT
+ - 性能优化
+ - 吞吐回退
+description: "火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。"
+tags: [Ecosystem]
+---
+
+火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。
+
+<!--truncate-->
+
+一次吞吐回退中,火焰图最宽的路径并不是最终瓶颈。真正值得追查的两个位置,在 Lua 采样里只有 3.0% 和 3.9%。如果按热点排序,它们根本进不了第一轮优化名单。
+
+这次排查最有价值的不是某个补丁,而是一条证据链:先确认 CPU 确实是瓶颈;再横向看火焰图宽度,建立候选;然后沿调用栈纵向追,看重复调用和公共路径如何放大成本;当火焰图解释不了端到端差距时,继续查 LuaJIT 编译与中断事件;最后用每请求调用次数和配对 A/B 实验定量。
+
+> 数据范围:下文数据来自同一受控环境中的一个 APISIX 内部定制构建,其中加载了 100 余个插件。吞吐统一归一化,只用于说明定位方法与因果链,不代表 Apache APISIX OSS 在其他硬件、配置或负载下的通用表现。
+
+## 1. 先确认 CPU 瓶颈,而不是上来就看火焰图
+
+火焰图显示的是采样期间 CPU 在执行什么。只有目标 worker 的 CPU 接近饱和、吞吐受这个核限制时,火焰图的宽度才有资格解释性能回退。
+
+我们在采样前固定了这些条件:
+
+- APISIX 单 worker 并绑定独占物理核;
+- 上游服务和压测端使用其他核,避免 CPU 争抢;
+- 请求模型、配置、响应内容保持一致;
+- 吞吐回退可稳定复现,错误率和响应结果没有偏移;
+- 目标 worker 持续接近饱和,上游、网络、压测端仍有余量。
+
+如果不满足这些前提,首先应该查连接、网络、上游或压测端,而不是在 on-CPU 火焰图里找答案。
+
+## 2. 横向看宽度,而不是排名
+
+我们使用 eBPF 以 500 Hz 同时采集 C 和 Lua 调用栈。下面是排查中的真实 Lua on-CPU 火焰图。
+
+
+
+*搜索 `run_global_rules` 后命中 3 处,但这些位置在整张图里并不醒目。原始采样共 2,446 个样本;截图已移除进程 PID。*
+
+横向看火焰图,看的是宽度,不是 x 轴顺序。宽度越大,采样落在该路径上的次数越多。初步候选位置按 Lua self time 排序:
+
+| 候选位置 | Lua self time | 初判 |
+|---|---:|---|
+| Prometheus exporter | 25.9% | 最值得先看 |
+| `ctx.var` 元方法 | 15.1% | 第二候选 |
+| 自定义日志旁路 | 3.9% | 很容易被忽略 |
+| `run_global_rules` | 3.0% | 很容易被忽略 |
+
+这里的 self time 指:在带 Lua 上下文的样本中,直接落在这个位置上的比例,不是总 CPU 占比。本次采样约 19.6% 的样本缺失 Lua 上下文,所以这些百分比只用于选候选,不能直接预测吞吐收益。
+
+这个排名有四个盲区:
+
+1. LuaJIT 解释执行时,不同的 Lua 代码可能折叠到共享的 `lj_BC_*`、`lj_vm_*` 符号里。
+2. 已编译的 JIT trace 不一定能被常规栈回溯完整展开,因此可观察到的 JIT 样本只是下界。
+3. 表查找、内存分配、GC 等成本会记在 C 符号上,而不是触发的 Lua 行上。
+4. 一个调用可能改变调用方的 JIT 状态,使成本看起来落在调用方函数的头上。
+
+`run_global_rules` 还有一个特征:它没有形成一根大柱子,而是散在多个请求阶段的调用栈里。单处不宽,不等于合起来不贵。
+
+## 3. 纵向沿栈看,3% 如何被公共路径放大
+
+选中某个 `run_global_rules` 方块后,纵向看调用栈:底部是请求阶段入口,向上经过 `common_phase`、Global Rule 插件筛选、调度,再到具体插件执行。
+
+
+
+*所选栈会被重新铺满画布,因此图中的宽度已经归一化,不能再当作它占总 CPU 的比例。*
+
+纵向高度本身不代表耗时。它的作用是看清成本从哪里进入、被谁调用、为什么反复出现。
+
+修复前,`run_global_rules()` 会在请求的多个阶段调用 `_M.filter()`。`_M.filter()` 遍历所有已加载插件,再逐个判断该插件是否被 Global Rule 配置:
+
+```lua
+-- 简化示意,非 APISIX 完整实现
+for _, plugin_obj in ipairs(local_plugins) do
+ local name = plugin_obj.name
+ local plugin_conf = user_plugin_conf[name]
+ if type(plugin_conf) ~= "table" then
+ goto continue
+ end
+ -- 处理已配置插件
+ ::continue::
+end
+```
+
+两个放大器都在这里:
+
+1. 单次过滤成本随已加载插件数量的增长。
+2. 相同过滤结果跨请求阶段重复生成;`body_filter` 与 `delayed_body_filter` 还可能按响应块多次进入。
+
+本次测试配置中,一个请求实际触发了 **9 次过滤**。也就是说,“从 100+ 插件中找出已配置的少量插件”这件事,被同一个请求重复了 9 次。
+
+这解释了为什么 Lua 层 3.0% 会低估整条路径成本:
+
+| 实际工作 | 火焰图常见归属 |
+|---|---|
+| 循环本身 | `plugin.lua` 对应位置 |
+| 大量表查找 | `lj_BC_TGETS` |
+| 临时表分配 | `lj_alloc_malloc` |
+| 临时对象回收 | `gc_sweep` |
+| 未编译解释器派发 | `lj_vm_*` / `lj_BC_*` |
+
+横向看,它只是 3% 的候选;纵向看,才会发现它位于一条被反复进入的公共路径。优化方向也很直接:同一请求内,如果 Global Rule 和匹配路由没有变化,就复用已筛选的插件集合,而不是每个阶段重新生成。
+
+## 4. 火焰图解释不了差距时,查 LuaJIT 编译事件
+
+第二条路径来自自定义观测组件。它和日志旁路不属于 APISIX OSS,但揭示的问题对 APISIX 插件和 OpenResty 扩展有参考价值:一次看似很轻的调用,既可能产生直接成本,也可能改变调用方之后以解释器还是机器码运行。
+
+### 4.1 解释器派发异常活跃
+
+把 C 层样本按运行时类型重新分类,最值得注意的不是某行 Lua,而是解释器与 JIT 的关系:
+
+| 运行类别 | 每请求 CPU 时间 | 占本次样本 |
+|---|---:|---:|
+| 解释器派发:`lj_BC_*` / `lj_vm_*` | 5.86 μs | 29.6% |
+| 可观察到的 JIT trace 执行 | 2.55 μs | 12.9% |
+
+JIT trace 难以完整 unwind,12.9% 只能看作下界。但在同一环境中,解释器派发如此活跃,已经构成强信号:某些高频路径可能没有稳定地跑在机器码里。
+
+这不等于 trace 越多越好。初始化代码不需要追求编译,不同 trace 的成本差异也很大。只有在 CPU 饱和、路径高频、解释器派发异常时,编译结果才值得深挖。
+
+### 4.2 `jit.v` 发出警报,也制造了第一次误判
+
+开启 `jit.v` 后,一次短时压测输出中看到 417 次 trace 编译成功、493 次 abort。按位置聚合后,一批路径的 abort 次数整齐停在 11。
+
+但这是编译事件数,不是唯一函数数、覆盖率或 CPU 时间。`jit.v` 有几个边界:
+
+- abort 行显示的是 trace 中断位置,而惩罚记在 trace 起点,两者可能不是同一行。
+- 某函数从未成为 trace 起点,不代表它没有被编译,函数体可能已被内联进父 trace。
+- 文本日志很难对功能开启/关闭两组结果做稳定的集合差。
+
+我们一开始也把“起点出现次数为 0”误读成“整个函数都在解释执行”。后来用 `jit.dump` 字节码模式复核,才发现多个函数入口没有成为 root trace,但函数体已多次进入其他 trace。真正反复失败的是阶段入口和编排函数。
+
+> 看不到 trace 起点,只能证明这里没有成为 root trace 锚点;不能证明整个函数没有进入机器码。
+
+### 4.3 把 `start`、`stop`、`abort` 归回同一起点
+
+要回答“哪段代码没有编译、为什么”,需要让 LuaJIT 自己交事件流。核心做法是按 trace id 保存 `start` 的位置,再把后续 `stop` 或 `abort` 归回同一个起点:
+
+```lua
+-- 简化示意,实际回调参数和解析逻辑更复杂
+local trace_start = {}
+
+jit.attach(function(what, trace_id, func, pc, err_code)
+ if what == "start" then
+ trace_start[trace_id] = locate(func, pc)
+ elseif what == "stop" then
+ record_compiled(trace_start[trace_id])
+ trace_start[trace_id] = nil
+ elseif what == "abort" then
+ record_abort(trace_start[trace_id], err_code)
+ trace_start[trace_id] = nil
+ end
+end, "trace")
+```
+
+实际探针还要用 `jit.util.funcinfo` 解析源位置,用 `jit.vmdef.traceerr` 还原 abort 原因。这样就能比较开启/关闭自定义组件时:哪些起点进入 compiled 集合,哪些反复 abort,以及是否发生 trace flush。
+
+在本次 LuaJIT 构建中,失败惩罚从 72 开始逐次翻倍:第 10 次是 36,864,第 11 次达到 73,728,超过 60,000 上限。多个起点恰好 abort ×11 后不再增长;它们没有进入 compiled 集合,采集期间也没有 trace flush。两组存活 trace 数均低于缓存上限,因此排除了 trace 缓存被挤爆的可能。这些证据共同说明:对应的 trace 起点已被放弃继续编译。
+
+仍然不能扩大结论:被放弃的是 trace 起点,不一定是整个函数体;其他部分仍可能被内联进别的 trace。
+
+按调用来源聚合后,受影响的阶段入口和编排函数都经过同一条自定义日志旁路:日志级别不足时虽不输出,但仍会检查请求阶段、调用栈和请求上下文。新增调用本身没有形成大柱子,却改变了调用方的 JIT 结果,使部分成本看起来落在正常请求函数头上。
+
+```lua
+-- 自定义扩展的简化示意,不是 APISIX OSS 实现
+if log_level_is_suppressed then
+ check_debug_capture(...)
+ check_request_buffer(...)
+ return
+end
+```
+
+JIT 数据在这里承担两个职责:发现火焰图无法正确归属的成本,验证功能开关确实改变了高频路径的执行状态。但它不能直接告诉我们吞吐损失多大;真正的方向也不是“强迫所有函数编译”,而是让关闭状态下本来不该发生的工作根本不执行。
+
+还有一个容易让结论失真的陷阱:探针必须装在被观测模块加载之前。如果模块在 `require` 时保存了函数引用,之后替换原函数,计数器只能看到少量没被快照走的调用。本次探针注入晚时测到 1 次/请求,前移到 `require("apisix")` 之前后,才确认真实值是 5 次/请求。
+
+## 5. 配对 A/B 收口:实验回答“值多少钱”
+
+火焰图给位置,调用栈给放大链,LuaJIT 事件解释错位成本。最终还要靠配对实验定量。
+
+以 Global Rule 路径为例,我们保留相同的调度和配置,只让 Prometheus 业务函数进入后立即返回,用来区分“插件业务逻辑慢”和“进入插件前的公共路径慢”。为避免把定制环境绝对 RPS 当成 APISIX OSS benchmark,关闭 Prometheus 的吞吐被归一化为 100:
Review Comment:
Section 5 heading (`收口`) and the `实验回答“值多少钱”` construction.
````suggestion
JIT 数据在这里干了两件事:把火焰图归错位置的开销挖出来,以及证明这个开关确实改变了高频路径的执行状态。它回答不了“这条路径值多少吞吐”。而且真正该做的也不是把所有函数都逼着编译,是让关掉功能之后本来就不该发生的活儿别发生。
还有个坑差点让结论整个作废:探针必须装在被观测模块加载之前。模块在 `require` 的时候可能已经把函数引用存下来了,你之后再替换原函数,计数器只能看到少数几次没被快照走的调用。我们第一次注入得晚,测出来 1 次/请求;把探针挪到 `require("apisix")` 之前,真实值是 5 次/请求。
## 5. 用配对 A/B 把账算清
火焰图给位置,调用栈给放大链,LuaJIT 事件解释错位的开销。这几样都回答不了“到底值多少钱”,得靠实验。
拿 Global Rule 这条路径举例:调度和配置全部保持原样,只让 Prometheus 的业务函数一进来就返回,用来区分“插件业务逻辑慢”和“进插件之前的公共路径慢”。为了避免把定制环境的绝对 RPS 当成 APISIX OSS 的 benchmark,我们把关掉 Prometheus 时的吞吐归一化成 100:
````
##########
blog/zh/blog/2026/08/28/debugging-apisix-throughput-regression.md:
##########
@@ -0,0 +1,234 @@
+---
+title: "火焰图没撒谎,但没直接告诉我们瓶颈:一次 APISIX 吞吐回退定位"
+authors:
+ - name: "Xin Rong"
+ title: "Author"
+ url: "https://github.com/AlinsRan"
+ image_url: "https://github.com/AlinsRan.png"
+ - name: "Yilia Lin"
+ title: "Technical Writer"
+ url: "https://github.com/Yilialinn"
+ image_url: "https://github.com/Yilialinn.png"
+keywords:
+ - Apache APISIX
+ - 火焰图
+ - LuaJIT
+ - 性能优化
+ - 吞吐回退
+description: "火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。"
+tags: [Ecosystem]
+---
+
+火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。
+
+<!--truncate-->
+
+一次吞吐回退中,火焰图最宽的路径并不是最终瓶颈。真正值得追查的两个位置,在 Lua 采样里只有 3.0% 和 3.9%。如果按热点排序,它们根本进不了第一轮优化名单。
+
+这次排查最有价值的不是某个补丁,而是一条证据链:先确认 CPU 确实是瓶颈;再横向看火焰图宽度,建立候选;然后沿调用栈纵向追,看重复调用和公共路径如何放大成本;当火焰图解释不了端到端差距时,继续查 LuaJIT 编译与中断事件;最后用每请求调用次数和配对 A/B 实验定量。
+
+> 数据范围:下文数据来自同一受控环境中的一个 APISIX 内部定制构建,其中加载了 100 余个插件。吞吐统一归一化,只用于说明定位方法与因果链,不代表 Apache APISIX OSS 在其他硬件、配置或负载下的通用表现。
+
+## 1. 先确认 CPU 瓶颈,而不是上来就看火焰图
+
+火焰图显示的是采样期间 CPU 在执行什么。只有目标 worker 的 CPU 接近饱和、吞吐受这个核限制时,火焰图的宽度才有资格解释性能回退。
+
+我们在采样前固定了这些条件:
+
+- APISIX 单 worker 并绑定独占物理核;
+- 上游服务和压测端使用其他核,避免 CPU 争抢;
+- 请求模型、配置、响应内容保持一致;
+- 吞吐回退可稳定复现,错误率和响应结果没有偏移;
+- 目标 worker 持续接近饱和,上游、网络、压测端仍有余量。
+
+如果不满足这些前提,首先应该查连接、网络、上游或压测端,而不是在 on-CPU 火焰图里找答案。
+
+## 2. 横向看宽度,而不是排名
+
+我们使用 eBPF 以 500 Hz 同时采集 C 和 Lua 调用栈。下面是排查中的真实 Lua on-CPU 火焰图。
+
+
+
+*搜索 `run_global_rules` 后命中 3 处,但这些位置在整张图里并不醒目。原始采样共 2,446 个样本;截图已移除进程 PID。*
+
+横向看火焰图,看的是宽度,不是 x 轴顺序。宽度越大,采样落在该路径上的次数越多。初步候选位置按 Lua self time 排序:
+
+| 候选位置 | Lua self time | 初判 |
+|---|---:|---|
+| Prometheus exporter | 25.9% | 最值得先看 |
+| `ctx.var` 元方法 | 15.1% | 第二候选 |
+| 自定义日志旁路 | 3.9% | 很容易被忽略 |
+| `run_global_rules` | 3.0% | 很容易被忽略 |
+
+这里的 self time 指:在带 Lua 上下文的样本中,直接落在这个位置上的比例,不是总 CPU 占比。本次采样约 19.6% 的样本缺失 Lua 上下文,所以这些百分比只用于选候选,不能直接预测吞吐收益。
+
+这个排名有四个盲区:
+
+1. LuaJIT 解释执行时,不同的 Lua 代码可能折叠到共享的 `lj_BC_*`、`lj_vm_*` 符号里。
+2. 已编译的 JIT trace 不一定能被常规栈回溯完整展开,因此可观察到的 JIT 样本只是下界。
+3. 表查找、内存分配、GC 等成本会记在 C 符号上,而不是触发的 Lua 行上。
+4. 一个调用可能改变调用方的 JIT 状态,使成本看起来落在调用方函数的头上。
+
+`run_global_rules` 还有一个特征:它没有形成一根大柱子,而是散在多个请求阶段的调用栈里。单处不宽,不等于合起来不贵。
+
+## 3. 纵向沿栈看,3% 如何被公共路径放大
+
+选中某个 `run_global_rules` 方块后,纵向看调用栈:底部是请求阶段入口,向上经过 `common_phase`、Global Rule 插件筛选、调度,再到具体插件执行。
+
+
+
+*所选栈会被重新铺满画布,因此图中的宽度已经归一化,不能再当作它占总 CPU 的比例。*
+
+纵向高度本身不代表耗时。它的作用是看清成本从哪里进入、被谁调用、为什么反复出现。
+
+修复前,`run_global_rules()` 会在请求的多个阶段调用 `_M.filter()`。`_M.filter()` 遍历所有已加载插件,再逐个判断该插件是否被 Global Rule 配置:
+
+```lua
+-- 简化示意,非 APISIX 完整实现
+for _, plugin_obj in ipairs(local_plugins) do
+ local name = plugin_obj.name
+ local plugin_conf = user_plugin_conf[name]
+ if type(plugin_conf) ~= "table" then
+ goto continue
+ end
+ -- 处理已配置插件
+ ::continue::
+end
+```
+
+两个放大器都在这里:
+
+1. 单次过滤成本随已加载插件数量的增长。
+2. 相同过滤结果跨请求阶段重复生成;`body_filter` 与 `delayed_body_filter` 还可能按响应块多次进入。
+
+本次测试配置中,一个请求实际触发了 **9 次过滤**。也就是说,“从 100+ 插件中找出已配置的少量插件”这件事,被同一个请求重复了 9 次。
+
+这解释了为什么 Lua 层 3.0% 会低估整条路径成本:
+
+| 实际工作 | 火焰图常见归属 |
+|---|---|
+| 循环本身 | `plugin.lua` 对应位置 |
+| 大量表查找 | `lj_BC_TGETS` |
+| 临时表分配 | `lj_alloc_malloc` |
+| 临时对象回收 | `gc_sweep` |
+| 未编译解释器派发 | `lj_vm_*` / `lj_BC_*` |
+
+横向看,它只是 3% 的候选;纵向看,才会发现它位于一条被反复进入的公共路径。优化方向也很直接:同一请求内,如果 Global Rule 和匹配路由没有变化,就复用已筛选的插件集合,而不是每个阶段重新生成。
+
+## 4. 火焰图解释不了差距时,查 LuaJIT 编译事件
+
+第二条路径来自自定义观测组件。它和日志旁路不属于 APISIX OSS,但揭示的问题对 APISIX 插件和 OpenResty 扩展有参考价值:一次看似很轻的调用,既可能产生直接成本,也可能改变调用方之后以解释器还是机器码运行。
+
+### 4.1 解释器派发异常活跃
+
+把 C 层样本按运行时类型重新分类,最值得注意的不是某行 Lua,而是解释器与 JIT 的关系:
+
+| 运行类别 | 每请求 CPU 时间 | 占本次样本 |
+|---|---:|---:|
+| 解释器派发:`lj_BC_*` / `lj_vm_*` | 5.86 μs | 29.6% |
+| 可观察到的 JIT trace 执行 | 2.55 μs | 12.9% |
+
+JIT trace 难以完整 unwind,12.9% 只能看作下界。但在同一环境中,解释器派发如此活跃,已经构成强信号:某些高频路径可能没有稳定地跑在机器码里。
+
+这不等于 trace 越多越好。初始化代码不需要追求编译,不同 trace 的成本差异也很大。只有在 CPU 饱和、路径高频、解释器派发异常时,编译结果才值得深挖。
+
+### 4.2 `jit.v` 发出警报,也制造了第一次误判
+
+开启 `jit.v` 后,一次短时压测输出中看到 417 次 trace 编译成功、493 次 abort。按位置聚合后,一批路径的 abort 次数整齐停在 11。
+
+但这是编译事件数,不是唯一函数数、覆盖率或 CPU 时间。`jit.v` 有几个边界:
+
+- abort 行显示的是 trace 中断位置,而惩罚记在 trace 起点,两者可能不是同一行。
+- 某函数从未成为 trace 起点,不代表它没有被编译,函数体可能已被内联进父 trace。
+- 文本日志很难对功能开启/关闭两组结果做稳定的集合差。
+
+我们一开始也把“起点出现次数为 0”误读成“整个函数都在解释执行”。后来用 `jit.dump` 字节码模式复核,才发现多个函数入口没有成为 root trace,但函数体已多次进入其他 trace。真正反复失败的是阶段入口和编排函数。
+
+> 看不到 trace 起点,只能证明这里没有成为 root trace 锚点;不能证明整个函数没有进入机器码。
+
+### 4.3 把 `start`、`stop`、`abort` 归回同一起点
+
+要回答“哪段代码没有编译、为什么”,需要让 LuaJIT 自己交事件流。核心做法是按 trace id 保存 `start` 的位置,再把后续 `stop` 或 `abort` 归回同一个起点:
+
+```lua
+-- 简化示意,实际回调参数和解析逻辑更复杂
+local trace_start = {}
+
+jit.attach(function(what, trace_id, func, pc, err_code)
+ if what == "start" then
+ trace_start[trace_id] = locate(func, pc)
+ elseif what == "stop" then
+ record_compiled(trace_start[trace_id])
+ trace_start[trace_id] = nil
+ elseif what == "abort" then
+ record_abort(trace_start[trace_id], err_code)
+ trace_start[trace_id] = nil
+ end
+end, "trace")
+```
+
+实际探针还要用 `jit.util.funcinfo` 解析源位置,用 `jit.vmdef.traceerr` 还原 abort 原因。这样就能比较开启/关闭自定义组件时:哪些起点进入 compiled 集合,哪些反复 abort,以及是否发生 trace flush。
+
+在本次 LuaJIT 构建中,失败惩罚从 72 开始逐次翻倍:第 10 次是 36,864,第 11 次达到 73,728,超过 60,000 上限。多个起点恰好 abort ×11 后不再增长;它们没有进入 compiled 集合,采集期间也没有 trace flush。两组存活 trace 数均低于缓存上限,因此排除了 trace 缓存被挤爆的可能。这些证据共同说明:对应的 trace 起点已被放弃继续编译。
+
+仍然不能扩大结论:被放弃的是 trace 起点,不一定是整个函数体;其他部分仍可能被内联进别的 trace。
+
+按调用来源聚合后,受影响的阶段入口和编排函数都经过同一条自定义日志旁路:日志级别不足时虽不输出,但仍会检查请求阶段、调用栈和请求上下文。新增调用本身没有形成大柱子,却改变了调用方的 JIT 结果,使部分成本看起来落在正常请求函数头上。
+
+```lua
+-- 自定义扩展的简化示意,不是 APISIX OSS 实现
+if log_level_is_suppressed then
+ check_debug_capture(...)
+ check_request_buffer(...)
+ return
+end
+```
+
+JIT 数据在这里承担两个职责:发现火焰图无法正确归属的成本,验证功能开关确实改变了高频路径的执行状态。但它不能直接告诉我们吞吐损失多大;真正的方向也不是“强迫所有函数编译”,而是让关闭状态下本来不该发生的工作根本不执行。
+
+还有一个容易让结论失真的陷阱:探针必须装在被观测模块加载之前。如果模块在 `require` 时保存了函数引用,之后替换原函数,计数器只能看到少量没被快照走的调用。本次探针注入晚时测到 1 次/请求,前移到 `require("apisix")` 之前后,才确认真实值是 5 次/请求。
+
+## 5. 配对 A/B 收口:实验回答“值多少钱”
+
+火焰图给位置,调用栈给放大链,LuaJIT 事件解释错位成本。最终还要靠配对实验定量。
+
+以 Global Rule 路径为例,我们保留相同的调度和配置,只让 Prometheus 业务函数进入后立即返回,用来区分“插件业务逻辑慢”和“进入插件前的公共路径慢”。为避免把定制环境绝对 RPS 当成 APISIX OSS benchmark,关闭 Prometheus 的吞吐被归一化为 100:
+
+| 内部 A/B 场景 | 相对吞吐指数 | 相对关闭 Prometheus |
+|---|---:|---:|
+| 关闭 Prometheus | 100.0 | 基准 |
+| 保留插件与调度,业务函数立即返回 | 77.2 | -22.8% |
+| 完整 Prometheus Global Rule | 56.9 | -43.1% |
+
+即使插件业务代码被短路,差距仍然显著。这个实验不能把成本定位到某一行,但足以证明损耗不只来自指标计算,进入插件前的公共路径本身就值得追查。再结合每请求 9 次过滤、纵向调用栈和 C 层成本分布,放大链才真正闭合。
+
+自定义观测组件也用同样方法:保持负载与配置一致,分别采集开启和关闭下的编译事件、每请求调用次数、吞吐和响应正确性。JIT 事件解释“为什么图上没有足够宽的新柱子”,端到端 A/B 回答“这条路径到底值多少钱”。
+
+整个排查可以压缩成五步:
+
+| 步骤 | 核心问题 | 证据 |
+|---|---|---|
+| 1. 确认 CPU 前提 | 火焰图是否有资格解释回退? | worker 饱和、绑核、上下游余量、稳定复现 |
+| 2. 横向看宽度 | 样本主要落在哪里? | 全局火焰图与候选排序 |
+| 3. 纵向沿栈看 | 小成本为何被放大? | 调用阶段、公共函数、每请求调用次数 |
+| 4. 检查 LuaJIT | 成本为何错位或消失? | `start`、`stop`、`abort`、`flush` 与 `jit.dump` |
+| 5. 配对 A/B 收口 | 真实量级与因果是什么? | 吞吐、延迟、调用次数、错误率与响应一致性 |
+
+这些结论必须留在明确边界内:
+
+- 火焰图宽度、Lua self time 与吞吐变化的分母不同,不能直接相减或相除。
+- 自定义观测组件不属于 APISIX OSS,只用于说明自定义扩展可能遇到的通用问题。
+- 29.6%、12.9%、417、493 和 abort ×11 都属于本次构建和采集窗口,不能外推。
+- “没有成为 trace 起点”不等于“函数没有编译”,必须检查函数体是否进入其他 trace。
+- JIT 编译结果是定位信号,不是最终性能指标;优化仍需验证吞吐、延迟、错误率、响应内容和资源回收。
+
+火焰图没有撒谎。横向宽度告诉我们 CPU 样本聚集在哪,纵向调用栈告诉我们成本如何被公共路径放大。但当成本进入解释器、JIT trace、分配器和调用方后,火焰图看到的就不再是完整归属。
+
+这时最有效的动作不是继续猜哪行 Lua 应该更快,而是让 LuaJIT 交出编译事件,再用每请求调用次数和配对 A/B 把量级钉死。最终值得优化的,往往不是最宽的柱子,而是那段经过证据证明、根本不需要反复发生的工作。
Review Comment:
Two changes. The caveat list drops the `没有成为 trace 起点` item, which 4.2 already states twice (once as a blockquote, once as `仍然不能扩大结论`) — 5 items reads as defensive padding. And the last two paragraphs return to the `火焰图没有撒谎` hook for the third time; reworded.
````suggestion
业务代码都被短路了,差距还是这么大。这个实验定位不到具体某一行,但足以说明开销不全在指标计算上,进插件之前的公共路径本身就该查。再对上每请求 9 次过滤、调用栈的形状和 C 层的开销分布,这条放大链才算说通。
自定义观测组件走的是同一套流程:负载和配置不动,分别在开和关两种状态下采编译事件、每请求调用次数、吞吐和响应正确性。JIT 事件回答“图上为什么没多出一根够宽的柱子”,端到端 A/B 回答“这条路径到底值多少”。
整个排查压缩下来是五步:
| 步骤 | 要回答的问题 | 看什么 |
|---|---|---|
| 1. 确认 CPU 前提 | 火焰图能否解释这次回退? | worker 饱和、绑核、上下游余量、稳定复现 |
| 2. 看宽度挑候选 | 样本主要落在哪儿? | 全局火焰图与候选排序 |
| 3. 顺着栈往上看 | 一点小开销为什么会被放大? | 调用阶段、公共函数、每请求调用次数 |
| 4. 查 LuaJIT | 开销为什么错位或者消失了? | `start`、`stop`、`abort`、`flush` 与 `jit.dump` |
| 5. 配对 A/B | 真实量级多大,因果成不成立? | 吞吐、延迟、调用次数、错误率与响应一致性 |
有几条边界得说清楚:
- 火焰图宽度、Lua self time 和吞吐变化的分母各不相同,不能直接相减或相除。
- 29.6%、12.9%、417、493 和 abort ×11 都只对这次的构建和采集窗口成立,不能外推。
- 自定义观测组件不属于 APISIX OSS,写进来只是因为它暴露的问题对自定义扩展有普遍性。
- JIT 编译结果是定位信号,不是性能指标;改完还是要拿吞吐、延迟、错误率、响应内容和资源回收去验。
火焰图的宽度告诉我们 CPU 的采样堆在哪,调用栈告诉我们一小块开销怎么被公共路径放大。但当开销散进解释器派发、JIT trace、分配器和调用方之后,火焰图给出的归属就不完整了。
这种时候继续猜哪行 Lua 该更快,基本是在浪费时间。更有效的做法是让 LuaJIT 交出编译事件,再用每请求调用次数和配对 A/B 把量级钉死。最后真正值得改的,往往不是最宽的那根柱子,而是那段根本不需要反复发生的工作。
````
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@apisix.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] blog: add APISIX throughput regression investigation (en + zh) [apisix-website]
Posted by "kayx23 (via GitHub)" <gi...@apache.org>.
kayx23 commented on code in PR #2115:
URL: https://github.com/apache/apisix-website/pull/2115#discussion_r3879625383
##########
blog/en/blog/2026/08/28/debugging-apisix-throughput-regression.md:
##########
@@ -0,0 +1,232 @@
+---
+title: "APISIX Throughput Regression: Beyond the Flame Graph"
+authors:
+ - name: "Xin Rong"
+ title: "Author"
+ url: "https://github.com/AlinsRan"
+ image_url: "https://github.com/AlinsRan.png"
+ - name: "Yilia Lin"
+ title: "Technical Writer"
+ url: "https://github.com/Yilialinn"
+ image_url: "https://github.com/Yilialinn.png"
+keywords:
+ - Apache APISIX
+ - flame graph
+ - LuaJIT
+ - performance optimization
+ - throughput regression
+description: "An APISIX throughput regression showed why the widest path in the flame graph is not always the real bottleneck—and how CPU data, call stacks, LuaJIT events, and paired A/B tests exposed it."
+tags: [Ecosystem]
+---
+
+Flame graphs are honest, but they are not verdicts. In this APISIX throughput regression, the widest path was not the real bottleneck. The paths worth chasing accounted for just 3.0% and 3.9% of the Lua samples.
Review Comment:
This opening sounds written rather than spoken. “Honest, but they are not verdicts” is a slogan; the Chinese just says the flame graph didn’t lie and also didn’t name the bottleneck.
Something closer to the source, and more natural:
> The flame graph didn’t lie, but it also didn’t point to the bottleneck. In this APISIX throughput regression, the widest path was not the one that mattered. The two places worth chasing were only 3.0% and 3.9% of the Lua samples.
##########
blog/en/blog/2026/08/28/debugging-apisix-throughput-regression.md:
##########
@@ -0,0 +1,232 @@
+---
+title: "APISIX Throughput Regression: Beyond the Flame Graph"
+authors:
+ - name: "Xin Rong"
+ title: "Author"
+ url: "https://github.com/AlinsRan"
+ image_url: "https://github.com/AlinsRan.png"
+ - name: "Yilia Lin"
+ title: "Technical Writer"
+ url: "https://github.com/Yilialinn"
+ image_url: "https://github.com/Yilialinn.png"
+keywords:
+ - Apache APISIX
+ - flame graph
+ - LuaJIT
+ - performance optimization
+ - throughput regression
+description: "An APISIX throughput regression showed why the widest path in the flame graph is not always the real bottleneck—and how CPU data, call stacks, LuaJIT events, and paired A/B tests exposed it."
+tags: [Ecosystem]
+---
+
+Flame graphs are honest, but they are not verdicts. In this APISIX throughput regression, the widest path was not the real bottleneck. The paths worth chasing accounted for just 3.0% and 3.9% of the Lua samples.
+
+<!--truncate-->
+
+Ranked by hotspot size alone, neither path would have made the first optimization shortlist. The key was not a single patch but a chain of evidence: confirm that the worker is CPU-bound, compare flame-graph widths, follow repeated call paths, inspect LuaJIT compilation and aborts when the numbers stop adding up, and use per-request call counts plus paired A/B tests to measure the impact.
+
+> **Data scope:** The data below comes from one internally customized APISIX build in the same controlled environment, with more than 100 plugins loaded. Throughput is normalized and is used only to illustrate the diagnostic method and causal chain. It does not represent the general performance of Apache APISIX OSS under other hardware, configurations, or workloads.
+
+## 1. Confirm the Worker Is CPU-Bound
+
+A flame graph shows where the CPU spent its sampled time. Its width matters to a throughput regression only when the target worker is close to saturation and that core limits throughput.
+
+Before collecting the profile, we held the following conditions constant:
+
+- APISIX ran with a single worker pinned to a dedicated physical core.
+- The upstream service and load generator ran on other cores to avoid CPU contention.
+- The request model, configuration, and response content remained unchanged.
+- The regression reproduced consistently, with stable error rates and response results.
Review Comment:
“Response results” isn’t a thing people say. The previous bullet already has “response content”; here the Chinese is that error rate and responses didn’t drift.
> The regression reproduced consistently. Error rates and responses did not drift.
##########
blog/en/blog/2026/08/28/debugging-apisix-throughput-regression.md:
##########
@@ -0,0 +1,232 @@
+---
+title: "APISIX Throughput Regression: Beyond the Flame Graph"
+authors:
+ - name: "Xin Rong"
+ title: "Author"
+ url: "https://github.com/AlinsRan"
+ image_url: "https://github.com/AlinsRan.png"
+ - name: "Yilia Lin"
+ title: "Technical Writer"
+ url: "https://github.com/Yilialinn"
+ image_url: "https://github.com/Yilialinn.png"
+keywords:
+ - Apache APISIX
+ - flame graph
+ - LuaJIT
+ - performance optimization
+ - throughput regression
+description: "An APISIX throughput regression showed why the widest path in the flame graph is not always the real bottleneck—and how CPU data, call stacks, LuaJIT events, and paired A/B tests exposed it."
+tags: [Ecosystem]
+---
+
+Flame graphs are honest, but they are not verdicts. In this APISIX throughput regression, the widest path was not the real bottleneck. The paths worth chasing accounted for just 3.0% and 3.9% of the Lua samples.
+
+<!--truncate-->
+
+Ranked by hotspot size alone, neither path would have made the first optimization shortlist. The key was not a single patch but a chain of evidence: confirm that the worker is CPU-bound, compare flame-graph widths, follow repeated call paths, inspect LuaJIT compilation and aborts when the numbers stop adding up, and use per-request call counts plus paired A/B tests to measure the impact.
+
+> **Data scope:** The data below comes from one internally customized APISIX build in the same controlled environment, with more than 100 plugins loaded. Throughput is normalized and is used only to illustrate the diagnostic method and causal chain. It does not represent the general performance of Apache APISIX OSS under other hardware, configurations, or workloads.
+
+## 1. Confirm the Worker Is CPU-Bound
+
+A flame graph shows where the CPU spent its sampled time. Its width matters to a throughput regression only when the target worker is close to saturation and that core limits throughput.
Review Comment:
“Its width matters to a throughput regression” doesn’t read like English. The Chinese is: width is only allowed to explain the regression when the worker is nearly saturated and that core is what limits throughput.
Try:
> A flame graph shows where the CPU spent its sampled time. That width only explains a throughput drop when the target worker is close to saturation and that core is the limit.
##########
blog/en/blog/2026/08/28/debugging-apisix-throughput-regression.md:
##########
@@ -0,0 +1,232 @@
+---
+title: "APISIX Throughput Regression: Beyond the Flame Graph"
+authors:
+ - name: "Xin Rong"
+ title: "Author"
+ url: "https://github.com/AlinsRan"
+ image_url: "https://github.com/AlinsRan.png"
+ - name: "Yilia Lin"
+ title: "Technical Writer"
+ url: "https://github.com/Yilialinn"
+ image_url: "https://github.com/Yilialinn.png"
+keywords:
+ - Apache APISIX
+ - flame graph
+ - LuaJIT
+ - performance optimization
+ - throughput regression
+description: "An APISIX throughput regression showed why the widest path in the flame graph is not always the real bottleneck—and how CPU data, call stacks, LuaJIT events, and paired A/B tests exposed it."
+tags: [Ecosystem]
+---
+
+Flame graphs are honest, but they are not verdicts. In this APISIX throughput regression, the widest path was not the real bottleneck. The paths worth chasing accounted for just 3.0% and 3.9% of the Lua samples.
+
+<!--truncate-->
+
+Ranked by hotspot size alone, neither path would have made the first optimization shortlist. The key was not a single patch but a chain of evidence: confirm that the worker is CPU-bound, compare flame-graph widths, follow repeated call paths, inspect LuaJIT compilation and aborts when the numbers stop adding up, and use per-request call counts plus paired A/B tests to measure the impact.
+
+> **Data scope:** The data below comes from one internally customized APISIX build in the same controlled environment, with more than 100 plugins loaded. Throughput is normalized and is used only to illustrate the diagnostic method and causal chain. It does not represent the general performance of Apache APISIX OSS under other hardware, configurations, or workloads.
+
+## 1. Confirm the Worker Is CPU-Bound
+
+A flame graph shows where the CPU spent its sampled time. Its width matters to a throughput regression only when the target worker is close to saturation and that core limits throughput.
+
+Before collecting the profile, we held the following conditions constant:
+
+- APISIX ran with a single worker pinned to a dedicated physical core.
+- The upstream service and load generator ran on other cores to avoid CPU contention.
+- The request model, configuration, and response content remained unchanged.
+- The regression reproduced consistently, with stable error rates and response results.
+- The target worker remained close to saturation, while the upstream service, network, and load generator still had headroom.
+
+If any of these conditions is not met, start with connections, the network, the upstream service, or the load generator—not an on-CPU flame graph.
+
+## 2. Read Width, Not Rank
+
+We used eBPF to sample C and Lua call stacks simultaneously at 500 Hz. Here is the actual Lua on-CPU flame graph from the investigation.
+
+
+
+Figure 1: Global view of the actual flame graph. Searching for `run_global_rules` produced 3 matches, none of which stood out in the full graph. The original profile contained 2,446 samples; process PIDs were removed from the screenshot.
+
+In a flame graph, x-axis position does not imply order. Width reflects how many samples landed on a path. Ranked by Lua self time, the first shortlist looked like this:
+
+| Candidate location | Lua self time | Initial assessment |
+|---|---:|---|
+| Prometheus exporter | 25.9% | Highest-priority candidate |
+| `ctx.var` metamethod | 15.1% | Second candidate |
+| Custom logging bypass | 3.9% | Easy to overlook |
+| `run_global_rules` | 3.0% | Easy to overlook |
+
+Here, self time is the share of samples with Lua context that landed directly at a location—not a share of total CPU time. About 19.6% of the samples lacked Lua context, so these percentages are useful for selecting candidates, not predicting throughput gains.
+
+This ranking has four blind spots:
+
+1. When LuaJIT executes interpreted code, different Lua code paths may collapse into shared `lj_BC_*` and `lj_vm_*` symbols.
+2. Compiled JIT traces cannot always be fully expanded by conventional stack unwinding, so observable JIT samples provide only a lower bound.
+3. Costs such as table lookups, memory allocation, and garbage collection are attributed to C symbols rather than to the Lua lines that triggered them.
+4. A call can change the caller's JIT state, making the resulting cost appear at the head of the caller function.
+
+There was another clue: `run_global_rules` did not form one large column. It appeared across stacks from several request phases. No single occurrence was wide, but the combined cost could still be substantial.
+
+## 3. Follow the Stack to See How 3% Gets Amplified
+
+Select a `run_global_rules` frame and follow the stack upward. The request-phase entry point sits at the bottom, followed by `common_phase`, Global Rule plugin filtering, dispatch, and finally individual plugin execution.
+
+
+
+Figure 2: Interactive zoomed view after selecting one `run_global_rules` stack. The selected stack is stretched to fill the canvas, so its width is normalized and must not be interpreted as its share of total CPU time. Click the image to view it at full size.
+
+Stack height does not represent elapsed time. It shows where the cost enters, who invokes it, and why it keeps coming back.
+
+Before the fix, `run_global_rules()` called `_M.filter()` in several request phases. `_M.filter()` iterated through every loaded plugin and checked each one to determine whether it had been configured in the Global Rule:
+
+```lua
+-- Simplified illustration, not the complete APISIX implementation
+for _, plugin_obj in ipairs(local_plugins) do
+ local name = plugin_obj.name
+ local plugin_conf = user_plugin_conf[name]
+ if type(plugin_conf) ~= "table" then
+ goto continue
+ end
+ -- Process configured plugins
+ ::continue::
+end
+```
+
+Two factors amplified the cost:
+
+1. The cost of each filtering pass increased with the number of loaded plugins.
+2. The same filtering result was regenerated across request phases. The `body_filter` and `delayed_body_filter` phases could also be entered multiple times for response-body chunks.
+
+In this test configuration, one request triggered 9 filtering passes. The same request scanned more than 100 loaded plugins for the same small configured set 9 times.
+
+This explains why a Lua-level self time of 3.0% understated the cost of the entire path:
+
+| Actual work | Common flame-graph attribution |
+|---|---|
+| The loop itself | Corresponding location in `plugin.lua` |
+| Large numbers of table lookups | `lj_BC_TGETS` |
+| Temporary table allocation | `lj_alloc_malloc` |
+| Temporary object reclamation | `gc_sweep` |
+| Uncompiled interpreter dispatch | `lj_vm_*` / `lj_BC_*` |
+
+At 3% self time, this path looked minor. Following the stack showed that it sat on a shared path entered repeatedly. The optimization followed directly: within one request, if the Global Rule and matched route have not changed, reuse the filtered plugin set instead of rebuilding it in every phase.
+
+## 4. When the Flame Graph Doesn't Add Up, Check LuaJIT
+
+The second lead came from a custom observability component. Neither the component nor its logging bypass is part of APISIX OSS, but the failure mode matters to APISIX plugins and OpenResty extensions: a call that looks cheap can add direct cost and change whether subsequent caller code runs in the interpreter or as machine code.
+
+### 4.1 Interpreter Dispatch Was Abnormally Active
+
+After reclassifying the C-level samples by runtime category, the most notable signal was not any individual Lua line, but the relationship between interpreter execution and JIT execution:
+
+| Runtime category | CPU time per request | Share of samples |
+|---|---:|---:|
+| Interpreter dispatch: `lj_BC_*` / `lj_vm_*` | 5.86 μs | 29.6% |
+| Observable JIT trace execution | 2.55 μs | 12.9% |
+
+JIT traces do not always unwind cleanly, so 12.9% is only a lower bound. Still, the amount of interpreter dispatch in the same environment was a strong signal that some high-frequency paths might not be running consistently as machine code.
+
+This is not an argument for maximizing trace count. Initialization code does not need to be compiled, and trace costs vary widely. Compilation results are worth investigating only when the CPU is saturated, the path is hot, and interpreter dispatch is unusually active.
+
+### 4.2 `jit.v` Raised the Alarm—and Caused the First Misdiagnosis
+
+With `jit.v` enabled, one short load test reported 417 successful trace compilations and 493 aborts. After aggregation by source location, a group of paths each stopped at exactly 11 aborts.
+
+These are compilation-event counts—not unique functions, coverage, or CPU time. `jit.v` also has several important limitations:
+
+- An abort line shows the trace abort location, while the penalty is applied to the trace starting point; the two may not be the same line.
+- A function never appearing as a trace starting point does not mean it was never compiled. Its body may have been inlined into a parent trace.
+- Text logs make it difficult to perform stable set comparisons between results collected with a feature enabled and disabled.
Review Comment:
“Stable set comparisons” is a literal of 集合差. An engineer would say you can’t reliably diff the two logs.
> Text logs make it hard to diff the enabled vs disabled runs in a stable way.
##########
blog/en/blog/2026/08/28/debugging-apisix-throughput-regression.md:
##########
@@ -0,0 +1,232 @@
+---
+title: "APISIX Throughput Regression: Beyond the Flame Graph"
+authors:
+ - name: "Xin Rong"
+ title: "Author"
+ url: "https://github.com/AlinsRan"
+ image_url: "https://github.com/AlinsRan.png"
+ - name: "Yilia Lin"
+ title: "Technical Writer"
+ url: "https://github.com/Yilialinn"
+ image_url: "https://github.com/Yilialinn.png"
+keywords:
+ - Apache APISIX
+ - flame graph
+ - LuaJIT
+ - performance optimization
+ - throughput regression
+description: "An APISIX throughput regression showed why the widest path in the flame graph is not always the real bottleneck—and how CPU data, call stacks, LuaJIT events, and paired A/B tests exposed it."
+tags: [Ecosystem]
+---
+
+Flame graphs are honest, but they are not verdicts. In this APISIX throughput regression, the widest path was not the real bottleneck. The paths worth chasing accounted for just 3.0% and 3.9% of the Lua samples.
+
+<!--truncate-->
+
+Ranked by hotspot size alone, neither path would have made the first optimization shortlist. The key was not a single patch but a chain of evidence: confirm that the worker is CPU-bound, compare flame-graph widths, follow repeated call paths, inspect LuaJIT compilation and aborts when the numbers stop adding up, and use per-request call counts plus paired A/B tests to measure the impact.
+
+> **Data scope:** The data below comes from one internally customized APISIX build in the same controlled environment, with more than 100 plugins loaded. Throughput is normalized and is used only to illustrate the diagnostic method and causal chain. It does not represent the general performance of Apache APISIX OSS under other hardware, configurations, or workloads.
+
+## 1. Confirm the Worker Is CPU-Bound
+
+A flame graph shows where the CPU spent its sampled time. Its width matters to a throughput regression only when the target worker is close to saturation and that core limits throughput.
+
+Before collecting the profile, we held the following conditions constant:
+
+- APISIX ran with a single worker pinned to a dedicated physical core.
+- The upstream service and load generator ran on other cores to avoid CPU contention.
+- The request model, configuration, and response content remained unchanged.
+- The regression reproduced consistently, with stable error rates and response results.
+- The target worker remained close to saturation, while the upstream service, network, and load generator still had headroom.
+
+If any of these conditions is not met, start with connections, the network, the upstream service, or the load generator—not an on-CPU flame graph.
+
+## 2. Read Width, Not Rank
+
+We used eBPF to sample C and Lua call stacks simultaneously at 500 Hz. Here is the actual Lua on-CPU flame graph from the investigation.
+
+
+
+Figure 1: Global view of the actual flame graph. Searching for `run_global_rules` produced 3 matches, none of which stood out in the full graph. The original profile contained 2,446 samples; process PIDs were removed from the screenshot.
+
+In a flame graph, x-axis position does not imply order. Width reflects how many samples landed on a path. Ranked by Lua self time, the first shortlist looked like this:
+
+| Candidate location | Lua self time | Initial assessment |
+|---|---:|---|
+| Prometheus exporter | 25.9% | Highest-priority candidate |
+| `ctx.var` metamethod | 15.1% | Second candidate |
+| Custom logging bypass | 3.9% | Easy to overlook |
+| `run_global_rules` | 3.0% | Easy to overlook |
+
+Here, self time is the share of samples with Lua context that landed directly at a location—not a share of total CPU time. About 19.6% of the samples lacked Lua context, so these percentages are useful for selecting candidates, not predicting throughput gains.
+
+This ranking has four blind spots:
+
+1. When LuaJIT executes interpreted code, different Lua code paths may collapse into shared `lj_BC_*` and `lj_vm_*` symbols.
+2. Compiled JIT traces cannot always be fully expanded by conventional stack unwinding, so observable JIT samples provide only a lower bound.
+3. Costs such as table lookups, memory allocation, and garbage collection are attributed to C symbols rather than to the Lua lines that triggered them.
+4. A call can change the caller's JIT state, making the resulting cost appear at the head of the caller function.
+
+There was another clue: `run_global_rules` did not form one large column. It appeared across stacks from several request phases. No single occurrence was wide, but the combined cost could still be substantial.
+
+## 3. Follow the Stack to See How 3% Gets Amplified
+
+Select a `run_global_rules` frame and follow the stack upward. The request-phase entry point sits at the bottom, followed by `common_phase`, Global Rule plugin filtering, dispatch, and finally individual plugin execution.
+
+
+
+Figure 2: Interactive zoomed view after selecting one `run_global_rules` stack. The selected stack is stretched to fill the canvas, so its width is normalized and must not be interpreted as its share of total CPU time. Click the image to view it at full size.
+
+Stack height does not represent elapsed time. It shows where the cost enters, who invokes it, and why it keeps coming back.
+
+Before the fix, `run_global_rules()` called `_M.filter()` in several request phases. `_M.filter()` iterated through every loaded plugin and checked each one to determine whether it had been configured in the Global Rule:
+
+```lua
+-- Simplified illustration, not the complete APISIX implementation
+for _, plugin_obj in ipairs(local_plugins) do
+ local name = plugin_obj.name
+ local plugin_conf = user_plugin_conf[name]
+ if type(plugin_conf) ~= "table" then
+ goto continue
+ end
+ -- Process configured plugins
+ ::continue::
+end
+```
+
+Two factors amplified the cost:
+
+1. The cost of each filtering pass increased with the number of loaded plugins.
+2. The same filtering result was regenerated across request phases. The `body_filter` and `delayed_body_filter` phases could also be entered multiple times for response-body chunks.
+
+In this test configuration, one request triggered 9 filtering passes. The same request scanned more than 100 loaded plugins for the same small configured set 9 times.
+
+This explains why a Lua-level self time of 3.0% understated the cost of the entire path:
+
+| Actual work | Common flame-graph attribution |
+|---|---|
+| The loop itself | Corresponding location in `plugin.lua` |
+| Large numbers of table lookups | `lj_BC_TGETS` |
+| Temporary table allocation | `lj_alloc_malloc` |
+| Temporary object reclamation | `gc_sweep` |
+| Uncompiled interpreter dispatch | `lj_vm_*` / `lj_BC_*` |
+
+At 3% self time, this path looked minor. Following the stack showed that it sat on a shared path entered repeatedly. The optimization followed directly: within one request, if the Global Rule and matched route have not changed, reuse the filtered plugin set instead of rebuilding it in every phase.
+
+## 4. When the Flame Graph Doesn't Add Up, Check LuaJIT
+
+The second lead came from a custom observability component. Neither the component nor its logging bypass is part of APISIX OSS, but the failure mode matters to APISIX plugins and OpenResty extensions: a call that looks cheap can add direct cost and change whether subsequent caller code runs in the interpreter or as machine code.
+
+### 4.1 Interpreter Dispatch Was Abnormally Active
+
+After reclassifying the C-level samples by runtime category, the most notable signal was not any individual Lua line, but the relationship between interpreter execution and JIT execution:
+
+| Runtime category | CPU time per request | Share of samples |
+|---|---:|---:|
+| Interpreter dispatch: `lj_BC_*` / `lj_vm_*` | 5.86 μs | 29.6% |
+| Observable JIT trace execution | 2.55 μs | 12.9% |
+
+JIT traces do not always unwind cleanly, so 12.9% is only a lower bound. Still, the amount of interpreter dispatch in the same environment was a strong signal that some high-frequency paths might not be running consistently as machine code.
+
+This is not an argument for maximizing trace count. Initialization code does not need to be compiled, and trace costs vary widely. Compilation results are worth investigating only when the CPU is saturated, the path is hot, and interpreter dispatch is unusually active.
+
+### 4.2 `jit.v` Raised the Alarm—and Caused the First Misdiagnosis
+
+With `jit.v` enabled, one short load test reported 417 successful trace compilations and 493 aborts. After aggregation by source location, a group of paths each stopped at exactly 11 aborts.
+
+These are compilation-event counts—not unique functions, coverage, or CPU time. `jit.v` also has several important limitations:
+
+- An abort line shows the trace abort location, while the penalty is applied to the trace starting point; the two may not be the same line.
+- A function never appearing as a trace starting point does not mean it was never compiled. Its body may have been inlined into a parent trace.
+- Text logs make it difficult to perform stable set comparisons between results collected with a feature enabled and disabled.
+
+At first, we read “0 appearances as a starting point” as “the whole function runs in the interpreter.” The bytecode mode of `jit.dump` proved otherwise: several function entries never became root traces, but their bodies repeatedly entered other traces. The recurring failures were in phase-entry and orchestration functions.
+
+> The absence of a trace starting point proves only that the location did not become a root-trace anchor. It does not prove that the entire function never entered machine code.
+
+### 4.3 Correlate `start`, `stop`, and `abort` by Trace Start
+
+To learn what failed to compile and why, we needed the event stream from LuaJIT itself. Store each `start` location by trace ID, then map the matching `stop` or `abort` back to that same starting point:
+
+```lua
+-- Simplified illustration; actual callback arguments and parsing are more complex
+local trace_start = {}
+
+jit.attach(function(what, trace_id, func, pc, err_code)
+ if what == "start" then
+ trace_start[trace_id] = locate(func, pc)
+ elseif what == "stop" then
+ record_compiled(trace_start[trace_id])
+ trace_start[trace_id] = nil
+ elseif what == "abort" then
+ record_abort(trace_start[trace_id], err_code)
+ trace_start[trace_id] = nil
+ end
+end, "trace")
+```
+
+The real probe also needs `jit.util.funcinfo` to resolve source locations and `jit.vmdef.traceerr` to recover abort reasons. With that data, we can compare the component's enabled and disabled states: which starting points compile, which repeatedly abort, and whether a trace flush occurs.
+
+In the LuaJIT build used here, the failure penalty started at 72 and doubled after each failure. It was 36,864 on the 10th failure and reached 73,728 on the 11th, above the 60,000 limit. Several starting points stopped at exactly 11 aborts and never entered the compiled set; no trace flush occurred during collection. The number of live traces in both test variants remained below the cache limit, ruling out trace-cache exhaustion. Together, the evidence showed that LuaJIT had abandoned further compilation attempts for those trace starts.
+
+Keep the conclusion narrow: LuaJIT abandoned those trace starts, not necessarily the entire functions. Other parts of the same functions could still have been inlined into different traces.
Review Comment:
“Keep the conclusion narrow” is editor-voice. The next sentence already does the work.
> That still doesn’t mean the whole function was abandoned. Other parts of it could have been inlined into different traces.
##########
blog/en/blog/2026/08/28/debugging-apisix-throughput-regression.md:
##########
@@ -0,0 +1,232 @@
+---
+title: "APISIX Throughput Regression: Beyond the Flame Graph"
+authors:
+ - name: "Xin Rong"
+ title: "Author"
+ url: "https://github.com/AlinsRan"
+ image_url: "https://github.com/AlinsRan.png"
+ - name: "Yilia Lin"
+ title: "Technical Writer"
+ url: "https://github.com/Yilialinn"
+ image_url: "https://github.com/Yilialinn.png"
+keywords:
+ - Apache APISIX
+ - flame graph
+ - LuaJIT
+ - performance optimization
+ - throughput regression
+description: "An APISIX throughput regression showed why the widest path in the flame graph is not always the real bottleneck—and how CPU data, call stacks, LuaJIT events, and paired A/B tests exposed it."
+tags: [Ecosystem]
+---
+
+Flame graphs are honest, but they are not verdicts. In this APISIX throughput regression, the widest path was not the real bottleneck. The paths worth chasing accounted for just 3.0% and 3.9% of the Lua samples.
+
+<!--truncate-->
+
+Ranked by hotspot size alone, neither path would have made the first optimization shortlist. The key was not a single patch but a chain of evidence: confirm that the worker is CPU-bound, compare flame-graph widths, follow repeated call paths, inspect LuaJIT compilation and aborts when the numbers stop adding up, and use per-request call counts plus paired A/B tests to measure the impact.
+
+> **Data scope:** The data below comes from one internally customized APISIX build in the same controlled environment, with more than 100 plugins loaded. Throughput is normalized and is used only to illustrate the diagnostic method and causal chain. It does not represent the general performance of Apache APISIX OSS under other hardware, configurations, or workloads.
+
+## 1. Confirm the Worker Is CPU-Bound
+
+A flame graph shows where the CPU spent its sampled time. Its width matters to a throughput regression only when the target worker is close to saturation and that core limits throughput.
+
+Before collecting the profile, we held the following conditions constant:
+
+- APISIX ran with a single worker pinned to a dedicated physical core.
+- The upstream service and load generator ran on other cores to avoid CPU contention.
+- The request model, configuration, and response content remained unchanged.
+- The regression reproduced consistently, with stable error rates and response results.
+- The target worker remained close to saturation, while the upstream service, network, and load generator still had headroom.
+
+If any of these conditions is not met, start with connections, the network, the upstream service, or the load generator—not an on-CPU flame graph.
+
+## 2. Read Width, Not Rank
+
+We used eBPF to sample C and Lua call stacks simultaneously at 500 Hz. Here is the actual Lua on-CPU flame graph from the investigation.
+
+
+
+Figure 1: Global view of the actual flame graph. Searching for `run_global_rules` produced 3 matches, none of which stood out in the full graph. The original profile contained 2,446 samples; process PIDs were removed from the screenshot.
+
+In a flame graph, x-axis position does not imply order. Width reflects how many samples landed on a path. Ranked by Lua self time, the first shortlist looked like this:
+
+| Candidate location | Lua self time | Initial assessment |
+|---|---:|---|
+| Prometheus exporter | 25.9% | Highest-priority candidate |
+| `ctx.var` metamethod | 15.1% | Second candidate |
+| Custom logging bypass | 3.9% | Easy to overlook |
+| `run_global_rules` | 3.0% | Easy to overlook |
+
+Here, self time is the share of samples with Lua context that landed directly at a location—not a share of total CPU time. About 19.6% of the samples lacked Lua context, so these percentages are useful for selecting candidates, not predicting throughput gains.
+
+This ranking has four blind spots:
+
+1. When LuaJIT executes interpreted code, different Lua code paths may collapse into shared `lj_BC_*` and `lj_vm_*` symbols.
+2. Compiled JIT traces cannot always be fully expanded by conventional stack unwinding, so observable JIT samples provide only a lower bound.
+3. Costs such as table lookups, memory allocation, and garbage collection are attributed to C symbols rather than to the Lua lines that triggered them.
+4. A call can change the caller's JIT state, making the resulting cost appear at the head of the caller function.
+
+There was another clue: `run_global_rules` did not form one large column. It appeared across stacks from several request phases. No single occurrence was wide, but the combined cost could still be substantial.
+
+## 3. Follow the Stack to See How 3% Gets Amplified
+
+Select a `run_global_rules` frame and follow the stack upward. The request-phase entry point sits at the bottom, followed by `common_phase`, Global Rule plugin filtering, dispatch, and finally individual plugin execution.
+
+
+
+Figure 2: Interactive zoomed view after selecting one `run_global_rules` stack. The selected stack is stretched to fill the canvas, so its width is normalized and must not be interpreted as its share of total CPU time. Click the image to view it at full size.
+
+Stack height does not represent elapsed time. It shows where the cost enters, who invokes it, and why it keeps coming back.
+
+Before the fix, `run_global_rules()` called `_M.filter()` in several request phases. `_M.filter()` iterated through every loaded plugin and checked each one to determine whether it had been configured in the Global Rule:
+
+```lua
+-- Simplified illustration, not the complete APISIX implementation
+for _, plugin_obj in ipairs(local_plugins) do
+ local name = plugin_obj.name
+ local plugin_conf = user_plugin_conf[name]
+ if type(plugin_conf) ~= "table" then
+ goto continue
+ end
+ -- Process configured plugins
+ ::continue::
+end
+```
+
+Two factors amplified the cost:
+
+1. The cost of each filtering pass increased with the number of loaded plugins.
+2. The same filtering result was regenerated across request phases. The `body_filter` and `delayed_body_filter` phases could also be entered multiple times for response-body chunks.
+
+In this test configuration, one request triggered 9 filtering passes. The same request scanned more than 100 loaded plugins for the same small configured set 9 times.
+
+This explains why a Lua-level self time of 3.0% understated the cost of the entire path:
+
+| Actual work | Common flame-graph attribution |
+|---|---|
+| The loop itself | Corresponding location in `plugin.lua` |
+| Large numbers of table lookups | `lj_BC_TGETS` |
+| Temporary table allocation | `lj_alloc_malloc` |
+| Temporary object reclamation | `gc_sweep` |
+| Uncompiled interpreter dispatch | `lj_vm_*` / `lj_BC_*` |
+
+At 3% self time, this path looked minor. Following the stack showed that it sat on a shared path entered repeatedly. The optimization followed directly: within one request, if the Global Rule and matched route have not changed, reuse the filtered plugin set instead of rebuilding it in every phase.
+
+## 4. When the Flame Graph Doesn't Add Up, Check LuaJIT
+
+The second lead came from a custom observability component. Neither the component nor its logging bypass is part of APISIX OSS, but the failure mode matters to APISIX plugins and OpenResty extensions: a call that looks cheap can add direct cost and change whether subsequent caller code runs in the interpreter or as machine code.
+
+### 4.1 Interpreter Dispatch Was Abnormally Active
+
+After reclassifying the C-level samples by runtime category, the most notable signal was not any individual Lua line, but the relationship between interpreter execution and JIT execution:
+
+| Runtime category | CPU time per request | Share of samples |
+|---|---:|---:|
+| Interpreter dispatch: `lj_BC_*` / `lj_vm_*` | 5.86 μs | 29.6% |
+| Observable JIT trace execution | 2.55 μs | 12.9% |
+
+JIT traces do not always unwind cleanly, so 12.9% is only a lower bound. Still, the amount of interpreter dispatch in the same environment was a strong signal that some high-frequency paths might not be running consistently as machine code.
+
+This is not an argument for maximizing trace count. Initialization code does not need to be compiled, and trace costs vary widely. Compilation results are worth investigating only when the CPU is saturated, the path is hot, and interpreter dispatch is unusually active.
+
+### 4.2 `jit.v` Raised the Alarm—and Caused the First Misdiagnosis
+
+With `jit.v` enabled, one short load test reported 417 successful trace compilations and 493 aborts. After aggregation by source location, a group of paths each stopped at exactly 11 aborts.
+
+These are compilation-event counts—not unique functions, coverage, or CPU time. `jit.v` also has several important limitations:
+
+- An abort line shows the trace abort location, while the penalty is applied to the trace starting point; the two may not be the same line.
+- A function never appearing as a trace starting point does not mean it was never compiled. Its body may have been inlined into a parent trace.
+- Text logs make it difficult to perform stable set comparisons between results collected with a feature enabled and disabled.
+
+At first, we read “0 appearances as a starting point” as “the whole function runs in the interpreter.” The bytecode mode of `jit.dump` proved otherwise: several function entries never became root traces, but their bodies repeatedly entered other traces. The recurring failures were in phase-entry and orchestration functions.
+
+> The absence of a trace starting point proves only that the location did not become a root-trace anchor. It does not prove that the entire function never entered machine code.
+
+### 4.3 Correlate `start`, `stop`, and `abort` by Trace Start
+
+To learn what failed to compile and why, we needed the event stream from LuaJIT itself. Store each `start` location by trace ID, then map the matching `stop` or `abort` back to that same starting point:
+
+```lua
+-- Simplified illustration; actual callback arguments and parsing are more complex
+local trace_start = {}
+
+jit.attach(function(what, trace_id, func, pc, err_code)
+ if what == "start" then
+ trace_start[trace_id] = locate(func, pc)
+ elseif what == "stop" then
+ record_compiled(trace_start[trace_id])
+ trace_start[trace_id] = nil
+ elseif what == "abort" then
+ record_abort(trace_start[trace_id], err_code)
+ trace_start[trace_id] = nil
+ end
+end, "trace")
+```
+
+The real probe also needs `jit.util.funcinfo` to resolve source locations and `jit.vmdef.traceerr` to recover abort reasons. With that data, we can compare the component's enabled and disabled states: which starting points compile, which repeatedly abort, and whether a trace flush occurs.
+
+In the LuaJIT build used here, the failure penalty started at 72 and doubled after each failure. It was 36,864 on the 10th failure and reached 73,728 on the 11th, above the 60,000 limit. Several starting points stopped at exactly 11 aborts and never entered the compiled set; no trace flush occurred during collection. The number of live traces in both test variants remained below the cache limit, ruling out trace-cache exhaustion. Together, the evidence showed that LuaJIT had abandoned further compilation attempts for those trace starts.
+
+Keep the conclusion narrow: LuaJIT abandoned those trace starts, not necessarily the entire functions. Other parts of the same functions could still have been inlined into different traces.
+
+When the data was grouped by caller, every affected phase-entry and orchestration function passed through the same custom logging bypass. When the log level was too low to emit output, this path still inspected the request phase, call stack, and request context. The added call did not produce a wide column of its own, but it changed the JIT outcome of its callers, making part of the cost appear at the heads of ordinary request-processing functions.
+
+```lua
+-- Simplified custom extension, not an APISIX OSS implementation
+if log_level_is_suppressed then
+ check_debug_capture(...)
+ check_request_buffer(...)
+ return
+end
+```
+
+The JIT data did two jobs: it exposed costs the flame graph could not attribute cleanly, and it showed that toggling the feature changed how hot paths executed. It still could not quantify the throughput loss. The goal was not to force every function to compile; it was to remove work that should never happen when the feature is disabled.
+
+One more trap: install the probe before loading the module under observation. A module can capture a function reference during `require`; replacing that function later leaves the captured reference untouched. Our late probe saw 1 call per request. Moving it before `require("apisix")` exposed the real rate: 5 calls per request.
+
+## 5. Close the Loop with Paired A/B Tests
+
+Flame graphs identify locations, call stacks reveal amplification, and LuaJIT events explain misplaced costs. Paired experiments tell us how much those paths are actually worth.
+
+For the Global Rule path, we kept the same dispatch and configuration but returned immediately after entering the Prometheus business function. This helped distinguish the cost of the plugin's business logic from that of the shared path before plugin entry. To avoid presenting absolute RPS from a customized environment as an APISIX OSS benchmark, we normalized throughput with Prometheus disabled to 100:
+
+| Internal A/B scenario | Relative throughput index | Relative to Prometheus disabled |
+|---|---:|---:|
+| Prometheus disabled | 100.0 | Baseline |
+| Plugin and dispatch retained; business function returns immediately | 77.2 | -22.8% |
+| Complete Prometheus Global Rule | 56.9 | -43.1% |
+
+The gap remained even after short-circuiting the plugin's business code. The experiment did not identify a single expensive line, but it ruled out metric calculation as the only cause. Combined with 9 filtering passes per request, the call stacks, and the C-level cost distribution, it closed the amplification chain.
+
+We used the same method for the custom observability component. With workload and configuration held constant, we collected compilation events, per-request call counts, throughput, and response correctness with the component on and off. JIT events explained why no wide new column appeared in the graph; the end-to-end A/B test measured the actual cost.
+
+The investigation comes down to five steps:
+
+| Step | Core question | Evidence |
+|---|---|---|
+| 1. Verify the worker is CPU-bound | Is the target worker actually CPU-bound? | Worker saturation, CPU pinning, spare upstream and load-generator capacity, and stable reproduction |
Review Comment:
The Chinese question is whether the flame graph is even allowed to explain the regression, not a restatement of “is the worker CPU-bound?” That’s the point of §1.
> Can the flame graph explain this regression?
Evidence can stay as-is, maybe add network to match 上下游余量.
##########
blog/en/blog/2026/08/28/debugging-apisix-throughput-regression.md:
##########
@@ -0,0 +1,232 @@
+---
+title: "APISIX Throughput Regression: Beyond the Flame Graph"
+authors:
+ - name: "Xin Rong"
+ title: "Author"
+ url: "https://github.com/AlinsRan"
+ image_url: "https://github.com/AlinsRan.png"
+ - name: "Yilia Lin"
+ title: "Technical Writer"
+ url: "https://github.com/Yilialinn"
+ image_url: "https://github.com/Yilialinn.png"
+keywords:
+ - Apache APISIX
+ - flame graph
+ - LuaJIT
+ - performance optimization
+ - throughput regression
+description: "An APISIX throughput regression showed why the widest path in the flame graph is not always the real bottleneck—and how CPU data, call stacks, LuaJIT events, and paired A/B tests exposed it."
+tags: [Ecosystem]
+---
+
+Flame graphs are honest, but they are not verdicts. In this APISIX throughput regression, the widest path was not the real bottleneck. The paths worth chasing accounted for just 3.0% and 3.9% of the Lua samples.
+
+<!--truncate-->
+
+Ranked by hotspot size alone, neither path would have made the first optimization shortlist. The key was not a single patch but a chain of evidence: confirm that the worker is CPU-bound, compare flame-graph widths, follow repeated call paths, inspect LuaJIT compilation and aborts when the numbers stop adding up, and use per-request call counts plus paired A/B tests to measure the impact.
+
+> **Data scope:** The data below comes from one internally customized APISIX build in the same controlled environment, with more than 100 plugins loaded. Throughput is normalized and is used only to illustrate the diagnostic method and causal chain. It does not represent the general performance of Apache APISIX OSS under other hardware, configurations, or workloads.
+
+## 1. Confirm the Worker Is CPU-Bound
+
+A flame graph shows where the CPU spent its sampled time. Its width matters to a throughput regression only when the target worker is close to saturation and that core limits throughput.
+
+Before collecting the profile, we held the following conditions constant:
+
+- APISIX ran with a single worker pinned to a dedicated physical core.
+- The upstream service and load generator ran on other cores to avoid CPU contention.
+- The request model, configuration, and response content remained unchanged.
+- The regression reproduced consistently, with stable error rates and response results.
+- The target worker remained close to saturation, while the upstream service, network, and load generator still had headroom.
+
+If any of these conditions is not met, start with connections, the network, the upstream service, or the load generator—not an on-CPU flame graph.
+
+## 2. Read Width, Not Rank
+
+We used eBPF to sample C and Lua call stacks simultaneously at 500 Hz. Here is the actual Lua on-CPU flame graph from the investigation.
+
+
+
+Figure 1: Global view of the actual flame graph. Searching for `run_global_rules` produced 3 matches, none of which stood out in the full graph. The original profile contained 2,446 samples; process PIDs were removed from the screenshot.
+
+In a flame graph, x-axis position does not imply order. Width reflects how many samples landed on a path. Ranked by Lua self time, the first shortlist looked like this:
+
+| Candidate location | Lua self time | Initial assessment |
+|---|---:|---|
+| Prometheus exporter | 25.9% | Highest-priority candidate |
+| `ctx.var` metamethod | 15.1% | Second candidate |
+| Custom logging bypass | 3.9% | Easy to overlook |
+| `run_global_rules` | 3.0% | Easy to overlook |
+
+Here, self time is the share of samples with Lua context that landed directly at a location—not a share of total CPU time. About 19.6% of the samples lacked Lua context, so these percentages are useful for selecting candidates, not predicting throughput gains.
+
+This ranking has four blind spots:
+
+1. When LuaJIT executes interpreted code, different Lua code paths may collapse into shared `lj_BC_*` and `lj_vm_*` symbols.
+2. Compiled JIT traces cannot always be fully expanded by conventional stack unwinding, so observable JIT samples provide only a lower bound.
+3. Costs such as table lookups, memory allocation, and garbage collection are attributed to C symbols rather than to the Lua lines that triggered them.
+4. A call can change the caller's JIT state, making the resulting cost appear at the head of the caller function.
+
+There was another clue: `run_global_rules` did not form one large column. It appeared across stacks from several request phases. No single occurrence was wide, but the combined cost could still be substantial.
+
+## 3. Follow the Stack to See How 3% Gets Amplified
+
+Select a `run_global_rules` frame and follow the stack upward. The request-phase entry point sits at the bottom, followed by `common_phase`, Global Rule plugin filtering, dispatch, and finally individual plugin execution.
+
+
+
+Figure 2: Interactive zoomed view after selecting one `run_global_rules` stack. The selected stack is stretched to fill the canvas, so its width is normalized and must not be interpreted as its share of total CPU time. Click the image to view it at full size.
+
+Stack height does not represent elapsed time. It shows where the cost enters, who invokes it, and why it keeps coming back.
+
+Before the fix, `run_global_rules()` called `_M.filter()` in several request phases. `_M.filter()` iterated through every loaded plugin and checked each one to determine whether it had been configured in the Global Rule:
+
+```lua
+-- Simplified illustration, not the complete APISIX implementation
+for _, plugin_obj in ipairs(local_plugins) do
+ local name = plugin_obj.name
+ local plugin_conf = user_plugin_conf[name]
+ if type(plugin_conf) ~= "table" then
+ goto continue
+ end
+ -- Process configured plugins
+ ::continue::
+end
+```
+
+Two factors amplified the cost:
+
+1. The cost of each filtering pass increased with the number of loaded plugins.
+2. The same filtering result was regenerated across request phases. The `body_filter` and `delayed_body_filter` phases could also be entered multiple times for response-body chunks.
+
+In this test configuration, one request triggered 9 filtering passes. The same request scanned more than 100 loaded plugins for the same small configured set 9 times.
+
+This explains why a Lua-level self time of 3.0% understated the cost of the entire path:
+
+| Actual work | Common flame-graph attribution |
+|---|---|
+| The loop itself | Corresponding location in `plugin.lua` |
+| Large numbers of table lookups | `lj_BC_TGETS` |
+| Temporary table allocation | `lj_alloc_malloc` |
+| Temporary object reclamation | `gc_sweep` |
+| Uncompiled interpreter dispatch | `lj_vm_*` / `lj_BC_*` |
+
+At 3% self time, this path looked minor. Following the stack showed that it sat on a shared path entered repeatedly. The optimization followed directly: within one request, if the Global Rule and matched route have not changed, reuse the filtered plugin set instead of rebuilding it in every phase.
+
+## 4. When the Flame Graph Doesn't Add Up, Check LuaJIT
+
+The second lead came from a custom observability component. Neither the component nor its logging bypass is part of APISIX OSS, but the failure mode matters to APISIX plugins and OpenResty extensions: a call that looks cheap can add direct cost and change whether subsequent caller code runs in the interpreter or as machine code.
+
+### 4.1 Interpreter Dispatch Was Abnormally Active
+
+After reclassifying the C-level samples by runtime category, the most notable signal was not any individual Lua line, but the relationship between interpreter execution and JIT execution:
+
+| Runtime category | CPU time per request | Share of samples |
+|---|---:|---:|
+| Interpreter dispatch: `lj_BC_*` / `lj_vm_*` | 5.86 μs | 29.6% |
+| Observable JIT trace execution | 2.55 μs | 12.9% |
+
+JIT traces do not always unwind cleanly, so 12.9% is only a lower bound. Still, the amount of interpreter dispatch in the same environment was a strong signal that some high-frequency paths might not be running consistently as machine code.
+
+This is not an argument for maximizing trace count. Initialization code does not need to be compiled, and trace costs vary widely. Compilation results are worth investigating only when the CPU is saturated, the path is hot, and interpreter dispatch is unusually active.
+
+### 4.2 `jit.v` Raised the Alarm—and Caused the First Misdiagnosis
+
+With `jit.v` enabled, one short load test reported 417 successful trace compilations and 493 aborts. After aggregation by source location, a group of paths each stopped at exactly 11 aborts.
+
+These are compilation-event counts—not unique functions, coverage, or CPU time. `jit.v` also has several important limitations:
+
+- An abort line shows the trace abort location, while the penalty is applied to the trace starting point; the two may not be the same line.
+- A function never appearing as a trace starting point does not mean it was never compiled. Its body may have been inlined into a parent trace.
+- Text logs make it difficult to perform stable set comparisons between results collected with a feature enabled and disabled.
+
+At first, we read “0 appearances as a starting point” as “the whole function runs in the interpreter.” The bytecode mode of `jit.dump` proved otherwise: several function entries never became root traces, but their bodies repeatedly entered other traces. The recurring failures were in phase-entry and orchestration functions.
+
+> The absence of a trace starting point proves only that the location did not become a root-trace anchor. It does not prove that the entire function never entered machine code.
+
+### 4.3 Correlate `start`, `stop`, and `abort` by Trace Start
+
+To learn what failed to compile and why, we needed the event stream from LuaJIT itself. Store each `start` location by trace ID, then map the matching `stop` or `abort` back to that same starting point:
+
+```lua
+-- Simplified illustration; actual callback arguments and parsing are more complex
+local trace_start = {}
+
+jit.attach(function(what, trace_id, func, pc, err_code)
+ if what == "start" then
+ trace_start[trace_id] = locate(func, pc)
+ elseif what == "stop" then
+ record_compiled(trace_start[trace_id])
+ trace_start[trace_id] = nil
+ elseif what == "abort" then
+ record_abort(trace_start[trace_id], err_code)
+ trace_start[trace_id] = nil
+ end
+end, "trace")
+```
+
+The real probe also needs `jit.util.funcinfo` to resolve source locations and `jit.vmdef.traceerr` to recover abort reasons. With that data, we can compare the component's enabled and disabled states: which starting points compile, which repeatedly abort, and whether a trace flush occurs.
+
+In the LuaJIT build used here, the failure penalty started at 72 and doubled after each failure. It was 36,864 on the 10th failure and reached 73,728 on the 11th, above the 60,000 limit. Several starting points stopped at exactly 11 aborts and never entered the compiled set; no trace flush occurred during collection. The number of live traces in both test variants remained below the cache limit, ruling out trace-cache exhaustion. Together, the evidence showed that LuaJIT had abandoned further compilation attempts for those trace starts.
+
+Keep the conclusion narrow: LuaJIT abandoned those trace starts, not necessarily the entire functions. Other parts of the same functions could still have been inlined into different traces.
+
+When the data was grouped by caller, every affected phase-entry and orchestration function passed through the same custom logging bypass. When the log level was too low to emit output, this path still inspected the request phase, call stack, and request context. The added call did not produce a wide column of its own, but it changed the JIT outcome of its callers, making part of the cost appear at the heads of ordinary request-processing functions.
+
+```lua
+-- Simplified custom extension, not an APISIX OSS implementation
+if log_level_is_suppressed then
+ check_debug_capture(...)
+ check_request_buffer(...)
+ return
+end
+```
+
+The JIT data did two jobs: it exposed costs the flame graph could not attribute cleanly, and it showed that toggling the feature changed how hot paths executed. It still could not quantify the throughput loss. The goal was not to force every function to compile; it was to remove work that should never happen when the feature is disabled.
+
+One more trap: install the probe before loading the module under observation. A module can capture a function reference during `require`; replacing that function later leaves the captured reference untouched. Our late probe saw 1 call per request. Moving it before `require("apisix")` exposed the real rate: 5 calls per request.
+
+## 5. Close the Loop with Paired A/B Tests
+
+Flame graphs identify locations, call stacks reveal amplification, and LuaJIT events explain misplaced costs. Paired experiments tell us how much those paths are actually worth.
+
+For the Global Rule path, we kept the same dispatch and configuration but returned immediately after entering the Prometheus business function. This helped distinguish the cost of the plugin's business logic from that of the shared path before plugin entry. To avoid presenting absolute RPS from a customized environment as an APISIX OSS benchmark, we normalized throughput with Prometheus disabled to 100:
+
+| Internal A/B scenario | Relative throughput index | Relative to Prometheus disabled |
+|---|---:|---:|
+| Prometheus disabled | 100.0 | Baseline |
+| Plugin and dispatch retained; business function returns immediately | 77.2 | -22.8% |
+| Complete Prometheus Global Rule | 56.9 | -43.1% |
+
+The gap remained even after short-circuiting the plugin's business code. The experiment did not identify a single expensive line, but it ruled out metric calculation as the only cause. Combined with 9 filtering passes per request, the call stacks, and the C-level cost distribution, it closed the amplification chain.
+
+We used the same method for the custom observability component. With workload and configuration held constant, we collected compilation events, per-request call counts, throughput, and response correctness with the component on and off. JIT events explained why no wide new column appeared in the graph; the end-to-end A/B test measured the actual cost.
+
+The investigation comes down to five steps:
+
+| Step | Core question | Evidence |
+|---|---|---|
+| 1. Verify the worker is CPU-bound | Is the target worker actually CPU-bound? | Worker saturation, CPU pinning, spare upstream and load-generator capacity, and stable reproduction |
+| 2. Compare widths | Where do samples accumulate? | Global flame graph and candidate ranking |
+| 3. Follow stacks | What multiplies a small local cost? | Request phases, shared functions, and per-request call counts |
+| 4. Inspect LuaJIT | Why does cost appear elsewhere or go missing? | `start`, `stop`, `abort`, `flush`, and `jit.dump` |
+| 5. Run paired A/B tests | How large is the effect, and is it causal? | Throughput, latency, call counts, error rate, and response consistency |
+
+Keep the conclusions within clear boundaries:
+
+- Flame-graph width, Lua self time, and throughput changes use different denominators and cannot be directly subtracted or divided.
+- The custom observability component is not part of APISIX OSS. It is included only to illustrate a general issue that custom extensions may encounter.
+- The values 29.6%, 12.9%, 417, 493, and abort ×11 apply only to this build and collection window and must not be extrapolated.
+- “Did not become a trace starting point” does not mean “the function was not compiled.” You must check whether the function body entered other traces.
+- JIT compilation results are diagnostic signals, not final performance metrics. Any optimization must still be validated against throughput, latency, error rate, response content, and resource reclamation.
+
+The flame graph was not wrong. Width showed where CPU samples accumulated, and call stacks showed how shared paths amplified the cost. But once that cost spread across interpreter dispatch, JIT traces, the allocator, and caller functions, the graph no longer showed complete attribution.
+
+When the graph stops adding up, do not keep guessing which Lua line should be faster. Pull the compilation-event stream from LuaJIT, count calls per request, and run paired A/B tests. The best optimization target is often not the widest column, but the work the evidence shows should never have repeated in the first place.
+
+## References
+
+1. [WPS with Apache APISIX: Flame Graph and LuaJIT Performance Practices](https://apisix.apache.org/blog/2021/09/28/wps-usercase/)
+2. [1s to 10ms: Reproducing, Diagnosing, and Fixing Prometheus Tail Latency](https://api7.ai/blog/1s-to-10ms-reducing-prometheus-delay-in-api-gateway)
Review Comment:
These English titles don’t match the pages they link to:
- https://apisix.apache.org/blog/2021/09/28/wps-usercase/ is “WPS with Apache APISIX to create new API gateway experience”
- https://api7.ai/blog/1s-to-10ms-reducing-prometheus-delay-in-api-gateway is “1s to 10ms: Reducing Prometheus Delay in API Gateway”
Paraphrasing the Chinese link text is fine on the zh page. On the English page, use the real titles.
##########
blog/en/blog/2026/08/28/debugging-apisix-throughput-regression.md:
##########
@@ -0,0 +1,232 @@
+---
+title: "APISIX Throughput Regression: Beyond the Flame Graph"
+authors:
+ - name: "Xin Rong"
+ title: "Author"
+ url: "https://github.com/AlinsRan"
+ image_url: "https://github.com/AlinsRan.png"
+ - name: "Yilia Lin"
+ title: "Technical Writer"
+ url: "https://github.com/Yilialinn"
+ image_url: "https://github.com/Yilialinn.png"
+keywords:
+ - Apache APISIX
+ - flame graph
+ - LuaJIT
+ - performance optimization
+ - throughput regression
+description: "An APISIX throughput regression showed why the widest path in the flame graph is not always the real bottleneck—and how CPU data, call stacks, LuaJIT events, and paired A/B tests exposed it."
+tags: [Ecosystem]
+---
+
+Flame graphs are honest, but they are not verdicts. In this APISIX throughput regression, the widest path was not the real bottleneck. The paths worth chasing accounted for just 3.0% and 3.9% of the Lua samples.
+
+<!--truncate-->
+
+Ranked by hotspot size alone, neither path would have made the first optimization shortlist. The key was not a single patch but a chain of evidence: confirm that the worker is CPU-bound, compare flame-graph widths, follow repeated call paths, inspect LuaJIT compilation and aborts when the numbers stop adding up, and use per-request call counts plus paired A/B tests to measure the impact.
+
+> **Data scope:** The data below comes from one internally customized APISIX build in the same controlled environment, with more than 100 plugins loaded. Throughput is normalized and is used only to illustrate the diagnostic method and causal chain. It does not represent the general performance of Apache APISIX OSS under other hardware, configurations, or workloads.
+
+## 1. Confirm the Worker Is CPU-Bound
+
+A flame graph shows where the CPU spent its sampled time. Its width matters to a throughput regression only when the target worker is close to saturation and that core limits throughput.
+
+Before collecting the profile, we held the following conditions constant:
+
+- APISIX ran with a single worker pinned to a dedicated physical core.
+- The upstream service and load generator ran on other cores to avoid CPU contention.
+- The request model, configuration, and response content remained unchanged.
+- The regression reproduced consistently, with stable error rates and response results.
+- The target worker remained close to saturation, while the upstream service, network, and load generator still had headroom.
+
+If any of these conditions is not met, start with connections, the network, the upstream service, or the load generator—not an on-CPU flame graph.
+
+## 2. Read Width, Not Rank
+
+We used eBPF to sample C and Lua call stacks simultaneously at 500 Hz. Here is the actual Lua on-CPU flame graph from the investigation.
+
+
+
+Figure 1: Global view of the actual flame graph. Searching for `run_global_rules` produced 3 matches, none of which stood out in the full graph. The original profile contained 2,446 samples; process PIDs were removed from the screenshot.
+
+In a flame graph, x-axis position does not imply order. Width reflects how many samples landed on a path. Ranked by Lua self time, the first shortlist looked like this:
+
+| Candidate location | Lua self time | Initial assessment |
+|---|---:|---|
+| Prometheus exporter | 25.9% | Highest-priority candidate |
+| `ctx.var` metamethod | 15.1% | Second candidate |
+| Custom logging bypass | 3.9% | Easy to overlook |
+| `run_global_rules` | 3.0% | Easy to overlook |
+
+Here, self time is the share of samples with Lua context that landed directly at a location—not a share of total CPU time. About 19.6% of the samples lacked Lua context, so these percentages are useful for selecting candidates, not predicting throughput gains.
+
+This ranking has four blind spots:
+
+1. When LuaJIT executes interpreted code, different Lua code paths may collapse into shared `lj_BC_*` and `lj_vm_*` symbols.
+2. Compiled JIT traces cannot always be fully expanded by conventional stack unwinding, so observable JIT samples provide only a lower bound.
+3. Costs such as table lookups, memory allocation, and garbage collection are attributed to C symbols rather than to the Lua lines that triggered them.
+4. A call can change the caller's JIT state, making the resulting cost appear at the head of the caller function.
+
+There was another clue: `run_global_rules` did not form one large column. It appeared across stacks from several request phases. No single occurrence was wide, but the combined cost could still be substantial.
+
+## 3. Follow the Stack to See How 3% Gets Amplified
+
+Select a `run_global_rules` frame and follow the stack upward. The request-phase entry point sits at the bottom, followed by `common_phase`, Global Rule plugin filtering, dispatch, and finally individual plugin execution.
+
+
+
+Figure 2: Interactive zoomed view after selecting one `run_global_rules` stack. The selected stack is stretched to fill the canvas, so its width is normalized and must not be interpreted as its share of total CPU time. Click the image to view it at full size.
+
+Stack height does not represent elapsed time. It shows where the cost enters, who invokes it, and why it keeps coming back.
+
+Before the fix, `run_global_rules()` called `_M.filter()` in several request phases. `_M.filter()` iterated through every loaded plugin and checked each one to determine whether it had been configured in the Global Rule:
+
+```lua
+-- Simplified illustration, not the complete APISIX implementation
+for _, plugin_obj in ipairs(local_plugins) do
+ local name = plugin_obj.name
+ local plugin_conf = user_plugin_conf[name]
+ if type(plugin_conf) ~= "table" then
+ goto continue
+ end
+ -- Process configured plugins
+ ::continue::
+end
+```
+
+Two factors amplified the cost:
+
+1. The cost of each filtering pass increased with the number of loaded plugins.
+2. The same filtering result was regenerated across request phases. The `body_filter` and `delayed_body_filter` phases could also be entered multiple times for response-body chunks.
+
+In this test configuration, one request triggered 9 filtering passes. The same request scanned more than 100 loaded plugins for the same small configured set 9 times.
+
+This explains why a Lua-level self time of 3.0% understated the cost of the entire path:
+
+| Actual work | Common flame-graph attribution |
+|---|---|
+| The loop itself | Corresponding location in `plugin.lua` |
+| Large numbers of table lookups | `lj_BC_TGETS` |
+| Temporary table allocation | `lj_alloc_malloc` |
+| Temporary object reclamation | `gc_sweep` |
+| Uncompiled interpreter dispatch | `lj_vm_*` / `lj_BC_*` |
+
+At 3% self time, this path looked minor. Following the stack showed that it sat on a shared path entered repeatedly. The optimization followed directly: within one request, if the Global Rule and matched route have not changed, reuse the filtered plugin set instead of rebuilding it in every phase.
+
+## 4. When the Flame Graph Doesn't Add Up, Check LuaJIT
+
+The second lead came from a custom observability component. Neither the component nor its logging bypass is part of APISIX OSS, but the failure mode matters to APISIX plugins and OpenResty extensions: a call that looks cheap can add direct cost and change whether subsequent caller code runs in the interpreter or as machine code.
+
+### 4.1 Interpreter Dispatch Was Abnormally Active
+
+After reclassifying the C-level samples by runtime category, the most notable signal was not any individual Lua line, but the relationship between interpreter execution and JIT execution:
+
+| Runtime category | CPU time per request | Share of samples |
+|---|---:|---:|
+| Interpreter dispatch: `lj_BC_*` / `lj_vm_*` | 5.86 μs | 29.6% |
+| Observable JIT trace execution | 2.55 μs | 12.9% |
+
+JIT traces do not always unwind cleanly, so 12.9% is only a lower bound. Still, the amount of interpreter dispatch in the same environment was a strong signal that some high-frequency paths might not be running consistently as machine code.
+
+This is not an argument for maximizing trace count. Initialization code does not need to be compiled, and trace costs vary widely. Compilation results are worth investigating only when the CPU is saturated, the path is hot, and interpreter dispatch is unusually active.
+
+### 4.2 `jit.v` Raised the Alarm—and Caused the First Misdiagnosis
+
+With `jit.v` enabled, one short load test reported 417 successful trace compilations and 493 aborts. After aggregation by source location, a group of paths each stopped at exactly 11 aborts.
+
+These are compilation-event counts—not unique functions, coverage, or CPU time. `jit.v` also has several important limitations:
+
+- An abort line shows the trace abort location, while the penalty is applied to the trace starting point; the two may not be the same line.
+- A function never appearing as a trace starting point does not mean it was never compiled. Its body may have been inlined into a parent trace.
+- Text logs make it difficult to perform stable set comparisons between results collected with a feature enabled and disabled.
+
+At first, we read “0 appearances as a starting point” as “the whole function runs in the interpreter.” The bytecode mode of `jit.dump` proved otherwise: several function entries never became root traces, but their bodies repeatedly entered other traces. The recurring failures were in phase-entry and orchestration functions.
+
+> The absence of a trace starting point proves only that the location did not become a root-trace anchor. It does not prove that the entire function never entered machine code.
+
+### 4.3 Correlate `start`, `stop`, and `abort` by Trace Start
+
+To learn what failed to compile and why, we needed the event stream from LuaJIT itself. Store each `start` location by trace ID, then map the matching `stop` or `abort` back to that same starting point:
+
+```lua
+-- Simplified illustration; actual callback arguments and parsing are more complex
+local trace_start = {}
+
+jit.attach(function(what, trace_id, func, pc, err_code)
+ if what == "start" then
+ trace_start[trace_id] = locate(func, pc)
+ elseif what == "stop" then
+ record_compiled(trace_start[trace_id])
+ trace_start[trace_id] = nil
+ elseif what == "abort" then
+ record_abort(trace_start[trace_id], err_code)
+ trace_start[trace_id] = nil
+ end
+end, "trace")
+```
+
+The real probe also needs `jit.util.funcinfo` to resolve source locations and `jit.vmdef.traceerr` to recover abort reasons. With that data, we can compare the component's enabled and disabled states: which starting points compile, which repeatedly abort, and whether a trace flush occurs.
+
+In the LuaJIT build used here, the failure penalty started at 72 and doubled after each failure. It was 36,864 on the 10th failure and reached 73,728 on the 11th, above the 60,000 limit. Several starting points stopped at exactly 11 aborts and never entered the compiled set; no trace flush occurred during collection. The number of live traces in both test variants remained below the cache limit, ruling out trace-cache exhaustion. Together, the evidence showed that LuaJIT had abandoned further compilation attempts for those trace starts.
+
+Keep the conclusion narrow: LuaJIT abandoned those trace starts, not necessarily the entire functions. Other parts of the same functions could still have been inlined into different traces.
+
+When the data was grouped by caller, every affected phase-entry and orchestration function passed through the same custom logging bypass. When the log level was too low to emit output, this path still inspected the request phase, call stack, and request context. The added call did not produce a wide column of its own, but it changed the JIT outcome of its callers, making part of the cost appear at the heads of ordinary request-processing functions.
+
+```lua
+-- Simplified custom extension, not an APISIX OSS implementation
+if log_level_is_suppressed then
+ check_debug_capture(...)
+ check_request_buffer(...)
+ return
+end
+```
+
+The JIT data did two jobs: it exposed costs the flame graph could not attribute cleanly, and it showed that toggling the feature changed how hot paths executed. It still could not quantify the throughput loss. The goal was not to force every function to compile; it was to remove work that should never happen when the feature is disabled.
+
+One more trap: install the probe before loading the module under observation. A module can capture a function reference during `require`; replacing that function later leaves the captured reference untouched. Our late probe saw 1 call per request. Moving it before `require("apisix")` exposed the real rate: 5 calls per request.
+
+## 5. Close the Loop with Paired A/B Tests
+
+Flame graphs identify locations, call stacks reveal amplification, and LuaJIT events explain misplaced costs. Paired experiments tell us how much those paths are actually worth.
+
+For the Global Rule path, we kept the same dispatch and configuration but returned immediately after entering the Prometheus business function. This helped distinguish the cost of the plugin's business logic from that of the shared path before plugin entry. To avoid presenting absolute RPS from a customized environment as an APISIX OSS benchmark, we normalized throughput with Prometheus disabled to 100:
+
+| Internal A/B scenario | Relative throughput index | Relative to Prometheus disabled |
+|---|---:|---:|
+| Prometheus disabled | 100.0 | Baseline |
+| Plugin and dispatch retained; business function returns immediately | 77.2 | -22.8% |
+| Complete Prometheus Global Rule | 56.9 | -43.1% |
+
+The gap remained even after short-circuiting the plugin's business code. The experiment did not identify a single expensive line, but it ruled out metric calculation as the only cause. Combined with 9 filtering passes per request, the call stacks, and the C-level cost distribution, it closed the amplification chain.
+
+We used the same method for the custom observability component. With workload and configuration held constant, we collected compilation events, per-request call counts, throughput, and response correctness with the component on and off. JIT events explained why no wide new column appeared in the graph; the end-to-end A/B test measured the actual cost.
+
+The investigation comes down to five steps:
+
+| Step | Core question | Evidence |
+|---|---|---|
+| 1. Verify the worker is CPU-bound | Is the target worker actually CPU-bound? | Worker saturation, CPU pinning, spare upstream and load-generator capacity, and stable reproduction |
+| 2. Compare widths | Where do samples accumulate? | Global flame graph and candidate ranking |
+| 3. Follow stacks | What multiplies a small local cost? | Request phases, shared functions, and per-request call counts |
+| 4. Inspect LuaJIT | Why does cost appear elsewhere or go missing? | `start`, `stop`, `abort`, `flush`, and `jit.dump` |
+| 5. Run paired A/B tests | How large is the effect, and is it causal? | Throughput, latency, call counts, error rate, and response consistency |
+
+Keep the conclusions within clear boundaries:
Review Comment:
Same editor-voice as “Keep the conclusion narrow.” Just:
> A few limits on what this shows:
Also, “resource reclamation” a few lines down is landfill language. Say “and that memory is actually freed” or “GC behavior.”
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@apisix.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] blog: add APISIX throughput regression investigation (en + zh) [apisix-website]
Posted by "AlinsRan (via GitHub)" <gi...@apache.org>.
AlinsRan commented on code in PR #2115:
URL: https://github.com/apache/apisix-website/pull/2115#discussion_r3879714206
##########
blog/zh/blog/2026/08/28/debugging-apisix-throughput-regression.md:
##########
@@ -0,0 +1,234 @@
+---
+title: "火焰图没撒谎,但没直接告诉我们瓶颈:一次 APISIX 吞吐回退定位"
+authors:
+ - name: "Xin Rong"
+ title: "Author"
+ url: "https://github.com/AlinsRan"
+ image_url: "https://github.com/AlinsRan.png"
+ - name: "Yilia Lin"
+ title: "Technical Writer"
+ url: "https://github.com/Yilialinn"
+ image_url: "https://github.com/Yilialinn.png"
+keywords:
+ - Apache APISIX
+ - 火焰图
+ - LuaJIT
+ - 性能优化
+ - 吞吐回退
+description: "火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。"
+tags: [Ecosystem]
+---
+
+火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。
+
+<!--truncate-->
+
+一次吞吐回退中,火焰图最宽的路径并不是最终瓶颈。真正值得追查的两个位置,在 Lua 采样里只有 3.0% 和 3.9%。如果按热点排序,它们根本进不了第一轮优化名单。
+
+这次排查最有价值的不是某个补丁,而是一条证据链:先确认 CPU 确实是瓶颈;再横向看火焰图宽度,建立候选;然后沿调用栈纵向追,看重复调用和公共路径如何放大成本;当火焰图解释不了端到端差距时,继续查 LuaJIT 编译与中断事件;最后用每请求调用次数和配对 A/B 实验定量。
+
+> 数据范围:下文数据来自同一受控环境中的一个 APISIX 内部定制构建,其中加载了 100 余个插件。吞吐统一归一化,只用于说明定位方法与因果链,不代表 Apache APISIX OSS 在其他硬件、配置或负载下的通用表现。
+
+## 1. 先确认 CPU 瓶颈,而不是上来就看火焰图
+
+火焰图显示的是采样期间 CPU 在执行什么。只有目标 worker 的 CPU 接近饱和、吞吐受这个核限制时,火焰图的宽度才有资格解释性能回退。
+
+我们在采样前固定了这些条件:
+
+- APISIX 单 worker 并绑定独占物理核;
+- 上游服务和压测端使用其他核,避免 CPU 争抢;
+- 请求模型、配置、响应内容保持一致;
+- 吞吐回退可稳定复现,错误率和响应结果没有偏移;
+- 目标 worker 持续接近饱和,上游、网络、压测端仍有余量。
+
+如果不满足这些前提,首先应该查连接、网络、上游或压测端,而不是在 on-CPU 火焰图里找答案。
+
+## 2. 横向看宽度,而不是排名
+
+我们使用 eBPF 以 500 Hz 同时采集 C 和 Lua 调用栈。下面是排查中的真实 Lua on-CPU 火焰图。
+
+
+
+*搜索 `run_global_rules` 后命中 3 处,但这些位置在整张图里并不醒目。原始采样共 2,446 个样本;截图已移除进程 PID。*
+
+横向看火焰图,看的是宽度,不是 x 轴顺序。宽度越大,采样落在该路径上的次数越多。初步候选位置按 Lua self time 排序:
+
+| 候选位置 | Lua self time | 初判 |
+|---|---:|---|
+| Prometheus exporter | 25.9% | 最值得先看 |
+| `ctx.var` 元方法 | 15.1% | 第二候选 |
+| 自定义日志旁路 | 3.9% | 很容易被忽略 |
+| `run_global_rules` | 3.0% | 很容易被忽略 |
+
+这里的 self time 指:在带 Lua 上下文的样本中,直接落在这个位置上的比例,不是总 CPU 占比。本次采样约 19.6% 的样本缺失 Lua 上下文,所以这些百分比只用于选候选,不能直接预测吞吐收益。
+
+这个排名有四个盲区:
+
+1. LuaJIT 解释执行时,不同的 Lua 代码可能折叠到共享的 `lj_BC_*`、`lj_vm_*` 符号里。
+2. 已编译的 JIT trace 不一定能被常规栈回溯完整展开,因此可观察到的 JIT 样本只是下界。
+3. 表查找、内存分配、GC 等成本会记在 C 符号上,而不是触发的 Lua 行上。
+4. 一个调用可能改变调用方的 JIT 状态,使成本看起来落在调用方函数的头上。
+
+`run_global_rules` 还有一个特征:它没有形成一根大柱子,而是散在多个请求阶段的调用栈里。单处不宽,不等于合起来不贵。
+
+## 3. 纵向沿栈看,3% 如何被公共路径放大
+
+选中某个 `run_global_rules` 方块后,纵向看调用栈:底部是请求阶段入口,向上经过 `common_phase`、Global Rule 插件筛选、调度,再到具体插件执行。
+
+
+
+*所选栈会被重新铺满画布,因此图中的宽度已经归一化,不能再当作它占总 CPU 的比例。*
+
+纵向高度本身不代表耗时。它的作用是看清成本从哪里进入、被谁调用、为什么反复出现。
+
+修复前,`run_global_rules()` 会在请求的多个阶段调用 `_M.filter()`。`_M.filter()` 遍历所有已加载插件,再逐个判断该插件是否被 Global Rule 配置:
+
+```lua
+-- 简化示意,非 APISIX 完整实现
+for _, plugin_obj in ipairs(local_plugins) do
+ local name = plugin_obj.name
+ local plugin_conf = user_plugin_conf[name]
+ if type(plugin_conf) ~= "table" then
+ goto continue
+ end
+ -- 处理已配置插件
+ ::continue::
+end
+```
+
+两个放大器都在这里:
+
+1. 单次过滤成本随已加载插件数量的增长。
+2. 相同过滤结果跨请求阶段重复生成;`body_filter` 与 `delayed_body_filter` 还可能按响应块多次进入。
+
+本次测试配置中,一个请求实际触发了 **9 次过滤**。也就是说,“从 100+ 插件中找出已配置的少量插件”这件事,被同一个请求重复了 9 次。
+
+这解释了为什么 Lua 层 3.0% 会低估整条路径成本:
+
+| 实际工作 | 火焰图常见归属 |
+|---|---|
+| 循环本身 | `plugin.lua` 对应位置 |
+| 大量表查找 | `lj_BC_TGETS` |
+| 临时表分配 | `lj_alloc_malloc` |
+| 临时对象回收 | `gc_sweep` |
+| 未编译解释器派发 | `lj_vm_*` / `lj_BC_*` |
+
+横向看,它只是 3% 的候选;纵向看,才会发现它位于一条被反复进入的公共路径。优化方向也很直接:同一请求内,如果 Global Rule 和匹配路由没有变化,就复用已筛选的插件集合,而不是每个阶段重新生成。
+
+## 4. 火焰图解释不了差距时,查 LuaJIT 编译事件
+
+第二条路径来自自定义观测组件。它和日志旁路不属于 APISIX OSS,但揭示的问题对 APISIX 插件和 OpenResty 扩展有参考价值:一次看似很轻的调用,既可能产生直接成本,也可能改变调用方之后以解释器还是机器码运行。
+
+### 4.1 解释器派发异常活跃
+
+把 C 层样本按运行时类型重新分类,最值得注意的不是某行 Lua,而是解释器与 JIT 的关系:
+
+| 运行类别 | 每请求 CPU 时间 | 占本次样本 |
+|---|---:|---:|
+| 解释器派发:`lj_BC_*` / `lj_vm_*` | 5.86 μs | 29.6% |
+| 可观察到的 JIT trace 执行 | 2.55 μs | 12.9% |
+
+JIT trace 难以完整 unwind,12.9% 只能看作下界。但在同一环境中,解释器派发如此活跃,已经构成强信号:某些高频路径可能没有稳定地跑在机器码里。
+
+这不等于 trace 越多越好。初始化代码不需要追求编译,不同 trace 的成本差异也很大。只有在 CPU 饱和、路径高频、解释器派发异常时,编译结果才值得深挖。
+
+### 4.2 `jit.v` 发出警报,也制造了第一次误判
+
+开启 `jit.v` 后,一次短时压测输出中看到 417 次 trace 编译成功、493 次 abort。按位置聚合后,一批路径的 abort 次数整齐停在 11。
+
+但这是编译事件数,不是唯一函数数、覆盖率或 CPU 时间。`jit.v` 有几个边界:
+
+- abort 行显示的是 trace 中断位置,而惩罚记在 trace 起点,两者可能不是同一行。
+- 某函数从未成为 trace 起点,不代表它没有被编译,函数体可能已被内联进父 trace。
+- 文本日志很难对功能开启/关闭两组结果做稳定的集合差。
+
+我们一开始也把“起点出现次数为 0”误读成“整个函数都在解释执行”。后来用 `jit.dump` 字节码模式复核,才发现多个函数入口没有成为 root trace,但函数体已多次进入其他 trace。真正反复失败的是阶段入口和编排函数。
+
+> 看不到 trace 起点,只能证明这里没有成为 root trace 锚点;不能证明整个函数没有进入机器码。
+
+### 4.3 把 `start`、`stop`、`abort` 归回同一起点
+
+要回答“哪段代码没有编译、为什么”,需要让 LuaJIT 自己交事件流。核心做法是按 trace id 保存 `start` 的位置,再把后续 `stop` 或 `abort` 归回同一个起点:
+
+```lua
+-- 简化示意,实际回调参数和解析逻辑更复杂
+local trace_start = {}
+
+jit.attach(function(what, trace_id, func, pc, err_code)
+ if what == "start" then
+ trace_start[trace_id] = locate(func, pc)
+ elseif what == "stop" then
+ record_compiled(trace_start[trace_id])
+ trace_start[trace_id] = nil
+ elseif what == "abort" then
+ record_abort(trace_start[trace_id], err_code)
+ trace_start[trace_id] = nil
+ end
+end, "trace")
+```
+
+实际探针还要用 `jit.util.funcinfo` 解析源位置,用 `jit.vmdef.traceerr` 还原 abort 原因。这样就能比较开启/关闭自定义组件时:哪些起点进入 compiled 集合,哪些反复 abort,以及是否发生 trace flush。
+
+在本次 LuaJIT 构建中,失败惩罚从 72 开始逐次翻倍:第 10 次是 36,864,第 11 次达到 73,728,超过 60,000 上限。多个起点恰好 abort ×11 后不再增长;它们没有进入 compiled 集合,采集期间也没有 trace flush。两组存活 trace 数均低于缓存上限,因此排除了 trace 缓存被挤爆的可能。这些证据共同说明:对应的 trace 起点已被放弃继续编译。
+
+仍然不能扩大结论:被放弃的是 trace 起点,不一定是整个函数体;其他部分仍可能被内联进别的 trace。
+
+按调用来源聚合后,受影响的阶段入口和编排函数都经过同一条自定义日志旁路:日志级别不足时虽不输出,但仍会检查请求阶段、调用栈和请求上下文。新增调用本身没有形成大柱子,却改变了调用方的 JIT 结果,使部分成本看起来落在正常请求函数头上。
+
+```lua
+-- 自定义扩展的简化示意,不是 APISIX OSS 实现
+if log_level_is_suppressed then
+ check_debug_capture(...)
+ check_request_buffer(...)
+ return
+end
+```
+
+JIT 数据在这里承担两个职责:发现火焰图无法正确归属的成本,验证功能开关确实改变了高频路径的执行状态。但它不能直接告诉我们吞吐损失多大;真正的方向也不是“强迫所有函数编译”,而是让关闭状态下本来不该发生的工作根本不执行。
+
+还有一个容易让结论失真的陷阱:探针必须装在被观测模块加载之前。如果模块在 `require` 时保存了函数引用,之后替换原函数,计数器只能看到少量没被快照走的调用。本次探针注入晚时测到 1 次/请求,前移到 `require("apisix")` 之前后,才确认真实值是 5 次/请求。
+
+## 5. 配对 A/B 收口:实验回答“值多少钱”
+
+火焰图给位置,调用栈给放大链,LuaJIT 事件解释错位成本。最终还要靠配对实验定量。
+
+以 Global Rule 路径为例,我们保留相同的调度和配置,只让 Prometheus 业务函数进入后立即返回,用来区分“插件业务逻辑慢”和“进入插件前的公共路径慢”。为避免把定制环境绝对 RPS 当成 APISIX OSS benchmark,关闭 Prometheus 的吞吐被归一化为 100:
+
+| 内部 A/B 场景 | 相对吞吐指数 | 相对关闭 Prometheus |
+|---|---:|---:|
+| 关闭 Prometheus | 100.0 | 基准 |
+| 保留插件与调度,业务函数立即返回 | 77.2 | -22.8% |
+| 完整 Prometheus Global Rule | 56.9 | -43.1% |
+
+即使插件业务代码被短路,差距仍然显著。这个实验不能把成本定位到某一行,但足以证明损耗不只来自指标计算,进入插件前的公共路径本身就值得追查。再结合每请求 9 次过滤、纵向调用栈和 C 层成本分布,放大链才真正闭合。
+
+自定义观测组件也用同样方法:保持负载与配置一致,分别采集开启和关闭下的编译事件、每请求调用次数、吞吐和响应正确性。JIT 事件解释“为什么图上没有足够宽的新柱子”,端到端 A/B 回答“这条路径到底值多少钱”。
+
+整个排查可以压缩成五步:
+
+| 步骤 | 核心问题 | 证据 |
+|---|---|---|
+| 1. 确认 CPU 前提 | 火焰图是否有资格解释回退? | worker 饱和、绑核、上下游余量、稳定复现 |
+| 2. 横向看宽度 | 样本主要落在哪里? | 全局火焰图与候选排序 |
+| 3. 纵向沿栈看 | 小成本为何被放大? | 调用阶段、公共函数、每请求调用次数 |
+| 4. 检查 LuaJIT | 成本为何错位或消失? | `start`、`stop`、`abort`、`flush` 与 `jit.dump` |
+| 5. 配对 A/B 收口 | 真实量级与因果是什么? | 吞吐、延迟、调用次数、错误率与响应一致性 |
+
+这些结论必须留在明确边界内:
+
+- 火焰图宽度、Lua self time 与吞吐变化的分母不同,不能直接相减或相除。
+- 自定义观测组件不属于 APISIX OSS,只用于说明自定义扩展可能遇到的通用问题。
+- 29.6%、12.9%、417、493 和 abort ×11 都属于本次构建和采集窗口,不能外推。
+- “没有成为 trace 起点”不等于“函数没有编译”,必须检查函数体是否进入其他 trace。
+- JIT 编译结果是定位信号,不是最终性能指标;优化仍需验证吞吐、延迟、错误率、响应内容和资源回收。
+
+火焰图没有撒谎。横向宽度告诉我们 CPU 样本聚集在哪,纵向调用栈告诉我们成本如何被公共路径放大。但当成本进入解释器、JIT trace、分配器和调用方后,火焰图看到的就不再是完整归属。
+
+这时最有效的动作不是继续猜哪行 Lua 应该更快,而是让 LuaJIT 交出编译事件,再用每请求调用次数和配对 A/B 把量级钉死。最终值得优化的,往往不是最宽的柱子,而是那段经过证据证明、根本不需要反复发生的工作。
Review Comment:
Two changes. The caveat list drops the `没有成为 trace 起点` item, which 4.2 already states twice (once as a blockquote, once as `仍然不能扩大结论`) — 5 items reads as defensive padding. And the last two paragraphs return to the `火焰图没有撒谎` hook for the third time; reworded.
````suggestion
业务代码都被短路了,差距还是这么大。这个实验定位不到具体某一行,但足以说明开销不全在指标计算上,进插件之前的公共路径本身就该查。再对上每请求 9 次过滤、调用栈的形状和 C 层的开销分布,这条放大链才算说通。
自定义观测组件走的是同一套流程:负载和配置不动,分别在开和关两种状态下采编译事件、每请求调用次数、吞吐和响应正确性。JIT 事件回答“图上为什么没多出一根够宽的柱子”,端到端 A/B 回答“这条路径到底值多少”。
整个排查压缩下来是五步:
| 步骤 | 要回答的问题 | 看什么 |
|---|---|---|
| 1. 确认 CPU 前提 | 火焰图能否解释这次回退? | worker 饱和、绑核、上下游余量、稳定复现 |
| 2. 看宽度挑候选 | 样本主要落在哪儿? | 全局火焰图与候选排序 |
| 3. 顺着栈往上看 | 一点小开销为什么会被放大? | 调用阶段、公共函数、每请求调用次数 |
| 4. 查 LuaJIT | 开销为什么错位或者消失了? | `start`、`stop`、`abort`、`flush` 与 `jit.dump` |
| 5. 配对 A/B | 真实量级多大,因果成不成立? | 吞吐、延迟、调用次数、错误率与响应一致性 |
有几条边界得说清楚:
- 火焰图宽度、Lua self time 和吞吐变化的分母各不相同,不能直接相减或相除。
- 29.6%、12.9%、417、493 和 abort ×11 都只对这次的构建和采集窗口成立,不能外推。
- 自定义观测组件不属于 APISIX OSS,写进来只是因为它暴露的问题对自定义扩展有普遍性。
- JIT 编译结果是定位信号,不是性能指标;改完还是要拿吞吐、延迟、错误率、响应内容和资源回收去验。
火焰图没有骗人。宽度告诉我们 CPU 的采样堆在哪,调用栈告诉我们一小块开销怎么被公共路径放大。但当开销散进解释器派发、JIT trace、分配器和调用方之后,火焰图给出的归属就不完整了。
这种时候继续猜哪行 Lua 该更快,基本是在浪费时间。更有效的做法是让 LuaJIT 交出编译事件,再用每请求调用次数和配对 A/B 把量级钉死。最后真正值得改的,往往不是最宽的那根柱子,而是那段根本不需要反复发生的工作。
````
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@apisix.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] blog: add APISIX throughput regression investigation (en + zh) [apisix-website]
Posted by "AlinsRan (via GitHub)" <gi...@apache.org>.
AlinsRan commented on code in PR #2115:
URL: https://github.com/apache/apisix-website/pull/2115#discussion_r3879714206
##########
blog/zh/blog/2026/08/28/debugging-apisix-throughput-regression.md:
##########
@@ -0,0 +1,234 @@
+---
+title: "火焰图没撒谎,但没直接告诉我们瓶颈:一次 APISIX 吞吐回退定位"
+authors:
+ - name: "Xin Rong"
+ title: "Author"
+ url: "https://github.com/AlinsRan"
+ image_url: "https://github.com/AlinsRan.png"
+ - name: "Yilia Lin"
+ title: "Technical Writer"
+ url: "https://github.com/Yilialinn"
+ image_url: "https://github.com/Yilialinn.png"
+keywords:
+ - Apache APISIX
+ - 火焰图
+ - LuaJIT
+ - 性能优化
+ - 吞吐回退
+description: "火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。"
+tags: [Ecosystem]
+---
+
+火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。
+
+<!--truncate-->
+
+一次吞吐回退中,火焰图最宽的路径并不是最终瓶颈。真正值得追查的两个位置,在 Lua 采样里只有 3.0% 和 3.9%。如果按热点排序,它们根本进不了第一轮优化名单。
+
+这次排查最有价值的不是某个补丁,而是一条证据链:先确认 CPU 确实是瓶颈;再横向看火焰图宽度,建立候选;然后沿调用栈纵向追,看重复调用和公共路径如何放大成本;当火焰图解释不了端到端差距时,继续查 LuaJIT 编译与中断事件;最后用每请求调用次数和配对 A/B 实验定量。
+
+> 数据范围:下文数据来自同一受控环境中的一个 APISIX 内部定制构建,其中加载了 100 余个插件。吞吐统一归一化,只用于说明定位方法与因果链,不代表 Apache APISIX OSS 在其他硬件、配置或负载下的通用表现。
+
+## 1. 先确认 CPU 瓶颈,而不是上来就看火焰图
+
+火焰图显示的是采样期间 CPU 在执行什么。只有目标 worker 的 CPU 接近饱和、吞吐受这个核限制时,火焰图的宽度才有资格解释性能回退。
+
+我们在采样前固定了这些条件:
+
+- APISIX 单 worker 并绑定独占物理核;
+- 上游服务和压测端使用其他核,避免 CPU 争抢;
+- 请求模型、配置、响应内容保持一致;
+- 吞吐回退可稳定复现,错误率和响应结果没有偏移;
+- 目标 worker 持续接近饱和,上游、网络、压测端仍有余量。
+
+如果不满足这些前提,首先应该查连接、网络、上游或压测端,而不是在 on-CPU 火焰图里找答案。
+
+## 2. 横向看宽度,而不是排名
+
+我们使用 eBPF 以 500 Hz 同时采集 C 和 Lua 调用栈。下面是排查中的真实 Lua on-CPU 火焰图。
+
+
+
+*搜索 `run_global_rules` 后命中 3 处,但这些位置在整张图里并不醒目。原始采样共 2,446 个样本;截图已移除进程 PID。*
+
+横向看火焰图,看的是宽度,不是 x 轴顺序。宽度越大,采样落在该路径上的次数越多。初步候选位置按 Lua self time 排序:
+
+| 候选位置 | Lua self time | 初判 |
+|---|---:|---|
+| Prometheus exporter | 25.9% | 最值得先看 |
+| `ctx.var` 元方法 | 15.1% | 第二候选 |
+| 自定义日志旁路 | 3.9% | 很容易被忽略 |
+| `run_global_rules` | 3.0% | 很容易被忽略 |
+
+这里的 self time 指:在带 Lua 上下文的样本中,直接落在这个位置上的比例,不是总 CPU 占比。本次采样约 19.6% 的样本缺失 Lua 上下文,所以这些百分比只用于选候选,不能直接预测吞吐收益。
+
+这个排名有四个盲区:
+
+1. LuaJIT 解释执行时,不同的 Lua 代码可能折叠到共享的 `lj_BC_*`、`lj_vm_*` 符号里。
+2. 已编译的 JIT trace 不一定能被常规栈回溯完整展开,因此可观察到的 JIT 样本只是下界。
+3. 表查找、内存分配、GC 等成本会记在 C 符号上,而不是触发的 Lua 行上。
+4. 一个调用可能改变调用方的 JIT 状态,使成本看起来落在调用方函数的头上。
+
+`run_global_rules` 还有一个特征:它没有形成一根大柱子,而是散在多个请求阶段的调用栈里。单处不宽,不等于合起来不贵。
+
+## 3. 纵向沿栈看,3% 如何被公共路径放大
+
+选中某个 `run_global_rules` 方块后,纵向看调用栈:底部是请求阶段入口,向上经过 `common_phase`、Global Rule 插件筛选、调度,再到具体插件执行。
+
+
+
+*所选栈会被重新铺满画布,因此图中的宽度已经归一化,不能再当作它占总 CPU 的比例。*
+
+纵向高度本身不代表耗时。它的作用是看清成本从哪里进入、被谁调用、为什么反复出现。
+
+修复前,`run_global_rules()` 会在请求的多个阶段调用 `_M.filter()`。`_M.filter()` 遍历所有已加载插件,再逐个判断该插件是否被 Global Rule 配置:
+
+```lua
+-- 简化示意,非 APISIX 完整实现
+for _, plugin_obj in ipairs(local_plugins) do
+ local name = plugin_obj.name
+ local plugin_conf = user_plugin_conf[name]
+ if type(plugin_conf) ~= "table" then
+ goto continue
+ end
+ -- 处理已配置插件
+ ::continue::
+end
+```
+
+两个放大器都在这里:
+
+1. 单次过滤成本随已加载插件数量的增长。
+2. 相同过滤结果跨请求阶段重复生成;`body_filter` 与 `delayed_body_filter` 还可能按响应块多次进入。
+
+本次测试配置中,一个请求实际触发了 **9 次过滤**。也就是说,“从 100+ 插件中找出已配置的少量插件”这件事,被同一个请求重复了 9 次。
+
+这解释了为什么 Lua 层 3.0% 会低估整条路径成本:
+
+| 实际工作 | 火焰图常见归属 |
+|---|---|
+| 循环本身 | `plugin.lua` 对应位置 |
+| 大量表查找 | `lj_BC_TGETS` |
+| 临时表分配 | `lj_alloc_malloc` |
+| 临时对象回收 | `gc_sweep` |
+| 未编译解释器派发 | `lj_vm_*` / `lj_BC_*` |
+
+横向看,它只是 3% 的候选;纵向看,才会发现它位于一条被反复进入的公共路径。优化方向也很直接:同一请求内,如果 Global Rule 和匹配路由没有变化,就复用已筛选的插件集合,而不是每个阶段重新生成。
+
+## 4. 火焰图解释不了差距时,查 LuaJIT 编译事件
+
+第二条路径来自自定义观测组件。它和日志旁路不属于 APISIX OSS,但揭示的问题对 APISIX 插件和 OpenResty 扩展有参考价值:一次看似很轻的调用,既可能产生直接成本,也可能改变调用方之后以解释器还是机器码运行。
+
+### 4.1 解释器派发异常活跃
+
+把 C 层样本按运行时类型重新分类,最值得注意的不是某行 Lua,而是解释器与 JIT 的关系:
+
+| 运行类别 | 每请求 CPU 时间 | 占本次样本 |
+|---|---:|---:|
+| 解释器派发:`lj_BC_*` / `lj_vm_*` | 5.86 μs | 29.6% |
+| 可观察到的 JIT trace 执行 | 2.55 μs | 12.9% |
+
+JIT trace 难以完整 unwind,12.9% 只能看作下界。但在同一环境中,解释器派发如此活跃,已经构成强信号:某些高频路径可能没有稳定地跑在机器码里。
+
+这不等于 trace 越多越好。初始化代码不需要追求编译,不同 trace 的成本差异也很大。只有在 CPU 饱和、路径高频、解释器派发异常时,编译结果才值得深挖。
+
+### 4.2 `jit.v` 发出警报,也制造了第一次误判
+
+开启 `jit.v` 后,一次短时压测输出中看到 417 次 trace 编译成功、493 次 abort。按位置聚合后,一批路径的 abort 次数整齐停在 11。
+
+但这是编译事件数,不是唯一函数数、覆盖率或 CPU 时间。`jit.v` 有几个边界:
+
+- abort 行显示的是 trace 中断位置,而惩罚记在 trace 起点,两者可能不是同一行。
+- 某函数从未成为 trace 起点,不代表它没有被编译,函数体可能已被内联进父 trace。
+- 文本日志很难对功能开启/关闭两组结果做稳定的集合差。
+
+我们一开始也把“起点出现次数为 0”误读成“整个函数都在解释执行”。后来用 `jit.dump` 字节码模式复核,才发现多个函数入口没有成为 root trace,但函数体已多次进入其他 trace。真正反复失败的是阶段入口和编排函数。
+
+> 看不到 trace 起点,只能证明这里没有成为 root trace 锚点;不能证明整个函数没有进入机器码。
+
+### 4.3 把 `start`、`stop`、`abort` 归回同一起点
+
+要回答“哪段代码没有编译、为什么”,需要让 LuaJIT 自己交事件流。核心做法是按 trace id 保存 `start` 的位置,再把后续 `stop` 或 `abort` 归回同一个起点:
+
+```lua
+-- 简化示意,实际回调参数和解析逻辑更复杂
+local trace_start = {}
+
+jit.attach(function(what, trace_id, func, pc, err_code)
+ if what == "start" then
+ trace_start[trace_id] = locate(func, pc)
+ elseif what == "stop" then
+ record_compiled(trace_start[trace_id])
+ trace_start[trace_id] = nil
+ elseif what == "abort" then
+ record_abort(trace_start[trace_id], err_code)
+ trace_start[trace_id] = nil
+ end
+end, "trace")
+```
+
+实际探针还要用 `jit.util.funcinfo` 解析源位置,用 `jit.vmdef.traceerr` 还原 abort 原因。这样就能比较开启/关闭自定义组件时:哪些起点进入 compiled 集合,哪些反复 abort,以及是否发生 trace flush。
+
+在本次 LuaJIT 构建中,失败惩罚从 72 开始逐次翻倍:第 10 次是 36,864,第 11 次达到 73,728,超过 60,000 上限。多个起点恰好 abort ×11 后不再增长;它们没有进入 compiled 集合,采集期间也没有 trace flush。两组存活 trace 数均低于缓存上限,因此排除了 trace 缓存被挤爆的可能。这些证据共同说明:对应的 trace 起点已被放弃继续编译。
+
+仍然不能扩大结论:被放弃的是 trace 起点,不一定是整个函数体;其他部分仍可能被内联进别的 trace。
+
+按调用来源聚合后,受影响的阶段入口和编排函数都经过同一条自定义日志旁路:日志级别不足时虽不输出,但仍会检查请求阶段、调用栈和请求上下文。新增调用本身没有形成大柱子,却改变了调用方的 JIT 结果,使部分成本看起来落在正常请求函数头上。
+
+```lua
+-- 自定义扩展的简化示意,不是 APISIX OSS 实现
+if log_level_is_suppressed then
+ check_debug_capture(...)
+ check_request_buffer(...)
+ return
+end
+```
+
+JIT 数据在这里承担两个职责:发现火焰图无法正确归属的成本,验证功能开关确实改变了高频路径的执行状态。但它不能直接告诉我们吞吐损失多大;真正的方向也不是“强迫所有函数编译”,而是让关闭状态下本来不该发生的工作根本不执行。
+
+还有一个容易让结论失真的陷阱:探针必须装在被观测模块加载之前。如果模块在 `require` 时保存了函数引用,之后替换原函数,计数器只能看到少量没被快照走的调用。本次探针注入晚时测到 1 次/请求,前移到 `require("apisix")` 之前后,才确认真实值是 5 次/请求。
+
+## 5. 配对 A/B 收口:实验回答“值多少钱”
+
+火焰图给位置,调用栈给放大链,LuaJIT 事件解释错位成本。最终还要靠配对实验定量。
+
+以 Global Rule 路径为例,我们保留相同的调度和配置,只让 Prometheus 业务函数进入后立即返回,用来区分“插件业务逻辑慢”和“进入插件前的公共路径慢”。为避免把定制环境绝对 RPS 当成 APISIX OSS benchmark,关闭 Prometheus 的吞吐被归一化为 100:
+
+| 内部 A/B 场景 | 相对吞吐指数 | 相对关闭 Prometheus |
+|---|---:|---:|
+| 关闭 Prometheus | 100.0 | 基准 |
+| 保留插件与调度,业务函数立即返回 | 77.2 | -22.8% |
+| 完整 Prometheus Global Rule | 56.9 | -43.1% |
+
+即使插件业务代码被短路,差距仍然显著。这个实验不能把成本定位到某一行,但足以证明损耗不只来自指标计算,进入插件前的公共路径本身就值得追查。再结合每请求 9 次过滤、纵向调用栈和 C 层成本分布,放大链才真正闭合。
+
+自定义观测组件也用同样方法:保持负载与配置一致,分别采集开启和关闭下的编译事件、每请求调用次数、吞吐和响应正确性。JIT 事件解释“为什么图上没有足够宽的新柱子”,端到端 A/B 回答“这条路径到底值多少钱”。
+
+整个排查可以压缩成五步:
+
+| 步骤 | 核心问题 | 证据 |
+|---|---|---|
+| 1. 确认 CPU 前提 | 火焰图是否有资格解释回退? | worker 饱和、绑核、上下游余量、稳定复现 |
+| 2. 横向看宽度 | 样本主要落在哪里? | 全局火焰图与候选排序 |
+| 3. 纵向沿栈看 | 小成本为何被放大? | 调用阶段、公共函数、每请求调用次数 |
+| 4. 检查 LuaJIT | 成本为何错位或消失? | `start`、`stop`、`abort`、`flush` 与 `jit.dump` |
+| 5. 配对 A/B 收口 | 真实量级与因果是什么? | 吞吐、延迟、调用次数、错误率与响应一致性 |
+
+这些结论必须留在明确边界内:
+
+- 火焰图宽度、Lua self time 与吞吐变化的分母不同,不能直接相减或相除。
+- 自定义观测组件不属于 APISIX OSS,只用于说明自定义扩展可能遇到的通用问题。
+- 29.6%、12.9%、417、493 和 abort ×11 都属于本次构建和采集窗口,不能外推。
+- “没有成为 trace 起点”不等于“函数没有编译”,必须检查函数体是否进入其他 trace。
+- JIT 编译结果是定位信号,不是最终性能指标;优化仍需验证吞吐、延迟、错误率、响应内容和资源回收。
+
+火焰图没有撒谎。横向宽度告诉我们 CPU 样本聚集在哪,纵向调用栈告诉我们成本如何被公共路径放大。但当成本进入解释器、JIT trace、分配器和调用方后,火焰图看到的就不再是完整归属。
+
+这时最有效的动作不是继续猜哪行 Lua 应该更快,而是让 LuaJIT 交出编译事件,再用每请求调用次数和配对 A/B 把量级钉死。最终值得优化的,往往不是最宽的柱子,而是那段经过证据证明、根本不需要反复发生的工作。
Review Comment:
Two changes. The caveat list drops the `没有成为 trace 起点` item, which 4.2 already states twice (once as a blockquote, once as `仍然不能扩大结论`) — 5 items reads as defensive padding. And the last two paragraphs return to the `火焰图没有撒谎` hook for the third time; reworded.
````suggestion
业务代码都被短路了,差距还是这么大。这个实验定位不到具体某一行,但足以说明开销不全在指标计算上,进插件之前的公共路径本身就该查。再对上每请求 9 次过滤、调用栈的形状和 C 层的开销分布,这条放大链才算说通。
自定义观测组件走的是同一套流程:负载和配置不动,分别在开和关两种状态下采编译事件、每请求调用次数、吞吐和响应正确性。JIT 事件回答“图上为什么没多出一根够宽的柱子”,端到端 A/B 回答“这条路径到底值多少”。
整个排查压缩下来是五步:
| 步骤 | 要回答的问题 | 看什么 |
|---|---|---|
| 1. 确认 CPU 前提 | 火焰图能否解释这次回退? | worker 饱和、绑核、上下游余量、稳定复现 |
| 2. 看宽度挑候选 | 样本主要落在哪儿? | 全局火焰图与候选排序 |
| 3. 顺着栈往上看 | 一点小开销为什么会被放大? | 调用阶段、公共函数、每请求调用次数 |
| 4. 查 LuaJIT | 开销为什么错位或者消失了? | `start`、`stop`、`abort`、`flush` 与 `jit.dump` |
| 5. 配对 A/B | 真实量级多大,因果成不成立? | 吞吐、延迟、调用次数、错误率与响应一致性 |
有几条边界得说清楚:
- 火焰图宽度、Lua self time 和吞吐变化的分母各不相同,不能直接相减或相除。
- 29.6%、12.9%、417、493 和 abort ×11 都只对这次的构建和采集窗口成立,不能外推。
- 自定义观测组件不属于 APISIX OSS,写进来只是因为它暴露的问题对自定义扩展有普遍性。
- JIT 编译结果是定位信号,不是性能指标;改完还是要拿吞吐、延迟、错误率、响应内容和资源回收去验。
火焰图的宽度告诉我们 CPU 的采样堆在哪,调用栈告诉我们一小块开销怎么被公共路径放大。但当开销散进解释器派发、JIT trace、分配器和调用方之后,火焰图给出的归属就不完整了。
这种时候继续猜哪行 Lua 该更快,基本是在浪费时间。更有效的做法是让 LuaJIT 交出编译事件,再用每请求调用次数和配对 A/B 把量级钉死。最后真正值得改的,往往不是最宽的那根柱子,而是那段根本不需要反复发生的工作。
````
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@apisix.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] blog: add APISIX throughput regression investigation (en + zh) [apisix-website]
Posted by "AlinsRan (via GitHub)" <gi...@apache.org>.
AlinsRan commented on code in PR #2115:
URL: https://github.com/apache/apisix-website/pull/2115#discussion_r3879714170
##########
blog/zh/blog/2026/08/28/debugging-apisix-throughput-regression.md:
##########
@@ -0,0 +1,234 @@
+---
+title: "火焰图没撒谎,但没直接告诉我们瓶颈:一次 APISIX 吞吐回退定位"
+authors:
+ - name: "Xin Rong"
+ title: "Author"
+ url: "https://github.com/AlinsRan"
+ image_url: "https://github.com/AlinsRan.png"
+ - name: "Yilia Lin"
+ title: "Technical Writer"
+ url: "https://github.com/Yilialinn"
+ image_url: "https://github.com/Yilialinn.png"
+keywords:
+ - Apache APISIX
+ - 火焰图
+ - LuaJIT
+ - 性能优化
+ - 吞吐回退
+description: "火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。"
+tags: [Ecosystem]
+---
+
+火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。
+
+<!--truncate-->
+
+一次吞吐回退中,火焰图最宽的路径并不是最终瓶颈。真正值得追查的两个位置,在 Lua 采样里只有 3.0% 和 3.9%。如果按热点排序,它们根本进不了第一轮优化名单。
+
+这次排查最有价值的不是某个补丁,而是一条证据链:先确认 CPU 确实是瓶颈;再横向看火焰图宽度,建立候选;然后沿调用栈纵向追,看重复调用和公共路径如何放大成本;当火焰图解释不了端到端差距时,继续查 LuaJIT 编译与中断事件;最后用每请求调用次数和配对 A/B 实验定量。
+
+> 数据范围:下文数据来自同一受控环境中的一个 APISIX 内部定制构建,其中加载了 100 余个插件。吞吐统一归一化,只用于说明定位方法与因果链,不代表 Apache APISIX OSS 在其他硬件、配置或负载下的通用表现。
+
+## 1. 先确认 CPU 瓶颈,而不是上来就看火焰图
+
+火焰图显示的是采样期间 CPU 在执行什么。只有目标 worker 的 CPU 接近饱和、吞吐受这个核限制时,火焰图的宽度才有资格解释性能回退。
+
+我们在采样前固定了这些条件:
+
+- APISIX 单 worker 并绑定独占物理核;
+- 上游服务和压测端使用其他核,避免 CPU 争抢;
+- 请求模型、配置、响应内容保持一致;
+- 吞吐回退可稳定复现,错误率和响应结果没有偏移;
+- 目标 worker 持续接近饱和,上游、网络、压测端仍有余量。
+
+如果不满足这些前提,首先应该查连接、网络、上游或压测端,而不是在 on-CPU 火焰图里找答案。
+
+## 2. 横向看宽度,而不是排名
+
+我们使用 eBPF 以 500 Hz 同时采集 C 和 Lua 调用栈。下面是排查中的真实 Lua on-CPU 火焰图。
+
+
+
+*搜索 `run_global_rules` 后命中 3 处,但这些位置在整张图里并不醒目。原始采样共 2,446 个样本;截图已移除进程 PID。*
+
+横向看火焰图,看的是宽度,不是 x 轴顺序。宽度越大,采样落在该路径上的次数越多。初步候选位置按 Lua self time 排序:
+
+| 候选位置 | Lua self time | 初判 |
+|---|---:|---|
+| Prometheus exporter | 25.9% | 最值得先看 |
+| `ctx.var` 元方法 | 15.1% | 第二候选 |
+| 自定义日志旁路 | 3.9% | 很容易被忽略 |
+| `run_global_rules` | 3.0% | 很容易被忽略 |
+
+这里的 self time 指:在带 Lua 上下文的样本中,直接落在这个位置上的比例,不是总 CPU 占比。本次采样约 19.6% 的样本缺失 Lua 上下文,所以这些百分比只用于选候选,不能直接预测吞吐收益。
+
+这个排名有四个盲区:
+
+1. LuaJIT 解释执行时,不同的 Lua 代码可能折叠到共享的 `lj_BC_*`、`lj_vm_*` 符号里。
+2. 已编译的 JIT trace 不一定能被常规栈回溯完整展开,因此可观察到的 JIT 样本只是下界。
+3. 表查找、内存分配、GC 等成本会记在 C 符号上,而不是触发的 Lua 行上。
+4. 一个调用可能改变调用方的 JIT 状态,使成本看起来落在调用方函数的头上。
+
+`run_global_rules` 还有一个特征:它没有形成一根大柱子,而是散在多个请求阶段的调用栈里。单处不宽,不等于合起来不贵。
+
+## 3. 纵向沿栈看,3% 如何被公共路径放大
+
+选中某个 `run_global_rules` 方块后,纵向看调用栈:底部是请求阶段入口,向上经过 `common_phase`、Global Rule 插件筛选、调度,再到具体插件执行。
+
+
+
+*所选栈会被重新铺满画布,因此图中的宽度已经归一化,不能再当作它占总 CPU 的比例。*
+
+纵向高度本身不代表耗时。它的作用是看清成本从哪里进入、被谁调用、为什么反复出现。
+
+修复前,`run_global_rules()` 会在请求的多个阶段调用 `_M.filter()`。`_M.filter()` 遍历所有已加载插件,再逐个判断该插件是否被 Global Rule 配置:
+
+```lua
+-- 简化示意,非 APISIX 完整实现
+for _, plugin_obj in ipairs(local_plugins) do
+ local name = plugin_obj.name
+ local plugin_conf = user_plugin_conf[name]
+ if type(plugin_conf) ~= "table" then
+ goto continue
+ end
+ -- 处理已配置插件
+ ::continue::
+end
+```
+
+两个放大器都在这里:
+
+1. 单次过滤成本随已加载插件数量的增长。
+2. 相同过滤结果跨请求阶段重复生成;`body_filter` 与 `delayed_body_filter` 还可能按响应块多次进入。
+
+本次测试配置中,一个请求实际触发了 **9 次过滤**。也就是说,“从 100+ 插件中找出已配置的少量插件”这件事,被同一个请求重复了 9 次。
+
+这解释了为什么 Lua 层 3.0% 会低估整条路径成本:
Review Comment:
`两个放大器都在这里` plus a two-item list is heavier machinery than the point needs. Folded into one sentence; the 9-passes number is unchanged.
````suggestion
两个放大因素叠在一起:单次过滤的开销跟着已加载插件数量涨;同一份过滤结果又在各个请求阶段被反复生成,`body_filter` 和 `delayed_body_filter` 还会按响应块多进来几次。
这次的配置下,一个请求实际触发了 **9 次过滤**。也就是说,“从 100 多个插件里挑出那几个配置过的”,同一个请求干了 9 遍。
到这里就能解释 3.0% 为什么低估了整条路径:实际干的活儿,大部分并没有记在 Lua 那一行上。
````
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@apisix.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] blog: add APISIX throughput regression investigation (en + zh) [apisix-website]
Posted by "AlinsRan (via GitHub)" <gi...@apache.org>.
AlinsRan commented on code in PR #2115:
URL: https://github.com/apache/apisix-website/pull/2115#discussion_r3879927820
##########
blog/zh/blog/2026/08/28/debugging-apisix-throughput-regression.md:
##########
@@ -0,0 +1,234 @@
+---
+title: "火焰图没撒谎,但没直接告诉我们瓶颈:一次 APISIX 吞吐回退定位"
+authors:
+ - name: "Xin Rong"
+ title: "Author"
+ url: "https://github.com/AlinsRan"
+ image_url: "https://github.com/AlinsRan.png"
+ - name: "Yilia Lin"
+ title: "Technical Writer"
+ url: "https://github.com/Yilialinn"
+ image_url: "https://github.com/Yilialinn.png"
+keywords:
+ - Apache APISIX
+ - 火焰图
+ - LuaJIT
+ - 性能优化
+ - 吞吐回退
+description: "火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。"
+tags: [Ecosystem]
+---
+
+火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。
+
+<!--truncate-->
+
+一次吞吐回退中,火焰图最宽的路径并不是最终瓶颈。真正值得追查的两个位置,在 Lua 采样里只有 3.0% 和 3.9%。如果按热点排序,它们根本进不了第一轮优化名单。
+
+这次排查最有价值的不是某个补丁,而是一条证据链:先确认 CPU 确实是瓶颈;再横向看火焰图宽度,建立候选;然后沿调用栈纵向追,看重复调用和公共路径如何放大成本;当火焰图解释不了端到端差距时,继续查 LuaJIT 编译与中断事件;最后用每请求调用次数和配对 A/B 实验定量。
+
+> 数据范围:下文数据来自同一受控环境中的一个 APISIX 内部定制构建,其中加载了 100 余个插件。吞吐统一归一化,只用于说明定位方法与因果链,不代表 Apache APISIX OSS 在其他硬件、配置或负载下的通用表现。
+
+## 1. 先确认 CPU 瓶颈,而不是上来就看火焰图
+
+火焰图显示的是采样期间 CPU 在执行什么。只有目标 worker 的 CPU 接近饱和、吞吐受这个核限制时,火焰图的宽度才有资格解释性能回退。
+
+我们在采样前固定了这些条件:
+
+- APISIX 单 worker 并绑定独占物理核;
+- 上游服务和压测端使用其他核,避免 CPU 争抢;
+- 请求模型、配置、响应内容保持一致;
+- 吞吐回退可稳定复现,错误率和响应结果没有偏移;
+- 目标 worker 持续接近饱和,上游、网络、压测端仍有余量。
+
+如果不满足这些前提,首先应该查连接、网络、上游或压测端,而不是在 on-CPU 火焰图里找答案。
+
+## 2. 横向看宽度,而不是排名
+
+我们使用 eBPF 以 500 Hz 同时采集 C 和 Lua 调用栈。下面是排查中的真实 Lua on-CPU 火焰图。
+
+
+
+*搜索 `run_global_rules` 后命中 3 处,但这些位置在整张图里并不醒目。原始采样共 2,446 个样本;截图已移除进程 PID。*
+
+横向看火焰图,看的是宽度,不是 x 轴顺序。宽度越大,采样落在该路径上的次数越多。初步候选位置按 Lua self time 排序:
+
+| 候选位置 | Lua self time | 初判 |
+|---|---:|---|
+| Prometheus exporter | 25.9% | 最值得先看 |
+| `ctx.var` 元方法 | 15.1% | 第二候选 |
+| 自定义日志旁路 | 3.9% | 很容易被忽略 |
+| `run_global_rules` | 3.0% | 很容易被忽略 |
+
+这里的 self time 指:在带 Lua 上下文的样本中,直接落在这个位置上的比例,不是总 CPU 占比。本次采样约 19.6% 的样本缺失 Lua 上下文,所以这些百分比只用于选候选,不能直接预测吞吐收益。
+
+这个排名有四个盲区:
+
+1. LuaJIT 解释执行时,不同的 Lua 代码可能折叠到共享的 `lj_BC_*`、`lj_vm_*` 符号里。
+2. 已编译的 JIT trace 不一定能被常规栈回溯完整展开,因此可观察到的 JIT 样本只是下界。
+3. 表查找、内存分配、GC 等成本会记在 C 符号上,而不是触发的 Lua 行上。
+4. 一个调用可能改变调用方的 JIT 状态,使成本看起来落在调用方函数的头上。
+
+`run_global_rules` 还有一个特征:它没有形成一根大柱子,而是散在多个请求阶段的调用栈里。单处不宽,不等于合起来不贵。
+
+## 3. 纵向沿栈看,3% 如何被公共路径放大
+
+选中某个 `run_global_rules` 方块后,纵向看调用栈:底部是请求阶段入口,向上经过 `common_phase`、Global Rule 插件筛选、调度,再到具体插件执行。
+
+
+
+*所选栈会被重新铺满画布,因此图中的宽度已经归一化,不能再当作它占总 CPU 的比例。*
+
+纵向高度本身不代表耗时。它的作用是看清成本从哪里进入、被谁调用、为什么反复出现。
+
+修复前,`run_global_rules()` 会在请求的多个阶段调用 `_M.filter()`。`_M.filter()` 遍历所有已加载插件,再逐个判断该插件是否被 Global Rule 配置:
+
+```lua
+-- 简化示意,非 APISIX 完整实现
+for _, plugin_obj in ipairs(local_plugins) do
+ local name = plugin_obj.name
+ local plugin_conf = user_plugin_conf[name]
+ if type(plugin_conf) ~= "table" then
+ goto continue
+ end
+ -- 处理已配置插件
+ ::continue::
+end
+```
+
+两个放大器都在这里:
+
+1. 单次过滤成本随已加载插件数量的增长。
+2. 相同过滤结果跨请求阶段重复生成;`body_filter` 与 `delayed_body_filter` 还可能按响应块多次进入。
+
+本次测试配置中,一个请求实际触发了 **9 次过滤**。也就是说,“从 100+ 插件中找出已配置的少量插件”这件事,被同一个请求重复了 9 次。
+
+这解释了为什么 Lua 层 3.0% 会低估整条路径成本:
+
+| 实际工作 | 火焰图常见归属 |
+|---|---|
+| 循环本身 | `plugin.lua` 对应位置 |
+| 大量表查找 | `lj_BC_TGETS` |
+| 临时表分配 | `lj_alloc_malloc` |
+| 临时对象回收 | `gc_sweep` |
+| 未编译解释器派发 | `lj_vm_*` / `lj_BC_*` |
+
+横向看,它只是 3% 的候选;纵向看,才会发现它位于一条被反复进入的公共路径。优化方向也很直接:同一请求内,如果 Global Rule 和匹配路由没有变化,就复用已筛选的插件集合,而不是每个阶段重新生成。
+
+## 4. 火焰图解释不了差距时,查 LuaJIT 编译事件
+
+第二条路径来自自定义观测组件。它和日志旁路不属于 APISIX OSS,但揭示的问题对 APISIX 插件和 OpenResty 扩展有参考价值:一次看似很轻的调用,既可能产生直接成本,也可能改变调用方之后以解释器还是机器码运行。
+
+### 4.1 解释器派发异常活跃
+
+把 C 层样本按运行时类型重新分类,最值得注意的不是某行 Lua,而是解释器与 JIT 的关系:
+
+| 运行类别 | 每请求 CPU 时间 | 占本次样本 |
+|---|---:|---:|
+| 解释器派发:`lj_BC_*` / `lj_vm_*` | 5.86 μs | 29.6% |
+| 可观察到的 JIT trace 执行 | 2.55 μs | 12.9% |
+
+JIT trace 难以完整 unwind,12.9% 只能看作下界。但在同一环境中,解释器派发如此活跃,已经构成强信号:某些高频路径可能没有稳定地跑在机器码里。
+
+这不等于 trace 越多越好。初始化代码不需要追求编译,不同 trace 的成本差异也很大。只有在 CPU 饱和、路径高频、解释器派发异常时,编译结果才值得深挖。
+
+### 4.2 `jit.v` 发出警报,也制造了第一次误判
+
+开启 `jit.v` 后,一次短时压测输出中看到 417 次 trace 编译成功、493 次 abort。按位置聚合后,一批路径的 abort 次数整齐停在 11。
+
+但这是编译事件数,不是唯一函数数、覆盖率或 CPU 时间。`jit.v` 有几个边界:
+
+- abort 行显示的是 trace 中断位置,而惩罚记在 trace 起点,两者可能不是同一行。
+- 某函数从未成为 trace 起点,不代表它没有被编译,函数体可能已被内联进父 trace。
+- 文本日志很难对功能开启/关闭两组结果做稳定的集合差。
+
+我们一开始也把“起点出现次数为 0”误读成“整个函数都在解释执行”。后来用 `jit.dump` 字节码模式复核,才发现多个函数入口没有成为 root trace,但函数体已多次进入其他 trace。真正反复失败的是阶段入口和编排函数。
+
+> 看不到 trace 起点,只能证明这里没有成为 root trace 锚点;不能证明整个函数没有进入机器码。
+
+### 4.3 把 `start`、`stop`、`abort` 归回同一起点
+
+要回答“哪段代码没有编译、为什么”,需要让 LuaJIT 自己交事件流。核心做法是按 trace id 保存 `start` 的位置,再把后续 `stop` 或 `abort` 归回同一个起点:
+
+```lua
+-- 简化示意,实际回调参数和解析逻辑更复杂
+local trace_start = {}
+
+jit.attach(function(what, trace_id, func, pc, err_code)
+ if what == "start" then
+ trace_start[trace_id] = locate(func, pc)
+ elseif what == "stop" then
+ record_compiled(trace_start[trace_id])
+ trace_start[trace_id] = nil
+ elseif what == "abort" then
+ record_abort(trace_start[trace_id], err_code)
+ trace_start[trace_id] = nil
+ end
+end, "trace")
+```
+
+实际探针还要用 `jit.util.funcinfo` 解析源位置,用 `jit.vmdef.traceerr` 还原 abort 原因。这样就能比较开启/关闭自定义组件时:哪些起点进入 compiled 集合,哪些反复 abort,以及是否发生 trace flush。
+
+在本次 LuaJIT 构建中,失败惩罚从 72 开始逐次翻倍:第 10 次是 36,864,第 11 次达到 73,728,超过 60,000 上限。多个起点恰好 abort ×11 后不再增长;它们没有进入 compiled 集合,采集期间也没有 trace flush。两组存活 trace 数均低于缓存上限,因此排除了 trace 缓存被挤爆的可能。这些证据共同说明:对应的 trace 起点已被放弃继续编译。
+
+仍然不能扩大结论:被放弃的是 trace 起点,不一定是整个函数体;其他部分仍可能被内联进别的 trace。
+
+按调用来源聚合后,受影响的阶段入口和编排函数都经过同一条自定义日志旁路:日志级别不足时虽不输出,但仍会检查请求阶段、调用栈和请求上下文。新增调用本身没有形成大柱子,却改变了调用方的 JIT 结果,使部分成本看起来落在正常请求函数头上。
+
+```lua
+-- 自定义扩展的简化示意,不是 APISIX OSS 实现
+if log_level_is_suppressed then
+ check_debug_capture(...)
+ check_request_buffer(...)
+ return
+end
+```
+
+JIT 数据在这里承担两个职责:发现火焰图无法正确归属的成本,验证功能开关确实改变了高频路径的执行状态。但它不能直接告诉我们吞吐损失多大;真正的方向也不是“强迫所有函数编译”,而是让关闭状态下本来不该发生的工作根本不执行。
+
+还有一个容易让结论失真的陷阱:探针必须装在被观测模块加载之前。如果模块在 `require` 时保存了函数引用,之后替换原函数,计数器只能看到少量没被快照走的调用。本次探针注入晚时测到 1 次/请求,前移到 `require("apisix")` 之前后,才确认真实值是 5 次/请求。
+
+## 5. 配对 A/B 收口:实验回答“值多少钱”
+
+火焰图给位置,调用栈给放大链,LuaJIT 事件解释错位成本。最终还要靠配对实验定量。
+
+以 Global Rule 路径为例,我们保留相同的调度和配置,只让 Prometheus 业务函数进入后立即返回,用来区分“插件业务逻辑慢”和“进入插件前的公共路径慢”。为避免把定制环境绝对 RPS 当成 APISIX OSS benchmark,关闭 Prometheus 的吞吐被归一化为 100:
+
+| 内部 A/B 场景 | 相对吞吐指数 | 相对关闭 Prometheus |
+|---|---:|---:|
+| 关闭 Prometheus | 100.0 | 基准 |
+| 保留插件与调度,业务函数立即返回 | 77.2 | -22.8% |
+| 完整 Prometheus Global Rule | 56.9 | -43.1% |
+
+即使插件业务代码被短路,差距仍然显著。这个实验不能把成本定位到某一行,但足以证明损耗不只来自指标计算,进入插件前的公共路径本身就值得追查。再结合每请求 9 次过滤、纵向调用栈和 C 层成本分布,放大链才真正闭合。
+
+自定义观测组件也用同样方法:保持负载与配置一致,分别采集开启和关闭下的编译事件、每请求调用次数、吞吐和响应正确性。JIT 事件解释“为什么图上没有足够宽的新柱子”,端到端 A/B 回答“这条路径到底值多少钱”。
+
+整个排查可以压缩成五步:
+
+| 步骤 | 核心问题 | 证据 |
+|---|---|---|
+| 1. 确认 CPU 前提 | 火焰图是否有资格解释回退? | worker 饱和、绑核、上下游余量、稳定复现 |
+| 2. 横向看宽度 | 样本主要落在哪里? | 全局火焰图与候选排序 |
+| 3. 纵向沿栈看 | 小成本为何被放大? | 调用阶段、公共函数、每请求调用次数 |
+| 4. 检查 LuaJIT | 成本为何错位或消失? | `start`、`stop`、`abort`、`flush` 与 `jit.dump` |
+| 5. 配对 A/B 收口 | 真实量级与因果是什么? | 吞吐、延迟、调用次数、错误率与响应一致性 |
+
+这些结论必须留在明确边界内:
+
+- 火焰图宽度、Lua self time 与吞吐变化的分母不同,不能直接相减或相除。
+- 自定义观测组件不属于 APISIX OSS,只用于说明自定义扩展可能遇到的通用问题。
+- 29.6%、12.9%、417、493 和 abort ×11 都属于本次构建和采集窗口,不能外推。
+- “没有成为 trace 起点”不等于“函数没有编译”,必须检查函数体是否进入其他 trace。
+- JIT 编译结果是定位信号,不是最终性能指标;优化仍需验证吞吐、延迟、错误率、响应内容和资源回收。
+
+火焰图没有撒谎。横向宽度告诉我们 CPU 样本聚集在哪,纵向调用栈告诉我们成本如何被公共路径放大。但当成本进入解释器、JIT trace、分配器和调用方后,火焰图看到的就不再是完整归属。
+
+这时最有效的动作不是继续猜哪行 Lua 应该更快,而是让 LuaJIT 交出编译事件,再用每请求调用次数和配对 A/B 把量级钉死。最终值得优化的,往往不是最宽的柱子,而是那段经过证据证明、根本不需要反复发生的工作。
Review Comment:
````suggestion
火焰图没有撒谎。宽度告诉我们 CPU 样本聚集在哪,调用栈告诉我们这些成本如何被公共路径放大。但成本一旦被记到解释器符号、JIT trace、内存分配器或者调用方头上,火焰图给出的归属就不再完整。
这时候最有效的做法不是继续猜哪行 Lua 应该更快,而是让 LuaJIT 交出编译事件,再用每请求调用次数和配对 A/B 实验把这条路径的真实开销测出来。最终值得优化的,往往不是最宽的柱子,而是那些有证据表明根本不需要反复执行的工作。
````
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@apisix.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] blog: add APISIX throughput regression investigation (en + zh) [apisix-website]
Posted by "AlinsRan (via GitHub)" <gi...@apache.org>.
AlinsRan commented on code in PR #2115:
URL: https://github.com/apache/apisix-website/pull/2115#discussion_r3879714159
##########
blog/zh/blog/2026/08/28/debugging-apisix-throughput-regression.md:
##########
@@ -0,0 +1,234 @@
+---
+title: "火焰图没撒谎,但没直接告诉我们瓶颈:一次 APISIX 吞吐回退定位"
+authors:
+ - name: "Xin Rong"
+ title: "Author"
+ url: "https://github.com/AlinsRan"
+ image_url: "https://github.com/AlinsRan.png"
+ - name: "Yilia Lin"
+ title: "Technical Writer"
+ url: "https://github.com/Yilialinn"
+ image_url: "https://github.com/Yilialinn.png"
+keywords:
+ - Apache APISIX
+ - 火焰图
+ - LuaJIT
+ - 性能优化
+ - 吞吐回退
+description: "火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。"
+tags: [Ecosystem]
+---
+
+火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。
+
+<!--truncate-->
+
+一次吞吐回退中,火焰图最宽的路径并不是最终瓶颈。真正值得追查的两个位置,在 Lua 采样里只有 3.0% 和 3.9%。如果按热点排序,它们根本进不了第一轮优化名单。
+
+这次排查最有价值的不是某个补丁,而是一条证据链:先确认 CPU 确实是瓶颈;再横向看火焰图宽度,建立候选;然后沿调用栈纵向追,看重复调用和公共路径如何放大成本;当火焰图解释不了端到端差距时,继续查 LuaJIT 编译与中断事件;最后用每请求调用次数和配对 A/B 实验定量。
+
+> 数据范围:下文数据来自同一受控环境中的一个 APISIX 内部定制构建,其中加载了 100 余个插件。吞吐统一归一化,只用于说明定位方法与因果链,不代表 Apache APISIX OSS 在其他硬件、配置或负载下的通用表现。
+
+## 1. 先确认 CPU 瓶颈,而不是上来就看火焰图
+
+火焰图显示的是采样期间 CPU 在执行什么。只有目标 worker 的 CPU 接近饱和、吞吐受这个核限制时,火焰图的宽度才有资格解释性能回退。
+
+我们在采样前固定了这些条件:
+
+- APISIX 单 worker 并绑定独占物理核;
+- 上游服务和压测端使用其他核,避免 CPU 争抢;
+- 请求模型、配置、响应内容保持一致;
+- 吞吐回退可稳定复现,错误率和响应结果没有偏移;
+- 目标 worker 持续接近饱和,上游、网络、压测端仍有余量。
+
+如果不满足这些前提,首先应该查连接、网络、上游或压测端,而不是在 on-CPU 火焰图里找答案。
+
+## 2. 横向看宽度,而不是排名
+
+我们使用 eBPF 以 500 Hz 同时采集 C 和 Lua 调用栈。下面是排查中的真实 Lua on-CPU 火焰图。
+
+
+
+*搜索 `run_global_rules` 后命中 3 处,但这些位置在整张图里并不醒目。原始采样共 2,446 个样本;截图已移除进程 PID。*
+
+横向看火焰图,看的是宽度,不是 x 轴顺序。宽度越大,采样落在该路径上的次数越多。初步候选位置按 Lua self time 排序:
Review Comment:
This opening block is the main source of the machine-written tone. `火焰图没有撒谎` appears in `description`, in the lead, and again in the conclusion; `证据链` and the 横向/纵向 framing are methodology vocabulary rather than a description of what happened. Rewritten as a plain account. This also turns the Figure 1 caption into a real caption instead of an all-italic paragraph.
````suggestion
description: "一次 APISIX 吞吐回退的排查记录。火焰图里最宽的两根柱子都不是原因,真正的问题在 Lua 采样里只占 3.0% 和 3.9%,得靠调用栈、LuaJIT 编译事件和 A/B 实验才挖得出来。"
tags: [Ecosystem]
---
一次 APISIX 吞吐回退的排查记录。火焰图里最宽的两根柱子都不是原因,真正的问题在 Lua 采样里只占 3.0% 和 3.9%,得靠调用栈、LuaJIT 编译事件和 A/B 实验才挖得出来。
<!--truncate-->
按热点大小排队,这两处根本进不了第一轮优化名单。
这次排查留下来的东西不是某个补丁,而是一套翻查顺序:先确认瓶颈真的在 CPU 上;再看火焰图的宽度挑候选;然后顺着调用栈往上,看这点开销是怎么被公共路径放大的;等火焰图解释不了端到端的差距了,就去查 LuaJIT 的编译和中断事件;最后用每请求调用次数和配对 A/B 把量级定下来。
> 数据说明:下文的数据来自同一个受控环境里的一个 APISIX 内部定制构建,加载了 100 多个插件。吞吐做了归一化,只用来说明排查方法和因果关系,不代表 Apache APISIX OSS 在其他硬件、配置或负载下的表现。
## 1. 先确认瓶颈真在 CPU 上
火焰图只能告诉你采样期间 CPU 在跑什么。如果 worker 根本没跑满,吞吐卡在别的地方,那再宽的柱子也解释不了这次回退。
所以采样之前,我们把环境固定成这样:
- APISIX 单 worker,绑一个独占的物理核;
- 上游服务和压测端跑在别的核上,避免 CPU 争抢;
- 请求模型、配置、响应内容三样都不变;
- 吞吐回退能稳定复现,错误率和响应结果没有偏移;
- 目标 worker 一直接近饱和,而上游、网络、压测端都还有余量。
这几条只要有一条不满足,就该先去查连接、网络、上游或者压测端,而不是抓一张 on-CPU 火焰图慢慢看。
## 2. 看宽度,不是看排名
我们用 eBPF 以 500 Hz 同时采 C 和 Lua 的调用栈。下面是这次排查里真实的 Lua on-CPU 火焰图。

图 1:火焰图全局视图。搜索 `run_global_rules` 命中 3 处,但在整张图里都不显眼。原始采样共 2,446 个样本,截图去掉了进程 PID。
火焰图的 x 轴没有先后关系,有意义的只是宽度:一条路径越宽,说明落在它上面的采样越多。按 Lua self time 排下来,第一轮候选是这样的:
````
##########
blog/zh/blog/2026/08/28/debugging-apisix-throughput-regression.md:
##########
@@ -0,0 +1,234 @@
+---
+title: "火焰图没撒谎,但没直接告诉我们瓶颈:一次 APISIX 吞吐回退定位"
+authors:
+ - name: "Xin Rong"
+ title: "Author"
+ url: "https://github.com/AlinsRan"
+ image_url: "https://github.com/AlinsRan.png"
+ - name: "Yilia Lin"
+ title: "Technical Writer"
+ url: "https://github.com/Yilialinn"
+ image_url: "https://github.com/Yilialinn.png"
+keywords:
+ - Apache APISIX
+ - 火焰图
+ - LuaJIT
+ - 性能优化
+ - 吞吐回退
+description: "火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。"
+tags: [Ecosystem]
+---
+
+火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。
+
+<!--truncate-->
+
+一次吞吐回退中,火焰图最宽的路径并不是最终瓶颈。真正值得追查的两个位置,在 Lua 采样里只有 3.0% 和 3.9%。如果按热点排序,它们根本进不了第一轮优化名单。
+
+这次排查最有价值的不是某个补丁,而是一条证据链:先确认 CPU 确实是瓶颈;再横向看火焰图宽度,建立候选;然后沿调用栈纵向追,看重复调用和公共路径如何放大成本;当火焰图解释不了端到端差距时,继续查 LuaJIT 编译与中断事件;最后用每请求调用次数和配对 A/B 实验定量。
+
+> 数据范围:下文数据来自同一受控环境中的一个 APISIX 内部定制构建,其中加载了 100 余个插件。吞吐统一归一化,只用于说明定位方法与因果链,不代表 Apache APISIX OSS 在其他硬件、配置或负载下的通用表现。
+
+## 1. 先确认 CPU 瓶颈,而不是上来就看火焰图
+
+火焰图显示的是采样期间 CPU 在执行什么。只有目标 worker 的 CPU 接近饱和、吞吐受这个核限制时,火焰图的宽度才有资格解释性能回退。
+
+我们在采样前固定了这些条件:
+
+- APISIX 单 worker 并绑定独占物理核;
+- 上游服务和压测端使用其他核,避免 CPU 争抢;
+- 请求模型、配置、响应内容保持一致;
+- 吞吐回退可稳定复现,错误率和响应结果没有偏移;
+- 目标 worker 持续接近饱和,上游、网络、压测端仍有余量。
+
+如果不满足这些前提,首先应该查连接、网络、上游或压测端,而不是在 on-CPU 火焰图里找答案。
+
+## 2. 横向看宽度,而不是排名
+
+我们使用 eBPF 以 500 Hz 同时采集 C 和 Lua 调用栈。下面是排查中的真实 Lua on-CPU 火焰图。
+
+
+
+*搜索 `run_global_rules` 后命中 3 处,但这些位置在整张图里并不醒目。原始采样共 2,446 个样本;截图已移除进程 PID。*
+
+横向看火焰图,看的是宽度,不是 x 轴顺序。宽度越大,采样落在该路径上的次数越多。初步候选位置按 Lua self time 排序:
+
+| 候选位置 | Lua self time | 初判 |
+|---|---:|---|
+| Prometheus exporter | 25.9% | 最值得先看 |
+| `ctx.var` 元方法 | 15.1% | 第二候选 |
+| 自定义日志旁路 | 3.9% | 很容易被忽略 |
+| `run_global_rules` | 3.0% | 很容易被忽略 |
+
+这里的 self time 指:在带 Lua 上下文的样本中,直接落在这个位置上的比例,不是总 CPU 占比。本次采样约 19.6% 的样本缺失 Lua 上下文,所以这些百分比只用于选候选,不能直接预测吞吐收益。
+
+这个排名有四个盲区:
+
+1. LuaJIT 解释执行时,不同的 Lua 代码可能折叠到共享的 `lj_BC_*`、`lj_vm_*` 符号里。
+2. 已编译的 JIT trace 不一定能被常规栈回溯完整展开,因此可观察到的 JIT 样本只是下界。
+3. 表查找、内存分配、GC 等成本会记在 C 符号上,而不是触发的 Lua 行上。
+4. 一个调用可能改变调用方的 JIT 状态,使成本看起来落在调用方函数的头上。
+
+`run_global_rules` 还有一个特征:它没有形成一根大柱子,而是散在多个请求阶段的调用栈里。单处不宽,不等于合起来不贵。
+
+## 3. 纵向沿栈看,3% 如何被公共路径放大
+
+选中某个 `run_global_rules` 方块后,纵向看调用栈:底部是请求阶段入口,向上经过 `common_phase`、Global Rule 插件筛选、调度,再到具体插件执行。
+
+
+
+*所选栈会被重新铺满画布,因此图中的宽度已经归一化,不能再当作它占总 CPU 的比例。*
+
+纵向高度本身不代表耗时。它的作用是看清成本从哪里进入、被谁调用、为什么反复出现。
+
+修复前,`run_global_rules()` 会在请求的多个阶段调用 `_M.filter()`。`_M.filter()` 遍历所有已加载插件,再逐个判断该插件是否被 Global Rule 配置:
Review Comment:
Same pattern: the heading and the closing line are built as matched couplets (横向/纵向, `单处不宽,不等于合起来不贵`). Reworded, and the Figure 2 caption made plain.
````suggestion
这里的 self time 指的是:在能拿到 Lua 上下文的样本里,直接落在这个位置上的比例,不是它占总 CPU 的比例。这次采样有 19.6% 的样本没有 Lua 上下文,所以这几个数字只够用来挑候选,不能拿去推算优化能换回多少吞吐。
排在前面也不代表就该先动它,这个排名有四个盲区:
1. LuaJIT 解释执行的时候,不同的 Lua 代码可能折叠进同一批 `lj_BC_*`、`lj_vm_*` 符号里。
2. 已编译的 JIT trace 不一定能被常规栈回溯完整展开,所以看得见的 JIT 样本只是个下界。
3. 表查找、内存分配、GC 这些开销记在 C 符号上,而不是触发它们的那行 Lua 上。
4. 一次调用可能改变调用方的 JIT 状态,于是开销看起来落在了调用方函数的头上。
`run_global_rules` 还有个当时没在意的特征:它没有形成一根显眼的柱子,而是散在好几个请求阶段的栈里。每一处都不宽,加起来是多少就不好说了。
## 3. 顺着调用栈往上:3% 是怎么被放大的
点开其中一个 `run_global_rules` 方块,把这条栈单独放大看:底下是请求阶段的入口,往上依次是 `common_phase`、Global Rule 的插件筛选、调度,最后才轮到具体插件。

图 2:选中一条 `run_global_rules` 栈之后的放大视图。所选栈会被重新铺满画布,宽度已经归一化,不能再当成它占总 CPU 的比例。点击图片可查看大图。
栈的高度本身跟耗时没关系。它的用处是看清楚这段开销从哪儿进来、被谁调用、为什么会反复出现。
修复之前,`run_global_rules()` 会在请求的好几个阶段各调一次 `_M.filter()`。而 `_M.filter()` 的做法是遍历所有已加载的插件,挨个判断这个插件有没有被 Global Rule 配置过:
````
##########
blog/zh/blog/2026/08/28/debugging-apisix-throughput-regression.md:
##########
@@ -0,0 +1,234 @@
+---
+title: "火焰图没撒谎,但没直接告诉我们瓶颈:一次 APISIX 吞吐回退定位"
+authors:
+ - name: "Xin Rong"
+ title: "Author"
+ url: "https://github.com/AlinsRan"
+ image_url: "https://github.com/AlinsRan.png"
+ - name: "Yilia Lin"
+ title: "Technical Writer"
+ url: "https://github.com/Yilialinn"
+ image_url: "https://github.com/Yilialinn.png"
+keywords:
+ - Apache APISIX
+ - 火焰图
+ - LuaJIT
+ - 性能优化
+ - 吞吐回退
+description: "火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。"
+tags: [Ecosystem]
+---
+
+火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。
+
+<!--truncate-->
+
+一次吞吐回退中,火焰图最宽的路径并不是最终瓶颈。真正值得追查的两个位置,在 Lua 采样里只有 3.0% 和 3.9%。如果按热点排序,它们根本进不了第一轮优化名单。
+
+这次排查最有价值的不是某个补丁,而是一条证据链:先确认 CPU 确实是瓶颈;再横向看火焰图宽度,建立候选;然后沿调用栈纵向追,看重复调用和公共路径如何放大成本;当火焰图解释不了端到端差距时,继续查 LuaJIT 编译与中断事件;最后用每请求调用次数和配对 A/B 实验定量。
+
+> 数据范围:下文数据来自同一受控环境中的一个 APISIX 内部定制构建,其中加载了 100 余个插件。吞吐统一归一化,只用于说明定位方法与因果链,不代表 Apache APISIX OSS 在其他硬件、配置或负载下的通用表现。
+
+## 1. 先确认 CPU 瓶颈,而不是上来就看火焰图
+
+火焰图显示的是采样期间 CPU 在执行什么。只有目标 worker 的 CPU 接近饱和、吞吐受这个核限制时,火焰图的宽度才有资格解释性能回退。
+
+我们在采样前固定了这些条件:
+
+- APISIX 单 worker 并绑定独占物理核;
+- 上游服务和压测端使用其他核,避免 CPU 争抢;
+- 请求模型、配置、响应内容保持一致;
+- 吞吐回退可稳定复现,错误率和响应结果没有偏移;
+- 目标 worker 持续接近饱和,上游、网络、压测端仍有余量。
+
+如果不满足这些前提,首先应该查连接、网络、上游或压测端,而不是在 on-CPU 火焰图里找答案。
+
+## 2. 横向看宽度,而不是排名
+
+我们使用 eBPF 以 500 Hz 同时采集 C 和 Lua 调用栈。下面是排查中的真实 Lua on-CPU 火焰图。
+
+
+
+*搜索 `run_global_rules` 后命中 3 处,但这些位置在整张图里并不醒目。原始采样共 2,446 个样本;截图已移除进程 PID。*
+
+横向看火焰图,看的是宽度,不是 x 轴顺序。宽度越大,采样落在该路径上的次数越多。初步候选位置按 Lua self time 排序:
+
+| 候选位置 | Lua self time | 初判 |
+|---|---:|---|
+| Prometheus exporter | 25.9% | 最值得先看 |
+| `ctx.var` 元方法 | 15.1% | 第二候选 |
+| 自定义日志旁路 | 3.9% | 很容易被忽略 |
+| `run_global_rules` | 3.0% | 很容易被忽略 |
+
+这里的 self time 指:在带 Lua 上下文的样本中,直接落在这个位置上的比例,不是总 CPU 占比。本次采样约 19.6% 的样本缺失 Lua 上下文,所以这些百分比只用于选候选,不能直接预测吞吐收益。
+
+这个排名有四个盲区:
+
+1. LuaJIT 解释执行时,不同的 Lua 代码可能折叠到共享的 `lj_BC_*`、`lj_vm_*` 符号里。
+2. 已编译的 JIT trace 不一定能被常规栈回溯完整展开,因此可观察到的 JIT 样本只是下界。
+3. 表查找、内存分配、GC 等成本会记在 C 符号上,而不是触发的 Lua 行上。
+4. 一个调用可能改变调用方的 JIT 状态,使成本看起来落在调用方函数的头上。
+
+`run_global_rules` 还有一个特征:它没有形成一根大柱子,而是散在多个请求阶段的调用栈里。单处不宽,不等于合起来不贵。
+
+## 3. 纵向沿栈看,3% 如何被公共路径放大
+
+选中某个 `run_global_rules` 方块后,纵向看调用栈:底部是请求阶段入口,向上经过 `common_phase`、Global Rule 插件筛选、调度,再到具体插件执行。
+
+
+
+*所选栈会被重新铺满画布,因此图中的宽度已经归一化,不能再当作它占总 CPU 的比例。*
+
+纵向高度本身不代表耗时。它的作用是看清成本从哪里进入、被谁调用、为什么反复出现。
+
+修复前,`run_global_rules()` 会在请求的多个阶段调用 `_M.filter()`。`_M.filter()` 遍历所有已加载插件,再逐个判断该插件是否被 Global Rule 配置:
+
+```lua
+-- 简化示意,非 APISIX 完整实现
+for _, plugin_obj in ipairs(local_plugins) do
+ local name = plugin_obj.name
+ local plugin_conf = user_plugin_conf[name]
+ if type(plugin_conf) ~= "table" then
+ goto continue
+ end
+ -- 处理已配置插件
+ ::continue::
+end
+```
+
+两个放大器都在这里:
+
+1. 单次过滤成本随已加载插件数量的增长。
+2. 相同过滤结果跨请求阶段重复生成;`body_filter` 与 `delayed_body_filter` 还可能按响应块多次进入。
+
+本次测试配置中,一个请求实际触发了 **9 次过滤**。也就是说,“从 100+ 插件中找出已配置的少量插件”这件事,被同一个请求重复了 9 次。
+
+这解释了为什么 Lua 层 3.0% 会低估整条路径成本:
+
+| 实际工作 | 火焰图常见归属 |
+|---|---|
+| 循环本身 | `plugin.lua` 对应位置 |
+| 大量表查找 | `lj_BC_TGETS` |
+| 临时表分配 | `lj_alloc_malloc` |
+| 临时对象回收 | `gc_sweep` |
+| 未编译解释器派发 | `lj_vm_*` / `lj_BC_*` |
+
+横向看,它只是 3% 的候选;纵向看,才会发现它位于一条被反复进入的公共路径。优化方向也很直接:同一请求内,如果 Global Rule 和匹配路由没有变化,就复用已筛选的插件集合,而不是每个阶段重新生成。
+
+## 4. 火焰图解释不了差距时,查 LuaJIT 编译事件
+
+第二条路径来自自定义观测组件。它和日志旁路不属于 APISIX OSS,但揭示的问题对 APISIX 插件和 OpenResty 扩展有参考价值:一次看似很轻的调用,既可能产生直接成本,也可能改变调用方之后以解释器还是机器码运行。
+
+### 4.1 解释器派发异常活跃
+
+把 C 层样本按运行时类型重新分类,最值得注意的不是某行 Lua,而是解释器与 JIT 的关系:
Review Comment:
The 横向看…纵向看 couplet again, plus a section heading that spells out the whole thesis. Shortened.
````suggestion
| 未编译的解释器派发 | `lj_vm_*` / `lj_BC_*` |
只看宽度它就是个 3% 的小候选,顺着栈看才发现它蹲在一条被反复进入的公共路径上。改法也就摆在那儿了:同一个请求里,只要 Global Rule 和匹配到的路由没变,就把筛过一次的插件集合留下来复用,别每个阶段重算一遍。
## 4. 火焰图对不上账的时候,去查 LuaJIT
第二条线索来自一个自定义的观测组件。这个组件和它的日志旁路都不属于 APISIX OSS,但它踩的坑对写 APISIX 插件和 OpenResty 扩展的人都有参考价值:一次看上去很轻的调用,除了自己那点开销,还可能改变调用方后面是跑解释器还是跑机器码。
### 4.1 解释器派发活跃得反常
把 C 层的样本按运行时类型重新归类之后,最扎眼的不是哪一行 Lua,而是解释器和 JIT 的比例:
````
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@apisix.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] blog: add APISIX throughput regression investigation (en + zh) [apisix-website]
Posted by "AlinsRan (via GitHub)" <gi...@apache.org>.
AlinsRan commented on code in PR #2115:
URL: https://github.com/apache/apisix-website/pull/2115#discussion_r3879999569
##########
blog/zh/blog/2026/08/28/debugging-apisix-throughput-regression.md:
##########
@@ -0,0 +1,234 @@
+---
+title: "火焰图没撒谎,但没直接告诉我们瓶颈:一次 APISIX 吞吐回退定位"
Review Comment:
The object of 告诉我们 is incomplete — 告诉我们瓶颈 leaves out 在哪. And 吞吐回退定位 turns a verb into a noun that doesn't read naturally in Chinese.
````suggestion
title: "火焰图没撒谎,但没直接指出瓶颈在哪:一次 APISIX 吞吐回退排查"
````
##########
blog/zh/blog/2026/08/28/debugging-apisix-throughput-regression.md:
##########
@@ -0,0 +1,234 @@
+---
+title: "火焰图没撒谎,但没直接告诉我们瓶颈:一次 APISIX 吞吐回退定位"
+authors:
+ - name: "Xin Rong"
+ title: "Author"
+ url: "https://github.com/AlinsRan"
+ image_url: "https://github.com/AlinsRan.png"
+ - name: "Yilia Lin"
+ title: "Technical Writer"
+ url: "https://github.com/Yilialinn"
+ image_url: "https://github.com/Yilialinn.png"
+keywords:
+ - Apache APISIX
+ - 火焰图
+ - LuaJIT
+ - 性能优化
+ - 吞吐回退
+description: "火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。"
+tags: [Ecosystem]
+---
+
+火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。
+
+<!--truncate-->
+
+一次吞吐回退中,火焰图最宽的路径并不是最终瓶颈。真正值得追查的两个位置,在 Lua 采样里只有 3.0% 和 3.9%。如果按热点排序,它们根本进不了第一轮优化名单。
+
+这次排查最有价值的不是某个补丁,而是一条证据链:先确认 CPU 确实是瓶颈;再横向看火焰图宽度,建立候选;然后沿调用栈纵向追,看重复调用和公共路径如何放大成本;当火焰图解释不了端到端差距时,继续查 LuaJIT 编译与中断事件;最后用每请求调用次数和配对 A/B 实验定量。
Review Comment:
Five steps separated by four semicolons in one sentence — the reader has to hold all of them before reaching the body. Split after the second step. `建立候选` is also not a natural collocation; 圈定 is.
````suggestion
这次排查最有价值的不是某个补丁,而是一条证据链:先确认 CPU 确实是瓶颈,再横向看火焰图宽度,圈定候选位置。然后沿调用栈纵向追,看重复调用和公共路径如何放大成本;当火焰图解释不了端到端差距时,继续查 LuaJIT 编译与中断事件;最后用每请求调用次数和配对 A/B 实验定量。
````
##########
blog/zh/blog/2026/08/28/debugging-apisix-throughput-regression.md:
##########
@@ -0,0 +1,234 @@
+---
+title: "火焰图没撒谎,但没直接告诉我们瓶颈:一次 APISIX 吞吐回退定位"
+authors:
+ - name: "Xin Rong"
+ title: "Author"
+ url: "https://github.com/AlinsRan"
+ image_url: "https://github.com/AlinsRan.png"
+ - name: "Yilia Lin"
+ title: "Technical Writer"
+ url: "https://github.com/Yilialinn"
+ image_url: "https://github.com/Yilialinn.png"
+keywords:
+ - Apache APISIX
+ - 火焰图
+ - LuaJIT
+ - 性能优化
+ - 吞吐回退
+description: "火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。"
+tags: [Ecosystem]
+---
+
+火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。
+
+<!--truncate-->
+
+一次吞吐回退中,火焰图最宽的路径并不是最终瓶颈。真正值得追查的两个位置,在 Lua 采样里只有 3.0% 和 3.9%。如果按热点排序,它们根本进不了第一轮优化名单。
+
+这次排查最有价值的不是某个补丁,而是一条证据链:先确认 CPU 确实是瓶颈;再横向看火焰图宽度,建立候选;然后沿调用栈纵向追,看重复调用和公共路径如何放大成本;当火焰图解释不了端到端差距时,继续查 LuaJIT 编译与中断事件;最后用每请求调用次数和配对 A/B 实验定量。
+
+> 数据范围:下文数据来自同一受控环境中的一个 APISIX 内部定制构建,其中加载了 100 余个插件。吞吐统一归一化,只用于说明定位方法与因果链,不代表 Apache APISIX OSS 在其他硬件、配置或负载下的通用表现。
+
+## 1. 先确认 CPU 瓶颈,而不是上来就看火焰图
+
+火焰图显示的是采样期间 CPU 在执行什么。只有目标 worker 的 CPU 接近饱和、吞吐受这个核限制时,火焰图的宽度才有资格解释性能回退。
+
+我们在采样前固定了这些条件:
+
+- APISIX 单 worker 并绑定独占物理核;
+- 上游服务和压测端使用其他核,避免 CPU 争抢;
+- 请求模型、配置、响应内容保持一致;
+- 吞吐回退可稳定复现,错误率和响应结果没有偏移;
+- 目标 worker 持续接近饱和,上游、网络、压测端仍有余量。
+
+如果不满足这些前提,首先应该查连接、网络、上游或压测端,而不是在 on-CPU 火焰图里找答案。
+
+## 2. 横向看宽度,而不是排名
+
+我们使用 eBPF 以 500 Hz 同时采集 C 和 Lua 调用栈。下面是排查中的真实 Lua on-CPU 火焰图。
+
+
+
+*搜索 `run_global_rules` 后命中 3 处,但这些位置在整张图里并不醒目。原始采样共 2,446 个样本;截图已移除进程 PID。*
+
+横向看火焰图,看的是宽度,不是 x 轴顺序。宽度越大,采样落在该路径上的次数越多。初步候选位置按 Lua self time 排序:
+
+| 候选位置 | Lua self time | 初判 |
+|---|---:|---|
+| Prometheus exporter | 25.9% | 最值得先看 |
+| `ctx.var` 元方法 | 15.1% | 第二候选 |
+| 自定义日志旁路 | 3.9% | 很容易被忽略 |
+| `run_global_rules` | 3.0% | 很容易被忽略 |
+
+这里的 self time 指:在带 Lua 上下文的样本中,直接落在这个位置上的比例,不是总 CPU 占比。本次采样约 19.6% 的样本缺失 Lua 上下文,所以这些百分比只用于选候选,不能直接预测吞吐收益。
+
+这个排名有四个盲区:
+
+1. LuaJIT 解释执行时,不同的 Lua 代码可能折叠到共享的 `lj_BC_*`、`lj_vm_*` 符号里。
+2. 已编译的 JIT trace 不一定能被常规栈回溯完整展开,因此可观察到的 JIT 样本只是下界。
+3. 表查找、内存分配、GC 等成本会记在 C 符号上,而不是触发的 Lua 行上。
+4. 一个调用可能改变调用方的 JIT 状态,使成本看起来落在调用方函数的头上。
+
+`run_global_rules` 还有一个特征:它没有形成一根大柱子,而是散在多个请求阶段的调用栈里。单处不宽,不等于合起来不贵。
+
+## 3. 纵向沿栈看,3% 如何被公共路径放大
+
+选中某个 `run_global_rules` 方块后,纵向看调用栈:底部是请求阶段入口,向上经过 `common_phase`、Global Rule 插件筛选、调度,再到具体插件执行。
+
+
+
+*所选栈会被重新铺满画布,因此图中的宽度已经归一化,不能再当作它占总 CPU 的比例。*
+
+纵向高度本身不代表耗时。它的作用是看清成本从哪里进入、被谁调用、为什么反复出现。
+
+修复前,`run_global_rules()` 会在请求的多个阶段调用 `_M.filter()`。`_M.filter()` 遍历所有已加载插件,再逐个判断该插件是否被 Global Rule 配置:
+
+```lua
+-- 简化示意,非 APISIX 完整实现
+for _, plugin_obj in ipairs(local_plugins) do
+ local name = plugin_obj.name
+ local plugin_conf = user_plugin_conf[name]
+ if type(plugin_conf) ~= "table" then
+ goto continue
+ end
+ -- 处理已配置插件
+ ::continue::
+end
+```
+
+两个放大器都在这里:
+
+1. 单次过滤成本随已加载插件数量的增长。
+2. 相同过滤结果跨请求阶段重复生成;`body_filter` 与 `delayed_body_filter` 还可能按响应块多次进入。
+
+本次测试配置中,一个请求实际触发了 **9 次过滤**。也就是说,“从 100+ 插件中找出已配置的少量插件”这件事,被同一个请求重复了 9 次。
+
+这解释了为什么 Lua 层 3.0% 会低估整条路径成本:
+
+| 实际工作 | 火焰图常见归属 |
+|---|---|
+| 循环本身 | `plugin.lua` 对应位置 |
+| 大量表查找 | `lj_BC_TGETS` |
+| 临时表分配 | `lj_alloc_malloc` |
+| 临时对象回收 | `gc_sweep` |
+| 未编译解释器派发 | `lj_vm_*` / `lj_BC_*` |
+
+横向看,它只是 3% 的候选;纵向看,才会发现它位于一条被反复进入的公共路径。优化方向也很直接:同一请求内,如果 Global Rule 和匹配路由没有变化,就复用已筛选的插件集合,而不是每个阶段重新生成。
+
+## 4. 火焰图解释不了差距时,查 LuaJIT 编译事件
+
+第二条路径来自自定义观测组件。它和日志旁路不属于 APISIX OSS,但揭示的问题对 APISIX 插件和 OpenResty 扩展有参考价值:一次看似很轻的调用,既可能产生直接成本,也可能改变调用方之后以解释器还是机器码运行。
+
+### 4.1 解释器派发异常活跃
+
+把 C 层样本按运行时类型重新分类,最值得注意的不是某行 Lua,而是解释器与 JIT 的关系:
+
+| 运行类别 | 每请求 CPU 时间 | 占本次样本 |
+|---|---:|---:|
+| 解释器派发:`lj_BC_*` / `lj_vm_*` | 5.86 μs | 29.6% |
+| 可观察到的 JIT trace 执行 | 2.55 μs | 12.9% |
+
+JIT trace 难以完整 unwind,12.9% 只能看作下界。但在同一环境中,解释器派发如此活跃,已经构成强信号:某些高频路径可能没有稳定地跑在机器码里。
+
+这不等于 trace 越多越好。初始化代码不需要追求编译,不同 trace 的成本差异也很大。只有在 CPU 饱和、路径高频、解释器派发异常时,编译结果才值得深挖。
+
+### 4.2 `jit.v` 发出警报,也制造了第一次误判
+
+开启 `jit.v` 后,一次短时压测输出中看到 417 次 trace 编译成功、493 次 abort。按位置聚合后,一批路径的 abort 次数整齐停在 11。
+
+但这是编译事件数,不是唯一函数数、覆盖率或 CPU 时间。`jit.v` 有几个边界:
+
+- abort 行显示的是 trace 中断位置,而惩罚记在 trace 起点,两者可能不是同一行。
+- 某函数从未成为 trace 起点,不代表它没有被编译,函数体可能已被内联进父 trace。
+- 文本日志很难对功能开启/关闭两组结果做稳定的集合差。
+
+我们一开始也把“起点出现次数为 0”误读成“整个函数都在解释执行”。后来用 `jit.dump` 字节码模式复核,才发现多个函数入口没有成为 root trace,但函数体已多次进入其他 trace。真正反复失败的是阶段入口和编排函数。
+
+> 看不到 trace 起点,只能证明这里没有成为 root trace 锚点;不能证明整个函数没有进入机器码。
+
+### 4.3 把 `start`、`stop`、`abort` 归回同一起点
+
+要回答“哪段代码没有编译、为什么”,需要让 LuaJIT 自己交事件流。核心做法是按 trace id 保存 `start` 的位置,再把后续 `stop` 或 `abort` 归回同一个起点:
+
+```lua
+-- 简化示意,实际回调参数和解析逻辑更复杂
+local trace_start = {}
+
+jit.attach(function(what, trace_id, func, pc, err_code)
+ if what == "start" then
+ trace_start[trace_id] = locate(func, pc)
+ elseif what == "stop" then
+ record_compiled(trace_start[trace_id])
+ trace_start[trace_id] = nil
+ elseif what == "abort" then
+ record_abort(trace_start[trace_id], err_code)
+ trace_start[trace_id] = nil
+ end
+end, "trace")
+```
+
+实际探针还要用 `jit.util.funcinfo` 解析源位置,用 `jit.vmdef.traceerr` 还原 abort 原因。这样就能比较开启/关闭自定义组件时:哪些起点进入 compiled 集合,哪些反复 abort,以及是否发生 trace flush。
+
+在本次 LuaJIT 构建中,失败惩罚从 72 开始逐次翻倍:第 10 次是 36,864,第 11 次达到 73,728,超过 60,000 上限。多个起点恰好 abort ×11 后不再增长;它们没有进入 compiled 集合,采集期间也没有 trace flush。两组存活 trace 数均低于缓存上限,因此排除了 trace 缓存被挤爆的可能。这些证据共同说明:对应的 trace 起点已被放弃继续编译。
+
+仍然不能扩大结论:被放弃的是 trace 起点,不一定是整个函数体;其他部分仍可能被内联进别的 trace。
+
+按调用来源聚合后,受影响的阶段入口和编排函数都经过同一条自定义日志旁路:日志级别不足时虽不输出,但仍会检查请求阶段、调用栈和请求上下文。新增调用本身没有形成大柱子,却改变了调用方的 JIT 结果,使部分成本看起来落在正常请求函数头上。
+
+```lua
+-- 自定义扩展的简化示意,不是 APISIX OSS 实现
+if log_level_is_suppressed then
+ check_debug_capture(...)
+ check_request_buffer(...)
+ return
+end
+```
+
+JIT 数据在这里承担两个职责:发现火焰图无法正确归属的成本,验证功能开关确实改变了高频路径的执行状态。但它不能直接告诉我们吞吐损失多大;真正的方向也不是“强迫所有函数编译”,而是让关闭状态下本来不该发生的工作根本不执行。
+
+还有一个容易让结论失真的陷阱:探针必须装在被观测模块加载之前。如果模块在 `require` 时保存了函数引用,之后替换原函数,计数器只能看到少量没被快照走的调用。本次探针注入晚时测到 1 次/请求,前移到 `require("apisix")` 之前后,才确认真实值是 5 次/请求。
+
+## 5. 配对 A/B 收口:实验回答“值多少钱”
Review Comment:
`收口` is jargon and is never explained in the article; putting it in a section heading makes it worse. Spelling out what the section does is clearer, and it keeps the 值多少钱 idea.
````suggestion
## 5. 用配对 A/B 定量:这条路径到底值多少
````
##########
blog/zh/blog/2026/08/28/debugging-apisix-throughput-regression.md:
##########
@@ -0,0 +1,234 @@
+---
+title: "火焰图没撒谎,但没直接告诉我们瓶颈:一次 APISIX 吞吐回退定位"
+authors:
+ - name: "Xin Rong"
+ title: "Author"
+ url: "https://github.com/AlinsRan"
+ image_url: "https://github.com/AlinsRan.png"
+ - name: "Yilia Lin"
+ title: "Technical Writer"
+ url: "https://github.com/Yilialinn"
+ image_url: "https://github.com/Yilialinn.png"
+keywords:
+ - Apache APISIX
+ - 火焰图
+ - LuaJIT
+ - 性能优化
+ - 吞吐回退
+description: "火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。"
+tags: [Ecosystem]
+---
+
+火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。
+
+<!--truncate-->
+
+一次吞吐回退中,火焰图最宽的路径并不是最终瓶颈。真正值得追查的两个位置,在 Lua 采样里只有 3.0% 和 3.9%。如果按热点排序,它们根本进不了第一轮优化名单。
+
+这次排查最有价值的不是某个补丁,而是一条证据链:先确认 CPU 确实是瓶颈;再横向看火焰图宽度,建立候选;然后沿调用栈纵向追,看重复调用和公共路径如何放大成本;当火焰图解释不了端到端差距时,继续查 LuaJIT 编译与中断事件;最后用每请求调用次数和配对 A/B 实验定量。
+
+> 数据范围:下文数据来自同一受控环境中的一个 APISIX 内部定制构建,其中加载了 100 余个插件。吞吐统一归一化,只用于说明定位方法与因果链,不代表 Apache APISIX OSS 在其他硬件、配置或负载下的通用表现。
+
+## 1. 先确认 CPU 瓶颈,而不是上来就看火焰图
+
+火焰图显示的是采样期间 CPU 在执行什么。只有目标 worker 的 CPU 接近饱和、吞吐受这个核限制时,火焰图的宽度才有资格解释性能回退。
+
+我们在采样前固定了这些条件:
+
+- APISIX 单 worker 并绑定独占物理核;
+- 上游服务和压测端使用其他核,避免 CPU 争抢;
+- 请求模型、配置、响应内容保持一致;
+- 吞吐回退可稳定复现,错误率和响应结果没有偏移;
+- 目标 worker 持续接近饱和,上游、网络、压测端仍有余量。
+
+如果不满足这些前提,首先应该查连接、网络、上游或压测端,而不是在 on-CPU 火焰图里找答案。
+
+## 2. 横向看宽度,而不是排名
+
+我们使用 eBPF 以 500 Hz 同时采集 C 和 Lua 调用栈。下面是排查中的真实 Lua on-CPU 火焰图。
+
+
+
+*搜索 `run_global_rules` 后命中 3 处,但这些位置在整张图里并不醒目。原始采样共 2,446 个样本;截图已移除进程 PID。*
+
+横向看火焰图,看的是宽度,不是 x 轴顺序。宽度越大,采样落在该路径上的次数越多。初步候选位置按 Lua self time 排序:
+
+| 候选位置 | Lua self time | 初判 |
+|---|---:|---|
+| Prometheus exporter | 25.9% | 最值得先看 |
+| `ctx.var` 元方法 | 15.1% | 第二候选 |
+| 自定义日志旁路 | 3.9% | 很容易被忽略 |
+| `run_global_rules` | 3.0% | 很容易被忽略 |
+
+这里的 self time 指:在带 Lua 上下文的样本中,直接落在这个位置上的比例,不是总 CPU 占比。本次采样约 19.6% 的样本缺失 Lua 上下文,所以这些百分比只用于选候选,不能直接预测吞吐收益。
+
+这个排名有四个盲区:
+
+1. LuaJIT 解释执行时,不同的 Lua 代码可能折叠到共享的 `lj_BC_*`、`lj_vm_*` 符号里。
+2. 已编译的 JIT trace 不一定能被常规栈回溯完整展开,因此可观察到的 JIT 样本只是下界。
+3. 表查找、内存分配、GC 等成本会记在 C 符号上,而不是触发的 Lua 行上。
+4. 一个调用可能改变调用方的 JIT 状态,使成本看起来落在调用方函数的头上。
+
+`run_global_rules` 还有一个特征:它没有形成一根大柱子,而是散在多个请求阶段的调用栈里。单处不宽,不等于合起来不贵。
+
+## 3. 纵向沿栈看,3% 如何被公共路径放大
+
+选中某个 `run_global_rules` 方块后,纵向看调用栈:底部是请求阶段入口,向上经过 `common_phase`、Global Rule 插件筛选、调度,再到具体插件执行。
+
+
+
+*所选栈会被重新铺满画布,因此图中的宽度已经归一化,不能再当作它占总 CPU 的比例。*
+
+纵向高度本身不代表耗时。它的作用是看清成本从哪里进入、被谁调用、为什么反复出现。
+
+修复前,`run_global_rules()` 会在请求的多个阶段调用 `_M.filter()`。`_M.filter()` 遍历所有已加载插件,再逐个判断该插件是否被 Global Rule 配置:
+
+```lua
+-- 简化示意,非 APISIX 完整实现
+for _, plugin_obj in ipairs(local_plugins) do
+ local name = plugin_obj.name
+ local plugin_conf = user_plugin_conf[name]
+ if type(plugin_conf) ~= "table" then
+ goto continue
+ end
+ -- 处理已配置插件
+ ::continue::
+end
+```
+
+两个放大器都在这里:
+
+1. 单次过滤成本随已加载插件数量的增长。
+2. 相同过滤结果跨请求阶段重复生成;`body_filter` 与 `delayed_body_filter` 还可能按响应块多次进入。
+
+本次测试配置中,一个请求实际触发了 **9 次过滤**。也就是说,“从 100+ 插件中找出已配置的少量插件”这件事,被同一个请求重复了 9 次。
+
+这解释了为什么 Lua 层 3.0% 会低估整条路径成本:
+
+| 实际工作 | 火焰图常见归属 |
+|---|---|
+| 循环本身 | `plugin.lua` 对应位置 |
+| 大量表查找 | `lj_BC_TGETS` |
+| 临时表分配 | `lj_alloc_malloc` |
+| 临时对象回收 | `gc_sweep` |
+| 未编译解释器派发 | `lj_vm_*` / `lj_BC_*` |
+
+横向看,它只是 3% 的候选;纵向看,才会发现它位于一条被反复进入的公共路径。优化方向也很直接:同一请求内,如果 Global Rule 和匹配路由没有变化,就复用已筛选的插件集合,而不是每个阶段重新生成。
+
+## 4. 火焰图解释不了差距时,查 LuaJIT 编译事件
+
+第二条路径来自自定义观测组件。它和日志旁路不属于 APISIX OSS,但揭示的问题对 APISIX 插件和 OpenResty 扩展有参考价值:一次看似很轻的调用,既可能产生直接成本,也可能改变调用方之后以解释器还是机器码运行。
+
+### 4.1 解释器派发异常活跃
+
+把 C 层样本按运行时类型重新分类,最值得注意的不是某行 Lua,而是解释器与 JIT 的关系:
+
+| 运行类别 | 每请求 CPU 时间 | 占本次样本 |
+|---|---:|---:|
+| 解释器派发:`lj_BC_*` / `lj_vm_*` | 5.86 μs | 29.6% |
+| 可观察到的 JIT trace 执行 | 2.55 μs | 12.9% |
+
+JIT trace 难以完整 unwind,12.9% 只能看作下界。但在同一环境中,解释器派发如此活跃,已经构成强信号:某些高频路径可能没有稳定地跑在机器码里。
+
+这不等于 trace 越多越好。初始化代码不需要追求编译,不同 trace 的成本差异也很大。只有在 CPU 饱和、路径高频、解释器派发异常时,编译结果才值得深挖。
+
+### 4.2 `jit.v` 发出警报,也制造了第一次误判
+
+开启 `jit.v` 后,一次短时压测输出中看到 417 次 trace 编译成功、493 次 abort。按位置聚合后,一批路径的 abort 次数整齐停在 11。
+
+但这是编译事件数,不是唯一函数数、覆盖率或 CPU 时间。`jit.v` 有几个边界:
+
+- abort 行显示的是 trace 中断位置,而惩罚记在 trace 起点,两者可能不是同一行。
+- 某函数从未成为 trace 起点,不代表它没有被编译,函数体可能已被内联进父 trace。
+- 文本日志很难对功能开启/关闭两组结果做稳定的集合差。
+
+我们一开始也把“起点出现次数为 0”误读成“整个函数都在解释执行”。后来用 `jit.dump` 字节码模式复核,才发现多个函数入口没有成为 root trace,但函数体已多次进入其他 trace。真正反复失败的是阶段入口和编排函数。
+
+> 看不到 trace 起点,只能证明这里没有成为 root trace 锚点;不能证明整个函数没有进入机器码。
+
+### 4.3 把 `start`、`stop`、`abort` 归回同一起点
+
+要回答“哪段代码没有编译、为什么”,需要让 LuaJIT 自己交事件流。核心做法是按 trace id 保存 `start` 的位置,再把后续 `stop` 或 `abort` 归回同一个起点:
+
+```lua
+-- 简化示意,实际回调参数和解析逻辑更复杂
+local trace_start = {}
+
+jit.attach(function(what, trace_id, func, pc, err_code)
+ if what == "start" then
+ trace_start[trace_id] = locate(func, pc)
+ elseif what == "stop" then
+ record_compiled(trace_start[trace_id])
+ trace_start[trace_id] = nil
+ elseif what == "abort" then
+ record_abort(trace_start[trace_id], err_code)
+ trace_start[trace_id] = nil
+ end
+end, "trace")
+```
+
+实际探针还要用 `jit.util.funcinfo` 解析源位置,用 `jit.vmdef.traceerr` 还原 abort 原因。这样就能比较开启/关闭自定义组件时:哪些起点进入 compiled 集合,哪些反复 abort,以及是否发生 trace flush。
+
+在本次 LuaJIT 构建中,失败惩罚从 72 开始逐次翻倍:第 10 次是 36,864,第 11 次达到 73,728,超过 60,000 上限。多个起点恰好 abort ×11 后不再增长;它们没有进入 compiled 集合,采集期间也没有 trace flush。两组存活 trace 数均低于缓存上限,因此排除了 trace 缓存被挤爆的可能。这些证据共同说明:对应的 trace 起点已被放弃继续编译。
Review Comment:
Two things make this hard to read. `多个起点恰好 abort ×11 后不再增长` shifts subject mid-clause — what stops growing is the abort count, not the starting points, so it first reads as "the number of starting points stopped increasing". And `对应的 trace 起点已被放弃继续编译` is a passive wrapped around a pivot construction with no actor; LuaJIT is the one that gave up.
````suggestion
在本次 LuaJIT 构建中,失败惩罚从 72 开始逐次翻倍:第 10 次是 36,864,第 11 次达到 73,728,超过 60,000 上限。多个起点的 abort 次数恰好停在 11 次,之后不再增加;它们没有进入 compiled 集合,采集期间也没有 trace flush。两组存活 trace 数均低于缓存上限,因此排除了 trace 缓存被挤爆的可能。这些证据共同说明:LuaJIT 已经放弃编译这些 trace 起点。
````
##########
blog/zh/blog/2026/08/28/debugging-apisix-throughput-regression.md:
##########
@@ -0,0 +1,234 @@
+---
+title: "火焰图没撒谎,但没直接告诉我们瓶颈:一次 APISIX 吞吐回退定位"
+authors:
+ - name: "Xin Rong"
+ title: "Author"
+ url: "https://github.com/AlinsRan"
+ image_url: "https://github.com/AlinsRan.png"
+ - name: "Yilia Lin"
+ title: "Technical Writer"
+ url: "https://github.com/Yilialinn"
+ image_url: "https://github.com/Yilialinn.png"
+keywords:
+ - Apache APISIX
+ - 火焰图
+ - LuaJIT
+ - 性能优化
+ - 吞吐回退
+description: "火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。"
+tags: [Ecosystem]
+---
+
+火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。
+
+<!--truncate-->
+
+一次吞吐回退中,火焰图最宽的路径并不是最终瓶颈。真正值得追查的两个位置,在 Lua 采样里只有 3.0% 和 3.9%。如果按热点排序,它们根本进不了第一轮优化名单。
+
+这次排查最有价值的不是某个补丁,而是一条证据链:先确认 CPU 确实是瓶颈;再横向看火焰图宽度,建立候选;然后沿调用栈纵向追,看重复调用和公共路径如何放大成本;当火焰图解释不了端到端差距时,继续查 LuaJIT 编译与中断事件;最后用每请求调用次数和配对 A/B 实验定量。
+
+> 数据范围:下文数据来自同一受控环境中的一个 APISIX 内部定制构建,其中加载了 100 余个插件。吞吐统一归一化,只用于说明定位方法与因果链,不代表 Apache APISIX OSS 在其他硬件、配置或负载下的通用表现。
+
+## 1. 先确认 CPU 瓶颈,而不是上来就看火焰图
+
+火焰图显示的是采样期间 CPU 在执行什么。只有目标 worker 的 CPU 接近饱和、吞吐受这个核限制时,火焰图的宽度才有资格解释性能回退。
+
+我们在采样前固定了这些条件:
+
+- APISIX 单 worker 并绑定独占物理核;
+- 上游服务和压测端使用其他核,避免 CPU 争抢;
+- 请求模型、配置、响应内容保持一致;
+- 吞吐回退可稳定复现,错误率和响应结果没有偏移;
+- 目标 worker 持续接近饱和,上游、网络、压测端仍有余量。
+
+如果不满足这些前提,首先应该查连接、网络、上游或压测端,而不是在 on-CPU 火焰图里找答案。
+
+## 2. 横向看宽度,而不是排名
+
+我们使用 eBPF 以 500 Hz 同时采集 C 和 Lua 调用栈。下面是排查中的真实 Lua on-CPU 火焰图。
+
+
+
+*搜索 `run_global_rules` 后命中 3 处,但这些位置在整张图里并不醒目。原始采样共 2,446 个样本;截图已移除进程 PID。*
+
+横向看火焰图,看的是宽度,不是 x 轴顺序。宽度越大,采样落在该路径上的次数越多。初步候选位置按 Lua self time 排序:
+
+| 候选位置 | Lua self time | 初判 |
+|---|---:|---|
+| Prometheus exporter | 25.9% | 最值得先看 |
+| `ctx.var` 元方法 | 15.1% | 第二候选 |
+| 自定义日志旁路 | 3.9% | 很容易被忽略 |
+| `run_global_rules` | 3.0% | 很容易被忽略 |
+
+这里的 self time 指:在带 Lua 上下文的样本中,直接落在这个位置上的比例,不是总 CPU 占比。本次采样约 19.6% 的样本缺失 Lua 上下文,所以这些百分比只用于选候选,不能直接预测吞吐收益。
+
+这个排名有四个盲区:
+
+1. LuaJIT 解释执行时,不同的 Lua 代码可能折叠到共享的 `lj_BC_*`、`lj_vm_*` 符号里。
+2. 已编译的 JIT trace 不一定能被常规栈回溯完整展开,因此可观察到的 JIT 样本只是下界。
+3. 表查找、内存分配、GC 等成本会记在 C 符号上,而不是触发的 Lua 行上。
+4. 一个调用可能改变调用方的 JIT 状态,使成本看起来落在调用方函数的头上。
+
+`run_global_rules` 还有一个特征:它没有形成一根大柱子,而是散在多个请求阶段的调用栈里。单处不宽,不等于合起来不贵。
+
+## 3. 纵向沿栈看,3% 如何被公共路径放大
+
+选中某个 `run_global_rules` 方块后,纵向看调用栈:底部是请求阶段入口,向上经过 `common_phase`、Global Rule 插件筛选、调度,再到具体插件执行。
+
+
+
+*所选栈会被重新铺满画布,因此图中的宽度已经归一化,不能再当作它占总 CPU 的比例。*
+
+纵向高度本身不代表耗时。它的作用是看清成本从哪里进入、被谁调用、为什么反复出现。
+
+修复前,`run_global_rules()` 会在请求的多个阶段调用 `_M.filter()`。`_M.filter()` 遍历所有已加载插件,再逐个判断该插件是否被 Global Rule 配置:
+
+```lua
+-- 简化示意,非 APISIX 完整实现
+for _, plugin_obj in ipairs(local_plugins) do
+ local name = plugin_obj.name
+ local plugin_conf = user_plugin_conf[name]
+ if type(plugin_conf) ~= "table" then
+ goto continue
+ end
+ -- 处理已配置插件
+ ::continue::
+end
+```
+
+两个放大器都在这里:
+
+1. 单次过滤成本随已加载插件数量的增长。
+2. 相同过滤结果跨请求阶段重复生成;`body_filter` 与 `delayed_body_filter` 还可能按响应块多次进入。
+
+本次测试配置中,一个请求实际触发了 **9 次过滤**。也就是说,“从 100+ 插件中找出已配置的少量插件”这件事,被同一个请求重复了 9 次。
+
+这解释了为什么 Lua 层 3.0% 会低估整条路径成本:
+
+| 实际工作 | 火焰图常见归属 |
+|---|---|
+| 循环本身 | `plugin.lua` 对应位置 |
+| 大量表查找 | `lj_BC_TGETS` |
+| 临时表分配 | `lj_alloc_malloc` |
+| 临时对象回收 | `gc_sweep` |
+| 未编译解释器派发 | `lj_vm_*` / `lj_BC_*` |
+
+横向看,它只是 3% 的候选;纵向看,才会发现它位于一条被反复进入的公共路径。优化方向也很直接:同一请求内,如果 Global Rule 和匹配路由没有变化,就复用已筛选的插件集合,而不是每个阶段重新生成。
+
+## 4. 火焰图解释不了差距时,查 LuaJIT 编译事件
+
+第二条路径来自自定义观测组件。它和日志旁路不属于 APISIX OSS,但揭示的问题对 APISIX 插件和 OpenResty 扩展有参考价值:一次看似很轻的调用,既可能产生直接成本,也可能改变调用方之后以解释器还是机器码运行。
+
+### 4.1 解释器派发异常活跃
+
+把 C 层样本按运行时类型重新分类,最值得注意的不是某行 Lua,而是解释器与 JIT 的关系:
+
+| 运行类别 | 每请求 CPU 时间 | 占本次样本 |
+|---|---:|---:|
+| 解释器派发:`lj_BC_*` / `lj_vm_*` | 5.86 μs | 29.6% |
+| 可观察到的 JIT trace 执行 | 2.55 μs | 12.9% |
+
+JIT trace 难以完整 unwind,12.9% 只能看作下界。但在同一环境中,解释器派发如此活跃,已经构成强信号:某些高频路径可能没有稳定地跑在机器码里。
+
+这不等于 trace 越多越好。初始化代码不需要追求编译,不同 trace 的成本差异也很大。只有在 CPU 饱和、路径高频、解释器派发异常时,编译结果才值得深挖。
+
+### 4.2 `jit.v` 发出警报,也制造了第一次误判
+
+开启 `jit.v` 后,一次短时压测输出中看到 417 次 trace 编译成功、493 次 abort。按位置聚合后,一批路径的 abort 次数整齐停在 11。
+
+但这是编译事件数,不是唯一函数数、覆盖率或 CPU 时间。`jit.v` 有几个边界:
+
+- abort 行显示的是 trace 中断位置,而惩罚记在 trace 起点,两者可能不是同一行。
+- 某函数从未成为 trace 起点,不代表它没有被编译,函数体可能已被内联进父 trace。
+- 文本日志很难对功能开启/关闭两组结果做稳定的集合差。
+
+我们一开始也把“起点出现次数为 0”误读成“整个函数都在解释执行”。后来用 `jit.dump` 字节码模式复核,才发现多个函数入口没有成为 root trace,但函数体已多次进入其他 trace。真正反复失败的是阶段入口和编排函数。
+
+> 看不到 trace 起点,只能证明这里没有成为 root trace 锚点;不能证明整个函数没有进入机器码。
+
+### 4.3 把 `start`、`stop`、`abort` 归回同一起点
+
+要回答“哪段代码没有编译、为什么”,需要让 LuaJIT 自己交事件流。核心做法是按 trace id 保存 `start` 的位置,再把后续 `stop` 或 `abort` 归回同一个起点:
+
+```lua
+-- 简化示意,实际回调参数和解析逻辑更复杂
+local trace_start = {}
+
+jit.attach(function(what, trace_id, func, pc, err_code)
+ if what == "start" then
+ trace_start[trace_id] = locate(func, pc)
+ elseif what == "stop" then
+ record_compiled(trace_start[trace_id])
+ trace_start[trace_id] = nil
+ elseif what == "abort" then
+ record_abort(trace_start[trace_id], err_code)
+ trace_start[trace_id] = nil
+ end
+end, "trace")
+```
+
+实际探针还要用 `jit.util.funcinfo` 解析源位置,用 `jit.vmdef.traceerr` 还原 abort 原因。这样就能比较开启/关闭自定义组件时:哪些起点进入 compiled 集合,哪些反复 abort,以及是否发生 trace flush。
+
+在本次 LuaJIT 构建中,失败惩罚从 72 开始逐次翻倍:第 10 次是 36,864,第 11 次达到 73,728,超过 60,000 上限。多个起点恰好 abort ×11 后不再增长;它们没有进入 compiled 集合,采集期间也没有 trace flush。两组存活 trace 数均低于缓存上限,因此排除了 trace 缓存被挤爆的可能。这些证据共同说明:对应的 trace 起点已被放弃继续编译。
+
+仍然不能扩大结论:被放弃的是 trace 起点,不一定是整个函数体;其他部分仍可能被内联进别的 trace。
+
+按调用来源聚合后,受影响的阶段入口和编排函数都经过同一条自定义日志旁路:日志级别不足时虽不输出,但仍会检查请求阶段、调用栈和请求上下文。新增调用本身没有形成大柱子,却改变了调用方的 JIT 结果,使部分成本看起来落在正常请求函数头上。
+
+```lua
+-- 自定义扩展的简化示意,不是 APISIX OSS 实现
+if log_level_is_suppressed then
+ check_debug_capture(...)
+ check_request_buffer(...)
+ return
+end
+```
+
+JIT 数据在这里承担两个职责:发现火焰图无法正确归属的成本,验证功能开关确实改变了高频路径的执行状态。但它不能直接告诉我们吞吐损失多大;真正的方向也不是“强迫所有函数编译”,而是让关闭状态下本来不该发生的工作根本不执行。
+
+还有一个容易让结论失真的陷阱:探针必须装在被观测模块加载之前。如果模块在 `require` 时保存了函数引用,之后替换原函数,计数器只能看到少量没被快照走的调用。本次探针注入晚时测到 1 次/请求,前移到 `require("apisix")` 之前后,才确认真实值是 5 次/请求。
+
+## 5. 配对 A/B 收口:实验回答“值多少钱”
+
+火焰图给位置,调用栈给放大链,LuaJIT 事件解释错位成本。最终还要靠配对实验定量。
+
+以 Global Rule 路径为例,我们保留相同的调度和配置,只让 Prometheus 业务函数进入后立即返回,用来区分“插件业务逻辑慢”和“进入插件前的公共路径慢”。为避免把定制环境绝对 RPS 当成 APISIX OSS benchmark,关闭 Prometheus 的吞吐被归一化为 100:
Review Comment:
`关闭 Prometheus 的吞吐被归一化为 100` is genuinely ambiguous — it parses as 关闭[Prometheus 的吞吐]. The passive also has no actor. (Also 定制环境绝对 RPS is missing 的.)
````suggestion
以 Global Rule 路径为例,我们保留相同的调度和配置,只让 Prometheus 业务函数进入后立即返回,用来区分“插件业务逻辑慢”和“进入插件前的公共路径慢”。为避免把定制环境的绝对 RPS 当成 APISIX OSS benchmark,我们把关闭 Prometheus 时的吞吐归一化为 100:
````
##########
blog/zh/blog/2026/08/28/debugging-apisix-throughput-regression.md:
##########
@@ -0,0 +1,234 @@
+---
+title: "火焰图没撒谎,但没直接告诉我们瓶颈:一次 APISIX 吞吐回退定位"
+authors:
+ - name: "Xin Rong"
+ title: "Author"
+ url: "https://github.com/AlinsRan"
+ image_url: "https://github.com/AlinsRan.png"
+ - name: "Yilia Lin"
+ title: "Technical Writer"
+ url: "https://github.com/Yilialinn"
+ image_url: "https://github.com/Yilialinn.png"
+keywords:
+ - Apache APISIX
+ - 火焰图
+ - LuaJIT
+ - 性能优化
+ - 吞吐回退
+description: "火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。"
+tags: [Ecosystem]
+---
+
+火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。
+
+<!--truncate-->
+
+一次吞吐回退中,火焰图最宽的路径并不是最终瓶颈。真正值得追查的两个位置,在 Lua 采样里只有 3.0% 和 3.9%。如果按热点排序,它们根本进不了第一轮优化名单。
+
+这次排查最有价值的不是某个补丁,而是一条证据链:先确认 CPU 确实是瓶颈;再横向看火焰图宽度,建立候选;然后沿调用栈纵向追,看重复调用和公共路径如何放大成本;当火焰图解释不了端到端差距时,继续查 LuaJIT 编译与中断事件;最后用每请求调用次数和配对 A/B 实验定量。
+
+> 数据范围:下文数据来自同一受控环境中的一个 APISIX 内部定制构建,其中加载了 100 余个插件。吞吐统一归一化,只用于说明定位方法与因果链,不代表 Apache APISIX OSS 在其他硬件、配置或负载下的通用表现。
+
+## 1. 先确认 CPU 瓶颈,而不是上来就看火焰图
+
+火焰图显示的是采样期间 CPU 在执行什么。只有目标 worker 的 CPU 接近饱和、吞吐受这个核限制时,火焰图的宽度才有资格解释性能回退。
+
+我们在采样前固定了这些条件:
+
+- APISIX 单 worker 并绑定独占物理核;
+- 上游服务和压测端使用其他核,避免 CPU 争抢;
+- 请求模型、配置、响应内容保持一致;
+- 吞吐回退可稳定复现,错误率和响应结果没有偏移;
+- 目标 worker 持续接近饱和,上游、网络、压测端仍有余量。
+
+如果不满足这些前提,首先应该查连接、网络、上游或压测端,而不是在 on-CPU 火焰图里找答案。
+
+## 2. 横向看宽度,而不是排名
+
+我们使用 eBPF 以 500 Hz 同时采集 C 和 Lua 调用栈。下面是排查中的真实 Lua on-CPU 火焰图。
+
+
+
+*搜索 `run_global_rules` 后命中 3 处,但这些位置在整张图里并不醒目。原始采样共 2,446 个样本;截图已移除进程 PID。*
+
+横向看火焰图,看的是宽度,不是 x 轴顺序。宽度越大,采样落在该路径上的次数越多。初步候选位置按 Lua self time 排序:
+
+| 候选位置 | Lua self time | 初判 |
+|---|---:|---|
+| Prometheus exporter | 25.9% | 最值得先看 |
+| `ctx.var` 元方法 | 15.1% | 第二候选 |
+| 自定义日志旁路 | 3.9% | 很容易被忽略 |
+| `run_global_rules` | 3.0% | 很容易被忽略 |
+
+这里的 self time 指:在带 Lua 上下文的样本中,直接落在这个位置上的比例,不是总 CPU 占比。本次采样约 19.6% 的样本缺失 Lua 上下文,所以这些百分比只用于选候选,不能直接预测吞吐收益。
+
+这个排名有四个盲区:
+
+1. LuaJIT 解释执行时,不同的 Lua 代码可能折叠到共享的 `lj_BC_*`、`lj_vm_*` 符号里。
+2. 已编译的 JIT trace 不一定能被常规栈回溯完整展开,因此可观察到的 JIT 样本只是下界。
+3. 表查找、内存分配、GC 等成本会记在 C 符号上,而不是触发的 Lua 行上。
+4. 一个调用可能改变调用方的 JIT 状态,使成本看起来落在调用方函数的头上。
+
+`run_global_rules` 还有一个特征:它没有形成一根大柱子,而是散在多个请求阶段的调用栈里。单处不宽,不等于合起来不贵。
+
+## 3. 纵向沿栈看,3% 如何被公共路径放大
+
+选中某个 `run_global_rules` 方块后,纵向看调用栈:底部是请求阶段入口,向上经过 `common_phase`、Global Rule 插件筛选、调度,再到具体插件执行。
+
+
+
+*所选栈会被重新铺满画布,因此图中的宽度已经归一化,不能再当作它占总 CPU 的比例。*
+
+纵向高度本身不代表耗时。它的作用是看清成本从哪里进入、被谁调用、为什么反复出现。
+
+修复前,`run_global_rules()` 会在请求的多个阶段调用 `_M.filter()`。`_M.filter()` 遍历所有已加载插件,再逐个判断该插件是否被 Global Rule 配置:
+
+```lua
+-- 简化示意,非 APISIX 完整实现
+for _, plugin_obj in ipairs(local_plugins) do
+ local name = plugin_obj.name
+ local plugin_conf = user_plugin_conf[name]
+ if type(plugin_conf) ~= "table" then
+ goto continue
+ end
+ -- 处理已配置插件
+ ::continue::
+end
+```
+
+两个放大器都在这里:
+
+1. 单次过滤成本随已加载插件数量的增长。
+2. 相同过滤结果跨请求阶段重复生成;`body_filter` 与 `delayed_body_filter` 还可能按响应块多次进入。
+
+本次测试配置中,一个请求实际触发了 **9 次过滤**。也就是说,“从 100+ 插件中找出已配置的少量插件”这件事,被同一个请求重复了 9 次。
+
+这解释了为什么 Lua 层 3.0% 会低估整条路径成本:
+
+| 实际工作 | 火焰图常见归属 |
+|---|---|
+| 循环本身 | `plugin.lua` 对应位置 |
+| 大量表查找 | `lj_BC_TGETS` |
+| 临时表分配 | `lj_alloc_malloc` |
+| 临时对象回收 | `gc_sweep` |
+| 未编译解释器派发 | `lj_vm_*` / `lj_BC_*` |
+
+横向看,它只是 3% 的候选;纵向看,才会发现它位于一条被反复进入的公共路径。优化方向也很直接:同一请求内,如果 Global Rule 和匹配路由没有变化,就复用已筛选的插件集合,而不是每个阶段重新生成。
+
+## 4. 火焰图解释不了差距时,查 LuaJIT 编译事件
+
+第二条路径来自自定义观测组件。它和日志旁路不属于 APISIX OSS,但揭示的问题对 APISIX 插件和 OpenResty 扩展有参考价值:一次看似很轻的调用,既可能产生直接成本,也可能改变调用方之后以解释器还是机器码运行。
+
+### 4.1 解释器派发异常活跃
+
+把 C 层样本按运行时类型重新分类,最值得注意的不是某行 Lua,而是解释器与 JIT 的关系:
+
+| 运行类别 | 每请求 CPU 时间 | 占本次样本 |
+|---|---:|---:|
+| 解释器派发:`lj_BC_*` / `lj_vm_*` | 5.86 μs | 29.6% |
+| 可观察到的 JIT trace 执行 | 2.55 μs | 12.9% |
+
+JIT trace 难以完整 unwind,12.9% 只能看作下界。但在同一环境中,解释器派发如此活跃,已经构成强信号:某些高频路径可能没有稳定地跑在机器码里。
+
+这不等于 trace 越多越好。初始化代码不需要追求编译,不同 trace 的成本差异也很大。只有在 CPU 饱和、路径高频、解释器派发异常时,编译结果才值得深挖。
+
+### 4.2 `jit.v` 发出警报,也制造了第一次误判
+
+开启 `jit.v` 后,一次短时压测输出中看到 417 次 trace 编译成功、493 次 abort。按位置聚合后,一批路径的 abort 次数整齐停在 11。
+
+但这是编译事件数,不是唯一函数数、覆盖率或 CPU 时间。`jit.v` 有几个边界:
+
+- abort 行显示的是 trace 中断位置,而惩罚记在 trace 起点,两者可能不是同一行。
+- 某函数从未成为 trace 起点,不代表它没有被编译,函数体可能已被内联进父 trace。
+- 文本日志很难对功能开启/关闭两组结果做稳定的集合差。
+
+我们一开始也把“起点出现次数为 0”误读成“整个函数都在解释执行”。后来用 `jit.dump` 字节码模式复核,才发现多个函数入口没有成为 root trace,但函数体已多次进入其他 trace。真正反复失败的是阶段入口和编排函数。
+
+> 看不到 trace 起点,只能证明这里没有成为 root trace 锚点;不能证明整个函数没有进入机器码。
+
+### 4.3 把 `start`、`stop`、`abort` 归回同一起点
+
+要回答“哪段代码没有编译、为什么”,需要让 LuaJIT 自己交事件流。核心做法是按 trace id 保存 `start` 的位置,再把后续 `stop` 或 `abort` 归回同一个起点:
+
+```lua
+-- 简化示意,实际回调参数和解析逻辑更复杂
+local trace_start = {}
+
+jit.attach(function(what, trace_id, func, pc, err_code)
+ if what == "start" then
+ trace_start[trace_id] = locate(func, pc)
+ elseif what == "stop" then
+ record_compiled(trace_start[trace_id])
+ trace_start[trace_id] = nil
+ elseif what == "abort" then
+ record_abort(trace_start[trace_id], err_code)
+ trace_start[trace_id] = nil
+ end
+end, "trace")
+```
+
+实际探针还要用 `jit.util.funcinfo` 解析源位置,用 `jit.vmdef.traceerr` 还原 abort 原因。这样就能比较开启/关闭自定义组件时:哪些起点进入 compiled 集合,哪些反复 abort,以及是否发生 trace flush。
+
+在本次 LuaJIT 构建中,失败惩罚从 72 开始逐次翻倍:第 10 次是 36,864,第 11 次达到 73,728,超过 60,000 上限。多个起点恰好 abort ×11 后不再增长;它们没有进入 compiled 集合,采集期间也没有 trace flush。两组存活 trace 数均低于缓存上限,因此排除了 trace 缓存被挤爆的可能。这些证据共同说明:对应的 trace 起点已被放弃继续编译。
+
+仍然不能扩大结论:被放弃的是 trace 起点,不一定是整个函数体;其他部分仍可能被内联进别的 trace。
+
+按调用来源聚合后,受影响的阶段入口和编排函数都经过同一条自定义日志旁路:日志级别不足时虽不输出,但仍会检查请求阶段、调用栈和请求上下文。新增调用本身没有形成大柱子,却改变了调用方的 JIT 结果,使部分成本看起来落在正常请求函数头上。
+
+```lua
+-- 自定义扩展的简化示意,不是 APISIX OSS 实现
+if log_level_is_suppressed then
+ check_debug_capture(...)
+ check_request_buffer(...)
+ return
+end
+```
+
+JIT 数据在这里承担两个职责:发现火焰图无法正确归属的成本,验证功能开关确实改变了高频路径的执行状态。但它不能直接告诉我们吞吐损失多大;真正的方向也不是“强迫所有函数编译”,而是让关闭状态下本来不该发生的工作根本不执行。
+
+还有一个容易让结论失真的陷阱:探针必须装在被观测模块加载之前。如果模块在 `require` 时保存了函数引用,之后替换原函数,计数器只能看到少量没被快照走的调用。本次探针注入晚时测到 1 次/请求,前移到 `require("apisix")` 之前后,才确认真实值是 5 次/请求。
+
+## 5. 配对 A/B 收口:实验回答“值多少钱”
+
+火焰图给位置,调用栈给放大链,LuaJIT 事件解释错位成本。最终还要靠配对实验定量。
+
+以 Global Rule 路径为例,我们保留相同的调度和配置,只让 Prometheus 业务函数进入后立即返回,用来区分“插件业务逻辑慢”和“进入插件前的公共路径慢”。为避免把定制环境绝对 RPS 当成 APISIX OSS benchmark,关闭 Prometheus 的吞吐被归一化为 100:
+
+| 内部 A/B 场景 | 相对吞吐指数 | 相对关闭 Prometheus |
+|---|---:|---:|
+| 关闭 Prometheus | 100.0 | 基准 |
+| 保留插件与调度,业务函数立即返回 | 77.2 | -22.8% |
+| 完整 Prometheus Global Rule | 56.9 | -43.1% |
+
+即使插件业务代码被短路,差距仍然显著。这个实验不能把成本定位到某一行,但足以证明损耗不只来自指标计算,进入插件前的公共路径本身就值得追查。再结合每请求 9 次过滤、纵向调用栈和 C 层成本分布,放大链才真正闭合。
Review Comment:
`放大链才真正闭合` — 闭合 is a coined term here, and 放大链 itself is never defined. Plain wording carries the same meaning.
````suggestion
即使插件业务代码被短路,差距仍然显著。这个实验不能把成本定位到某一行,但足以证明损耗不只来自指标计算,进入插件前的公共路径本身就值得追查。再结合每请求 9 次过滤、纵向调用栈和 C 层成本分布,这条放大链才算说得通。
````
##########
blog/zh/blog/2026/08/28/debugging-apisix-throughput-regression.md:
##########
@@ -0,0 +1,234 @@
+---
+title: "火焰图没撒谎,但没直接告诉我们瓶颈:一次 APISIX 吞吐回退定位"
+authors:
+ - name: "Xin Rong"
+ title: "Author"
+ url: "https://github.com/AlinsRan"
+ image_url: "https://github.com/AlinsRan.png"
+ - name: "Yilia Lin"
+ title: "Technical Writer"
+ url: "https://github.com/Yilialinn"
+ image_url: "https://github.com/Yilialinn.png"
+keywords:
+ - Apache APISIX
+ - 火焰图
+ - LuaJIT
+ - 性能优化
+ - 吞吐回退
+description: "火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。"
+tags: [Ecosystem]
+---
+
+火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。
+
+<!--truncate-->
+
+一次吞吐回退中,火焰图最宽的路径并不是最终瓶颈。真正值得追查的两个位置,在 Lua 采样里只有 3.0% 和 3.9%。如果按热点排序,它们根本进不了第一轮优化名单。
+
+这次排查最有价值的不是某个补丁,而是一条证据链:先确认 CPU 确实是瓶颈;再横向看火焰图宽度,建立候选;然后沿调用栈纵向追,看重复调用和公共路径如何放大成本;当火焰图解释不了端到端差距时,继续查 LuaJIT 编译与中断事件;最后用每请求调用次数和配对 A/B 实验定量。
+
+> 数据范围:下文数据来自同一受控环境中的一个 APISIX 内部定制构建,其中加载了 100 余个插件。吞吐统一归一化,只用于说明定位方法与因果链,不代表 Apache APISIX OSS 在其他硬件、配置或负载下的通用表现。
+
+## 1. 先确认 CPU 瓶颈,而不是上来就看火焰图
+
+火焰图显示的是采样期间 CPU 在执行什么。只有目标 worker 的 CPU 接近饱和、吞吐受这个核限制时,火焰图的宽度才有资格解释性能回退。
+
+我们在采样前固定了这些条件:
+
+- APISIX 单 worker 并绑定独占物理核;
+- 上游服务和压测端使用其他核,避免 CPU 争抢;
+- 请求模型、配置、响应内容保持一致;
+- 吞吐回退可稳定复现,错误率和响应结果没有偏移;
+- 目标 worker 持续接近饱和,上游、网络、压测端仍有余量。
+
+如果不满足这些前提,首先应该查连接、网络、上游或压测端,而不是在 on-CPU 火焰图里找答案。
+
+## 2. 横向看宽度,而不是排名
+
+我们使用 eBPF 以 500 Hz 同时采集 C 和 Lua 调用栈。下面是排查中的真实 Lua on-CPU 火焰图。
+
+
+
+*搜索 `run_global_rules` 后命中 3 处,但这些位置在整张图里并不醒目。原始采样共 2,446 个样本;截图已移除进程 PID。*
+
+横向看火焰图,看的是宽度,不是 x 轴顺序。宽度越大,采样落在该路径上的次数越多。初步候选位置按 Lua self time 排序:
+
+| 候选位置 | Lua self time | 初判 |
+|---|---:|---|
+| Prometheus exporter | 25.9% | 最值得先看 |
+| `ctx.var` 元方法 | 15.1% | 第二候选 |
+| 自定义日志旁路 | 3.9% | 很容易被忽略 |
+| `run_global_rules` | 3.0% | 很容易被忽略 |
+
+这里的 self time 指:在带 Lua 上下文的样本中,直接落在这个位置上的比例,不是总 CPU 占比。本次采样约 19.6% 的样本缺失 Lua 上下文,所以这些百分比只用于选候选,不能直接预测吞吐收益。
+
+这个排名有四个盲区:
+
+1. LuaJIT 解释执行时,不同的 Lua 代码可能折叠到共享的 `lj_BC_*`、`lj_vm_*` 符号里。
+2. 已编译的 JIT trace 不一定能被常规栈回溯完整展开,因此可观察到的 JIT 样本只是下界。
+3. 表查找、内存分配、GC 等成本会记在 C 符号上,而不是触发的 Lua 行上。
+4. 一个调用可能改变调用方的 JIT 状态,使成本看起来落在调用方函数的头上。
+
+`run_global_rules` 还有一个特征:它没有形成一根大柱子,而是散在多个请求阶段的调用栈里。单处不宽,不等于合起来不贵。
+
+## 3. 纵向沿栈看,3% 如何被公共路径放大
+
+选中某个 `run_global_rules` 方块后,纵向看调用栈:底部是请求阶段入口,向上经过 `common_phase`、Global Rule 插件筛选、调度,再到具体插件执行。
+
+
+
+*所选栈会被重新铺满画布,因此图中的宽度已经归一化,不能再当作它占总 CPU 的比例。*
+
+纵向高度本身不代表耗时。它的作用是看清成本从哪里进入、被谁调用、为什么反复出现。
+
+修复前,`run_global_rules()` 会在请求的多个阶段调用 `_M.filter()`。`_M.filter()` 遍历所有已加载插件,再逐个判断该插件是否被 Global Rule 配置:
+
+```lua
+-- 简化示意,非 APISIX 完整实现
+for _, plugin_obj in ipairs(local_plugins) do
+ local name = plugin_obj.name
+ local plugin_conf = user_plugin_conf[name]
+ if type(plugin_conf) ~= "table" then
+ goto continue
+ end
+ -- 处理已配置插件
+ ::continue::
+end
+```
+
+两个放大器都在这里:
+
+1. 单次过滤成本随已加载插件数量的增长。
+2. 相同过滤结果跨请求阶段重复生成;`body_filter` 与 `delayed_body_filter` 还可能按响应块多次进入。
+
+本次测试配置中,一个请求实际触发了 **9 次过滤**。也就是说,“从 100+ 插件中找出已配置的少量插件”这件事,被同一个请求重复了 9 次。
+
+这解释了为什么 Lua 层 3.0% 会低估整条路径成本:
+
+| 实际工作 | 火焰图常见归属 |
+|---|---|
+| 循环本身 | `plugin.lua` 对应位置 |
+| 大量表查找 | `lj_BC_TGETS` |
+| 临时表分配 | `lj_alloc_malloc` |
+| 临时对象回收 | `gc_sweep` |
+| 未编译解释器派发 | `lj_vm_*` / `lj_BC_*` |
+
+横向看,它只是 3% 的候选;纵向看,才会发现它位于一条被反复进入的公共路径。优化方向也很直接:同一请求内,如果 Global Rule 和匹配路由没有变化,就复用已筛选的插件集合,而不是每个阶段重新生成。
+
+## 4. 火焰图解释不了差距时,查 LuaJIT 编译事件
+
+第二条路径来自自定义观测组件。它和日志旁路不属于 APISIX OSS,但揭示的问题对 APISIX 插件和 OpenResty 扩展有参考价值:一次看似很轻的调用,既可能产生直接成本,也可能改变调用方之后以解释器还是机器码运行。
+
+### 4.1 解释器派发异常活跃
+
+把 C 层样本按运行时类型重新分类,最值得注意的不是某行 Lua,而是解释器与 JIT 的关系:
+
+| 运行类别 | 每请求 CPU 时间 | 占本次样本 |
+|---|---:|---:|
+| 解释器派发:`lj_BC_*` / `lj_vm_*` | 5.86 μs | 29.6% |
+| 可观察到的 JIT trace 执行 | 2.55 μs | 12.9% |
+
+JIT trace 难以完整 unwind,12.9% 只能看作下界。但在同一环境中,解释器派发如此活跃,已经构成强信号:某些高频路径可能没有稳定地跑在机器码里。
+
+这不等于 trace 越多越好。初始化代码不需要追求编译,不同 trace 的成本差异也很大。只有在 CPU 饱和、路径高频、解释器派发异常时,编译结果才值得深挖。
+
+### 4.2 `jit.v` 发出警报,也制造了第一次误判
+
+开启 `jit.v` 后,一次短时压测输出中看到 417 次 trace 编译成功、493 次 abort。按位置聚合后,一批路径的 abort 次数整齐停在 11。
+
+但这是编译事件数,不是唯一函数数、覆盖率或 CPU 时间。`jit.v` 有几个边界:
+
+- abort 行显示的是 trace 中断位置,而惩罚记在 trace 起点,两者可能不是同一行。
+- 某函数从未成为 trace 起点,不代表它没有被编译,函数体可能已被内联进父 trace。
+- 文本日志很难对功能开启/关闭两组结果做稳定的集合差。
+
+我们一开始也把“起点出现次数为 0”误读成“整个函数都在解释执行”。后来用 `jit.dump` 字节码模式复核,才发现多个函数入口没有成为 root trace,但函数体已多次进入其他 trace。真正反复失败的是阶段入口和编排函数。
+
+> 看不到 trace 起点,只能证明这里没有成为 root trace 锚点;不能证明整个函数没有进入机器码。
+
+### 4.3 把 `start`、`stop`、`abort` 归回同一起点
+
+要回答“哪段代码没有编译、为什么”,需要让 LuaJIT 自己交事件流。核心做法是按 trace id 保存 `start` 的位置,再把后续 `stop` 或 `abort` 归回同一个起点:
+
+```lua
+-- 简化示意,实际回调参数和解析逻辑更复杂
+local trace_start = {}
+
+jit.attach(function(what, trace_id, func, pc, err_code)
+ if what == "start" then
+ trace_start[trace_id] = locate(func, pc)
+ elseif what == "stop" then
+ record_compiled(trace_start[trace_id])
+ trace_start[trace_id] = nil
+ elseif what == "abort" then
+ record_abort(trace_start[trace_id], err_code)
+ trace_start[trace_id] = nil
+ end
+end, "trace")
+```
+
+实际探针还要用 `jit.util.funcinfo` 解析源位置,用 `jit.vmdef.traceerr` 还原 abort 原因。这样就能比较开启/关闭自定义组件时:哪些起点进入 compiled 集合,哪些反复 abort,以及是否发生 trace flush。
+
+在本次 LuaJIT 构建中,失败惩罚从 72 开始逐次翻倍:第 10 次是 36,864,第 11 次达到 73,728,超过 60,000 上限。多个起点恰好 abort ×11 后不再增长;它们没有进入 compiled 集合,采集期间也没有 trace flush。两组存活 trace 数均低于缓存上限,因此排除了 trace 缓存被挤爆的可能。这些证据共同说明:对应的 trace 起点已被放弃继续编译。
+
+仍然不能扩大结论:被放弃的是 trace 起点,不一定是整个函数体;其他部分仍可能被内联进别的 trace。
+
+按调用来源聚合后,受影响的阶段入口和编排函数都经过同一条自定义日志旁路:日志级别不足时虽不输出,但仍会检查请求阶段、调用栈和请求上下文。新增调用本身没有形成大柱子,却改变了调用方的 JIT 结果,使部分成本看起来落在正常请求函数头上。
+
+```lua
+-- 自定义扩展的简化示意,不是 APISIX OSS 实现
+if log_level_is_suppressed then
+ check_debug_capture(...)
+ check_request_buffer(...)
+ return
+end
+```
+
+JIT 数据在这里承担两个职责:发现火焰图无法正确归属的成本,验证功能开关确实改变了高频路径的执行状态。但它不能直接告诉我们吞吐损失多大;真正的方向也不是“强迫所有函数编译”,而是让关闭状态下本来不该发生的工作根本不执行。
+
+还有一个容易让结论失真的陷阱:探针必须装在被观测模块加载之前。如果模块在 `require` 时保存了函数引用,之后替换原函数,计数器只能看到少量没被快照走的调用。本次探针注入晚时测到 1 次/请求,前移到 `require("apisix")` 之前后,才确认真实值是 5 次/请求。
+
+## 5. 配对 A/B 收口:实验回答“值多少钱”
+
+火焰图给位置,调用栈给放大链,LuaJIT 事件解释错位成本。最终还要靠配对实验定量。
+
+以 Global Rule 路径为例,我们保留相同的调度和配置,只让 Prometheus 业务函数进入后立即返回,用来区分“插件业务逻辑慢”和“进入插件前的公共路径慢”。为避免把定制环境绝对 RPS 当成 APISIX OSS benchmark,关闭 Prometheus 的吞吐被归一化为 100:
+
+| 内部 A/B 场景 | 相对吞吐指数 | 相对关闭 Prometheus |
+|---|---:|---:|
+| 关闭 Prometheus | 100.0 | 基准 |
+| 保留插件与调度,业务函数立即返回 | 77.2 | -22.8% |
+| 完整 Prometheus Global Rule | 56.9 | -43.1% |
+
+即使插件业务代码被短路,差距仍然显著。这个实验不能把成本定位到某一行,但足以证明损耗不只来自指标计算,进入插件前的公共路径本身就值得追查。再结合每请求 9 次过滤、纵向调用栈和 C 层成本分布,放大链才真正闭合。
+
+自定义观测组件也用同样方法:保持负载与配置一致,分别采集开启和关闭下的编译事件、每请求调用次数、吞吐和响应正确性。JIT 事件解释“为什么图上没有足够宽的新柱子”,端到端 A/B 回答“这条路径到底值多少钱”。
+
+整个排查可以压缩成五步:
+
+| 步骤 | 核心问题 | 证据 |
+|---|---|---|
+| 1. 确认 CPU 前提 | 火焰图是否有资格解释回退? | worker 饱和、绑核、上下游余量、稳定复现 |
+| 2. 横向看宽度 | 样本主要落在哪里? | 全局火焰图与候选排序 |
+| 3. 纵向沿栈看 | 小成本为何被放大? | 调用阶段、公共函数、每请求调用次数 |
+| 4. 检查 LuaJIT | 成本为何错位或消失? | `start`、`stop`、`abort`、`flush` 与 `jit.dump` |
+| 5. 配对 A/B 收口 | 真实量级与因果是什么? | 吞吐、延迟、调用次数、错误率与响应一致性 |
Review Comment:
Same `收口` as the section 5 heading.
````suggestion
| 5. 配对 A/B 定量 | 真实量级与因果是什么? | 吞吐、延迟、调用次数、错误率与响应一致性 |
````
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@apisix.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] blog: add APISIX throughput regression investigation (en + zh) [apisix-website]
Posted by "Yilialinn (via GitHub)" <gi...@apache.org>.
Yilialinn commented on code in PR #2115:
URL: https://github.com/apache/apisix-website/pull/2115#discussion_r3879763470
##########
blog/en/blog/2026/08/28/debugging-apisix-throughput-regression.md:
##########
@@ -0,0 +1,232 @@
+---
+title: "APISIX Throughput Regression: Beyond the Flame Graph"
+authors:
+ - name: "Xin Rong"
+ title: "Author"
+ url: "https://github.com/AlinsRan"
+ image_url: "https://github.com/AlinsRan.png"
+ - name: "Yilia Lin"
+ title: "Technical Writer"
+ url: "https://github.com/Yilialinn"
+ image_url: "https://github.com/Yilialinn.png"
+keywords:
+ - Apache APISIX
+ - flame graph
+ - LuaJIT
+ - performance optimization
+ - throughput regression
+description: "An APISIX throughput regression showed why the widest path in the flame graph is not always the real bottleneck—and how CPU data, call stacks, LuaJIT events, and paired A/B tests exposed it."
+tags: [Ecosystem]
+---
+
+Flame graphs are honest, but they are not verdicts. In this APISIX throughput regression, the widest path was not the real bottleneck. The paths worth chasing accounted for just 3.0% and 3.9% of the Lua samples.
+
+<!--truncate-->
+
+Ranked by hotspot size alone, neither path would have made the first optimization shortlist. The key was not a single patch but a chain of evidence: confirm that the worker is CPU-bound, compare flame-graph widths, follow repeated call paths, inspect LuaJIT compilation and aborts when the numbers stop adding up, and use per-request call counts plus paired A/B tests to measure the impact.
+
+> **Data scope:** The data below comes from one internally customized APISIX build in the same controlled environment, with more than 100 plugins loaded. Throughput is normalized and is used only to illustrate the diagnostic method and causal chain. It does not represent the general performance of Apache APISIX OSS under other hardware, configurations, or workloads.
+
+## 1. Confirm the Worker Is CPU-Bound
+
+A flame graph shows where the CPU spent its sampled time. Its width matters to a throughput regression only when the target worker is close to saturation and that core limits throughput.
+
+Before collecting the profile, we held the following conditions constant:
+
+- APISIX ran with a single worker pinned to a dedicated physical core.
+- The upstream service and load generator ran on other cores to avoid CPU contention.
+- The request model, configuration, and response content remained unchanged.
+- The regression reproduced consistently, with stable error rates and response results.
+- The target worker remained close to saturation, while the upstream service, network, and load generator still had headroom.
+
+If any of these conditions is not met, start with connections, the network, the upstream service, or the load generator—not an on-CPU flame graph.
+
+## 2. Read Width, Not Rank
+
+We used eBPF to sample C and Lua call stacks simultaneously at 500 Hz. Here is the actual Lua on-CPU flame graph from the investigation.
+
+
+
+Figure 1: Global view of the actual flame graph. Searching for `run_global_rules` produced 3 matches, none of which stood out in the full graph. The original profile contained 2,446 samples; process PIDs were removed from the screenshot.
+
+In a flame graph, x-axis position does not imply order. Width reflects how many samples landed on a path. Ranked by Lua self time, the first shortlist looked like this:
+
+| Candidate location | Lua self time | Initial assessment |
+|---|---:|---|
+| Prometheus exporter | 25.9% | Highest-priority candidate |
+| `ctx.var` metamethod | 15.1% | Second candidate |
+| Custom logging bypass | 3.9% | Easy to overlook |
+| `run_global_rules` | 3.0% | Easy to overlook |
+
+Here, self time is the share of samples with Lua context that landed directly at a location—not a share of total CPU time. About 19.6% of the samples lacked Lua context, so these percentages are useful for selecting candidates, not predicting throughput gains.
+
+This ranking has four blind spots:
+
+1. When LuaJIT executes interpreted code, different Lua code paths may collapse into shared `lj_BC_*` and `lj_vm_*` symbols.
+2. Compiled JIT traces cannot always be fully expanded by conventional stack unwinding, so observable JIT samples provide only a lower bound.
+3. Costs such as table lookups, memory allocation, and garbage collection are attributed to C symbols rather than to the Lua lines that triggered them.
+4. A call can change the caller's JIT state, making the resulting cost appear at the head of the caller function.
+
+There was another clue: `run_global_rules` did not form one large column. It appeared across stacks from several request phases. No single occurrence was wide, but the combined cost could still be substantial.
+
+## 3. Follow the Stack to See How 3% Gets Amplified
+
+Select a `run_global_rules` frame and follow the stack upward. The request-phase entry point sits at the bottom, followed by `common_phase`, Global Rule plugin filtering, dispatch, and finally individual plugin execution.
+
+
+
+Figure 2: Interactive zoomed view after selecting one `run_global_rules` stack. The selected stack is stretched to fill the canvas, so its width is normalized and must not be interpreted as its share of total CPU time. Click the image to view it at full size.
+
+Stack height does not represent elapsed time. It shows where the cost enters, who invokes it, and why it keeps coming back.
+
+Before the fix, `run_global_rules()` called `_M.filter()` in several request phases. `_M.filter()` iterated through every loaded plugin and checked each one to determine whether it had been configured in the Global Rule:
+
+```lua
+-- Simplified illustration, not the complete APISIX implementation
+for _, plugin_obj in ipairs(local_plugins) do
+ local name = plugin_obj.name
+ local plugin_conf = user_plugin_conf[name]
+ if type(plugin_conf) ~= "table" then
+ goto continue
+ end
+ -- Process configured plugins
+ ::continue::
+end
+```
+
+Two factors amplified the cost:
+
+1. The cost of each filtering pass increased with the number of loaded plugins.
+2. The same filtering result was regenerated across request phases. The `body_filter` and `delayed_body_filter` phases could also be entered multiple times for response-body chunks.
+
+In this test configuration, one request triggered 9 filtering passes. The same request scanned more than 100 loaded plugins for the same small configured set 9 times.
+
+This explains why a Lua-level self time of 3.0% understated the cost of the entire path:
+
+| Actual work | Common flame-graph attribution |
+|---|---|
+| The loop itself | Corresponding location in `plugin.lua` |
+| Large numbers of table lookups | `lj_BC_TGETS` |
+| Temporary table allocation | `lj_alloc_malloc` |
+| Temporary object reclamation | `gc_sweep` |
+| Uncompiled interpreter dispatch | `lj_vm_*` / `lj_BC_*` |
+
+At 3% self time, this path looked minor. Following the stack showed that it sat on a shared path entered repeatedly. The optimization followed directly: within one request, if the Global Rule and matched route have not changed, reuse the filtered plugin set instead of rebuilding it in every phase.
+
+## 4. When the Flame Graph Doesn't Add Up, Check LuaJIT
+
+The second lead came from a custom observability component. Neither the component nor its logging bypass is part of APISIX OSS, but the failure mode matters to APISIX plugins and OpenResty extensions: a call that looks cheap can add direct cost and change whether subsequent caller code runs in the interpreter or as machine code.
+
+### 4.1 Interpreter Dispatch Was Abnormally Active
+
+After reclassifying the C-level samples by runtime category, the most notable signal was not any individual Lua line, but the relationship between interpreter execution and JIT execution:
+
+| Runtime category | CPU time per request | Share of samples |
+|---|---:|---:|
+| Interpreter dispatch: `lj_BC_*` / `lj_vm_*` | 5.86 μs | 29.6% |
+| Observable JIT trace execution | 2.55 μs | 12.9% |
+
+JIT traces do not always unwind cleanly, so 12.9% is only a lower bound. Still, the amount of interpreter dispatch in the same environment was a strong signal that some high-frequency paths might not be running consistently as machine code.
+
+This is not an argument for maximizing trace count. Initialization code does not need to be compiled, and trace costs vary widely. Compilation results are worth investigating only when the CPU is saturated, the path is hot, and interpreter dispatch is unusually active.
+
+### 4.2 `jit.v` Raised the Alarm—and Caused the First Misdiagnosis
+
+With `jit.v` enabled, one short load test reported 417 successful trace compilations and 493 aborts. After aggregation by source location, a group of paths each stopped at exactly 11 aborts.
+
+These are compilation-event counts—not unique functions, coverage, or CPU time. `jit.v` also has several important limitations:
+
+- An abort line shows the trace abort location, while the penalty is applied to the trace starting point; the two may not be the same line.
+- A function never appearing as a trace starting point does not mean it was never compiled. Its body may have been inlined into a parent trace.
+- Text logs make it difficult to perform stable set comparisons between results collected with a feature enabled and disabled.
+
+At first, we read “0 appearances as a starting point” as “the whole function runs in the interpreter.” The bytecode mode of `jit.dump` proved otherwise: several function entries never became root traces, but their bodies repeatedly entered other traces. The recurring failures were in phase-entry and orchestration functions.
+
+> The absence of a trace starting point proves only that the location did not become a root-trace anchor. It does not prove that the entire function never entered machine code.
+
+### 4.3 Correlate `start`, `stop`, and `abort` by Trace Start
+
+To learn what failed to compile and why, we needed the event stream from LuaJIT itself. Store each `start` location by trace ID, then map the matching `stop` or `abort` back to that same starting point:
+
+```lua
+-- Simplified illustration; actual callback arguments and parsing are more complex
+local trace_start = {}
+
+jit.attach(function(what, trace_id, func, pc, err_code)
+ if what == "start" then
+ trace_start[trace_id] = locate(func, pc)
+ elseif what == "stop" then
+ record_compiled(trace_start[trace_id])
+ trace_start[trace_id] = nil
+ elseif what == "abort" then
+ record_abort(trace_start[trace_id], err_code)
+ trace_start[trace_id] = nil
+ end
+end, "trace")
+```
+
+The real probe also needs `jit.util.funcinfo` to resolve source locations and `jit.vmdef.traceerr` to recover abort reasons. With that data, we can compare the component's enabled and disabled states: which starting points compile, which repeatedly abort, and whether a trace flush occurs.
+
+In the LuaJIT build used here, the failure penalty started at 72 and doubled after each failure. It was 36,864 on the 10th failure and reached 73,728 on the 11th, above the 60,000 limit. Several starting points stopped at exactly 11 aborts and never entered the compiled set; no trace flush occurred during collection. The number of live traces in both test variants remained below the cache limit, ruling out trace-cache exhaustion. Together, the evidence showed that LuaJIT had abandoned further compilation attempts for those trace starts.
+
+Keep the conclusion narrow: LuaJIT abandoned those trace starts, not necessarily the entire functions. Other parts of the same functions could still have been inlined into different traces.
Review Comment:
Updated in abd89cc. Removed the editor-style instruction and stated the technical limit directly: LuaJIT may not have abandoned those functions entirely.
##########
blog/en/blog/2026/08/28/debugging-apisix-throughput-regression.md:
##########
@@ -0,0 +1,232 @@
+---
+title: "APISIX Throughput Regression: Beyond the Flame Graph"
+authors:
+ - name: "Xin Rong"
+ title: "Author"
+ url: "https://github.com/AlinsRan"
+ image_url: "https://github.com/AlinsRan.png"
+ - name: "Yilia Lin"
+ title: "Technical Writer"
+ url: "https://github.com/Yilialinn"
+ image_url: "https://github.com/Yilialinn.png"
+keywords:
+ - Apache APISIX
+ - flame graph
+ - LuaJIT
+ - performance optimization
+ - throughput regression
+description: "An APISIX throughput regression showed why the widest path in the flame graph is not always the real bottleneck—and how CPU data, call stacks, LuaJIT events, and paired A/B tests exposed it."
+tags: [Ecosystem]
+---
+
+Flame graphs are honest, but they are not verdicts. In this APISIX throughput regression, the widest path was not the real bottleneck. The paths worth chasing accounted for just 3.0% and 3.9% of the Lua samples.
+
+<!--truncate-->
+
+Ranked by hotspot size alone, neither path would have made the first optimization shortlist. The key was not a single patch but a chain of evidence: confirm that the worker is CPU-bound, compare flame-graph widths, follow repeated call paths, inspect LuaJIT compilation and aborts when the numbers stop adding up, and use per-request call counts plus paired A/B tests to measure the impact.
+
+> **Data scope:** The data below comes from one internally customized APISIX build in the same controlled environment, with more than 100 plugins loaded. Throughput is normalized and is used only to illustrate the diagnostic method and causal chain. It does not represent the general performance of Apache APISIX OSS under other hardware, configurations, or workloads.
+
+## 1. Confirm the Worker Is CPU-Bound
+
+A flame graph shows where the CPU spent its sampled time. Its width matters to a throughput regression only when the target worker is close to saturation and that core limits throughput.
+
+Before collecting the profile, we held the following conditions constant:
+
+- APISIX ran with a single worker pinned to a dedicated physical core.
+- The upstream service and load generator ran on other cores to avoid CPU contention.
+- The request model, configuration, and response content remained unchanged.
+- The regression reproduced consistently, with stable error rates and response results.
+- The target worker remained close to saturation, while the upstream service, network, and load generator still had headroom.
+
+If any of these conditions is not met, start with connections, the network, the upstream service, or the load generator—not an on-CPU flame graph.
+
+## 2. Read Width, Not Rank
+
+We used eBPF to sample C and Lua call stacks simultaneously at 500 Hz. Here is the actual Lua on-CPU flame graph from the investigation.
+
+
+
+Figure 1: Global view of the actual flame graph. Searching for `run_global_rules` produced 3 matches, none of which stood out in the full graph. The original profile contained 2,446 samples; process PIDs were removed from the screenshot.
+
+In a flame graph, x-axis position does not imply order. Width reflects how many samples landed on a path. Ranked by Lua self time, the first shortlist looked like this:
+
+| Candidate location | Lua self time | Initial assessment |
+|---|---:|---|
+| Prometheus exporter | 25.9% | Highest-priority candidate |
+| `ctx.var` metamethod | 15.1% | Second candidate |
+| Custom logging bypass | 3.9% | Easy to overlook |
+| `run_global_rules` | 3.0% | Easy to overlook |
+
+Here, self time is the share of samples with Lua context that landed directly at a location—not a share of total CPU time. About 19.6% of the samples lacked Lua context, so these percentages are useful for selecting candidates, not predicting throughput gains.
+
+This ranking has four blind spots:
+
+1. When LuaJIT executes interpreted code, different Lua code paths may collapse into shared `lj_BC_*` and `lj_vm_*` symbols.
+2. Compiled JIT traces cannot always be fully expanded by conventional stack unwinding, so observable JIT samples provide only a lower bound.
+3. Costs such as table lookups, memory allocation, and garbage collection are attributed to C symbols rather than to the Lua lines that triggered them.
+4. A call can change the caller's JIT state, making the resulting cost appear at the head of the caller function.
+
+There was another clue: `run_global_rules` did not form one large column. It appeared across stacks from several request phases. No single occurrence was wide, but the combined cost could still be substantial.
+
+## 3. Follow the Stack to See How 3% Gets Amplified
+
+Select a `run_global_rules` frame and follow the stack upward. The request-phase entry point sits at the bottom, followed by `common_phase`, Global Rule plugin filtering, dispatch, and finally individual plugin execution.
+
+
+
+Figure 2: Interactive zoomed view after selecting one `run_global_rules` stack. The selected stack is stretched to fill the canvas, so its width is normalized and must not be interpreted as its share of total CPU time. Click the image to view it at full size.
+
+Stack height does not represent elapsed time. It shows where the cost enters, who invokes it, and why it keeps coming back.
+
+Before the fix, `run_global_rules()` called `_M.filter()` in several request phases. `_M.filter()` iterated through every loaded plugin and checked each one to determine whether it had been configured in the Global Rule:
+
+```lua
+-- Simplified illustration, not the complete APISIX implementation
+for _, plugin_obj in ipairs(local_plugins) do
+ local name = plugin_obj.name
+ local plugin_conf = user_plugin_conf[name]
+ if type(plugin_conf) ~= "table" then
+ goto continue
+ end
+ -- Process configured plugins
+ ::continue::
+end
+```
+
+Two factors amplified the cost:
+
+1. The cost of each filtering pass increased with the number of loaded plugins.
+2. The same filtering result was regenerated across request phases. The `body_filter` and `delayed_body_filter` phases could also be entered multiple times for response-body chunks.
+
+In this test configuration, one request triggered 9 filtering passes. The same request scanned more than 100 loaded plugins for the same small configured set 9 times.
+
+This explains why a Lua-level self time of 3.0% understated the cost of the entire path:
+
+| Actual work | Common flame-graph attribution |
+|---|---|
+| The loop itself | Corresponding location in `plugin.lua` |
+| Large numbers of table lookups | `lj_BC_TGETS` |
+| Temporary table allocation | `lj_alloc_malloc` |
+| Temporary object reclamation | `gc_sweep` |
+| Uncompiled interpreter dispatch | `lj_vm_*` / `lj_BC_*` |
+
+At 3% self time, this path looked minor. Following the stack showed that it sat on a shared path entered repeatedly. The optimization followed directly: within one request, if the Global Rule and matched route have not changed, reuse the filtered plugin set instead of rebuilding it in every phase.
+
+## 4. When the Flame Graph Doesn't Add Up, Check LuaJIT
+
+The second lead came from a custom observability component. Neither the component nor its logging bypass is part of APISIX OSS, but the failure mode matters to APISIX plugins and OpenResty extensions: a call that looks cheap can add direct cost and change whether subsequent caller code runs in the interpreter or as machine code.
+
+### 4.1 Interpreter Dispatch Was Abnormally Active
+
+After reclassifying the C-level samples by runtime category, the most notable signal was not any individual Lua line, but the relationship between interpreter execution and JIT execution:
+
+| Runtime category | CPU time per request | Share of samples |
+|---|---:|---:|
+| Interpreter dispatch: `lj_BC_*` / `lj_vm_*` | 5.86 μs | 29.6% |
+| Observable JIT trace execution | 2.55 μs | 12.9% |
+
+JIT traces do not always unwind cleanly, so 12.9% is only a lower bound. Still, the amount of interpreter dispatch in the same environment was a strong signal that some high-frequency paths might not be running consistently as machine code.
+
+This is not an argument for maximizing trace count. Initialization code does not need to be compiled, and trace costs vary widely. Compilation results are worth investigating only when the CPU is saturated, the path is hot, and interpreter dispatch is unusually active.
+
+### 4.2 `jit.v` Raised the Alarm—and Caused the First Misdiagnosis
+
+With `jit.v` enabled, one short load test reported 417 successful trace compilations and 493 aborts. After aggregation by source location, a group of paths each stopped at exactly 11 aborts.
+
+These are compilation-event counts—not unique functions, coverage, or CPU time. `jit.v` also has several important limitations:
+
+- An abort line shows the trace abort location, while the penalty is applied to the trace starting point; the two may not be the same line.
+- A function never appearing as a trace starting point does not mean it was never compiled. Its body may have been inlined into a parent trace.
+- Text logs make it difficult to perform stable set comparisons between results collected with a feature enabled and disabled.
+
+At first, we read “0 appearances as a starting point” as “the whole function runs in the interpreter.” The bytecode mode of `jit.dump` proved otherwise: several function entries never became root traces, but their bodies repeatedly entered other traces. The recurring failures were in phase-entry and orchestration functions.
+
+> The absence of a trace starting point proves only that the location did not become a root-trace anchor. It does not prove that the entire function never entered machine code.
+
+### 4.3 Correlate `start`, `stop`, and `abort` by Trace Start
+
+To learn what failed to compile and why, we needed the event stream from LuaJIT itself. Store each `start` location by trace ID, then map the matching `stop` or `abort` back to that same starting point:
+
+```lua
+-- Simplified illustration; actual callback arguments and parsing are more complex
+local trace_start = {}
+
+jit.attach(function(what, trace_id, func, pc, err_code)
+ if what == "start" then
+ trace_start[trace_id] = locate(func, pc)
+ elseif what == "stop" then
+ record_compiled(trace_start[trace_id])
+ trace_start[trace_id] = nil
+ elseif what == "abort" then
+ record_abort(trace_start[trace_id], err_code)
+ trace_start[trace_id] = nil
+ end
+end, "trace")
+```
+
+The real probe also needs `jit.util.funcinfo` to resolve source locations and `jit.vmdef.traceerr` to recover abort reasons. With that data, we can compare the component's enabled and disabled states: which starting points compile, which repeatedly abort, and whether a trace flush occurs.
+
+In the LuaJIT build used here, the failure penalty started at 72 and doubled after each failure. It was 36,864 on the 10th failure and reached 73,728 on the 11th, above the 60,000 limit. Several starting points stopped at exactly 11 aborts and never entered the compiled set; no trace flush occurred during collection. The number of live traces in both test variants remained below the cache limit, ruling out trace-cache exhaustion. Together, the evidence showed that LuaJIT had abandoned further compilation attempts for those trace starts.
+
+Keep the conclusion narrow: LuaJIT abandoned those trace starts, not necessarily the entire functions. Other parts of the same functions could still have been inlined into different traces.
+
+When the data was grouped by caller, every affected phase-entry and orchestration function passed through the same custom logging bypass. When the log level was too low to emit output, this path still inspected the request phase, call stack, and request context. The added call did not produce a wide column of its own, but it changed the JIT outcome of its callers, making part of the cost appear at the heads of ordinary request-processing functions.
+
+```lua
+-- Simplified custom extension, not an APISIX OSS implementation
+if log_level_is_suppressed then
+ check_debug_capture(...)
+ check_request_buffer(...)
+ return
+end
+```
+
+The JIT data did two jobs: it exposed costs the flame graph could not attribute cleanly, and it showed that toggling the feature changed how hot paths executed. It still could not quantify the throughput loss. The goal was not to force every function to compile; it was to remove work that should never happen when the feature is disabled.
+
+One more trap: install the probe before loading the module under observation. A module can capture a function reference during `require`; replacing that function later leaves the captured reference untouched. Our late probe saw 1 call per request. Moving it before `require("apisix")` exposed the real rate: 5 calls per request.
+
+## 5. Close the Loop with Paired A/B Tests
+
+Flame graphs identify locations, call stacks reveal amplification, and LuaJIT events explain misplaced costs. Paired experiments tell us how much those paths are actually worth.
+
+For the Global Rule path, we kept the same dispatch and configuration but returned immediately after entering the Prometheus business function. This helped distinguish the cost of the plugin's business logic from that of the shared path before plugin entry. To avoid presenting absolute RPS from a customized environment as an APISIX OSS benchmark, we normalized throughput with Prometheus disabled to 100:
+
+| Internal A/B scenario | Relative throughput index | Relative to Prometheus disabled |
+|---|---:|---:|
+| Prometheus disabled | 100.0 | Baseline |
+| Plugin and dispatch retained; business function returns immediately | 77.2 | -22.8% |
+| Complete Prometheus Global Rule | 56.9 | -43.1% |
+
+The gap remained even after short-circuiting the plugin's business code. The experiment did not identify a single expensive line, but it ruled out metric calculation as the only cause. Combined with 9 filtering passes per request, the call stacks, and the C-level cost distribution, it closed the amplification chain.
+
+We used the same method for the custom observability component. With workload and configuration held constant, we collected compilation events, per-request call counts, throughput, and response correctness with the component on and off. JIT events explained why no wide new column appeared in the graph; the end-to-end A/B test measured the actual cost.
+
+The investigation comes down to five steps:
+
+| Step | Core question | Evidence |
+|---|---|---|
+| 1. Verify the worker is CPU-bound | Is the target worker actually CPU-bound? | Worker saturation, CPU pinning, spare upstream and load-generator capacity, and stable reproduction |
+| 2. Compare widths | Where do samples accumulate? | Global flame graph and candidate ranking |
+| 3. Follow stacks | What multiplies a small local cost? | Request phases, shared functions, and per-request call counts |
+| 4. Inspect LuaJIT | Why does cost appear elsewhere or go missing? | `start`, `stop`, `abort`, `flush`, and `jit.dump` |
+| 5. Run paired A/B tests | How large is the effect, and is it causal? | Throughput, latency, call counts, error rate, and response consistency |
+
+Keep the conclusions within clear boundaries:
+
+- Flame-graph width, Lua self time, and throughput changes use different denominators and cannot be directly subtracted or divided.
+- The custom observability component is not part of APISIX OSS. It is included only to illustrate a general issue that custom extensions may encounter.
+- The values 29.6%, 12.9%, 417, 493, and abort ×11 apply only to this build and collection window and must not be extrapolated.
+- “Did not become a trace starting point” does not mean “the function was not compiled.” You must check whether the function body entered other traces.
+- JIT compilation results are diagnostic signals, not final performance metrics. Any optimization must still be validated against throughput, latency, error rate, response content, and resource reclamation.
+
+The flame graph was not wrong. Width showed where CPU samples accumulated, and call stacks showed how shared paths amplified the cost. But once that cost spread across interpreter dispatch, JIT traces, the allocator, and caller functions, the graph no longer showed complete attribution.
+
+When the graph stops adding up, do not keep guessing which Lua line should be faster. Pull the compilation-event stream from LuaJIT, count calls per request, and run paired A/B tests. The best optimization target is often not the widest column, but the work the evidence shows should never have repeated in the first place.
+
+## References
+
+1. [WPS with Apache APISIX: Flame Graph and LuaJIT Performance Practices](https://apisix.apache.org/blog/2021/09/28/wps-usercase/)
+2. [1s to 10ms: Reproducing, Diagnosing, and Fixing Prometheus Tail Latency](https://api7.ai/blog/1s-to-10ms-reducing-prometheus-delay-in-api-gateway)
Review Comment:
Updated in abd89cc. Verified both linked pages and replaced the paraphrases with their canonical English titles.
##########
blog/en/blog/2026/08/28/debugging-apisix-throughput-regression.md:
##########
@@ -0,0 +1,232 @@
+---
+title: "APISIX Throughput Regression: Beyond the Flame Graph"
+authors:
+ - name: "Xin Rong"
+ title: "Author"
+ url: "https://github.com/AlinsRan"
+ image_url: "https://github.com/AlinsRan.png"
+ - name: "Yilia Lin"
+ title: "Technical Writer"
+ url: "https://github.com/Yilialinn"
+ image_url: "https://github.com/Yilialinn.png"
+keywords:
+ - Apache APISIX
+ - flame graph
+ - LuaJIT
+ - performance optimization
+ - throughput regression
+description: "An APISIX throughput regression showed why the widest path in the flame graph is not always the real bottleneck—and how CPU data, call stacks, LuaJIT events, and paired A/B tests exposed it."
+tags: [Ecosystem]
+---
+
+Flame graphs are honest, but they are not verdicts. In this APISIX throughput regression, the widest path was not the real bottleneck. The paths worth chasing accounted for just 3.0% and 3.9% of the Lua samples.
+
+<!--truncate-->
+
+Ranked by hotspot size alone, neither path would have made the first optimization shortlist. The key was not a single patch but a chain of evidence: confirm that the worker is CPU-bound, compare flame-graph widths, follow repeated call paths, inspect LuaJIT compilation and aborts when the numbers stop adding up, and use per-request call counts plus paired A/B tests to measure the impact.
+
+> **Data scope:** The data below comes from one internally customized APISIX build in the same controlled environment, with more than 100 plugins loaded. Throughput is normalized and is used only to illustrate the diagnostic method and causal chain. It does not represent the general performance of Apache APISIX OSS under other hardware, configurations, or workloads.
+
+## 1. Confirm the Worker Is CPU-Bound
+
+A flame graph shows where the CPU spent its sampled time. Its width matters to a throughput regression only when the target worker is close to saturation and that core limits throughput.
+
+Before collecting the profile, we held the following conditions constant:
+
+- APISIX ran with a single worker pinned to a dedicated physical core.
+- The upstream service and load generator ran on other cores to avoid CPU contention.
+- The request model, configuration, and response content remained unchanged.
+- The regression reproduced consistently, with stable error rates and response results.
+- The target worker remained close to saturation, while the upstream service, network, and load generator still had headroom.
+
+If any of these conditions is not met, start with connections, the network, the upstream service, or the load generator—not an on-CPU flame graph.
+
+## 2. Read Width, Not Rank
+
+We used eBPF to sample C and Lua call stacks simultaneously at 500 Hz. Here is the actual Lua on-CPU flame graph from the investigation.
+
+
+
+Figure 1: Global view of the actual flame graph. Searching for `run_global_rules` produced 3 matches, none of which stood out in the full graph. The original profile contained 2,446 samples; process PIDs were removed from the screenshot.
+
+In a flame graph, x-axis position does not imply order. Width reflects how many samples landed on a path. Ranked by Lua self time, the first shortlist looked like this:
+
+| Candidate location | Lua self time | Initial assessment |
+|---|---:|---|
+| Prometheus exporter | 25.9% | Highest-priority candidate |
+| `ctx.var` metamethod | 15.1% | Second candidate |
+| Custom logging bypass | 3.9% | Easy to overlook |
+| `run_global_rules` | 3.0% | Easy to overlook |
+
+Here, self time is the share of samples with Lua context that landed directly at a location—not a share of total CPU time. About 19.6% of the samples lacked Lua context, so these percentages are useful for selecting candidates, not predicting throughput gains.
+
+This ranking has four blind spots:
+
+1. When LuaJIT executes interpreted code, different Lua code paths may collapse into shared `lj_BC_*` and `lj_vm_*` symbols.
+2. Compiled JIT traces cannot always be fully expanded by conventional stack unwinding, so observable JIT samples provide only a lower bound.
+3. Costs such as table lookups, memory allocation, and garbage collection are attributed to C symbols rather than to the Lua lines that triggered them.
+4. A call can change the caller's JIT state, making the resulting cost appear at the head of the caller function.
+
+There was another clue: `run_global_rules` did not form one large column. It appeared across stacks from several request phases. No single occurrence was wide, but the combined cost could still be substantial.
+
+## 3. Follow the Stack to See How 3% Gets Amplified
+
+Select a `run_global_rules` frame and follow the stack upward. The request-phase entry point sits at the bottom, followed by `common_phase`, Global Rule plugin filtering, dispatch, and finally individual plugin execution.
+
+
+
+Figure 2: Interactive zoomed view after selecting one `run_global_rules` stack. The selected stack is stretched to fill the canvas, so its width is normalized and must not be interpreted as its share of total CPU time. Click the image to view it at full size.
+
+Stack height does not represent elapsed time. It shows where the cost enters, who invokes it, and why it keeps coming back.
+
+Before the fix, `run_global_rules()` called `_M.filter()` in several request phases. `_M.filter()` iterated through every loaded plugin and checked each one to determine whether it had been configured in the Global Rule:
+
+```lua
+-- Simplified illustration, not the complete APISIX implementation
+for _, plugin_obj in ipairs(local_plugins) do
+ local name = plugin_obj.name
+ local plugin_conf = user_plugin_conf[name]
+ if type(plugin_conf) ~= "table" then
+ goto continue
+ end
+ -- Process configured plugins
+ ::continue::
+end
+```
+
+Two factors amplified the cost:
+
+1. The cost of each filtering pass increased with the number of loaded plugins.
+2. The same filtering result was regenerated across request phases. The `body_filter` and `delayed_body_filter` phases could also be entered multiple times for response-body chunks.
+
+In this test configuration, one request triggered 9 filtering passes. The same request scanned more than 100 loaded plugins for the same small configured set 9 times.
+
+This explains why a Lua-level self time of 3.0% understated the cost of the entire path:
+
+| Actual work | Common flame-graph attribution |
+|---|---|
+| The loop itself | Corresponding location in `plugin.lua` |
+| Large numbers of table lookups | `lj_BC_TGETS` |
+| Temporary table allocation | `lj_alloc_malloc` |
+| Temporary object reclamation | `gc_sweep` |
+| Uncompiled interpreter dispatch | `lj_vm_*` / `lj_BC_*` |
+
+At 3% self time, this path looked minor. Following the stack showed that it sat on a shared path entered repeatedly. The optimization followed directly: within one request, if the Global Rule and matched route have not changed, reuse the filtered plugin set instead of rebuilding it in every phase.
+
+## 4. When the Flame Graph Doesn't Add Up, Check LuaJIT
+
+The second lead came from a custom observability component. Neither the component nor its logging bypass is part of APISIX OSS, but the failure mode matters to APISIX plugins and OpenResty extensions: a call that looks cheap can add direct cost and change whether subsequent caller code runs in the interpreter or as machine code.
+
+### 4.1 Interpreter Dispatch Was Abnormally Active
+
+After reclassifying the C-level samples by runtime category, the most notable signal was not any individual Lua line, but the relationship between interpreter execution and JIT execution:
+
+| Runtime category | CPU time per request | Share of samples |
+|---|---:|---:|
+| Interpreter dispatch: `lj_BC_*` / `lj_vm_*` | 5.86 μs | 29.6% |
+| Observable JIT trace execution | 2.55 μs | 12.9% |
+
+JIT traces do not always unwind cleanly, so 12.9% is only a lower bound. Still, the amount of interpreter dispatch in the same environment was a strong signal that some high-frequency paths might not be running consistently as machine code.
+
+This is not an argument for maximizing trace count. Initialization code does not need to be compiled, and trace costs vary widely. Compilation results are worth investigating only when the CPU is saturated, the path is hot, and interpreter dispatch is unusually active.
+
+### 4.2 `jit.v` Raised the Alarm—and Caused the First Misdiagnosis
+
+With `jit.v` enabled, one short load test reported 417 successful trace compilations and 493 aborts. After aggregation by source location, a group of paths each stopped at exactly 11 aborts.
+
+These are compilation-event counts—not unique functions, coverage, or CPU time. `jit.v` also has several important limitations:
+
+- An abort line shows the trace abort location, while the penalty is applied to the trace starting point; the two may not be the same line.
+- A function never appearing as a trace starting point does not mean it was never compiled. Its body may have been inlined into a parent trace.
+- Text logs make it difficult to perform stable set comparisons between results collected with a feature enabled and disabled.
Review Comment:
Updated in abd89cc. The text now says that text logs make it hard to reliably diff results between enabled and disabled runs.
##########
blog/en/blog/2026/08/28/debugging-apisix-throughput-regression.md:
##########
@@ -0,0 +1,232 @@
+---
+title: "APISIX Throughput Regression: Beyond the Flame Graph"
+authors:
+ - name: "Xin Rong"
+ title: "Author"
+ url: "https://github.com/AlinsRan"
+ image_url: "https://github.com/AlinsRan.png"
+ - name: "Yilia Lin"
+ title: "Technical Writer"
+ url: "https://github.com/Yilialinn"
+ image_url: "https://github.com/Yilialinn.png"
+keywords:
+ - Apache APISIX
+ - flame graph
+ - LuaJIT
+ - performance optimization
+ - throughput regression
+description: "An APISIX throughput regression showed why the widest path in the flame graph is not always the real bottleneck—and how CPU data, call stacks, LuaJIT events, and paired A/B tests exposed it."
+tags: [Ecosystem]
+---
+
+Flame graphs are honest, but they are not verdicts. In this APISIX throughput regression, the widest path was not the real bottleneck. The paths worth chasing accounted for just 3.0% and 3.9% of the Lua samples.
+
+<!--truncate-->
+
+Ranked by hotspot size alone, neither path would have made the first optimization shortlist. The key was not a single patch but a chain of evidence: confirm that the worker is CPU-bound, compare flame-graph widths, follow repeated call paths, inspect LuaJIT compilation and aborts when the numbers stop adding up, and use per-request call counts plus paired A/B tests to measure the impact.
+
+> **Data scope:** The data below comes from one internally customized APISIX build in the same controlled environment, with more than 100 plugins loaded. Throughput is normalized and is used only to illustrate the diagnostic method and causal chain. It does not represent the general performance of Apache APISIX OSS under other hardware, configurations, or workloads.
+
+## 1. Confirm the Worker Is CPU-Bound
+
+A flame graph shows where the CPU spent its sampled time. Its width matters to a throughput regression only when the target worker is close to saturation and that core limits throughput.
+
+Before collecting the profile, we held the following conditions constant:
+
+- APISIX ran with a single worker pinned to a dedicated physical core.
+- The upstream service and load generator ran on other cores to avoid CPU contention.
+- The request model, configuration, and response content remained unchanged.
+- The regression reproduced consistently, with stable error rates and response results.
+- The target worker remained close to saturation, while the upstream service, network, and load generator still had headroom.
+
+If any of these conditions is not met, start with connections, the network, the upstream service, or the load generator—not an on-CPU flame graph.
+
+## 2. Read Width, Not Rank
+
+We used eBPF to sample C and Lua call stacks simultaneously at 500 Hz. Here is the actual Lua on-CPU flame graph from the investigation.
+
+
+
+Figure 1: Global view of the actual flame graph. Searching for `run_global_rules` produced 3 matches, none of which stood out in the full graph. The original profile contained 2,446 samples; process PIDs were removed from the screenshot.
+
+In a flame graph, x-axis position does not imply order. Width reflects how many samples landed on a path. Ranked by Lua self time, the first shortlist looked like this:
+
+| Candidate location | Lua self time | Initial assessment |
+|---|---:|---|
+| Prometheus exporter | 25.9% | Highest-priority candidate |
+| `ctx.var` metamethod | 15.1% | Second candidate |
+| Custom logging bypass | 3.9% | Easy to overlook |
+| `run_global_rules` | 3.0% | Easy to overlook |
+
+Here, self time is the share of samples with Lua context that landed directly at a location—not a share of total CPU time. About 19.6% of the samples lacked Lua context, so these percentages are useful for selecting candidates, not predicting throughput gains.
+
+This ranking has four blind spots:
+
+1. When LuaJIT executes interpreted code, different Lua code paths may collapse into shared `lj_BC_*` and `lj_vm_*` symbols.
+2. Compiled JIT traces cannot always be fully expanded by conventional stack unwinding, so observable JIT samples provide only a lower bound.
+3. Costs such as table lookups, memory allocation, and garbage collection are attributed to C symbols rather than to the Lua lines that triggered them.
+4. A call can change the caller's JIT state, making the resulting cost appear at the head of the caller function.
+
+There was another clue: `run_global_rules` did not form one large column. It appeared across stacks from several request phases. No single occurrence was wide, but the combined cost could still be substantial.
+
+## 3. Follow the Stack to See How 3% Gets Amplified
+
+Select a `run_global_rules` frame and follow the stack upward. The request-phase entry point sits at the bottom, followed by `common_phase`, Global Rule plugin filtering, dispatch, and finally individual plugin execution.
+
+
+
+Figure 2: Interactive zoomed view after selecting one `run_global_rules` stack. The selected stack is stretched to fill the canvas, so its width is normalized and must not be interpreted as its share of total CPU time. Click the image to view it at full size.
+
+Stack height does not represent elapsed time. It shows where the cost enters, who invokes it, and why it keeps coming back.
+
+Before the fix, `run_global_rules()` called `_M.filter()` in several request phases. `_M.filter()` iterated through every loaded plugin and checked each one to determine whether it had been configured in the Global Rule:
+
+```lua
+-- Simplified illustration, not the complete APISIX implementation
+for _, plugin_obj in ipairs(local_plugins) do
+ local name = plugin_obj.name
+ local plugin_conf = user_plugin_conf[name]
+ if type(plugin_conf) ~= "table" then
+ goto continue
+ end
+ -- Process configured plugins
+ ::continue::
+end
+```
+
+Two factors amplified the cost:
+
+1. The cost of each filtering pass increased with the number of loaded plugins.
+2. The same filtering result was regenerated across request phases. The `body_filter` and `delayed_body_filter` phases could also be entered multiple times for response-body chunks.
+
+In this test configuration, one request triggered 9 filtering passes. The same request scanned more than 100 loaded plugins for the same small configured set 9 times.
+
+This explains why a Lua-level self time of 3.0% understated the cost of the entire path:
+
+| Actual work | Common flame-graph attribution |
+|---|---|
+| The loop itself | Corresponding location in `plugin.lua` |
+| Large numbers of table lookups | `lj_BC_TGETS` |
+| Temporary table allocation | `lj_alloc_malloc` |
+| Temporary object reclamation | `gc_sweep` |
+| Uncompiled interpreter dispatch | `lj_vm_*` / `lj_BC_*` |
+
+At 3% self time, this path looked minor. Following the stack showed that it sat on a shared path entered repeatedly. The optimization followed directly: within one request, if the Global Rule and matched route have not changed, reuse the filtered plugin set instead of rebuilding it in every phase.
+
+## 4. When the Flame Graph Doesn't Add Up, Check LuaJIT
+
+The second lead came from a custom observability component. Neither the component nor its logging bypass is part of APISIX OSS, but the failure mode matters to APISIX plugins and OpenResty extensions: a call that looks cheap can add direct cost and change whether subsequent caller code runs in the interpreter or as machine code.
+
+### 4.1 Interpreter Dispatch Was Abnormally Active
+
+After reclassifying the C-level samples by runtime category, the most notable signal was not any individual Lua line, but the relationship between interpreter execution and JIT execution:
+
+| Runtime category | CPU time per request | Share of samples |
+|---|---:|---:|
+| Interpreter dispatch: `lj_BC_*` / `lj_vm_*` | 5.86 μs | 29.6% |
+| Observable JIT trace execution | 2.55 μs | 12.9% |
+
+JIT traces do not always unwind cleanly, so 12.9% is only a lower bound. Still, the amount of interpreter dispatch in the same environment was a strong signal that some high-frequency paths might not be running consistently as machine code.
+
+This is not an argument for maximizing trace count. Initialization code does not need to be compiled, and trace costs vary widely. Compilation results are worth investigating only when the CPU is saturated, the path is hot, and interpreter dispatch is unusually active.
+
+### 4.2 `jit.v` Raised the Alarm—and Caused the First Misdiagnosis
+
+With `jit.v` enabled, one short load test reported 417 successful trace compilations and 493 aborts. After aggregation by source location, a group of paths each stopped at exactly 11 aborts.
+
+These are compilation-event counts—not unique functions, coverage, or CPU time. `jit.v` also has several important limitations:
+
+- An abort line shows the trace abort location, while the penalty is applied to the trace starting point; the two may not be the same line.
+- A function never appearing as a trace starting point does not mean it was never compiled. Its body may have been inlined into a parent trace.
+- Text logs make it difficult to perform stable set comparisons between results collected with a feature enabled and disabled.
+
+At first, we read “0 appearances as a starting point” as “the whole function runs in the interpreter.” The bytecode mode of `jit.dump` proved otherwise: several function entries never became root traces, but their bodies repeatedly entered other traces. The recurring failures were in phase-entry and orchestration functions.
+
+> The absence of a trace starting point proves only that the location did not become a root-trace anchor. It does not prove that the entire function never entered machine code.
+
+### 4.3 Correlate `start`, `stop`, and `abort` by Trace Start
+
+To learn what failed to compile and why, we needed the event stream from LuaJIT itself. Store each `start` location by trace ID, then map the matching `stop` or `abort` back to that same starting point:
+
+```lua
+-- Simplified illustration; actual callback arguments and parsing are more complex
+local trace_start = {}
+
+jit.attach(function(what, trace_id, func, pc, err_code)
+ if what == "start" then
+ trace_start[trace_id] = locate(func, pc)
+ elseif what == "stop" then
+ record_compiled(trace_start[trace_id])
+ trace_start[trace_id] = nil
+ elseif what == "abort" then
+ record_abort(trace_start[trace_id], err_code)
+ trace_start[trace_id] = nil
+ end
+end, "trace")
+```
+
+The real probe also needs `jit.util.funcinfo` to resolve source locations and `jit.vmdef.traceerr` to recover abort reasons. With that data, we can compare the component's enabled and disabled states: which starting points compile, which repeatedly abort, and whether a trace flush occurs.
+
+In the LuaJIT build used here, the failure penalty started at 72 and doubled after each failure. It was 36,864 on the 10th failure and reached 73,728 on the 11th, above the 60,000 limit. Several starting points stopped at exactly 11 aborts and never entered the compiled set; no trace flush occurred during collection. The number of live traces in both test variants remained below the cache limit, ruling out trace-cache exhaustion. Together, the evidence showed that LuaJIT had abandoned further compilation attempts for those trace starts.
+
+Keep the conclusion narrow: LuaJIT abandoned those trace starts, not necessarily the entire functions. Other parts of the same functions could still have been inlined into different traces.
+
+When the data was grouped by caller, every affected phase-entry and orchestration function passed through the same custom logging bypass. When the log level was too low to emit output, this path still inspected the request phase, call stack, and request context. The added call did not produce a wide column of its own, but it changed the JIT outcome of its callers, making part of the cost appear at the heads of ordinary request-processing functions.
+
+```lua
+-- Simplified custom extension, not an APISIX OSS implementation
+if log_level_is_suppressed then
+ check_debug_capture(...)
+ check_request_buffer(...)
+ return
+end
+```
+
+The JIT data did two jobs: it exposed costs the flame graph could not attribute cleanly, and it showed that toggling the feature changed how hot paths executed. It still could not quantify the throughput loss. The goal was not to force every function to compile; it was to remove work that should never happen when the feature is disabled.
+
+One more trap: install the probe before loading the module under observation. A module can capture a function reference during `require`; replacing that function later leaves the captured reference untouched. Our late probe saw 1 call per request. Moving it before `require("apisix")` exposed the real rate: 5 calls per request.
+
+## 5. Close the Loop with Paired A/B Tests
+
+Flame graphs identify locations, call stacks reveal amplification, and LuaJIT events explain misplaced costs. Paired experiments tell us how much those paths are actually worth.
+
+For the Global Rule path, we kept the same dispatch and configuration but returned immediately after entering the Prometheus business function. This helped distinguish the cost of the plugin's business logic from that of the shared path before plugin entry. To avoid presenting absolute RPS from a customized environment as an APISIX OSS benchmark, we normalized throughput with Prometheus disabled to 100:
+
+| Internal A/B scenario | Relative throughput index | Relative to Prometheus disabled |
+|---|---:|---:|
+| Prometheus disabled | 100.0 | Baseline |
+| Plugin and dispatch retained; business function returns immediately | 77.2 | -22.8% |
+| Complete Prometheus Global Rule | 56.9 | -43.1% |
+
+The gap remained even after short-circuiting the plugin's business code. The experiment did not identify a single expensive line, but it ruled out metric calculation as the only cause. Combined with 9 filtering passes per request, the call stacks, and the C-level cost distribution, it closed the amplification chain.
+
+We used the same method for the custom observability component. With workload and configuration held constant, we collected compilation events, per-request call counts, throughput, and response correctness with the component on and off. JIT events explained why no wide new column appeared in the graph; the end-to-end A/B test measured the actual cost.
+
+The investigation comes down to five steps:
+
+| Step | Core question | Evidence |
+|---|---|---|
+| 1. Verify the worker is CPU-bound | Is the target worker actually CPU-bound? | Worker saturation, CPU pinning, spare upstream and load-generator capacity, and stable reproduction |
+| 2. Compare widths | Where do samples accumulate? | Global flame graph and candidate ranking |
+| 3. Follow stacks | What multiplies a small local cost? | Request phases, shared functions, and per-request call counts |
+| 4. Inspect LuaJIT | Why does cost appear elsewhere or go missing? | `start`, `stop`, `abort`, `flush`, and `jit.dump` |
+| 5. Run paired A/B tests | How large is the effect, and is it causal? | Throughput, latency, call counts, error rate, and response consistency |
+
+Keep the conclusions within clear boundaries:
Review Comment:
Updated in abd89cc. Replaced the editor-style lead-in with “A few limits on what this shows” and changed resource reclamation to GC behavior.
##########
blog/en/blog/2026/08/28/debugging-apisix-throughput-regression.md:
##########
@@ -0,0 +1,232 @@
+---
+title: "APISIX Throughput Regression: Beyond the Flame Graph"
+authors:
+ - name: "Xin Rong"
+ title: "Author"
+ url: "https://github.com/AlinsRan"
+ image_url: "https://github.com/AlinsRan.png"
+ - name: "Yilia Lin"
+ title: "Technical Writer"
+ url: "https://github.com/Yilialinn"
+ image_url: "https://github.com/Yilialinn.png"
+keywords:
+ - Apache APISIX
+ - flame graph
+ - LuaJIT
+ - performance optimization
+ - throughput regression
+description: "An APISIX throughput regression showed why the widest path in the flame graph is not always the real bottleneck—and how CPU data, call stacks, LuaJIT events, and paired A/B tests exposed it."
+tags: [Ecosystem]
+---
+
+Flame graphs are honest, but they are not verdicts. In this APISIX throughput regression, the widest path was not the real bottleneck. The paths worth chasing accounted for just 3.0% and 3.9% of the Lua samples.
Review Comment:
Updated in abd89cc. The opening now follows the source more closely and reads naturally without the slogan-like phrasing.
##########
blog/en/blog/2026/08/28/debugging-apisix-throughput-regression.md:
##########
@@ -0,0 +1,232 @@
+---
+title: "APISIX Throughput Regression: Beyond the Flame Graph"
+authors:
+ - name: "Xin Rong"
+ title: "Author"
+ url: "https://github.com/AlinsRan"
+ image_url: "https://github.com/AlinsRan.png"
+ - name: "Yilia Lin"
+ title: "Technical Writer"
+ url: "https://github.com/Yilialinn"
+ image_url: "https://github.com/Yilialinn.png"
+keywords:
+ - Apache APISIX
+ - flame graph
+ - LuaJIT
+ - performance optimization
+ - throughput regression
+description: "An APISIX throughput regression showed why the widest path in the flame graph is not always the real bottleneck—and how CPU data, call stacks, LuaJIT events, and paired A/B tests exposed it."
+tags: [Ecosystem]
+---
+
+Flame graphs are honest, but they are not verdicts. In this APISIX throughput regression, the widest path was not the real bottleneck. The paths worth chasing accounted for just 3.0% and 3.9% of the Lua samples.
+
+<!--truncate-->
+
+Ranked by hotspot size alone, neither path would have made the first optimization shortlist. The key was not a single patch but a chain of evidence: confirm that the worker is CPU-bound, compare flame-graph widths, follow repeated call paths, inspect LuaJIT compilation and aborts when the numbers stop adding up, and use per-request call counts plus paired A/B tests to measure the impact.
+
+> **Data scope:** The data below comes from one internally customized APISIX build in the same controlled environment, with more than 100 plugins loaded. Throughput is normalized and is used only to illustrate the diagnostic method and causal chain. It does not represent the general performance of Apache APISIX OSS under other hardware, configurations, or workloads.
+
+## 1. Confirm the Worker Is CPU-Bound
+
+A flame graph shows where the CPU spent its sampled time. Its width matters to a throughput regression only when the target worker is close to saturation and that core limits throughput.
+
+Before collecting the profile, we held the following conditions constant:
+
+- APISIX ran with a single worker pinned to a dedicated physical core.
+- The upstream service and load generator ran on other cores to avoid CPU contention.
+- The request model, configuration, and response content remained unchanged.
+- The regression reproduced consistently, with stable error rates and response results.
+- The target worker remained close to saturation, while the upstream service, network, and load generator still had headroom.
+
+If any of these conditions is not met, start with connections, the network, the upstream service, or the load generator—not an on-CPU flame graph.
+
+## 2. Read Width, Not Rank
+
+We used eBPF to sample C and Lua call stacks simultaneously at 500 Hz. Here is the actual Lua on-CPU flame graph from the investigation.
+
+
+
+Figure 1: Global view of the actual flame graph. Searching for `run_global_rules` produced 3 matches, none of which stood out in the full graph. The original profile contained 2,446 samples; process PIDs were removed from the screenshot.
+
+In a flame graph, x-axis position does not imply order. Width reflects how many samples landed on a path. Ranked by Lua self time, the first shortlist looked like this:
+
+| Candidate location | Lua self time | Initial assessment |
+|---|---:|---|
+| Prometheus exporter | 25.9% | Highest-priority candidate |
+| `ctx.var` metamethod | 15.1% | Second candidate |
+| Custom logging bypass | 3.9% | Easy to overlook |
+| `run_global_rules` | 3.0% | Easy to overlook |
+
+Here, self time is the share of samples with Lua context that landed directly at a location—not a share of total CPU time. About 19.6% of the samples lacked Lua context, so these percentages are useful for selecting candidates, not predicting throughput gains.
+
+This ranking has four blind spots:
+
+1. When LuaJIT executes interpreted code, different Lua code paths may collapse into shared `lj_BC_*` and `lj_vm_*` symbols.
+2. Compiled JIT traces cannot always be fully expanded by conventional stack unwinding, so observable JIT samples provide only a lower bound.
+3. Costs such as table lookups, memory allocation, and garbage collection are attributed to C symbols rather than to the Lua lines that triggered them.
+4. A call can change the caller's JIT state, making the resulting cost appear at the head of the caller function.
+
+There was another clue: `run_global_rules` did not form one large column. It appeared across stacks from several request phases. No single occurrence was wide, but the combined cost could still be substantial.
+
+## 3. Follow the Stack to See How 3% Gets Amplified
+
+Select a `run_global_rules` frame and follow the stack upward. The request-phase entry point sits at the bottom, followed by `common_phase`, Global Rule plugin filtering, dispatch, and finally individual plugin execution.
+
+
+
+Figure 2: Interactive zoomed view after selecting one `run_global_rules` stack. The selected stack is stretched to fill the canvas, so its width is normalized and must not be interpreted as its share of total CPU time. Click the image to view it at full size.
+
+Stack height does not represent elapsed time. It shows where the cost enters, who invokes it, and why it keeps coming back.
+
+Before the fix, `run_global_rules()` called `_M.filter()` in several request phases. `_M.filter()` iterated through every loaded plugin and checked each one to determine whether it had been configured in the Global Rule:
+
+```lua
+-- Simplified illustration, not the complete APISIX implementation
+for _, plugin_obj in ipairs(local_plugins) do
+ local name = plugin_obj.name
+ local plugin_conf = user_plugin_conf[name]
+ if type(plugin_conf) ~= "table" then
+ goto continue
+ end
+ -- Process configured plugins
+ ::continue::
+end
+```
+
+Two factors amplified the cost:
+
+1. The cost of each filtering pass increased with the number of loaded plugins.
+2. The same filtering result was regenerated across request phases. The `body_filter` and `delayed_body_filter` phases could also be entered multiple times for response-body chunks.
+
+In this test configuration, one request triggered 9 filtering passes. The same request scanned more than 100 loaded plugins for the same small configured set 9 times.
+
+This explains why a Lua-level self time of 3.0% understated the cost of the entire path:
+
+| Actual work | Common flame-graph attribution |
+|---|---|
+| The loop itself | Corresponding location in `plugin.lua` |
+| Large numbers of table lookups | `lj_BC_TGETS` |
+| Temporary table allocation | `lj_alloc_malloc` |
+| Temporary object reclamation | `gc_sweep` |
+| Uncompiled interpreter dispatch | `lj_vm_*` / `lj_BC_*` |
+
+At 3% self time, this path looked minor. Following the stack showed that it sat on a shared path entered repeatedly. The optimization followed directly: within one request, if the Global Rule and matched route have not changed, reuse the filtered plugin set instead of rebuilding it in every phase.
+
+## 4. When the Flame Graph Doesn't Add Up, Check LuaJIT
+
+The second lead came from a custom observability component. Neither the component nor its logging bypass is part of APISIX OSS, but the failure mode matters to APISIX plugins and OpenResty extensions: a call that looks cheap can add direct cost and change whether subsequent caller code runs in the interpreter or as machine code.
+
+### 4.1 Interpreter Dispatch Was Abnormally Active
+
+After reclassifying the C-level samples by runtime category, the most notable signal was not any individual Lua line, but the relationship between interpreter execution and JIT execution:
+
+| Runtime category | CPU time per request | Share of samples |
+|---|---:|---:|
+| Interpreter dispatch: `lj_BC_*` / `lj_vm_*` | 5.86 μs | 29.6% |
+| Observable JIT trace execution | 2.55 μs | 12.9% |
+
+JIT traces do not always unwind cleanly, so 12.9% is only a lower bound. Still, the amount of interpreter dispatch in the same environment was a strong signal that some high-frequency paths might not be running consistently as machine code.
+
+This is not an argument for maximizing trace count. Initialization code does not need to be compiled, and trace costs vary widely. Compilation results are worth investigating only when the CPU is saturated, the path is hot, and interpreter dispatch is unusually active.
+
+### 4.2 `jit.v` Raised the Alarm—and Caused the First Misdiagnosis
+
+With `jit.v` enabled, one short load test reported 417 successful trace compilations and 493 aborts. After aggregation by source location, a group of paths each stopped at exactly 11 aborts.
+
+These are compilation-event counts—not unique functions, coverage, or CPU time. `jit.v` also has several important limitations:
+
+- An abort line shows the trace abort location, while the penalty is applied to the trace starting point; the two may not be the same line.
+- A function never appearing as a trace starting point does not mean it was never compiled. Its body may have been inlined into a parent trace.
+- Text logs make it difficult to perform stable set comparisons between results collected with a feature enabled and disabled.
+
+At first, we read “0 appearances as a starting point” as “the whole function runs in the interpreter.” The bytecode mode of `jit.dump` proved otherwise: several function entries never became root traces, but their bodies repeatedly entered other traces. The recurring failures were in phase-entry and orchestration functions.
+
+> The absence of a trace starting point proves only that the location did not become a root-trace anchor. It does not prove that the entire function never entered machine code.
+
+### 4.3 Correlate `start`, `stop`, and `abort` by Trace Start
+
+To learn what failed to compile and why, we needed the event stream from LuaJIT itself. Store each `start` location by trace ID, then map the matching `stop` or `abort` back to that same starting point:
+
+```lua
+-- Simplified illustration; actual callback arguments and parsing are more complex
+local trace_start = {}
+
+jit.attach(function(what, trace_id, func, pc, err_code)
+ if what == "start" then
+ trace_start[trace_id] = locate(func, pc)
+ elseif what == "stop" then
+ record_compiled(trace_start[trace_id])
+ trace_start[trace_id] = nil
+ elseif what == "abort" then
+ record_abort(trace_start[trace_id], err_code)
+ trace_start[trace_id] = nil
+ end
+end, "trace")
+```
+
+The real probe also needs `jit.util.funcinfo` to resolve source locations and `jit.vmdef.traceerr` to recover abort reasons. With that data, we can compare the component's enabled and disabled states: which starting points compile, which repeatedly abort, and whether a trace flush occurs.
+
+In the LuaJIT build used here, the failure penalty started at 72 and doubled after each failure. It was 36,864 on the 10th failure and reached 73,728 on the 11th, above the 60,000 limit. Several starting points stopped at exactly 11 aborts and never entered the compiled set; no trace flush occurred during collection. The number of live traces in both test variants remained below the cache limit, ruling out trace-cache exhaustion. Together, the evidence showed that LuaJIT had abandoned further compilation attempts for those trace starts.
+
+Keep the conclusion narrow: LuaJIT abandoned those trace starts, not necessarily the entire functions. Other parts of the same functions could still have been inlined into different traces.
+
+When the data was grouped by caller, every affected phase-entry and orchestration function passed through the same custom logging bypass. When the log level was too low to emit output, this path still inspected the request phase, call stack, and request context. The added call did not produce a wide column of its own, but it changed the JIT outcome of its callers, making part of the cost appear at the heads of ordinary request-processing functions.
+
+```lua
+-- Simplified custom extension, not an APISIX OSS implementation
+if log_level_is_suppressed then
+ check_debug_capture(...)
+ check_request_buffer(...)
+ return
+end
+```
+
+The JIT data did two jobs: it exposed costs the flame graph could not attribute cleanly, and it showed that toggling the feature changed how hot paths executed. It still could not quantify the throughput loss. The goal was not to force every function to compile; it was to remove work that should never happen when the feature is disabled.
+
+One more trap: install the probe before loading the module under observation. A module can capture a function reference during `require`; replacing that function later leaves the captured reference untouched. Our late probe saw 1 call per request. Moving it before `require("apisix")` exposed the real rate: 5 calls per request.
+
+## 5. Close the Loop with Paired A/B Tests
+
+Flame graphs identify locations, call stacks reveal amplification, and LuaJIT events explain misplaced costs. Paired experiments tell us how much those paths are actually worth.
+
+For the Global Rule path, we kept the same dispatch and configuration but returned immediately after entering the Prometheus business function. This helped distinguish the cost of the plugin's business logic from that of the shared path before plugin entry. To avoid presenting absolute RPS from a customized environment as an APISIX OSS benchmark, we normalized throughput with Prometheus disabled to 100:
+
+| Internal A/B scenario | Relative throughput index | Relative to Prometheus disabled |
+|---|---:|---:|
+| Prometheus disabled | 100.0 | Baseline |
+| Plugin and dispatch retained; business function returns immediately | 77.2 | -22.8% |
+| Complete Prometheus Global Rule | 56.9 | -43.1% |
+
+The gap remained even after short-circuiting the plugin's business code. The experiment did not identify a single expensive line, but it ruled out metric calculation as the only cause. Combined with 9 filtering passes per request, the call stacks, and the C-level cost distribution, it closed the amplification chain.
+
+We used the same method for the custom observability component. With workload and configuration held constant, we collected compilation events, per-request call counts, throughput, and response correctness with the component on and off. JIT events explained why no wide new column appeared in the graph; the end-to-end A/B test measured the actual cost.
+
+The investigation comes down to five steps:
+
+| Step | Core question | Evidence |
+|---|---|---|
+| 1. Verify the worker is CPU-bound | Is the target worker actually CPU-bound? | Worker saturation, CPU pinning, spare upstream and load-generator capacity, and stable reproduction |
Review Comment:
Updated in abd89cc. The core question is now whether the flame graph can explain the regression, and the evidence column explicitly includes network headroom.
##########
blog/en/blog/2026/08/28/debugging-apisix-throughput-regression.md:
##########
@@ -0,0 +1,232 @@
+---
+title: "APISIX Throughput Regression: Beyond the Flame Graph"
+authors:
+ - name: "Xin Rong"
+ title: "Author"
+ url: "https://github.com/AlinsRan"
+ image_url: "https://github.com/AlinsRan.png"
+ - name: "Yilia Lin"
+ title: "Technical Writer"
+ url: "https://github.com/Yilialinn"
+ image_url: "https://github.com/Yilialinn.png"
+keywords:
+ - Apache APISIX
+ - flame graph
+ - LuaJIT
+ - performance optimization
+ - throughput regression
+description: "An APISIX throughput regression showed why the widest path in the flame graph is not always the real bottleneck—and how CPU data, call stacks, LuaJIT events, and paired A/B tests exposed it."
+tags: [Ecosystem]
+---
+
+Flame graphs are honest, but they are not verdicts. In this APISIX throughput regression, the widest path was not the real bottleneck. The paths worth chasing accounted for just 3.0% and 3.9% of the Lua samples.
+
+<!--truncate-->
+
+Ranked by hotspot size alone, neither path would have made the first optimization shortlist. The key was not a single patch but a chain of evidence: confirm that the worker is CPU-bound, compare flame-graph widths, follow repeated call paths, inspect LuaJIT compilation and aborts when the numbers stop adding up, and use per-request call counts plus paired A/B tests to measure the impact.
+
+> **Data scope:** The data below comes from one internally customized APISIX build in the same controlled environment, with more than 100 plugins loaded. Throughput is normalized and is used only to illustrate the diagnostic method and causal chain. It does not represent the general performance of Apache APISIX OSS under other hardware, configurations, or workloads.
+
+## 1. Confirm the Worker Is CPU-Bound
+
+A flame graph shows where the CPU spent its sampled time. Its width matters to a throughput regression only when the target worker is close to saturation and that core limits throughput.
Review Comment:
Updated in abd89cc. The sentence now says that flame-graph width can explain a throughput drop only when the worker is near saturation and that core is the bottleneck.
##########
blog/en/blog/2026/08/28/debugging-apisix-throughput-regression.md:
##########
@@ -0,0 +1,232 @@
+---
+title: "APISIX Throughput Regression: Beyond the Flame Graph"
+authors:
+ - name: "Xin Rong"
+ title: "Author"
+ url: "https://github.com/AlinsRan"
+ image_url: "https://github.com/AlinsRan.png"
+ - name: "Yilia Lin"
+ title: "Technical Writer"
+ url: "https://github.com/Yilialinn"
+ image_url: "https://github.com/Yilialinn.png"
+keywords:
+ - Apache APISIX
+ - flame graph
+ - LuaJIT
+ - performance optimization
+ - throughput regression
+description: "An APISIX throughput regression showed why the widest path in the flame graph is not always the real bottleneck—and how CPU data, call stacks, LuaJIT events, and paired A/B tests exposed it."
+tags: [Ecosystem]
+---
+
+Flame graphs are honest, but they are not verdicts. In this APISIX throughput regression, the widest path was not the real bottleneck. The paths worth chasing accounted for just 3.0% and 3.9% of the Lua samples.
+
+<!--truncate-->
+
+Ranked by hotspot size alone, neither path would have made the first optimization shortlist. The key was not a single patch but a chain of evidence: confirm that the worker is CPU-bound, compare flame-graph widths, follow repeated call paths, inspect LuaJIT compilation and aborts when the numbers stop adding up, and use per-request call counts plus paired A/B tests to measure the impact.
+
+> **Data scope:** The data below comes from one internally customized APISIX build in the same controlled environment, with more than 100 plugins loaded. Throughput is normalized and is used only to illustrate the diagnostic method and causal chain. It does not represent the general performance of Apache APISIX OSS under other hardware, configurations, or workloads.
+
+## 1. Confirm the Worker Is CPU-Bound
+
+A flame graph shows where the CPU spent its sampled time. Its width matters to a throughput regression only when the target worker is close to saturation and that core limits throughput.
+
+Before collecting the profile, we held the following conditions constant:
+
+- APISIX ran with a single worker pinned to a dedicated physical core.
+- The upstream service and load generator ran on other cores to avoid CPU contention.
+- The request model, configuration, and response content remained unchanged.
+- The regression reproduced consistently, with stable error rates and response results.
Review Comment:
Updated in abd89cc. Replaced the unnatural response-results phrasing with two direct sentences about reproducibility, error rates, and responses.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@apisix.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] blog: add APISIX throughput regression investigation (en + zh) [apisix-website]
Posted by "AlinsRan (via GitHub)" <gi...@apache.org>.
AlinsRan commented on code in PR #2115:
URL: https://github.com/apache/apisix-website/pull/2115#discussion_r3879714146
##########
blog/zh/blog/2026/08/28/debugging-apisix-throughput-regression.md:
##########
@@ -0,0 +1,234 @@
+---
+title: "火焰图没撒谎,但没直接告诉我们瓶颈:一次 APISIX 吞吐回退定位"
Review Comment:
Title: the hook plus a restatement of the topic is a generated-copy tell. A plain descriptive title works better and matches the rest of the site.
````suggestion
title: "一次 APISIX 吞吐回退排查:问题不在最宽的那根柱子上"
````
##########
blog/zh/blog/2026/08/28/debugging-apisix-throughput-regression.md:
##########
@@ -0,0 +1,234 @@
+---
+title: "火焰图没撒谎,但没直接告诉我们瓶颈:一次 APISIX 吞吐回退定位"
+authors:
+ - name: "Xin Rong"
+ title: "Author"
+ url: "https://github.com/AlinsRan"
+ image_url: "https://github.com/AlinsRan.png"
+ - name: "Yilia Lin"
+ title: "Technical Writer"
+ url: "https://github.com/Yilialinn"
+ image_url: "https://github.com/Yilialinn.png"
+keywords:
+ - Apache APISIX
+ - 火焰图
+ - LuaJIT
+ - 性能优化
+ - 吞吐回退
+description: "火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。"
+tags: [Ecosystem]
+---
+
+火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。
+
+<!--truncate-->
+
+一次吞吐回退中,火焰图最宽的路径并不是最终瓶颈。真正值得追查的两个位置,在 Lua 采样里只有 3.0% 和 3.9%。如果按热点排序,它们根本进不了第一轮优化名单。
+
+这次排查最有价值的不是某个补丁,而是一条证据链:先确认 CPU 确实是瓶颈;再横向看火焰图宽度,建立候选;然后沿调用栈纵向追,看重复调用和公共路径如何放大成本;当火焰图解释不了端到端差距时,继续查 LuaJIT 编译与中断事件;最后用每请求调用次数和配对 A/B 实验定量。
+
+> 数据范围:下文数据来自同一受控环境中的一个 APISIX 内部定制构建,其中加载了 100 余个插件。吞吐统一归一化,只用于说明定位方法与因果链,不代表 Apache APISIX OSS 在其他硬件、配置或负载下的通用表现。
+
+## 1. 先确认 CPU 瓶颈,而不是上来就看火焰图
+
+火焰图显示的是采样期间 CPU 在执行什么。只有目标 worker 的 CPU 接近饱和、吞吐受这个核限制时,火焰图的宽度才有资格解释性能回退。
+
+我们在采样前固定了这些条件:
+
+- APISIX 单 worker 并绑定独占物理核;
+- 上游服务和压测端使用其他核,避免 CPU 争抢;
+- 请求模型、配置、响应内容保持一致;
+- 吞吐回退可稳定复现,错误率和响应结果没有偏移;
+- 目标 worker 持续接近饱和,上游、网络、压测端仍有余量。
+
+如果不满足这些前提,首先应该查连接、网络、上游或压测端,而不是在 on-CPU 火焰图里找答案。
+
+## 2. 横向看宽度,而不是排名
+
+我们使用 eBPF 以 500 Hz 同时采集 C 和 Lua 调用栈。下面是排查中的真实 Lua on-CPU 火焰图。
+
+
+
+*搜索 `run_global_rules` 后命中 3 处,但这些位置在整张图里并不醒目。原始采样共 2,446 个样本;截图已移除进程 PID。*
+
+横向看火焰图,看的是宽度,不是 x 轴顺序。宽度越大,采样落在该路径上的次数越多。初步候选位置按 Lua self time 排序:
+
+| 候选位置 | Lua self time | 初判 |
+|---|---:|---|
+| Prometheus exporter | 25.9% | 最值得先看 |
+| `ctx.var` 元方法 | 15.1% | 第二候选 |
+| 自定义日志旁路 | 3.9% | 很容易被忽略 |
+| `run_global_rules` | 3.0% | 很容易被忽略 |
+
+这里的 self time 指:在带 Lua 上下文的样本中,直接落在这个位置上的比例,不是总 CPU 占比。本次采样约 19.6% 的样本缺失 Lua 上下文,所以这些百分比只用于选候选,不能直接预测吞吐收益。
+
+这个排名有四个盲区:
+
+1. LuaJIT 解释执行时,不同的 Lua 代码可能折叠到共享的 `lj_BC_*`、`lj_vm_*` 符号里。
+2. 已编译的 JIT trace 不一定能被常规栈回溯完整展开,因此可观察到的 JIT 样本只是下界。
+3. 表查找、内存分配、GC 等成本会记在 C 符号上,而不是触发的 Lua 行上。
+4. 一个调用可能改变调用方的 JIT 状态,使成本看起来落在调用方函数的头上。
+
+`run_global_rules` 还有一个特征:它没有形成一根大柱子,而是散在多个请求阶段的调用栈里。单处不宽,不等于合起来不贵。
+
+## 3. 纵向沿栈看,3% 如何被公共路径放大
+
+选中某个 `run_global_rules` 方块后,纵向看调用栈:底部是请求阶段入口,向上经过 `common_phase`、Global Rule 插件筛选、调度,再到具体插件执行。
+
+
+
+*所选栈会被重新铺满画布,因此图中的宽度已经归一化,不能再当作它占总 CPU 的比例。*
+
+纵向高度本身不代表耗时。它的作用是看清成本从哪里进入、被谁调用、为什么反复出现。
+
+修复前,`run_global_rules()` 会在请求的多个阶段调用 `_M.filter()`。`_M.filter()` 遍历所有已加载插件,再逐个判断该插件是否被 Global Rule 配置:
Review Comment:
Same pattern: the heading and the closing line are built as matched couplets (横向/纵向, `单处不宽,不等于合起来不贵`). Reworded, and the Figure 2 caption made plain.
````suggestion
这里的 self time 指的是:在能拿到 Lua 上下文的样本里,直接落在这个位置上的比例,不是它占总 CPU 的比例。这次采样有 19.6% 的样本没有 Lua 上下文,所以这几个数字只够用来挑候选,不能拿去推算优化能换回多少吞吐。
排在前面也不代表就该先动它,这个排名有四个盲区:
1. LuaJIT 解释执行的时候,不同的 Lua 代码可能折叠进同一批 `lj_BC_*`、`lj_vm_*` 符号里。
2. 已编译的 JIT trace 不一定能被常规栈回溯完整展开,所以看得见的 JIT 样本只是个下界。
3. 表查找、内存分配、GC 这些开销记在 C 符号上,而不是触发它们的那行 Lua 上。
4. 一次调用可能改变调用方的 JIT 状态,于是开销看起来落在了调用方函数的头上。
`run_global_rules` 还有个当时没在意的特征:它没有形成一根显眼的柱子,而是散在好几个请求阶段的栈里。每一处都不宽,加起来是多少就不好说了。
## 3. 顺着调用栈往上:3% 是怎么被放大的
点开其中一个 `run_global_rules` 方块,把这条栈单独放大看:底下是请求阶段的入口,往上依次是 `common_phase`、Global Rule 的插件筛选、调度,最后才轮到具体插件。

图 2:选中一条 `run_global_rules` 栈之后的放大视图。所选栈会被重新铺满画布,宽度已经归一化,不能再当成它占总 CPU 的比例。点击图片可查看大图。
栈的高度本身跟耗时没关系。它的用处是看清楚这段开销从哪儿进来、被谁调用、为什么会反复出现。
修复之前,`run_global_rules()` 会在请求的好几个阶段各调一次 `_M.filter()`。而 `_M.filter()` 的做法是遍历所有已加载的插件,挨个判断这个插件有没有被 Global Rule 配置过:
````
##########
blog/zh/blog/2026/08/28/debugging-apisix-throughput-regression.md:
##########
@@ -0,0 +1,234 @@
+---
+title: "火焰图没撒谎,但没直接告诉我们瓶颈:一次 APISIX 吞吐回退定位"
+authors:
+ - name: "Xin Rong"
+ title: "Author"
+ url: "https://github.com/AlinsRan"
+ image_url: "https://github.com/AlinsRan.png"
+ - name: "Yilia Lin"
+ title: "Technical Writer"
+ url: "https://github.com/Yilialinn"
+ image_url: "https://github.com/Yilialinn.png"
+keywords:
+ - Apache APISIX
+ - 火焰图
+ - LuaJIT
+ - 性能优化
+ - 吞吐回退
+description: "火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。"
+tags: [Ecosystem]
+---
+
+火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。
+
+<!--truncate-->
+
+一次吞吐回退中,火焰图最宽的路径并不是最终瓶颈。真正值得追查的两个位置,在 Lua 采样里只有 3.0% 和 3.9%。如果按热点排序,它们根本进不了第一轮优化名单。
+
+这次排查最有价值的不是某个补丁,而是一条证据链:先确认 CPU 确实是瓶颈;再横向看火焰图宽度,建立候选;然后沿调用栈纵向追,看重复调用和公共路径如何放大成本;当火焰图解释不了端到端差距时,继续查 LuaJIT 编译与中断事件;最后用每请求调用次数和配对 A/B 实验定量。
+
+> 数据范围:下文数据来自同一受控环境中的一个 APISIX 内部定制构建,其中加载了 100 余个插件。吞吐统一归一化,只用于说明定位方法与因果链,不代表 Apache APISIX OSS 在其他硬件、配置或负载下的通用表现。
+
+## 1. 先确认 CPU 瓶颈,而不是上来就看火焰图
+
+火焰图显示的是采样期间 CPU 在执行什么。只有目标 worker 的 CPU 接近饱和、吞吐受这个核限制时,火焰图的宽度才有资格解释性能回退。
+
+我们在采样前固定了这些条件:
+
+- APISIX 单 worker 并绑定独占物理核;
+- 上游服务和压测端使用其他核,避免 CPU 争抢;
+- 请求模型、配置、响应内容保持一致;
+- 吞吐回退可稳定复现,错误率和响应结果没有偏移;
+- 目标 worker 持续接近饱和,上游、网络、压测端仍有余量。
+
+如果不满足这些前提,首先应该查连接、网络、上游或压测端,而不是在 on-CPU 火焰图里找答案。
+
+## 2. 横向看宽度,而不是排名
+
+我们使用 eBPF 以 500 Hz 同时采集 C 和 Lua 调用栈。下面是排查中的真实 Lua on-CPU 火焰图。
+
+
+
+*搜索 `run_global_rules` 后命中 3 处,但这些位置在整张图里并不醒目。原始采样共 2,446 个样本;截图已移除进程 PID。*
+
+横向看火焰图,看的是宽度,不是 x 轴顺序。宽度越大,采样落在该路径上的次数越多。初步候选位置按 Lua self time 排序:
+
+| 候选位置 | Lua self time | 初判 |
+|---|---:|---|
+| Prometheus exporter | 25.9% | 最值得先看 |
+| `ctx.var` 元方法 | 15.1% | 第二候选 |
+| 自定义日志旁路 | 3.9% | 很容易被忽略 |
+| `run_global_rules` | 3.0% | 很容易被忽略 |
+
+这里的 self time 指:在带 Lua 上下文的样本中,直接落在这个位置上的比例,不是总 CPU 占比。本次采样约 19.6% 的样本缺失 Lua 上下文,所以这些百分比只用于选候选,不能直接预测吞吐收益。
+
+这个排名有四个盲区:
+
+1. LuaJIT 解释执行时,不同的 Lua 代码可能折叠到共享的 `lj_BC_*`、`lj_vm_*` 符号里。
+2. 已编译的 JIT trace 不一定能被常规栈回溯完整展开,因此可观察到的 JIT 样本只是下界。
+3. 表查找、内存分配、GC 等成本会记在 C 符号上,而不是触发的 Lua 行上。
+4. 一个调用可能改变调用方的 JIT 状态,使成本看起来落在调用方函数的头上。
+
+`run_global_rules` 还有一个特征:它没有形成一根大柱子,而是散在多个请求阶段的调用栈里。单处不宽,不等于合起来不贵。
+
+## 3. 纵向沿栈看,3% 如何被公共路径放大
+
+选中某个 `run_global_rules` 方块后,纵向看调用栈:底部是请求阶段入口,向上经过 `common_phase`、Global Rule 插件筛选、调度,再到具体插件执行。
+
+
+
+*所选栈会被重新铺满画布,因此图中的宽度已经归一化,不能再当作它占总 CPU 的比例。*
+
+纵向高度本身不代表耗时。它的作用是看清成本从哪里进入、被谁调用、为什么反复出现。
+
+修复前,`run_global_rules()` 会在请求的多个阶段调用 `_M.filter()`。`_M.filter()` 遍历所有已加载插件,再逐个判断该插件是否被 Global Rule 配置:
+
+```lua
+-- 简化示意,非 APISIX 完整实现
+for _, plugin_obj in ipairs(local_plugins) do
+ local name = plugin_obj.name
+ local plugin_conf = user_plugin_conf[name]
+ if type(plugin_conf) ~= "table" then
+ goto continue
+ end
+ -- 处理已配置插件
+ ::continue::
+end
+```
+
+两个放大器都在这里:
+
+1. 单次过滤成本随已加载插件数量的增长。
+2. 相同过滤结果跨请求阶段重复生成;`body_filter` 与 `delayed_body_filter` 还可能按响应块多次进入。
+
+本次测试配置中,一个请求实际触发了 **9 次过滤**。也就是说,“从 100+ 插件中找出已配置的少量插件”这件事,被同一个请求重复了 9 次。
+
+这解释了为什么 Lua 层 3.0% 会低估整条路径成本:
+
+| 实际工作 | 火焰图常见归属 |
+|---|---|
+| 循环本身 | `plugin.lua` 对应位置 |
+| 大量表查找 | `lj_BC_TGETS` |
+| 临时表分配 | `lj_alloc_malloc` |
+| 临时对象回收 | `gc_sweep` |
+| 未编译解释器派发 | `lj_vm_*` / `lj_BC_*` |
+
+横向看,它只是 3% 的候选;纵向看,才会发现它位于一条被反复进入的公共路径。优化方向也很直接:同一请求内,如果 Global Rule 和匹配路由没有变化,就复用已筛选的插件集合,而不是每个阶段重新生成。
+
+## 4. 火焰图解释不了差距时,查 LuaJIT 编译事件
+
+第二条路径来自自定义观测组件。它和日志旁路不属于 APISIX OSS,但揭示的问题对 APISIX 插件和 OpenResty 扩展有参考价值:一次看似很轻的调用,既可能产生直接成本,也可能改变调用方之后以解释器还是机器码运行。
+
+### 4.1 解释器派发异常活跃
+
+把 C 层样本按运行时类型重新分类,最值得注意的不是某行 Lua,而是解释器与 JIT 的关系:
Review Comment:
The 横向看…纵向看 couplet again, plus a section heading that spells out the whole thesis. Shortened.
````suggestion
| 未编译的解释器派发 | `lj_vm_*` / `lj_BC_*` |
只看宽度它就是个 3% 的小候选,顺着栈看才发现它蹲在一条被反复进入的公共路径上。改法也就摆在那儿了:同一个请求里,只要 Global Rule 和匹配到的路由没变,就把筛过一次的插件集合留下来复用,别每个阶段重算一遍。
## 4. 火焰图对不上账的时候,去查 LuaJIT
第二条线索来自一个自定义的观测组件。这个组件和它的日志旁路都不属于 APISIX OSS,但它踩的坑对写 APISIX 插件和 OpenResty 扩展的人都有参考价值:一次看上去很轻的调用,除了自己那点开销,还可能改变调用方后面是跑解释器还是跑机器码。
### 4.1 解释器派发活跃得反常
把 C 层的样本按运行时类型重新归类之后,最扎眼的不是哪一行 Lua,而是解释器和 JIT 的比例:
````
##########
blog/zh/blog/2026/08/28/debugging-apisix-throughput-regression.md:
##########
@@ -0,0 +1,234 @@
+---
+title: "火焰图没撒谎,但没直接告诉我们瓶颈:一次 APISIX 吞吐回退定位"
+authors:
+ - name: "Xin Rong"
+ title: "Author"
+ url: "https://github.com/AlinsRan"
+ image_url: "https://github.com/AlinsRan.png"
+ - name: "Yilia Lin"
+ title: "Technical Writer"
+ url: "https://github.com/Yilialinn"
+ image_url: "https://github.com/Yilialinn.png"
+keywords:
+ - Apache APISIX
+ - 火焰图
+ - LuaJIT
+ - 性能优化
+ - 吞吐回退
+description: "火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。"
+tags: [Ecosystem]
+---
+
+火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。
+
+<!--truncate-->
+
+一次吞吐回退中,火焰图最宽的路径并不是最终瓶颈。真正值得追查的两个位置,在 Lua 采样里只有 3.0% 和 3.9%。如果按热点排序,它们根本进不了第一轮优化名单。
+
+这次排查最有价值的不是某个补丁,而是一条证据链:先确认 CPU 确实是瓶颈;再横向看火焰图宽度,建立候选;然后沿调用栈纵向追,看重复调用和公共路径如何放大成本;当火焰图解释不了端到端差距时,继续查 LuaJIT 编译与中断事件;最后用每请求调用次数和配对 A/B 实验定量。
+
+> 数据范围:下文数据来自同一受控环境中的一个 APISIX 内部定制构建,其中加载了 100 余个插件。吞吐统一归一化,只用于说明定位方法与因果链,不代表 Apache APISIX OSS 在其他硬件、配置或负载下的通用表现。
+
+## 1. 先确认 CPU 瓶颈,而不是上来就看火焰图
+
+火焰图显示的是采样期间 CPU 在执行什么。只有目标 worker 的 CPU 接近饱和、吞吐受这个核限制时,火焰图的宽度才有资格解释性能回退。
+
+我们在采样前固定了这些条件:
+
+- APISIX 单 worker 并绑定独占物理核;
+- 上游服务和压测端使用其他核,避免 CPU 争抢;
+- 请求模型、配置、响应内容保持一致;
+- 吞吐回退可稳定复现,错误率和响应结果没有偏移;
+- 目标 worker 持续接近饱和,上游、网络、压测端仍有余量。
+
+如果不满足这些前提,首先应该查连接、网络、上游或压测端,而不是在 on-CPU 火焰图里找答案。
+
+## 2. 横向看宽度,而不是排名
+
+我们使用 eBPF 以 500 Hz 同时采集 C 和 Lua 调用栈。下面是排查中的真实 Lua on-CPU 火焰图。
+
+
+
+*搜索 `run_global_rules` 后命中 3 处,但这些位置在整张图里并不醒目。原始采样共 2,446 个样本;截图已移除进程 PID。*
+
+横向看火焰图,看的是宽度,不是 x 轴顺序。宽度越大,采样落在该路径上的次数越多。初步候选位置按 Lua self time 排序:
+
+| 候选位置 | Lua self time | 初判 |
+|---|---:|---|
+| Prometheus exporter | 25.9% | 最值得先看 |
+| `ctx.var` 元方法 | 15.1% | 第二候选 |
+| 自定义日志旁路 | 3.9% | 很容易被忽略 |
+| `run_global_rules` | 3.0% | 很容易被忽略 |
+
+这里的 self time 指:在带 Lua 上下文的样本中,直接落在这个位置上的比例,不是总 CPU 占比。本次采样约 19.6% 的样本缺失 Lua 上下文,所以这些百分比只用于选候选,不能直接预测吞吐收益。
+
+这个排名有四个盲区:
+
+1. LuaJIT 解释执行时,不同的 Lua 代码可能折叠到共享的 `lj_BC_*`、`lj_vm_*` 符号里。
+2. 已编译的 JIT trace 不一定能被常规栈回溯完整展开,因此可观察到的 JIT 样本只是下界。
+3. 表查找、内存分配、GC 等成本会记在 C 符号上,而不是触发的 Lua 行上。
+4. 一个调用可能改变调用方的 JIT 状态,使成本看起来落在调用方函数的头上。
+
+`run_global_rules` 还有一个特征:它没有形成一根大柱子,而是散在多个请求阶段的调用栈里。单处不宽,不等于合起来不贵。
+
+## 3. 纵向沿栈看,3% 如何被公共路径放大
+
+选中某个 `run_global_rules` 方块后,纵向看调用栈:底部是请求阶段入口,向上经过 `common_phase`、Global Rule 插件筛选、调度,再到具体插件执行。
+
+
+
+*所选栈会被重新铺满画布,因此图中的宽度已经归一化,不能再当作它占总 CPU 的比例。*
+
+纵向高度本身不代表耗时。它的作用是看清成本从哪里进入、被谁调用、为什么反复出现。
+
+修复前,`run_global_rules()` 会在请求的多个阶段调用 `_M.filter()`。`_M.filter()` 遍历所有已加载插件,再逐个判断该插件是否被 Global Rule 配置:
+
+```lua
+-- 简化示意,非 APISIX 完整实现
+for _, plugin_obj in ipairs(local_plugins) do
+ local name = plugin_obj.name
+ local plugin_conf = user_plugin_conf[name]
+ if type(plugin_conf) ~= "table" then
+ goto continue
+ end
+ -- 处理已配置插件
+ ::continue::
+end
+```
+
+两个放大器都在这里:
+
+1. 单次过滤成本随已加载插件数量的增长。
+2. 相同过滤结果跨请求阶段重复生成;`body_filter` 与 `delayed_body_filter` 还可能按响应块多次进入。
+
+本次测试配置中,一个请求实际触发了 **9 次过滤**。也就是说,“从 100+ 插件中找出已配置的少量插件”这件事,被同一个请求重复了 9 次。
+
+这解释了为什么 Lua 层 3.0% 会低估整条路径成本:
+
+| 实际工作 | 火焰图常见归属 |
+|---|---|
+| 循环本身 | `plugin.lua` 对应位置 |
+| 大量表查找 | `lj_BC_TGETS` |
+| 临时表分配 | `lj_alloc_malloc` |
+| 临时对象回收 | `gc_sweep` |
+| 未编译解释器派发 | `lj_vm_*` / `lj_BC_*` |
+
+横向看,它只是 3% 的候选;纵向看,才会发现它位于一条被反复进入的公共路径。优化方向也很直接:同一请求内,如果 Global Rule 和匹配路由没有变化,就复用已筛选的插件集合,而不是每个阶段重新生成。
+
+## 4. 火焰图解释不了差距时,查 LuaJIT 编译事件
+
+第二条路径来自自定义观测组件。它和日志旁路不属于 APISIX OSS,但揭示的问题对 APISIX 插件和 OpenResty 扩展有参考价值:一次看似很轻的调用,既可能产生直接成本,也可能改变调用方之后以解释器还是机器码运行。
+
+### 4.1 解释器派发异常活跃
+
+把 C 层样本按运行时类型重新分类,最值得注意的不是某行 Lua,而是解释器与 JIT 的关系:
+
+| 运行类别 | 每请求 CPU 时间 | 占本次样本 |
+|---|---:|---:|
+| 解释器派发:`lj_BC_*` / `lj_vm_*` | 5.86 μs | 29.6% |
+| 可观察到的 JIT trace 执行 | 2.55 μs | 12.9% |
+
+JIT trace 难以完整 unwind,12.9% 只能看作下界。但在同一环境中,解释器派发如此活跃,已经构成强信号:某些高频路径可能没有稳定地跑在机器码里。
+
+这不等于 trace 越多越好。初始化代码不需要追求编译,不同 trace 的成本差异也很大。只有在 CPU 饱和、路径高频、解释器派发异常时,编译结果才值得深挖。
+
+### 4.2 `jit.v` 发出警报,也制造了第一次误判
+
+开启 `jit.v` 后,一次短时压测输出中看到 417 次 trace 编译成功、493 次 abort。按位置聚合后,一批路径的 abort 次数整齐停在 11。
+
+但这是编译事件数,不是唯一函数数、覆盖率或 CPU 时间。`jit.v` 有几个边界:
+
+- abort 行显示的是 trace 中断位置,而惩罚记在 trace 起点,两者可能不是同一行。
+- 某函数从未成为 trace 起点,不代表它没有被编译,函数体可能已被内联进父 trace。
+- 文本日志很难对功能开启/关闭两组结果做稳定的集合差。
+
+我们一开始也把“起点出现次数为 0”误读成“整个函数都在解释执行”。后来用 `jit.dump` 字节码模式复核,才发现多个函数入口没有成为 root trace,但函数体已多次进入其他 trace。真正反复失败的是阶段入口和编排函数。
+
+> 看不到 trace 起点,只能证明这里没有成为 root trace 锚点;不能证明整个函数没有进入机器码。
+
+### 4.3 把 `start`、`stop`、`abort` 归回同一起点
+
+要回答“哪段代码没有编译、为什么”,需要让 LuaJIT 自己交事件流。核心做法是按 trace id 保存 `start` 的位置,再把后续 `stop` 或 `abort` 归回同一个起点:
Review Comment:
De-jargoning only. Keeps the misdiagnosis in 4.2 — it is the most readable part of the article — but phrases it as something that happened rather than a lesson being delivered.
````suggestion
JIT trace 的栈不一定能完整 unwind,12.9% 只能算下界。但在同一个环境里,解释器派发能占到这个量,本身就是个够强的信号:有些高频路径大概没稳定跑在机器码里。
这不是说 trace 越多越好。初始化的代码本来就不必追求编译,不同 trace 之间的开销差别也很大。只有在 CPU 已经饱和、路径确实高频、解释器派发又反常的时候,编译结果才值得往下挖。
### 4.2 `jit.v` 报了警,也把我们带沟里过一次
打开 `jit.v` 跑一轮短压测,输出里有 417 次 trace 编译成功、493 次 abort。按位置聚合之后,一批路径的 abort 次数整整齐齐停在 11。
但这些是编译事件的次数,不是函数个数,也不是覆盖率或者 CPU 时间。`jit.v` 本身还有几个边界:
- abort 行显示的是 trace 中断的位置,而惩罚记在 trace 起点上,两者可能不是同一行。
- 某个函数从没当过 trace 起点,不代表它没被编译,函数体可能已经内联进父 trace 了。
- 文本日志很难对功能开和关两组结果做稳定的集合差。
我们一开始就把“起点出现次数为 0”读成了“整个函数都在解释执行”。后来用 `jit.dump` 的字节码模式复核才发现不是这么回事:好几个函数入口确实没成为 root trace,但函数体已经多次进过别的 trace。真正在反复失败的是阶段入口和编排函数。
> 看不到 trace 起点,只能说明这里没当上 root trace 的锚点,不能说明整个函数没进过机器码。
### 4.3 把 `start`、`stop`、`abort` 归回同一个起点
想知道到底哪段代码没编译成、为什么没编译成,得让 LuaJIT 自己把事件流交出来。思路不复杂:按 trace id 把 `start` 的位置记下来,后面的 `stop` 或 `abort` 再归回这个起点。
````
##########
blog/zh/blog/2026/08/28/debugging-apisix-throughput-regression.md:
##########
@@ -0,0 +1,234 @@
+---
+title: "火焰图没撒谎,但没直接告诉我们瓶颈:一次 APISIX 吞吐回退定位"
+authors:
+ - name: "Xin Rong"
+ title: "Author"
+ url: "https://github.com/AlinsRan"
+ image_url: "https://github.com/AlinsRan.png"
+ - name: "Yilia Lin"
+ title: "Technical Writer"
+ url: "https://github.com/Yilialinn"
+ image_url: "https://github.com/Yilialinn.png"
+keywords:
+ - Apache APISIX
+ - 火焰图
+ - LuaJIT
+ - 性能优化
+ - 吞吐回退
+description: "火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。"
+tags: [Ecosystem]
+---
+
+火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。
+
+<!--truncate-->
+
+一次吞吐回退中,火焰图最宽的路径并不是最终瓶颈。真正值得追查的两个位置,在 Lua 采样里只有 3.0% 和 3.9%。如果按热点排序,它们根本进不了第一轮优化名单。
+
+这次排查最有价值的不是某个补丁,而是一条证据链:先确认 CPU 确实是瓶颈;再横向看火焰图宽度,建立候选;然后沿调用栈纵向追,看重复调用和公共路径如何放大成本;当火焰图解释不了端到端差距时,继续查 LuaJIT 编译与中断事件;最后用每请求调用次数和配对 A/B 实验定量。
+
+> 数据范围:下文数据来自同一受控环境中的一个 APISIX 内部定制构建,其中加载了 100 余个插件。吞吐统一归一化,只用于说明定位方法与因果链,不代表 Apache APISIX OSS 在其他硬件、配置或负载下的通用表现。
+
+## 1. 先确认 CPU 瓶颈,而不是上来就看火焰图
+
+火焰图显示的是采样期间 CPU 在执行什么。只有目标 worker 的 CPU 接近饱和、吞吐受这个核限制时,火焰图的宽度才有资格解释性能回退。
+
+我们在采样前固定了这些条件:
+
+- APISIX 单 worker 并绑定独占物理核;
+- 上游服务和压测端使用其他核,避免 CPU 争抢;
+- 请求模型、配置、响应内容保持一致;
+- 吞吐回退可稳定复现,错误率和响应结果没有偏移;
+- 目标 worker 持续接近饱和,上游、网络、压测端仍有余量。
+
+如果不满足这些前提,首先应该查连接、网络、上游或压测端,而不是在 on-CPU 火焰图里找答案。
+
+## 2. 横向看宽度,而不是排名
+
+我们使用 eBPF 以 500 Hz 同时采集 C 和 Lua 调用栈。下面是排查中的真实 Lua on-CPU 火焰图。
+
+
+
+*搜索 `run_global_rules` 后命中 3 处,但这些位置在整张图里并不醒目。原始采样共 2,446 个样本;截图已移除进程 PID。*
+
+横向看火焰图,看的是宽度,不是 x 轴顺序。宽度越大,采样落在该路径上的次数越多。初步候选位置按 Lua self time 排序:
Review Comment:
This opening block is the main source of the machine-written tone. `火焰图没有撒谎` appears in `description`, in the lead, and again in the conclusion; `证据链` and the 横向/纵向 framing are methodology vocabulary rather than a description of what happened. Rewritten as a plain account. This also turns the Figure 1 caption into a real caption instead of an all-italic paragraph.
````suggestion
description: "一次 APISIX 吞吐回退的排查记录。火焰图里最宽的两根柱子都不是原因,真正的问题在 Lua 采样里只占 3.0% 和 3.9%,得靠调用栈、LuaJIT 编译事件和 A/B 实验才挖得出来。"
tags: [Ecosystem]
---
一次 APISIX 吞吐回退的排查记录。火焰图里最宽的两根柱子都不是原因,真正的问题在 Lua 采样里只占 3.0% 和 3.9%,得靠调用栈、LuaJIT 编译事件和 A/B 实验才挖得出来。
<!--truncate-->
按热点大小排队,这两处根本进不了第一轮优化名单。
这次排查留下来的东西不是某个补丁,而是一套翻查顺序:先确认瓶颈真的在 CPU 上;再看火焰图的宽度挑候选;然后顺着调用栈往上,看这点开销是怎么被公共路径放大的;等火焰图解释不了端到端的差距了,就去查 LuaJIT 的编译和中断事件;最后用每请求调用次数和配对 A/B 把量级定下来。
> 数据说明:下文的数据来自同一个受控环境里的一个 APISIX 内部定制构建,加载了 100 多个插件。吞吐做了归一化,只用来说明排查方法和因果关系,不代表 Apache APISIX OSS 在其他硬件、配置或负载下的表现。
## 1. 先确认瓶颈真在 CPU 上
火焰图只能告诉你采样期间 CPU 在跑什么。如果 worker 根本没跑满,吞吐卡在别的地方,那再宽的柱子也解释不了这次回退。
所以采样之前,我们把环境固定成这样:
- APISIX 单 worker,绑一个独占的物理核;
- 上游服务和压测端跑在别的核上,避免 CPU 争抢;
- 请求模型、配置、响应内容三样都不变;
- 吞吐回退能稳定复现,错误率和响应结果没有偏移;
- 目标 worker 一直接近饱和,而上游、网络、压测端都还有余量。
这几条只要有一条不满足,就该先去查连接、网络、上游或者压测端,而不是抓一张 on-CPU 火焰图慢慢看。
## 2. 看宽度,不是看排名
我们用 eBPF 以 500 Hz 同时采 C 和 Lua 的调用栈。下面是这次排查里真实的 Lua on-CPU 火焰图。

图 1:火焰图全局视图。搜索 `run_global_rules` 命中 3 处,但在整张图里都不显眼。原始采样共 2,446 个样本,截图去掉了进程 PID。
火焰图的 x 轴没有先后关系,有意义的只是宽度:一条路径越宽,说明落在它上面的采样越多。按 Lua self time 排下来,第一轮候选是这样的:
````
##########
blog/zh/blog/2026/08/28/debugging-apisix-throughput-regression.md:
##########
@@ -0,0 +1,234 @@
+---
+title: "火焰图没撒谎,但没直接告诉我们瓶颈:一次 APISIX 吞吐回退定位"
+authors:
+ - name: "Xin Rong"
+ title: "Author"
+ url: "https://github.com/AlinsRan"
+ image_url: "https://github.com/AlinsRan.png"
+ - name: "Yilia Lin"
+ title: "Technical Writer"
+ url: "https://github.com/Yilialinn"
+ image_url: "https://github.com/Yilialinn.png"
+keywords:
+ - Apache APISIX
+ - 火焰图
+ - LuaJIT
+ - 性能优化
+ - 吞吐回退
+description: "火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。"
+tags: [Ecosystem]
+---
+
+火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。
+
+<!--truncate-->
+
+一次吞吐回退中,火焰图最宽的路径并不是最终瓶颈。真正值得追查的两个位置,在 Lua 采样里只有 3.0% 和 3.9%。如果按热点排序,它们根本进不了第一轮优化名单。
+
+这次排查最有价值的不是某个补丁,而是一条证据链:先确认 CPU 确实是瓶颈;再横向看火焰图宽度,建立候选;然后沿调用栈纵向追,看重复调用和公共路径如何放大成本;当火焰图解释不了端到端差距时,继续查 LuaJIT 编译与中断事件;最后用每请求调用次数和配对 A/B 实验定量。
+
+> 数据范围:下文数据来自同一受控环境中的一个 APISIX 内部定制构建,其中加载了 100 余个插件。吞吐统一归一化,只用于说明定位方法与因果链,不代表 Apache APISIX OSS 在其他硬件、配置或负载下的通用表现。
+
+## 1. 先确认 CPU 瓶颈,而不是上来就看火焰图
+
+火焰图显示的是采样期间 CPU 在执行什么。只有目标 worker 的 CPU 接近饱和、吞吐受这个核限制时,火焰图的宽度才有资格解释性能回退。
+
+我们在采样前固定了这些条件:
+
+- APISIX 单 worker 并绑定独占物理核;
+- 上游服务和压测端使用其他核,避免 CPU 争抢;
+- 请求模型、配置、响应内容保持一致;
+- 吞吐回退可稳定复现,错误率和响应结果没有偏移;
+- 目标 worker 持续接近饱和,上游、网络、压测端仍有余量。
+
+如果不满足这些前提,首先应该查连接、网络、上游或压测端,而不是在 on-CPU 火焰图里找答案。
+
+## 2. 横向看宽度,而不是排名
+
+我们使用 eBPF 以 500 Hz 同时采集 C 和 Lua 调用栈。下面是排查中的真实 Lua on-CPU 火焰图。
+
+
+
+*搜索 `run_global_rules` 后命中 3 处,但这些位置在整张图里并不醒目。原始采样共 2,446 个样本;截图已移除进程 PID。*
+
+横向看火焰图,看的是宽度,不是 x 轴顺序。宽度越大,采样落在该路径上的次数越多。初步候选位置按 Lua self time 排序:
+
+| 候选位置 | Lua self time | 初判 |
+|---|---:|---|
+| Prometheus exporter | 25.9% | 最值得先看 |
+| `ctx.var` 元方法 | 15.1% | 第二候选 |
+| 自定义日志旁路 | 3.9% | 很容易被忽略 |
+| `run_global_rules` | 3.0% | 很容易被忽略 |
+
+这里的 self time 指:在带 Lua 上下文的样本中,直接落在这个位置上的比例,不是总 CPU 占比。本次采样约 19.6% 的样本缺失 Lua 上下文,所以这些百分比只用于选候选,不能直接预测吞吐收益。
+
+这个排名有四个盲区:
+
+1. LuaJIT 解释执行时,不同的 Lua 代码可能折叠到共享的 `lj_BC_*`、`lj_vm_*` 符号里。
+2. 已编译的 JIT trace 不一定能被常规栈回溯完整展开,因此可观察到的 JIT 样本只是下界。
+3. 表查找、内存分配、GC 等成本会记在 C 符号上,而不是触发的 Lua 行上。
+4. 一个调用可能改变调用方的 JIT 状态,使成本看起来落在调用方函数的头上。
+
+`run_global_rules` 还有一个特征:它没有形成一根大柱子,而是散在多个请求阶段的调用栈里。单处不宽,不等于合起来不贵。
+
+## 3. 纵向沿栈看,3% 如何被公共路径放大
+
+选中某个 `run_global_rules` 方块后,纵向看调用栈:底部是请求阶段入口,向上经过 `common_phase`、Global Rule 插件筛选、调度,再到具体插件执行。
+
+
+
+*所选栈会被重新铺满画布,因此图中的宽度已经归一化,不能再当作它占总 CPU 的比例。*
+
+纵向高度本身不代表耗时。它的作用是看清成本从哪里进入、被谁调用、为什么反复出现。
+
+修复前,`run_global_rules()` 会在请求的多个阶段调用 `_M.filter()`。`_M.filter()` 遍历所有已加载插件,再逐个判断该插件是否被 Global Rule 配置:
+
+```lua
+-- 简化示意,非 APISIX 完整实现
+for _, plugin_obj in ipairs(local_plugins) do
+ local name = plugin_obj.name
+ local plugin_conf = user_plugin_conf[name]
+ if type(plugin_conf) ~= "table" then
+ goto continue
+ end
+ -- 处理已配置插件
+ ::continue::
+end
+```
+
+两个放大器都在这里:
+
+1. 单次过滤成本随已加载插件数量的增长。
+2. 相同过滤结果跨请求阶段重复生成;`body_filter` 与 `delayed_body_filter` 还可能按响应块多次进入。
+
+本次测试配置中,一个请求实际触发了 **9 次过滤**。也就是说,“从 100+ 插件中找出已配置的少量插件”这件事,被同一个请求重复了 9 次。
+
+这解释了为什么 Lua 层 3.0% 会低估整条路径成本:
+
+| 实际工作 | 火焰图常见归属 |
+|---|---|
+| 循环本身 | `plugin.lua` 对应位置 |
+| 大量表查找 | `lj_BC_TGETS` |
+| 临时表分配 | `lj_alloc_malloc` |
+| 临时对象回收 | `gc_sweep` |
+| 未编译解释器派发 | `lj_vm_*` / `lj_BC_*` |
+
+横向看,它只是 3% 的候选;纵向看,才会发现它位于一条被反复进入的公共路径。优化方向也很直接:同一请求内,如果 Global Rule 和匹配路由没有变化,就复用已筛选的插件集合,而不是每个阶段重新生成。
+
+## 4. 火焰图解释不了差距时,查 LuaJIT 编译事件
+
+第二条路径来自自定义观测组件。它和日志旁路不属于 APISIX OSS,但揭示的问题对 APISIX 插件和 OpenResty 扩展有参考价值:一次看似很轻的调用,既可能产生直接成本,也可能改变调用方之后以解释器还是机器码运行。
+
+### 4.1 解释器派发异常活跃
+
+把 C 层样本按运行时类型重新分类,最值得注意的不是某行 Lua,而是解释器与 JIT 的关系:
+
+| 运行类别 | 每请求 CPU 时间 | 占本次样本 |
+|---|---:|---:|
+| 解释器派发:`lj_BC_*` / `lj_vm_*` | 5.86 μs | 29.6% |
+| 可观察到的 JIT trace 执行 | 2.55 μs | 12.9% |
+
+JIT trace 难以完整 unwind,12.9% 只能看作下界。但在同一环境中,解释器派发如此活跃,已经构成强信号:某些高频路径可能没有稳定地跑在机器码里。
+
+这不等于 trace 越多越好。初始化代码不需要追求编译,不同 trace 的成本差异也很大。只有在 CPU 饱和、路径高频、解释器派发异常时,编译结果才值得深挖。
+
+### 4.2 `jit.v` 发出警报,也制造了第一次误判
+
+开启 `jit.v` 后,一次短时压测输出中看到 417 次 trace 编译成功、493 次 abort。按位置聚合后,一批路径的 abort 次数整齐停在 11。
+
+但这是编译事件数,不是唯一函数数、覆盖率或 CPU 时间。`jit.v` 有几个边界:
+
+- abort 行显示的是 trace 中断位置,而惩罚记在 trace 起点,两者可能不是同一行。
+- 某函数从未成为 trace 起点,不代表它没有被编译,函数体可能已被内联进父 trace。
+- 文本日志很难对功能开启/关闭两组结果做稳定的集合差。
+
+我们一开始也把“起点出现次数为 0”误读成“整个函数都在解释执行”。后来用 `jit.dump` 字节码模式复核,才发现多个函数入口没有成为 root trace,但函数体已多次进入其他 trace。真正反复失败的是阶段入口和编排函数。
+
+> 看不到 trace 起点,只能证明这里没有成为 root trace 锚点;不能证明整个函数没有进入机器码。
+
+### 4.3 把 `start`、`stop`、`abort` 归回同一起点
+
+要回答“哪段代码没有编译、为什么”,需要让 LuaJIT 自己交事件流。核心做法是按 trace id 保存 `start` 的位置,再把后续 `stop` 或 `abort` 归回同一个起点:
+
+```lua
+-- 简化示意,实际回调参数和解析逻辑更复杂
+local trace_start = {}
+
+jit.attach(function(what, trace_id, func, pc, err_code)
+ if what == "start" then
+ trace_start[trace_id] = locate(func, pc)
+ elseif what == "stop" then
+ record_compiled(trace_start[trace_id])
+ trace_start[trace_id] = nil
+ elseif what == "abort" then
+ record_abort(trace_start[trace_id], err_code)
+ trace_start[trace_id] = nil
+ end
+end, "trace")
+```
+
+实际探针还要用 `jit.util.funcinfo` 解析源位置,用 `jit.vmdef.traceerr` 还原 abort 原因。这样就能比较开启/关闭自定义组件时:哪些起点进入 compiled 集合,哪些反复 abort,以及是否发生 trace flush。
+
+在本次 LuaJIT 构建中,失败惩罚从 72 开始逐次翻倍:第 10 次是 36,864,第 11 次达到 73,728,超过 60,000 上限。多个起点恰好 abort ×11 后不再增长;它们没有进入 compiled 集合,采集期间也没有 trace flush。两组存活 trace 数均低于缓存上限,因此排除了 trace 缓存被挤爆的可能。这些证据共同说明:对应的 trace 起点已被放弃继续编译。
+
+仍然不能扩大结论:被放弃的是 trace 起点,不一定是整个函数体;其他部分仍可能被内联进别的 trace。
+
+按调用来源聚合后,受影响的阶段入口和编排函数都经过同一条自定义日志旁路:日志级别不足时虽不输出,但仍会检查请求阶段、调用栈和请求上下文。新增调用本身没有形成大柱子,却改变了调用方的 JIT 结果,使部分成本看起来落在正常请求函数头上。
Review Comment:
Long clause chains joined by semicolons read as translated spec text. Split into sentences; no data or conclusion changed.
````suggestion
真正的探针还要用 `jit.util.funcinfo` 解析源码位置,用 `jit.vmdef.traceerr` 还原 abort 的原因。有了这份数据,才能把组件开和关两种状态摆在一起比:哪些起点进了 compiled 集合,哪些在反复 abort,中间有没有发生 trace flush。
我们用的这个 LuaJIT 构建里,失败惩罚从 72 开始,每失败一次翻一倍:第 10 次是 36,864,第 11 次到 73,728,越过了 60,000 的上限。那批起点恰好都停在 abort ×11,之后再没进过 compiled 集合,采集期间也没有 trace flush,两组的存活 trace 数都在缓存上限以下,trace 缓存被挤爆这个可能性也排除了。几条凑在一起,结论就比较清楚:LuaJIT 放弃了这些 trace 起点。
但结论到此为止:被放弃的是 trace 起点,不一定是整个函数体,函数的其他部分照样可能被内联进别的 trace。
按调用来源再聚合一次,受影响的阶段入口和编排函数全都经过同一条自定义日志旁路。这条路径在日志级别不够、根本不会输出的时候,依然要去检查请求阶段、调用栈和请求上下文。它自己没在图上留下一根像样的柱子,却把调用方的 JIT 结果改掉了,于是一部分开销看起来落在了正常请求函数的头上。
````
##########
blog/zh/blog/2026/08/28/debugging-apisix-throughput-regression.md:
##########
@@ -0,0 +1,234 @@
+---
+title: "火焰图没撒谎,但没直接告诉我们瓶颈:一次 APISIX 吞吐回退定位"
+authors:
+ - name: "Xin Rong"
+ title: "Author"
+ url: "https://github.com/AlinsRan"
+ image_url: "https://github.com/AlinsRan.png"
+ - name: "Yilia Lin"
+ title: "Technical Writer"
+ url: "https://github.com/Yilialinn"
+ image_url: "https://github.com/Yilialinn.png"
+keywords:
+ - Apache APISIX
+ - 火焰图
+ - LuaJIT
+ - 性能优化
+ - 吞吐回退
+description: "火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。"
+tags: [Ecosystem]
+---
+
+火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。
+
+<!--truncate-->
+
+一次吞吐回退中,火焰图最宽的路径并不是最终瓶颈。真正值得追查的两个位置,在 Lua 采样里只有 3.0% 和 3.9%。如果按热点排序,它们根本进不了第一轮优化名单。
+
+这次排查最有价值的不是某个补丁,而是一条证据链:先确认 CPU 确实是瓶颈;再横向看火焰图宽度,建立候选;然后沿调用栈纵向追,看重复调用和公共路径如何放大成本;当火焰图解释不了端到端差距时,继续查 LuaJIT 编译与中断事件;最后用每请求调用次数和配对 A/B 实验定量。
+
+> 数据范围:下文数据来自同一受控环境中的一个 APISIX 内部定制构建,其中加载了 100 余个插件。吞吐统一归一化,只用于说明定位方法与因果链,不代表 Apache APISIX OSS 在其他硬件、配置或负载下的通用表现。
+
+## 1. 先确认 CPU 瓶颈,而不是上来就看火焰图
+
+火焰图显示的是采样期间 CPU 在执行什么。只有目标 worker 的 CPU 接近饱和、吞吐受这个核限制时,火焰图的宽度才有资格解释性能回退。
+
+我们在采样前固定了这些条件:
+
+- APISIX 单 worker 并绑定独占物理核;
+- 上游服务和压测端使用其他核,避免 CPU 争抢;
+- 请求模型、配置、响应内容保持一致;
+- 吞吐回退可稳定复现,错误率和响应结果没有偏移;
+- 目标 worker 持续接近饱和,上游、网络、压测端仍有余量。
+
+如果不满足这些前提,首先应该查连接、网络、上游或压测端,而不是在 on-CPU 火焰图里找答案。
+
+## 2. 横向看宽度,而不是排名
+
+我们使用 eBPF 以 500 Hz 同时采集 C 和 Lua 调用栈。下面是排查中的真实 Lua on-CPU 火焰图。
+
+
+
+*搜索 `run_global_rules` 后命中 3 处,但这些位置在整张图里并不醒目。原始采样共 2,446 个样本;截图已移除进程 PID。*
+
+横向看火焰图,看的是宽度,不是 x 轴顺序。宽度越大,采样落在该路径上的次数越多。初步候选位置按 Lua self time 排序:
+
+| 候选位置 | Lua self time | 初判 |
+|---|---:|---|
+| Prometheus exporter | 25.9% | 最值得先看 |
+| `ctx.var` 元方法 | 15.1% | 第二候选 |
+| 自定义日志旁路 | 3.9% | 很容易被忽略 |
+| `run_global_rules` | 3.0% | 很容易被忽略 |
+
+这里的 self time 指:在带 Lua 上下文的样本中,直接落在这个位置上的比例,不是总 CPU 占比。本次采样约 19.6% 的样本缺失 Lua 上下文,所以这些百分比只用于选候选,不能直接预测吞吐收益。
+
+这个排名有四个盲区:
+
+1. LuaJIT 解释执行时,不同的 Lua 代码可能折叠到共享的 `lj_BC_*`、`lj_vm_*` 符号里。
+2. 已编译的 JIT trace 不一定能被常规栈回溯完整展开,因此可观察到的 JIT 样本只是下界。
+3. 表查找、内存分配、GC 等成本会记在 C 符号上,而不是触发的 Lua 行上。
+4. 一个调用可能改变调用方的 JIT 状态,使成本看起来落在调用方函数的头上。
+
+`run_global_rules` 还有一个特征:它没有形成一根大柱子,而是散在多个请求阶段的调用栈里。单处不宽,不等于合起来不贵。
+
+## 3. 纵向沿栈看,3% 如何被公共路径放大
+
+选中某个 `run_global_rules` 方块后,纵向看调用栈:底部是请求阶段入口,向上经过 `common_phase`、Global Rule 插件筛选、调度,再到具体插件执行。
+
+
+
+*所选栈会被重新铺满画布,因此图中的宽度已经归一化,不能再当作它占总 CPU 的比例。*
+
+纵向高度本身不代表耗时。它的作用是看清成本从哪里进入、被谁调用、为什么反复出现。
+
+修复前,`run_global_rules()` 会在请求的多个阶段调用 `_M.filter()`。`_M.filter()` 遍历所有已加载插件,再逐个判断该插件是否被 Global Rule 配置:
+
+```lua
+-- 简化示意,非 APISIX 完整实现
+for _, plugin_obj in ipairs(local_plugins) do
+ local name = plugin_obj.name
+ local plugin_conf = user_plugin_conf[name]
+ if type(plugin_conf) ~= "table" then
+ goto continue
+ end
+ -- 处理已配置插件
+ ::continue::
+end
+```
+
+两个放大器都在这里:
+
+1. 单次过滤成本随已加载插件数量的增长。
+2. 相同过滤结果跨请求阶段重复生成;`body_filter` 与 `delayed_body_filter` 还可能按响应块多次进入。
+
+本次测试配置中,一个请求实际触发了 **9 次过滤**。也就是说,“从 100+ 插件中找出已配置的少量插件”这件事,被同一个请求重复了 9 次。
+
+这解释了为什么 Lua 层 3.0% 会低估整条路径成本:
+
+| 实际工作 | 火焰图常见归属 |
+|---|---|
+| 循环本身 | `plugin.lua` 对应位置 |
+| 大量表查找 | `lj_BC_TGETS` |
+| 临时表分配 | `lj_alloc_malloc` |
+| 临时对象回收 | `gc_sweep` |
+| 未编译解释器派发 | `lj_vm_*` / `lj_BC_*` |
+
+横向看,它只是 3% 的候选;纵向看,才会发现它位于一条被反复进入的公共路径。优化方向也很直接:同一请求内,如果 Global Rule 和匹配路由没有变化,就复用已筛选的插件集合,而不是每个阶段重新生成。
+
+## 4. 火焰图解释不了差距时,查 LuaJIT 编译事件
+
+第二条路径来自自定义观测组件。它和日志旁路不属于 APISIX OSS,但揭示的问题对 APISIX 插件和 OpenResty 扩展有参考价值:一次看似很轻的调用,既可能产生直接成本,也可能改变调用方之后以解释器还是机器码运行。
+
+### 4.1 解释器派发异常活跃
+
+把 C 层样本按运行时类型重新分类,最值得注意的不是某行 Lua,而是解释器与 JIT 的关系:
+
+| 运行类别 | 每请求 CPU 时间 | 占本次样本 |
+|---|---:|---:|
+| 解释器派发:`lj_BC_*` / `lj_vm_*` | 5.86 μs | 29.6% |
+| 可观察到的 JIT trace 执行 | 2.55 μs | 12.9% |
+
+JIT trace 难以完整 unwind,12.9% 只能看作下界。但在同一环境中,解释器派发如此活跃,已经构成强信号:某些高频路径可能没有稳定地跑在机器码里。
+
+这不等于 trace 越多越好。初始化代码不需要追求编译,不同 trace 的成本差异也很大。只有在 CPU 饱和、路径高频、解释器派发异常时,编译结果才值得深挖。
+
+### 4.2 `jit.v` 发出警报,也制造了第一次误判
+
+开启 `jit.v` 后,一次短时压测输出中看到 417 次 trace 编译成功、493 次 abort。按位置聚合后,一批路径的 abort 次数整齐停在 11。
+
+但这是编译事件数,不是唯一函数数、覆盖率或 CPU 时间。`jit.v` 有几个边界:
+
+- abort 行显示的是 trace 中断位置,而惩罚记在 trace 起点,两者可能不是同一行。
+- 某函数从未成为 trace 起点,不代表它没有被编译,函数体可能已被内联进父 trace。
+- 文本日志很难对功能开启/关闭两组结果做稳定的集合差。
+
+我们一开始也把“起点出现次数为 0”误读成“整个函数都在解释执行”。后来用 `jit.dump` 字节码模式复核,才发现多个函数入口没有成为 root trace,但函数体已多次进入其他 trace。真正反复失败的是阶段入口和编排函数。
+
+> 看不到 trace 起点,只能证明这里没有成为 root trace 锚点;不能证明整个函数没有进入机器码。
+
+### 4.3 把 `start`、`stop`、`abort` 归回同一起点
+
+要回答“哪段代码没有编译、为什么”,需要让 LuaJIT 自己交事件流。核心做法是按 trace id 保存 `start` 的位置,再把后续 `stop` 或 `abort` 归回同一个起点:
+
+```lua
+-- 简化示意,实际回调参数和解析逻辑更复杂
+local trace_start = {}
+
+jit.attach(function(what, trace_id, func, pc, err_code)
+ if what == "start" then
+ trace_start[trace_id] = locate(func, pc)
+ elseif what == "stop" then
+ record_compiled(trace_start[trace_id])
+ trace_start[trace_id] = nil
+ elseif what == "abort" then
+ record_abort(trace_start[trace_id], err_code)
+ trace_start[trace_id] = nil
+ end
+end, "trace")
+```
+
+实际探针还要用 `jit.util.funcinfo` 解析源位置,用 `jit.vmdef.traceerr` 还原 abort 原因。这样就能比较开启/关闭自定义组件时:哪些起点进入 compiled 集合,哪些反复 abort,以及是否发生 trace flush。
+
+在本次 LuaJIT 构建中,失败惩罚从 72 开始逐次翻倍:第 10 次是 36,864,第 11 次达到 73,728,超过 60,000 上限。多个起点恰好 abort ×11 后不再增长;它们没有进入 compiled 集合,采集期间也没有 trace flush。两组存活 trace 数均低于缓存上限,因此排除了 trace 缓存被挤爆的可能。这些证据共同说明:对应的 trace 起点已被放弃继续编译。
+
+仍然不能扩大结论:被放弃的是 trace 起点,不一定是整个函数体;其他部分仍可能被内联进别的 trace。
+
+按调用来源聚合后,受影响的阶段入口和编排函数都经过同一条自定义日志旁路:日志级别不足时虽不输出,但仍会检查请求阶段、调用栈和请求上下文。新增调用本身没有形成大柱子,却改变了调用方的 JIT 结果,使部分成本看起来落在正常请求函数头上。
+
+```lua
+-- 自定义扩展的简化示意,不是 APISIX OSS 实现
+if log_level_is_suppressed then
+ check_debug_capture(...)
+ check_request_buffer(...)
+ return
+end
+```
+
+JIT 数据在这里承担两个职责:发现火焰图无法正确归属的成本,验证功能开关确实改变了高频路径的执行状态。但它不能直接告诉我们吞吐损失多大;真正的方向也不是“强迫所有函数编译”,而是让关闭状态下本来不该发生的工作根本不执行。
+
+还有一个容易让结论失真的陷阱:探针必须装在被观测模块加载之前。如果模块在 `require` 时保存了函数引用,之后替换原函数,计数器只能看到少量没被快照走的调用。本次探针注入晚时测到 1 次/请求,前移到 `require("apisix")` 之前后,才确认真实值是 5 次/请求。
+
+## 5. 配对 A/B 收口:实验回答“值多少钱”
+
+火焰图给位置,调用栈给放大链,LuaJIT 事件解释错位成本。最终还要靠配对实验定量。
+
+以 Global Rule 路径为例,我们保留相同的调度和配置,只让 Prometheus 业务函数进入后立即返回,用来区分“插件业务逻辑慢”和“进入插件前的公共路径慢”。为避免把定制环境绝对 RPS 当成 APISIX OSS benchmark,关闭 Prometheus 的吞吐被归一化为 100:
Review Comment:
Section 5 heading (`收口`) and the `实验回答“值多少钱”` construction.
````suggestion
JIT 数据在这里干了两件事:把火焰图归错位置的开销挖出来,以及证明这个开关确实改变了高频路径的执行状态。它回答不了“这条路径值多少吞吐”。而且真正该做的也不是把所有函数都逼着编译,是让关掉功能之后本来就不该发生的活儿别发生。
还有个坑差点让结论整个作废:探针必须装在被观测模块加载之前。模块在 `require` 的时候可能已经把函数引用存下来了,你之后再替换原函数,计数器只能看到少数几次没被快照走的调用。我们第一次注入得晚,测出来 1 次/请求;把探针挪到 `require("apisix")` 之前,真实值是 5 次/请求。
## 5. 用配对 A/B 把账算清
火焰图给位置,调用栈给放大链,LuaJIT 事件解释错位的开销。这几样都回答不了“到底值多少钱”,得靠实验。
拿 Global Rule 这条路径举例:调度和配置全部保持原样,只让 Prometheus 的业务函数一进来就返回,用来区分“插件业务逻辑慢”和“进插件之前的公共路径慢”。为了避免把定制环境的绝对 RPS 当成 APISIX OSS 的 benchmark,我们把关掉 Prometheus 时的吞吐归一化成 100:
````
##########
blog/zh/blog/2026/08/28/debugging-apisix-throughput-regression.md:
##########
@@ -0,0 +1,234 @@
+---
+title: "火焰图没撒谎,但没直接告诉我们瓶颈:一次 APISIX 吞吐回退定位"
+authors:
+ - name: "Xin Rong"
+ title: "Author"
+ url: "https://github.com/AlinsRan"
+ image_url: "https://github.com/AlinsRan.png"
+ - name: "Yilia Lin"
+ title: "Technical Writer"
+ url: "https://github.com/Yilialinn"
+ image_url: "https://github.com/Yilialinn.png"
+keywords:
+ - Apache APISIX
+ - 火焰图
+ - LuaJIT
+ - 性能优化
+ - 吞吐回退
+description: "火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。"
+tags: [Ecosystem]
+---
+
+火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。
+
+<!--truncate-->
+
+一次吞吐回退中,火焰图最宽的路径并不是最终瓶颈。真正值得追查的两个位置,在 Lua 采样里只有 3.0% 和 3.9%。如果按热点排序,它们根本进不了第一轮优化名单。
+
+这次排查最有价值的不是某个补丁,而是一条证据链:先确认 CPU 确实是瓶颈;再横向看火焰图宽度,建立候选;然后沿调用栈纵向追,看重复调用和公共路径如何放大成本;当火焰图解释不了端到端差距时,继续查 LuaJIT 编译与中断事件;最后用每请求调用次数和配对 A/B 实验定量。
+
+> 数据范围:下文数据来自同一受控环境中的一个 APISIX 内部定制构建,其中加载了 100 余个插件。吞吐统一归一化,只用于说明定位方法与因果链,不代表 Apache APISIX OSS 在其他硬件、配置或负载下的通用表现。
+
+## 1. 先确认 CPU 瓶颈,而不是上来就看火焰图
+
+火焰图显示的是采样期间 CPU 在执行什么。只有目标 worker 的 CPU 接近饱和、吞吐受这个核限制时,火焰图的宽度才有资格解释性能回退。
+
+我们在采样前固定了这些条件:
+
+- APISIX 单 worker 并绑定独占物理核;
+- 上游服务和压测端使用其他核,避免 CPU 争抢;
+- 请求模型、配置、响应内容保持一致;
+- 吞吐回退可稳定复现,错误率和响应结果没有偏移;
+- 目标 worker 持续接近饱和,上游、网络、压测端仍有余量。
+
+如果不满足这些前提,首先应该查连接、网络、上游或压测端,而不是在 on-CPU 火焰图里找答案。
+
+## 2. 横向看宽度,而不是排名
+
+我们使用 eBPF 以 500 Hz 同时采集 C 和 Lua 调用栈。下面是排查中的真实 Lua on-CPU 火焰图。
+
+
+
+*搜索 `run_global_rules` 后命中 3 处,但这些位置在整张图里并不醒目。原始采样共 2,446 个样本;截图已移除进程 PID。*
+
+横向看火焰图,看的是宽度,不是 x 轴顺序。宽度越大,采样落在该路径上的次数越多。初步候选位置按 Lua self time 排序:
+
+| 候选位置 | Lua self time | 初判 |
+|---|---:|---|
+| Prometheus exporter | 25.9% | 最值得先看 |
+| `ctx.var` 元方法 | 15.1% | 第二候选 |
+| 自定义日志旁路 | 3.9% | 很容易被忽略 |
+| `run_global_rules` | 3.0% | 很容易被忽略 |
+
+这里的 self time 指:在带 Lua 上下文的样本中,直接落在这个位置上的比例,不是总 CPU 占比。本次采样约 19.6% 的样本缺失 Lua 上下文,所以这些百分比只用于选候选,不能直接预测吞吐收益。
+
+这个排名有四个盲区:
+
+1. LuaJIT 解释执行时,不同的 Lua 代码可能折叠到共享的 `lj_BC_*`、`lj_vm_*` 符号里。
+2. 已编译的 JIT trace 不一定能被常规栈回溯完整展开,因此可观察到的 JIT 样本只是下界。
+3. 表查找、内存分配、GC 等成本会记在 C 符号上,而不是触发的 Lua 行上。
+4. 一个调用可能改变调用方的 JIT 状态,使成本看起来落在调用方函数的头上。
+
+`run_global_rules` 还有一个特征:它没有形成一根大柱子,而是散在多个请求阶段的调用栈里。单处不宽,不等于合起来不贵。
+
+## 3. 纵向沿栈看,3% 如何被公共路径放大
+
+选中某个 `run_global_rules` 方块后,纵向看调用栈:底部是请求阶段入口,向上经过 `common_phase`、Global Rule 插件筛选、调度,再到具体插件执行。
+
+
+
+*所选栈会被重新铺满画布,因此图中的宽度已经归一化,不能再当作它占总 CPU 的比例。*
+
+纵向高度本身不代表耗时。它的作用是看清成本从哪里进入、被谁调用、为什么反复出现。
+
+修复前,`run_global_rules()` 会在请求的多个阶段调用 `_M.filter()`。`_M.filter()` 遍历所有已加载插件,再逐个判断该插件是否被 Global Rule 配置:
+
+```lua
+-- 简化示意,非 APISIX 完整实现
+for _, plugin_obj in ipairs(local_plugins) do
+ local name = plugin_obj.name
+ local plugin_conf = user_plugin_conf[name]
+ if type(plugin_conf) ~= "table" then
+ goto continue
+ end
+ -- 处理已配置插件
+ ::continue::
+end
+```
+
+两个放大器都在这里:
+
+1. 单次过滤成本随已加载插件数量的增长。
+2. 相同过滤结果跨请求阶段重复生成;`body_filter` 与 `delayed_body_filter` 还可能按响应块多次进入。
+
+本次测试配置中,一个请求实际触发了 **9 次过滤**。也就是说,“从 100+ 插件中找出已配置的少量插件”这件事,被同一个请求重复了 9 次。
+
+这解释了为什么 Lua 层 3.0% 会低估整条路径成本:
+
+| 实际工作 | 火焰图常见归属 |
+|---|---|
+| 循环本身 | `plugin.lua` 对应位置 |
+| 大量表查找 | `lj_BC_TGETS` |
+| 临时表分配 | `lj_alloc_malloc` |
+| 临时对象回收 | `gc_sweep` |
+| 未编译解释器派发 | `lj_vm_*` / `lj_BC_*` |
+
+横向看,它只是 3% 的候选;纵向看,才会发现它位于一条被反复进入的公共路径。优化方向也很直接:同一请求内,如果 Global Rule 和匹配路由没有变化,就复用已筛选的插件集合,而不是每个阶段重新生成。
+
+## 4. 火焰图解释不了差距时,查 LuaJIT 编译事件
+
+第二条路径来自自定义观测组件。它和日志旁路不属于 APISIX OSS,但揭示的问题对 APISIX 插件和 OpenResty 扩展有参考价值:一次看似很轻的调用,既可能产生直接成本,也可能改变调用方之后以解释器还是机器码运行。
+
+### 4.1 解释器派发异常活跃
+
+把 C 层样本按运行时类型重新分类,最值得注意的不是某行 Lua,而是解释器与 JIT 的关系:
+
+| 运行类别 | 每请求 CPU 时间 | 占本次样本 |
+|---|---:|---:|
+| 解释器派发:`lj_BC_*` / `lj_vm_*` | 5.86 μs | 29.6% |
+| 可观察到的 JIT trace 执行 | 2.55 μs | 12.9% |
+
+JIT trace 难以完整 unwind,12.9% 只能看作下界。但在同一环境中,解释器派发如此活跃,已经构成强信号:某些高频路径可能没有稳定地跑在机器码里。
+
+这不等于 trace 越多越好。初始化代码不需要追求编译,不同 trace 的成本差异也很大。只有在 CPU 饱和、路径高频、解释器派发异常时,编译结果才值得深挖。
+
+### 4.2 `jit.v` 发出警报,也制造了第一次误判
+
+开启 `jit.v` 后,一次短时压测输出中看到 417 次 trace 编译成功、493 次 abort。按位置聚合后,一批路径的 abort 次数整齐停在 11。
+
+但这是编译事件数,不是唯一函数数、覆盖率或 CPU 时间。`jit.v` 有几个边界:
+
+- abort 行显示的是 trace 中断位置,而惩罚记在 trace 起点,两者可能不是同一行。
+- 某函数从未成为 trace 起点,不代表它没有被编译,函数体可能已被内联进父 trace。
+- 文本日志很难对功能开启/关闭两组结果做稳定的集合差。
+
+我们一开始也把“起点出现次数为 0”误读成“整个函数都在解释执行”。后来用 `jit.dump` 字节码模式复核,才发现多个函数入口没有成为 root trace,但函数体已多次进入其他 trace。真正反复失败的是阶段入口和编排函数。
+
+> 看不到 trace 起点,只能证明这里没有成为 root trace 锚点;不能证明整个函数没有进入机器码。
+
+### 4.3 把 `start`、`stop`、`abort` 归回同一起点
+
+要回答“哪段代码没有编译、为什么”,需要让 LuaJIT 自己交事件流。核心做法是按 trace id 保存 `start` 的位置,再把后续 `stop` 或 `abort` 归回同一个起点:
+
+```lua
+-- 简化示意,实际回调参数和解析逻辑更复杂
+local trace_start = {}
+
+jit.attach(function(what, trace_id, func, pc, err_code)
+ if what == "start" then
+ trace_start[trace_id] = locate(func, pc)
+ elseif what == "stop" then
+ record_compiled(trace_start[trace_id])
+ trace_start[trace_id] = nil
+ elseif what == "abort" then
+ record_abort(trace_start[trace_id], err_code)
+ trace_start[trace_id] = nil
+ end
+end, "trace")
+```
+
+实际探针还要用 `jit.util.funcinfo` 解析源位置,用 `jit.vmdef.traceerr` 还原 abort 原因。这样就能比较开启/关闭自定义组件时:哪些起点进入 compiled 集合,哪些反复 abort,以及是否发生 trace flush。
+
+在本次 LuaJIT 构建中,失败惩罚从 72 开始逐次翻倍:第 10 次是 36,864,第 11 次达到 73,728,超过 60,000 上限。多个起点恰好 abort ×11 后不再增长;它们没有进入 compiled 集合,采集期间也没有 trace flush。两组存活 trace 数均低于缓存上限,因此排除了 trace 缓存被挤爆的可能。这些证据共同说明:对应的 trace 起点已被放弃继续编译。
+
+仍然不能扩大结论:被放弃的是 trace 起点,不一定是整个函数体;其他部分仍可能被内联进别的 trace。
+
+按调用来源聚合后,受影响的阶段入口和编排函数都经过同一条自定义日志旁路:日志级别不足时虽不输出,但仍会检查请求阶段、调用栈和请求上下文。新增调用本身没有形成大柱子,却改变了调用方的 JIT 结果,使部分成本看起来落在正常请求函数头上。
+
+```lua
+-- 自定义扩展的简化示意,不是 APISIX OSS 实现
+if log_level_is_suppressed then
+ check_debug_capture(...)
+ check_request_buffer(...)
+ return
+end
+```
+
+JIT 数据在这里承担两个职责:发现火焰图无法正确归属的成本,验证功能开关确实改变了高频路径的执行状态。但它不能直接告诉我们吞吐损失多大;真正的方向也不是“强迫所有函数编译”,而是让关闭状态下本来不该发生的工作根本不执行。
+
+还有一个容易让结论失真的陷阱:探针必须装在被观测模块加载之前。如果模块在 `require` 时保存了函数引用,之后替换原函数,计数器只能看到少量没被快照走的调用。本次探针注入晚时测到 1 次/请求,前移到 `require("apisix")` 之前后,才确认真实值是 5 次/请求。
+
+## 5. 配对 A/B 收口:实验回答“值多少钱”
+
+火焰图给位置,调用栈给放大链,LuaJIT 事件解释错位成本。最终还要靠配对实验定量。
+
+以 Global Rule 路径为例,我们保留相同的调度和配置,只让 Prometheus 业务函数进入后立即返回,用来区分“插件业务逻辑慢”和“进入插件前的公共路径慢”。为避免把定制环境绝对 RPS 当成 APISIX OSS benchmark,关闭 Prometheus 的吞吐被归一化为 100:
+
+| 内部 A/B 场景 | 相对吞吐指数 | 相对关闭 Prometheus |
+|---|---:|---:|
+| 关闭 Prometheus | 100.0 | 基准 |
+| 保留插件与调度,业务函数立即返回 | 77.2 | -22.8% |
+| 完整 Prometheus Global Rule | 56.9 | -43.1% |
+
+即使插件业务代码被短路,差距仍然显著。这个实验不能把成本定位到某一行,但足以证明损耗不只来自指标计算,进入插件前的公共路径本身就值得追查。再结合每请求 9 次过滤、纵向调用栈和 C 层成本分布,放大链才真正闭合。
+
+自定义观测组件也用同样方法:保持负载与配置一致,分别采集开启和关闭下的编译事件、每请求调用次数、吞吐和响应正确性。JIT 事件解释“为什么图上没有足够宽的新柱子”,端到端 A/B 回答“这条路径到底值多少钱”。
+
+整个排查可以压缩成五步:
+
+| 步骤 | 核心问题 | 证据 |
+|---|---|---|
+| 1. 确认 CPU 前提 | 火焰图是否有资格解释回退? | worker 饱和、绑核、上下游余量、稳定复现 |
+| 2. 横向看宽度 | 样本主要落在哪里? | 全局火焰图与候选排序 |
+| 3. 纵向沿栈看 | 小成本为何被放大? | 调用阶段、公共函数、每请求调用次数 |
+| 4. 检查 LuaJIT | 成本为何错位或消失? | `start`、`stop`、`abort`、`flush` 与 `jit.dump` |
+| 5. 配对 A/B 收口 | 真实量级与因果是什么? | 吞吐、延迟、调用次数、错误率与响应一致性 |
+
+这些结论必须留在明确边界内:
+
+- 火焰图宽度、Lua self time 与吞吐变化的分母不同,不能直接相减或相除。
+- 自定义观测组件不属于 APISIX OSS,只用于说明自定义扩展可能遇到的通用问题。
+- 29.6%、12.9%、417、493 和 abort ×11 都属于本次构建和采集窗口,不能外推。
+- “没有成为 trace 起点”不等于“函数没有编译”,必须检查函数体是否进入其他 trace。
+- JIT 编译结果是定位信号,不是最终性能指标;优化仍需验证吞吐、延迟、错误率、响应内容和资源回收。
+
+火焰图没有撒谎。横向宽度告诉我们 CPU 样本聚集在哪,纵向调用栈告诉我们成本如何被公共路径放大。但当成本进入解释器、JIT trace、分配器和调用方后,火焰图看到的就不再是完整归属。
+
+这时最有效的动作不是继续猜哪行 Lua 应该更快,而是让 LuaJIT 交出编译事件,再用每请求调用次数和配对 A/B 把量级钉死。最终值得优化的,往往不是最宽的柱子,而是那段经过证据证明、根本不需要反复发生的工作。
Review Comment:
Two changes. The caveat list drops the `没有成为 trace 起点` item, which 4.2 already states twice (once as a blockquote, once as `仍然不能扩大结论`) — 5 items reads as defensive padding. And the last two paragraphs return to the `火焰图没有撒谎` hook for the third time; reworded.
````suggestion
业务代码都被短路了,差距还是这么大。这个实验定位不到具体某一行,但足以说明开销不全在指标计算上,进插件之前的公共路径本身就该查。再对上每请求 9 次过滤、调用栈的形状和 C 层的开销分布,这条放大链才算说通。
自定义观测组件走的是同一套流程:负载和配置不动,分别在开和关两种状态下采编译事件、每请求调用次数、吞吐和响应正确性。JIT 事件回答“图上为什么没多出一根够宽的柱子”,端到端 A/B 回答“这条路径到底值多少”。
整个排查压缩下来是五步:
| 步骤 | 要回答的问题 | 看什么 |
|---|---|---|
| 1. 确认 CPU 前提 | 火焰图有没有资格解释这次回退? | worker 饱和、绑核、上下游余量、稳定复现 |
| 2. 看宽度挑候选 | 样本主要落在哪儿? | 全局火焰图与候选排序 |
| 3. 顺着栈往上看 | 一点小开销为什么会被放大? | 调用阶段、公共函数、每请求调用次数 |
| 4. 查 LuaJIT | 开销为什么错位或者消失了? | `start`、`stop`、`abort`、`flush` 与 `jit.dump` |
| 5. 配对 A/B | 真实量级多大,因果成不成立? | 吞吐、延迟、调用次数、错误率与响应一致性 |
有几条边界得说清楚:
- 火焰图宽度、Lua self time 和吞吐变化的分母各不相同,不能直接相减或相除。
- 29.6%、12.9%、417、493 和 abort ×11 都只对这次的构建和采集窗口成立,不能外推。
- 自定义观测组件不属于 APISIX OSS,写进来只是因为它暴露的问题对自定义扩展有普遍性。
- JIT 编译结果是定位信号,不是性能指标;改完还是要拿吞吐、延迟、错误率、响应内容和资源回收去验。
火焰图没有骗人。宽度告诉我们 CPU 的采样堆在哪,调用栈告诉我们一小块开销怎么被公共路径放大。但当开销散进解释器派发、JIT trace、分配器和调用方之后,火焰图给出的归属就不完整了。
这种时候继续猜哪行 Lua 该更快,基本是在浪费时间。更有效的做法是让 LuaJIT 交出编译事件,再用每请求调用次数和配对 A/B 把量级钉死。最后真正值得改的,往往不是最宽的那根柱子,而是那段根本不需要反复发生的工作。
````
##########
blog/zh/blog/2026/08/28/debugging-apisix-throughput-regression.md:
##########
@@ -0,0 +1,234 @@
+---
+title: "火焰图没撒谎,但没直接告诉我们瓶颈:一次 APISIX 吞吐回退定位"
+authors:
+ - name: "Xin Rong"
+ title: "Author"
+ url: "https://github.com/AlinsRan"
+ image_url: "https://github.com/AlinsRan.png"
+ - name: "Yilia Lin"
+ title: "Technical Writer"
+ url: "https://github.com/Yilialinn"
+ image_url: "https://github.com/Yilialinn.png"
+keywords:
+ - Apache APISIX
+ - 火焰图
+ - LuaJIT
+ - 性能优化
+ - 吞吐回退
+description: "火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。"
+tags: [Ecosystem]
+---
+
+火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。
+
+<!--truncate-->
+
+一次吞吐回退中,火焰图最宽的路径并不是最终瓶颈。真正值得追查的两个位置,在 Lua 采样里只有 3.0% 和 3.9%。如果按热点排序,它们根本进不了第一轮优化名单。
+
+这次排查最有价值的不是某个补丁,而是一条证据链:先确认 CPU 确实是瓶颈;再横向看火焰图宽度,建立候选;然后沿调用栈纵向追,看重复调用和公共路径如何放大成本;当火焰图解释不了端到端差距时,继续查 LuaJIT 编译与中断事件;最后用每请求调用次数和配对 A/B 实验定量。
+
+> 数据范围:下文数据来自同一受控环境中的一个 APISIX 内部定制构建,其中加载了 100 余个插件。吞吐统一归一化,只用于说明定位方法与因果链,不代表 Apache APISIX OSS 在其他硬件、配置或负载下的通用表现。
+
+## 1. 先确认 CPU 瓶颈,而不是上来就看火焰图
+
+火焰图显示的是采样期间 CPU 在执行什么。只有目标 worker 的 CPU 接近饱和、吞吐受这个核限制时,火焰图的宽度才有资格解释性能回退。
+
+我们在采样前固定了这些条件:
+
+- APISIX 单 worker 并绑定独占物理核;
+- 上游服务和压测端使用其他核,避免 CPU 争抢;
+- 请求模型、配置、响应内容保持一致;
+- 吞吐回退可稳定复现,错误率和响应结果没有偏移;
+- 目标 worker 持续接近饱和,上游、网络、压测端仍有余量。
+
+如果不满足这些前提,首先应该查连接、网络、上游或压测端,而不是在 on-CPU 火焰图里找答案。
+
+## 2. 横向看宽度,而不是排名
+
+我们使用 eBPF 以 500 Hz 同时采集 C 和 Lua 调用栈。下面是排查中的真实 Lua on-CPU 火焰图。
+
+
+
+*搜索 `run_global_rules` 后命中 3 处,但这些位置在整张图里并不醒目。原始采样共 2,446 个样本;截图已移除进程 PID。*
+
+横向看火焰图,看的是宽度,不是 x 轴顺序。宽度越大,采样落在该路径上的次数越多。初步候选位置按 Lua self time 排序:
+
+| 候选位置 | Lua self time | 初判 |
+|---|---:|---|
+| Prometheus exporter | 25.9% | 最值得先看 |
+| `ctx.var` 元方法 | 15.1% | 第二候选 |
+| 自定义日志旁路 | 3.9% | 很容易被忽略 |
+| `run_global_rules` | 3.0% | 很容易被忽略 |
+
+这里的 self time 指:在带 Lua 上下文的样本中,直接落在这个位置上的比例,不是总 CPU 占比。本次采样约 19.6% 的样本缺失 Lua 上下文,所以这些百分比只用于选候选,不能直接预测吞吐收益。
+
+这个排名有四个盲区:
+
+1. LuaJIT 解释执行时,不同的 Lua 代码可能折叠到共享的 `lj_BC_*`、`lj_vm_*` 符号里。
+2. 已编译的 JIT trace 不一定能被常规栈回溯完整展开,因此可观察到的 JIT 样本只是下界。
+3. 表查找、内存分配、GC 等成本会记在 C 符号上,而不是触发的 Lua 行上。
+4. 一个调用可能改变调用方的 JIT 状态,使成本看起来落在调用方函数的头上。
+
+`run_global_rules` 还有一个特征:它没有形成一根大柱子,而是散在多个请求阶段的调用栈里。单处不宽,不等于合起来不贵。
+
+## 3. 纵向沿栈看,3% 如何被公共路径放大
+
+选中某个 `run_global_rules` 方块后,纵向看调用栈:底部是请求阶段入口,向上经过 `common_phase`、Global Rule 插件筛选、调度,再到具体插件执行。
+
+
+
+*所选栈会被重新铺满画布,因此图中的宽度已经归一化,不能再当作它占总 CPU 的比例。*
+
+纵向高度本身不代表耗时。它的作用是看清成本从哪里进入、被谁调用、为什么反复出现。
+
+修复前,`run_global_rules()` 会在请求的多个阶段调用 `_M.filter()`。`_M.filter()` 遍历所有已加载插件,再逐个判断该插件是否被 Global Rule 配置:
+
+```lua
+-- 简化示意,非 APISIX 完整实现
+for _, plugin_obj in ipairs(local_plugins) do
+ local name = plugin_obj.name
+ local plugin_conf = user_plugin_conf[name]
+ if type(plugin_conf) ~= "table" then
+ goto continue
+ end
+ -- 处理已配置插件
+ ::continue::
+end
+```
+
+两个放大器都在这里:
+
+1. 单次过滤成本随已加载插件数量的增长。
+2. 相同过滤结果跨请求阶段重复生成;`body_filter` 与 `delayed_body_filter` 还可能按响应块多次进入。
+
+本次测试配置中,一个请求实际触发了 **9 次过滤**。也就是说,“从 100+ 插件中找出已配置的少量插件”这件事,被同一个请求重复了 9 次。
+
+这解释了为什么 Lua 层 3.0% 会低估整条路径成本:
Review Comment:
`两个放大器都在这里` plus a two-item list is heavier machinery than the point needs. Folded into one sentence; the 9-passes number is unchanged.
````suggestion
两个放大因素叠在一起:单次过滤的开销跟着已加载插件数量涨;同一份过滤结果又在各个请求阶段被反复生成,`body_filter` 和 `delayed_body_filter` 还会按响应块多进来几次。
这次的配置下,一个请求实际触发了 **9 次过滤**。也就是说,“从 100 多个插件里挑出那几个配置过的”,同一个请求干了 9 遍。
到这里就能解释 3.0% 为什么低估了整条路径:实际干的活儿,大部分并没有记在 Lua 那一行上。
````
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@apisix.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] blog: add APISIX throughput regression investigation (en + zh) [apisix-website]
Posted by "AlinsRan (via GitHub)" <gi...@apache.org>.
AlinsRan commented on code in PR #2115:
URL: https://github.com/apache/apisix-website/pull/2115#discussion_r3879927820
##########
blog/zh/blog/2026/08/28/debugging-apisix-throughput-regression.md:
##########
@@ -0,0 +1,234 @@
+---
+title: "火焰图没撒谎,但没直接告诉我们瓶颈:一次 APISIX 吞吐回退定位"
+authors:
+ - name: "Xin Rong"
+ title: "Author"
+ url: "https://github.com/AlinsRan"
+ image_url: "https://github.com/AlinsRan.png"
+ - name: "Yilia Lin"
+ title: "Technical Writer"
+ url: "https://github.com/Yilialinn"
+ image_url: "https://github.com/Yilialinn.png"
+keywords:
+ - Apache APISIX
+ - 火焰图
+ - LuaJIT
+ - 性能优化
+ - 吞吐回退
+description: "火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。"
+tags: [Ecosystem]
+---
+
+火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。
+
+<!--truncate-->
+
+一次吞吐回退中,火焰图最宽的路径并不是最终瓶颈。真正值得追查的两个位置,在 Lua 采样里只有 3.0% 和 3.9%。如果按热点排序,它们根本进不了第一轮优化名单。
+
+这次排查最有价值的不是某个补丁,而是一条证据链:先确认 CPU 确实是瓶颈;再横向看火焰图宽度,建立候选;然后沿调用栈纵向追,看重复调用和公共路径如何放大成本;当火焰图解释不了端到端差距时,继续查 LuaJIT 编译与中断事件;最后用每请求调用次数和配对 A/B 实验定量。
+
+> 数据范围:下文数据来自同一受控环境中的一个 APISIX 内部定制构建,其中加载了 100 余个插件。吞吐统一归一化,只用于说明定位方法与因果链,不代表 Apache APISIX OSS 在其他硬件、配置或负载下的通用表现。
+
+## 1. 先确认 CPU 瓶颈,而不是上来就看火焰图
+
+火焰图显示的是采样期间 CPU 在执行什么。只有目标 worker 的 CPU 接近饱和、吞吐受这个核限制时,火焰图的宽度才有资格解释性能回退。
+
+我们在采样前固定了这些条件:
+
+- APISIX 单 worker 并绑定独占物理核;
+- 上游服务和压测端使用其他核,避免 CPU 争抢;
+- 请求模型、配置、响应内容保持一致;
+- 吞吐回退可稳定复现,错误率和响应结果没有偏移;
+- 目标 worker 持续接近饱和,上游、网络、压测端仍有余量。
+
+如果不满足这些前提,首先应该查连接、网络、上游或压测端,而不是在 on-CPU 火焰图里找答案。
+
+## 2. 横向看宽度,而不是排名
+
+我们使用 eBPF 以 500 Hz 同时采集 C 和 Lua 调用栈。下面是排查中的真实 Lua on-CPU 火焰图。
+
+
+
+*搜索 `run_global_rules` 后命中 3 处,但这些位置在整张图里并不醒目。原始采样共 2,446 个样本;截图已移除进程 PID。*
+
+横向看火焰图,看的是宽度,不是 x 轴顺序。宽度越大,采样落在该路径上的次数越多。初步候选位置按 Lua self time 排序:
+
+| 候选位置 | Lua self time | 初判 |
+|---|---:|---|
+| Prometheus exporter | 25.9% | 最值得先看 |
+| `ctx.var` 元方法 | 15.1% | 第二候选 |
+| 自定义日志旁路 | 3.9% | 很容易被忽略 |
+| `run_global_rules` | 3.0% | 很容易被忽略 |
+
+这里的 self time 指:在带 Lua 上下文的样本中,直接落在这个位置上的比例,不是总 CPU 占比。本次采样约 19.6% 的样本缺失 Lua 上下文,所以这些百分比只用于选候选,不能直接预测吞吐收益。
+
+这个排名有四个盲区:
+
+1. LuaJIT 解释执行时,不同的 Lua 代码可能折叠到共享的 `lj_BC_*`、`lj_vm_*` 符号里。
+2. 已编译的 JIT trace 不一定能被常规栈回溯完整展开,因此可观察到的 JIT 样本只是下界。
+3. 表查找、内存分配、GC 等成本会记在 C 符号上,而不是触发的 Lua 行上。
+4. 一个调用可能改变调用方的 JIT 状态,使成本看起来落在调用方函数的头上。
+
+`run_global_rules` 还有一个特征:它没有形成一根大柱子,而是散在多个请求阶段的调用栈里。单处不宽,不等于合起来不贵。
+
+## 3. 纵向沿栈看,3% 如何被公共路径放大
+
+选中某个 `run_global_rules` 方块后,纵向看调用栈:底部是请求阶段入口,向上经过 `common_phase`、Global Rule 插件筛选、调度,再到具体插件执行。
+
+
+
+*所选栈会被重新铺满画布,因此图中的宽度已经归一化,不能再当作它占总 CPU 的比例。*
+
+纵向高度本身不代表耗时。它的作用是看清成本从哪里进入、被谁调用、为什么反复出现。
+
+修复前,`run_global_rules()` 会在请求的多个阶段调用 `_M.filter()`。`_M.filter()` 遍历所有已加载插件,再逐个判断该插件是否被 Global Rule 配置:
+
+```lua
+-- 简化示意,非 APISIX 完整实现
+for _, plugin_obj in ipairs(local_plugins) do
+ local name = plugin_obj.name
+ local plugin_conf = user_plugin_conf[name]
+ if type(plugin_conf) ~= "table" then
+ goto continue
+ end
+ -- 处理已配置插件
+ ::continue::
+end
+```
+
+两个放大器都在这里:
+
+1. 单次过滤成本随已加载插件数量的增长。
+2. 相同过滤结果跨请求阶段重复生成;`body_filter` 与 `delayed_body_filter` 还可能按响应块多次进入。
+
+本次测试配置中,一个请求实际触发了 **9 次过滤**。也就是说,“从 100+ 插件中找出已配置的少量插件”这件事,被同一个请求重复了 9 次。
+
+这解释了为什么 Lua 层 3.0% 会低估整条路径成本:
+
+| 实际工作 | 火焰图常见归属 |
+|---|---|
+| 循环本身 | `plugin.lua` 对应位置 |
+| 大量表查找 | `lj_BC_TGETS` |
+| 临时表分配 | `lj_alloc_malloc` |
+| 临时对象回收 | `gc_sweep` |
+| 未编译解释器派发 | `lj_vm_*` / `lj_BC_*` |
+
+横向看,它只是 3% 的候选;纵向看,才会发现它位于一条被反复进入的公共路径。优化方向也很直接:同一请求内,如果 Global Rule 和匹配路由没有变化,就复用已筛选的插件集合,而不是每个阶段重新生成。
+
+## 4. 火焰图解释不了差距时,查 LuaJIT 编译事件
+
+第二条路径来自自定义观测组件。它和日志旁路不属于 APISIX OSS,但揭示的问题对 APISIX 插件和 OpenResty 扩展有参考价值:一次看似很轻的调用,既可能产生直接成本,也可能改变调用方之后以解释器还是机器码运行。
+
+### 4.1 解释器派发异常活跃
+
+把 C 层样本按运行时类型重新分类,最值得注意的不是某行 Lua,而是解释器与 JIT 的关系:
+
+| 运行类别 | 每请求 CPU 时间 | 占本次样本 |
+|---|---:|---:|
+| 解释器派发:`lj_BC_*` / `lj_vm_*` | 5.86 μs | 29.6% |
+| 可观察到的 JIT trace 执行 | 2.55 μs | 12.9% |
+
+JIT trace 难以完整 unwind,12.9% 只能看作下界。但在同一环境中,解释器派发如此活跃,已经构成强信号:某些高频路径可能没有稳定地跑在机器码里。
+
+这不等于 trace 越多越好。初始化代码不需要追求编译,不同 trace 的成本差异也很大。只有在 CPU 饱和、路径高频、解释器派发异常时,编译结果才值得深挖。
+
+### 4.2 `jit.v` 发出警报,也制造了第一次误判
+
+开启 `jit.v` 后,一次短时压测输出中看到 417 次 trace 编译成功、493 次 abort。按位置聚合后,一批路径的 abort 次数整齐停在 11。
+
+但这是编译事件数,不是唯一函数数、覆盖率或 CPU 时间。`jit.v` 有几个边界:
+
+- abort 行显示的是 trace 中断位置,而惩罚记在 trace 起点,两者可能不是同一行。
+- 某函数从未成为 trace 起点,不代表它没有被编译,函数体可能已被内联进父 trace。
+- 文本日志很难对功能开启/关闭两组结果做稳定的集合差。
+
+我们一开始也把“起点出现次数为 0”误读成“整个函数都在解释执行”。后来用 `jit.dump` 字节码模式复核,才发现多个函数入口没有成为 root trace,但函数体已多次进入其他 trace。真正反复失败的是阶段入口和编排函数。
+
+> 看不到 trace 起点,只能证明这里没有成为 root trace 锚点;不能证明整个函数没有进入机器码。
+
+### 4.3 把 `start`、`stop`、`abort` 归回同一起点
+
+要回答“哪段代码没有编译、为什么”,需要让 LuaJIT 自己交事件流。核心做法是按 trace id 保存 `start` 的位置,再把后续 `stop` 或 `abort` 归回同一个起点:
+
+```lua
+-- 简化示意,实际回调参数和解析逻辑更复杂
+local trace_start = {}
+
+jit.attach(function(what, trace_id, func, pc, err_code)
+ if what == "start" then
+ trace_start[trace_id] = locate(func, pc)
+ elseif what == "stop" then
+ record_compiled(trace_start[trace_id])
+ trace_start[trace_id] = nil
+ elseif what == "abort" then
+ record_abort(trace_start[trace_id], err_code)
+ trace_start[trace_id] = nil
+ end
+end, "trace")
+```
+
+实际探针还要用 `jit.util.funcinfo` 解析源位置,用 `jit.vmdef.traceerr` 还原 abort 原因。这样就能比较开启/关闭自定义组件时:哪些起点进入 compiled 集合,哪些反复 abort,以及是否发生 trace flush。
+
+在本次 LuaJIT 构建中,失败惩罚从 72 开始逐次翻倍:第 10 次是 36,864,第 11 次达到 73,728,超过 60,000 上限。多个起点恰好 abort ×11 后不再增长;它们没有进入 compiled 集合,采集期间也没有 trace flush。两组存活 trace 数均低于缓存上限,因此排除了 trace 缓存被挤爆的可能。这些证据共同说明:对应的 trace 起点已被放弃继续编译。
+
+仍然不能扩大结论:被放弃的是 trace 起点,不一定是整个函数体;其他部分仍可能被内联进别的 trace。
+
+按调用来源聚合后,受影响的阶段入口和编排函数都经过同一条自定义日志旁路:日志级别不足时虽不输出,但仍会检查请求阶段、调用栈和请求上下文。新增调用本身没有形成大柱子,却改变了调用方的 JIT 结果,使部分成本看起来落在正常请求函数头上。
+
+```lua
+-- 自定义扩展的简化示意,不是 APISIX OSS 实现
+if log_level_is_suppressed then
+ check_debug_capture(...)
+ check_request_buffer(...)
+ return
+end
+```
+
+JIT 数据在这里承担两个职责:发现火焰图无法正确归属的成本,验证功能开关确实改变了高频路径的执行状态。但它不能直接告诉我们吞吐损失多大;真正的方向也不是“强迫所有函数编译”,而是让关闭状态下本来不该发生的工作根本不执行。
+
+还有一个容易让结论失真的陷阱:探针必须装在被观测模块加载之前。如果模块在 `require` 时保存了函数引用,之后替换原函数,计数器只能看到少量没被快照走的调用。本次探针注入晚时测到 1 次/请求,前移到 `require("apisix")` 之前后,才确认真实值是 5 次/请求。
+
+## 5. 配对 A/B 收口:实验回答“值多少钱”
+
+火焰图给位置,调用栈给放大链,LuaJIT 事件解释错位成本。最终还要靠配对实验定量。
+
+以 Global Rule 路径为例,我们保留相同的调度和配置,只让 Prometheus 业务函数进入后立即返回,用来区分“插件业务逻辑慢”和“进入插件前的公共路径慢”。为避免把定制环境绝对 RPS 当成 APISIX OSS benchmark,关闭 Prometheus 的吞吐被归一化为 100:
+
+| 内部 A/B 场景 | 相对吞吐指数 | 相对关闭 Prometheus |
+|---|---:|---:|
+| 关闭 Prometheus | 100.0 | 基准 |
+| 保留插件与调度,业务函数立即返回 | 77.2 | -22.8% |
+| 完整 Prometheus Global Rule | 56.9 | -43.1% |
+
+即使插件业务代码被短路,差距仍然显著。这个实验不能把成本定位到某一行,但足以证明损耗不只来自指标计算,进入插件前的公共路径本身就值得追查。再结合每请求 9 次过滤、纵向调用栈和 C 层成本分布,放大链才真正闭合。
+
+自定义观测组件也用同样方法:保持负载与配置一致,分别采集开启和关闭下的编译事件、每请求调用次数、吞吐和响应正确性。JIT 事件解释“为什么图上没有足够宽的新柱子”,端到端 A/B 回答“这条路径到底值多少钱”。
+
+整个排查可以压缩成五步:
+
+| 步骤 | 核心问题 | 证据 |
+|---|---|---|
+| 1. 确认 CPU 前提 | 火焰图是否有资格解释回退? | worker 饱和、绑核、上下游余量、稳定复现 |
+| 2. 横向看宽度 | 样本主要落在哪里? | 全局火焰图与候选排序 |
+| 3. 纵向沿栈看 | 小成本为何被放大? | 调用阶段、公共函数、每请求调用次数 |
+| 4. 检查 LuaJIT | 成本为何错位或消失? | `start`、`stop`、`abort`、`flush` 与 `jit.dump` |
+| 5. 配对 A/B 收口 | 真实量级与因果是什么? | 吞吐、延迟、调用次数、错误率与响应一致性 |
+
+这些结论必须留在明确边界内:
+
+- 火焰图宽度、Lua self time 与吞吐变化的分母不同,不能直接相减或相除。
+- 自定义观测组件不属于 APISIX OSS,只用于说明自定义扩展可能遇到的通用问题。
+- 29.6%、12.9%、417、493 和 abort ×11 都属于本次构建和采集窗口,不能外推。
+- “没有成为 trace 起点”不等于“函数没有编译”,必须检查函数体是否进入其他 trace。
+- JIT 编译结果是定位信号,不是最终性能指标;优化仍需验证吞吐、延迟、错误率、响应内容和资源回收。
+
+火焰图没有撒谎。横向宽度告诉我们 CPU 样本聚集在哪,纵向调用栈告诉我们成本如何被公共路径放大。但当成本进入解释器、JIT trace、分配器和调用方后,火焰图看到的就不再是完整归属。
+
+这时最有效的动作不是继续猜哪行 Lua 应该更快,而是让 LuaJIT 交出编译事件,再用每请求调用次数和配对 A/B 把量级钉死。最终值得优化的,往往不是最宽的柱子,而是那段经过证据证明、根本不需要反复发生的工作。
Review Comment:
This closing block is the hardest part of the article to read. Four things block comprehension:
- `成本进入解释器、JIT trace、分配器和调用方` — 进入 can't carry this. Cost doesn't enter a caller; it gets *charged* to these symbols. The list also mixes four different kinds of thing under one verb. The article already has the precise wording earlier (`表查找、内存分配、GC 等成本会记在 C 符号上`, and the 火焰图常见归属 table header).
- `火焰图看到的就不再是完整归属` — subject and object don't match. 归属 is not something the flame graph *sees*, it's the judgement it *gives*.
- `把量级钉死` — 钉死 in Chinese means fixing something in place, not English "pin down". And what is measured is the cost of the path, not an order of magnitude.
- `那段经过证据证明、根本不需要反复发生的工作` — three stacked modifiers before 工作; the reader only learns the subject at the end.
Also `横向宽度` is redundant (width is horizontal by definition) and 动作 is an odd word for an investigative step. The argument and `火焰图没有撒谎` are unchanged.
````suggestion
火焰图没有撒谎。宽度告诉我们 CPU 样本聚集在哪,调用栈告诉我们这些成本如何被公共路径放大。但成本一旦被记到解释器符号、JIT trace、内存分配器或者调用方头上,火焰图给出的归属就不再完整。
这时候最有效的做法不是继续猜哪行 Lua 应该更快,而是让 LuaJIT 交出编译事件,再用每请求调用次数和配对 A/B 实验把这条路径的真实开销测出来。最终值得优化的,往往不是最宽的柱子,而是那些有证据表明根本不需要反复执行的工作。
````
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@apisix.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] blog: add APISIX throughput regression investigation (en + zh) [apisix-website]
Posted by "Yilialinn (via GitHub)" <gi...@apache.org>.
Yilialinn merged PR #2115:
URL: https://github.com/apache/apisix-website/pull/2115
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@apisix.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org