[
https://issues.apache.org/jira/browse/KAFKA-21171?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
]
sanghyeok An updated KAFKA-21171:
---------------------------------
Labels: client streams (was: )
> Repeated task offset snapshot rebuilding adds overhead with the Streams
> rebalance protocol
> ------------------------------------------------------------------------------------------
>
> Key: KAFKA-21171
> URL: https://issues.apache.org/jira/browse/KAFKA-21171
> Project: Kafka
> Issue Type: Improvement
> Reporter: sanghyeok An
> Assignee: sanghyeok An
> Priority: Minor
> Labels: client, streams
>
> h2. Summary
> With {{{}group.protocol=streams{}}}, {{StreamThread}} rebuilds the task
> offset sum snapshot on every processing loop iteration, even when the offsets
> and task states have not changed. This performs task enumeration, map
> copying, filtering, and allocation independently of whether the next
> heartbeat will report new offsets.
> A local JMH benchmark shows that the cost of one refresh grows approximately
> linearly with task count. In a separate 10-minute application profile, this
> path accounted for *580 of 1,207 Java execution samples (48.1%)* from the
> Streams processing thread, including descendant calls.
> h2. Current behavior
> {{TaskManager.maybeUpdateTaskOffsetSumSnapshot()}} performs the following
> work:
> # Copies the process-wide local offset map from {{{}StateDirectory{}}}.
> # Builds a map of this thread's tasks through {{{}allTasks(){}}}, including
> tasks held by the state updater and tasks awaiting initialization.
> # Removes running active tasks and overlays live changelog offset sums for
> other eligible tasks.
> # Builds a separate map with heartbeat task IDs and publishes a read-only
> snapshot.
> The snapshot is refreshed on each loop iteration in both processing-loop
> variants. Heartbeat code separately checks assignment changes, the offset
> reporting interval, warm-up progress, and whether the values changed. These
> checks limit offset transmission but do not avoid the preceding snapshot
> construction.
> The copied local offset map is in memory; this path does not read task state
> from disk on every iteration. Under {{{}group.protocol=classic{}}}, this
> snapshot refresh method returns immediately; classic reports offset sums
> through its subscription metadata path.
> h2. Environment
> * Kafka {{{}4.5.0-SNAPSHOT{}}}, checkout
> {{{}e0b738a78819d95dd7779ec3d98effbb9f6a0f9c{}}}.
> * macOS AArch64; JFR reports 14 hardware threads.
> * JMH: JMH 1.37, JDK 25.0.3.
> * Application profile: JDK 21.0.5+11-LTS.
> * Affected released versions have not been established.
> h2. JMH results
> {{TaskOffsetSumSnapshotBenchmark.refreshSnapshot}} measures one refresh with
> unchanged offsets. All tasks belong to one stream thread and each has one
> changelog partition.
> The fixture uses the real task manager, task registry, state manager, state
> updater, and local offset map. It does not start a broker or perform disk
> I/O. RUNNING tasks are registered as active tasks and produce an empty final
> snapshot. RESTORING tasks remain in the state updater input queue; the
> updater thread is not started.
> Settings: one worker, three forks, {{{}-Xms2g -Xmx2g{}}}, five 2-second
> warmup iterations and five 2-second measurement iterations per fork,
> {{{}avgt{}}}, and {{{}-prof gc{}}}. There are 15 measurements per parameter
> combination. Time errors below are the JMH 99.9% confidence interval
> half-widths.
> ||Task count||Task state||Time (us/op, mean ± error)||Allocation (B/op)||
> |0|RUNNING|0.257 ± 0.002|2,120.001|
> |0|RESTORING|0.259 ± 0.001|2,120.001|
> |100|RUNNING|8.481 ± 0.057|31,890.708|
> |100|RESTORING|22.821 ± 0.111|77,048.131|
> |1,000|RUNNING|89.110 ± 1.960|346,829.769|
> |1,000|RESTORING|248.191 ± 1.702|831,546.901|
> |5,000|RUNNING|429.778 ± 5.789|1,732,682.127|
> |5,000|RESTORING|1,304.563 ± 22.457|4,111,075.094|
> The RUNNING cases show that copying and filtering still incur a cost when all
> local entries are excluded from the final snapshot. Allocation is measured
> per refresh, not retained heap size.
> The results were collected in two runs: task count 1,000, and task counts
> 0/100/5,000. For example, the latter used:
> {code:bash}
> java -jar
> jmh-benchmarks/build/libs/kafka-jmh-benchmarks-4.5.0-SNAPSHOT-all.jar \
> TaskOffsetSumSnapshotBenchmark \
> -p taskCount=0,100,5000 -p taskState=RUNNING,RESTORING \
> -f 3 -wi 5 -i 5 -w 2s -r 2s \
> -prof gc -foe true \
> -rf json -rff jmh-benchmarks/build/task-offset-snapshot-scaling.json
> {code}
> Benchmark source: [^TaskOffsetSumSnapshotBenchmark.java]
> Raw results: [^task-offset-snapshot-1000.json],
> [^task-offset-snapshot-scaling.json]
> h2. Application profile and flame graph
> The application uses one sub-topology:
> {noformat}
> snapshot-bench-input
> -> groupByKey()
> -> count(persistent key-value store)
> {noformat}
> The configured test uses {{{}group.protocol=streams{}}},
> {{{}num.stream.threads=1{}}}, and {{{}poll.ms=10{}}}. JFR was recorded with
> {{{}settings=profile{}}}, a 60-second start delay, and a 600-second duration.
> The intended workload was 100 tasks with no continuous input; the exact
> assigned task count and input activity during this recording were not
> independently captured.
> The recording contains 1,225 {{jdk.ExecutionSample}} events, including 1,207
> from {{{}StreamThread-1{}}}. The flame graph uses these Java execution events
> only. Native-method, allocation, and waiting events are excluded; none of the
> recorded execution stacks are marked truncated.
> ||Processing-thread call path, including descendants||Samples||Share of 1,207
> samples||
> |TaskManager.maybeUpdateTaskOffsetSumSnapshot|580|48.1%|
> |TaskManager.process|255|21.1%|
> |StreamThread.maybeCommit|157|13.0%|
> |TaskManager.recordTaskProcessRatio|128|10.6%|
> |TaskManager.punctuate|39|3.2%|
> |pollPhase and other paths|48|4.0%|
> The {{process}} and {{maybeCommit}} paths include readiness checks; their
> presence does not establish that records were processed or commits completed.
> Within the 580 snapshot-path samples, the sampled call sites were:
> ||Snapshot operation||Samples||
> |allTasks() and task iteration preparation|284|
> |Copying the StateDirectory offset map|194|
> |Removing running active tasks from the copied map|98|
> |Other|4|
> Descendant frames include {{{}TaskId.hashCode(){}}}, {{HashMap}}
> insertion/removal, {{ConcurrentHashMap}} traversal, and state-updater task
> enumeration.
> *48.1% is a share of Java execution samples, not whole-process CPU
> utilization or the fraction of a CPU core consumed.*
> Separately, cumulative thread CPU counters in the first and last recorded
> thread dumps increased by 63.956 seconds over 543.632 seconds of elapsed time
> for the Streams processing thread. This corresponds to approximately *11.8%
> of one CPU core* over that interval. The sample share does not establish an
> exact CPU time or potential saving for the snapshot method.
> Flame graphs: [^stream-thread.svg], [^all-java.svg]. Download and open the
> SVG in a browser to zoom and search for
> {{{}maybeUpdateTaskOffsetSumSnapshot{}}}.
> h2. Interpretation and limits
> The microbenchmark demonstrates repeated work with unchanged inputs, and the
> application profile identifies snapshot construction as a prominent Java
> execution path in the measured run.
> An earlier 120-second recording with {{poll.ms=100}} contained only 27 Java
> execution samples and no snapshot-path samples. It is too sparse to establish
> the snapshot's CPU share. The application ID also changed between runs, and
> task/input conditions were not fully recorded, so these profiles are not a
> controlled comparison of poll intervals.
> The JMH and application runs used different JDK versions. No production
> throughput regression, lock contention bottleneck, or measured improvement is
> established by these results.
--
This message was sent by Atlassian Jira
(v8.20.10#820010)