Skip to content

Commit caaee30

Browse files
committed
feat(runtime): report native memory held by wrappers to V8, forward memory pressure
V8 only weighs its own heap (plus ArrayBuffer backing stores) when scheduling collections, so a JS wrapper holding megabytes of native memory looks like a few dozen bytes and can sit uncollected until the process is jetsammed. - Wrappers can carry an ExternalMemoryCharge (a v8::ExternalMemoryAccounter) that is returned whenever the wrapper is deleted, from any thread; once the isolate's gate is closed the decrease is skipped since disposal drops it. - interop.alloc(n) charges n bytes to the returned pointer. - Objects JS likely owns (JS constructors, initializers, +1 returns, class factory methods) get an estimated size: NSData length, UIImage/CGImage bitmap size, CVPixelBuffer data size. CG/CV are resolved at runtime, so no new link dependencies. NSDataAdapter is excluded (V8 already counts it). - interop.setExternalSize(obj, bytes) / interop.getExternalSize(obj) let libraries declare the native footprint of what they wrap. - A process-wide dispatch memory-pressure source forwards normal/warn/critical to every live isolate via Isolate::MemoryPressureNotification.
1 parent 87a1a4b commit caaee30

13 files changed

Lines changed: 526 additions & 0 deletions

File tree

‎NativeScript/runtime/ArgConverter.mm‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
#include <Foundation/Foundation.h>
33
#include <sstream>
44
#include "DictionaryAdapter.h"
5+
#include "ExternalMemory.h"
56
#include "Helpers.h"
67
#include "Interop.h"
78
#include "NSExceptionSupport.h"
@@ -638,6 +639,7 @@
638639
if (!resultIsOwned) {
639640
[result retain];
640641
}
642+
ExternalMemory::ChargeEstimatedSize(isolate, thiz);
641643
}
642644
}
643645

