Как стать автором
Обновить

Комментарии 415

Главное — правильно померять! =)
Ага, компилятором Intel)
На самом деле, вопрос «какой код быстрее?» имеет больше прикладной вопрос, чем реальный. В реальности начинают с вопроса «какой разработчик дешевле?» )
Включение галки Optimize Code в Net Core 3.1 ускоряет выполнение в 4 раза на моём i7-4790 (0,9с против 3.6с)
Кроме того C# используют безопасное обращение к массиву.

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


Я C# не знаю, так что если вы дадите вариант с ансейф-доступом, то я с радостью его прогоню на той же машине под Mono. Ну и да, в Java-то обращения тоже безопасные, насколько я могу судить.

Доберусь домой сравню у себя на машине с растом.
Сделал условие циклов по s1.Length и s2.Length
Убрал преобразование строки в массив байт.
Эта версия работает 0.63 сек
using System;
using System.Diagnostics;
using System.Linq;

public class Program
{
    public static Int32 LevDist(string s1, string s2)
    {
        var m = s1.Length;
        var n = s2.Length;

        // Edge cases.
        if (m == 0)
        {
            return n;
        }
        if (n == 0)
        {
            return m;
        }

        var v0 = Enumerable.Range(0, n + 1).ToArray();
        var v1 = new int[n + 1];
        v0.CopyTo(v1, 0);

        for (var i = 0; i < s1.Length; ++i)
        {
            v1[0] = i + 1;

            for (var j = 0; j < s2.Length; ++j)
            {
                var substCost = (s1[i] == s2[j]) ? v0[j] : (v0[j] + 1);
                var delCost = v0[j + 1] + 1;
                var insCost = v1[j] + 1;

                v1[j + 1] = Math.Min(substCost, delCost);
                if (insCost < v1[j + 1])
                {
                    v1[j + 1] = insCost;
                }
            }

            var temp = v0;
            v0 = v1;
            v1 = temp;
        }

        return v0[n];
    }

    public static void Main()
    {
        var s1 = new String('a', 15000);
        var s2 = new String('a', 15000);
        var s3 = new String('b', 15000);

        for (var i = 0; i < 5; i++)
        {
            Stopwatch execTimer = new Stopwatch();
            execTimer.Start();

            Console.WriteLine(LevDist(s1, s2));
            Console.WriteLine(LevDist(s1, s3));

            execTimer.Stop();
            var execTime = execTimer.ElapsedMilliseconds / 1000.0;

            Console.WriteLine($"Finished in {execTime:0.000}s");
        }
    }
}


Unsafe версию не выкладываю — она работает примерно так же. Видимо из-за необходимости пинить указатель, или ещё какие-то накладные расходы вылазят.

пс. кстати у вас в описании теста указан размер строки 20000, а в бенчах везде 15000. Это так задумано?
Не только, в коде С++ там тоже 20000, а во всех остальных языках 15000 даже в С, что как бы намекает.
что как бы намекает.

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


Код для всех примеров сравнивает строки длиной 15 тысяч символов, а не 20, однако числа перенормированы для длины в 20 тысяч символов.
Может народ дурить не будешь, в коде С++ ты 4 раза выделил память на 20000 символов
    std::string s1(20000, 'a');
    std::string s2(20000, 'a');
    std::string s3(20000, 'b');

  std::vector<int64_t> v0;
  v0.resize(n + 1);
  std::iota(v0.begin(), v0.end(), 0);


а в коде С ты выдялешь 15000
   const int len = 15000;
    int i;
    char s1[15001], *s2 = s1, s3[15001];

причем создаешь как ты говоришь на СТЕК, а не на динамической памяти как в С++.

Это вообще буквально до замера всё делается.
Не влияет ну совсем.

Еще один, он потом пробегает это в двух вложенных циклах, именно размер созданого массива.

Да. У C голый результат с 15 тыщами символов получится в районе 0.5-0.6 секунд (на память). После нормировки на разные длины получится то, что приведено в таблице.


Собственно, сишная реализация была одной из тех, корректность переномировки которых я проверил отдельно.

А размеры…


Код для всех примеров сравнивает строки длиной 15 тысяч символов, а не 20, однако числа перенормированы для длины в 20 тысяч символов.

ну запусти сам и проверь разницу 15 и 20 тыс.

Если хочешь, вот что я сравнивал (15 000):


C
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>

static long
lev_dist (const char *s1,
          const char *s2)
{
    unsigned long m, n;
    unsigned long i, j;
    long *v0, *v1;
    long ret, *temp;

    m = strlen (s1);
    n = strlen (s2);

    /* Edge cases. */
    if (m == 0) {
        return n;
    } else if (n == 0) {
        return m;
    }

    v0 = malloc (sizeof (long) * (n + 1));
    v1 = malloc (sizeof (long) * (n + 1));

    if (v0 == NULL || v1 == NULL) {
        fprintf (stderr, "failed to allocate memory\n");
        exit (-1);
    }

    for (i = 0; i <= n; ++i) {
        v0[i] = i;
    }
    memcpy (v1, v0, n + 1);

    for (i = 0; i < m; ++i) {
        v1[0] = i + 1;

        for (j = 0; j < n; ++j) {
            const long subst_cost = (s1[i] == s2[j]) ? v0[j] : (v0[j] + 1);
            const long del_cost = v0[j + 1] + 1;
            const long ins_cost = v1[j] + 1;

#if !defined(__GNUC__) || defined(__llvm__)
            if (subst_cost < del_cost) {
                v1[j + 1] = subst_cost;
            } else {
                v1[j + 1] = del_cost;
            }
#else
            v1[j + 1] = (subst_cost < del_cost) ? subst_cost : del_cost;
#endif
            if (ins_cost < v1[j + 1]) {
                v1[j + 1] = ins_cost;
            }
        }

        temp = v0;
        v0 = v1;
        v1 = temp;
    }

    ret = v0[n];
    free (v0);
    free (v1);
    return ret;
}

int
main ()
{
    const int len = 15000;
    int i;
    char s1[15001], *s2 = s1, s3[15001];
    clock_t start_time, exec_time;

    for (i = 0; i < len; ++i) {
        s1[i] = 'a';
        s3[i] = 'b';
    }
    s1[len] = s3[len] = '\0';

    start_time = clock ();

    printf ("%ld\n", lev_dist (s1, s2));
    printf ("%ld\n", lev_dist (s1, s3));

    exec_time = clock () - start_time;
    fprintf (stderr,
             "Finished in %.3fs\n",
             ((double) exec_time) / CLOCKS_PER_SEC);
    return 0;
}

C++
#include <chrono>
#include <cstdio>
#include <numeric>
#include <string>
#include <vector>

static long
lev_dist(const std::string &s1,
         const std::string &s2) noexcept
{
    const auto m = s1.size();
    const auto n = s2.size();

    // Edge cases.
    if (m == 0) {
        return n;
    } else if (n == 0) {
        return m;
    }

    const auto ca1 = s1.c_str();
    const auto ca2 = s2.c_str();

    std::vector<long> v0(n + 1);
    std::iota(v0.begin(), v0.end(), 0);

    auto v1 = v0;

    for (size_t i = 0; i < m; ++i) {
        v1[0] = i + 1;

        for (size_t j = 0; j < n; ++j) {
            const auto subst_cost = (ca1[i] == ca2[j]) ? v0[j] : (v0[j] + 1);
            const auto del_cost = v0[j + 1] + 1;
            const auto ins_cost = v1[j] + 1;

            // std::min({ subst_cost, del_cost, ins_cost }) is slow.
            v1[j + 1] = std::min(subst_cost, del_cost);
            if (ins_cost < v1[j + 1]) {
                v1[j + 1] = ins_cost;
            }
        }

        std::swap(v0, v1);
    }

    return v0[n];
}

int
main()
{
    const auto s1 = std::string(15000, 'a');
    const auto &s2 = s1;
    const auto s3 = std::string(15000, 'b');

    const auto start_time = std::chrono::steady_clock::now();

    printf("%ld\n", lev_dist(s1, s2));
    printf("%ld\n", lev_dist(s1, s3));

    const auto end_time = std::chrono::steady_clock::now();
    fprintf(stderr,
            "Finished in %.3lfs\n",
            std::chrono::duration<double>(end_time - start_time).count());
    return 0;
}

Получив:


  • gcc 9.2: ~0.727с
  • clang 9: ~0.775с
  • g++ 9.2: ~0.869с
  • clang++ 9: ~0.791с
Не влияет ну совсем.

Так-то стек лежит надёжно в L1-кэше процессора, а динамическая память — далеко-далеко и её кучу раз придётся доставать.

Как это стек лежит в L1-кеше, если там минимум 60 килобайт на три строки, а кеш весь целиком в два раза меньше?

Оу, да, погорячился. В L1 будет только верхушка, остальное в L2.
20000 символов

Про это я уже написал. Это написано в тексте поста, это написано в комментариях теперь уже раза три, так что я не понимаю, что вам тут не нравится.


4 раза

Технически — пять раз, там ещё копия этого v0 есть.


причем создаешь как ты говоришь на СТЕК, а не на динамической памяти как в С++.

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


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

1. 20000 и 15000 это разный обьем памяти и циклов.
2. Количество памяти влияет на кеш, а он как известно очень мал
3 ????
4 выдляем 100500 нормируем и говорим что С++ медленный.

В обоих случаях данные не помещаются в L1. В обоих случаях данные целиком помещаются в L2.


Раз вы не верите, что нормировку я проверил, давайте я сделаю это при вас. Правда, на другой машине с другим процессором, к той у меня сейчас доступа нет.


Код прямо из поста, 15 тыщ символов, минимальное время из пяти запусков — 0.578 секунд, стандартное отклонение — 0.0026 секунды.
Поменял везде 15 тыщ на 20 тыщ, минимальное время — 1.031 секунд, стандартное отклонение — 0.0021 секунды.
Нормировка: 0.578 * (4 / 3) ** 2 даёт 1.028 секунд.


1.028 достаточно близко к 1.031? Особенно учитывая, что разница между ними примерно равна стандартному отклонению каждого из них?

В чистом С коде «нечестно» вызывать strlen, поскольку там вместо немедленноо return m_stringLength происходит поиск в массиве из 20001 char элементов значения, равного нулю. Поэтому unsigned long m, n нужно передавать как параметры lev_dist — на моей стороне это съедает лишние 2% или около того.

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


Что, в принципе, ожидаемо, так как на цикл приходится, считайте, всё время работы программы, а strlen дёргается вне цикла.

Возможно, на вашей стороне разница просто перекрывается обычным разбросом значений между запусками. Я пробую на ноутбучном i7-4710HQ, и у меня разница заметна.

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

Разница перекрывается не "обычным разбросом значений", а двумя вложенными циклами.


Когда время выполнения имеет вид a N2 + b N + c, то для достаточно большого N на коэффициенты b и c просто пофигу.


Да, при особо неудачном раскладе вы можете из-за strlen получить время 101%. Но если там вышло 190% — дело точно не в strlen.

Еще в чистом С коде ошибка
Должно быть
memcpy(v1, v0, sizeof (long) * (n + 1));

Поправил, спасибо.

Разницы никакой, так как эти данные никто не читает.

Более того, массив v1, по хорошему, вообще не нужен.
ну просто понятно, что код на хаскеле, который вы оптимайзили 2 часа будет работать возможно лучше, чем нативный на цпп, но его же тоже можно прооптимайзить? Массивы, быстрый вывод и уже должен полететь сильно быстрее
но его же тоже можно прооптимайзить?

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


Массивы

Так там и так массив.


быстрый вывод

Он делается один раз за время жизни программы, в самом конце.

в ++ах стринги а не массив (ну если я правильно смотрел), логика вычисления минимума сильно избыточная и лишние +1-ы, которые можно не делать 2 раза, а просто выбрать минимум и бахнуть ++. Ну просто на хаскеле вы за 0.06 секунд сражались, а тут как-то нативненько, я бы сказал
логика вычисления минимума сильно избыточная и лишние +1-ы, которые можно не делать 2 раза, а просто выбрать минимум и бахнуть ++

Про +1 хорошее замечание. Я переместил его внутрь, как в хаскеле — ничего не изменилось. Кроме того, я даже сделал GVN руками, сохраняя предыдущее значение на стеке для следующей переменной (как последняя оптимизация в хаскеле) — плюсам от нее ни холодно, ни жарко, видать, gcc 9.2 и clang 9 оба умеют ее делать (что не расходится с моими ожиданиями, я не так давно про нее где-то читал, в частности, что в них она реализована).


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

Массивы
Так там и так массив.
Строки — это не массивы. Это странная штука, которая заточена под быстрое-быстрое копирование всякого-разного из одного места в другое и редкие операции, собственно, с их элементами.

Ассимтотика там такая же как в массивах, но константа — заметно выше по скорости и заметно ниже по памяти.
Строки — это не массивы. Это странная штука, которая заточена под быстрое-быстрое копирование всякого-разного из одного места в другое и редкие операции, собственно, с их элементами.

Это в плюсах? На длинах порядка тысяч, когда SSO не играет?

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

Да, теоретически компилятор может эту проверку вытащить наружу и сделать 4 версии — но не факт, что он это делает. Проверять нужно.

А процессор, конечно, справится без вопросов — но всё давно ему эти инструкции нужно декодировать, как минимум…

