CPython vs. IronPython: вычисление MD5-хеша

Понадобилось как-то в проекте сделать автообновление для клиентского приложения. Так как работало оно с отечественными криптопровайдерами, доступ к которым проще получить из .Net, написано оно было на IronPython. При этом C# выбран не был, так как на стороне сервера уже активно использовался python и сильно переучиваться не хотелось.

Казалось бы всё просто. Был набросан скрипт, который вычисляет md5-хеши для файлов входящих в состав приложения, сводит всё в один файл со строками вида “относительный путь”:”md5” и выкладывает в директорию раздачи статики nginx. Клиентское приложение при запуске забирает файлик, прогоняет аналогичный скрипт, и сверяет полученный результат с эталоном.

Но тут обнаружилась маленькая деталь. В IronPython скрипт выполнялся в несколько раз медленнее. И это на достаточно быстром железе. У пользователя же оно могло быть значительно слабее. Началась оптимизация, в ходе которой родилась мысль провести сравнение производительности CPython и IronPython на этом примере. В статье, соответственно, рассматриваются три отдельных результата: для CPython, IronPython и IronPython с адаптированным скриптом.
Результаты под катом.

Конфигурация

  • Core i5 650 3.20 GHz
  • 8 Гб ОЗУ
  • Windows 7 Enterprise x64
  • Python 2.7.1
  • IronPython 2.7.3

В качестве «еды» для скрипта использовалась директория с файлами приложения. В её состав входит Runtime самого IronPython, дополнительные библиотеки и прочие необходимые файлы. Всего порядка 350 файлов от килобайта до трех мегабайт.

Код скрипта:
 1|  import os
 2|  import hashlib
 3|  
 4|  def getMD5sum(fileName):
 5|      m = hashlib.md5()
 6|      fd = open(fileName, 'rb')
 7|      b = fd.read()
 8|      m.update(b)
 9|      fd.close()
10|      return m.hexdigest()
11|  
12|  output = ''
13|  rootpath = 'app'
14|  
15|  for dirname, dirnames, filenames in os.walk(rootpath):
16|      for filename in filenames:
17|          fname = os.path.join(dirname, filename).replace('\\', '/')
18|          md5sum = getMD5sum(fname)
19|          output+='{0}:{1}\n'.format(fname.replace(rootpath, ''), md5sum)
20|  
21|  f = open('./checksums.csv', 'w')
22|  f.write(output)
23|  f.close()


Тот же скрипт, адаптированный для IronPython:
 1|  import os
 2|  import System.IO
 3|  from System.Security.Cryptography import MD5CryptoServiceProvider
 4|
 5|  def getMD5sum(fileName):
 6|      b = System.IO.File.ReadAllBytes(fileName)
 7|      md5 = MD5CryptoServiceProvider()
 8|      hash = md5.ComputeHash(b)
 9|      result = ''
10|      for b in hash:
11|          result += b.ToString("x2")
12|      return result
13|
14|  output = ''
15|  rootpath = 'app'
16|
17|  for dirname, dirnames, filenames in os.walk(rootpath):
18|      for filename in filenames:
19|          fname = os.path.join(dirname, filename).replace('\\', '/')
20|          md5sum = getMD5sum(fname)
21|          output += fname.replace(rootpath, '', 1) + ':' + md5sum + '\n'
21|
22|  System.IO.File.WriteAllText('checksums.csv', output) 

В принципе, вся адаптация сводится к тому, что чтение/запись файлов и вычисление хешей переписаны на .Net. Это даёт достаточный прирост производительности. Связано это с тем, что сам ipy написан на c# и большая часть «батареек» просто обёртка к .Net. В этом смысле интересным может выглядеть разница между 19 строкой основного и 21 адаптированного:

19|  output += '{0}:{1}\n'.format(fname.replace(rootpath, ''), md5sum) 

21|  output += fname.replace(rootpath, '', 1) + ':' + md5sum + '\n' 

В ipy второй вариант оказался быстрее. Что касается python, я не смог увидеть разницы, превышающей статистическую погрешность.

Результаты

И так, результаты холодных пусков (средние):
  • CPython: ~0,06 с.
  • IronPython: ~0,33 с.
  • IronPython (адаптированный скрипт): ~0,16 с.

