Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

GSFFIInvocation: Use objc_msg_lookup in -invokeWithTarget: to avoid skipping hidden classes #473

Merged
merged 4 commits into from
Dec 13, 2024

Conversation

hmelder
Copy link
Contributor

@hmelder hmelder commented Dec 2, 2024

When observing properties via a proxy object, all messages are encapsulated into an NSInvocation object.
From the example in #472, the following messages are forwarded to the underlying object:

  • addObserver:forKeyPath:options:context:
  • removeObserver:forKeyPath:options:context:
  • setName:

When adding a observer for the first time (assuming that there are no associated objects), a hidden subclass is created and attached (ISA changes) to the TObject instance. setName: is then swizzled and added to the hidden subclass.
The current invocation mechanism uses APIs that skip over hidden classes during method lookup, resulting in the original IMP being called instead of the swizzled one.

This is documented in NSKVOSwizzling:

/*
A crucial design decision was made here: because object_getClass() skips
autogenerated subclasses, the likes of which are used for associated objects,
the KVO machinery (if implemented using manual subclassing) would delete all
associated objects on an observed instance. That was deemed unacceptable.
Likewise, this implementation is not free of issues:
- The _np methods are nonportable.
- Anyone using class_getMethodImplementation(object_getClass(...), ...) will
receive the original IMP for any overridden method.
- We have to manually load isa to get at the secret subclass (thus the use of
ABI_ISA/ABI_SUPER.)
- It is dependent upon a libobjc2 implementation detail:
object_addMethod_np creates a hidden subclass for the object's class (one
per object!)
*/

Here is a quick micro benchmark to compare the old mechanism with objc_msg_lookup. Please note, that I have not benchmarked the legacy GCC runtime.

System

OS:  Ubuntu 24.10
Architecture: AArch64
libobjc2: 0c1a89373f137489a829c69009d7ce752ac46b85
2024-12-02T17:11:28+01:00
Running ./a.out
Run on (12 X 2000 MHz CPU s)
Load Average: 1.49, 1.20, 1.06
***WARNING*** Library was built as DEBUG. Timings may be affected.
-------------------------------------------------------------
Benchmark                   Time             CPU   Iterations
-------------------------------------------------------------
BM_objc_msg_lookup       7.86 ns         7.86 ns     89906353
BM_GSGetMethod           12.5 ns         12.4 ns     56523293
#include <benchmark/benchmark.h>
#include <Foundation/Foundation.h>

struct objc_method *
GSGetMethod(Class cls, SEL sel,
  BOOL searchInstanceMethods,
  BOOL searchSuperClasses)
{
  if (cls == 0 || sel == 0)
    {
      return 0;
    }

  if (searchSuperClasses == NO)
    {
      unsigned int	count;
      Method		method = NULL;
      Method		*methods;

      if (searchInstanceMethods == NO)
	{
	  methods = class_copyMethodList(object_getClass(cls), &count);
	}
      else
	{
	  methods = class_copyMethodList(cls, &count);
	}
      if (methods != NULL)
	{
	  unsigned int	index = 0;

	  while ((method = methods[index++]) != NULL)
	    {
	      if (sel_isEqual(sel, method_getName(method)))
		{
		  break;
		}
	    }
	  free(methods);
	}
      return method;
    }
  else
    {
      if (searchInstanceMethods == NO)
	{
	  return class_getClassMethod(cls, sel);
	}
      else
	{
	  return class_getInstanceMethod(cls, sel);
	}
    }
}

static void BM_objc_msg_lookup(benchmark::State& state) {
  NSObject *target = [NSObject new];
  SEL sel = @selector(description);
  IMP imp;

  for (auto _ : state) {
    benchmark::DoNotOptimize(imp = objc_msg_lookup(target, sel));
  }
}

static void BM_GSGetMethod(benchmark::State& state) {
  Class cls = [NSObject class];
  SEL sel = @selector(description);
  IMP imp;

  for (auto _ : state) {
    benchmark::DoNotOptimize(imp = method_getImplementation(GSGetMethod(cls, sel, YES, YES)));
  }
}

BENCHMARK(BM_objc_msg_lookup);
BENCHMARK(BM_GSGetMethod);
BENCHMARK_MAIN();

The new KVO implementation for libobjc2/clang, located in Source/NSKVO*, reuses
or installs a hidden class and subsequently adds the swizzled method to the
hidden class. Make sure that the invocation mechanism calls the swizzled method.
The current implementation skips hidden classes, which breaks KVO.
It turns out that GSGetMethod + method_getImplementation is about
50% slower than objc_msg_lookup (gnustep-2.2 ABI).
@hmelder hmelder requested a review from rfm as a code owner December 2, 2024 16:15
GCC does not support private ivar definitions in the implementation
block.
@hmelder
Copy link
Contributor Author

hmelder commented Dec 2, 2024

Seems like legacy KVO is not happy with the new tests. Should I mark them as hopeful?

base/NSKVOSupport/proxy.m:
Failed test:     (2024-12-02 16:19:52.736 +0000) proxy.m:136 ... change notification for dependent key 'derivedName' is emitted first
Failed test:     (2024-12-02 16:19:52.740 +0000) proxy.m:119 ... Got two change notifications
Failed test:     (2024-12-02 16:19:52.740 +0000) proxy.m:122 ... Got two change notifications

@hmelder
Copy link
Contributor Author

hmelder commented Dec 3, 2024

Assuming that #474 (comment) is refering to this PR:

libobjc2 provides a safe way to do IMP caching: You lookup the slot and check the cache version, before invoking IMP. If there is a mismatch, a new lookup is required.

https://github.com/gnustep/libobjc2/blob/0c1a89373f137489a829c69009d7ce752ac46b85/objc/slot.h#L13-L20

We can probably write a shim for the GCC runtime which defines slots.

@hmelder
Copy link
Contributor Author

hmelder commented Dec 3, 2024

Here are the APIs (located in objc/runtime.h):

/**
 * Look up a slot, without invoking any forwarding mechanisms.  The third
 * parameter is used to return a pointer to the current value of the version
 * counter.  If this value is equal to `objc_method_cache_version` then the
 * slot is safe to reuse without performing another lookup.
 */
OBJC_PUBLIC
extern struct objc_slot2 *objc_get_slot2(Class, SEL, uint64_t*)
	OBJC_NONPORTABLE;

/**
 * Look up a slot, invoking any required forwarding mechanisms.  The third
 * parameter is used to return a pointer to the current value of the version
 * counter.  If this value is equal to `objc_method_cache_version` then the
 * slot is safe to reuse without performing another lookup.
 */
OBJC_PUBLIC
extern struct objc_slot2 *objc_slot_lookup_version(id *receiver, SEL selector, uint64_t*)
	OBJC_NONPORTABLE;

Copy link
Contributor

@rfm rfm left a comment

Choose a reason for hiding this comment

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

Yes, this all seems good to me, and yes the tests that fail under the old kvo implementation should be marked as hopeful since we aren't expecting them to pass.

@hmelder hmelder merged commit 3f27cb0 into master Dec 13, 2024
5 of 8 checks passed
@hmelder hmelder deleted the GSFFIInvocation-hidden-classes branch December 13, 2024 11:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

Successfully merging this pull request may close these issues.

2 participants