Ну что ж, std::string не подходит для строк. Неприятное открытие для меня.


Надо мне уже завязывать с плюсами, что-то это все как-то...

Ну что ж, std::string не подходит для строк. Неприятное открытие для меня.
Просто строки, в большинстве программ, это не то, что вы думаете. В Java отличие вообще явное: есть java.lang.String (где строка обычно воспринимается «как единое целое»), есть java.lang.StringBuilder.

А в C++ всё можно сделать и прямо в std::string, а можно — перебросив их в std::vector… но это имеет смысл делать только если строки большие.

Если маленькие, в среднем меньше 20-30 символов — то затраты на переброску не окупят ускорение. А они, в реальных программах, как правило такие и есть.

Надо мне уже завязывать с плюсами, что-то это все как-то...
Ну дык. Где вы ещё столько ногострелов найдёте в одном месте? Разве что в PHP и то не факт…
Просто строки, в большинстве программ, это не то, что вы думаете.

Ну так там уникод какой или ещё какая ерунда, что не даёт относиться к ним как к массиву байт. А тут, казалось бы...


Где вы ещё столько ногострелов найдёте в одном месте?

В моем личном зачёте после плюсов пока лидирует Coq. Но оно там несколько другого рода.

Ну так там уникод какой или ещё какая ерунда, что не даёт относиться к ним как к массиву байт.
Именно.

А тут, казалось бы...
Структура, разработанная для работы с объектами, которые почти никогда не обрабатываются как массив байт плохо справляется с этим?

Чего вас удивляет?

Ну это всё равно как если бы вы использовали для замера скорости движения автомобиля автокран, экскаватор или вообще шасси для запуска ракет С-400 и удивлялись — а чё они так медленно ездят-то?

А потому что они не для этого. Да, они, конечно, и автомобили тоже… но это — не их основное предназначение!

В моем личном зачёте после плюсов пока лидирует Coq.
Ну вот… и тут C++ не лидер.
Структура, разработанная для работы с объектами, которые почти никогда не обрабатываются как массив байт плохо справляется с этим?

Ну, может, это я такой, но строки как массивы байт нужно довольно часто обрабатывать.


Другое дело, что нечасто это строки хотя бы в несколько килобайт.


Ну вот… и тут C++ не лидер.

Лидер-лидер, все остальное как раз следует за ним.

Другое дело, что нечасто это строки хотя бы в несколько килобайт.
Ну вот тут и порылась собака: строки, в современном C++, заточены под выигрыш за счёт отказа (довольно частого) от new/delete. Когда строк много, они, в среднем, короткие, копируют их часто (а внутрь смотрят мало) — получается очень хороший результат.

А вот когда все строки длинные, копируют их мало, внутрь смотрят часто… тогда выигрыша от отказа от new/delete — нет… а плата за него (хоть и небольшая) — есть…

Так-то tradeoff неплохой: проигрыш — всегда не слишком большой (почти никогда не больше чем вдвое, обычно процентов 20-30, даже если специально делать плохие условия для них), а вот выигрыш — может быть очень и очень приличным… но вот как раз конкретно в вашем тесте проигрыш есть, выигрыша — нету.
Ну, может, это я такой, но строки как массивы байт нужно довольно часто обрабатывать.

Это вы такой. Прикладному программисту строки надо считывать, выводить, парсить, формировать через форматирование-интерполяцию или конкатенацию, в крайних случаях менять в них регистр или искать подстроки.


Для всего этого придумываются свои структуры данных, не являющиеся просто массивами байт (и std::string в текущих реализациях — далеко не самая сложная из них).

А в C++ всё можно сделать и прямо в std::string, а можно — перебросив их в std::vector… но это имеет смысл делать только если строки большие.

А в чём смысл перебрасывания, когда в std::string есть метод reserve(size_type)? Вполне аналог функций StringBuilder-а.

Вы дискуссию-то читали? У std::string дорогой operator[]. Примерно вдвое дороже, чем у std::vector.

Да, это 3-4 операции вместо 1-2, но это всё равно разница вдвое.

Однако поскольку она мала в абсолютных величинах это не имеет смысла если вы применяете к строке «однопроходные» алгоритмы: времени на копирование туда и обратно будет потрачено столько, что операции сразу с std::string будут быстрее.

А вот если у вас алгоритм O(N²)… или даже если вы просто делаете 10 проходов при обработке вашей строки… это может иметь смысл.

Это из за small-string-optimization лишние операции?
Ну а если string_view. тогда должен быть просто char* + size_t, и не надо делать проверку где лежат данные.

Ну что ж, std::string не подходит для строк.

недавно ктото заявил что std::stream не подходит для юникода.
Вообще за 5 лет универа и 10 лет работы, всё еще понимаю что ничего не понимаю про строки (а ещё про даты и плавающую запятую, а ещё и про файловые системы) и чем дальше, тем больше накапливается разного непонимания.
Особенно те, где! американские_символы. Нам раньше каждую неделю присылали какой то иероглиф в тикет, который был должен отрисовываться как то не так. Например не справаналево, а снизувверх например.

Зато приятно общаться с учениками, горит огонь в глазах, и думают что уже всё знают :)
Особенно те, где! американские_символы. Нам раньше каждую неделю присылали какой то иероглиф в тикет, который был должен отрисовываться как то не так.

Не, ну я в своё время писал строки, которые адекватно поддерживают UTF-8 и умеют работать в терминах уникодовых символов (не глифов, нет, просто единичных символов, забыл, как они там на юникодовом называются) с O(1)-доступом к символу по индексу, и там как раз было предельно понятно, что std::string не подходит, но тут-то… массив байт...

У Вас сложность O(n) или более нелинейная? Корректность нормировки вызывает сомнения.

O(n²) в данном случае.

кстати у вас в описании теста указан размер строки 20000, а в бенчах везде 15000. Это так задумано?

Да. C++-код и хаскель-код из первой «части» писался мной, и так как я работал над всего двумя языками, мне было не лень гонять их на чуть больших данных. Для остальных языков это куда больнее, особенно когда они работают в 100-300 раз дольше этих двух версий.


Так как сложность алгоритма — O(mn), т. е. квадратичная для строк одинаковой длины, можно просто умножить ваш результат на соотношение длин строк (т. е. на (4/3)²).


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

Простите, но на кой чёрт вообще сдалась эта нормировка? Почему во всех языках не запускать тест на одинаковом числе символов?
Официальный ответ тут… но как по мне — это очень хороший способо отловить «разоблачителей», которых не волнует ничего, кроме обсирания «неправильной», как им кажется, точки зрения.

Очень хорошо получилось и прекрасно показало что публика на Хабре вовсе не так продвинута, как она сама о себе думает…
Очень хорошо получилось и прекрасно показало что публика на Хабре вовсе не так продвинута, как она сама о себе думает…

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


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

Пресловутый «король» это уже проверил — не воспринимают.

Ну то есть после громких криков даже его редкие разумные комментарии минусуют.

Наверное нужно что-то ещё.

Ну и для красоты, замените


var temp = v0;
v0 = v1;
v1 = temp;

на


(v0, v1) = (v1, v0);

Чем мы хуже гошников, в конце-то концов? :)

Чем мы хуже гошников, в конце-то концов? :)

Таки тем, что это C#7 без особой на то причины (а там замер с Mono 5).

Но таблице указано Mono 6.6.0

А, и правда.

Эта верия работает еще быстрее
using System;
using System.Diagnostics;
using System.Linq;
using System.Text;

public class Program
{
    public static Int32 LevDist(string s1, string s2)
    {
        if (s1.Length == 0)
        {
            return s2.Length;
        }
        else if (s2.Length == 0)
        {
            return s1.Length;
        }

        var v0 = new int[s2.Length + 1];
        var v1 = new int[s2.Length + 1];
        for (int i = 0; i < v0.Length; i++)
        {
            v0[i] = v1[0] = i;
        }

        for (var i = 0; i < s1.Length; i++)
        {
            v1[0] = i + 1;

            for (var j = 0; j < s2.Length; j++)
            {
                var substCost = (s1[i] == s2[j]) ? v0[j] : (v0[j] + 1);
                var delCost = v0[j + 1] + 1;
                var insCost = v1[j] + 1;

                v1[j + 1] = Math.Min(substCost, delCost);
                if (insCost < v1[j + 1])
                {
                    v1[j + 1] = insCost;
                }
            }

            (v0, v1) = (v1, v0);
        }

        return v0[s2.Length];
    }

    public static void Main()
    {
        string s1 = new String('a', 15000);
        string s2 = s1;
        string s3 = new String('b', 15000);

        for (var i = 0; i < 5; i++)
        {
            Stopwatch execTimer = new Stopwatch();
            execTimer.Start();

            Console.WriteLine(LevDist(s1, s2));
            Console.WriteLine(LevDist(s1, s3));

            execTimer.Stop();
            double execTime = execTimer.ElapsedMilliseconds / 1000.0;

            Console.WriteLine($"Finished in {execTime.ToString():0.000}s");
        }
    }
}


Тут Enumerable.Range(...) заменен на обычный масив и для красоты сделал (v0, v1) = (v1, v0). Выполняться стало за 0.55 вместо 0.65. Запускал на .NET Core SDK (3.1.100), следующим образом dotnet run -c release

На той же машине раст отрабатывает за 0.490. Запускался через cargo run --release

Что-то все эти изменения к mono (на которой я могу проверить) не очень дружественны. Эта версия выполняется ещё медленнее — 3.189 с минимальное время.

Честно говоря не очень ясно, кому сегодня нужен чистый Mono, когда есть .Net Core. Учитывая что MS купили Xamarin вместе с Mono и частично используют его в Core.
А кому вообще нужен чистый C# без Windows? В каком-нибудь Unity уже .Net Core или всё ещё Mono?

Пока там Mono — сказать, что Mono никому не нужен таки нельзя…
.Net Core вполне себе кросс-платформенный.
И есть даже возможность компиляции нативных бинарников (например CoreRT).
Mono разве что в Unitiy, и то со временем думаю перейдут на Core.
Ну вот когда перейдут — тогда, я думаю, и автор про Mono забудет…
Половина(если не больше) топ игор в appstore и google play написаных на Unity. Разные красивые AR демки от Google и Apple сразуже имеют поддержку Unity или вообще написаны на ей. Все это так, фигня.
Конечно нет.
Просто, имхо, количестов C# кода в играх на unity(там логику можно писать не только на C#) мне кажется меньшим чем той же охапки бизнесс-логики написанных для ASP
Как минимум я считаю что он скорее приближаетсяпо сложности ASP.NET сейчас.
Да, возможно это просто моё субъективное восприятие, а реальное положение дел иное.

Разве ASP ещё не перенесли на .NET Core?

Есть AspNet Core, но он не совместим с обычным AspNet. Много старого легаси осталось фреймворке. Все новое уже на core.
Ну не вижу ничего особенного. Цель моно была быть максимально совместимым с .Net framework. Там даже некоторые баги пришлось копировать, чтобы ничего не сломать. .Net Core же сделан с «нуля» без груза обратной совместимости.

Еще немного оптимизаций. Вынес вывод в консоль из замеров. Стало работать за 0.54-0.53. ТОже самое для rust стало 0.47-0.48
using System;
using System.Diagnostics;
using System.Linq;
using System.Text;

public class Program
{
    public static Int32 LevDist(string s1, string s2)
    {
        if (s1.Length == 0)
        {
            return s2.Length;
        }
        else if (s2.Length == 0)
        {
            return s1.Length;
        }

        var v0 = new int[s2.Length + 1];
        var v1 = new int[s2.Length + 1];
        for (int i = 0; i < v0.Length; i++)
        {
            v0[i] = v1[0] = i;
        }

        for (var i = 0; i < s1.Length; i++)
        {
            v1[0] = i + 1;

            for (var j = 0; j < s2.Length; j++)
            {
                var substCost = (s1[i] == s2[j]) ? v0[j] : (v0[j] + 1);
                var delCost = v0[j + 1] + 1;
                var insCost = v1[j] + 1;

                v1[j + 1] = Math.Min(Math.Min(substCost, delCost), insCost);
            }

            (v0, v1) = (v1, v0);
        }

        return v0[s2.Length];
    }

    public static void Main()
    {
        string s1 = new String('a', 15000);
        string s2 = s1;
        string s3 = new String('b', 15000);

        for (var i = 0; i < 5; i++)
        {
            Stopwatch execTimer = new Stopwatch();
            execTimer.Start();

            var levDist1 = LevDist(s1, s2);
            var levDist2 = LevDist(s1, s3);

            execTimer.Stop();
            
            Console.WriteLine(levDist1);
            Console.WriteLine(levDist2);
            double execTime = execTimer.ElapsedMilliseconds / 1000.0;

            Console.WriteLine($"Finished in {execTime.ToString():0.000}s");
        }
    }
}

У меня на mono 6.6.0 стало чуть медленнее: 3.052 с против предыдущих 2.967 с.

C# версия из статьи на Core 3.1 у меня отрабатывает за 760 ms (Core i7 7700k).
С небольшими оптимизациями — 560 ms: image

Код для BenchmarkDotNet
public class LevensteinDistance
{
    [Params(15_000, 20_000)]
    public int Size;

    private string _s1;
    private string _s2;
    private string _s3;

    [GlobalSetup]
    public void Setup()
    {
        _s1 = new string('a', Size);
        _s2 = new string('a', Size);
        _s3 = new string('b', Size);
    }

