Введение

В начале этого года я провел небольшое исследование на тему измерения производительности sequences. Точнее я хотел бы, чтобы оно было небольшим, но в результате оно заняло 3-4 месяца моей активной работы.

В рамках этого исследования я измерял скорость преобразования коллекций через sequences в сравнении с обычными методами преобразования коллекций. Во время исследования, я обратил внимание на то, что некоторые функции sequences работают медленнее, чем могли бы.

Я предложил способы оптимизации этих функций и JetBrains приняли мои предложения и уже включили их в kotlin 2.0 (Issue в kotlin с моими оптимизациями)

Я подробно рассказываю о принципах работы каждой функции sequences в своей статье Измеряя sequences.

Здесь же, я хочу рассказать именно об оптимизациях и показать, как небольшие изменения в коде могут ускорять функции на 15-20%. Показать, насколько важно знать нюансы генерации kotlin в byte-code и как это влияет на скорость работы функций.

Оптимизация distinct

Я обратил внимание, что реализация функции distinct алгоритмически очень похожа на функцию filter. При этом функция distinct имеет существенный проигрыш по сравнению с коллекциями -15%, а функция filter выигрывает у коллекций примерно 3%-5% .

Результаты измерений filter

Размер списка

Collection (ns)

Sequence (ns)

%

100

42 828

43 557

-2%

1 000

488 092

479 876

2%

10 000

5 014 653

4 862 738

3%

50 000

25 080 389

24 081 423

4%

100 000

50 261 272

48 016 813

4%

Результаты измерения distinct

Размер списка

Collection (ns)

Sequence (ns)

%

100

83 113

100 762

-21%

1 000

1 005 813

1 169 785

-16%

10 000

10 851 783

12 856 475

-18%

50 000

60 652 896

69 893 855

-15%

100 000

129 569 604

146 594 000

-13%

Мне показалось это странным и я решил изучить код обеих функций, чтобы понять, за счет чего возникают такие потери в функции distinct.

Реализация distinct

Декоратор для distinct устроен довольно просто. Внутри он создает HashSet observed и накапливает в нем все возвращаемые элементы. Таким образом, если элемент уже есть в observed, то это значит, что он уже возвращался и дальше он будет пропускаться.

private class DistinctIterator<T, K>(
    private val source: Iterator<T>, 
    private val keySelector: (T) -> K
) : AbstractIterator<T>() {
    private val observed = HashSet<K>()

    override fun computeNext() {
        while (source.hasNext()) {
            val next = source.next()
            val key = keySelector(next)

            if (observed.add(key)) {
                setNext(next)
                return
            }
        }

        done()
    }
}

Если мы посмотрим реализацию кода коллекций, то мы увидим в коллекциях практически идентичный код. По сути, он делает тоже самое. Создает HashSet и накапливает в нем возвращенные элементы.

public inline fun <T, K> Iterable<T>.distinctBy(
    selector: (T) -> K
): List<T> {
    val set = HashSet<K>()
    val list = ArrayList<T>()
    for (e in this) {
        val key = selector(e)
        if (set.add(key))
            list.add(e)
    }
    return list
}

Таким образом понятно, что если реализация sequences и коллекций в целом совпадает, то потери где то в другом месте. Если мы посмотрим на объявление класса в sequences, то мы увидим какой то абстрактный класс AbstractIterator.

private class DistinctIterator<T, K>(
    private val source: Iterator<T>, 
    private val keySelector: (T) -> K
) : AbstractIterator<T>() {

  // ....
}

Именно в нем находится реализация всех базовых методов distinct декоратора. И если следовать логике, то и все наши потери должны находится в этом абстрактном классе.

Реализация класса AbstractIterator

По сути этот класс хранит в переменной state текущее состояние, вычислялся ли следующий элемент итератора или нет. Если он еще не вычислялся, то он вызывает функцию tryToComputeNext(), вычисляющую следующий элемент.

private enum class State {
    Ready,
    NotReady,
    Done,
    Failed
}

public abstract class AbstractIterator<T> : Iterator<T> {
    private var state = State.NotReady
    private var nextValue: T? = null

    override fun hasNext(): Boolean {
        require(state != State.Failed)
        return when (state) {
            State.Done -> false
            State.Ready -> true
            else -> tryToComputeNext()
        }
    }

