diff --git a/benchmarks/src/jmh/kotlin/CommonFormats.kt b/benchmarks/src/jmh/kotlin/CommonFormats.kt new file mode 100644 index 000000000..1c383c608 --- /dev/null +++ b/benchmarks/src/jmh/kotlin/CommonFormats.kt @@ -0,0 +1,167 @@ +/* + * Copyright 2019-2025 JetBrains s.r.o. and contributors. + * Use of this source code is governed by the Apache 2.0 License that can be found in the LICENSE.txt file. + */ + +@file:Suppress("unused") + +package kotlinx.datetime + +import kotlinx.datetime.format.* +import org.openjdk.jmh.annotations.* +import org.openjdk.jmh.infra.Blackhole +import java.util.concurrent.* + +@Warmup(iterations = 20, time = 2) +@Measurement(iterations = 30, time = 2) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@State(Scope.Benchmark) +@Fork(2) +open class CommonFormats { + + @Benchmark + fun buildPythonDateTimeFormat(blackhole: Blackhole) { + val v = LocalDateTime.Format { + year() + char('-') + monthNumber() + char('-') + day() + char(' ') + hour() + char(':') + minute() + optional { + char(':') + second() + optional { + char('.') + secondFraction() + } + } + } + blackhole.consume(v) + } + + @Benchmark + fun buildIsoDateTimeFormat(blackhole: Blackhole) { + val format = LocalDateTime.Format { + date(LocalDate.Format { + year() + char('-') + monthNumber() + char('-') + day() + }) + alternativeParsing({ char('t') }) { char('T') } + time(LocalTime.Format { + hour() + char(':') + minute() + alternativeParsing({}) { + char(':') + second() + optional { + char('.') + secondFraction(1, 9) + } + } + }) + } + blackhole.consume(format) + } + + @Benchmark + fun buildFourDigitsUtcOffsetFormat(blackhole: Blackhole) { + val format = UtcOffset.Format { + offsetHours() + offsetMinutesOfHour() + } + blackhole.consume(format) + } + + @Benchmark + fun buildRfc1123DateTimeFormat(blackhole: Blackhole) { + val format = DateTimeComponents.Format { + alternativeParsing({ + // the day of week may be missing + }) { + dayOfWeek(DayOfWeekNames.ENGLISH_ABBREVIATED) + chars(", ") + } + day(Padding.NONE) + char(' ') + monthName(MonthNames.ENGLISH_ABBREVIATED) + char(' ') + year() + char(' ') + hour() + char(':') + minute() + optional { + char(':') + second() + } + chars(" ") + alternativeParsing({ + chars("UT") + }, { + chars("Z") + }) { + optional("GMT") { + offset(UtcOffset.Format { + offsetHours() + offsetMinutesOfHour() + }) + } + } + } + blackhole.consume(format) + } + + @Benchmark + fun buildIsoDateTimeOffsetFormat(blackhole: Blackhole) { + val format = DateTimeComponents.Format { + date(LocalDate.Format { + year() + char('-') + monthNumber() + char('-') + day() + }) + alternativeParsing({ + char('t') + }) { + char('T') + } + hour() + char(':') + minute() + char(':') + second() + optional { + char('.') + secondFraction(1, 9) + } + alternativeParsing({ + offsetHours() + }) { + offset(UtcOffset.Format { + alternativeParsing({ chars("z") }) { + optional("Z") { + offsetHours() + char(':') + offsetMinutesOfHour() + optional { + char(':') + offsetSecondsOfMinute() + } + } + } + }) + } + } + blackhole.consume(format) + } +} diff --git a/benchmarks/src/jmh/kotlin/ParallelFormatBenchmark.kt b/benchmarks/src/jmh/kotlin/ParallelFormatBenchmark.kt new file mode 100644 index 000000000..c74dfaa02 --- /dev/null +++ b/benchmarks/src/jmh/kotlin/ParallelFormatBenchmark.kt @@ -0,0 +1,88 @@ +/* + * Copyright 2019-2025 JetBrains s.r.o. and contributors. + * Use of this source code is governed by the Apache 2.0 License that can be found in the LICENSE.txt file. + */ + +@file:Suppress("unused") + +package kotlinx.datetime + +import kotlinx.datetime.format.alternativeParsing +import kotlinx.datetime.format.char +import org.openjdk.jmh.annotations.* +import org.openjdk.jmh.infra.Blackhole +import java.util.concurrent.TimeUnit + +@Warmup(iterations = 10, time = 2) +@Measurement(iterations = 20, time = 2) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@State(Scope.Benchmark) +@Fork(1) +open class ParallelFormatBenchmark { + + @Param("2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12") + var n = 0 + + @Benchmark + fun formatCreationWithAlternativeParsing(blackhole: Blackhole) { + val format = LocalDateTime.Format { + repeat(n) { + alternativeParsing( + { monthNumber() }, + { day() }, + primaryFormat = { hour() } + ) + char('@') + minute() + char('#') + second() + } + } + blackhole.consume(format) + } + + @Benchmark + fun formatCreationWithNestedAlternativeParsing(blackhole: Blackhole) { + val format = LocalDateTime.Format { + repeat(n) { index -> + alternativeParsing( + { monthNumber(); char('-'); day() }, + { day(); char('/'); monthNumber() }, + primaryFormat = { year(); char('-'); monthNumber(); char('-'); day() } + ) + + if (index and 1 == 0) { + alternativeParsing( + { + alternativeParsing( + { hour(); char(':'); minute() }, + { minute(); char(':'); second() }, + primaryFormat = { hour(); char(':'); minute(); char(':'); second() } + ) + }, + primaryFormat = { + year(); char('-'); monthNumber(); char('-'); day() + char('T') + hour(); char(':'); minute(); char(':'); second() + } + ) + } + + char('|') + if (index % 3 == 0) { + char('|') + } + + if (index and 2 == 0) { + alternativeParsing( + { char('Z') }, + { char('+'); hour(); char(':'); minute() }, + primaryFormat = { char('-'); hour(); char(':'); minute() } + ) + } + } + } + blackhole.consume(format) + } +} diff --git a/benchmarks/src/jmh/kotlin/SerialFormatBenchmark.kt b/benchmarks/src/jmh/kotlin/SerialFormatBenchmark.kt new file mode 100644 index 000000000..d6eeaad03 --- /dev/null +++ b/benchmarks/src/jmh/kotlin/SerialFormatBenchmark.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2019-2025 JetBrains s.r.o. and contributors. + * Use of this source code is governed by the Apache 2.0 License that can be found in the LICENSE.txt file. + */ + +@file:Suppress("unused") + +package kotlinx.datetime + +import kotlinx.datetime.format.char +import org.openjdk.jmh.annotations.* +import org.openjdk.jmh.infra.Blackhole +import java.util.concurrent.TimeUnit + +@Warmup(iterations = 10, time = 2) +@Measurement(iterations = 20, time = 2) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@State(Scope.Benchmark) +@Fork(1) +open class SerialFormatBenchmark { + + @Param("1", "2", "4", "8", "16", "32", "64", "128", "256", "512", "1024") + var n = 0 + + @Benchmark + fun largeSerialFormat(blackhole: Blackhole) { + val format = LocalDateTime.Format { + repeat(n) { + char('^') + monthNumber() + char('&') + day() + char('!') + hour() + char('$') + minute() + char('#') + second() + char('@') + } + } + blackhole.consume(format) + } +} diff --git a/core/common/src/internal/format/parser/Parser.kt b/core/common/src/internal/format/parser/Parser.kt index 9958e3fb9..22901f38a 100644 --- a/core/common/src/internal/format/parser/Parser.kt +++ b/core/common/src/internal/format/parser/Parser.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 JetBrains s.r.o. and contributors. + * Copyright 2023-2025 JetBrains s.r.o. and contributors. * Use of this source code is governed by the Apache 2.0 License that can be found in the LICENSE.txt file. */ @@ -41,19 +41,42 @@ internal class ParserStructure( "${operations.joinToString(", ")}(${followedBy.joinToString(";")})" } -// TODO: O(size of the resulting parser ^ 2), but can be O(size of the resulting parser) internal fun List>.concat(): ParserStructure { - fun ParserStructure.append(other: ParserStructure): ParserStructure = if (followedBy.isEmpty()) { - ParserStructure(operations + other.operations, other.followedBy) - } else { - ParserStructure(operations, followedBy.map { it.append(other) }) + fun mergeOperations( + baseOperations: List>, + numberSpan: List>?, + unconditionalModifications: List>, + simplifiedParserStructure: ParserStructure, + ): ParserStructure { + val operationsToMerge = simplifiedParserStructure.operations + val firstOperation = operationsToMerge.firstOrNull() + val mergedOperations = buildList { + addAll(baseOperations) + when { + numberSpan == null -> { + addAll(operationsToMerge) + } + firstOperation is NumberSpanParserOperation -> { + add(NumberSpanParserOperation(numberSpan + firstOperation.consumers)) + for (i in 1..operationsToMerge.lastIndex) { + add(operationsToMerge[i]) + } + } + else -> { + add(NumberSpanParserOperation(numberSpan)) + addAll(operationsToMerge) + } + } + addAll(unconditionalModifications) + } + return ParserStructure(mergedOperations, simplifiedParserStructure.followedBy) } - fun ParserStructure.simplify(unconditionalModifications: List>): ParserStructure { + fun ParserStructure.simplifyAndAppend(other: ParserStructure): ParserStructure { val newOperations = mutableListOf>() var currentNumberSpan: MutableList>? = null - val unconditionalModificationsForTails = unconditionalModifications.toMutableList() - // joining together the number consumers in this parser before the first alternative; + val unconditionalModifications = mutableListOf>() + // joining together the number of consumers in this parser before the first alternative; // collecting the unconditional modifications to push them to the end of all the parser's branches. for (op in operations) { if (op is NumberSpanParserOperation) { @@ -63,17 +86,20 @@ internal fun List>.concat(): ParserStructure { currentNumberSpan = op.consumers.toMutableList() } } else if (op is UnconditionalModification) { - unconditionalModificationsForTails.add(op) + unconditionalModifications.add(op) } else { if (currentNumberSpan != null) { newOperations.add(NumberSpanParserOperation(currentNumberSpan)) currentNumberSpan = null + newOperations.addAll(unconditionalModifications) + unconditionalModifications.clear() } newOperations.add(op) } } + val mergedTails = followedBy.flatMap { - val simplified = it.simplify(unconditionalModificationsForTails) + val simplified = it.simplifyAndAppend(other) // parser `ParserStructure(emptyList(), p)` is equivalent to `p`, // unless `p` is empty. For example, ((a|b)|(c|d)) is equivalent to (a|b|c|d). // As a special case, `ParserStructure(emptyList(), emptyList())` represents a parser that recognizes an empty @@ -83,46 +109,55 @@ internal fun List>.concat(): ParserStructure { else listOf(simplified) }.ifEmpty { - // preserving the invariant that `mergedTails` contains all unconditional modifications - listOf(ParserStructure(unconditionalModificationsForTails, emptyList())) + if (other.operations.isNotEmpty()) { + return mergeOperations(newOperations, currentNumberSpan, unconditionalModifications, other) + } + other.followedBy } + return if (currentNumberSpan == null) { // the last operation was not a number span, or it was a number span that we are allowed to interrupt + newOperations.addAll(unconditionalModifications) ParserStructure(newOperations, mergedTails) - } else if (mergedTails.none { - it.operations.firstOrNull()?.let { it is NumberSpanParserOperation } == true - }) { + } else if (mergedTails.none { it.operations.firstOrNull() is NumberSpanParserOperation }) { // the last operation was a number span, but there are no alternatives that start with a number span. newOperations.add(NumberSpanParserOperation(currentNumberSpan)) + newOperations.addAll(unconditionalModifications) ParserStructure(newOperations, mergedTails) } else { val newTails = mergedTails.map { - when (val firstOperation = it.operations.firstOrNull()) { - is NumberSpanParserOperation -> { - ParserStructure( - listOf(NumberSpanParserOperation(currentNumberSpan + firstOperation.consumers)) + it.operations.drop( - 1 - ), - it.followedBy - ) - } + mergeOperations(emptyList(), currentNumberSpan, unconditionalModifications, it) + } + ParserStructure(newOperations, newTails) + } + } - null -> ParserStructure( - listOf(NumberSpanParserOperation(currentNumberSpan)), - it.followedBy - ) + var result = ParserStructure(emptyList(), emptyList()) + val accumulatedOperations = mutableListOf>>() - else -> ParserStructure( - listOf(NumberSpanParserOperation(currentNumberSpan)) + it.operations, - it.followedBy - ) + fun flushAccumulatedOperations() { + if (accumulatedOperations.isNotEmpty()) { + val operations = buildList { + for (parserOperations in accumulatedOperations.asReversed()) { + addAll(parserOperations) } } - ParserStructure(newOperations, newTails) + result = ParserStructure(operations, emptyList()).simplifyAndAppend(result) + accumulatedOperations.clear() } } - val naiveParser = foldRight(ParserStructure(emptyList(), emptyList())) { parser, acc -> parser.append(acc) } - return naiveParser.simplify(emptyList()) + + for (parser in this.asReversed()) { + if (parser.followedBy.isEmpty()) { + accumulatedOperations.add(parser.operations) + } else { + flushAccumulatedOperations() + result = parser.simplifyAndAppend(result) + } + } + + flushAccumulatedOperations() + return result } internal interface Copyable {