Ускорение в 14 000 раз или Победа компьютерной науки

Автор оригинала: James Hiebert
  • Перевод
Как разработчику научного ПО мне приходится много программировать. И большинство людей из других научных областей склонны думать, что программирование — это «просто» набросать код и запустить его. У меня хорошие рабочие отношения со многими коллегами, в том числе из других стран… Физика, климатология, биология и т. д. Но когда дело доходит до разработки ПО, то складывается отчётливое впечатление, что они думают: «Эй, что тут может быть сложного?! Мы просто записываем несколько инструкций о том, что должен сделать компьютер, нажимаем кнопку „Выполнить” и готово — получаем ответ!»

Проблема в том, что невероятно легко написать инструкции, которые означают не то, что вы думаете. Например, программа может совершенно не поддаваться интерпретации компьютером. Кроме того, нет буквально никакого способа определить, завершится ли программа вообще, не выполнив её. И есть много, очень много способов сильно замедлить выполнение программы. В смысле… реально замедлить. Так замедлить, что выполнение займёт всю вашу жизнь или больше. Это чаще всего происходит с программами, которые написаны людьми без компьютерного образования, то есть учёными из других областей. Моя работа — исправлять такие программы.

Люди не понимают, что информатика учит вас теории вычислений, сложности алгоритмов, вычислимости (то есть можем ли мы действительно что-то вычислить? Слишком часто мы считаем само собой разумеющимся, что можем!) Информатика даёт знания, логику и методы анализа, помогающие написать код, который выполнится за минимальное количество времени или с минимальным использованием ресурсов.

Позвольте показать пример огромной оптимизации одного простого скрипта, написанного моим коллегой.

В климатологии мы много масштабируем. Мы берём показания температуры и осадков из крупномасштабной глобальной климатической модели и сопоставляем их с мелкомасштабной локальной сеткой. Допустим, глобальная сетка равна 50×25, а локальная — 1000×500. Для каждой ячейки сетки в локальной сетке мы хотим знать, какой ячейке сетки в глобальной сетке она соответствует.

Простой способ представить проблему — это минимизировать расстояние между L[n] и G[n]. Получается такой поиск:

для каждой локальной ячейки L[i]:
  для каждой глобальной ячейки G[j]:
     вычислить расстояние между L[i] и G[j]
  найти минимальное расстояние в наборе L[i] * G
  вернуть индекс минимума

Кажется достаточно простым. Однако если посмотреть внимательнее, то мы делаем много дополнительной работы. Посмотрите на алгоритм с точки зрения размера входных данных.

для каждой локальной ячейки L[i]:                           # Сделать L раз
  для каждой глобальной ячейки G[j]:                        # Сделать L x G раз
     вычислить расстояние между L[i] и G[j]                 # Сделать L x G раз
  найти минимальное расстояние в наборе d[i*j]              # Прочитать G ячеек L раз (L x G)
  найти индекс, для которого ячейка соответствует минимуму  # Прочитать G ячеек L раз (L x G)

Код выглядит примерно так:

obs.lon <- ncvar_get(nc.obs, 'lon')
obs.lat <- ncvar_get(nc.obs, 'lat')
n.lon <- length(obs.lon)
n.lat <- length(obs.lat)

obs.lats <- matrix(obs.lat, nrow=n.lon, ncol=n.lat, byrow=TRUE)
obs.lons <- matrix(obs.lon, nrow=n.lon, ncol=n.lat)
obs.time <- netcdf.calendar(nc.obs)

gcm.lon <- ncvar_get(nc.gcm, 'lon')-360
gcm.lat <- ncvar_get(nc.gcm, 'lat')
gcm.lats <- matrix(gcm.lat, ncol=length(gcm.lat), nrow=length(gcm.lon),
                   byrow=TRUE)
gcm.lons <- matrix(gcm.lon, ncol=length(gcm.lat), nrow=length(gcm.lon))
gcm.lons.lats <- cbind(c(gcm.lons), c(gcm.lats))

# Figure out which GCM grid boxes are associated with each fine-scale grid point
# Confine search to 10 deg. x 10 deg. neighbourhood

dxy <- 10
mdist <- function(x, y)
    apply(abs(sweep(data.matrix(y), 2, data.matrix(x), '-')), 1, sum)
