Skip to content

Remove deep-freezing via IceNine in favor of Ractor#203

Open
cllns wants to merge 1 commit intomainfrom
deep-freeze-with-ractor
Open

Remove deep-freezing via IceNine in favor of Ractor#203
cllns wants to merge 1 commit intomainfrom
deep-freeze-with-ractor

Conversation

@cllns
Copy link
Member

@cllns cllns commented Mar 4, 2026

IceNine is unmaintained and Ractor is maintained by ruby-core.

There's an open proposal to add deep_freeze to Ruby core, but it hasn't been accepted yet.

Also, since Ractor is implemented in C, it's faster and more memory efficient than IceNine (which is implemented in Ruby). I show this with benchmarks below.

I worked with Claude to write some speed and memory/allocations benchmarks. Memory is a bit tricky to benchmark, so I provided both RSS (which is inconsistent due to GC timing) and just number of allocations (with GC stopped).

Note that there are GC benefits to freezing (deeply frozen objects get pushed out of the GC passes that are run frequently), they're just hard to benchmark. An additional, if obvious benefit is that it... well, makes the object shareable via Ractor as well.

I used Ractor as the baseline to show that it's faster than IceNine. I also included non-deep freezing ("regular") for comparison.

Note that Dry::Struct::Values are technically deprecated, but I think we should re-consider that, since it does have benefits.

Deep Freeze Benchmark Results

Ruby 4.0.0 · arm64-darwin · 100k objects per scenario

Speed

Speed Benchmark Script
# frozen_string_literal: true

require "benchmark/ips"
require "dry/struct"
require "ice_nine"

module Types
  include Dry.Types()
end

class RegularUser < Dry::Struct
  attribute :name, Types::Strict::String
  attribute :age, Types::Strict::Integer
end

class IceNineFrozenUser < Dry::Struct
  attribute :name, Types::Strict::String
  attribute :age, Types::Strict::Integer

  def self.new(*)
    IceNine.deep_freeze(super)
  end
end

class RactorFrozenUser < Dry::Struct
  attribute :name, Types::Strict::String
  attribute :age, Types::Strict::Integer

  def self.new(*)
    Ractor.make_shareable(super)
  end
end

class Address < Dry::Struct
  attribute :street, Types::Strict::String
  attribute :city, Types::Strict::String
  attribute :zip, Types::Strict::String
end

class RegularPerson < Dry::Struct
  attribute :name, Types::Strict::String
  attribute :age, Types::Strict::Integer
  attribute :address, Address
  attribute :tags, Types::Strict::Array.of(Types::Strict::String)
end

class FrozenAddress < Dry::Struct
  attribute :street, Types::Strict::String
  attribute :city, Types::Strict::String
  attribute :zip, Types::Strict::String
end

class IceNineFrozenPerson < Dry::Struct
  attribute :name, Types::Strict::String
  attribute :age, Types::Strict::Integer
  attribute :address, FrozenAddress
  attribute :tags, Types::Strict::Array.of(Types::Strict::String)

  def self.new(*)
    IceNine.deep_freeze(super)
  end
end

class RactorFrozenPerson < Dry::Struct
  attribute :name, Types::Strict::String
  attribute :age, Types::Strict::Integer
  attribute :address, FrozenAddress
  attribute :tags, Types::Strict::Array.of(Types::Strict::String)

  def self.new(*)
    Ractor.make_shareable(super)
  end
end

SIMPLE_ATTRS = { name: "Jane", age: 21 }.freeze
NESTED_ATTRS = {
  name: "Jane",
  age: 21,
  address: { street: "123 Main St", city: "Springfield", zip: "62701" },
  tags: %w[admin user],
}.freeze

SCENARIOS = {
  simple: {
    label: "Simple (2 attributes)",
    attrs: SIMPLE_ATTRS,
    classes: {
      "regular" => RegularUser,
      "ice_nine" => IceNineFrozenUser,
      "ractor" => RactorFrozenUser,
    },
  },
  nested: {
    label: "Nested (7 attributes, struct + array)",
    attrs: NESTED_ATTRS,
    classes: {
      "regular" => RegularPerson,
      "ice_nine" => IceNineFrozenPerson,
      "ractor" => RactorFrozenPerson,
    },
  },
}.freeze

