Шесть последовательных статей о слиянии Rails и Merb были опубликованы на www.engineyard.com с декабря 2009 до апреля 2010 года. Это вторая статья. Первая тут.

Следующим замечательным улучшением, которое мы надеялись внедрить в Rails из Merb, была лучшая производительность. Поскольку Merb появился после Rails, разработчики Merb имели возможность узнать, какие части Rails использовались чаще, и оптимизировать их производительность.

Мы хотели взять из Merb изменения, улучшающие производительность, и перенести их в Rails 3. В этом посте я расскажу о нескольких оптимизационных моментах, добавленных в Rails 3: уменьшение времени работы контроллера и (сильное) ускорение рендера коллекции партиалов.


Для начала мы сфокусировались на производительности нескольких специфических, но широко используемых частях Rails:
  • Непроизводительные издержки (маршрутизатор плюс цена за вход и выход из контроллера)
  • render :text
  • render :template
  • render :partial
  • рендер нескольких (10 и 100) одинаковых партиалов в цикле
  • рендер нескольких (10 и 100) одинаковых партиалов с помощью collection

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

Непроизводительные издержки контроллера


Первым шагом было усовершенствование издержек работы Rails контроллера. В Rails 2.3 нет способов их тестирования, потому что вы вынуждены использовать render :string, чтобы послать текст клиенту в ответ, что подразумевает выполнение всего процесса рендера. И все же, мы хотели уменьшить это насколько возможно.

Делая это, мы использовали fork Стефана Каеса (Stefan Kaes’ fork of ruby-prof), идущий вместе с CallStackPrinter (лучший способ визуализации профайловых данных из Ruby приложения, из всех, что я видел). Мы также написали несколько бенчмарков, которые могли дублировать профайлер в процессе его работы, чтобы сфокусироваться на конкретном участке и получить более точные данные.

Когда мы посмотрели на процесс работы контроллера, оказалось, что доминирующую часть занимало создание ответа. Покопав��ись глубже, мы увидели, что ActionController устанавливал заголовки напрямую, потом парсил их снова перед тем, как вернуть ответ, чтобы получить дополнительную информацию. Хороший пример этого феномена — заголовок Content-Type, который имеет два компонента (сам content-type и опциональный charset). Оба компонента были доступны в объекте Response через геттер и сеттер:

Copy Source | Copy HTML<br/>def content_type=(mime_type)<br/>  self.headers["Content-Type"] =<br/>    if mime_type =~ /charset/ || (c = charset).nil?<br/>      mime_type.to_s<br/>    else<br/>      "#{mime_type}; charset=#{c}"<br/>    end<br/>end<br/> <br/># Returns the response's content MIME type, or nil if content type has been set.<br/>def content_type<br/>  content_type = String(headers["Content-Type"] || headers["type"]).split(";")[ 0]<br/>  content_type.blank? ? nil : content_type<br/>end<br/> <br/># Set the charset of the Content-Type header. Set to nil to remove it.<br/># If no content type is set, it defaults to HTML.<br/>def charset=(charset)<br/>  headers["Content-Type"] =<br/>    if charset<br/>      "#{content_type || Mime::HTML}; charset=#{charset}"<br/>    else<br/>      content_type || Mime::HTML.to_s<br/>    end<br/>end<br/> <br/>def charset<br/>  charset = String(headers["Content-Type"] || headers["type"]).split(";")[1]<br/>  charset.blank? ? nil : charset.strip.split("=")[1]<br/>end <br/>

Как видите, объект Response работал напрямую с заголовком Content-Type, парся часть заголовка при необходимости. Это было особенно проблематично, потому что Response совершал лишнюю работу над заголовками во время приготовлений перед отсылкой ответа клиенту:

Copy Source | Copy HTML<br/>def assign_default_content_type_and_charset!<br/>  self.content_type ||= Mime::HTML<br/>  self.charset ||= default_charset unless sending_file?<br/>end <br/>

То есть перед отправкой ответа Rails снова разбивал заголовок Content-Type по точке с запятой и потом делал еще работу со строками, чтобы соединить их вместе снова. И конечно, Response#content_type= использовался в других частях Rails, чтобы правильно установить его в зависимости от типа шаблона или внутри блоков respond_to.

Это не занимало сотни миллисекунд при запросе, но в сильно кешированных приложениях издержки могли быть выше стоимости вынимания объектов из кеша и возврата их клиенту.

Решением в данном случае было хранить тип контента и charset в полях объекта ответа и объединять их одной простой быстрой операцией при подготовке ответа.

Copy Source | Copy HTML<br/>attr_accessor :charset, :content_type<br/> <br/>def assign_default_content_type_and_charset!<br/>  return if headers[CONTENT_TYPE].present?<br/> <br/>  @content_type ||= Mime::HTML<br/>  @charset ||= self.class.default_charset<br/> <br/>  type = @content_type.to_s.dup<br/>  type < < "; charset=#{@charset}" unless @sending_file<br/> <br/>  headers[CONTENT_TYPE] = type<br/>end <br/>

Теперь мы просто находим переменные экземпляра и создаем один String. Множественные изменения этих строк кода снизили время изержек примерно с 400 мксек до 100 мксек. Конечно, не сильно большое количество времени, но оно могло реально ослабить чувствительные к производительности приложения.

Рендер коллекции партиалов


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

Для начала реализация в Rails 2.3:

