Skip to content

Commit 8478ee6

Browse files
authored
Speed up time interval arounding around dst (#56371)
When an index spans a daylight savings time transition we can't use our optimization that rewrites the requested time zone to a fixed time zone and instead we used to fall back to a java.util.time based rounding implementation. In #55559 we optimized "time unit" rounding. This optimizes "time interval" rounding. The java.util.time based implementation is about 1650% slower than the rounding implementation for a fixed time zone. This replaces it with a similar optimization that is only about 30% slower than the fixed time zone. The java.util.time implementation allocates a ton of short lived objects but the optimized implementation doesn't. So it *might* end up being faster than the microbenchmarks imply.
1 parent 10bbf05 commit 8478ee6

File tree

5 files changed

+240
-93
lines changed

5 files changed

+240
-93
lines changed

benchmarks/src/main/java/org/elasticsearch/common/RoundingBenchmark.java

+13-3
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
package org.elasticsearch.common;
2121

2222
import org.elasticsearch.common.time.DateFormatter;
23+
import org.elasticsearch.common.unit.TimeValue;
24+
import org.elasticsearch.search.aggregations.bucket.histogram.DateHistogramAggregationBuilder;
2325
import org.openjdk.jmh.annotations.Benchmark;
2426
import org.openjdk.jmh.annotations.BenchmarkMode;
2527
import org.openjdk.jmh.annotations.Fork;
@@ -60,8 +62,8 @@ public class RoundingBenchmark {
6062
@Param({ "UTC", "America/New_York" })
6163
public String zone;
6264

63-
@Param({ "MONTH_OF_YEAR", "HOUR_OF_DAY" })
64-
public String timeUnit;
65+
@Param({ "calendar year", "calendar hour", "10d", "5d", "1h" })
66+
public String interval;
6567

6668
@Param({ "1", "10000", "1000000", "100000000" })
6769
public int count;
@@ -86,7 +88,15 @@ public void buildDates() {
8688
dates[i] = date;
8789
date += diff;
8890
}
89-
Rounding rounding = Rounding.builder(Rounding.DateTimeUnit.valueOf(timeUnit)).timeZone(ZoneId.of(zone)).build();
91+
Rounding.Builder roundingBuilder;
92+
if (interval.startsWith("calendar ")) {
93+
roundingBuilder = Rounding.builder(
94+
DateHistogramAggregationBuilder.DATE_FIELD_UNITS.get(interval.substring("calendar ".length()))
95+
);
96+
} else {
97+
roundingBuilder = Rounding.builder(TimeValue.parseTimeValue(interval, "interval"));
98+
}
99+
Rounding rounding = roundingBuilder.timeZone(ZoneId.of(zone)).build();
90100
switch (rounder) {
91101
case "java time":
92102
rounderBuilder = rounding::prepareJavaTime;

server/src/main/java/org/elasticsearch/common/LocalTimeOffset.java

+1-1
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ public static Lookup lookup(ZoneId zone, long minUtcMillis, long maxUtcMillis) {
9191
*
9292
* @return a lookup function of {@code null} if none could be built
9393
*/
94-
public static LocalTimeOffset lookupFixedOffset(ZoneId zone) {
94+
public static LocalTimeOffset fixedOffset(ZoneId zone) {
9595
return checkForFixedZone(zone, zone.getRules());
9696
}
9797

server/src/main/java/org/elasticsearch/common/Rounding.java

+174-82
Original file line numberDiff line numberDiff line change
@@ -439,7 +439,7 @@ public Prepared prepare(long minUtcMillis, long maxUtcMillis) {
439439

440440
@Override
441441
public Prepared prepareForUnknown() {
442-
LocalTimeOffset offset = LocalTimeOffset.lookupFixedOffset(timeZone);
442+
LocalTimeOffset offset = LocalTimeOffset.fixedOffset(timeZone);
443443
if (offset != null) {
444444
if (unitRoundsToMidnight) {
445445
return new FixedToMidnightRounding(offset);
@@ -555,7 +555,7 @@ public long inGap(long localMillis, Gap gap) {
555555
@Override
556556
public long beforeGap(long localMillis, Gap gap) {
557557
return gap.previous().localToUtc(localMillis, this);
558-
};
558+
}
559559

560560
@Override
561561
public long inOverlap(long localMillis, Overlap overlap) {
@@ -565,7 +565,7 @@ public long inOverlap(long localMillis, Overlap overlap) {
565565
@Override
566566
public long beforeOverlap(long localMillis, Overlap overlap) {
567567
return overlap.previous().localToUtc(localMillis, this);
568-
};
568+
}
569569
}
570570

571571
private class NotToMidnightRounding extends AbstractNotToMidnightRounding implements LocalTimeOffset.Strategy {
@@ -739,21 +739,15 @@ public final long nextRoundingValue(long utcMillis) {
739739

740740
static class TimeIntervalRounding extends Rounding {
741741
static final byte ID = 2;
742-
/** Since, there is no offset of -1 ms, it is safe to use -1 for non-fixed timezones */
743-
private static final long TZ_OFFSET_NON_FIXED = -1;
744742

745743
private final long interval;
746744
private final ZoneId timeZone;
747-
/** For fixed offset timezones, this is the offset in milliseconds, otherwise TZ_OFFSET_NON_FIXED */
748-
private final long fixedOffsetMillis;
749745

750746
TimeIntervalRounding(long interval, ZoneId timeZone) {
751747
if (interval < 1)
752748
throw new IllegalArgumentException("Zero or negative time interval not supported");
753749
this.interval = interval;
754750
this.timeZone = timeZone;
755-
this.fixedOffsetMillis = timeZone.getRules().isFixedOffset() ?
756-
timeZone.getRules().getOffset(Instant.EPOCH).getTotalSeconds() * 1000 : TZ_OFFSET_NON_FIXED;
757751
}
758752

759753
TimeIntervalRounding(StreamInput in) throws IOException {
@@ -773,88 +767,32 @@ public byte id() {
773767

774768
@Override
775769
public Prepared prepare(long minUtcMillis, long maxUtcMillis) {
776-
return prepareForUnknown();
770+
long minLookup = minUtcMillis - interval;
771+
long maxLookup = maxUtcMillis;
772+
773+
LocalTimeOffset.Lookup lookup = LocalTimeOffset.lookup(timeZone, minLookup, maxLookup);
774+
if (lookup == null) {
775+
return prepareJavaTime();
776+
}
777+
LocalTimeOffset fixedOffset = lookup.fixedInRange(minLookup, maxLookup);
778+
if (fixedOffset != null) {
779+
return new FixedRounding(fixedOffset);
780+
}
781+
return new VariableRounding(lookup);
777782
}
778783

779784
@Override
780785
public Prepared prepareForUnknown() {
786+
LocalTimeOffset offset = LocalTimeOffset.fixedOffset(timeZone);
787+
if (offset != null) {
788+
return new FixedRounding(offset);
789+
}
781790
return prepareJavaTime();
782791
}
783792

784793
@Override
785794
Prepared prepareJavaTime() {
786-
return new Prepared() {
787-
@Override
788-
public long round(long utcMillis) {
789-
if (fixedOffsetMillis != TZ_OFFSET_NON_FIXED) {
790-
// This works as long as the tz offset doesn't change. It is worth getting this case out of the way first,
791-
// as the calculations for fixing things near to offset changes are a little expensive and unnecessary
792-
// in the common case of working with fixed offset timezones (such as UTC).
793-
long localMillis = utcMillis + fixedOffsetMillis;
794-
return (roundKey(localMillis, interval) * interval) - fixedOffsetMillis;
795-
}
796-
final Instant utcInstant = Instant.ofEpochMilli(utcMillis);
797-
final LocalDateTime rawLocalDateTime = LocalDateTime.ofInstant(utcInstant, timeZone);
798-
799-
// a millisecond value with the same local time, in UTC, as `utcMillis` has in `timeZone`
800-
final long localMillis = utcMillis + timeZone.getRules().getOffset(utcInstant).getTotalSeconds() * 1000;
801-
assert localMillis == rawLocalDateTime.toInstant(ZoneOffset.UTC).toEpochMilli();
802-
803-
final long roundedMillis = roundKey(localMillis, interval) * interval;
804-
final LocalDateTime roundedLocalDateTime = LocalDateTime.ofInstant(Instant.ofEpochMilli(roundedMillis), ZoneOffset.UTC);
805-
806-
// Now work out what roundedLocalDateTime actually means
807-
final List<ZoneOffset> currentOffsets = timeZone.getRules().getValidOffsets(roundedLocalDateTime);
808-
if (currentOffsets.isEmpty() == false) {
809-
// There is at least one instant with the desired local time. In general the desired result is
810-
// the latest rounded time that's no later than the input time, but this could involve rounding across
811-
// a timezone transition, which may yield the wrong result
812-
final ZoneOffsetTransition previousTransition = timeZone.getRules().previousTransition(utcInstant.plusMillis(1));
813-
for (int offsetIndex = currentOffsets.size() - 1; 0 <= offsetIndex; offsetIndex--) {
814-
final OffsetDateTime offsetTime = roundedLocalDateTime.atOffset(currentOffsets.get(offsetIndex));
815-
final Instant offsetInstant = offsetTime.toInstant();
816-
if (previousTransition != null && offsetInstant.isBefore(previousTransition.getInstant())) {
817-
/*
818-
* Rounding down across the transition can yield the
819-
* wrong result. It's best to return to the transition
820-
* time and round that down.
821-
*/
822-
return round(previousTransition.getInstant().toEpochMilli() - 1);
823-
}
824-
825-
if (utcInstant.isBefore(offsetTime.toInstant()) == false) {
826-
return offsetInstant.toEpochMilli();
827-
}
828-
}
829-
830-
final OffsetDateTime offsetTime = roundedLocalDateTime.atOffset(currentOffsets.get(0));
831-
final Instant offsetInstant = offsetTime.toInstant();
832-
assert false : this + " failed to round " + utcMillis + " down: " + offsetInstant + " is the earliest possible";
833-
return offsetInstant.toEpochMilli(); // TODO or throw something?
834-
} else {
835-
// The desired time isn't valid because within a gap, so just return the gap time.
836-
ZoneOffsetTransition zoneOffsetTransition = timeZone.getRules().getTransition(roundedLocalDateTime);
837-
return zoneOffsetTransition.getInstant().toEpochMilli();
838-
}
839-
}
840-
841-
@Override
842-
public long nextRoundingValue(long time) {
843-
int offsetSeconds = timeZone.getRules().getOffset(Instant.ofEpochMilli(time)).getTotalSeconds();
844-
long millis = time + interval + offsetSeconds * 1000;
845-
return ZonedDateTime.ofInstant(Instant.ofEpochMilli(millis), ZoneOffset.UTC)
846-
.withZoneSameLocal(timeZone)
847-
.toInstant().toEpochMilli();
848-
}
849-
850-
private long roundKey(long value, long interval) {
851-
if (value < 0) {
852-
return (value - interval + 1) / interval;
853-
} else {
854-
return value / interval;
855-
}
856-
}
857-
};
795+
return new JavaTimeRounding();
858796
}
859797

860798
@Override
@@ -888,6 +826,160 @@ public boolean equals(Object obj) {
888826
public String toString() {
889827
return "Rounding[" + interval + " in " + timeZone + "]";
890828
}
829+
830+
private long roundKey(long value, long interval) {
831+
if (value < 0) {
832+
return (value - interval + 1) / interval;
833+
} else {
834+
return value / interval;
835+
}
836+
}
837+
838+
/**
839+
* Rounds to down inside of a time zone with an "effectively fixed"
840+
* time zone. A time zone can be "effectively fixed" if:
841+
* <ul>
842+
* <li>It is UTC</li>
843+
* <li>It is a fixed offset from UTC at all times (UTC-5, America/Phoenix)</li>
844+
* <li>It is fixed over the entire range of dates that will be rounded</li>
845+
* </ul>
846+
*/
847+
private class FixedRounding implements Prepared {
848+
private final LocalTimeOffset offset;
849+
850+
FixedRounding(LocalTimeOffset offset) {
851+
this.offset = offset;
852+
}
853+
854+
@Override
855+
public long round(long utcMillis) {
856+
return offset.localToUtcInThisOffset(roundKey(offset.utcToLocalTime(utcMillis), interval) * interval);
857+
}
858+
859+
@Override
860+
public long nextRoundingValue(long utcMillis) {
861+
// TODO this is used in date range's collect so we should optimize it too
862+
return new JavaTimeRounding().nextRoundingValue(utcMillis);
863+
}
864+
}
865+
866+
/**
867+
* Rounds down inside of any time zone, even if it is not
868+
* "effectively fixed". See {@link FixedRounding} for a description of
869+
* "effectively fixed".
870+
*/
871+
private class VariableRounding implements Prepared, LocalTimeOffset.Strategy {
872+
private final LocalTimeOffset.Lookup lookup;
873+
874+
VariableRounding(LocalTimeOffset.Lookup lookup) {
875+
this.lookup = lookup;
876+
}
877+
878+
@Override
879+
public long round(long utcMillis) {
880+
LocalTimeOffset offset = lookup.lookup(utcMillis);
881+
return offset.localToUtc(roundKey(offset.utcToLocalTime(utcMillis), interval) * interval, this);
882+
}
883+
884+
@Override
885+
public long nextRoundingValue(long utcMillis) {
886+
// TODO this is used in date range's collect so we should optimize it too
887+
return new JavaTimeRounding().nextRoundingValue(utcMillis);
888+
}
889+
890+
@Override
891+
public long inGap(long localMillis, Gap gap) {
892+
return gap.startUtcMillis();
893+
}
894+
895+
@Override
896+
public long beforeGap(long localMillis, Gap gap) {
897+
return gap.previous().localToUtc(localMillis, this);
898+
}
899+
900+
@Override
901+
public long inOverlap(long localMillis, Overlap overlap) {
902+
// Convert the overlap at this offset because that'll produce the largest result.
903+
return overlap.localToUtcInThisOffset(localMillis);
904+
}
905+
906+
@Override
907+
public long beforeOverlap(long localMillis, Overlap overlap) {
908+
return overlap.previous().localToUtc(roundKey(overlap.firstNonOverlappingLocalTime() - 1, interval) * interval, this);
909+
}
910+
}
911+
912+
/**
913+
* Rounds down inside of any time zone using {@link LocalDateTime}
914+
* directly. It'll be slower than {@link VariableRounding} and much
915+
* slower than {@link FixedRounding}. We use it when we don' have an
916+
* "effectively fixed" time zone and we can't get a
917+
* {@link LocalTimeOffset.Lookup}. We might not be able to get one
918+
* because:
919+
* <ul>
920+
* <li>We don't know how to look up the minimum and maximum dates we
921+
* are going to round.</li>
922+
* <li>We expect to round over thousands and thousands of years worth
923+
* of dates with the same {@link Prepared} instance.</li>
924+
* </ul>
925+
*/
926+
private class JavaTimeRounding implements Prepared {
927+
@Override
928+
public long round(long utcMillis) {
929+
final Instant utcInstant = Instant.ofEpochMilli(utcMillis);
930+
final LocalDateTime rawLocalDateTime = LocalDateTime.ofInstant(utcInstant, timeZone);
931+
932+
// a millisecond value with the same local time, in UTC, as `utcMillis` has in `timeZone`
933+
final long localMillis = utcMillis + timeZone.getRules().getOffset(utcInstant).getTotalSeconds() * 1000;
934+
assert localMillis == rawLocalDateTime.toInstant(ZoneOffset.UTC).toEpochMilli();
935+
936+
final long roundedMillis = roundKey(localMillis, interval) * interval;
937+
final LocalDateTime roundedLocalDateTime = LocalDateTime.ofInstant(Instant.ofEpochMilli(roundedMillis), ZoneOffset.UTC);
938+
939+
// Now work out what roundedLocalDateTime actually means
940+
final List<ZoneOffset> currentOffsets = timeZone.getRules().getValidOffsets(roundedLocalDateTime);
941+
if (currentOffsets.isEmpty() == false) {
942+
// There is at least one instant with the desired local time. In general the desired result is
943+
// the latest rounded time that's no later than the input time, but this could involve rounding across
944+
// a timezone transition, which may yield the wrong result
945+
final ZoneOffsetTransition previousTransition = timeZone.getRules().previousTransition(utcInstant.plusMillis(1));
946+
for (int offsetIndex = currentOffsets.size() - 1; 0 <= offsetIndex; offsetIndex--) {
947+
final OffsetDateTime offsetTime = roundedLocalDateTime.atOffset(currentOffsets.get(offsetIndex));
948+
final Instant offsetInstant = offsetTime.toInstant();
949+
if (previousTransition != null && offsetInstant.isBefore(previousTransition.getInstant())) {
950+
/*
951+
* Rounding down across the transition can yield the
952+
* wrong result. It's best to return to the transition
953+
* time and round that down.
954+
*/
955+
return round(previousTransition.getInstant().toEpochMilli() - 1);
956+
}
957+
958+
if (utcInstant.isBefore(offsetTime.toInstant()) == false) {
959+
return offsetInstant.toEpochMilli();
960+
}
961+
}
962+
963+
final OffsetDateTime offsetTime = roundedLocalDateTime.atOffset(currentOffsets.get(0));
964+
final Instant offsetInstant = offsetTime.toInstant();
965+
assert false : this + " failed to round " + utcMillis + " down: " + offsetInstant + " is the earliest possible";
966+
return offsetInstant.toEpochMilli(); // TODO or throw something?
967+
} else {
968+
// The desired time isn't valid because within a gap, so just return the start of the gap
969+
ZoneOffsetTransition zoneOffsetTransition = timeZone.getRules().getTransition(roundedLocalDateTime);
970+
return zoneOffsetTransition.getInstant().toEpochMilli();
971+
}
972+
}
973+
974+
@Override
975+
public long nextRoundingValue(long time) {
976+
int offsetSeconds = timeZone.getRules().getOffset(Instant.ofEpochMilli(time)).getTotalSeconds();
977+
long millis = time + interval + offsetSeconds * 1000;
978+
return ZonedDateTime.ofInstant(Instant.ofEpochMilli(millis), ZoneOffset.UTC)
979+
.withZoneSameLocal(timeZone)
980+
.toInstant().toEpochMilli();
981+
}
982+
}
891983
}
892984

893985
static class OffsetRounding extends Rounding {

server/src/test/java/org/elasticsearch/common/LocalTimeOffsetTests.java

+2-2
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ public void testRangeTooLarge() {
4646

4747
public void testNotFixed() {
4848
ZoneId zone = ZoneId.of("America/New_York");
49-
assertThat(LocalTimeOffset.lookupFixedOffset(zone), nullValue());
49+
assertThat(LocalTimeOffset.fixedOffset(zone), nullValue());
5050
}
5151

5252
public void testUtc() {
@@ -59,7 +59,7 @@ public void testFixedOffset() {
5959
}
6060

6161
private void assertFixOffset(ZoneId zone, long offsetMillis) {
62-
LocalTimeOffset fixed = LocalTimeOffset.lookupFixedOffset(zone);
62+
LocalTimeOffset fixed = LocalTimeOffset.fixedOffset(zone);
6363
assertThat(fixed, notNullValue());
6464

6565
LocalTimeOffset.Lookup lookup = LocalTimeOffset.lookup(zone, Long.MIN_VALUE, Long.MAX_VALUE);

0 commit comments

Comments
 (0)