This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch 
gh-readonly-queue/dev/pr-12021-25d6ba7f641b1f84bbf29281f1acb98bedc26a64
in repository https://gitbox.apache.org/repos/asf/seatunnel.git

commit 36b0c9fe8e89223b45e8a313d4e5a2ee59274575
Author: zhiweiniu <[email protected]>
AuthorDate: Tue Sep 1 07:27:45 2026 +0000

    [Feature][CI] Add benchmark profiling diagnostics (#12021)
---
 .github/workflows/backend.yml                |  19 +-
 .github/workflows/benchmarks_diagnostics.yml | 253 +++++++++++++
 docs/en/engines/zeta/benchmark.md            |  87 +++++
 docs/zh/engines/zeta/benchmark.md            |  78 ++++
 seatunnel-benchmarks/README.md               | 108 +++---
 tools/benchmarks/profile_benchmarks.sh       | 338 +++++++++++++++++
 tools/benchmarks/profile_report.py           | 522 +++++++++++++++++++++++++++
 tools/benchmarks/test_profile_benchmarks.py  | 268 ++++++++++++++
 tools/benchmarks/test_profile_report.py      | 228 ++++++++++++
 tools/benchmarks/test_regression_report.py   | 148 ++++++++
 tools/benchmarks/test_save_jmh_result.py     | 180 +++++++++
 11 files changed, 2170 insertions(+), 59 deletions(-)

diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml
index c641d9d3dc..371a404c7b 100644
--- a/.github/workflows/backend.yml
+++ b/.github/workflows/backend.yml
@@ -164,6 +164,7 @@ jobs:
       edge-agent: ${{ steps.filter.outputs.edge-agent }}
       edge-agent-e2e: ${{ steps.filter.outputs.edge-agent-e2e }}
       benchmarks: ${{ steps.filter.outputs.benchmarks }}
+      benchmark_tools: ${{ steps.filter.outputs.benchmark_tools }}
       docs: ${{ steps.filter.outputs.docs }}
       helm: ${{ steps.filter.outputs.helm }}
       ut-modules: ${{ steps.ut-modules.outputs.modules || '[]' }}
@@ -226,11 +227,17 @@ jobs:
           echo "helm=$true_or_false" >> $GITHUB_OUTPUT
           echo "helm_files=$file_list" >> $GITHUB_OUTPUT
 
-          benchmark_files=`python 
tools/update_modules_check/check_file_updates.py ua $workspace apache/dev 
origin/$current_branch "seatunnel-benchmarks/**" "tools/benchmarks/**" 
".github/workflows/benchmarks.yml"`
+          benchmark_files=`python 
tools/update_modules_check/check_file_updates.py ua $workspace apache/dev 
origin/$current_branch "seatunnel-benchmarks/**" "tools/benchmarks/**" 
".github/workflows/benchmarks.yml" 
".github/workflows/benchmarks_diagnostics.yml"`
           true_or_false=${benchmark_files%%$'\n'*}
           file_list=${benchmark_files#*$'\n'}
           echo "benchmarks=$true_or_false" >> $GITHUB_OUTPUT
           echo "benchmarks_files=$file_list" >> $GITHUB_OUTPUT
+
+          benchmark_tool_files=`python 
tools/update_modules_check/check_file_updates.py ua $workspace apache/dev 
origin/$current_branch "tools/benchmarks/**"`
+          true_or_false=${benchmark_tool_files%%$'\n'*}
+          file_list=${benchmark_tool_files#*$'\n'}
+          echo "benchmark_tools=$true_or_false" >> $GITHUB_OUTPUT
+          echo "benchmark_tools_files=$file_list" >> $GITHUB_OUTPUT
           
           engine_e2e_files=`python 
tools/update_modules_check/check_file_updates.py ua $workspace apache/dev 
origin/$current_branch "seatunnel-e2e/seatunnel-engine-e2e/**"`
           true_or_false=${engine_e2e_files%%$'\n'*}
@@ -562,6 +569,16 @@ jobs:
             -f 1 -wi 0 -i 1 -r 1s -foe true
           find "${result_dir}" -name '*.json' -print -quit | grep -q .
 
+  benchmark-tools-test:
+    needs: [ changes, sanity-check ]
+    if: needs.changes.outputs.benchmark_tools == 'true'
+    runs-on: ubuntu-latest
+    timeout-minutes: 10
+    steps:
+      - uses: actions/checkout@v4
+      - name: Check benchmark tools
+        run: python3 -m unittest discover -s tools/benchmarks -p 'test_*.py'
+
   updated-modules-integration-test-part-1:
     needs: [ changes, sanity-check ]
     if: needs.changes.outputs.api == 'false' && needs.changes.outputs.engine 
== 'false' && needs.changes.outputs.it-modules != '[]'
diff --git a/.github/workflows/benchmarks_diagnostics.yml 
b/.github/workflows/benchmarks_diagnostics.yml
new file mode 100644
index 0000000000..8b10adb5ce
--- /dev/null
+++ b/.github/workflows/benchmarks_diagnostics.yml
@@ -0,0 +1,253 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the 'License'); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an 'AS IS' BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+name: Benchmarks Diagnostics
+
+on:
+  workflow_dispatch:
+    inputs:
+      seatunnel_ref:
+        description: 'SeaTunnel branch, tag, or commit SHA to diagnose.'
+        required: false
+        type: string
+        default: 'dev'
+      pr_number:
+        description: 'Optional trusted PR to diagnose. The workflow executes 
its code.'
+        required: false
+        type: string
+      benchmark:
+        description: 'One exact JMH benchmark method; multi-match selectors 
are rejected.'
+        required: true
+        type: string
+        default: 'IntermediateQueueBenchmark.disruptorRecordHandoff$'
+      java_version:
+        description: 'JDK used by the diagnostic run.'
+        required: true
+        type: choice
+        options:
+          - '8'
+          - '11'
+        default: '8'
+      profile:
+        description: 'Profiler mode. all runs CPU, wall-clock, lock, and GC 
separately.'
+        required: true
+        type: choice
+        options:
+          - 'cpu'
+          - 'wall'
+          - 'lock'
+          - 'gc'
+          - 'all'
+        default: 'cpu'
+      capture_jfr:
+        description: 'Also capture a JFR recording for offline JVM analysis.'
+        required: true
+        type: boolean
+        default: false
+      jmh_args:
+        description: 'Optional JMH args, for example: -wi 1 -i 1 -w 1s -r 1s. 
Forks are fixed at 1.'
+        required: false
+        type: string
+
+permissions:
+  contents: read
+
+jobs:
+  diagnostics:
+    name: Capture diagnostics with Java ${{ inputs.java_version }}
+    runs-on: ubuntu-24.04
+    timeout-minutes: 240
+
+    env:
+      ASYNC_PROFILER_CPU_EVENT: 'ctimer'
+      ASYNC_PROFILER_VERSION: '4.5'
+      ASYNC_PROFILER_SHA256: 
'89546fbb9ee0fc5496c7edd4099b0709489bc78b0d8057ccbb4b801f6b032b62'
+      BENCHMARKS: ${{ inputs.benchmark }}
+      JAVA_VERSION: ${{ inputs.java_version }}
+      PROFILE_JMH_ARGS: ${{ inputs.jmh_args || '' }}
+      PR_NUMBER: ${{ inputs.pr_number || '' }}
+      SEATUNNEL_REF: ${{ inputs.seatunnel_ref || 'dev' }}
+      TARGET_DIRECTORY: target
+    steps:
+      - name: Checkout benchmark target
+        uses: actions/checkout@v4
+        with:
+          fetch-depth: 0
+          ref: ${{ inputs.pr_number != '' && format('refs/pull/{0}/head', 
inputs.pr_number) || inputs.seatunnel_ref || 'dev' }}
+          path: target
+
+      - name: Checkout workflow tools
+        uses: actions/checkout@v4
+        with:
+          ref: ${{ github.sha }}
+          path: workflow-source
+          sparse-checkout: tools/benchmarks
+
+      - name: Set up JDK ${{ inputs.java_version }} with Maven cache
+        if: inputs.pr_number == ''
+        uses: actions/setup-java@v4
+        with:
+          distribution: temurin
+          java-version: ${{ inputs.java_version }}
+          cache: maven
+
+      - name: Set up JDK ${{ inputs.java_version }} without Maven cache
+        if: inputs.pr_number != ''
+        uses: actions/setup-java@v4
+        with:
+          distribution: temurin
+          java-version: ${{ inputs.java_version }}
+
+      - name: Build benchmark module
+        id: build_benchmark
+        working-directory: target
+        run: ./mvnw -Pbenchmark -pl seatunnel-benchmarks -am -DskipTests 
package
+
+      - name: Install async-profiler
+        id: install_async_profiler
+        if: >-
+          steps.build_benchmark.outcome == 'success' &&
+          (inputs.profile == 'cpu' ||
+          inputs.profile == 'wall' ||
+          inputs.profile == 'lock' ||
+          inputs.profile == 'all')
+        run: |
+          archive="${RUNNER_TEMP}/async-profiler.tar.gz"
+          install_directory="${RUNNER_TEMP}/async-profiler"
+          curl --fail --location --retry 5 --retry-all-errors \
+            --output "${archive}" \
+            
"https://github.com/async-profiler/async-profiler/releases/download/v${ASYNC_PROFILER_VERSION}/async-profiler-${ASYNC_PROFILER_VERSION}-linux-x64.tar.gz";
+          echo "${ASYNC_PROFILER_SHA256}  ${archive}" | sha256sum --check 
--strict
+          mkdir -p "${install_directory}"
+          tar --extract --gzip --file "${archive}" --directory 
"${install_directory}" --strip-components=1
+          echo "ASYNC_PROFILER_HOME=${install_directory}" >> "${GITHUB_ENV}"
+
+      - name: Profile CPU
+        if: >-
+          always() &&
+          steps.build_benchmark.outcome == 'success' &&
+          steps.install_async_profiler.outcome == 'success' &&
+          (inputs.profile == 'cpu' || inputs.profile == 'all')
+        run: |
+          bash workflow-source/tools/benchmarks/profile_benchmarks.sh profile 
cpu \
+            --repository "${TARGET_DIRECTORY}" \
+            --output "${GITHUB_WORKSPACE}/benchmark-diagnostics/profile-cpu"
+
+      - name: Profile wall clock
+        if: >-
+          always() &&
+          steps.build_benchmark.outcome == 'success' &&
+          steps.install_async_profiler.outcome == 'success' &&
+          (inputs.profile == 'wall' || inputs.profile == 'all')
+        run: |
+          bash workflow-source/tools/benchmarks/profile_benchmarks.sh profile 
wall \
+            --repository "${TARGET_DIRECTORY}" \
+            --output "${GITHUB_WORKSPACE}/benchmark-diagnostics/profile-wall"
+
+      - name: Profile locks
+        if: >-
+          always() &&
+          steps.build_benchmark.outcome == 'success' &&
+          steps.install_async_profiler.outcome == 'success' &&
+          (inputs.profile == 'lock' || inputs.profile == 'all')
+        run: |
+          bash workflow-source/tools/benchmarks/profile_benchmarks.sh profile 
lock \
+            --repository "${TARGET_DIRECTORY}" \
+            --output "${GITHUB_WORKSPACE}/benchmark-diagnostics/profile-lock"
+
+      - name: Profile GC and allocation
+        if: >-
+          always() &&
+          steps.build_benchmark.outcome == 'success' &&
+          (inputs.profile == 'gc' || inputs.profile == 'all')
+        run: |
+          bash workflow-source/tools/benchmarks/profile_benchmarks.sh profile 
gc \
+            --repository "${TARGET_DIRECTORY}" \
+            --output "${GITHUB_WORKSPACE}/benchmark-diagnostics/profile-gc"
+
+      - name: Capture JFR
+        if: >-
+          always() &&
+          steps.build_benchmark.outcome == 'success' &&
+          inputs.capture_jfr
+        run: |
+          bash workflow-source/tools/benchmarks/profile_benchmarks.sh capture 
jfr \
+            --repository "${TARGET_DIRECTORY}" \
+            --output "${GITHUB_WORKSPACE}/benchmark-diagnostics/capture-jfr"
+
+      - name: Upload CPU profile
+        if: always() && (inputs.profile == 'cpu' || inputs.profile == 'all')
+        uses: actions/upload-artifact@v4
+        with:
+          name: seatunnel-benchmark-profile-cpu-java${{ env.JAVA_VERSION 
}}-${{ github.run_id }}-${{ github.run_attempt }}
+          retention-days: 30
+          if-no-files-found: warn
+          path: benchmark-diagnostics/profile-cpu/**
+
+      - name: Upload wall-clock profile
+        if: always() && (inputs.profile == 'wall' || inputs.profile == 'all')
+        uses: actions/upload-artifact@v4
+        with:
+          name: seatunnel-benchmark-profile-wall-java${{ env.JAVA_VERSION 
}}-${{ github.run_id }}-${{ github.run_attempt }}
+          retention-days: 30
+          if-no-files-found: warn
+          path: benchmark-diagnostics/profile-wall/**
+
+      - name: Upload lock profile
+        if: always() && (inputs.profile == 'lock' || inputs.profile == 'all')
+        uses: actions/upload-artifact@v4
+        with:
+          name: seatunnel-benchmark-profile-lock-java${{ env.JAVA_VERSION 
}}-${{ github.run_id }}-${{ github.run_attempt }}
+          retention-days: 30
+          if-no-files-found: warn
+          path: benchmark-diagnostics/profile-lock/**
+
+      - name: Upload GC profile
+        if: always() && (inputs.profile == 'gc' || inputs.profile == 'all')
+        uses: actions/upload-artifact@v4
+        with:
+          name: seatunnel-benchmark-profile-gc-java${{ env.JAVA_VERSION }}-${{ 
github.run_id }}-${{ github.run_attempt }}
+          retention-days: 30
+          if-no-files-found: warn
+          path: benchmark-diagnostics/profile-gc/**
+
+      - name: Upload JFR capture
+        if: always() && inputs.capture_jfr
+        uses: actions/upload-artifact@v4
+        with:
+          name: seatunnel-benchmark-capture-jfr-java${{ env.JAVA_VERSION 
}}-${{ github.run_id }}-${{ github.run_attempt }}
+          retention-days: 30
+          if-no-files-found: warn
+          path: benchmark-diagnostics/capture-jfr/**
+
+      - name: Publish diagnostic summary
+        if: always()
+        run: |
+          python3 workflow-source/tools/benchmarks/profile_report.py summary \
+            --diagnostics-dir benchmark-diagnostics \
+            --profile "${{ inputs.profile }}" \
+            --capture-jfr "${{ inputs.capture_jfr }}" \
+            --repository "${TARGET_DIRECTORY}" \
+            --ref "${SEATUNNEL_REF}" \
+            --pr-number "${PR_NUMBER}" \
+            --benchmark "${BENCHMARKS}" \
+            --java "${JAVA_VERSION}" \
+            --jmh-args "${PROFILE_JMH_ARGS}" \
+            --artifacts-url 
"${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}#artifacts"
 \
+            --run-id "${GITHUB_RUN_ID}" \
+            --run-attempt "${GITHUB_RUN_ATTEMPT}" \
+            >> "${GITHUB_STEP_SUMMARY}"
diff --git a/docs/en/engines/zeta/benchmark.md 
b/docs/en/engines/zeta/benchmark.md
index 6af8b54524..b20db41fa6 100644
--- a/docs/en/engines/zeta/benchmark.md
+++ b/docs/en/engines/zeta/benchmark.md
@@ -87,6 +87,13 @@ during each measured job instead of deferring file writes 
across several invocat
 ./mvnw -Pbenchmark -pl seatunnel-benchmarks -am -DskipTests package
 ```
 
+### Import the Module in IntelliJ IDEA
+
+The module is behind the inactive `benchmark` Maven profile, so IDEA may not 
import it when the
+root project is first opened. In the Maven tool window, expand `Profiles`, 
enable `benchmark`, and
+click `Reload All Maven Projects`. If the module is still absent, right-click
+`seatunnel-benchmarks/pom.xml`, select `Add as Maven Project`, and reload 
Maven once more.
+
 List every JMH method:
 
 ```bash
@@ -142,6 +149,75 @@ java -jar seatunnel-benchmarks/target/benchmarks.jar 
SeaTunnelRowBenchmark \
 For a quick functional validation, add `-f 1 -wi 0 -i 1 -r 1s` to shorten the 
run. A single un-warmed
 sample is not valid performance evidence.
 
+### Read Workflow Reports
+
+The scheduled and manually triggered `Benchmarks` workflow runs each selected 
benchmark on Java 8
+and Java 11. Each Java job uploads one artifact containing:
+
+- the original `*.jmh.json`, preserving every fork and iteration sample;
+- a versioned `*.report.json`, normalizing benchmark names, parameters, 
scores, errors, units,
+  direction, commit, JVM, CPU, and runner metadata;
+- `summary.md`, which is also rendered in the GitHub Actions Job Summary;
+- the environment fingerprint and any full-pipeline sample JSON.
+
+The normalized report includes median pipeline throughput, P50/P95/P99/max 
latency, latency growth,
+completeness, and sustainable-sample counts. Raw samples and a schema version 
allow later tooling to
+consume saved artifacts without parsing console logs. The workflow does not 
push results to a
+repository branch.
+
+Manual runs offer common selectors through `benchmarks`; `custom_benchmarks` 
accepts a class,
+method, or regular expression and overrides that choice. `.*` selects all 
current and future
+benchmarks. When `pr_number` is set, the workflow executes `baseline -> PR -> 
PR -> baseline` on the
+same worker, compares the median of both runs for each revision, and reports a 
direction-adjusted
+percentage where a positive value is favorable.
+
+Absolute results remain sensitive to machine load, warmup, CPU frequency, and 
runner hardware.
+Use GitHub-hosted results as trend and functional-check evidence. Prefer 
repeated baseline/change
+runs on the same machine, as the PR comparison does, or use a fixed 
self-hosted runner for a future
+regression gate.
+
+### Diagnose an Unstable Benchmark
+
+Use profiling only after a normal run shows an unexpected Score, Error, or CV. 
The diagnostic
+runner keeps its report separate because profiler overhead makes its Score 
unsuitable for
+regression comparisons. A diagnostic selector must resolve to exactly one 
benchmark method;
+selectors such as `.*` or a class name that matches several methods are 
rejected.
+
+Install the complete async-profiler distribution and set `ASYNC_PROFILER_HOME` 
before running CPU,
+wall-clock, or lock profiling. The runner records JFR first and uses the 
bundled `jfrconv` to create
+forward and reverse flame graphs. GC profiling and JFR capture use JMH's 
built-in profilers and do
+not need async-profiler:
+
+```bash
+bash tools/benchmarks/profile_benchmarks.sh profile cpu --benchmark 
'IntermediateQueueBenchmark.disruptorRecordHandoff$'
+bash tools/benchmarks/profile_benchmarks.sh profile wall --benchmark 
'IntermediateQueueBenchmark.disruptorRecordHandoff$'
+bash tools/benchmarks/profile_benchmarks.sh profile lock --benchmark 
'IntermediateQueueBenchmark.disruptorRecordHandoff$'
+bash tools/benchmarks/profile_benchmarks.sh profile gc --benchmark 
'IntermediateQueueBenchmark.disruptorRecordHandoff$'
+bash tools/benchmarks/profile_benchmarks.sh capture jfr --benchmark 
'IntermediateQueueBenchmark.disruptorRecordHandoff$'
+```
+
+CPU, wall-clock, and lock modes use JMH's async-profiler integration. GC mode 
uses JMH's GC
+profiler, and `capture jfr` uses JMH's JFR profiler. The runner always uses 
exactly one fork so that
+file-based profiler output cannot be overwritten by later forks. Warmup and 
measurement settings
+still come from benchmark annotations unless they are overridden after `--`, 
for example
+`-- -wi 1 -i 1 -w 1s -r 1s`. Each run gets a new default output directory; an 
explicit `--output`
+directory must be empty to prevent stale artifacts from being mixed into the 
report.
+
+The raw JMH JSON records async-profiler as `secondaryMetrics.async` with a 
`NaN` Score because it
+produces files rather than a numeric secondary metric. This is expected. The 
diagnostic report
+shows the collected sample count; when lock profiling observes no contention, 
it reports zero
+samples and intentionally omits an empty flame graph.
+
+The manual `Benchmarks Diagnostics` workflow requires one exact `benchmark` 
method and one
+`java_version`. It is separate from the scheduled and manually triggered 
`Benchmarks` workflow,
+which continues to run the Java 8/11 matrix. Selecting `all` runs CPU, 
wall-clock, lock, and GC
+profiling as separate steps and uploads four independently downloadable 
artifacts; `capture_jfr`
+adds a fifth JFR artifact. Each artifact contains only its mode's JFR 
recordings, flame graphs, text
+summaries, JMH logs, and JSON reports. The job summary shows the target, 
benchmark settings,
+per-mode results, and independent artifact names without repeating the full 
file inventory. On the
+hosted Linux runner, CPU profiling uses async-profiler's `ctimer` event so it 
does not depend on
+`perf_event` permissions.
+
 ## Metrics
 
 ### Sample Validity
@@ -223,6 +299,17 @@ Files under `pipeline-results` are custom JSON rather than 
JMH JSON. Inspect the
 `tools/benchmarks/save_jmh_result.py` and 
`tools/benchmarks/regression_report.py` to generate
 normalized JSON and Markdown reports.
 
+## Add a Benchmark
+
+Keep cases small and focused on hot paths that run on one machine without 
external services. Useful
+targets include `SeaTunnelRow` operations, format parsing and serialization, 
Transform hot paths,
+connector option parsing, and split generation.
+
+Extend `BenchmarkBase` to inherit the shared JMH mode, forks, warmup, 
measurement, state, and output
+unit defaults. Keep benchmark-specific state and setup in the benchmark class. 
Full-pipeline engine
+lifecycle and controls belong in `SeaTunnelEnvironmentContext` or a focused 
subclass so checkpoint,
+failure-recovery, and metrics scenarios can be added without duplicating 
cluster setup.
+
 ## Performance Cost and Limitations
 
 - A pipeline benchmark starts an embedded Zeta cluster and requires at least 4 
GiB of available heap.
diff --git a/docs/zh/engines/zeta/benchmark.md 
b/docs/zh/engines/zeta/benchmark.md
index f6f850b8fd..e4f91bc13d 100644
--- a/docs/zh/engines/zeta/benchmark.md
+++ b/docs/zh/engines/zeta/benchmark.md
@@ -82,6 +82,13 @@ Source 使用基于绝对时间的开环调度。每条记录都携带计划生
 ./mvnw -Pbenchmark -pl seatunnel-benchmarks -am -DskipTests package
 ```
 
+### 在 IntelliJ IDEA 中导入模块
+
+该模块位于默认未启用的 `benchmark` Maven profile 中,因此首次打开根项目时 IDEA 可能不会
+自动导入。在 Maven 工具窗口中展开 `Profiles`,启用 `benchmark`,然后点击
+`Reload All Maven Projects`。如果仍未显示该模块,右键点击
+`seatunnel-benchmarks/pom.xml`,选择 `Add as Maven Project`,再重新加载一次 Maven。
+
 查看全部 JMH 方法:
 
 ```bash
@@ -136,6 +143,67 @@ java -jar seatunnel-benchmarks/target/benchmarks.jar 
SeaTunnelRowBenchmark \
 快速功能验证时可以增加 `-f 1 -wi 0 -i 1 -r 1s` 缩短运行时间。没有预热且只有一个样本的
 结果不能用于性能结论。
 
+### 查看 Workflow 报告
+
+定时或手动触发的 `Benchmarks` workflow 会在 Java 8 和 Java 11 上运行所选 benchmark。
+每个 Java job 会上传一个 artifact,其中包含:
+
+- 原始 `*.jmh.json`,保留所有 fork 和 iteration 样本;
+- 带版本的 `*.report.json`,统一记录 benchmark 名称、参数、Score、Error、单位、优化方向、
+  Commit、JVM、CPU 和 Runner 元数据;
+- `summary.md`,同时展示在 GitHub Actions Job Summary 中;
+- 环境指纹和完整 Pipeline 的样本 JSON(如有)。
+
+标准化报告还包含 Pipeline 吞吐中位数、P50/P95/P99/最大延迟、延迟增长、完整性和可持续样本
+数量。保留原始样本和 Schema 版本后,后续工具无需解析控制台日志即可消费已有 artifact。
+该 workflow 不会把结果推送到仓库分支。
+
+手动运行可以通过 `benchmarks` 选择常用 selector;`custom_benchmarks` 可以填写类名、方法名或
+正则表达式,并覆盖前者。`.*` 会选择当前及未来的所有 benchmark。设置 `pr_number` 后,
+workflow 会在同一 Worker 上按 `baseline -> PR -> PR -> baseline` 顺序运行,比较两个版本各自
+两次结果的中位数,并输出经过优化方向校正的百分比;正值表示 Candidate 向更优方向变化。
+
+绝对结果仍会受到机器负载、预热、CPU 频率和 Runner 硬件影响。GitHub 托管 Runner 的结果
+适合用于趋势与功能检查。精确比较应像 PR 对比一样,在同一台机器上重复运行 Base 与 Change;
+未来如需作为回归门禁,则应使用固定的 Self-hosted Runner。
+
+### 诊断不稳定的 Benchmark
+
+只有正常运行出现异常的 Score、Error 或 CV 后,才使用 profiling 继续定位。Profiler 会引入
+额外开销,因此诊断报告与正常报告完全分开,诊断 Score 不能用于性能回归比较。诊断 selector
+必须且只能匹配一个 benchmark 方法;`.*` 或能够匹配多个方法的类名会被拒绝。
+
+运行 CPU、wall-clock 或 lock profiling 前,需要安装完整的 async-profiler,并设置
+`ASYNC_PROFILER_HOME`。Runner 会先生成 JFR,再使用安装包内的 `jfrconv` 生成正向和反向
+火焰图。GC profiling 和 JFR capture 使用 JMH 内置 profiler,不依赖 async-profiler:
+
+```bash
+bash tools/benchmarks/profile_benchmarks.sh profile cpu --benchmark 
'IntermediateQueueBenchmark.disruptorRecordHandoff$'
+bash tools/benchmarks/profile_benchmarks.sh profile wall --benchmark 
'IntermediateQueueBenchmark.disruptorRecordHandoff$'
+bash tools/benchmarks/profile_benchmarks.sh profile lock --benchmark 
'IntermediateQueueBenchmark.disruptorRecordHandoff$'
+bash tools/benchmarks/profile_benchmarks.sh profile gc --benchmark 
'IntermediateQueueBenchmark.disruptorRecordHandoff$'
+bash tools/benchmarks/profile_benchmarks.sh capture jfr --benchmark 
'IntermediateQueueBenchmark.disruptorRecordHandoff$'
+```
+
+CPU、wall-clock 和 lock 模式直接使用 JMH 的 async-profiler 集成,GC 模式使用 JMH GC
+profiler,`capture jfr` 使用 JMH JFR profiler。Runner 始终使用一个 fork,防止后续 fork
+覆盖文件型 profiler 的产物。预热和测量设置默认仍来自 benchmark 注解;如需覆盖,将参数
+放在 `--` 后,例如 `-- -wi 1 -i 1 -w 1s -r 1s`。默认输出目录每次运行都不同;显式指定
+的 `--output` 目录必须为空,避免旧产物混入报告。
+
+async-profiler 产生文件而不是数值型 secondary metric,因此原始 JMH JSON 中的
+`secondaryMetrics.async` Score 为 `NaN`,这是预期行为。诊断报告会显示采集到的样本数;
+lock profiling 没有观察到竞争时会报告 0 个样本,并且不会生成没有内容的火焰图。
+
+手动触发 `Benchmarks Diagnostics` workflow 时,必须指定一个精确的 `benchmark` 方法和一个
+`java_version`。该 workflow 与定时或手动触发的 `Benchmarks` workflow 相互独立,后者继续
+运行 Java 8/11 matrix。选择 `all` 会分别执行 CPU、wall-clock、lock 和 GC step,并上传四个
+可以独立下载的 artifact;`capture_jfr` 会增加第五个 JFR artifact。每个 artifact 只包含对应
+模式的 JFR、火焰图、文本摘要、JMH 日志和 JSON 报告。Job Summary 会集中显示目标、
+benchmark 设置、各模式结果和独立 artifact 名称,不再重复完整文件清单。GitHub 托管的
+Linux runner 使用 async-profiler 的 `ctimer` 事件进行 CPU profiling,不依赖 `perf_event`
+权限。
+
 ## 指标
 
 ### 样本有效性
@@ -215,6 +283,16 @@ JMH Visualizer 会把参数值拼成标签。例如 `600000:4:256:64` 依次表
 `tools/benchmarks/save_jmh_result.py` 和 
`tools/benchmarks/regression_report.py` 生成
 标准化 JSON 和 Markdown 报告。
 
+## 添加 Benchmark
+
+Benchmark 应保持小而专注,优先选择无需外部服务、可以在单机运行的热点路径,例如
+`SeaTunnelRow` 操作、格式解析与序列化、Transform 热点、Connector 参数解析和 Split 生成。
+
+新 Benchmark 应继承 `BenchmarkBase`,复用统一的 JMH Mode、Fork、预热、测量、State 和输出
+单位配置;Benchmark 自身只保留场景相关的状态与 Setup。完整 Pipeline 的引擎生命周期和控制
+逻辑应放在 `SeaTunnelEnvironmentContext` 或职责明确的子类中,以便后续增加 Checkpoint、
+故障恢复和 Metrics 场景时无需复制集群 Setup。
+
 ## 开销与限制
 
 - Pipeline Benchmark 会在本机启动嵌入式 Zeta 集群,需要至少 4 GiB 可用堆内存。
diff --git a/seatunnel-benchmarks/README.md b/seatunnel-benchmarks/README.md
index 2204bbfc9f..506bdf419f 100644
--- a/seatunnel-benchmarks/README.md
+++ b/seatunnel-benchmarks/README.md
@@ -49,79 +49,71 @@ java -jar seatunnel-benchmarks/target/benchmarks.jar 
SeaTunnelRowBenchmark \
   -rff seatunnel-benchmarks/target/benchmark-result.json
 ```
 
-## Benchmark reports
-
-The `Benchmarks` GitHub Actions workflow builds three artifacts for every Java 
version:
+## Run Zeta full-pipeline benchmarks
 
-- the original JMH JSON, which preserves all forks and iteration samples;
-- a versioned `*.report.json`, which normalizes benchmark name, parameters, 
score, error, unit,
-  direction, commit, JVM, CPU, and runner image;
-- a Markdown report, which is also displayed in the GitHub Actions job summary.
+```bash
+java -jar seatunnel-benchmarks/target/benchmarks.jar SeaTunnelPipelineBenchmark
+```
 
-The normalized report also includes median pipeline throughput, latency 
P50/P95/P99/max, latency
-growth, completeness, and the number of sustainable samples. Keeping the raw 
samples and a schema
-version allows a later Codespeed service or regression checker to consume 
saved artifacts without
-parsing console logs. The workflow also saves the CPU, kernel, runner image, 
memory, and JDK
-fingerprint. It does not push benchmark data to a repository branch.
+## Install async-profiler
 
-Manual runs provide a `benchmarks` dropdown for common JMH selectors. The 
optional
-`custom_benchmarks` input accepts any class name, method name, or regular 
expression and overrides
-the dropdown, so a new benchmark can run before it is added to the common 
choices. `.*` runs every
-current and future benchmark. An optional PR number compares that PR with 
`seatunnel_ref` on the
-same worker in `base -> PR -> PR -> base` order. The comparison report uses 
the median of the two
-baseline and two candidate runs and shows a direction-adjusted percentage; 
positive means the
-candidate moved in the favorable direction.
+CPU, wall-clock, and lock profiling require async-profiler's library and 
bundled `jfrconv`.
+Set `ASYNC_PROFILER_HOME` to the complete installation. On macOS with Homebrew:
 
-JMH treats selectors as regular expressions. Append `$` to an exact method 
selector when other
-method names share the same prefix.
+```bash
+brew install async-profiler
+export ASYNC_PROFILER_HOME="$(brew --prefix async-profiler)"
+```
 
-GitHub-hosted runners can execute the workflow reliably while still having 
materially different
-host CPU performance. Treat these artifacts as trend and functional-check 
data, not as a regression
-gate based on one run or on JMH's within-run `scoreError`. A future regression 
gate should compare
-the base and change on the same worker or use a fixed self-hosted runner.
+On Linux x64:
 
-## IntelliJ IDEA
+```bash
+SEATUNNEL_ASYNC_PROFILER_VERSION=4.5
+SEATUNNEL_ASYNC_PROFILER_HOME="${PWD}/seatunnel-benchmarks/target/async-profiler"
+SEATUNNEL_ASYNC_PROFILER_ARCHIVE="${SEATUNNEL_ASYNC_PROFILER_HOME}.tar.gz"
+
+curl --fail --location --retry 5 --retry-all-errors \
+  --output "${SEATUNNEL_ASYNC_PROFILER_ARCHIVE}" \
+  
"https://github.com/async-profiler/async-profiler/releases/download/v${SEATUNNEL_ASYNC_PROFILER_VERSION}/async-profiler-${SEATUNNEL_ASYNC_PROFILER_VERSION}-linux-x64.tar.gz";
+echo "89546fbb9ee0fc5496c7edd4099b0709489bc78b0d8057ccbb4b801f6b032b62  
${SEATUNNEL_ASYNC_PROFILER_ARCHIVE}" \
+  | sha256sum --check --strict
+mkdir -p "${SEATUNNEL_ASYNC_PROFILER_HOME}"
+tar --extract --gzip \
+  --file "${SEATUNNEL_ASYNC_PROFILER_ARCHIVE}" \
+  --directory "${SEATUNNEL_ASYNC_PROFILER_HOME}" \
+  --strip-components=1
+export ASYNC_PROFILER_HOME="${SEATUNNEL_ASYNC_PROFILER_HOME}"
+```
 
-The benchmark module is behind the inactive `benchmark` Maven profile, so IDEA 
may not import it
-automatically after opening the SeaTunnel root project.
+## Profile one benchmark
 
-To make IDEA recognize the module:
+```bash
+bash tools/benchmarks/profile_benchmarks.sh profile cpu \
+  --benchmark 'IntermediateQueueBenchmark.disruptorRecordHandoff$'
 
-1. Open the Maven tool window.
-2. Expand `Profiles`.
-3. Enable the `benchmark` profile.
-4. Click `Reload All Maven Projects`.
+bash tools/benchmarks/profile_benchmarks.sh profile wall \
+  --benchmark 'IntermediateQueueBenchmark.disruptorRecordHandoff$'
 
-If the module is still not shown, right-click `seatunnel-benchmarks/pom.xml` 
and choose
-`Add as Maven Project`, then reload Maven once more.
+bash tools/benchmarks/profile_benchmarks.sh profile lock \
+  --benchmark 'IntermediateQueueBenchmark.disruptorRecordHandoff$'
 
-## Interpreting results
+bash tools/benchmarks/profile_benchmarks.sh profile gc \
+  --benchmark 'IntermediateQueueBenchmark.disruptorRecordHandoff$'
 
-Benchmark results are sensitive to machine load, JVM warmup, CPU frequency, 
and runner type. Prefer
-comparing repeated baseline/change runs on the same machine instead of 
comparing absolute numbers
-from different machines.
+bash tools/benchmarks/profile_benchmarks.sh capture jfr \
+  --benchmark 'IntermediateQueueBenchmark.disruptorRecordHandoff$'
+```
 
-## Run Zeta full-pipeline benchmarks
+GC and JFR use JMH's built-in profilers and do not require async-profiler. 
Override JMH warmup and
+measurement settings after `--`:
 
 ```bash
-java -jar seatunnel-benchmarks/target/benchmarks.jar SeaTunnelPipelineBenchmark
+bash tools/benchmarks/profile_benchmarks.sh profile gc \
+  --benchmark 'IntermediateQueueBenchmark.disruptorRecordHandoff$' \
+  -- -wi 1 -i 1 -w 1s -r 1s
 ```
 
-See the [Zeta benchmark guide](../docs/en/engines/zeta/benchmark.md) for 
architecture, parameters,
-resource settings, and result interpretation.
-
-## Adding benchmarks
-
-Keep benchmark cases small and focused. Good first targets are hot paths that 
can run on a single
-machine without external services, such as:
-
-- `SeaTunnelRow` operations
-- format parsing and serialization
-- transform hot paths
-- connector option parsing
-- split generation logic
+The profiling script accepts exactly one benchmark method and always uses one 
fork.
 
-Common JMH and JVM settings live in the single `BenchmarkBase`. Individual 
benchmark classes
-extend it and only define their own data setup and benchmark methods. Engine 
lifecycle and
-engine-level controls belong in `SeaTunnelEnvironmentContext`, so checkpoint, 
failure-recovery,
-and metrics scenarios can be added without duplicating cluster setup in each 
benchmark.
+See the [Zeta benchmark guide](../docs/en/engines/zeta/benchmark.md) for 
workflow reports, IntelliJ
+IDEA setup, benchmark parameters, metrics, result interpretation, and 
contribution guidance.
diff --git a/tools/benchmarks/profile_benchmarks.sh 
b/tools/benchmarks/profile_benchmarks.sh
new file mode 100644
index 0000000000..0a54eb0d51
--- /dev/null
+++ b/tools/benchmarks/profile_benchmarks.sh
@@ -0,0 +1,338 @@
+#!/usr/bin/env bash
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+set -euo pipefail
+
+script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
+default_repository_dir=$(cd "${script_dir}/../.." && pwd)
+
+usage() {
+    cat <<'EOF'
+Usage:
+  profile_benchmarks.sh profile <cpu|wall|lock|gc> [options] [-- JMH_ARGS...]
+  profile_benchmarks.sh capture jfr [options] [-- JMH_ARGS...]
+
+Options:
+  --benchmark REGEX  JMH selector. Defaults to $BENCHMARKS or one queue 
benchmark method.
+                     The selector must resolve to exactly one benchmark method.
+  --repository DIR   Repository containing the benchmark JAR. Defaults to this 
script's repository.
+  --output DIR       Output directory. Defaults below 
seatunnel-benchmarks/target/profiles.
+  -h, --help         Show this help.
+
+Examples:
+  profile_benchmarks.sh profile cpu
+  profile_benchmarks.sh profile lock --benchmark 
'IntermediateQueueBenchmark.blockingQueueRecordHandoff$'
+  profile_benchmarks.sh profile gc -- -f 1 -wi 1 -i 1 -w 1s -r 1s
+  profile_benchmarks.sh capture jfr --benchmark 
'IntermediateQueueBenchmark.disruptorRecordHandoff$'
+EOF
+}
+
+fail() {
+    echo "ERROR: $*" >&2
+    exit 1
+}
+
+if [[ $# -eq 1 && ( "$1" == "-h" || "$1" == "--help" ) ]]; then
+    usage
+    exit 0
+fi
+
+if [[ $# -lt 2 ]]; then
+    usage >&2
+    exit 1
+fi
+
+command_name="$1"
+mode="$2"
+shift 2
+
+case "${command_name}" in
+    profile)
+        case "${mode}" in
+            cpu | wall | lock | gc) ;;
+            *) fail "Unsupported profile mode '${mode}'. Expected cpu, wall, 
lock, or gc." ;;
+        esac
+        ;;
+    capture)
+        case "${mode}" in
+            jfr) ;;
+            *) fail "Unsupported capture mode '${mode}'. Expected jfr." ;;
+        esac
+        ;;
+    -h | --help)
+        usage
+        exit 0
+        ;;
+    *) fail "Unsupported command '${command_name}'. Expected profile or 
capture." ;;
+esac
+
+benchmark_selector="${BENCHMARKS:-IntermediateQueueBenchmark.disruptorRecordHandoff$}"
+repository_dir="${default_repository_dir}"
+output_directory=""
+jmh_arguments=()
+
+while [[ $# -gt 0 ]]; do
+    case "$1" in
+        --benchmark)
+            [[ $# -ge 2 ]] || fail "--benchmark requires a value"
+            benchmark_selector="$2"
+            shift 2
+            ;;
+        --output)
+            [[ $# -ge 2 ]] || fail "--output requires a value"
+            output_directory="$2"
+            shift 2
+            ;;
+        --repository)
+            [[ $# -ge 2 ]] || fail "--repository requires a value"
+            repository_dir="$2"
+            shift 2
+            ;;
+        --)
+            shift
+            jmh_arguments=("$@")
+            break
+            ;;
+        -h | --help)
+            usage
+            exit 0
+            ;;
+        *) fail "Unknown script option '$1'. Put JMH arguments after --." ;;
+    esac
+done
+
+if [[ ${#jmh_arguments[@]} -eq 0 && -n "${PROFILE_JMH_ARGS:-}" ]]; then
+    read -r -a jmh_arguments <<< "${PROFILE_JMH_ARGS}"
+fi
+
+if [[ "${repository_dir}" != /* ]]; then
+    repository_dir="${PWD}/${repository_dir}"
+fi
+[[ -d "${repository_dir}" ]] || fail "Repository directory does not exist: 
${repository_dir}"
+repository_dir=$(cd "${repository_dir}" && pwd)
+benchmark_jar="${repository_dir}/seatunnel-benchmarks/target/benchmarks.jar"
+[[ -f "${benchmark_jar}" ]] || fail "Missing ${benchmark_jar}. Build it with: 
./mvnw -Pbenchmark -pl seatunnel-benchmarks -am -DskipTests package"
+
+fork_option_seen=false
+for ((argument_index = 0; argument_index < ${#jmh_arguments[@]}; 
argument_index++)); do
+    case "${jmh_arguments[argument_index]}" in
+        -f)
+            [[ "${fork_option_seen}" == "false" ]] || fail "Specify the JMH -f 
option only once."
+            [[ $((argument_index + 1)) -lt ${#jmh_arguments[@]} ]] || fail 
"JMH -f requires a value."
+            [[ "${jmh_arguments[argument_index + 1]}" == "1" ]] || fail 
"Diagnostic runs require exactly one fork; use '-f 1'."
+            fork_option_seen=true
+            ;;
+        -f=*)
+            [[ "${fork_option_seen}" == "false" ]] || fail "Specify the JMH -f 
option only once."
+            [[ "${jmh_arguments[argument_index]}" == "-f=1" ]] || fail 
"Diagnostic runs require exactly one fork; use '-f 1'."
+            fork_option_seen=true
+            ;;
+    esac
+done
+if [[ "${fork_option_seen}" == "false" ]]; then
+    jmh_arguments+=( -f 1 )
+fi
+
+set +e
+benchmark_listing=$(java -jar "${benchmark_jar}" "${benchmark_selector}" -l)
+listing_status=$?
+set -e
+[[ ${listing_status} -eq 0 ]] || fail "Could not resolve the benchmark 
selector '${benchmark_selector}'."
+
+resolved_benchmarks=()
+while IFS= read -r benchmark_name; do
+    case "${benchmark_name}" in
+        "" | Benchmarks:*) continue ;;
+        *) resolved_benchmarks+=( "${benchmark_name}" ) ;;
+    esac
+done <<< "${benchmark_listing}"
+
+if [[ ${#resolved_benchmarks[@]} -eq 0 ]]; then
+    fail "Benchmark selector '${benchmark_selector}' did not match a 
benchmark."
+fi
+if [[ ${#resolved_benchmarks[@]} -ne 1 ]]; then
+    printf 'Matched benchmarks:\n' >&2
+    printf '  %s\n' "${resolved_benchmarks[@]}" >&2
+    fail "Benchmark diagnostics require one exact benchmark method; 
'${benchmark_selector}' matched ${#resolved_benchmarks[@]}."
+fi
+
+if [[ -z "${output_directory}" ]]; then
+    run_id="$(date -u +%Y%m%dT%H%M%SZ)-$$"
+    
output_directory="${repository_dir}/seatunnel-benchmarks/target/profiles/${command_name}-${mode}-${run_id}"
+elif [[ "${output_directory}" != /* ]]; then
+    output_directory="${PWD}/${output_directory}"
+fi
+
+if [[ -d "${output_directory}" ]]; then
+    first_existing_artifact=$(find "${output_directory}" -mindepth 1 -print 
-quit)
+    [[ -z "${first_existing_artifact}" ]] || fail "Output directory is not 
empty: ${output_directory}"
+elif [[ -e "${output_directory}" ]]; then
+    fail "Output path is not a directory: ${output_directory}"
+fi
+
+raw_directory="${output_directory}/raw"
+pipeline_directory="${output_directory}/pipeline-results"
+jmh_result="${output_directory}/result.jmh.json"
+jmh_log="${output_directory}/jmh.log"
+profile_json="${output_directory}/profile-report.json"
+profile_markdown="${output_directory}/profile-summary.md"
+mkdir -p "${raw_directory}" "${pipeline_directory}"
+
+resolve_async_profiler_library() {
+    local candidate
+    for candidate in \
+        "${ASYNC_PROFILER_HOME}/lib/libasyncProfiler.so" \
+        "${ASYNC_PROFILER_HOME}/lib/libasyncProfiler.dylib"; do
+        if [[ -f "${candidate}" ]]; then
+            echo "${candidate}"
+            return
+        fi
+    done
+    fail "async-profiler library was not found under ASYNC_PROFILER_HOME."
+}
+
+convert_async_profiler_jfr() {
+    local profile_jfr
+    local profile_summary
+    profile_jfr=$(find "${raw_directory}" -type f -name 'jfr-*.jfr' -print 
-quit)
+    profile_summary=$(find "${raw_directory}" -type f -name 'summary-*.txt' 
-print -quit)
+    [[ -n "${profile_jfr}" && -n "${profile_summary}" ]] || return 1
+
+    local sample_count
+    sample_count=$(
+        awk -F ':' '/^Total samples/ {gsub(/[[:space:]]/, "", $2); print $2; 
exit}' \
+            "${profile_summary}"
+    )
+    if [[ ! "${sample_count}" =~ ^[0-9]+$ ]]; then
+        echo "ERROR: Could not read async-profiler sample count from 
${profile_summary}." >&2
+        return 1
+    fi
+    if [[ ${sample_count} -eq 0 ]]; then
+        echo "async-profiler collected no ${mode} samples; no flame graph was 
generated."
+        return 0
+    fi
+
+    local event_option="--${mode}"
+    local profile_prefix
+    profile_prefix=$(basename "${profile_jfr}" .jfr)
+    profile_prefix=${profile_prefix#jfr-}
+    local profile_directory
+    profile_directory=$(dirname "${profile_jfr}")
+    local 
forward_html="${profile_directory}/flame-${profile_prefix}-forward.html"
+    local 
reverse_html="${profile_directory}/flame-${profile_prefix}-reverse.html"
+
+    "${async_profiler_converter}" --output html "${event_option}" --threads \
+        "${profile_jfr}" "${forward_html}"
+    "${async_profiler_converter}" --output html "${event_option}" --threads 
--reverse \
+        "${profile_jfr}" "${reverse_html}"
+}
+
+profiler_arguments=()
+async_profiler_converter=""
+case "${command_name}:${mode}" in
+    profile:cpu | profile:wall | profile:lock)
+        [[ -n "${ASYNC_PROFILER_HOME:-}" ]] || fail "Set ASYNC_PROFILER_HOME 
to the complete async-profiler installation."
+        async_profiler_library=$(resolve_async_profiler_library)
+        async_profiler_converter="${ASYNC_PROFILER_HOME}/bin/jfrconv"
+        [[ -x "${async_profiler_converter}" ]] || fail "Missing async-profiler 
converter: ${async_profiler_converter}"
+        event="${mode}"
+        interval=10000000
+        if [[ "${mode}" == "cpu" ]]; then
+            event="${ASYNC_PROFILER_CPU_EVENT:-cpu}"
+        fi
+        if [[ "${mode}" == "lock" ]]; then
+            # Lock profiling uses the interval as a contention-duration 
threshold. A low threshold
+            # keeps short queue monitor contention visible; profiler scores 
remain diagnostic only.
+            interval=10000
+        fi
+        profiler_arguments+=(
+            -prof
+            
"async:libPath=${async_profiler_library};event=${event};interval=${interval};threads=true;output=jfr,text;dir=${raw_directory}"
+        )
+        ;;
+    profile:gc)
+        profiler_arguments+=(
+            -prof
+            "gc:alloc=true;churn=true;churnWait=500"
+        )
+        ;;
+    capture:jfr)
+        profiler_arguments+=(
+            -prof
+            "jfr:dir=${raw_directory};configName=profile;stackDepth=256"
+        )
+        ;;
+esac
+
+java_command=(
+    java
+    "-Dseatunnel.benchmark.result.dir=${pipeline_directory}"
+    -jar
+    "${benchmark_jar}"
+    "${benchmark_selector}"
+    -foe
+    true
+    -rf
+    json
+    -rff
+    "${jmh_result}"
+)
+java_command+=("${profiler_arguments[@]}")
+java_command+=("${jmh_arguments[@]}")
+
+echo "Diagnostic: ${command_name} ${mode}"
+echo "Benchmark: ${resolved_benchmarks[0]}"
+echo "Output: ${output_directory}"
+echo "JMH log: ${jmh_log}"
+
+set +e
+(
+    cd "${repository_dir}"
+    "${java_command[@]}"
+) > "${jmh_log}" 2>&1
+benchmark_status=$?
+set -e
+
+diagnostic_status=${benchmark_status}
+if [[ ${benchmark_status} -eq 0 && -n "${async_profiler_converter}" ]]; then
+    if ! convert_async_profiler_jfr; then
+        diagnostic_status=1
+    fi
+fi
+
+report_status=0
+python3 "${script_dir}/profile_report.py" mode \
+    --mode "${mode}" \
+    --command "${command_name}" \
+    --jmh "${jmh_result}" \
+    --artifact-dir "${output_directory}" \
+    --output-json "${profile_json}" \
+    --output-md "${profile_markdown}" \
+    --status "${diagnostic_status}" || report_status=$?
+
+if [[ ${benchmark_status} -ne 0 ]]; then
+    echo "JMH failed with status ${benchmark_status}; the last 200 log lines 
follow:" >&2
+    tail -n 200 "${jmh_log}" >&2
+    exit "${benchmark_status}"
+fi
+if [[ ${diagnostic_status} -ne 0 ]]; then
+    echo "async-profiler post-processing failed; inspect ${profile_markdown}." 
>&2
+    exit "${diagnostic_status}"
+fi
+echo "Diagnostic report: ${profile_markdown}"
+exit "${report_status}"
diff --git a/tools/benchmarks/profile_report.py 
b/tools/benchmarks/profile_report.py
new file mode 100644
index 0000000000..d091990d02
--- /dev/null
+++ b/tools/benchmarks/profile_report.py
@@ -0,0 +1,522 @@
+#!/usr/bin/env python3
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+"""Build a standalone report for benchmark profiling artifacts."""
+
+import argparse
+import datetime
+import json
+import math
+import pathlib
+import re
+import subprocess
+
+
+SCHEMA = "seatunnel-profile/v1"
+GC_COLUMNS = (
+    ("gc.alloc.rate.norm", "Alloc/op", "B/op"),
+    ("gc.alloc.rate", "Alloc rate", "MB/sec"),
+    ("gc.count", "GC count", "counts"),
+    ("gc.time", "GC time", "ms"),
+)
+PROFILE_MODES = ("cpu", "wall", "lock", "gc")
+MODE_DETAILS = {
+    "cpu": {
+        "directory": "profile-cpu",
+        "label": "CPU",
+        "contents": "Flame graphs, async-profiler JFR and text summary",
+    },
+    "wall": {
+        "directory": "profile-wall",
+        "label": "Wall",
+        "contents": "Flame graphs, async-profiler JFR and text summary",
+    },
+    "lock": {
+        "directory": "profile-lock",
+        "label": "Lock",
+        "contents": "Contention flame graphs, JFR and text summary",
+    },
+    "gc": {
+        "directory": "profile-gc",
+        "label": "GC",
+        "contents": "Allocation and garbage-collection metrics",
+    },
+    "jfr": {
+        "directory": "capture-jfr",
+        "label": "JFR",
+        "contents": "JVM Flight Recorder capture",
+    },
+}
+
+
+def parse_args():
+    parser = argparse.ArgumentParser(description=__doc__)
+    commands = parser.add_subparsers(dest="report_command", required=True)
+
+    mode_parser = commands.add_parser("mode", help="Build one profiler 
report.")
+    mode_parser.add_argument("--mode", required=True)
+    mode_parser.add_argument(
+        "--command", required=True, choices=("profile", "capture")
+    )
+    mode_parser.add_argument("--jmh", required=True, type=pathlib.Path)
+    mode_parser.add_argument("--artifact-dir", required=True, 
type=pathlib.Path)
+    mode_parser.add_argument("--output-json", required=True, type=pathlib.Path)
+    mode_parser.add_argument("--output-md", required=True, type=pathlib.Path)
+    mode_parser.add_argument("--status", required=True, type=int)
+
+    summary_parser = commands.add_parser(
+        "summary", help="Build the combined GitHub Actions summary."
+    )
+    summary_parser.add_argument(
+        "--diagnostics-dir", required=True, type=pathlib.Path
+    )
+    summary_parser.add_argument(
+        "--profile", required=True, choices=("none", "cpu", "wall", "lock", 
"gc", "all")
+    )
+    summary_parser.add_argument(
+        "--capture-jfr", required=True, choices=("true", "false")
+    )
+    summary_parser.add_argument("--repository", required=True, 
type=pathlib.Path)
+    summary_parser.add_argument("--ref", required=True)
+    summary_parser.add_argument("--pr-number", default="")
+    summary_parser.add_argument("--benchmark", required=True)
+    summary_parser.add_argument("--java", required=True)
+    summary_parser.add_argument("--jmh-args", default="")
+    summary_parser.add_argument("--artifacts-url", required=True)
+    summary_parser.add_argument("--run-id", required=True)
+    summary_parser.add_argument("--run-attempt", required=True)
+    return parser.parse_args()
+
+
+def finite_or_none(value):
+    if value is None:
+        return None
+    number = float(value)
+    return number if math.isfinite(number) else None
+
+
+def load_jmh(path):
+    if not path.is_file():
+        return []
+    try:
+        with path.open(encoding="utf-8") as handle:
+            return json.load(handle)
+    except (OSError, json.JSONDecodeError):
+        return []
+
+
+def load_profile_report(path):
+    if not path.is_file():
+        return None
+    try:
+        with path.open(encoding="utf-8") as handle:
+            report = json.load(handle)
+    except (OSError, json.JSONDecodeError):
+        return None
+    return report if isinstance(report, dict) else None
+
+
+def resolve_commit(repository):
+    try:
+        result = subprocess.run(
+            ["git", "-C", str(repository), "rev-parse", "--short=12", "HEAD"],
+            check=True,
+            stdout=subprocess.PIPE,
+            stderr=subprocess.DEVNULL,
+            text=True,
+        )
+    except (OSError, subprocess.CalledProcessError):
+        return "unknown"
+    return result.stdout.strip() or "unknown"
+
+
+def benchmark_name(result):
+    return result.get("benchmark", "unknown")
+
+
+def compact_params(params):
+    if not params:
+        return "default"
+    return ", ".join("{}={}".format(key, params[key]) for key in 
sorted(params))
+
+
+def gc_metrics(results):
+    metrics = []
+    for result in results:
+        secondary = result.get("secondaryMetrics", {})
+        values = {}
+        for name, metric in secondary.items():
+            if not name.startswith("gc."):
+                continue
+            values[name] = {
+                "value": finite_or_none(metric.get("score")),
+                "error": finite_or_none(metric.get("scoreError")),
+                "unit": metric.get("scoreUnit"),
+            }
+        if values:
+            metrics.append(
+                {
+                    "benchmark": benchmark_name(result),
+                    "params": result.get("params", {}),
+                    "metrics": values,
+                }
+            )
+    return metrics
+
+
+def human_size(size):
+    value = float(size)
+    for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
+        if abs(value) < 1024.0 or unit == "TiB":
+            return "{:.1f} {}".format(value, unit)
+        value /= 1024.0
+    return "{} B".format(size)
+
+
+def artifacts(directory, excluded):
+    values = []
+    if not directory.is_dir():
+        return values
+    excluded_paths = {path.resolve() for path in excluded}
+    for path in sorted(directory.rglob("*")):
+        if not path.is_file() or path.resolve() in excluded_paths:
+            continue
+        values.append(
+            {
+                "path": str(path.relative_to(directory)),
+                "size_bytes": path.stat().st_size,
+            }
+        )
+    return values
+
+
+def async_sample_count(directory):
+    counts = []
+    if directory.is_dir():
+        for summary in directory.rglob("summary-*.txt"):
+            match = re.search(
+                r"^Total samples\s*:\s*(\d+)",
+                summary.read_text(encoding="utf-8"),
+                flags=re.MULTILINE,
+            )
+            if match:
+                counts.append(int(match.group(1)))
+    return sum(counts) if counts else None
+
+
+def format_number(value):
+    if value is None:
+        return "n/a"
+    if abs(value) >= 1000:
+        return "{:,.2f}".format(value)
+    if abs(value) >= 1:
+        return "{:.3f}".format(value)
+    return "{:.6f}".format(value)
+
+
+def gc_report_lines(metrics):
+    """Return GC/allocation Markdown lines, or no section when metrics are 
unavailable."""
+    if not metrics:
+        return []
+    lines = [
+        "### GC and allocation metrics",
+        "",
+        "These values come from JMH's GC profiler and cover measurement 
iterations only. "
+        "The primary JMH Score from a profiled run is diagnostic and is not 
compared with "
+        "normal benchmark results.",
+        "",
+        "- **Alloc/op (B/op):** average bytes allocated per benchmark 
operation. This is the "
+        "most direct allocation-efficiency metric; lower is generally better.",
+        "- **Alloc rate (MB/sec):** average allocation throughput. It depends 
on both bytes "
+        "allocated per operation and operations per second, so a high value 
alone does not "
+        "prove a regression.",
+        "- **GC count (counts):** total garbage collections observed across 
the measured "
+        "iterations and forks. It is not normalized per operation.",
+        "- **GC time (ms):** accumulated collection time reported by the JVM 
across the "
+        "measured iterations and forks. It is JVM- and collector-dependent and 
is not an "
+        "exact stop-the-world pause-time measurement.",
+        "",
+        "Compare these values only when the JVM, collector, heap settings, 
benchmark selector, "
+        "and JMH fork/warmup/measurement settings are the same. Capture JFR 
separately when "
+        "collection phases and pause details are needed.",
+        "",
+        "| Benchmark | Parameters | Alloc/op | Alloc rate | GC count | GC time 
|",
+        "| --- | --- | ---: | ---: | ---: | ---: |",
+    ]
+    for result in metrics:
+        row = []
+        for name, _, unit in GC_COLUMNS:
+            metric = result["metrics"].get(name)
+            row.append(
+                "{} {}".format(format_number(metric["value"]), unit) if metric 
else "n/a"
+            )
+        lines.append(
+            "| `{}` | `{}` | {} | {} | {} | {} |".format(
+                result["benchmark"].rsplit(".", 1)[-1],
+                compact_params(result["params"]),
+                *row,
+            )
+        )
+    return lines
+
+
+def artifact_report_lines(values):
+    if not values:
+        return ["### Artifacts", "", "No diagnostic artifacts were produced."]
+    lines = [
+        "### Artifacts",
+        "",
+        "Open these files locally, or download the workflow artifact for 
offline analysis.",
+        "",
+        "| File | Size |",
+        "| --- | ---: |",
+    ]
+    for artifact in values:
+        lines.append(
+            "| `{}` | {} |".format(
+                artifact["path"], human_size(artifact["size_bytes"])
+            )
+        )
+    return lines
+
+
+def report_status(report):
+    if report is None:
+        return "not produced"
+    status = report.get("status")
+    if status == 0:
+        return "passed"
+    return "failed ({})".format(status if status is not None else "unknown")
+
+
+def build_report(args):
+    jmh_results = load_jmh(args.jmh)
+    gc = gc_metrics(jmh_results)
+    generated_artifacts = artifacts(
+        args.artifact_dir, (args.output_json, args.output_md)
+    )
+    return {
+        "schema": SCHEMA,
+        "generated_at": 
datetime.datetime.now(datetime.timezone.utc).isoformat(),
+        "command": args.command,
+        "mode": args.mode,
+        "status": args.status,
+        "score_comparable": False,
+        "async_samples": async_sample_count(args.artifact_dir),
+        "gc_metrics": gc,
+        "artifacts": generated_artifacts,
+    }
+
+
+def render_markdown(report):
+    lines = [
+        "## {} {} diagnostics".format(
+            report["mode"].upper(), report["command"]
+        ),
+        "",
+        "- Status: `{}`".format(report_status(report)),
+        "- Schema: `{}`".format(report["schema"]),
+        "- Score comparable with normal benchmarks: `no`",
+    ]
+    if report["mode"] in ("cpu", "wall", "lock"):
+        sample_count = report.get("async_samples")
+        lines.extend(
+            [
+                "- Async-profiler samples: `{}`".format(
+                    sample_count if sample_count is not None else "unknown"
+                ),
+                "- `secondaryMetrics.async` in the raw JMH JSON is a 
file-profiler marker; "
+                "its `NaN` Score is expected and is not a missing performance 
metric.",
+            ]
+        )
+        if sample_count == 0:
+            lines.extend(
+                [
+                    "",
+                    "> No matching {} events were observed, so no flame graph 
was generated.".format(
+                        report["mode"]
+                    ),
+                ]
+            )
+    for section in (
+        gc_report_lines(report["gc_metrics"]),
+        artifact_report_lines(report["artifacts"]),
+    ):
+        if section:
+            lines.extend([""] + section)
+    return "\n".join(lines) + "\n"
+
+
+def selected_modes(profile, capture_jfr):
+    modes = []
+    if profile == "all":
+        modes.extend(PROFILE_MODES)
+    elif profile != "none":
+        modes.append(profile)
+    if capture_jfr:
+        modes.append("jfr")
+    return modes
+
+
+def mode_report(root, mode):
+    directory = root / MODE_DETAILS[mode]["directory"]
+    return load_profile_report(directory / "profile-report.json")
+
+
+def workflow_mode_lines(mode, report):
+    details = MODE_DETAILS[mode]
+    lines = [
+        "### {} diagnostics".format(details["label"]),
+        "",
+        "- Status: `{}`".format(report_status(report)),
+    ]
+    if report is None:
+        return lines
+    if mode in ("cpu", "wall", "lock"):
+        sample_count = report.get("async_samples")
+        lines.append(
+            "- Async-profiler samples: `{}`".format(
+                sample_count if sample_count is not None else "unknown"
+            )
+        )
+        if sample_count == 0:
+            lines.extend(
+                [
+                    "",
+                    "> No matching {} events were observed, so no flame graph 
was generated.".format(
+                        mode
+                    ),
+                ]
+            )
+    if mode == "gc":
+        gc_lines = gc_report_lines(report.get("gc_metrics", []))
+        if gc_lines:
+            gc_lines[0] = "#### GC and allocation metrics"
+            lines.extend([""] + gc_lines)
+    return lines
+
+
+def workflow_artifact_name(mode, java, run_id, run_attempt):
+    if mode == "jfr":
+        prefix = "seatunnel-benchmark-capture-jfr"
+    else:
+        prefix = "seatunnel-benchmark-profile-{}".format(mode)
+    return "{}-java{}-{}-{}".format(prefix, java, run_id, run_attempt)
+
+
+def render_workflow_summary(
+    diagnostics_dir,
+    profile,
+    capture_jfr,
+    target_ref,
+    pr_number,
+    commit,
+    benchmark,
+    java,
+    jmh_args,
+    artifacts_url,
+    run_id,
+    run_attempt,
+):
+    """Return the combined GitHub Actions summary for the selected diagnostic 
modes."""
+    modes = selected_modes(profile, capture_jfr)
+    reports = {mode: mode_report(diagnostics_dir, mode) for mode in modes}
+    target = "PR #{}".format(pr_number) if pr_number else target_ref
+    settings = jmh_args or "benchmark annotations"
+    lines = [
+        "## Benchmark diagnostics",
+        "",
+        "- Target: `{}` at `{}`".format(target, commit),
+        "- Benchmark: `{}`".format(benchmark),
+        "- Java: `{}`".format(java),
+        "- JMH settings: `{}`".format(settings),
+        "- Profiled scores comparable with normal benchmarks: `no`",
+    ]
+    if any(mode in ("cpu", "wall", "lock") for mode in modes):
+        lines.extend(
+            [
+                "- `secondaryMetrics.async` is a file-profiler marker; its 
`NaN` Score "
+                "is expected.",
+            ]
+        )
+    for mode in modes:
+        lines.extend([""] + workflow_mode_lines(mode, reports[mode]))
+
+    lines.extend(
+        [
+            "",
+            "### Downloads",
+            "",
+            "Download each selected mode independently from the "
+            "[run artifacts]({}).".format(artifacts_url),
+            "",
+            "| Mode | Status | Artifact | Contents |",
+            "| --- | --- | --- | --- |",
+        ]
+    )
+    for mode in modes:
+        details = MODE_DETAILS[mode]
+        directory = diagnostics_dir / details["directory"]
+        artifact = (
+            workflow_artifact_name(mode, java, run_id, run_attempt)
+            if directory.is_dir()
+            else "not produced"
+        )
+        lines.append(
+            "| {} | `{}` | `{}` | {} |".format(
+                details["label"],
+                report_status(reports[mode]),
+                artifact,
+                details["contents"],
+            )
+        )
+    return "\n".join(lines) + "\n"
+
+
+def main():
+    args = parse_args()
+    if args.report_command == "summary":
+        print(
+            render_workflow_summary(
+                diagnostics_dir=args.diagnostics_dir,
+                profile=args.profile,
+                capture_jfr=args.capture_jfr == "true",
+                target_ref=args.ref,
+                pr_number=args.pr_number,
+                commit=resolve_commit(args.repository),
+                benchmark=args.benchmark,
+                java=args.java,
+                jmh_args=args.jmh_args,
+                artifacts_url=args.artifacts_url,
+                run_id=args.run_id,
+                run_attempt=args.run_attempt,
+            ),
+            end="",
+        )
+        return
+
+    report = build_report(args)
+    args.output_json.parent.mkdir(parents=True, exist_ok=True)
+    with args.output_json.open("w", encoding="utf-8") as handle:
+        json.dump(report, handle, indent=2, sort_keys=True)
+        handle.write("\n")
+    args.output_md.write_text(render_markdown(report), encoding="utf-8")
+
+
+if __name__ == "__main__":
+    main()
diff --git a/tools/benchmarks/test_profile_benchmarks.py 
b/tools/benchmarks/test_profile_benchmarks.py
new file mode 100644
index 0000000000..1eab3f3a67
--- /dev/null
+++ b/tools/benchmarks/test_profile_benchmarks.py
@@ -0,0 +1,268 @@
+#!/usr/bin/env python3
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+import os
+import pathlib
+import subprocess
+import tempfile
+import unittest
+
+
+SCRIPT = pathlib.Path(__file__).with_name("profile_benchmarks.sh")
+
+
+FAKE_JAVA = """#!/usr/bin/env bash
+set -euo pipefail
+
+for argument in "$@"; do
+    if [[ "${argument}" == "-l" ]]; then
+        printf 'Benchmarks:\n'
+        case "${FAKE_LIST_MODE:-one}" in
+            one)
+                printf '%s\n' 
'org.apache.seatunnel.benchmark.QueueBenchmark.publish'
+                ;;
+            two)
+                printf '%s\n' \
+                    'org.apache.seatunnel.benchmark.QueueBenchmark.publish' \
+                    'org.apache.seatunnel.benchmark.QueueBenchmark.consume'
+                ;;
+            zero) ;;
+        esac
+        exit 0
+    fi
+done
+
+printf '%s\n' "$@" > "${FAKE_JAVA_ARGS_FILE}"
+result_file=''
+profiler_directory=''
+previous_argument=''
+for argument in "$@"; do
+    if [[ "${previous_argument}" == "-rff" ]]; then
+        result_file="${argument}"
+    fi
+    case "${argument}" in
+        async:*)
+            profiler_directory="${argument##*;dir=}"
+            profiler_directory="${profiler_directory%%;*}"
+            ;;
+    esac
+    previous_argument="${argument}"
+done
+[[ -n "${result_file}" ]]
+printf '[]\n' > "${result_file}"
+if [[ -n "${profiler_directory}" ]]; then
+    trial_directory="${profiler_directory}/fake-benchmark"
+    mkdir -p "${trial_directory}"
+    printf '%s\n' \
+        '--- Execution profile ---' \
+        "Total samples       : ${FAKE_ASYNC_SAMPLES:-42}" \
+        > "${trial_directory}/summary-cpu.txt"
+    printf 'jfr\n' > "${trial_directory}/jfr-cpu.jfr"
+fi
+"""
+
+
+FAKE_JFRCONV = """#!/usr/bin/env bash
+set -euo pipefail
+
+printf '%s\n' "$@" >> "${FAKE_CONVERTER_ARGS_FILE}"
+for output_file in "$@"; do :; done
+printf '<html>\nf(1)\n</html>\n' > "${output_file}"
+"""
+
+
+class ProfileBenchmarksTest(unittest.TestCase):
+
+    def setUp(self):
+        self.temporary_directory = tempfile.TemporaryDirectory()
+        self.root = pathlib.Path(self.temporary_directory.name)
+        self.repository = self.root / "candidate"
+        target = self.repository / "seatunnel-benchmarks" / "target"
+        target.mkdir(parents=True)
+        (target / "benchmarks.jar").touch()
+
+        fake_bin = self.root / "bin"
+        fake_bin.mkdir()
+        fake_java = fake_bin / "java"
+        fake_java.write_text(FAKE_JAVA, encoding="utf-8")
+        fake_java.chmod(0o755)
+
+        profiler_home = self.root / "async-profiler"
+        profiler_library = profiler_home / "lib" / "libasyncProfiler.dylib"
+        profiler_library.parent.mkdir(parents=True)
+        profiler_library.touch()
+        profiler_converter = profiler_home / "bin" / "jfrconv"
+        profiler_converter.parent.mkdir(parents=True)
+        profiler_converter.write_text(FAKE_JFRCONV, encoding="utf-8")
+        profiler_converter.chmod(0o755)
+
+        self.java_arguments = self.root / "java-arguments.txt"
+        self.converter_arguments = self.root / "converter-arguments.txt"
+        self.environment = os.environ.copy()
+        self.environment.pop("BENCHMARKS", None)
+        self.environment.pop("PROFILE_JMH_ARGS", None)
+        self.environment["FAKE_JAVA_ARGS_FILE"] = str(self.java_arguments)
+        self.environment["FAKE_CONVERTER_ARGS_FILE"] = 
str(self.converter_arguments)
+        self.environment["ASYNC_PROFILER_HOME"] = str(profiler_home)
+        self.environment["PATH"] = "{}{}{}".format(
+            fake_bin, os.pathsep, self.environment["PATH"]
+        )
+
+    def tearDown(self):
+        self.temporary_directory.cleanup()
+
+    def run_script(self, *arguments, environment=None):
+        return subprocess.run(
+            ["bash", str(SCRIPT), *arguments],
+            cwd=self.root,
+            env=environment or self.environment,
+            text=True,
+            stdout=subprocess.PIPE,
+            stderr=subprocess.PIPE,
+            check=False,
+        )
+
+    def test_help_documents_repository_option(self):
+        result = self.run_script("--help")
+
+        self.assertEqual(0, result.returncode)
+        self.assertIn("--repository DIR", result.stdout)
+
+    def test_rejects_selector_that_matches_multiple_benchmarks(self):
+        environment = self.environment.copy()
+        environment["FAKE_LIST_MODE"] = "two"
+
+        result = self.run_script(
+            "profile",
+            "gc",
+            "--repository",
+            str(self.repository),
+            "--benchmark",
+            ".*",
+            environment=environment,
+        )
+
+        self.assertNotEqual(0, result.returncode)
+        self.assertIn("matched 2", result.stderr)
+        self.assertFalse(self.java_arguments.exists())
+
+    def test_rejects_more_than_one_fork(self):
+        result = self.run_script(
+            "profile",
+            "gc",
+            "--repository",
+            str(self.repository),
+            "--",
+            "-f",
+            "2",
+        )
+
+        self.assertNotEqual(0, result.returncode)
+        self.assertIn("require exactly one fork", result.stderr)
+        self.assertFalse(self.java_arguments.exists())
+
+    def test_runs_candidate_jar_with_one_fork(self):
+        output = self.root / "diagnostics"
+
+        result = self.run_script(
+            "profile",
+            "gc",
+            "--repository",
+            str(self.repository),
+            "--output",
+            str(output),
+            "--",
+            "-wi",
+            "0",
+            "-i",
+            "1",
+            "-r",
+            "1s",
+        )
+
+        self.assertEqual(0, result.returncode, result.stderr)
+        arguments = 
self.java_arguments.read_text(encoding="utf-8").splitlines()
+        self.assertIn(str(self.repository / 
"seatunnel-benchmarks/target/benchmarks.jar"), arguments)
+        self.assertEqual("1", arguments[arguments.index("-f") + 1])
+        self.assertTrue((output / "jmh.log").is_file())
+        self.assertTrue((output / "profile-report.json").is_file())
+        self.assertTrue((output / "profile-summary.md").is_file())
+
+    def test_rejects_nonempty_output_directory(self):
+        output = self.root / "diagnostics"
+        output.mkdir()
+        (output / "old-profile.jfr").touch()
+
+        result = self.run_script(
+            "profile",
+            "gc",
+            "--repository",
+            str(self.repository),
+            "--output",
+            str(output),
+        )
+
+        self.assertNotEqual(0, result.returncode)
+        self.assertIn("Output directory is not empty", result.stderr)
+        self.assertFalse(self.java_arguments.exists())
+
+    def test_converts_async_jfr_to_forward_and_reverse_flame_graphs(self):
+        output = self.root / "cpu-diagnostics"
+
+        result = self.run_script(
+            "profile",
+            "cpu",
+            "--repository",
+            str(self.repository),
+            "--output",
+            str(output),
+        )
+
+        self.assertEqual(0, result.returncode, result.stderr)
+        trial = output / "raw" / "fake-benchmark"
+        self.assertIn("f(1)", (trial / "flame-cpu-forward.html").read_text())
+        self.assertIn("f(1)", (trial / "flame-cpu-reverse.html").read_text())
+        converter_arguments = 
self.converter_arguments.read_text(encoding="utf-8")
+        self.assertIn("--cpu", converter_arguments)
+        self.assertIn("--reverse", converter_arguments)
+
+    def test_zero_lock_samples_do_not_create_empty_flame_graph(self):
+        environment = self.environment.copy()
+        environment["FAKE_ASYNC_SAMPLES"] = "0"
+        output = self.root / "lock-diagnostics"
+
+        result = self.run_script(
+            "profile",
+            "lock",
+            "--repository",
+            str(self.repository),
+            "--output",
+            str(output),
+            environment=environment,
+        )
+
+        self.assertEqual(0, result.returncode, result.stderr)
+        self.assertFalse(self.converter_arguments.exists())
+        self.assertFalse(list(output.rglob("flame-*.html")))
+        summary = (output / "profile-summary.md").read_text(encoding="utf-8")
+        self.assertIn("Async-profiler samples: `0`", summary)
+        self.assertIn("no flame graph was generated", summary)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/tools/benchmarks/test_profile_report.py 
b/tools/benchmarks/test_profile_report.py
new file mode 100644
index 0000000000..bc36542ba2
--- /dev/null
+++ b/tools/benchmarks/test_profile_report.py
@@ -0,0 +1,228 @@
+#!/usr/bin/env python3
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+import json
+import pathlib
+import tempfile
+import unittest
+
+import profile_report
+
+
+class ProfileReportTest(unittest.TestCase):
+
+    @staticmethod
+    def write_profile_report(root, directory, **values):
+        report_directory = root / directory
+        report_directory.mkdir(parents=True)
+        report = {
+            "schema": profile_report.SCHEMA,
+            "mode": directory,
+            "command": "profile",
+            "status": 0,
+            "gc_metrics": [],
+            "artifacts": [],
+        }
+        report.update(values)
+        (report_directory / "profile-report.json").write_text(
+            json.dumps(report), encoding="utf-8"
+        )
+
+    def test_ignores_incomplete_jmh_result(self):
+        with tempfile.TemporaryDirectory() as directory:
+            result = pathlib.Path(directory) / "result.json"
+            result.write_text("[", encoding="utf-8")
+
+            self.assertEqual([], profile_report.load_jmh(result))
+
+    def test_extracts_gc_secondary_metrics(self):
+        results = [
+            {
+                "benchmark": "org.apache.seatunnel.Queue.publish",
+                "params": {"capacity": "1024"},
+                "secondaryMetrics": {
+                    "gc.alloc.rate.norm": {
+                        "score": 8.0,
+                        "scoreError": 0.1,
+                        "scoreUnit": "B/op",
+                    },
+                    "gc.count": {
+                        "score": 2.0,
+                        "scoreError": 0.0,
+                        "scoreUnit": "counts",
+                    },
+                    "unrelated": {
+                        "score": 1.0,
+                        "scoreError": 0.0,
+                        "scoreUnit": "x",
+                    },
+                },
+            }
+        ]
+
+        metrics = profile_report.gc_metrics(results)
+
+        self.assertEqual(1, len(metrics))
+        self.assertEqual(8.0, 
metrics[0]["metrics"]["gc.alloc.rate.norm"]["value"])
+        self.assertNotIn("unrelated", metrics[0]["metrics"])
+
+    def test_builds_artifact_list(self):
+        with tempfile.TemporaryDirectory() as directory:
+            root = pathlib.Path(directory)
+            raw = root / "raw" / "benchmark"
+            raw.mkdir(parents=True)
+            (raw / "profile.jfr").write_bytes(b"jfr")
+            artifacts = profile_report.artifacts(root, ())
+
+            self.assertEqual(
+                ["raw/benchmark/profile.jfr"],
+                [artifact["path"] for artifact in artifacts],
+            )
+
+    def test_renders_diagnostic_warning(self):
+        markdown = profile_report.render_markdown(
+            {
+                "mode": "cpu",
+                "command": "profile",
+                "status": 0,
+                "schema": profile_report.SCHEMA,
+                "gc_metrics": [],
+                "artifacts": [],
+            }
+        )
+
+        self.assertIn("Score comparable with normal benchmarks: `no`", 
markdown)
+        self.assertIn("No diagnostic artifacts were produced", markdown)
+
+    def test_reports_async_sample_count_and_nan_marker(self):
+        with tempfile.TemporaryDirectory() as directory:
+            root = pathlib.Path(directory)
+            summary = root / "raw" / "benchmark" / "summary-cpu.txt"
+            summary.parent.mkdir(parents=True)
+            summary.write_text(
+                "--- Execution profile ---\nTotal samples       : 42\n",
+                encoding="utf-8",
+            )
+
+            self.assertEqual(42, profile_report.async_sample_count(root))
+
+        markdown = profile_report.render_markdown(
+            {
+                "mode": "cpu",
+                "command": "profile",
+                "status": 0,
+                "schema": profile_report.SCHEMA,
+                "async_samples": 42,
+                "gc_metrics": [],
+                "artifacts": [],
+            }
+        )
+        self.assertIn("Async-profiler samples: `42`", markdown)
+        self.assertIn("its `NaN` Score is expected", markdown)
+
+    def test_explains_gc_metrics(self):
+        markdown = "\n".join(
+            profile_report.gc_report_lines(
+                [
+                    {
+                        "benchmark": "org.apache.seatunnel.Queue.publish",
+                        "params": {},
+                        "metrics": {},
+                    }
+                ]
+            )
+        )
+
+        self.assertIn("Alloc/op (B/op)", markdown)
+        self.assertIn("high value alone does not prove a regression", markdown)
+        self.assertIn("not normalized per operation", markdown)
+        self.assertIn("not an exact stop-the-world pause-time measurement", 
markdown)
+        self.assertIn("Capture JFR separately", markdown)
+
+    def test_renders_compact_workflow_summary_for_cpu_and_jfr(self):
+        with tempfile.TemporaryDirectory() as directory:
+            root = pathlib.Path(directory)
+            self.write_profile_report(root, "profile-cpu", async_samples=9453)
+            self.write_profile_report(
+                root, "capture-jfr", mode="jfr", command="capture"
+            )
+
+            markdown = profile_report.render_workflow_summary(
+                diagnostics_dir=root,
+                profile="cpu",
+                capture_jfr=True,
+                target_ref="dev",
+                pr_number="",
+                commit="0123456789ab",
+                benchmark="IntermediateQueueBenchmark.disruptorRecordHandoff$",
+                java="11",
+                jmh_args="-f 1 -wi 1 -i 1 -w 1s -r 1s",
+                artifacts_url="https://example.test/actions/runs/42#artifacts";,
+                run_id="42",
+                run_attempt="2",
+            )
+
+        self.assertIn("Target: `dev` at `0123456789ab`", markdown)
+        self.assertIn("Async-profiler samples: `9453`", markdown)
+        self.assertLess(
+            markdown.index("### CPU diagnostics"),
+            markdown.index("### JFR diagnostics"),
+        )
+        self.assertIn("seatunnel-benchmark-profile-cpu-java11-42-2", markdown)
+        self.assertIn("seatunnel-benchmark-capture-jfr-java11-42-2", markdown)
+        self.assertIn("https://example.test/actions/runs/42#artifacts";, 
markdown)
+        self.assertNotIn("raw/", markdown)
+
+    def test_renders_all_modes_in_order_and_marks_missing_artifact(self):
+        with tempfile.TemporaryDirectory() as directory:
+            root = pathlib.Path(directory)
+            self.write_profile_report(root, "profile-cpu", async_samples=1)
+            self.write_profile_report(root, "profile-wall", async_samples=2)
+            self.write_profile_report(root, "profile-lock", async_samples=3)
+            self.write_profile_report(root, "capture-jfr", mode="jfr")
+
+            markdown = profile_report.render_workflow_summary(
+                diagnostics_dir=root,
+                profile="all",
+                capture_jfr=True,
+                target_ref="dev",
+                pr_number="12021",
+                commit="fedcba987654",
+                benchmark="Queue.method$",
+                java="8",
+                jmh_args="",
+                artifacts_url="https://example.test/artifacts";,
+                run_id="7",
+                run_attempt="1",
+            )
+
+        headings = [
+            "### CPU diagnostics",
+            "### Wall diagnostics",
+            "### Lock diagnostics",
+            "### GC diagnostics",
+            "### JFR diagnostics",
+        ]
+        positions = [markdown.index(heading) for heading in headings]
+        self.assertEqual(sorted(positions), positions)
+        self.assertIn("Target: `PR #12021` at `fedcba987654`", markdown)
+        self.assertIn("| GC | `not produced` | `not produced` |", markdown)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/tools/benchmarks/test_regression_report.py 
b/tools/benchmarks/test_regression_report.py
new file mode 100644
index 0000000000..3cb3686d4b
--- /dev/null
+++ b/tools/benchmarks/test_regression_report.py
@@ -0,0 +1,148 @@
+#!/usr/bin/env python3
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+import json
+import pathlib
+import sys
+import tempfile
+import unittest
+from unittest import mock
+
+import regression_report
+
+
+class RegressionReportTest(unittest.TestCase):
+
+    def test_rejects_unknown_report_schema(self):
+        with tempfile.TemporaryDirectory() as directory:
+            path = pathlib.Path(directory) / "report.json"
+            path.write_text(json.dumps({"schema_version": 2}), 
encoding="utf-8")
+
+            with self.assertRaisesRegex(ValueError, "Unsupported benchmark 
report schema"):
+                regression_report.load_report(path)
+
+    def test_jmh_comparison_uses_medians_and_converts_units(self):
+        baselines = [
+            self.report("dev", self.jmh_metric(1000.0, "ops/s")),
+            self.report("dev", self.jmh_metric(1.0, "ops/ms")),
+        ]
+        candidates = [
+            self.report("pr", self.jmh_metric(1.1, "ops/ms")),
+            self.report("pr", self.jmh_metric(1100.0, "ops/s")),
+        ]
+
+        markdown = "\n".join(
+            regression_report.jmh_comparison_lines(baselines, candidates)
+        )
+
+        self.assertIn("1.000", markdown)
+        self.assertIn("1.100", markdown)
+        self.assertIn("+10.00%", markdown)
+        self.assertIn("ops/ms", markdown)
+
+    def test_lower_is_better_change_is_reported_as_positive(self):
+        metric = self.jmh_metric(10.0, "ms/op", direction="lower")
+        baseline = self.report("dev", metric)
+        candidate = self.report(
+            "pr", self.jmh_metric(8.0, "ms/op", direction="lower")
+        )
+
+        markdown = "\n".join(
+            regression_report.jmh_comparison_lines([baseline], [candidate])
+        )
+
+        self.assertIn("+20.00%", markdown)
+
+    def test_report_explains_score_error_and_cv(self):
+        metric = self.jmh_metric(100.0, "ops/s")
+        metric.update({"score_error": 5.0, "sample_standard_deviation": 10.0})
+
+        markdown = "\n".join(regression_report.jmh_report_lines([metric]))
+
+        self.assertIn("confidence-interval half-width", markdown)
+        self.assertIn("sample standard deviation", markdown)
+        self.assertIn("5.00%", markdown)
+        self.assertIn("10.00%", markdown)
+
+    def test_main_writes_markdown_report(self):
+        with tempfile.TemporaryDirectory() as directory:
+            root = pathlib.Path(directory)
+            source = root / "report.json"
+            output = root / "nested" / "report.md"
+            source.write_text(
+                json.dumps(self.report("dev", self.jmh_metric(100.0, 
"ops/s"))),
+                encoding="utf-8",
+            )
+            arguments = [
+                "regression_report.py",
+                "--input",
+                str(source),
+                "--output",
+                str(output),
+            ]
+
+            with mock.patch.object(sys, "argv", arguments):
+                regression_report.main()
+
+            markdown = output.read_text(encoding="utf-8")
+
+        self.assertIn("## SeaTunnel benchmark report", markdown)
+        self.assertIn("Queue.publish", markdown)
+        self.assertTrue(markdown.endswith("\n"))
+
+    @staticmethod
+    def jmh_metric(value, unit, direction="higher"):
+        return {
+            "name": 
"org.apache.seatunnel.QueueBenchmark.publish[capacity=1024]",
+            "benchmark": "org.apache.seatunnel.QueueBenchmark.publish",
+            "kind": "jmh",
+            "value": value,
+            "score_error": None,
+            "sample_standard_deviation": None,
+            "relative_score_error": None,
+            "unit": unit,
+            "direction": direction,
+            "mode": "thrpt" if direction == "higher" else "avgt",
+            "params": {"capacity": "1024"},
+            "forks": 1,
+            "samples": [value],
+        }
+
+    @staticmethod
+    def report(ref, metric):
+        return {
+            "schema_version": 1,
+            "generated_at": "2026-08-31T00:00:00+00:00",
+            "source": {
+                "ref": ref,
+                "commit": "{}-commit".format(ref),
+                "run_id": "42",
+                "suite": "test",
+            },
+            "environment": {
+                "name": "local",
+                "java_requested": "8",
+                "jdk_version": "1.8.0_472",
+            },
+            "metrics": [metric],
+            "pipeline_correctness": {},
+        }
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/tools/benchmarks/test_save_jmh_result.py 
b/tools/benchmarks/test_save_jmh_result.py
new file mode 100644
index 0000000000..fa107a5372
--- /dev/null
+++ b/tools/benchmarks/test_save_jmh_result.py
@@ -0,0 +1,180 @@
+#!/usr/bin/env python3
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+import json
+import pathlib
+import sys
+import tempfile
+import unittest
+from unittest import mock
+
+import save_jmh_result
+
+
+class SaveJmhResultTest(unittest.TestCase):
+
+    def test_normalizes_jmh_samples_and_uncertainty(self):
+        metrics = save_jmh_result.jmh_metrics(
+            [
+                {
+                    "benchmark": "org.apache.seatunnel.Queue.publish",
+                    "mode": "thrpt",
+                    "forks": 2,
+                    "params": {"workers": "2", "capacity": "1024"},
+                    "primaryMetric": {
+                        "score": 100.0,
+                        "scoreError": 5.0,
+                        "scoreUnit": "ops/s",
+                        "rawData": [[90.0, 110.0], [100.0]],
+                    },
+                }
+            ]
+        )
+
+        self.assertEqual(1, len(metrics))
+        metric = metrics[0]
+        self.assertEqual(
+            "org.apache.seatunnel.Queue.publish[capacity=1024,workers=2]",
+            metric["name"],
+        )
+        self.assertEqual([90.0, 110.0, 100.0], metric["samples"])
+        self.assertEqual(10.0, metric["sample_standard_deviation"])
+        self.assertEqual(0.05, metric["relative_score_error"])
+        self.assertEqual("higher", metric["direction"])
+
+    def test_aggregates_pipeline_medians_correctness_and_clamping(self):
+        with tempfile.TemporaryDirectory() as directory:
+            pipeline_dir = pathlib.Path(directory)
+            samples = [
+                self.pipeline_sample("sourceSink-0-0", 1000.0, True, True, 1),
+                self.pipeline_sample("sourceSink-0-1", 1200.0, False, False, 
2),
+            ]
+            for index, sample in enumerate(samples):
+                (pipeline_dir / "{}.json".format(index)).write_text(
+                    json.dumps(sample), encoding="utf-8"
+                )
+
+            metrics, correctness = 
save_jmh_result.pipeline_metrics(pipeline_dir)
+
+        throughput = next(
+            metric
+            for metric in metrics
+            if metric["metric"] == "throughput_rows_per_second"
+        )
+        p50 = next(
+            metric for metric in metrics if metric["metric"] == 
"event_time_latency_p50_ms"
+        )
+        p99 = next(
+            metric for metric in metrics if metric["metric"] == 
"event_time_latency_p99_ms"
+        )
+        self.assertEqual(1100.0, throughput["value"])
+        self.assertFalse(p50["clamped"])
+        self.assertTrue(p99["clamped"])
+
+        values = next(iter(correctness.values()))
+        self.assertEqual(2, values["sample_count"])
+        self.assertEqual(1, values["complete_samples"])
+        self.assertEqual(1, values["sustainable_samples"])
+        self.assertEqual(2, values["latency_percentiles_clamped_samples"])
+        self.assertEqual(3, values["latency_overflow_rows"])
+
+    def test_main_writes_versioned_report_and_environment(self):
+        with tempfile.TemporaryDirectory() as directory:
+            root = pathlib.Path(directory)
+            jmh = root / "jmh.json"
+            output = root / "nested" / "report.json"
+            jmh.write_text(
+                json.dumps(
+                    [
+                        {
+                            "benchmark": "org.apache.seatunnel.Queue.publish",
+                            "mode": "thrpt",
+                            "forks": 1,
+                            "params": {},
+                            "jdkVersion": "1.8.0_472",
+                            "vmName": "OpenJDK 64-Bit Server VM",
+                            "jmhVersion": "1.37",
+                            "primaryMetric": {
+                                "score": 10.0,
+                                "scoreError": 1.0,
+                                "scoreUnit": "ops/s",
+                                "rawData": [[10.0]],
+                            },
+                        }
+                    ]
+                ),
+                encoding="utf-8",
+            )
+            arguments = [
+                "save_jmh_result.py",
+                "--jmh",
+                str(jmh),
+                "--output-json",
+                str(output),
+                "--ref",
+                "dev",
+                "--commit",
+                "abc123",
+                "--java",
+                "8",
+                "--environment",
+                "local",
+                "--run-id",
+                "42",
+                "--timestamp",
+                "2026-08-31T00:00:00+00:00",
+            ]
+
+            with mock.patch.object(sys, "argv", arguments):
+                save_jmh_result.main()
+
+            report = json.loads(output.read_text(encoding="utf-8"))
+
+        self.assertEqual(save_jmh_result.SCHEMA_VERSION, 
report["schema_version"])
+        self.assertEqual("2026-08-31T00:00:00+00:00", report["generated_at"])
+        self.assertEqual("abc123", report["source"]["commit"])
+        self.assertEqual("1.8.0_472", report["environment"]["jdk_version"])
+        self.assertEqual(1, len(report["metrics"]))
+
+    @staticmethod
+    def pipeline_sample(run_id, throughput, complete, sustainable, 
overflow_rows):
+        expected_rows = 100
+        return {
+            "run_id": run_id,
+            "offered_rate_rows_per_second": 1000,
+            "parallelism": 2,
+            "payload_size": 256,
+            "transform_operations": 64,
+            "processed_rows": expected_rows if complete else expected_rows - 1,
+            "expected_rows": expected_rows,
+            "sustainable": sustainable,
+            "latency_percentiles_clamped": True,
+            "latency_overflow_rows": overflow_rows,
+            "throughput_rows_per_second": throughput,
+            "event_time_latency_p50_ms": 10.0,
+            "event_time_latency_p95_ms": 100.0,
+            "event_time_latency_p99_ms": 60001.0,
+            "event_time_latency_max_ms": 70000.0,
+            "first_half_p99_ms": 500.0,
+            "second_half_p99_ms": 60001.0,
+            "latency_growth_ratio": 2.0,
+        }
+
+
+if __name__ == "__main__":
+    unittest.main()

Reply via email to