diff --git a/Ports/iOSPort/nativeSources/CN1MetalGlyphAtlas.m b/Ports/iOSPort/nativeSources/CN1MetalGlyphAtlas.m index cba3a170764..066616c4653 100644 --- a/Ports/iOSPort/nativeSources/CN1MetalGlyphAtlas.m +++ b/Ports/iOSPort/nativeSources/CN1MetalGlyphAtlas.m @@ -36,12 +36,13 @@ #include "TargetConditionals.h" #if !TARGET_OS_WATCH +#import "CN1Metalcompat.h" #import "CN1ES2compat.h" #ifdef CN1_USE_METAL #import "CN1MetalGlyphAtlas.h" -#define CN1_METAL_ATLAS_INITIAL_W 1024 -#define CN1_METAL_ATLAS_INITIAL_H 1024 +#define CN1_METAL_ATLAS_INITIAL_W 256 +#define CN1_METAL_ATLAS_INITIAL_H 256 #define CN1_METAL_ATLAS_MAX_W 2048 #define CN1_METAL_ATLAS_MAX_H 2048 #define CN1_METAL_ATLAS_PADDING 1 @@ -106,6 +107,22 @@ + (nullable instancetype)atlasForFont:(nonnull UIFont *)font { return atlas; } +// See initWithCTFont: -- one atlas per font SIZE makes the initial dimension a +// per-size cost, not a one-off. +static int cn1AtlasInitialDim(void) { + static int v = -1; + if (v < 0) { + const char *e = getenv("CN1_ATLAS_INITIAL"); + int parsed = e != NULL ? atoi(e) : CN1_METAL_ATLAS_INITIAL_W; + // Powers of two only, and never above the growth ceiling. + if (parsed < 64 || parsed > CN1_METAL_ATLAS_MAX_W || (parsed & (parsed - 1)) != 0) { + parsed = CN1_METAL_ATLAS_INITIAL_W; + } + v = parsed; + } + return v; +} + + (nullable instancetype)atlasForCTFont:(nonnull CTFontRef)ctFont { if (ctFont == NULL) return nil; CFStringRef psName = CTFontCopyPostScriptName(ctFont); @@ -182,8 +199,15 @@ - (instancetype)initWithCTFont:(CTFontRef)ctFont key:(NSString *)key { // render garbage / nothing — store premultiplied BGRA instead. _isColor = (CTFontGetSymbolicTraits(_ctFont) & kCTFontTraitColorGlyphs) != 0; - _textureWidth = CN1_METAL_ATLAS_INITIAL_W; - _textureHeight = CN1_METAL_ATLAS_INITIAL_H; + // The cache key is postScriptName|SIZE, so every distinct text size gets its + // OWN atlas -- and a Material text theme has fifteen-odd sizes across two or + // three families. Starting each one at 1024x1024 therefore reserved a + // megabyte per size before a single glyph was rasterised. Start small and let + // the existing doubling path (growAtlas, up to CN1_METAL_ATLAS_MAX) size each + // atlas to what its font actually needs. CN1_ATLAS_INITIAL overrides for A/B; + // pass 1024 to reproduce the old behaviour. + _textureWidth = cn1AtlasInitialDim(); + _textureHeight = _textureWidth; MTLTextureDescriptor *desc = [MTLTextureDescriptor texture2DDescriptorWithPixelFormat:(_isColor ? MTLPixelFormatBGRA8Unorm : MTLPixelFormatR8Unorm) @@ -192,6 +216,7 @@ - (instancetype)initWithCTFont:(CTFontRef)ctFont key:(NSString *)key { mipmapped:NO]; desc.usage = MTLTextureUsageShaderRead; _texture = [device newTextureWithDescriptor:desc]; + CN1_TEX_NOTE("glyphAtlasInitial", _texture); if (_texture == nil) { CFRelease(_ctFont); _ctFont = NULL; @@ -249,6 +274,7 @@ - (BOOL)tryGrowAtlas { width:(NSUInteger)newW height:(NSUInteger)newH mipmapped:NO]; desc.usage = MTLTextureUsageShaderRead; id newTex = [device newTextureWithDescriptor:desc]; + CN1_TEX_NOTE("glyphAtlasGrow", newTex); if (newTex == nil) return NO; // Drop slots; next reference re-rasterises into the larger atlas. @@ -303,10 +329,20 @@ - (CN1MetalGlyphSlot *)slotForGlyph:(CGGlyph)glyph { _cursorX = CN1_METAL_ATLAS_PADDING; _shelfHeight = 0; } - if (_shelfY + gh > _textureHeight - CN1_METAL_ATLAS_PADDING) { - if (![self tryGrowAtlas]) return nil; - if (_cursorX + gw > _textureWidth - CN1_METAL_ATLAS_PADDING || - _shelfY + gh > _textureHeight - CN1_METAL_ATLAS_PADDING) { + // Grow while the glyph fails to fit in EITHER dimension. Testing height + // alone was sufficient only because the atlas used to start at 1024 with + // glyphs capped at CN1_METAL_ATLAS_GLYPH_MAX (256), so a fresh shelf always + // had room across and the width test could never fire. At the smaller + // starting sizes cn1AtlasInitialDim now allows, a glyph can be as wide as + // the whole atlas -- and without a width test the slot below is handed to + // replaceRegion: as a region running past the texture edge. + // + // tryGrowAtlas drops every slot and resets the shelf cursor to the origin + // of the new, larger texture, so each iteration re-tests against it and the + // loop terminates either by fitting or by hitting the growth ceiling. + while (_cursorX + gw > _textureWidth - CN1_METAL_ATLAS_PADDING || + _shelfY + gh > _textureHeight - CN1_METAL_ATLAS_PADDING) { + if (![self tryGrowAtlas]) { return nil; } } diff --git a/Ports/iOSPort/nativeSources/CN1Metalcompat.h b/Ports/iOSPort/nativeSources/CN1Metalcompat.h index 925bdc90e42..c2088005533 100644 --- a/Ports/iOSPort/nativeSources/CN1Metalcompat.h +++ b/Ports/iOSPort/nativeSources/CN1Metalcompat.h @@ -82,6 +82,21 @@ typedef struct { simd_float4x4 transform; } CN1MetalMatrices; +// -------- GPU memory census (diagnostic; compiled out by default) -------- + +// vmmap tells you the GPU holds N megabytes; it cannot tell you which allocation +// made them. MTLTexture.allocatedSize is the authoritative per-texture figure and +// MTLDevice.currentAllocatedSize the authoritative live total, so recording the +// first at every creation site and printing the second attributes the whole +// number. Enable with -DCN1_TEXTURE_CENSUS. +#ifdef CN1_TEXTURE_CENSUS +void cn1TextureCensusNote(const char *site, id t); +void cn1TextureCensusDump(const char *label); +#define CN1_TEX_NOTE(site, t) cn1TextureCensusNote((site), (t)) +#else +#define CN1_TEX_NOTE(site, t) do {} while(0) +#endif + // -------- Encoder lifecycle (called by CodenameOne_GLViewController / METALView) -------- // Called by METALView.setFramebuffer after acquiring a command encoder for diff --git a/Ports/iOSPort/nativeSources/CN1Metalcompat.m b/Ports/iOSPort/nativeSources/CN1Metalcompat.m index db0604da3aa..781b6174b5b 100644 --- a/Ports/iOSPort/nativeSources/CN1Metalcompat.m +++ b/Ports/iOSPort/nativeSources/CN1Metalcompat.m @@ -132,6 +132,66 @@ static void ensurePipelineCache(void) { // --------------- Encoder lifecycle --------------- +#ifdef CN1_TEXTURE_CENSUS +#define CN1_TEXCENSUS_SITES 32 +static const char *cn1TexSiteNames[CN1_TEXCENSUS_SITES]; +static long long cn1TexSiteBytes[CN1_TEXCENSUS_SITES]; +static long cn1TexSiteCount[CN1_TEXCENSUS_SITES]; +static int cn1TexSiteUsed = 0; +static pthread_mutex_t cn1TexCensusMutex = PTHREAD_MUTEX_INITIALIZER; + +void cn1TextureCensusNote(const char *site, id t) { + if(t == nil || site == 0) { + return; + } + long long sz = (long long)[t allocatedSize]; + // Log the big ones individually with their true shape. allocatedSize alone + // cannot be reasoned about -- a 2048x1536 BGRA8 render target reporting 21MB + // against a raw 12.6MB is either a bigger texture than you assumed or GPU-side + // compression metadata, and only the dimensions tell you which. + if(sz >= 1048576) { + fprintf(stderr, "[TEX] %-24s %5lux%-5lu fmt=%lu storage=%lu %.2fMB (raw %.2fMB)\n", + site, (unsigned long)[t width], (unsigned long)[t height], + (unsigned long)[t pixelFormat], (unsigned long)[t storageMode], + sz / (1024.0 * 1024.0), + ([t width] * [t height] * 4.0) / (1024.0 * 1024.0)); + fflush(stderr); + } + pthread_mutex_lock(&cn1TexCensusMutex); + int i = 0; + for( ; i < cn1TexSiteUsed ; i++) { + if(cn1TexSiteNames[i] == site) { // string literals: pointer identity is enough + break; + } + } + if(i == cn1TexSiteUsed && cn1TexSiteUsed < CN1_TEXCENSUS_SITES) { + cn1TexSiteNames[cn1TexSiteUsed++] = site; + } + if(i < CN1_TEXCENSUS_SITES) { + cn1TexSiteBytes[i] += sz; + cn1TexSiteCount[i]++; + } + pthread_mutex_unlock(&cn1TexCensusMutex); +} + +void cn1TextureCensusDump(const char *label) { + id d = CN1MetalDevice(); + pthread_mutex_lock(&cn1TexCensusMutex); + // currentAllocatedSize is LIVE; the per-site figures are CUMULATIVE. A site + // whose cumulative total dwarfs the live total is churn, not residency -- + // which is itself the answer for the scratch/mutable-image paths. + fprintf(stderr, "[TEX:%s] device live currentAllocatedSize=%.2fMB\n", label, + d != nil ? [d currentAllocatedSize] / (1024.0 * 1024.0) : 0.0); + for(int i = 0 ; i < cn1TexSiteUsed ; i++) { + fprintf(stderr, "[TEX:%s] %9.2fMB cumulative %6ld allocs %s\n", label, + cn1TexSiteBytes[i] / (1024.0 * 1024.0), cn1TexSiteCount[i], + cn1TexSiteNames[i]); + } + pthread_mutex_unlock(&cn1TexCensusMutex); + fflush(stderr); +} +#endif + void CN1MetalBeginFrame(id encoder, simd_float4x4 projection, int framebufferWidth, @@ -1275,6 +1335,7 @@ void CN1MetalFillGradient(int kind, width:width height:height mipmapped:NO]; desc.usage = MTLTextureUsageShaderRead; id tex = [device newTextureWithDescriptor:desc]; + CN1_TEX_NOTE("alphaMaskGlyph", tex); if (tex == nil) { return nil; } @@ -1397,6 +1458,7 @@ void CN1MetalDrawAlphaMaskRadial(id texture, width:w height:h mipmapped:NO]; desc.usage = MTLTextureUsageShaderRead; id texture = [device newTextureWithDescriptor:desc]; + CN1_TEX_NOTE("textureFromUIImage", texture); [texture replaceRegion:MTLRegionMake2D(0, 0, w, h) mipmapLevel:0 withBytes:rawData @@ -1450,6 +1512,7 @@ void CN1MetalEnsureMutableTexture(GLUIImage *image, int width, int height) { desc.usage = MTLTextureUsageRenderTarget | MTLTextureUsageShaderRead; desc.storageMode = MTLStorageModePrivate; id tex = [device newTextureWithDescriptor:desc]; + CN1_TEX_NOTE("mutableImage", tex); if (tex == nil) return; // Clear new texture to the fill colour stashed by createNativeMutableImage. // Default Image.createImage(w, h) → 0xffffffff opaque white; createImage(w, h, argb) @@ -1527,6 +1590,7 @@ void CN1MetalEnsureMutableTexture(GLUIImage *image, int width, int height) { seedStencilDesc.usage = MTLTextureUsageRenderTarget; seedStencilDesc.storageMode = MTLStorageModePrivate; seedStencilTex = [device newTextureWithDescriptor:seedStencilDesc]; + CN1_TEX_NOTE("mutableSeedStencil", seedStencilTex); if (seedStencilTex != nil) { seedPass.stencilAttachment.texture = seedStencilTex; seedPass.stencilAttachment.loadAction = MTLLoadActionClear; @@ -1622,6 +1686,7 @@ BOOL CN1MetalBeginMutableImageDraw(GLUIImage *image) { stencilDesc.usage = MTLTextureUsageRenderTarget; stencilDesc.storageMode = MTLStorageModePrivate; stencilTex = [device newTextureWithDescriptor:stencilDesc]; + CN1_TEX_NOTE("mutableDrawStencil", stencilTex); if (stencilTex != nil) { desc.stencilAttachment.texture = stencilTex; desc.stencilAttachment.loadAction = MTLLoadActionClear; @@ -1770,6 +1835,7 @@ BOOL CN1MetalReadMutableImagePixels(GLUIImage *image, int *outARGB, desc.usage = MTLTextureUsageShaderRead; desc.storageMode = MTLStorageModeShared; id shared = [device newTextureWithDescriptor:desc]; + CN1_TEX_NOTE("mutableFlushShared", shared); if (shared == nil) return NO; id blitCb = [queue commandBuffer]; @@ -1853,6 +1919,7 @@ static void cn1MetalReadbackFreeData(void * __unused info, const void *data, siz desc.usage = MTLTextureUsageShaderRead; desc.storageMode = MTLStorageModeShared; id shared = [device newTextureWithDescriptor:desc]; + CN1_TEX_NOTE("mutableReadShared", shared); if (shared == nil) return nil; id blitCb = [queue commandBuffer]; diff --git a/Ports/iOSPort/nativeSources/EAGLView.m b/Ports/iOSPort/nativeSources/EAGLView.m index 792b401a5bc..7ad62fbf224 100644 --- a/Ports/iOSPort/nativeSources/EAGLView.m +++ b/Ports/iOSPort/nativeSources/EAGLView.m @@ -47,6 +47,25 @@ - (void)deleteFramebuffer; @implementation EAGLView +// Mirrors METALView's override -- see the long comment there. UIKit asks a +// container for its accessibility elements only when something is consuming the +// semantic tree, so the query is the signal that a client exists, and unlike the +// UIAccessibilityIs*Running flags it covers the technologies UIKit publishes no +// flag for (Voice Control, Full Keyboard Access). This backend needs its own +// copy: CodenameOne_GLViewController installs EAGLView rather than METALView +// whenever CN1_USE_METAL is absent, and without it every portable-tree +// invalidation is discarded on the GL path. +- (NSArray *)accessibilityElements { +#if !TARGET_OS_WATCH + // The note function is itself gated on !TARGET_OS_WATCH (UIAccessibility's + // status notifications do not exist there), so the call has to be too or the + // watch build fails to link. + extern void cn1AccessibilityNoteClientQuery(void); + cn1AccessibilityNoteClientQuery(); +#endif + return [super accessibilityElements]; +} + @synthesize context; @synthesize peerComponentsLayer; diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index a336a88846b..d5a8d01936a 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -7603,6 +7603,18 @@ static CGImageRef cn1_copyMetalScreenTextureImage(METALView *mv) { } id src = mv.screenTexture; if (src == nil) { + // Direct-to-drawable mode (CN1_DIRECT_DRAWABLE, opt-in) keeps no + // retained screen texture, so there is nothing here to read back and + // the caller falls through to drawViewHierarchyInRect:. That is correct + // on device but samples the CALayer's presented drawable, so it can lag + // a frame -- exactly the staleness this readback exists to avoid, and + // on headless Catalyst (no display link) it can be stale indefinitely. + // + // Reading the live drawable instead is not the fix: retaining it past + // present starves nextDrawable, and after present the buffer is + // recycled for the following frame. A deterministic capture needs a + // one-shot render into a scratch target, which is worth doing when the + // mode stops being opt-in. Until then the default path is unaffected. return NULL; } NSUInteger w = src.width; @@ -14894,6 +14906,115 @@ static void cn1_resetContext(void) { } #endif // !TARGET_OS_TV +#if !TARGET_OS_WATCH +BOOL cn1AccessibilityEagerLatched(void); +void cn1RegisterAccessibilityStatusObservers(void); + +// A technology STARTING is not a component mutation, so nothing in the portable +// layer would schedule the projection it needs and the native tree would stay +// empty until some unrelated UI change happened to invalidate something. These +// notifications are the trigger for that transition. +// +// Once any of them fires we latch eager projection on for the rest of the +// process rather than flipping it back and forth. The technologies UIKit will +// not report at all are handled by cn1AccessibilityNoteClientQuery below, which +// does not depend on flags or notifications. +static BOOL cn1A11yLatched = NO; + +BOOL cn1AccessibilityEagerLatched(void) { + return cn1A11yLatched; +} + +static void cn1AccessibilityStatusChanged(CFNotificationCenterRef center, void *observer, + CFStringRef name, const void *object, + CFDictionaryRef userInfo) { + cn1A11yLatched = YES; + com_codename1_impl_ios_IOSImplementation_assistiveTechnologyStatusChanged__( + CN1_THREAD_GET_STATE_PASS_SINGLE_ARG); +} + +// Called from the METALView / EAGLView accessibilityElements getters: a real +// client asked for +// the tree. This, not the running flags, is what makes the gate correct for the +// technologies UIKit will not report -- see the comment on that getter. +void cn1AccessibilityNoteClientQuery(void) { + if(cn1A11yLatched) { + return; // one transition only; this is on a UIKit query path + } + cn1A11yLatched = YES; + com_codename1_impl_ios_IOSImplementation_assistiveTechnologyStatusChanged__( + CN1_THREAD_GET_STATE_PASS_SINGLE_ARG); +} + +void cn1RegisterAccessibilityStatusObservers(void) { + static BOOL done = NO; + if(done) { + return; + } + done = YES; + // Built up rather than written as a literal: the AssistiveTouch notification + // is iOS 10, and ios.deployment_target lets IPhoneBuilder emit older + // targets, where the weakly-linked constant is nil -- and a nil inside an + // @[] literal raises. Same reason the running check below is guarded. + NSMutableArray *names = [NSMutableArray arrayWithCapacity:3]; + if(UIAccessibilityVoiceOverStatusDidChangeNotification != nil) { + [names addObject:UIAccessibilityVoiceOverStatusDidChangeNotification]; + } + if(UIAccessibilitySwitchControlStatusDidChangeNotification != nil) { + [names addObject:UIAccessibilitySwitchControlStatusDidChangeNotification]; + } + if(UIAccessibilityAssistiveTouchStatusDidChangeNotification != nil) { + [names addObject:UIAccessibilityAssistiveTouchStatusDidChangeNotification]; + } + for(NSString *n in names) { + CFNotificationCenterAddObserver(CFNotificationCenterGetLocalCenter(), NULL, + cn1AccessibilityStatusChanged, + (__bridge CFStringRef)n, NULL, + CFNotificationSuspensionBehaviorDeliverImmediately); + } +} +#endif + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isAssistiveTechnologyActive___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + // CN1_EAGER_A11Y=1 restores the old always-project behaviour for an A/B. + if(getenv("CN1_EAGER_A11Y") != NULL) { + return JAVA_TRUE; + } +#if !TARGET_OS_WATCH + // These three are the ENTIRE public surface for "is an assistive technology + // running": UIKit exposes IsVoiceOverRunning, IsSwitchControlRunning and + // IsAssistiveTouchRunning and nothing else. In particular there is no + // public running flag for Voice Control or Full Keyboard Access, so this + // cannot detect them -- see cn1AccessibilityStatusChanged for how that gap + // is covered rather than ignored. + cn1RegisterAccessibilityStatusObservers(); + if(cn1AccessibilityEagerLatched()) { + return JAVA_TRUE; + } + if(UIAccessibilityIsVoiceOverRunning() || UIAccessibilityIsSwitchControlRunning()) { + return JAVA_TRUE; + } + // iOS 10. Weakly linked, so on an older deployment target the symbol is + // null and calling it jumps through nothing -- test the pointer first. + if(UIAccessibilityIsAssistiveTouchRunning != NULL && + UIAccessibilityIsAssistiveTouchRunning()) { + return JAVA_TRUE; + } + return JAVA_FALSE; +#else + return JAVA_FALSE; +#endif +} + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isDirectToDrawable___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { +#ifdef CN1_USE_METAL + extern int cn1DirectToDrawableEnabled(void); + return cn1DirectToDrawableEnabled() ? JAVA_TRUE : JAVA_FALSE; +#else + return JAVA_FALSE; +#endif +} + JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isBiometricsSupported___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { #if !TARGET_OS_WATCH && !TARGET_OS_TV if (NSClassFromString(@"LAContext") == NULL) { diff --git a/Ports/iOSPort/nativeSources/METALView.h b/Ports/iOSPort/nativeSources/METALView.h index 7ffbd9015ab..1123ab1d002 100644 --- a/Ports/iOSPort/nativeSources/METALView.h +++ b/Ports/iOSPort/nativeSources/METALView.h @@ -56,6 +56,12 @@ // partial dirty-region flushes. Mark it invalid on foreground; the first // full-screen repaint clears it before drawing fresh content. BOOL retainedFramebufferInvalid; + + /// Direct-to-drawable only: whether THIS frame has already cleared its + /// drawable. The blur/glass/lens ops end the frame's encoder and open + /// another on the same drawable, and a second clear would erase everything + /// painted before the effect -- so only the first pass of a frame clears. + BOOL directFrameCleared; BOOL clearRetainedFramebufferOnNextFrame; } @property (nonatomic, retain) id commandQueue; diff --git a/Ports/iOSPort/nativeSources/METALView.m b/Ports/iOSPort/nativeSources/METALView.m index 42550329564..9d4ad3c180c 100644 --- a/Ports/iOSPort/nativeSources/METALView.m +++ b/Ports/iOSPort/nativeSources/METALView.m @@ -409,6 +409,98 @@ -(BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event // is never published, and CN1MetalGlyphAtlas+atlasForFont: returns nil // for every font -- which is exactly the "no atlas available" failure // the Mac CI surfaced. +// The stencil attachment is loadAction=Clear / storeAction=DontCare in every pass +// that binds it -- the frame pass, the present pass and the resize preserve -- so +// its contents never outlive a render pass. That is precisely the contract for +// MTLStorageModeMemoryless, which keeps the buffer in tile memory and costs ZERO +// physical bytes. The older code chose Private and reasoned the cost was "tiny +// (1 byte/pixel)"; CN1_TEXTURE_CENSUS measured it at 5.6MB per allocation at +// 2048x1536, so the assumption was simply wrong -- and A/B'd over three launches +// Memoryless is worth ~3.8MB of physical footprint. Memoryless exists only on +// tile-based (Apple-family) GPUs, hence the runtime probe: Intel Macs and the +// older Intel-Mac CI simulators still get Private. CN1_STENCIL_PRIVATE=1 forces +// the old behaviour for an A/B. +static MTLStorageMode cn1StencilStorageMode(id device) { + static int forcePrivate = -1; + if(forcePrivate < 0) { + forcePrivate = getenv("CN1_STENCIL_PRIVATE") != NULL ? 1 : 0; + } + // supportsFamily: is iOS 13 / macOS 10.15. ios.deployment_target is a build + // hint and IPhoneBuilder will happily emit a target below that, where this + // selector does not exist and the message would terminate the app during + // view initialisation. Probe before sending, and fall back to Private. + if(!forcePrivate && device != nil && + [device respondsToSelector:@selector(supportsFamily:)] && + [device supportsFamily:MTLGPUFamilyApple1]) { + return MTLStorageModeMemoryless; + } + return MTLStorageModePrivate; +} + +// DIRECT-TO-DRAWABLE mode (opt-in, CN1_DIRECT_DRAWABLE=1). +// +// The default renderer draws into a persistent full-window `screenTexture` and +// blits it onto the drawable at present. That retained buffer is what lets CN1 +// repaint only a dirty region: last frame's pixels are still there. It also +// costs a full-window texture -- 12.12MB at 2048x1536 -- which a renderer that +// draws straight into the drawable does not pay at all. +// +// In direct mode there is no screenTexture: the frame's render pass targets the +// drawable itself. The drawable must then be acquired at setFramebuffer (start +// of frame) rather than at present, and held across op encoding -- which does +// cost some nextDrawable latency, the reason the default path acquires it late. +// +// CORRECTNESS REQUIREMENT: the layer vends a DIFFERENT buffer each frame, so a +// region left unpainted shows content from two or three frames ago, not from +// last frame. Every frame must therefore repaint everything, which +// IOSImplementation.paintDirty arranges by enqueueing the whole Form whenever +// anything is dirty. Do not enable this mode without that half. +static int cn1DirectToDrawable(void) { + static int v = -1; + if(v < 0) { + v = getenv("CN1_DIRECT_DRAWABLE") != NULL ? 1 : 0; + } + return v; +} + +// Non-static so IOSNative can ask, since the Java paint model must follow the +// renderer's choice rather than decide it independently. +int cn1DirectToDrawableEnabled(void) { + return cn1DirectToDrawable(); +} + +// UIKit asks a container for its accessibility elements only when something is +// actually consuming the semantic tree, so this query IS the signal that a +// client exists -- and unlike UIAccessibilityIsVoiceOverRunning and friends it +// does not depend on per-technology flags. That matters because UIKit publishes +// running flags for exactly three technologies (VoiceOver, Switch Control, +// AssistiveTouch) and none for Voice Control or Full Keyboard Access, so a +// flags-only gate cannot see those clients at all -- including when they are +// already enabled at launch, where no status notification ever fires either. +// +// Latching here covers every technology, present and future. The first query +// may see a tree that has not been projected yet; noting the client schedules +// that projection and the resulting layout-changed notification brings the +// client back for the real one, so it is self-correcting rather than a +// permanently empty tree. +// +// One configuration this does not see: CodenameOne_GLViewController re-roots +// self.view to a plain UIView when a peer component is added mid-transition, and +// updateAccessibilityTree then sets the elements on THAT view rather than on +// this one. There the gate falls back to the running flags and the status +// notifications -- i.e. to the behaviour without this hook, not to something +// worse. +- (NSArray *)accessibilityElements { +#if !TARGET_OS_WATCH + // The note function is itself gated on !TARGET_OS_WATCH (UIAccessibility's + // status notifications do not exist there), so the call has to be too or the + // watch build fails to link. + extern void cn1AccessibilityNoteClientQuery(void); + cn1AccessibilityNoteClientQuery(); +#endif + return [super accessibilityElements]; +} + - (void)cn1SetupMetal { self.clearsContextBeforeDrawing = NO; if ([[UIScreen mainScreen] respondsToSelector:@selector(scale)] && isRetina()) { @@ -422,14 +514,15 @@ - (void)cn1SetupMetal { metalLayer.device = MTLCreateSystemDefaultDevice(); metalLayer.opaque = TRUE; metalLayer.pixelFormat = MTLPixelFormatBGRA8Unorm; - // framebufferOnly must be NO: presentFramebuffer blits screenTexture + // framebufferOnly must be NO: presentScreenTextureInto: blits screenTexture // into the drawable via copyFromTexture:toTexture:, and Metal's blit // validation aborts ("destinationTexture must not be a framebufferOnly - // texture") when the destination drawable was framebufferOnly. Debug - // builds with Metal API Validation enabled crash on the first paint; - // release builds silently produced undefined-behaviour copies on some - // GPUs. Trading the (small) memoryless-storage benefit for a working - // present path. + // texture") when the destination drawable was framebufferOnly. + // Presenting with a textured quad instead (which framebufferOnly = YES + // permits) was built and measured: Metal validation was clean and the + // resident IOSurface did not move by even a megabyte, because Apple's + // lossless compression saves bandwidth rather than allocation. Not worth + // a second present path. metalLayer.framebufferOnly = NO; // Colour space for the Metal layer. Default is sRGB so colours // match the GL path's CAEAGLLayer output: without it, CG-rasterised @@ -468,7 +561,27 @@ - (void)cn1SetupMetal { // most CAMetalLayer use cases. Combined with our nextDrawable // skip-frame fallback in presentFramebuffer this keeps the // pipeline non-blocking under pressure. - metalLayer.maximumDrawableCount = 3; + // + // The cap is an upper bound on what CoreAnimation MAY vend, not a + // reservation, and in the retained path it is inert: 2 and 3 both settle + // at exactly 24.0MB of IOSurface, because that path only ever has two + // drawables in flight (measured at four paired launches per setting). + // + // Direct mode is different -- it holds the drawable across the whole + // frame, so the third slot really is taken. At 3 the GPU allocation + // swings between 21.8MB and 27.2MB from launch to launch; at 2 it is + // 21.8MB every time. That is worth ~4.5MB on average and, more usefully, + // turns a 10.8MB launch-to-launch spread into 1.9MB. Hence 2 there and 3 + // here, where the headroom is free. CN1_MAX_DRAWABLES re-runs the A/B. + { + const char *e = getenv("CN1_MAX_DRAWABLES"); + NSUInteger n = e != NULL ? (NSUInteger)atoi(e) + : (cn1DirectToDrawable() ? 2 : 3); + if(n < 2 || n > 3) { + n = 3; // CAMetalLayer only accepts 2 or 3 + } + metalLayer.maximumDrawableCount = n; + } // `makeCommandQueue` is the Swift name; Objective-C uses `newCommandQueue`. // newCommandQueue returns +1 (NARC family); release the local after // the synthesized retain setter takes its own retain so we end up at @@ -477,6 +590,15 @@ - (void)cn1SetupMetal { self.commandQueue = newQueue; #ifndef CN1_USE_ARC [newQueue release]; +#endif +#ifdef CN1_TEXTURE_CENSUS + // Frame-count triggers are useless here: an idle gallery presents fewer + // than twenty frames in thirty seconds, so a "every 200th present" hook + // never fires. Wall-clock does. + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(20 * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ cn1TextureCensusDump("t20"); }); + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(35 * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ cn1TextureCensusDump("t35"); }); #endif // Publish the device + queue to CN1Metalcompat so its global // accessors don't have to dereference our (UIView) layer from @@ -484,9 +606,25 @@ - (void)cn1SetupMetal { // means CN1MetalDevice / CN1MetalCommandQueue become cheap static // reads safe to invoke from the EDT and any background GCD queue. CN1MetalSetDeviceAndCommandQueue(metalLayer.device, self.commandQueue); - CGSize sz = self.bounds.size; - CGFloat s = self.contentScaleFactor; - [self updateFrameBufferSize:(int)(sz.width * s) h:(int)(sz.height * s)]; + // Do NOT size the framebuffer here. self.bounds has not been laid out + // yet, and on Mac Catalyst an unlaid-out view reports the whole DISPLAY: + // CN1_TEXTURE_CENSUS measured the resulting screenTexture at 3456x2234 / + // 30.03MB, allocated and then thrown away moments later when + // layoutSubviews supplies the real 2048x1536 / 12.12MB. That transient + // 30MB is also the best candidate for the +-20MB run-to-run swing in + // this process's footprint, since whether it is still resident when you + // sample is pure timing. + // + // layoutSubviews always follows and sizes it correctly, and + // createRenderPassDescriptor already treats a nil screenTexture as "no + // frame this pass", so a frame attempted in between is a safe no-op + // rather than a crash. CN1_EAGER_FRAMEBUFFER=1 restores the old eager + // sizing for an A/B. + if (getenv("CN1_EAGER_FRAMEBUFFER") != NULL) { + CGSize sz = self.bounds.size; + CGFloat s = self.contentScaleFactor; + [self updateFrameBufferSize:(int)(sz.width * s) h:(int)(sz.height * s)]; + } // Drop the glyph atlas + text cache + gradient cache on memory // pressure. Pipeline state cache stays — those are precious to @@ -549,6 +687,24 @@ - (void)deleteFramebuffer } +// The polygon-clip stencil (#3921) is needed by both render paths, so it is +// factored out of updateFrameBufferSize: -- direct mode returns early, before +// the screen-texture initialisation it has no texture to do. +- (void)buildStencilTextureForWidth:(int)pw height:(int)ph layer:(CAMetalLayer *)layer { + // Storage mode is chosen at runtime -- see cn1StencilStorageMode. + MTLTextureDescriptor *stencilDesc = [MTLTextureDescriptor + texture2DDescriptorWithPixelFormat:MTLPixelFormatStencil8 + width:pw height:ph mipmapped:NO]; + stencilDesc.usage = MTLTextureUsageRenderTarget; + stencilDesc.storageMode = cn1StencilStorageMode(layer.device); + id newStencil = [layer.device newTextureWithDescriptor:stencilDesc]; + CN1_TEX_NOTE("stencilTexture", newStencil); + self.stencilTexture = newStencil; +#ifndef CN1_USE_ARC + [newStencil release]; +#endif +} + -(void)updateFrameBufferSize:(int)w h:(int)h { // Trust caller-supplied physical-pixel dimensions; fall back to bounds // only if the caller passes 0. Reading self.bounds alone is unsafe @@ -629,6 +785,10 @@ -(void)updateFrameBufferSize:(int)w h:(int)h { MTLTextureDescriptor *desc = [MTLTextureDescriptor texture2DDescriptorWithPixelFormat:MTLPixelFormatBGRA8Unorm width:pw height:ph mipmapped:NO]; + // ShaderRead measured FREE here: dropping it (with the blit present path, the + // only consumer that samples rather than blits) left allocatedSize unchanged + // at 21.1MB. The 21.1MB-vs-12.1MB gap against an identical drawable is GPU + // compression metadata, not this flag. desc.usage = MTLTextureUsageRenderTarget | MTLTextureUsageShaderRead; desc.storageMode = MTLStorageModePrivate; // newTextureWithDescriptor returns +1 (NARC family); the synthesized @@ -636,7 +796,11 @@ -(void)updateFrameBufferSize:(int)w h:(int)h { // local once the property holds its own retain so we don't leak the // previous screenTexture every time the framebuffer is resized // (rotation, window resize, etc.). - id newScreen = [layer.device newTextureWithDescriptor:desc]; + // Direct mode renders into the drawable, so the retained buffer -- and its + // 12.12MB -- is simply never allocated. + id newScreen = cn1DirectToDrawable() ? nil + : [layer.device newTextureWithDescriptor:desc]; + CN1_TEX_NOTE("screenTexture", newScreen); self.screenTexture = newScreen; #ifndef CN1_USE_ARC [newScreen release]; @@ -648,6 +812,17 @@ -(void)updateFrameBufferSize:(int)w h:(int)h { // exists we scale-blit it in (preserving the last visible content across // the resize -- see the oldScreen capture above); otherwise we just clear // to opaque black. + // Direct mode has no screenTexture to initialise, and a render pass with a + // nil colour attachment and no explicit renderTargetWidth/Height is invalid + // -- it survives today only because the encoder comes back nil and every + // message to it is a no-op, which is not something to rely on. + if (self.screenTexture == nil) { + [self buildStencilTextureForWidth:pw height:ph layer:layer]; +#ifndef CN1_USE_ARC + [oldScreen release]; +#endif + return; + } id clearCb = [self.commandQueue commandBuffer]; MTLRenderPassDescriptor *clearPass = [MTLRenderPassDescriptor renderPassDescriptor]; clearPass.colorAttachments[0].texture = self.screenTexture; @@ -672,8 +847,9 @@ -(void)updateFrameBufferSize:(int)w h:(int)h { texture2DDescriptorWithPixelFormat:MTLPixelFormatStencil8 width:pw height:ph mipmapped:NO]; clearStencilDesc.usage = MTLTextureUsageRenderTarget; - clearStencilDesc.storageMode = MTLStorageModePrivate; + clearStencilDesc.storageMode = cn1StencilStorageMode(layer.device); clearStencilTex = [layer.device newTextureWithDescriptor:clearStencilDesc]; + CN1_TEX_NOTE("resizePreserveStencil", clearStencilTex); if (clearStencilTex != nil) { clearPass.stencilAttachment.texture = clearStencilTex; clearPass.stencilAttachment.loadAction = MTLLoadActionClear; @@ -701,22 +877,7 @@ -(void)updateFrameBufferSize:(int)w h:(int)h { [clearEnc endEncoding]; [clearCb commit]; - // Build a matching Stencil8 attachment for polygon-shape clipping - // (#3921). Private storage rather than Memoryless because Memoryless - // is only supported on tile-based deferred GPUs (iOS Simulator on - // older Intel-Mac CI runners doesn't accept it). The stencil is - // ephemeral conceptually but Private works on all GPU families and - // the size cost is tiny (1 byte/pixel). - MTLTextureDescriptor *stencilDesc = [MTLTextureDescriptor - texture2DDescriptorWithPixelFormat:MTLPixelFormatStencil8 - width:pw height:ph mipmapped:NO]; - stencilDesc.usage = MTLTextureUsageRenderTarget; - stencilDesc.storageMode = MTLStorageModePrivate; - id newStencil = [layer.device newTextureWithDescriptor:stencilDesc]; - self.stencilTexture = newStencil; -#ifndef CN1_USE_ARC - [newStencil release]; -#endif + [self buildStencilTextureForWidth:pw height:ph layer:layer]; // Push the preserved frame onto the layer so the rotation never shows // black (#5162) -- but NOT synchronously here. updateFrameBufferSize: runs @@ -769,6 +930,9 @@ -(void)presentPreservedFrameIfNeeded { } needsResizePresent = NO; if (self.screenTexture == nil) { + // Direct mode keeps no previous frame to re-present; the resize simply + // repaints (#5162's black flash is not reachable there because every + // frame is a full repaint anyway). return; } // An encoder may be mid-frame if the EDT started painting between the resize @@ -784,15 +948,7 @@ -(void)presentPreservedFrameIfNeeded { return; } id presentCb = [self.commandQueue commandBuffer]; - id blit = [presentCb blitCommandEncoder]; - [blit copyFromTexture:self.screenTexture - sourceSlice:0 sourceLevel:0 - sourceOrigin:MTLOriginMake(0, 0, 0) - sourceSize:MTLSizeMake(framebufferWidth, framebufferHeight, 1) - toTexture:dr.texture - destinationSlice:0 destinationLevel:0 - destinationOrigin:MTLOriginMake(0, 0, 0)]; - [blit endEncoding]; + [self presentScreenTextureInto:dr.texture commandBuffer:presentCb]; [presentCb presentDrawable:dr]; [presentCb commit]; } @@ -837,7 +993,22 @@ -(void)prepareRetainedFramebufferForDrawRect:(CGRect)rect displayWidth:(int)disp } -(void)createRenderPassDescriptor { - if (self.screenTexture == nil) { + id target = self.screenTexture; + if (cn1DirectToDrawable()) { + // Acquire the frame's drawable here, at the START of the frame, and hold + // it until presentFramebuffer. The default path deliberately acquires + // late to keep dwell time down; direct mode cannot, because the ops + // encode straight into it. + if (self.drawable == nil) { + CAMetalLayer *layer = (CAMetalLayer*)self.layer; + self.drawable = [layer nextDrawable]; + // A freshly vended buffer holds a frame from two or three presents + // ago, so it has to be cleared once -- but only once per frame. + directFrameCleared = NO; + } + target = self.drawable.texture; + } + if (target == nil) { self.renderPassDescriptor = nil; return; } @@ -848,8 +1019,26 @@ -(void)createRenderPassDescriptor { // before. MTLLoadActionLoad preserves previous pixels (vs MTLLoadActionClear // which would wipe everything each frame) — CN1 only queues diff ops // per frame; the OpenGL path relies on its renderbuffer persisting. - colorAttachment.texture = self.screenTexture; - if (clearRetainedFramebufferOnNextFrame) { + colorAttachment.texture = target; + if (cn1DirectToDrawable()) { + if (!directFrameCleared) { + // First pass of the frame. The buffer holds a frame from two or + // three presents ago, so Load would be meaningless; the Form + // repaints in full every frame (IOSImplementation.paintDirty) and + // Clear is the cheaper load action on a tile GPU anyway. + colorAttachment.loadAction = MTLLoadActionClear; + colorAttachment.clearColor = MTLClearColorMake(0.0, 0.0, 0.0, 1.0); + directFrameCleared = YES; + } else { + // Mid-frame restart: blurScreenRegionX / glassScreenRegionX / + // lensScreenRegionX end the encoder and reopen one on the SAME + // drawable. Clearing again here would wipe everything painted + // before the effect and present only the effect and whatever + // followed it. + colorAttachment.loadAction = MTLLoadActionLoad; + } + clearRetainedFramebufferOnNextFrame = NO; + } else if (clearRetainedFramebufferOnNextFrame) { colorAttachment.loadAction = MTLLoadActionClear; colorAttachment.clearColor = MTLClearColorMake(0.0, 0.0, 0.0, 1.0); clearRetainedFramebufferOnNextFrame = NO; @@ -905,12 +1094,24 @@ - (void)setFramebuffer // command buffer -- the only path that produces real glass on a running app // (the offscreen-image blur only covered fidelity tiles). Costs a GPU sync per // glass paint; acceptable for the small, mostly-static nav/tab bars. +// The texture holding what has already been painted THIS frame -- the backdrop +// a glass/blur/lens op samples. That is screenTexture in the default retained +// path and the frame's own drawable in direct mode, where these ops read back +// from the drawable they are drawing into (legal: the layer is framebufferOnly +// = NO, and each op ends and commits the encoder before blitting). +- (id)backdropTexture { + if (self.screenTexture != nil) { + return self.screenTexture; + } + return self.drawable.texture; +} + - (void)blurScreenRegionX:(int)x y:(int)y w:(int)w h:(int)h radius:(float)radius { - if (self.screenTexture == nil || w <= 0 || h <= 0 || radius <= 0.0f) { + if ([self backdropTexture] == nil || w <= 0 || h <= 0 || radius <= 0.0f) { return; } CGFloat s = self.contentScaleFactor; - int texW = (int)self.screenTexture.width, texH = (int)self.screenTexture.height; + int texW = (int)[self backdropTexture].width, texH = (int)[self backdropTexture].height; int fx = (int)(x * s), fy = (int)(y * s), fw = (int)(w * s), fh = (int)(h * s); if (fx < 0) { fw += fx; fx = 0; } if (fy < 0) { fh += fy; fy = 0; } @@ -939,9 +1140,10 @@ - (void)blurScreenRegionX:(int)x y:(int)y w:(int)w h:(int)h radius:(float)radius desc.usage = MTLTextureUsageShaderRead; desc.storageMode = MTLStorageModeShared; id scratch = [device newTextureWithDescriptor:desc]; + CN1_TEX_NOTE("blurScratch", scratch); id blitCb = [self.commandQueue commandBuffer]; id blit = [blitCb blitCommandEncoder]; - [blit copyFromTexture:self.screenTexture sourceSlice:0 sourceLevel:0 + [blit copyFromTexture:[self backdropTexture] sourceSlice:0 sourceLevel:0 sourceOrigin:MTLOriginMake(fx, fy, 0) sourceSize:MTLSizeMake(fw, fh, 1) toTexture:scratch destinationSlice:0 destinationLevel:0 destinationOrigin:MTLOriginMake(0, 0, 0)]; @@ -1053,7 +1255,7 @@ static uint64_t cn1GlassBackdropHash(const uint8_t *bytes, size_t len) { - (void)glassScreenRegionX:(int)x y:(int)y w:(int)w h:(int)h radius:(float)radius cornerRadius:(float)cornerRadius sat:(float)sat scale:(float)scale offset:(float)offset refract:(float)refract specular:(float)specular { - if (self.screenTexture == nil || w <= 0 || h <= 0 || radius <= 0.0f) { + if ([self backdropTexture] == nil || w <= 0 || h <= 0 || radius <= 0.0f) { return; } // CN1-logical -> framebuffer-pixel scale. NOT contentScaleFactor alone: @@ -1064,7 +1266,7 @@ - (void)glassScreenRegionX:(int)x y:(int)y w:(int)w h:(int)h radius:(float)radiu // the region in the fidelity app (wrong screenTexture slice + 3x radius). float sv = scaleValue > 0.0f ? scaleValue : 1.0f; CGFloat s = self.contentScaleFactor / sv; - int texW = (int)self.screenTexture.width, texH = (int)self.screenTexture.height; + int texW = (int)[self backdropTexture].width, texH = (int)[self backdropTexture].height; int fx = (int)(x * s), fy = (int)(y * s), fw = (int)(w * s), fh = (int)(h * s); if (fx < 0) { fw += fx; fx = 0; } if (fy < 0) { fh += fy; fy = 0; } @@ -1098,9 +1300,10 @@ - (void)glassScreenRegionX:(int)x y:(int)y w:(int)w h:(int)h radius:(float)radiu desc.usage = MTLTextureUsageShaderRead; desc.storageMode = MTLStorageModeShared; id scratch = [device newTextureWithDescriptor:desc]; + CN1_TEX_NOTE("glassScratch", scratch); id blitCb = [self.commandQueue commandBuffer]; id blit = [blitCb blitCommandEncoder]; - [blit copyFromTexture:self.screenTexture sourceSlice:0 sourceLevel:0 + [blit copyFromTexture:[self backdropTexture] sourceSlice:0 sourceLevel:0 sourceOrigin:MTLOriginMake(ax0, ay0, 0) sourceSize:MTLSizeMake(aw, ah, 1) toTexture:scratch destinationSlice:0 destinationLevel:0 destinationOrigin:MTLOriginMake(0, 0, 0)]; @@ -1222,12 +1425,12 @@ - (void)drawGlassPatch:(uint32_t *)patch fw:(int)fw fh:(int)fh x:(int)x y:(int)y // samples within its own bounds. Runs during the drain like the glass op. - (void)lensScreenRegionX:(int)x y:(int)y w:(int)w h:(int)h cornerRadius:(float)cornerRadius magnify:(float)magnify aberration:(float)aberration tintColor:(int)tintColor tintStrength:(float)tintStrength { - if (self.screenTexture == nil || w <= 0 || h <= 0) { + if ([self backdropTexture] == nil || w <= 0 || h <= 0) { return; } float sv = scaleValue > 0.0f ? scaleValue : 1.0f; CGFloat s = self.contentScaleFactor / sv; - int texW = (int)self.screenTexture.width, texH = (int)self.screenTexture.height; + int texW = (int)[self backdropTexture].width, texH = (int)[self backdropTexture].height; int fx = (int)(x * s), fy = (int)(y * s), fw = (int)(w * s), fh = (int)(h * s); if (fx < 0) { fw += fx; fx = 0; } if (fy < 0) { fh += fy; fy = 0; } @@ -1261,11 +1464,12 @@ - (void)lensScreenRegionX:(int)x y:(int)y w:(int)w h:(int)h cornerRadius:(float) desc.usage = MTLTextureUsageShaderRead; desc.storageMode = MTLStorageModePrivate; id scratch = [device newTextureWithDescriptor:desc]; + CN1_TEX_NOTE("lensScratch", scratch); if (scratch == nil) { [self setFramebuffer]; return; } // 3) Blit the bar region screenTexture -> scratch on the frame's command buffer. id blit = [self.commandBuffer blitCommandEncoder]; - [blit copyFromTexture:self.screenTexture sourceSlice:0 sourceLevel:0 + [blit copyFromTexture:[self backdropTexture] sourceSlice:0 sourceLevel:0 sourceOrigin:MTLOriginMake(fx, fy, 0) sourceSize:MTLSizeMake(fw, fh, 1) toTexture:scratch destinationSlice:0 destinationLevel:0 destinationOrigin:MTLOriginMake(0, 0, 0)]; @@ -1284,6 +1488,25 @@ - (void)lensScreenRegionX:(int)x y:(int)y w:(int)w h:(int)h cornerRadius:(float) CN1MetalDrawLens(scratch, x, y, w, h, fw, fh, magnify, aberration, tintColor, tintStrength, crPx); } +// Put the frame held in screenTexture onto \a dst (a drawable's texture), +// either as a full-screen textured quad (default) or via a blit +// (CN1_PRESENT_BLIT=1). Both write the same pixels; they differ only in +// Both write the same pixels onto the drawable. +-(void)presentScreenTextureInto:(id)dst commandBuffer:(id)cb { + if (self.screenTexture == nil || dst == nil || cb == nil) { + return; + } + id blit = [cb blitCommandEncoder]; + [blit copyFromTexture:self.screenTexture + sourceSlice:0 sourceLevel:0 + sourceOrigin:MTLOriginMake(0, 0, 0) + sourceSize:MTLSizeMake(framebufferWidth, framebufferHeight, 1) + toTexture:dst + destinationSlice:0 destinationLevel:0 + destinationOrigin:MTLOriginMake(0, 0, 0)]; + [blit endEncoding]; +} + - (BOOL)presentFramebuffer { if (self.renderCommandEncoder == nil) { @@ -1304,28 +1527,77 @@ - (BOOL)presentFramebuffer self.renderCommandEncoder = nil; self.renderPassDescriptor = nil; - // Acquire the drawable here (not in setFramebuffer) to minimise its - // dwell time -- holding a drawable across the whole op-encoding phase - // stalls nextDrawable for subsequent frames. - CAMetalLayer *layer = (CAMetalLayer*)self.layer; - id dr = [layer nextDrawable]; + // Direct mode already holds the drawable -- the ops encoded straight into + // it, so there is nothing to copy and nothing to acquire. + id dr = self.drawable; + if (!cn1DirectToDrawable()) { + // Acquire the drawable here (not in setFramebuffer) to minimise its + // dwell time -- holding a drawable across the whole op-encoding phase + // stalls nextDrawable for subsequent frames. + CAMetalLayer *layer = (CAMetalLayer*)self.layer; + dr = [layer nextDrawable]; + if (dr == nil) { + // Memory pressure dropped the drawable. Commit render work so + // screenTexture still updates; skip this frame's present. + [self.commandBuffer commit]; + self.commandBuffer = nil; + return NO; + } + self.drawable = dr; + [self presentScreenTextureInto:dr.texture commandBuffer:self.commandBuffer]; + } if (dr == nil) { - // Memory pressure dropped the drawable. Commit render work so - // screenTexture still updates; skip this frame's present. [self.commandBuffer commit]; self.commandBuffer = nil; return NO; } - self.drawable = dr; - id blit = [self.commandBuffer blitCommandEncoder]; - [blit copyFromTexture:self.screenTexture - sourceSlice:0 sourceLevel:0 - sourceOrigin:MTLOriginMake(0, 0, 0) - sourceSize:MTLSizeMake(framebufferWidth, framebufferHeight, 1) - toTexture:dr.texture - destinationSlice:0 destinationLevel:0 - destinationOrigin:MTLOriginMake(0, 0, 0)]; - [blit endEncoding]; +#ifdef CN1_VERIFY_PRESENT + // Reads back a patch of the drawable that is ACTUALLY being presented. + // Nothing else here can prove the screen is not blank: if the drawable were + // nil the render pass would be nil, every op would no-op against a null + // encoder, and Metal validation would still be clean and FIRSTFRAME would + // still print. An offscreen repaint (bench_shot) cannot prove it either -- + // it paints the scene graph again rather than reading the framebuffer. + { + static int shots = 0; + if (dr != nil && shots < 3) { + shots++; + int px = 128, py = 128, pw = 64, ph = 64; + id dev = CN1MetalDevice(); + MTLTextureDescriptor *pd = [MTLTextureDescriptor + texture2DDescriptorWithPixelFormat:MTLPixelFormatBGRA8Unorm + width:pw height:ph mipmapped:NO]; + pd.usage = MTLTextureUsageShaderRead; + pd.storageMode = MTLStorageModeShared; + id probe = [dev newTextureWithDescriptor:pd]; + if (probe != nil) { + id pb = [self.commandBuffer blitCommandEncoder]; + [pb copyFromTexture:dr.texture sourceSlice:0 sourceLevel:0 + sourceOrigin:MTLOriginMake(px, py, 0) + sourceSize:MTLSizeMake(pw, ph, 1) + toTexture:probe destinationSlice:0 destinationLevel:0 + destinationOrigin:MTLOriginMake(0, 0, 0)]; + [pb endEncoding]; + [self.commandBuffer addCompletedHandler:^(id cb) { + uint32_t buf[64 * 64]; + [probe getBytes:buf bytesPerRow:64 * 4 + fromRegion:MTLRegionMake2D(0, 0, 64, 64) mipmapLevel:0]; + unsigned long nonBlack = 0, sum = 0; + for(int i = 0 ; i < 64 * 64 ; i++) { + uint32_t p = buf[i]; + if((p & 0x00FFFFFF) != 0) nonBlack++; + sum = sum * 31u + p; + } + fprintf(stderr, "BENCH:PRESENTED nonBlack=%lu/4096 hash=%lu\n", nonBlack, sum); + fflush(stderr); +#ifndef CN1_USE_ARC + [probe release]; +#endif + }]; + } + } + } +#endif [self.commandBuffer presentDrawable:dr]; [self.commandBuffer commit]; self.drawable = nil; diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index 4a14451df8f..6f8b6f86ddd 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -63,6 +63,7 @@ import com.codename1.push.PushActionsProvider; import com.codename1.ui.BrowserComponent; import com.codename1.ui.Form; +import com.codename1.ui.accessibility.AccessibilityManager; import com.codename1.ui.Label; import com.codename1.ui.events.ActionEvent; import com.codename1.ui.events.ActionListener; @@ -1809,7 +1810,78 @@ static boolean hitTest(int x, int y) { return true; } + /// True when the Metal view renders straight into the drawable, so no retained + /// screen texture exists. Resolved once -- the renderer decides it at startup. + private int directToDrawable = -1; + + private boolean isDirectToDrawable() { + if (directToDrawable < 0) { + boolean d = false; + try { + d = nativeInstance.isDirectToDrawable(); + } catch (Throwable t) { + d = false; + } + directToDrawable = d ? 1 : 0; + } + return directToDrawable == 1; + } + + /// Direct-to-drawable rendering presents a DIFFERENT buffer every frame, so a + /// region left unpainted does not show last frame -- it shows whatever was in + /// that buffer two or three presents ago. Painting only the dirty components, + /// which is the whole point of {@code paintDirty}, is therefore incorrect in + /// that mode. + /// + /// Enqueueing the current Form ahead of the superclass call is enough to fix + /// it: a Component queued with a null dirty region is painted under a + /// full-screen clip, and {@code repaint(Animation)} already drops any child + /// whose ancestor is queued, so this both forces the full paint and collapses + /// the queue instead of adding to it. Components enqueued BEFORE this call + /// still repaint redundantly; that is a real cost, and it is the trade the + /// mode exists to make. + @Override + public void paintDirty() { + if (isDirectToDrawable() && hasPendingPaints()) { + Form f = Display.getInstance().getCurrent(); + if (f != null) { + // Painted HERE rather than enqueued. repaint(f) would append, + // and the superclass drains in order, so the full-form paint + // would land on top of overlay animations already queued -- + // Container.TransitionAnimation queues its Transition through + // Display.repaint(t), and painting the form over it makes the + // transition vanish or snap to its end state. The background + // has to go down first and the queue drain on top of it. + Graphics wrapper = getCodenameOneGraphics(); + if (wrapper != null) { + int dwidth = getDisplayWidth(); + int dheight = getDisplayHeight(); + wrapper.translate(-wrapper.getTranslateX(), -wrapper.getTranslateY()); + wrapper.resetAffine(); + wrapper.setClip(0, 0, dwidth, dheight); + f.setDirtyRegion(null); + f.paintComponent(wrapper, true); + } + } + } + super.paintDirty(); + } + public void flushGraphics(int x, int y, int width, int height) { + if (isDirectToDrawable()) { + // The flush region is not just a hint here: CodenameOne_GLViewController + // hands it to ClipRect.setDrawRect and the Metal path clamps every + // screen op to it. The superclass derives it from the queued + // components alone, so with a partially dirty Component in the queue + // it would be that component's rect -- while direct mode has already + // cleared the ENTIRE drawable and repainted the whole Form (see + // paintDirty). Everything the Form drew outside that rect would be + // clipped away and the rest of the frame would present black. + x = 0; + y = 0; + width = getDisplayWidth(); + height = getDisplayHeight(); + } globalGraphics.clipApplied = false; flushBuffer(0, x, y, width, height); if (isDesktop()) { @@ -13031,6 +13103,61 @@ public boolean isAccessibilityTreeSupported() { return true; } + /// UIKit PULLS the semantic tree (it asks the view for accessibility elements), + /// so the portable tree only has to be projected eagerly while something is + /// actually listening. The base class notes exactly this -- "pull-based ports + /// should override this to return true only while assistive technology is + /// active" -- but the iOS port never overrode it, so it inherited + /// isAccessibilityTreeSupported() and rebuilt the whole snapshot on EVERY + /// invalidation: every layout, every scroll, every text setter, on every + /// device, whether or not VoiceOver was running. Measured with + /// malloc_history that was 4.0MB of live allocation under + /// AccessibilityManager.getSnapshot on an idle Mac Catalyst app with no + /// assistive technology running at all, plus the CPU to build it. + /// + /// Turning VoiceOver on mid-session is picked up on the next invalidation -- + /// the flag is read per call, and any mutation after that point projects + /// normally. + /// Invoked from native when an assistive-technology status notification fires. + /// + /// The status flip itself is not a component mutation, so without this nothing + /// would schedule the projection a newly-started technology needs and the + /// native tree would stay empty until some unrelated UI change happened to + /// invalidate a component. Marks the whole current form dirty so the very next + /// pass rebuilds and pushes the tree. + public static void assistiveTechnologyStatusChanged() { + final IOSImplementation impl = instance; + if (impl == null) { + return; + } + Display d = Display.getInstance(); + if (d == null) { + return; + } + d.callSerially(new Runnable() { + @Override + public void run() { + Form f = Display.getInstance().getCurrent(); + if (f != null) { + AccessibilityManager.getInstance().invalidate(f, + AccessibilityManager.CHANGE_STRUCTURE + | AccessibilityManager.CHANGE_CONTENT + | AccessibilityManager.CHANGE_STATE); + } + } + }); + } + + @Override + public boolean isAccessibilityTreeUpdateRequired() { + try { + return nativeInstance.isAssistiveTechnologyActive(); + } catch (Throwable t) { + // Never let a semantics optimisation take the app down. + return true; + } + } + public static void performAccessibilityActionFromNative(long nodeId, String actionId, String argument) { if (instance != null) { instance.performAccessibilityAction(nodeId, actionId, argument); diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java index 5e75e8820bf..ab94be29f49 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java @@ -1073,6 +1073,18 @@ native void walletExtensionAddPassEntry(boolean remote, String identifier, Strin /** True when LAContext.canEvaluatePolicy(deviceOwnerAuthenticationWithBiometrics) succeeds. */ native boolean isBiometricsSupported(); + /// True when the Metal view renders straight into the CAMetalLayer drawable + /// instead of into a retained screen texture. The renderer decides this, not + /// Java, but the paint model has to follow it: a direct-mode frame presents a + /// different buffer every time, so anything left unpainted shows a frame from + /// two or three presents ago. See IOSImplementation.paintDirty. + native boolean isDirectToDrawable(); + + /// True while VoiceOver, Switch Control or Voice Control is actually running. + /// UIKit pulls the semantic tree on demand, so nothing needs projecting eagerly + /// unless one of these is listening. + native boolean isAssistiveTechnologyActive(); + /** Same as {@link #isBiometricsSupported()} but also requires at least one biometric to be enrolled. */ native boolean canAuthenticateBiometric(); diff --git a/vm/ByteCodeTranslator/src/cn1_globals.h b/vm/ByteCodeTranslator/src/cn1_globals.h index 9aba3030b45..aed96f7ba37 100644 --- a/vm/ByteCodeTranslator/src/cn1_globals.h +++ b/vm/ByteCodeTranslator/src/cn1_globals.h @@ -225,6 +225,15 @@ struct clazz { // an exact registry instead of a distance heuristic (see gcMarkObject). Only // meaningful under CN1_CONSERVATIVE_GC_ROOTS; stays zero otherwise. JAVA_BOOLEAN cn1ClazzRegistered; +#ifdef CN1_ALLOC_CENSUS + // TRAILING for the same reason as cn1ClazzRegistered above: the generated + // clazz initializers are positional and never name these, so C zero-fills + // them and no translator change is needed. Plain non-atomic counters -- this + // is a diagnostic build only, and an increment lost to a race costs nothing + // a census is meant to answer. + long cn1AllocCount; + long cn1AllocBytes; +#endif }; #ifdef CN1_CONSERVATIVE_GC_ROOTS @@ -243,6 +252,21 @@ extern void cn1GcRegisterClazz(struct clazz* c); #define CN1_CLAZZ_REGISTER(cptr) do {} while(0) #endif +// Allocation volume BY CLASS. "The heap is 60MB" names nothing anyone can act +// on; "3.1MB of java_lang_Long" names a fix. Counted at every allocation path +// (both inline BiBOP bump paths and the out-of-line codenameOneGcMalloc), so +// unlike a walk of allObjectsInHeap it does not silently miss the BiBOP and +// nursery objects -- which is precisely where small, high-churn objects such as +// boxed values live. +#ifdef CN1_ALLOC_CENSUS +#define CN1_ALLOC_CENSUS_COUNT(cptr, sz) do { \ + struct clazz* __cc = (struct clazz*)(cptr); \ + if(__cc != 0) { __cc->cn1AllocCount++; __cc->cn1AllocBytes += (long)(sz); } \ + } while(0) +#else +#define CN1_ALLOC_CENSUS_COUNT(cptr, sz) do {} while(0) +#endif + #define EMPTY_INTERFACES ((const struct clazz**)0) struct JavaObjectPrototype { @@ -733,8 +757,36 @@ extern JAVA_OBJECT* constantPoolObjects; extern int classListSize; extern struct clazz* classesList[]; -// this needs to be fixed to actually return a JAVA_OBJECT... -#define STRING_FROM_CONSTANT_POOL_OFFSET(off) constantPoolObjects[off] +/** + * The String object for a literal, materialised on FIRST USE. + * + * initConstantPool used to build every literal in the application before main + * ran. On a large transpiled application that was 38,238 java.lang.String + * objects and their backing arrays -- 61% of every live object in the process -- + * for an application that touches a few thousand of them: every localisation of + * every string for every locale, every UIID, every demo description, all + * allocated, all pinned by the pool's own GC root, none of them ever read. + * + * The fast path is a load and a predicted-taken branch, which is what the old + * macro compiled to anyway. Literal IDENTITY is preserved -- the slow path is + * serialised and double-checked, so "x" == "x" stays true, which Java requires + * and generated code relies on. + */ +extern JAVA_OBJECT cn1MaterializeConstantPoolString(int off); +/// Start-up attribution probe; prints elapsed-since-process-start when +/// CN1_STARTUP_PHASES is set, and costs one cached getenv otherwise. +extern void cn1StartupPhase(const char* name); +/// ACQUIRE, paired with the RELEASE store in cn1MaterializeConstantPoolString. +/// The mutex there serialises writers and keeps literal identity, but it +/// establishes nothing with these readers -- they never take it. Without the +/// pairing a thread may observe the published pointer while the String's fields +/// are still invisible to it (a plain concurrent read/write is a data race in +/// any case, and on arm64 it is one that actually reorders). +#define CN1_CONSTANT_POOL_LOAD(off) \ + ((JAVA_OBJECT)__atomic_load_n(&constantPoolObjects[off], __ATOMIC_ACQUIRE)) +#define STRING_FROM_CONSTANT_POOL_OFFSET(off) \ + (__builtin_expect(CN1_CONSTANT_POOL_LOAD(off) != JAVA_NULL, 1) \ + ? CN1_CONSTANT_POOL_LOAD(off) : cn1MaterializeConstantPoolString(off)) #define BC_IINC(val, num) ilocals_##val##_ += num; @@ -1575,6 +1627,7 @@ static inline JAVA_OBJECT cn1BibopFastAlloc(CODENAME_ONE_THREAD_STATE, int size, // __codenameOneReferenceCount + __codenameOneThreadData relocated out of the // header (force-visited / monitor side tables); no per-object stores. o->__heapPosition = CN1_BIBOP_HEAP_POS; + CN1_ALLOC_CENSUS_COUNT(parent, size); #ifdef DEBUG_GC_ALLOCATIONS o->className = threadStateData->callStackClass[threadStateData->callStackOffset - 1]; o->line = threadStateData->callStackLine[threadStateData->callStackOffset - 1]; @@ -1664,6 +1717,7 @@ static inline JAVA_OBJECT cn1BibopFastAllocNoZero(CODENAME_ONE_THREAD_STATE, int // class pointer. o->__codenameOneParentClsReference = (struct clazz*)0; o->__heapPosition = CN1_BIBOP_HEAP_POS; + CN1_ALLOC_CENSUS_COUNT(parent, size); #ifdef DEBUG_GC_ALLOCATIONS o->className = threadStateData->callStackClass[threadStateData->callStackOffset - 1]; o->line = threadStateData->callStackLine[threadStateData->callStackOffset - 1]; diff --git a/vm/ByteCodeTranslator/src/cn1_globals.m b/vm/ByteCodeTranslator/src/cn1_globals.m index 0c488012226..7256c221003 100644 --- a/vm/ByteCodeTranslator/src/cn1_globals.m +++ b/vm/ByteCodeTranslator/src/cn1_globals.m @@ -1367,6 +1367,7 @@ static void cn1DrainDeadThreadPending() { * A simple concurrent mark algorithm that traverses the currently running threads */ extern int recursionKey; // force-mark pass epoch (defined below, near gcMarkObject) +static void cn1ForceVisitedPrune(int key); // force-visited side table sweep (defined with the table) // ---- SATB (snapshot-at-the-beginning) deletion-barrier log ------------------- // gcSatbActive is set for the whole concurrent mark and read by CN1_SATB_DELETE on @@ -1572,6 +1573,9 @@ void codenameOneGCMark() { // Bump the force-mark pass epoch so the force-visited side table's prior-cycle entries // read as not-visited (relocated from the old per-object __codenameOneReferenceCount). recursionKey++; + // Release the force-visited entries for objects that were not force-visited in + // the cycle just finished -- most of them for objects the last sweep freed. + cn1ForceVisitedPrune(recursionKey); #ifdef CN1_CONSERVATIVE_GC_ROOTS // PHASE 3b: ensure the universal thread-stop signal handler is installed (idempotent, // first GC only). Used to stop+scan threads we cannot cooperatively park. @@ -1829,7 +1833,14 @@ void codenameOneGCMark() { { extern const char* cn1GcMarkPhase; cn1GcMarkPhase = "constant-pool"; } #endif for(int iter = 0 ; iter < CN1_CONSTANT_POOL_SIZE ; iter++) { - gcMarkObject(d, (JAVA_OBJECT)constantPoolObjects[iter], JAVA_TRUE); + // Most entries are JAVA_NULL now (the pool fills on first use); the + // explicit test skips the call rather than paying it per empty slot. + // Acquire for the same reason: the collector marks through this pointer + // and must see a fully constructed String behind it. + JAVA_OBJECT poolEntry = (JAVA_OBJECT)__atomic_load_n(&constantPoolObjects[iter], __ATOMIC_ACQUIRE); + if(poolEntry != JAVA_NULL) { + gcMarkObject(d, poolEntry, JAVA_TRUE); + } } #ifdef CN1_CONSERVATIVE_GC_ROOTS @@ -2296,6 +2307,75 @@ void printObjectTypesInHeap(CODENAME_ONE_THREAD_STATE) { #ifndef CN1_DISABLE_BIBOP static void cn1BibopSweep(CODENAME_ONE_THREAD_STATE); #endif +#ifdef CN1_HEAP_HISTOGRAM +/** + * Names what is actually ON the heap, biggest first. + * + * "The heap is 65MB" is not a thing anyone can fix; "there are 90,000 of THIS + * class" is. Every live object carries a pointer to its clazz, and the + * collector already has to walk the whole registry, so a census costs one pass + * and answers the only question that matters when a heap is bigger than it + * should be. Compiled out unless CN1_HEAP_HISTOGRAM is defined; print it from a + * diagnostic build, read it, and take the top of the list. + */ +static void cn1HeapHistogram(void) { + #define CN1_HIST_MAX 512 + static const char* names[CN1_HIST_MAX]; + static long counts[CN1_HIST_MAX]; + int used = 0; + long total = 0; + int t = currentSizeOfAllObjectsInHeap; + for(int iter = 0 ; iter < t ; iter++) { + JAVA_OBJECT o = allObjectsInHeap[iter]; + if(o == JAVA_NULL || o->__codenameOneParentClsReference == 0) { + continue; + } + total++; + const char* n = o->__codenameOneParentClsReference->clsName; + if(n == 0) { + n = "?"; + } + int found = -1; + for(int i = 0 ; i < used ; i++) { + if(names[i] == n) { // clsName is a static literal: pointer identity is enough + found = i; + break; + } + } + if(found < 0) { + if(used < CN1_HIST_MAX) { + found = used++; + names[found] = n; + counts[found] = 0; + } else { + continue; + } + } + counts[found]++; + } + fprintf(stderr, "[HEAP] %ld live objects in %d classes\n", total, used); + for(int shown = 0 ; shown < 25 ; shown++) { + int best = -1; + for(int i = 0 ; i < used ; i++) { + if(counts[i] > 0 && (best < 0 || counts[i] > counts[best])) { + best = i; + } + } + if(best < 0) { + break; + } + fprintf(stderr, "[HEAP] %8ld %s\n", counts[best], names[best]); + counts[best] = 0; + } + fflush(stderr); +} +#endif + +#ifdef CN1_HEAP_HISTOGRAM +void cn1HeapHistogramPublic(void) { + cn1HeapHistogram(); +} +#endif // Release the threads the mark parked as aggressive allocators. Called on both exits // from codenameOneGCSweep -- see the one that skips the reclaim. static void cn1GcReleaseBlockedThreads(void) { @@ -4181,6 +4261,129 @@ void cn1GcRegisterClazz(struct clazz* c) { c->cn1ClazzRegistered = JAVA_TRUE; } +#if defined(CN1_ALLOC_CENSUS) && defined(__APPLE__) +// cn1HeapAccounting weighs legacy-heap blocks with malloc_size (the object +// header does not record instance size). The other include of this header is +// scoped to CN1_GC_VERIFY builds, so the census needs its own. +#include +#endif + +#ifdef CN1_ALLOC_CENSUS +/** + * Prints allocation volume by class, biggest first. + * + * Deliberately a census of what was ALLOCATED rather than of what is live: churn + * is what costs, and a live-object walk cannot see the BiBOP or nursery objects + * at all (they never enter allObjectsInHeap), which is exactly where the small + * high-turnover objects sit. Counters are read without synchronisation; a + * diagnostic wants the shape, not the last digit. + */ +/** + * Separates the JAVA heap from native allocation. + * + * vmmap cannot do this: BiBOP arenas are posix_memalign'd so they land in + * MALLOC_LARGE and the legacy heap in MALLOC_SMALL, side by side with every + * Metal, CoreGraphics and image buffer the process owns. Comparing "our malloc + * total" against another runtime's figures therefore compares a heap against a + * heap PLUS a renderer. These numbers are the heap on its own. + * + * Reserved is what BiBOP has taken from the allocator; live is what objects + * actually occupy. The difference is the honest cost of the page pool: BiBOP + * never frees a page back per-object, it pools swept pages by size class. + */ +void cn1HeapAccounting(const char* label) { + long long pages = 0, capBytes = 0, liveBytes = 0, ownedPages = 0, emptyPages = 0; + CN1BibopPage* p = atomic_load_explicit(&bibopAllPages, memory_order_acquire); + while(p != 0) { + pages++; + capBytes += CN1_BIBOP_PAGE_SIZE; + int bi = atomic_load_explicit(&p->bumpIndex, memory_order_relaxed); + int live = bi - p->freeCount; + if(live < 0) { + live = 0; + } + if(live == 0) { + emptyPages++; + } + liveBytes += (long long)live * (long long)p->slotSize; + if(p->owned) { + ownedPages++; + } + p = atomic_load_explicit(&p->nextAll, memory_order_acquire); + } + // The legacy heap is NOT part of the BiBOP figures above and is easy to + // forget: objects too big for the largest size class go through calloc and + // the allObjectsInHeap table instead. Reporting only the BiBOP total + // understates the Java heap by whatever these weigh, so weigh them -- + // malloc_size gives the true block size for a calloc'd pointer. + long long legacyBytes = 0, legacyLive = 0; + int nHeap = currentSizeOfAllObjectsInHeap; + for(int i = 0 ; i < nHeap ; i++) { + JAVA_OBJECT o = allObjectsInHeap[i]; + if(o == JAVA_NULL) { + continue; + } + legacyLive++; +#if defined(__APPLE__) + legacyBytes += (long long)malloc_size((void*)o); +#endif + } + fprintf(stderr, + "[JHEAP:%s] bibop pages=%lld reserved=%.2fMB live=%.2fMB slack=%.2fMB " + "(owned=%lld empty=%lld) | legacy objects=%lld bytes=%.2fMB | " + "JAVA TOTAL live=%.2fMB resident=%.2fMB\n", + label, pages, capBytes / 1048576.0, liveBytes / 1048576.0, + (capBytes - liveBytes) / 1048576.0, ownedPages, emptyPages, + legacyLive, legacyBytes / 1048576.0, + (liveBytes + legacyBytes) / 1048576.0, + (capBytes + legacyBytes) / 1048576.0); + fflush(stderr); +} + +void cn1AllocCensus(const char* label) { + struct Row { const char* name; long count; long bytes; }; + static struct Row rows[4096]; + int used = 0; + long long totalBytes = 0, totalCount = 0; + for(int i = 0 ; i < CN1_CLAZZ_SET_SIZE ; i++) { + uintptr_t v = atomic_load_explicit(&cn1ClazzSet[i], memory_order_relaxed); + if(v == 0) { + continue; + } + struct clazz* c = (struct clazz*)v; + if(c->cn1AllocBytes == 0 && c->cn1AllocCount == 0) { + continue; + } + totalBytes += c->cn1AllocBytes; + totalCount += c->cn1AllocCount; + if(used < 4096) { + rows[used].name = c->clsName ? c->clsName : "?"; + rows[used].count = c->cn1AllocCount; + rows[used].bytes = c->cn1AllocBytes; + used++; + } + } + fprintf(stderr, "[ALLOC:%s] %lld objects, %lld bytes (%.1fMB) across %d classes\n", + label, totalCount, totalBytes, totalBytes / (1024.0 * 1024.0), used); + for(int shown = 0 ; shown < 30 ; shown++) { + int best = -1; + for(int i = 0 ; i < used ; i++) { + if(rows[i].bytes > 0 && (best < 0 || rows[i].bytes > rows[best].bytes)) { + best = i; + } + } + if(best < 0) { + break; + } + fprintf(stderr, "[ALLOC:%s] %10ld bytes %9ld objs %s\n", + label, rows[best].bytes, rows[best].count, rows[best].name); + rows[best].bytes = 0; + } + fflush(stderr); +} +#endif + + // ========================== Immortal object registry ========================== // Objects deliberately REMOVED from the heap table (interned constant-pool // strings, static-final removal values, VM cache singletons) are unresolvable @@ -6536,6 +6739,7 @@ JAVA_OBJECT codenameOneGcMalloc(CODENAME_ONE_THREAD_STATE, int size, struct claz } allocationsSinceLastGC += size; totalAllocations += size; + CN1_ALLOC_CENSUS_COUNT(parent, size); #ifdef CN1_GC_INSTRUMENT extern long long cn1_instr_allocCount; cn1_instr_allocCount++; #endif @@ -6910,6 +7114,36 @@ void codenameOneGcFree(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT obj) { struct CN1FVEntry { JAVA_OBJECT key; int epoch; struct CN1FVEntry* next; }; #define CN1_FV_BUCKETS 4096 static struct CN1FVEntry* cn1FVBuckets[CN1_FV_BUCKETS]; +// Entries currently in the buckets, so the sweep below can decide whether the +// table is worth walking at all. +static long cn1FVLive = 0; +// Entries the sweep has taken out of the buckets, kept for re-use instead of +// being handed back to the allocator. An allocation storm collects constantly and +// force-visits a different set of objects every time, so the same entries are +// retired and re-created cycle after cycle: freeing them made the sweep 13% of +// the objectAllocation benchmark all by itself (44.5ms against 38.8ms with the +// sweep taken out). Recycling keeps the table's memory at the high-water mark of +// objects force-visited at once -- which is the bound that matters -- and takes +// the allocator out of the collector's inner loop entirely. +static struct CN1FVEntry* cn1FVRecycle = 0; +// Entries come from ARENA BLOCKS, not from one malloc each. Nothing ever frees +// an entry individually -- the sweep moves it to the recycle list above -- so +// per-entry allocation buys nothing and costs a great deal: measured on the +// gallery, this table held 74,443 live entries, which was HALF of the 150,836 +// malloc nodes in the whole process. Every one carries allocator bookkeeping and +// scatters through the small-block regions, and on this platform the pages a +// peak of them touched are never given back. One block per 4096 entries turns +// 74,443 allocations into eighteen. +// +// Blocks are never freed. The table's high-water mark is the bound that matters +// and the recycle list already caps reuse; handing a block back would only be +// possible if every entry in it were free at once, which is not worth tracking. +#define CN1_FV_ARENA_ENTRIES 4096 +static struct CN1FVEntry* cn1FVArena = 0; +static int cn1FVArenaUsed = CN1_FV_ARENA_ENTRIES; // forces the first block +// Sweep only once the table has actually grown; the table has to be BOUNDED, not +// minimal, and a sweep of a small table is pure cost. +#define CN1_FV_PRUNE_THRESHOLD 8192 static inline unsigned cn1FVHash(JAVA_OBJECT o) { uintptr_t p = (uintptr_t)o; p >>= 4; @@ -6928,12 +7162,67 @@ static int cn1ForceVisitedTestAndSet(JAVA_OBJECT obj, int key) { } e = e->next; } - e = (struct CN1FVEntry*)malloc(sizeof(struct CN1FVEntry)); + if(cn1FVRecycle != 0) { + e = cn1FVRecycle; + cn1FVRecycle = e->next; + } else { + if(cn1FVArenaUsed >= CN1_FV_ARENA_ENTRIES) { + struct CN1FVEntry* block = (struct CN1FVEntry*)malloc( + sizeof(struct CN1FVEntry) * CN1_FV_ARENA_ENTRIES); + if(block == 0) { + // Out of memory for the guard table. Returning 0 says "not + // visited", which costs a re-traversal and terminates anyway + // because the mark bit is already set -- slower, never wrong. + return 0; + } + cn1FVArena = block; + cn1FVArenaUsed = 0; + } + e = &cn1FVArena[cn1FVArenaUsed++]; + } e->key = obj; e->epoch = key; e->next = cn1FVBuckets[h]; cn1FVBuckets[h] = e; + cn1FVLive++; return 0; } +// Drop entries no longer in use. Nothing ever removed from this table: an entry +// was allocated the first time an object was force-visited and then kept for the +// life of the process, keyed by an object pointer that the very next sweep could +// free. Two costs, both unbounded. The obvious one is 32 bytes of malloc per +// distinct object ever force-visited -- measured at 134,619 live 32-byte blocks +// in a gallery application that had 113,824 live Java objects, i.e. the table had +// outgrown the heap it was describing. The one that hurts more is the bucket +// chains: lookup is a linear walk, so every force-visit in every later cycle pays +// for every object that died in an earlier one, and the collector gets slower the +// longer the application runs -- exactly the shape that never shows up in a +// benchmark and always shows up in a long session. +// +// Called once per cycle from codenameOneGCMark, right after recursionKey is +// bumped, on the GC thread with no marking in flight. An entry survives if it was +// force-visited in the cycle that just finished (epoch == key - 1), so a working +// set that keeps being force-visited is never reallocated and the table settles at +// the size of that working set instead of the size of all history. +static void cn1ForceVisitedPrune(int key) { + if(cn1FVLive < CN1_FV_PRUNE_THRESHOLD) { + return; + } + for(int b = 0 ; b < CN1_FV_BUCKETS ; b++) { + struct CN1FVEntry** pp = &cn1FVBuckets[b]; + while(*pp) { + struct CN1FVEntry* e = *pp; + if(e->epoch < key - 1) { + *pp = e->next; + e->next = cn1FVRecycle; + cn1FVRecycle = e; + cn1FVLive--; + } else { + pp = &e->next; + } + } + } +} + // Iterative mark using an explicit worklist. The previous implementation recursed // through reference fields, building one C stack frame per Java reference traversed. // iOS gives secondary pthreads a ~512KB stack, so a chain of a few thousand references @@ -8474,7 +8763,77 @@ void cn1ThrowStackOverflow(CODENAME_ONE_THREAD_STATE) { throwException(threadStateData, soe); } +static pthread_mutex_t constantPoolMutex = PTHREAD_MUTEX_INITIALIZER; + +/** + * Builds the String for a literal the first time anything asks for it. + * + * Serialised and double-checked so that a literal has exactly ONE String for + * the life of the process: Java guarantees `"x" == "x"`, and generated code + * compares literals by identity. The new object is reachable from this thread's + * pending allocations until it is stored into the pool array, which is itself a + * GC root, so there is no window in which it can be collected. + */ +JAVA_OBJECT cn1MaterializeConstantPoolString(int off) { + struct ThreadLocalData* threadStateData = getThreadLocalData(); + pthread_mutex_lock(&constantPoolMutex); + JAVA_OBJECT o = (JAVA_OBJECT)__atomic_load_n(&constantPoolObjects[off], __ATOMIC_ACQUIRE); + if(o == JAVA_NULL) { + o = newStringFromCString(threadStateData, constantPool[off]); + // RELEASE so a reader that sees this pointer also sees the String's + // fields. The mutex orders writers against each other and nothing else: + // the fast path in STRING_FROM_CONSTANT_POOL_OFFSET never acquires it. + __atomic_store_n(&constantPoolObjects[off], o, __ATOMIC_RELEASE); + } + pthread_mutex_unlock(&constantPoolMutex); + return o; +} + +#if defined(__APPLE__) +#include +#include +/** + * Milliseconds since this PROCESS was forked, for start-up attribution. + * + * Measured against the KERNEL's record of process start, not a mark taken inside + * the application, because the interesting part of a slow launch is what happens + * before any of our code runs. Gated on CN1_STARTUP_PHASES: a shipping build + * pays one getenv and nothing else. + */ +static int cn1StartupPhasesOn(void) { + static int on = -1; + if(on < 0) { + on = getenv("CN1_STARTUP_PHASES") ? 1 : 0; + } + return on; +} + +double cn1MillisSinceProcessStart(void) { + struct kinfo_proc info; + size_t len = sizeof(info); + int mib[4] = { CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid() }; + if(sysctl(mib, 4, &info, &len, NULL, 0) != 0) { + return -1.0; + } + struct timeval now; + gettimeofday(&now, NULL); + struct timeval start = info.kp_proc.p_starttime; + return (now.tv_sec - start.tv_sec) * 1000.0 + (now.tv_usec - start.tv_usec) / 1000.0; +} + +void cn1StartupPhase(const char* name) { + if(!cn1StartupPhasesOn()) { + return; + } + fprintf(stderr, "BENCH:PHASE-AT %-28s %8.1f ms\n", name, cn1MillisSinceProcessStart()); + fflush(stderr); +} +#else +void cn1StartupPhase(const char* name) { } +#endif + void initConstantPool() { + cn1StartupPhase("main"); __STATIC_INITIALIZER_java_lang_Class(getThreadLocalData()); struct ThreadLocalData* threadStateData = getThreadLocalData(); enteringNativeAllocations(); @@ -8492,13 +8851,15 @@ void initConstantPool() { //int cStringSize = CN1_CONSTANT_POOL_SIZE * sizeof(char*); //int jStringSize = CN1_CONSTANT_POOL_SIZE * sizeof(JAVA_ARRAY); //JAVA_OBJECT internedStrings = get_static_java_lang_String_str(); + // The pool starts EMPTY. Every literal used to be built right here, before + // main ran: on a large transpiled application that was 38,238 String objects + // plus their backing arrays, 61% of every live object in the process, for an + // application that reads a few thousand of them. They are built on first use + // now -- see cn1MaterializeConstantPoolString. The array itself is still + // allocated eagerly because a non-null constantPoolObjects is what the rest + // of the runtime tests to decide the VM is up. for(int iter = 0 ; iter < CN1_CONSTANT_POOL_SIZE ; iter++) { - //long length = strlen(constantPool[iter]); - //cStringSize += length + 1; - //jStringSize += length * sizeof(JAVA_ARRAY_CHAR) + sizeof(struct JavaArrayPrototype) + sizeof(struct obj__java_lang_String); - JAVA_OBJECT oo = newStringFromCString(threadStateData, constantPool[iter]); - tmpConstantPoolObjects[iter] = oo; - // java_util_ArrayList_add___java_lang_Object_R_boolean(threadStateData, internedStrings, oo); + tmpConstantPoolObjects[iter] = JAVA_NULL; } #if defined(__OBJC__) //NSLog(@"Size of constant pool in c: %i and j: %i", cStringSize, jStringSize); diff --git a/vm/ByteCodeTranslator/src/nativeMethods.m b/vm/ByteCodeTranslator/src/nativeMethods.m index 3d83f66f779..e14acc00d1d 100644 --- a/vm/ByteCodeTranslator/src/nativeMethods.m +++ b/vm/ByteCodeTranslator/src/nativeMethods.m @@ -33,6 +33,9 @@ #include #include #include +#if defined(__APPLE__) +#include +#endif #ifndef MAX #define MAX(a,b) ((a) > (b) ? (a) : (b)) @@ -1480,6 +1483,30 @@ JAVA_DOUBLE java_lang_Math_atan___double_R_double(CODENAME_ONE_THREAD_STATE, JAV return atan(a); } +JAVA_DOUBLE java_lang_Math_acos___double_R_double(CODENAME_ONE_THREAD_STATE, JAVA_DOUBLE a) { + return acos(a); +} + +JAVA_DOUBLE java_lang_Math_asin___double_R_double(CODENAME_ONE_THREAD_STATE, JAVA_DOUBLE a) { + return asin(a); +} + +JAVA_DOUBLE java_lang_Math_atan2___double_double_R_double(CODENAME_ONE_THREAD_STATE, JAVA_DOUBLE y, JAVA_DOUBLE x) { + return atan2(y, x); +} + +JAVA_DOUBLE java_lang_Math_exp___double_R_double(CODENAME_ONE_THREAD_STATE, JAVA_DOUBLE a) { + return exp(a); +} + +JAVA_DOUBLE java_lang_Math_log___double_R_double(CODENAME_ONE_THREAD_STATE, JAVA_DOUBLE a) { + return log(a); +} + +JAVA_DOUBLE java_lang_Math_log10___double_R_double(CODENAME_ONE_THREAD_STATE, JAVA_DOUBLE a) { + return log10(a); +} + JAVA_BOOLEAN isClassNameEqual(const char * clsName, JAVA_ARRAY_CHAR* chrs, int length) { for(int i = 0 ; i < length ; i++) { if(clsName[i] != chrs[i]) return JAVA_FALSE; @@ -1520,9 +1547,17 @@ JAVA_BOOLEAN java_lang_Class_isArray___R_boolean(CODENAME_ONE_THREAD_STATE, JAVA return clz->isArray; } +// NOTE on instanceofFunction's argument order: despite its parameter names, it +// is called as instanceofFunction(TARGET_TYPE, OBJECT_CLASS) — see BC_INSTANCEOF, +// which passes the bytecode's type operand first and GET_CLASS_ID(obj) second. +// It then indexes classInstanceOf[] by the OBJECT's class (whose table lists that +// class's supertypes) and searches it for the target. Both helpers below must +// therefore pass the receiver Class first. + JAVA_BOOLEAN java_lang_Class_isAssignableFrom___java_lang_Class_R_boolean(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT cls, JAVA_OBJECT cls2) { struct clazz* clz1 = (struct clazz*)cls; struct clazz* clz2 = (struct clazz*)cls2; + // A.isAssignableFrom(B): target is A, the class under test is B. return instanceofFunction(clz1->classId, clz2->classId); } @@ -1530,7 +1565,24 @@ JAVA_BOOLEAN java_lang_Class_isInstance___java_lang_Object_R_boolean(CODENAME_ON if(obj == JAVA_NULL) { return JAVA_FALSE; } struct clazz* clz1 = (struct clazz*)cls; struct clazz* clz2 = (struct clazz*)CN1_CLASS_OF(obj); // tag-aware: a tagged Integer has no header - return instanceofFunction(clz2->classId, clz1->classId); + // A.isInstance(o): target is A, the class under test is o's class. These were + // reversed, so isInstance searched the TARGET's supertype table for the + // object's class and answered false for every subclass — every + // Class.isInstance in a native build was wrong unless the types were equal. + return instanceofFunction(clz1->classId, clz2->classId); +} + +JAVA_OBJECT java_lang_Class_getSuperclass___R_java_lang_Class(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT cls) { + struct clazz* clz = (struct clazz*)cls; + // Object, primitives and void already carry a null baseClass, so they need + // no special case. Interfaces DO: a class file records java/lang/Object as + // an interface's super_class and Parser.visit copies that straight into + // baseClass, so the isInterface flag is the only thing separating them -- + // and Class.getSuperclass() is required to report null for an interface. + if(clz->isInterface) { + return JAVA_NULL; + } + return (JAVA_OBJECT)clz->baseClass; } JAVA_BOOLEAN java_lang_Class_isInterface___R_boolean(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT cls) { @@ -1856,6 +1908,50 @@ JAVA_VOID java_lang_System_gcMarkSweep__(CODENAME_ONE_THREAD_STATE) { threadStateData->exception = JAVA_NULL; } flushReleaseQueue(); +#ifdef CN1_ALLOC_CENSUS + { + // Several points, not one: allocation during startup and allocation once + // the first screen is up are different questions, and a single sample + // cannot tell them apart. + extern void cn1AllocCensus(const char*); + extern void cn1HeapAccounting(const char*); + static int cn1CensusCycle = 0; + int c = ++cn1CensusCycle; + if(c == 3 || c == 10 || c == 25 || c == 50) { + char lbl[32]; + snprintf(lbl, sizeof(lbl), "cycle%d", c); + cn1AllocCensus(lbl); + cn1HeapAccounting(lbl); + } + } +#endif +#ifdef CN1_HEAP_HISTOGRAM + { + // Once, on the third cycle: the first two run while the first screen is + // still being built, and a census of a half-built heap names the wrong + // things. + extern void cn1HeapHistogramPublic(void); + static int cn1HistCycle = 0; + if(++cn1HistCycle == 3) { + cn1HeapHistogramPublic(); + } + } +#endif + // No call reclaims the emptied pages. A sweep frees objects with free(), + // and libmalloc keeps most of the emptied pages in its zone rather than + // returning them to the kernel -- they stay counted against the process. + // malloc_zone_pressure_relief looks like the answer and is not: measured on + // macOS 26, a program that mallocs 200,000 x 500 bytes, touches them and + // frees every one sits at 52.9MB of physical footprint, and stays at + // exactly 52.9MB after malloc_zone_pressure_relief(NULL, 0) AND after + // calling it on every zone from malloc_get_all_zones. Three revisions of + // this file called it here on a countdown (every sixteenth cycle, every + // eighth, then every cycle) and the idle remainder never moved: 45.8MB of + // "MALLOC_SMALL (empty)" against 0.8MB for the same application built with + // a competing toolchain. The retained pages are a function of how many + // small blocks were ever live at once, so the only lever is allocating + // fewer of them -- see the object allocator, which keeps Java objects out + // of malloc entirely for exactly this reason. lowMemoryMode = JAVA_FALSE; gcCurrentlyRunning = JAVA_FALSE; } diff --git a/vm/JavaAPI/src/java/lang/System.java b/vm/JavaAPI/src/java/lang/System.java index c5ed34679d5..9b214c28ddd 100644 --- a/vm/JavaAPI/src/java/lang/System.java +++ b/vm/JavaAPI/src/java/lang/System.java @@ -80,6 +80,21 @@ public void run() { } } gcShouldLoop = true; + // Thirty seconds. Collecting more often during start-up, to + // shed the launch garbage sooner, has been tried once and is NOT + // settled: widening the idle interval from one second instead + // (cycles at t=2,3,5,9,17,33s rather than t=2,32s) measured a + // settled footprint of 216.6MB against 169.8MB for the flat + // interval -- but the same flat-interval build re-measured 252.8MB + // an hour later, by which point the host had 13.4GB of its 14.3GB + // swap in use. Physical footprint moves with the host's memory + // pressure, so those three numbers were never comparable and the + // experiment proved nothing either way. + // + // If you revisit it: build BOTH binaries, keep both .app bundles, + // and interleave them in one session (A,B,A,B) on a host that is + // not swapping. A soak of one build followed by a soak of the + // other an hour later measures the machine. while(gcShouldLoop) { try { System.gcMarkSweep();