web-dev-qa-db-fra.com

Swift performance: map () et Reduce () vs pour les boucles

J'écris du code critique pour les performances dans Swift. Après avoir implémenté toutes les optimisations auxquelles je pouvais penser et profilé l'application dans Instruments, je me suis rendu compte que la grande majorité des cycles CPU sont consacrés à effectuer des opérations map() et reduce() sur des tableaux de Flotteurs. Donc, juste pour voir ce qui se passerait, j'ai remplacé toutes les instances de map et reduce par de bonnes boucles for à l'ancienne. Et à mon grand étonnement ... les boucles for étaient beaucoup, beaucoup plus rapides!

Un peu déconcerté par cela, j'ai décidé d'effectuer quelques repères approximatifs. Dans un test, j'ai eu map renvoyé un tableau de flottants après avoir effectué une arithmétique simple comme ceci:

// Populate array with 1,000,000,000 random numbers
var array = [Float](count: 1_000_000_000, repeatedValue: 0)
for i in 0..<array.count {
    array[i] = Float(random())
}
let start = NSDate()
// Construct a new array, with each element from the original multiplied by 5
let output = array.map({ (element) -> Float in
    return element * 5
})
// Log the elapsed time
let elapsed = NSDate().timeIntervalSinceDate(start)
print(elapsed)

Et l'implémentation de boucle for équivalente:

var output = [Float]()
for element in array {
    output.append(element * 5)
}

Temps d'exécution moyen pour map: 20,1 secondes. Temps d'exécution moyen de la boucle for: 11,2 secondes. Les résultats étaient similaires en utilisant des entiers au lieu de flottants.

J'ai créé un benchmark similaire pour tester les performances du reduce de Swift. Cette fois, les boucles reduce et for ont obtenu à peu près les mêmes performances lors de la sommation des éléments d'un grand tableau. Mais quand je boucle le test 100 000 fois comme ceci:

// Populate array with 1,000,000 random numbers
var array = [Float](count: 1_000_000, repeatedValue: 0)
for i in 0..<array.count {
    array[i] = Float(random())
}
let start = NSDate()
// Perform operation 100,000 times
for _ in 0..<100_000 {
    let sum = array.reduce(0, combine: {$0 + $1})
}
// Log the elapsed time
let elapsed = NSDate().timeIntervalSinceDate(start)
print(elapsed)

contre:

for _ in 0..<100_000 {
    var sum: Float = 0
    for element in array {
        sum += element
    }
}

La méthode reduce prend 29 secondes tandis que la boucle for prend (apparemment) 0,000003 secondes.

Naturellement, je suis prêt à ignorer ce dernier test à la suite d'une optimisation du compilateur, mais je pense qu'il peut donner un aperçu de la façon dont le compilateur optimise différemment les boucles par rapport aux méthodes de tableau intégrées de Swift. Notez que tous les tests ont été effectués avec une optimisation -Os sur un MacBook Pro 2,5 GHz i7. Les résultats variaient selon la taille du tableau et le nombre d'itérations, mais les boucles for surpassaient toujours les autres méthodes d'au moins 1,5 fois, parfois jusqu'à 10 fois.

Je suis un peu perplexe quant aux performances de Swift ici. Les méthodes Array intégrées ne devraient-elles pas être plus rapides que l'approche naïve pour effectuer de telles opérations? Peut-être que quelqu'un avec plus de connaissances de bas niveau que moi peut éclairer la situation.

53
hundley

Les méthodes Array intégrées ne devraient-elles pas être plus rapides que l'approche naïve pour effectuer de telles opérations? Peut-être que quelqu'un avec plus de connaissances de bas niveau que moi peut éclairer la situation.

Je veux juste essayer d'aborder cette partie de la question et plus du niveau conceptuel (avec peu de compréhension de la nature de l'optimiseur de Swift de ma part) avec un "pas nécessairement". Cela vient plus d'une expérience dans la conception de compilateurs et l'architecture informatique que d'une connaissance approfondie de la nature de l'optimiseur de Swift.

frais généraux d'appel

Avec des fonctions telles que map et reduce acceptant des fonctions comme entrées, il place une plus grande pression sur l'optimiseur pour le mettre dans un sens. La tentation naturelle dans un tel cas, à moins d'une optimisation très agressive, est de constamment basculer entre l'implémentation de, par exemple, map et la fermeture que vous avez fournie, et de transmettre également des données à travers ces branches de code disparates (via les registres et la pile, généralement).

