LINUX.ORG.RU

Очень медленные regex в GNU C Library. Или это я что-то неправильно делаю?

 , ,


1

7

Изучая C с удивлением заметил что стандартные POSIX регексы из glibc работают на порядок медленнее чем в Lua. Решил замерить на одинаковых по логике и шаблону программах. Вот программа на C для подсчёта количества слов на русском языке в тексте с кодировкой UTF-8:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <regex.h>

// максимальный размер слова
#define MAXWORD 100

int main (int argc, char* argv []) {
    FILE* file = fopen(argv[1], "r");
    // размер файла в байтах
    fseek(file, 0, SEEK_END);
    long size_file = ftell(file);
    fseek(file, 0, SEEK_SET);
    // выделение памяти под текст
    char* text = malloc(size_file + 1);
    // чтение всего файла
    fread(text, 1, size_file, file);
    text[size_file] = '\0';
    fclose(file);
    regex_t regex;
    // шаблон слова на кириллице
    char* pattern =
        "[\xD0\xD1]" // первый байт
        "[\x80-\xBF]" //второй байт
        "[\x80-\xBF\xD0\xD1]*"; // остальныее, если есть
    if (regcomp(&regex, pattern, REG_EXTENDED) != 0) {
        fputs("Ошибка компиляции regex\n", stderr);
        return 1;
    }
    regmatch_t match;
    char word [MAXWORD];
    long count = 0;
    // начальная позиция поиска
    char* pos = text;
    while (regexec(&regex, pos, 1, &match, 0) == 0) {
        int len = match.rm_eo - match.rm_so;
        // ограничение длины слова
        len = (len < MAXWORD) ? len : MAXWORD - 1;
        // извлечение слова
        memcpy(word, pos + match.rm_so, len);
        word[len] = '\0';
        // смещаем начало следующего поиска
        pos += match.rm_eo;
        ++count;
//      printf("%-7ld%s\n", count, word);
    }
    printf("Всего слов: %ld\n", count);
    regfree(&regex);
    free(text);
    return 0;
}

И похожий по логике и с таким же регексом скрипт на Lua:

file = io.open(arg[1], "r");
text = file:read("a")
file:close()
count = 0
pattern = "[\xD0\xD1][\x80-\xBF][\x80-\xBF\xD0\xD1]*"
for word in string.gmatch(text, pattern) do
--  print(word)
    count = count + 1
end
print("Всего слов: " .. count)

Компилируем и замеряем

~ $ clang -o count count.c
~ $ time ./count voina-i-mir.fb2
Всего слов: 531982

real    4m37.622s
user    4m37.228s
sys     0m0.032s
~ $ time lua count.lua voina-i-mir.fb2
Всего слов: 531982

real    0m0.464s
user    0m0.428s
sys     0m0.036s

Разница в 650 раз! И не в пользу glibc. Единственное объяснение, которое я пока вижу, что регексы в Lua реализованы очень просто. Об этом Роберту Иерузалимски (создатель Lua) в своей книге пишет следующее:

Типичная реализация POSIX-регулярных выражений занимает более 4000 строк кода, что превышает половину размера всех стандартных библиотек Lua вместе взятых. Для сравнения, реализация сопоставления с образцом в Lua занимает менее 600 строк.

Возможно именно из-за своей простоты они такие быстрые в Lua. Но всё равно разница в 650 раз озадачивает. То ли лыжи не едут, то ли я что-то неправильно делаю?

Ответ на: комментарий от urxvt

string.gmach() в Lua не просто находит позиции совпадений, а возвращает на каждую итерацию подстроку и присваивается её переменной word. К тому же в Lua строки не изменяемые, а значит на каждую подстроку выделяется память, которая потом чистится сборщиком мусора… Если так подумать, то должно работать очень медленно, а на практике оказалось наоборот. Впрочем, упростил цикл в C, чтобы он просто считал количество совпадений:

while (regexec(&regex, pos, 1, &match, 0) == 0) {
pos += match.rm_eo;
++count;
}

Разницы никакой. Узкое место в этом цикле это именно сопоставление с шаблоном.

Megaorcus
() автор топика
Ответ на: комментарий от dataman

