web-dev-qa-db-fra.com

expression régulière c ++ 11 plus lente que python

salut je voudrais comprendre pourquoi le code suivant qui fait un split de chaîne en utilisant regex

#include<regex>
#include<vector>
#include<string>

std::vector<std::string> split(const std::string &s){
    static const std::regex rsplit(" +");
    auto rit = std::sregex_token_iterator(s.begin(), s.end(), rsplit, -1);
    auto rend = std::sregex_token_iterator();
    auto res = std::vector<std::string>(rit, rend);
    return res;
}

int main(){
    for(auto i=0; i< 10000; ++i)
       split("a b c", " ");
    return 0;
}

est plus lent que le code suivant python

import re
for i in range(10000):
    re.split(' +', 'a b c')

voici

> python test.py  0.05s user 0.01s system 94% cpu 0.070 total
./test  0.26s user 0.00s system 99% cpu 0.296 total

J'utilise clang ++ sur osx.

la compilation avec -O3 le ramène à 0.09s user 0.00s system 99% cpu 0.109 total

71
locojay

Remarquer

Voir aussi cette réponse: https://stackoverflow.com/a/21708215 qui était la base pour EDIT 2 sur le bas ici.


J'ai augmenté la boucle à 1000000 pour obtenir une meilleure mesure de synchronisation.

C'est mon Python timing:

real    0m2.038s
user    0m2.009s
sys     0m0.024s

Voici un équivalent de votre code, juste un peu plus joli:

#include <regex>
#include <vector>
#include <string>

std::vector<std::string> split(const std::string &s, const std::regex &r)
{
    return {
        std::sregex_token_iterator(s.begin(), s.end(), r, -1),
        std::sregex_token_iterator()
    };
}

int main()
{
    const std::regex r(" +");
    for(auto i=0; i < 1000000; ++i)
       split("a b c", r);
    return 0;
}

Horaire:

real    0m5.786s
user    0m5.779s
sys     0m0.005s

Il s'agit d'une optimisation pour éviter la construction/allocation d'objets vectoriels et de chaînes:

#include <regex>
#include <vector>
#include <string>

void split(const std::string &s, const std::regex &r, std::vector<std::string> &v)
{
    auto rit = std::sregex_token_iterator(s.begin(), s.end(), r, -1);
    auto rend = std::sregex_token_iterator();
    v.clear();
    while(rit != rend)
    {
        v.Push_back(*rit);
        ++rit;
    }
}

int main()
{
    const std::regex r(" +");
    std::vector<std::string> v;
    for(auto i=0; i < 1000000; ++i)
       split("a b c", r, v);
    return 0;
}

Horaire:

real    0m3.034s
user    0m3.029s
sys     0m0.004s

C'est près d'une amélioration de 100% des performances.

Le vecteur est créé avant la boucle et peut augmenter sa mémoire lors de la première itération. Ensuite, il n'y a pas de désallocation de mémoire par clear(), le vecteur maintient la mémoire et construit les chaînes en place .


Une autre augmentation des performances consisterait à éviter complètement la construction/destruction std::string, Et donc l'allocation/la désallocation de ses objets.

Il s'agit d'une tentative dans ce sens:

#include <regex>
#include <vector>
#include <string>

void split(const char *s, const std::regex &r, std::vector<std::string> &v)
{
    auto rit = std::cregex_token_iterator(s, s + std::strlen(s), r, -1);
    auto rend = std::cregex_token_iterator();
    v.clear();
    while(rit != rend)
    {
        v.Push_back(*rit);
        ++rit;
    }
}

Horaire:

real    0m2.509s
user    0m2.503s
sys     0m0.004s

