objc_msgSend IMP缓存性能优化之——完美哈希

196 阅读9分钟

更多精彩文章,欢迎关注作者微信公众号:码工笔记

背景 & 问题

了解Objective-C语言的同学都知道objc_msgSend在这门语言里的核心地位,每个ObjC方法调用最终都会调到这个方法,因此其执行效率要求非常高。

当我们调用一个Objective-C方法时,ObjC会根据class和selector来查找IMP(具体的函数地址),然后将(selector地址, IMP)二元组对放到此class对应的一个哈希表中,以提高下次调用的效率。

但之前使用的普通哈希表有两个问题:

  • 哈希函数只是对selector的地址值做了二进制掩码(mask),如果同一个class的两个selector地址的低位相同,就会出现哈希冲突(冲突使用线性探测法来解决),从而影响查找效率
  • 哈希表占用内存较大:移动设备上一般占70MB左右

如果能将这种动态哈希表改为静态数据,并存储到shared cache文件中,就可以实现:

  • 为每个class构造出一个完美哈希表,避免哈希冲突带来的开销
  • 哈希表内容全部存储在clean memory中(随时可以换出,节省内存)

Apple在2020年对IMP缓存机制做了优化,本文就介绍一下此优化的具体方案(具体实现代码见参考资料[2][3])。

IMP缓存哈希表优化

1、哈希函数

对于任意一个class,会为它生成一个静态哈希表用来缓存selector对应的IMP地址,所用的哈希函数是:

hash(x) = (x >> shift) & mask

其中:

  • shift和mask是两个跟class相关的参数(每个class都有自己的shift和mask值),每个class有一张独立的哈希表。

  • x是selector的地址。因为所有的selector都是由dyld中的shared cache builder负责排布的,我们实际上可以任意决定所有x的取值,也就是说我们不光可以控制哈希函数,还可以控制自变量的取值空间。

所以,现在问题变成了:

  • 给定一个class集合,以及这些class实现的一些selector集合,如何决定各个selector的地址,同时如何为每个class找到合适的shift和mask值,使得对每个class来说,h(selector)=(selector>>shift)&mask 是class内selector的完美哈希函数?

注:对一个内存地址进行shift和mask,相当于是取这个地址二进制的不同bit段

2、具体算法

算法主要分两个阶段:

1)找合适的shift和mask值

先给selector地址的部分高位部分进行赋位,然后再为每个class找一个合适的shift和mask值。

这是个回溯算法,其流程如下。

顺序遍历所有class:

在遍历过程中,需要保证为当前class找到的shift和mask值能够与当前class中所有selector地址上已赋值的bit相匹配(不改变已赋值的bit)。

  • 举例:假设class1中实现了selector1、selector2,class2实现了selector2、selector3,遍历顺序是先class1,后class2,那么当遍历到class2时,class2的shift2、mask2既要满足使class2的IMP缓存哈希表完美,即h(selector2) != h(selector3),还要保证它对selector2地址的修改与处理class1对selector2的修改不冲突。假设h(selector2) == 0x0,h(selector2) = 0x1,根据哈希函数及shift2、mask2就能算出来selector2相应bit的取值,这些bit如果在之前遍历class1时已经赋过值,且与现在要赋的值不同,则说明不匹配,则此shift2、mask2是不合理的,需要重新寻找。

在匹配的情况下,再将各selector地址中新找到的(shift,mask)对应的bit进行置位。

在处理每个class的过程中,我们穷举所有合乎条件的shift和mask值,直到找到能满足要求的参数。

如果不存在满足条件的shift和mask,则需要回溯;如果尝试了多次回溯还是找不到合适的解,我们就放弃这个class,将它放入黑名单(后续流程再处理)。

因为控制的是selector的地址,而selector结构本身是有长度的,为了防止多个selector地址靠得太近导致selector结构放不下,我们将selector地址低位的7个bit空出来不使用,这样得到的每个selector地址就对应了128byte的内存空间。我们可以用这些内存空间存放selector内容,剩余的部分则可填入其他selector。

本步骤主要是给selector地址的高位赋值,确定其所在的128byte内存块(桶),然后再将属于这个桶的所有selector内容顺序填入桶的内存块中。

2)对桶内放不下的selector进行重排

如果步骤 1)中某些桶中selector数量过多导致128byte放不下了,就需要将其中某些selector移到别的合适的桶。

其流程如下:

遍历selector所在的所有class,为selector所在的桶地址建立一个“约束集合”:

  • 检查各class的哈希表中哪些槽是空的,也就是selector地址中的哪些位可选哪些值,作为约束条件
  • 前提是所有class对应的shift和mask值都不变,所以上面说的“哪些位”是确定的

如果能找到一个满足所有约束的解,则可以据此来得到selector的新值。

  • 通过改变各class哈希表中selector对应IMP所占的槽位,来改变selector的地址,使所有class哈希表保持完美

如果找不到满足约束的解,则也可以丢弃与此selector相关的class。

所有selector都找到合适的桶(128byte内存块)后,就可以按顺序将selector结构依次填入桶中了。selector地址的低7位即可由同一桶内其所有前序selector的内容长度相加得到。

3、运行时使用IMP Cache