-O3/O2 тоже пробовал. Разницы нет. Вечером попробую на другой машине скопировать с помощью gcc. Но сомневаюсь что результат будет значительно отличаться. По второму замечанию ответил выше.

Megaorcus
() автор топика
Ответ на: комментарий от Megaorcus

К тому же в Lua строки не изменяемые, а значит на каждую подстроку выделяется память, которая потом чистится сборщиком мусора…

Вовсе не значит. В Си идиотские кончающиеся нулём строки, из-за чего для извлечения строки приходится её копировать. Я не знаю как именно сделано в Луа, но если отказаться от нуля на конце, а вместо того хранить указатель на первую букву и длину строки, то извлечение подстрок можно делать без копирования, а неизменяемость строк этому только помогает.

debugger ★★★★★
()
Ответ на: комментарий от debugger

Я не знаю как именно сделано в Луа, но если отказаться от нуля на конце, а вместо того хранить указатель на первую букву и длину строки, то извлечение подстрок можно делать без копирования, а неизменяемость строк этому только помогает.

Но по видимому в Lua не так. Вот ещё одна цитата из той же книги Роберту Иерузалимского:

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

local buff = ""
for line in io.lines() do
    buff = buff .. line .. "\n"
end

Несмотря на безобидный вид, такой код в Lua может привести к серьёзным проблемам производительности для больших файлов. Например, чтение файла размером 4.5 МБ на современной машине может занять более 30 секунд.

Почему это происходит? Представим, что мы в середине цикла чтения: каждая строка содержит 20 байт, и мы уже прочитали 2500 строк, поэтому `buff` содержит строку размером 50 КБ. При каждой конкатенации `buff..line.."\n"` Lua создаёт новую строку размером 50020 байт и копирует в неё 50000 байт из `buff`. То есть для каждой новой строки Lua перемещает около 50 КБ памяти, и этот объём постоянно растёт. Алгоритм имеет квадратичную сложность. После чтения всего 100 новых строк (2 КБ данных) Lua уже переместил более 5 МБ памяти. К моменту прочтения 350 КБ данных будет перемещено более 50 ГБ. (Эта проблема характерна не только для Lua - другие языки с неизменяемыми строками, такие как Java, демонстрируют схожее поведение.)
Megaorcus
() автор топика
Ответ на: комментарий от debugger

Ну, и в доказательство моего предположения:

Я переписал программку на C++:

#include  <filesystem>
#include  <fstream>
#include  <iostream>
#include  <regex>
#include  <vector>

using namespace std;
using namespace std::filesystem;

int main ( int argc, char * argv[] ) {

    string name = argv[ 1 ];

    auto size = file_size( name );
    vector< char > text;
    text.reserve( size + 1 );
    text.resize( size );

    auto file = std::ifstream( name );
    file.read( text.data(), text.size() );
    file.close();
    text.push_back( 0 );

    cout << "go\n";
    auto re = regex(
        "[\xD0\xD1]"
        "[\x80-\xBF]"
        "[\x80-\xBF\xD0\xD1]*"
    );
    size_t pos = 0;
    size_t count = 0;
    while ( pos < size ) {
        cmatch m;
        if ( not regex_search( text.data() + pos, m, re ) ) {
            break;
        };
        count += 1;
        cout << setw( 7 ) << left << count << m.str() << "\n";
        pos += m.position() + m.length();
    };

    cerr << count << "\n";
    return 0;
};
$ g++ -O2 -Wall ./regexp.cpp -o ./a++.out

$ time ./a++.out ./Война\ и\ мир\,\ 1.fb2 > /dev/null
221345

real	0m2.867s
user	0m2.856s
sys	0m0.006s

Работает она так же медленно, как и сишный вариант TC. Теперь делаем небольшое изменение:

#include  <filesystem>
#include  <fstream>
#include  <iostream>
#include  <regex>
#include  <vector>

using namespace std;
using namespace std::filesystem;

