[K/N] Enable new mm for native by default

This commit is contained in:
Pavel Kunyavskiy
2022-05-02 15:44:20 +02:00
committed by Space
parent b739344b1c
commit 9801a21abb
15 changed files with 145 additions and 102 deletions
@@ -252,15 +252,17 @@ class K2Native : CLICompiler<K2NativeCompilerArguments>() {
"relaxed" -> MemoryModel.RELAXED
"strict" -> MemoryModel.STRICT
"experimental" -> MemoryModel.EXPERIMENTAL
null -> null
else -> {
configuration.report(ERROR, "Unsupported memory model ${arguments.memoryModel}")
MemoryModel.STRICT
null
}
}
// TODO: revise priority and/or report conflicting values.
val memoryModel = get(BinaryOptions.memoryModel) ?: memoryModelFromArgument
put(BinaryOptions.memoryModel, memoryModel)
if (get(BinaryOptions.memoryModel) == null) {
putIfNotNull(BinaryOptions.memoryModel, memoryModelFromArgument)
}
when {
arguments.generateWorkerTestRunner -> put(GENERATE_TEST_RUNNER, TestRunnerKind.WORKER)
@@ -316,9 +318,6 @@ class K2Native : CLICompiler<K2NativeCompilerArguments>() {
DestroyRuntimeMode.ON_SHUTDOWN
}
})
if (arguments.gc != null && memoryModel != MemoryModel.EXPERIMENTAL) {
configuration.report(ERROR, "-Xgc is only supported for -memory-model experimental")
}
putIfNotNull(GARBAGE_COLLECTOR, when (arguments.gc) {
null -> null
"noop" -> GC.NOOP
@@ -329,13 +328,8 @@ class K2Native : CLICompiler<K2NativeCompilerArguments>() {
null
}
})
put(PROPERTY_LAZY_INITIALIZATION, when (arguments.propertyLazyInitialization) {
null -> {
when (memoryModel) {
MemoryModel.EXPERIMENTAL -> true
else -> false
}
}
putIfNotNull(PROPERTY_LAZY_INITIALIZATION, when (arguments.propertyLazyInitialization) {
null -> null
"enable" -> true
"disable" -> false
else -> {
@@ -343,22 +337,17 @@ class K2Native : CLICompiler<K2NativeCompilerArguments>() {
false
}
})
put(ALLOCATION_MODE, when (arguments.allocator) {
null -> {
when (memoryModel) {
MemoryModel.EXPERIMENTAL -> "mimalloc"
else -> "std"
}
}
"std" -> arguments.allocator!!
"mimalloc" -> arguments.allocator!!
putIfNotNull(ALLOCATION_MODE, when (arguments.allocator) {
null -> null
"std" -> AllocationMode.STD
"mimalloc" -> AllocationMode.MIMALLOC
else -> {
configuration.report(ERROR, "Expected 'std' or 'mimalloc' for allocator")
"std"
AllocationMode.STD
}
})
put(WORKER_EXCEPTION_HANDLING, when (arguments.workerExceptionHandling) {
null -> if (memoryModel == MemoryModel.EXPERIMENTAL) WorkerExceptionHandling.USE_HOOK else WorkerExceptionHandling.LEGACY
putIfNotNull(WORKER_EXCEPTION_HANDLING, when (arguments.workerExceptionHandling) {
null -> null
"legacy" -> WorkerExceptionHandling.LEGACY
"use-hook" -> WorkerExceptionHandling.USE_HOOK
else -> {
@@ -48,7 +48,7 @@ class K2NativeCompilerArguments : CommonCompilerArguments() {
var manifestFile: String? = null
@Argument(value="-memory-model", valueDescription = "<model>", description = "Memory model to use, 'strict' and 'experimental' are currently supported")
var memoryModel: String? = "strict"
var memoryModel: String? = null
@Argument(value="-module-name", deprecatedName = "-module_name", valueDescription = "<name>", description = "Specify a name for the compilation module")
var moduleName: String? = null
@@ -0,0 +1,11 @@
/*
* Copyright 2010-2022 JetBrains s.r.o. and Kotlin Programming Language contributors.
* Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file.
*/
package org.jetbrains.kotlin.backend.konan
enum class AllocationMode {
STD,
MIMALLOC
}
@@ -14,8 +14,11 @@ import org.jetbrains.kotlin.library.KotlinLibrary
import org.jetbrains.kotlin.library.resolver.KotlinLibraryResolveResult
class CacheSupport(
val configuration: CompilerConfiguration,
private val configuration: CompilerConfiguration,
resolvedLibraries: KotlinLibraryResolveResult,
optimizationsEnabled: Boolean,
memoryModel: MemoryModel,
propertyLazyInitialization: Boolean,
target: KonanTarget,
produce: CompilerOutputKind
) {
@@ -53,9 +56,9 @@ class CacheSupport(
val hasCachedLibs = explicitCacheFiles.isNotEmpty() || implicitCacheDirectories.isNotEmpty()
val ignoreReason = when {
configuration.getBoolean(KonanConfigKeys.OPTIMIZATION) -> "for optimized compilation"
configuration.get(BinaryOptions.memoryModel) == MemoryModel.EXPERIMENTAL -> "with experimental memory model"
configuration.getBoolean(KonanConfigKeys.PROPERTY_LAZY_INITIALIZATION) -> "with experimental lazy top levels initialization"
optimizationsEnabled -> "for optimized compilation"
memoryModel != MemoryModel.EXPERIMENTAL -> "with strict memory model"
!propertyLazyInitialization -> "without lazy top levels initialization"
configuration.get(BinaryOptions.stripDebugInfoFromNativeLibs) == false -> "with native libs debug info"
else -> null
}
@@ -460,7 +460,7 @@ internal class Context(config: KonanConfig) : KonanBackendContext(config) {
fun shouldUseDebugInfoFromNativeLibs() = shouldContainAnyDebugInfo() &&
config.configuration.get(BinaryOptions.stripDebugInfoFromNativeLibs) == false
fun shouldOptimize() = config.configuration.getBoolean(KonanConfigKeys.OPTIMIZATION)
fun shouldOptimize() = config.optimizationsEnabled
fun ghaEnabled() = ::globalHierarchyAnalysisResult.isInitialized
fun useLazyFileInitializers() = config.propertyLazyInitialization
@@ -59,9 +59,10 @@ class KonanConfig(val project: Project, val configuration: CompilerConfiguration
val lightDebug: Boolean = configuration.get(KonanConfigKeys.LIGHT_DEBUG)
?: target.family.isAppleFamily // Default is true for Apple targets.
val generateDebugTrampoline = debug && configuration.get(KonanConfigKeys.GENERATE_DEBUG_TRAMPOLINE) ?: false
val optimizationsEnabled = configuration.getBoolean(KonanConfigKeys.OPTIMIZATION)
val memoryModel: MemoryModel by lazy {
when (configuration.get(BinaryOptions.memoryModel)!!) {
when (configuration.get(BinaryOptions.memoryModel)) {
MemoryModel.STRICT -> MemoryModel.STRICT
MemoryModel.RELAXED -> {
configuration.report(CompilerMessageSeverity.ERROR,
@@ -82,6 +83,13 @@ class KonanConfig(val project: Project, val configuration: CompilerConfiguration
MemoryModel.EXPERIMENTAL
}
}
null -> {
if (target.supportsThreads() && destroyRuntimeMode != DestroyRuntimeMode.LEGACY) {
MemoryModel.EXPERIMENTAL
} else {
MemoryModel.STRICT
}
}
}
}
val destroyRuntimeMode: DestroyRuntimeMode get() = configuration.get(KonanConfigKeys.DESTROY_RUNTIME_MODE)!!
@@ -99,7 +107,10 @@ class KonanConfig(val project: Project, val configuration: CompilerConfiguration
realGc
}
val runtimeAssertsMode: RuntimeAssertsMode get() = configuration.get(BinaryOptions.runtimeAssertionsMode) ?: RuntimeAssertsMode.IGNORE
val workerExceptionHandling: WorkerExceptionHandling get() = configuration.get(KonanConfigKeys.WORKER_EXCEPTION_HANDLING)!!
val workerExceptionHandling: WorkerExceptionHandling get() = configuration.get(KonanConfigKeys.WORKER_EXCEPTION_HANDLING) ?: when (memoryModel) {
MemoryModel.EXPERIMENTAL -> WorkerExceptionHandling.USE_HOOK
else -> WorkerExceptionHandling.LEGACY
}
val runtimeLogs: String? get() = configuration.get(KonanConfigKeys.RUNTIME_LOGS)
val freezing: Freezing by lazy {
val freezingMode = configuration.get(BinaryOptions.freezing)
@@ -134,8 +145,7 @@ class KonanConfig(val project: Project, val configuration: CompilerConfiguration
val needCompilerVerification: Boolean
get() = configuration.get(KonanConfigKeys.VERIFY_COMPILER) ?:
(configuration.getBoolean(KonanConfigKeys.OPTIMIZATION) ||
CompilerVersion.CURRENT.meta != MetaVersion.RELEASE)
(optimizationsEnabled || CompilerVersion.CURRENT.meta != MetaVersion.RELEASE)
init {
if (!platformManager.isEnabled(target)) {
@@ -177,24 +187,6 @@ class KonanConfig(val project: Project, val configuration: CompilerConfiguration
konanKlibDir = File(distribution.klib)
)
internal val cacheSupport = CacheSupport(configuration, resolvedLibraries, target, produce)
internal val cachedLibraries: CachedLibraries
get() = cacheSupport.cachedLibraries
internal val librariesToCache: Set<KotlinLibrary>
get() = cacheSupport.librariesToCache
val outputFiles =
OutputFiles(configuration.get(KonanConfigKeys.OUTPUT) ?: cacheSupport.tryGetImplicitOutput(),
target, produce)
val tempFiles = TempFiles(outputFiles.outputName, configuration.get(KonanConfigKeys.TEMPORARY_FILES_DIR))
val outputFile get() = outputFiles.mainFile
private val implicitModuleName: String
get() = File(outputFiles.outputName).name
val fullExportedNamePrefix: String
get() = configuration.get(KonanConfigKeys.FULL_EXPORTED_NAME_PREFIX) ?: implicitModuleName
@@ -205,11 +197,6 @@ class KonanConfig(val project: Project, val configuration: CompilerConfiguration
val shortModuleName: String?
get() = configuration.get(KonanConfigKeys.SHORT_MODULE_NAME)
val infoArgsOnly = configuration.kotlinSourceRoots.isEmpty()
&& configuration[KonanConfigKeys.INCLUDED_LIBRARIES].isNullOrEmpty()
&& librariesToCache.isEmpty()
&& configuration[KonanConfigKeys.EXPORTED_LIBRARIES].isNullOrEmpty()
fun librariesWithDependencies(moduleDescriptor: ModuleDescriptor?): List<KonanLibrary> {
if (moduleDescriptor == null) error("purgeUnneeded() only works correctly after resolve is over, and we have successfully marked package files as needed or not needed.")
return resolvedLibraries.filterRoots { (!it.isDefault && !this.purgeUserLibs) || it.isNeededForLink }.getFullList(TopologicalLibraryOrder).cast()
@@ -217,20 +204,27 @@ class KonanConfig(val project: Project, val configuration: CompilerConfiguration
val shouldCoverSources = configuration.getBoolean(KonanConfigKeys.COVERAGE)
private val shouldCoverLibraries = !configuration.getList(KonanConfigKeys.LIBRARIES_TO_COVER).isNullOrEmpty()
val allocationMode by lazy {
when (configuration.get(KonanConfigKeys.ALLOCATION_MODE)) {
null -> when {
memoryModel == MemoryModel.EXPERIMENTAL && target.supportsMimallocAllocator() -> AllocationMode.MIMALLOC
else -> AllocationMode.STD
}
AllocationMode.STD -> AllocationMode.STD
AllocationMode.MIMALLOC -> {
if (target.supportsMimallocAllocator()) {
AllocationMode.MIMALLOC
} else {
configuration.report(CompilerMessageSeverity.STRONG_WARNING,
"Mimalloc allocator isn't supported on target ${target.name}. Used standard mode.")
AllocationMode.STD
}
}
}
}
internal val runtimeNativeLibraries: List<String> = mutableListOf<String>().apply {
if (debug) add("debug.bc")
val useMimalloc = if (configuration.get(KonanConfigKeys.ALLOCATION_MODE) == "mimalloc") {
if (target.supportsMimallocAllocator()) {
true
} else {
configuration.report(CompilerMessageSeverity.STRONG_WARNING,
"Mimalloc allocator isn't supported on target ${target.name}. Used standard mode.")
false
}
} else {
false
}
when (memoryModel) {
MemoryModel.STRICT -> {
add("strict.bc")
@@ -264,11 +258,14 @@ class KonanConfig(val project: Project, val configuration: CompilerConfiguration
add("source_info_libbacktrace.bc")
add("libbacktrace.bc")
}
if (useMimalloc) {
add("opt_alloc.bc")
add("mimalloc.bc")
} else {
add("std_alloc.bc")
when (allocationMode) {
AllocationMode.MIMALLOC -> {
add("opt_alloc.bc")
add("mimalloc.bc")
}
AllocationMode.STD -> {
add("std_alloc.bc")
}
}
}.map {
File(distribution.defaultNatives(target)).child(it).absolutePath
@@ -302,7 +299,11 @@ class KonanConfig(val project: Project, val configuration: CompilerConfiguration
internal val isInteropStubs: Boolean get() = manifestProperties?.getProperty("interop") == "true"
internal val propertyLazyInitialization: Boolean get() = configuration.get(KonanConfigKeys.PROPERTY_LAZY_INITIALIZATION)!!
internal val propertyLazyInitialization: Boolean get() = configuration.get(KonanConfigKeys.PROPERTY_LAZY_INITIALIZATION) ?:
when (memoryModel) {
MemoryModel.EXPERIMENTAL -> true
else -> false
}
internal val lazyIrForCaches: Boolean get() = configuration.get(KonanConfigKeys.LAZY_IR_FOR_CACHES)!!
@@ -321,6 +322,39 @@ class KonanConfig(val project: Project, val configuration: CompilerConfiguration
get() = configuration.get(BinaryOptions.unitSuspendFunctionObjCExport) ?: UnitSuspendFunctionObjCExport.DEFAULT
internal val testDumpFile: File? = configuration[KonanConfigKeys.TEST_DUMP_OUTPUT_PATH]?.let(::File)
internal val cacheSupport = CacheSupport(
configuration = configuration,
resolvedLibraries = resolvedLibraries,
memoryModel = memoryModel,
optimizationsEnabled = optimizationsEnabled,
propertyLazyInitialization = propertyLazyInitialization,
target = target,
produce = produce
)
internal val cachedLibraries: CachedLibraries
get() = cacheSupport.cachedLibraries
internal val librariesToCache: Set<KotlinLibrary>
get() = cacheSupport.librariesToCache
val outputFiles =
OutputFiles(configuration.get(KonanConfigKeys.OUTPUT) ?: cacheSupport.tryGetImplicitOutput(),
target, produce)
val tempFiles = TempFiles(outputFiles.outputName, configuration.get(KonanConfigKeys.TEMPORARY_FILES_DIR))
val outputFile get() = outputFiles.mainFile
private val implicitModuleName: String
get() = File(outputFiles.outputName).name
val infoArgsOnly = configuration.kotlinSourceRoots.isEmpty()
&& configuration[KonanConfigKeys.INCLUDED_LIBRARIES].isNullOrEmpty()
&& librariesToCache.isEmpty()
&& configuration[KonanConfigKeys.EXPORTED_LIBRARIES].isNullOrEmpty()
}
fun CompilerConfiguration.report(priority: CompilerMessageSeverity, message: String)
@@ -96,7 +96,7 @@ class KonanConfigKeys {
= CompilerConfigurationKey.create("program or library name")
val OVERRIDE_CLANG_OPTIONS: CompilerConfigurationKey<List<String>>
= CompilerConfigurationKey.create("arguments for clang")
val ALLOCATION_MODE: CompilerConfigurationKey<String>
val ALLOCATION_MODE: CompilerConfigurationKey<AllocationMode>
= CompilerConfigurationKey.create("allocation mode")
val EXPORT_KDOC: CompilerConfigurationKey<Boolean>
= CompilerConfigurationKey.create("export KDoc into klib and framework")
@@ -160,8 +160,7 @@ internal class Linker(val context: Context) {
}
val needsProfileLibrary = context.coverage.enabled
val mimallocEnabled = config.get(KonanConfigKeys.ALLOCATION_MODE) == "mimalloc" &&
target.supportsMimallocAllocator()
val mimallocEnabled = context.config.allocationMode == AllocationMode.MIMALLOC
val linkerInput = determineLinkerInput(objectFiles, linkerOutput)
try {
@@ -624,28 +624,28 @@ internal fun PhaseConfig.konanPhasesConfig(config: KonanConfig) {
disableUnless(linkBitcodeDependenciesPhase, config.produce.involvesLinkStage)
disableUnless(checkExternalCallsPhase, getBoolean(KonanConfigKeys.CHECK_EXTERNAL_CALLS))
disableUnless(rewriteExternalCallsCheckerGlobals, getBoolean(KonanConfigKeys.CHECK_EXTERNAL_CALLS))
disableUnless(optimizeTLSDataLoadsPhase, getBoolean(KonanConfigKeys.OPTIMIZATION))
disableUnless(optimizeTLSDataLoadsPhase, config.optimizationsEnabled)
disableUnless(objectFilesPhase, config.produce.involvesLinkStage)
disableUnless(linkerPhase, config.produce.involvesLinkStage)
disableIf(testProcessorPhase, getNotNull(KonanConfigKeys.GENERATE_TEST_RUNNER) == TestRunnerKind.NONE)
disableIf(dumpTestsPhase, getNotNull(KonanConfigKeys.GENERATE_TEST_RUNNER) == TestRunnerKind.NONE || config.testDumpFile == null)
disableUnless(buildDFGPhase, getBoolean(KonanConfigKeys.OPTIMIZATION))
disableUnless(devirtualizationAnalysisPhase, getBoolean(KonanConfigKeys.OPTIMIZATION))
disableUnless(devirtualizationPhase, getBoolean(KonanConfigKeys.OPTIMIZATION))
disableUnless(escapeAnalysisPhase, getBoolean(KonanConfigKeys.OPTIMIZATION))
disableUnless(buildDFGPhase, config.optimizationsEnabled)
disableUnless(devirtualizationAnalysisPhase, config.optimizationsEnabled)
disableUnless(devirtualizationPhase, config.optimizationsEnabled)
disableUnless(escapeAnalysisPhase, config.optimizationsEnabled)
// Inline accessors only in optimized builds due to separate compilation and possibility to get broken
// debug information.
disableUnless(propertyAccessorInlinePhase, getBoolean(KonanConfigKeys.OPTIMIZATION))
disableUnless(inlineClassPropertyAccessorsPhase, getBoolean(KonanConfigKeys.OPTIMIZATION))
disableUnless(dcePhase, getBoolean(KonanConfigKeys.OPTIMIZATION))
disableUnless(removeRedundantCallsToFileInitializersPhase, getBoolean(KonanConfigKeys.OPTIMIZATION))
disableUnless(ghaPhase, getBoolean(KonanConfigKeys.OPTIMIZATION))
disableUnless(propertyAccessorInlinePhase, config.optimizationsEnabled)
disableUnless(inlineClassPropertyAccessorsPhase, config.optimizationsEnabled)
disableUnless(dcePhase, config.optimizationsEnabled)
disableUnless(removeRedundantCallsToFileInitializersPhase, config.optimizationsEnabled)
disableUnless(ghaPhase, config.optimizationsEnabled)
disableUnless(verifyBitcodePhase, config.needCompilerVerification || getBoolean(KonanConfigKeys.VERIFY_BITCODE))
disableUnless(fileInitializersPhase, getBoolean(KonanConfigKeys.PROPERTY_LAZY_INITIALIZATION))
disableUnless(removeRedundantCallsToFileInitializersPhase, getBoolean(KonanConfigKeys.PROPERTY_LAZY_INITIALIZATION))
disableUnless(fileInitializersPhase, config.propertyLazyInitialization)
disableUnless(removeRedundantCallsToFileInitializersPhase, config.propertyLazyInitialization)
disableUnless(removeRedundantSafepointsPhase, config.configuration.get(BinaryOptions.memoryModel) == MemoryModel.EXPERIMENTAL)
disableUnless(removeRedundantSafepointsPhase, config.memoryModel == MemoryModel.EXPERIMENTAL)
val isDescriptorsOnlyLibrary = config.metadataKlib == true
disableIf(psiToIrPhase, isDescriptorsOnlyLibrary)
@@ -93,7 +93,9 @@ tasks.withType(KonanCompileNativeBinary.class).configureEach {
enableTwoStageCompilation = twoStageEnabled
}
ext.isExperimentalMM = project.globalTestArgs.contains("-memory-model") && project.globalTestArgs.contains("experimental")
ext.isExperimentalMM = !(project.globalTestArgs.contains("-memory-model") &&
project.globalTestArgs.contains("strict")) &&
project.testTarget != 'wasm32'
ext.isNoopGC = project.globalTestArgs.contains("-Xgc=noop")
// TODO: It also makes sense to test -g without asserts, and also to test -opt with asserts.
@@ -5568,7 +5570,8 @@ if (isAppleTarget(project)) {
}
frameworkTest("testMultipleFrameworksStatic") {
if (cacheTesting != null && !isExperimentalMM) {
// this test doesn't work with caches. For now caches are enabled only if isExperimentalMM is true, even if cacheTesting is passed
if (cacheTesting != null && isExperimentalMM) {
// See https://youtrack.jetbrains.com/issue/KT-34261.
expectedExitStatus = 134
}
@@ -178,10 +178,10 @@ class LldbTests {
val application = swiftc("application", swiftSrc, "-F", root.toString())
"""
> b kfun:#b(){}kotlin.String
Breakpoint 1: where = [..]`kfun:#b(){}kotlin.String [..] at b.kt:1:12, [..]
Breakpoint 1: where = [..]`kfun:#b(){}kotlin.String [..] at b.kt:1:1, [..]
> b kfun:#a(){}kotlin.String
Breakpoint 2: where = [..]`kfun:#a(){}kotlin.String [..] at a.kt:1:12, [..]
Breakpoint 2: where = [..]`kfun:#a(){}kotlin.String [..] at a.kt:1:1, [..]
> q
""".trimIndent().lldb(application)
}
@@ -3,11 +3,11 @@
set "ALL_PARAMS=%konanCompilerArgs%"
set "MEMORY_MODEL=%1"
if "%MEMORY_MODEL%" == "experimental" (
if "%MEMORY_MODEL%" == "legacy" (
if "%ALL_PARAMS%" == "" (
set "ALL_PARAMS=-memory-model experimental"
set "ALL_PARAMS=-memory-model strict"
) ELSE (
set "ALL_PARAMS=-memory-model experimental %ALL_PARAMS%"
set "ALL_PARAMS=-memory-model strict %ALL_PARAMS%"
)
)
if not "%ALL_PARAMS%" == "" (
@@ -3,11 +3,11 @@
ALL_PARAMS="$konanCompilerArgs"
MEMORY_MODEL=$1
if [ $MEMORY_MODEL = "experimental" ]; then
if [ $MEMORY_MODEL = "legacy" ]; then
if [ "$ALL_PARAMS" != "" ]; then
ALL_PARAMS=" $ALL_PARAMS"
fi
ALL_PARAMS="-memory-model experimental$ALL_PARAMS"
ALL_PARAMS="-memory-model strict$ALL_PARAMS"
fi
if [ "$ALL_PARAMS" != "" ]; then
ALL_PARAMS="-PcompilerArgs=$ALL_PARAMS"
@@ -173,7 +173,7 @@ private object NativeTestSupport {
)
private fun computeMemoryModel(enforcedProperties: EnforcedProperties): MemoryModel =
ClassLevelProperty.MEMORY_MODEL.readValue(enforcedProperties, MemoryModel.values(), default = MemoryModel.DEFAULT)
ClassLevelProperty.MEMORY_MODEL.readValue(enforcedProperties, MemoryModel.values(), default = MemoryModel.EXPERIMENTAL)
private fun computeThreadStateChecker(enforcedProperties: EnforcedProperties): ThreadStateChecker {
val useThreadStateChecker =
@@ -89,7 +89,11 @@ internal enum class OptimizationMode(private val description: String, val compil
* The Kotlin/Native memory model.
*/
internal enum class MemoryModel(val compilerFlags: List<String>?) {
DEFAULT(null),
/**
* TODO: rename DEFAULT to LEGACY. It was postponed, as it would require simultaneous change in teamcity configuration
* but it should be done at some point.
*/
DEFAULT(listOf("-memory-model", "strict")),
EXPERIMENTAL(listOf("-memory-model", "experimental"));
override fun toString() = compilerFlags?.joinToString(prefix = "(", separator = " ", postfix = ")").orEmpty()