diff --git a/dm/gfx/nvn/nvn_hmap.h b/dm/gfx/nvn/nvn_hmap.h index 8c3d204..0dd0314 100644 --- a/dm/gfx/nvn/nvn_hmap.h +++ b/dm/gfx/nvn/nvn_hmap.h @@ -18,6 +18,14 @@ static inline uint64_t nvn_hm_hash(const void *k, uint32_t len) { static inline void nvn_hm_init(nvn_hmap *m, uint32_t klen) { memset(m, 0, sizeof *m); m->klen = klen; } static inline void *nvn_hm_get(const nvn_hmap *m, const void *k) { if (!m->n) return NULL; + if (m->klen == 8) { // 8-byte keys (serials, pointers): one word compared per probe instead of a memcmp call with a run-time length (same probes, same answer; MacNativeGames 7431eb7) + uint64_t kk; memcpy(&kk, k, 8); + for (uint32_t i = (uint32_t)nvn_hm_hash(k, 8) & (m->cap - 1);; i = (i + 1) & (m->cap - 1)) { + if (!m->vals[i]) return NULL; + uint64_t ki; memcpy(&ki, m->keys + (size_t)i * 8, 8); + if (ki == kk) return m->vals[i]; + } + } for (uint32_t i = (uint32_t)nvn_hm_hash(k, m->klen) & (m->cap - 1);; i = (i + 1) & (m->cap - 1)) { if (!m->vals[i]) return NULL; if (!memcmp(m->keys + (size_t)i * m->klen, k, m->klen)) return m->vals[i]; diff --git a/dm/gfx/nvn/nvn_mtl.m b/dm/gfx/nvn/nvn_mtl.m index 840be0d..815c846 100644 --- a/dm/gfx/nvn/nvn_mtl.m +++ b/dm/gfx/nvn/nvn_mtl.m @@ -1508,7 +1508,7 @@ void mtl_drain(void) { present_drop_for_suspend(); mtl_present_flush(); snap_dra // ================= Render replay ================= #include "../../host/front.h" -static int g_red_once[113]; +static int g_red_once[117]; #define RED_ONCE(k, ...) do { if (!g_red_once[k]++) MLOG("[RED] " __VA_ARGS__); } while (0) // NVN vertex streams 0–15 → Metal vertex buffer slots: 0–13 go to 16–29 (30 is the constant fallback buffer), 14 and 15 to 14 and 15. Metal has only the 31 slots 0–30; translated shaders use only buffer(0) (the argument buffer), // and the host's own vertex functions read no vertex buffers. 13 Sentinels uses stream 15, and the old 16 + stream went out of range (Metal assertion: buffer index (31) must be < 31) @@ -1688,7 +1688,7 @@ static MTLTextureDescriptor *tex_desc(const tex_t *t, int packaged) { uint32_t pf = 0; uint8_t swz[4]; if (!nvn_fmt_to_mtl(t->d.format, &pf, swz) || !pf) { RED_ONCE(0, "texture format %#x has no Metal equivalent (first case)", t->d.format); return nil; } // Metal's limits: 16384 in 2D, 2048 in 3D, 2048 array layers; beyond them Metal aborts the process on an assertion, so they are caught here with a red line - if (t->d.width > 16384 || t->d.height > 16384 || t->d.depth > 2048 || t->d.levels > 15) { RED_ONCE(71, "texture size exceeds Metal limits: %u×%u×%u levels %u format %#x target %d (first case ser=%llu)", t->d.width, t->d.height, t->d.depth, t->d.levels, t->d.format, t->d.target, (unsigned long long)t->serial); return nil; } + if (t->d.width > 16384 || t->d.height > 16384 || t->d.depth > 2048) { RED_ONCE(71, "texture size exceeds Metal limits: %u×%u×%u levels %u format %#x target %d (first case ser=%llu)", t->d.width, t->d.height, t->d.depth, t->d.levels, t->d.format, t->d.target, (unsigned long long)t->serial); return nil; } MTLTextureDescriptor *d = [MTLTextureDescriptor new]; d.pixelFormat = (MTLPixelFormat)pf; d.width = t->d.width ? t->d.width : 1; d.height = t->d.height ? t->d.height : 1; d.depth = 1; d.arrayLength = 1; @@ -1711,6 +1711,29 @@ static MTLTextureDescriptor *tex_desc(const tex_t *t, int packaged) { case 10: RED_ONCE(1, "buffer textures (TARGET_BUFFER) not implemented"); return nil; default: d.textureType = MTLTextureType2D; break; } + // Fields NVN takes that Metal refuses: Metal aborts the process on them (-[MTLTextureDescriptorInternal validateWithDevice:]). An array or 3D texture of no layers gets one; one of + // more than 2048 is not made. More levels than the size holds (on NVN the extra ones are 1x1; a mod's 1x1 texture declaring 9, MacNativeGames f864eb0): made with the levels the + // size holds, and copies to the others are skipped (tex_level_ok). A sample count the GPU lacks (Apple GPUs have 2 and 4; NVN also 8 and 16): the largest it has below, which + // the pipelines follow (their sample count is the attachments'); a multisample texture of one sample has no Metal form and is not made + if (d.depth < 1) d.depth = 1; + if (d.arrayLength < 1) d.arrayLength = 1; + if (d.arrayLength > 2048) { RED_ONCE(113, "texture of %lu layers exceeds Metal's 2048: not made (format %#x target %d, first case ser=%llu)", (unsigned long)d.arrayLength, t->d.format, t->d.target, (unsigned long long)t->serial); return nil; } + NSUInteger big = MAX(d.width, d.height), fit = 1; + if (d.textureType == MTLTextureType3D) big = MAX(big, d.depth); + while (big >> fit) fit++; + if (d.mipmapLevelCount > fit) { + RED_ONCE(114, "texture %u×%u×%u declares %lu levels, its size holds %lu: made with %lu, the others are not kept (format %#x, first case ser=%llu)", t->d.width, t->d.height, t->d.depth, + (unsigned long)d.mipmapLevelCount, (unsigned long)fit, (unsigned long)fit, t->d.format, (unsigned long long)t->serial); + d.mipmapLevelCount = fit; + } + if (d.textureType == MTLTextureType2DMultisample || d.textureType == MTLTextureType2DMultisampleArray) { + NSUInteger want = d.sampleCount, s = want; + while (s > 1 && ![g_dev supportsTextureSampleCount:s]) s--; + if (s != want) RED_ONCE(115, "multisample texture of %lu samples: this GPU has %lu at most below it%s (format %#x, first case ser=%llu)", (unsigned long)want, (unsigned long)s, + s < 2 ? ", not made" : ", made with them", t->d.format, (unsigned long long)t->serial); + if (s < 2) return nil; + d.sampleCount = s; + } d.storageMode = MTLStorageModePrivate; // Render targets and assets both go in GPU private storage (lossless compression available) d.usage = MTLTextureUsageShaderRead | (fmt_compressed(t->d.format) || packaged ? 0 : MTLTextureUsageRenderTarget) | (t->d.flags & NVN_TEXTURE_FLAGS_IMAGE ? MTLTextureUsageShaderWrite : 0); // Image usage: shader-writable (compute kernels generating the 3D color lookup table, etc. @@ -4850,6 +4873,61 @@ static void pso_bundle_open(NSMutableArray> *archp) { g_arch_bundle = a; g_bundle_dir = dir; [archp addObject:a]; MLOG("pipeline archive bundled with the app: %s, %.1f MB, opened in %.1f ms", p.UTF8String, [[NSFileManager.defaultManager attributesOfItemAtPath:p error:nil] fileSize] / 1048576.0, (bp_now() - t0) / 1e6); } +// Metal's archive builder works in a folder of the user cache folder (com.apple.gpuarchiver/gpuarchiver-*: the captured functions, descriptors and reflection, about 0.4-5 MB). +// The first capture after a flush makes one, and no flush, release or exit removes it, so every flush left one (456 folders, 2.5 GB on one Mac). Nothing reads it once its flush +// has written the file: a probe that removed each after its flush took every pipeline from the files again (found by MacNativeGames, 1eff989, for MTLBinaryArchive). On the Mac the +// folder is shared by all of the user's programs and the process keeps nothing open in it, so the first capture after a flush (g_ga_mu) lists the folder before and after its +// compile and takes the one folder that appeared as its own; the flush removes it (and exit, when no flush came). When more than one appeared (another program's capture in the +// same few ms) none is touched. On iOS the folder is the app's own: the start removes what earlier runs left +static pthread_mutex_t g_ga_mu = PTHREAD_MUTEX_INITIALIZER; static _Atomic int g_ga_seen, g_ga_recap; static NSString *g_ga_mine; static _Atomic int g_ga_said; +static NSString *ga_dir(void) { + static NSString *dir; static dispatch_once_t once; + dispatch_once(&once, ^{ char b[PATH_MAX]; if (confstr(_CS_DARWIN_USER_CACHE_DIR, b, sizeof b)) dir = [@(b) stringByAppendingPathComponent:@"com.apple.gpuarchiver"]; }); + return dir; +} +static NSSet *ga_list(void) { + NSArray *a = ga_dir() ? [NSFileManager.defaultManager contentsOfDirectoryAtPath:ga_dir() error:nil] : nil; + return [NSSet setWithArray:[a filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"self BEGINSWITH 'gpuarchiver-'"]] ?: @[]]; +} +// A compile the serializer captures, with g_arch_rw held shared: the first one after a flush notes the folder it made +static id ga_capture(MTL4RenderPipelineDescriptor *d, MTL4CompilerTaskOptions *o, NSError **e) { + if (o.lookupArchives.count) atomic_store(&g_ga_recap, 1); + // other compiles do not wait for the one that looks: theirs land in the same folder (at worst one made before its first listing, so none is taken and the folder stays) + if (!g_ser4 || atomic_load(&g_ga_seen) || pthread_mutex_trylock(&g_ga_mu)) return [g_comp4 newRenderPipelineStateWithDescriptor:d compilerTaskOptions:o error:e]; + if (atomic_load(&g_ga_seen)) { pthread_mutex_unlock(&g_ga_mu); return [g_comp4 newRenderPipelineStateWithDescriptor:d compilerTaskOptions:o error:e]; } + NSSet *before = ga_list(); + id ps = [g_comp4 newRenderPipelineStateWithDescriptor:d compilerTaskOptions:o error:e]; + if (ps) { // a failed compile may have captured nothing: the next capture looks again + NSMutableSet *made = [ga_list() mutableCopy]; [made minusSet:before]; + if (made.count == 1) g_ga_mine = made.anyObject; // none: a recapture through lookup archives, whose folder the flush makes (ga_flushed) + else if (made.count > 1 && atomic_fetch_add(&g_ga_said, 1) < 4) MLOG("pipeline archive: %lu new Metal build folders during the first capture since the last flush: none is removed", (unsigned long)made.count); + atomic_store(&g_ga_seen, 1); + } + pthread_mutex_unlock(&g_ga_mu); + return ps; +} +static void ga_remove(NSString *name) { + NSError *e = nil; + if (name && ![NSFileManager.defaultManager removeItemAtPath:[ga_dir() stringByAppendingPathComponent:name] error:&e] && atomic_fetch_add(&g_ga_said, 1) < 4) + MLOG("[RED] pipeline archive: Metal's build folder %s not removed (%s)", name.UTF8String, e.localizedDescription.UTF8String); +} +// After a flush wrote its file, with g_arch_rw held alone: its build folder goes. Bases recaptured through lookup archives (compaction) get theirs during the flush, not at +// their compile: after such captures the one folder that appeared while it wrote (before: listed just before it) goes as well. Other flushes make none, so a folder appearing +// then is another program's +static void ga_flushed(NSSet *before) { + ga_remove(g_ga_mine); + if (atomic_exchange(&g_ga_recap, 0)) { + NSMutableSet *made = [ga_list() mutableCopy]; [made minusSet:before]; + if (made.count == 1) ga_remove(made.anyObject); + } + g_ga_mine = nil; atomic_store(&g_ga_seen, 0); +} +// At exit: the folder of captures no flush wrote, unless a compile is still under way +static void ga_exit(void) { + if (pthread_rwlock_trywrlock(&g_arch_rw)) return; + ga_remove(g_ga_mine); g_ga_mine = nil; + pthread_rwlock_unlock(&g_arch_rw); +} static void pso4_init(void) { static dispatch_once_t once; dispatch_once(&once, ^{ @@ -4863,6 +4941,14 @@ static void pso4_init(void) { g_arch_q = dispatch_queue_create("dm.nvn.pso-archive", dispatch_queue_attr_make_with_qos_class(DISPATCH_QUEUE_SERIAL, QOS_CLASS_UTILITY, 0)); MTL4PipelineDataSetSerializerDescriptor *sd = [MTL4PipelineDataSetSerializerDescriptor new]; sd.configuration = MTL4PipelineDataSetSerializerConfigurationCaptureBinaries; ser = [g_dev newPipelineDataSetSerializerWithDescriptor:sd]; + atexit(ga_exit); +#if TARGET_OS_IPHONE + NSSet *old = ga_list(); // listed before any capture, so this run's own folder is never among them + if (old.count) dispatch_async(dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{ + int n = 0; for (NSString *name in old) n += [NSFileManager.defaultManager removeItemAtPath:[ga_dir() stringByAppendingPathComponent:name] error:nil]; + MLOG("pipeline archive: %d of Metal's build folders left by earlier runs removed", n); + }); +#endif uint64_t t0 = bp_now(); unsigned long long bytes = 0; NSMutableArray *runf = [NSMutableArray array]; NSArray *names = [[[NSFileManager.defaultManager contentsOfDirectoryAtPath:g_arch_dir error:nil] filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"self ENDSWITH '.mtl4a'"]] @@ -4899,7 +4985,10 @@ static void arch_flush(void) { uint64_t t0 = bp_now(); NSError *e = nil; int vd = atomic_exchange(&g_arch_new_vd, 0); // exact: no compile is between capture and count while the write side is held NSString *path = [g_arch_dir stringByAppendingPathComponent:[NSString stringWithFormat:vd ? @"%05d.mtl4a" : @"p%05d.mtl4a", g_arch_seq++]], *tmp = [path stringByAppendingString:@".tmp"]; - BOOL ok = [g_ser4 serializeAsArchiveAndFlushToURL:[NSURL fileURLWithPath:tmp] error:&e]; pthread_rwlock_unlock(&g_arch_rw); + NSSet *ga_before = atomic_load(&g_ga_recap) ? ga_list() : nil; + BOOL ok = [g_ser4 serializeAsArchiveAndFlushToURL:[NSURL fileURLWithPath:tmp] error:&e]; + if (ok) ga_flushed(ga_before); + pthread_rwlock_unlock(&g_arch_rw); ok = ok && !rename(tmp.fileSystemRepresentation, path.fileSystemRepresentation); if (!ok) { MLOG("[RED] pipeline archive: writing %s failed%s%s", path.UTF8String, e ? ": " : "", e ? e.localizedDescription.UTF8String : ""); unlink(tmp.fileSystemRepresentation); return; } g_arch_flush_ok = 1; @@ -4993,13 +5082,13 @@ static id pso_build_ex(MTLRenderPipelineDescriptor *d, N int recap = base && from_run && g_compact_opts && atomic_load(&g_warm_running), lk = recap || (!base && compile); if (lk) pthread_rwlock_rdlock(&g_arch_rw); if (recap) { // compaction (see g_compact_opts): taken again through the compiler, which captures it - id cb = [g_comp4 newRenderPipelineStateWithDescriptor:b compilerTaskOptions:g_compact_opts error:nil]; + id cb = ga_capture(b, g_compact_opts, nil); if (cb) { base = cb; atomic_fetch_add(&g_arch_new, 1); g_compacted++; if (vd) g_arch_new_vd++; } } if (!base) { if (g_arch4.count + g_arch4p.count) { g_pt_miss_n++; g_pt_miss_ns += t1 - t0; } if (compile) { - NSError *be = nil; base = [g_comp4 newRenderPipelineStateWithDescriptor:b compilerTaskOptions:nil error:&be]; job->error = be; job->compiled = 1; + NSError *be = nil; base = ga_capture(b, nil, &be); job->error = be; job->compiled = 1; g_pt_comp_ns += bp_now() - t1; if (base) { g_pb_built++; if (vd) g_arch_new_vd++; if (g_ser4) arch_note_new(); } } @@ -5227,7 +5316,7 @@ static void pre_pair(NSString *vk, NSString *fk, int zero) { for (id a in g_arch4p) if ([a newRenderPipelineStateWithDescriptor:m error:nil]) { g_pre_archived++; return; } for (id a in g_arch4) if ([a newRenderPipelineStateWithDescriptor:m error:nil]) { g_pre_archived++; return; } NSError *e = nil; - pthread_rwlock_rdlock(&g_arch_rw); id ps = [g_comp4 newRenderPipelineStateWithDescriptor:m compilerTaskOptions:nil error:&e]; pthread_rwlock_unlock(&g_arch_rw); + pthread_rwlock_rdlock(&g_arch_rw); id ps = ga_capture(m, nil, &e); pthread_rwlock_unlock(&g_arch_rw); if (ps) { g_pre_built++; atomic_fetch_add(&g_arch_new, 1); } else { if (g_pre_failed++ < 4) MLOG("[RED] pipeline prebuild: the base of %s + %s failed: %s", vk.UTF8String, fk.UTF8String, e.localizedDescription.UTF8String); } } @@ -8676,6 +8765,12 @@ static void dispatch(const p_dispatch *dp) { if (own) enc_c_end(ce); } // GPU: texture -> buffer (row stride taken from SetCopyRowStride, layer stride from SetCopyImageStride, 0 = tightly packed) +// A copy at a level the host texture lacks (more levels declared than its size holds, tex_desc) is skipped: Metal does not check the level of a blit +static int tex_level_ok(id t, NSUInteger level, const char *what) { + if (level < t.mipmapLevelCount) return 1; + RED_ONCE(116, "%s at level %lu of a texture with %lu levels: skipped (first case)", what, (unsigned long)level, (unsigned long)t.mipmapLevelCount); + return 0; +} static void copy_t2b(const p_copy_b2t *c) { rp_flush(); // Materialize pending clears before reading, matching copy_t2t. g_wr_epoch++; g_mdx_epoch++; @@ -8684,6 +8779,7 @@ static void copy_t2b(const p_copy_b2t *c) { if (!t) return; int bw, bh, bpb = nvn_fmt_bpb(c->tex->d.format, &bw, &bh); unsigned level = c->has_view && c->view.set_levels ? c->view.base_level : 0, layer0 = c->has_view && c->view.set_layers ? c->view.min_layer : 0; + if (!tex_level_ok(t, level, "texture-to-buffer copy")) return; size_t row = S.copy_stride ? (size_t)S.copy_stride : (size_t)((c->r.width + bw - 1) / bw) * bpb; size_t rows = (c->r.height + bh - 1) / bh, img = S.copy_img_stride ? (size_t)S.copy_img_stride : row * rows; // per layer: SetCopyImageStride, else tightly packed int is3d = c->tex->d.target == 2, nz = c->r.depth ? c->r.depth : 1; @@ -8726,7 +8822,7 @@ void mtl_read_texels(const tex_t *t, unsigned level, unsigned layer0, const NVNc // Finding: docs/reference/memory.md, GPU readback without an autorelease pool. @autoreleasepool { id mt = tex_mtl(t); - if (!mt) { memset(dst, 0, img * nz); return; } + if (!mt || !tex_level_ok(mt, level, "CPU readback")) { memset(dst, 0, img * nz); return; } mtl_finish_gpu_at(WS_READTEX); id buf = [g_dev newBufferWithLength:wb * hb * nz options:MTLResourceStorageModeShared]; id cb = q_cb(); id b = enc_b_begin(cb); @@ -8743,6 +8839,7 @@ static void copy_b2t(const p_copy_b2t *c) { if (!t) { PD("C ser=%llu (no host texture)", c->tex->serial); return; } int bw, bh, bpb = nvn_fmt_bpb(c->tex->d.format, &bw, &bh); unsigned level = c->has_view && c->view.set_levels ? c->view.base_level : 0, layer0 = c->has_view && c->view.set_layers ? c->view.min_layer : 0; + if (!tex_level_ok(t, level, "buffer-to-texture copy")) return; size_t row = S.copy_stride ? (size_t)S.copy_stride : (size_t)((c->r.width + bw - 1) / bw) * bpb; size_t rows = (c->r.height + bh - 1) / bh, img = S.copy_img_stride ? (size_t)S.copy_img_stride : row * rows; // per layer: SetCopyImageStride, else tightly packed int is3d = c->tex->d.target == 2, nz = c->r.depth ? c->r.depth : 1; @@ -8869,6 +8966,7 @@ static void copy_t2t(const p_copy_t2t *c) { if (!s || !d) return; unsigned sl = c->has_sv && c->sv.set_levels ? c->sv.base_level : 0, dl = c->has_dv && c->dv.set_levels ? c->dv.base_level : 0; unsigned sz0 = c->has_sv && c->sv.set_layers ? c->sv.min_layer : 0, dz0 = c->has_dv && c->dv.set_layers ? c->dv.min_layer : 0; + if (!tex_level_ok(s, sl, "texture copy source") || !tex_level_ok(d, dl, "texture copy destination")) return; if (c->flags & DM_COPY_RESOLVE) { // Downsample: multisample resolve into the single-sample destination (depth takes sample 0); with equal sample counts it is a plain whole copy if (s.sampleCount == d.sampleCount && s.pixelFormat == d.pixelFormat && s.width == d.width && s.height == d.height) { enc_b_copy_texture(blit(), s, sz0, sl, MTLOriginMake(0, 0, 0), MTLSizeMake(s.width, s.height, 1), d, dz0, dl, MTLOriginMake(0, 0, 0)); diff --git a/dm/gfx/tests/t_pso_archive.m b/dm/gfx/tests/t_pso_archive.m index 32eb129..fb8086b 100644 --- a/dm/gfx/tests/t_pso_archive.m +++ b/dm/gfx/tests/t_pso_archive.m @@ -2,7 +2,7 @@ // Checks: pipelines that differ only in colour state share one base; a specialized pipeline draws the same pixels as one built through the MTLDevice; a flushed archive // file serves the base again without compiling; a flush of bases without a vertex layout is a p file, where a base with a layout is not looked up; a function with no // Metal 4 descriptor keeps the MTLDevice path; pso_key_norm drops what no pipeline depends on; compaction merges the files the prewarm took bases from into one despite a -// mid-prewarm flush; depth-only draws write depth; concurrent colour variants compile one base +// mid-prewarm flush; depth-only draws write depth; concurrent colour variants compile one base; a flush removes the build folder Metal made for its captures #include "../nvn/nvn_mtl.m" uint64_t g_t0_ns; int dm_guest_as_contains(uintptr_t address) { (void)address; return 0; } @@ -150,9 +150,13 @@ int main(void) { @autoreleasepool { EXPECT("one specialization per pipeline", 3, g_pb_spec); // the archive: flushed to a file, a later start takes the base from it without compiling EXPECT("one base waits for the archive", 1, g_arch_new); + NSString *ga = g_ga_mine ? [ga_dir() stringByAppendingPathComponent:g_ga_mine] : nil; // fails only if another program captured in the same few ms + EXPECT("the first capture found Metal's build folder", 1, ga && [NSFileManager.defaultManager fileExistsAtPath:ga]); arch_flush(); NSString *file = [@(dir) stringByAppendingPathComponent:@"00000.mtl4a"]; EXPECT("archive file written", 1, [NSFileManager.defaultManager fileExistsAtPath:file]); + EXPECT("the flush removed Metal's build folder", 0, ga && [NSFileManager.defaultManager fileExistsAtPath:ga]); + EXPECT("the next capture looks again", 0, g_ga_seen); EXPECT("nothing left to write", 0, g_arch_new); nvn_hm_clear(&g_pbase, hm_rel); // as a new start: no base in memory, the archive opened g_arch4 = @[[g_dev newArchiveWithURL:[NSURL fileURLWithPath:file] error:&err]]; diff --git a/dm/gfx/tests/t_view_levels.m b/dm/gfx/tests/t_view_levels.m index 9439418..b4b0461 100644 --- a/dm/gfx/tests/t_view_levels.m +++ b/dm/gfx/tests/t_view_levels.m @@ -1,4 +1,5 @@ // Registered texture views use only the mip levels present in the texture; exercise the production Metal path. +// Texture fields Metal refuses (tex_desc): levels past what the size holds, layer counts of 0 or past 2048, sample counts the GPU lacks; copies at a missing level are skipped. #include "../nvn/nvn_mtl.m" uint64_t g_t0_ns; int dm_guest_as_contains(uintptr_t address) { (void)address; return 0; } @@ -29,8 +30,34 @@ static void view_case(int tid, unsigned level, unsigned count, unsigned expected } os_unfair_lock_unlock(&g_rmu); } +static MTLTextureDescriptor *desc_of(int target, unsigned w, unsigned h, unsigned depth, unsigned levels, unsigned samples) { + tex_t t = {.magic=MAGIC('T','E','X',0), .serial=98000}; + t.d = (nvn_texdesc){.target=target, .format=NVN_FORMAT_RGBA8, .width=w, .height=h, .depth=depth, .levels=levels, .samples=samples}; + return tex_desc(&t, 0); +} +static void desc_caps(void) { + MTLTextureDescriptor *d = desc_of(NVN_TEXTURE_TARGET_2D, 1, 1, 1, 9, 1); // a mod's 1x1 texture declaring 9 levels: Metal aborted the process + CHECK(d && d.mipmapLevelCount == 1, "1x1 with 9 levels: one level"); + id one = d ? [g_dev newTextureWithDescriptor:d] : nil; + CHECK(one != nil, "1x1 with 9 levels: made"); + CHECK(one && tex_level_ok(one, 0, "test") && !tex_level_ok(one, 3, "test"), "a copy at a missing level is skipped"); + d = desc_of(NVN_TEXTURE_TARGET_2D, 64, 16, 1, 12, 1); + CHECK(d && d.mipmapLevelCount == 7, "64x16 with 12 levels: seven"); + d = desc_of(NVN_TEXTURE_TARGET_3D, 4, 4, 16, 9, 1); + CHECK(d && d.mipmapLevelCount == 5 && [g_dev newTextureWithDescriptor:d], "4x4x16 3D with 9 levels: five (the depth counts)"); + d = desc_of(NVN_TEXTURE_TARGET_2D, 64, 64, 1, 7, 1); + CHECK(d && d.mipmapLevelCount == 7, "a full chain is kept"); + d = desc_of(NVN_TEXTURE_TARGET_2D_ARRAY, 8, 8, 0, 1, 1); + CHECK(d && d.arrayLength == 1 && [g_dev newTextureWithDescriptor:d], "array of no layers: one"); + CHECK(desc_of(NVN_TEXTURE_TARGET_1D_ARRAY, 8, 4096, 1, 1, 1) == nil, "1D array of 4096 layers: not made"); + d = desc_of(NVN_TEXTURE_TARGET_2D_MULTISAMPLE, 16, 16, 1, 1, 8); + NSUInteger s8 = 8; while (s8 > 1 && ![g_dev supportsTextureSampleCount:s8]) s8--; + CHECK(d && d.sampleCount == s8 && d.mipmapLevelCount == 1 && [g_dev newTextureWithDescriptor:d], "8 samples: the most this GPU has"); + CHECK(desc_of(NVN_TEXTURE_TARGET_2D_MULTISAMPLE, 16, 16, 1, 1, 1) == nil, "multisample of one sample: not made"); +} int main(void) { @autoreleasepool { mtl_init(); + desc_caps(); view_case(300, 0, 7, 7, 1); view_case(301, 1, 7, 6, 6); view_case(302, 1, 2, 2, 1); diff --git a/docs/agents/symptoms.md b/docs/agents/symptoms.md index c6264e8..2975c57 100644 --- a/docs/agents/symptoms.md +++ b/docs/agents/symptoms.md @@ -234,6 +234,7 @@ One line per symptom in the [subsystem references](../reference/): what you see - virtual ticks keep the average, but a frame still stays up two or three refreshes in turn (13.9 / 20.8 ms at 144 Hz), and Apple's HUD showed the frame interval jumping between them. → [Fullscreen switches the screen to a rate the frame rate divides](../reference/frame-rate.md#fullscreen-switches-the-screen-to-a-rate-the-frame-rate-divides) - autorelease work takes a measurable share of replay time, with temporary arena-buffer ownership traffic a few times per draw. → [Arena buffer ownership on allocation](../reference/frame-rate.md#arena-buffer-ownership-on-allocation) - the submit thread waits on the texture / sampler registry lock while a lower-priority registration thread is preempted holding it. → [Registry lock priority inheritance](../reference/frame-rate.md#registry-lock-priority-inheritance) +- in a replay profile, `_platform_memcmp` is most of the time spent in serial lookups (`tex_mtl` through `g_by_serial`). → [Hash-table lookups of 8-byte keys](../reference/frame-rate.md#hash-table-lookups-of-8-byte-keys) - the GPU sits idle each frame while the host translates the submission (a game that does not submit frame N+1 until frame N's GPU work is done). → [Submissions handed to the GPU in segments](../reference/frame-rate.md#submissions-handed-to-the-gpu-in-segments) - the submit thread's CPU samples dominated by `mtl_addr` / `mtl_gpu_addr` (vertex buffers in `draw`, uniform buffers in `encode_args`). → [Address to memory pool lookups by binary search](../reference/frame-rate.md#address-to-memory-pool-lookups-by-binary-search) - stalls of 30–60 ms about once a second, at 30 fps and at 60 (99th percentile 63–127 ms); the main thread waits 20–34 ms for the render thread once a second. → [Time-constraint computation declared below the thread's peaks](../reference/frame-rate.md#time-constraint-computation-declared-below-the-threads-peaks) @@ -304,6 +305,7 @@ One line per symptom in the [subsystem references](../reference/): what you see - torn digits and a background in blue steps (Rhythm Heaven Groove's latency calibration). → [Automatic block height truncates](../reference/rendering.md#automatic-block-height-truncates) - magenta (the ASTC error colour) on streamed textures in a sparse pool; `DM_NVN_ZBLOCK` finds such textures (all-zero blocks). → [Sparse textures laid out in 64 KB tiles](../reference/rendering.md#sparse-textures-laid-out-in-64-kb-tiles) - streamed textures read 0 (magenta on Monster Hunter Rise's title screen's clothes). → [Sparse textures bound from their first mapped level](../reference/rendering.md#sparse-textures-bound-from-their-first-mapped-level) +- the process ends at once (the window vanishes), with `-[MTLTextureDescriptorInternal validateWithDevice:]` failing an assertion such as `requests 9 mipmap levels, but the dimensions (1, 1, 1) can only support 1`, or `sampleCount must be > 1 for multisample textures`. → [Texture fields Metal refuses](../reference/rendering.md#texture-fields-metal-refuses) - ≈13 MB/frame leak. → [Transient render targets shared by storage](../reference/rendering.md#transient-render-targets-shared-by-storage) - GPU memory grows with every image shown; the memory line's `shared textures` count keeps rising. → [Shared-storage entries taken over by a new texture](../reference/rendering.md#shared-storage-entries-taken-over-by-a-new-texture) - the whole picture came out darker and more saturated on the Mac and the iPhone: comparing with a Switch screenshot, every channel at every level equalled the Switch value decoded from sRGB once more (0.55 → 0.26, 0.75 → 0.52). → [Render-target views that change the format](../reference/rendering.md#render-target-views-that-change-the-format) @@ -435,6 +437,7 @@ One line per symptom in the [subsystem references](../reference/): what you see - a null read at `main+0x3316dc` in `nnMain`, right after the window setup (the program object's interface pointers at +0xd0 / +0xd8 both null). → [GLSLC GPU code version against the driver's range](../reference/shaders.md#glslc-gpu-code-version-against-the-drivers-range) - a self-contained Mac app (`--platform mac-app`) logs `pipeline prebuild: no .../Resources/dm/shader_pairs.tsv, nothing to build` although every shader library is there; it draws no hybrid stand-ins, and each pipeline seen for the first time compiles on the submit thread (frames of 100–550 ms). → [Shader-pair tables in a Mac app](../reference/shaders.md#shader-pair-tables-in-a-mac-app) - a truncated `.mtl4a` reaches `MTL4ArchiveImpl::loadFromURL`; the bundle reports coverage with `0 of 0 probed bases found`; rebuilt shader libraries reuse an archive of their old binaries; or packing fails with `pso_offline: no base to build` when every eligible pair has function constants. → [Offline pipeline archive reuse and fallback](../reference/shaders.md#offline-pipeline-archive-reuse-and-fallback) +- the user cache folder (`getconf DARWIN_USER_CACHE_DIR`, then `com.apple.gpuarchiver/`) fills with `gpuarchiver-*` folders of 0.3–5 MB each, one per pipeline archive flush, and they stay after the game exits: 456 folders, 2.5 GB, on one Mac after two days of NVN game runs. On iOS they stay in the app's own cache folder. → [Metal's build folders left by pipeline archive flushes](../reference/shaders.md#metals-build-folders-left-by-pipeline-archive-flushes) - identical render pipelines compile repeatedly under different keys, lengthening startup prewarm and adding pipeline misses during play. → [Pipeline keys omit unused colour state](../reference/shaders.md#pipeline-keys-omit-unused-colour-state) - a vertex shader whose pairs changed was restored unpadded from `cas/`. → [Shader cache key covering the pairs](../reference/shaders.md#shader-cache-key-covering-the-pairs) - `fragmentFunction main0 cannot be used to build a pipeline state` (a Metal validation assertion). → [Function constants specialized at load](../reference/shaders.md#function-constants-specialized-at-load) diff --git a/docs/reference/frame-rate.md b/docs/reference/frame-rate.md index e42aa90..d079589 100644 --- a/docs/reference/frame-rate.md +++ b/docs/reference/frame-rate.md @@ -157,6 +157,14 @@ Frame pacing: the host's frame step, engine frame limiters, present intervals an - **Found on:** [The Legend of Zelda: Tears of the Kingdom](../../dm/games/0100F2C0115B6000/README.md), measured upstream in MacNativeGames commit `5f5de5e` and ported in [PR #195](https://github.com/seedds/nxnative/pull/195): in a shrine, game threads at priority 46 delayed the submit thread at priority 97 by about 1.8 ms a frame. Waiting fell from 155 to about 0 ms/s there, with unchanged frame time. Those game measurements have not been repeated here. - **Check:** `t_stencil_view`, `t_view_levels` and `t_view_swizzle` exercise production registry lookups and cached views with the new lock on the Mac. +### Hash-table lookups of 8-byte keys + +- **Symptom:** in a replay profile, `_platform_memcmp` is most of the time spent in serial lookups (`tex_mtl` through `g_by_serial`). +- **Cause:** `nvn_hm_get` compared each probed key with `memcmp` and the table's run-time key length, a libc call per probe. The serial, live-serial, ghost, view-cache and compute-pipeline tables have 8-byte keys and are looked up several times per draw. +- **Fix:** for 8-byte keys `nvn_hm_get` loads the key as one word and compares words. The hash, probe order and answers are the same; puts and deletes are unchanged. +- **Found on:** MacNativeGames commit `7431eb7` (38 of 55 samples of the serial lookup were `memcmp` in a Tears of the Kingdom profile). Here, a lookup in a 3,000-entry table of 8-byte keys takes 1.5 ns instead of 5.9 ns on an M4 Pro (`clang -O2`, 60 million lookups); no game frame has been measured. +- **Check:** `t_hmap` checks 8- and 20-byte keys against a naive model through random puts, overwrites, deletes and clears. + ### Submissions handed to the GPU in segments - **Symptom:** the GPU sits idle each frame while the host translates the submission (a game that does not submit frame N+1 until frame N's GPU work is done). @@ -165,6 +173,7 @@ Frame pacing: the host's frame step, engine frame limiters, present intervals an - **Found on:** [The Legend of Zelda: Breath of the Wild](../../dm/games/01007EF00011E000/README.md): a stack sample in the shrine had the main thread in `nvnSyncWait` (the fence of the frame just submitted) for 59% of the time and the render thread in `nvnQueueSubmitCommands` for 40%; the translation was about 3,600 draws and 347 passes, 7 ms, so per frame the GPU ran 13–14 ms and then sat idle 7.7 ms (`DM_NVN_TSV` rows `@gpu_beg_ms` / `@gpu_end_ms` against `@call_ms`). GPU idle per frame went from 7.7 to 3.8 ms; most of the rest is the game's own work between its fence wait and the next submit (about 2.5 ms). The other five games' frame times were unchanged; Metroid Dread's p95 dropped from 19.6 to 16.9 ms. - **Note:** after this, BotW's docked frame p50 was 16.7 ms (was 20.8 ms before segmenting). Host translation is 8 ms per frame (22,000 instructions per draw), spread over Metal's encoder creation and draw calls, argument encoding, Objective-C retain / release and pool lookups. The per-pass GPU timer (`DM_NVN_PASSGPU`) is switched off under Metal 4 (`pg_render` returns at once), so GPU time cannot yet be attributed to passes. Render-target traffic per frame is in `DM_NVN_TSV`'s `@rt_load_mb` / `@rt_store_mb`. - **Note:** with the encode thread (`DM_NVN_ENC_THREAD`) a segment's commit record is handed to it at once (`rec_publish` in `sub_cut`), not when its block of records fills. Breath of the Wild on an M2 Max (encode thread and snapshots, `DM_FPS_TARGET=120`): the frame's wait on its newest fence 12.5 ms with `DM_NVN_CUT=0`, 5.6 ms with 64, 5.35 ms with 16. +- **Note:** MacNativeGames moved its cut to 30 draws for the first segment and 40 after (`2fa27d7`; uncapped Tears of the Kingdom in a shrine, 10.77 → 10.60 ms a frame). At this repository's 60 fps cap the size changes nothing where the GPU finishes inside the frame: Breath of the Wild outdoors on an M4 Pro (2026-10-26, the outdoor seed of the [BotW Mac routes](../../dm/games/01007EF00011E000/README.md#mac-routes), frames 3,001–5,700, cut 64) waited on its fence 0.02 ms a frame on average (`@sync_ms`), with GPU span 10.8 ms. The default stays 64; a GPU-bound device (an iPhone) is where a smaller cut could show, through `DM_NVN_CUT`. ### Address to memory pool lookups by binary search diff --git a/docs/reference/rendering.md b/docs/reference/rendering.md index 70a87e3..c30b85f 100644 --- a/docs/reference/rendering.md +++ b/docs/reference/rendering.md @@ -137,6 +137,14 @@ The replay on Metal (`dm/gfx/nvn/nvn_mtl.m`) and the Vulkan and OpenGL layers. E - **Fix:** reduction samplers filter mips linearly (`samp_by_id`): at the integer LODs reductions are used with, that is the same level. - **Found on:** [Pokémon Legends: Z-A](../../dm/games/0100F43008C44000/README.md): its depth pyramid is built with a min-reduction sampler (`SamplerBuilderSetReductionFilter 1`; `151607c7a92386cc` samples the level above at an explicit LOD). +### Texture fields Metal refuses + +- **Symptom:** the process ends at once (the window vanishes), with `-[MTLTextureDescriptorInternal validateWithDevice:]` failing an assertion such as `requests 9 mipmap levels, but the dimensions (1, 1, 1) can only support 1`, or `sampleCount must be > 1 for multisample textures`. +- **Cause:** NVN takes texture fields Metal refuses, and Metal aborts instead of returning nil: more mip levels than the size holds (on NVN the extra levels are 1×1), an array or 3D texture of no layers, more than 2048 layers (a 1D array's layer count is its height, which the size check let through), and sample counts the GPU lacks (an M4 Pro has 1, 2 and 4; NVN also has 8 and 16). A blit at a level the Metal texture lacks is not checked either. +- **Fix:** `tex_desc` makes such textures with the levels their size holds, one layer for none, and the largest sample count the GPU has below the one asked for (the pipelines follow, since their sample count is the attachments'). A red line names each case once. Textures of more than 2048 layers, and multisample textures of one sample, are not made. Copies and `ReadTexels` at a level the host texture lacks are skipped (`tex_level_ok`). Retail data is within every bound except, possibly, 8- or 16-sample targets. +- **Found on:** reported by MacNativeGames (`f864eb0`): a mod's 1×1 texture in Tears of the Kingdom declaring 9 levels. No game here is known to reach it; the sample counts (an M4 Pro has 1, 2 and 4, and a multisample texture of one sample aborts) were checked on that Mac while porting the fix. +- **Check:** `t_view_levels` makes each case with Metal's validation layer on: a 1×1 texture of 9 levels, a 3D texture whose depth holds the levels, an array of no layers, a 1D array of 4,096 layers, an 8-sample and a 1-sample multisample texture, and a copy at a missing level. + ## Render targets, passes and clears ### Transient render targets shared by storage diff --git a/docs/reference/shaders.md b/docs/reference/shaders.md index 2146350..455c826 100644 --- a/docs/reference/shaders.md +++ b/docs/reference/shaders.md @@ -727,6 +727,14 @@ shader on a GPU. - **Found on:** [The Legend of Zelda: Breath of the Wild](../../dm/games/01007EF00011E000/README.md#found-on-this-game), during review of its offline archive path with synthetic libraries and pair lists; no new gameplay or iPhone run. - **Check:** `dm/tools/tests/test_pso_offline.py` changes vertex and fragment library bytes without changing entries or stamps, and exercises the all-constant plan. `t_pso_bundle` rejects bare headers, truncated archives, malformed load commands and out-of-range universal slices, accepts real offline and serialized archives, and rejects zero-probe coverage. +### Metal's build folders left by pipeline archive flushes + +- **Symptom:** the user cache folder (`getconf DARWIN_USER_CACHE_DIR`, then `com.apple.gpuarchiver/`) fills with `gpuarchiver-*` folders of 0.3–5 MB each, one per pipeline archive flush, and they stay after the game exits: 456 folders, 2.5 GB, on one Mac after two days of NVN game runs. On iOS they stay in the app's own cache folder. +- **Cause:** the Metal 4 serializer (`MTL4PipelineDataSetSerializer`) makes a build folder for the captures between two flushes: at the first compile it captures, or, for bases taken again from lookup archives (compaction), during the flush. No flush, release or exit removes the folder. Nothing reads it once its flush has written the archive file: a probe that removed each folder after its flush took every pipeline from the files again. The process keeps no file open in it, and on the Mac the folder is shared by all of the user's programs. +- **Fix:** `nvn_mtl.m` (`ga_capture`, `ga_flushed`, `ga_exit`): the first capture after a flush lists the folder before and after its compile and takes the one folder that appeared as its own; the flush removes it once its file is written, together with the one folder the flush made when it held recaptures; exit removes the folder of captures no flush wrote. When more than one folder appeared in that window (another program's capture), none is removed. On iOS the start removes the folders earlier runs left. Folders left before this fix stay; they are safe to delete while no game or Metal tool runs. +- **Found on:** reported by MacNativeGames (`1eff989`, `110e6c6`, Metal 3's `MTLBinaryArchive`, 25 GB overnight on Tears of the Kingdom); reproduced here with the Metal 4 serializer by a probe and on this repository's Mac, no single game identified. +- **Check:** `t_pso_archive` checks that the first capture finds the folder and the flush removes it; a run of the test leaves no new folder (it left two before the fix, one from compaction and one from captures no flush wrote). + ### Pipeline keys omit unused colour state - **Symptom:** identical render pipelines compile repeatedly under different keys, lengthening startup prewarm and adding pipeline misses during play.