Skip to content

Reduce copies of S7 class objects - #743

Open
hadley wants to merge 5 commits into
mainfrom
issue-742-shared-class-reference
Open

Reduce copies of S7 class objects#743
hadley wants to merge 5 commits into
mainfrom
issue-742-shared-class-reference

Conversation

@hadley

@hadley hadley commented Jul 29, 2026

Copy link
Copy Markdown
Member

Fixes #742.

This prototype stores an environment-backed class reference on ordinary S7 instances instead of the class closure itself. This avoids the deep copy from sys.function() and ensures saveRDS() saves a single copy of the object.

The tests use a small internal C obj_addr() helper to assert reference identity directly.

@hadley

This comment was marked as outdated.

@hadley

hadley commented Jul 30, 2026

Copy link
Copy Markdown
Member Author

I benchmarked this locally against main (R 4.5.3, aarch64 macOS; bench 1.1.4, lobstr 1.2.1). I ran bench/constructor.R twice in alternating order and averaged the two reported medians. I also corrected the memory case locally so it creates one class and passes two instances of that class to lobstr::obj_sizes(); previously it measured two separately created class definitions.

Marginal retained memory per instance:

hierarchy depth main PR reduction
1 3,048 B 168 B 94.5%
5 7,208 B 168 B 97.7%
10 12,720 B 168 B 98.7%
20 24,120 B 168 B 99.3%

So the PR achieves the intended result: per-instance class storage becomes a flat 168 B rather than growing with the class hierarchy.

Construction timings (average of the two median runs):

case main PR change
depth1 24.6 µs 27.0 µs +10%
depth5 119.7 µs 135.1 µs +13%
depth10 238.2 µs 283.1 µs +19%
oneshot5 31.9 µs 36.6 µs +15%
wide0 23.7 µs 26.7 µs +13%
wide2 40.7 µs 43.0 µs +6%
wide10 102.2 µs 102.2 µs 0%
wide50 413.4 µs 402.6 µs -3%
setter 41.9 µs 44.4 µs +6%
custom 33.1 µs 35.5 µs +7%

validate5 was effectively unchanged (+1%). The timing cost is most visible for lightweight/deep constructors, where the extra class-reference lookup is a larger share of total work; it disappears as property work dominates.

@hadley

hadley commented Jul 30, 2026

Copy link
Copy Markdown
Member Author

Follow-up class-operation benchmarks, now in a separate classes benchmark section (depth-5 class; same local setup as above):

case main PR change
retrieve with S7_class() 1.4 µs 1.6 µs +14%
identical() on classes from two instances 17.5 µs 0.5 µs -97%
class_extends() on classes from two instances 16.5 µs 0.7 µs -96%

This confirms the expected downstream benefit of shared class identity: operations whose first step is an identity comparison become roughly 24–35× faster. Direct class retrieval is slightly slower because the C accessor must dereference the environment-backed class reference.

@hadley

hadley commented Jul 30, 2026

Copy link
Copy Markdown
Member Author

I also prototyped having S7-generated constructors pass the class reference explicitly to new_object(), bypassing the dynamic get0() lookup. I ran three alternating baseline/prototype sessions and averaged the reported medians:

case dynamic lookup explicit reference change
depth 1 27.3 µs 26.7 µs -2.2%
depth 5 138.1 µs 134.7 µs -2.4%
depth 10 286.0 µs 280.1 µs -2.1%
one-shot depth 5 36.3 µs 35.4 µs -2.6%
custom 36.0 µs 35.9 µs -0.3%

So explicit passing saves about 0.6 µs per new_object() call, consistent with the isolated lookup benchmark, but it does not materially move whole-constructor performance (~2%, below our 10% threshold). I reverted the prototype rather than complicating generated calls and the new_object() interface.

Comment thread R/class.R
# the reference instead of the closure, avoiding `sys.function()` and ensuring
# that objects serialized together share a single copy of their class.
new_class_ref <- function() {
ref <- new.env(parent = emptyenv())

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

My guess is that if we set hash = FALSE here we'll see slightly better performance. (probably above, in the constructor_env, too)

x <- Foo()
y <- Foo()

x_ref <- attr(x, "_S7_class", exact = TRUE)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Do we have a principled policy for when to use the underscore prefix and when to use the dot prefix?

@hadley

hadley commented Jul 30, 2026

Copy link
Copy Markdown
Member Author

I set hash = FALSE on both new.env() calls introduced by this branch and benchmarked the change back-to-back.

  • Class definition: 73 µs → 72 µs, effectively unchanged.
  • Allocation per class definition: 8.41 KB → 7.86 KB, about 1.07x less.
  • Constructor, class lookup, and instance memory benchmarks were unchanged within noise; all differences were ≤3%.

Focused class and constructor tests pass: 163 assertions.

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.

Think about how to store the class object

2 participants