Ce type de surcharge de branchement/appel est très difficile à éliminer pour l'optimiseur, en particulier compte tenu de la flexibilité des fermetures de Swift (pas impossible mais conceptuellement assez difficile). Les optimiseurs C++ peuvent incorporer des appels d'objet fonction, mais avec beaucoup plus de restrictions et de techniques de génération de code nécessaires pour le faire, là où le compilateur devrait effectivement générer un tout nouvel ensemble d'instructions pour map pour chaque type d'objet fonction que vous transmettez (et avec l'aide explicite du programmeur indiquant un modèle de fonction utilisé pour la génération de code).

Il ne devrait donc pas être très surprenant de constater que vos boucles roulées à la main peuvent fonctionner plus rapidement - elles mettent beaucoup moins de pression sur l'optimiseur. J'ai vu que certaines personnes citent que ces fonctions d'ordre supérieur devraient pouvoir aller plus vite car le fournisseur est capable de faire des choses comme paralléliser la boucle, mais pour paralléliser efficacement la boucle, il faudrait d'abord le type d'informations qui devraient généralement permettre à l'optimiseur d'inline les appels de fonction imbriqués à un point où ils deviennent aussi bon marché que les boucles roulées à la main. Sinon, l'implémentation de fonction/fermeture que vous transmettez sera effectivement opaque pour des fonctions comme map/reduce: ils ne peuvent que l'appeler et payer les frais généraux de le faire, et ne peuvent pas le paralléliser car ils ne peuvent rien supposer de la nature des effets secondaires et de la sécurité des threads.

Bien sûr, tout cela est conceptuel - Swift peut être en mesure d'optimiser ces cas à l'avenir, ou il peut déjà être en mesure de le faire maintenant (voir -Ofast comme moyen couramment cité pour accélérer Swift aller plus vite au détriment de la sécurité). Mais cela impose une contrainte plus lourde à l'optimiseur, à tout le moins, pour utiliser ce type de fonctions sur les boucles roulées à la main, et les différences de temps que vous voyez dans le premier benchmark semblent refléter le type de différences que l'on pourrait attendre avec cette surcharge d'appel supplémentaire. La meilleure façon de le savoir est de regarder l'assembly et d'essayer divers indicateurs d'optimisation.

Fonctions standard

Cela ne décourage pas l'utilisation de ces fonctions. Ils expriment de manière plus concise l'intention, ils peuvent augmenter la productivité. Et s'appuyer sur eux pourrait permettre à votre base de code d'accélérer dans les futures versions de Swift sans aucune implication de votre part. Mais ils ne seront pas nécessairement toujours plus rapides - c'est un bon général règle de penser qu'une fonction de bibliothèque de niveau supérieur qui exprime plus directement ce que vous voulez faire va être plus rapide, mais il y a toujours des exceptions à la règle (mais mieux découvert avec le recul avec un profileur en main car il est préférable de se tromper du côté de la confiance que de la méfiance ici).

Repères artificiels

Quant à votre deuxième benchmark, il est presque certainement le résultat de l'optimisation du code par le compilateur qui n'a aucun effet secondaire qui affecte la sortie de l'utilisateur. Les repères artificiels ont tendance à être notoirement trompeurs en raison de ce que les optimiseurs font pour éliminer les effets secondaires non pertinents (effets secondaires qui n'affectent pas la sortie de l'utilisateur, essentiellement). Donc, vous devez être prudent lorsque vous construisez des benchmarks avec des temps qui semblent trop beaux pour être vrais qu'ils ne sont pas le résultat de l'optimiseur simplement ignorer tout le travail que vous vouliez réellement comparer. À tout le moins, vous voulez que vos tests produisent un résultat final collecté à partir du calcul.

30
Dragon Energy

Je ne peux pas en dire beaucoup sur votre premier test (map() vs append() en boucle) mais je peux confirmer vos résultats. La boucle d'ajout devient encore plus rapide si vous ajoutez

output.reserveCapacity(array.count)

après la création du tableau. Il semble que Apple peut améliorer les choses ici et vous pouvez déposer un rapport de bogue.

Dans

for _ in 0..<100_000 {
    var sum: Float = 0
    for element in array {
        sum += element
    }
}

le compilateur supprime (probablement) la boucle entière car les résultats calculés ne sont pas du tout utilisés. Je ne peux que spéculer pourquoi une optimisation similaire ne se produit pas dans

for _ in 0..<100_000 {
    let sum = array.reduce(0, combine: {$0 + $1})
}

mais il serait plus difficile de décider si l'appel de reduce() avec la fermeture a des effets secondaires ou non.

Si le code de test est légèrement modifié pour calculer et imprimer une somme totale

do {
    var total = Float(0.0)
    let start = NSDate()
    for _ in 0..<100_000 {
        total += array.reduce(0, combine: {$0 + $1})
    }
    let elapsed = NSDate().timeIntervalSinceDate(start)
    print("sum with reduce:", elapsed)
    print(total)
}

do {
    var total = Float(0.0)
    let start = NSDate()
    for _ in 0..<100_000 {
        var sum = Float(0.0)
        for element in array {
            sum += element
        }
        total += sum
    }
    let elapsed = NSDate().timeIntervalSinceDate(start)
    print("sum with loop:", elapsed)
    print(total)
}

puis les deux variantes prennent environ 10 secondes dans mon test.

14
Martin R

J'ai fait un ensemble rapide de tests de performances mesurant les performances de transformations répétées sur un tableau de chaînes, et il a montré que .map était beaucoup plus performant qu'une boucle for, par un facteur d'environ 10x.

Les résultats de la capture d'écran ci-dessous montrent que les transformations chaînées dans un seul bloc map surpassent plusieurs map avec une seule transformation dans chacune, et toute utilisation de map surpasse les boucles .

Demonstration of map vs for loop performance

Code que j'ai utilisé dans une aire de jeux:

import Foundation
import XCTest

class MapPerfTests: XCTestCase {
        var array =
                [
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString",
                        "MyString"
        ]

        func testForLoopAllInOnePerf() {
                measure {
                        var newArray: [String] = []
                        for item in array {
                                newArray.append(item.uppercased().lowercased().uppercased().lowercased())
                        }
                }
        }

        func testForLoopMultipleStagesPerf() {
                measure {
                        var newArray: [String] = []
                        for item in array {
                                let t1 = item.uppercased()
                                let t2 = item.lowercased()
                                let t3 = item.uppercased()
                                let t4 = item.lowercased()
                                newArray.append(t4)
                        }
                }
        }

        func testMultipleMapPerf() {
                measure {
                        let newArray = array
                                .map( { $0.uppercased() } )
                                .map( { $0.lowercased() } )
                                .map( { $0.uppercased() } )
                                .map( { $0.lowercased() } )
                }
        }

        func testSingleMapPerf() {
                measure {
                        let newArray = array
                                .map( { $0.uppercased().lowercased().uppercased().lowercased() } )
                }
        }
}

MapPerfTests.defaultTestSuite.run()
6
Oletha