    override fun next(): T {
        if (!hasNext()) throw NoSuchElementException()
        state = State.NotReady
        @Suppress("UNCHECKED_CAST")
        return nextValue as T
    }

    private fun tryToComputeNext(): Boolean {
        state = State.Failed
        computeNext()
        return state == State.Ready
    }

    /**
     * Computes the next item in the iterator.
     *
     * This callback method should call one of these two methods:
     *
     * * [setNext] with the next value of the iteration
     * * [done] to indicate there are no more elements
     *
     * Failure to call either method will result in the iteration terminating with a failed state
     */
    abstract protected fun computeNext(): Unit

    /**
     * Sets the next value in the iteration, called from the [computeNext] function
     */
    protected fun setNext(value: T): Unit {
        nextValue = value
        state = State.Ready
    }

    /**
     * Sets the state to done so that the iteration terminates.
     */
    protected fun done() {
        state = State.Done
    }
}

Кажется, что код написан очень грамотно и на первый взгляд проблем здесь быть не должно. Но где же они тогда?

Декоратор sequences для функции fliter реализован очень похожим образом, однако, он не дает такого проигрыша по сравнению с коллекциями. Я начал сравнивать код двух декораторов и искать существенные отличия.

Реализации функции filter

Как видите, принцип реализации фильтра очень похож. Тут тоже есть состояние вычисления следующего элемента и оно также хранится в поле nextState

Также есть метод вычисления следующего элемента calcNext(), который является полной аналогией метода tryToComputeNext() в функции distinct

internal class FilteringSequence<T>(
    private val sequence: Sequence<T>,
    private val sendWhen: Boolean = true,
    private val predicate: (T) -> Boolean
) : Sequence<T> {

    override fun iterator(): Iterator<T> = object : Iterator<T> {
        val iterator = sequence.iterator()
        // -1 for unknown, 0 for done, 1 for continue
        var nextState: Int = -1 
        var nextItem: T? = null

        private fun calcNext() {
            while (iterator.hasNext()) {
                val item = iterator.next()
                if (predicate(item) == sendWhen) {
                    nextItem = item
                    nextState = 1
                    return
                }
            }
            nextState = 0
        }

        override fun next(): T {
            if (nextState == -1)
                calcNext()
            if (nextState == 0)
                throw NoSuchElementException()
            val result = nextItem
            nextItem = null
            nextState = -1
            @Suppress("UNCHECKED_CAST")
            return result as T
        }

        override fun hasNext(): Boolean {
            if (nextState == -1)
                calcNext()
            return nextState == 1
        }
    }
}

Кажется, что за исключением мелких деталей реализации, эти две функции очень похожи по принципам своей работы.

Так где же потери функции distinct?

Сначала, в силу своей некомпетенции, я подозревал абстрактный класс и вызовы виртуальных методов. Но нет, если убрать абстрактный класс и перенести все в один общий класс, то функция не начинает работать быстрее.

Единственное, чем еще существенно отличаются эти две функции - это Enum.

В случае filter мы храним состояние в виде обычного числа Int

        // -1 for unknown, 0 for done, 1 for continue
        var nextState: Int = -1 

А в случае distinct мы используем для этого типизированный Enum

private enum class State {
    Ready,
    NotReady,
    Done,
    Failed
}

    private var state = State.NotReady

Но не может же использование Enum класса давать 15% потери производительности.

Но других вариантов из анализа кода я больше не видел, поэтому решил спуститься на уровень ниже и перейти к анализу byte-code обеих функций.

Если мы посмотрим byte-code проверки переменной nextState для функции filter, то не увидим там ничего лишнего. Просто загрузка переменных в стек, проверка их значения и передача управления на вызов соответствующего блока кода.

Но если посмотреть byte-code проверки переменной state в функции distinct, то мы увидим нечто интересное.

Это код проверки стейта в функции distinct

        return when (state) {
            State.Done -> false
            State.Ready -> true
            else -> tryToComputeNext()
        }

А это byte-code, который ей соответсвует

#1  private final hasNext()Z
#2   L0
#3    LINENUMBER 20 L0
#4    ALOAD 0
#5    GETFIELD AbstractIterator.state : Lcom/AbstractIterator$State;
#6    GETSTATIC AbstractIterator$WhenMappings.$EnumSwitchMapping$0 : [I
#7    SWAP
#8    INVOKEVIRTUAL AbstractIterator$State.ordinal ()I
#9    IALOAD
#10   L1
#11    TABLESWITCH
#12      1: L2
#13      2: L3
#14      default: L4
#15   L2

