среда, 11 ноября 2015 г.

cgrep vs ack

Cgrep и ack — это две grep-подобные программы, приспособленные для поиска внутри исходных текстов на разных языках программирования. Ack написана на perl и существует довольно давно, в то время как cgrep — относительно новая программа, написанная на haskell и обладающая большим количеством экспериментальных возможностей. Я не буду подробно сравнивать всевозможные аспекты использования этих программ, но отмечу их наиболее яркие положительные качества и недостатки и в конце приведу мои собственные настройки для декорирования их вывода на терминал. Итак, начнем с положительных качеств ack.
  • Возможность настроить рекурсивный поиск только внутри исходников указанного языка программирования (или нескольких языков). Соответствие файла определенному языку программирования определяется несколькими способами: расширением имени файла, точным именем файла (например, Makefile для make) и соответствием имени файла или первой строки в файле заданному шаблону регулярного выражения. Настройки соответствий языков по умолчанию можно вывести с помощью опции ack --dump: список языков находится внутри опций --type-add. Собственно включение в поиск определенного языка задается синтетической опцией --язык, где язык соответствует одному из значений в выведенном списке, стоящих сразу после символа =. Например, чтобы включить поиск внутри исходников C++, нужно указать опцию --cpp.
  • Возможность гибкой настройки вывода контекста вокруг строк с найденными совпадениями: для этого имеются целых три опции -A, -B и -C.
  • Гибкая настройка формата вывода, включая цветовую подсветку найденных совпадений и возможность перенаправления во внешнюю программу или скрипт с опцией --pager.
  • Задание настроек по умолчанию в файле .ackrc в домашней директории и поддиректориях.
  • Поиск файлов по имени или регулярному выражению (опция -g). Это просто киллер-фича! Теперь от громоздкой и негибкой команды find . -name '*pattern*' можно отказаться. Или почти отказаться… Есть два тонких момента, даже три. Во-первых, имеются опции по умолчанию --ignore-file, которые можно вывести с помощью опции --dump: в них указано, какие файлы будут игнорироваться при поиске. В частности, туда должны входить файлы jpg, png и тому подобные. Чтобы отменить все настройки по умолчанию, в том числе настройки игнорирования файлов, нужно просто добавить опцию --ignore-ack-defaults. Но… Вряд ли после этого ack начнет находить jpg и png файлы! Потому что они бинарные! И это было во-вторых. Чтобы преодолеть это ограничение, нужно поступить так, как сказано здесь, то есть добавить еще две опции --type-set='all:match:.*' -k (см. также определение алиаса ackf в конце этой статьи). Ну а в-третьих, пустые директории, а также всякого рода потоки и другие не-файлы, искусственно привязанные к файловой системе (сокеты, FIFO и т.п.), ack будет игнорировать в любом случае.
Прежде чем перейти к плюсам cgrep, я отмечу ее серьезный недостаток по сравнению с ack. Это невозможность вывода контекста вокруг строк с найденными совпадениями. А теперь плюсы.
  • Скорость и возможность параллельного исполнения. Это все-таки haskell!
  • Поддержка разных типов поиска. Кроме дословного поиска и поиска совпадения с регулярным выражением (включая POSIX и PCRE), сюда входят префиксный и суффиксный поиски, а также поиск с учетом расстояния Левенштейна. Последний тип поиска позволяет находить похожие слова или слова с возможными ошибками.
  • Поддержка семантического поиска позволяет искать совпадения в коде, комментариях или строковых литералах отдельно (опции -c, -m и -l), ограничивать поиск специфическими типами идентификаторов, такими как ключевые слова, литералы, директивы препроцессора и операторы (наилучшим образом поддерживаются языки C и C++). Опция -S позволяет настроить семантический поиск с помощью специального семантического языка (простейшие примеры можно увидеть, запустив cgrep --help).
  • Как и в ack, можно настроить поиск только внутри исходников определенного языка (или языков). Список поддерживаемых языков и соответствующие им расширения имен файлов можно вывести с помощью опции --lang-maps. Чтобы включить в поиск определенный язык, нужно указать его в опции --lang, например, --lang=Cpp.
  • Как и в ack, можно гибко настроить формат вывода с помощью опции --format. Вывод подсветки задается, но сами цвета не настраиваются (в функции cgr, которую я приведу ниже, изменение цветов подсветки достигается с помощью обработки в sed).
  • Некоторые настройки по умолчанию можно записать в файле .cgreprc в домашней директории.
Нужно отметить, что поиск на основе регулярных выражений в cgrep отличается от того, как это реализовано в ack или в обычном grep. Если последние ищут совпадения в каждой отдельной строке ввода, то cgrep применяет шаблон ко всему входному тексту. Это может приводить к тонким семантическим отличиям для некоторых шаблонов. Например, класс символов \s включает переносы строк, а значит найденное совпадение после применения шаблона с \s может быть многострочным. Ситуация осложняется тем, что cgrep выведет только первую строку совпадения (а это уже баг, на мой взгляд), которая может не содержать значимой информации, тем самым дезориентируя пользователя. Поэтому я советую вместо \s использовать класс горизонтальных пробелов \h (правда, он доступен только в PCRE) — это будет гарантировать отсутствие переносов строк в найденном совпадении. Ниже я привожу картинку с выводом ack и cgrep на моем терминале, а также настройки и скрипты, которые позволяют добиться этого результата. Файл .ackrc.
--color-match=rgb551
--color-filename=rgb342
--color-lineno=rgb233

