diff --git a/benchmarks/src/jmh/kotlin/ParallelFormatBenchmark.kt b/benchmarks/src/jmh/kotlin/ParallelFormatBenchmark.kt new file mode 100644 index 000000000..07bc7eed7 --- /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 = 5, time = 1) +@Measurement(iterations = 5, time = 1) +@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/PythonDateTimeFormatBenchmark.kt b/benchmarks/src/jmh/kotlin/PythonDateTimeFormatBenchmark.kt new file mode 100644 index 000000000..24e3bbfcb --- /dev/null +++ b/benchmarks/src/jmh/kotlin/PythonDateTimeFormatBenchmark.kt @@ -0,0 +1,47 @@ +/* + * 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 kotlinx.datetime.format.optional +import org.openjdk.jmh.annotations.* +import org.openjdk.jmh.infra.Blackhole +import java.util.concurrent.* + +@Warmup(iterations = 5, time = 1) +@Measurement(iterations = 5, time = 1) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@State(Scope.Benchmark) +@Fork(1) +open class PythonDateTimeFormatBenchmark { + + @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) + } +} diff --git a/benchmarks/src/jmh/kotlin/SerialFormatBenchmark.kt b/benchmarks/src/jmh/kotlin/SerialFormatBenchmark.kt new file mode 100644 index 000000000..fb63f577e --- /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 = 5, time = 1) +@Measurement(iterations = 5, time = 1) +@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/ConcatenatedListView.kt b/core/common/src/internal/format/parser/ConcatenatedListView.kt new file mode 100644 index 000000000..3f11c3826 --- /dev/null +++ b/core/common/src/internal/format/parser/ConcatenatedListView.kt @@ -0,0 +1,41 @@ +/* + * 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. + */ + +package kotlinx.datetime.internal.format.parser + +internal class ConcatenatedListView(val list1: List, val list2: List) : AbstractList() { + override val size: Int + get() = list1.size + list2.size + + override fun get(index: Int): T = if (index < list1.size) list1[index] else list2[index - list1.size] + + override fun iterator(): Iterator = ConcatenatedListViewIterator() + + private inner class ConcatenatedListViewIterator : Iterator { + private val iterators: List> = buildList { + collectIterators(list1) + collectIterators(list2) + } + private var index = 0 + + private fun MutableList>.collectIterators(list: List) { + if (list is ConcatenatedListView) { + collectIterators(list.list1) + collectIterators(list.list2) + } else { + add(list.iterator()) + } + } + + override fun hasNext(): Boolean { + while (index < iterators.size && !iterators[index].hasNext()) { + index++ + } + return index < iterators.size + } + + override fun next(): T = iterators[index].next() + } +} diff --git a/core/common/src/internal/format/parser/Parser.kt b/core/common/src/internal/format/parser/Parser.kt index 9958e3fb9..eb9de566b 100644 --- a/core/common/src/internal/format/parser/Parser.kt +++ b/core/common/src/internal/format/parser/Parser.kt @@ -44,83 +44,86 @@ internal class ParserStructure( // 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) + ParserStructure(ConcatenatedListView(operations, other.operations), other.followedBy) } else { ParserStructure(operations, followedBy.map { it.append(other) }) } - fun ParserStructure.simplify(unconditionalModifications: List>): ParserStructure { - val newOperations = mutableListOf>() - var currentNumberSpan: MutableList>? = null - val unconditionalModificationsForTails = unconditionalModifications.toMutableList() - // joining together the number 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) { - if (currentNumberSpan != null) { - currentNumberSpan.addAll(op.consumers) + val cache = hashMapOf, List>>, ParserStructure>() + + fun ParserStructure.simplify(unconditionalModifications: List>): ParserStructure = + cache.getOrPut(this to unconditionalModifications) { + val newOperations = mutableListOf>() + var currentNumberSpan: MutableList>? = null + val unconditionalModificationsForTails = unconditionalModifications.toMutableList() + // joining together the number 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) { + if (currentNumberSpan != null) { + currentNumberSpan.addAll(op.consumers) + } else { + currentNumberSpan = op.consumers.toMutableList() + } + } else if (op is UnconditionalModification) { + unconditionalModificationsForTails.add(op) } else { - currentNumberSpan = op.consumers.toMutableList() - } - } else if (op is UnconditionalModification) { - unconditionalModificationsForTails.add(op) - } else { - if (currentNumberSpan != null) { - newOperations.add(NumberSpanParserOperation(currentNumberSpan)) - currentNumberSpan = null + if (currentNumberSpan != null) { + newOperations.add(NumberSpanParserOperation(currentNumberSpan)) + currentNumberSpan = null + } + newOperations.add(op) } - newOperations.add(op) } - } - val mergedTails = followedBy.flatMap { - val simplified = it.simplify(unconditionalModificationsForTails) - // 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 - // string. For example, (|a|b) is not equivalent to (a|b). - if (simplified.operations.isEmpty()) - simplified.followedBy.ifEmpty { listOf(simplified) } - else - listOf(simplified) - }.ifEmpty { - // preserving the invariant that `mergedTails` contains all unconditional modifications - listOf(ParserStructure(unconditionalModificationsForTails, emptyList())) - } - return if (currentNumberSpan == null) { - // the last operation was not a number span, or it was a number span that we are allowed to interrupt - ParserStructure(newOperations, mergedTails) - } else if (mergedTails.none { - it.operations.firstOrNull()?.let { it is NumberSpanParserOperation } == true - }) { - // the last operation was a number span, but there are no alternatives that start with a number span. - newOperations.add(NumberSpanParserOperation(currentNumberSpan)) - 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 - ), + val mergedTails = followedBy.flatMap { + val simplified = it.simplify(unconditionalModificationsForTails) + // 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 + // string. For example, (|a|b) is not equivalent to (a|b). + if (simplified.operations.isEmpty()) + simplified.followedBy.ifEmpty { listOf(simplified) } + else + listOf(simplified) + }.ifEmpty { + // preserving the invariant that `mergedTails` contains all unconditional modifications + listOf(ParserStructure(unconditionalModificationsForTails, emptyList())) + } + if (currentNumberSpan == null) { + // the last operation was not a number span, or it was a number span that we are allowed to interrupt + ParserStructure(newOperations, mergedTails) + } else if (mergedTails.none { + it.operations.firstOrNull()?.let { it is NumberSpanParserOperation } == true + }) { + // the last operation was a number span, but there are no alternatives that start with a number span. + newOperations.add(NumberSpanParserOperation(currentNumberSpan)) + 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 + ) + } + + null -> ParserStructure( + listOf(NumberSpanParserOperation(currentNumberSpan)), it.followedBy ) - } - - null -> ParserStructure( - listOf(NumberSpanParserOperation(currentNumberSpan)), - it.followedBy - ) - else -> ParserStructure( - listOf(NumberSpanParserOperation(currentNumberSpan)) + it.operations, - it.followedBy - ) + else -> ParserStructure( + listOf(NumberSpanParserOperation(currentNumberSpan)) + it.operations, + it.followedBy + ) + } } + ParserStructure(newOperations, newTails) } - ParserStructure(newOperations, newTails) } - } val naiveParser = foldRight(ParserStructure(emptyList(), emptyList())) { parser, acc -> parser.append(acc) } return naiveParser.simplify(emptyList()) }