Схватка Elixir и Ruby. Phoenix против Rails

Эта статья — демонстрация сравнительного анализа языков Elixir и Ruby: в ней пойдёт речь о том, какую производительность обеспечивают веб-фреймворки Phoenix и Rails при выполнении одних и тех же задач. Прежде чем перейти к примерам кода и результатам бенчмарков, ответим на несколько распространённых вопросов о тестах такого рода.

Если коротко, то Phoenix справился с заданием в 10,63 раза быстрее, чем Rails, обеспечив более низкую загрузку CPU.

ЧАВО

Не думаете ли вы, что пытаетесь сравнить несравнимое?

Вовсе нет. Приведённые тесты — прямое сопоставление полюбившихся особенностей Ruby (Rails) с Elixir (Phoenix). Elixir гарантирует предоставить то, что все так ценят в Ruby: скорость разработки, метапрограммирование, изящные API и DSL, но при этом работать быстрее, обеспечивая надёжную конкурентную модель и распределённую обработку данных. Цель данной статьи — выяснить, как уже полюбившиеся особенности Ruby без потери удобства программных интерфейсов реализуются в Elixir с точки зрения производительности веб-фреймворков.

Так ли необходимы бенчмарки?

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

Что конкретно вы сравниваете?

Elixir Phoenix Framework

  • Phoenix 0.3.1
  • Cowboy webserver (single Elixir node)
  • Erlang 17.1
  • Ruby on Rails

Rails 4.0.4

  • Rails 4.0.4
  • Puma webserver (4 воркера — по одному на каждое ядро)
  • MRI Ruby 2.1.0

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

  1. Сопоставим запрос к серверу и направим его к соответствующему действию контроллера, разбирая именованные параметры

  2. Внутри контроллера рендерим представление (в зависимости от заголовка Accept в запросе), которое помещается в родительский макет. Представления рендерятся посредством встроенных в язык шаблонизаторов (ERB, EEx).

  3. В представлении рендерим коллекцию данных, полученных из контроллера.

  4. Отправляем ответ клиенту.

Вот и всё. Мы тестируем типовое сопоставление маршрутов и отображение стека представлений, что явно выходит за рамки примера «Hello World». В обоих приложениях производится рендеринг макета, представлений и их составляющих для проведения анализа реальной производительности при выполнении основной задачи веб-фреймворка. Чтобы не перегружать IO в обоих приложениях отключены кэширование представлений и логирование запросов. Для проведения бенчмаркинга мы использовали инструмент wrk. Помимо тестирования на локальном сервере, мы также провели тестирование и на удалённом (основанном на heroku dynos) с целью исключения влияния этого инструмента на результаты, полученные в ходе локального эксперимента. Хватит болтовни, посмотрим, как выглядит код.

Маршрутизаторы

Phoenix

defmodule Benchmarker.Router do
  use Phoenix.Router
  alias Benchmarker.Controllers

  get "/:title", Controllers.Pages, :index, as: :page
end

Rails

Benchmarker::Application.routes.draw do
  root to: "pages#index"
  get "/:title", to: "pages#index", as: :page
end

Контроллеры

Phoenix

defmodule Benchmarker.Controllers.Pages do
  use Phoenix.Controller

  def index(conn, %{"title" => title}) do
    render conn, "index", title: title, members: [
      %{name: "Chris McCord"},
      %{name: "Matt Sears"},
      %{name: "David Stump"},
      %{name: "Ricardo Thompson"}
    ]
  end
end

Rails

class PagesController < ApplicationController

  def index
    @title = params[:title]
    @members = [
      {name: "Chris McCord"},
      {name: "Matt Sears"},
      {name: "David Stump"},
      {name: "Ricardo Thompson"}
    ]
    render "index"
  end
end

Представления

Phoenix (EEx)

...
    <h4>Team Members</h4>
    <ul>
      <%= for member <- @members do %>
        <li>
          <%= render "bio.html", member: member %>
        </li>
      <% end %>
    </ul>
...
<b>Name:</b> <%= @member.name %>

Rails (ERB)

...
    <h4>Team Members</h4>
    <ul>
      <% for member in @members do %>
        <li>
          <%= render partial: "bio.html", locals: {member: member} %>
        </li>
      <% end %>
    </ul>
...
<b>Name:</b> <%= member[:name] %>

Результаты для локального сервера

Phoenix показал в 10,63 раза большую производительность и более устойчивое среднеквадратическое отклонение времени задержки. Такие результаты доказывают реальные преимущества конкурентной модели Elixir. Одна нода Elixir использует все необходимые ресурсы CPU и требуемый объём памяти, в то время как веб-сервер Puma (Rails) для реализации многопоточности создаёт по одному процессу на каждом ядре CPU.

Phoenix:
req/s: 12,120.00
Stdev: 3.35ms
Max latency: 43.30ms

Rails:
req/s: 1,140.53
Stdev: 18.96ms
Max latency: 159.43ms

Phoenix

$ mix do deps.get, compile
$ MIX_ENV=prod mix compile.protocols
$ MIX_ENV=prod elixir -pa _build/prod/consolidated -S mix phoenix.start
Running Elixir.Benchmarker.Router with Cowboy on port 4000

$ wrk -t4 -c100 -d30S --timeout 2000 "http://127.0.0.1:4000/showdown"
Running 10s test @ http://127.0.0.1:4000/showdown
4 threads and 100 connections
Thread Stats   Avg      Stdev     Max   +/- Stdev
Latency     8.31ms    3.53ms  43.30ms   79.38%
Req/Sec     3.11k   376.89     4.73k    79.83%
121202 requests in 10.00s, 254.29MB read
Requests/sec:  12120.94
Transfer/sec:     25.43MB

