Skip to content

Commit 19e327a

Browse files
committed
perf: Speed up VersionedIntervalTimeline operations
1 parent 9a70797 commit 19e327a

2 files changed

Lines changed: 82 additions & 8 deletions

File tree

benchmarks/src/test/java/org/apache/druid/timeline/VersionedIntervalTimelineBenchmark.java

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import org.apache.druid.java.util.common.granularity.GranularityType;
2727
import org.apache.druid.timeline.partition.NumberedOverwriteShardSpec;
2828
import org.apache.druid.timeline.partition.NumberedShardSpec;
29+
import org.apache.druid.timeline.partition.PartitionChunk;
2930
import org.apache.druid.timeline.partition.PartitionIds;
3031
import org.apache.druid.timeline.partition.ShardSpec;
3132
import org.joda.time.DateTime;
@@ -53,7 +54,7 @@
5354
import java.util.concurrent.ThreadLocalRandom;
5455

5556
@State(Scope.Benchmark)
56-
@Fork(value = 1, jvmArgsAppend = {"-XX:+UseG1GC"})
57+
@Fork(value = 1, jvmArgsAppend = {"-XX:+UseG1GC", "-Xmx4g"})
5758
@Warmup(iterations = 10)
5859
@Measurement(iterations = 10)
5960
@BenchmarkMode({Mode.Throughput})
@@ -73,7 +74,7 @@ public class VersionedIntervalTimelineBenchmark
7374
@Param({"false", "true"})
7475
private boolean useSegmentLock;
7576

76-
@Param({"MONTH", "DAY"})
77+
@Param({"MONTH", "DAY", "HOUR", "MINUTE"})
7778
private GranularityType segmentGranularity;
7879

7980
private List<Interval> intervals;
@@ -216,16 +217,22 @@ public void benchAdd(Blackhole blackhole)
216217
}
217218
}
218219