int main ( int argc, char * argv[] ) {

    string name = argv[ 1 ];

    auto size = file_size( name );
    vector< char > text;
    text.reserve( size + 1 );
    text.resize( size );

    auto file = std::ifstream( name );
    file.read( text.data(), text.size() );
    file.close();
    text.push_back( 0 );

    cout << "go\n";
    auto re = regex(
        "[\xD0\xD1]"
        "[\x80-\xBF]"
        "[\x80-\xBF\xD0\xD1]*"
    );
    size_t pos = 0;
    size_t count = 0;
    vector< char > const & ctext = text;
    while ( pos < size ) {
        cmatch m;
        if ( not regex_search( ctext.data() + pos, ctext.data() + size, m, re ) ) {
            break;
        };
        count += 1;
        cout << setw( 7 ) << left << count << m.str() << "\n";
        pos += m.position() + m.length();
    };

    cerr << count << "\n";
    return 0;
};

Вуаля, программа почти летает:

$ time ./a++.out ./Война\ и\ мир\,\ 1.fb2 > /dev/null
221345

real	0m0.086s
user	0m0.079s
sys	0m0.006s

Лишь чуточку уступая в скорости Луа:

$ time ./regexp.lua ./Война\ и\ мир\,\ 1.fb2 > /dev/null

real	0m0.075s
user	0m0.058s
sys	0m0.017s

Ну и для примера, время чемпиона:

$ time ./regexp.pl ./Война\ и\ мир\,\ 1.fb2 > /dev/null

real	0m0.040s
user	0m0.035s
sys	0m0.005s

debugger ★★★★★
()
Ответ на: комментарий от Megaorcus

Но по видимому в Lua не так.

Твой пример не есть доказательство твоему утверждению. Я говорил про извлечение подстрок, а в твоём примере — конструирование новой строки.

debugger ★★★★★
()
Ответ на: комментарий от debugger

Возможно, но я упростил цикл в Cи коде до простого подсчёта количества совпадений без извлечения подстрок. Скорость программы не изменилась. Значит не в копирование строк дело. А дело в квадратичной сложности поиска подстроки по образцу. Сравнил с файлами разных размеров:

~ $ du -h tolstoy/books/23.txt
1.9M    tolstoy/books/23.txt
~ $ du -h tolstoy/books/01.txt
336K    tolstoy/books/01.txt
~ $ time ./count tolstoy/books/01.txt
Всего слов: 29962

real    0m0.504s
user    0m0.491s
sys     0m0.013s
~ $ time ./count tolstoy/books/23.txt
Всего слов: 168094

real    0m12.599s
user    0m12.583s
sys     0m0.013s
~ $ lua
Lua 5.3.6  Copyright (C) 1994-2020 Lua.org, PUC-Rio
> 1900/336
5.6547619047619
> 168094/29962
5.6102396368734
> 12.583/0.491
25.627291242363
>

Т.е. с увеличением размера файла в 2 раза время увеличивается в 4. По видимому в Lua совпадения ищутся в пределах подстроки ограниченной символом перевода строки ‘\n’. А у меня в Си он каждый раз сканирует весь файл.

Megaorcus
() автор топика
Ответ на: комментарий от Megaorcus

Да все виснет

local buff = ""
for line in io.lines(arg[1]) do
    buff = buff .. line .. "\n"
en
lua test.lua DataBreaches/EYEOFGOD.csv

На питоне же такое быстро работает:

~/workspace
❯ cat test.py
import sys
import io

sio = io.StringIO()
for line in open(sys.argv[1]):
    sio.write(line)
print("Size:", sio.tell())

~/workspace
❯ time python test.py DataBreaches/EYEOFGOD.csv
Size: 29610044
python test.py DataBreaches/EYEOFGOD.csv  0.21s user 0.05s system 99% cpu 0.259 total
rtxtxtrx ★★★
()
Ответ на: комментарий от Werenter

Да я показал как его ускорить в 1000 раз. В LUA так же можно сделать? (Код на LUA валидный я просто неполностью его скопировал)

rtxtxtrx ★★★
()
Последнее исправление: rtxtxtrx (всего исправлений: 1)
Ответ на: комментарий от Megaorcus

Да всё уже написано, осталось только собрать воедино.

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

Ты сообщение от vel видел? Он указал куда тратится время. Зря только он накатил бочку на glibc, которая в тормозах не сильно виновата. Этот интерфейс (regcomp, regexec) определяеся стандартом POSIX, и, соответственно стандарту, в regexec передаётся только указатель, длину строки regexec вынужден вычислять каждый раз заново.

