Skip to content

perf(metadata): reference interfaces by class-name index - #436

Draft
edusperoni wants to merge 6 commits into
mainfrom
feat/metadata-size-structural
Draft

perf(metadata): reference interfaces by class-name index#436
edusperoni wants to merge 6 commits into
mainfrom
feat/metadata-size-structural

Conversation

@edusperoni

@edusperoni edusperoni commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Description

Draft, stacked on #434 — review only the last commit. #434 holds the small self-contained wins; this branch is for structural format changes, the ones that reshape a record rather than eliding a field.

bytes vs baseline
baseline (main) 12,033,254
after #434 11,251,503 −6.50%
after this PR 10,734,555 −10.79%

This commit accounts for −516,948 B.

Current behavior

An interface reference inside a type encoding costs 9 bytes: a 1-byte tag, a 4-byte name pointer, and a 4-byte pointer to its protocol list.

Across the SDK there are 92,844 such references, of which 88,172 carry no protocols at all — and they name only 2,960 distinct classes. So the overwhelmingly common case pays 8 bytes of pointers to say "NSString, no protocols", over and over.

New behavior

A new InterfaceIndexReference tag encodes the class as a uint16 index into a new class-name table: 3 bytes instead of 9. References that genuinely carry protocols keep the existing form, so nothing is lost.

The table is written between the module table and the heap. It costs 11,844 bytes (2,960 entries + count), which is already netted out of the figure above.

There is a fallback: internClassName returns false once the table would exceed UINT16_MAX, and the generator emits the old pointer form instead. So a hypothetically enormous SDK degrades in size rather than corrupting.

Reader changes

TypeEncoding gains three helpers, and all 16 call sites across Interop.mm, ArgConverter.mm, ClassBuilder.mm and FFICall.cpp now go through them:

  • isInterfaceReference() — true for both spellings. This is the important one: a site that keeps comparing against InterfaceDeclarationReference alone would silently drop the indexed form into whatever its default branch does, mis-marshalling an argument with no crash to point at it.
  • interfaceName() — resolves the name for either spelling.
  • interfaceProtocols() — returns nullptr for the indexed form, which by construction has none.

MetaFile::classNames() is resolved once in setInstance rather than per lookup, because locating the table means walking every preceding table and two of the converted sites are on hot marshalling paths.

Also here: hardening the empty-array interning from #434

#434 made push_binaryArray reuse a single empty array. That is safe in the heap, where arrays are reached by offset, but wrong for the header tables, which the runtime locates positionally by walking sizeInBytes(). Interning one there would shift every table after it, including the heap pointer.

It could not actually trigger in #434 — bucket arrays are never empty (the hashtable floors at 100 buckets) and only one other header array existed. Adding a fifth table makes two-empties reachable, so push_binaryArray now takes an explicit shouldIntern, and all five header writes pass false.

Verification

Both files rendered to a canonical, offset-independent form and diffed:

188,518 lines rendered, 0 lines differ

The renderer auto-detects the extra root table (the heap always opens with a 0 marker byte, so a nonzero word in that position is the class-name count) and renders an indexed reference identically to a protocol-less pointer reference — so if the two ever disagreed on the resolved class name, it would show up as a diff rather than being masked.

I also checked the emitted table directly: 2,960 entries, all offsets distinct, first names resolving to NSString, NSUUID, ARAnchor, …, and the heap marker byte still at offset 0.

Build status

The NativeScript target compiles cleanly with these changes — every source file, including all 16 converted call sites. The link step fails in my worktree on -lzip, a prebuilt artifact the worktree does not have; that is environmental and unrelated. The device suite has not been run.

Still to come on this branch

change saves notes
enum __tsEnum(...) → binary name/value table ~0.66 MB 92% of that section is duplicated member names and JSON punctuation; swiftNameFields[i] is a literal suffix of fullNameFields[i] (MetaFactory.cpp:412-418) and both are written. Must preserve insertion order — __tsEnum builds a bidirectional map whose reverse entries are last-write-wins
method encoding-list count is derivable ~0.24 MB count == 1 + colons(selector), 0 mismatches across all 60,589 methods
_introduced → flag bits ~0.15 MB only 92 distinct values; needs 7 bits, and bit 9 is now taken, so this needs bits 10–15 plus reclaiming bit 6 (IsIosAppExtensionAvailable, which the runtime never reads)
dedupe identical type-encoding blobs ~0.19 MB blocked: ParametrizedCall::Get (FFICall.cpp:279-306) keys its ffi_cif cache on the TypeEncoding pointer alone while callers pass different initialParameterIndex/argsCount. Latent only because no two declarations share a blob today; deduping aliases them and silently builds the wrong cif. Re-key the cache first