Copy Source | Copy HTML<br/>def render_partial_collection(options = {}) #:nodoc:<br/>  return nil if options[:collection].blank?<br/> <br/>  partial = options[:partial]<br/>  spacer = options[:spacer_template] ? render(:partial => options[:spacer_template]) : ''<br/>  local_assigns = options[:locals] ? options[:locals].clone : {}<br/>  as = options[:as]<br/> <br/>  index =  0<br/>  options[:collection].map do |object|<br/>    _partial_path ||= partial ||<br/>      ActionController::RecordIdentifier.partial_path(object, controller.class.controller_path)<br/>    template = _pick_partial_template(_partial_path)<br/>    local_assigns[template.counter_name] = index<br/>    result = template.render_partial(self, object, local_assigns.dup, as)<br/>    index += 1<br/>    result<br/>  end.join(spacer).html_safe!<br/>end <br/>

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

Copy Source | Copy HTML<br/>with = [opts.delete(:with)].flatten<br/>as = (opts.delete(:as) || template.match(%r[(?:.*/)?_([^\./]*)])[1]).to_sym<br/> <br/># Ensure that as is in the locals hash even if it isn't passed in here<br/># so that it's included in the preamble.<br/>locals = opts.merge(:collection_index => -1, :collection_size => with.size, as => opts[as])<br/>template_method, template_location = _template_for(<br/>  template,<br/>  opts.delete(:format) || content_type,<br/>  kontroller,<br/>  template_path,<br/>  locals.keys)<br/> <br/># this handles an edge-case where the name of the partial is _foo.* and your opts<br/># have :foo as a key.<br/>named_local = opts.key?(as)<br/> <br/>sent_template = with.map do |temp|<br/>  locals[as] = temp unless named_local<br/> <br/>  if template_method && self.respond_to?(template_method)<br/>    locals[:collection_index] += 1<br/>    send(template_method, locals)<br/>  else<br/>    raise TemplateNotFound, "Could not find template at #{template_location}.*"<br/>  end<br/>end.join<br/> <br/>sent_template <br/>

Сейчас нам понятно, что это далеко от идеала. Тут происходит много всего (и я лично хотел бы увидеть этот метод отрефакторенным). Но интересная часть — то, что происходит внутри цикла (начиная с sent_template = with.map). В отличие от ActionView, который выяснял имя шаблона, брал объект шаблона, имя счетчика и т.д., Merb ограничил активность внутри цикла установкой нескольких значений Hash и вызовом метода.

Для коллекции из 100 партиалов разница между издержками могла быть около 10 мс и 3 мс. Для коллекции маленьких партиалов это было заметно (и причина для инлайн партиалов, которые соответствовали для того, чтобы бы партиалами на первом месте).

В Rails 3, мы улучшили производительность за счет уменьшения того, что происходит внутри цикла. К сожалению, одна особенность Rails сделала оптимизацию немного труднее. В частности, вы могли отрендерить партиал, используя гетерогенную коллекцию (коллекцию, содержащую объекты Post, Article и Page, например) и Rails рендерили бы правильный шаблон для каждого объекта (объекты Article рендерятся в _article.html.erb, и т.д.). Это означает, что не всегда есть возможность точно определить шаблон, который должен быть отрендерен.

Столкнувшись с этой проблемой, нам не удалось полностью оптимизировать гетерогенный случай, но мы сделали render :partial => "name", :collection => @array быстрее. Для достижения этого мы разбили логику на 2 пути: более быстрый для случая, когда мы знаем шаблон, и более медленный для случая, когда он должен быть определен в зависимости от объекта.

Вот как теперь выглядит рендер коллекции в случае, когда мы знаем шаблон:

Copy Source | Copy HTML<br/>def collection_with_template(template = @template)<br/>  segments, locals, as = [], @locals, @options[:as] || template.variable_name<br/> <br/>  counter_name = template.counter_name<br/>  locals[counter_name] = -1<br/> <br/>  @collection.each do |object|<br/>    locals[counter_name] += 1<br/>    locals[as] = object<br/> <br/>    segments < < template.render(@view, locals)<br/>  end<br/> <br/>  @template = template<br/>  segments<br/>end <br/>

Что важно, сам цикл теперь маленький (даже проще чем то, что происходило внутри цикла в Merb). Что еще стоит отметить, это то, что в процессе улучшения производительности кода, мы создали объект PartialRenderer для отслеживания состояния. Хотя вы могли подумать, что создание нового объекта было бы обойтись дорого, оказывается, что создание объектов в Ruby относительно дешево и объекты могут предлагать возможности кеширования, которые гораздо сложнее в процедурном коде.

Для тех, кто хочет увидеть улучшения в картинках, вот несколько вещей: во-первых, улучшение между Rails 2.3 и Rails 3 на Ruby 1.9 (меньший столбик означает бОльшую скорость).
image

А вот для более дорогих операций:
image

Наконец, сравнение Rails 3 на четырех платформах (Ruby 1.8, Ruby 1.9, Rubinius и JRuby):
image

Как вы можете видеть, Rails 3 заметно быстрее, чем Rails 2.3, и что все платформы (включая Rubinius!) заметно улучшены по сравнению с Ruby 1.8. В общем, чудесный год для Ruby!

В следующем посте я расскажу про улучшения в API Rails 3 для авторов плагинов — следите за сообщениями и, как всегда, оставляйте комментарии!