Во втором плюсовом варианте из моего сообщения в функцию поиска передаются два указателя: на начало и на конец строки — и этот вариант программы летает.

debugger ★★★★★
()

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

rtxtxtrx ★★★
()
Ответ на: комментарий от debugger

Иными словами, ТС прав, когда с самого начала утверждал "стандартные POSIX регексы из glibc работают на порядок медленнее чем в … ". Проблема - определение интерфейса regexec, которое так сделано из-за ограничений С. Так?

VIT
()
Ответ на: комментарий от VIT

Иными словами, ТС прав, когда с самого начала утверждал "стандартные POSIX регексы из glibc работают на порядок медленнее чем в … ". Проблема - определение интерфейса regexec, которое так сделано из-за ограничений С. Так?

Как тебе сказать… ТС нашёл пример, когда программа на Си работает значительно медленнее аналогичной программы на Луа. Пример конкретный — очень длинная строка, в которой много-много-много раз ищется вхождение, соответствующее регулярному выражению. Но вот формулировка проблемы ТСом («стандартные POSIX регексы из glibc работают на порядок медленнее чем в Lua») опускает множество деталей, и получается слишком сильное обобщение.

Прав ли ТС? В общем случае нет: можно составить другой пример, когда из массива с несколькими миллионами коротких строк будут выбираться строки, соответствующие регулярному выражению. Будут ли в этом примере «стандартные POSIX регексы из glibc на порядок медленнее»? (Особенно если склеивать соответствующие строки в одну большую…)

Вторая половина твоей формулировки «определение интерфейса regexec, которое так сделано из-за ограничений С» тоже не вполне точна. Строго говоря, обсуждаемая проблема возникла не из-за сишных ограничений, а из-за сишных э-э-э… традиций, что ли. Ничто не мешает реализовать на Си другой интерфейс, например:

int regexec(const regex_t * preg, const char * string, size_t len,
    size_t nmatch, regmatch_t pmatch, int eflags);

(обрати внимание на параметр len), и проблемы не будет. Собственно, в плюсовой библиотеке так и сделали. Почему разработчики POSIX стандарта не сделали это сразу — хз. Видимо, не рассчитывали на такой случай, как у ТС.

P. S. Возможно, есть ещё один вариант: переписать regexec так, чтобы длина строки ему была не нужна, но в эту область я лезть не хочу.

debugger ★★★★★
()
Последнее исправление: debugger (всего исправлений: 1)
Ответ на: комментарий от VIT

Проблема - определение интерфейса regexec, которое так сделано из-за ограничений С. Так?

Перетак и нет не так :D Lua на том же C и написана и внутри работает как обычный C. Хватит тут выдумывать на пару с @debugger :D Ограничения в си какие-то, подстроки медленные, хоспади, у си меньше ограничений чем у любого языка практически, а строки там, во первых их нет, а во вторых они самые быстрые среди всех, в lua есть просто иммутабельность, когда если взята 1 строка раметром 5 байт и она встречается в воде, в коде 1000000 раз то она будет занимать 5 байт и всё.

Это плюс, он очень бустит скорость, за счёт исключения трасфера данных и дубликации, часто всё в кешах крутится что в сотни раз быстрее, одна из сравниваемых строк например может 10000000 раз лежать в кеше, практически нулевая стоимость доступа, а там сейчас мегабайты влазазят, вот тебе и буст за счёт локальности данных. Хотя как сказано выше можно и обратный эффект словить бездумно конкатенируя, а это аллокация и поиск существования. Реализация регрексов в POSIX наверное быстрая, а вот подход к данным медленный, наверное если слово встречается 10000000 раз оно столько же раз аллокацию делает, заполняет и что-то там у себя ещё строит, но эт смотреть надо.