--noheading
--pager=sed -e 's/\(.\+\)\([:-]\)\(\x1b\[38;5;109m[[:digit:]]\+\x1b\[0m\)\2/\1 ⎜ \3 ⎜ /'
            -e 's/\x1b/@eCHr@/g' | column -t -s-o⎜ |
        sed -e 's/@eCHr@/\x1b/g'
            -e 's/\(\s\+\)\(\x1b\[38;5;109m[[:digit:]]\+\x1b\[0m\)\(\s\+\)/\3\2\1/' |
        hl -u -255 -b '^--$' -rb -216 '\x{239C}'
Обратите внимание, я разбил последнюю строку с опцией --pager на пять отдельных строк для удобочитаемости. На самом деле это должна быть одна строка и разбивать ее на части нельзя! А это функция-обертка cgr (ее можно поместить в .bashrc).
function cgr
{
    `env which cgrep` --color --format='#f ⎜ #n ⎜ #l' -r "$@" |
            sed -e 's/\x1b\[1m\x1b\[94m/@eCHrF@/' \
                -e 's/\x1b\[1m/@eCHrB@/g' \
                -e 's/\x1b\[m/@eCHrE@/g' |
            column -t -s-o|
            sed -e 's/@eCHrF@/\x1b\[38;5;150m/' \
                -e 's/@eCHrB@/\x1b\[38;5;227m/g' \
                -e 's/@eCHrE@/\x1b\[m/g' \
                -e 's/\(\s\+\)\([[:digit:]]\+\)\(\s\+\)/\3\2\1/' |
            hl -u -73 '\h+\d+\h+(?=\x{239C})' -216 '\x{239C}'
}
(Здесь переносы допустимы). Программа hl доступна отсюда — она нужна для дополнительной подсветки колонки с номерами строк, я писал о ней здесь и здесь. Ну и алиас для поиска файлов с помощью ack.
alias ackf='ack --ignore-ack-defaults --type-set=all:match:. -k --color --nopager -g'
Update. Начиная с версии util-linux 2.28 программа column научилась игнорировать управляющие последовательности ANSI, поэтому ужасные вре́менные замены текста типа @eCHr@ в опции --pager из .ackrc и в функции cgr больше не нужны. Соответственно, теперь они будут выглядеть так (содержимое опции --pager должно по-прежнему находиться в одной строке).
--pager=sed 's/\(.\+\)\([:-]\)\(\x1b\[38;5;109m[[:digit:]]\+\x1b\[0m\)\2/\1 ⎜ \3 ⎜ /' |
        column -t -s-o⎜ |
        sed 's/\(\s\+\)\(\x1b\[38;5;109m[[:digit:]]\+\x1b\[0m\)\(\s\+\)/\3\2\1/' |
        hl -u -255 -b '^--$' -rb -216 '\x{239C}'

function cgr
{
    `env which cgrep` --color --format='#f ⎜ #n ⎜ #l' --no-column -r "$@" |
            column -t -s-o|
            sed -e 's/\x1b\[1;94m/\x1b\[38;5;150m/' \
                -e 's/\x1b\[1m/\x1b\[38;5;227m/g' \
                -e 's/\(\s\+\)\([[:digit:]]\+\)\(\s\+\)/\3\2\1/' |
            hl -u -73 '\h+\d+\h+(?=\x{239C})' -216 '\x{239C}'
}
Также обратите внимание на некоторые изменения в функции cgr, связанные с обновленным cgrep, в частности на новую опцию --no-column.

пятница, 18 сентября 2015 г.

nginx upstrand to configure super-layers of upstreams

Recently I equipped my nginx combined upstreams module with a new entity that I named upstrand. An upstrand is an nginx configuration block that can be defined in the http clause after upstream blocks. It was designed to combine upstreams in super-layers where all upstreams keep their identity and do not get flattened down to separate servers. This can be useful in multiple areas. Let me rephrase here an example from the module documentation. Imagine a geographically distributed system of backends to deliver tasks to an executor. Let the executor be implemented as a poller separated from the backends by an nginx router proxying requests from the poller to an arbitrary upstream in a list. These upstreams (i.e. geographical parts) may have multiple servers within. Let them send HTTP status 204 if they do not have any tasks at the moment. If an upstream has 10 servers and the first server sends 204 No tasks upon receiving a request from the poller then other 9 servers will presumably send the same status in a short time interval. The nginx upstreams are smart enough to skip checking all servers if the first server returns status 204: the status will be sent to the poller and then the poller must decide what to do next. This scheme has several shortcomings. Remember words arbitrary upstream that I intentionally emphasized in the previous paragraph? Nginx cannot choose arbitrary upstreams! It can combine several upstreams into a bigger upstream but in this case the poller may sequentially receive up to 10 204 responses from the same upstream trying to get a next task. The concrete value (10 or less) of repeated requests depends on how the bigger upstream combines separate servers from the geographical upstreams. On the other hand the poller may know the topology of the backends and send requests to concrete upstreams. I even do not want to explain how bad this is. And again it will send new requests when already polled upstreams have no tasks. What if encapsulate all the polling logic inside the proxy? In this case the proxy router would initiate a new request to another upstream itself if the previous server responds with 204 No tasks. It would eventually send back to the poller a new task or the final 204 status when there were no tasks in all upstreams. The single request from the poller and no need for knowledge about the backends topology downstream the router! This was a good example of what the new upstrand thing can accomplish. Let’s look at a simple nginx configuration that involves upstrands.
worker_processes  1;
error_log  /var/log/nginx/error.log  info;

