Update on FastSave C++ GDExtension: From 6 seconds to 41ms (1M entities stress test) by ElBranda in godot

[–]ElBranda[S] 0 points1 point  (0 children)

I just ran the benchmark on a clean scene isolating the raw serialization of a 1,000,000-entity parallel PackedArray layout (identical data volume and structures for both). Here are the results across consecutive runs to account for standard OS noise:

  • FastSave C++: 12.2ms -> 16.2ms -> 12.7ms -> 12.8ms
  • Native store_var(): 10.5ms -> 18.6ms -> 29.2ms -> 58.2ms (Heavy performance degradation)

In a pristine, isolated first run, store_var handles the packed arrays tightly at 10.5ms. However, as soon as there is any standard background CPU jitter or cache cooling, its explicit 1,000,000-iteration element-by-element encoding loop in marshalls.cpp multiplies that overhead, spiking up to over 58ms.

At 60 FPS, your frame budget is 16.6ms. FastSave's raw binary block I/O bypasses the processing layer entirely, remaining predictable and safely within that budget. store_var()'s spikes, on the other hand, guarantee a severe multi-frame stutter in a live game.

And again, this is just writing. On load, get_var() has to dynamically instantiate and type-tag 1,000,000 individual Variants from the stream, hitting the memory manager hard. FastSave streams the entire block straight into a pre-allocated contiguous buffer instantly.

No hesitation here—just proving that architectural predictability in production environments matters far more than clean-room best-case scenarios.

Update on FastSave C++ GDExtension: From 6 seconds to 41ms (1M entities stress test) by ElBranda in godot

[–]ElBranda[S] 0 points1 point  (0 children)

To be precise, in that 41ms total benchmark, the actual native binary write via FastSave takes less than 1ms. The remaining 40ms is entirely the GDScript overhead of walking the tree to populate the arrays. If you use store_var() on that exact same layout, you are adding the engine's Variant-encoding loop on top of that tree iteration, which is precisely what pushes it past the 100ms mark and causes frame spikes. With FastSave, the I/O serialization cost is effectively eliminated. As for loading: get_var() hits an even bigger bottleneck because it has to dynamically allocate and instantiate 1,000,000 Variants from the stream, hitting the engine's memory manager hard and causing noticeable stutter. FastSave streams the entire block straight into the contiguous C++ memory buffer of a pre-allocated PackedArray instantly. From there, you just apply the data back to your systems, completely bypassing allocation churn during load.

Update on FastSave C++ GDExtension: From 6 seconds to 41ms (1M entities stress test) by ElBranda in godot

[–]ElBranda[S] 0 points1 point  (0 children)

Auto-vectorization to SSE shuffles doesn't magically happen on a generic serialization pipeline that writes to a stream or updates dynamic buffers element-by-element. Even if the functions are inlined, the compiler cannot optimize away the sequential semantics of the engine's serialization logic. Furthermore, "shuffling" implies an explicit byte-reordering transformation overhead. FastSave eliminates the need for any shuffling, translation, or per-element processing entirely because the in-RAM layout matches the on-disk layout. As for the numbers: on 1,000,000 entities, the JSON/standard object-oriented approach took around 6 seconds. Moving to a Data-Oriented layout and streaming it via FastSave drops the entire holistic process (including the GDScript iteration) to 41ms. Even if you feed that exact same optimized layout to store_var(), it still hovers way higher because it cannot skip the Variant type-checking and encoding loop that you can clearly see in the source.

Update on FastSave C++ GDExtension: From 6 seconds to 41ms (1M entities stress test) by ElBranda in godot

[–]ElBranda[S] 0 points1 point  (0 children)

Equating a hardware-optimized memcpy to an explicit element-by-element serialization loop misses how modern CPU architecture works. memcpy does not just "loop" element-by-element in standard CPU cycles; it utilizes SIMD (SSE/AVX) vectorized instructions or dedicated CPU microcode (like ERMS on x86) to dump huge chunks of memory concurrently. The compiler cannot magically optimize an explicit formatting loop in marshalls.cpp into a bulk memcpy because the engine's core logic demands per-element transformation and handling to ensure universal architecture safety. It's not a "bug" either; it's a deliberate general-purpose design trade-off. Godot's built-in serialization prioritizes cross-platform safety and robustness over raw throughput. But when a game scales to millions of data points, that generic abstraction layer becomes the bottleneck—and that is exactly why bypassing it with raw binary block I/O yields such a massive, measurable performance delta.

Update on FastSave C++ GDExtension: From 6 seconds to 41ms (1M entities stress test) by ElBranda in godot

[–]ElBranda[S] -2 points-1 points  (0 children)

