From 79ac920eb0716e7acf7244f8604dd71722deab9b Mon Sep 17 00:00:00 2001 From: Takeshi KOMIYA Date: Thu, 21 May 2026 15:15:44 +0900 Subject: [PATCH 1/2] Reuse decls in resolve_type_names when no type names change `resolve_type_names` previously rebuilt every declaration, member, and type even when type name resolution did not change anything. The first resolve must produce absolutized type names, so its cost is unavoidable; but the second and later resolves were re-allocating identical structures for no benefit, pressuring GC heavily. This change makes every `map_type_name` / `map_type` / `resolve_*` helper return its receiver when each child maps back to a value `equal?` to the original. Combined with the existing flyweight behavior of `TypeName` and `Namespace`, declarations whose type names were already absolute are now reused verbatim across resolves. The first resolve is therefore unchanged in both wall time and allocations. The numbers below compare the second-and-later resolves only, measured on conference-app (kaigionrails/conference-app): - allocated per resolve: 20.60 MB / 387,664 objects -> 3.52 MB / 64,293 objects (-83%) - retained over 10 resolves: 18.36 MB / 346,343 objects -> 1.25 MB / 22,973 objects (-93%) - resolve wall time, p99: 112.7 ms -> 98.3 ms (-12.8%) - GC major / 50 resolves: 4 -> 1 (-75%) Single-shot CLI usage (`rbs list` etc.) calls resolve_type_names only once, so it sees no change. Long-running clients such as Steep that re-resolve repeatedly are the primary beneficiaries. --- lib/rbs.rb | 46 ++++++++ lib/rbs/ast/type_param.rb | 22 ++-- lib/rbs/environment.rb | 241 ++++++++++++++++++++++++------------- lib/rbs/method_type.rb | 23 ++-- lib/rbs/types.rb | 243 ++++++++++++++++++++------------------ sig/rbs.rbs | 4 + sig/types.rbs | 4 - 7 files changed, 358 insertions(+), 225 deletions(-) diff --git a/lib/rbs.rb b/lib/rbs.rb index bbc8c8382c..7211652144 100644 --- a/lib/rbs.rb +++ b/lib/rbs.rb @@ -102,5 +102,51 @@ def print_warning() logger.warn { message } end end + + # Internal helper for `map_type_name` / `map_type` / `resolve_*` paths + # in this gem. The given block is invoked for every element. Returns + # the input array unchanged (the same object) when every mapped result + # is `equal?` to its source; otherwise returns a fresh array with the + # changed elements substituted in. Callers detect a no-op by comparing + # the return value with the input via `equal?`, which avoids + # allocating a `[mapped, changed]` tuple on every invocation. + def map_if_changed(array, &) + return array if array.empty? + + result = array + changed = false + array.each_with_index do |element, i| + new_element = yield(element) + next if new_element.equal?(element) + + unless changed + result = array.dup + changed = true + end + result[i] = new_element + end + result + end + + # Hash counterpart of `map_if_changed`: transforms values through the + # block and returns the receiver unchanged when every value identity + # is preserved. + def transform_values_if_changed(hash, &) + return hash if hash.empty? + + result = hash + changed = false + hash.each do |key, value| + new_value = yield(value) + next if new_value.equal?(value) + + unless changed + result = hash.dup + changed = true + end + result[key] = new_value + end + result + end end end diff --git a/lib/rbs/ast/type_param.rb b/lib/rbs/ast/type_param.rb index b4f599b94a..33cbda9d82 100644 --- a/lib/rbs/ast/type_param.rb +++ b/lib/rbs/ast/type_param.rb @@ -67,25 +67,23 @@ def to_json(state = JSON::State.new) end def map_type(&block) - if b = upper_bound_type - _upper_bound_type = yield(b) - end - - if b = lower_bound_type - _lower_bound_type = yield(b) - end + new_upper_bound_type = upper_bound_type ? yield(upper_bound_type) : nil + new_lower_bound_type = lower_bound_type ? yield(lower_bound_type) : nil + new_default_type = default_type ? yield(default_type) : nil - if dt = default_type - _default_type = yield(dt) + if new_upper_bound_type.equal?(upper_bound_type) && + new_lower_bound_type.equal?(lower_bound_type) && + new_default_type.equal?(default_type) + return self end TypeParam.new( name: name, variance: variance, - upper_bound: _upper_bound_type, - lower_bound: _lower_bound_type, + upper_bound: new_upper_bound_type, + lower_bound: new_lower_bound_type, location: location, - default_type: _default_type + default_type: new_default_type ).unchecked!(unchecked?) end diff --git a/lib/rbs/environment.rb b/lib/rbs/environment.rb index 0fb4347142..4419b2941c 100644 --- a/lib/rbs/environment.rb +++ b/lib/rbs/environment.rb @@ -508,7 +508,7 @@ def resolve_signature(resolver, table, dirs, decls, only: nil) end end - decls = decls.map do |decl| + decls = RBS.map_if_changed(decls) do |decl| if only && !only.member?(decl) decl else @@ -541,7 +541,7 @@ def resolve_type_names(only: nil) end each_ruby_source do |source| - decls = source.declarations.map do |decl| + decls = RBS.map_if_changed(source.declarations) do |decl| if only if only.include?(decl) resolve_ruby_decl(resolver, decl, context: nil, prefix: Namespace.root) @@ -577,9 +577,11 @@ def append_context(context, decl) def resolve_declaration(resolver, map, decl, context:, prefix:) if decl.is_a?(AST::Declarations::Global) # @type var decl: AST::Declarations::Global + new_type = absolute_type(resolver, map, decl.type, context: nil) + return decl if new_type.equal?(decl.type) return AST::Declarations::Global.new( name: decl.name, - type: absolute_type(resolver, map, decl.type, context: nil), + type: new_type, location: decl.location, comment: decl.comment, annotations: decl.annotations @@ -592,32 +594,42 @@ def resolve_declaration(resolver, map, decl, context:, prefix:) inner_context = append_context(outer_context, decl) prefix_ = prefix + decl.name.to_namespace - AST::Declarations::Class.new( - name: decl.name.with_prefix(prefix), - type_params: resolve_type_params(resolver, map, decl.type_params, context: inner_context), - super_class: decl.super_class&.yield_self do |super_class| + new_name = decl.name.with_prefix(prefix) + new_type_params = resolve_type_params(resolver, map, decl.type_params, context: inner_context) + new_super_class = decl.super_class&.then do |super_class| + new_super_name = absolute_type_name(resolver, map, super_class.name, context: outer_context) + new_super_args = RBS.map_if_changed(super_class.args) {|type| absolute_type(resolver, map, type, context: outer_context) } + if new_super_name.equal?(super_class.name) && new_super_args.equal?(super_class.args) + super_class + else AST::Declarations::Class::Super.new( - name: absolute_type_name(resolver, map, super_class.name, context: outer_context), - args: super_class.args.map {|type| absolute_type(resolver, map, type, context: outer_context) }, + name: new_super_name, + args: new_super_args, location: super_class.location ) - end, - members: decl.members.map do |member| - case member - when AST::Members::Base - resolve_member(resolver, map, member, context: inner_context) - when AST::Declarations::Base - resolve_declaration( - resolver, - map, - member, - context: inner_context, - prefix: prefix_ - ) - else - raise - end - end, + end + end + new_members = RBS.map_if_changed(decl.members) do |member| + case member + when AST::Members::Base + resolve_member(resolver, map, member, context: inner_context) + when AST::Declarations::Base + resolve_declaration(resolver, map, member, context: inner_context, prefix: prefix_) + else + raise + end + end + if new_name.equal?(decl.name) && + new_type_params.equal?(decl.type_params) && + new_super_class.equal?(decl.super_class) && + new_members.equal?(decl.members) + return decl + end + AST::Declarations::Class.new( + name: new_name, + type_params: new_type_params, + super_class: new_super_class, + members: new_members, location: decl.location, annotations: decl.annotations, comment: decl.comment @@ -628,81 +640,122 @@ def resolve_declaration(resolver, map, decl, context:, prefix:) inner_context = append_context(outer_context, decl) prefix_ = prefix + decl.name.to_namespace - AST::Declarations::Module.new( - name: decl.name.with_prefix(prefix), - type_params: resolve_type_params(resolver, map, decl.type_params, context: inner_context), - self_types: decl.self_types.map do |module_self| + new_name = decl.name.with_prefix(prefix) + new_type_params = resolve_type_params(resolver, map, decl.type_params, context: inner_context) + new_self_types = RBS.map_if_changed(decl.self_types) do |module_self| + new_self_name = absolute_type_name(resolver, map, module_self.name, context: inner_context) + new_self_args = RBS.map_if_changed(module_self.args) {|type| absolute_type(resolver, map, type, context: inner_context) } + if new_self_name.equal?(module_self.name) && new_self_args.equal?(module_self.args) + module_self + else AST::Declarations::Module::Self.new( - name: absolute_type_name(resolver, map, module_self.name, context: inner_context), - args: module_self.args.map {|type| absolute_type(resolver, map, type, context: inner_context) }, + name: new_self_name, + args: new_self_args, location: module_self.location ) - end, - members: decl.members.map do |member| - case member - when AST::Members::Base - resolve_member(resolver, map, member, context: inner_context) - when AST::Declarations::Base - resolve_declaration( - resolver, - map, - member, - context: inner_context, - prefix: prefix_ - ) - else - raise - end - end, + end + end + new_members = RBS.map_if_changed(decl.members) do |member| + case member + when AST::Members::Base + resolve_member(resolver, map, member, context: inner_context) + when AST::Declarations::Base + resolve_declaration(resolver, map, member, context: inner_context, prefix: prefix_) + else + raise + end + end + if new_name.equal?(decl.name) && + new_type_params.equal?(decl.type_params) && + new_self_types.equal?(decl.self_types) && + new_members.equal?(decl.members) + return decl + end + AST::Declarations::Module.new( + name: new_name, + type_params: new_type_params, + self_types: new_self_types, + members: new_members, location: decl.location, annotations: decl.annotations, comment: decl.comment ) when AST::Declarations::Interface + new_name = decl.name.with_prefix(prefix) + new_type_params = resolve_type_params(resolver, map, decl.type_params, context: context) + new_members = RBS.map_if_changed(decl.members) do |member| + resolve_member(resolver, map, member, context: context) + end + if new_name.equal?(decl.name) && + new_type_params.equal?(decl.type_params) && + new_members.equal?(decl.members) + return decl + end AST::Declarations::Interface.new( - name: decl.name.with_prefix(prefix), - type_params: resolve_type_params(resolver, map, decl.type_params, context: context), - members: decl.members.map do |member| - resolve_member(resolver, map, member, context: context) - end, + name: new_name, + type_params: new_type_params, + members: new_members, comment: decl.comment, location: decl.location, annotations: decl.annotations ) when AST::Declarations::TypeAlias + new_name = decl.name.with_prefix(prefix) + new_type_params = resolve_type_params(resolver, map, decl.type_params, context: context) + new_type = absolute_type(resolver, map, decl.type, context: context) + if new_name.equal?(decl.name) && + new_type_params.equal?(decl.type_params) && + new_type.equal?(decl.type) + return decl + end AST::Declarations::TypeAlias.new( - name: decl.name.with_prefix(prefix), - type_params: resolve_type_params(resolver, map, decl.type_params, context: context), - type: absolute_type(resolver, map, decl.type, context: context), + name: new_name, + type_params: new_type_params, + type: new_type, location: decl.location, annotations: decl.annotations, comment: decl.comment ) when AST::Declarations::Constant + new_name = decl.name.with_prefix(prefix) + new_type = absolute_type(resolver, map, decl.type, context: context) + if new_name.equal?(decl.name) && new_type.equal?(decl.type) + return decl + end AST::Declarations::Constant.new( - name: decl.name.with_prefix(prefix), - type: absolute_type(resolver, map, decl.type, context: context), + name: new_name, + type: new_type, location: decl.location, comment: decl.comment, annotations: decl.annotations ) when AST::Declarations::ClassAlias + new_name = decl.new_name.with_prefix(prefix) + new_old_name = absolute_type_name(resolver, map, decl.old_name, context: context) + if new_name.equal?(decl.new_name) && new_old_name.equal?(decl.old_name) + return decl + end AST::Declarations::ClassAlias.new( - new_name: decl.new_name.with_prefix(prefix), - old_name: absolute_type_name(resolver, map, decl.old_name, context: context), + new_name: new_name, + old_name: new_old_name, location: decl.location, comment: decl.comment, annotations: decl.annotations ) when AST::Declarations::ModuleAlias + new_name = decl.new_name.with_prefix(prefix) + new_old_name = absolute_type_name(resolver, map, decl.old_name, context: context) + if new_name.equal?(decl.new_name) && new_old_name.equal?(decl.old_name) + return decl + end AST::Declarations::ModuleAlias.new( - new_name: decl.new_name.with_prefix(prefix), - old_name: absolute_type_name(resolver, map, decl.old_name, context: context), + new_name: new_name, + old_name: new_old_name, location: decl.location, comment: decl.comment, annotations: decl.annotations @@ -869,14 +922,19 @@ def resolve_ruby_member(resolver, member, context:) def resolve_member(resolver, map, member, context:) case member when AST::Members::MethodDefinition + new_overloads = RBS.map_if_changed(member.overloads) do |overload| + new_method_type = resolve_method_type(resolver, map, overload.method_type, context: context) + if new_method_type.equal?(overload.method_type) + overload + else + overload.update(method_type: new_method_type) + end + end + return member if new_overloads.equal?(member.overloads) AST::Members::MethodDefinition.new( name: member.name, kind: member.kind, - overloads: member.overloads.map do |overload| - overload.update( - method_type: resolve_method_type(resolver, map, overload.method_type, context: context) - ) - end, + overloads: new_overloads, comment: member.comment, overloading: member.overloading?, annotations: member.annotations, @@ -884,9 +942,11 @@ def resolve_member(resolver, map, member, context:) visibility: member.visibility ) when AST::Members::AttrAccessor + new_type = absolute_type(resolver, map, member.type, context: context) + return member if new_type.equal?(member.type) AST::Members::AttrAccessor.new( name: member.name, - type: absolute_type(resolver, map, member.type, context: context), + type: new_type, kind: member.kind, annotations: member.annotations, comment: member.comment, @@ -895,9 +955,11 @@ def resolve_member(resolver, map, member, context:) visibility: member.visibility ) when AST::Members::AttrReader + new_type = absolute_type(resolver, map, member.type, context: context) + return member if new_type.equal?(member.type) AST::Members::AttrReader.new( name: member.name, - type: absolute_type(resolver, map, member.type, context: context), + type: new_type, kind: member.kind, annotations: member.annotations, comment: member.comment, @@ -906,9 +968,11 @@ def resolve_member(resolver, map, member, context:) visibility: member.visibility ) when AST::Members::AttrWriter + new_type = absolute_type(resolver, map, member.type, context: context) + return member if new_type.equal?(member.type) AST::Members::AttrWriter.new( name: member.name, - type: absolute_type(resolver, map, member.type, context: context), + type: new_type, kind: member.kind, annotations: member.annotations, comment: member.comment, @@ -917,46 +981,61 @@ def resolve_member(resolver, map, member, context:) visibility: member.visibility ) when AST::Members::InstanceVariable + new_type = absolute_type(resolver, map, member.type, context: context) + return member if new_type.equal?(member.type) AST::Members::InstanceVariable.new( name: member.name, - type: absolute_type(resolver, map, member.type, context: context), + type: new_type, comment: member.comment, location: member.location ) when AST::Members::ClassInstanceVariable + new_type = absolute_type(resolver, map, member.type, context: context) + return member if new_type.equal?(member.type) AST::Members::ClassInstanceVariable.new( name: member.name, - type: absolute_type(resolver, map, member.type, context: context), + type: new_type, comment: member.comment, location: member.location ) when AST::Members::ClassVariable + new_type = absolute_type(resolver, map, member.type, context: context) + return member if new_type.equal?(member.type) AST::Members::ClassVariable.new( name: member.name, - type: absolute_type(resolver, map, member.type, context: context), + type: new_type, comment: member.comment, location: member.location ) when AST::Members::Include + new_name = absolute_type_name(resolver, map, member.name, context: context) + new_args = RBS.map_if_changed(member.args) {|type| absolute_type(resolver, map, type, context: context) } + return member if new_name.equal?(member.name) && new_args.equal?(member.args) AST::Members::Include.new( - name: absolute_type_name(resolver, map, member.name, context: context), - args: member.args.map {|type| absolute_type(resolver, map, type, context: context) }, + name: new_name, + args: new_args, comment: member.comment, location: member.location, annotations: member.annotations ) when AST::Members::Extend + new_name = absolute_type_name(resolver, map, member.name, context: context) + new_args = RBS.map_if_changed(member.args) {|type| absolute_type(resolver, map, type, context: context) } + return member if new_name.equal?(member.name) && new_args.equal?(member.args) AST::Members::Extend.new( - name: absolute_type_name(resolver, map, member.name, context: context), - args: member.args.map {|type| absolute_type(resolver, map, type, context: context) }, + name: new_name, + args: new_args, comment: member.comment, location: member.location, annotations: member.annotations ) when AST::Members::Prepend + new_name = absolute_type_name(resolver, map, member.name, context: context) + new_args = RBS.map_if_changed(member.args) {|type| absolute_type(resolver, map, type, context: context) } + return member if new_name.equal?(member.name) && new_args.equal?(member.args) AST::Members::Prepend.new( - name: absolute_type_name(resolver, map, member.name, context: context), - args: member.args.map {|type| absolute_type(resolver, map, type, context: context) }, + name: new_name, + args: new_args, comment: member.comment, location: member.location, annotations: member.annotations @@ -975,7 +1054,7 @@ def resolve_method_type(resolver, map, type, context:) end def resolve_type_params(resolver, map, params, context:) - params.map do |param| + RBS.map_if_changed(params) do |param| param.map_type {|type| _ = absolute_type(resolver, map, type, context: context) } end end diff --git a/lib/rbs/method_type.rb b/lib/rbs/method_type.rb index cc676fbd6c..7c7ffb603f 100644 --- a/lib/rbs/method_type.rb +++ b/lib/rbs/method_type.rb @@ -63,24 +63,25 @@ def free_variables(set = Set.new) end def map_type(&block) + new_type = type.map_type(&block) + new_block = self.block&.map_type(&block) + if new_type.equal?(type) && new_block.equal?(self.block) + return self + end self.class.new( type_params: type_params, - type: type.map_type(&block), - block: self.block&.map_type(&block), + type: new_type, + block: new_block, location: location ) end def map_type_bound(&block) - if type_params.empty? - self - else - self.update( - type_params: type_params.map {|param| - param.map_type(&block) - } - ) - end + return self if type_params.empty? + + new_type_params = RBS.map_if_changed(type_params) {|param| param.map_type(&block) } + return self if new_type_params.equal?(type_params) + self.update(type_params: new_type_params) end def each_type(&block) diff --git a/lib/rbs/types.rb b/lib/rbs/types.rb index 9c95949bd7..8d01fc900a 100644 --- a/lib/rbs/types.rb +++ b/lib/rbs/types.rb @@ -299,20 +299,17 @@ def to_s(level = 0) end def map_type_name(&block) - ClassSingleton.new( - name: yield(name, location, self), - args: args.map {|type| type.map_type_name(&block) }, - location: location - ) + new_name = yield(name, location, self) + new_args = RBS.map_if_changed(args) {|type| type.map_type_name(&block) } + return self if new_name.equal?(name) && new_args.equal?(args) + ClassSingleton.new(name: new_name, args: new_args, location: location) end def map_type(&block) if block - ClassSingleton.new( - name: name, - args: args.map {|type| yield type }, - location: location - ) + new_args = RBS.map_if_changed(args) {|type| yield type } + return self if new_args.equal?(args) + ClassSingleton.new(name: name, args: new_args, location: location) else enum_for :map_type end @@ -343,20 +340,17 @@ def sub(s) end def map_type_name(&block) - Interface.new( - name: yield(name, location, self), - args: args.map {|type| type.map_type_name(&block) }, - location: location - ) + new_name = yield(name, location, self) + new_args = RBS.map_if_changed(args) {|type| type.map_type_name(&block) } + return self if new_name.equal?(name) && new_args.equal?(args) + Interface.new(name: new_name, args: new_args, location: location) end def map_type(&block) if block - Interface.new( - name: name, - args: args.map {|type| yield type }, - location: location - ) + new_args = RBS.map_if_changed(args) {|type| yield type } + return self if new_args.equal?(args) + Interface.new(name: name, args: new_args, location: location) else enum_for(:map_type) end @@ -387,20 +381,17 @@ def sub(s) end def map_type_name(&block) - ClassInstance.new( - name: yield(name, location, self), - args: args.map {|type| type.map_type_name(&block) }, - location: location - ) + new_name = yield(name, location, self) + new_args = RBS.map_if_changed(args) {|type| type.map_type_name(&block) } + return self if new_name.equal?(name) && new_args.equal?(args) + ClassInstance.new(name: new_name, args: new_args, location: location) end def map_type(&block) if block - ClassInstance.new( - name: name, - args: args.map {|type| yield type }, - location: location - ) + new_args = RBS.map_if_changed(args) {|type| yield type } + return self if new_args.equal?(args) + ClassInstance.new(name: name, args: new_args, location: location) else enum_for :map_type end @@ -429,20 +420,17 @@ def sub(s) end def map_type_name(&block) - Alias.new( - name: yield(name, location, self), - args: args.map {|arg| arg.map_type_name(&block) }, - location: location - ) + new_name = yield(name, location, self) + new_args = RBS.map_if_changed(args) {|type| type.map_type_name(&block) } + return self if new_name.equal?(name) && new_args.equal?(args) + Alias.new(name: new_name, args: new_args, location: location) end def map_type(&block) if block - Alias.new( - name: name, - args: args.map {|type| yield type }, - location: location - ) + new_args = RBS.map_if_changed(args) {|type| yield type } + return self if new_args.equal?(args) + Alias.new(name: name, args: new_args, location: location) else enum_for :map_type end @@ -504,18 +492,16 @@ def each_type(&block) end def map_type_name(&block) - Tuple.new( - types: types.map {|type| type.map_type_name(&block) }, - location: location - ) + new_types = RBS.map_if_changed(types) {|type| type.map_type_name(&block) } + return self if new_types.equal?(types) + Tuple.new(types: new_types, location: location) end def map_type(&block) if block - Tuple.new( - types: types.map {|type| yield type }, - location: location - ) + new_types = RBS.map_if_changed(types) {|type| yield type } + return self if new_types.equal?(types) + Tuple.new(types: new_types, location: location) else enum_for :map_type end @@ -622,18 +608,26 @@ def each_type(&block) end def map_type_name(&block) - Record.new( - all_fields: all_fields.transform_values {|ty, required| [ty.map_type_name(&block), required] }, - location: location - ) + changed = false + new_all_fields = all_fields.transform_values do |ty, required| + new_ty = ty.map_type_name(&block) + changed ||= !new_ty.equal?(ty) + [new_ty, required] + end #: Hash[key, [t, bool]] + return self unless changed + Record.new(all_fields: new_all_fields, location: location) end def map_type(&block) if block - Record.new( - all_fields: all_fields.transform_values {|type, required| [yield(type), required] }, - location: location - ) + changed = false + new_all_fields = all_fields.transform_values do |type, required| + new_type = yield(type) + changed ||= !new_type.equal?(type) + [new_type, required] + end #: Hash[key, [t, bool]] + return self unless changed + Record.new(all_fields: new_all_fields, location: location) else enum_for :map_type end @@ -708,18 +702,16 @@ def each_type end def map_type_name(&block) - Optional.new( - type: type.map_type_name(&block), - location: location - ) + new_type = type.map_type_name(&block) + return self if new_type.equal?(type) + Optional.new(type: new_type, location: location) end def map_type(&block) if block - Optional.new( - type: yield(type), - location: location - ) + new_type = yield(type) + return self if new_type.equal?(type) + Optional.new(type: new_type, location: location) else enum_for :map_type end @@ -803,17 +795,18 @@ def each_type(&block) def map_type(&block) if block - Union.new(types: types.map(&block), location: location) + new_types = RBS.map_if_changed(types, &block) + return self if new_types.equal?(types) + Union.new(types: new_types, location: location) else enum_for :map_type end end def map_type_name(&block) - Union.new( - types: types.map {|type| type.map_type_name(&block) }, - location: location - ) + new_types = RBS.map_if_changed(types) {|type| type.map_type_name(&block) } + return self if new_types.equal?(types) + Union.new(types: new_types, location: location) end def has_self_type? @@ -886,17 +879,18 @@ def each_type(&block) def map_type(&block) if block - Intersection.new(types: types.map(&block), location: location) + new_types = RBS.map_if_changed(types, &block) + return self if new_types.equal?(types) + Intersection.new(types: new_types, location: location) else enum_for :map_type end end def map_type_name(&block) - Intersection.new( - types: types.map {|type| type.map_type_name(&block) }, - location: location - ) + new_types = RBS.map_if_changed(types) {|type| type.map_type_name(&block) } + return self if new_types.equal?(types) + Intersection.new(types: new_types, location: location) end def has_self_type? @@ -936,7 +930,9 @@ def hash def map_type(&block) if block - Param.new(name: name, type: yield(type), location: location) + new_type = yield(type) + return self if new_type.equal?(type) + Param.new(name: name, type: new_type, location: location) else enum_for :map_type end @@ -1035,37 +1031,41 @@ def free_variables(set = Set.new) def map_type(&block) if block + new_required_positionals = RBS.map_if_changed(required_positionals) {|param| param.map_type(&block) } + new_optional_positionals = RBS.map_if_changed(optional_positionals) {|param| param.map_type(&block) } + new_rest_positionals = rest_positionals&.map_type(&block) + new_trailing_positionals = RBS.map_if_changed(trailing_positionals) {|param| param.map_type(&block) } + new_required_keywords = RBS.transform_values_if_changed(required_keywords) {|param| param.map_type(&block) } + new_optional_keywords = RBS.transform_values_if_changed(optional_keywords) {|param| param.map_type(&block) } + new_rest_keywords = rest_keywords&.map_type(&block) + new_return_type = yield(return_type) + + if new_required_positionals.equal?(required_positionals) && + new_optional_positionals.equal?(optional_positionals) && + new_rest_positionals.equal?(rest_positionals) && + new_trailing_positionals.equal?(trailing_positionals) && + new_required_keywords.equal?(required_keywords) && + new_optional_keywords.equal?(optional_keywords) && + new_rest_keywords.equal?(rest_keywords) && + new_return_type.equal?(return_type) + return self + end + Function.new( - required_positionals: amap(required_positionals) {|param| param.map_type(&block) }, - optional_positionals: amap(optional_positionals) {|param| param.map_type(&block) }, - rest_positionals: rest_positionals&.yield_self {|param| param.map_type(&block) }, - trailing_positionals: amap(trailing_positionals) {|param| param.map_type(&block) }, - required_keywords: hmapv(required_keywords) {|param| param.map_type(&block) }, - optional_keywords: hmapv(optional_keywords) {|param| param.map_type(&block) }, - rest_keywords: rest_keywords&.yield_self {|param| param.map_type(&block) }, - return_type: yield(return_type) + required_positionals: new_required_positionals, + optional_positionals: new_optional_positionals, + rest_positionals: new_rest_positionals, + trailing_positionals: new_trailing_positionals, + required_keywords: new_required_keywords, + optional_keywords: new_optional_keywords, + rest_keywords: new_rest_keywords, + return_type: new_return_type ) else enum_for :map_type end end - def amap(array, &block) - if array.empty? - _ = array - else - array.map(&block) - end - end - - def hmapv(hash, &block) - if hash.empty? - _ = hash - else - hash.transform_values(&block) - end - end - def map_type_name(&block) map_type do |type| type.map_type_name(&block) @@ -1261,16 +1261,18 @@ def free_variables(acc = Set.new) def map_type(&block) if block - update(return_type: yield(return_type)) + new_return_type = yield(return_type) + return self if new_return_type.equal?(return_type) + update(return_type: new_return_type) else enum_for :map_type end end def map_type_name(&block) - UntypedFunction.new( - return_type: return_type.map_type_name(&block) - ) + new_return_type = return_type.map_type_name(&block) + return self if new_return_type.equal?(return_type) + UntypedFunction.new(return_type: new_return_type) end def each_type(&block) @@ -1384,10 +1386,15 @@ def sub(s) end def map_type(&block) + new_type = type.map_type(&block) + new_self_type = self_type ? yield(self_type) : nil + if new_type.equal?(type) && new_self_type.equal?(self_type) + return self + end Block.new( required: required, - type: type.map_type(&block), - self_type: self_type ? yield(self_type) : nil + type: new_type, + self_type: new_self_type ) end end @@ -1485,22 +1492,24 @@ def each_type(&block) end def map_type_name(&block) - Proc.new( - type: type.map_type_name(&block), - block: self.block&.map_type {|type| type.map_type_name(&block) }, - self_type: self_type&.map_type_name(&block), - location: location - ) + new_type = type.map_type_name(&block) + new_block = self.block&.map_type {|type| type.map_type_name(&block) } + new_self_type = self_type&.map_type_name(&block) + if new_type.equal?(type) && new_block.equal?(self.block) && new_self_type.equal?(self_type) + return self + end + Proc.new(type: new_type, block: new_block, self_type: new_self_type, location: location) end def map_type(&block) if block - Proc.new( - type: type.map_type(&block), - block: self.block&.map_type(&block), - self_type: self_type ? yield(self_type) : nil, - location: location - ) + new_type = type.map_type(&block) + new_block = self.block&.map_type(&block) + new_self_type = self_type ? yield(self_type) : nil + if new_type.equal?(type) && new_block.equal?(self.block) && new_self_type.equal?(self_type) + return self + end + Proc.new(type: new_type, block: new_block, self_type: new_self_type, location: location) else enum_for :map_type end diff --git a/sig/rbs.rbs b/sig/rbs.rbs index 2b544de827..2a4bb15a43 100644 --- a/sig/rbs.rbs +++ b/sig/rbs.rbs @@ -11,6 +11,10 @@ module RBS def self.print_warning: () { () -> String } -> void + def self.map_if_changed: [T < Object] (Array[T]) { (T) -> T } -> Array[T] + + def self.transform_values_if_changed: [K, V < Object] (Hash[K, V]) { (V) -> V } -> Hash[K, V] + self.@logger: Logger? self.@logger_output: IO? diff --git a/sig/types.rbs b/sig/types.rbs index 2c806de7aa..b7e256bdf7 100644 --- a/sig/types.rbs +++ b/sig/types.rbs @@ -443,10 +443,6 @@ module RBS def has_keyword?: () -> bool - def amap: [A, B] (Array[A]) { (A) -> B } -> Array[B] - - def hmapv: [X, Y, Z] (Hash[X, Y]) { (Y) -> Z } -> Hash[X, Z] - def has_self_type?: () -> bool def has_classish_type?: () -> bool From d11c02a76a96da973ab70196726cc90986ac5c91 Mon Sep 17 00:00:00 2001 From: Takeshi KOMIYA Date: Wed, 27 May 2026 02:44:22 +0900 Subject: [PATCH 2/2] Add resolve_rails_env benchmarks Add benchmark_resolve_rails_env.rb (benchmark/ips) and memory_resolve_rails_env.rb (memory_profiler) measuring the edit-cycle re-resolve that long-running clients such as Steep hit: unload one source, add it back, then resolve_type_names. This is the path that benefits from reusing declarations whose type names did not change. The naming follows the existing benchmarks: where *_new_rails_env.rb measures building a Rails environment (Environment.new + first resolve), these measure re-resolving an already-built Rails environment. Both use the existing prepare_collection! / new_rails_env helpers, so they need no fixture beyond `gem 'rails'`. Also require 'pathname' in benchmark/utils.rb: requiring "rbs" only loads the builtin pathname, which lacks Pathname#rmtree on Ruby 4.0, so prepare_collection!'s at_exit cleanup raised NoMethodError. Requiring pathname explicitly loads the full implementation. Co-Authored-By: Claude Opus 4.8 (1M context) --- benchmark/benchmark_resolve_rails_env.rb | 25 ++++++++++++++++++++++++ benchmark/memory_resolve_rails_env.rb | 23 ++++++++++++++++++++++ benchmark/utils.rb | 1 + 3 files changed, 49 insertions(+) create mode 100644 benchmark/benchmark_resolve_rails_env.rb create mode 100644 benchmark/memory_resolve_rails_env.rb diff --git a/benchmark/benchmark_resolve_rails_env.rb b/benchmark/benchmark_resolve_rails_env.rb new file mode 100644 index 0000000000..42c6ae0fba --- /dev/null +++ b/benchmark/benchmark_resolve_rails_env.rb @@ -0,0 +1,25 @@ +require_relative './utils' + +require 'benchmark/ips' + +# `resolve_type_names` absolutizes every type name in the environment. The +# first resolve must do that work, but long-running clients such as Steep +# re-resolve on every edit: they unload the edited source, re-add it, and +# resolve again. This benchmark reproduces that edit cycle -- unload one +# source, add it back, then resolve -- which is the path that benefits from +# reusing declarations whose type names did not change. + +tmpdir = prepare_collection! + +base_env = new_rails_env(tmpdir) +sample_source = base_env.each_rbs_source.first or raise + +Benchmark.ips do |x| + x.time = 10 + + x.report("resolve_type_names") do + env = base_env.unload([sample_source.buffer]) + env.add_source(sample_source) + env.resolve_type_names + end +end diff --git a/benchmark/memory_resolve_rails_env.rb b/benchmark/memory_resolve_rails_env.rb new file mode 100644 index 0000000000..e058b9217f --- /dev/null +++ b/benchmark/memory_resolve_rails_env.rb @@ -0,0 +1,23 @@ +require_relative './utils' + +require 'memory_profiler' + +# See benchmark_resolve_type_names.rb for the scenario. Here we profile the +# allocations of a single edit-cycle resolve: one source is unloaded and added +# back (not profiled), then `resolve_type_names` is profiled on its own. + +tmpdir = prepare_collection! + +base_env = new_rails_env(tmpdir) +sample_source = base_env.each_rbs_source.first or raise + +env = base_env.unload([sample_source.buffer]) +env.add_source(sample_source) + +_ = resolved = nil + +r = MemoryProfiler.report do + resolved = env.resolve_type_names +end + +r.pretty_print diff --git a/benchmark/utils.rb b/benchmark/utils.rb index d96044281a..5b5f890e3a 100644 --- a/benchmark/utils.rb +++ b/benchmark/utils.rb @@ -1,5 +1,6 @@ require "rbs" require 'tmpdir' +require 'pathname' def prepare_collection! tmpdir = Pathname(Dir.mktmpdir)