events {
    worker_connections  1024;
}

http {
    upstream u01 {
        server localhost:8020;
    }
    upstream u02 {
        server localhost:8030;
    }
    upstream b01 {
        server localhost:8040;
    }
    upstream b02 {
        server localhost:8050;
    }
    
    upstrand us1 {
        upstream ~^u0;
        upstream b01 backup;
        order start_random;
        next_upstream_statuses 204 5xx;
    }
    upstrand us2 {
        upstream ~^u0;
        upstream b02 backup;
        order start_random;
        next_upstream_statuses 5xx;
    }

    server {
        listen       8010;
        server_name  main;

        location /us1 {
            proxy_pass http://$upstrand_us1;
        }
        location /us2 {
            rewrite ^ /index.html last;
        }
        location /index.html {
            proxy_pass http://$upstrand_us2;
        }
    }
    server {
        listen       8020;
        server_name  server01;

        location / {
            return 503;
        }
    }
    server {
        listen       8030;
        server_name  server02;

        location / {
            return 503;
        }
    }
    server {
        listen       8040;
        server_name  server03;

        location / {
            echo "In 8040";
        }
    }
    server {
        listen       8050;
        server_name  server04;

        location / {
            proxy_pass http://rsssf.com/;
        }
    }
}
Upstrand us1 imports 2 upstreams u01 and u02 using a regular expression (directive upstream regards names as regular expressions when they start with tilde) and backup upstream b01. While searching through the upstreams for a response status that may be sent to the client the upstrand performs 2 cycles: normal and backup. Backup cycle starts only when all upstreams in the normal cycle responded with unacceptable statuses. Directive order start_random indicates that the first upstream in the upstrand after the worker started up will be chosen randomly, next upstreams will follow the round-robin order. Directive next_upstream_statuses lists HTTP response statuses that will sign to the router to postpone the response and send the request to the next upstream. The directive accepts 4xx and 5xx statuses notation. If all servers in normal and backup cycles responded with unacceptable statuses (like 204 No tasks in the above example) the last response is sent to the client. Upstrand us2 does not differ a lot. It imports same u01 and u02 upstreams in normal cycle and b02 in backup cycle. Each upstream in this configuration has a single server. Servers from upstreams u01 and u02 simply return status 503, server from b01 says In 8040, server from b02 proxies the request to a good old days site rsssf.com (which is a great place for soccer stats!) that I chose because it won’t redirect to https and will send back a huge response: a nice thing to test if buffering of the response won’t break it while going through the proxy router and filters of the combined upstreams module. Let’s look how it works. Start nginx with the above configuration and a sniffer on the loopback interface (better in another terminal).
nginx -c /path/to/our/nginx.conf
ngrep -W byline -d lo '' tcp
Run a client for location /us1.
curl 'http://localhost:8010/us1'
In 8040
Nice. We are in the backup upstream b01. However it shows too little information. That’s why we ran a sniffer. Here is its output.
######
T 127.0.0.1:44070 -> 127.0.0.1:8010 [AP]
GET /us1 HTTP/1.1.
User-Agent: curl/7.40.0.
Host: localhost:8010.
Accept: */*.
.

#####
T 127.0.0.1:37341 -> 127.0.0.1:8030 [AP]
GET /us1 HTTP/1.0.
Host: u02.
Connection: close.
User-Agent: curl/7.40.0.
Accept: */*.
.

##
T 127.0.0.1:8030 -> 127.0.0.1:37341 [AP]
HTTP/1.1 503 Service Temporarily Unavailable.
Server: nginx/1.8.0.
Date: Fri, 18 Sep 2015 12:58:01 GMT.
Content-Type: text/html.
Content-Length: 212.
Connection: close.
.
<html>.
<head><title>503 Service Temporarily Unavailable</title></head>.
<body bgcolor="white">.
<center><h1>503 Service Temporarily Unavailable</h1></center>.
<hr><center>nginx/1.8.0</center>.
</body>.
</html>.