Une amélioration ultime serait d'avoir un std::vector De const char * En retour, où chaque pointeur de caractère pointerait vers une sous-chaîne à l'intérieur du s c d'origine chaîne elle-même. Le problème est que vous ne pouvez pas faire cela car chacun d'eux ne serait pas terminé par null (pour cela, voir l'utilisation de C++ 1y string_ref Dans un exemple ultérieur).


Cette dernière amélioration pourrait également être réalisée avec ceci:

#include <regex>
#include <vector>
#include <string>

void split(const std::string &s, const std::regex &r, std::vector<std::string> &v)
{
    auto rit = std::cregex_token_iterator(s.data(), s.data() + s.length(), r, -1);
    auto rend = std::cregex_token_iterator();
    v.clear();
    while(rit != rend)
    {
        v.Push_back(*rit);
        ++rit;
    }
}

int main()
{
    const std::regex r(" +");
    std::vector<std::string> v;
    for(auto i=0; i < 1000000; ++i)
       split("a b c", r, v); // the constant string("a b c") should be optimized
                             // by the compiler. I got the same performance as
                             // if it was an object outside the loop
    return 0;
}

J'ai construit les échantillons avec clang 3.3 (à partir du tronc) avec -O3. Peut-être que d'autres bibliothèques d'expressions régulières sont plus performantes, mais dans tous les cas, les allocations/désallocations sont souvent un problème de performances.


Boost.Regex

Voici le timing boost::regex Pour l'exemple d'arguments chaîne c :

real    0m1.284s
user    0m1.278s
sys     0m0.005s

Le même code, l'interface boost::regex Et std::regex Dans cet exemple sont identiques, juste nécessaires pour changer l'espace de noms et l'inclure.

Meilleurs voeux pour qu'elle s'améliore avec le temps, les implémentations de regex stdlib C++ en sont à leurs balbutiements.

ÉDITER

Pour terminer, j'ai essayé ceci (la suggestion "d'amélioration ultime" mentionnée ci-dessus) et cela n'a amélioré en rien les performances de la version équivalente de std::vector<std::string> &v:

#include <regex>
#include <vector>
#include <string>

template<typename Iterator> class intrusive_substring
{
private:
    Iterator begin_, end_;

public:
    intrusive_substring(Iterator begin, Iterator end) : begin_(begin), end_(end) {}

    Iterator begin() {return begin_;}
    Iterator end() {return end_;}
};

using intrusive_char_substring = intrusive_substring<const char *>;

void split(const std::string &s, const std::regex &r, std::vector<intrusive_char_substring> &v)
{
    auto rit = std::cregex_token_iterator(s.data(), s.data() + s.length(), r, -1);
    auto rend = std::cregex_token_iterator();
    v.clear(); // This can potentially be optimized away by the compiler because
               // the intrusive_char_substring destructor does nothing, so
               // resetting the internal size is the only thing to be done.
               // Formerly allocated memory is maintained.
    while(rit != rend)
    {
        v.emplace_back(rit->first, rit->second);
        ++rit;
    }
}

int main()
{
    const std::regex r(" +");
    std::vector<intrusive_char_substring> v;
    for(auto i=0; i < 1000000; ++i)
       split("a b c", r, v);

    return 0;
}

Cela a à voir avec la proposition array_ref et string_ref . Voici un exemple de code l'utilisant:

#include <regex>
#include <vector>
#include <string>
#include <string_ref>

void split(const std::string &s, const std::regex &r, std::vector<std::string_ref> &v)
{
    auto rit = std::cregex_token_iterator(s.data(), s.data() + s.length(), r, -1);
    auto rend = std::cregex_token_iterator();
    v.clear();
    while(rit != rend)
    {
        v.emplace_back(rit->first, rit->length());
        ++rit;
    }
}

int main()
{
    const std::regex r(" +");
    std::vector<std::string_ref> v;
    for(auto i=0; i < 1000000; ++i)
       split("a b c", r, v);

    return 0;
}

Il sera également moins cher de renvoyer un vecteur de string_ref Plutôt que string copies pour le cas de split avec retour de vecteur.

EDIT 2

Cette nouvelle solution est capable d'obtenir une sortie en retour. J'ai utilisé l'implémentation libc ++ de string_view (string_ref De Marshall Clow renommée) trouvée à https://github.com/mclow/string_view .

#include <string>
#include <string_view>
#include <boost/regex.hpp>
#include <boost/range/iterator_range.hpp>
#include <boost/iterator/transform_iterator.hpp>

using namespace std;
using namespace std::experimental;
using namespace boost;

string_view stringfier(const cregex_token_iterator::value_type &match) {
    return {match.first, static_cast<size_t>(match.length())};
}

using string_view_iterator =
    transform_iterator<decltype(&stringfier), cregex_token_iterator>;

iterator_range<string_view_iterator> split(string_view s, const regex &r) {
    return {
        string_view_iterator(
            cregex_token_iterator(s.begin(), s.end(), r, -1),
            stringfier
        ),
        string_view_iterator()
    };
}

int main() {
    const regex r(" +");
    for (size_t i = 0; i < 1000000; ++i) {
        split("a b c", r);
    }
}

Horaire:

real    0m0.385s
user    0m0.385s
sys     0m0.000s

Notez à quel point cela est plus rapide que les résultats précédents. Bien sûr, il ne remplit pas un vector à l'intérieur de la boucle (ni ne correspond à rien à l'avance probablement aussi), mais vous obtenez quand même une plage, que vous pouvez étendre avec for basé sur une plage, ou même l'utiliser pour remplir un vector.

