Analisi di scalabilità: da ~10 a 100 giocatori concorrenti (parti critiche + previsioni) #171

Open
opened 2026-07-23 20:41:27 +02:00 by marco · 0 comments
Owner

Analisi di scalabilità: da ~10 a 100 giocatori concorrenti

Analisi statica del codice (nessun benchmark eseguito). Obiettivo: individuare cosa si rompe passando da ~10 a 100 player concorrenti, ignorando l'hardware — quindi il focus è sulla complessità algoritmica, sulle allocazioni e sul fatto che tutto il lavoro CPU converge su un unico thread di simulazione (design ModernUO-style, Simulation, #54). Il tetto reale non è "quanti core", è "il thread di simulazione riesce a chiudere il lavoro di un tick in < 100 ms".

Le cifre "worst case" assumono uno scenario denso (evento/città: 100 player mutuamente visibili nello stesso AoI). Lo scenario "sparso" (player distribuiti sulla mappa) è molto più leggero, ma le query restano comunque full-scan.


Costanti misurate (GameOptions)

Parametro Valore Conseguenza
TickMilliseconds 100 10 tick/s; budget di 100 ms per tick sul thread sim
MoveCooldownTicks 2 max 5 mosse/s per player (1 ogni 2 tick)
AoiRadius 12 (Chebyshev) finestra visibile = (2·12+1)² = 625 tile
PersistenceIntervalSeconds 15 snapshot completo ogni 15 s
ChatMinIntervalMs 750 ~1,33 msg chat/s per player
OutboundCapacity (WebSocketConnection) 256 oltre 256 msg in coda → client disconnesso
ProtocolJson System.Text.Json reflection, SerializestringUTF8.GetBytes 2 alloc/msg per destinatario

Parti critiche (ordinate per leva)

🔴 1. Il reconcile del tick è O(N × E) — quadratico nei player

WorldTick.ReconcileAsync cicla per ogni osservatore (foreach observerId in connections.PlayerIds) e per ciascuno chiama quattro query spaziali full-scan (PlayersWithin, ItemsWithin, MobilesWithin, CorpsesWithin). Ogni Within itera l'intera collezione calcolando la distanza Chebyshev (es. PlayerRegistry.Within, ItemRegistry.Within:402, stesso pattern per Mobile/Corpse). Nessun indice spaziale.

Costo per tick ≈ N × (N + I + M + C) (N=player, I=item, M=creature, C=corpse).

Il termine player–player è O(N²):

Player Check player/player/tick /secondo Fattore
10 100 1.000
100 10.000 100.000 100×

Cioè: 10× i player ⇒ 100× il lavoro di scan. Con un mondo popolato (ipotesi I≈500, M≈200, C≈20): 100 × 820 = 82.000 check/tick ⇒ 820k check/s contro ~8,5k/s a 10 player. Il check Chebyshev è economico (~ns), quindi a 100 non è l'aritmetica a saturare — è quello che segue.

🔴 2. Serializzazione JSON per-destinatario, sul thread di simulazione

WebSocketConnection.SendAsync esegue ProtocolJson.Serialize(message) + UTF8.GetBytes inline e sincrono sul thread chiamante. Nel reconcile del tick quel thread è il thread sim. Ogni SendToAsync(listener, msg) ri-serializza lo stesso messaggio da capo. Un PlayerMoved visto da K osservatori viene serializzato K volte.

Worst case (cluster denso di 100, tutti che si muovono al cap): ~50 movers/tick × ~99 osservatori ≈ 4.950 PlayerMoved/tick, ognuno serializzato indipendentemente sul thread sim:

Player (denso) Serializzazioni/tick /secondo
10 ~45 ~450
100 ~4.950 ~49.500