    [Benchmark(Baseline = true)]
    public int Levenstein_Orig()
    {
        return LevDist_Orig(_s1, _s2) + LevDist_Orig(_s1, _s3);
    }

    public int LevDist_Orig(string s1, string s2)
    {
        var ca1 = Encoding.UTF8.GetBytes(s1);
        var ca2 = Encoding.UTF8.GetBytes(s2);

        return LevDist_Orig(ca1, ca2);
    }

    public int LevDist_Orig(byte[] s1, byte[] s2)
    {
        var m = s1.Length;
        var n = s2.Length;

        // Edge cases.
        if (m == 0)
        {
            return n;
        }
        else if (n == 0)
        {
            return m;
        }

        Int32[] v0 = Enumerable.Range(0, n + 1).ToArray();
        var v1 = new Int32[n + 1];

        v0.CopyTo(v1, 0);

        for (var i = 0; i < m; ++i)
        {
            v1[0] = i + 1;

            for (var j = 0; j < n; ++j)
            {
                var substCost = (s1[i] == s2[j]) ? v0[j] : (v0[j] + 1);
                var delCost = v0[j + 1] + 1;
                var insCost = v1[j] + 1;

                v1[j + 1] = Math.Min(substCost, delCost);
                if (insCost < v1[j + 1])
                {
                    v1[j + 1] = insCost;
                }
            }

            var temp = v0;
            v0 = v1;
            v1 = temp;
        }

        return v0[n];
    }

    [Benchmark]
    public int Levenstein_Opt()
    {
        return LevDist_Opt(_s1, _s2) + LevDist_Opt(_s1, _s3);
    }

    public int LevDist_Opt(string s1, string s2)
    {
        if (s1.Length == 0)
        {
            return s2.Length;
        }
        else if (s2.Length == 0)
        {
            return s1.Length;
        }

        var v0 = Enumerable.Range(0, Size + 1).ToArray();
        var v1 = new int[Size + 1];
        v0.CopyTo(v1, 0);

        for (var i = 0; i < s1.Length; i++)
        {
            v1[0] = i + 1;

            for (var j = 0; j < s2.Length; j++)
            {
                var substCost = (s1[i] == s2[j]) ? v0[j] : (v0[j] + 1);
                var delCost = v0[j + 1] + 1;
                var insCost = v1[j] + 1;

                v1[j + 1] = Math.Min(Math.Min(substCost, delCost), insCost);
            }

            (v0, v1) = (v1, v0);
        }

        return v0[s2.Length];
    }
}

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

Только не используйте Range в высокопроизводительном коде:


var v0 = Enumerable.Range(0, Size + 1).ToArray();

Попробуем сравнить версию с Range и обычным циклом с спомощью SharpLab. Вызов Range никак не инлайнится в релизе, поэтому лезем за ним в документацию на referencesource, копируем код, избавляемся от мусора и получаем такое. Как видим, метод крайне не оптимален: используется yield внутри, который генерирует кучу лишнего кода для такой простой операции, как инициализация массива последовательными значениями.

Вы смотрите на исходники .NET Framework. Исходники .NET Core вот тут: source.dot.net.
Дело в том, что метод в Enumerable.Range в Core оптимизирован так, что работает в ~10 раз быстрее, чем в Framework и по времени выполнения почти не отличается от ручного заполнения массива через цикл.

Мои тесты:
image

Код
[Params(15_000)]
public int Size;

[Benchmark]
public int Range()
{
    int[] arr = Enumerable.Range(0, Size + 1).ToArray();
    return arr[0];
}

[Benchmark]
public int Loop()
{
    var arr = new int[Size + 1];

    for (var i = 0; i < Size; i++)
        arr[i] = i;

    return arr[0];
}

Плюс Enumerable.Range выполняется всего один раз в коде автора, так что его влияние на общий результат минимально.
Но в целом вы правы: если бы, скажем, этот метод выполнялся в цикле или размер массива был побольше (500к+) или мы бы взяли Framework вместо Core, тогда да — лучше было бы использовать ручное заполнение.

Хм, действительно, я залез не в особо актуальную версию) Все равно взял себе за правило не использовать yield в высокопроизводительном коде, т.к. встречал как минимум одно место, где из-за него проседала производительность.

yield можно использовать в любом коде, главное учитывать его оверхед.
Если вычислительная сложность задачи между yield'ами высокая, то влияние самого yield'а будет ничтожным.
В тестах С# string, в java и других byte[], не справедливо
C# unsafe
using System;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;

public class Program
{
    [MethodImpl(MethodImplOptions.AggressiveOptimization)]
    public static unsafe Int32 LevDist(char[] s1, char[] s2)
    {
        var m = s1.Length;
        var n = s2.Length;

        // Edge cases.
        if (m == 0)
            return n;
        if (n == 0)
            return m;
        var n1 = n + 1;
        var v0Ptr = Marshal.AllocHGlobal(n1 * sizeof(int));
        var v1Ptr = Marshal.AllocHGlobal(n1 * sizeof(int));
        var v0 = (int*) v0Ptr;
        var v1 = (int*) v1Ptr;
        for (var i = 0; i < n1; i++)
        {
            v0[i] = i;
            v1[i] = i;
        }

        for (var i = 0; i < m; ++i)
        {
            v1[0] = i + 1;
            for (var j = 0; j < n; ++j)
            {
                var substCost = (s1[i] == s2[j]) ? v0[j] : (v0[j] + 1);
                var delCost = v0[j + 1] + 1;
                var insCost = v1[j] + 1;

                v1[j + 1] = Math.Min(Math.Min(substCost, delCost), insCost);
            }

            var temp = v0;
            v0 = v1;
            v1 = temp;
        }

        var result = v0[n];
        Marshal.FreeHGlobal(v0Ptr);
        Marshal.FreeHGlobal(v1Ptr);
        return result;
    }

    public static void Main()
    {
        var s1 = new char[15000];
        for (var i = 0; i < s1.Length; i++)
            s1[i] = 'a';
        var s2 = s1;
        var s3 = new char[15000];
        for (var i = 0; i < s3.Length; i++)
            s3[i] = 'b';
        for (var i = 0; i < 5; ++i)
        {
            var execTimer = new Stopwatch();
            execTimer.Start();

            Console.WriteLine(LevDist(s1, s2));
            Console.WriteLine(LevDist(s1, s3));

            execTimer.Stop();
            Console.WriteLine($"Finished 2 in {(execTimer.ElapsedMilliseconds / 1000.0):0.000}s");
        }
    }
}


go: 0.491s
.net: 0.699s

C# .NET Core 3.1


Это unsafe реализация, у меня она медленее на 0.11 с обычной реализации на строках на R7 2700X.


Unsafe с выводом вне блока unsafe - 0.667 с
using System;
using System.Diagnostics;

public class Program
{
    public static unsafe int LevDist(char* s1, int m, char* s2, int n)
    {
        // Edge cases.
        if (m == 0)
        {
            return n;
        }
        else if (n == 0)
        {
            return m;
        }

        var v0 = new int[n + 1];
        var v1 = new int[n + 1];

        for (var i = 0; i < m; ++i)
        {
            v1[0] = i + 1;

            for (var j = 0; j < n; ++j)
            {
                var substCost = (s1[i] == s2[j]) ? v0[j] : (v0[j] + 1);
                var delCost = v0[j + 1] + 1;
                var insCost = v1[j] + 1;

                v1[j + 1] = Math.Min(substCost, delCost);
                if (insCost < v1[j + 1])
                {
                    v1[j + 1] = insCost;
                }
            }

            var temp = v0;
            v0 = v1;
            v1 = temp;
        }

        return v0[n];
    }

    public static int Main()
    {
        var s1 = new string('a', 15000);
        var s2 = s1;
        var s3 = new string('b', 15000);

        var execTimer = new Stopwatch();
        execTimer.Start();

        int res1, res2;

        unsafe
        {

            fixed (char* c1 = s1)
            fixed (char* c2 = s2)
            fixed (char* c3 = s3)
            {
                res1 = LevDist(c1, s1.Length, c2, s2.Length);
                res2 = LevDist(c1, s1.Length, c3, s3.Length);
            }
        }

        execTimer.Stop();

        Console.WriteLine(res1);
        Console.WriteLine(res2);

        var execTime = execTimer.ElapsedMilliseconds / 1000.0;

        Console.WriteLine($"Finished in {execTime:0.000}s");
        return 0;
    }
}

Однако, если в данной реализации вывод положить внутрь unsafe блока и убрать переменные, то результаты уравниваются с результатом стандартной реализации, но с отдельными переменными и отдельным выводом (с unsafe всё наоборот).


Unsafe с выводом внутри блока unsafe - 0.556 с
using System;
using System.Diagnostics;

public class Program
{
    public static unsafe int LevDist(char* s1, int m, char* s2, int n)
    {
        // Edge cases.
        if (m == 0)
        {
            return n;
        }
        else if (n == 0)
        {
            return m;
        }

        var v0 = new int[n + 1];
        var v1 = new int[n + 1];

        for (var i = 0; i < m; ++i)
        {
            v1[0] = i + 1;

            for (var j = 0; j < n; ++j)
            {
                var substCost = (s1[i] == s2[j]) ? v0[j] : (v0[j] + 1);
                var delCost = v0[j + 1] + 1;
                var insCost = v1[j] + 1;

                v1[j + 1] = Math.Min(substCost, delCost);
                if (insCost < v1[j + 1])
                {
                    v1[j + 1] = insCost;
                }
            }

            var temp = v0;
            v0 = v1;
            v1 = temp;
        }

        return v0[n];
    }

    public static int Main()
    {
        var s1 = new string('a', 15000);
        var s2 = s1;
        var s3 = new string('b', 15000);

        var execTimer = new Stopwatch();
        execTimer.Start();

        unsafe
        {

            fixed (char* c1 = s1)
            fixed (char* c2 = s2)
            fixed (char* c3 = s3)
            {
                Console.WriteLine(LevDist(c1, s1.Length, c2, s2.Length));
                Console.WriteLine(LevDist(c1, s1.Length, c3, s3.Length));
            }
        }

        execTimer.Stop();

        var execTime = execTimer.ElapsedMilliseconds / 1000.0;

        Console.WriteLine($"Finished in {execTime:0.000}s");
        return 0;
    }
}

Стандартная safe реализация.


Safe с выводом вне блока замера времени - 0.556 с
using System;
using System.Diagnostics;

public class Program
{
    public static int LevDist(string s1, string s2)
    {
        var m = s1.Length;
        var n = s2.Length;

        // Edge cases.
        if (m == 0)
        {
            return n;
        }
        else if (n == 0)
        {
            return m;
        }

        var v0 = new int[n + 1];
        var v1 = new int[n + 1];

        for (var i = 0; i < m; ++i)
        {
            v1[0] = i + 1;

            for (var j = 0; j < n; ++j)
            {
                var substCost = (s1[i] == s2[j]) ? v0[j] : (v0[j] + 1);
                var delCost = v0[j + 1] + 1;
                var insCost = v1[j] + 1;

                v1[j + 1] = Math.Min(substCost, delCost);
                if (insCost < v1[j + 1])
                {
                    v1[j + 1] = insCost;
                }
            }

            var temp = v0;
            v0 = v1;
            v1 = temp;
        }

        return v0[n];
    }

    public static int Main()
    {
        var s1 = new string('a', 15000);
        var s2 = s1;
        var s3 = new string('b', 15000);

        var execTimer = new Stopwatch();
        execTimer.Start();

        var res1 = LevDist(s1, s2);
        var res2 = LevDist(s1, s3);

        execTimer.Stop();

        Console.WriteLine(res1);
        Console.WriteLine(res2);

        var execTime = execTimer.ElapsedMilliseconds / 1000.0;

        Console.WriteLine($"Finished in {execTime:0.000}s");
        return 0;
    }
}

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


Safe с выводом внутри блока замера времени - 0.667 c
using System;
using System.Diagnostics;

public class Program
{
    public static int LevDist(string s1, string s2)
    {
        var m = s1.Length;
        var n = s2.Length;

        // Edge cases.
        if (m == 0)
        {
            return n;
        }
        else if (n == 0)
        {
            return m;
        }

        var v0 = new int[n + 1];
        var v1 = new int[n + 1];

        for (var i = 0; i < m; ++i)
        {
            v1[0] = i + 1;

            for (var j = 0; j < n; ++j)
            {
                var substCost = (s1[i] == s2[j]) ? v0[j] : (v0[j] + 1);
                var delCost = v0[j + 1] + 1;
                var insCost = v1[j] + 1;

                v1[j + 1] = Math.Min(substCost, delCost);
                if (insCost < v1[j + 1])
                {
                    v1[j + 1] = insCost;
                }
            }

            var temp = v0;
            v0 = v1;
            v1 = temp;
        }

        return v0[n];
    }

    public static int Main()
    {
        var s1 = new string('a', 15000);
        var s2 = s1;
        var s3 = new string('b', 15000);

        var execTimer = new Stopwatch();
        execTimer.Start();

        Console.WriteLine(LevDist(s1, s2));
        Console.WriteLine(LevDist(s1, s3));

        execTimer.Stop();

        var execTime = execTimer.ElapsedMilliseconds / 1000.0;

        Console.WriteLine($"Finished in {execTime:0.000}s");
        return 0;
    }
}

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