nn <- list()
for (i in seq_along(obs.lons)) {
    if((i %% 500)==0) cat(i, '')
    gcm.lims <- ((gcm.lons.lats[,1] >= (obs.lons[i]-dxy)) &
                 (gcm.lons.lats[,1] <= (obs.lons[i]+dxy))) &
                ((gcm.lons.lats[,2] >= (obs.lats[i]-dxy)) &
                 (gcm.lons.lats[,2] <= (obs.lats[i]+dxy)))
    gcm.lims <- which(gcm.lims)
    nn.min <- which.min(mdist(c(obs.lons[i], obs.lats[i]),
                        gcm.lons.lats[gcm.lims,]))
    nn[[i]] <- gcm.lims[nn.min]
}
nn <- unlist(nn)

Похоже на простой алгоритм. «Просто» вычислить расстояния, а затем найти минимум. Но в такой реализации по мере роста числа локальных ячеек наша стоимость вычислений растёт на её произведение с числом ячеек глобальной сетки. Канадские данные ANUSPLIN содержат 1068×510 ячеек (в общей сложности 544 680). Предположим, что наш GCM содержит 50×25 ячеек (в общей сложности 1250). Таким образом, стоимость внутреннего цикла в «каких-то вычислительных единицах» равна:

$(c_0 \cdot L \times G) + (c_1 \cdot L \times G) + (c_2 \cdot L \times G)$


где члены $c$ — это константы, соответствующие стоимости вычисления расстояния между двумя точками, нахождения минимальной точки и нахождения индекса массива. На самом деле, мы не заботимся (особо) о постоянных членах, потому что они не зависят от размера входных данных. Так что можно просто сложить их и оценить стоимость вычисления;

$(c \cdot L \times G)$


Таким образом, для этого набора входных данных наша стоимость составляет $544,680 \times 1,250 = 680,850,000$.

680 миллионов.

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

> source('BCCA/naive.implementation.R')
500 1000 1500 2000 2500 3000 ... 543000 543500 544000 544500 [1] "Elapsed Time"
    user   system  elapsed 
1668.868    8.926 1681.728 

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

Другая важная структура, которой мы не воспользовались — тот факт, что широты идут в измерении $x$, а долготы — в измерении $y$. Таким образом, вместо выполнения операции $x \times y$ раз мы можем выполнить её $x + y$ раз. Это огромная оптимизация.

Как это выглядит в псевдокоде?

Для каждого local[x]:
    bisect_search(local[x], Global[x])

Для каждого local[y]:
    bisect_search(local[y], Global[y])

возвращается 2d сетка результатов поиска для каждого измерения

В коде:

## Perform a binary search on the *sorted* vector v
## Return the array index of the element closest to x
find.nearest <- function(x, v) {
    if (length(v) == 1) {
        return(1)
    }
    if (length(v) == 2) {
        return(which.min(abs(v - x)))
    }
    mid <- ceiling(length(v) / 2)
    if (x == v[mid]) {
        return(mid)
    } else if (x < v[mid]) {
        return(find.nearest(x, v[1:mid]))
    }
    else {
        return((mid - 1) + find.nearest(x, v[mid:length(v)]))
    }
}

regrid.one.dim <- function(coarse.points, fine.points) {
    return(sapply(fine.points, find.nearest, coarse.points))
}

## Take a fine scale (e.g. ANUSPLINE) grid of latitudes and longitudes
## and find the indicies that correspond to a coarse scale (e.g. a GCM) grid
## Since the search is essentially a minimizing distance in 2 dimensions
## We can actually search independently in each dimensions separately (which
## is a huge optimization, making the run time x + y instead of x * y) and
## then reconstruct the indices to create a full grid
regrid.coarse.to.fine <- function(coarse.lats, coarse.lons, fine.lats, fine.lons) {
    xi <- regrid.one.dim(gcm.lon, obs.lon)
    yi <- regrid.one.dim(gcm.lat, obs.lat)
    ## Two dimensional grid of indices
    xi <- matrix(xi, ncol=length(fine.lats), nrow=length(fine.lons), byrow=F)
    yi <- matrix(yi, ncol=length(fine.lats), nrow=length(fine.lons), byrow=T)
    return(list(xi=xi, yi=yi))
}

Стоимость каждого поиска методом деления пополам — это log от входного размера. На этот раз наш входной размер разделён на пространство X и Y, поэтому будем использовать $G_x, G_y, L_x$ и $L_y$ для Global, Local, X и Y.

$cost = L_x \times log_2 G_x + L_y \times log_2 G_y + L_x \times L_y$


Затраты получаются в 553 076. Что ж, 553 тысячи звучит намного лучше, чем 680 миллионов. Как это отразится на времени выполнения?

> ptm <- proc.time(); rv <- regrid.coarse.to.fine(gcm.lat, gcm.lon, obs.lat, obs.lon); print('Elapsed Time'); print(proc.time() - ptm)[1] "Elapsed Time"
   user  system elapsed 
  0.117   0.000   0.117 