NAMES = %w[regular ice_nine ractor].freeze

puts "=== Instantiation speed ==="
puts "ruby #{RUBY_VERSION} (#{RUBY_PLATFORM})"
puts

summaries = []

SCENARIOS.each do |_key, scenario|
  puts "### #{scenario[:label]}"
  puts

  results = {}

  Benchmark.ips(quiet: true) do |x|
    scenario[:classes].each do |name, klass|
      attrs = scenario[:attrs]
      x.report(name) { klass.new(**attrs) }
    end
  end.entries.each { |entry| results[entry.label] = entry }

  ractor_ips = results["ractor"].stats.central_tendency

  puts "%-12s %10s %14s" % ["", "i/s", "vs ractor"]
  puts "-" * 38

  NAMES.each do |name|
    entry_ips = results[name].stats.central_tendency
    ratio = entry_ips / ractor_ips
    vs = name == "ractor" ? "baseline" : format_vs(ratio)
    puts "%-12s %10s %14s" % [name, format("%.0fk", entry_ips / 1000), vs]
  end

  regular_ips = results["regular"].stats.central_tendency
  ice_ips = results["ice_nine"].stats.central_tendency

  summaries << {
    label: scenario[:label],
    vs_ice_nine: ractor_ips / ice_ips,
    vs_regular: regular_ips / ractor_ips,
  }

  puts
end

puts "--- Summary ---"
summaries.each do |s|
  puts "#{s[:label]}:"
  puts "  ractor is #{((s[:vs_ice_nine] - 1.0) * 100).round(0)}% faster than ice_nine"
  puts "  ractor is #{((s[:vs_regular] - 1.0) * 100).round(0)}% slower than regular (no freezing)"
end

Simple (2 attributes)

Strategy i/s vs ractor
regular 864k 67% faster
ractor 518k baseline
ice_nine 223k 57% slower

Nested (7 attributes, struct + array)

Strategy i/s vs ractor
regular 250k 54% faster
ractor 163k baseline
ice_nine 69k 58% slower

Ractor is ~2.3× faster than IceNine, at ~54–67% the speed of no freezing.


Allocations

Allocations Benchmark Script
# frozen_string_literal: true

require "dry/struct"
require "ice_nine"

module Types
  include Dry.Types()
end

class RegularUser < Dry::Struct
  attribute :name, Types::Strict::String
  attribute :age, Types::Strict::Integer
end

class IceNineFrozenUser < Dry::Struct
  attribute :name, Types::Strict::String
  attribute :age, Types::Strict::Integer

  def self.new(*)
    IceNine.deep_freeze(super)
  end
end

class RactorFrozenUser < Dry::Struct
  attribute :name, Types::Strict::String
  attribute :age, Types::Strict::Integer

  def self.new(*)
    Ractor.make_shareable(super)
  end
end

class Address < Dry::Struct
  attribute :street, Types::Strict::String
  attribute :city, Types::Strict::String
  attribute :zip, Types::Strict::String
end

class RegularPerson < Dry::Struct
  attribute :name, Types::Strict::String
  attribute :age, Types::Strict::Integer
  attribute :address, Address
  attribute :tags, Types::Strict::Array.of(Types::Strict::String)
end

class FrozenAddress < Dry::Struct
  attribute :street, Types::Strict::String
  attribute :city, Types::Strict::String
  attribute :zip, Types::Strict::String
end

class IceNineFrozenPerson < Dry::Struct
  attribute :name, Types::Strict::String
  attribute :age, Types::Strict::Integer
  attribute :address, FrozenAddress
  attribute :tags, Types::Strict::Array.of(Types::Strict::String)

  def self.new(*)
    IceNine.deep_freeze(super)
  end