Не вооруженным глазом видно, что один и тот же скрипт в python и IronPython исполняются с более чем пятикратным преимуществом на стороне python. В тоже время, скрипт, адаптированный для ipy хоть и исполняется по-прежнему медленнее, но результат уже вполне приемлем.

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

Типичные результаты:
CPython ipy ipy (адапт.)
0:00:00.057000 0:00:00.327000 0:00:00.161000
0:00:00.056000 0:00:00.243000 0:00:00.093000
0:00:00.055000 0:00:00.234000 0:00:00.099000
0:00:00.058000 0:00:00.228000 0:00:00.096000
0:00:00.055000 0:00:00.226000 0:00:00.093000
0:00:00.055000 0:00:00.236000 0:00:00.093000
0:00:00.055000 0:00:00.225000 0:00:00.093000
0:00:00.055000 0:00:00.261000 0:00:00.092000
0:00:00.057000 0:00:00.240000 0:00:00.092000
0:00:00.057000 0:00:00.227000 0:00:00.093000

Выводы

По результатам этого теста уже можно сделать более или менее правдоподобный вывод. Видно, что приблизительно 0,7 секунды – время, необходимое просто для запуска самого интерпретатора IronPython. За это время скрипт, запущенный в нативном python уже успевает завершиться. CPython стартует фактически мгновенно и как видно, первая итерация была такой же быстрой, как и последующие. При этом видно, что даже оптимизированный для ipy код, запущенный на горячую – почти в полтора раза медленнее нативного.

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

Рад буду услышать конструктивную критику.

UPD
mstyura предложил более оптимизированный вариант скрипта для ipy с более интересным результатом:

from System.IO import StreamWriter, Directory, SearchOption, File, Path
from System import String, BitConverter, Environment, Array
from System.Security.Cryptography import MD5CryptoServiceProvider

def getMD5sum(fileName):
    stm = File.OpenRead(fileName)
    md5 = MD5CryptoServiceProvider()
    hash = md5.ComputeHash(stm)
    stm.Close()
    return BitConverter.ToString(hash).Replace("-", "").ToLower()

rootpath = 'app'
workingDir = Environment.CurrentDirectory

Environment.CurrentDirectory = rootpath

appFiles = Directory.EnumerateFiles('.', '*', SearchOption.AllDirectories)

output = StreamWriter(File.OpenWrite(Path.Combine(workingDir, 'checksums.csv')))
for _, file in enumerate(appFiles):
    output.Write(file.replace(".", "", 1).replace("\\", "/"))
    output.Write(":")
    output.WriteLine(getMD5sum(file))

output.Close()

Environment.CurrentDirectory = workingDir

Результат этого варианта:
0:00:00.116000
0:00:00.063000
0:00:00.064000
0:00:00.063000
0:00:00.059000
0:00:00.059000
0:00:00.058000
0:00:00.058000
0:00:00.058000
0:00:00.059000