Кроме того C# используют безопасное обращение к массиву.

Его можно обойти, если использовать контрукцию вида:
for (var i = 0; i < array.Length; i++)
{
    var item = array[i];
}

Копиляров в состоянии понять что i ограничено по array.Length и переполнения быть не может.
Реально работает.
В моём случае оригинальная версия работает 0,84с, версия с a.Length работает 0,68с, а unsafe версия работает 0.63с.
Я и не думал об этом, хотя всегда использую именно a.Length, но не для скорости, а чтоб не накосячить.

Самое удивительное, что раньше, когда компьютеры были большими, наоборот, учили сохранять длину во временную переменную перед циклом.

Или если использовать foreach.

Немного улучшенная версия на C#
public static Int32 LevDist(string s1, string s2)
{
	return LevDist(s1.ToCharArray(), s2.ToCharArray());
}

public static Int32 LevDist(char[] s1, char[] s2)
{
	var m = s1.Length;
	var n = s2.Length;

	// Edge cases.
	if (m == 0)
	{
		return n;
	}
	else if (n == 0)
	{
		return m;
	}

	var v0 = new int[n+1];
	var v1 = new int[n+1];
	for(var i = 0; i < n; i++)
		v0[i] = v1[i] = i;

	for (var i = 0; i < m; i++)
	{
		v1[0] = i + 1;

		for (var j = 0; j < n; j++)
		{
			var substCost = (s1[i] == s2[j]) ? v0[j] : (v0[j] + 1);
			var delCost = v0[j + 1] + 1;
			var insCost = v1[j] + 1;

			v1[j + 1] = Math.Min(Math.Min(substCost, delCost), insCost);
		}

		var temp = v0;
		v0 = v1;
		v1 = temp;
	}

	return v0[n];
}

public static void Main()
{
	string s1 = new String('a', 15000);
	string s2 = s1;
	string s3 = new String('b', 15000);

	Stopwatch execTimer = new Stopwatch();
	execTimer.Start();

	Console.WriteLine(LevDist(s1, s2));
	Console.WriteLine(LevDist(s1, s3));

	execTimer.Stop();
	double execTime = execTimer.ElapsedMilliseconds / 1000.0;

	Console.WriteLine($"Finished in {execTime:0.000}s");
}

Со включенной оптимизацией на моей машине дает 0.77с против 5.6c в оригинальной версии.

На удивление, использование ReadOnlySpan дает худшие результаты по сравнению с копированием массива в char[].
На удивление, использование ReadOnlySpan дает худшие результаты по сравнению с копированием массива в char[].


Не могли бы показать вариант с ReadOnlySpan? Интересно посмотреть почему так происходит, какая версия .Net Core?
Да все то же самое, только вместо .ToCharArray() используется .AsSpan(). Использую последний LINQPad 6, в нем .NET Core 3.1.
А зачем вообще строки в массив копировать? Судя по коду, никакие изменения в s1 и s2 не делаются, а читать символы по индексу можно прямо у строки.

Просто засунуть тело функции LevDist(char[] s1, char[] s2) в LevDist(string s1, string s2) и все будет работать еще быстрее.
Так тоже можно, но никакого прироста скорости не дает. Полагаю, дело в том, что индексация массива оптимизирована лучше, чем индексация строки.
Посмотрел внимательнее: там в бенчмарке всего два раза вызывается LevDist так что это копирование просто копейки на фоне остальной работы. Наверное во время реальной работы, когда вызовов LevDist будут тысячи, влияние будет уже другое.

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

Часто приходится встречать строчки длинной в тысячи символов?

Нет, чаще — в десятки тысяч символов. Я эту ерунду на исходный код натравливаю.

Исходный код в десятки тысяч символов? Это обфусцированный какой-то?
Знаю про древний стандарт в 80 символов, у редакторов даже подсветка раньше была, когда выходишь дальше.
Интересно было-бы найти статистику гитхаба, сколько там средняя длинна строки в исходниках.

Впрочем, не особо важно. Если подумать, копирование в массивы в данном случае будет не дольше чем заполнение двух массивов int дальше по коду и уж явно сильно быстрее, чем вложенный цикл m*n. Так что на производительность слабо влияет. Просто в нем нет никакого смысла, почему-бы и не убрать.

Там строка — это весь файл целиком, без разбития по привычным человеку строкам по \n.

Все, понял. Интересно, что за задачка. Может проще было-бы делать какой-нибудь обычный diff, а это расстояние считать только для тех строк, которые различаются по диффу.
А то квадратичная сложность от длинны файла как-то совсем не очень, как-бы сильно не было все соптимизированно.

Пытаюсь по имеющейся кодовой базе подобрать стиль для автоформаттера. Я про это тоже скоро напишу (сегодня вот начал писать статью).


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

Ну не знаю, лично у меня замена в сишарпе Enumerable.Range на ручной цикл дала порядка 10% прироста производительности.

Ну, код я не проверял, слишком уж не знаю C# для этого. Это я скорее про расходы от копирования говорил (предположительно от копирования один раз в начале работы функции).

impwx Span это же всеже больше про память. Здесь есть небольшие бенчмарки, где спаны немного уступают по скорости массивам
adamsitnik.com/Span/#span-vs-array
Еще раз убеждаемся, что люди, не работающие с языками не должны даже пытаться проводить какие-либо бенчмарки.

А когда взял кто-то, кто видит C# не в первый раз, то и вуаля — Java уже медленее.

А что в том коде на сишарпе не так? Ну да, пара моментов неоптимально написана (типа кэширования m и n), но в остльном нормальный такой код.


Как челоек получил 5.6с для меня — загадка. У меня код выше отрабатывает примерно за 0.63 секунды, чуть-чуть быстрее чем оригинальный.

Запустите код со строками в 20 тыщ символов.


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

Сори, тупанул.


С 20к символов у меня сишарп выдает 1.18с. Раст — 0.8с.
На .net 472 — 0.935c. Это объясняется тем, что в неткоре джит делает ставку на быстрый запуск и хуже оптимизирует. Недавно ввели tieredJIT которы до старого уровня иногда догоняет во время работы, когда какой-то код горячим получается, но очевидно на задачке выполняемой секунду он не успевает ничего сделать.

В статье код для 15 тыщ, но результаты как для 20 (путаница, да, сорян).

У меня получилось так:
NET Core 3.1 — 0.9 s
NET Framework 4.7.2 — 1.2 s
Mono 6.6.0 — 1.8 s

Просто кто-то программировать не умеет, меняем:
 v1[j + 1] = std::min({delCost, insCost, substCost});

на
v1[j + 1] = std::min(std::min(delCost, insCost), substCost);

и о чудо, не нужно список(вектор) из трех переменных инициализировать (выделять память) и удалять ее. На моей машине ускорение в 3 раза.
P. S. Ох уж эти истории про медленный C и C++

А там и не выделяется ничего — initializer_list должен разворачиваться в компилтайме, это таки не лист и не вектор. И да, в одном из заключений про это написано.


А ещё производительность зависит от того, в каком порядке вы выполняете сравнения.

Так ты не болаболь а проверь, теоретик

И про это в заключении тоже написано. Вот, процитирую себя же на всякий:


При некоторых из этих порядков мне таки удалось воспроизвести ускорение в случае C++. Так, если вместо std::min({ delCost, insCost, substCost }) написать std::min(substCost, std::min(delCost, insCost)), то время работы под gcc увеличивается до 1.440 секунд, а под clang — уменьшается до 0.840 секунд (ура, быстрее всех остальных вариантов и почти хаскель). Похоже, clang не любит initializer lists и не может их заинлайнить, а gcc шедулит операции и регистры так, что, по крайней мере, на этих конкретных данных результат проигрывает.
Но ещё раз отмечу, что оптимизацией порядка в случае хаскеля я не занимался, и заниматься этим в рамках этого бенчмарка не рекомендую в любом языке.
Кроме того, это значит, что нельзя рассчитывать на компилятор даже в таком языке, как C++, где в компиляторы влиты десятки тысяч человеколет.
Вы тут пришите про порядок, а проблема вот нифига не в нём — проблема в обращения в использовании initializer_list и, соотвественно, «недооптимизированных» обращениях в память.

Это проблема QoI.


И gcc тому же с ручной развёрткой становится хуже, а не лучше.

И gcc тому же с ручной развёрткой становится хуже, а не лучше.
Пожимает плечами. Это уже совсем другая история.

GCC, в последнее время, очень сильно сдал. Google оттуда всех разработчиков снял и куча народу из «академиев» тоже на Clang переключились.

Кое-где, иногда, он ещё может «показть класс» (всё-таки в него много тысяч человеко-лет вбухано тоже), но объективно — он таки от Clang отстаёт.
А что за история с гуглом и GCC?
Никакой особенной истории — просто если раньше Google компилировал все свои сервера GCC, то сейчас произошла консолидация и используется Clang/LLVM везде, где возможно.

Как несложно догадаться раньше Google содержал сколько-то контрибуторов в GCC, сейчас — часть переключилась на Clang, часть уволилась…
А там и не выделяется ничего — initializer_list должен разворачиваться в компилтайме, это таки не лист и не вектор.
initializer_list — это, увы, именно-таки массив. И обрабатывается как массив и косяки у него, как при работе с массивом.

И да — из-за проблем с алиасингом подобные вещи компилятору «тяжело» обрабатывать. Я внизу приводил вообще патологический пример.

Это одно из мест, где разработчики языка придумали очередную подлянку, которую должны уметь «изводить» разработчики компиляторов — а они, увы, не всегда справляются.
initializer_list — это, увы, именно-таки массив

Но не вектор. Динамических аллокаций там нет (то есть, компилятор, наверное, имеет право реализовывать их с аллокациями, но зачем ненавидеть своих пользователей?).


Кроме того, предельный случай без всех этих листов и шаблонных minов тоже приведен — это вариант на С. И он не оказывается существенно быстрее.

Динамических аллокаций там нет (то есть, компилятор, наверное, имеет право реализовывать их с аллокациями, но зачем ненавидеть своих пользователей?).
Динамических аллокаций нет, а проблемы алиасинга — есть.

Для оптимизации C++ они — вообще больное место.

Кроме того, предельный случай без всех этих листов и шаблонных minов тоже приведен — это вариант на С. И он не оказывается существенно быстрее.
А что-нибудь вообще — оказывается «существенно быстрее»? Не так, что вы, в версии на C (и исправленной версии на C++) уже добрались до пределов того, что можно сделать без векторизации?

Автовекторизация в C++ сейчас — это скорее игрушки (типа inline в прошлом веке) — мы вообще пока не в курсе чего там можно автоматически сделать. Обрабатываются только кой-какие самые очевидные циклы.
Динамических аллокаций нет, а проблемы алиасинга — есть.

Именно! Значит, по крайней мере, писать идиоматичный код надо с оглядкой на ассемблер. Ну, как вы и говорите.


И, значит, идиоматичный код почти наверное не будет в топе производительности, хотя казалось бы — zero-overhead abstractions и прочие красивые слова.


Впрочем, ваши ссылки ниже я ещё не смотрел внимательно — с мобильника по пути на работу это не очень, чуть позже гляну.


А что-нибудь вообще — оказывается «существенно быстрее»? Не так, что вы, в версии на C (и исправленной версии на C++) уже добрались до пределов того, что можно сделать без векторизации?

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

И, значит, идиоматичный код почти наверное не будет в топе производительности, хотя казалось бы — zero-overhead abstractions и прочие красивые слова.
Ну — слова-то красивые, а на практике — надо в ассемблер смотреть. Когда я в школе учился я вообще был большим скептиком по отношению к C++: что толку от абстракций, которые «могли бы быть zero-overhead», если на практике — там «overhead» нифига даже не «zero»?

Сейчас компиляторы умеют уже убирать 99% этих абстракций, но иногда… вот это вот что такое? Какая абстракция и куда протекла, что компилятор вдруг решил в оптимизированном «до упора» коде оставить test cl, cl — притом, что в cl, в этот момент, будет нуль!

Но это именно алгоритмические улучшения.
На это вроде C++ компиляторы не пытаются замахиваться, всё-таки. Да и никакие другие промышленные (не зная что нам «на переднем крае науки, впрочем).

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


Собственно, если вдруг интересно, вот этот папир с более эффективной реализацией.


tl;dr там два улучшения — можно сравнивать не посимвольно, а сразу 8 (или сколько байт в вашем слове) символов через хитрые наблюдения и немного препроцессинга (что теоретически даёт улучшение константы в 8 раз), плюс не обязательно смотреть на всю матрицу, а можно идти вдоль диагонали, отступая не сильно далеко от неё, особенно если вы удовлетворитесь ответом вида «расстояние больше N» для некоторого наперёд заданного N.


Увы, я в своей задаче понял, что расстояние Левенштейна — не лучшая метрика, и мне резко стало лень реализовывать более эффективные варианты.

А какая метрика лучше в Вашей задаче?

Я сравниваю расстояние между исходными кодами, отформатированными согласно разным стилям. Соответственно, лучше рассматривать пару из (d1, d2), где d1 — модуль разности гистограмм непробельных символов, d2 — сколько пробельных символов надо удалить или добавить, чтобы выровнять две строки. Соответственно, сравнение тогда лексикографическое.


Ну или в коде.