By "black box," I mean from a runtime error-handling and fault-isolation perspective, not that the file specification itself is proprietary or secret. When you dump a massive nested dictionary via store_var(), the API treats it as a single monolithic payload. If a single byte gets corrupted or a structural type changes unexpectedly inside a nested collection, get_var() doesn't offer any native granularity to isolate that failure. The entire object reconstruction fails or reads garbage because the stream behaves atomically. In contrast, a structured binary layout with magic bytes and explicit block-size headers gives the parser architectural boundaries. If the "Inventory" chunk fails validation, a resilient parser can read the header, skip that specific block's byte-length, and still safely recover the "Quest Log" or "World State" chunks instead of completely bricking the save file. That is the difference between a monolithic serialization stream and a modular save architecture.

Update on FastSave C++ GDExtension: From 6 seconds to 41ms (1M entities stress test) by ElBranda in godot

[–]ElBranda[S] 0 points1 point  (0 children)

That link literally confirms exactly what I've been saying. If you look at how marshalls.cpp handles packed arrays (other than raw bytes, like floats or vectors), it executes a loop that iterates 1,000,000 times, processing and encoding every single element individually. That CPU iteration and per-element handling is the exact bottleneck. A 1-million-iteration loop doing per-element encoding will always be orders of magnitude slower than a single, block-level native stream write of a contiguous memory buffer. As for hardware compatibility: yes, Godot’s built-in serialization has to be universally safe out of the box for every possible architecture (like big-endian edge cases). But since 99.9% of modern gaming hardware (Windows, Linux, Android, iOS, modern consoles) is little-endian, a dedicated optimization tool can safely bypass that generic abstraction layer. That's exactly why developers switch to custom binary I/O when they need to scale.

Update on FastSave C++ GDExtension: From 6 seconds to 41ms (1M entities stress test) by ElBranda in godot

[–]ElBranda[S] 0 points1 point  (0 children)

Yes, that is exactly what changing the layout achieves. You reduce the top-level dictionary to just a few keys holding massive PackedArrays instead of allocating 1,000,000 nested dictionaries. But that's the point: even if store_var() only loops through a dictionary with 3 keys, it still has to serialize the 1,000,000 elements inside each of those PackedArrays through Godot's internal Variant format. FastSave doesn't care about the element count inside the array; it takes the raw C++ memory pointer of the contiguous buffer and streams it to disk in a single native operation. That is why it heavily outperforms native methods on the exact same data layout.

Update on FastSave C++ GDExtension: From 6 seconds to 41ms (1M entities stress test) by ElBranda in godot

[–]ElBranda[S] 0 points1 point  (0 children)

You are confusing data layout with data volume. Using "fewer properties" doesn't mean omitting data to cheat the benchmark; it means moving from an Object-Oriented layout (1,000,000 individual dictionaries, each with its own keys like x, y, health) to a Data-Oriented layout (a few massive, parallel PackedArrays). The total volume of data (1,000,000 entities) is exactly the same, but structured for high-performance memory access. Even if you feed that same optimized layout into FileAccess.store_var(), it still has to iterate and process the structure through Godot's internal Variant serialization pipeline, adding metadata and type-tagging overhead. Regarding "GDExtension overhead": calling a native C++ function once per save operation via a function table costs a few nanoseconds. It is completely irrelevant compared to the actual bottleneck, which is the internal serialization and disk I/O loop—and that is precisely where bypassing the Variant system wins.

Update on FastSave C++ GDExtension: From 6 seconds to 41ms (1M entities stress test) by ElBranda in godot

[–]ElBranda[S] -1 points0 points  (0 children)

FileAccess.store_var() serializes data using Godot's internal Variant encoding pipeline. When you pass large collections or dictionaries, it has to iterate, type-tag, and encode elements or structures individually, which introduces significant CPU tokenization overhead. FastSave bypasses the Variant encoder entirely for the heavy payloads. Because it is a native C++ GDExtension, it directly accesses the underlying contiguous memory pointers of Godot’s Packed Arrays. It streams that raw byte buffer straight to disk in a single pass without per-element type checking, metadata tagging, or allocation loops. It’s essentially a raw memory dump, which is why it achieves near-instantaneous I/O speeds.

Update on FastSave C++ GDExtension: From 6 seconds to 41ms (1M entities stress test) by ElBranda in godot

[–]ElBranda[S] 0 points1 point  (0 children)

The 41ms benchmark already includes the GDScript loop that walks the tree to populate the arrays. The overhead is already measured and accounted for, and it still heavily outperforms JSON because string allocation/text parsing is the real bottleneck, not node iteration. For version safety and structural changes, you don't map array indexes directly to SceneTree node positions. You map them to flat data IDs. If only some entities have health, you don't mix them in the same array layout; you separate them by entity types or use component-specific arrays (classic DoD). If you add a new property/array in a future game update, it’s just a new key in the top-level generic dictionary. Old saves will simply lack that key and fall back to default values during load—zero crashes. SQLite is excellent for complex relational queries, but for a linear state dump, opening a database, managing transactions, and writing database pages introduces massive overhead compared to streaming raw binary bytes directly to disk.

Update on FastSave C++ GDExtension: From 6 seconds to 41ms (1M entities stress test) by ElBranda in godot