‎NativeScript/runtime/Caches.h‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,8 @@ class Caches {
178178
robin_hood::unordered_map<const void*,
179179
std::shared_ptr<v8::Persistent<v8::Object>>>
180180
PointerInstances;
181+
// ExternalMemory's size estimator per class, resolved on first sight.
182+
robin_hood::unordered_map<Class, uint8_t> ExternalSizeEstimators;
181183

182184
// Live IsolateTracked instances (URL, URLSearchParams, URLPattern). Their
183185
// weak-callback finalizers never fire at isolate disposal, so teardown

‎NativeScript/runtime/DataWrapper.h‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
#include "Common.h"
1212
#include "ConcurrentQueue.h"
13+
#include "ExternalMemory.h"
1314
#include "Metadata.h"
1415
#include "libffi.h"
1516

@@ -163,8 +164,15 @@ class BaseDataWrapper {
163164

164165
void GcUnprotect() { this->gcProtected_ = false; }
165166

167+
ExternalMemoryCharge* ExternalCharge() { return this->externalCharge_.get(); }
168+
169+
void SetExternalCharge(std::unique_ptr<ExternalMemoryCharge> charge) {
170+
this->externalCharge_ = std::move(charge);
171+
}
172+
166173
private:
167174
bool gcProtected_;
175+
std::unique_ptr<ExternalMemoryCharge> externalCharge_;
168176
};
169177

170178
class EnumDataWrapper : public BaseDataWrapper {
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
#ifndef ExternalMemory_h
2+
#define ExternalMemory_h
3+
4+
#include <cstddef>
5+
6+
#include "v8-external-memory-accounter.h"
7+
#include "v8.h"
8+
9+
namespace tns {
10+
11+
class BaseDataWrapper;
12+
13+
// Native bytes a wrapper keeps alive, reported to V8 so the collector weighs
14+
// them when scheduling collections. The wrapper owns it, so the bytes are
15+
// returned whenever the wrapper is deleted, from whichever path deletes it.
16+
//
17+
// Releasing is safe from any thread and after the isolate is gone: it pins
18+
// the isolate's gate instead of taking its Locker (the decrease is a single
19+
// atomic in V8 and never collects), and once the gate is closed the isolate
20+
// is being disposed and the bytes are dropped with it.
21+
class ExternalMemoryCharge {
22+
public:
23+
ExternalMemoryCharge(v8::Isolate* isolate, int gateId)
24+
: isolate_(isolate), gateId_(gateId) {}
25+
~ExternalMemoryCharge();
26+
ExternalMemoryCharge(const ExternalMemoryCharge&) = delete;
27+
ExternalMemoryCharge& operator=(const ExternalMemoryCharge&) = delete;
28+
29+
size_t Bytes() const { return bytes_; }
30+
v8::Isolate* Isolate() const { return isolate_; }
31+
32+
// Isolate thread only: an increase can run a collection before returning.
33+
void Set(size_t bytes);
34+
35+
private:
36+
v8::Isolate* isolate_;
37+
int gateId_;
38+
size_t bytes_ = 0;
39+
v8::ExternalMemoryAccounter accounter_;
40+
};
41+
42+
namespace ExternalMemory {
43+
44+
// Replaces the bytes `wrapper` reports for `isolate`; 0 drops the charge.
45+
// Isolate thread only. A wrapper charged for another isolate keeps its charge.
46+
void SetSize(v8::Isolate* isolate, BaseDataWrapper* wrapper, size_t bytes);
47+
48+
// Charges an estimate of the native footprint of the ObjC or CF object that
49+
// `value` wraps, unless it already carries a charge or its class has no
50+
// estimator. Only call it for objects JS plausibly holds the last reference
51+
// to: collecting the wrapper of an object that native code also retains frees
52+
// nothing, so charging it only buys collections that cannot pay off.
53+
void ChargeEstimatedSize(v8::Isolate* isolate, v8::Local<v8::Value> value);
54+
55+
// Forwards the system's memory pressure events to every live isolate.
56+
// Process-wide and idempotent.
57+
void StartMemoryPressureMonitoring();
58+
59+
} // namespace ExternalMemory
60+
61+
} // namespace tns
62+
63+
#endif /* ExternalMemory_h */
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
#include "ExternalMemory.h"
2+
3+
#include <CoreFoundation/CoreFoundation.h>
4+
#include <Foundation/Foundation.h>
5+
#include <dispatch/dispatch.h>
6+
#include <dlfcn.h>
7+
#include <objc/message.h>
8+
#include <objc/runtime.h>
9+
10+
#include "Caches.h"
11+
#include "DataWrapper.h"
12+
#include "Helpers.h"
13+
#include "IsolateWrapper.h"
14+
#include "NSDataAdapter.h"
15+
#include "Runtime.h"
16+
17+
using namespace v8;
18+
19+
namespace tns {
20+
21+
ExternalMemoryCharge::~ExternalMemoryCharge() {
22+
if (bytes_ == 0) {
23+
return;
24+
}
25+
// A closed gate means the isolate is being disposed, which drops its
26+
// external memory total with it.
27+
if (IsolateGates::TryPin(gateId_)) {
28+
accounter_.Decrease(isolate_, bytes_);
29+
IsolateGates::Unpin(gateId_);
30+
}
31+
}
32+
33+
void ExternalMemoryCharge::Set(size_t bytes) {
34+
if (bytes > bytes_) {
35+
accounter_.Increase(isolate_, bytes - bytes_);
36+
} else if (bytes < bytes_) {
37+
accounter_.Decrease(isolate_, bytes_ - bytes);
38+
}
39+
bytes_ = bytes;
40+
}
41+
42+
namespace ExternalMemory {
43+
44+
namespace {
45+
46+
// Values of Caches::ExternalSizeEstimators.
47+
enum Estimator : uint8_t {
48+
kNone,
49+
kNSData,
50+
kUIImage,
51+
// Toll-free bridged CF instances all share one class, so the estimator is
52+
// picked per object from its CFTypeID.
53+
kCFType,
54+
};
55+
56+
typedef CFTypeID (*TypeIDFn)(void);
57+
typedef size_t (*SizeOfFn)(CFTypeRef);
58+
59+
// Resolved at runtime so the runtime does not link CoreGraphics or CoreVideo
60+
// itself; apps that never load them simply get no estimate.
61+
struct CFEstimators {
62+
TypeIDFn cgImageTypeID = nullptr;
63+
SizeOfFn cgImageBytesPerRow = nullptr;
64+
SizeOfFn cgImageHeight = nullptr;
65+
TypeIDFn pixelBufferTypeID = nullptr;
66+
SizeOfFn pixelBufferDataSize = nullptr;
67+
};
68+
69+
const CFEstimators& GetCFEstimators() {
70+
static CFEstimators estimators = [] {
71+
CFEstimators e;
72+
e.cgImageTypeID = (TypeIDFn)dlsym(RTLD_DEFAULT, "CGImageGetTypeID");
73+
e.cgImageBytesPerRow = (SizeOfFn)dlsym(RTLD_DEFAULT, "CGImageGetBytesPerRow");
74+
e.cgImageHeight = (SizeOfFn)dlsym(RTLD_DEFAULT, "CGImageGetHeight");
75+
e.pixelBufferTypeID = (TypeIDFn)dlsym(RTLD_DEFAULT, "CVPixelBufferGetTypeID");
76+
e.pixelBufferDataSize = (SizeOfFn)dlsym(RTLD_DEFAULT, "CVPixelBufferGetDataSize");
77+
return e;
78+
}();
79+
return estimators;
80+
}
81+
82+
size_t CGImageSize(CFTypeRef image) {
83+
const CFEstimators& e = GetCFEstimators();
84+
if (image == nullptr || e.cgImageBytesPerRow == nullptr || e.cgImageHeight == nullptr) {
85+
return 0;
86+
}
87+
return e.cgImageBytesPerRow(image) * e.cgImageHeight(image);
88+
}
89+
90+
Estimator ResolveEstimator(Class klass) {
91+
// NSDataAdapter exposes bytes of a JS buffer that V8 already accounts for.
92+
if ([klass isSubclassOfClass:[NSDataAdapter class]]) {
93+
return kNone;
94+
}
95+
if ([klass isSubclassOfClass:[NSData class]]) {
96+
return kNSData;
97+
}
98+
static Class uiImageClass = objc_getClass("UIImage");
99+
if (uiImageClass != nil && [klass isSubclassOfClass:uiImageClass]) {
100+
return kUIImage;
101+
}
102+
static Class cfTypeClass = objc_getClass("__NSCFType");
103+
if (cfTypeClass != nil && klass == cfTypeClass) {
104+
return kCFType;
105+
}
106+
return kNone;
107+
}
108+
109+
size_t Estimate(Estimator estimator, id obj) {
110+
switch (estimator) {
111+
case kNSData:
112+
return [(NSData*)obj length];
113+
case kUIImage: {
114+
// UIKit is not linked by the runtime, so its selectors are registered
115+
// rather than declared. A symbol image rasterizes on -CGImage, and one
116+
// backed by a CIImage has no bitmap until it is rendered (NULL here).
117+
static SEL isSymbolImage = sel_registerName("isSymbolImage");
118+
static SEL cgImage = sel_registerName("CGImage");
119+
if ([obj respondsToSelector:isSymbolImage] &&
120+
((BOOL (*)(id, SEL))objc_msgSend)(obj, isSymbolImage)) {
121+
return 0;
122+
}
123+
return CGImageSize(((CFTypeRef (*)(id, SEL))objc_msgSend)(obj, cgImage));
124+
}
125+
case kCFType: {
126+
const CFEstimators& e = GetCFEstimators();
127+
CFTypeID type = CFGetTypeID((CFTypeRef)obj);
128+
if (e.cgImageTypeID != nullptr && type == e.cgImageTypeID()) {
129+
return CGImageSize((CFTypeRef)obj);
130+
}
131+
if (e.pixelBufferTypeID != nullptr && e.pixelBufferDataSize != nullptr &&
132+
type == e.pixelBufferTypeID()) {
133+
return e.pixelBufferDataSize((CFTypeRef)obj);
134+
}
135+
return 0;
136+
}
137+
case kNone:
138+
return 0;
139+
}
140+
return 0;
141+
}
142+
143+
} // namespace
144+
145+
void SetSize(Isolate* isolate, BaseDataWrapper* wrapper, size_t bytes) {
146+
ExternalMemoryCharge* charge = wrapper->ExternalCharge();
147+
if (charge == nullptr) {
148+
if (bytes == 0) {
149+
return;
150+
}
151+
auto created =
152+
std::make_unique<ExternalMemoryCharge>(isolate, Caches::Get(isolate)->getGateId());
153+
charge = created.get();
154+
wrapper->SetExternalCharge(std::move(created));
155+
} else if (charge->Isolate() != isolate) {
156+
return;
157+
}
158+
charge->Set(bytes);
159+
}
160+
161+
void ChargeEstimatedSize(Isolate* isolate, Local<Value> value) {
162+
BaseDataWrapper* wrapper = tns::GetValue(isolate, value);
163+
if (wrapper == nullptr || wrapper->Type() != WrapperType::ObjCObject ||
164+
wrapper->ExternalCharge() != nullptr) {
165+
return;
166+
}
167+
ObjCDataWrapper* objcWrapper = static_cast<ObjCDataWrapper*>(wrapper);
168+
id obj = objcWrapper->Data();
169+
if (obj == nil) {
170+
return;
171+
}
172+
173+
auto& estimators = Caches::Get(isolate)->ExternalSizeEstimators;
174+
Class klass = objcWrapper->Klass();
175+
auto it = estimators.find(klass);
176+
Estimator estimator;
177+
if (it != estimators.end()) {
178+
estimator = static_cast<Estimator>(it->second);
179+
} else {
180+
estimator = ResolveEstimator(klass);
181+
estimators.emplace(klass, estimator);
182+
}
183+
if (estimator == kNone) {
184+
return;
185+
}
186+
187+
SetSize(isolate, wrapper, Estimate(estimator, obj));
188+
}
189+
190+
void StartMemoryPressureMonitoring() {
191+
static dispatch_source_t source;
192+
static dispatch_once_t once;
193+
dispatch_once(&once, ^{
194+
source = dispatch_source_create(DISPATCH_SOURCE_TYPE_MEMORYPRESSURE, 0,
195+
DISPATCH_MEMORYPRESSURE_NORMAL | DISPATCH_MEMORYPRESSURE_WARN |
196+
DISPATCH_MEMORYPRESSURE_CRITICAL,
197+
dispatch_get_global_queue(QOS_CLASS_UTILITY, 0));
198+
dispatch_source_set_event_handler(source, ^{
199+
unsigned long status = dispatch_source_get_data(source);
200+
MemoryPressureLevel level = MemoryPressureLevel::kNone;
201+
if (status & DISPATCH_MEMORYPRESSURE_CRITICAL) {
202+
level = MemoryPressureLevel::kCritical;
203+
} else if (status & DISPATCH_MEMORYPRESSURE_WARN) {
204+
level = MemoryPressureLevel::kModerate;
205+
}
206+
Runtime::NotifyMemoryPressure(level);
207+
});
208+
dispatch_resume(source);
209+
});
210+
}
211+
212+
} // namespace ExternalMemory
213+
214+
} // namespace tns

‎NativeScript/runtime/Interop.h‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,8 @@ class Interop {
149149
v8::Local<v8::Object> interop);
150150
static void RegisterAdoptFunction(v8::Local<v8::Context> context,
151151
v8::Local<v8::Object> interop);
152+
static void RegisterExternalSizeFunctions(v8::Local<v8::Context> context,
153+
v8::Local<v8::Object> interop);
152154
static void RegisterSizeOfFunction(v8::Local<v8::Context> context,
153155
v8::Local<v8::Object> interop);
154156
static void RegisterEscapeExceptionFunction(v8::Local<v8::Context> context,

‎NativeScript/runtime/Interop.mm‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
#include "Constants.h"
88
#include "DictionaryAdapter.h"
99
#include "ExtVector.h"
10+
#include "ExternalMemory.h"
1011
#include "Helpers.h"
1112
#include "NSDataAdapter.h"
1213
#include "NativeScriptException.h"
@@ -1841,6 +1842,15 @@ inline bool isBool() {
18411842
methodCall.context_, methodCall.typeEncoding_, &call, marshalToPrimitive, nullptr, false,
18421843
methodCall.ownsReturnedObject_, methodCall.returnsUnmanaged_, methodCall.isInitializer_);
18431844

1845+
// Owned returns, initializers and class factory methods hand out objects JS
1846+
// most likely holds the last reference to. Instance getters do not: their
1847+
// results usually stay retained by the receiver.
1848+
bool returnsFreshObject = methodCall.ownsReturnedObject_ || methodCall.isInitializer_ ||
1849+
(!methodCall.isPrimitiveFunction_ && !isInstanceMethod);
1850+
if (returnsFreshObject && !result.IsEmpty() && result->IsObject()) {
1851+
ExternalMemory::ChargeEstimatedSize(v8::Isolate::GetCurrent(), result);
1852+
}
1853+
18441854
return result;
18451855
}
18461856

0 commit comments

Comments
 (0)