DispatchQueue: почему последовательный завершается быстрее, чем параллельный? - PullRequest
0 голосов
/ 11 февраля 2019

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

Теперь ... прежде чем все здесь сойдут с ума из-за того факта, что вышеприведенное утверждение не всегда верно, потому что многопоточность сопряжена со многими неопределенностями, позвольте мне объяснить.

Из прочтения документации Apple я знаю, что вы не можете гарантировать получение нескольких потоков при их запросе.ОС (iOS) назначит потоки так, как считает нужным.Например, если устройство имеет только одно ядро, оно назначит одно ядро, и последовательный интерфейс будет немного быстрее из-за того, что код инициализации параллельной работы займет некоторое дополнительное время, но не обеспечит повышение производительности, поскольку устройство имеет только одно ядро.

Однако: эта разница должна быть незначительной.Но в моей настройке POC разница огромна.В моем POC, одновременный медленнее примерно в 1/3 времени.

Если последовательное выполнение завершается через 6 секунд , одновременное выполнение завершается через 9 секунд .
Эта тенденция продолжается даже при более высоких нагрузках.если сериал завершается за 125 секунд , одновременный будет конкурировать за 215 секунд .Это также происходит не один раз, а каждый раз.

Интересно, допустил ли я ошибку при создании этого POC, и если да, то как мне доказать, что одновременное выполнение нескольких тяжелых задач действительно быстрее, чем последовательное?

Мой POC в быстрых юнит-тестах:

func performHeavyTask(_ completion: (() -> Void)?) {
    var counter = 0
    while counter < 50000 {
        print(counter)
        counter = counter.advanced(by: 1)
    }
    completion?()
}

// MARK: - Serial
func testSerial () {
    let start = DispatchTime.now()
    let _ = DispatchQueue.global(qos: .userInitiated)
    let mainDPG = DispatchGroup()
    mainDPG.enter()
    DispatchQueue.global(qos: .userInitiated).async {[weak self] in
        guard let self = self else { return }
        for _ in 0...10 {
            self.performHeavyTask(nil)
        }
        mainDPG.leave()
    }
    mainDPG.wait()
    let end = DispatchTime.now()
    let nanoTime = end.uptimeNanoseconds - start.uptimeNanoseconds // <<<<< Difference in nano seconds (UInt64)
    print("NanoTime: \(nanoTime / 1_000_000_000)")
}

// MARK: - Concurrent
func testConcurrent() {
    let start = DispatchTime.now()
    let _ = DispatchQueue.global(qos: .userInitiated)
    let mainDPG = DispatchGroup()
    mainDPG.enter()
    DispatchQueue.global(qos: .userInitiated).async {
        let dispatchGroup = DispatchGroup()
        let _ = DispatchQueue.global(qos: .userInitiated)
        DispatchQueue.concurrentPerform(iterations: 10) { index in
            dispatchGroup.enter()
            self.performHeavyTask({
                dispatchGroup.leave()
            })
        }
        dispatchGroup.wait()
        mainDPG.leave()
    }
    mainDPG.wait()
    let end = DispatchTime.now()
    let nanoTime = end.uptimeNanoseconds - start.uptimeNanoseconds // <<<<< Difference in nano seconds (UInt64)
    print("NanoTime: \(nanoTime / 1_000_000_000)")
}

Подробности:

ОС: macOS High Sierra
Название модели: MacBook Pro
Идентификатор модели: MacBookPro11,4
Имя процессора: Intel Core i7
Скорость процессора: 2,2 ГГц
Количество процессоров: 1
Общее количество ядер: 4

Обатесты проводились на симуляторе iPhone XS Max.Оба теста были выполнены сразу после перезагрузки всего Mac (чтобы избежать того, что Mac занят приложениями, отличными от запуска этого модульного теста, размытие результатов)

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

Я также приму ответ, который показывает POC, надежно проверяющий это.Он не обязательно должен показывать, что параллельный всегда быстрее, чем последовательный (см. Выше объяснение, почему нет).Но хотя бы какое-то время

1 Ответ

0 голосов
/ 11 февраля 2019

Есть две проблемы:

  1. Я бы не стал делать print внутри цикла.Это синхронизировано, и вы, вероятно, испытаете большее снижение производительности при параллельной реализации.Здесь не вся история, но это не помогает.

  2. Даже после удаления print из цикла, 50000 приращений счетчика просто не достаточно, чтобы увидетьвыгода concurrentPerform.Как Улучшение в циклическом коде говорит:

    ... И хотя этот [concurrentPerform] может быть хорошим способом повышения производительности в коде, основанном на циклах, вы все равно должныиспользуйте эту технику с пониманием.Хотя очереди на отправку имеют очень низкие накладные расходы, все еще существуют затраты на планирование каждой итерации цикла в потоке.Поэтому вы должны убедиться, что ваш код цикла выполняет достаточно работы, чтобы оправдать затраты.Сколько именно работы вам нужно сделать, это то, что вы должны измерить с помощью инструментов повышения производительности.

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

    Итак, в моем примере ниже я заменил этот вращающийся цикл более вычислительно интенсивным вычислением (вычисляя π с использованием исторического,но не очень эффективный, алгоритм).

В сторону:

Вместо того, чтобы измерять производительность самостоятельно, если вы делаете это в рамках XCTestCase модульного теста, вы можете использовать measure для оценки производительности.Это повторяет сравнительный анализ несколько раз, фиксирует истекшее время, усредняет результаты и т. Д. Просто убедитесь, что отредактировали свою схему, чтобы тестовое действие использовало оптимизированную сборку «выпуска», а не «отладочную» сборку.

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

Вы неТакже не нужно использовать группы рассылки для ожидания завершения concurrentPerform.Он работает синхронно.

В то время как документация concurrentPerform , скажем так, «тонкая», документация для dispatch_apply (что concurrentPerform использует ) говорит:

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

Это не совсем материално, но стоит отметить, что ваш for _ in 0...10 { ... } выполняет 11 итераций, а не 10. Вы, очевидно, хотели использовать ..<.

Таким образом, вот пример, помещающий его в модульное тестирование, но заменяющий «тяжелые» вычисления чем-то более интенсивным в вычислительном отношении:

class MyAppTests: XCTestCase {

    // calculate pi using Gregory-Leibniz series

    func calculatePi(iterations: Int) -> Double {
        var result = 0.0
        var sign = 1.0
        for i in 0 ..< iterations {
            result += sign / Double(i * 2 + 1)
            sign *= -1
        }
        return result * 4
    }

    func performHeavyTask(iteration: Int) {
        let pi = calculatePi(iterations: 100_000_000)

        print(iteration, .pi - pi)
    }

    func testSerial () {
        measure {
            for i in 0..<10 {
                self.performHeavyTask(iteration: i)
            }
        }
    }

    func testConcurrent() {
        measure {
            DispatchQueue.concurrentPerform(iterations: 10) { i in
                self.performHeavyTask(iteration: i)
            }
        }
    }

}

На моем MacBook Pro 2018 с Intel Core i9 с частотой 2,9 ГГц,при выпуске сборки параллельный тест занимал в среднем 0,247 секунды, в то время как серийный тест занимал примерно в четыре раза больше времени - 1,030 секунды.

...