August 10, 2024·6 min read·engineeringrubyrailsyjitdeployment

Rails 7.2 makes YJIT part of its application defaults

New defaults enable YJIT on Ruby 3.3, reduce Puma threads and add jemalloc to generated Dockerfiles. Versioned configuration keeps framework availability separate from adoption.

A runtime optimization can exist for years without becoming part of the ordinary application setup. Someone must know which interpreter supports it and arrange activation. Rails 7.2 changes that adoption path for YJIT by enabling it through the framework's new defaults on Ruby 3.3 or newer.

The August 10 release announcement places this beside a lower default Puma thread count and jemalloc in generated Dockerfiles. These changes affect different parts of execution. YJIT compiles Ruby code, thread configuration affects concurrency under the Global VM Lock, and the allocator affects memory handling. Their inclusion together makes production configuration a substantial part of the release.

I still find Ruby readable and approachable for object-oriented applications, even though I question choosing it automatically for new work. These defaults are useful because they address the cost of running the applications that already use it. They also make the framework's assumptions available for inspection in generated files and versioned settings.

Ruby 3.3 supplies runtime activation

The Ruby 3.3 announcement introduced RubyVM::YJIT.enable, allowing YJIT activation at runtime. A program no longer needs to rely solely on startup flags or environment variables to enable compilation. That release explicitly identified Rails 7.2 as a planned user of the API.

Runtime activation also permits enabling the compiler after application boot. The Ruby announcement explains that capability separately from merely starting the process with YJIT enabled. It gives framework initialization a way to choose when compilation becomes active.

That is why Ruby 3.3 matters here, even though YJIT first became available in CRuby 3.1. The framework default relies on the newer runtime activation facility. Rails 7.2's minimum supported Ruby version is 3.1, but its support floor and the condition for this particular default are different facts.

The Rails announcement describes latency improvements observed with YJIT. Those figures should not become a promised result for every application. The more concrete release behavior is the activation policy, using an available Ruby 3.3 facility without asking each newly configured application to arrange its own startup option.

Versioned defaults control adoption

The Rails 7.2 configuration implementation initializes yjit to false. Loading the 7.2 defaults sets it to true after loading the preceding version's defaults. This makes the meaning of enabled-by-default more precise than a claim that changing a gem version necessarily flips every existing application setting.

New applications adopt the generated configuration for their framework version. Existing applications can retain earlier versioned defaults while updating the framework. The YJIT setting therefore belongs in the discussion of configuration adoption, alongside whether the interpreter supplies the needed support.

The release notes provide an explicit way to disable the setting. In application configuration, the choice can be stated directly.

Rails.application.config.yjit = false

This is a framework setting. It should not be confused with a claim that Rails can remove JIT-generated code from an already running process simply because a configuration value changes. The documented use is selecting application behavior through its configuration.

An explicit switch also matters for environments whose constraints differ from the framework's expected application. A default can make the common case easier while leaving the choice visible. Keeping that choice separate from the interpreter support floor avoids making YJIT a prerequisite for every Rails 7.2 deployment.

Compilation uses memory as well as CPU

Ruby 3.3's release notes describe reduced memory for compiled-code metadata and more compact code on Arm64. They also add a cold-code threshold and adjust the compilation-call threshold for applications with many instruction sequences. Those changes make the runtime's compilation policy relevant to the framework's decision to enable it.

The same Ruby release disables code garbage collection by default. Its executable-memory option becomes a hard limit at which compilation of new code stops. Existing compiled code remains useful without periodic code-GC interruptions, while the process avoids treating compilation memory as unbounded.

This explains why enabling YJIT is a runtime-policy choice, rather than a syntax change in Rails application code. The compiler decides which operations to compile and keeps generated code and supporting metadata. The application receives the execution behavior of that implementation without translating its own methods manually.

The Ruby announcement also describes compiling more optional-argument calls and exception handlers, and reducing exits for unsupported call types. These are concrete improvements to the kinds of Ruby execution that can remain in compiled code. They provide context for the newer interpreter's role without requiring an invented benchmark for a particular Rails application.

Fewer Puma threads target lock waiting

Rails changes the generated Puma thread default from five to three. The announcement gives a specific reason, reducing request latency caused by excessive waiting for the Global VM Lock. More application threads do not necessarily translate into more useful simultaneous Ruby execution.

The versioned release notes describe the expected workload behind that decision, well-optimized applications with quick SQL queries and slow external work moved into jobs. That context matters. A thread default embodies assumptions about the work requests perform.

YJIT and thread count consequently address different causes of latency. Making Ruby execution faster does not establish the best number of request threads, and changing thread count does not compile more Ruby methods. The release presents both as choices in the generated production setup.

I prefer defaults with a stated mechanism to a vague promise that more concurrency is better. The lock-wait explanation gives the thread change a reason an operator can relate to the application's request model. It does not require treating three as a universal optimum for every Ruby process.

Generated deployment and development files carry policy

The default Dockerfile now includes jemalloc. The release notes connect allocator choice to memory fragmentation, particularly with multithreaded servers such as Puma. This is another implementation-level change whose purpose is distinct from collecting Ruby objects or compiling methods.

A generated Dockerfile applies to the environment built from that file. An existing deployment image does not acquire a different allocator merely because the application's Rails dependency changes. That distinction is similar to versioned framework configuration, where availability and adoption have separate steps.

Rails 7.2 also offers development-container generation for new and existing applications. The generated setup includes a database choice, Redis and headless Chrome for system tests, with local-disk Active Storage support. These files describe a shared development environment, while the production Dockerfile addresses the deployed application environment.

The release adds CI and security-scanning defaults for new applications too. Those choices broaden the generated project's starting point. They should be understood as files and dependencies a new application receives, rather than retroactive changes to every existing repository.

Job publication now respects transaction completion

Another production-relevant change defers Active Job enqueueing until after a database transaction commits. Previously, a worker could receive a job while the transaction that created its referenced data was still open. The worker then ran against state that was not yet committed.

The new behavior drops the deferred job when the transaction rolls back. Queue implementations can choose to disable the behavior, and applications can set the policy for individual jobs. The release also introduces transaction-level commit and rollback callbacks and ActiveRecord.after_all_transactions_commit for work that must follow persistence.

Transaction-level callbacks matter when the operation spans more than one record. The release's example updates an article and registers an after_commit callback on the transaction object yielded by the transaction block. That places the publication condition on completion of the unit of work. The callback does not need to be modeled as a lifecycle hook belonging to one record solely to obtain the correct timing.

The all-transactions callback serves code that may run inside or outside a transaction. Its purpose is to defer follow-up work until the surrounding persistence obligations have completed. This gives application methods an interface for stating their sequencing requirement without assuming that every caller establishes the same transaction nesting. It is particularly relevant where a notification or background action will be handled by another process.

This is an ordering improvement independent of YJIT. Faster execution cannot correct a job published before its required database state exists. Rails 7.2 therefore changes both how ordinary requests execute and when background work becomes visible, with explicit settings and adapter behavior defining where those new defaults apply.