Optimize parent controller path retrieval in JMeterThread#6721
Optimize parent controller path retrieval in JMeterThread#6721rajat315315 wants to merge 2 commits into
Conversation
|
Please share benchmark code |
milamberspace
left a comment
There was a problem hiding this comment.
Thanks for the optimization, @rajat315315 — the microbenchmark and the motivation are clear, and caching a static path is the right instinct. One blocking correctness issue needs to be resolved before this can land, plus a testing gap; details inline.
Blocking (see inline on the cache field): the cache is keyed on Sampler, but AbstractTestElement defines equals/hashCode by value (propMap), whereas the original traversal matches the node by reference identity (node == nodeToFind). Two distinct-but-equal samplers would collide and get the wrong parent-controller path on error, and samplers mutate their properties at runtime (unstable hash key). Switching to IdentityHashMap restores the original identity semantics.
Testing: the change alters behaviour on a correctness-sensitive path (loop control on sample error) and adds no tests, so a cache-correctness regression would be silent. Please add a regression test that runs enough iterations to exercise the cache-hit branch and asserts identical Break/Continue/StartNextLoop behaviour to the uncached path — ideally including two equal-but-distinct samplers to lock in the identity concern above. TestTransactionController and the existing onErrorStartNextLoop tests are good starting points.
(Not blocking: the two red macOS checks are the flaky :src:dist-check:batchServerBatchTestLocal distributed test, unrelated to this diff — the rest of the matrix, including the same-hashcode job, is green.)
This review was drafted by an AI-assisted tool (Apache Magpie) and may contain mistakes. An Apache JMeter maintainer has reviewed and confirmed this submission. See CONTRIBUTING.md.
|
Thanks for the PR. I think we should take a different approach here. |
|
I agree. I totally missed that we are building the same cache in |
|
Benchmarking Results
Note: All execution times represent average time per operation. Lower is better. Key Takeaways
|
|
I used below benchmark script to compare the time taken. /*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to you under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.jmeter.threads;
import java.util.List;
import java.util.concurrent.TimeUnit;
import org.apache.jmeter.control.Controller;
import org.apache.jmeter.control.GenericController;
import org.apache.jmeter.control.LoopController;
import org.apache.jmeter.samplers.AbstractSampler;
import org.apache.jmeter.samplers.Entry;
import org.apache.jmeter.samplers.SampleResult;
import org.apache.jorphan.collections.ListedHashTree;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Param;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Warmup;
import org.openjdk.jmh.infra.Blackhole;
import org.openjdk.jmh.profile.GCProfiler;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;
/**
* Benchmark comparing two approaches for retrieving a sampler's parent
* controller path in JMeterThread:
* <ul>
* <li><b>traversal</b> — original approach: traverse the entire HashTree
* with {@link FindTestElementsUpToRootTraverser} on every call
* (master branch behavior).</li>
* <li><b>compilerLookup</b> — optimized approach: O(1) lookup via
* {@link TestCompiler#getControllersForSampler} which reuses the
* controller path already computed during compilation.</li>
* </ul>
*/
@Fork(value = 2, jvmArgsPrepend = {"-Xmx256m"})
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class ControllerPathLookupBenchmark {
/** Depth of nested controllers above the target sampler. */
@Param({"2", "5", "10"})
int treeDepth;
/** Number of sibling samplers at each controller level (widens the tree). */
@Param({"1", "10"})
int siblingsPerLevel;
private ListedHashTree testTree;
private TestCompiler compiler;
private AbstractSampler targetSampler;
/** Minimal sampler implementation for benchmarking. */
private static class BenchmarkSampler extends AbstractSampler {
private static final long serialVersionUID = 1L;
BenchmarkSampler(String name) {
setName(name);
}
@Override
public SampleResult sample(Entry e) {
return null;
}
}
@Setup
public void setup() {
testTree = new ListedHashTree();
// Build a tree: root controller -> nested controllers -> samplers
// depth=2: Root -> Controller1 -> [targetSampler, sibling1, ...]
// depth=5: Root -> C1 -> C2 -> C3 -> C4 -> [targetSampler, ...]
// depth=10: Root -> C1 -> ... -> C9 -> [targetSampler, ...]
LoopController root = new LoopController();
root.setName("RootLoop");
root.setLoops(1);
ListedHashTree currentSubTree = (ListedHashTree) testTree.add(root);
// Create nested controllers
for (int d = 1; d < treeDepth; d++) {
GenericController ctrl = new GenericController();
ctrl.setName("Controller-" + d);
// Add sibling samplers at this level (to make the tree wider)
for (int s = 0; s < siblingsPerLevel; s++) {
BenchmarkSampler sibling = new BenchmarkSampler("Sibling-" + d + "-" + s);
currentSubTree.add(ctrl, sibling);
}
currentSubTree = (ListedHashTree) currentSubTree.add(ctrl);
}
// Add the target sampler at the deepest level
targetSampler = new BenchmarkSampler("TargetSampler");
currentSubTree.add(targetSampler);
// Add more siblings at the deepest level
for (int s = 0; s < siblingsPerLevel; s++) {
currentSubTree.add(new BenchmarkSampler("DeepSibling-" + s));
}
// Compile the tree (populates samplerConfigMap)
TestCompiler.initialize();
compiler = new TestCompiler(testTree);
testTree.traverse(compiler);
}
/**
* OLD approach (master): traverse the full tree every time.
*/
@Benchmark
public List<Controller> traversal(Blackhole bh) {
FindTestElementsUpToRootTraverser traverser =
new FindTestElementsUpToRootTraverser(targetSampler);
testTree.traverse(traverser);
List<Controller> controllers = traverser.getControllersToRoot();
bh.consume(controllers);
return controllers;
}
/**
* NEW approach: O(1) lookup via TestCompiler's precomputed map.
*/
@Benchmark
public List<Controller> compilerLookup(Blackhole bh) {
List<Controller> controllers = compiler.getControllersForSampler(targetSampler);
bh.consume(controllers);
return controllers;
}
public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(ControllerPathLookupBenchmark.class.getSimpleName())
.addProfiler(GCProfiler.class)
.detectJvmArgs()
.build();
new Runner(opt).run();
}
} |
|
Request to re-review.. |
|
Thanks for looking into this — reusing the path The description does not match the diffThe description explains a CorrectnessI walked through the equivalence and it holds:
One behavioral difference worth calling out in the description: if the same The empty-list case deserves a log lineReturning A miss is reachable in a degraded state, and it is worth tracing where the failure lands. That is a worse outcome than today's behavior, where the loop action is skipped but the thread survives, and the underlying error has already been reported by the earlier catch. Turning a degraded loop into a dead load-generator thread is a behavior change that does not belong in a performance PR. I would keep the lookup total and put the invariant check at the call site: List<Controller> controllers = compiler.getControllersForSampler(realSampler);
if (controllers.isEmpty()) {
log.warn("No parent controllers found for sampler '{}', loop action is skipped", realSampler.getName());
}
consumer.accept(controllers);This keeps the new public API total — an unknown sampler maps to an empty list, and callers pick their own failure policy — while making the anomaly greppable. If maintainers prefer fail-fast, API surface
Also: Generics
Consumer<? super List<Controller>> consumerAt the three current call sites this makes no practical difference — they are method references, and inference handles them either way. But it costs nothing and restores the original variance, so I would rather keep it. The return type of TestsThis is my main ask. 1. Equivalence test — cheapest and highest value. Build a tree, traverse it with both 2. Behavioral tests through 3. Item 1 is what guards the specific thing this PR could break. Items 2 and 3 close a coverage gap that predates the PR, so they are a fair ask but not strictly yours to fix. |
Fixes: #6720
Description
This PR introduces a thread-local parent controller path cache (
parentControllersCache) insideJMeterThread.Specifically, the changes are:
HashMapmappingSamplertoList<Controller>inJMeterThread.CachedPathToRootTraverserextendingFindTestElementsUpToRootTraverserto bypass tree walks on cache hits.triggerLoopLogicalActionOnParentControllersto lookup resolved paths in the cache and save the path on a cache miss.Motivation and Context
When a sampler execution fails with the
onErrorStartNextLoopoption enabled, or when loop logical actions (such as Break/Continue) are triggered, JMeter traverses the entire test tree using DFS (testTree.traverse()) from the sampler to the root to resolve parent controllers.For large test plans with deep nesting, this full tree walk is redundant since the test tree's hierarchy is static during thread execution. Under error-heavy loads, these continuous traversals degrade performance and spike CPU usage. Caching the path to the root per-sampler inside each thread avoids this overhead entirely.
How Has This Been Tested?
Created a microbenchmark simulating a deeply nested test plan structure (10 nested levels of loop controllers) over 100,000 iterations:
292.57 ms30.68 msCompiled core module Java classes using
./gradlew :src:core:compileJava.Executed the core test suite using
./gradlew :src:core:test(379 tests completed, 0 failed, 1 skipped).Screenshots (if appropriate):
N/A (performance optimization)
Types of changes
Checklist: