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

@@ -201,10 +201,91 @@ pub const LLVMEmitter = struct {
self.emitFunction(&func, @intCast(i));
}
// Pass 2.5: Emit Obj-C selector init constructor (Phase 1.5).
self.emitObjcSelectorInit();
// Pass 3: Verify typeSizeBytes matches LLVM's ABI sizes
self.verifySizes();
}
/// Synthesize a module constructor that populates each interned
/// Obj-C selector slot via `sel_registerName`, once at module load.
/// Registered in `@llvm.global_ctors` so dyld / ld.so runs it
/// before main. Per `#objc_call` site collapses to a single load
/// from the slot — matches clang's `@selector(...)` lowering.
fn emitObjcSelectorInit(self: *LLVMEmitter) void {
if (self.ir_mod.objc_selector_cache.items.len == 0) return;
// Look up the `sel_registerName` extern that the lowerer already
// declared. If for some reason it's absent (shouldn't happen —
// every interned selector got there via the same lowering path),
// bail out and let the per-call fallback run.
const sel_reg_name = "sel_registerName";
const sel_reg_z = self.alloc.dupeZ(u8, sel_reg_name) catch unreachable;
defer self.alloc.free(sel_reg_z);
const sel_reg_fn = c.LLVMGetNamedFunction(self.llvm_module, sel_reg_z.ptr);
if (sel_reg_fn == null) return;
const sel_reg_ty = c.LLVMGlobalGetValueType(sel_reg_fn);
// Create the constructor: void __sx_objc_selector_init().
const void_ty = self.cached_void;
var no_params: [0]c.LLVMTypeRef = .{};
const ctor_ty = c.LLVMFunctionType(void_ty, &no_params, 0, 0);
const ctor = c.LLVMAddFunction(self.llvm_module, "__sx_objc_selector_init", ctor_ty);
c.LLVMSetLinkage(ctor, c.LLVMInternalLinkage);
const entry = c.LLVMAppendBasicBlockInContext(self.context, ctor, "entry");
c.LLVMPositionBuilderAtEnd(self.builder, entry);
// For each (selector_str, slot_global): emit
// %sel = call ptr @sel_registerName(<"selector:">)
// store ptr %sel, ptr @OBJC_SELECTOR_REFERENCES_<sel>
for (self.ir_mod.objc_selector_cache.items) |entry_kv| {
const sel_str = entry_kv.sel;
const slot_gid = entry_kv.slot;
const slot_global = self.global_map.get(@intCast(slot_gid.index())) orelse continue;
// Selector string constant. Make it private so multiple
// constructors don't clash. `i8` array with NUL terminator.
const sel_str_z = self.alloc.allocSentinel(u8, sel_str.len, 0) catch continue;
defer self.alloc.free(sel_str_z);
@memcpy(sel_str_z[0..sel_str.len], sel_str);
const str_const = c.LLVMConstStringInContext(self.context, sel_str_z.ptr, @intCast(sel_str.len), 0);
const str_global = c.LLVMAddGlobal(self.llvm_module, c.LLVMTypeOf(str_const), "OBJC_METH_VAR_NAME_");
c.LLVMSetInitializer(str_global, str_const);
c.LLVMSetLinkage(str_global, c.LLVMPrivateLinkage);
c.LLVMSetGlobalConstant(str_global, 1);
c.LLVMSetUnnamedAddress(str_global, c.LLVMGlobalUnnamedAddr);
var sel_args: [1]c.LLVMValueRef = .{str_global};
const sel_val = c.LLVMBuildCall2(self.builder, sel_reg_ty, sel_reg_fn, &sel_args, 1, "sel");
_ = c.LLVMBuildStore(self.builder, sel_val, slot_global);
}
_ = c.LLVMBuildRetVoid(self.builder);
// Register in @llvm.global_ctors. Layout per LLVM Language
// Reference: `[N x { i32, void()*, i8* }]`. Priority 65535 =
// default; the third field carries an "associated data"
// pointer (null for our case).
const i32_ty = self.cached_i32;
const ptr_ty = self.cached_ptr;
var ctor_field_types: [3]c.LLVMTypeRef = .{ i32_ty, ptr_ty, ptr_ty };
const ctor_struct_ty = c.LLVMStructTypeInContext(self.context, &ctor_field_types, 3, 0);
var ctor_fields: [3]c.LLVMValueRef = .{
c.LLVMConstInt(i32_ty, 65535, 0),
ctor,
c.LLVMConstNull(ptr_ty),
};
const ctor_entry = c.LLVMConstNamedStruct(ctor_struct_ty, &ctor_fields, 3);
const ctors_arr_ty = c.LLVMArrayType2(ctor_struct_ty, 1);
var ctor_entries: [1]c.LLVMValueRef = .{ctor_entry};
const ctors_init = c.LLVMConstArray2(ctor_struct_ty, &ctor_entries, 1);
const ctors_global = c.LLVMAddGlobal(self.llvm_module, ctors_arr_ty, "llvm.global_ctors");
c.LLVMSetInitializer(ctors_global, ctors_init);
c.LLVMSetLinkage(ctors_global, c.LLVMAppendingLinkage);
}
/// Compare IR typeSizeBytes against LLVMABISizeOfType for all user-defined types.
fn verifySizes(self: *LLVMEmitter) void {
// Skip for wasm32: 4-byte pointers vs IR's assumed 8-byte,