Не думаю что многие умеют читать byte-code (по крайней мере я не умел), поэтому подробно прокомментирую что здесь происходит буквально построчно.

#5 - загружает в локальный стек указатель на переменную state
#6 - загружает в локальный стек массив ordinal значений enum класса
#7 - меняет две этих переменные местами в локальном стеке
#8 - получает ordinal значение переменной state
#9 - IALOAD ищет в массиве ordinal значений enum класса индекс ordinal значения переменной state
#11 - найденный индекс передается на вход оператору TABLESWITCH и дальше происходит переход по нужной ветке кода.

В итоге получается, что при использовании оператора when в сочетании с enum, мы получаем при каждом сравнении лишнюю загрузку массива всех значений enum в локальный стек.

При этом, если мы перепишем сравнение через ordinal значения, то можем избежать этой ненужной и затратной операции

        return when (state.ordinal) {
            State.Done.ordinal -> false
            State.Ready.ordinal -> true
            else -> tryToComputeNext()
        }

По сути такая проверка значения enum через поиск в массиве ordinal значений enum класса может быть актуальна только в одном случае. Когда код проверки значения может быть скомпилирован отдельно от кода объявления enum класса. Кажется что в случае с android это невозможно.

Чуть позже, после того, как я докопался до сути проблемы, я нашел старую статью Jake Wharton, в которой он описывает похожую проблему

Jake Wharton - R8 Optimization: Enum Switch Maps, 2019

В этой статье он обещал исправить это еще в 2019 году, но видимо не успел.

Результаты оптимизации distinct

Я попробовал переписать функцию distinct с учетом нюансов обработки enum. Я убрал использование enum и использую для хранения состояния обычные Int константы.

В результате это ускорило работу функции примерно на 10%-15%, что на мой взгляд довольно существенно

Результаты замеров оригинальной и оптимизированной функции distinct

Размер списка

Collection (ns)

Original Sequence (ns)

Optimized Sequence (ns)

Ускорение

100

83 113

100 762

80 538

18%

1 000

1 005 813

1 169 785

984 303

16%

10 000

10 851 783

12 856 475

11 111 379

17%

50 000

60 652 896

69 893 855

61 164 896

14%

100 000

129 569 604

146 594 000

129 624 667

12%

Полный код оптимизированной фунции distinct
private class OptimizedDistinctIterator<T, K>(
    private val source: Iterator<T>, 
    private val keySelector: (T) -> K
) : Iterator<T>{
    private val observed = HashSet<K>()
    // { UNDEFINED_STATE, HAS_NEXT_ITEM, HAS_FINISHED }
    private var nextState: Int = UNDEFINED_STATE
    private var nextItem: T? = null

    override fun hasNext(): Boolean {
        if (nextState == UNDEFINED_STATE)
            calcNext()
        return nextState == HAS_NEXT_ITEM
    }

    override fun next(): T {
        if (nextState == UNDEFINED_STATE)
            calcNext()
        if (nextState == HAS_FINISHED)
            throw NoSuchElementException()
        nextState = UNDEFINED_STATE
        return nextItem as T
    }

    private fun calcNext() {
        while (source.hasNext()) {
            val next = source.next()
            val key = keySelector(next)

            if (observed.add(key)) {
                nextItem = next
                nextState = HAS_NEXT_ITEM // found next item
                return
            }
        }
        nextState = HAS_FINISHED // end of iterator
    }
}

Оптимизация flatten

Когда я проводил свое исследование, то обнаружил, что функция flatten проигрывает коллекциям почти в 2 раза. Эта функция позволяет развернуть список списков в один большой, линейный список. Это довольно часто используется в различных преобразованиях данных.

Результаты измерений flatten

Размер списка

Collection (ns)

Sequence (ns)

%

100

147 451

414 376

-181%

1 000

1 512 504

4 167 416

-176%

10 000

15 345 992

41 305 365

-169%

50 000

75 917 917

205 262 897

-170%

100 000

142 455 042

401 473 313

-182%

Проигрыш в два раза, это слишком много.

По сути это ставит крест на использовании sequences для преобразования коллекций, если в нашем преобразовании встречается хотя бы одно преобразование через flatten