end

class RactorFrozenPerson < Dry::Struct
  attribute :name, Types::Strict::String
  attribute :age, Types::Strict::Integer
  attribute :address, FrozenAddress
  attribute :tags, Types::Strict::Array.of(Types::Strict::String)

  def self.new(*)
    Ractor.make_shareable(super)
  end
end

SIMPLE_ATTRS = { name: "Jane", age: 21 }.freeze
NESTED_ATTRS = {
  name: "Jane",
  age: 21,
  address: { street: "123 Main St", city: "Springfield", zip: "62701" },
  tags: %w[admin user],
}.freeze

SCENARIOS = {
  simple: {
    label: "Simple (2 attributes)",
    attrs: SIMPLE_ATTRS,
    classes: {
      "regular" => RegularUser,
      "ice_nine" => IceNineFrozenUser,
      "ractor" => RactorFrozenUser,
    },
  },
  nested: {
    label: "Nested (7 attributes, struct + array)",
    attrs: NESTED_ATTRS,
    classes: {
      "regular" => RegularPerson,
      "ice_nine" => IceNineFrozenPerson,
      "ractor" => RactorFrozenPerson,
    },
  },
}.freeze

NAMES = %w[regular ice_nine ractor].freeze

OBJECT_COUNT = 100_000

puts "=== Allocations (GC disabled) — #{OBJECT_COUNT} objects ==="
puts "ruby #{RUBY_VERSION} (#{RUBY_PLATFORM})"
puts

def format_pct(ratio, more:, less:)
  pct = ((ratio - 1.0) * 100).abs.round(0)
  ratio < 1.0 ? "#{pct}% #{less}" : "#{pct}% #{more}"
end

summaries = []

SCENARIOS.each do |_key, scenario|
  puts "### #{scenario[:label]}"
  puts

  results = {}

  scenario[:classes].each do |name, klass|
    GC.start(full_mark: true, immediate_sweep: true)
    GC.compact
    GC.disable

    before = GC.stat(:total_allocated_objects)
    OBJECT_COUNT.times { klass.new(**scenario[:attrs]) }
    after = GC.stat(:total_allocated_objects)

    GC.enable
    GC.start(full_mark: true, immediate_sweep: true)

    results[name] = after - before
  end

  ractor_allocs = results["ractor"]

  puts "%-12s %14s %10s %14s" % ["", "total allocs", "per .new", "vs ractor"]
  puts "-" * 54

  NAMES.each do |name|
    allocs = results[name]
    per_new = allocs / OBJECT_COUNT
    ratio = allocs.to_f / ractor_allocs
    vs = name == "ractor" ? "baseline" : format_pct(ratio, more: "more", less: "fewer")
    puts "%-12s %14s %10s %14s" % [name, format("%dk", allocs / 1000), per_new, vs]
  end

  summaries << {
    label: scenario[:label],
    per_regular: results["regular"] / OBJECT_COUNT,
    per_ractor: ractor_allocs / OBJECT_COUNT,
    per_ice_nine: results["ice_nine"] / OBJECT_COUNT,
  }

  puts
end

puts "--- Summary ---"
summaries.each do |s|
  puts "#{s[:label]}:"
  puts "  ractor allocates #{s[:per_ractor]} objects per .new (vs #{s[:per_ice_nine]} ice_nine, #{s[:per_regular]} regular)"
end

Simple (2 attributes)

Strategy total allocs per .new vs ractor
regular 300k 3 50% fewer
ractor 600k 6 baseline
ice_nine 1,200k 12 100% more

Nested (7 attributes, struct + array)

Strategy total allocs per .new vs ractor
regular 700k 7 36% fewer
ractor 1,100k 11 baseline
ice_nine 2,700k 27 145% more

Ractor allocates 2× fewer objects than IceNine per .new.


Memory (RSS)

Memory (RSS) Benchmark Script
# frozen_string_literal: true

require "dry/struct"
require "ice_nine"

module Types
  include Dry.Types()
end