########
T 127.0.0.1:50128 -> 127.0.0.1:8020 [AP]
GET /us1 HTTP/1.0.
Host: u01.
Connection: close.
User-Agent: curl/7.40.0.
Accept: */*.
.

##
T 127.0.0.1:8020 -> 127.0.0.1:50128 [AP]
HTTP/1.1 503 Service Temporarily Unavailable.
Server: nginx/1.8.0.
Date: Fri, 18 Sep 2015 12:58:01 GMT.
Content-Type: text/html.
Content-Length: 212.
Connection: close.
.
<html>.
<head><title>503 Service Temporarily Unavailable</title></head>.
<body bgcolor="white">.
<center><h1>503 Service Temporarily Unavailable</h1></center>.
<hr><center>nginx/1.8.0</center>.
</body>.
</html>.

########
T 127.0.0.1:55270 -> 127.0.0.1:8040 [AP]
GET /us1 HTTP/1.0.
Host: b01.
Connection: close.
User-Agent: curl/7.40.0.
Accept: */*.
.

##
T 127.0.0.1:8040 -> 127.0.0.1:55270 [AP]
HTTP/1.1 200 OK.
Server: nginx/1.8.0.
Date: Fri, 18 Sep 2015 12:58:01 GMT.
Content-Type: text/plain.
Connection: close.
.
In 8040

#####
T 127.0.0.1:8010 -> 127.0.0.1:44070 [AP]
HTTP/1.1 200 OK.
Server: nginx/1.8.0.
Date: Fri, 18 Sep 2015 12:58:01 GMT.
Content-Type: text/plain.
Transfer-Encoding: chunked.
Connection: keep-alive.
.
8.
In 8040
.
0.
.
The client sent the request to port 8010 (our router’s frontend). Then the router started the normal cycle: it proxied the request to the upstream u02 (8030 server) which responded with unacceptable status 503, after that the router tried the next upstream u01 from the normal cycle with the only 8020 server and received 503 status response again. Finally the router started backup cycle and received response In 8040 with HTTP status 200 from 8040 server that belonged to upstream b01, This response was sent back to the client and shown on the terminal. I won’t show results of testing location /us2: the response was very large while the scenario is essentially the same. To search through the sniffer output is boring. Let’s better show what we are visiting during waiting for response on the terminal. An addition of a simple location
        location /echo/us1 {
            echo $upstrand_us1;
        }
into the frontend 8010 server will make our testing easy. (Beware that older versions of the echo module compiled for nginx-1.8.0 will behave badly in the next tests! I used latest version v0.58 and it worked fine.) Restart nginx and run some curls again.
curl 'http://localhost:8010/echo/us1'
u02
curl 'http://localhost:8010/echo/us1'
u01
curl 'http://localhost:8010/echo/us1'
u02
curl 'http://localhost:8010/echo/us1'
u01
curl 'http://localhost:8010/echo/us1'
u02
Hmm. Servers from the normal cycle following each other in the round-robin manner. Upstrand us1 must return response from the first upstream in normal cycle whose status is not listed in next_upstream_statuses. Directive echo normally returns 200 and this status is not present in the list. Thus the list of the upstreams shown on the terminal corresponds to first upstreams in the normal cycle: they start randomly and proceed in the round-robin sequence. Let’s now show the last upstreams chosen during the two cycles. As soon as servers from upstreams u01 and u02 in the normal cycle will always return unacceptable status 503 we must expect that upstream b01 from backup cycle will always be the last. To check this we will add status 200 which is normally returned by echo into the list of directive next_upstream_statuses in the upstrand us1.
    upstrand us1 {
        upstream ~^u0;
        upstream b01 backup;
        order start_random;
        next_upstream_statuses 200 204 5xx;
    }
Run curls.
curl 'http://localhost:8010/echo/us1'
b01
curl 'http://localhost:8010/echo/us1'
b01
curl 'http://localhost:8010/echo/us1'
b01
Nice, as expected. And now let’s show all upstreams the router tries before sending the last response. For such a task there is another upstrand block directive debug_intermediate_stages (do not use it in other cases besides testing because it is not designed for normal usage in upstrands).
    upstrand us1 {
        upstream ~^u0;
        upstream b01 backup;
        order start_random;
        next_upstream_statuses 200 204 5xx;
        debug_intermediate_stages;
    }
