Skip to content

[Test][Zeta] Add a checkpoint scheduling delay benchmark - #12418

Merged
nzw921rx merged 8 commits into
apache:devfrom
Vivek1106-04:benchmark-checkpoint-scheduling
Oct 1, 2026
Merged

nzw921rx merged 8 commits into
apache:devfrom
Vivek1106-04:benchmark-checkpoint-scheduling

Conversation

@Vivek1106-04

@Vivek1106-04 Vivek1106-04 commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Purpose of this pull request

Adds CheckpointSchedulingBenchmark, a JMH benchmark for the checkpoint
scheduling layer. Requested by @nzw921rx on
#12339 so that a change to the
scheduling model can be judged on measurements rather than on argument.

Today that layer is a blind spot. CheckpointingTimeBenchmark#checkpointSingleInput
goes through CheckpointBenchmarkTrigger, which reflects straight into
createPendingCheckpoint and startTriggerPendingCheckpoint, so the scheduler
is out of the measured path entirely, and checkpoint completion time is
dominated by the barrier round-trip anyway.

This benchmark measures how late a periodic checkpoint trigger runs after it is
due, as a distribution (Mode.SampleTime: p50 / p99 / max, not only a mean),
while sweeping the number of active pipelines on one member.

Fixture: a real member, real coordinators, fake tasks

  • One real SeaTunnel member provides checkpoint storage (isolated in the trial's
    temp directory), the IMaps, the checkpoint monitor service and the engine
    context. IMap persistence is off: its write-through MapStore stalls checkpoint
    id allocation for hundreds of milliseconds, which is not what is measured.
  • FakeTaskCheckpointManager extends CheckpointManager and creates real
    CheckpointCoordinators through the existing constructor, one
    single-pipeline job each. Only the tasks are fake, at the existing
    protected sendOperationToMemberNode: the fake reports READY_START and
    acknowledges each barrier. Every other operation gets an empty success.
  • Whichever checkpoint scheduler the engine on the classpath uses is the one
    measured, so the same benchmark runs unchanged on dev and on [Improve][Zeta] Share checkpoint trigger scheduling across active pipelines #12165.

No engine code change, no Mockito, no new dependency.

Measurement boundary

  • @Setup(Level.Invocation), not timed: picks the coordinator due soonest,
    parks until shortly before it is due, then spins to the due time.
  • @Benchmark, timed: spins until that coordinator's pendingCounter goes from
    0 to 1, i.e. until the trigger has created its pending checkpoint.

The score is the scheduling delay plus the trigger's own decision logic up to
creating the checkpoint. The due time is "previous observation + interval",
which is a few microseconds early because the trigger body increments
pendingCounter just before re-arming. The offset is identical on every
scheduler compared; the reason is a comment at the spin loop.

Keeping the samples honest

  • Probes. Only a fixed subset of coordinators is measured, started at least
    100 ms apart; the rest are load. Measuring all of them would drop whichever
    trigger came due while another was being measured, and those are the
    triggers that bunch up under contention, which biases the result towards
    short delays where the scheduler is under the most pressure. Which
    coordinators are probes is fixed by index, so the samples carry no such
    selection.
  • Skip guard. A due trigger that cannot be measured is skipped, and the
    count is printed every iteration, pass or fail, split by cause: checkpoint
    already pending, came due during another sample, parking overran, ran before
    its estimated due time. An iteration with more than 10% skipped fails the
    run instead of reporting a number. The limit applies once an iteration has at
    least 50 due triggers, so the CI job's one-second smoke iteration, which sees
    only a handful, is not failed by a single skip.
  • Interval. pipelineNum * 20 ms, floored at 200 ms, so every point of the
    sweep sees the same trigger rate and storage load. 500 pipelines gives 10 s.

Engine internals it depends on

Two fields are read by reflection, read-only:
CheckpointCoordinator.pendingCounter and
CheckpointBarrierTriggerOperation.barrier (no getter). The fake also encodes
two task messages. Each has a comment at its read site, and a missing field
fails the benchmark at setup with the class and field named.

Tooling change: save_jmh_result.py

For sample modes JMH writes raw data as rawDataHistogram, not rawData.
jmh_metrics() only read rawData, so every SampleTime benchmark was
reported with CV = 0%. No existing benchmark used SampleTime, which is why
this had not shown up. Each iteration's histogram is now reduced to its mean,
so CV keeps meaning variation between iterations as it does for the other
modes. Benchmarks that publish rawData are unchanged.

Does this PR introduce any user-facing change?

No. Benchmark and benchmark-tooling code only.

How was this patch tested?

./mvnw -Pbenchmark -pl seatunnel-benchmarks verify -Dtest='CheckpointScheduling*Test'
Tests run: 7, Failures: 0, Errors: 0, Skipped: 0

python3 -m unittest test_save_jmh_result    # plus the other tools/benchmarks tests
OK

CheckpointSchedulingFixtureTest runs the fixture on a real member: due
triggers of real coordinators are measured, teardown leaves none of the
fixture's scheduler threads behind (a pre-existing checkpoint-* thread from
elsewhere in the JVM is ignored), parameters are validated, probe spacing is
computed as documented, the skip limit applies only once enough triggers were
due, and a missing engine field is reported by name.
CheckpointSchedulingBenchmarkTest checks the JMH mode, unit and thread count. The new
test_save_jmh_result case failed before the fix.

Full sweep, local, one fork, 1 warmup + 3 measurement iterations of 10 s,
dev vs #12165 head c773cc091, same benchmark code on both:

pipelines scheduler threads dev scheduler threads #12165 skipped dev skipped #12165
1 1 2 2 of 174 1 of 175
10 15 8 1 of 323 2 of 305
100 195 10 2 of 346 2 of 344
500 995 10 1 of 353 2 of 352

The scheduler thread counts in this table are lower bounds. They were taken
before trial setup waited for every coordinator to trigger once, when it waited
only for the measured ones, so a few load coordinators could still be arming
their first trigger when the threads were counted. The benchmark now prints the
full count.

Delay numbers are deliberately not in this table: on macOS every timed wait
fires about 5 ms late, for a plain ScheduledThreadPoolExecutor too, so the
local distribution sits on the OS timer floor. The delay comparison will come
from the Linux runners through the benchmarks.yml entry.

Check list

No new dependency, no user-facing change, no incompatibility, no connector
code. docs/en/engines/zeta/benchmark.md covers running the benchmark suite
and needs no per-benchmark section.

CheckpointingTimeBenchmark cannot see how a checkpoint trigger was scheduled:
CheckpointBenchmarkTrigger reflects straight into createPendingCheckpoint and
startTriggerPendingCheckpoint, so the scheduler is out of the measured path,
and the number it reports is dominated by the barrier round-trip in any case.

CheckpointSchedulingBenchmark measures the scheduling layer instead: the delay
between a checkpoint trigger becoming due and its scheduling thread running it,
sampled as a distribution. The axis it sweeps is the pipeline count, because
every CheckpointCoordinator builds its own two-thread pool and a member running
P pipelines therefore carries 2P scheduler threads.