220+
/**
221+
* Measures the cost of draining 10% of the timeline in one shot by rebuilding the timeline fresh
222+
* each invocation. This is the "bulk removal" case — use {@link Mode#AverageTime} so the result
223+
* is interpretable as "seconds per full 10%-drain" rather than a near-zero throughput figure.
224+
*/
219225
@Benchmark
226+
@BenchmarkMode(Mode.AverageTime)
220227
public void benchRemove(Blackhole blackhole)
221228
{
222229
final List<DataSegment> segmentsCopy = new ArrayList<>(segments);
223-
final SegmentTimeline timeline = SegmentTimeline.forSegments(segmentsCopy);
230+
final SegmentTimeline localTimeline = SegmentTimeline.forSegments(segmentsCopy);
224231
final int numTests = (int) (segmentsCopy.size() * 0.1);
225232
for (int i = 0; i < numTests; i++) {
226233
final DataSegment segment = segmentsCopy.remove(ThreadLocalRandom.current().nextInt(segmentsCopy.size()));
227234
blackhole.consume(
228-
timeline.remove(
235+
localTimeline.remove(
229236
segment.getInterval(),
230237
segment.getVersion(),
231238
segment.getShardSpec().createChunk(segment)
@@ -234,6 +241,27 @@ public void benchRemove(Blackhole blackhole)
234241
}
235242
}
236243

244+
/**
245+
* Measures per-remove throughput against the pre-built timeline. One invocation = one remove + one
246+
* re-add to keep the timeline stable.
247+
*/
248+
@Benchmark
249+
@BenchmarkMode(Mode.Throughput)
250+
public void benchRemoveWithReplacement(Blackhole blackhole)
251+
{
252+
final DataSegment segment = segments.get(ThreadLocalRandom.current().nextInt(segments.size()));
253+
final PartitionChunk<DataSegment> chunk = segment.getShardSpec().createChunk(segment);
254+
final PartitionChunk<DataSegment> removed = timeline.remove(
255+
segment.getInterval(),
256+
segment.getVersion(),
257+
chunk
258+
);
259+
if (removed != null) {
260+
timeline.add(segment.getInterval(), segment.getVersion(), chunk);
261+
}
262+
blackhole.consume(removed);
263+
}
264+
237265
@Benchmark
238266
public void benchLookup(Blackhole blackhole)
239267
{
@@ -245,6 +273,26 @@ public void benchLookup(Blackhole blackhole)
245273
blackhole.consume(timeline.lookup(queryInterval));
246274
}
247275

276+
/**
277+
* Looks up a single-interval window — the case that benefits most from the O(log N + K) range scan since only a
278+
* tiny fraction of the timeline is touched regardless of overall size.
279+
*/
280+
@Benchmark
281+
public void benchLookupSingleInterval(Blackhole blackhole)
282+
{
283+
final int intervalIndex = ThreadLocalRandom.current().nextInt(intervals.size());
284+
blackhole.consume(timeline.lookup(intervals.get(intervalIndex)));
285+
}
286+
287+
/**
288+
* Full-range lookup — scans all segments regardless of optimization; provides an upper-bound baseline.
289+
*/
290+
@Benchmark
291+
public void benchLookupFullRange(Blackhole blackhole)
292+
{
293+
blackhole.consume(timeline.lookup(TOTAL_INTERVAL));
294+
}
295+
248296
@Benchmark
249297
public void benchIsOvershadowed(Blackhole blackhole)
250298
{

processing/src/main/java/org/apache/druid/timeline/VersionedIntervalTimeline.java

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,10 @@ public class VersionedIntervalTimeline<VersionType, ObjectType extends Overshado
8888
Comparators.intervalsByStartThenEnd()
8989
);
9090
// true interval -> version -> timelineEntry
91-
private final Map<Interval, TreeMap<VersionType, TimelineEntry>> allTimelineEntries = new HashMap<>();
91+
// NavigableMap so the private remove() can range-scan for overlapping intervals instead of a full O(N) pass.
92+
private final NavigableMap<Interval, TreeMap<VersionType, TimelineEntry>> allTimelineEntries = new TreeMap<>(
93+
Comparators.intervalsByStartThenEnd()
94+
);
9295
private final AtomicInteger numObjects = new AtomicInteger();
9396

9497
private final Comparator<? super VersionType> versionComparator;
@@ -118,7 +121,7 @@ public static <VersionType, ObjectType extends Overshadowable<ObjectType>> Itera
118121
.iterator();
119122
}
120123

121-
public Map<Interval, TreeMap<VersionType, TimelineEntry>> getAllTimelineEntries()
124+
public NavigableMap<Interval, TreeMap<VersionType, TimelineEntry>> getAllTimelineEntries()
122125
{
123126
return allTimelineEntries;
124127
}
@@ -715,7 +718,11 @@ private void remove(
715718
{
716719
timeline.remove(interval);
717720

718-
for (Entry<Interval, TreeMap<VersionType, TimelineEntry>> versionEntry : allTimelineEntries.entrySet()) {
721+
// Use headMap to skip entries whose start >= interval.end (those can never overlap), then filter for actual overlap.
722+
// Reduces the scan to O(M) where M is the # of intervals with start < interval.end.
723+
final Interval headBound = new Interval(interval.getEnd(), interval.getEnd());
724+
for (Entry<Interval, TreeMap<VersionType, TimelineEntry>> versionEntry :
725+
allTimelineEntries.headMap(headBound, false).entrySet()) {
719726
if (versionEntry.getKey().overlap(interval) != null) {
720727
if (incompleteOk) {
721728
add(timeline, versionEntry.getKey(), versionEntry.getValue().lastEntry().getValue());
@@ -747,8 +754,27 @@ private List<TimelineObjectHolder<VersionType, ObjectType>> lookup(Interval inte
747754
timeline = completePartitionsTimeline;
748755
}
749756

750-
for (Entry<Interval, TimelineEntry> entry : timeline.entrySet()) {
757+
// Both completePartitionsTimeline and incompletePartitionsTimeline contain non-overlapping adjusted intervals
758+
// sorted by (start, end). To find entries overlapping [interval.start, interval.end) we only need to scan
759+
// the range [floorKey(interval.start), first key with start >= interval.end). The floor handles the one
760+
// entry that may have started before interval.start but still extends into the query window.
761+
final Interval searchStart = new Interval(interval.getStart(), DateTimes.MAX);
762+
Interval startKey = timeline.floorKey(searchStart);
763+
if (startKey == null) {
764+
// No entry starts at or before interval.start; begin from the first entry in the map.
765+
startKey = timeline.isEmpty() ? null : timeline.firstKey();
766+
}
767+
if (startKey == null) {
768+
return retVal;
769+
}
770+
771+
for (Entry<Interval, TimelineEntry> entry : timeline.tailMap(startKey, true).entrySet()) {
751772
Interval timelineInterval = entry.getKey();
773+
// All entries from here forward start at or after startKey.start. Once we reach an entry whose
774+
// start is at or past the query end, there can be no more overlaps.
775+
if (timelineInterval.getStartMillis() >= interval.getEndMillis()) {
776+
break;
777+
}
752778
TimelineEntry val = entry.getValue();
753779

754780
// exclude empty partition holders (i.e. tombstones) since they do not add value

0 commit comments

Comments
 (0)