perf: process 16 chunks/player/tick on dedicated server, revert async save

Chunk loading now batches up to 16 nearest-first requests per player per
tick on dedicated server (client stays at 1), improving tick recovery
time after player join.

Reverts the async save system -- the background thread snapshot/compress
path added complexity without measurable benefit. Autosave on Windows64
server now uses the standard synchronous flush like client, in
preparation for a proper async implementation from upstream.
This commit is contained in:
itsRevela 2026-04-05 15:56:39 -05:00
parent be17d4028f
commit 207d90de28
5 changed files with 36 additions and 192 deletions

View file

@ -487,37 +487,52 @@ void PlayerChunkMap::getChunkAndRemovePlayer(int x, int z, shared_ptr<ServerPlay
}
// 4J - added - actually create & add player to a playerchunk, if there is one queued for this player.
// Processes up to CHUNKS_PER_PLAYER_PER_TICK requests per call to speed up initial chunk loading.
#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD)
static const int CHUNKS_PER_PLAYER_PER_TICK = 16;
#else
static const int CHUNKS_PER_PLAYER_PER_TICK = 1;
#endif
void PlayerChunkMap::tickAddRequests(shared_ptr<ServerPlayer> player)
{
if( addRequests.size() )
{
// Find the nearest chunk request to the player
int px = static_cast<int>(player->x);
int pz = static_cast<int>(player->z);
int minDistSq = -1;
auto itNearest = addRequests.end();
for (auto it = addRequests.begin(); it != addRequests.end(); it++)
{
if( it->player == player )
for (int processed = 0; processed < CHUNKS_PER_PLAYER_PER_TICK; processed++)
{
// Find the nearest chunk request to the player
int minDistSq = -1;
auto itNearest = addRequests.end();
for (auto it = addRequests.begin(); it != addRequests.end(); it++)
{
int xm = ( it->x * 16 ) + 8;
int zm = ( it->z * 16 ) + 8;
int distSq = (xm - px) * (xm - px) +
(zm - pz) * (zm - pz);
if( ( minDistSq == -1 ) || ( distSq < minDistSq ) )
if( it->player == player )
{
minDistSq = distSq;
itNearest = it;
int xm = ( it->x * 16 ) + 8;
int zm = ( it->z * 16 ) + 8;
int distSq = (xm - px) * (xm - px) +
(zm - pz) * (zm - pz);
if( ( minDistSq == -1 ) || ( distSq < minDistSq ) )
{
minDistSq = distSq;
itNearest = it;
}
}
}
}
// If we found one at all, then do this one
if( itNearest != addRequests.end() )
{
getChunk(itNearest->x, itNearest->z, true)->add(itNearest->player);
addRequests.erase(itNearest);
// If we found one, process it and continue; otherwise done
if( itNearest != addRequests.end() )
{
getChunk(itNearest->x, itNearest->z, true)->add(itNearest->player);
addRequests.erase(itNearest);
}
else
{
break;
}
}
}
}

View file

@ -977,11 +977,8 @@ void ServerLevel::save(bool force, ProgressListener *progressListener, bool bAut
if (progressListener != nullptr) progressListener->progressStage(IDS_PROGRESS_SAVING_CHUNKS);
#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 defined(_XBOX_ONE) || defined(__ORBIS__)
// Our autosave is a minimal save. All the chunks are saves by the constant save process
if(bAutosave)
{
chunkSource->saveAllEntities();

View file

@ -16,7 +16,6 @@
#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"
@ -331,7 +330,6 @@ static void TickCoreSystems()
g_NetworkManager.DoWork();
ProfileManager.Tick();
StorageManager.Tick();
ConsoleSaveFileOriginal::CommitPendingAsyncSave();
}
/**

View file

@ -12,26 +12,6 @@
#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
@ -694,130 +674,6 @@ 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);
// Allocate compression buffer while still on the game thread and
// holding the lock (StorageManager is not thread-safe).
byte *compData = static_cast<byte *>(StorageManager.AllocateSaveData(fileSize + 8));
if (compData == nullptr)
{
app.DebugPrintf("Async save: failed to allocate compression buffer, falling back to sync\n");
delete[] snapshot;
goto sync_flush;
}
// Release the lock -- main thread and chunk trickle saves can resume
ReleaseSaveAccess();
// Pack context for the background thread
struct AsyncSaveContext
{
byte *snapshot;
byte *compData;
unsigned int fileSize;
ConsoleSaveFile *self;
PBYTE thumbData;
DWORD thumbSize;
BYTE textMetadata[88];
int textMetadataBytes;
};
auto *ctx = new AsyncSaveContext();
ctx->snapshot = snapshot;
ctx->compData = compData;
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;
Compression::getCompression()->Compress(
ctx->compData + 8, &compLength, ctx->snapshot, ctx->fileSize);
ZeroMemory(ctx->compData, 8);
int saveVer = 0;
memcpy(ctx->compData, &saveVer, sizeof(int));
unsigned int fs = ctx->fileSize;
memcpy(ctx->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
{
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;
}
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
@ -1276,20 +1132,3 @@ 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

View file

@ -77,11 +77,6 @@ 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();