регулярний вираз c ++ 11 повільніший за python


77

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

#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;
}

повільніше, ніж наступний код python

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

ось

> 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

Я використовую clang ++ на osx.

компіляція з -O3 зводить його до 0.09s user 0.00s system 99% cpu 0.109 total


8
Ви запускаєте збірку налагодження? Використовуючи шаблони, переконайтеся, що у вас є опції для увімкнення та вимкнення; Є багато перевірок безпеки, які в іншому випадку потрапляють у ваш код.
ssube

25
Вони роблять не те саме. Наприклад, код С ++ виконує конкатенацію рядків, а Python - ні.
інтержой

21
Регулярний вираз у випадку з Python може бути скомпільований / оптимізований лише один раз. Бібліотека регулярних виразів C ++ буде створювати та оптимізувати регулярний вираз знову і знову. Просто для запису, спробуйте визначити rsplitрегулярний вираз як статичну константу. У випадку з Python, бібліотека re може працювати з компілятором, підтримуючи список оптимізованих регулярних виразів.
Дієго Севілья

8
Ось чому люди використовують python для таких завдань: це позбавляє програміста від необхідності входити в ці самі технічні аналізи того, що впливає на продуктивність.
Marcin

9
Я можу приблизно відтворити ваші результати, і, просто замінивши libc ++ std :: regex на boost :: regex, версія C ++ перевершує python приблизно на 10-15%. Я не думаю, що реалізація libc ++ ще є особливо ефективною.
Куббі

Відповіді:


100

Зауважте

Дивіться також цю відповідь: https://stackoverflow.com/a/21708215, яка була основою для EDIT 2 внизу тут.


Я збільшив цикл до 1000000, щоб отримати кращий показник часу.

Це мій час Python:

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

Ось еквівалент вашого коду, трохи гарніший:

#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;
}

Час:

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

Це оптимізація, щоб уникнути побудови / розподілу векторних та рядкових об’єктів:

#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;
}

Час:

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

Це майже на 100% покращення продуктивності.

Вектор створюється перед циклом і може збільшити свою пам’ять за першу ітерацію. Після цього не відбувається звільнення пам'яті clear(), вектор підтримує пам'ять і будує рядки на місці .


Ще одним підвищенням продуктивності було б уникнення будівництва / руйнування std::string повністю, а отже, розподілу / вивільнення об'єктів.

Це орієнтовно в цьому напрямку:

#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;
    }
}

Час:

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

Кінцевим покращенням було б повернення std::vectorof const char *as, де кожен вказівник char вказував би на підрядок всередині самого оригінального s рядка c . Проблема в тому, що ви не можете цього зробити, тому що кожен з них не буде припинений до нуля (для цього див. Використання C ++ 1ystring_ref .


Останнього покращення також можна досягти за допомогою цього:

#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;
}

Я побудував зразки з дзвінком 3.3 (із стовбура) за допомогою -O3. Можливо, інші бібліотеки регулярних виразів можуть працювати ефективніше, але в будь-якому випадку розподіли / вивільнення часто є результатом.


Boost.Regex

Це boost::regexчас для зразка аргументів рядка c :

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

Той самий код boost::regexта std::regexінтерфейс у цьому зразку ідентичні, просто необхідні для зміни простору імен та включення.

Найкращі побажання, щоб з часом воно покращувалось, реалізації регулярних виразів C ++ stdlib перебувають у зародковому стані.

РЕДАГУВАТИ

Для завершення я спробував це (згадана вище пропозиція "остаточного вдосконалення"), і це нічим не покращило продуктивність еквівалентної 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;
}

Це пов’язано з пропозицією array_ref та string_ref . Ось зразок коду з його використанням:

#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;
}

Також буде дешевше повернути вектор, string_refа не stringкопіювати у випадку splitз векторним поверненням.

РЕДАГУВАТИ 2

Це нове рішення здатне отримувати результат шляхом повернення. Я використовував string_view( string_refперейменований) реалізацію libc ++ Маршалла Клоу, знайдену за адресою 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);
    }
}

Час:

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

Зверніть увагу, наскільки швидше це порівняно з попередніми результатами. Звичайно, це не заповнення vectorвнутрішнього циклу (а також, мабуть, заздалегідь нічого не збігається), але ви в будь-якому випадку отримуєте діапазон, який ви можете діапазонувати на основі діапазону for, або навіть використовувати його для заповненняvector .

Оскільки розподіл розмірів по iterator_rangeстворює string_views над оригіналом string(або рядком, що закінчується нулем ), це стає дуже легким, ніколи не генеруючи непотрібних розподілів рядків.

Просто для порівняння використання цієї splitреалізації, але насправді заповнення a vectorми могли б зробити це:

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();
    }
}

Тут використовується алгоритм копіювання діапазону посилення, щоб заповнити вектор у кожній ітерації, час:

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

Як бачимо, великої різниці в порівнянні з оптимізованою string_viewверсією параметру виводу немає.

Також зверніть увагу, що є пропозиція щодо такоїstd::split роботи.


Ще одну спробу спробувати: static const string s("a b c");і split(s,r,v).
jthill

@jthill Я думаю, це покращило б версію аргументу std :: string, але я вважаю, що статичний не потрібен, це буде просто оголошено поза циклом, замість попередньої конструкції / знищення з рядка c.
pepper_chico