class RegularUser < Dry::Struct
  attribute :name, Types::Strict::String
  attribute :age, Types::Strict::Integer
end

class IceNineFrozenUser < Dry::Struct
  attribute :name, Types::Strict::String
  attribute :age, Types::Strict::Integer

  def self.new(*)
    IceNine.deep_freeze(super)
  end
end

class RactorFrozenUser < Dry::Struct
  attribute :name, Types::Strict::String
  attribute :age, Types::Strict::Integer

  def self.new(*)
    Ractor.make_shareable(super)
  end
end

class Address < Dry::Struct
  attribute :street, Types::Strict::String
  attribute :city, Types::Strict::String
  attribute :zip, Types::Strict::String
end

class RegularPerson < Dry::Struct
  attribute :name, Types::Strict::String
  attribute :age, Types::Strict::Integer
  attribute :address, Address
  attribute :tags, Types::Strict::Array.of(Types::Strict::String)
end

class FrozenAddress < Dry::Struct
  attribute :street, Types::Strict::String
  attribute :city, Types::Strict::String
  attribute :zip, Types::Strict::String
end

class IceNineFrozenPerson < Dry::Struct
  attribute :name, Types::Strict::String
  attribute :age, Types::Strict::Integer
  attribute :address, FrozenAddress
  attribute :tags, Types::Strict::Array.of(Types::Strict::String)

  def self.new(*)
    IceNine.deep_freeze(super)
  end
end

class RactorFrozenPerson < Dry::Struct
  attribute :name, Types::Strict::String
  attribute :age, Types::Strict::Integer
  attribute :address, FrozenAddress
  attribute :tags, Types::Strict::Array.of(Types::Strict::String)

  def self.new(*)
    Ractor.make_shareable(super)
  end
end

SIMPLE_ATTRS = { name: "Jane", age: 21 }.freeze
NESTED_ATTRS = {
  name: "Jane",
  age: 21,
  address: { street: "123 Main St", city: "Springfield", zip: "62701" },
  tags: %w[admin user],
}.freeze

SCENARIOS = {
  simple: {
    label: "Simple (2 attributes)",
    attrs: SIMPLE_ATTRS,
    classes: {
      "regular" => RegularUser,
      "ice_nine" => IceNineFrozenUser,
      "ractor" => RactorFrozenUser,
    },
  },
  nested: {
    label: "Nested (7 attributes, struct + array)",
    attrs: NESTED_ATTRS,
    classes: {
      "regular" => RegularPerson,
      "ice_nine" => IceNineFrozenPerson,
      "ractor" => RactorFrozenPerson,
    },
  },
}.freeze

NAMES = %w[regular ice_nine ractor].freeze

OBJECT_COUNT = 100_000
RUNS = 5

puts "=== Memory (RSS) — #{OBJECT_COUNT} retained objects, median of #{RUNS} runs ==="
puts "ruby #{RUBY_VERSION} (#{RUBY_PLATFORM})"
puts

def format_pct(ratio, more:, less:)
  pct = ((ratio - 1.0) * 100).abs.round(0)
  ratio < 1.0 ? "#{pct}% #{less}" : "#{pct}% #{more}"
end

def measure_rss_in_fork(klass_name, scenario_key)
  read_io, write_io = IO.pipe

  pid = fork do
    read_io.close

    klass = SCENARIOS.fetch(scenario_key)[:classes].fetch(klass_name)
    attrs = SCENARIOS.fetch(scenario_key)[:attrs]

    GC.start(full_mark: true, immediate_sweep: true)
    GC.compact

    rss_before = `ps -o rss= -p #{Process.pid}`.to_i
    objects = Array.new(OBJECT_COUNT) { klass.new(**attrs) }
    GC.start(full_mark: true, immediate_sweep: true)
    rss_after = `ps -o rss= -p #{Process.pid}`.to_i

    _ = objects
    write_io.puts(rss_after - rss_before)
    write_io.close
  end

  write_io.close
  delta_kb = read_io.read.to_i
  read_io.close
  Process.wait(pid)

  delta_kb / 1024.0