Deriving method jsName (~1.42 MB, the single biggest lever) stays deferred pending a decision on Meta::jsName()'s signature — see #434 for the detail. Performance is not the blocker there; the API shape is.

Does your pull request have unit tests?

Not yet — draft. This changes reader behavior, so the device suite must run before it leaves draft.

…ding serializer

BinaryTypeEncodingSerializer held its BinaryWriter by value. The writer owns the
string-interning map, so the copy interned into a second map and every string
reachable from both the meta path and the type-encoding path was written to the
heap twice.

4519 strings were stored at two offsets; every one of them was the cross-path
case.
push_binaryArray wrote a fresh count-of-zero for every empty array, and nothing
deduplicated them. Empty protocol lists alone accounted for 95k of them.

Empty arrays carry no payload, so they can all share one offset. Offset 0 stays
the null sentinel: the heap reserves a marker byte there, so no real array can
land on it.
57933 of 60589 methods carry no constructor tokens, yet every MethodMeta paid a
4-byte pointer for the field. It is the trailing field and MethodMeta has no
subclass, so it can be left out entirely and gated on a flag.

Also widens the member flag mask: it cleared bits 8 and up alongside the type
bits, which would silently discard any member flag stored there. No member in
the SDK sets bit 8 today, so this changes nothing on its own.
An interface reference in a type encoding cost 9 bytes: a tag, a name pointer
and a protocols pointer. 88172 of the 92844 references in the SDK carry no
protocols at all, and only 2960 distinct class names are referenced.

Those references now encode as a tag plus a uint16 index into a new class-name
table, 3 bytes instead of 9. References that do carry protocols keep the old
form, as does any reference the generator cannot index because the table is
full.

The table sits between the module table and the heap, so the runtime resolves
it once in setInstance rather than walking the preceding tables on every
lookup.

Readers must test isInterfaceReference() rather than comparing against a single
tag, and read the name through interfaceName(); comparing against
InterfaceDeclarationReference alone would silently drop the indexed form into
whatever the default branch does.

Also gates the empty-array interning behind an explicit flag. Header tables are
located positionally, so interning one there would move every table after it —
harmless while only one such table could be empty, but not once a second exists.
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: be3ce75e-5746-49c7-ae3a-563e30cf6a63

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Every method and function signature wrote its own encoding list, so common
shapes were stored thousands of times over. Lists are now serialized to scratch
and interned by their exact bytes; nothing in them depends on where the list
lands, so identical lists are interchangeable.

This is worth far more after the class-name index than before it: encoding the
common interface reference as a bare index removed the per-reference pointers
that used to make otherwise-identical signatures differ.

Sharing lists means two declarations can now point at one encoding, which the
ffi_cif cache was not prepared for -- it keyed on the encoding pointer alone
while callers pass different initialParameterIndex/argsCount, so aliasing would
hand back a cif describing the wrong stack. It now keys on all three.
Enums were serialized as JS source, __tsEnum({"A":1,...}), compiled and run on
first access. Almost all of that text was redundant: every member name is
already interned in the heap because the same enum's constants are emitted as
standalone globals, so the source string held a second copy of each name plus
JSON punctuation.

They now serialize as a count followed by (name offset, int64 value) pairs and
the runtime builds the object directly. Entries stay in stored order because
__tsEnum's reverse mapping is last-write-wins, so reordering would change which
name a duplicated value maps back to.

Also fixes push_number, which masked with `255 << pad`. That shifts an int, so
it overflowed past the fourth byte and silently zeroed the high half of every
8-byte value. Caught by an enum whose members use bits 62 and 63.
Base automatically changed from feat/metadata-size to main August 12, 2026 21:50
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.

1 participant