NVN: pools resident on first GPU use; vis overflow buffer; view format prediction

Ported from d2r-mobile (e2a0c9a9b, 2eb575877), adapted:
- Memory pools no longer go into the residency set when created. On
  iOS a no-copy buffer in the residency set is read in and wired whole,
  even if the game never uses the pool (upstream measured +530 MB for
  one unused 512 MB pool; a 4 GB A14 was killed at launch). A pool now
  becomes resident, and the set is committed on the spot, the first
  time mtl_addr / mtl_gpu_addr hands out its address. Mario keeps 163
  of its 871 pools resident, Engage 18 of 30. The memory line shows
  the count.
- The occlusion-query overflow buffer (one submission past VIS_SLOTS)
  was cleared at the next submission without being retired, so it
  stayed in the residency set; overflow also must never retire a ring
  buffer still in rotation. Both fixed.
- Pipeline prediction uses the format a colour attachment is actually
  attached with (the view's, when a view changes the format), shared
  with attach(). Prediction used the base format, so Mario's last pass
  (written through an sRGB view) predicted the wrong pipeline.
- Mario regression t76 (same scene, 60 fps), Engage movie / title /
  demo run and the iOS compile are clean.
This commit is contained in:
seedds committed 2026-10-15 07:41:44 +08:00
1 parent aadb9398e8
commit 7aef571f0b
1 file changed
+47 -18
+47 -18
View File
@@ -206,10 +206,11 @@ void bf_want_note(const char *key) {
// ---------- 内存池 ----------
// ---------- Memory pools ----------
typedef struct { uintptr_t lo, hi; uintptr_t base; __unsafe_unretained id<MTLBuffer> buf; uint64_t gpu; const void *key; } pool_ent;
typedef struct { uintptr_t lo, hi; uintptr_t base; __unsafe_unretained id<MTLBuffer> buf; uint64_t gpu; const void *key; int res; } pool_ent; // res:已放进驻留集(按需,见 pool_res) | res: already in the residency set (on demand, see pool_res)
static pool_ent *g_pools; static int g_npool, g_cap;
static NSMutableArray *g_keep; // 持有缓冲引用(pool_ent 里是不持有的指针) | Holds the buffer references (pool_ent has non-owning pointers)
static uint64_t g_pool_adds;
static int g_pool_res_n; // 在册池里已放进驻留集的个数(内存读数行用;持 g_mu) | registered pools already in the residency set (for the memory readout; under g_mu)
#define C1_BANK_SIZE (4u << 20)
#define C1_DECL_SIZE (64u << 10)
@@ -339,16 +340,27 @@ uint64_t dm_mtl_l3_sample(uint32_t tick) {
// No-copy buffers of finalized pools (weak references, under g_mu): the memory report counts how many are still alive and their length -- a buffer that outlives its pool is still strongly referenced by something else (a texture built directly on the pool, etc.)
static NSHashTable *g_dead_pools;
static _Atomic uint64_t g_reg_gen = 1; // 内存池 / 贴图 / 采样器登记的变更代号:参数缓冲复用以它为失效条件之一 | Change generation of memory pool / texture / sampler registration: one of the invalidation conditions for argument buffer reuse
// 内存池按需驻留(取自 d2r-mobile e2a0c9a9b):零拷贝缓冲放进驻留集,iOS 会把整个池读进内存并钉成 wired,游戏从没用过的池也一样
// (他们在 17 Pro Max 上量到一个 512 MB 的渲染目标池一局取址 0 次、整机 wired 多出约 530 MB;4 GB 的 A14 机因此启动几秒就被系统结束)。
// 所以池建好时不放进驻留集,GPU 第一次经 mtl_addr / mtl_gpu_addr 取到它的地址时才放进去并当场提交驻留集——GPU 拿池地址只有这两个口子,
// 当场提交早于用到它的任何命令缓冲提交。CPU 侧经 mtl_addr 读池内容也会触发(保守,不漏)。调用方持 g_mu
// On-demand residency of memory pools (from d2r-mobile e2a0c9a9b): putting a zero-copy buffer into the residency set makes iOS read the whole pool into memory and pin it as wired, even a pool the game never uses
// (they measured on a 17 Pro Max a 512 MB render-target pool addressed 0 times in a run, with about 530 MB more device wired memory; a 4 GB A14 was ended by the system within seconds of launch because of it).
// So a pool is not put into the residency set when created; it is put in, and the residency set committed on the spot, the first time the GPU gets its address through mtl_addr / mtl_gpu_addr -- these are the only two ways the GPU gets pool addresses,
// and committing on the spot is earlier than any command buffer commit that uses it. CPU-side reads of pool contents through mtl_addr trigger it too (conservative, never misses). Caller holds g_mu
static void pool_res(int i) {
if (g_pools[i].res) return;
res_add(g_pools[i].buf); res_commit(); g_pools[i].res = 1; g_pool_res_n++;
}
void mtl_pool_add(const void *mem, uint64_t size) {
mtl_init(); g_reg_gen++;
uintptr_t pg = (uintptr_t)getpagesize(), lo = (uintptr_t)mem, hi = lo + size;
uintptr_t a = lo & ~(pg - 1), b = (hi + pg - 1) & ~(pg - 1);
id<MTLBuffer> buf = [g_dev newBufferWithBytesNoCopy:(void *)a length:b - a options:MTLResourceStorageModeShared deallocator:nil];
if (!buf) MDIE("内存池 %p+%#llx 建零拷贝缓冲失败", mem, (unsigned long long)size);
res_add(buf);
pthread_mutex_lock(&g_mu);
if (g_npool == g_cap) { g_cap = g_cap ? g_cap * 2 : 64; g_pools = realloc(g_pools, g_cap * sizeof *g_pools); }
g_pools[g_npool++] = (pool_ent){lo, hi, a, buf, buf.gpuAddress, mem};
g_pools[g_npool++] = (pool_ent){lo, hi, a, buf, buf.gpuAddress, mem, 0}; // 先不进驻留集:第一次被取址时才放(pool_res) | not in the residency set yet: put in the first time it is addressed (pool_res)
g_pool_adds++;
[g_keep addObject:buf];
pthread_mutex_unlock(&g_mu);
@@ -362,7 +374,7 @@ void mtl_pool_del(const void *mem) {
// The no-copy buffer goes through deferred release (out of the residency set and unreferenced after submitted commands complete). It used to be removed only from g_keep while the residency set still held a strong reference: after the pool was freed the buffer stayed alive wrapping that guest memory,
// and a new pool at the same address wrapped it again. Engage's title demo cycles scenes; GPU allocated grew to 5.4 GB and resident memory to 3.6 GB in 850 s
for (int i = 0; i < g_npool; i++)
if (g_pools[i].key == mem) { id<MTLBuffer> b = g_pools[i].buf; [g_keep removeObject:b]; defer_obj_release(b);
if (g_pools[i].key == mem) { id<MTLBuffer> b = g_pools[i].buf; [g_keep removeObject:b]; defer_obj_release(b); if (g_pools[i].res) g_pool_res_n--;
if (!g_dead_pools) g_dead_pools = [NSHashTable weakObjectsHashTable];
[g_dead_pools addObject:b]; g_pools[i] = g_pools[--g_npool]; break; }
pthread_mutex_unlock(&g_mu);
@@ -373,7 +385,7 @@ int mtl_addr(uint64_t addr, uint64_t len, void **buf, uint64_t *off) {
pthread_mutex_lock(&g_mu);
for (int i = 0; i < g_npool; i++)
if (addr >= g_pools[i].lo && addr + len <= g_pools[i].hi) {
*buf = (__bridge void *)g_pools[i].buf; *off = addr - g_pools[i].base; ok = 1; break;
pool_res(i); *buf = (__bridge void *)g_pools[i].buf; *off = addr - g_pools[i].base; ok = 1; break;
}
pthread_mutex_unlock(&g_mu);
return ok;
@@ -383,7 +395,7 @@ int mtl_gpu_addr(uint64_t addr, uint64_t len, void **buf, uint64_t *off, uint64_
pthread_mutex_lock(&g_mu);
for (int i = 0; i < g_npool; i++)
if (addr >= g_pools[i].lo && addr + len <= g_pools[i].hi) {
*buf = (__bridge void *)g_pools[i].buf; *off = addr - g_pools[i].base; *gpu = g_pools[i].gpu + *off; ok = 1; break;
pool_res(i); *buf = (__bridge void *)g_pools[i].buf; *off = addr - g_pools[i].base; *gpu = g_pools[i].gpu + *off; ok = 1; break;
}
pthread_mutex_unlock(&g_mu);
return ok;
@@ -409,6 +421,7 @@ typedef struct { int n; vis_slot s[]; } vis_list;
typedef struct { __strong id<MTLBuffer> buf; uint64_t fence; } vis_buf;
static vis_buf g_vis_ring[4]; static int g_vis_ring_n, g_vis_ring_i = -1;
static id<MTLBuffer> g_vis; static uint32_t g_vis_n; // 本次提交的可见性缓冲与已用槽数 | This submission's visibility buffer and number of slots used
static int vis_in_ring(id<MTLBuffer> b) { for (int i = 0; i < g_vis_ring_n; i++) if (g_vis_ring[i].buf == b) return 1; return 0; }
static int g_vis_on, g_vis_counting, g_vis_cur = -1; static uint64_t g_vis_cur_pass;
static vis_slot *g_vs; static int g_nvs, g_vscap; // 自上次清零以来的槽 | Slots since the last reset
static uint64_t g_cnt_report[32], g_cnt_reset[32]; // 各计数器类型的报告 / 清零次数(诊断) | Report / reset counts per counter type (diagnostics)
@@ -492,6 +505,9 @@ void mtl_sub_begin(void) {
[g_alloc4[alloc_i] reset];
[g_cb beginCommandBufferWithAllocator:g_alloc4[alloc_i]];
g_commit_blocks = [NSMutableArray array];
// 上次提交槽用满时另建的溢出缓冲(见 g_vis_n >= VIS_SLOTS 那一处):退役。原先这里直接置空,它留在驻留集里永远不放(取自 d2r-mobile 2eb575877)
// The overflow buffer created when the previous submission ran out of slots (see g_vis_n >= VIS_SLOTS): retire it. It used to be just cleared here and stayed in the residency set for good (from d2r-mobile 2eb575877)
if (g_vis && !vis_in_ring(g_vis)) defer_obj_release(g_vis);
g_npost = 0; g_vis = nil; g_vis_n = 0; g_vis_cur = -1; g_npend = 0; g_pend_over = 0;
zskip_clear(); g_zskip_count = 0;
int pick = -1;
@@ -1492,8 +1508,8 @@ void mtl_mem_stat(unsigned long long frame, unsigned long long rss_mb) {
unsigned long dead_n = 0; unsigned long long dead_mb = 0;
// 弱引用表取出的对象是「保留 + 自动释放」:帧钩子在客体线程上,没有会排空的自动释放池,不包一层就把数到的缓冲永远钉住(读数自己造出泄漏)
// Objects read from a weak table come back retained + autoreleased: the frame hook runs on a guest thread with no autorelease pool that drains, so without this wrapper every buffer counted would be pinned for good (the readout creating the leak itself)
@autoreleasepool { pthread_mutex_lock(&g_mu); for (id<MTLBuffer> b in g_dead_pools) { dead_n++; dead_mb += b.length; } pthread_mutex_unlock(&g_mu); } dead_mb >>= 20; // 按序号挂着的宿主对象(贴图 / 预排布贴图 / 程序):显卡已分配一路涨而它也一路涨,就是贴图没放 | host objects held by serial (textures / packaged textures / programs): if it climbs along with GPU allocated, textures are not being released
MLOG("内存 至帧 %llu:常驻 %llu MB,显卡已分配 %llu MB,延迟释放队列 %lu,驻留集 %lu 个,序号对象 %u 个,内存池 %d 个(峰值 %d / 跟踪表容量 %d;已释放仍活 %lu 个 %llu MB),共用贴图 %u 张(随池淘汰累计 %llu),贴图重传累计 %llu 次,每帧竞技场累计新建 %llu 块", frame, rss_mb, gpu, nd, nr, ns, live, peak, cap, dead_n, dead_mb, ashared, (unsigned long long)g_alias_evicted, (unsigned long long)g_pkg_reuploads, (unsigned long long)g_arena_made);
int pres = 0; @autoreleasepool { pthread_mutex_lock(&g_mu); pres = g_pool_res_n; for (id<MTLBuffer> b in g_dead_pools) { dead_n++; dead_mb += b.length; } pthread_mutex_unlock(&g_mu); } dead_mb >>= 20; // 按序号挂着的宿主对象(贴图 / 预排布贴图 / 程序):显卡已分配一路涨而它也一路涨,就是贴图没放 | host objects held by serial (textures / packaged textures / programs): if it climbs along with GPU allocated, textures are not being released
MLOG("内存 至帧 %llu:常驻 %llu MB,显卡已分配 %llu MB,延迟释放队列 %lu,驻留集 %lu 个,序号对象 %u 个,内存池 %d 个(其中已驻留 %d;峰值 %d / 跟踪表容量 %d;已释放仍活 %lu 个 %llu MB),共用贴图 %u 张(随池淘汰累计 %llu),贴图重传累计 %llu 次,每帧竞技场累计新建 %llu 块", frame, rss_mb, gpu, nd, nr, ns, live, pres, peak, cap, dead_n, dead_mb, ashared, (unsigned long long)g_alias_evicted, (unsigned long long)g_pkg_reuploads, (unsigned long long)g_arena_made);
if (g_allocprof > 0) { char ab[256]; int o = 0; for (int i = 0; i < g_alloc_n && o < 200; i++) o += snprintf(ab + o, sizeof ab - o, " #%d %llu MB", i, (unsigned long long)(g_alloc4[i].allocatedSize >> 20));
for (int i = 0; i < 3 && o < 240; i++) o += snprintf(ab + o, sizeof ab - o, " 传%d %llu 出%d %llu", i, (unsigned long long)(g_up_alloc4[i].allocatedSize >> 20), i, (unsigned long long)(g_present_alloc4[i].allocatedSize >> 20));
MLOG("命令分配器占用:%s", ab);
@@ -1913,6 +1929,17 @@ static void rp_end(void) { if (g_enc) { AP_B;
} }
void pg_sub_begin_ext(uint64_t fence) { pg_sub_begin(fence); }
void pg_commit_ext(id<MTLCommandBuffer> cb) { pg_commit(cb); }
// 附件实际挂上的格式:视图换了格式且挂得上就是视图格式,否则底图格式。挂附件(attach)与管线预测(pso_predict_rest)共用,口径一致——
// 原先预测按底图格式,经 sRGB 视图写的通道(马力欧末道)预测出的管线格式不对,真管线到首次绘制才现编(取自 d2r-mobile e2a0c9a9b)
// The format an attachment is actually attached with: the view's format if the view changes the format and can be attached, otherwise the base texture's. Shared by attaching (attach) and pipeline prediction (pso_predict_rest) so both agree --
// prediction used the base format, so for a pass written through an sRGB view (Mario's last pass) the predicted pipeline had the wrong format and the real one was compiled at the first draw (from d2r-mobile e2a0c9a9b)
static MTLPixelFormat rt_view_fmt(id<MTLTexture> base, const view_t *v, int has_v) {
if (!base || !has_v || !v->set_format) return base.pixelFormat;
uint32_t pf = 0; uint8_t swz[4];
if (!nvn_fmt_to_mtl(v->format, &pf, swz) || !pf || pf == (uint32_t)base.pixelFormat) return base.pixelFormat;
if (tex_need_pfv(base.pixelFormat, (MTLPixelFormat)pf) && !(base.usage & MTLTextureUsagePixelFormatView)) return base.pixelFormat;
return (MTLPixelFormat)pf;
}
static void attach(MTLRenderPassAttachmentDescriptor *a, const tex_t *t, const view_t *v, int has_v) {
id<MTLTexture> base = tex_mtl(t); a.texture = base;
if (has_v && v->set_levels) a.level = v->base_level;
@@ -1922,14 +1949,14 @@ static void attach(MTLRenderPassAttachmentDescriptor *a, const tex_t *t, const v
// A view that changes the format is attached as a Metal view of that format (pipeline formats follow the attachment). SMBW's last pass writes the RGBA8 swapchain texture through an RGBA8_SRGB view, so the hardware encodes sRGB on write;
// attaching the base texture instead stored the linear values as they were and the whole picture came out dark (every value decoded from sRGB once more, on the Mac and the iPhone). The view lives until this submission completes
if (base && has_v && v->set_format) {
uint32_t pf = 0; uint8_t swz[4];
if (nvn_fmt_to_mtl(v->format, &pf, swz) && pf && pf != (uint32_t)base.pixelFormat) {
if (!tex_need_pfv(base.pixelFormat, (MTLPixelFormat)pf) || (base.usage & MTLTextureUsagePixelFormatView)) {
id<MTLTexture> vw = [base newTextureViewWithPixelFormat:(MTLPixelFormat)pf];
if (vw) { a.texture = vw; defer_obj_unref(vw); }
else RED_ONCE(67, "渲染目标视图建不出:格式 %#x(底图 Metal 格式 %lu)", v->format, (unsigned long)base.pixelFormat);
} else RED_ONCE(68, "渲染目标视图格式 %#x 挂不上(底图 Metal 格式 %lu 建时没有换格式用途):按底图格式画", v->format, (unsigned long)base.pixelFormat);
}
MTLPixelFormat pf = rt_view_fmt(base, v, has_v);
if (pf != base.pixelFormat) {
id<MTLTexture> vw = [base newTextureViewWithPixelFormat:pf];
if (vw) { a.texture = vw; defer_obj_unref(vw); }
else RED_ONCE(67, "渲染目标视图建不出:格式 %#x(底图 Metal 格式 %lu)", v->format, (unsigned long)base.pixelFormat);
} else { uint32_t want = 0; uint8_t swz[4];
if (nvn_fmt_to_mtl(v->format, &want, swz) && want && want != (uint32_t)base.pixelFormat)
RED_ONCE(68, "渲染目标视图格式 %#x 挂不上(底图 Metal 格式 %lu 建时没有换格式用途):按底图格式画", v->format, (unsigned long)base.pixelFormat); }
}
}
// mode 1(clear_only):只为落地挂起的清除而开的通道,只挂有清除的附件;mode 2:前瞻证明本通道不用深度模板,不挂深度。
@@ -2060,7 +2087,9 @@ static id<MTL4RenderCommandEncoder> rp_begin_ex_(int clear_only) {
g_enc_pso = nil; g_enc_dss = nil;
}
if (g_vis_on) {
if (!g_vis || g_vis_n >= VIS_SLOTS) { if (g_vis) defer_obj_release(g_vis); g_vis = [g_dev newBufferWithLength:VIS_SLOTS * 8 options:MTLResourceStorageModeShared]; res_add(g_vis); g_vis_n = 0; }
// 槽用满(一次提交超过 VIS_SLOTS 个计数通道):另建溢出缓冲接着用。环里的缓冲不能退役(还要轮着用,退役会把它移出驻留集),只退上一块溢出缓冲
// Slots used up (one submission exceeds VIS_SLOTS counting passes): continue in a new overflow buffer. A ring buffer must not be retired (it is still used in rotation, and retiring removes it from the residency set); only a previous overflow buffer is
if (!g_vis || g_vis_n >= VIS_SLOTS) { if (g_vis && !vis_in_ring(g_vis)) defer_obj_release(g_vis); g_vis = [g_dev newBufferWithLength:VIS_SLOTS * 8 options:MTLResourceStorageModeShared]; res_add(g_vis); g_vis_n = 0; }
pd.visibilityResultBuffer = g_vis;
}
g_enc_vis = 0; g_enc_has_vis = g_vis_on;
@@ -3447,7 +3476,7 @@ static void pso_predict_rest(void) {
if (!fz_next(&cur, &q, &hq, &aq)) break;
if (hq.op == OP_SET_RT) {
const p_set_rt *r = aq; memset(cf, 0, sizeof cf); dfull = sfull = 0; samples = 1;
for (int i = 0; i < r->n && i < 8; i++) { id<MTLTexture> t = r->color[i] ? tex_mtl(r->color[i]) : nil; if (t) { cf[i] = (uint32_t)t.pixelFormat; samples = (uint32_t)t.sampleCount; } }
for (int i = 0; i < r->n && i < 8; i++) { id<MTLTexture> t = r->color[i] ? tex_mtl(r->color[i]) : nil; if (t) { cf[i] = (uint32_t)rt_view_fmt(t, &r->cview[i], r->has_cview[i]); samples = (uint32_t)t.sampleCount; } }
id<MTLTexture> dt = r->depth ? tex_mtl(r->depth) : nil;
if (dt) { MTLPixelFormat f = dt.pixelFormat; samples = (uint32_t)dt.sampleCount;
dfull = f != MTLPixelFormatStencil8 ? (uint32_t)f : 0; sfull = (f == MTLPixelFormatStencil8 || f == MTLPixelFormatDepth32Float_Stencil8) ? (uint32_t)f : 0; }