end

def median(values)
  sorted = values.sort
  mid = sorted.size / 2
  sorted.size.odd? ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2.0
end

summaries = []

SCENARIOS.each do |scenario_key, scenario|
  puts "### #{scenario[:label]}"
  puts

  results = {}

  NAMES.each do |name|
    samples = RUNS.times.map { measure_rss_in_fork(name, scenario_key) }
    results[name] = median(samples)
  end

  ractor_rss = results["ractor"]

  puts "%-12s %10s %14s" % ["", "RSS", "vs ractor"]
  puts "-" * 38

  NAMES.each do |name|
    rss = results[name]
    ratio = rss / ractor_rss
    vs = name == "ractor" ? "baseline" : format_pct(ratio, more: "more", less: "less")
    puts "%-12s %8s MB %14s" % [name, format("%.1f", rss), vs]
  end

  summaries << {
    label: scenario[:label],
    regular: results["regular"],
    ractor: results["ractor"],
    ice_nine: results["ice_nine"],
  }

  puts
end

puts "--- Summary ---"
summaries.each do |s|
  puts "#{s[:label]}:"
  puts "  ractor uses #{format("%.1f", s[:ractor])} MB (vs #{format("%.1f", s[:ice_nine])} MB ice_nine, #{format("%.1f", s[:regular])} MB regular)"
end

Simple (2 attributes)

Strategy RSS vs ractor
regular 24.2 MB 11% less
ractor 27.2 MB baseline
ice_nine 36.3 MB 33% more

Nested (7 attributes, struct + array)

Strategy RSS vs ractor
regular 49.0 MB 6% less
ractor 52.2 MB baseline
ice_nine 97.5 MB 87% more

Ractor uses 33–87% less memory than IceNine, within 6–11% of no freezing.


Summary

Ractor-based deep freezing outperforms IceNine across every dimension:

Metric vs IceNine vs no freezing
Speed ~2.3× faster ~1.6× slower
Allocations ~2.3× fewer ~1.6× more
Memory (RSS) 33–87% less 6–11% more

IceNine is unmaintained and Ractor is maintained by ruby-core
@cllns cllns requested a review from flash-gordon March 4, 2026 05:41
@cllns
Copy link
Member Author

cllns commented Mar 4, 2026

@headius Any tips on what we should do for JRuby here? Keep the IceNine dependency for JRuby only?

@headius
Copy link

headius commented Mar 4, 2026

Supporting the deep-freezing capability of Ractor.make_shareable is not difficult, but there's no alternative Ruby shipping that currently.

I did a native implementation of Ractor's freezing APIs for JRuby here: jruby/jruby#9029

This implementation is a light port of the C logic and tries to emulate the same status flags as Ractor in CRuby to avoid repeatedly traversing a known frozen graph.

There's another pure-Ruby implementation of Ractors by @eregon here: jruby/jruby#9029

This version implements more of the API but will tend to re-traverse already-frozen graphs due to being pure-Ruby and not able to manipulate the internal state of objects (especially frozen ones).

I think the best approach lies somewhere in the middle, where implementations that already can do parallelism primarily just deep freeze graphs and an API shim layer makes it largely feel the same.

I also opened a feature request of sorts, to try to formalize deep freezing as a concept separate from Ractor. If we'd have managed to do that before 4.0, you'd just use that API on its own and not even be bothered with Ractor. Alas, I don't get a lot of free time to shepherd it forward.

JRuby 10.1 will ship with some form of Ractor.make_shareable, since I suspect others will also want this API, but I still believe that efficient deep-freezing is a feature that should be independent of Ractor. Perhap we need to pull in someone from IceNine to discuss how we can standardize a deep-freezing API that works whether Ractor is available or not?

@eregon
Copy link

eregon commented Mar 5, 2026

I think it'd be good to push for https://bugs.ruby-lang.org/issues/21665 again.
@headius Could you reply there?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants