ffi 1.5: intern Obj-C selectors — one static SEL slot per unique name

101/101 regression tests pass; the IR snapshot for the selector-
sharing test diff flips from four per-call `sel_registerName` calls
to two (one per unique selector) routed through a module-init
constructor — matching what clang emits for `@selector(...)`.

Hot-path cost collapses from a libobjc hashtable lookup per call to
a single load of a static `SEL*` slot:

  Before (Phase 1.3):
    %sel = call ptr @sel_registerName(<"init">)
    call ptr @objc_msgSend(<recv>, %sel)

  After (Phase 1.5):
    %sel = load ptr, ptr @OBJC_SELECTOR_REFERENCES_init
    call ptr @objc_msgSend(<recv>, %sel)

  +  @OBJC_SELECTOR_REFERENCES_init    = internal global ptr null
  +  @OBJC_SELECTOR_REFERENCES_release = internal global ptr null
  +  define internal void @__sx_objc_selector_init() {
  +    %sel  = call ptr @sel_registerName(ptr @OBJC_METH_VAR_NAME_)
  +    store ptr %sel, ptr @OBJC_SELECTOR_REFERENCES_init
  +    %sel1 = call ptr @sel_registerName(ptr @OBJC_METH_VAR_NAME_.2)
  +    store ptr %sel1, ptr @OBJC_SELECTOR_REFERENCES_release
  +    ret void
  +  }
  +  @llvm.global_ctors = appending global [1 x { i32, ptr, ptr }]
  +    [{ ..., ptr @__sx_objc_selector_init, ptr null }]

Implementation:
  module.zig    | new `objc_selector_cache: ArrayList(ObjcSelectorEntry)`
                  with `lookupObjcSelector` / `appendObjcSelector`. List
                  (not hashmap) keeps emit order stable across builds so
                  the IR snapshot doesn't flicker on rehash.
  lower.zig     | `internObjcSelector(sel)` creates the slot on first
                  use, returns the same `GlobalId` on every subsequent
                  call to the same selector. lowerFfiIntrinsicCall now
                  emits `global_addr + load` for literal selectors.
                  Non-literal selectors keep the `sel_registerName`
                  fallback. Declaring `sel_registerName` lazily on
                  first intern so emit_llvm finds it for the
                  constructor body.
  emit_llvm.zig | new `emitObjcSelectorInit` pass synthesizes a void
                  constructor that loops over the cache, calls
                  `sel_registerName` for each unique selector string,
                  stores the result in the slot. Constructor is
                  registered in `@llvm.global_ctors` with default
                  priority (65535) so dyld runs it before main.

The `@OBJC_METH_VAR_NAME_` private string globals and unnamed-addr
flag match clang's exact emission shape — picked up by the system
linker into the right Mach-O sections on macOS / iOS. Chess
Android + iOS-sim still build clean (no `#objc_call` in chess yet —
phase-3 migration will start exercising this).
This commit is contained in:
agra
2026-05-19 13:09:34 +03:00
parent 26a04e49d0
commit b8a412ddc7
4 changed files with 189 additions and 31 deletions

View File

@@ -26,14 +26,24 @@ pub const Module = struct {
globals: std.ArrayList(Global),
/// Maps (protocol_ty, concrete_ty) → list of method FuncIds.
impl_table: ImplTable,
/// Interned Obj-C selectors. Kept as an insertion-ordered list of
/// (selector_string, slot_GlobalId) so emit_llvm.zig produces the
/// init constructor in a stable order across builds (the
/// selector-sharing IR snapshot would otherwise flicker on
/// hashtable rehash). `#objc_call` lowering uses
/// `lookupObjcSelector` / `appendObjcSelector` to read/write it.
objc_selector_cache: std.ArrayList(ObjcSelectorEntry),
alloc: Allocator,
pub const ObjcSelectorEntry = struct { sel: []const u8, slot: GlobalId };
pub fn init(alloc: Allocator) Module {
return .{
.types = TypeTable.init(alloc),
.functions = std.ArrayList(Function).empty,
.globals = std.ArrayList(Global).empty,
.impl_table = ImplTable.init(alloc),
.objc_selector_cache = std.ArrayList(ObjcSelectorEntry).empty,
.alloc = alloc,
};
}
@@ -45,9 +55,24 @@ pub const Module = struct {
self.functions.deinit(self.alloc);
self.globals.deinit(self.alloc);
self.impl_table.deinit();
self.objc_selector_cache.deinit(self.alloc);
self.types.deinit();
}
/// Linear scan — N is the count of UNIQUE selectors per program,
/// not the count of call sites. Real programs hit dozens, not
/// millions; a hashmap would be premature here.
pub fn lookupObjcSelector(self: *const Module, sel: []const u8) ?GlobalId {
for (self.objc_selector_cache.items) |entry| {
if (std.mem.eql(u8, entry.sel, sel)) return entry.slot;
}
return null;
}
pub fn appendObjcSelector(self: *Module, sel: []const u8, slot: GlobalId) void {
self.objc_selector_cache.append(self.alloc, .{ .sel = sel, .slot = slot }) catch unreachable;
}
pub fn addFunction(self: *Module, func: Function) FuncId {
const id = FuncId.fromIndex(@intCast(self.functions.items.len));
self.functions.append(self.alloc, func) catch unreachable;