Restart nginx and run curls again.
curl 'http://localhost:8010/echo/us1'
b01
u02
u01
curl 'http://localhost:8010/echo/us1'
b01
u01
u02
curl 'http://localhost:8010/echo/us1'
b01
u02
u01
curl 'http://localhost:8010/echo/us1'
b01
u01
u02
Looks interesting. The last upstream is shown first and the first upstream is shown last. Do not worry, this is an artefact of the upstrand implementation. And now I want to say a little bit about the implementation. All standard and third-party directives like proxy_pass and echo that magically traverse upstreams in an upstrand refer to a magic variable whose name starts with upstrand_ and ends with the upstrand name. Such magic variables are created automatically in the configuration handler ngx_http_upstrand_block() for each declared upstrand. The handler of these variables ngx_http_upstrand_variable() creates the combined upstreams module’s request context with imported from the module’s main configuration indices of the next upstreams in both normal and backup cycles and shifts the main configuration’s indices forward for the future requests. The module installs response header and body filters. The header filter checks whether the response context exists (i.e. an upstrand variable was accessed) and if so it may start up a subrequest: this depends on the response status of the main request or the previous subrequest if they have been already launched. In nginx a subrequest runs the location’s content handler again. Remember that the both proxy and echo directives referred to our magic variable? This means that their content handlers accessed the variable’s handler on every subrequest because the upstrand variables had attribute nocacheable. The variable’s handler might feed the subrequests with the shifted index of the next upstream in the upstrand after checking whether to move to the backup cycle or finish the subrequests when all upstreams in the both cycles had been exhausted. In the latter case the final response was extracted from the subrequests’ response headers and body buffer chains and returned to the client. Want to know how? In case when the subrequests have been launched, original headers of the main request are replaced by headers of the last subrequest. Thanks to running subrequests from the header filter the response bodies nest in reverse order (this explains why our last test listed visited upstreams from last to first). This makes possible to break feeding next body filters with older responses bodies following the last response body and thus to return the body of the last subrequest. The beginning of the older response bodies is recognized by the last buffer in the last response’s buffer chain. Replacing the main request output headers and the response body buffer chain by those from the last response does the trick.

воскресенье, 16 августа 2015 г.

Не только лишь все

Мне всегда нравилось это высказывание Кличко, но не потому, что оно какое-то глупое или смишное, на что указывают многочисленные комментаторы, большинство из которых сами вполне достойны этих двух эпитетов. Просто есть в этой фразе ссылка на некоторую неинтуитивную теорию множеств. В самом деле, почему все должны принадлежать всем? Нет ли во множестве всех какого-то подмножества, всем не принадлежащего? Ничего не напоминает? Да это же шикарнейший парафраз парадокса Бертрана Рассела для наивной теории множеств Кантора, при преодолении которого были открыты целые новые направления в математике и формальной логике, в том числе лямбда-исчисление и, следовательно, основы современных функциональных языков программирования! К чему это я? Да к тому, что глупым людям что ни скажи, им всё или смешно или непонятно, а умные могут кучу полезных научных теорий наваять. P.S. Все животные все, но некоторые все́е.

суббота, 1 августа 2015 г.

Haskell, производительность, criterion

Стали с приятелем меряться скоростями на больших данных в задаче о монетах из предыдущей статьи. Он на C++, я на haskell. Само собой, даже самое быстрое решение из всех в ней представленных — с Mapочень, значительно уступает простому оптимизированному итеративному алгоритму на C. Из серьезных проблем решения с Map я вижу как минимум две. Во-первых, minNumCoinsMap генерирует огромный список от 0 до m, а затем берет его последний элемент. Список — классная структура данных в теории функционального программирования, вот только не очень быстрая, а для получения последнего элемента нужно пробежаться по всем элементам, начиная с первого. Все это очень нездо́рово, когда число m равно нескольким миллионам. Во-вторых, я использовал хотя и быструю, но неизменяемую (immutable) реализацию Map. Это значит, что на каждом шаге внутри функции step функция insert возвращает новый ассоциативный массив с уже накопленными данными и одним новым — очень расточительный подход! Чтобы хотя бы на порядок приблизиться к скорости алгоритма на C++, о списке и неизменяемом Map нужно забыть. Хотя на самом деле у меня было четыре итерации постепенного улучшения производительности за счет изменения деталей алгоритма и примитивов, его реализующих. На первой итерации я оставил immutable Map, но в функции step перестал копировать его полностью. В самом деле, алгоритм на шаге m проверяет уже полученные решения, записанные в массив, начиная со значения m - max c, где max c — максимальный номинал монеты в списке c. Соответственно, строки с определением функции step приняли следующий вид.
            where step a x = let cur = min (s ! (m - x) + 1) $ snd a
                             in (insert m cur (snd $ split (m - mc) s), cur)
                    where mc = maximum c
Функция split определена в модуле Data.IntMap.Strict и ее нужно импортировать. Это немного улучшило производительность. На второй итерации я заменил неизменяемый Map на изменяемый (mutable) Array из модуля Data.Array.IO. Новая функция minNumCoinsArray представлена ниже.
import           Data.Array.IO
import           Data.Array.Base             (unsafeRead, unsafeWrite)

-- ----------

minNumCoinsArray :: [Int] -> Int -> IO Int
minNumCoinsArray c m = do
    a <- newArray (0, m) 0 :: IO (IOUArray Int Int)
    mapM_ (mnc cs a) [1 .. m]
    unsafeRead a m
    where cs = sortBy (flip compare) c
          mnc :: [Int] -> IOUArray Int Int -> Int -> IO ()
          mnc c a m = foldM (step a) m (dropWhile (> m) c) >>= unsafeWrite a m
            where step a b x = liftM (min b . succ) $ unsafeRead a $ m - x