Что еще обидней, flatten является базовым декоратором в sequences и на его основе сделаны также и другие функции. Например функция plus

public operator fun <T> Sequence<T>.plus(elements: Iterable<T>): Sequence<T> {
    return sequenceOf(this, elements.asSequence()).flatten()
}

Мне захотелось это исправить и вдохновленный успехом с оптимизацией distinct, я решил попробовать оптимизировать и flatten

Реализация flatten

Декоратор для flatten довольно сложен для понимания. В целом принцип его работы следующий:

На каждый вызов метода hasNext(), вызывается функция ensureItemIterator(), которая вычисляет итератор вложенного списка для текущего элемента и записывает его значение в поле itemIterator.

При вызове метода next() он просто вызывает itemIterator.next() и таким образом последовательно перебираются все элементы и список списков разворачивается в линейный список.

internal class FlatteningSequence<T, R, E>
constructor(
    private val sequence: Sequence<T>,
    private val transformer: (T) -> R,
    private val iterator: (R) -> Iterator<E>
) : Sequence<E> {
  
    override fun iterator(): Iterator<E> = object : Iterator<E> {
        val iterator = sequence.iterator()
        var itemIterator: Iterator<E>? = null

        override fun next(): E {
            if (!ensureItemIterator())
                throw NoSuchElementException()
            return itemIterator!!.next()
        }

        override fun hasNext(): Boolean {
            return ensureItemIterator()
        }

        private fun ensureItemIterator(): Boolean {
            if (itemIterator?.hasNext() == false)
                itemIterator = null

            while (itemIterator == null) {
                if (!iterator.hasNext()) {
                    return false
                } else {
                    val element = iterator.next()
                    val nextItemIterator = iterator(transformer(element))
                    if (nextItemIterator.hasNext()) {
                        itemIterator = nextItemIterator
                        return true
                    }
                }
            }
            return true
        }
    }
}

На первый взгляд, класс написан сложно, но если присмотреться, то понимаешь, что он работает достаточно оптимально и зацепится особо не за что.