> str(rv)
List of 2
 $ xi: num [1:1068, 1:510] 15 15 15 15 15 15 15 15 15 15 ...
 $ yi: num [1:1068, 1:510] 13 13 13 13 13 13 13 13 13 13 ...
> 

0,117 секунды. То, что раньше выполнялось почти полчаса, теперь занимает чуть больше одной десятой секунды.

> 1668.868 / .117
[1] 14263.83

Таааааак… Понимаю, что я обучен заниматься такой оптимизацией и знаю, как её делать хорошо, но даже меня удивляет и впечатляет, насколько значительно ускорение. Примерно в 14 тысяч раз.

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

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

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

    +3

    Хм, а сетка квадратная? Или там какие-то особенности из-за кривизны земли? Описанное в статье подозрительно похоже на задачу увеличения (или уменьшения) изображения, которая, как мне кажется, может быть решена вообще за O(width * height)
    P.S. Почему-то думал что они делают бинарный поиск для каждого элемента, а они один раз ищут индексы независимо по x и y, а потом просто читают из сетки, так что у них тоже O(width * height) и всё ок.

      +1
      Там даже по тексту видно ):
      — количество ячеек — 544 680
      — вычисленные затраты — 553 076
      +2

      Иногда прирост производительности можно получить вообще ничего не меняя в коде. Меня недавно удивил факт (и, думаю, удивит многих), что один и тот же код, который занимается применением регулярного выражения к разному тексту, на python на компьютере с windows 10 выполняется на 25% медленнее, чем в WSL2, которая запущена на этом же компьютере в этой же windows 10. Вероятно, тут какой-то косяк с реализацией модуля re под windows и поэтому такой неожиданный результат.

        0
        Вы не могли бы поделиться ссылкой про это (очень любопытно, но беглым поиском пока не нашёл)?
          +2

          Если хотите попробовать то же самое, где я увидел этот эффект, то запустите launcher.py из https://github.com/ReinRaus/RegexCrossTool/
          Суть всего этого кода проста: много раз в цикле применять регулярное выражение к разному тексту.
          Заметная разница в производительности присутствует только для регулярных выражений, что скорее всего свидетельствует о проблеме в модуле re.
          Забавен сам факт, что код, запущенный в WSL2 работает быстрее, чем в хостовой ОС.

            0
            Спасибо, интересно, любопытно — чисто интуитивно, другой джиттер работает, наверно.
              0
              Разные компиляторы? Blender, например, работает в Linux значительно быстрее, чем в Windows. Потому что там GCC, а тут MSVC.
                0
                Плюс ещё совершенно разные ядра: разные реализации планировщика процессов/потоков, разные реализации системы управления памятью кучи процесса.

                Но лично мне странным кажется что прямо на много быстрее. MSVC очень не плохой компилятор так-то, как и реализация стандартных библиотек C/C++ не самая плохая у MS.
                  0
                  Плюс ещё совершенно разные ядра: разные реализации планировщика процессов/потоков, разные реализации системы управления памятью кучи процесса.
                  На двух/четырёхъядерных CPU влияние это оказывает вот совсем никакое, а замеры ещё тогда делались. Это же чисто вычислительные задачи.
                  Но лично мне странным кажется что прямо на много быстрее. MSVC очень не плохой компилятор так-то, как и реализация стандартных библиотек C/C++ не самая плохая у MS.
                  Мне тоже, но факт. Даже сборка mingw ранее на сайте лежала, можно было сравнить. Дело не в ОС, а в компиляторе.
            +1
            Ну питон это очень высокоуровневый язык, и что там внутри не совсем понятно, и много чего может влиять на его скорость.
            Меня больше удивляет, что код который написан на ассемблере на Win7 выполняется заметней медленней чем на WinXP, а на бесятке ещё медленней. Хотя код не использует библиотек и довольно простой, и весь влезает в кэш.
              0
              Меня больше удивляет, что код который написан на ассемблере на Win7 выполняется заметней медленней чем на WinXP, а на бесятке ещё медленней

              а пример или ссылку можно поглядеть?

              Для каждого local[x]:
              bisect_search(local[x], Global[x])

              Для каждого local[y]:
              bisect_search(local[y], Global[y])

              это только если поиск «стабилен» и не зависит от порядка поиска по отдельным осям, что далеко не факт в общем случае. А без всего этого мы перешли от брутфорса к двоичному поиску, глупо удивляться такому приросту на огромных взводных данных)
                0
                Сейчас конкретный пример не могу дать.
                Но у меня тут, код на разных Осях работал по разному.
                Но думаю что срабатывают всякие прерывания, защитник виндоус, антивирус и ещё много чего. Так же библиотечные функции работают гораздо медленней. Например, если код запустить много раз подряд, то время выполнения сравнивается т.е. 7-ка = XP. А у бесятки и этого мало, всё равно что-то процесс тормозит.
                  +1

                  Защита от Spectre/Meltdown и подобных уязвимостей скорее всего. Посмотреть состояние защиты можно PowerShell скриптом Get-SpeculationControlSettings. https://github.com/microsoft/SpeculationControl

                0
                >>медленней чем на WinXP, а на БЕСЯТКЕ ещё медленней
                это вы по Фрейду оговорились, или специально?
                Потому как для меня она таки бесятка, без вариантов
                  0
                  Такая же закономерность для кода на фортране (числодробилка, правда довольно сложная).
                  Под виртуалкой (Windows XP mode) на 20% быстрее примерно.
                    0
                    Быстрее 7 или 10?
                      0
                      На 10-ке не тестировал, только Win7 и XP.
                    0
                    Честно говоря, вы заинтриговали таким заявлением. Решил проверить для программы на Delphi, Win 32, работа со строками и файлами, в основном однопоток. Запустил обработку в виртуальной машине в Windows XP, и потом непосредственно в Windows 10. И что Вы думаете, в виртуальной машине тест был быстрее в полтора раза!

                    Со всеми этими графическими наворотами мы забыли, насколько мощными стали наши компьютеры…
                  +1
                  Сразу сходу виден кодер.
                  Программист математику эту задачку отнесёт первым делом, а математик скажет, что квадранты придуманы давным давно, и напишет алгебру квадрантов для программиста. А программист всё и запрограммирует радостно. Но нет. Кодеры — это кодеры. Бесполезные существа.
                    0

                    Осталось только Программисту понять, что тут задача для математика и отнести ему. Сколько таких мест он пропустит? Напишет неэффективное решение и никто даже не заметит, что тут можно сделать на порядки быстрее. Если же Программист будет каждую хоть сколько-нибудь математичную задачу отдавать математику, то вскоре окажется, что все задачи программиста, кроме самых тривальных формочек, проходят сначала через математика. Осталось этого Математика научить кодировать, и он полностью заменяет такого программиста.

                      0
                      Я вот и есть такой математик, умеющий программировать. Составив алгоритм решения очередной задачи, я, конечно, могу этот алгоритм запрограммировать. Но в обязанности программиста входит не только написание программы, но и её дальнейшая поддержка, а вот этим заниматься не хочу. Если изменилась постановка задачи, то в алгоритм я изменения с удовольствием внесу, а в программу — не хочу. Поэтому математик может заменить программиста только в одноразовых программах: написал, запустил и выбросил.
                      0

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

                      +1
                      Кстати! А на сколько для заведомо тяжёлой задачи, оптимально использовать заведомо медленные ЯП, в точности Python?
                      Я считаю что лучше брать, например, С++ и интринстики, чтобы сразу получить быстрый код, по крайней мере точно знаешь, что программа не выполняет лишний код.
                      Хотя быстрая программа, это прежде всего быстрый алгоритм.

                      Например, мне удалось создать очень быстрый алгоритм для решения «Задача о 19 ферзях»
                      wasm.in/threads/zadacha-o-19-ferzjax.24611
                      Тут я использовал битовую доску т.е. BitBoard. Алгоритм придумал лет 18-20 назад, в детстве, когда только начал увлекаться программированием, и даже личного компа не было.
                      И да, алгоритм заведомо предполагает, что будет реализоваться на нормальном компилирующим ЯП.
                        +1

                        Ну вот люди вроде меня используют. Я по образованию биолог, на питоне пишу прилично, а вот на C++ дальше хэлловорлда в десятом классе не забирался. Так что у меня есть по сути три опции:


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

                          +2
                          Посмотрите питоновские библиотеки numpy, scipy и pandas. Они специально были сделаны для эффективной работы с массивами данных (в том числе и научных).
                          Так как внутри у них неонка движок на С и FORTRAN, то на типовых задачах они могут дать скорость, сравнимую с сишным кодом.
                            0

                            Очевидно, что вне numpy/scipy я тысячи чисел ворочать и не собираюсь. Плюс sklearn для всякого обучения (да даже для тривиальных визуализаций типа PCA). Проблема в том, что многие проблемы биоинформатики — это какая-нибудь история с обработкой строк, для которой в питоне я супербыстрых имплементаций не знаю.

                            0

                            Жаль, что сейчас Delphi даже не рассматривают. Хотя там быстрый компилятор, нативный код, и есть бесплатная Community Edition https://www.embarcadero.com/products/delphi/starter

                              0
                              К есть еще вариант использовать numba. В сочетании с numpy — приличное ускорение.
                            +2
                            Интересная статья, и я имею дело с сетками.
                            Но я не могу понять постановку задачи!
                            «Мы берём показания температуры и осадков из крупномасштабной глобальной климатической модели и сопоставляем их с мелкомасштабной локальной сеткой. Допустим, глобальная сетка равна 50×25, а локальная — 1000×500. Для каждой ячейки сетки в локальной сетке мы хотим знать, какой ячейке сетки в глобальной сетке она соответствует».
                            Я могу нарисовать сетку 50×25, каждую ячейку разбить сеткой 1000×500… а что надо дальше определить?
                            Help!
                              0

                              Как я понял, это два разбиения одного и того же участка.

                                0
                                Не очень понятно. Ну и данных мало. Самое веселое если это две неструктурированных сетки. Но вроде структурированные — если прям задано сколько на сколько. Тогда ничего обходить не надо, а надо просто пересчитать алгебраически. Или ячейки разных размеров причем неизвестно каких? Тогда можно гибридно…
                                Жалко, что перевод — не спросить у автора.
                                0
                                Я понял задачу так (аналогия):
                                Допустим есть тетрадный лист разбитый на крупную клетку, мы дополнительно этот же лист размечаем миллиметровкой на мелкую сетку. Теперь, допустим каждая крупная клетка листа имеет свой цвет (параметры погоды), а нам надо узнать цвета для каждой мелкой ячейки миллиметровой сетки, выясняя какой клетке из крупной сетки она принадлежит.
                                  +2

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

                                    0
                                    ну как бы масштаб это коэффициент. умножаешь/делишь на него и переходишь от крупного/мелкого масштаба к другому
                                +4

                                Где логика? В картографии все элементарно решается кодированием — когда каждому более мелкому участку карты присваивается код в котором указано в каком более крупном участке он лежит. И кто так задачи решает почему точку например в Африке ищут по всей сетке в том числе на северном полюсе? Тут задача не программерская, а наверное тест при собеседовании на логику.

                                  0
                                  Правильный индекс ускоряет поиск.
                                    0

                                    в данном случае — не ускоряет, а отменяет.

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

                                  Ничего удивительного, без профильного образования и опыта люди в любой сфере могут сотворить такую дичь, которая профессионалу позволит её разобрать и улучшить в кучу раз. Просто потому, что он уже решал такие задачи много раз. Тут надо о другом подумать — учёный хоть и плохонько, но всё же написал программу для решения своей задачи. А программист с нуля эту задачу вообще бы не решил, возможно даже и не сформулировал бы. Так что каждому своё.
                                    +1
                                    Информатика даёт знания, логику и методы анализа, помогающие написать код, который выполнится за минимальное количество времени или с минимальным использованием ресурсов.

                                    Осталось добавить временные ресурсы и человеческие, и мы можем получить в результате что работающий в 14000 раз быстрее код может оказаться более прожорливым на ресурсы. ;)
                                      +1

                                      Зачастую, если программист изучал computer science, то написать хорошее решение не сильно сложнее, а иногда, даже проще. Был в моей практике пример, когда наивное решение — полный перебор — занимало 300 строк сложноватого кода, когда как хорошее решение динамическим программированием, мало того, что было сильно быстрее, еще и занимало 20 строк с комментариями.

                                      +3

                                      Сегодня приличный Джуниор на собеседовании расскажет про The Big O. А годный Джуниор еще и попытается применить на практике.


                                      Надо быть уж совсем деревянным, чтобы в подобной задаче не усмотреть очевидную возможность оптимизации.

                                        0

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

                                          0
                                          Естественно. А что в природе есть программисты, знающие математические алгоритмы? Чё правда что ли?)) Вы чего там оптимизировать собрались-то? Зачем?
                                          Математик даст вам квадранты, которые работают на сдвигах. И всё. Там нечего оптимизировать. Вообще нечего. [ВООБЩЕ НЕЧЕГО]

                                          Для тех кто капитально в танке. Вы решили научиться летать. Но вы автолюбитель. Причём может быть даже крутой. А толку то?) Вот вы к своей убогой машине пилите крылья.
                                          А на самом деле вам надо идти к инженеру авиастроителю) Однако же он вам скажет что ваша машина вообще никуда не годна, и предложит совершенно новую штуку, т.е. например вертолёт. Он вам не даст оптимизаций на авто, это же бессмыслица)))) Надо полагать, вы охотник до бессмыслиц?)

                                          Тут не кран надо менять, а всю систему. (с)

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

                                      Самое читаемое