Этот массив работает с unboxed данными, расположенными непрерывно на участке памяти в куче от 0 до m — это очень эффективная структура данных (и уже не чистая). Кроме того, здесь оптимизирована свертка по списку монет c: сначала монеты сортируются в обратном порядке, а внутри свертки линейная функция filter заменена на dropWhile: в нормальной ситуации, когда самый старший номинал значительно меньше полной стоимости m, функция dropWhile будет почти всегда останавливаться на первом же элементе. Эта реализация при больших значениях m отставала от референсной программы на C++ на порядок. На третьей итерации я всего лишь заменил mutable unboxed Array на mutable unboxed Vector — чисто посмотреть, что из этого выйдет. Вот реализация функции minNumCoinsVector.
import qualified Data.Vector.Unboxed.Mutable as V

-- ----------

minNumCoinsVector :: [Int] -> Int -> IO Int
minNumCoinsVector c m = do
    v <- V.new $ m + 1
    V.unsafeWrite v 0 0
    mapM_ (mnc cs v) [1 .. m]
    V.unsafeRead v m
    where cs = sortBy (flip compare) c
          mnc c v m = foldM (step v) m (dropWhile (> m) c) >>= V.unsafeWrite v m
            where step v a x = liftM (min a . succ) $ V.unsafeRead v $ m - x
Результат не сильно отличался от предыдущего. И тогда я решил выжать из этого решения все что можно до последней капли (ну, наверное, все-таки не до последней). Никаких функциональных штучек вроде списков и сверток, никаких min, succ, dropWhile и тому подобного. Берем тупой итеративный алгоритм как в C и применяем его к тупой C-подобной структуре данных mutable unboxed Vector. Список монет реализуем как immutable unboxed Vector — ведь он не изменяется.
import qualified Data.Vector.Unboxed         as VI

-- ----------

minNumCoinsVectorBasic :: VI.Vector Int -> Int -> IO Int
minNumCoinsVectorBasic c m = do
    v <- V.new $ m + 1
    V.unsafeWrite v 0 0
    mapM_ (mnc c v) [1 .. m]
    V.unsafeRead v m
    where mnc c v m = VI.foldM_ (step v) m c
            where step v a x =
                    if x > m
                        then return a
                        else do y <- V.unsafeRead v $ m - x
                                let cur = y + 1
                                if cur < a
                                    then do V.unsafeWrite v m cur
                                            return cur
                                    else return a
Список монет теперь имеет тип VI.Vector Int и должен формироваться перед вызовом minNumCoinsVectorBasic, например так.
          coins    = [24, 18, 14, 11, 10, 8, 5, 3, 1]
          vcoins   = VI.fromList $ sortBy (flip compare) coins
Предельно просто и уныло, бейсик какой-то, но … Вы будете смеяться, но это заработало на порядок быстрее и догнало программу на C++. Репутация haskell была восстановлена! Для измерения производительности я использовал библиотеку criterion. Функция main приняла вид
import           Criterion.Main
import           System.Environment

-- ----------

main = do
    args <- getArgs
    if benchArg `elem` args
        then withArgs (filter (/= benchArg) args) $ defaultMain
             [
                 bench ("map " ++ show m2)          $ whnf
                     (minNumCoinsMap        coins) m2,
                 bench ("array " ++ show m2)        $ whnfIO $
                     minNumCoinsArray       coins  m2,
                 bench ("vector " ++ show m2)       $ whnfIO $
                     minNumCoinsVector      coins  m2,
                 bench ("vector basic " ++ show m2) $ whnfIO $
                     minNumCoinsVectorBasic vcoins m2
             ]
        else {-print $ minNumCoinsPlain [1, 2, 5] 20   -- m = 50 will hang it!-}
             {-print $ minNumCoins_     [1, 2, 5] 20   -- m = 50 will hang it!-}
             {-print $ minNumCoins      [1, 2, 5] 10002-}
             {-print $ minNumCoinsMemo  [1, 2, 5] 10001-}
             {-print $ minNumCoinsMap   [1, 2, 5] 10001-}
             {-print $ minNumCoins      coins m1-}
             {-print $ minNumCoinsMap   coins m1-}
             {-print $ minNumCoinsMap   coins m2-}
             {-minNumCoinsArray         coins m2 >>= print-}
             minNumCoinsVectorBasic   vcoins m2 >>= print
    where benchArg = "-b"
          coins    = [24, 18, 14, 11, 10, 8, 5, 3, 1]
          vcoins   = VI.fromList $ sortBy (flip compare) coins
          m1       = 16659
          m2       = 1665900
То есть, для запуска criterion в эту программу нужно предать опцию -b. Скомпилировав программу с оптимизацией под LLVM,
ghc --make -O2 -fllvm -optlo-O3 minNumCoins.hs
и запустив ее с опцией -b,
./minNumCoins -b -o minNumCoins.html
benchmarking map 1665900
time                 3.304 s    (3.158 s .. 3.478 s)
                     1.000 R²   (0.999 R² .. 1.000 R²)
mean                 3.394 s    (3.370 s .. 3.411 s)
std dev              25.30 ms   (0.0 s .. 29.13 ms)
variance introduced by outliers: 19% (moderately inflated)

benchmarking array 1665900
time                 145.4 ms   (143.3 ms .. 147.7 ms)
                     1.000 R²   (1.000 R² .. 1.000 R²)