Но потом я обратил внимание на то, что поле itemIterator объявлено как nullable тип

    override fun iterator(): Iterator<E> = object : Iterator<E> {
        val iterator = sequence.iterator()
        var itemIterator: Iterator<E>? = null

А это значит, что при обращении к этому полю, kotlin каждый раз будет добавлять проверку на то, что значение поля не Null. А это дополнительная операция чтения.

Я пометил в коде звездочками все такие места, где мы имеем лишнюю проверку на Null значение.

        override fun next(): E {
            if (!ensureItemIterator())
                throw NoSuchElementException()
**          return itemIterator!!.next()
        }

        private fun ensureItemIterator(): Boolean {
**          if (itemIterator?.hasNext() == false)
                itemIterator = null

Я устранил этот недочет через создание синглтона EmptyIterator, который реализует логику, которая должна быть у ItemIterator в случае Null значения.

// Empty iterator for cause when we haven't next element
private object EmptyIterator: Iterator<Nothing> {
    override fun hasNext(): Boolean = false
    override fun next(): Nothing = throw NoSuchElementException()
}

Это позволило мне убрать все проверки на Null при обращении к полю itemIterator

    override fun iterator(): Iterator<E> = object : Iterator<E> {
        val iterator = sequence.iterator()
        var itemIterator: Iterator<E> = EmptyIterator

        override fun next(): E {
            if (!ensureItemIterator())
                throw NoSuchElementException()
          return itemIterator.next() // было itemIterator!!.next()
        }

        private fun ensureItemIterator(): Boolean {
          if (itemIterator.hasNext() == false) // было itemIterator?.hasNext()
                itemIterator = null
      

Затем я обратил внимание на то, что при каждом вызове next() и hasNext() мы всегда безусловно вызываем достаточно сложную функцию ensureItemIterator()

        override fun next(): E {
            if (!ensureItemIterator())
                throw NoSuchElementException()
            return itemIterator!!.next()
        }

        override fun hasNext(): Boolean {
            return ensureItemIterator()
        }

Так как с итераторами мы всегда работаем парой методов hasNext() + next(), то кажется расточительным вызывать два раза функцию ensureItemIterator() для обработки каждого элемента. Ее результат вполне можно закешировать в поле, хранящем состояние ее вычисления.

Я ввел поле state и запоминаю его при каждом вычислении ensureItemIterator(), а сбрасываю при вызове метода next(), когда мы переходим к следующему элементу. Таким образом функция ensureItemIterator() вызывается теперь только один раз на пару вызовов hasNext() + next()

Все это позволило мне значительно оптимизировать время выполнения функции flatten.

Результаты оптимизации flatten

Отказ от nullable переменной и введение состояния вычисления для ensureItemIterator существенно ускорило работу функции. Выигрыш 35%-37%, что на мой взгляд довольно существенно.

Результаты замеров оригинальной и оптимизированной функции flatten

Размер списка

Collection (ns)

Original Sequence (ns)

Optimized Sequence (ns)

Ускорение

100

147 451

414 376

348 026

42%

1 000

1 512 504

4 167 416

3 568 417

38%

10 000

15 345 992

41 305 365

35 307 083

37%

50 000

75 917 917

205 262 897

175 828 229

36%

100 000

142 455 042

401 473 313

369 176 459

36%

Полный код оптимизированной функции flatten
// Empty iterator for cause when we haven't next element
private object EmptyIterator: Iterator<Nothing> {
    override fun hasNext(): Boolean = false
    override fun next(): Nothing = throw NoSuchElementException()
}

internal class FlatteningSequence<T, R, E>
constructor(
    private val sequence: Sequence<T>,
    private val transformer: (T) -> R,
    private val iterator: (R) -> Iterator<E>
) : Sequence<E> {

    override fun iterator(): Iterator<E> = object : Iterator<E> {
        private val iterator = sequence.iterator()
        private var itemIterator: Iterator<E> = EmptyIterator 
        
        // { UNDEFINED_STATE, HAS_NEXT_ITEM, HAS_FINISHED }
        private var state: Int = UNDEFINED_STATE 

        override fun next(): E {
            if (state == UNDEFINED_STATE) { 
                ensureItemIterator()
            }
            state = UNDEFINED_STATE
            return itemIterator.next()
        }

        override fun hasNext(): Boolean {
            return when (state) { 
                HAS_NEXT_ITEM -> true
                HAS_FINISHED -> false
                else -> ensureItemIterator()
            }
        }

        private fun ensureItemIterator(): Boolean {
            if (itemIterator.hasNext()) {
                state = HAS_NEXT_ITEM
                return true
            } else {
                while (iterator.hasNext()) {
                    val nextItemIterator = iterator(transformer(iterator.next()))
                    if (nextItemIterator.hasNext()) {
                        itemIterator = nextItemIterator
                        state = HAS_NEXT_ITEM
                        return true
                    }
                }
                state = HAS_FINISHED
                itemIterator = EmptyIterator
                return false
            }
        }
    }
}

Отправка оптимизаций в JetBrains

Сначала я боялся, что передача моих предложений по оптимизации sequences будет сложным и мучительным процессом. Все таки JetBrains - это глобальная компания и мне было сложно представить, что они могут заинтересоваться моими предложениями. Но все оказалось намного проще, чем я думал.

В JetBrains есть открытый youtrack, в котором кто угодно может создать свою Issue и описать проблему или предложение.

Довольно долго моя issue с предложениями по оптимизации висела без движения. Ее почти сразу разметили нужными тегами и отнесли к библиотекам kotlin. А затем, в течение нескольких месяцев, по ней не было никаких изменений.

Но спустя 3 или 4 месяца, до нее все таки добрался разработчик и буквально через неделю она уже была влита в основную ветку kotlin 2.0.

И теперь частичка меня, мои идеи, есть в kotlin. И от осознания этого меня "прет" до сих пор.

Если ты обнаружил проблему или видишь какое то хорошее решение, то не надо боятся создавать Issue в глобальные продукты.

Эти продукты делают такие же люди как ты и вполне возможно, что именно тебе пришла в голову гениальная идея, которая поможет сделать продукт лучше.

Комментарии (13)


  1. FirsofMaxim
    25.10.2023 07:05

    Макс, спасибо за цикл статей. Забавно, что это стандартная библиотека Kotlin и казалось бы все должно быть заоптимизировано...


    1. MaxSidorov Автор
      25.10.2023 07:05
      +1

      Да, удивительное рядом). Меня больше поразило, что Джек Вортон обнаружил это еще в 2019 году и они так и не поправили это для связки Enum + When


      1. kirich1409
        25.10.2023 07:05

        Я знаю что у ProGuard/R8 есть замена простых Enum на Int, но вот не знал что это не работает со switch


  1. ValeryIvanov
    25.10.2023 07:05

    Смотрю я на все эти ухищрения, для того, чтобы итераторы нормально работали и меня охватывает тоска. Вот поддерживала бы jvm генераторы или разработчики kotlin реализовали бы их сами на уровне генерации байткода(не знаю, насколько это реализуемо, но компилятор C# именно так и делает), столько лишней работы можно было бы избежать. Например, реализация flatten стала бы настолько простой, что даже как-то смешно.

    fun <T> Iterable<Iterable<T>>.flatten(): Iterable<T>{
        for(iterable in this){
            for(item in iterable){
                yield item
            }
        }
    }
    

    И производительность при этом проседать не должна, так как по сути при генерации получился бы такой же код, как и в ручной реализации, а при наличии поддержки yield со стороны JVM, ещё бы и прибавку в скорости могли получить.


    1. MaxSidorov Автор
      25.10.2023 07:05
      -3

      Ты не совсем верно уловил суть sequences. Это ленивая конструкция, а в твоем примере ты просто перебираешь все элементы подряд.
      Код твоего цикла for на самом деле в Java выглядит вот так и он безусловно переберет все элементы за раз.

      while (iterable.hasItem()) { iterable.next() }

      А в случае sequences вызов каждой итерации по циклу запускается внешним кодом. Сам цикл ленивый и он не перебирает элементы. И все это обернуто в удобные конструкции так, что ты даже не замечаешь, что работаешь теперь не с коллекциями, а с последовательностями.

      В случае преобразования коллекций использование sequences дает некоторый выигрыш по скорости. Но основная задача sequences не в этом, а в возможности работать с огромными структурами данных, не загружая их целиком в память.

      Например ты можешь через sequences лениво читать файл в 20ГБ по одной строке, при этом снаружи этот код будет выглядеть также, как будто ты работаешь с обычной коллекцией и загрузил весь файл в память. При этом в памяти у тебя всегда будет только одна строка из огромного файла.

      csfFile.useLines
          .drop(1)
          .map { line -> line.toRecord() }
          .filter { record -> record.hasAccount }
          .forEach { record -> repository.writeRecord(record) }


      1. mayorovp
        25.10.2023 07:05
        +3

        Вообще-то, оператор yield как раз ленивый, и "запускается" внешним кодом


        1. MaxSidorov Автор
          25.10.2023 07:05

          тогда сорри, значит это я неправильно понял) Уже очень давно не писал на C#
          Интересная концепция, посмотрю на досуге ее.


    1. Gromilo
      25.10.2023 07:05

      На самом деле в шарпах тоже полно магии. Линку изнутри сделан на кастомных стейтмашинах, которые мутируют друг в друга, если возможно.

      Вот, например, WhereSelectArrayIterator.


      1. ValeryIvanov
        25.10.2023 07:05
        +2

        Понятно, что в стандартной библиотеки нужно использовать самые оптимальные по памяти и скорости алгоритмы, идти на любые ухищрения, лишь бы всё работало быстро и эффективно. Но читать код всех этих стейтмашин, от этого проще не становится.


  1. AsuraPro
    25.10.2023 07:05
    +1

    Супер, круто сделал!

    Единственное при сравнении алгоритмов также следует учитывать разницу в используемой памяти


    1. MaxSidorov Автор
      25.10.2023 07:05

      Согласен, на самом деле я смотрел ее, но по инстансам они равны. В коллекциях на каждое преобразование создается инстанс коллекции, а в сиквенсах инстанс декоратора.

      Выигрыш по памяти для сиквенсов очевиден, так как в коллекциях постоянно выделяется и освобождается массив + копирование элементов. И именно за счет исключения этого сиквенсы и выигрывают.

      Но вы правы, я не копал глубоко эту тему. Просто поверхностно посмотрел профайлером.


  1. brake
    25.10.2023 07:05

    Спасибо за статью! Печально, что на Хабре теперь приходится такие статьи прям выискивать среди «шума».


    1. MaxSidorov Автор
      25.10.2023 07:05

      Спасибо за приятный отзыв. Он особенно приятен, потому что тоже испытываю подобные ощущения)

      Количество шума как то резко выросло в последнее время.