A ~1–3 µs per serializzazione sono 5–15 ms/tick solo di JSON, sopra allo scan e alla GC. Somma reconcile + scan + GC + serializzazione + say/swing/spell (anch'essi fan-out per-osservatore) e sotto evento il tick sfora i 100 ms → i tick accumulano ritardo → movimento a scatti per tutti → la coda (illimitata) di InvokeAsync si allunga → spirale di latenza.

🟠 3. Pressione sulla GC (allocazioni per-tick)

Ogni Within alloca una List<T> nuova e ogni entità in range alloca un record di stato fresco (ToState(), MobileState, GroundItem, CorpseState) ad ogni tick:

  • Liste: 4 × N per tick → a 100 = 400 liste/tick = 4.000/s.
  • Record di stato: cluster denso → 100 obs × ~100 in range = ~10.000 PlayerState/tick = 100k/s (+ item/mobile/corpse).

Su un thread singolo, la GC Gen0 continua si traduce in pause che mangiano il budget di 100 ms. Bonus: metrics.UpdateWorldCounts(...) in WorldTick fa due enumerazioni LINQ complete (Count(predicate)) su tutti i mobile e tutti gli item ad ogni tick, solo per una gauge.

🟠 4. Il thread di simulazione è l'unico collo di bottiglia globale

Ogni intento del client (move, say, attack, pickup, parole di spell…) fa il marshalling sul thread sim via Simulation.InvokeAsync (canale unbounded), serializzato con il tick attraverso un solo reader. A 100 player: ~500 mosse/s + say + attack + spell + i connect. Coda illimitata ⇒ sotto sovraccarico niente drop, solo latenza crescente. È corretto per la consistenza (atomicità senza lock), ma significa che tick-scan + tutta la serializzazione + tutti gli intenti vivono su un core logico. Ignorando l'hardware, questo è il tetto.

🟡 5. Persistenza: build dei blob sul thread sim + riscrittura completa del file

WorldPersister.SaveAsync ogni 15 s chiama world.SerializeOnlinePlayers() via InvokeAsync — costruisce i blob di tutti i player (posizione+vitals+skill+item) sul thread sim, sincrono, bloccando il tick. A 10 player è trascurabile; a 100 con inventari veri è un hiccup misurabile ogni 15 s. Poi PlayerStore.FlushAsync riscrive l'intero world.sav (temp → replace atomico) off-thread — riscrittura completa ogni 15 s, che cresce con il numero totale di account salvati (i blob offline restano in memoria e nel file). Off-thread va bene per l'IO, ma il build on-thread e la riscrittura full sono da tenere d'occhio.

🟡 6. Auth (SQLite) sotto "login storm"

SQLite è single-writer. 100 primi-login quasi simultanei (creazione account = INSERT) si serializzano sul write-lock; un restart del server con riconnessione di massa può dare timeout/latenza in login. Le letture (login di ritorno) con WAL vanno bene. Nota collegata: la stessa ondata di riconnessioni colpisce il connect-path del GameServer (ogni connect = InvokeAsync con RestorePlayer + PlayersWithin) in coda dietro i tick.

🟡 7. OutboundCapacity = 256 → disconnessioni sotto carico

In un cluster denso ogni client riceve ~500 msg/s. Un client che si impunta per > ~0,5 s supera i 256 in coda e viene chiuso d'ufficio. A 100 player (più msg/s per client) un hiccup di rete transitorio produce più disconnessioni che a 10.


Previsioni sintetiche (10 vs 100, worst case denso)

Metrica 10 player 100 player Scala
Check di distanza player/player, /s 1.000 100.000
Scan totali AoI, /s (mondo popolato) ~8,5k ~820k ~100×
Serializzazioni JSON, /s ~450 ~49.500 ~110×
Liste allocate nel reconcile, /s 400 4.000 10× (×dim. cluster per i record)
Egress downstream aggregato ~0,3 MB/s ~3 MB/s (~26 Mbit/s) ~10×
Stall persistenza sul thread sim trascurabile hiccup ogni 15 s

Verdetto: il design è pulito e corretto, e a 10 player tutte queste voci sono invisibili. Sono tutte O(N²) / full-scan / per-destinatario: diventano il muro esattamente a scala 100, e soprattutto sotto clustering (evento, città, PvP di gruppo). Lo scenario sparso regge molto meglio, ma le query full-scan restano.


Interventi consigliati (per leva decrescente)

  1. Indice spaziale per l'AoI (bucket a griglia per cella, o quadtree). Trasforma le Within da O(tutte le entità) a O(celle nel raggio + entità in quelle celle), e il termine player O(N²) in ~O(N·k) con k = vicini medi. Leva più alta di tutte.
  2. Serializza-una-volta e broadcast dei byte. Serializzare ogni messaggio distinto una volta e mandare gli stessi byte a tutti i destinatari. Elimina la ridondanza per-destinatario dal thread sim.
  3. Togliere la serializzazione dal thread sim. Accodare l'oggetto messaggio (o i byte già pronti) e lasciare che la serializzazione/encoding avvenga fuori dal tick (i send-pump per-connessione esistono già).
  4. Ridurre le allocazioni per-tick. Riuso di buffer/liste, evitare la ricostruzione di ToState() per entità immutate, eliminare il Count(predicate) full-scan delle metriche ad ogni tick (aggiornare le gauge in modo incrementale).
  5. Formato wire binario (più piccolo e più veloce di JSON). Il protocollo è già versionato (ProtocolVersion.Current = 10) e c'è già la direzione mappa binaria (#143/#115) — stesso seam.
  6. Persistenza incrementale. Serializzare solo i player "dirty"; valutare di non tenere in memoria tutti gli account; il full-rewrite resta accettabile a scala-amici ma va annotato.
  7. Auth login-herd. WAL + retry/backoff su SQLITE_BUSY; eventuale limite di concorrenza sul connect-path.

Ordine di implementazione suggerito: (1) indice spaziale → (2) serializza-una-volta → (3) serializzazione off-thread → (4) allocazioni. I primi due, da soli, tagliano gli assi N² e ×K che dominano lo scenario denso.


Analisi statica del codice; nessuna misura runtime. I valori I≈500 / M≈200 / C≈20 e "cluster denso di 100" sono ipotesi di scenario, non dati osservati — il prossimo passo naturale è un load-test sintetico (bot headless) per validare le previsioni con le gauge Prometheus già esposte (#159): tempo-tick, msg/s, byte salvati.

## Analisi di scalabilità: da ~10 a 100 giocatori concorrenti Analisi **statica del codice** (nessun benchmark eseguito). Obiettivo: individuare cosa si rompe passando da ~10 a 100 player concorrenti, **ignorando l'hardware** — quindi il focus è sulla complessità algoritmica, sulle allocazioni e sul fatto che *tutto* il lavoro CPU converge su un **unico thread di simulazione** (design ModernUO-style, `Simulation`, #54). Il tetto reale non è "quanti core", è "il thread di simulazione riesce a chiudere il lavoro di un tick in < 100 ms". > Le cifre "worst case" assumono uno **scenario denso** (evento/città: 100 player mutuamente visibili nello stesso AoI). Lo scenario "sparso" (player distribuiti sulla mappa) è molto più leggero, ma le query restano comunque full-scan. --- ### Costanti misurate (`GameOptions`) | Parametro | Valore | Conseguenza | |---|---|---| | `TickMilliseconds` | 100 | **10 tick/s**; budget di 100 ms per tick sul thread sim | | `MoveCooldownTicks` | 2 | max **5 mosse/s per player** (1 ogni 2 tick) | | `AoiRadius` | 12 (Chebyshev) | finestra visibile = (2·12+1)² = **625 tile** | | `PersistenceIntervalSeconds` | 15 | snapshot completo ogni 15 s | | `ChatMinIntervalMs` | 750 | ~1,33 msg chat/s per player | | `OutboundCapacity` (`WebSocketConnection`) | 256 | oltre 256 msg in coda → **client disconnesso** | | `ProtocolJson` | System.Text.Json **reflection**, `Serialize`→`string`→`UTF8.GetBytes` | 2 alloc/msg **per destinatario** | --- ## Parti critiche (ordinate per leva) ### 🔴 1. Il reconcile del tick è O(N × E) — quadratico nei player `WorldTick.ReconcileAsync` cicla **per ogni osservatore** (`foreach observerId in connections.PlayerIds`) e per ciascuno chiama quattro query spaziali **full-scan** (`PlayersWithin`, `ItemsWithin`, `MobilesWithin`, `CorpsesWithin`). Ogni `Within` itera l'**intera** collezione calcolando la distanza Chebyshev (es. `PlayerRegistry.Within`, `ItemRegistry.Within:402`, stesso pattern per Mobile/Corpse). **Nessun indice spaziale.** Costo per tick ≈ `N × (N + I + M + C)` (N=player, I=item, M=creature, C=corpse). Il termine player–player è **O(N²)**: | Player | Check player/player/tick | /secondo | Fattore | |---|---|---|---| | 10 | 100 | 1.000 | 1× | | 100 | 10.000 | 100.000 | **100×** | Cioè: 10× i player ⇒ **100×** il lavoro di scan. Con un mondo popolato (ipotesi I≈500, M≈200, C≈20): `100 × 820 = 82.000` check/tick ⇒ **820k check/s** contro ~8,5k/s a 10 player. Il check Chebyshev è economico (~ns), quindi a 100 non è l'aritmetica a saturare — è quello che segue. ### 🔴 2. Serializzazione JSON **per-destinatario, sul thread di simulazione** `WebSocketConnection.SendAsync` esegue `ProtocolJson.Serialize(message)` + `UTF8.GetBytes` **inline e sincrono** sul thread chiamante. Nel reconcile del tick quel thread **è** il thread sim. Ogni `SendToAsync(listener, msg)` ri-serializza lo **stesso** messaggio da capo. Un `PlayerMoved` visto da K osservatori viene serializzato **K volte**. Worst case (cluster denso di 100, tutti che si muovono al cap): ~50 movers/tick × ~99 osservatori ≈ **4.950 `PlayerMoved`/tick**, ognuno serializzato indipendentemente sul thread sim: | Player (denso) | Serializzazioni/tick | /secondo | |---|---|---| | 10 | ~45 | ~450 | | 100 | ~4.950 | **~49.500** | A ~1–3 µs per serializzazione sono **5–15 ms/tick** solo di JSON, **sopra** allo scan e alla GC. Somma reconcile + scan + GC + serializzazione + say/swing/spell (anch'essi fan-out per-osservatore) e sotto evento il tick **sfora i 100 ms** → i tick accumulano ritardo → movimento a scatti **per tutti** → la coda (illimitata) di `InvokeAsync` si allunga → spirale di latenza. ### 🟠 3. Pressione sulla GC (allocazioni per-tick) Ogni `Within` alloca una `List<T>` nuova e ogni entità in range alloca un record di stato fresco (`ToState()`, `MobileState`, `GroundItem`, `CorpseState`) **ad ogni tick**: - Liste: `4 × N` per tick → a 100 = **400 liste/tick** = 4.000/s. - Record di stato: cluster denso → 100 obs × ~100 in range = **~10.000 `PlayerState`/tick** = 100k/s (+ item/mobile/corpse). Su un thread singolo, la GC Gen0 continua si traduce in **pause che mangiano il budget di 100 ms**. Bonus: `metrics.UpdateWorldCounts(...)` in `WorldTick` fa **due enumerazioni LINQ complete** (`Count(predicate)`) su tutti i mobile e tutti gli item **ad ogni tick**, solo per una gauge. ### 🟠 4. Il thread di simulazione è l'unico collo di bottiglia globale Ogni intento del client (`move`, `say`, `attack`, `pickup`, parole di spell…) fa il marshalling sul thread sim via `Simulation.InvokeAsync` (canale **unbounded**), serializzato con il tick attraverso **un solo reader**. A 100 player: ~500 mosse/s + say + attack + spell + i connect. Coda illimitata ⇒ sotto sovraccarico **niente drop, solo latenza crescente**. È corretto per la consistenza (atomicità senza lock), ma significa che tick-scan + tutta la serializzazione + tutti gli intenti vivono **su un core logico**. Ignorando l'hardware, questo è **il** tetto. ### 🟡 5. Persistenza: build dei blob sul thread sim + riscrittura completa del file `WorldPersister.SaveAsync` ogni 15 s chiama `world.SerializeOnlinePlayers()` via `InvokeAsync` — costruisce i blob di **tutti** i player (posizione+vitals+skill+item) **sul thread sim**, sincrono, **bloccando il tick**. A 10 player è trascurabile; a 100 con inventari veri è un **hiccup misurabile ogni 15 s**. Poi `PlayerStore.FlushAsync` riscrive **l'intero `world.sav`** (temp → replace atomico) off-thread — riscrittura **completa** ogni 15 s, che cresce con il numero **totale** di account salvati (i blob offline restano in memoria e nel file). Off-thread va bene per l'IO, ma il build on-thread e la riscrittura full sono da tenere d'occhio. ### 🟡 6. Auth (SQLite) sotto "login storm" SQLite è **single-writer**. 100 primi-login quasi simultanei (creazione account = INSERT) si **serializzano** sul write-lock; un restart del server con riconnessione di massa può dare timeout/latenza in login. Le letture (login di ritorno) con WAL vanno bene. Nota collegata: la stessa ondata di riconnessioni colpisce il connect-path del GameServer (ogni connect = `InvokeAsync` con `RestorePlayer` + `PlayersWithin`) **in coda dietro i tick**. ### 🟡 7. `OutboundCapacity = 256` → disconnessioni sotto carico In un cluster denso ogni client riceve ~500 msg/s. Un client che si impunta per > ~0,5 s supera i 256 in coda e viene **chiuso d'ufficio**. A 100 player (più msg/s per client) un hiccup di rete transitorio produce **più disconnessioni** che a 10. --- ## Previsioni sintetiche (10 vs 100, worst case denso) | Metrica | 10 player | 100 player | Scala | |---|---|---|---| | Check di distanza player/player, /s | 1.000 | 100.000 | **N²** | | Scan totali AoI, /s (mondo popolato) | ~8,5k | ~820k | ~100× | | Serializzazioni JSON, /s | ~450 | ~49.500 | ~110× | | Liste allocate nel reconcile, /s | 400 | 4.000 | 10× (×dim. cluster per i record) | | Egress downstream aggregato | ~0,3 MB/s | **~3 MB/s (~26 Mbit/s)** | ~10× | | Stall persistenza sul thread sim | trascurabile | hiccup ogni 15 s | — | **Verdetto:** il design è pulito e corretto, e a 10 player tutte queste voci sono invisibili. Sono tutte O(N²) / full-scan / per-destinatario: **diventano il muro esattamente a scala 100, e soprattutto sotto clustering** (evento, città, PvP di gruppo). Lo scenario sparso regge molto meglio, ma le query full-scan restano. --- ## Interventi consigliati (per leva decrescente) 1. **Indice spaziale per l'AoI** (bucket a griglia per cella, o quadtree). Trasforma le `Within` da O(tutte le entità) a O(celle nel raggio + entità in quelle celle), e il termine player O(N²) in ~O(N·k) con k = vicini medi. **Leva più alta di tutte.** 2. **Serializza-una-volta e broadcast dei byte.** Serializzare ogni messaggio distinto **una** volta e mandare gli stessi byte a tutti i destinatari. Elimina la ridondanza per-destinatario dal thread sim. 3. **Togliere la serializzazione dal thread sim.** Accodare l'**oggetto** messaggio (o i byte già pronti) e lasciare che la serializzazione/encoding avvenga fuori dal tick (i send-pump per-connessione esistono già). 4. **Ridurre le allocazioni per-tick.** Riuso di buffer/liste, evitare la ricostruzione di `ToState()` per entità immutate, eliminare il `Count(predicate)` full-scan delle metriche ad ogni tick (aggiornare le gauge in modo incrementale). 5. **Formato wire binario** (più piccolo e più veloce di JSON). Il protocollo è già versionato (`ProtocolVersion.Current = 10`) e c'è già la direzione mappa binaria (#143/#115) — stesso seam. 6. **Persistenza incrementale.** Serializzare solo i player "dirty"; valutare di non tenere in memoria **tutti** gli account; il full-rewrite resta accettabile a scala-amici ma va annotato. 7. **Auth login-herd.** WAL + retry/backoff su `SQLITE_BUSY`; eventuale limite di concorrenza sul connect-path. > Ordine di implementazione suggerito: **(1) indice spaziale → (2) serializza-una-volta → (3) serializzazione off-thread → (4) allocazioni**. I primi due, da soli, tagliano gli assi N² e ×K che dominano lo scenario denso. --- *Analisi statica del codice; nessuna misura runtime. I valori I≈500 / M≈200 / C≈20 e "cluster denso di 100" sono ipotesi di scenario, non dati osservati — il prossimo passo naturale è un load-test sintetico (bot headless) per validare le previsioni con le gauge Prometheus già esposte (#159): tempo-tick, msg/s, byte salvati.*
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
marco/IsoMmo#171
No description provided.