mean                 144.1 ms   (143.6 ms .. 145.0 ms)
std dev              890.1 μs   (399.6 μs .. 1.244 ms)
variance introduced by outliers: 12% (moderately inflated)

benchmarking vector 1665900
time                 136.2 ms   (135.4 ms .. 137.5 ms)
                     1.000 R²   (1.000 R² .. 1.000 R²)
mean                 137.7 ms   (137.0 ms .. 139.8 ms)
std dev              1.518 ms   (389.3 μs .. 2.314 ms)
variance introduced by outliers: 11% (moderately inflated)

benchmarking vector basic 1665900
time                 20.87 ms   (20.48 ms .. 21.20 ms)
                     0.996 R²   (0.988 R² .. 0.999 R²)
mean                 21.22 ms   (20.88 ms .. 21.96 ms)
std dev              1.090 ms   (452.6 μs .. 1.646 ms)
variance introduced by outliers: 18% (moderately inflated)
я получил вот такой замечательный графический отчет. Все картинки внутри отчета интерактивные — для их просмотра нужно разрешить javascript. Я не хочу делать никаких выводов, хотя они напрашиваются сами собой. Исходный код программы по-прежнему доступен отсюда.

пятница, 24 июля 2015 г.

Мемоизация функций в haskell на конкретном примере

Возьмем пресловутую задачу вычисления n-ого числа Фибоначчи. Наивное решение на haskell
fib 0 = 0
fib 1 = 1
fib n = fib (n - 1) + fib (n - 2)
не будет работать для больших n: попробуйте посчитать fib 50! Проблема заключается в экспоненциальном росте числа рекурсивных вызовов fib при увеличении n и невозможности сведения этих вызовов при таком определении функции в простой цикл. И это при том, что интуитивно задача является простейшей, а для получения, скажем, 50-ого числа, нужно всего-то вычислить 48 последовательных значений fib, начиная с n = 2. То есть практически нам нужно сделать всего 48 вычислений fib и каким-то образом запомнить их результаты, чтобы не вызывать функцию fib вновь и вновь. Механизм запоминания результатов вызовов функции для разных ее аргументов с дальнейшей подстановкой уже вычисленных значений вместо повторных вызовов этой же функции с теми же аргументами называется мемоизацией (memoization). Удивительно, но мемоизация функций поддерживается компилятором ghc из коробки. Почему же тогда в приведенной реализации функции fib она не работает? Оказывается, компилятор не может мемоизировать вызовы функций со связанными аргументами, поскольку связанный аргумент может изменить семантику вызова функции и не гарантирует одинаковый результат в случае ее повторных вызовов. Зато функции, объявленные без указания аргументов (то есть бесточечные определения) будут успешно мемоизироваться! Очень подробно мемоизация функций рассматривается в этом руководстве, там же приводится решение задачи вычисления n-ого числа Фибоначчи с определением функции в бесточечном стиле. Я не стану больше говорить о числах Фибоначчи, однако замечу, что очевидный алгоритм решения этой задачи, следующий непосредственно из определения fib и заключающийся в последовательном нахождении всех чисел fib i для i от 2 до n, представляет собой классический пример так называемого динамического программирования. В этой статье я покажу решение похожей задачи о поиске минимального числа монет с номиналами из заданного списка c, которые в сумме составляют заданную стоимость m. Алгоритм решения так же основан на последовательном вычислении частных решений для стоимостей от 1 до m в предположении, что количество монет для стоимости 0 равно 0. Подробное описание алгоритма и псевдокод можно легко найти в интернете (например, здесь). Поэтому я не стану на этом останавливаться, а сразу же приведу наивное решение.
minNumCoinsPlain :: [Int] -> Int -> Int
minNumCoinsPlain c m = map (mnc c) [0 .. m] !! m
    where mnc c m = foldl (\a x -> min (minNumCoinsPlain c (m - x) + 1) a) m $
                    filter (<= m) c
Наивное оно по той же причине, что и приведенное решение задачи о числах Фибоначчи — слишком много рекурсивных вызовов minNumCoinsPlain при больших значениях m. Вы можете скачать исходный код примера отсюда, загрузить его в ghci и протестировать при не очень больших m (начните с 25, при 50 вычисления должны плотно висеть, при необходимости их можно прервать стандартной комбинацией клавиш Ctrl-C).
:l minNumCoins
[1 of 1] Compiling Main             ( minNumCoins.hs, interpreted )
Ok, modules loaded: Main.
:set +s
minNumCoinsPlain [1, 2, 5] 25
5
(4.22 secs, 1,137,893,584 bytes)
Больше четырех секунд вычислений для задачи, которую решит любой школьник не задумываясь! Давайте уберем аргумент m из определения функции: останется аргумент c — функция не стала бесточечной, но ведь c вообще не меняется между вызовами, может компилятор сможет мемоизировать minNumCoinsPlain в таком случае?
minNumCoins_ :: [Int] -> Int -> Int
minNumCoins_ c = (map (mnc c) [0 ..] !!)
    where mnc _ 0 = 0
          mnc c m = foldl (\a x -> min (minNumCoins_ c (m - x) + 1) a) m $
                    filter (<= m) c