И да в lua нет регрексов там шаблоны, местами похоже, местами очень, делает тоже самое, но не совсем, но в обычных случаях неотличимо, в сложных несравнимо, оно просто разное. Короче дьявол в технических мелочах и оконечной реализации конкретного места. Скорость луа выезжает на алгоритмах тем самым оно нивелирует в целом неспешную работу языка до приличных значений, а обычные С реализации часто опираются на дефолтный перфоманс из коробки, быстро по умолчанию, но как только дело доходит до трансфера данных 10005000 миллиардов аллокаций, деревьев и прочего, начинается адок, (в любом языке) и нужно заруливать оптимизации алгоритмические, но у них есть минус, оптимизации не могут быть общими, они покрывают частные случаи и мне кажется POSIX грепы просто сделаны такими потому что они должны быть универсальны, а скорость работы предсказуема. От и всё.

Просто реализация такая какая есть.

LINUX-ORG-RU ★★★★★
()
Последнее исправление: LINUX-ORG-RU (всего исправлений: 1)

стандартные POSIX регексы из glibc работают на порядок медленнее чем в Lua.

Ещё чуть чуть и ты заметишь, что самолеты в небе летают быстрее, чем плавают подводные лодки под водой.

регексы в Lua реализованы очень просто.

Да уж проще некуда. Они вообще не реализованы. Шаблоны в lua НЕ ЯВЛЯЮТСЯ регулярными выражениями, о чём написано вообще везде большими буквами.

То ли лыжи не едут,

Да, ты стоишь в них на асфальте в июне месяце.

LamerOk ★★★★★
()
Последнее исправление: LamerOk (всего исправлений: 1)
Ответ на: комментарий от LINUX-ORG-RU
text = io.open(arg[1], "r"):read("*a")
pattern = "[\xD0\xD1][\x80-\xBF][\x80-\xBF\xD0\xD1]*"
local uniq  = { }
local count =  0
string.gsub(text,pattern,function(x)
    if not uniq[x] then
       uniq[x] = true;
       count = count + 1
    end
end)
print("Всего уникальных слов: " .. count)
time  lua5.4 main.lua  wim.fb2
Всего уникальных слов: 35515

real	0m0,196s
user	0m0,171s
sys	0m0,024s

35 тыщ уникальных слов ниочём.

dron@gnu:~/Рабочий-стол/Толстой Лев. Война и мир. Том 1 и 2 - royallib.com.fb2$ g++  -g3 -O3 main.cpp 
dron@gnu:~/Рабочий-стол/Толстой Лев. Война и мир. Том 1 и 2 - royallib.com.fb2$ valgrind ./a.out 
==669824== Memcheck, a memory error detector
....
==669844== HEAP SUMMARY:
==669844==     in use at exit: 0 bytes in 0 blocks
==669844==   total heap usage: 757,756 allocs, 757,756 frees, 82,083,147 bytes allocated
==669844== 

Вот тебе и, слишком много аллокаций в регрепах, убавить и взлетит как самолёт. (хотя после дел @debugger оно и так уже летает)

Так что неча ругать, и то и то хорошее :) Все молодцы и умницы

LINUX-ORG-RU ★★★★★
()
Последнее исправление: LINUX-ORG-RU (всего исправлений: 2)
Ответ на: комментарий от VIT

Всем значит можно, а мне нельзя. Ишь ты, щас вот =)

Вроде разобрался @debugger, неспроста ник носит.

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

Хрен на lua, не об том речь.

Нет не хрен, луа няфка, ибо луа это сишный конфиг на стероидах.

LINUX-ORG-RU ★★★★★
()
Ответ на: комментарий от Megaorcus

Т.е. с увеличением размера файла в 2 раза время увеличивается в 4. По видимому в Lua совпадения ищутся в пределах подстроки ограниченной символом перевода строки ‘\n’. А у меня в Си он каждый раз сканирует весь файл. Переписал код так, чтобы regexec искал совпадения в подстроках ограниченный символом ‘\n’

// извлечение строки из текста
char* newline(char* line, char* text) {
    // поиск символа '\n'
    int len = 0;
    for (; text[len] != '\n' && text[len] !='\0'; ++len);
    if (text[len] == '\0')
        return NULL;
    else {
        len = (len < MAXLINE) ? len : MAXLINE - 1;		 
        memcpy(line, text, len);
        line[len] = '\0';
        // начало следующей строки
        text += len + 1;
        return text;
	}
}
   ....
