Files
2026-10-10 14:58:40 +00:00

358 lines
16 KiB
C++

// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
//
// Where the renderer (gpu/renderer.cpp) puts the two big allocations the CPU
// writes: guest physical memory as the GPU sees it (512 MB: imported without
// a copy, or a copy the write watch keeps current) and the upload ring
// (192 MB: draw constants, converted indices, texture staging). A plan is a
// list of places to try in turn; the renderer goes on to the next when the
// driver refuses one, so a refused allocation is never fatal. Vulkan-free
// (the flags are Vulkan's values): tests/memory_plan_test.cpp checks it alone
// against the memory of the devices we know.
//
// Chosen by capability, not by device type alone. "CPU-mapped VRAM" (a
// device-local, host-visible memory type) is all of VRAM with resizable BAR,
// but only a 256 MB window without it (NVIDIA before Ampere, and any GPU with
// resizable BAR off in the firmware). v0.1.0 put a discrete GPU's 512 MB copy
// there whatever its size, and the driver's refusal (out of device memory)
// aborted the game at startup (issue #2: a GTX 1080 Ti and an RTX 4070 on
// Linux). Now a place is planned first only when its heap holds what we put
// there with room to spare: at most half the heap (kHeapShareDivisor; the
// rest is for the driver, the game's textures and render targets, and other
// programs). Otherwise, on a discrete GPU: guest memory imported (the GPU
// reads system memory over PCIe without a copy: VK_EXT_external_memory_host,
// which NVIDIA's driver has), then a copy in system memory, uncached
// (write-combined) first; the ring in cached system memory, then uncached.
// Every list ends with the places that didn't fit, as a last try.
//
// Devices that fit keep exactly what v0.1.0 chose (the tests pin each one):
// the Steam Machine (discrete RDNA3, resizable BAR: copy and ring in VRAM),
// the Steam Deck (integrated: import, which RADV refuses for the guest's
// shared mapping, then a copy in uncached system memory; ring in VRAM), the
// Steam Frame and other unified-memory devices (a copy in their one
// host-visible device memory), and Apple's (imported).
#pragma once
#include <cstdint>
#include <cstdio>
#include <string>
#include <vector>
namespace gpu::memplan
{
// VkMemoryPropertyFlagBits.
constexpr uint32_t kDeviceLocal = 0x1, kHostVisible = 0x2, kHostCoherent = 0x4, kHostCached = 0x8;
constexpr uint32_t kMapped = kHostVisible | kHostCoherent;
// Never planned: lazily allocated, protected, and AMD's device-coherent
// and device-uncached types (slower; they come after the plain ones).
constexpr uint32_t kSpecial = 0x10 | 0x20 | 0x40 | 0x80;
constexpr uint32_t kNoType = UINT32_MAX;
constexpr uint64_t kGuestBytes = 512ull << 20;
constexpr uint64_t kRingBytes = 192ull << 20;
// What we plan into a heap: at most this share of it (1/2).
constexpr uint64_t kHeapShareDivisor = 2;
// VkDriverId values the plan knows.
constexpr uint32_t kDriverIntelMesa = 6; // VK_DRIVER_ID_INTEL_OPEN_SOURCE_MESA (ANV, Linux)
enum class Gpu : uint8_t { kDiscrete, kIntegrated, kOther };
// kVram: device-local and host-visible ("CPU-mapped VRAM"; on a unified
// memory device, its one host-visible memory). kSystem: host-visible, not
// device-local, not cached (write-combined system memory). kCached:
// host-visible and cached; not device-local for guest memory, any for
// the ring (NFSMW_RING_MEMORY=cached chose so before).
enum class Place : uint8_t { kImport, kVram, kSystem, kCached };
struct Type
{
uint32_t flags = 0, heap = 0;
};
struct Memory
{
std::vector<Type> types;
std::vector<uint64_t> heaps; // bytes
};
struct Candidate
{
Place place;
uint32_t type; // kNoType for kImport (the import finds its own)
bool fits; // its heap holds it (kHeapShareDivisor); always true for kImport
};
struct Plan
{
std::vector<Candidate> order; // try in turn
std::string why; // the reason for the order, for the log
};
// The environment's overrides (unset or unknown: kAuto).
// NFSMW_SHARED_MEMORY=import|shadow: import first / never import.
// NFSMW_SHADOW_MEMORY=vram|gtt: the copy's first place.
// NFSMW_RING_MEMORY=vram|cached|system: the ring's first place.
// An override is tried first whatever its heap's size, and the default
// order follows it.
struct Overrides
{
enum class Shared : uint8_t { kAuto, kImport, kShadow } shared = Shared::kAuto;
Place copy = Place::kImport; // kImport: none (kVram or kSystem when set)
Place ring = Place::kImport; // kImport: none
};
inline bool Is(const char* v, const char* s)
{
return v && std::string(v) == s;
}
inline Overrides ParseOverrides(const char* shared, const char* shadow, const char* ring)
{
Overrides o;
o.shared = Is(shared, "import") ? Overrides::Shared::kImport : Is(shared, "shadow") ? Overrides::Shared::kShadow : Overrides::Shared::kAuto;
o.copy = Is(shadow, "vram") ? Place::kVram : Is(shadow, "gtt") ? Place::kSystem : Place::kImport;
o.ring = Is(ring, "vram") ? Place::kVram : Is(ring, "cached") ? Place::kCached : Is(ring, "system") ? Place::kSystem : Place::kImport;
return o;
}
// Host-memory import that the driver offers but that can't work here:
// nullptr when it can, else why not (for the log). Intel's Linux driver
// (ANV) imports host memory as an i915 (or xe) userptr object. Each
// protection change on those pages (the write watch's mprotect, many a
// frame) invalidates it, and the next submission pins all its pages
// again for writing; any page the write watch holds read-only or
// inaccessible then fails that (EFAULT), the kernel refuses the
// submission, and ANV reports the device lost: issue #2's Arrow Lake
// laptop, VK_ERROR_DEVICE_LOST in vkQueueSubmit 0.8 s after the import,
// with ANV's dump of the refused batch (the 512 MB "host-ptr" object at
// the guest's 0xA0000000 window) just before. NVIDIA's driver pins the
// pages once at import and MoltenVK wraps them in a Metal buffer, so
// protection changes don't touch the GPU's mapping there.
inline const char* ImportRefusal(uint32_t driverId)
{
if (driverId == kDriverIntelMesa)
return "Intel's Linux driver can't keep imported memory mapped while the write watch protects its pages (issue #2)";
return nullptr;
}
// NFSMW_TEST_BAR_HEAP_MB (a test hook): every heap holding a CPU-mapped
// VRAM type reads as `bytes`, as on a GPU without resizable BAR.
inline void ShrinkMappedVram(Memory& m, uint64_t bytes)
{
for (const Type& t : m.types)
if ((t.flags & (kDeviceLocal | kHostVisible)) == (kDeviceLocal | kHostVisible) && t.heap < m.heaps.size())
m.heaps[t.heap] = bytes;
}
// The first memory type among `bits` for `place` (kNoType: none).
inline uint32_t FindType(const Memory& m, uint32_t bits, Place place, bool ring)
{
auto find = [&](auto&& ok) {
for (uint32_t i = 0; i < m.types.size() && i < 32; i++)
if ((bits & (1u << i)) && !(m.types[i].flags & kSpecial) && (m.types[i].flags & kMapped) == kMapped && ok(m.types[i].flags))
return i;
return kNoType;
};
switch (place)
{
case Place::kVram:
return find([](uint32_t f) { return (f & kDeviceLocal) != 0; });
case Place::kSystem:
return find([](uint32_t f) { return !(f & (kDeviceLocal | kHostCached)); });
case Place::kCached:
{
uint32_t t = find([](uint32_t f) { return (f & kHostCached) && !(f & kDeviceLocal); });
return t != kNoType || !ring ? t : find([](uint32_t f) { return (f & kHostCached) != 0; });
}
case Place::kImport:
break;
}
return kNoType;
}
inline uint64_t HeapOf(const Memory& m, uint32_t type)
{
return type < m.types.size() && m.types[type].heap < m.heaps.size() ? m.heaps[m.types[type].heap] : 0;
}
// `bytes` more in `type`'s heap, beside `also` already there, within kHeapShareDivisor.
inline bool Fits(const Memory& m, uint32_t type, uint64_t bytes, uint64_t also = 0)
{
return type != kNoType && also + bytes <= HeapOf(m, type) / kHeapShareDivisor;
}
inline std::string Size(uint64_t bytes)
{
char s[32];
if (bytes >= (10ull << 30))
snprintf(s, sizeof(s), "%.0f GB", double(bytes) / double(1ull << 30));
else if (bytes >= (1ull << 30))
snprintf(s, sizeof(s), "%.2f GB", double(bytes) / double(1ull << 30));
else
snprintf(s, sizeof(s), "%llu MB", (unsigned long long)(bytes >> 20));
return s;
}
// For NFSMW_TEST_ALLOC_FAIL and the log.
inline const char* Name(Place place, bool ring)
{
switch (place)
{
case Place::kImport: return "import";
case Place::kVram: return ring ? "ring-vram" : "copy-vram";
case Place::kSystem: return ring ? "ring-system" : "copy-system";
case Place::kCached: return ring ? "ring-cached" : "copy-cached";
}
return "?";
}
namespace detail
{
// `places` in order, each once, those with a type (or kImport).
inline void Add(Plan& p, const Memory& m, uint32_t bits, const std::vector<Place>& places, bool ring, uint64_t bytes, uint64_t also)
{
for (Place place : places)
{
bool seen = false;
for (const Candidate& c : p.order)
seen |= c.place == place;
if (seen)
continue;
if (place == Place::kImport)
{
p.order.push_back({ place, kNoType, true });
continue;
}
uint32_t type = FindType(m, bits, place, ring);
if (type != kNoType)
p.order.push_back({ place, type, Fits(m, type, bytes, place == Place::kVram ? also : 0) });
}
}
inline std::string HeapText(const Memory& m, uint32_t type)
{
return "heap " + std::to_string(m.types[type].heap) + ", " + Size(HeapOf(m, type));
}
}
struct GuestInput
{
Gpu gpu = Gpu::kOther;
bool importable = false; // VK_EXT_external_memory_host
const char* importRefusal = nullptr; // ImportRefusal()
uint32_t bits = ~0u; // the copy buffer's memoryTypeBits
Overrides overrides;
};
// Guest physical memory.
inline Plan PlanGuestMemory(const Memory& m, const GuestInput& in)
{
Plan p;
const Overrides& o = in.overrides;
uint32_t vram = FindType(m, in.bits, Place::kVram, false);
bool vramFits = Fits(m, vram, kGuestBytes);
bool import = in.importable && o.shared != Overrides::Shared::kShadow &&
(!in.importRefusal || o.shared == Overrides::Shared::kImport);
// The copy's places, best first.
std::vector<Place> copy;
switch (in.gpu)
{
case Gpu::kDiscrete:
copy = vramFits ? std::vector<Place>{ Place::kVram, Place::kSystem, Place::kCached }
: std::vector<Place>{ Place::kSystem, Place::kCached, Place::kVram };
break;
case Gpu::kIntegrated:
// Its "VRAM" is a carve-out of the same RAM, 1 GB on a Deck, and
// the copy there with the textures and targets didn't fit: at
// every world load the kernel moved ~500 MB of it out to GTT,
// and the GPU reads GTT as fast. So system memory (write-combined
// GTT on amdgpu, uncached first), and the device's host-visible
// memory where that is all there is (unified memory: the Steam
// Frame, Intel).
copy = { Place::kSystem, Place::kCached, Place::kVram };
break;
case Gpu::kOther:
copy = { Place::kVram, Place::kSystem, Place::kCached };
break;
}
if (o.copy != Place::kImport)
copy.insert(copy.begin(), o.copy);
// Import: first, except on a discrete GPU whose own memory takes the
// copy (VRAM it holds, or the place NFSMW_SHADOW_MEMORY names): the
// GPU reads its VRAM faster than system memory over PCIe.
std::vector<Place> places;
bool copyFirst = in.gpu == Gpu::kDiscrete && o.shared != Overrides::Shared::kImport && (vramFits || o.copy != Place::kImport);
if (import && !copyFirst)
places.push_back(Place::kImport);
places.push_back(copy.front());
if (import && copyFirst)
places.push_back(Place::kImport);
places.insert(places.end(), copy.begin() + 1, copy.end());
detail::Add(p, m, in.bits, places, false, kGuestBytes, 0);
// Why.
std::string why;
switch (in.gpu)
{
case Gpu::kDiscrete:
if (vram == kNoType)
why = "a discrete GPU without CPU-mapped VRAM";
else if (vramFits)
why = "a discrete GPU whose CPU-mapped VRAM (" + detail::HeapText(m, vram) + ") holds the 512 MB copy: resizable BAR";
else
why = "a discrete GPU whose CPU-mapped VRAM (" + detail::HeapText(m, vram) +
") is too small for the 512 MB copy: no resizable BAR";
break;
case Gpu::kIntegrated:
why = "an integrated GPU (system memory)";
break;
case Gpu::kOther:
why = "a GPU neither discrete nor integrated";
break;
}
if (o.shared == Overrides::Shared::kImport)
why += in.importable ? "; NFSMW_SHARED_MEMORY=import" : "; NFSMW_SHARED_MEMORY=import, but the driver can't import host memory";
else if (o.shared == Overrides::Shared::kShadow)
why += "; NFSMW_SHARED_MEMORY=shadow";
else if (!in.importable && (in.gpu != Gpu::kDiscrete || !vramFits))
why += "; the driver can't import host memory";
else if (in.importRefusal)
why += std::string("; not imported: ") + in.importRefusal;
if (o.copy == Place::kVram)
why += "; NFSMW_SHADOW_MEMORY=vram";
else if (o.copy == Place::kSystem)
why += "; NFSMW_SHADOW_MEMORY=gtt";
p.why = why;
return p;
}
// The upload ring. `guestType`: the memory type guest memory's copy went
// to (kNoType: imported), whose 512 MB count against the ring's heap.
inline Plan PlanRing(const Memory& m, uint32_t bits, uint32_t guestType, Place override = Place::kImport)
{
Plan p;
uint32_t vram = FindType(m, bits, Place::kVram, true);
uint64_t also = guestType != kNoType && vram != kNoType && guestType < m.types.size() &&
m.types[guestType].heap == m.types[vram].heap ? kGuestBytes : 0;
bool vramFits = Fits(m, vram, kRingBytes, also);
std::vector<Place> places = vramFits ? std::vector<Place>{ Place::kVram, Place::kCached, Place::kSystem }
: std::vector<Place>{ Place::kCached, Place::kSystem, Place::kVram };
if (override != Place::kImport)
places.insert(places.begin(), override);
detail::Add(p, m, bits, places, true, kRingBytes, also);
if (vram == kNoType)
p.why = "no CPU-mapped VRAM";
else if (vramFits)
p.why = "CPU-mapped VRAM (" + detail::HeapText(m, vram) + ") holds it";
else
p.why = "CPU-mapped VRAM (" + detail::HeapText(m, vram) + ") is too small for the 192 MB ring" +
(also ? " beside the copy" : "");
if (override == Place::kVram)
p.why += "; NFSMW_RING_MEMORY=vram";
else if (override == Place::kCached)
p.why += "; NFSMW_RING_MEMORY=cached";
else if (override == Place::kSystem)
p.why += "; NFSMW_RING_MEMORY=system";
return p;
}
}