Проверяем в ghci.
minNumCoins_ [1, 2, 5] 25
5
(3.66 secs, 1,084,731,832 bytes)
Чуда не произошло. Наличие в определении функции связанного аргумента c способно повлиять на семантику вызовов функции, поэтому компилятор не может выполнить ее мемоизацию. Кстати, метод избавления от связанного аргумента m с помощью применения сечения функции (!!) я взял из упомянутого руководства на haskell.org. Давайте избавимся и от аргумента c! Это сделать не так уж сложно, учитывая, что он не изменяется между вызовами функции.
minNumCoins :: [Int] -> Int -> Int
minNumCoins c =
    let minNumCoinsMemo = (map (mnc c) [0 ..] !!)
        mnc _ 0 = 0
        mnc c m = foldl (\a x -> min (minNumCoinsMemo (m - x) + 1) a) m $
                  filter (<= m) c
    in minNumCoinsMemo
Видите, я зафиксировал c в функции-обертке minNumCoins, в то время как основная рабочая лошадка — функция minNumCoinsMemo — объявлена в бесточечном стиле и вызывается рекурсивно из вспомогательной функции mnc c. Это значит, что ghc должен ее наконец-то мемоизировать!
minNumCoins [1, 2, 5] 25
5
(0.01 secs, 3,910,424 bytes)
Ура! Мгновенное выполнение. Проверим на большой стоимости m.
minNumCoins [1, 2, 5] 10001
2001
(16.53 secs, 0 bytes)
Не быстро, но и не бесконечно долго! Собственно, фиксация аргументов, которую мы здесь проделали вручную, может быть реализована с помощью функции fix из модуля Data.Function: читайте упомянутое выше руководство. Однако, при количестве аргументов от двух и более, прямое использование fix становится трудоемким и подверженным ошибкам. Поэтому лучше всего воспользоваться готовыми функциями из модуля Data.Function.Memoize. Для нашей задачи, в частности, нужна функция memoFix2, поскольку наша рекурсивная функция ожидает на входе два аргумента c и m. Давайте протестируем решение с memoFix2. Прежде всего следует импортировать эту функцию из модуля Data.Function.Memoize.
import Data.Function.Memoize (memoFix2)
Определение новой функции minNumCoinsMemo.
minNumCoinsMemo :: [Int] -> Int -> Int
minNumCoinsMemo = memoFix2 mnc
    where mnc _ _ 0 = 0
          mnc f c m = foldl (\a x -> min (f c (m - x) + 1) a) m $
                      filter (<= m) c
Обратите внимание, функция minNumCoinsMemo объявлена в бесточечном стиле, а у вспомогательной функции mnc появился новый аргумент f, который вышел на первое место — это и есть наша фиксированная функция, которая рекурсивно вызывается внутри mnc. Проверим ее скорость.
minNumCoinsMemo [1, 2, 5] 10001
2001
(1.00 secs, 527,741,984 bytes)
Вот это уже очень хорошо! Новая функция оказалась быстрее нашей самодельной функции minNumCoins на порядок. Но это еще не всё. Хотя с мемоизацией мы закончили. На самом деле, природа задачи о минимальном количестве монет такова, что мы можем попробовать вручную собирать уже подсчитанные результаты в список или в ассоциативный массив (Map): это легко сделать с помощью функций mapAccumL или mapAccumR из модуля Data.List. Очевидно, что поиск уже найденных значений внутри списка потребует линейной скорости, а внутри Map — логарифмической. Я не знаю как организован поиск мемоизированных значений изнутри, но даже если он происходит за константное время, логарифм при таких небольших значениях как 10001 может составить ему хорошую конкуренцию. Поэтому давайте протестируем этакую народную самописную реализацию мемоизации на основе хорошо оптимизированного, строгого типа IntMap. Импортируем нужные функции.
import Data.IntMap.Strict (singleton, insert, (!))
import Data.List (mapAccumL)
Определяем новую функцию
minNumCoinsMap :: [Int] -> Int -> Int
minNumCoinsMap c m = snd (mapAccumL (mnc c) (singleton 0 0) [0 .. m]) !! m
    where mnc c s m = foldl step (s, m) $ filter (<= m) c
            where step a x = let cur = min (s ! (m - x) + 1) $ snd a
                             in (insert m cur s, cur)
Почти все то же самое, но на этот раз вместо рекурсивных вызовов функций типа f c (m - x), внутри функции свертки step мы обращаемся к соответствующему элементу массива s в предположении, что он уже был вычислен в предыдущих итерациях. Проверяем функцию minNumCoinsMap.
minNumCoinsMap [1, 2, 5] 10001
2001
(0.15 secs, 20,208,520 bytes)
Весьма красноречивый результат. Народная мемоизация в этой задаче — чемпион. Напоследок приведу ссылку на замечательную статью из журнала Практика функционального программирования, в которой исследуется взаимосвязь рекурсии, мемоизации и динамического программирования.