regmatch_t match;
char word [MAXWORD];
char line [MAXLINE];
long count = 0;
char* pos_line = text;
while ((pos_line = newline(line, pos_line)) != NULL) {
    // начальная позиция поиска
    char* pos = line;
    while (regexec(&regex, pos, 1, &match, 0) == 0) {
        int len = match.rm_eo - match.rm_so;
        // ограничение длины слова
        len = (len < MAXWORD) ? len : MAXWORD - 1;
        // извлечение слова
        memcpy(word, pos + match.rm_so, len);
        word[len] = '\0';
        // смещаем начало следующего поиска
        pos += match.rm_eo;
        ++count;
    }
}
printf("Всего слов: %ld\n", count);

И вышло ускорение в 90 раз.

~ $ time ./count1 voina-i-mir.fb2
Всего слов: 531982

real    0m3.110s
user    0m3.088s
sys     0m0.021s

По сравнению с первоначальный 4 мин 37 сек прирост значительный, хотя и не дотягивает пока до скорости Lua.

Megaorcus
() автор топика
Ответ на: комментарий от cobold

Дело как раз и именно в С - в нём изначально нет строк, а сложившаяся традиция организовывать строки имеет ограничение. Об этом и разговор. Можно реализовать другие строки, переписать нужную функцию, или вообще взять другой язык, например С++ или Луа, но то, как сделано в С сейчас неоптимально.

VIT
()
Ответ на: комментарий от VIT

Можно. На надо подумать как ограничивать длину строки для regexec(). Первое что приходит на ум - предварительно обработать текст заменив все ‘\n’ на ‘\0’. Ещё вариант - читать файл построчно. Но тут опасность переполнение массива, т.к. изначально длина строки не известна. Разве что брать очень большой буфер, чтобы точно влезло. Или читать каждую строку посимвольно в динамический расширяемый массив. Короче, вариантов много. Будет чем в свободное время заняться.

Megaorcus
() автор топика
       REG_STARTEND
              Match [string + pmatch[0].rm_so, string + pmatch[0].rm_eo)
              instead of [string, string + strlen(string)).  This allows
              matching embedded NUL bytes and avoids a strlen(3) on
              known-length strings.  If any matches are returned
              (REG_NOSUB wasn't passed to regcomp(), the match succeeded,
              and nmatch > 0), they overwrite pmatch as usual, and the
              match offsets remain relative to string (not string +
              pmatch[0].rm_so).  This flag is a BSD extension, not
              present in POSIX.
utf8nowhere ★★★★
()
Ответ на: комментарий от utf8nowhere

я про тупо передавать длину буфера, как тут предлагается.

А также про потоко-небезопасный setenv и прочее. У меня полное впечатление, что 20-30 лет назад люди клепали спеки, вообще не думая о том как это будет реализовано.

…а сейчас клепают реализации, вообще не думая о спеках. Жизнь - боль.

Lrrr ★★★★★
()
Ответ на: комментарий от VIT

А можешь написать то же самое, но не извлекать строку, не использовать memcpy, а искать по месту?

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

// создаем список строк
struct list* root = list_lines(text);
struct list* line = root;
// засекаем время счета слов
clock_t t1 = clock();
while (line != NULL) {
    // начальная позиция поиска в строке
    char* pos = line->pos;
    while (regexec(&regex, pos, 1, &match, 0) == 0) {
        pos += match.rm_eo;
        ++count;
    }
    // переходим к следующей строке
    line = line->next;
}
clock_t t2 = clock();
printf("Всего слов: %ld\n", count);
printf ("Время поиска: %.3f с\n",(double)(t2 - t1) / CLOCKS_PER_SEC);
listfree(root);
regfree(&regex);
free(text);
return 0;

Результат такой:

~ $ time ./count2 voina-i-mir.fb2
Всего слов: 531982
Время поиска: 2.941 с

real    0m3.044s
user    0m3.007s
sys     0m0.037s

Как видим 98% времени выполнения программы - это перебор строк и поиск в каждой строке. Но внутри этого цикла строки не копируются, память не выделяется и не очищается. Так что как изначально и предполагал - дело не в скорости копирования строк, а в скорости regexec() glibc. Осталось только из спортивного интереса реализовать ручное сравнение байтов UTF-8 символов.

Megaorcus
() автор топика