1
@RnMss немає потреби, return std::move(some_vector)коли some_vectorце xvalue. Я пропоную вам шукати це ключове слово на SO. На RVO / NRVO не покладається.
pepper_chico

1
Ви забули додати std::regex::optimizeдо регулярного виразу ctor. Це змусило б регулярний вираз використовувати детермінований FSA.
bit2shift

1
Будь ласка, додайте резюме у верхню частину вашої відповіді, зараз це досить важко для TL; DR люди :)
Dima Tisnek

5

Для оптимізації загалом потрібно уникати двох речей:

  • спалювання циклів процесора для непотрібних речей
  • бездіяльно чекаючи чогось трапиться (читання пам'яті, читання з диска, читання з мережі, ...)

Ці два способи можуть бути протилежними, оскільки іноді це швидше обчислює щось, ніж кешує все це в пам’яті ... так що це гра балансу.

Давайте проаналізуємо ваш код:

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;
}

Чи можемо ми зробити краще? Що ж, якщо ми змогли б повторно використати наявне сховище замість того, щоб продовжувати виділяти та вивільняти пам’ять, ми мали б помітити значне покращення [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

У тесті, який ви виконуєте, де кількість підрівнень постійна на ітераціях, цю версію навряд чи вдасться побити: вона виділить пам'ять лише під час першого запуску (як для, так rsplitі result), а потім продовжить повторне використання наявної пам'яті.

[1]: Застереження, я лише довів, що цей код правильний, я не перевіряв його (як сказав би Дональд Кнут).


3
Я зробив майже однакову реалізацію, але пропустив її, оскільки це нічого не покращує для цього зразка. Отримав таку ж продуктивність, як версія push_back ...
pepper_chico

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

1
@chico: Я згоден з цим resize, проте проблема зменшення розміру полягає в тому, що ви спричиняєте вивільнення хвоста, std::stringщо призведе до перерозподілу по дорозі. Звичайно, string_refальтернатива не зазнала б таких бід.
Matthieu M.

3

Як щодо цього верю? Це не регулярний вираз, але він досить швидко вирішує розділення ...

#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

реальні 0m0.132s користувач 0m0.000s sys 0m0.109s


0

Я б сказав, що регулярний вираз C ++ 11 набагато повільніший за perl і, можливо, від python.

Щоб правильно виміряти продуктивність, краще проводити тести, використовуючи якийсь нетривіальний вираз, інакше ви вимірюєте все, крім самого регулярного виразу.

Наприклад, для порівняння C ++ 11 та perl

Тестовий код С ++ 11

  #include <iostream>
  #include <string>
  #include <regex>
  #include <chrono>

  int main ()
  {
     int veces = 10000;
     int count = 0;
     std::regex expres ("([^-]*)-([^-]*)-(\\d\\d\\d:\\d\\d)---(.*)");

     std::string text ("some-random-text-453:10--- etc etc blah");
     std::smatch macha;

     auto start = std::chrono::system_clock::now();

     for (int ii = 0;  ii < veces; ii ++)
        count += std::regex_search (text, macha, expres);

     auto milli = std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::system_clock::now() - start).count();

     std::cout << count << "/" << veces << " matches " << milli << " ms --> " << (milli / (float) veces) << " ms per regex_search" << std::endl;
     return 0;
  }

на моєму комп'ютері, компілюючи з gcc 4.9.3, я отримую вихід

 10000/10000 matches 1704 ms --> 0.1704 ms per regex_search

код тесту perl

  use Time::HiRes qw/ time sleep /;

  my $veces = 1000000;
  my $conta = 0;
  my $phrase = "some-random-text-453:10--- etc etc blah";

  my $start = time;

  for (my $ii = 0; $ii < $veces; $ii++)
  {   
     if ($phrase =~ m/([^-]*)-([^-]*)-(\d\d\d:\d\d)---(.*)/)
     {
        $conta = $conta + 1;
     }
  }
  my $elapsed = (time - $start) * 1000.0;
  print $conta . "/" . $veces . " matches " . $elapsed . " ms --> " . ($elapsed / $veces) . " ms per regex\n";

знову використовуючи perl v5.8.8 у своєму комп’ютері

  1000000/1000000 matches 2929.55303192139 ms --> 0.00292955303192139 ms per regex

Отже, у цьому тесті співвідношення C ++ 11 / perl становить

   0.1704 / 0.002929 = 58.17 times slower than perl

в реальних сценаріях я отримую співвідношення приблизно в 100-200 разів повільніше. Так, наприклад, синтаксичний аналіз великого файлу з мільйоном рядків займає близько секунди для perl, тоді як для програми C ++ 11 за допомогою регулярного виразу може знадобитися більше хвилин (!).


1
Я спробував те саме сьогодні (2019) з gcc 8.2 та perl 5.16 і отримав 1,8 мкс на regex_searchC ++ та 1,5 мкс на regexperl . Моя думка полягає в тому, що продуктивність дуже залежить від реалізації, і, мабуть, реалізація регулярних виразів у libstdc ++ значно покращилася. Коли я перейшов на boost.regex , я отримав 0,5 мкс на regex_searchC ++ . У цьому сила C ++ - ви не отримуєте продуктивність автоматично, але ви можете керувати нею.
Даніель Лангр,
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.