How long a trigger occupies its thread is a parameter rather than a fixed cost,
since that is what decides whether a given pool width is wide enough and it is
not the same for every deployment.
@Fork(
value = 3,
jvmArgsAppend = {
"-Xms2g",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please use:
"-Xms4g",
"-Xmx4g",

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in e8a8450b0 - 4g, matching CheckpointingTimeBenchmark.

@nzw921rx nzw921rx left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

benchmarks.yml need add this benchmark

Comment thread seatunnel-benchmarks/README.md Outdated
- `pipelineNum`: pipelines scheduling checkpoints on the member. Each `CheckpointCoordinator`
builds its own two-thread pool, so this also sets the scheduler thread count.
- `checkpointIntervalMillis`: interval of the background trigger load. The default is far below
the production default of 300000 so that a short iteration sees a realistic number of triggers.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The benchmark testing document has been refactored and does not require any changes

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reverted in 556de1f92 - README is back to dev untouched. I had missed that the benchmark documentation moved to docs/en/engines/zeta/benchmark.md; that file is unchanged too.

…flow

Adds CheckpointSchedulingBenchmark to the benchmark suite choices so it can be
dispatched from the Benchmarks workflow like the other benchmark classes.

Reverts the README change: benchmark documentation now lives in
docs/en/engines/zeta/benchmark.md and needs no per-benchmark section here.
@github-actions github-actions Bot added the CI&CD label Sep 21, 2026
@Vivek1106-04

Copy link
Copy Markdown
Contributor Author

All three items are pushed.

  • Heap: e8a8450b0, 4g, matching CheckpointingTimeBenchmark.
  • README: 556de1f92, reverted. I had missed that the benchmark documentation
    moved to docs/en/engines/zeta/benchmark.md; that file is unchanged too.
  • benchmarks.yml: 556de1f92, CheckpointSchedulingBenchmark is now in the
    benchmarks choice list next to CheckpointingTimeBenchmark.
    tools/benchmarks/test_run_benchmarks.py still passes, 7/7.

One judgement call I did not make on my own: I did not add it to
tools/benchmarks/suites/benchmarks_core.txt. The benchmark has 8 parameter
configurations (pipelineNum 1/10/100/500 x triggerBodyMicros 0/500) at
roughly 4 minutes each, so nightly inclusion costs about 32 minutes per Java
version against the 240-minute budget, and the suite file selects by class or
method, so the parameter matrix cannot be narrowed there.

If you want it tracked nightly the options are to take that cost, or to cut the
defaults in the benchmark itself to a smaller matrix and keep the full sweep for
manual dispatch. That is your call on the nightly budget - say which and I will
push it.

I am running the full sweep on dev now and will post the numbers here, then
the identical sweep against #12165 so both scheduling models are measured on the
same settings.

@DanielLeens DanielLeens left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks a lot for this contribution, @Vivek1106-04, and for turning around the three items from @nzw921rx so quickly. The idea is a good one: the checkpoint scheduling layer really is a blind spot today (CheckpointingTimeBenchmark reflects straight into the trigger and never touches the ScheduledExecutorService), and having a distribution-style (SampleTime) benchmark for it will help the discussion in #12339 and #12165 a lot. The code is clean, follows the existing *Benchmark + *BenchmarkState delegate pattern, carries the ASF headers, and CI is green. I have no hard blockers, but I found one design point that affects how far the resulting numbers can be trusted, plus two small test and reporting points.

What Problem Does This PR Solve?

  • User pain point: there is no way to measure how long a due checkpoint trigger waits for its scheduling thread. Every CheckpointCoordinator creates its own Executors.newScheduledThreadPool(2, ...) (CheckpointCoordinator.java:240-247), and the proposal in #12339 wants to replace that with a member-wide scheduler. Without a benchmark, the two models can only be argued about.
  • Fix approach: add CheckpointSchedulingBenchmark (JMH Mode.SampleTime, microseconds) plus a CheckpointSchedulingBenchmarkState that builds one 2-thread scheduler per pipeline, arms a periodic "trigger body" on each, and measures the round trip of a zero-delay task submitted to pipeline 0's scheduler. It sweeps pipelineNum (1/10/100/500) and triggerBodyMicros (0/500), registers the class in the benchmarks.yml choice list, and adds a small unit test for the harness.
  • One-sentence summary: benchmark-only harness that samples per-pipeline scheduler wake-up delay so a shared scheduler can later be compared on identical settings.

Before / after: before, -Pbenchmark had no class that exercises the scheduling threads; after, CheckpointSchedulingBenchmark.perPipelineSchedulerTriggerDelay reports p50/p99/max for each (pipelineNum, triggerBodyMicros) pair and can be selected from the benchmarks dropdown.

1. Code Change Review

1.1 Core Logic Analysis

What changed (4 files, +454/-0, all under seatunnel-benchmarks/ plus one line in .github/workflows/benchmarks.yml; no engine, connector or API code):

  • CheckpointSchedulingBenchmark.java: JMH entry point with one PerPipelineSchedulerState (@Setup(Level.Trial) / @TearDown(Level.Trial)) that delegates to the state class. JVM args (-Xms4g -Xmx4g -XX:+UseG1GC -XX:+AlwaysPreTouch ...) are identical to CheckpointingTimeBenchmark, so the heap request from @nzw921rx is satisfied.
  • CheckpointSchedulingBenchmarkState.java: creates pipelineNum ScheduledThreadPoolExecutor(2) instances named like the real coordinator threads, arms scheduleWithFixedDelay(runTriggerBody, staggeredInitialDelay, checkpointIntervalMillis) on each, and exposes scheduleAndAwaitTrigger() which does schedulers.get(0).schedule(latch::countDown, 0, ms) and waits on the latch (30 s guard).
  • CheckpointSchedulingBenchmarkTest.java: 4 tests (annotation contract, 200 triggers under load, teardown leaves no thread, parameter validation).
  • benchmarks.yml: adds 'CheckpointSchedulingBenchmark' to the benchmarks choice list (line 43).

Key findings:

  • The harness mirrors the real pool shape correctly: 2 core threads, setRemoveOnCancelPolicy(true), same thread-name prefix (CheckpointCoordinator.java:240-248 vs CheckpointSchedulingBenchmarkState.java:152-167). The one thing it leaves out is the MDCTracer.tracing(...) wrapper, which is fine for a scheduling-latency measurement.
  • Parameter validation matches the engine (MIN_CHECKPOINT_INTERVAL_MILLIS = 10 matches CheckpointConfig.java:57).
  • Lifecycle is sound: shutdownNow() then awaitTermination() per pool, background failures are surfaced through an AtomicReference and rethrown on the next measured call.
  • tools/benchmarks/run_benchmarks.sh accepts a bare class name as selector, so the new dropdown entry works without further wiring.
  • The measured path, however, cannot exhibit queueing in the per-pipeline model (see Issue 1).

Correctness analysis of the measured path (no engine lifecycle is touched, so no runtime-path block is needed): scheduleAndAwaitTrigger() -> ScheduledThreadPoolExecutor.schedule() -> DelayedWorkQueue -> idle worker of pool 0 wakes -> latch.countDown() -> JMH thread wakes. Each pool has 2 workers and exactly one periodic body (0 or 500 microseconds once per 1000 ms). The measured task therefore always finds an idle worker, and the sample is essentially one thread hand-off.

Evidence from the fork CI run for this head (benchmark-test (8, ubuntu-latest), 1 fork, no warmup, 1 iteration of 1 s per combination): p50 is 38.1 us (pipelineNum=1, body 0), 37.8 us (1, 500), 36.8 / 36.9 us (10), 29.8 / 29.1 us (100), 33.5 / 32.6 us (500). That is flat across both parameters. This is a short single-fork run, so it is only supporting evidence; the conclusion above comes from the code.

1.2 Compatibility Impact

Fully compatible. Test-scope code plus a workflow_dispatch choice entry; no config option, default, serialization, checkpoint/savepoint or SPI change, and no change to any existing benchmark or to the nightly suite.

1.3 Performance / Side-Effect Analysis

  • Runtime cost of the benchmark itself: @Fork(3) from the class, BenchmarkBase defaults of 3 warmup and 5 measurement iterations of 10 s, 8 parameter combinations. That matches the author's estimate of roughly 4 minutes per combination, about 32 minutes per Java version if run in full. It is not part of benchmarks_core.txt, so nightly runs are unaffected. I agree with the decision not to add it to the nightly suite for now; see Issue 1 for why the matrix is currently larger than the information it can produce.
  • Resource footprint: 500 pipelines means 500 to about 1000 threads inside a fork that already has -Xms4g -XX:+AlwaysPreTouch. The CI validation run completed all 8 combinations in a few seconds each, so this is fine on ubuntu-latest.
  • The unit test finishes in 0.045 s in the fork CI log (Java 8 job), so the added CI time is negligible. Nothing new runs in the default unit-test job, since the module is behind -Pbenchmark.

1.4 Error Handling and Logging

No swallowed exceptions in the main paths. runTriggerBody re-interrupts on InterruptedException and records other throwables for the next measured call; the 30 s latch guard and 10 s shutdown guard fail loudly. Formal issues:

Issue 1: The per-pipeline baseline cannot show queueing, so triggerBodyMicros (and largely pipelineNum) do not influence the measured sample

  • Location: CheckpointSchedulingBenchmarkState.java:78-93 (one periodic body per pool), :104-117 (measured task always goes to schedulers.get(0)), CheckpointSchedulingBenchmark.java:94-101 (parameter matrix).
  • Problem: each pool has 2 threads and only one periodic body, with a duty cycle of at most 500 us per 1000 ms. The measured task can only wait if both threads of pool 0 are busy, which cannot happen. Other pipelines live in other pools and only compete for CPU. The real coordinator can hold both threads (a blocked trigger plus a blocked timeout watchdog, CheckpointCoordinator.java:550 and :1029), but the harness never schedules a second task kind per pool. As a result, the number that comes out is thread wake-up latency, and the triggerBodyMicros sweep (half of the 8 combinations) is not expected to move it. The fork CI numbers quoted in 1.1 agree with this. Note also that the PR description says the parameter "decides whether a given pool width is wide enough", which is only true once a model in which timers and bodies share threads exists.
  • Potential risk: the full sweep the author plans to post for dev will look flat, and that flatness can be read as "the current model has no scheduling delay", which is not what was actually measured. It also spends about 16 of the 32 minutes per Java version on combinations that cannot differ.
  • Best improvement: pick whichever fits the plan for #12165. (a) Keep the harness as the deliberate control for a later shared-scheduler state, and say so in the class Javadoc ("expected to be flat; the axis becomes meaningful once bodies and timers share threads"), and trim the default matrix (for example triggerBodyMicros = 500 only) until then. Or (b) make the baseline faithful: arm both a trigger-like and a watchdog-like task per pool, with a body long enough that both workers can be occupied, so a measured task can actually queue.
  • Severity: Medium (non-blocking, but worth settling before the numbers are used to judge #12165)
  • Raised by another reviewer: No

Issue 2: Teardown assertion scans process-wide thread names and one test lacks a finally

  • Location: CheckpointSchedulingBenchmarkTest.java:79-90 (line 89), CheckpointSchedulingBenchmarkState.java:146-150.
  • Problem: shouldStopEverySchedulerThreadOnTearDown asserts Thread.getAllStackTraces() contains zero threads whose name starts with checkpoint-coordinator-. Two weaknesses: (a) the scan is JVM-wide, and the same surefire JVM also runs SeaTunnelEnvironmentContextTest, which starts a real mini cluster and executes jobs whose real CheckpointCoordinators use exactly the same thread-name prefix (CheckpointCoordinator.java:245-246); if any of those threads is still winding down when this test runs, the assertion fails through no fault of this class. (b) awaitTermination returns when the pool state becomes TERMINATED, which happens just before the last worker thread actually exits, so an immediate scan can very rarely still see a live thread. Separately, the test at lines 79-90 calls tearDown() without try/finally, so a failure in scheduleAndAwaitTrigger() leaves up to 8 non-daemon pool threads behind (test 2 already does this correctly at lines 64-66).
  • Potential risk: low-probability flake in the benchmark-test job that runs for every PR touching seatunnel-benchmarks/**. I could not reproduce or confirm either race from static reading, which is why this is Low.
  • Best improvement: have the state's ThreadFactory record the threads it creates, and in the test join(timeout) on those and assert !isAlive(). That removes the global scan and closes the exit race in one go. Wrap the body of the test in try/finally like test 2.
  • Severity: Low
  • Raised by another reviewer: No

Issue 3: The reported scheduler thread count is timing dependent, and is printed with System.out

  • Location: CheckpointSchedulingBenchmark.java:111-113, CheckpointSchedulingBenchmarkState.java:146-150, class Javadoc CheckpointSchedulingBenchmark.java:47-49.
  • Problem: ThreadPoolExecutor starts core workers lazily, and ScheduledFutureTask starts the second worker only after a periodic task has run once. Immediately after setUp() the count is therefore between P and 2P, depending on how many staggered periodic triggers have already fired. The fork CI log shows exactly that: 1 pipelines: 1, 10 pipelines: 11, 100 pipelines: 102, 500 pipelines: 542. The Javadoc and PR text say "2P scheduler threads", and #12339 itself says "up to two". Also, System.out.printf is the only stdout write in seatunnel-benchmarks/src/main.
  • Potential risk: the "cost the shared scheduler exists to remove" is exactly the number this line is meant to report, and it will be non-reproducible between runs (and understated versus the worst case).
  • Best improvement: either call prestartAllCoreThreads() on each pool so the count is deterministically 2P and say "up to 2P" in the Javadoc, or report the count as a secondary JMH result instead of printing it.
  • Severity: Low
  • Raised by another reviewer: No

2. Code Quality Assessment

2.1 Coding Standards

Good. ASF license header on all three new files, no wildcard imports, comments explain why (sleep versus spin, why the measured task only signals, why the interval defaults far below 300000). The state class is package-private and small, and it follows the delegate pattern used by IntermediateQueueBenchmark. Nit: the private helper awaitEveryPipelineTriggered sits between test methods (CheckpointSchedulingBenchmarkTest.java:92-104); moving it below the last test would match the usual layout.

2.2 Test Coverage and Test Stability

Coverage of the harness is appropriate: main path (200 measured triggers under 8 pipelines with a 20 ms interval), lifecycle (teardown), and parameter validation for all three inputs. The first test only re-asserts class-level annotations, which mirrors the existing CheckpointingTimeBenchmarkTest pattern, so that is consistent with the module.

Stability rating: Risk present.

  • Hard sleeps: only the 20 ms polling loop bounded by a 30 s deadline (CheckpointSchedulingBenchmarkTest.java:98-104) and the intentional body sleep inside the background load. No latency thresholds are asserted, so the test cannot flake on machine speed.
  • Lower-bound assertions (>= PIPELINE_NUM) are safe: worker prestart on the first scheduleWithFixedDelay guarantees one live thread per pool.
  • No fixed ports and no static state; state.tearDown() runs in finally for test 2.
  • Risk items: the JVM-wide thread-name scan and the missing finally in test 3 (Issue 2, :89 and :84-87).
  • Where it runs: only in benchmark-test (8|11, ubuntu-latest), which passed on this head (58 tests, 0 failures in the Java 8 log, including all 4 new tests). The default unit-test job is skipped for this module.

2.3 Documentation Updates

No user-facing change, and I agree with the decision to leave seatunnel-benchmarks/README.md untouched after the benchmark docs moved to docs/en/engines/zeta/benchmark.md (that file does not enumerate benchmark classes, so nothing is out of date). If Issue 1 is taken in direction (a), the class Javadoc is the right place for the "expected to be flat" note.

3. Architectural Soundness

3.1 Elegance of the Solution

Long-term solution in intent, and the state/benchmark split is neat. As written it is a faithful model of the pool shape but not yet of the load shape (Issue 1). Isolating the scheduling layer instead of going through a real CheckpointCoordinator is the right call for this quantity.

3.2 Maintainability

Good. The harness has no dependency on engine internals beyond documented constants (pool width, thread-name prefix, minimum interval); if the engine changes those, the constants and Javadoc need to follow, which the comments already point out.

3.3 Extensibility

The PerPipelineSchedulerState / perPipelineSchedulerTriggerDelay naming clearly leaves room for a shared-scheduler state, which is the natural next step for #12165. Keeping the measured-task contract (scheduleAndAwaitTrigger) behind the delegate will make that comparison straightforward.

3.4 Historical-Version Compatibility

Not affected. No config, wire format, state or checkpoint format is touched; nothing here is on the 2.6 -> 3.x style upgrade path since it is benchmark code only. Existing benchmarks and the benchmarks_core suite are unchanged.

4. Issue Summary

# Issue Location Severity
1 Baseline harness cannot show queueing; triggerBodyMicros and pipelineNum do not influence the measured sample CheckpointSchedulingBenchmarkState.java:78-93,104-117, CheckpointSchedulingBenchmark.java:94-101 Medium
2 Teardown assertion scans JVM-wide thread names; test 3 lacks try/finally CheckpointSchedulingBenchmarkTest.java:79-90 Low
3 Reported scheduler thread count is timing dependent (P to 2P) and printed via System.out CheckpointSchedulingBenchmark.java:111-113 Low

No High-severity issues. CI status: the fork Build run and the benchmark-test jobs for Java 8 and 11 succeeded on this head.

5. Merge Recommendation

Conclusion: Ready to merge after fixes

  1. Blockers - must be fixed: none.
  2. Recommended fixes - non-blocking:
    • Issue 1: either document that the per-pipeline baseline is the expected-flat control and trim the default matrix, or make the load faithful so a measured task can actually queue. I would settle this before posting the full sweep, so the published numbers say what they measure.
    • Issue 2: track the pool threads in the state and join them instead of scanning all JVM threads; add the missing finally.
    • Issue 3: prestartAllCoreThreads() (or a secondary JMH metric) so the thread count is deterministic.

Overall assessment: a small, well-structured and well-commented harness that fills a real gap, with a green CI. The remaining points are about making the measurement say what the PR text says it says; none of them is a correctness risk for the engine.

Better alternative? Option A (small): keep the harness and add the Javadoc clarification plus a smaller default matrix. Option B (more faithful): model both the trigger and the timeout watchdog per pool so the baseline can queue, then reuse the same measured-task contract for a shared-scheduler state. I lean to B if the sweep is meant to be the evidence for #12165, and A if the intent is only to land a reusable control first.

Response to the existing review: @nzw921rx's change request had three items, and all three look addressed at this head: heap set to 4g in CheckpointSchedulingBenchmark.java:62-63 (same as CheckpointingTimeBenchmark), README no longer in the diff, and the class is in the benchmarks.yml choice list (line 43). The decision on adding it to benchmarks_core.txt belongs to the maintainers; my view is to leave it out until Issue 1 is settled and the matrix is smaller. @nzw921rx, could you take another look when convenient?

Thanks again for the careful work, and for being upfront that the posted numbers are a smoke run.

@Vivek1106-04

Copy link
Copy Markdown
Contributor Author

Full sweep on this branch, replacing the smoke numbers in the description.

Mode.SampleTime, 3 forks, 3 warmup and 5 measurement iterations, JDK 17,
-Xms4g -Xmx4g -XX:ActiveProcessorCount=4, checkpointIntervalMillis=1000.
All figures us/op.

P body us mean p50 p99 p99.9 scheduler threads
1 0 6.75 6.74 10.03 17.89 1
1 500 6.51 6.62 10.03 24.03 1
10 0 7.07 7.04 9.74 14.37 10
10 500 7.11 7.08 9.79 13.49 11
100 0 8.84 8.37 12.86 18.53 101
100 500 9.77 9.33 18.69 23.88 101
500 0 46.66 45.70 83.71 201.73 508
500 500 46.17 45.12 79.23 208.90 509

Errors on the means are 0.003 to 0.086 us. The benchmark resolves the thing it
was built to resolve: scheduling delay is flat from 1 to 100 pipelines and then
has a knee, reaching 6.8x the single-pipeline figure at 500, while the thread
count tracks the pipeline count exactly as newScheduledThreadPool(2, ...) per
coordinator implies.

The max column is left out on purpose. It is one sample out of several million
and is noisy here (333 us to 4096 us across these rows), so it is not a
statistic worth reading.

I ran the same benchmark with a second arm for the #12165 shared scheduler, and
posted that comparison, a dispatch-saturation sweep, and a trace of what the
dispatch body actually does on
#12339 (comment) rather
than here, since it is about that design rather than about this benchmark. The
short version relevant to this PR is that the benchmark separates the two
scheduling models cleanly, including locating a regime where the shared model is
four orders of magnitude worse, which was the point of building it.

These are single-machine numbers on JDK 17, while the workflow matrix is Java 8
and 11. Now that the benchmark is in the benchmarks.yml dispatch list it can
be run on CI hardware against either branch if you want the figures confirmed
there.

@davidzollo davidzollo added the First-time contributor First-time contributor label Sep 21, 2026
@davidzollo
davidzollo requested a review from nzw921rx September 21, 2026 15:37

@nzw921rx nzw921rx left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It appears that this benchmark only simulates a single pipeline occupying two threads and then performs a benchmark run. It does not exercise the current engine execution path. Therefore, I do not think it can accurately compare the performance improvement between the old and new implementations, nor can it serve as a reliable long-term performance baseline.

Enhancing CheckpointCoordinator#scheduleTriggerPendingCheckpoint would be a reasonable approach. We could introduce an @VisibleForTesting method that allows the benchmark to trigger checkpoint scheduling through the actual engine path. Otherwise, it would be extremely difficult to evaluate this accurately without making intrusive changes to the engine.

@DanielLeens

Copy link
Copy Markdown
Contributor

Thanks both for keeping this moving.

@Vivek1106-04, the full sweep is useful and it actually lines up with Issue 1 from my earlier review: triggerBodyMicros (0 vs 500) barely moves the numbers at any pipeline count, which is exactly what you'd expect if the measured task almost never has to wait behind the periodic body. The knee at 500 pipelines looks like thread/CPU contention from ~1000 live threads in one JVM rather than scheduling-queue pressure, so I'd read this sweep as "per-pool wake-up latency under near-zero load," not yet as a load-bearing comparison for #12165 - which is really the same thing @nzw921rx is getting at below.

@nzw921rx, I think you're right that the harness measures thread wake-up delay rather than queueing under the engine's real trigger pattern, and that this limits how far the numbers can be trusted as a stand-in for the actual scheduling model comparison - that's the same gap I flagged as Issue 1 (non-blocking) in my review.

On routing through the real path: there's already a testing hook at that boundary - CheckpointCoordinator#scheduleTriggerPendingCheckpoint(CheckpointType, long) (CheckpointCoordinator.java:547-553) is @VisibleForTesting protected. The catch is that it schedules tryTriggerPendingCheckpoint(checkpointType), which on the happy path calls createPendingCheckpoint(...) and startTriggerPendingCheckpoint(...) (CheckpointCoordinator.java:816-824) - i.e. it builds a real PendingCheckpoint, writes to checkpoint storage, and sends real RPC operations out to task locations. Driving the benchmark through that method means constructing a real (or fully mocked, as CheckpointCoordinatorTest does with Mockito) CheckpointManager, execution plan and task locations, so the sample would then include storage and RPC noise on top of scheduling delay, which is a different and heavier thing to measure than what this PR set out to isolate.

A cheaper middle ground that gets at the same question without that machinery: my Issue 1 write-up already proposed arming both a trigger-like task and a watchdog-like task per pool with a duty cycle long enough that both of a pool's 2 workers can be occupied, so the measured task genuinely has to queue behind them - closer to how tryTriggerPendingCheckpoint's own re-scheduling (scheduleTriggerPendingCheckpoint(checkpointType, 500L) when pendingCounter.get() > 0, line ~816) can pile a second timer onto the same pool. That would show whether pool width matters under real contention, still as a standalone harness.

Could you say which of these you're looking for: exercising tryTriggerPendingCheckpoint's full side effects (storage + RPC, higher fidelity but a much bigger, more intrusive change), or a queuing-faithful standalone harness along those lines? Either is a reasonable ask, but they're different asks with different cost, and it'd help @Vivek1106-04 to know which one unblocks this PR versus which is really scoped to the #12165 discussion.

@nzw921rx

Copy link
Copy Markdown
Member

Thanks both for keeping this moving.

@Vivek1106-04, the full sweep is useful and it actually lines up with Issue 1 from my earlier review: triggerBodyMicros (0 vs 500) barely moves the numbers at any pipeline count, which is exactly what you'd expect if the measured task almost never has to wait behind the periodic body. The knee at 500 pipelines looks like thread/CPU contention from ~1000 live threads in one JVM rather than scheduling-queue pressure, so I'd read this sweep as "per-pool wake-up latency under near-zero load," not yet as a load-bearing comparison for #12165 - which is really the same thing @nzw921rx is getting at below.

@nzw921rx, I think you're right that the harness measures thread wake-up delay rather than queueing under the engine's real trigger pattern, and that this limits how far the numbers can be trusted as a stand-in for the actual scheduling model comparison - that's the same gap I flagged as Issue 1 (non-blocking) in my review.

On routing through the real path: there's already a testing hook at that boundary - CheckpointCoordinator#scheduleTriggerPendingCheckpoint(CheckpointType, long) (CheckpointCoordinator.java:547-553) is @VisibleForTesting protected. The catch is that it schedules tryTriggerPendingCheckpoint(checkpointType), which on the happy path calls createPendingCheckpoint(...) and startTriggerPendingCheckpoint(...) (CheckpointCoordinator.java:816-824) - i.e. it builds a real PendingCheckpoint, writes to checkpoint storage, and sends real RPC operations out to task locations. Driving the benchmark through that method means constructing a real (or fully mocked, as CheckpointCoordinatorTest does with Mockito) CheckpointManager, execution plan and task locations, so the sample would then include storage and RPC noise on top of scheduling delay, which is a different and heavier thing to measure than what this PR set out to isolate.

A cheaper middle ground that gets at the same question without that machinery: my Issue 1 write-up already proposed arming both a trigger-like task and a watchdog-like task per pool with a duty cycle long enough that both of a pool's 2 workers can be occupied, so the measured task genuinely has to queue behind them - closer to how tryTriggerPendingCheckpoint's own re-scheduling (scheduleTriggerPendingCheckpoint(checkpointType, 500L) when pendingCounter.get() > 0, line ~816) can pile a second timer onto the same pool. That would show whether pool width matters under real contention, still as a standalone harness.

Could you say which of these you're looking for: exercising tryTriggerPendingCheckpoint's full side effects (storage + RPC, higher fidelity but a much bigger, more intrusive change), or a queuing-faithful standalone harness along those lines? Either is a reasonable ask, but they're different asks with different cost, and it'd help @Vivek1106-04 to know which one unblocks this PR versus which is really scoped to the #12165 discussion.

@DanielLeens Thank you for your reply
Real RPC/IO can cause distortion in test scheduling, but existing custom thread pools cannot test the engine's true methods. I provide the following ideas:
Add new construction methods to eliminate existing RPC behaviors and mockups such as IMAP

Design:

Checkpoint Scheduling Benchmark Design

Goal

Measure the scheduling delay of the real CheckpointCoordinator path without starting SeaTunnel or Hazelcast nodes.

The benchmark must not override tryTriggerPendingCheckpoint.

Measurement Boundary

Start:
scheduleTriggerPendingCheckpoint()

Measured:
scheduler queueing
→ scheduler wake-up
→ shared dispatch, when applicable
→ tryTriggerPendingCheckpoint()
→ state checks
→ coordinator lock
→ submission of createPendingCheckpoint to executorService

End:
executorService.execute() receives the checkpoint creation task

The remaining checkpoint path continues outside the measured region:

CheckpointID allocation
→ PendingCheckpoint creation
→ barrier RPC
→ task ACK
→ checkpoint storage
→ IMap state update
→ checkpoint completion

CheckpointCoordinator Constructor

The production constructor delegates to a testing constructor that accepts the real scheduler.

public CheckpointCoordinator(
        CheckpointManager manager,
        CheckpointStorage checkpointStorage,
        CheckpointConfig checkpointConfig,
        long jobId,
        CheckpointPlan plan,
        CheckpointIDCounter checkpointIdCounter,
        PipelineState pipelineState,
        ExecutorService executorService,
        IMap<Object, Object> runningJobStateIMap,
        boolean isStartWithSavePoint,
        CheckpointMonitorService checkpointMonitorService) {

    this(
            manager,
            checkpointStorage,
            checkpointConfig,
            jobId,
            plan,
            checkpointIdCounter,
            pipelineState,
            executorService,
            runningJobStateIMap,
            isStartWithSavePoint,
            checkpointMonitorService,
            manager.leaseCheckpointScheduler(plan.getPipelineId()));
}
@VisibleForTesting
protected CheckpointCoordinator(
        CheckpointManager manager,
        CheckpointStorage checkpointStorage,
        CheckpointConfig checkpointConfig,
        long jobId,
        CheckpointPlan plan,
        CheckpointIDCounter checkpointIdCounter,
        PipelineState pipelineState,
        ExecutorService executorService,
        IMap<Object, Object> runningJobStateIMap,
        boolean isStartWithSavePoint,
        CheckpointMonitorService checkpointMonitorService,
        PipelineCheckpointScheduler scheduler) {

    // Keep the existing initialization unchanged.
    this.executorService = executorService;
    this.checkpointManager = manager;
    this.checkpointStorage = checkpointStorage;
    this.checkpointIdCounter = checkpointIdCounter;
    this.runningJobStateIMap = runningJobStateIMap;
    this.checkpointMonitorService = checkpointMonitorService;
    this.scheduler = scheduler;

    // Initialize the remaining existing fields normally.
}

On dev, the same testing constructor accepts the real per-pipeline ScheduledExecutorService. The two revisions are compiled separately by the benchmark workflow.

Scheduling Probe

Use the constructor-injected ExecutorService as the measurement probe.

final class ProbingExecutorService extends AbstractExecutorService {

    private final ExecutorService delegate;
    private final AtomicReference<CountDownLatch> nextProbe =
            new AtomicReference<>();

    ProbingExecutorService(ExecutorService delegate) {
        this.delegate = delegate;
    }

    void arm(CountDownLatch probe) {
        nextProbe.set(probe);
    }

    @Override
    public void execute(Runnable command) {
        CountDownLatch probe = nextProbe.getAndSet(null);

        if (probe != null) {
            probe.countDown();
        }

        delegate.execute(command);
    }

    // Delegate shutdown and lifecycle methods to delegate.
}

The probe is armed only after coordinator initialization and task readiness have completed.

Benchmark Coordinator

The benchmark subclass only exposes the protected scheduling method.

final class BenchmarkCheckpointCoordinator
        extends CheckpointCoordinator {

    void scheduleForBenchmark() {
        scheduleTriggerPendingCheckpoint(
                CheckpointType.CHECKPOINT_TYPE,
                0L);
    }
}

It must not override:

tryTriggerPendingCheckpoint(...)

Therefore all existing checks, locking and pending-checkpoint behavior remain active.

Fixture

The Fixture uses real scheduling implementations and local in-memory replacements for external services.

Scheduler                 real
CheckpointCoordinator     real
tryTriggerPendingCheckpoint real
ExecutorService           real delegate with a submission probe
CheckpointIDCounter       in-memory AtomicLong
IMap                      ConcurrentHashMap-backed adapter
CheckpointStorage         in-memory implementation
RPC                       successful local fake
Task ACK                  successful local callback
SeaTunnel nodes           not started
Hazelcast cluster         not started

The memory-backed IMap must support the operations used by CheckpointCoordinator:

get()
set()
compute()
remove()

Example:

ConcurrentMap<Object, Object> state =
        new ConcurrentHashMap<>();

IMap<Object, Object> imap = mock(IMap.class);

when(imap.get(any()))
        .thenAnswer(invocation ->
                state.get(invocation.getArgument(0)));

doAnswer(invocation -> {
            state.put(
                    invocation.getArgument(0),
                    invocation.getArgument(1));
            return null;
        })
        .when(imap)
        .set(any(), any());

when(imap.compute(any(), any()))
        .thenAnswer(invocation -> {
            Object key = invocation.getArgument(0);
            BiFunction<Object, Object, Object> function =
                    invocation.getArgument(1);
            return state.compute(key, function);
        });

when(imap.remove(any()))
        .thenAnswer(invocation ->
                state.remove(invocation.getArgument(0)));

The RPC fake returns a completed InvocationFuture. After a barrier is sent, it feeds successful TaskAcknowledgeOperation instances back to the coordinator so that pendingCounter returns to zero.

The Fixture must report all tasks as READY_START and wait for initialization to finish before arming the scheduling probe.

Benchmark Invocation

CountDownLatch triggerSubmitted = new CountDownLatch(1);

probingExecutor.arm(triggerSubmitted);
coordinator.scheduleForBenchmark();
triggerSubmitted.await();

After the measured invocation, wait for the mocked ACK and checkpoint completion outside the measured region:

fixture.awaitCheckpointCompleted();

This prevents one sample from leaking pending checkpoint state into the next sample.

Comparison

The existing workflow compiles and runs each revision separately:

A1 = dev
B1 = candidate PR
B2 = candidate PR
A2 = dev

dev uses its real per-pipeline scheduler. The candidate PR uses its real shared scheduler. Both revisions keep the same Fixture behavior, workload, parameters and measurement boundary.

@Vivek1106-04 We look forward to your reply

@Vivek1106-04

Copy link
Copy Markdown
Contributor Author

@nzw921rx @DanielLeens On the question of which direction this harness should take: I
would take the queuing-faithful standalone harness, and I can push it here.

Taking the two options as Daniel framed them.

Driving the real CheckpointCoordinator#scheduleTriggerPendingCheckpoint. The hook
is there and is already @VisibleForTesting protected, so reaching it is not the
problem. The problem is what it pulls in: tryTriggerPendingCheckpoint on the happy path
calls createPendingCheckpoint and startTriggerPendingCheckpoint, so the sample would
include checkpoint-id allocation, a real PendingCheckpoint, checkpoint storage writes
and CheckpointBarrierTriggerOperation RPCs to task locations, on top of a mocked
CheckpointManager, execution plan and task locations of the kind CheckpointCoordinatorTest
builds with Mockito.

Two reasons I do not think that answers the question this benchmark was built for.
First, the measurement stops being scheduling delay: barrier plus storage dominates, and
the numbers would move with storage and RPC behaviour rather than with the scheduling
model. Second, we already have that measurement - CheckpointingTimeBenchmark is
barrier-ACK dominated, and its CheckpointBenchmarkTrigger reflects straight into
createPendingCheckpoint and startTriggerPendingCheckpoint. Routing this benchmark
through the same machinery would mostly reproduce it at P=1.

The queuing-faithful harness. @nzw921rx's objection is that the current state arms
one trigger-like task per pool and so measures thread wake-up latency rather than
queueing, and that is correct as written. Daniel's Issue 1 proposal fixes it directly:
arm both a trigger-like task and a watchdog-like task per pool, with a duty cycle long
enough that both of a pool's two workers can be occupied, so the measured task actually
has to queue behind them. That is faithful to the engine's real pattern - the trigger
re-arms itself at scheduleTriggerPendingCheckpoint(checkpointType, 500L) while a
checkpoint is pending, and the timeout watchdog is armed per checkpoint, so two timers
against a two-thread pool is what a busy pipeline actually looks like - and it keeps the
measured quantity as scheduling delay.

It also fixes the reading of the current sweep, which is Daniel's other point: the knee
at P=500 in what I posted is thread and CPU contention from roughly 1000 live threads in
one JVM, not queue pressure, and the harness as it stands cannot separate those. With
both timers armed it can.

So unless you want the full-fidelity version for its own sake, I will:

  1. add the watchdog-like task alongside the trigger-like one, with a configurable duty
    cycle, so a measured task can genuinely queue behind both workers of a pool;
  2. re-run the sweep and replace the numbers in the description, including the numbers I
    posted for [Improve][Zeta] Share checkpoint trigger scheduling across active pipelines #12165, since those were taken on the current harness and the P=500 knee
    in them should be read as thread contention rather than queueing;
  3. say in the class Javadoc what the harness does and does not model, so the next reader
    does not have to rediscover it.

If you would rather have the real-engine-path version, or both arms, say so and I will
do that instead - I would just rather not put a number in front of reviewers without
being able to say which of the two it measures.

Separately, this PR's earlier three items remain addressed at this head: 4g heap, README
reverted, and the class registered in the benchmarks.yml choice list. The
benchmarks_core.txt decision is still yours; my view is to leave it out until the
matrix is settled, since eight parameter configurations at roughly four minutes each is
about 32 minutes per Java version against the 240-minute nightly budget.

@nzw921rx

Copy link
Copy Markdown
Member

@nzw921rx @DanielLeens On the question of which direction this harness should take: I would take the queuing-faithful standalone harness, and I can push it here.

Taking the two options as Daniel framed them.

Driving the real CheckpointCoordinator#scheduleTriggerPendingCheckpoint. The hook is there and is already @VisibleForTesting protected, so reaching it is not the problem. The problem is what it pulls in: tryTriggerPendingCheckpoint on the happy path calls createPendingCheckpoint and startTriggerPendingCheckpoint, so the sample would include checkpoint-id allocation, a real PendingCheckpoint, checkpoint storage writes and CheckpointBarrierTriggerOperation RPCs to task locations, on top of a mocked CheckpointManager, execution plan and task locations of the kind CheckpointCoordinatorTest builds with Mockito.

Two reasons I do not think that answers the question this benchmark was built for. First, the measurement stops being scheduling delay: barrier plus storage dominates, and the numbers would move with storage and RPC behaviour rather than with the scheduling model. Second, we already have that measurement - CheckpointingTimeBenchmark is barrier-ACK dominated, and its CheckpointBenchmarkTrigger reflects straight into createPendingCheckpoint and startTriggerPendingCheckpoint. Routing this benchmark through the same machinery would mostly reproduce it at P=1.

The queuing-faithful harness. @nzw921rx's objection is that the current state arms one trigger-like task per pool and so measures thread wake-up latency rather than queueing, and that is correct as written. Daniel's Issue 1 proposal fixes it directly: arm both a trigger-like task and a watchdog-like task per pool, with a duty cycle long enough that both of a pool's two workers can be occupied, so the measured task actually has to queue behind them. That is faithful to the engine's real pattern - the trigger re-arms itself at scheduleTriggerPendingCheckpoint(checkpointType, 500L) while a checkpoint is pending, and the timeout watchdog is armed per checkpoint, so two timers against a two-thread pool is what a busy pipeline actually looks like - and it keeps the measured quantity as scheduling delay.

It also fixes the reading of the current sweep, which is Daniel's other point: the knee at P=500 in what I posted is thread and CPU contention from roughly 1000 live threads in one JVM, not queue pressure, and the harness as it stands cannot separate those. With both timers armed it can.

So unless you want the full-fidelity version for its own sake, I will:

  1. add the watchdog-like task alongside the trigger-like one, with a configurable duty
    cycle, so a measured task can genuinely queue behind both workers of a pool;
  2. re-run the sweep and replace the numbers in the description, including the numbers I
    posted for [Improve][Zeta] Share checkpoint trigger scheduling across active pipelines #12165, since those were taken on the current harness and the P=500 knee
    in them should be read as thread contention rather than queueing;
  3. say in the class Javadoc what the harness does and does not model, so the next reader
    does not have to rediscover it.

If you would rather have the real-engine-path version, or both arms, say so and I will do that instead - I would just rather not put a number in front of reviewers without being able to say which of the two it measures.

Separately, this PR's earlier three items remain addressed at this head: 4g heap, README reverted, and the class registered in the benchmarks.yml choice list. The benchmarks_core.txt decision is still yours; my view is to leave it out until the matrix is settled, since eight parameter configurations at roughly four minutes each is about 32 minutes per Java version against the 240-minute nightly budget.

Thank you, please take a look at my design above.

@Vivek1106-04

Copy link
Copy Markdown
Contributor Author

@nzw921rx Our comments crossed - mine went up about three minutes after yours and was
written without having read this. Please take this one as my actual answer, because your
design changes my view.

You are right and my objection does not apply to your version. I argued against
routing through the real path on the grounds that the sample would be dominated by
storage and RPC. Your measurement boundary removes that: it starts at
scheduleTriggerPendingCheckpoint and ends when executorService receives the
checkpoint-creation task, so everything I was worried about - id allocation,
PendingCheckpoint construction, barrier RPC, ACK, storage, IMap - is outside the
measured region by construction. That is a better answer than the standalone harness I
was proposing, because it measures the real tryTriggerPendingCheckpoint including its
state checks and its synchronized (lock), which is exactly the part the two scheduling
models differ in. I am happy to build it.

Five things I checked against the code before replying. One of them I think removes work
from your proposal rather than adding it.

1. The testing constructor is not needed, and cannot work as written

dev and #12165 already share the identical 11-argument public constructor. The
difference is entirely inside it:

So a benchmark that constructs a coordinator through the existing public constructor gets
each revision's real scheduler automatically, with one source file that compiles on both.
A constructor taking PipelineCheckpointScheduler could not compile on dev at all -
that type does not exist there - so it would force two divergent benchmark sources, which
is the thing the A1/B1/B2/A2 comparison most needs to avoid.

It also keeps this PR test-only. Adding a constructor to CheckpointCoordinator makes a
benchmark PR touch production engine code, which I would rather not do without a
committer explicitly asking for it.

2. The interval guard will stop the probe firing after the first sample

This is the one that needs a decision from you, because it shapes the fixture.
tryTriggerPendingCheckpoint returns early, after re-arming itself, when the trigger is
inside the configured interval (CheckpointCoordinator.java:782-799):

long interval = currentTimestamp - latestTriggerTimestamp.get();
if (interval < coordinatorConfig.getCheckpointInterval()) {
    scheduleTriggerPendingCheckpoint(
            checkpointType, coordinatorConfig.getCheckpointInterval() - interval);
    return;
}

latestTriggerTimestamp is only set in createPendingCheckpoint
(CheckpointCoordinator.java:1126), so after the first successful trigger every
subsequent scheduleForBenchmark() inside the interval takes that branch and never
reaches executorService. The armed latch would then wait for the re-armed trigger
instead of the one the sample intended. There is a second guard right after it for
checkpoint.min-pause.

Three ways out, and I would like your call:

  • pin checkpointInterval to the 10 ms minimum and checkpointMinPause to -1, which
    bounds sampling at roughly 100/s but keeps the production code path completely intact;
  • reset latestTriggerTimestamp between samples, which means reaching into coordinator
    state - the kind of override you asked to avoid;
  • treat the guarded path as the measured one deliberately, since the 500 ms re-arm at
    CheckpointCoordinator.java:841 is itself a real production behaviour.

I lean to the first: slow but honest, and JMH SampleTime copes with a low rate.

3. The single-shot probe can be tripped by the previous sample

arm() / getAndSet(null) fires on the next execute(), but the coordinator posts a lot
to that same executor - whenCompleteAsync, thenApplyAsync, thenAcceptAsync in
startTriggerPendingCheckpoint, and as of da0cbbf52 on #12165 the checkpoint-expiry
hop as well. If the previous sample's completion work is still draining, the probe closes
on that instead of on the trigger. awaitCheckpointCompleted() helps, but I would rather
the probe identify the submission than trust quiescence - simplest is for the fixture to
recognise the task it is waiting for, and to fail the sample loudly if it sees an
unexpected submission rather than silently recording the wrong number.

4. Mockito is not available in seatunnel-benchmarks, and these are main sources

The benchmarks live in src/main/java, so anything they need becomes a compile-scope
dependency of a shipped module. Mockito is not currently a dependency there. For the IMap
this is easy to avoid: a java.lang.reflect.Proxy over a ConcurrentHashMap covers
get / set / compute / remove in about twenty JDK-only lines. CheckpointManager
is a class rather than an interface, so it cannot be proxied - that one needs either
Mockito added to this module, or a real manager.

5. There is already a no-mock route to a real coordinator

SeaTunnelCheckpointEnvironmentContext starts in-JVM master and worker instances for
CheckpointingTimeBenchmark, so a real CheckpointManager, real IMap and loopback RPC
are reachable today without mocking anything. The cost is a heavier fixture per fork, and
the probe then needs a different seam, since the executor belongs to CoordinatorService
rather than to us.

What I would like from you

Point 1 removes the constructor change, and points 2 to 4 are fixture details I can
settle myself once you have answered point 2. The one genuine fork is whether you want
the no-cluster fixture you sketched, with a proxied IMap and Mockito added here for
CheckpointManager, or the mini-cluster route in point 5 that needs no fakes at all.

Say which, plus your preference on the interval guard, and I will push it. If you would
rather see it than discuss it, I can push the no-cluster version with the 10 ms interval
and the existing constructor, and we can argue with numbers in front of us.

@nzw921rx

nzw921rx commented Sep 22, 2026 •

Copy link
Copy Markdown
Member

@Vivek1106-04 Here are my views. If you have any different opinions, you can correct them

  1. Thank you for pointing out this gap. I think this can actually be handled directly in your PR by modifying the JMH setup() method to initialize a CheckpointManager. Then the coordinator can obtain the real scheduler through:
this.scheduler = manager.leaseCheckpointScheduler(pipelineId);
  1. Please take a look at the comments after my research🔥

  2. I agree with your point.

  3. Between the two approaches, I prefer building a real in-memory IMap implementation.

  4. I do not think we need to start a real SeaTunnel engine.

@nzw921rx

nzw921rx commented Sep 22, 2026 •

Copy link
Copy Markdown
Member

@Vivek1106-04 @Vivek1106-04 I agree with the first option in point 2: use the minimum valid checkpointInterval of 10 ms and set checkpointMinPause to -1.

To prevent the measured coordinator's automatically re-armed trigger from leaking into the next invocation, we can expose a small coordinator-level test hook:

@VisibleForTesting
void cancelScheduledCheckpointTriggers() {
    // Cancel only this coordinator's outstanding trigger tasks.
}

This method must not shut down or recreate the scheduler:

The invocation setup can then isolate checkpoint completion, timer cleanup, and the 10 ms interval from the measured sample:

@Setup(Level.Invocation)
public void prepareInvocation() throws Exception {
    awaitPreviousCheckpointCompleted();

    // Prevent the measured coordinator's automatic trigger
    // from racing with the next explicit benchmark trigger.
    coordinator.cancelScheduledCheckpointTriggers();

    // Outside the measured region.
    TimeUnit.MILLISECONDS.sleep(11);

    // Arm the task-specific probe only after the fixture is quiescent.
    probe.arm();
}

@Setup(Level.Invocation) is outside the timed benchmark operation, so the 11 ms wait is not included in the reported latency.

The measured region remains:

scheduleTriggerPendingCheckpoint
→ real scheduler
→ shared dispatch, when applicable
→ tryTriggerPendingCheckpoint
→ state checks
→ coordinator lock
→ submission to executorService

The previous checkpoint completion, automatic-trigger cleanup, and interval wait remain outside the measurement. Background coordinators continue running normally to preserve realistic multi-pipeline scheduling load.

@DanielLeens

DanielLeens commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Thanks both — this has converged into a genuinely good design, and I want to engage with the technical substance rather than just referee.

On the measurement boundary itself: I'm convinced. @nzw921rx's design (scheduleTriggerPendingCheckpoint() → real scheduler → tryTriggerPendingCheckpoint() → state checks → lock → submission to executorService, with everything from checkpoint-ID allocation onward left outside the sample) answers the question I asked in my last comment better than either of the two options I framed it as. It measures the real decision logic the two scheduling models actually differ in (the pendingCounter.get() > 0 re-arm at CheckpointCoordinator.java:816, the interval/min-pause guards just above it) without pulling in storage or RPC noise. Good outcome.

One thing I need to flag before this goes further: the "add a testing constructor that calls manager.leaseCheckpointScheduler(...)" piece won't compile on dev, and I don't think it's needed. I checked this in the current checkout rather than take either side's word for it:

  • CheckpointManager.java has no leaseCheckpointScheduler method and there is no PipelineCheckpointScheduler type anywhere in this tree — that API only exists on [Improve][Zeta] Share checkpoint trigger scheduling across active pipelines #12165's branch.
  • CheckpointCoordinator.java has exactly one public constructor (CheckpointCoordinator.java:212), and it already builds its own real scheduler inline (CheckpointCoordinator.java:239-250: Executors.newScheduledThreadPool(2, ...), then MDCTracer.tracing(scheduler)).

So @Vivek1106-04's point 1 is right as written: a benchmark that goes through the existing 11-arg constructor gets each revision's real scheduler for free — dev's pool here, #12165's leased scheduler there — with one source file that compiles against both checkouts. A constructor or setup path that calls leaseCheckpointScheduler directly is #12165-only and would force the two-source-files problem the A1/B1/B2/A2 comparison is specifically trying to avoid. @nzw921rx, given that, I'd drop the testing-constructor / explicit-lease idea from the design and lean on the constructor you already have — it gets you the same real-scheduler behavior with zero changes to CheckpointCoordinator.

On the interval guard (Vivek's point 2): pinning checkpointInterval to the 10 ms floor and checkpointMinPause to -1 is the right call, and I'm glad you both landed there — it keeps the actual production guard branches (CheckpointCoordinator.java:782-799 and the min-pause check just above) live and exercised on every sample instead of routing around them, at the cost of capping throughput to ~100 samples/sec, which SampleTime mode tolerates fine.

On cancelScheduledCheckpointTriggers() (nzw921rx, 13:44:16): the sketch undersells what this needs. It's not "expose a small hook over what's already there" — I checked, and there's nothing there to expose yet. scheduler.schedule(...) is only called twice in this file: once at line 1029 for the checkpoint-timeout future, whose result is captured and stored on the PendingCheckpoint object, and once at line 550 inside scheduleTriggerPendingCheckpoint itself, whose returned ScheduledFuture is discarded on the spot — nothing in CheckpointCoordinator keeps a handle to it. So "cancel only this coordinator's outstanding trigger tasks" requires adding a field to track that future (or a small set of them) to production CheckpointCoordinator, not just annotating an existing method. That's a small change, but it's real production surface, and it's the same tension @Vivek1106-04 already flagged about the constructor idea: I'd rather this PR stay test-only unless a committer is explicitly asked and agrees to the addition. Two ways to keep it test-only instead: (a) since you're already pinning the interval to 10 ms, just let the re-armed trigger fire and land on the next sample's queue rather than trying to suppress it, and have the fixture/probe recognize and discard an out-of-band submission (this is close to what Vivek proposed in his point 3 already — "fail the sample loudly if it sees an unexpected submission" is the same instinct in the other direction); or (b) accept the small field addition, but call it out explicitly in the PR description as a deliberate, minimal addition to CheckpointCoordinator rather than folding it in as if it were free.

Still open, and I don't think it needs me to resolve it: Vivek's point 5 in his 12:56:17 comment — no-cluster fixture (proxied IMap, plus adding Mockito to seatunnel-benchmarks to handle CheckpointManager, which is a concrete class and can't be proxied) versus the mini-cluster route (SeaTunnelCheckpointEnvironmentContext, the same one CheckpointingTimeBenchmark already uses — real CheckpointManager, real IMap, loopback RPC, no mocks, heavier fixture per fork). Mild preference from me toward the mini-cluster route only because it adds zero new test dependencies and zero fakes to reason about, but you two have done the legwork here and either is defensible — whichever you pick, please spell out the cost (Mockito-as-a-new-seatunnel-benchmarks-dependency in one case, fixture-per-fork overhead in the other) in the PR description so it's an explicit, reviewable trade-off rather than an implementation detail.

Nothing here blocks continuing the design conversation, and there's no new commit yet, so there's nothing to re-review on the code side. Once this lands as a diff I'll go through it against this design (measurement boundary, interval pinning, and whichever fixture route you pick) rather than re-litigate the discussion.

@Vivek1106-04

Copy link
Copy Markdown
Contributor Author

@nzw921rx @DanielLeens Before choosing between the two fixtures I built both and ran them, on dev and on #12165, because a few of the open questions could only be settled by running code. Short version: neither fixture works exactly as proposed, but a variant does, it needs no production change, and the same source runs on both revisions.

1. Measure the real periodic trigger instead of injecting one

Injecting a trigger per sample fights the interval guard in tryTriggerPendingCheckpoint. With a long interval the injected trigger is re-armed instead of run; with the 10 ms interval the coordinator re-arms itself every 10 ms and races the next sample, which is what cancelScheduledCheckpointTriggers() was for. That hook needs a new field on dev (the trigger's ScheduledFuture is discarded at CheckpointCoordinator.java:550) and a different implementation on #12165, so it would be production code on both sides.

The alternative needs neither. Let the coordinator run its own periodic trigger at its configured interval, and observe it: a sample is the time pendingCounter goes 0 -> 1, minus the previous trigger time plus the interval. A sample is kept only if the previous checkpoint completed before the next one was due, so every kept sample went through the normal path, including the lock and the executor submission, rather than the 500 ms pending re-arm. The field is read, never written. Nothing in CheckpointCoordinator changes.

2. The no-cluster fixture cannot sustain samples

pendingCounter is incremented synchronously on the trigger path and only comes back down when the checkpoint completes, or through cleanPendingCheckpoint, which sets shutdown = true. With no tasks, the checkpoint never completes, and from the second sample on every trigger takes the pendingCounter > 0 re-arm and never reaches the lock or the executor. So each sample's checkpoint has to really complete.

Faking the tasks entirely is also blocked: the fake network has to return an InvocationFuture, and in Hazelcast 5.1 that class is final with a package-private constructor that dereferences a live Invocation. A real member is needed to produce one.

3. The mini-cluster does not scale to the sweep

With SeaTunnelCheckpointEnvironmentContext and real submitted jobs (-XX:ActiveProcessorCount=4, 4g, as in the benchmark):

  • P = 1 works: the real trigger is observed and checkpoints take about 11 ms.
  • P = 100 does not: 361 s from submission to all jobs RUNNING, then zero completed checkpoints in a 60 s window, with TaskAcknowledgeOperation got rejected before execution due to not starting within the operation-call-timeout.

P = 500 is out of reach on the benchmark's own CPU budget.

4. What works: a real member, real coordinators, fake tasks

One real SeaTunnel member provides the real checkpoint storage, IMap, monitor service and engine context, so on #12165 the coordinators use the real member-wide scheduler. A real CheckpointManager builds real coordinators through the existing constructor. The only fake is an anonymous subclass overriding the existing protected sendOperationToMemberNode: it answers each barrier through the public acknowledgeTask, and returns a real completed future from a local no-op operation. No Mockito, no new dependency, no change to engine code.

P = 500 as 500 jobs of one pipeline, 10 s interval, starts staggered across one interval, 60 s window:

dev #12165
checkpoint scheduler threads 1000 10
live threads, whole JVM 1137 152
kept samples 499 of 499 500 of 500
average checkpoint 6.3 ms 5.6 ms
live heap after full GC 42 MB 41 MB

5. Two things the runs forced on the design

Pipelines must be spread across jobs. The same 500 pipelines as one job averaged 12 s per checkpoint, and no sample was usable. As 500 jobs it was 5.6 ms. I have not proven the cause; my suspicion is that every completion updates one per-job entry in the checkpoint overview IMap. Either way the benchmark should model many jobs, which is also what "500 active pipelines" in #12122 means in practice. The interval is set from the same constraint: at P = 500, a 1 s interval is 500 real checkpoint completions per second and storage cannot keep up, so the sweep uses a fixed 10 s.

Absolute delay numbers must come from CI. On macOS every timed wait fires about 4-5 ms late, and a plain ScheduledThreadPoolExecutor shows the same, so locally the delay distribution sits on the OS timer floor and hides a microsecond-scale difference. The thread counts and the checkpoint completion figures above are valid locally; the delay comparison will come from the Linux runners through the benchmarks.yml entry.

Costs, stated up front

  • Two internal fields are read by reflection: pendingCounter on the coordinator, and barrier on CheckpointBarrierTriggerOperation, which has no getter. Read-only, but a rename breaks the benchmark, and I would add a check that fails loudly if either field is missing.
  • Fake tasks acknowledge immediately, so the numbers are a best case for the completion side. The scheduling side is fully real.
  • The fake encodes two pieces of the task protocol, barrier acknowledgement and READY_START reporting, which would need updating if those change.

Next

Unless you see a problem with this, I will replace the standalone harness in this PR with this fixture: sweep P over 1 / 10 / 100 / 500 as jobs of one pipeline, report scheduler thread count and the scheduling delay distribution, fail the run if kept samples drop below a threshold rather than report a number from the re-arm path, and keep the benchmark in src/main with no new dependencies. checkpointSingleInput stays untouched.

@nzw921rx

Copy link
Copy Markdown
Member

@Vivek1106-04 Thank you for clarifying. I have a question

How will the calculated periodic-trigger delay be reported as a JMH metric?

JMH measures the execution time of the @Benchmark method. If the method only waits for the next periodic trigger, the result may include the remaining portion of the 10-second interval. Returning the calculated delay also does not make that value the SampleTime score.

Please clarify the exact @Setup(Level.Invocation) and @Benchmark boundaries, or how the calculated actualTriggerTime - expectedTriggerTime values will be exported as the benchmark distribution.

How is it compatible with the existing reporting of CV, ERROR, and SCORE?

@DanielLeens

Copy link
Copy Markdown
Contributor

Thanks for actually building and running both fixtures instead of settling this by argument — the P=500 table is the evidence that should decide it, and "real member, real coordinators, fake tasks only at the network boundary" is the right shape: no production code changes on either revision, and the two costs you flagged up front (reflection field renames, the two-message fake protocol) are both acceptable and worth a loud failure check as you said.

I don't have anything to add on top of @nzw921rx's open question about how the periodic-trigger delay actually surfaces as a JMH @Benchmark measurement — that's the one piece I'd also want nailed down before this lands, since it's the actual metric the sweep is reporting. Once that's answered I'm happy to do a final pass on the pushed fixture.

One small ask for whenever you do push it: could the two costs (the two reflected fields, and the fake's assumption about which two messages it needs to answer) get a short comment at their read sites in the benchmark source, not just in this thread? That's the part most likely to silently break on an unrelated engine-internals change later.

@Vivek1106-04

Copy link
Copy Markdown
Contributor Author

@nzw921rx Good question. As I described it, the @Benchmark method would have waited for the trigger, so the score would have included most of the 10 s interval. The fix is to make the timed region start at the moment the trigger is due. JMH's own timer then measures actual - expected, and the metric is a normal JMH primary metric.

Boundaries

State (Scope.Benchmark): the real member, the P coordinators, and for each coordinator the time its last trigger was observed (lastTriggerNanos).

@Setup(Level.Invocation), not timed:

  1. Pick the coordinator whose next trigger is due soonest, where expected = lastTriggerNanos + interval. The starts are spread across the interval, so at P = 500 with a 10 s interval one trigger is due every 20 ms.
  2. Check that its previous checkpoint has completed (pendingCounter == 0). If it has not, this trigger would take the 500 ms pending re-arm, so skip this coordinator for this cycle and count the skip.
  3. Park until about 1 ms before expected, then spin until System.nanoTime() >= expected. Parking alone would put the OS timer slack into the start of the timed region, so the last millisecond is a spin.
  4. Check the trigger has not fired yet. It cannot fire before expected (see the note below), so this only catches a setup that overran. Count it as a skip too.

@Benchmark, timed: spin on that coordinator's pendingCounter until it goes 0 -> 1, record the time in lastTriggerNanos, and return.

So JMH's timed region runs from expected to "trigger observed". That is the scheduling delay plus the trigger's decision logic up to the executor submission, which is the boundary agreed earlier. Nothing is computed afterwards or returned for JMH to interpret: the delay is the invocation time.

@TearDown(Level.Iteration) fails the run if skips go over a fixed share of invocations, so a run where most triggers took the re-arm path produces an error, not a number.

Notes on precision:

  • expected is slightly early. pendingCounter is incremented just before the coordinator calls schedule(..., interval) for the next trigger, so the real deadline is a few microseconds after lastTriggerNanos + interval. Each sample therefore also includes the end of the previous trigger body. That code is the same on dev and [Improve][Zeta] Share checkpoint trigger scheduling across active pipelines #12165, so it adds a small, equal offset to both.
  • Both timestamps come from the same spin loop on the same thread, and the setup overhead JMH leaves outside the timed region is in the tens of nanoseconds. That is small against a delay measured in microseconds, which is why Level.Invocation is acceptable here even though it is usually not recommended.
  • If two coordinators are due within one sample, the second one's trigger happens while the first is being measured, so it has no fresh lastTriggerNanos. It is resynchronised on its next trigger and not sampled for that cycle. With 20 ms between due times this should be rare, and it is counted with the other skips.
  • The spinning thread uses one core for roughly 1 ms out of every 20 ms, which is about 5 % of one of the 4 CPUs. It is the same on both revisions.

Reporting: SCORE and ERROR work unchanged, CV does not

With Mode.SampleTime, SCORE is the mean invocation time and ERROR is JMH's confidence interval. save_jmh_result.py reads both from primaryMetric as it does for every other benchmark, and p50 / p99 / max are in scorePercentiles in the same JSON.

CV is the gap. For sample modes JMH writes the raw data as rawDataHistogram (per fork, per iteration, [value, count] pairs), not rawData. jmh_metrics() only flattens rawData, so for any SampleTime benchmark it gets an empty list, sample_standard_deviation returns 0.0, and the report shows CV = 0 %. No existing benchmark in seatunnel-benchmarks uses SampleTime, which is why this has not shown up.

Two ways to handle it:

  • (a) Keep SampleTime and teach save_jmh_result.py to read rawDataHistogram. Compute each iteration's mean from its histogram and take the CV across those, so it means the same as the CV of the other modes (variation between iterations, not the spread of individual delays, which is expected to be wide). That is about ten lines plus a case in test_save_jmh_result.py. It changes shared tooling, so I would call it out in the PR description.
  • (b) Use Mode.AverageTime with the same boundaries. SCORE, ERROR and CV all work with no tool change, but the percentiles are lost, and for a scheduler the tail is the part that matters.

I prefer (a). If you would rather keep the tooling out of this PR, I will use (b) here and do (a) in a follow-up.

@DanielLeens Yes. The two reflected fields (pendingCounter and CheckpointBarrierTriggerOperation.barrier) and the two messages the fake task answers (barrier acknowledgement and READY_START) will each get a comment at the place they are read. The comment will say what engine behaviour it depends on and that the fixture fails at setup if the field is missing.

@DanielLeens

Copy link
Copy Markdown
Contributor

Thanks @Vivek1106-04, this answers the piece I was waiting on. The boundary you describe -- @Setup(Level.Invocation) parks/spins up to expected untimed, and the timed @Benchmark region only starts there and ends when pendingCounter flips -- means the measured value really is scheduling delay plus the trigger's own decision logic up to executor submission, not delay-plus-wait-for-the-next-trigger. That's the boundary this benchmark needs to be trustworthy, so I'm satisfied on that front.

On the SampleTime-vs-AverageTime call: I'd lean toward (a) too (teach save_jmh_result.py to read rawDataHistogram so CV keeps meaning "variation between iterations") since losing the tail percentiles on a scheduler benchmark seems like the wrong trade-off, but I don't think it's blocking either way -- (b) as a stopgap with (a) as a fast follow-up is fine if you'd rather keep the tooling change out of this PR's description.

Appreciate you committing to comments at both read sites (the two reflected fields, and the fake's two-message assumption) explaining what engine behavior each depends on and that the fixture fails loudly at setup if it's missing -- that was the main thing I wanted nailed down given how easy this would be to silently break on an unrelated engine-internals change. Once that's pushed alongside the CV fix (or the AverageTime fallback), I'll do the final pass on the fixture.

@SEZ9

SEZ9 commented Sep 24, 2026

Copy link
Copy Markdown
Collaborator

@Vivek1106-04 Two small follow-ups on the fixture design, nothing blocking:

  1. Your note that expected is slightly early (because pendingCounter is incremented just before the schedule(..., interval) call, so each sample also includes the end of the previous trigger body, equally on both revisions) is fine with me, but please put it as a comment next to the spin loop so nobody later "fixes" it.

  2. For the iteration-level teardown skip guard: what fixed share of invocations do you have in mind, and could the skip count (split into "previous checkpoint still pending" vs "setup overran") be printed per iteration even when the run passes? That keeps a borderline run visible instead of silently accepted.

Otherwise nothing to add beyond my last comment; I'll do the final pass once the fixture is pushed.

For sample modes JMH writes raw data as rawDataHistogram (fork -> iteration
-> [value, count] pairs) instead of rawData. jmh_metrics() only flattened
rawData, so any SampleTime benchmark got no samples, a standard deviation of
0 and a reported CV of 0%.

Each iteration's histogram is now reduced to its mean, so the samples keep
the meaning they have for the other modes: variation between iterations, not
the spread of individual invocations. Benchmarks that publish rawData are
unchanged.
Replaces the standalone thread-pool harness with a fixture that runs the
production checkpoint coordinators, so the benchmark measures whichever
checkpoint scheduler the engine on the classpath uses and runs unchanged on
every revision being compared.

One real member provides checkpoint storage, the IMaps, the monitor service
and the engine context. A CheckpointManager subclass creates real
coordinators through the existing constructor, one single-pipeline job per
coordinator, and fakes only the tasks at sendOperationToMemberNode: it
reports READY_START and acknowledges each barrier. IMap persistence is off,
since its write-through MapStore stalls checkpoint id allocation for hundreds
of milliseconds.

Each invocation measures one trigger. @setup(Level.Invocation) returns when
the trigger is due; the timed @benchmark spins until the coordinator's
pendingCounter goes from 0 to 1. The score is the scheduling delay plus the
trigger's decision logic up to creating the pending checkpoint.

A fixed subset of coordinators, started at least 100 ms apart, is measured;
the rest are load. Measuring all of them would drop whichever trigger came due
while another was being measured, which biases the result towards short
delays under contention. Skips are counted by cause and printed every
iteration, and an iteration with more than 10% skipped fails the run.

The interval is pipelineNum * 20 ms, floored at 200 ms, so every point of the
sweep sees the same trigger rate. Two engine fields are read by reflection:
CheckpointCoordinator.pendingCounter and CheckpointBarrierTriggerOperation
.barrier. The fixture fails at setup, naming the field, if either is missing.
@Vivek1106-04

Copy link
Copy Markdown
Contributor Author

The fixture is pushed (afd311519, be57ed0ae) and the PR description is rewritten to match it.

@SEZ9

  1. The note is at the timed spin loop in CheckpointSchedulingFixture#awaitTrigger: why expected is a few microseconds early, that the offset is identical on every scheduler compared, and not to move the start later.

  2. The limit is 10% of due triggers per iteration. Skips are printed every iteration, pass or fail, split by cause. Running it made me split one level further than you asked, because the causes turned out to mean different things:

    # measured 100 of 101 due triggers; skipped 0 with a checkpoint already pending before the due time,
    #   0 that came due while another trigger was being measured, 1 where parking overran the due time,
    #   0 that ran before their estimated due time
    

    "Setup overran" is now three counters: came due during another sample, parking overshot, and ran before the estimate (which would mean the phase estimate is wrong).

@nzw921rx The boundaries are the ones in my last comment: the untimed @Setup(Level.Invocation) ends at the due time, and the timed @Benchmark ends when pendingCounter goes from 0 to 1. For CV I went with (a): save_jmh_result.py now reads rawDataHistogram. Its new test failed before the fix. On the local sweep it now reports CV from 3.4% to 17%, plus 81% for dev at 500 pipelines, where the macOS distribution splits between two timer behaviours. Before the fix it reported 0% for every point.

@DanielLeens Both reflected fields and both faked task messages have a comment at their read site. A missing field fails at setup with the class and field named, which shouldNameTheEngineFieldWhenItNoLongerExists covers.

Three things the runs changed, compared with what I described earlier:

  • Only a fixed subset of coordinators is measured. Measuring every coordinator, a run on [Improve][Zeta] Share checkpoint trigger scheduling across active pipelines #12165 at 10 pipelines skipped 62 of 250 due triggers, all as "came due while another trigger was being measured". Phases drift as delays vary, so due times bunch up. Dropping the second of two near-simultaneous triggers biases the result towards short delays exactly under contention, which is the case this benchmark exists for. So probes are now every k-th coordinator, started at least 100 ms apart, and the rest are load. Probe triggers still collide with the load, and which coordinators are probes is fixed by index, so the samples carry no selection. After the change, that same run skipped 2 of 305.
  • IMap persistence is off. I first reused the storage benchmarks' member. Its write-through MapStore puts a file write on a partition thread for every engine IMap update, and a thread dump showed checkpoint id allocation blocked there for over 600 ms. The fixture keeps that member's isolated checkpoint storage but disables the MapStore.
  • The interval is pipelineNum * 20 ms, floored at 200 ms, not a flat 10 s. Every point of the sweep then sees the same trigger rate and storage load. 500 pipelines is still 10 s, and the floor keeps 1 pipeline from sending triggers down the 500 ms pending re-arm path.

Local sweep, dev against #12165 at c773cc091, the same benchmark code on both, full table in the description: all four pipeline counts pass on both, with at most 2 skips per point, and scheduler threads at 500 pipelines are 995 against 10. I have left the delay numbers out on purpose: on macOS every timed wait fires about 5 ms late, so the local distribution sits on that floor. The delay comparison needs the Linux runners.

@nzw921rx @DanielLeens @SEZ9 ready for the final pass.

… iterations

The CI benchmark job smoke-runs every benchmark with one 1-second iteration
and no warmup. At 10 pipelines that is about 7 due triggers, so a single
skipped trigger was already above the 10% limit, failed the iteration and,
with -foe, stopped the benchmarks queued after it.

The limit now applies once an iteration has at least 50 due triggers, which
a configured 10-second iteration reaches at about 10 samples per second.
Shorter iterations still print their skip counts.

@DanielLeens DanielLeens left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the very thorough rework, @Vivek1106-04, and thanks to @nzw921rx and @SEZ9 for pushing the design as hard as you did in the thread — that back-and-forth clearly made this a better benchmark. I re-read the entire discussion plus the currently pushed diff from scratch (not just the summary in the thread) before writing this, and I want to independently confirm the parts I can verify from source rather than just agree by default.

What Problem Does This PR Solve?

  • User pain point: there was no way to measure how late a periodic checkpoint trigger runs relative to when it is due, on the real scheduling path. CheckpointingTimeBenchmark reflects straight into createPendingCheckpoint/startTriggerPendingCheckpoint and is dominated by the barrier round-trip, so it is close to blind to the scheduling layer itself — which is exactly the layer #12339/#12165 want to change.
  • Fix approach (current head, materially different from the version I reviewed on 09-21): CheckpointSchedulingBenchmark now runs pipelineNum real CheckpointCoordinators inside one real SeaTunnel member (CheckpointSchedulingFixture), with tasks faked only at the network boundary (FakeTaskCheckpointManager#sendOperationToMemberNode). A @Setup(Level.Invocation) picks the coordinator due soonest and parks/spins to its due time (untimed); the @Benchmark body spins until that coordinator's pendingCounter flips 0→1 (timed). Only every probeStride-th coordinator is measured; the rest are real load.
  • One-sentence summary: the benchmark now measures scheduling delay on the actual production trigger path (tryTriggerPendingCheckpoint, its interval/min-pause guards, the real per-pipeline or shared scheduler) instead of on a standalone simulated thread pool.

Before/after in one example: at pipelineNum=500 on dev, the CI run on this head logs checkpoint scheduler threads for 500 pipelines: 996 — i.e. it is actually observing ~2 threads per real CheckpointCoordinator.scheduler (Executors.newScheduledThreadPool(2, ...), CheckpointCoordinator.java:239-250 on dev), not a synthetic stand-in pool.

1. Code Change Review

1.1 Core Logic Analysis

Files (relative to dev, 8 changed, +1137/-1): CheckpointSchedulingBenchmark.java, CheckpointSchedulingFixture.java (new, 578 lines), FakeTaskCheckpointManager.java (new), BenchmarkReflection.java (new), CheckpointSchedulingBenchmarkTest.java (rewritten), tools/benchmarks/save_jmh_result.py, tools/benchmarks/test_save_jmh_result.py, .github/workflows/benchmarks.yml (unchanged from the earlier round).

I verified the following claims against source rather than taking them on faith:

  • sendOperationToMemberNode is genuinely the seam production code uses for both task-start and barrier dispatch. CheckpointCoordinator.allTaskReady() calls notifyTaskStart() → checkpointManager::sendOperationToMemberNode for NotifyTaskStartOperation, and join()s the futures before arming the first trigger (CheckpointCoordinator.java:469-479); triggerCheckpoint() sends CheckpointBarrierTriggerOperation the same way (CheckpointCoordinator.java:1175-1178). FakeTaskCheckpointManager only special-cases the barrier operation and answers everything else (including the start notification) with a completed no-op future — that is sufficient because allTaskReady() only waits for the futures to complete, not for a specific payload.
  • The two reflected field names are correct on dev. CheckpointCoordinator.java:169: private final AtomicInteger pendingCounter = new AtomicInteger(0). CheckpointBarrierTriggerOperation.java: protected Barrier barrier, populated from the real CheckpointBarrier passed by triggerCheckpoint(CheckpointBarrier) (CheckpointCoordinator.java:1175-1177), so the (CheckpointBarrier) BARRIER_FIELD.get(operation) cast in FakeTaskCheckpointManager.readBarrier is sound. Both reads are behind BenchmarkReflection.requireField, which fails at class-load with the class+field name if either is renamed — good, matches what was promised in the thread.
  • No production/engine code changed. The whole diff outside seatunnel-benchmarks/ and tools/benchmarks/ is the one-line benchmarks.yml registration. This matches the explicit decision in the thread to keep this PR test-only rather than adding a testing constructor or a cancellation hook to CheckpointCoordinator.
  • This closes my own Issue 1 from the 09-21 review ("the per-pipeline baseline cannot show queueing, so the sweep parameters barely move the sample") — that harness is gone. The new fixture measures the real tryTriggerPendingCheckpoint decision logic (interval guard at CheckpointCoordinator.java:782-799, the lock, the pendingCounter re-arm check) under real multi-pipeline load. I'd consider this the direct, verified resolution of @nzw921rx's CHANGES_REQUESTED as well (see Merge Recommendation).
  • I checked the fork's CI run for this head (Vivek1106-04/seatunnel run 35957406479, job benchmark-test (8, ubuntu-latest)), not just the description's numbers: CheckpointSchedulingBenchmarkTest passes 7/7, and the actual sweep log lines are consistent with what the PR description claims, e.g. # checkpoint scheduler threads for 500 pipelines: 996; measuring 100 of them / measured 10 of 11 due triggers. So the "it actually works end-to-end" claim isn't just asserted, it's observable in this head's own CI output.

Runtime path (engine-touching, so per the review requirements I'm tracing it explicitly): FakeTaskCheckpointManager.startTask() → CheckpointManager.reportedTask(READY_START) → CheckpointCoordinator.reportedTask → allTaskReady() → notifyTaskStart()/scheduleTriggerPendingCheckpoint(interval) → real scheduler.schedule(...) → tryTriggerPendingCheckpoint() → createPendingCheckpoint → pendingCounter.incrementAndGet() (observed by the fixture) → triggerCheckpoint() → sendOperationToMemberNode(CheckpointBarrierTriggerOperation) → fake ack → checkpoint completes → pendingCounter back to 0 → next schedule(..., interval). This is the normal production path end to end; nothing here is a recovery/exception/race-only path.

1.2 Compatibility Impact

Fully compatible. Benchmark-only code (seatunnel-benchmarks) plus a benchmark-tooling change plus one workflow_dispatch choice entry. No config option, default, protocol, serialization, checkpoint/savepoint format, or SPI is touched; no existing benchmark's behavior changes for benchmarks that still publish rawData (see 1.3).

1.3 Performance / Side-Effect Analysis

  • The fixture starts a real Hazelcast member per JMH trial with up to ~1000 real threads at pipelineNum=500 — heavy, but that's the point of the benchmark and it's gated behind -Pbenchmark/benchmark-test, never the default unit-test job. CI completed all four points of the sweep in well under a minute on ubuntu-latest with the reduced CI iteration count.
  • IMap persistence is explicitly disabled in SchedulingEnvironmentContext (CheckpointSchedulingFixture.java, the SchedulingEnvironmentContext inner class) to avoid the write-through MapStore stalling checkpoint-id allocation — a deliberate, documented, and narrowly-scoped override (only the engine* map config, only MapStoreConfig.setEnabled(false)), not a blanket change to the storage benchmark's shared environment class.
  • save_jmh_result.py: histogram_mean/iteration_scores only change behavior when rawData is absent from primaryMetric (i.e., only for SampleTime-mode benchmarks, which today is only this one). Every existing AverageTime/Throughput benchmark keeps hitting the original flatten(primary.get("rawData", [])) path unchanged — I traced this explicitly since it's shared tooling and a regression here would silently corrupt CV for every other benchmark's dashboard history.

1.4 Error Handling and Logging

No swallowed exceptions on the main paths: checkFailure() propagates coordinator errors recorded via FakeTaskCheckpointManager.handleCheckpointError (AtomicReference<Throwable>, compareAndSet) into the benchmark thread instead of silently continuing; awaitTrigger() and resync() both fail loudly with a descriptive IllegalStateException on a stuck due trigger; endIteration() fails the iteration outright (not just logs) when skips exceed 10% of due triggers, per MAX_SKIP_RATIO. This is exactly the "fail the sample loudly rather than report a wrong number" design the thread converged on, and it's implemented as described.

Issue 1: The printed scheduler-thread count can under-report because resync() at setUp() only waits for probes, not every coordinator

  • Location: CheckpointSchedulingBenchmark.java:125-131 (the System.out.printf right after fixture.setUp()), CheckpointSchedulingFixture.java:395-421 (resync() only iterates i += probeStride), CheckpointSchedulingFixture.java:345-349 (countSchedulerThreads()).
  • Problem: resync() guarantees every probe coordinator has triggered at least once (so its pool has started its second worker), but non-probe ("load") coordinators are never waited on. ScheduledThreadPoolExecutor starts its second worker only after a periodic task has run once, so a load coordinator that hasn't triggered yet still shows one thread, not two. The printed count is therefore a lower bound, not "2P", at the moment setUp() returns — this is the same class of nondeterminism as Issue 3 in my 09-21 review, just re-surfacing in the new design. It's visible in this head's own CI log: 10 pipelines: 15 (not ~20), 100 pipelines: 196, 500 pipelines: 996 (both short of 200/1000).
  • Potential risk: low — this line is purely informational/logging, it does not feed the measured sample or the skip-ratio guard, so it cannot corrupt a reported delay number. But the class Javadoc/log line don't say "at least" or "a lower bound", so a reader comparing this number against #12165's thread count could slightly overstate the dev-side thread cost.
  • Best improvement: either wait for every coordinator (not just probes) to complete one trigger before printing (cheap: an extra resync-style pass over all indices, done once at setUp()), or just change the log text to "checkpoint scheduler threads for %d pipelines (lower bound, load coordinators may not have started their second worker yet): %d".
  • Severity: Low (non-blocking)
  • Raised by another reviewer: No (evolution of my own 09-21 Issue 3 against the new implementation)

Issue 2: shouldStopEverySchedulerThreadOnTearDown still does a JVM-wide thread-name scan

  • Location: CheckpointSchedulingBenchmarkTest.java:79-99 (test body, the countSchedulerThreads() > 0 poll loop and the final assertEquals(0L, ...)), CheckpointSchedulingFixture.java:71 (SCHEDULER_THREAD_NAME_PREFIX = "checkpoint-", now broader than the old "checkpoint-coordinator-").
  • Problem: this is the same shape of assertion I flagged as Issue 2 on 09-21 (still unaddressed in that specific respect, even though the harness around it was rewritten): Thread.getAllStackTraces() is scanned process-wide. CheckpointingTimeBenchmarkTest in the same module/surefire JVM also starts a real member whose real CheckpointCoordinators use the same "checkpoint-coordinator-*"-style prefix. If any of those threads from a different test class is still winding down when this assertion runs, the test can fail through no fault of this class — and the widened prefix ("checkpoint-" vs the old "checkpoint-coordinator-") makes it match more thread names, not fewer.
  • Potential risk: low-probability flake in the benchmark-test job, same as before; I could not reproduce it and CI has been green on this exact assertion across the pushed commits, so I'm not treating it as a regression, just an unresolved carry-over.
  • Best improvement: unchanged from my earlier suggestion — have the fixture's coordinator/member thread factories record the threads they create and join(timeout)/!isAlive() on those specifically, instead of a global name-prefix scan.
  • Severity: Low (non-blocking, carried over)
  • Raised by another reviewer: No

Issue 3 (nit): two same-named constants with different values in coupled classes

  • Location: CheckpointSchedulingBenchmark.java:88 (MIN_CHECKPOINT_INTERVAL_MILLIS = 200L, the benchmark's chosen floor for pipelineNum * triggerSpacingMillis) vs. CheckpointSchedulingFixture.java:83 (MIN_CHECKPOINT_INTERVAL_MILLIS = CheckpointConfig.MINIMAL_CHECKPOINT_TIME, i.e. 10L, the engine's hard floor enforced in validateParameters()).
  • Problem: both are static final long MIN_CHECKPOINT_INTERVAL_MILLIS with different values and different meanings (a benchmark design choice vs. an engine-enforced minimum). Not a bug today — each class correctly uses its own constant — but the identical name across two directly-coupled classes is an easy trap for a future edit.
  • Best improvement: rename the benchmark-side one, e.g. TRIGGER_SPACING_FLOOR_MILLIS, to make the two constants visibly distinct.
  • Severity: Low (nit, non-blocking)
  • Raised by another reviewer: No

2. Code Quality Assessment

2.1 Coding Standards

Good. ASF headers on all four new files, no wildcard imports, comments consistently explain why (the early-expected offset at the spin loop, why only probes are measured, why IMap persistence is off, why the reflected fields are safe) rather than restating the code. BenchmarkReflection is a small, well-scoped, single-purpose utility. One Medium-ish documentation gap, not a coding-standards violation: neither the class Javadoc nor the log line in Issue 1 above documents that the printed thread count can under-report for load coordinators — worth a one-line addition.

2.2 Test Coverage and Test Stability

Coverage is appropriate for a benchmark harness: CheckpointSchedulingBenchmarkTest now exercises the real fixture end-to-end (shouldMeasureDueTriggersOfRealCoordinators, 20 real samples against real coordinators), lifecycle/teardown, parameter validation (verified to fail before any real member starts, since validateParameters() is the first statement in setUp() — no resource leak on the negative-parameter path), the pure probeStride/isSkipShareTooHigh helpers, and the reflection-failure message. This is a real improvement over the 09-21 version's harness-only tests, since it now exercises the actual engine path rather than a simulated stand-in.

Mandatory flaky-test stability analysis — rating: Risk present.

  • Hard sleeps: none of the concerning kind. startStaggered/awaitNextDueTrigger/resync use LockSupport.parkNanos to a computed deadline plus a bounded busy-spin, not a fixed Thread.sleep guess; resync()'s deadline scales with the configured interval (RESYNC_INTERVALS * intervalNanos + PENDING_REARM_NANOS), so it isn't a magic-number timeout independent of the test's own parameters.
  • Nondeterministic assertions: none found — shouldMeasureDueTriggersOfRealCoordinators asserts an exact sample count (SAMPLE_COUNT) reached through a loop that retries on skips rather than asserting on wall-clock thresholds.
  • Shared static state: none across fixture instances; BenchmarkReflection's cached Fields are read-only reflection handles, not mutable shared state.
  • Order dependence / resource release: shouldMeasureDueTriggersOfRealCoordinators releases via finally; the parameter-validation test never allocates a resource to begin with (see above). shouldStopEverySchedulerThreadOnTearDown doesn't wrap its own setUp()/tearDown() pair in try/finally, but that test's whole point is to call tearDown() itself, so this isn't a leak risk in the same way — only Issue 2's shared-prefix scan is a genuine (low-probability) flake vector.
  • Weak readiness signals: the opposite — resync() explicitly waits for "seen idle, then seen triggering" per probe rather than assuming readiness after a fixed delay, which is a stronger signal than most E2E readiness checks in this codebase.
  • Where it runs: only benchmark-test (8|11, ubuntu-latest), gated behind -Pbenchmark; confirmed green on this exact head (7/7 in CheckpointSchedulingBenchmarkTest, Tests run: 7, Failures: 0, Errors: 0 at 04:57:04Z–04:57:07Z in the fork CI log).
  • Net: I'm not rating this Stable outright because Issue 2's cross-test-class thread-name scan is a real, if low-probability, flake mechanism that predates this round and remains unaddressed; nothing here rises to High risk.

test_save_jmh_result.py's new case (test_uses_iteration_means_of_sample_time_histograms) has real failure capability: it asserts exact computed values ([15.0, 20.0, 25.0], sample_standard_deviation == 5.0) from a two-fork, mixed-bucket, one-empty-iteration histogram fixture, so a wrong weighting, a wrong iteration boundary, or a broken empty-iteration skip would all fail it, not just a presence check. The PR description states this test failed before the fix, which is exactly the "delete-the-implementation and watch it fail" property this needs.

2.3 Documentation Updates

No user-facing behavior changed, so docs/en/docs/zh don't need updates, and I agree with leaving seatunnel-benchmarks/README.md and docs/en/engines/zeta/benchmark.md untouched (neither enumerates individual benchmark classes). Minor: the PR description's "How was this patch tested?" section still says Tests run: 6, but the class now has 7 @Test methods and CI confirms Tests run: 7 — please update the description before merge; purely cosmetic, not a code issue.

3. Architectural Soundness

3.1 Elegance of the Solution

This is now the "faithful" option (Option B) from my 09-21 review rather than the "documented control" option (Option A), and I think that was the right call given #12165 needs a real comparison, not just a control. Real member + real coordinators + fakes only at the task-network boundary is a clean seam: it reuses an existing, already-battle-tested pattern (SeaTunnelStorageEnvironmentContext/StorageLifecycleFixtureJob/CheckpointStorageBenchmarkFixture already do "real member, fake edges" for other benchmarks in this same module) rather than inventing a new fixture style.

3.2 Maintainability

Good, with the caveats above. The two reflection points and the two faked task messages each have a comment at their read/answer site explaining what engine behavior they depend on, as promised in the thread, and a missing field now fails loudly at class-load with the class+field name (shouldNameTheEngineFieldWhenItNoLongerExists covers this). com.hazelcast.spi.impl.operationservice.impl.InvocationFuture is an internal Hazelcast type, but that's inherited from the CheckpointManager#sendOperationToMemberNode signature being overridden, not new fragility introduced by this PR.

3.3 Extensibility

Same fixture (real member + fakes at the boundary) runs unchanged against dev's per-pipeline scheduler and #12165's shared scheduler, which is the whole point — no forked benchmark sources, confirmed by the PR description's side-by-side dev vs. #12165 thread-count/skip table using "the same benchmark code on both."

3.4 Historical-Version Compatibility

Not affected. No config, wire format, checkpoint/savepoint format, or upgrade path is touched; this is test-only code with zero production surface change.

4. Issue Summary

# Issue Location Severity
1 Printed scheduler-thread count can under-report (resync only covers probes, not load coordinators) CheckpointSchedulingBenchmark.java:125-131, CheckpointSchedulingFixture.java:395-421,345-349 Low
2 Teardown test still does a JVM-wide thread-name scan (carried over from 09-21 Issue 2) CheckpointSchedulingBenchmarkTest.java:79-99, CheckpointSchedulingFixture.java:71 Low
3 Two same-named MIN_CHECKPOINT_INTERVAL_MILLIS constants with different meanings in coupled classes CheckpointSchedulingBenchmark.java:88, CheckpointSchedulingFixture.java:83 Low

No Medium or High-severity issues. CI status on this head: Vivek1106-04/seatunnel fork run 35957406479 — benchmark-test (8, ubuntu-latest) and (11, ubuntu-latest) both green, benchmark-tools-test (covers the Python change) green, License header/Code style green.

5. Merge Recommendation

Conclusion: Ready to merge

  1. Blockers — must be fixed: none.
  2. Recommended fixes — non-blocking, fine to take in a follow-up or a quick push either way:
    • Issue 1: note "lower bound" in the log line, or resync all coordinators (not just probes) before printing.
    • Issue 2: track fixture-owned threads directly instead of a global name-prefix scan, to remove the remaining cross-test-class flake vector.
    • Issue 3: rename the benchmark-side MIN_CHECKPOINT_INTERVAL_MILLIS for clarity.
    • Cosmetic: PR description's test count (6 → 7).
  3. Overall assessment: this is now a materially different and substantially stronger implementation than the one I reviewed on 09-21. It measures the real CheckpointCoordinator/tryTriggerPendingCheckpoint path under real multi-pipeline load, with no production code touched, a documented and bounded skip-guard instead of silently reporting bad samples, and CI evidence (not just claims) that it actually completes real checkpoints end to end at pipelineNum up to 500. The shared-tooling change to save_jmh_result.py is scoped correctly and is itself tested with a fixture that has genuine failure capability.

Response to the existing CHANGES_REQUESTED from @nzw921rx: your original point — "this benchmark only simulates a single pipeline occupying two threads... it does not exercise the current engine execution path" — was correct about the version it was written against, and I'd treat it as the same underlying concern as my own Issue 1 from that round. Based on what I verified above (real CheckpointCoordinators, real tryTriggerPendingCheckpoint, fakes only at sendOperationToMemberNode, confirmed completing real checkpoints on this head's own CI run), I believe this is now resolved at the source level, and the design you and Vivek converged on in the thread is exactly what got pushed. I'd suggest re-reviewing the current head when you have a chance to confirm you agree it's resolved, since your review state is what's currently blocking the merge gate — but from my independent read of the source and CI, I don't see a remaining engine-fidelity gap.

Nice work getting this to a "real path, no engine changes, CI-verified" state, @Vivek1106-04 — and thanks again for the patience through two full design iterations.

@davidzollo
davidzollo requested a review from nzw921rx September 24, 2026 15:01
- Trial setup now resyncs every coordinator, not only the probes, so
  every scheduler is armed before the thread count is printed; the
  printed count was a lower bound before.
- The fixture records checkpoint-* threads that exist before its setup
  and counts only newer ones, so a thread left by another test class in
  the same JVM cannot fail the teardown test.
- Rename the benchmark-side interval floor to
  MIN_MEASURABLE_INTERVAL_MILLIS, distinct from the fixture's
  MIN_CHECKPOINT_INTERVAL_MILLIS.
@Vivek1106-04

Copy link
Copy Markdown
Contributor Author

@DanielLeens Thank you for the approval and for checking the source and CI yourself instead of relying on my summary. I have pushed all three of your low-severity items in 550c22568:

  1. The printed thread count was a lower bound. Trial setup now resyncs every coordinator, not only the probes, before it prints. Every coordinator has therefore triggered and armed its scheduler by the time the threads are counted, so the line gives the full count. Iteration resyncs still cover only the probes. The thread counts in the PR description's local table were taken before this change, and the description now says they are lower bounds.
  2. The teardown test scanned every thread in the JVM. The fixture now records the checkpoint-* threads that already exist when its setup starts, and counts only threads that appear after that. The teardown test starts a checkpoint-foreign thread before the fixture and checks that the test passes while that thread is still alive. A scheduler thread left over from another test class therefore can no longer fail it.
  3. Two constants had the same name. The benchmark's constant is renamed to MIN_MEASURABLE_INTERVAL_MILLIS, and its Javadoc now says how it differs from the fixture's MIN_CHECKPOINT_INTERVAL_MILLIS, which is the lowest interval SeaTunnel accepts.

The test count in the description is corrected as well.

@nzw921rx When you have time, could you look at the current head again? Your concern that the benchmark did not exercise the real engine path shaped the rewrite. The benchmark now runs real CheckpointCoordinators through tryTriggerPendingCheckpoint and fakes only the network boundary, and the engine has no production code changes. If anything is still missing, please tell me and I will address it.

Thanks to @nzw921rx, @SEZ9 and @DanielLeens for reviewing this so carefully over several rounds. The benchmark is much stronger because of your feedback than the first version I opened.

@DanielLeens DanielLeens left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the quick turnaround, @Vivek1106-04. I re-reviewed the latest head at 550c22568, re-reading the entire diff against dev from scratch rather than just this last commit, and re-checked the production call chain and thread-naming assumptions against the current source again rather than reusing my notes from the 09-24 approval.

All three low-severity items from my previous review are resolved, cleanly and precisely:

  1. Printed scheduler-thread count was a lower bound. setUp() now resyncs every coordinator, not just the probes, before printing (resync(1)), so the line reflects the full thread cost at pipelineNum pipelines instead of only what the probes had armed so far.
  2. Teardown test scanned every thread in the JVM. The fixture now snapshots checkpoint-* threads that already exist before its own setUp() starts and only counts threads that appear after that. The rewritten shouldStopEverySchedulerThreadOnTearDown test proves this directly: it starts a synthetic checkpoint-foreign thread before the fixture, and the fixture's own thread count still reaches exactly 0 while that thread stays alive. I checked that reverting this filter would make the test fail (the foreign thread would be miscounted), so this is a real regression test for the bug it fixes, not a rename.
  3. Two same-named constants with different meanings. The benchmark's measuring-floor constant is renamed to MIN_MEASURABLE_INTERVAL_MILLIS, with a Javadoc that now explains how it differs from the fixture's engine-enforced MIN_CHECKPOINT_INTERVAL_MILLIS.

I didn't find anything new. The two files this round's commit doesn't touch (FakeTaskCheckpointManager, BenchmarkReflection) I re-checked against current dev anyway as part of the full re-review, and my 09-24 verification of the production seams (CheckpointManager#sendOperationToMemberNode, reportedTask, the reflected pendingCounter/barrier fields) still holds. save_jmh_result.py is unchanged and I re-confirmed its rawData-present fast path is untouched for every other benchmark.

Test stability: this round's test changes get a Stable rating from me. No hard sleeps, no nondeterministic assertions, and the new test actually removes a piece of implicit shared state (the global thread namespace) rather than adding any. I fetched the fork's own CI log for this exact head (not just the checks summary) and confirmed CheckpointSchedulingBenchmarkTest: 7/7 passing.

One thing worth calling out for whoever merges this: GitHub's review decision on this PR is still CHANGES_REQUESTED from @nzw921rx, recorded against commit 556de1f9227 — the version before the fixture was rewritten specifically to answer his concern that the benchmark didn't exercise the real engine path. I independently verified the current fixture does exercise the real CheckpointCoordinator/tryTriggerPendingCheckpoint path in my prior review and again this round. @Vivek1106-04 already pinged @nzw921rx for a fresh look on the current head — from my side there's no remaining technical blocker, just that outstanding human review to refresh.

Nice work closing these out quickly and precisely, @Vivek1106-04 — no scope creep, and a test that actually defends the fix.

Conclusion: Ready to merge

  1. Blockers — must be fixed: none.
  2. Recommended fixes — non-blocking: none remaining.
  3. Overall: this is a small, well-targeted fix for exactly the three points raised, backed by a genuine regression test and green CI on the current head. The only open item is procedural — please get @nzw921rx's re-review on 550c22568 before merging, since that's what the current CHANGES_REQUESTED state is gating on.

@nzw921rx nzw921rx left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BenchmarkReflection,CheckpointSchedulingFixture,FakeTaskCheckpointManager
I think they seem worth having a checkpoint package to separate them and reduce class bloating under the root module?

@Warmup(iterations = 2, time = 10)
@Measurement(iterations = 5, time = 10)
@Fork(
value = 2,

@nzw921rx nzw921rx Sep 29, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fork 2 change to 3?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or can you explain what considerations fork=2 is based on?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed to 3, so it matches BenchmarkBase and every other benchmark in the module. For this benchmark the extra fork is useful in itself: JIT state, thread placement and timer behaviour vary between JVMs, and scheduling delay is sensitive to all three, so more forks give a more representative distribution and a more stable CV. The cost is about 5 more minutes for the full pipelineNum sweep (4 points x 7 iterations x 10 s per fork, plus member startup).

@SEZ9

SEZ9 commented Sep 30, 2026

Copy link
Copy Markdown
Collaborator

Thanks @Vivek1106-04 — 550c22568 closes out the three low-severity items from my last pass:

  1. Thread count no longer a lower bound. Resyncing every coordinator, not just the probes, in trial setup before printing means the count reflects all armed schedulers at pipelineNum. Keeping the iteration resync probe-only is the right call since it stays outside the timed region. Marking the PR description's local table as lower bounds is the honest fix for numbers taken before this change.
  2. Teardown test scoped to the fixture's own threads. Snapshotting pre-existing checkpoint-* threads and asserting against a live checkpoint-foreign thread makes this a real regression test rather than a rename; a leftover scheduler from another test class can't fail it anymore.
  3. Constant naming. MIN_MEASURABLE_INTERVAL_MILLIS with a Javadoc contrasting it against the fixture's MIN_CHECKPOINT_INTERVAL_MILLIS removes the ambiguity.

Nothing further from me on these.

I see @nzw921rx has left a couple of new comments on the current head. I'll leave those between the two of you — please respond on the thread (or push a follow-up if you agree) so we have a clear final state before this is merged.

…kpoint package

Run the scheduling benchmark with 3 forks, like the other benchmarks.
@Vivek1106-04

Copy link
Copy Markdown
Contributor Author

@nzw921rx Agreed, moved BenchmarkReflection, CheckpointSchedulingFixture and FakeTaskCheckpointManager into org.apache.seatunnel.benchmark.checkpoint. This mirrors CheckpointStorageBenchmark with storage/checkpoint/CheckpointStorageBenchmarkFixture. CheckpointSchedulingBenchmark stays in the root package with the other @Benchmark classes, so the benchmarks.yml selector is unchanged. The fixture tests moved with it into checkpoint/CheckpointSchedulingFixtureTest, and CheckpointSchedulingBenchmarkTest keeps the annotation check. 7/7 pass locally.

The fork count is now 3 as well; details in the inline thread.

@Vivek1106-04
Vivek1106-04 requested a review from nzw921rx October 1, 2026 04:23

@nzw921rx nzw921rx left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

Non-blocking suggestion: In the future, consider increasing the warmup iterations (@WarmUp(iterations)) from 2 to 3 to ensure sufficient JIT compilation, avoid measurement being affected by compilation overhead during the formal measurement phase, and thereby improve the stability of benchmark results.

@nzw921rx
nzw921rx added this pull request to the merge queue Oct 1, 2026
Merged via the queue into apache:dev with commit d7e9931 Oct 1, 2026
3 checks passed
@nzw921rx

nzw921rx commented Oct 1, 2026

Copy link
Copy Markdown
Member

Hi @Vivek1106-04, following up after the merge, I ran the benchmark on Linux: https://github.com/apache/seatunnel/actions/runs/36820452487.

The run already uses three 10-second warmup iterations, but some measurements still trend downward. For example, with one pipeline on JDK 8, the first fork’s measurement scores are 161.505, 163.602, 143.061, 142.223 and 131.410 μs/op.

Could you please investigate whether warmup is sufficient and what causes this trend? Please also run the complete benchmark sweep locally, including all parameter combinations and forks, and share the commands, environment details and full per-iteration results.

As a follow-up, we should also consider a fixed-checkpoint-interval scenario and longer measurement runs to evaluate increasing scheduling load and obtain enough samples for tail percentiles. This would help strengthen the baseline for evaluating the shared scheduler.

@Vivek1106-04

Copy link
Copy Markdown
Contributor Author

@nzw921rx Thanks for running it, and sorry for the slow reply. I'm on it now and will start the local runs within the next hour.

A first look at the per-iteration data from run 36820452487: only the pipelineNum = 1 point is still trending down during measurement; 10, 100 and 500 flatten by about the first measured iteration. My working suspicion is the 200 ms interval floor. At one pipeline the member sees 5 triggers/s, against 50/s at 10, 100 and 500, so the trigger path gets about 10x less warmup and half the samples per iteration. That would also explain the CV shrinking as the pipeline count grows. This is not verified yet.

Plan:

  1. Confirm or rule this out on Linux (JMH -prof comp, longer warmup at pipelineNum = 1). macOS timed waits fire about 5 ms late, so local runs will be in a Linux container.
  2. Fix the warmup in a follow-up PR against dev.
  3. Run the full sweep (all parameters, 3 forks, JDK 8 and 11) and post the commands, environment details and full per-iteration results here.

I'll take the fixed-interval scenario and longer runs for tail percentiles as a separate follow-up.

@Vivek1106-04

Copy link
Copy Markdown
Contributor Author

@nzw921rx Thanks for running it on Linux. You were right: the warmup is not sufficient. Below are the cause, the evidence, a proposed fix, and the full sweep you asked for. I have not opened a PR for the fix yet; there is a question for you at the end.

What the score contains

The measured window runs from the time a trigger is due until its body has incremented pendingCounter. To split it, I ran a diagnostic build (fork branch bench-diag-12418, never proposed) that timestamps every trigger at its due time, at the start of its body, and at the increment. JDK 17, dev, 30 x 10 s iterations, p50 in microseconds:

JVM uptime 1 pipeline: due -> body start body 10 pipelines: due -> body start body
0-30 s 130 59 83 17
30-60 s 112 45 73 13
60-90 s 109 34 71 12
90-120 s 96 22 71 12
120-150 s 114 24 70 7.5
150-300 s 92-98 19-23 70 6.4-6.8

Both parts fall for the first two minutes. "Due -> body start" is not only the OS wake-up: it also covers ScheduledThreadPoolExecutor's queue and the MDCContext wrapper around every scheduled task, which compile late for the same reason.

Why it trends: the JIT compiles by invocation count

The trigger path runs 5 to 50 times a second, so it is compiled by how often it ran, not by how long the fork has run. From -XX:+PrintCompilation on the same runners, JDK 11 and 17 alike:

  • One pipeline: tryTriggerPendingCheckpoint and createPendingCheckpoint are interpreted until about 80 s of uptime (C1 tier 3) and never reach C2 within a 300 s trial. The ScheduledThreadPoolExecutor path reaches C2 at about 100 s.
  • Ten pipelines or more: the same methods reach C1 at 7 to 11 s and C2 at 110 to 130 s.
  • CI warms up for 30 s and then measures from roughly 30 to 100 s of uptime, so it records the curve on its way down.

A causal check: with -XX:TieredStopAtLevel=1 (C1 only), the one-pipeline body drops from 68 to 23 us in a single step at 54 s, exactly when those methods compile, and stays flat afterwards.

The one-pipeline point is hit hardest because of the 200 ms interval floor. The class Javadoc says every point sees the same trigger rate, but at one pipeline the member triggers 5 checkpoints a second where every other point triggers 50. Its code warms up ten times slower. Even once settled, its body runs at a lower tier (about 21 us against 6.6 us at ten pipelines), and its trigger wakes up on an otherwise idle 4-vCPU VM, which costs about 25 us more (about 95 against 70 us). That is why one pipeline scores higher than ten.

Why CV shrinks as pipelines grow: three separate effects

Decomposing your run's CV (computed, like the report, from the 15 iteration means):

JDK pipelineNum CV from sampling alone trend per iteration, per fork fork-to-fork offset
8 1 6.9% 2.6% -5.3, -2.4, -2.1% 2.3%
8 10 4.4% 4.7% +0.9, -1.0, -2.0% 1.4%
8 100 6.2% 6.4% -3.0, +0.1, +2.2% 2.2%
8 500 1.5% 1.1% -0.3, -0.7, -0.8% 0.3%
11 1 8.4% 2.5% -2.9, -6.7, -3.7% 2.0%
11 10 2.2% 1.2% -0.6, -0.8, -0.2% 1.6%
11 100 2.6% 1.2% -1.3, -1.2, -0.3% 1.5%
11 500 3.8% 1.4% +0.4, +0.8, -1.4% 2.6%
  1. Drift, at one pipeline: every fork falls 2 to 7% per iteration. This is the warmup problem.
  2. Sampling noise from a heavy tail, e.g. JDK 8 at 10 and 100 pipelines: the CV there is fully explained by sampling (single samples spread 24 to 35% around the mean, and one 2 ms sample moves a 100-sample mean by about 20%). No drift.
  3. Fork-to-fork offsets of 1 to 7%: each JVM compiles this code a little differently.

So CV is not monotonic in the pipeline count; the one-pipeline point is worst because it has the drift, 50 samples per iteration instead of 100, and the noisiest wake-ups.

Proposed fix

On branch fix-12418-benchmark-warmup, commit 1914eabf6, benchmark code and tests only:

  • Forks run with -XX:CompileThresholdScaling=0.05. (0.01 compiles so much at once in the first iteration that more than 10% of triggers come due unmeasured, and the skip limit fails the run.)
  • Each trial lets its own coordinators trigger 1,000 checkpoints, for at least 60 s, before JMH's warmup. Counting triggers rather than seconds gives the one-pipeline point the same compiled code as the others: about 200 s there, 60 s elsewhere.
  • The Javadoc documents the one-pipeline point as an idle-member point, to compare across revisions rather than against the other pipeline counts.

A first attempt, warming up on throwaway coordinators and cancelling them, did not work: cancelling them made the JIT deoptimise tryTriggerPendingCheckpoint right before the measurement started.

Full sweep, dev against the fix

Exact command of tools/benchmarks/run_benchmarks.sh, all pipeline counts, 3 forks, 3 warmup and 5 measurement iterations of 10 s. dev and the fix ran on the same runner, in both orders, on JDK 11 and 17: 12 forks per side and point. Run: https://github.com/Vivek1106-04/seatunnel/actions/runs/38295747109

pipelineNum mean trend per iteration, dev fix forks falling > 2% per iteration, dev fix CV, dev fix score range (us), dev fix
1 -3.5% -0.3% 8 / 12 5 / 12, scattered both ways 10.5% 9.4% 166-200 94-130
10 -1.9% +1.0% 3 / 12 0 / 12 7.0% 3.9% 87-105 77-95
100 -1.5% +0.3% 3 / 12 0 / 12 4.5% 3.6% 82-103 73-90
500 -1.0% -0.2% 2 / 12 1 / 12 4.5% 4.3% 100-121 93-111

No run of the fix hit the skip limit; one earlier dev run did, at 100 pipelines on JDK 11 (19 triggers came due while another was being measured).

What remains, and is not a warmup effect: at one pipeline, iteration-to-iteration variation stays around 9 to 10% on both sides, because that trigger's wake-up latency on an idle VM wanders by tens of microseconds over minutes. Forks still settle 1 to 5% apart. The per-iteration median is much steadier than the mean (CV 2 to 4% at ten pipelines or more with the fix), because the mean carries the occasional millisecond sample; reporting it would be a separate change to save_jmh_result.py.

Commands and environment

# Build, from each checkout
./mvnw -B -q -Pbenchmark -pl seatunnel-benchmarks -am -DskipTests -Dskip.spotless=true package
# Run, same as tools/benchmarks/run_benchmarks.sh
java -jar seatunnel-benchmarks/target/benchmarks.jar CheckpointSchedulingBenchmark -f 3 -wi 3 -i 5 -foe true -rf json -rff <side>.json
  • GitHub-hosted ubuntu-24.04, Linux 6.17.0-1022-azure x86_64, AMD EPYC 7763, 4 vCPU
  • Temurin 11.0.32.1 and 17.0.20.1. JDK 8 is no longer in the comparison because dev now builds for Java 11, and benchmarks.yml runs 11 and 17.
  • dev = b3898b62d; fix = 738f372e4 on the throwaway verification branch, identical benchmark code to 1914eabf6
  • The fork JVM options are the benchmark's own @Fork settings.

These ran on GitHub's runners in my fork rather than on my machine: on macOS every timed wait fires about 5 ms late, and Docker Desktop's Linux VM inherits that (one pipeline measured about 2.5 ms there against about 150 us on the runners), so a local run cannot show a microsecond-scale trend.

Per-iteration means (us), all forks

JDK 11, runner job j11-dev-then-fix (ran dev then fix)

side pipelineNum fork iter 1 iter 2 iter 3 iter 4 iter 5
dev 1 1 205.7 210.8 187.5 184.6 191.7
dev 1 2 202.7 169.8 175.9 169.8 166.1
dev 1 3 180.0 157.1 152.8 169.7 153.7
dev 10 1 91.4 89.4 91.0 90.3 92.6
dev 10 2 99.3 98.2 99.2 95.1 97.2
dev 10 3 121.3 99.4 102.4 99.4 97.5
dev 100 1 111.9 107.7 106.0 98.1 95.8
dev 100 2 115.2 97.5 111.1 97.0 95.7
dev 100 3 101.7 95.1 96.1 99.1 102.5
dev 500 1 123.0 114.3 116.9 117.0 115.4
dev 500 2 143.1 106.7 106.3 106.7 110.9
dev 500 3 128.1 133.2 139.4 128.1 120.7
fix 1 1 117.7 121.7 139.0 109.4 108.8
fix 1 2 109.2 106.5 130.2 104.9 96.0
fix 1 3 107.1 131.2 132.6 126.2 104.4
fix 10 1 85.9 85.1 88.2 87.5 88.2
fix 10 2 88.1 87.4 88.1 88.1 88.9
fix 10 3 87.7 87.6 89.7 91.1 114.7
fix 100 1 86.1 87.5 88.4 86.0 82.7
fix 100 2 86.0 83.7 85.5 84.3 98.5
fix 100 3 84.4 83.2 94.8 85.5 84.8
fix 500 1 97.8 99.1 99.1 98.0 98.7
fix 500 2 99.7 104.3 102.1 101.2 105.2
fix 500 3 98.9 101.1 101.5 103.3 99.6

JDK 11, runner job j11-fix-then-dev (ran fix then dev)

side pipelineNum fork iter 1 iter 2 iter 3 iter 4 iter 5
dev 1 1 239.7 226.5 188.2 168.1 179.8
dev 1 2 199.6 190.7 195.3 178.2 206.0
dev 1 3 226.4 215.8 221.1 183.8 179.1
dev 10 1 104.6 102.2 101.4 98.1 99.3
dev 10 2 102.7 104.4 103.4 102.9 99.5
dev 10 3 103.8 112.0 140.4 100.7 101.7
dev 100 1 98.5 118.1 99.1 100.6 100.8
dev 100 2 108.7 110.5 115.3 96.7 103.7
dev 100 3 100.2 98.6 95.6 100.7 99.6
dev 500 1 124.6 120.4 111.7 115.3 121.3
dev 500 2 118.3 117.7 115.8 116.1 121.0
dev 500 3 123.3 120.4 113.4 112.8 114.8
fix 1 1 121.1 142.2 137.7 130.3 145.2
fix 1 2 127.9 120.1 124.0 115.5 114.6
fix 1 3 135.7 146.9 153.1 126.8 111.5
fix 10 1 97.0 94.3 93.6 93.8 95.8
fix 10 2 93.1 90.8 100.4 96.4 95.7
fix 10 3 98.8 94.6 94.0 95.2 94.1
fix 100 1 90.3 89.5 95.5 91.5 88.8
fix 100 2 91.8 93.4 89.8 87.9 87.0
fix 100 3 90.1 90.2 91.7 89.4 87.4
fix 500 1 109.5 110.5 113.4 111.4 110.9
fix 500 2 106.5 109.7 115.2 110.0 107.4
fix 500 3 121.3 108.0 107.8 113.6 112.0

JDK 17, runner job j17-dev-then-fix (ran dev then fix)

side pipelineNum fork iter 1 iter 2 iter 3 iter 4 iter 5
dev 1 1 154.2 154.7 159.1 165.1 139.7
dev 1 2 170.6 173.2 182.8 162.6 169.1
dev 1 3 191.2 227.1 171.8 154.7 181.4
dev 10 1 104.3 102.3 102.1 93.9 107.0
dev 10 2 120.1 112.5 111.8 105.3 92.0
dev 10 3 102.6 101.7 97.0 92.8 90.9
dev 100 1 96.7 91.5 88.9 89.7 89.4
dev 100 2 91.7 87.9 88.2 87.7 86.7
dev 100 3 95.8 94.0 89.6 91.5 90.7
dev 500 1 105.3 103.7 105.5 107.3 106.4
dev 500 2 101.4 101.0 104.6 104.0 101.1
dev 500 3 99.5 100.0 103.8 102.6 99.6
fix 1 1 99.4 105.2 122.8 121.4 101.0
fix 1 2 111.9 108.8 98.0 110.7 104.6
fix 1 3 102.9 93.9 95.0 115.6 121.7
fix 10 1 85.2 85.7 84.0 84.6 83.4
fix 10 2 85.2 86.1 87.7 88.8 85.8
fix 10 3 84.3 84.4 85.0 84.0 87.7
fix 100 1 86.1 83.1 85.9 83.7 86.9
fix 100 2 84.2 80.3 87.4 91.3 90.1
fix 100 3 93.1 82.1 83.1 84.2 84.2
fix 500 1 94.5 92.6 94.3 95.8 93.6
fix 500 2 97.1 93.7 97.1 96.3 98.1
fix 500 3 99.2 129.4 94.9 95.6 94.4

JDK 17, runner job j17-fix-then-dev (ran fix then dev)

side pipelineNum fork iter 1 iter 2 iter 3 iter 4 iter 5
dev 1 1 175.5 163.0 156.3 152.1 145.3
dev 1 2 187.5 178.0 169.5 144.6 188.4
dev 1 3 179.3 173.9 160.0 153.0 159.5
dev 10 1 91.1 88.6 88.5 86.7 86.5
dev 10 2 88.4 89.0 85.6 85.8 84.7
dev 10 3 90.5 89.1 85.4 85.4 85.2
dev 100 1 84.6 82.6 81.6 81.8 80.4
dev 100 2 84.0 82.8 81.1 81.9 80.0
dev 100 3 83.5 82.6 81.0 82.8 80.0
dev 500 1 103.2 97.9 97.0 98.4 98.0
dev 500 2 105.5 101.0 100.3 100.5 100.8
dev 500 3 98.8 97.5 100.6 98.1 97.3
fix 1 1 89.7 90.0 93.9 91.7 90.6
fix 1 2 99.3 109.1 100.3 91.6 92.8
fix 1 3 85.3 89.2 91.7 109.6 91.6
fix 10 1 73.6 73.1 78.4 77.2 77.2
fix 10 2 76.2 76.5 80.4 80.1 80.6
fix 10 3 74.2 72.6 75.7 76.4 76.2
fix 100 1 70.5 69.7 73.8 73.9 74.6
fix 100 2 75.2 74.4 74.4 74.7 76.0
fix 100 3 70.7 70.5 73.8 74.1 72.7
fix 500 1 91.6 90.4 90.1 92.4 91.6
fix 500 2 94.6 96.4 95.7 94.7 94.4
fix 500 3 89.6 92.6 96.1 92.5 93.9

Before I open a PR

Two choices in the proposed fix change how this benchmark runs, so I would like your view first:

  1. -XX:CompileThresholdScaling=0.05 changes the JIT's thresholds inside the benchmark. The alternative is to keep JVM defaults and warm up for about 150 s per trial instead. In the 300 s trajectories above that is where ten pipelines stops drifting, though I have not run it as a full sweep, and it leaves the one-pipeline body at a lower compilation tier than the other points. Which would you prefer?
  2. Cost: the trigger-count warmup adds about 19 minutes per JDK to the CheckpointSchedulingBenchmark sweep in benchmarks.yml (about 200 s per fork at one pipeline, 60 s per fork elsewhere).

The one-pipeline point is kept and documented as an idle-member point. If you would rather drop it and start the sweep at ten pipelines, that removes the noisiest point and about half of the added time.

If this direction works for you, I will open it as a PR, or file an issue first if you prefer that. The fixed-interval scenario and longer measurement runs for tail percentiles stay a separate follow-up, which I will open as an issue once this is settled.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants