Profiling rails assets precompilation
Sep 1, 2013Assets precompilation on rails can take a fair bit of time. This is especially annoying in scenarios where you want to deploy your app multiple times a day. Let’s see if we can come up with a way to actually figure out where all this time is being spent. Also, while I will be focusing on rails 3.2 in this post, the general principle should be easy enough to apply to other rails versions.
Our first call of action is finding the assets precompilation logic. A bit of digging will turn up the assets.rake file for rails 3.2. The relevant code starts on lines 59-67 and from there on out invokes methods throughout the entire file.
# lines 59-67 of assets.rake
task :all do
Rake::Task["assets:precompile:primary"].invoke
# We need to reinvoke in order to run the secondary digestless
# asset compilation run - a fresh Sprockets environment is
# required in order to compile digestless assets as the
# environment has already cached the assets on the primary
# run.
if Rails.application.config.assets.digest
ruby_rake_task("assets:precompile:nondigest", false)
end
end
When we follow the calls made by the code above we can see that the actual compilation takes place on lines 50-56 of assets.rake and is done by the compile method of the Sprockets::StaticCompiler class.
# compile method of Sprockets::StaticCompiler class
def compile
manifest = {}
env.each_logical_path(paths) do |logical_path|
if asset = env.find_asset(logical_path)
digest_path = write_asset(asset)
manifest[asset.logical_path] = digest_path
manifest[aliased_path_for(asset.logical_path)] = digest_path
end
end
write_manifest(manifest) if @manifest
end
Now that we know which code does the compiling, we can think of two ways to add some profiling to this. We could checkout the rails repo from Github, modify it locally and point our Gemfile to our modified local version of rails. Or, we could create a new rake task and monkey patch the compile method of the Sprockets::StaticCompiler class. We’ll go with the second option here as it is the more straightforward to implement.
We’ll create a new rake file in the /lib/tasks folder of our rails app and name it profile_assets_precompilation.rake
. We then copy the contents of assets.rake into it, and wrap this code inside a new ‘profile’ namespace so as to avoid conflicts. At the top of this file we’ll also add our monkey patched compile method so as to make it output profiling info. The resulting file should look like shown below.
namespace :profile do
# monkey patch the compile method to output compilation times
module Sprockets
class StaticCompiler
def compile
manifest = {}
env.each_logical_path(paths) do |logical_path|
start_time = Time.now.to_f
if asset = env.find_asset(logical_path)
digest_path = write_asset(asset)
manifest[asset.logical_path] = digest_path
manifest[aliased_path_for(asset.logical_path)] = digest_path
end
# our profiling code
duration = Time.now.to_f - start_time
puts "#{logical_path} - #{duration.round(3)} seconds"
end
write_manifest(manifest) if @manifest
end
end
end
# contents of assets.rake
namespace :assets do
def ruby_rake_task(task, fork = true)
env = ENV['RAILS_ENV'] || 'production'
groups = ENV['RAILS_GROUPS'] || 'assets'
args = [$0, task,"RAILS_ENV=#{env}","RAILS_GROUPS=#{groups}"]
args << "--trace" if Rake.application.options.trace
if $0 =~ /rake\.bat\Z/i
Kernel.exec $0, *args
else
fork ? ruby(*args) : Kernel.exec(FileUtils::RUBY, *args)
end
end
# We are currently running with no explicit bundler group
# and/or no explicit environment - we have to reinvoke rake to
# execute this task.
def invoke_or_reboot_rake_task(task)
if ENV['RAILS_GROUPS'].to_s.empty? || ENV['RAILS_ENV'].to_s.empty?
ruby_rake_task task
else
Rake::Task[task].invoke
end
end
desc "Compile all the assets named in config.assets.precompile"
task :precompile do
invoke_or_reboot_rake_task "assets:precompile:all"
end
namespace :precompile do
def internal_precompile(digest=nil)
unless Rails.application.config.assets.enabled
warn "Cannot precompile assets if sprockets is disabled. Please set config.assets.enabled to true"
exit
end
# Ensure that action view is loaded and the appropriate
# sprockets hooks get executed
_ = ActionView::Base
config = Rails.application.config
config.assets.compile = true
config.assets.digest = digest unless digest.nil?
config.assets.digests = {}
env = Rails.application.assets
target = File.join(Rails.public_path, config.assets.prefix)
compiler = Sprockets::StaticCompiler.new(env,
target,
config.assets.precompile,
:manifest_path => config.assets.manifest,
:digest => config.assets.digest,
:manifest => digest.nil?)
compiler.compile
end
task :all do
Rake::Task["assets:precompile:primary"].invoke
# We need to reinvoke in order to run the secondary digestless
# asset compilation run - a fresh Sprockets environment is
# required in order to compile digestless assets as the
# environment has already cached the assets on the primary
# run.
ruby_rake_task("assets:precompile:nondigest", false) if Rails.application.config.assets.digest
end
task :primary => ["assets:environment", "tmp:cache:clear"] do
internal_precompile
end
task :nondigest => ["assets:environment", "tmp:cache:clear"] do
internal_precompile(false)
end
end
desc "Remove compiled assets"
task :clean do
invoke_or_reboot_rake_task "assets:clean:all"
end
namespace :clean do
task :all => ["assets:environment", "tmp:cache:clear"] do
config = Rails.application.config
public_asset_path = File.join(Rails.public_path, config.assets.prefix)
rm_rf public_asset_path, :secure => true
end
end
task :environment do
if Rails.application.config.assets.initialize_on_precompile
Rake::Task["environment"].invoke
else
Rails.application.initialize!(:assets)
Sprockets::Bootstrap.new(Rails.application).run
end
end
end
end
Now we can run bundle exec rake profile:assets:precompile
to precompile our assets while outputting profiling info. Hopefully we can now finally figure out why this is always taking so long :).