Видно, что ещё чуть-чуть и он перегонит python — идут практически наравне. Старт конечно всё ещё медленный, но и он стал быстрее, по всей видимости за счёт того, что не импортируются и не используются библиотеки Python. Но если сделать только добавить import os и в холостую однократно вызвать os.walk(rootpath) — это увеличит время первой итерации до ~ 0.145 c! Впрочем, видимо это сама функция так тяжела. Если вызвать что-нибудь простое типа os.getcwd() скорость сильно не меняется
Share post
AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 44

    +7
    Хотелось бы еще сравнение с pypy
      0
      Да, pypy любопытен. Но к сожалению, мной не разу не использовался, но при случае обязательно проведу сравнение. Очень интересно на сколько верно то, что говорят о его производительности.
        0
        1,500 файлов, общий объем чуть больше гигабайта.
        dl.dropbox.com/u/608214/md5.png
          0
          Это примерно так же как с ipy получается — в полтора раза медленнее. Любопытно, спасибо!
            0
            А что именно любопытно? Вы ведь тестируете что? Реализацию алгоритма md5 написанную на С? Ну да, этот кусок в pypy менее вылизан чем в cpython. Так он же и не для этого.
              0
              Любопытно скорее то, что тот диалект, на котором написан pypy (RPython) примерно эквивалентен по производительности с C# на котором написан ipy.
        +3
        Очевидный ход — код в строке 7 md5 = MD5CryptoServiceProvider() лучше бы вынести в глобальную переменную, чтобы не создавать новый объект на каждой итерации.
          +3
          Аналогично в строках 9-12 лучше использовать StringBuilder вместо создания нового объекта строки на каждый байт файла:

           9|      result = StringBuilder()
          10|      for b in hash:
          11|          result.Append(b.ToString("x2"))
          12|      return result.ToString()
          
            0
            так же мало повлияло на результат:
            0:00:00.161000
            0:00:00.091000
            0:00:00.094000
            0:00:00.098000
            0:00:00.096000
            0:00:00.096000
            0:00:00.098000
            0:00:00.097000
            0:00:00.100000
            0:00:00.096000
              –2
              При размерах строк до мегабайта — стрингбилдер медленнее и менее эффективен, чем простая конкатенация
                0
                А разве StringBuilder не копирует область памяти занимаемой добавляемой строкой в заранее выделенную память?
            0
            Ну и мало влияющая на скорость доработка:

            output += fname.replace(rootpath, '', 1) + ':' + md5sum + '\n'
            можно заменить на
            output += Path.GetFileName(fname) + ':' + md5sum + '\n'

            Если будут проблемы с окончаниями строк (в винде используется \r\n), вместо \n можно использовать Environment.NewLine
              0
              output += Path.GetFileName(fname) + ':' + md5sum + '\n'
              не подойдёт, так как возвращает только имя файла, тут же нужно относительный путь от корневой директории исключая её саму. То есть, если файл лежит где-то в /home/username/project/app/lib/file.py должно вернуться /lib/file.py чтобы удобно можно было потом на стороне клиента сравнить с таким же файлом не затрагивая текущую директорию, которая потенциально может отличаться.

              В целом, благодарю за советы, пожалуй, так правильнее, хоть и не влияет на скорость.
              0
              Совершенно не влияет. По крайней мере в таком масштабе. Хотя, понятно, что в теории должно.
              Тот же замер, во второй колонке md5 = MD5CryptoServiceProvider() вынесена в глобальную область
              0:00:00.174000 vs 0:00:00.172000
              0:00:00.092000 vs 0:00:00.100000
              0:00:00.097000 vs 0:00:00.100000
              0:00:00.096000 vs 0:00:00.094000
              0:00:00.103000 vs 0:00:00.096000
              0:00:00.102000 vs 0:00:00.102000
              0:00:00.104000 vs 0:00:00.097000
              0:00:00.098000 vs 0:00:00.097000
              0:00:00.095000 vs 0:00:00.095000
              0:00:00.096000 vs 0:00:00.095000
              +4
              Слишком малое время. В пределах погрешности. Нужно запускать так, чтобы выполнялось не меньше секунды (а лучше 10).
                0
                Да, масштаб времени не самый удачный. Но сравнить конкретно эти три случая вполне позволяет. Не позволяет, возможно, заметить более тонкую оптимизацию, которую предлагает catlion, но там по всей видимости речь уже будет идти о микросекундах. В данном случае они не сильно показательны. Если бы разница была бы не в сотых долях секунды, а хотя бы в тысячных — был бы смысл.
                  0
                  Для начала я бы попробовал посмотреть, как это будет работать на среднем клиентском железе.

                  Ну и чтобы два раза не вставать, если один из файлов будет открыт на запись, или удален в промежутке между 17 строкой и соответствующей итерацией, ваш код упадет с неотловленным исключением.
                    0
                    С исключением-то можно и по простому, добавить try-except-else в функцию:

                    def getMD5sum(fileName):
                        try:
                            b = System.IO.File.ReadAllBytes(fileName)
                        except System.IO.FileNotFoundException:
                            print 'file ' + fileName + ' deleted'
                            result = ''
                        except System.IO.IOException:
                            print 'file ' + fileName + ' is in use'
                            result = ''
                        else:
                            hash = md5.ComputeHash(b)
                            hashStr = StringBuilder()
                            for b in hash:
                                hashStr.Append(b.ToString("x2"))
                            result = hashStr.ToString()
                        return result
                    

                    Естественно, заменив print'ы на запись в лог.
                    Ну, а на счёт тестирования на типовом клиентском железе — это конечно же необходимо и обязательно. Нужно будет развернуть несколько виртуалок с разными характеристиками и версиями Windows и погонять там.
                0
                Заголовок правильнее было бы назвать «CPython vs. IronPython»
                  0
                  С учётом того, что речь действительно идет о разных интерпретаторах одного языка — логично писать CPython. Подправил.
                  +1
                  Оба варианта доставят массу неприятностей если дать им пройтись по коллекции HD-видео, ISO-образов и прочих гигабайтных файлов: именно для таких случаев и существует метод update.
                    0
                    В первом варианте он и используется. Кроме того, автор написал, что у него файлы до 3-х мегабайт. А если нужен хеш больших файлов, можно в цикле читать частями и делать update: fileName.read(blockSize)
                      0
                      в случае автора он избыточен, но я просто предупредил на случай запуска скрипта по более «тяжелым» файлам =)
                    0
                    ngen не пробовали использовать?
                      0
                      Пока не пробовал. Но как я понял, глянув сейчас в поиске, оно способно обеспечить более быстрый запуск, что может оказаться полезным.
                      0
                      Можно написать md5 функцию в одну строку:
                      def getMD5sum(fileName): return hashlib.md5(open(fileName, 'rb').read()).hexdigest()

                      Вместо replace хорошо-бы использовать slices:
                      output+='{0}:{1}\n'.format(fname[len(rootpath):], md5sum)

                      Еще мне не нравится, что вы используете os.walk(), который разбивает имя файла на части, а потом назад его собираете. Но я не нашел другого способа рекурсивно получить все файлы.

                      Это все, конечно, не влияет на производительность. Кстати, если поменять алгоритм на CRC32, можно получить выигрыш в 30%.
                      output+='{0}:{1}\n'.format(fname[len(rootpath):], md5sum)
                        0
                        Еще мне не нравится, что вы используете os.walk(), который разбивает имя файла на части, а потом назад его собираете. Но я не нашел другого способа рекурсивно получить все файлы.

                        os.walk основывается на os.listdir, который оперирует понятиями root path и basename — отсюда и разделение полного пути файла/директории на части.
                          0
                          Упс, последняя строка должна быть return zlib.crc32(open(fileName, 'rb').read())
                            0
                            CRC32 стоит попробовать, спасибо за мысль
                            0
                            Я тут подумал, а зачем открывать файлы? Если делать хеш со строки, получается примерно в 200 раз быстрее.
                            def getMD5sum(fileName): return hashlib.md5(fileName).hexdigest()
                              0
                              Уже понял зачем. Совсем забыл для чего предназначен скрипт.
                                0
                                С другой стороны, можно к названию добавлять размер файла, например.
                                  0
                                  При обновлении файла могут быть коллизии с одинаковым размером
                                    0
                                    Почему вы не храните где-нибудь номер версии? Зачем такие сложности с вычислением хешей?
                                      0
                                      С хлещем проще. Собрал новую сборку, прогнал скрипт и все. С версиями пришлось бы контролировать процесс более тщательно для каждого отдельного файла. К тому же, как видите, скрипты сравнительно малы и просты в обоих реализациях. Опять же, в конце концов, на клиенте можно схалтурить и хеш не вычислять или вычислять в случае какой-то особой необходимости. Вместо этого просто сохранять полученный при прошлом обновлении файл с хешами и сравнивать эталон с ним. Хотя это и не очень правильная мысль.
                                        0
                                        С хлещем = С хешем. Автозамена неудачно сработала
                                          +1
                                          Я думал это слэнг такой :-).
                              +1
                              Предлагаю использовать следующий вариант адаптированного для IronPython скрипта. Улучшение производительности, в сравнении с адаптированным вариантом из поста, получилось в ~3-4 раза на наборе из 5227 файлов, общим размером в 381Мб. Правда результат немного отличается, от результата скрипта из поста — путь начинается с ".", а не со "/", и под виндой будут виндовые слеши, но это вроде некритично. Дополнительно можно немного ускорить скрипт, добавив еще одно некритическое различие, удалив вызов ToLower() для хеша. Преимуществом скрипта ниже, кроме скорости исполнения, является и бережное отношение к памяти, т.е. для подсчета хеша, содержимое файла, как и весь список файлов, не загружается полностью, вывод результата не аккумулируется в программе.
                              Буду признателен автору, если он измерит производительность этого скрипта на своей машине и своем наборе тестовых файлов.

                              Кодярник
                              from System.IO import StreamWriter, Directory, SearchOption, File, Path
                              from System import String, BitConverter, Environment, Array
                              from System.Security.Cryptography import MD5CryptoServiceProvider
                              from System.Diagnostics import Stopwatch
                              
                              sw = Stopwatch.StartNew()
                              
                              def getMD5sum(fileName):
                                  stm = File.OpenRead(fileName)
                                  md5 = MD5CryptoServiceProvider()
                                  hash = md5.ComputeHash(stm)
                                  stm.Close()
                                  return BitConverter.ToString(hash).Replace("-", "").ToLower()
                              
                              rootpath = 'app'
                              workingDir = Environment.CurrentDirectory
                              
                              #hack to get rid of string replace in output
                              Environment.CurrentDirectory = rootpath
                              
                              appFiles = Directory.EnumerateFiles('.', '*', SearchOption.AllDirectories)
                              
                              output = StreamWriter(File.OpenWrite(Path.Combine(workingDir, 'checksums-fastest.csv')))
                              #magically enumerate some how speedup loop, probably .net -> python iterators interop flavor
                              for _, file in enumerate(appFiles):
                                  output.Write(file)
                                  output.Write(":")
                                  output.WriteLine(getMD5sum(file))
                              
                              output.Close()
                              
                              Environment.CurrentDirectory = workingDir
                              
                              print sw.Elapsed

                                0
                                Ух как. Вот этот результат уже очень даже интересен.
                                00:00:00.1086513
                                00:00:00.0653087
                                00:00:00.0619235
                                00:00:00.0580854
                                00:00:00.0581689
                                00:00:00.0563488
                                00:00:00.0576192
                                00:00:00.0562254
                                00:00:00.0565140
                                00:00:00.0569575
                                совершенно эквивалентен по скорости полученному в CPython

                                Немного его переделал:
                                заменил output.Write(file) на output.Write(file.replace(".", "", 1).replace("\\", "/"))
                                время замерил с помощью питоньего инструментария
                                (чтобы мерить одним методом одинаковый функционал с одинаковыми входными и выходными данными)
                                0:00:00.116000
                                0:00:00.063000
                                0:00:00.064000
                                0:00:00.063000
                                0:00:00.059000
                                0:00:00.059000
                                0:00:00.058000
                                0:00:00.058000
                                0:00:00.058000
                                0:00:00.059000

                                Немного медленнее, но не раздражает. С учётом того, что в реальности время мериться не будет, соответственно и ресурсы на это тратиться не будут. Уйдет лишний импорт.
                                Кстати, попробовал в вашем варианте вставить вместо File.OpenRead(fileName) File.ReadAllBytes(fileName) и сразу получил
                                0:00:00.113000
                                0:00:00.082000
                                0:00:00.078000
                                0:00:00.078000
                                0:00:00.077000
                                0:00:00.079000
                                0:00:00.081000
                                0:00:00.080000
                                0:00:00.078000
                                0:00:00.079000

                                Не уж-то на столько медленнее? Будет хорошим поводом пройтись по остальному коду приложения…
                                Спасибо!
                                  0
                                  File.ReadAllBytes(fileName) плох тем, что если наткнется на большой файлик, пямять приложения будет расходоваться не очень рационально. Кроме того, ComputeHash, вычисляющийся по System.IO.Stream, работает в фиксированном объеме памяти — 4Кб, т.е. буфер выделяется один раз и небольшого размера.
                              • UFO just landed and posted this here
                                  0
                                  Просто привычка, чтобы лишний раз файл не блокировать. Тут это не нужно и Ваш вариант должен быть эффективнее с точки зрения потребления памяти
                                  • UFO just landed and posted this here

                                Only users with full accounts can post comments. Log in, please.