Нет, конечно. Вектор выделяет память в рантайме, массив на стеке с известным размером (какой там правильный standardese для него) — нет.

Ну всё-таки «уважающий себя компилятор» должен такое убивать.

P. S. Ох уж эти истории про медленный C и C++
А они всегда про это: компилятор, вроде бы, должен лишнее удалять… но не всегда ему это удаётся. И это — после тысяч человеко-лет, в это вбуханных.
Компилятор, команды символьных строк, переводить в бинарный машинный код, а то что ты говоришь должен, делать программист. Например можно с помощью бинарных операций найти минимальное число, без использования if, источник как это сделать Алгоритмические трюки для программистов [Генри С. Уоррен мл.] Hacker's Delight
Не должен. std::initializer_list был разработан как раз для таких случаев. И его использование у 0xd34df00d — как раз вполне идеоматично.

А что у компилятора при этом ролики за шарики заезжают — так это как раз вечная беда C++: теоретически на нём можно писать не глядя в ассемблерный выхлоп, практически — регулярно вот подобные косяки и вылезают.

Про ваши трюки компилятор в курсе и вполне способен их применять — тут не в них дело.
Если это всегда эффективней явного ифа, тогда почему этого не делает компилятор? По-хорошему, программист должен писать человекочитаемый абстрактный код, а приведенный пример это как раз то чем должен был бы заниматься компилятор.
Эх, а кто умеет?!
Сам пишу на Java и вижу, что неправильно написать гораздо сложнее, чем упомнить и внимательно прочитать все детали конструкторов, const* указателей, ссылок и shared_ptr, а разница между ними иногда кратная. Еще и новые стандарты добавляют и добавляют синтаксического мусора…

Разницы нету, смотри тут https://godbolt.org/z/E5WRNT
В более общем случае может быть разница из-за порядка сравнений когда переменные приходят из разных мест но к "выделять память" отношения не имеет.

Проблема с clang очень похожа вот на это. Тут вообще какой-то бардак происходит.

Разработчики про это знают, так что, похоже, вы очень вовремя написали статью…

Clang++ даёт хорошие результаты, если использовать range-based for или если вместо std::string сравнивать прямо элементы C-массива (.c_str()).
Не знаю, насколько это близко к описанной проблеме, но что-то явно очень медленно при использовании оператора [] у std::string.

Не знаю, насколько это близко к описанной проблеме, но что-то явно очень медленно при использовании оператора [] у std::string.
У std::string очень сложный operator[]. Теоретически «общую» часть компилятор должен бы вынести… но, похоже, в данном случае — не выходит.

Может быть тоже проблемы aliasing'а легко…

Про алиасинг таки интересно. Там же нигде нет записи по char* или unsigned char* — с чего бы компилятору его опасаться?

С того, что это, блин, C/C++. Потому значение по указателю char* или unsigned char* может меняться (с точки зрения указателя) при записи в любую переменную адрес которой мог утечь куда-то.

В результате некоторые алгоритмы могут резко усколяться если вместо char использовать что-то типа:
  struct __attribute__((packed)) FastChar {
    long long value:8;
};

Об этом я не подумал, да. Вы правы. Правда, здесь-то я пишу в локальные для функции вектора, на которые даже адрес или ссылка ни разу не берётся (если не считать локальный же std::swap), и компилятору должно бы хватить мозгов это увидеть.


С другой стороны, LICM-вынос чтения символа из строки s1 на уровень внешнего цикла производительность (для gcc, по крайней мере) не меняет вообще никак.

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

Так что, как я уже сказал, это может влиять только на некоторые алгоритмы, далеко не на все.

У меня в соседнем коде был шанс алиасинга, и я уже подумывал заменить std::string на std::vector<CharWrapper>, где struct CharWrapper { char ch; }; — по идее этого достаточно, чтобы алиасинга не было, потому что больше нет указателей на char. У вас там, однако, обёртка выглядит по-другому. Что я теряю от такого, более простого варианта?

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

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

ЕМНИП, в случае с char там алиасинг может быть только однонаправленный — char* может указывать на кусок int, а вот int* на char указывать не может никогда. Ну, это если я правильно понял что означает термин glvalue (придумали, блин, терминологию, и всё лишь бы не рассуждать о языке в терминах самого языка!)

ЕМНИП, в случае с char там алиасинг может быть только однонаправленный — char* может указывать на кусок int, а вот int* на char указывать не может никогда.
Да, но для создания проблем этого же достаточно. Если вы имеете строку — то вы работает с её содержимым через указатели, а раз так, что алиасинг, с точки зрения компилятора, возможен.

Тут фишка в том, что да, вы, формально, не имеете права взять указатель на CharWrapper, прератить в указатель на int — и использовать. Всё так.

Но если указатель на CharWrapper приходит откуда-то снаружи, то у компилятора нет никой возможности узнать — это таки настоящий CharWrapper или кусок int (см. пример ниже)… и, соответственно, ему приходится «закладываться на подставу».
ЕМНИП, в случае с char там алиасинг может быть только однонаправленный — char* может указывать на кусок int, а вот int* на char указывать не может никогда.
Либо диапазоны могут пересекаться, и тогда int* и char* потенциально указывают на одну память (алиасятся), либо не пересекаются (и мы, возможно, исходим из этого предположения). Никакой однонаправленности в алиасинге нет

Но у нас нет int* и char*. У нас есть int* и char.

Дык в том месте, где про aliasing пишется речь идёт о glvalue и именно-таки о char, а не char*...

Ну да, там так и написано — алиасинг разрешен, если glvalue типа char. А если объект типа char, а glvalue какого-то другого типа — то алиасинг не разрешен.

Я получаю отсутствие объектов типа char*. Соответственно, я не пишу по таким указателям и не читаю, а это значит, что про алиасинг, по идее, можно не думать.

Ну вот рассмотрите такой код:

void foo(int);

int bar(CharWrapper* x, CharWrapper* y, int *z) {
  foo(*z);
  *x = {1};
  *y = {32};
  return *z;
}

Здесь ведь никаких UB нету с вашим подходом — и компилятору придётся это учитывать. А с моим — есть… и он может немного своевольничать.

Можете запустить и убедиться что ваша версия выдаёт 8234, а моя — 298…

Потому что в вашем случае всё равно доступ к данным идёт через glvalue типа char, а моём случае — через long long. Да, это небольшой такой long long, размером в один байт… но это всё равно long long!

А у вас там слева разве нет UB? Ну, где вы пишете в один член юниона, а потом читаете из другого?


Как иначе сделать так, чтобы ссылка на char внутри структуры указывала куда-то ещё, я не знаю.

Ну, где вы пишете в один член юниона, а потом читаете из другого?
Нет. Ну во всяком случае так считают разработчики компиляторов. Тут всё упирается в то, что все эти автогенерённые CharWrapper() и operator= автогенерируются компилятором — но остаются законными даже если указатель на CharWrapper() был порождён из указателя на другой тип…

If a program attempts to access the stored value of an object through a glvalue of other than one of the following types the behavior is undefined:

—(11.8) a char, unsigned char, or std::byte type
То есть в C++ вообще никого не волнует указатель какого типа вы используете… только к объекту какого типа вы пытаетесь «достучаться».

Как иначе сделать так, чтобы ссылка на char внутри структуры указывала куда-то ещё, я не знаю.
Через reinterpret_cast самое простое.
Нет.

Почему? Всю жизнь же было (ну и всю жизнь компиляторы это разрешали, да, но это другой разговор).


Ну и тут явно 6.7.3.1/5.


То есть в C++ вообще никого не волнует указатель какого типа вы используете… только к объекту какого типа вы пытаетесь «достучаться».

Я бы ожидал, что компилятор увидит, что я пытаюсь обратиться к char.


Через reinterpret_cast самое простое.

Но кастить вы будете к типу структуры, а он ничего не алиасит.

Ну и тут явно 6.7.3.1/5.
С какого перепугу? У нас по-прежнему всё время всё тот же int, с которого мы начинали… только доступ к нему — через объект типа charчто явно разрешено

Я бы ожидал, что компилятор увидит, что я пытаюсь обратиться к char.
Как он это увидит, извините? Если ему явно разрешено обращаться к объекту типа int через glvalue типа char?

Но кастить вы будете к типу структуры, а он ничего не алиасит.
Алиасит-алиасит! В том-то и дело, что алиасит. В этом-то и беда. C (а за ним и C++) так устроен.

То есть если вы имеете что-нибудь типа
struct Point {
  int lattitude;
  int longitude;
};
struct 3DPoint {
  int x,y,z;
};
то Point и 3DPoint можно безболезненно кастить друг к другу (обращаться к z у чего-то, что было рождено как Point, конечно, нельзя). Это очень активно применяется программистами на C, потому и появляются все эти чудесные pointer interconvertible типы.

И, соотвественно, можно заворачивать char в «обёртки» из struct, union или std::array сколько угодно — он всё равно останется charом и ему будет разрешего алиаситься с чем угодно.

Это вам, извините, не Rust и не Haskell. Ушки-то от C всё равно торчат…
С какого перепугу?

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


Как он это увидит, извините? Если ему явно разрешено обращаться к объекту типа int через glvalue типа char?

Потому что все способы сделать так, чтобы этот char указывал не на char, нелегальны, насколько я понимаю. Ну а что компиляторы по рукам вас не бьют за игры с юнионами — вопросы к компиляторам.


Алиасит-алиасит!

Не согласен:


то Point и 3DPoint можно безболезненно кастить друг к другу (обращаться к z у чего-то, что было рождено как Point, конечно, нельзя)

На эту тему я сходу так не уверен, но к nested object, сиречь к char, можно кастить, да. Но это именно что голый чар, он не был получен как указатель на что-то, и компилятор вполне имеет право считать, что он не алиасит.


Либо алиасит настолько же, насколько любой другой тип, могущий быть common subobject или как оно там называется. То есть, почти любой тип.

Потому что я зареюзил сторедж, записав в член-массив.
С чего вдруг? Вы модицицировали имеющееся там значение типа int используя данный вам char. Там по прежнему остался int.

Вы не туда смотрите — тут мой union вообще непричём, я мог бы и reinterpret_cast сделать.

Потому что все способы сделать так, чтобы этот char указывал не на char, нелегальны, насколько я понимаю.
Наоборот! Почти что любой способ будет корректным! Проблемы тут в том, что структура pointer-interconvertible со своим первым членом, а указатель на char должен алиасится с любым типом (иначе вообще никак нельзя написать memcpy или memmove).

Но это именно что голый чар, он не был получен как указатель на что-то, и компилятор вполне имеет право считать, что он не алиасит.
Почитайте ещё раз про pointer interconvertible типы внимательно. Обратите внимание на последний случай — мы имеем возможность придумать себе объект, которого не существует, но который поможет нам перейти от одного объекта к другому через воображаемые структуры и объединения!

А иначе как бы непонятно вы вообще могли использовать штуки типа offsetof.

Либо алиасит настолько же, насколько любой другой тип, могущий быть common subobject или как оно там называется. То есть, почти любой тип.
Вы тут мешаете в кучу два процесса:
1. Возможность преобразования типов указателей на объекты.
2. Возможность модификации самих объектов, на которые эти указатели указывают.

Так вот первое — можно делать почти всегда. Вы можете преобразовать указатель на int в указатель на float, char, CharWrapper или вообще почти что угодно (с функциями и, особенно, указателями на члены класса есть ограничения).

Однако это не значит, что вы можете, используя этот указатель, безопасно обращаться к значениям, которые там находятся!

Ментальная модель такая: разные типы обрабатываются разными устройствами. Неважно — у вас там 8087 (почитайте о том, как 8087 обращается в память — это весьма нетривиально) или Weitek — в любом случае «главный» CPU может обратиться за результами тогда, когда они ещё не готовы. Синхронизация, в общем, небесплатна — и осущствляется тогда, когда вы используете указатели на char.

Вот это — ментальная модель, описанная в документации на C++: возможность преобразовать один указатель в другой — не даёт вам, автоматически, права этот указатель использовать!

У вас же, похоже, ментальная модель какая-то совсем другая, потому что я даже следов её не вижу! Законно ли, скажем, преобразовать указатель на int в указатель на short, а потом сдвинуть его на пару элементов? Да не вопрос! А вот можно ли потом обращаться к этому shortу? Ну… если у вас там изначально была структура из intа и shortа… то да — законно и беспроблемно. А вот если там был, изначально, массив intов… тогда нет.

А вот char — он особое исключение. Он всегда может использоваться — независимо от того, с какого типа вы изначально начали и, что важно, тип результата тоже может быть каким угодно. Через него можно прямо добраться до байтов из которых состоит объект — без ограничений (и да, в стандарте есть места, где он говорит о «байтах из которых состоит объект»).

Да, во многих других языках типы — куда более «серьёзная» конструкция (не только Haskell, но даже и Pascal какой-нибудь). А в C++ — это тооооненькая-тооооненькая прослоечка над байтами.

А ограничения алиасинга, которые используют компиляторщики — они изначально для совсем-совсем другого был придуманы… Условно — для связки 8086+8087 (и других подобных).

Такие дела…
С чего вдруг? Вы модицицировали имеющееся там значение типа int используя данный вам char. Там по прежнему остался int.

Записав в другой объект с тем же стореджем.


Вы не туда смотрите — тут мой union вообще непричём, я мог бы и reinterpret_cast сделать.

