Skip to content

Add T::Utils.drop_unchecked_sigs! - #10572

Open
paracycle wants to merge 1 commit into
sorbet:masterfrom
Shopify:uk-drop-unchecked-sigs
Open

paracycle wants to merge 1 commit into
sorbet:masterfrom
Shopify:uk-drop-unchecked-sigs

Conversation

@paracycle

@paracycle paracycle commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

A sig whose runtime checks never run still costs memory. sorbet-runtime keeps every Signature in @signatures_by_method so that tools can introspect the method. These are the .checked(:never) sigs, plus the .checked(:tests) sigs outside a test environment. Such methods keep no wrapper at all, so nothing but introspection reads those objects.

This PR adds the following public method for dropping those sigs that are unchecked:

T::Utils.run_all_sig_blocks    # eagerly run sig blocks
T::Utils.drop_unchecked_sigs!  # free them from the registry

This call frees the signatures sorbet-runtime holds already, and keeps no state at all. It touches neither the wrapping nor the dispatch path, so signatures built after the call are recorded as usual: any sig block that runs later records the signature it derives back in the lookup table. Calling drop_unchecked_sigs! a second time frees those too.

The drop_unchecked_sigs! clears signatures from the signature look up table rather than preventing them from being inserted in the first place. This is because run_sig_block_for_key needs to read the registry to serve a thread that lost the race to run a sig block. Skipping inserting the signature into the lookup table at the source would have turned that benign case into a raise, including inside run_all_sig_blocks itself.

Since the method keeps no state, it is on T::Utils, next to run_all_sig_blocks, run_all_type_alias_blocks and eagerly_define_all_lazy_props_methods!. It is a one shot action at preload time, and not a configuration. There is also no way to undo it, because nothing can rebuild a freed signature.

The drawbacks of calling drop_unchecked_sigs! is documented in website/docs/load-time-tuning.md as the following:

  • T::Utils.signature_for_method will return nil for those methods.
  • Final method violations will lose the Made final here: source location.
  • A sig block that runs after the call cannot see a dropped parent signature, so sorbet-runtime skips the override checks against it. Call it after the application has loaded all of its code, like T::Utils.run_all_sig_blocks.

Signatures on abstract methods are never dropped.

Motivation

At Shopify we run all sig blocks after boot with checked level never. That unwraps every method eagerly, and the registry, and its associated Signature objects, just become dead weight of about 70 megabytes.

The alias path was the blocker for simply clearing the registry: an alias shares the first-call wrapper of the original and re-recorded its own signature on its first call. #10397 has already removed the registry from the alias dispatch path, so only the bookkeeping remained, and #10502 has made further progress on that front, so that this can become a method that needs no persistent state.

All we need at this point, is a safe way to clear the registry.

Test plan

See the included automated tests.

@paracycle
paracycle requested a review from a team as a code owner August 12, 2026 17:50
@paracycle
paracycle requested review from froydnj and jez and removed request for a team and froydnj August 12, 2026 17:50
@paracycle
paracycle force-pushed the uk-drop-unchecked-sigs branch from 512eba5 to 47ddd7e Compare August 13, 2026 21:58
Comment thread website/docs/load-time-tuning.md Outdated
@paracycle
paracycle force-pushed the uk-drop-unchecked-sigs branch from 47ddd7e to f47c3e8 Compare August 27, 2026 21:37
@paracycle paracycle changed the title Add T::Configuration.drop_unchecked_sigs! Add T::Utils.drop_unchecked_sigs! Aug 27, 2026
A `sig` whose runtime checks never run still costs memory: sorbet-runtime keeps
its `Signature` in `@signatures_by_method` so that tools can introspect the
method. These are the `.checked(:never)` sigs, plus the `.checked(:tests)` sigs
outside a test environment. Those methods carry no wrapper at all, so nothing
but introspection reads those objects.

The new method frees them:

    T::Utils.drop_unchecked_sigs!

It sweeps the registry and keeps no state: no flag, and no change to the
wrapping or dispatch paths. Later work records signatures again, both code that
loads after the call and the first call to a method reached through an alias, so
call the method again to free those too.

It sweeps rather than skipping the record at the source, because
`run_sig_block_for_key` reads the registry to serve a thread that lost the race
to run a `sig` block, including inside `run_all_sig_blocks`.

It lives on `T::Utils`, beside `run_all_sig_blocks`, `run_all_type_alias_blocks`
and `eagerly_define_all_lazy_props_methods!`, because it is a one-shot action at
preload time rather than configuration. Nothing can rebuild a freed signature,
so there is no way to undo the call.

The call trades introspection for memory:

- `T::Utils.signature_for_method` returns `nil` for those methods.
- `all_checked_tests_sigs` no longer lists them.
- Final method violations lose the "Made final here:" source location.
- A `sig` block that runs after the call cannot see a dropped parent signature,
  so sorbet-runtime skips the override checks against that signature. Call the
  method after the application loads all of its code, in the same way as
  `T::Utils.run_all_sig_blocks`.

Signatures on `abstract` methods are never dropped. `Method#arity`,
`Method#source_location` and `Method#parameters` report the same values, because
those methods stay unwrapped.

For 10 000 methods with `.checked(:never)` sigs, the call frees about 7 MiB,
near 730 bytes per signature.
@paracycle
paracycle force-pushed the uk-drop-unchecked-sigs branch from f47c3e8 to bac9d73 Compare August 27, 2026 21:55

@jez jez left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This mostly looks great to me, but I have some wording requests for the docs (thanks for updating the docs in the first place).

end
end

unchecked = Class.new do

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't seem like we need Class.new for these tests—the class could be declared like normal.

Comment on lines +71 to +80
This frees the signatures that `sorbet-runtime` holds already. There is no way back: nothing can rebuild a freed signature, because its `sig` block has already run. Signatures built after the call are recorded as usual: a `sig` block that runs later, either on the first call to its method or through `T::Utils.run_all_sig_blocks`, and the first call to a method through an alias. Call the method again to free those too:

```ruby
T::Utils.drop_unchecked_sigs!

# … the application autoloads more code …

T::Utils.run_all_sig_blocks # evaluates the new sig blocks
T::Utils.drop_unchecked_sigs! # frees the ones that never check anything
```

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "call the method again" has me a little confused: when is the recommended time to call this method?

Most of the other methods, like run_all_sig_blocks, are meant to run after all autoloading has finished (e.g. eager loading). Why is the recommendation for this method different, such that you would want to call it both before and after eager loading?

Comment on lines +82 to +85
Runtime dispatch never uses these signatures, so calls behave the same. Methods with such sigs keep no wrapper at all, so `Method#arity`, `Method#source_location` and `Method#parameters` report the same values as before. The option only trades away introspection:

- `T::Utils.signature_for_method` returns `nil` for those methods.
- Final method violations lose the `Made final here:` source location.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we want to rewrite this a little, to make this warning more clear. Specifically, I think we want to call attention that the result of signature_for_method will be different in production before and after calling this method. This method makes it so that code cannot rely on signature_for_method producing a signature for all sig'd methods, only those whose signatures are checked.

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.

2 participants