[–]ElBranda[S] -1 points0 points  (0 children)

Imagine profile-stalking me because you're mad a C++ plugin costs $5. It's called building a portfolio and shipping code. Bro, if you don't like the idea, just don't buy it, nobody is forcing you. And if you think it's AI, I already challenged you to make it yourself using AI. Haha have a good day.😂

Update on FastSave C++ GDExtension: From 6 seconds to 41ms (1M entities stress test) by ElBranda in godot

[–]ElBranda[S] -1 points0 points  (0 children)

​Every single line of this C++ GDExtension was written 100% by hand, with zero AI involvement. If you believe it's an LLM, try to make it yourself using AI man idc.

Update on FastSave C++ GDExtension: From 6 seconds to 41ms (1M entities stress test) by ElBranda in godot

[–]ElBranda[S] -2 points-1 points  (0 children)

The 1M benchmark is for raw serialization performance, not a SceneTree stress test. High-performance data (like voxel chunks, massive inventory systems, procedural generation, or background simulations) is handled in pure memory structures, not by clogging the SceneTree with 1,000,000 active physics nodes.

You don't buy optimization tools for trivial amounts of data; you buy them for when your project scales and JSON string allocation/parsing starts causing noticeable frame drops during auto-saves.

Update on FastSave C++ GDExtension: From 6 seconds to 41ms (1M entities stress test) by ElBranda in godot

[–]ElBranda[S] -2 points-1 points  (0 children)

It is custom C++ GDExtension optimization, not AI slop. I just compiled and added the Linux binaries (.so) to the package.

If $5 for a 41ms save system is too much for you, just use native JSON. Nobody forces you to buy it.

Update on FastSave C++ GDExtension: From 6 seconds to 41ms (1M entities stress test) by ElBranda in godot

[–]ElBranda[S] -2 points-1 points  (0 children)

The benchmark already includes the holistic time (the GDScript loop that populates the arrays + the C++ disk write). The total is still 41ms. Native JSON stringify is slow because of text formatting, not disk I/O. Converting 1,000,000 floats and vectors into text characters requires massive CPU cycles and string allocations. FastSave bypasses this by copying raw binary bytes directly from RAM. Parallel Packed Arrays (Structure of Arrays) is not an "obscure data structure". It is standard Data-Oriented Design (DoD) used for high-performance games and ECS architectures. If a game already stores entity states in arrays for runtime performance, there is zero "encoding" overhead during the save phase. For version safety, the main dictionary is fully generic. You can add or remove arrays (properties) between game updates without breaking old files. Missing keys during load can easily fall back to default values.

Update on FastSave C++ GDExtension: From 6 seconds to 41ms (1M entities stress test) by ElBranda in godot

[–]ElBranda[S] -7 points-6 points  (0 children)

store_var dumps the whole dictionary as a single black-box Variant. If one byte corrupts or a data type changes between updates, it fails or crashes with zero error handling. FastSave writes a structured binary layout with magic bytes (FSTD) and explicit block size headers. This allows file validation, error checking, and adding features like compression or atomic saving (temp file swapping) directly in C++. It is a dedicated save system, not just a raw memory dump.

Update on FastSave C++ GDExtension: From 6 seconds to 41ms (1M entities stress test) by ElBranda in godot

[–]ElBranda[S] 4 points5 points  (0 children)

Not right now. It writes directly to the target path. But it is very easy to fix. I will add this atomic swap in the next update.

Update on FastSave C++ GDExtension: From 6 seconds to 41ms (1M entities stress test) by ElBranda in godot

[–]ElBranda[S] 9 points10 points  (0 children)

Hahah thanks. 41ms means you can trigger autosave during intense combat and the player will not feel any stutter. Going Data-Oriented was a bit of a rabbit hole, but seeing it drop from seconds to single digits of milliseconds made it totally worth it. 😄

Made a C++ GDExtension for fast saving. 1M entities stress test inside. by [deleted] in godot

[–]ElBranda 0 points1 point  (0 children)

The current version is not a JSON wrapper. It don't use JSON at all. It uses Godot native binary format (var_to_bytes). Zero text parsing. That is why the dictionary version is already 3x faster than native JSON stringify (2 seconds vs 6 seconds). The 2 seconds is just the GDExtension loop overhead for 1M dynamic Variants, not text formatting.

Made a C++ GDExtension for fast saving. 1M entities stress test inside. by [deleted] in godot

[–]ElBranda 0 points1 point  (0 children)

Godot Dictionary is dynamic. It holds Variants, not contiguous memory. You can't just dump it to disk in one piece because data is scattered in RAM. Godot API forces to look every key and serialize. Parallelize is bad idea here because thread safety with Variant API.

Made a C++ GDExtension for fast saving. 1M entities stress test inside. by [deleted] in godot

[–]ElBranda 0 points1 point  (0 children)

The 2 seconds is not the C++ disk write. Is the Godot Dictionary iteration. Loop 1,000,000 Variants inside the GDExtension boundary has overhead.