Repository navigation
fix(runtime): charge the native memory of +0 objects once JS holds the last reference - #515
Draft
edusperoni wants to merge 1 commit into
Draft
edusperoni wants to merge 1 commit into
edusperoni wants to merge 1 commit into
Conversation
…e last reference A wrapper retains its object until V8 collects the wrapper, so a +0 object that reaches JS (a block or override argument, an instance method's return) is solely owned by its wrapper once the native caller lets go. V8 still saw a few dozen bytes for it: a worker receiving a 205 KB NSData per frame at 60 Hz grew past 1.4 GB without a single collection. - When a registered wrapper is first created for an object whose class has a size estimator, the object becomes a candidate in a bounded per-isolate list (4096); classes without an estimator cost one cached class lookup. Owned paths that charge right away drop their entry again. - Candidates are checked once, from a kCFRunLoopBeforeWaiting observer on the isolate's home run loop, ordered after UIKit's autorelease pool pop so the main thread's pool for the turn has drained. Candidates recorded on another thread (callbacks that enter the isolate from a dispatch queue) wait 16 ms for their native caller to let go, and a no-op EventLoop post makes an idle home loop run the pass that checks them. - A candidate is charged its estimate if its wrapper is still attached and the object's retain count is JS's own: the wrapper's retain plus one per live interop.bufferFromData buffer, which bufferFromData now counts on the wrapper. Collected or released wrappers, objects native still retains and immortal objects (constants, tagged pointers) are skipped. - Charging happens under the Locker on the home thread, behind the isolate pin and a cache validity check, and stops when execution is terminating; the observer is torn down first in ~Runtime.
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: true
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. Comment |
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The gap
#507 charges an estimated native size (NSData length, UIImage/CGImage bitmap, CVPixelBuffer) only for objects JS likely owns: JS constructors, initializers, +1 returns and class factory methods. A wrapper retains its object until V8 collects the wrapper, though, so a +0 object that crosses into JS is shared with native only while the call runs: a block or callback argument, a JS-implemented override's parameters, a +0 return. Once the native caller lets go, the wrapper is the sole owner until GC, and V8 still sees a few dozen bytes.
interop.bufferFromDatadoes not close the gap. V8 counts the ArrayBuffer's backing store, but the NSData wrapper keeps the bytes alive after the buffer dies, and in practice V8 never schedules the collection that would free the wrapper.Measured case
A worker receives a 205 KB
NSDataat 60 Hz as a +0 block argument from a Swift serial queue (a Network.framework loopback socket server), then callsinterop.bufferFromData. On an iOS 26.3 simulator with runtime 9.1.0-next (#507 included), over 2 minutes:interop.setExternalSize(data, data.length)per frameOn a device, that growth is a jetsam risk. This PR makes the right-hand column happen without the manual call.
Design
Candidates. In
ArgConverter::CreateJsWrapper, when an object first gets a registered wrapper and its class has an estimator, the object is recorded in a bounded per-isolate list: object, class, the registry handle (shared_ptr<Persistent>, which does not keep the object alive) and a due time. Classes without an estimator cost one cached class lookup. Wrappers that skip GC registration are never recorded. #507's owned paths charge immediately and drop their entry again, so they don't use up the list. Once the list holds 4096 entries, further objects go uncharged until the next check.Deferred check point. The check runs in a
kCFRunLoopBeforeWaitingobserver on the isolate's home run loop, which is the main thread or the worker thread. I chose this point because:LONG_MAX, after UIKit's pool pop/push observer (order0x7fffffff). By the time the check runs, the main thread's pool for the turn has drained. A next-turnCFRunLoopPerformBlockwould run before that drain.Callbacks often enter the isolate from a foreign thread: a block invoked on a dispatch queue takes the isolate's Locker on that queue's thread. The measured case is one. The native caller runs on that other thread, so the home loop has no point it can observe. Candidates recorded off the home thread are therefore due 16 ms later. A no-op
EventLoop::PostInternalDelayedmakes sure an otherwise idle home loop runs the pass that checks them. Wakes coalesce within 4 ms, and a wake that is kept always fires at or after the due time of the candidate it covers.Sole-owner test. At the check:
ObjCDataWrapperwith the sameData()andKlass(), and no charge yet.CFGetRetainCountmust equal JS's own retains: the wrapper's one, plus one per liveinterop.bufferFromDatabuffer.Only when all three hold is the estimate charged through #507's
ExternalMemory::SetSize. Otherwise the candidate is dropped.bufferFromDatatakes its own +1 for the buffer's lifetime. It now counts that retain on the wrapper (a shared atomic, decremented by the backing-store deleter on whatever thread frees it). The check reads the buffer count before the retain count, and the deleter drops the count before its retain, so a racing deleter can only make the check miss.Threading, gate and disposal. Increases only happen in
ChargeSoleOwners. It runs on the home thread, and the observer takes the steps in this order: isolate pin, Locker, a second cache validity check (because~Runtimeinvalidates the cache before it takes the Locker), isolate scope and context scope. Recording never charges. The decrease path from #507 is unchanged and still safe from any thread. The observer is invalidated first in~Runtime, together with the rejection observer. A terminating isolate is not charged. The candidate list holds onlyshared_ptrs to registry handles that teardown has already reset, so destroying it withCachesmakes no V8 call.Limits
CFRunLoopRun()inside one operation's pool, so on a worker only objects their native caller releases outright can qualify. The measured case and the fixture tests are in that category. Anything autoreleased on a worker stays in that pool until the worker exits; that leak predates this PR. Installing pool push/pop observers on the worker loop would be a separate follow-up.__NSSwiftDataover aDatathat Swift also keeps, a dispatch_data subrange, orNoCopyover a native buffer. feat(runtime): report native memory held by wrappers to V8, forward memory pressure #507's owned paths have the same imprecision.bufferFromDatabuffer are weighed twice, by V8 and by this charge, until the buffer dies. This is deliberate: in the measured case, V8 counting the buffer alone never brought the collection that frees the wrapper.Tests
New specs in
ExternalMemoryTests.js. The native helpers are a newTNSExternalMemoryfixture (MRC) plus anapitestmodule.externalMemory()reader of the isolate's external total.subdataWithRange), and a block argument JS also reads throughinterop.bufferFromData.NSMutableArray, a process-lifetime shared NSData, and[NSData data].__collect().Negative control. With candidate recording disabled, exactly the 8 positive specs fail and the "does not charge" spec passes.
Suite.
./run_tests.shon a dedicated simulator (iPhone 16 Pro, iOS 26.3): 1844 specs, 0 failures, 11 skipped. The AddressSanitizer lane (-a): 1844 specs, 0 failures, 16 skipped.Numbers
TestRunner repro of the measured case: a native serial queue delivers a fresh 205 KB
NSDataat 60 Hz as a +0 block argument into a worker, the worker callsinterop.bufferFromDataon each frame, and the main isolate samplesphys_footprintevery second for 120 s. One build was used for all three runs, with a temporary switch for the variants (not part of this PR):interop.setExternalSize(data, data.length)per frameThis PR matches the manual charge. The later first GC in this harness compared with the app (30–40 s against ~10 s) shows in both the manual and the automatic runs, so it comes from V8's initial external-memory limit with little other allocation, not from the deferral.