Rails

$ bundle
$ RACK_ENV=production bundle exec puma -w 4
[13057] Puma starting in cluster mode...
[13057] * Version 2.8.2 (ruby 2.1.0-p0), codename: Sir Edmund Percival Hillary
[13057] * Min threads: 0, max threads: 16
[13057] * Environment: production
[13057] * Process workers: 4
[13057] * Phased restart available
[13185] * Listening on tcp://0.0.0.0:9292

$ wrk -t4 -c100 -d30S --timeout 2000 "http://127.0.0.1:9292/showdown"
Running 10s test @ http://127.0.0.1:9292/showdown
4 threads and 100 connections
Thread Stats   Avg      Stdev     Max   +/- Stdev
Latency    21.67ms   18.96ms 159.43ms   85.53%
Req/Sec   449.74    413.36     1.10k    63.82%
11414 requests in 10.01s, 25.50MB read
Requests/sec:   1140.53
Transfer/sec:      2.55MB

Результаты для Heroku (1 dyno)

Phoenix оказался в 8,94 раза производительнее, снова показав намного более устойчивое среднеквадратическое отклонение времени задержки и в 3,74 раза меньшую загрузку CPU. Пытаясь получить на сервере Phoenix такую же нагрузку на CPU, как и на сервере Rails, мы столкнулись с нехваткой доступных сокетов. Возможно, Phoenix-приложение показало бы большую производительность, если бы сети клиентов имели более высокую пропускную способность. В случае использования удалённого сервера значение среднеквадратического отклонения имеет особую важность. Rails-приложение не смогло обеспечить устойчивое время отклика, показав результат времени задержки более 8 секунд. На практике, Phoenix-приложение в нагруженном состоянии должно работать намного более стабильно, чем приложение на Rails.

Phoenix:
req/s: 2,691.03
Stdev: 139.92ms
Max latency: 1.39s

Rails:
req/s: 301.36
Stdev: 2.06s
Max latency: 8.36s

Phoenix (Cold)

$ ./wrk -t12 -c800 -d30S --timeout 2000 "http://tranquil-brushlands-6459.herokuapp.com/showdown"
Running 30s test @ http://tranquil-brushlands-6459.herokuapp.com/showdown
12 threads and 800 connections
Thread Stats   Avg      Stdev     Max   +/- Stdev
Latency   317.15ms  139.55ms 970.43ms   81.12%
Req/Sec   231.43     66.07   382.00     63.92%
83240 requests in 30.00s, 174.65MB read
Socket errors: connect 0, read 1, write 0, timeout 0
Requests/sec:   2774.59
Transfer/sec:      5.82MB

Phoenix (Warm)

$ ./wrk -t12 -c800 -d180S --timeout 2000 "http://tranquil-brushlands-6459.herokuapp.com/showdown"
Running 3m test @ http://tranquil-brushlands-6459.herokuapp.com/showdown
12 threads and 800 connections
Thread Stats   Avg      Stdev     Max   +/- Stdev
Latency   318.52ms  139.92ms   1.39s    82.03%
Req/Sec   224.42     57.23   368.00     68.50%
484444 requests in 3.00m, 0.99GB read
Socket errors: connect 0, read 9, write 0, timeout 0
Requests/sec:   2691.03
Transfer/sec:      5.65MB

Загрузка

load_avg_1m=2.78

sample#memory_total=34.69MB
sample#memory_rss=33.57MB
sample#memory_cache=0.09MB
sample#memory_swap=1.03MB
sample#memory_pgpgin=204996pages sample#memory_pgpgout=196379pages

Rails (Cold)

$ ./wrk -t12 -c800 -d30S --timeout 2000 "http://dry-ocean-9525.herokuapp.com/showdown"
Running 30s test @ http://dry-ocean-9525.herokuapp.com/showdown
12 threads and 800 connections
Thread Stats   Avg      Stdev     Max   +/- Stdev
Latency     2.85s     1.33s    5.75s    65.73%
Req/Sec    22.68      7.18    61.00     69.71%
8276 requests in 30.03s, 18.70MB read
Requests/sec:    275.64
Transfer/sec:    637.86KB

Rails (Warm)

$ ./wrk -t12 -c800 -d180S --timeout 2000 "http://dry-ocean-9525.herokuapp.com/showdown"
Running 3m test @ http://dry-ocean-9525.herokuapp.com/showdown
12 threads and 800 connections
Thread Stats   Avg      Stdev     Max   +/- Stdev
Latency     3.07s     2.06s    8.36s    70.39%
Req/Sec    24.65      9.97    63.00     67.10%
54256 requests in 3.00m, 122.50MB read
Socket errors: connect 0, read 1, write 0, timeout 0
Requests/sec:    301.36
Transfer/sec:    696.77KB

Загрузка

sample#load_avg_1m=10.40

sample#memory_total=235.37MB
sample#memory_rss=235.35MB
sample#memory_cache=0.02MB
sample#memory_swap=0.00MB
sample#memory_pgpgin=66703pages
sample#memory_pgpgout=6449pages

Обобщённые результаты

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

Если вы хотели бы собственноручно провести такое же тестирование, то созданные приложения находятся в свободном доступе на сайте Github. Интересно было бы посмотреть, какие результаты получились на разном железе, особенно на том, которое могло бы сильнее нагрузить Phoenix-приложение.

За предоставленную информацию об установке приложений Heroku и тестировании удалённых серверов благодарим Джейсона Стибса!