К чему, к char*?


Почитайте ещё раз про pointer interconvertible типы внимательно. Обратите внимание на последний случай — мы имеем возможность придумать себе объект, которого не существует, но который поможет нам перейти от одного объекта к другому через воображаемые структуры и объединения!

Да, это значит, что вы можете привести указатель на CharWrapper к указателю на char.


Но это не значит, что glvalue, соответствующая CharWrapper'овскому char, может указывать на произвольный объект. Вы же её можете получить только от этого CharWrapper!


А иначе как бы непонятно вы вообще могли использовать штуки типа offsetof.

А я их не использую Что там компилятор делает и какую магию в своей нутрянке он предоставляет — не моё дело. Это его магия, семантика которой ему известна.


Ментальная модель такая: разные типы обрабатываются разными устройствами.

Моя ментальная модель предельно проста — для работы с объектом я могу использовать только указатель на него либо указатель на char/uchar/std::byte. Всё.


Законно ли, скажем, преобразовать указатель на int в указатель на short, а потом сдвинуть его на пару элементов?

Да, конечно.


Ну… если у вас там изначально была структура из intа и shortа… то да — законно и беспроблемно.

Не уверен. А вдруг у вас там компилятор паддинг вставит?


Более того, не уверен даже в случаях отсутствия паддинга (и с кем-то про это несколько лет назад даже спорил, но лень и на эту тему по стандарту бегать).


Но в любом случае это всё неважно. Да, если у вас есть указатель на CharWrapper, то вы можете из него сделать указатель на char. Но что это ломает? Вы, в конце концов, из любого объекта можете сделать указатель на char.


Вопрос в другом: можете ли вы сделать указатель на CharWrapper из указателя на char, который изначально не указывал на CharWrapper или на char? Я утверждаю, что нет, поэтому алиасинга быть не может.


Есть дыра, связанная с тем, что CharWrapper является pointer-interconvertible c любым другим типом, имеющим char первым членом, но это выполняется и для предложенного вами типа.

Записав в другой объект с тем же стореджем.
Ну уж нет. Читаем:
In simple assignment (=), the object referred to by the left operand is modified ([defns.access]) by replacing its value with the result of the right operand.

Простое присваивание не создаёт нового объекта — оно модифицирует существующий.

Отсюда, собственно, вся разница между конструктором и оператором присваивания.

Вы не туда смотрите — тут мой union вообще непричём, я мог бы и reinterpret_cast сделать.
К чему, к char*?
К char*, double*, CharWrapper*… да вообще указатель на один тип можно в указатель на почти любой другой тип в C++ безопасно кастить через reinterepret_cast. Вот использовать полученный указатель — да, можно только с ограничениями…

Да, это значит, что вы можете привести указатель на CharWrapper к указателю на char.

Но это не значит, что glvalue, соответствующая CharWrapper'овскому char, может указывать на произвольный объект. Вы же её можете получить только от этого CharWrapper!
Почему это? Я могу стартовать с указтеля на целое число, пробразовать его в указатель на char (нет ограничений), могу его потом и подвигать (кто запретит?), потом преобразовать в CharWrapper*.

Нигде на этом пути никаких запретов нет…

А я их не использую Что там компилятор делает и какую магию в своей нутрянке он предоставляет — не моё дело. Это его магия, семантика которой ему известна.
Причём тут «нутрянка»? Если у вас standard-layout-тип то вы имеете в вашей программе всеми этими удобствами пользоваться безусловно… а начиная с C++17 — разработчики компилятора могуть разрешить вам пользоваться всеми этими чудесами и в других случаях (но не обязаны).

Впрочем вам CharWrapper — он, конечно, standard-layout… что сразу закрывает тему.

Для того, чтобы написать offsetof — нужна магия, выходящая за рамки стандарта, да… но чтобы использовать — магии не нужно… а с вашим подходом она таки была бы нужна.

Моя ментальная модель предельно проста — для работы с объектом я могу использовать только указатель на него либо указатель на char/uchar/std::byte. Всё.
Это отличная ментальная модель, но разрабочики компилятров должны поддерживать не только такие вещи, а всё, что разрешает стандарт… а разрешает он весьма много чего…

Не уверен. А вдруг у вас там компилятор паддинг вставит?
Дык и размером int может оказаться не в два shortа. Для проверки подобных вещей как раз offsetof и sizeof и нужны.

Вопрос в другом: можете ли вы сделать указатель на CharWrapper из указателя на char, который изначально не указывал на CharWrapper или на char? Я утверждаю, что нет, поэтому алиасинга быть не может.
На основании чего вы это утверждаете? Кто вам это запретит? В каком именно месте возникнет UB? Pointer-interconvertible типы потому так и называются, что их можно конвертировать в любом направлении…

Есть дыра, связанная с тем, что CharWrapper является pointer-interconvertible c любым другим типом, имеющим char первым членом, но это выполняется и для предложенного вами типа.
Разумеется! Разница-то не в этом! Разница в том, что в моём типе никаких charов нету. Там long long. Небольшой такой, однобайтовый. Он не может ни с чем алиаситься.

Кстати я тут подумал и понял, что перемудрил. Ибо никакого «небольшого» long long, конечно не нужно. Достаточно небольшого однобайтового char… потому что у битовых полей же нельзя взять адрес — то есть тип битового поля не совпадает с объявленным типом этого поля… это отдельный тип… хотя выглядит, конечно, как «масло масляное»… но работает… но сносит крышу ICC… прекрасно, просто прекрасно…

Люблю C++: где ещё можно выстрелить себе в ногу столькими разными способами?
Простое присваивание не создаёт нового объекта — оно модифицирует существующий.

У юнионов создаёт. То есть, там написано не совсем это, и вы теоретически могли бы взять указатель на этот член (как вы и делаете), скастануть в char и что-то туда присвоить, но я, опять же, не уверен, что это легально. Получается какой-то уж больно очевидный хак и дыра.


Вот использовать полученный указатель — да, можно только с ограничениями…

Естественно, и мы же обсуждаем вопрос использования.


Я могу стартовать с указтеля на целое число, пробразовать его в указатель на char (нет ограничений), могут его потом подвигать (кто запретит?), потом преобразовать в CharWrapper*.

Прекрасно. И где у вас здесь возник алиасинг? Откуда у вас возможность через указатель на CharWrapper писать куда-то ещё?


На основании чего вы это утверждаете? Кто вам это запретит?

Если быть совсем формальным (один фиг не агду ковыряю), то сделать указатель можете. Разыменовать вы его потом не можете.


В каком именно месте возникнет UB?

В том, где вы попытаетесь работать с объектом с типом, отличающимся от CharWrapper, через указатель на CharWrapper.


То есть, конечно, вы его можете преобразовать обратно в char* и творить стандартную вакханалию, но вы её будете творить через указатель на char*. Нет указателя на char* — нет проблем, а я в сухости и безопасности.


Кстати я тут подумал и понял, что я перемудрил. Ибо никакого «небольшого» long long, конечно не нужно. Достаточно небольшого однобайтового char… потому что у битовых полей же нельзя взять адрес — то есть тип битового поля не совпадает с объявленным типом этого поля… это отдельный тип…

Я битовые поля не люблю, enum class meh : char в рамках вашего подхода хватит? Или даже signed char?

То есть, там написано не совсем это, и вы теоретически могли бы взять указатель на этот член (как вы и делаете), скастануть в char и что-то туда присвоить, но я, опять же, не уверен, что это легально.
Легально-легально. Вы вообще подумайте: кому и зачем могла бы быть нужно разрешение на получение доступа к объектам произвольного типа через glvalue типа char, unsigned char или std::byte, если бы на самом деле ограничения касались бы именно указателей?

Получается какой-то уж больно очевидный хак и дыра.
Никаких хаков и никакой дыры: вы можете записывать в char независимо откуда вы его взяли и через какие тернии этот char при этом прошёл. По крайней мере если мы не выходим за рамки standard-layout типов…

Естественно, и мы же обсуждаем вопрос использования.
А тут тоже всё просто: если это указатель на char и он указывает на какой-то объект… то обращаться к нему легально. Мы могли его лаже в чиселко превратить и куда-нибудь в SQL базу положить, а потом забрать… без разницы (впрочем это возможно только если в реализации существует intptr_t).

Откуда у вас возможность через указатель на CharWrapper писать куда-то ещё?
Ну тут-то как раз всё просто: если вы явно преобразуете этот указать обратно указатель на char — то всё будет точно законно. А вот должен ли автоматически сгенерированный оператор присваивания допускать то, что изначально указатель мог быть рождён из указателя на другой тип… хороший вопрос — не знаю, следует ли это откуда-нибудь.

То есть может быть с точки зрения стандарта вы, может быть, и правы. А вот с точки зрения GCC — точно нет. А поскольку ошибка консервативная (код корректный, хотя возможно не оптимальный) то вряд ли её кто-то будет править, пока не появятся реальные программы, где это важно…

Нет указателя на char* — нет проблем, а я в сухости и безопасности.
Этого недостаточно: тут же как раз всё наоборот — вы же хотите убедить компилятор, что ему разрешено чуть больше, чем с указателями на char*. Если компилятор вообще все указатели будет трактовать как указатели на char — всё же корректно будет (собственно для этого даже опция есть -fno-strict-aliasing).

Или даже signed char?
Signed char ничем не отличается… а вот enum — да, работает… похоже это самый лучший и переносимый вариант. Он и ICC не пугает
Вы вообще подумайте: кому и зачем могла бы быть нужно разрешение на получение доступа к объектам произвольного типа через glvalue типа char, unsigned char или std::byte, если бы на самом деле ограничения касались бы именно указателей?

Я уже давно перестал думать о «зачем» в контексте стандарта. По крайней мере, в таких местах, где куча разных разделов играет друг с другом в каких-то нетривиальных комбинациях.


Никаких хаков и никакой дыры: вы можете записывать в char независимо откуда вы его взяли и через какие тернии этот char при этом прошёл. По крайней мере если мы не выходим за рамки standard-layout типов…

Я о другом.


Пусть у вас


union
{
    int a;
    float b;
};

Тогда если вы сделаете


auto ptr = &b;
*ptr = 20;
a = 10;
return *ptr;

то это просто обязано быть невалидным.


А тут тоже всё просто: если это указатель на char и он указывает на какой-то объект… то обращаться к нему легально.

Но наличие отдельного типа CharWrapper именно что позволяет избегать наличия указателя на char в коде в явном виде!


Ну тут-то как раз всё просто: если вы явно преобразуете этот указать обратно указатель на char — то всё будет точно законно.

Полностью согласен. Но для этого мне надо получить указатель на char. И это работает для абсолютно любого типа: вы можете указатель на него превратить в указатель на char и писать куда угодно.


То есть может быть с точки зрения стандарта вы, может быть, и правы. А вот с точки зрения GCC — точно нет.

Вы меня уже убедили, что мой исходный вариант, вероятно, работает не очень, и надёжнее делать по-другому. Но я всё ещё не могу понять, где моя брешь в понимании алиасинга.


А поскольку ошибка консервативная (код корректный, хотя возможно не оптимальный) то вряд ли её кто-то будет править, пока не появятся реальные программы, где это важно…

Когда-то и сравнение this с нулём не выбрасывалось, а LLVM вроде как в 9-й версии это стал оптимизировать.


Этого недостаточно: тут же как раз всё наоборот — вы же хотите убедить компилятор, что ему разрешено чуть больше, чем с указателями на char*.

Именно! Поэтому у меня нет указателей на char.

Тогда если вы сделаете
auto ptr = &b;
*ptr = 20;
a = 10;
return *ptr;

то это просто обязано быть невалидным.
Ну тут у вас никаких chat нету и типы не similar, так что да — тут это невалидно.

Я уже давно перестал думать о «зачем» в контексте стандарта.
Тем не менее разработчики стандартна об этом думают (работа у них такая). В частности есть прекрасный пример, который разработчики компиляторов считают невалидным, хотя стандарт, вроде бы, его допускает.

  union DeathToHumans {
    float f;
    int i;
  };
  void foo(int* pi,
           float* pf,
           DeathToHumans* switcher) {
     switcher.f = 1.0;
     *pf = 2.0;
     switcher.i = 42;
     *pi = 57;
  }
  ...
  DeathToHumans x;
  foo(&x.i, &x.f, &x);
  ...
Причём даже разработчики стандарта, в принципе, признали, что то, что стандарт это допускает — «не есть хорошо»… потому что если такое признать допустимым, то получится что указатели на что угодно могут алиаситься, если между ними вызывается функция, кода которой мы не видим…

Однако пока никто не предложил хорошего решения (в смысле: изменения текста стандарта).

Но наличие отдельного типа CharWrapper именно что позволяет избегать наличия указателя на char в коде в явном виде!
Но речь же идёт не об указателе типа char, а о glvalue типа char!

Этого недостаточно: тут же как раз всё наоборот — вы же хотите убедить компилятор, что ему разрешено чуть больше, чем с указателями на char*.
Именно! Поэтому у меня нет указателей на char.
У вас-то нет… а вот в автоматически сгенерированной функции CharWrapper::operator=(const CharWrapper&) — они есть (вернее там есть glvalue типа char — но в нужном месте стандарта речь как раз о glvalue, не об указателях). И компилятор считает, что они могут обращаться к другому типу… хотя следует ли это из стандарта — я сказать и не могу…
Ну тут у вас никаких chat нету и типы не similar, так что да — тут это невалидно.