构建dyld shared cache阶段:

  • dyld依照上述算法算出的位置排布selector
  • dyld将各class的存了(selector, IMP)二元组的哈希表内容放到shared cache中
  • dyld将上述算法计算出的每个class对应的shift和mask值存到class结构中

objc_msgSend运行时阶段:

  • 根据class结构中存储的指针找到其IMP cache 哈希表起始位置(bucket)
  • 根据class结构中存储的shift、mask的值以及selector地址(实际是偏移),通过一次哈希计算(selector >> shift) & mask得到哈希表内偏移
  • 偏移处存放的就是(selector, IMP),从而得到IMP地址。

下面我们通过分析objc_msgSend的部分源码来看一下它使用IMP缓存哈希表的详细流程。

一进入objc_msgSend后,先是做了判空和取isa,然后就会调用CacheLookup:

	MSG_ENTRY _objc_msgSend
	UNWIND _objc_msgSend, NoFrame

	cmp	p0, #0			// nil check and tagged pointer check
	b.le	LNilOrTagged		//  (MSB tagged pointer looks negative)
    
	ldr	p14, [x0]		// p14 = raw isa
	GetClassFromIsa_p16 p14, 1, x0	// p16 = class
LGetIsaDone:
	// calls imp or objc_msgSend_uncached
	CacheLookup NORMAL, _objc_msgSend, __objc_msgSend_uncached

LNilOrTagged:
	b.eq	LReturnZero		// nil check
	GetTaggedClass
	b	LGetIsaDone

LReturnZero:
    ...
    
	END_ENTRY _objc_msgSend

CacheLookup是个汇编宏,其具体实现如下:

.macro CacheLookup Mode, Function, MissLabelDynamic, MissLabelConstant
	//   - x0 contains the receiver
	//   - x1 contains the selector
	//   - x16 contains the isa
	//   - other registers are set as per calling conventions
	//

	mov	x15, x16			// stash the original isa
LLookupStart\Function:
	// p1 = SEL, p16 = isa
	ldr	p11, [x16, #CACHE]			// p11 = mask|buckets

	and	p10, p11, #0x0000fffffffffffe	// p10 = buckets
	tbnz	p11, #0, LLookupPreopt\Function
    
  ;省略
  ...

其中#CACHE是16,就是取了一下isa内offset为16的数据,根据注释可以看出,取出来的是mask|buckets,存到了p11中,接着又把buckets分离出来看它是否为空,不为空(即IMP缓存哈希表存在)则跳转到LLookupPreopt\Function。

接着看LLookupPreopt\Function的具体过程(关键流程解释见代码中的相关注释):

LLookupPreopt\Function:
	and	p10, p11, #0x007ffffffffffffe	// p10 = buckets
	autdb	x10, x16			// auth as early as possible

	// x12 = (_cmd - first_shared_cache_sel)
	adrp	x9, _MagicSelRef@PAGE
	ldr	p9, [x9, _MagicSelRef@PAGEOFF]
	sub	p12, p1, p9

	// w9  = ((_cmd - first_shared_cache_sel) >> hash_shift & hash_mask)

	// bits 63..60 of x11 are the number of bits in hash_mask
	// bits 59..55 of x11 is hash_shift

	lsr	x17, x11, #55			// w17 = (hash_shift, ...)
	lsr	w9, w12, w17			// >>= shift

	lsr	x17, x11, #60			// w17 = mask_bits
	mov	x11, #0x7fff
	lsr	x11, x11, x17			// p11 = mask (0x7fff >> mask_bits)
	and	x9, x9, x11			// &= mask

	// sel_offs is 26 bits because it needs to address a 64 MB buffer (~ 20 MB as of writing)
	// keep the remaining 38 bits for the IMP offset, which may need to reach
	// across the shared cache. This offset needs to be shifted << 2. We did this
	// to give it even more reach, given the alignment of source (the class data)
	// and destination (the IMP)
    
;注:
;1x17 = *(x10 + x9 * 8),其中x10中存放的是bucket首地址,x9hash函数的结果
;2x17中存的就是IMP缓存哈希表中的目标条目,它是个bitmap,格式为:
;    高26位是sel_offs(即selectorshared cache中第一条selector之间的偏移量)
;    低38位是imp_offsIMP地址右移2位得到);
	ldr	x17, [x10, x9, LSL #3]		// x17 == (sel_offs << 38) | imp_offs
    
;注:x12中存放的是当前selectorsel_offs,用它跟x17中的sel_offs进行对比,若二者不相等,则说明cache miss了,此selector没有缓存
	cmp	x12, x17, LSR #38
	b.ne	5f				        // cache miss
	sbfiz x17, x17, #2, #38         // imp_offs = combined_imp_and_sel[0..37] << 2
	sub x17, x16, x17               // imp = isa - imp_offs

	br	x17

5:	ldur	x9, [x10, #-16]			// offset -16 is the fallback offset
	add	x16, x16, x9			// compute the fallback isa
	b	LLookupStart\Function		// lookup again with a new isa    

...

总体流程还是比较清晰的,也可以看出来,在objc_msgSend这种关键路径上,Apple做了大量的优化,每个64位里都encode很多信息,再加上完美哈希表,的确可谓极致。

【完】

参考资料