mirror of
https://github.com/neoStudiosLCE/neoLegacy.git
synced 2026-07-02 14:47:02 +00:00
perf: async autosave for dedicated server
Autosave previously froze the main thread for 2-6 seconds while compressing the entire save file with zlib. Now the save buffer is snapshotted under the lock (~18ms), then compression runs on a background thread. The compressed data is committed to StorageManager on the next main-thread tick via CommitPendingAsyncSave(). Also skip redundant full chunk saves during autosave on the dedicated server -- chunks are already persisted by the per-tick trickle save. Only entity data is flushed, matching Xbox/Orbis behavior. Added per-step timing to the autosave handler for diagnostics.
This commit is contained in:
parent
450891d8c4
commit
073a511217
|
|
@ -34,6 +34,9 @@
|
|||
#ifdef _WINDOWS64
|
||||
#include "Windows64\Network\WinsockNetLayer.h"
|
||||
#endif
|
||||
#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD)
|
||||
#include "..\Minecraft.Server\ServerLogger.h"
|
||||
#endif
|
||||
#include <sstream>
|
||||
#ifdef SPLIT_SAVES
|
||||
#include "..\Minecraft.World\ConsoleSaveFileSplit.h"
|
||||
|
|
@ -1881,11 +1884,23 @@ void MinecraftServer::run(int64_t seed, void *lpParameter)
|
|||
QueryPerformanceCounter(&qwTime);
|
||||
#endif
|
||||
|
||||
#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD)
|
||||
LARGE_INTEGER asTicksPerSec, asT0, asT1;
|
||||
QueryPerformanceFrequency(&asTicksPerSec);
|
||||
double asSecsPerTick = 1.0 / (double)asTicksPerSec.QuadPart;
|
||||
QueryPerformanceCounter(&asT0);
|
||||
LARGE_INTEGER asAfterPlayers, asAfterLevels, asAfterRules, asAfterFlush;
|
||||
#endif
|
||||
|
||||
if (players != nullptr)
|
||||
{
|
||||
players->saveAll(nullptr);
|
||||
}
|
||||
|
||||
#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD)
|
||||
QueryPerformanceCounter(&asAfterPlayers);
|
||||
#endif
|
||||
|
||||
for (unsigned int j = 0; j < levels.length; j++)
|
||||
{
|
||||
if( s_bServerHalted ) break;
|
||||
|
|
@ -1901,6 +1916,11 @@ void MinecraftServer::run(int64_t seed, void *lpParameter)
|
|||
PIXEndNamedEvent();
|
||||
#endif
|
||||
}
|
||||
|
||||
#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD)
|
||||
QueryPerformanceCounter(&asAfterLevels);
|
||||
#endif
|
||||
|
||||
if (!s_bServerHalted)
|
||||
{
|
||||
#if defined(_XBOX_ONE) || defined(__ORBIS__)
|
||||
|
|
@ -1912,7 +1932,24 @@ void MinecraftServer::run(int64_t seed, void *lpParameter)
|
|||
|
||||
PIXBeginNamedEvent(0, "Save to disc");
|
||||
#endif
|
||||
|
||||
#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD)
|
||||
QueryPerformanceCounter(&asAfterRules);
|
||||
#endif
|
||||
|
||||
levels[0]->saveToDisc(Minecraft::GetInstance()->progressRenderer, true);
|
||||
|
||||
#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD)
|
||||
QueryPerformanceCounter(&asAfterFlush);
|
||||
ServerRuntime::LogInfof("world-io",
|
||||
"autosave breakdown: players=%.0fms levels=%.0fms rules=%.0fms flush=%.0fms total=%.0fms",
|
||||
(asAfterPlayers.QuadPart - asT0.QuadPart) * asSecsPerTick * 1000.0,
|
||||
(asAfterLevels.QuadPart - asAfterPlayers.QuadPart) * asSecsPerTick * 1000.0,
|
||||
(asAfterRules.QuadPart - asAfterLevels.QuadPart) * asSecsPerTick * 1000.0,
|
||||
(asAfterFlush.QuadPart - asAfterRules.QuadPart) * asSecsPerTick * 1000.0,
|
||||
(asAfterFlush.QuadPart - asT0.QuadPart) * asSecsPerTick * 1000.0);
|
||||
#endif
|
||||
|
||||
#if defined(_XBOX_ONE) || defined(__ORBIS__)
|
||||
PIXEndNamedEvent();
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -977,8 +977,11 @@ void ServerLevel::save(bool force, ProgressListener *progressListener, bool bAut
|
|||
|
||||
if (progressListener != nullptr) progressListener->progressStage(IDS_PROGRESS_SAVING_CHUNKS);
|
||||
|
||||
#if defined(_XBOX_ONE) || defined(__ORBIS__)
|
||||
// Our autosave is a minimal save. All the chunks are saves by the constant save process
|
||||
#if defined(_XBOX_ONE) || defined(__ORBIS__) || (defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD))
|
||||
// Autosave is a minimal save. Chunks are saved continuously by the
|
||||
// per-tick trickle save process (ServerChunkCache::tick), so we only
|
||||
// need to flush entity data here. The full chunkSource->save() would
|
||||
// redundantly re-save all dirty chunks and block the main thread.
|
||||
if(bAutosave)
|
||||
{
|
||||
chunkSource->saveAllEntities();
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
#include "..\Security\IdentityTokenManager.h"
|
||||
#include "..\WorldManager.h"
|
||||
#include "..\Console\ServerCli.h"
|
||||
#include "..\..\Minecraft.World\ConsoleSaveFileOriginal.h"
|
||||
#include "Tesselator.h"
|
||||
#include "Windows64/4JLibs/inc/4J_Render.h"
|
||||
#include "Windows64/GameConfig/Minecraft.spa.h"
|
||||
|
|
@ -330,6 +331,7 @@ static void TickCoreSystems()
|
|||
g_NetworkManager.DoWork();
|
||||
ProfileManager.Tick();
|
||||
StorageManager.Tick();
|
||||
ConsoleSaveFileOriginal::CommitPendingAsyncSave();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -12,6 +12,27 @@
|
|||
#include "..\Minecraft.Client\Common\GameRules\LevelGenerationOptions.h"
|
||||
#include "..\Minecraft.World\net.minecraft.world.level.chunk.storage.h"
|
||||
|
||||
#ifdef _WINDOWS64
|
||||
#include <thread>
|
||||
#include <atomic>
|
||||
#include <mutex>
|
||||
extern bool g_Win64DedicatedServer;
|
||||
static std::atomic<bool> s_asyncSaveInFlight{false};
|
||||
|
||||
// Pending async save: background thread fills this, main thread commits it.
|
||||
struct PendingAsyncSave
|
||||
{
|
||||
ConsoleSaveFile *self;
|
||||
PBYTE thumbData;
|
||||
DWORD thumbSize;
|
||||
BYTE textMetadata[88];
|
||||
int textMetadataBytes;
|
||||
bool ready;
|
||||
};
|
||||
static std::mutex s_pendingSaveMutex;
|
||||
static PendingAsyncSave s_pendingSave = {};
|
||||
#endif
|
||||
|
||||
|
||||
#ifdef _XBOX
|
||||
#define RESERVE_ALLOCATION MEM_RESERVE | MEM_LARGE_PAGES
|
||||
|
|
@ -673,6 +694,138 @@ void ConsoleSaveFileOriginal::Flush(bool autosave, bool updateThumbnail )
|
|||
|
||||
unsigned int fileSize = header.GetFileSize();
|
||||
|
||||
#ifdef _WINDOWS64
|
||||
// --- Dedicated server async flush path ---
|
||||
// Snapshot pvSaveMem while holding the lock (fast memcpy), then release
|
||||
// the lock immediately so the main thread can continue ticking. Compression
|
||||
// and disk write happen on a detached background thread.
|
||||
if (g_Win64DedicatedServer)
|
||||
{
|
||||
// If a previous async save is still compressing, fall through to the
|
||||
// synchronous path to avoid queuing unbounded background work.
|
||||
if (s_asyncSaveInFlight.load(std::memory_order_acquire))
|
||||
{
|
||||
app.DebugPrintf("Async save: previous still in flight, falling back to sync\n");
|
||||
goto sync_flush;
|
||||
}
|
||||
|
||||
// Snapshot: copy the entire save buffer so we can release the lock
|
||||
QueryPerformanceCounter(&qwTime);
|
||||
byte *snapshot = new (std::nothrow) byte[fileSize];
|
||||
if (snapshot == nullptr)
|
||||
{
|
||||
app.DebugPrintf("Async save: failed to allocate %u byte snapshot, falling back to sync\n", fileSize);
|
||||
goto sync_flush;
|
||||
}
|
||||
memcpy(snapshot, pvSaveMem, fileSize);
|
||||
QueryPerformanceCounter(&qwNewTime);
|
||||
qwDeltaTime.QuadPart = qwNewTime.QuadPart - qwTime.QuadPart;
|
||||
fElapsedTime = fSecsPerTick * static_cast<FLOAT>(qwDeltaTime.QuadPart);
|
||||
app.DebugPrintf("Async save: snapshot %u bytes in %.3f sec\n", fileSize, fElapsedTime);
|
||||
|
||||
// Gather metadata while still on the main thread
|
||||
PBYTE pbThumbnailData = nullptr;
|
||||
DWORD dwThumbnailDataSize = 0;
|
||||
app.GetSaveThumbnail(&pbThumbnailData, &dwThumbnailDataSize);
|
||||
|
||||
BYTE bTextMetadata[88];
|
||||
ZeroMemory(bTextMetadata, 88);
|
||||
int64_t seed = 0;
|
||||
bool hasSeed = false;
|
||||
if (MinecraftServer::getInstance() != nullptr && MinecraftServer::getInstance()->levels[0] != nullptr)
|
||||
{
|
||||
seed = MinecraftServer::getInstance()->levels[0]->getLevelData()->getSeed();
|
||||
hasSeed = true;
|
||||
}
|
||||
int iTextMetadataBytes = app.CreateImageTextData(bTextMetadata, seed, hasSeed,
|
||||
app.GetGameHostOption(eGameHostOption_All), Minecraft::GetInstance()->getCurrentTexturePackId());
|
||||
|
||||
INT saveOrCheckpointId = 0;
|
||||
StorageManager.GetSaveUniqueNumber(&saveOrCheckpointId);
|
||||
TelemetryManager->RecordLevelSaveOrCheckpoint(ProfileManager.GetPrimaryPad(), saveOrCheckpointId, fileSize);
|
||||
|
||||
// Release the lock -- main thread and chunk trickle saves can resume
|
||||
ReleaseSaveAccess();
|
||||
|
||||
// Compress and write on a background thread.
|
||||
// Pack metadata into a heap struct so the lambda captures a single
|
||||
// owning pointer instead of stack arrays that go out of scope.
|
||||
struct AsyncSaveContext
|
||||
{
|
||||
byte *snapshot;
|
||||
unsigned int fileSize;
|
||||
ConsoleSaveFile *self;
|
||||
PBYTE thumbData;
|
||||
DWORD thumbSize;
|
||||
BYTE textMetadata[88];
|
||||
int textMetadataBytes;
|
||||
};
|
||||
|
||||
auto *ctx = new AsyncSaveContext();
|
||||
ctx->snapshot = snapshot;
|
||||
ctx->fileSize = fileSize;
|
||||
ctx->self = this;
|
||||
ctx->thumbData = pbThumbnailData;
|
||||
ctx->thumbSize = dwThumbnailDataSize;
|
||||
memcpy(ctx->textMetadata, bTextMetadata, 88);
|
||||
ctx->textMetadataBytes = iTextMetadataBytes;
|
||||
|
||||
s_asyncSaveInFlight.store(true, std::memory_order_release);
|
||||
|
||||
std::thread([ctx]()
|
||||
{
|
||||
unsigned int compLength = ctx->fileSize + 8;
|
||||
byte *compData = static_cast<byte *>(StorageManager.AllocateSaveData(compLength));
|
||||
if (compData == nullptr)
|
||||
{
|
||||
// Pre-calculate compressed size
|
||||
compLength = 0;
|
||||
Compression::getCompression()->Compress(nullptr, &compLength, ctx->snapshot, ctx->fileSize);
|
||||
compLength += 8;
|
||||
compData = static_cast<byte *>(StorageManager.AllocateSaveData(compLength));
|
||||
}
|
||||
|
||||
if (compData != nullptr)
|
||||
{
|
||||
Compression::getCompression()->Compress(compData + 8, &compLength, ctx->snapshot, ctx->fileSize);
|
||||
|
||||
ZeroMemory(compData, 8);
|
||||
int saveVer = 0;
|
||||
memcpy(compData, &saveVer, sizeof(int));
|
||||
unsigned int fs = ctx->fileSize;
|
||||
memcpy(compData + 4, &fs, sizeof(int));
|
||||
|
||||
app.DebugPrintf("Async save: compressed %u -> %u bytes\n", ctx->fileSize, compLength);
|
||||
|
||||
// Queue for the main thread to commit via StorageManager
|
||||
// (StorageManager requires main-thread calls + Tick() to flush)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(s_pendingSaveMutex);
|
||||
s_pendingSave.self = ctx->self;
|
||||
s_pendingSave.thumbData = ctx->thumbData;
|
||||
s_pendingSave.thumbSize = ctx->thumbSize;
|
||||
memcpy(s_pendingSave.textMetadata, ctx->textMetadata, 88);
|
||||
s_pendingSave.textMetadataBytes = ctx->textMetadataBytes;
|
||||
s_pendingSave.ready = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
app.DebugPrintf("Async save: failed to allocate compression buffer\n");
|
||||
s_asyncSaveInFlight.store(false, std::memory_order_release);
|
||||
}
|
||||
|
||||
delete[] ctx->snapshot;
|
||||
delete ctx;
|
||||
}).detach();
|
||||
|
||||
return;
|
||||
}
|
||||
sync_flush:
|
||||
#endif
|
||||
|
||||
// --- Original synchronous flush path (game client / non-server) ---
|
||||
|
||||
// Assume that the compression will make it smaller so initially attempt to allocate the current file size
|
||||
// We add 4 bytes to the start so that we can signal compressed data
|
||||
// And another 4 bytes to store the decompressed data size
|
||||
|
|
@ -1130,3 +1283,21 @@ void *ConsoleSaveFileOriginal::getWritePointer(FileEntry *file)
|
|||
{
|
||||
return static_cast<char *>(pvSaveMem) + file->currentFilePointer;;
|
||||
}
|
||||
|
||||
#ifdef _WINDOWS64
|
||||
void ConsoleSaveFileOriginal::CommitPendingAsyncSave()
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(s_pendingSaveMutex);
|
||||
if (!s_pendingSave.ready)
|
||||
return;
|
||||
|
||||
StorageManager.SetSaveImages(
|
||||
s_pendingSave.thumbData, s_pendingSave.thumbSize,
|
||||
nullptr, 0, s_pendingSave.textMetadata, s_pendingSave.textMetadataBytes);
|
||||
StorageManager.SaveSaveData(
|
||||
&ConsoleSaveFileOriginal::SaveSaveDataCallback, s_pendingSave.self);
|
||||
|
||||
s_pendingSave.ready = false;
|
||||
s_asyncSaveInFlight.store(false, std::memory_order_release);
|
||||
}
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -77,6 +77,11 @@ public:
|
|||
virtual void LockSaveAccess();
|
||||
virtual void ReleaseSaveAccess();
|
||||
|
||||
#ifdef _WINDOWS64
|
||||
// Called from the main thread to commit a completed async save to StorageManager.
|
||||
static void CommitPendingAsyncSave();
|
||||
#endif
|
||||
|
||||
virtual ESavePlatform getSavePlatform();
|
||||
virtual bool isSaveEndianDifferent();
|
||||
virtual void setLocalPlatform();
|
||||
|
|
|
|||
|
|
@ -14,6 +14,13 @@ This project is based on source code of Minecraft Legacy Console Edition v1.6.05
|
|||
|
||||
## Latest:
|
||||
|
||||
### Async Autosave (Dedicated Server)
|
||||
|
||||
- Autosave no longer freezes the server. Previously, every autosave compressed the entire world save file with zlib synchronously on the main thread, blocking all game ticks for 2-6 seconds depending on world size
|
||||
- The save buffer is now snapshotted (memcpy) while holding the lock (~18ms), then compression runs on a background thread. The compressed data is committed back to StorageManager on the next main-thread tick
|
||||
- Additionally, autosave on the dedicated server now only flushes entity data instead of re-saving all dirty chunks, matching the Xbox/Orbis behavior. Chunks are already saved continuously by the per-tick trickle save process
|
||||
- Autosave timing breakdown is now logged to `server.log` for diagnostics (e.g. `autosave breakdown: players=0ms levels=0ms rules=0ms flush=18ms total=18ms`)
|
||||
|
||||
### Dedicated Server Entity Tracking Optimization
|
||||
|
||||
- Eliminated unnecessary O(players^2 * entities) split-screen system-mate checks in the entity tracker on dedicated servers. The `EntityTracker::tick()`, `TrackedEntity::isVisible()`, and `TrackedEntity::broadcast()` functions all contained loops that called `IsSameSystem()` to support console split-screen couch co-op visibility expansion. On dedicated servers, all players are remote, so `IsSameSystem()` always returns false and these loops did nothing but waste CPU every tick
|
||||
|
|
|
|||
Loading…
Reference in a new issue