Так там у вас тоже char нету, есть только CharWrapper. Вы можете получить указатель на char, но для этого придётся сделать явный каст, и у вас будет указатель на char.


Причём даже разработчики стандарта, в принципе, признали, что то, что стандарт это допускает — «не есть хорошо»… потому что если такое признать допустимым, то получится что указатели на что угодно могут алиаситься, если между ними вызывается функция, кода которой мы не видим…

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


Но речь же идёт не об указателе типа char, а о glvalue типа char!

Мой поинт в том, что код заш… может, начинает алиасить тогда и только тогда, когда у вас в том или ином виде вылезает явный указатель на char. Скастуете CharWrapper к нему — будет боль и алиасинг. Нет каста — нет проблем.


И компилятор считает, что они могут обращаться к другому типу…

Вот мое впечатление — что компилятору здесь силёнок не хватает.


Впрочем, если сделать шаг назад, ситуация, когда два вроде как неглупых разработчика, более-менее знающих C++, обсуждают, как же на этом языке сделать строки, и уже почти сутки не могут придти к консенсусу — это прикольно. Хороший язык.

оставлю тут https://lkml.org/lkml/2018/6/5/769. тоже пытался понять после вашей дискуссии с "царём" в соседнем топике но ни**а не понял.

Увидел, от кого письмо — сразу понял, что в письме будет «в гцц работает, значит, всё в порядке».


Прям синдром Линуса в поле from.

Но линус не это написал. Он написал, что решение, которое принял комитет по стандартизации по поводу алиасинга — говно, хорошо, что в gcc есть инструменты его обхода. На сколько я могу судить, создатели Haskell и Rust согласны с Линусом по сути, потому в более новых языках эти проблемы тоже учтены иначе, чем в стандарте C.

Так наоборот, gcc позволяет программисту больше, давая себе меньше простора для оптимизаций.


А в других языках просто нет таких юнионов (или вообще указателей).

Ну, в Rust указатели очень даже есть. Но там отслеживание времени жизни даёт намного больше информации о том, что с чем способно алиаситься (если коротко — то в safe коде алиаситься не может ничего и ни с чем), чем в принципе возможно получить из информации о базовых типах.

Rust вообще мне жутко нравится идеологически и также жутко не нравится синтаксически… но то такое — мне и C++ в этом смысле нравится меньше, чем какой-нибудь Pascal.

А что бы вы в нем изменили? Ну, чтобы был хороший синтаксис?

Впрочем, если сделать шаг назад, ситуация, когда два вроде как неглупых разработчика, более-менее знающих C++, обсуждают, как же на этом языке сделать строки, и уже почти сутки не могут придти к консенсусу — это прикольно.
Почему не могут? Могут. Уже пришли к консенсусу: "enum FastChar : char {};" — это точно рабочий вариант.

А дальнейшее — это уже попытка придумать можно ли обойтись структурой…

Хороший язык.
Отличный просто. Никогда ни в чём нельзя быть уверенным.
Однако редкий трэш. Я про вот это:
mov byte ptr [rsp - 16], 0
......
mov rcx, qword ptr [rsp - 16]
mov qword ptr [rsp - 16], rcx
......
test cl, cl


Объяснение по ссылке выглядит убедительным (хотя для меня не очень понятным, ну да ладно), однако вообще это выглядит как, скажем так, некий приговор, если не языку, то использованным идиомам. Меньше кортежей, меньше…
Ну а какие альтернативы? Этот конкретный косяк разработчикам LLVM уже известен, фикс, как я сказал, на review…
Это понятно, но вопрос в том, что сложность и неочевидность ситуации порождается «богатыми возможностями языка». Сомнительный результат парадигмы «язык усложняем — программы упрощаем». Хотя конечно кортежи — вещь мощная, спору нет.

Просто не только у ФП-шников компиляторы ещё не достаточно умные.

Ну да, человечество заметно отстало от возможностей С++ )))
Оооо! Это вы ещё не смотрели на корутины! Там тоже идея в том, что «достаточно умные» компиляторы могут сделать вот всё то, что наворотили в C++20 «zero-cost» за счёт встраивания короутины в объемлющую процедуру.

Там до обещанного «zero-cost» ещё копать и копать — компиляторы ломаются в самых простейших случаях…
за счёт встраивания короутины в объемлющую процедуру


Хочется верить, что это встраивание не будет носить массовый характер, кмк в большинстве случаев все-таки корутине следует быть отдельной сопрограммой.
Зависит о того, что это за корутина. Если это генератор и используется он для ranges — то как раз хочется, чтобы встраивалось.

Но пока это происходит как и с initializer_list: встраивается-то оно встраивается, но ошмётки во все стороны торчат.
Ну, я не в курсе, что именно там за ошметки, но пространство для творчества там конечно есть. И, да, встроить простой генератор — еще можно, согласен.

Ситуация с initializer_list выглядит грустной еще и потому, что описание std::min с таким параметром не гласит о том, что при таком вызове раскручивается маховик репрессий чудес и аллокаций. Это все буквально выглядит просто как способ передачи параметров, использующий синтаксис initializer_list. И типа все должно быть потом прекрасно и быстро. А там вон чего творится…
А там вон чего творится…
Тут action item — однозначно «клевать мозг» разработчикам clang. Я попробую. Так быть не должно.
А почему вы на Go работаете с массивами, а на Rust — со строками? Метод bytes() создает cloned итератор, который не бесплатный.
Тоже самое с C#.
И вы получите тоже время выполния с отклонением в пределах статистической погрешности. По крайней мере на .NET 4.7.2.

Я их оба не знаю :)


Если кинете более эффективный вариант, то я его с радостью проверю сегодня вечером.

snippet
    <…>
    let ca1: Vec<u8> = s1.bytes().collect();
    let ca2: Vec<u8> = s2.bytes().collect();

    let mut v0: Vec<i32> = (0..).take(n + 1).collect();
    let mut v1 = v0.to_vec();

    for (i, c1) in ca1.iter().enumerate() {
        unsafe {
            *v1.get_unchecked_mut(0) = i as i32 + 1;

            for (j, c2) in ca2.iter().enumerate() {
    <…>

Действительно, так быстрее.

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

Отличная статья. Но я, признаться, недоумеваю, где же Idris с его завтипами? Интересно же.

Там с массивами так себе, а на списках я это делать не буду, он не сможет их соптимизировать. Надо брать Idris 2, собирать его из гита… Тема для отдельной статьи, в общем.

С удовольствием бы почитал. Прогаю на плюсах, с интересом перенимаю ФП практики, но не достиг просветления для перехода на Haskell. Бегло читал, что Idris ещё круче, потому буду ждать (предвкушая детальный разбор, анализ оптимизаций и ассемблерные листинги) :)

Круче означает помимо всего прочего — сложнее. Поэтому прежде чем прыгать в самую гущу лучше с чего попроще начать. Новичкам на плюсах наверное самую темную темплейт магию сразу не показывают, сначала простенько, на массивчиках, в рантайме, и потом уже понемногу-понемногу дальше.

Как посмотреть.


ФП учить — ИМХО без разницы, базовая семантика что в хаскеле, что в идрисе одна и та же (с точностью до ленивость). Идрис даже, может, чуть лучше, там из коробки можно делать дырки и смотреть на типы.


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


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

Про то, что Python не готов для вычислений, вы не правы. И PyPy не нужен, нужны только Numpy, Numba и самый обычный Python. Вот результаты

C
Windows 10, gcc version 8.1.0 (x86_64-posix-seh-rev0, Built by MinGW-W64 project)
$ gcc ld.c -Ofast -fPIC -fPIE -static -flto -o ld.exe
$ ld.exe
0
15000
Finished in 0.934s

Python
Косметические изменения в коде:
import sys
import time
import numpy as np
from numba import jit


@jit
def lev_dist(s1: bytes, s2: bytes) -> int:
    m = len(s1)
    n = len(s2)

    # Edge cases.
    if m == 0:
        return n
    elif n == 0:
        return m

    v0 = np.arange(0, n + 1)
    v1 = np.arange(0, n + 1)

    for i, c1 in enumerate(s1):
        v1[0] = i + 1

        for j, c2 in enumerate(s2):
            subst_cost = v0[j] if c1 == c2 else (v0[j] + 1)
            del_cost = v0[j + 1] + 1
            ins_cost = v1[j] + 1

            # min(subst_cost, del_cost, ins_cost) is slow.
            min_cost = subst_cost if subst_cost < del_cost else del_cost
            if ins_cost < min_cost:
                min_cost = ins_cost
            v1[j + 1] = min_cost

        v0, v1 = v1, v0

    return v0[n]


Результаты на Windows 10, Python 3.7.5
$ python ld.py
0
15000
Finished in 0.968s

На долю процентов C быстрее, но скорости практически сравнимы.

Умножьте на (4/3)² — для этих данных время квадратично зависит от длины, а везде строки в 20 тыщ символов.


Ну и, кстати, сколько тогда работы выполняется в питоне, а сколько — в C?

Можно привести и к 20 тыщ, но в целом видно, что C и Python практически сравнимы по скорости.
Ну и, кстати, сколько тогда работы выполняется в питоне, а сколько — в C?
Ну Python же это язык. У него может быть реализация как у интепретируемого языка, а может быть и jit компиляция.

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

Безусловно (поэтому там есть сравнение с pypy). Но FFI в C — это ИМХО как-то не очень честно при бенчмаркинге реализаций, так как вы тогда сравниваете не эффективность реализаций, а эффективность FFI.

так numba это не ffi

Теперь-то обратил внимание на @jit. Прикольная штука, буду иметь в виду, если вдруг придётся столкнуться с питоном.

Она, к сожалению, меняет семантику (и довольно существенно).

То есть если вы сами пишите код — в общем не проблема.

А вот взять чужой код и налепить на него jit — можно поиметь проблем.

Но учитывая ускорение в несколько раз — иногда можно поиграться…
Тут вопрос сложный. У нас есть Java Script с его V8, nim с его компиляцией в C, Julia с ее компиляцией через llvm. Да и тот же pypy в несколько раз медленнее numba, хотя оба пытаются компилировать код.

На мой взгляд вполне честно все сравнивать с точки зрения разработчика. Есть код, есть плафторма, есть результат.

Да и чем, грубо говоря, декоратор "@jit" так уж сильно отличается от набора специальных флагов «gcc» с подсказками для оптимизаций?

Я в том комментарии был неправ, так как неправильно прочитал код.


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

pip3 и через него модули numpy и numba.
Выше уже ответили, нужны только numpy и numba:
$ pip3 install numpy
$ pip3 install numba


На всякий случай, вот полный код:
#!/usr/bin/env python3
import sys
import time
import numpy as np
from numba import jit


@jit
def lev_dist(s1: bytes, s2: bytes) -> int:
    m = len(s1)
    n = len(s2)

    # Edge cases.
    if m == 0:
        return n
    elif n == 0:
        return m

    v0 = np.arange(0, n + 1)
    v1 = np.arange(0, n + 1)

    for i, c1 in enumerate(s1):
        v1[0] = i + 1

        for j, c2 in enumerate(s2):
            subst_cost = v0[j] if c1 == c2 else (v0[j] + 1)
            del_cost = v0[j + 1] + 1
            ins_cost = v1[j] + 1

            # min(subst_cost, del_cost, ins_cost) is slow.
            min_cost = subst_cost if subst_cost < del_cost else del_cost
            if ins_cost < min_cost:
                min_cost = ins_cost
            v1[j + 1] = min_cost

        v0, v1 = v1, v0

    return v0[n]


if __name__ == "__main__":
    s1 = b"a" * 15_000
    s2 = s1
    s3 = b"b" * 15_000

    exec_time = -time.monotonic()

    print(lev_dist(s1, s2))
    print(lev_dist(s1, s3))

    exec_time += time.monotonic()
    print(f"Finished in {exec_time:.3f}s", file=sys.stderr)
Начал с практически того же варианта, что и Вы, потом упростил внутренний цикл без потери производительности до:

        for j, c2 in enumerate(s2):
            v1[j+1] = min(v0[j] if c1 == c2 else (v0[j]+1), v0[j+1]+1, v1[j]+1)


Не понимаю, чем не понравился автору статьи min()?

Подобные "упрощения" не очень то и упрощают чтение кода. :)

Было 7 строк кода (понятного лишь из-за закомментированного варианта), 4 одноразовых переменных (которым еще имена придумать надо), стала одна строка, которая понятно что делает.

P.S. В конце концов можно так сделать:
        for j, c2 in enumerate(s2):
            v1[j+1] = min(v0[j] if c1 == c2 else (v0[j]+1), 
                          v0[j+1]+1, 
                          v1[j]+1)

0.998 с (на 20 тыщах). Неплохо!

Python c JIT-компиляцией через Numba просто рвёт все остальные скриптовые языки.

Why is NumPy Fast?

Vectorization describes the absence of any explicit looping, indexing, etc., in the code - these things are taking place, of course, just “behind the scenes” in optimized, pre-compiled C code

Ну как-то так. Я бы непричислял это к заслугам питона.

на любом языке можно писать на фортране

Numba – это что-то невероятное…
У меня оно не получилось быстрее C, но оно справилось быстрее Julia.

Круто, что даже без использования numpy при помощи numba можно прилично ускорить работу программы.


Например, если на код автора просто навесить декоратор @jit — программа отработает за 2.528 на моем железе.


Если убрать isinstance — уже будет 1.7.


Что не может не радовать. :)