Comme le fait de parcourir le iterator_range Crée des string_view Sur une chaîne d'origine string (ou une chaîne terminée par null null ), cela devient très léger, ne générant jamais d'allocations de chaînes inutiles.

Juste pour comparer en utilisant cette implémentation de split mais en remplissant réellement un vector, nous pourrions faire ceci:

int main() {
    const regex r(" +");
    vector<string_view> v;
    v.reserve(10);
    for (size_t i = 0; i < 1000000; ++i) {
        copy(split("a b c", r), back_inserter(v));
        v.clear();
    }
}

Cela utilise un algorithme de copie de plage de boost pour remplir le vecteur à chaque itération, le timing est:

real    0m1.002s
user    0m0.997s
sys     0m0.004s

Comme on peut le voir, il n'y a pas beaucoup de différence par rapport à la version optimisée du paramètre de sortie string_view.

Notez également qu'il y a ne proposition pour un std::split qui fonctionnerait ainsi.

88
pepper_chico

Pour les optimisations, en général, vous voulez éviter deux choses:

  • brûler les cycles CPU pour des trucs inutiles
  • en attendant que quelque chose se passe (lecture de mémoire, lecture de disque, lecture réseau, ...)

Les deux peuvent être antithétiques car parfois cela finit par être plus rapide à calculer quelque chose qu'à mettre tout cela en mémoire ... c'est donc un jeu d'équilibre.

Analysons votre code:

std::vector<std::string> split(const std::string &s){
    static const std::regex rsplit(" +"); // only computed once

    // search for first occurrence of rsplit
    auto rit = std::sregex_token_iterator(s.begin(), s.end(), rsplit, -1);

    auto rend = std::sregex_token_iterator();

    // simultaneously:
    // - parses "s" from the second to the past the last occurrence
    // - allocates one `std::string` for each match... at least! (there may be a copy)
    // - allocates space in the `std::vector`, possibly multiple times
    auto res = std::vector<std::string>(rit, rend);

    return res;
}

Pouvons-nous faire mieux? Eh bien, si nous pouvions réutiliser le stockage existant au lieu de conserver l'allocation et la désallocation de mémoire, nous devrions voir une amélioration significative [1]:

// Overwrites 'result' with the matches, returns the number of matches
// (note: 'result' is never shrunk, but may be grown as necessary)
size_t split(std::string const& s, std::vector<std::string>& result){
    static const std::regex rsplit(" +"); // only computed once

    auto rit = std::cregex_token_iterator(s.begin(), s.end(), rsplit, -1);
    auto rend = std::cregex_token_iterator();

    size_t pos = 0;

    // As long as possible, reuse the existing strings (in place)
    for (size_t max = result.size();
         rit != rend && pos != max;
         ++rit, ++pos)
    {
        result[pos].assign(rit->first, rit->second);
    }

    // When more matches than existing strings, extend capacity
    for (; rit != rend; ++rit, ++pos) {
        result.emplace_back(rit->first, rit->second);
    }

    return pos;
} // split

Dans le test que vous effectuez, où le nombre de sous-correspondances est constant d'une itération à l'autre, il est peu probable que cette version soit battue: elle n'allouera de la mémoire que lors de la première exécution (à la fois pour rsplit et result ), puis continuez à réutiliser la mémoire existante.

[1]: Avertissement, j'ai seulement prouvé que ce code était correct, je ne l'ai pas testé (comme dirait Donald Knuth).

5
Matthieu M.

Et cette verion? Ce n'est pas une expression rationnelle, mais cela résout la scission assez rapidement ...

#include <vector>
#include <string>
#include <algorithm>

size_t split2(const std::string& s, std::vector<std::string>& result)
{
    size_t count = 0;
    result.clear();
    std::string::const_iterator p1 = s.cbegin();
    std::string::const_iterator p2 = p1;
    bool run = true;
    do
    {
        p2 = std::find(p1, s.cend(), ' ');
        result.Push_back(std::string(p1, p2));
        ++count;

        if (p2 != s.cend())
        {
            p1 = std::find_if(p2, s.cend(), [](char c) -> bool
            {
                return c != ' ';
            });
        }
        else run = false;
    } while (run);
    return count;
}

int main()
{
    std::vector<std::string> v;
    std::string s = "a b c";
    for (auto i = 0; i < 100000; ++i)
        split2(s, v); 
    return 0;
}

$ time splittest.exe

réel 0m0.132s utilisateur 0m0.000s sys 0m0.109s

3
schorsch_76