Все-таки, если уже считаем в Питоне, то берем для этого инструменты, которые для него придумали.

Уже прокомментировали про C++, добавлю то же самое про D. Достаточно добавить флажок boundscheck=off и получить удвоение скорости. Размен безопасности на скорость, которую вы сделали в хаскеле.
P.S. Забыл про главное, компилятор ldc2, а не референсный DMD.

Не хочу комментировать, что 0xd34df00d намерял с C++, но ведь с D именно так оно и есть уже.
Можно было бы ещё LDC приложить, ведь он быстрее DMD справился с сей задачей.

ldc у меня не собрался. Дашь бинарь?

А, я про собранный им код.

Да, невнимательно посмотрел на флаги. Дописал в P.S.
Протестировал у себя, получил похожие цифры, а потом уже удвоился за счёт проверок границ. Даже не задумывался, что можно бенчмаркать DMD, на автомате взял ldc. Результаты у меня:
C gcc 8.3: Finished in 0.696s
D ldc2 1.10.0: Finished in 0.638s
Есть разброс, но в целом D версия чуть быстрее.
Пожалуйста, перетестируйте js:
const levDist = (s1, s2) => {
  const m = s1.length;
  const n = s2.length;

  // Edge cases.
  if (m === 0) {
    return n;
  } else if (n === 0) {
    return m;
  }

  let v0 = new Uint16Array(n + 1);
  let v1 = new Uint16Array(n + 1);

  for (let i = 0; i < m; ++i) {
    v1[0] = i + 1;

    for (let j = 0; j < n; ++j) {
      v1[j + 1] = Math.min(
        v0[j] + (s1[i] === s2[j] ? 0 : 1),
        v0[j + 1] + 1,
        v1[j] + 1
      );
    }

    [v0, v1] = [v1, v0];
  }

  return v0[n];
};


const s1 = new Uint8Array(15000).fill('a'.charCodeAt(0));
const s2 = s1.slice();
const s3 = new Uint8Array(15000).fill('b'.charCodeAt(0));

console.time('Finished in');

console.log(levDist(s1, s2));
console.log(levDist(s1, s3));

console.timeEnd('Finished in');

Спасибо, вечером на той же машине проверю и обновлю результаты.

Похоже, что V8 от Math.min, наоборот, получает выигрыш.

а зачем на с++ используется int64?

Для консистентности с хаскель-версией, там Int — знаковый и соответствующей машине битности. Впрочем, замена на uint64_t или на (u)int32_t вообще ничего не меняла на моей машине (что ожидаемо, работа с L2-кешем таки не оказывается боттлнеком, чтения/записи исключительно последовательные и хорошо предсказываются, векторизации здесь нет, а сравнения и сложения процессор выполняет, видимо, одинаково эффективно).

Если по кэшу нет разницы то логично.

Старому clang (6.0) очень помогло вынести в отдельную функцию внутренний цикл и написать __restrict. Что-то типа:
void calc_line(const char c1, const std::string &s2, const int64_t *__restrict v0, int64_t *__restrict v1, size_t n);

Сразу sse появляются и прочие удовольствия
Если начинать хитрые трюки устраивать, то непонятно где останавливаться. Как автор и заметил — так можно и до ручной генерации кода дойти.

Так что самый быстрый вариант, в пределе, будет выглядеть как asm("<многа букав>") — на любом языке.
А что же вы в GO unsafe не заюзали — он там тоже есть и тоже «педалирует» решение значительно.

С указательной арифметикой? Ну жуть же!
Обрати внимание на -gcflags=-B.
Да, всё так, без отключения проверок границ (что в коде сделать нельзя, к сожалению) было бы сильно хуже.

интересно что у меня на clang++8 если ставить флаг -std=gun++2a вместо -std=gnu++17 есть прирост где-то на 8%.
К тому же еще странно как у вас шланг так сливает гцц, я вот захожу на годболт, и там шланг векторизирует а гцц нет, и у меня на машине шланг8 быстрее гцц9.1. Сейчас поставлю новые версии.
Еще расту тоже надо указывать -C "target-cpu=native для честности. Кстати хаскелю наверное тоже что то такое т.к. это флаг скорее всего для ллвм.

интересно что у меня на clang++8 если ставить флаг -std=gun++2a вместо -std=gnu++17 есть прирост где-то на 8%.

Вот это вот действительно интересно. Я сходу не могу придумать, за счёт чего оно бы так могло быть.


К тому же еще странно как у вас шланг так сливает гцц, я вот захожу на годболт, и там шланг векторизирует а гцц нет, и у меня на машине шланг8 быстрее гцц9.1.

Я, кстати, не знаю, как тут так просто бы векторизовать, так как у каждого следующего элемента массива есть неприятная зависимость по данным от всех предыдущих. Если он там действительно векторизует, а не просто использует векторные регистры и векторные мувы, чтобы сразу 8-16-32 инта получать, то это очень круто.


Ну и да, на одной из машин у меня был clang 8, и там он был быстрее gcc, так что, вероятно, это регрессия. Но полный набор тестов на той машине я провести не мог, а ставить clang 8 на ту, где мог, просто потому, что он тут быстрее — это какое-то читерство.


Кстати хаскелю наверное тоже что то такое т.к. это флаг скорее всего для ллвм.

Если вкратце, то у меня не получилось, а жаль. Могло бы быть ещё чуточку быстрее.

наверное про векторизацию я нагнал и это был код iota

У меня разница между шланг8 и шланг9 минимальна. 8-ой шланг действительно дает маленькую регрессию при с++17.

А я уже тестил с оптимизацией s1[i] за границей цикла. Иначе (на вашем оригинальном коде) в шланге9 дикая регрессия больше чем в два раза!

Похоже, что за неполные 4 часа, неопротестованными остались всего несколько тестов =)

Было бы неплохо обновить статью новой табличкой результатов. И новыми выводами.

И еще статья дает больше информации не столько о скорости ЯП, сколько об их читаемости.

Как вечером доберусь до домашней машины — прогоню предложенные новые варианты и обновлю.


А выводы-то… Ну вот питон с numba приятно удивил, да. Другой вывод — похоже, я не умею передавать смысл того, что я делаю.

Видимо да.

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

И можно не пренебрегать безопасностью в угоду производительности.

Но в любом случае надо хорошо знать свой инструмент.
У меня вывод простой (из аналогичных тестов) — если язык компилируемый, то в большинстве случаев производительности достаточно, кроме разве что, специализированных задач.

С одной стороны — не только. Смотрите, как хорошо себя JS показывает на V8/SM.


С другой — не столько. Случаи, когда тот же хаскель был медленнее на несколько порядков, у меня тоже были. Правда, я тогда был сильно глупее и неопытнее. Думаю, на самом деле разница там уж не на порядки.

JIT стал хорош только по прошествии 20 лет, и то пока примеров единицы — JVM, NET, отчасти JS, и возможно Julia, остальные глотают пыль.

Проблема Хаскеля (ну и не только, например linq, sql), как я глупо думаю — что высокоуровневые абстракции скрывают реальные накладные расходы — и очень легко написать красивый код, получив ужасную практическую реализацию с квадратичными расходами.

А фиг его знает, в чём проблема.


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


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

Не хватает тестов clang -stdlibc :)
НЛО прилетело и опубликовало эту надпись здесь

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

НЛО прилетело и опубликовало эту надпись здесь

И так для каждой микроархитектуры.


А если серьёзно, я смотрел на асм (но больше на хаскелевский — интересно было, чего там компилятор наворотил). В общем, мне пока хватит.

Что, если в хаскелевском коде 0.8с уходит на инициализацию некого рантайма, а остальные 0.01с на что-то вроде


.LC0:
        .string "0"
main:
        movl    $.LC0, %edi
        jmp     puts

? Я бы добавил зависимость от входных данных, чтобы быть уверенным, что результат считается не в compile-time.

Ну тут две вещи дают уверенность:


  1. Я добавлял NOINLINE-прагму к определению функции (с очевидной семантикой) — ничего не изменилось вообще никак, а кроссмодульную оптимизацию в ghc не завезли.
  2. Библиотека criterion специально сделана так, чтобы данные не протекали в функцию до начала бенчмарка, и результаты от этой библиотеки согласуются с результатами из табличек.
вперде!

у меня уже есть ответ, впрочем
НЛО прилетело и опубликовало эту надпись здесь

А вы с 15к или с 20к символами запускали?


Вот это моя, наверное, самая большая ошибка — надо было не лениться и переделать везде на 20к. А то теперь одна путаница и неразбериха.


И код-то покажите, интересно же!

НЛО прилетело и опубликовало эту надпись здесь
Однако поддержу просьбу показать код…
PHP можно попросить удалить ненужные строки и переменные `$ca1`, `$ca2`:

    //$ca1 = $ca2 = [];
    //for ($i = 0; $i < $m; ++$i) {
    //    $ca1[] = ord($s1[$i]);
    //}
    //for ($i = 0; $i < $n; ++$i) {
    //    $ca2[] = ord($s2[$i]);
    //}


А соответствующие циклы заменить на:
    for($i = 0;  $i < $m; $i++) {
        $v1[0] = $i + 1;

        for($j = 0; $j < $n; $j++) {
            $subst_cost = ($s1[$i] === $s2[j]) ? $v0[$j] : ($v0[$j] + 1);
            $del_cost = $v0[$j + 1] + 1;
            $ins_cost = $v1[$j] + 1;

            // min($subst_cost, $del_cost, $ins_cost) is slow.
            $min_cost = ($subst_cost < $del_cost) ? $subst_cost : $del_cost;
            if ($ins_cost < $min_cost) {
                $min_cost = $ins_cost;
            }
            $v1[$j + 1] = $min_cost;
        }

        [$v0, $v1] = [$v1, $v0];
    }


 Минус два цикла — должно дать существенный прирост производительности

Сравнение чисел просто немножко дешевле.

минус два цикла с вычислением ord()
плюс строгое равенство при сравнении одиночных символов.

Зуб даю мой код быстрее и читабельнее. Не говоря уже про UTF-8
минус два цикла с вычислением ord()

Вне цикла подобные манипуляции вообще ничего не значат на общем фоне. Так-то их можно вообще вынести наружу… только зачем?


плюс строгое равенство при сравнении одиночных символов

При сравнении строк иначе и нельзя. А вот при сравнении (гарантированных) чисел никакой разницы тут не будет.


Не говоря уже про UTF-8

Все версии сделаны именно так, чтобы сравнивались именно индивидуальные байты для однородности и простоты (ну, в большинстве случаев).
Но вообще, как ни иронично, именно вариант с конвертацией в массив чисел более юникододружелюбный, потому что с ним для UTF-8 нужно просто заменить ord на mb_ord, а иначе нужно использовать mb_substr; а это, сам понимаешь, уже целый вызов функции внутри цикла.

минус два цикла с вычислением ord()

Вне цикла подобные манипуляции вообще ничего не значат на общем фоне. Так-то их можно вообще вынести наружу… только зачем?


Я не понял что именно вы предлагаете «вынести наружу»?

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

Но преобразование строк в числа — m + n операций (где это длины строк), а сравнений потом происходит в районе mn. 40 тысяч против 400 миллионов в этом случае.

И если их цель преобразовать строки в числа

Как бы, ord
Неужели так сложно поверить, что числа сравнивать дешевле, чем целые строки, хоть и единичные %)?


это немного высокая цена

Два for на 40 000 циклов суммарно.


ради того чтобы позже выиграть на сравнении чисел вместо строк

А вот сравнение происходит 400 000 000 раз.

 Понял, согласен.

Кто-нибудь пробовал в итоге запустить и сравнить эти два PHP варианта между собой? Рассуждения интересные, но хотелось бы понять на практике.

Я таки попробовал. И мой предложенный вариант не выдал улучшения, а таки негативно сказался на производительности примерно на 10-15%.
Не говоря уже про UTF-8

Предложенная функция всё равно остаётся метрикой (в математическом смысле), пусть и считает немного не то.


Но игнорирование уникода было одним из моих допустимых предположений, считать всё корректно для уникода (и особенно для UTF-8) чуть менее просто.

считать всё корректно для уникода (и особенно для UTF-8) чуть менее просто.

Тут зависит от реализации. Для раста можно заменить итератор по bytes на итератор по chars и получить всё то же самое и без особой просадки производительности.
А на php с кем-то предложенным тут mb_substr это будет уже O(n⁴), потому что для взятия каждого следующего символа надо будет пробежать по всей строке.
Добавлю свои 0.05 копеек.

В коде на С++ внутри одного цикла идет std::swap длинных векторов, в коде на С — меняются указатели. До кучи внутри swap идет вызов new/delete, которые на Це эквиваленты дерагнью malloc/free, что само по себе плохо сказывается на производительности и зависит от того, как там heap устроен, плюс есть эффект на забивание кешей процессора

Там нет вызова new/delete. И быть его там не должно, если ваша реализация STL зачем-то там выделяет/освобождает память, то её лучше выкинуть.


В этом случае std::swap действительно делает чуть больше работы, чем обмен указателями (так как он обменивает ещё и длины, а они одинаковые), но я не думаю, что это принципиально что-то меняет.