Skip to content

Decode SOG in chunks to avoid wasm32 OOM on large files (fixes #490) - #491

Open
n3ur0m4n wants to merge 2 commits into
sparkjsdev:mainfrom
n3ur0m4n:fix/sog-chunked-decode
Open

n3ur0m4n wants to merge 2 commits into
sparkjsdev:mainfrom
n3ur0m4n:fix/sog-chunked-decode

Conversation

@n3ur0m4n

Copy link
Copy Markdown
Contributor

Fixes #490

Cause

SogsDecoder kept everything alive at the same time: the zip buffer, the cache of compressed files, the five decoded RGBA images, full-size f32 arrays for every property, plus the receiving PackedSplats. On a 39.5M-splat SOG this adds up to about 4.5 GB, over the 4 GiB wasm32 address space. The allocation failure aborts, which surfaces as unreachable in the worker.

Changes (rust/spark-lib/src/sogs.rs only)

  • finish() takes the buffer with std::mem::take and passes it by value; the zip bytes are dropped right after the files are preloaded.
  • get_file removes an entry from the cache on its last use (use count per name taken from meta.json).
  • The compressed file is dropped right after its image is decoded.
  • No full-size f32 arrays: splats are emitted in MAX_SPLAT_CHUNK blocks with reused buffers, one set_batch per block.
  • shN centroids/labels are loaded once and decoded per block.

Results (Chrome, SOG v2, 39,548,487 splats, 415 MB, no shN)

Peak worker wasm memory Outcome
Before 4028 MiB crash (unreachable)
After 1177 MB loads completely

No regression on a 9.3M-splat SOG and on a streamed SOG.

Tests

Two new tests build 70,000-splat SOG zips in memory (more than one chunk, last one partial), v1 and v2, with 3 shN bands, feed them to SogsDecoder in several pushes and compare every value against a per-splat reference (relative tolerance 1e-6).

The memory64 fix proposed in #490 is independent and not included here.

The decoder kept the zip buffer, the compressed file cache, all decoded
RGBA images and full-size f32 arrays alive at the same time, which
exceeds the 4 GiB wasm32 limit on large files (~40M splats).

- finish() moves the zip buffer into decode_sogs, dropped after preload
- cache entries are removed on their last use
- compressed bytes are dropped right after each image decode
- splats are emitted in MAX_SPLAT_CHUNK blocks via set_batch, with
  reused buffers; shN centroids/labels are loaded once and decoded per
  block
- tests: 70k-splat v1/v2 SOG zips with 3 SH bands, compared against a
  per-splat reference

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Comment on lines +172 to +175
let mut file_cache: HashMap<String, Vec<u8>> = HashMap::new();
for f in meta_files(&meta) {
preload_file(&mut zip, &prefix, &mut file_cache, f)?;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The files in file_cache will exclusively be images. Changing it to a HashMap<String, ImageData> and moving the image decoding here could simplify things a bit. The raw file/image buffer can be dropped after decoding, eliminating the need to keep track of uses and mutating the cache.

Comment thread rust/spark-lib/src/sogs.rs Outdated
}
}

fn meta_files(meta: &PcSogsRoot) -> Vec<&String> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: naming could be improved as the returned file names are the image files referenced in the meta file, not a list of "meta files".

Comment thread rust/spark-lib/src/sogs.rs Outdated
Comment on lines +261 to +263
sh1: sh(1, 9),
sh2: sh(2, 15),
sh3: sh(3, 21),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: I think it's fine to inline and repeat the sh function here.

Comment thread rust/spark-lib/src/sogs.rs Outdated
};
let mut base = 0usize;
while base < num_splats {
let cnt = (num_splats - base).min(MAX_SPLAT_CHUNK);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: cnt -> count

Comment thread rust/spark-lib/src/sogs.rs Outdated
let i4 = i * 4;
) {
for i in 0..out_center.len() / 3 {
let i4 = (base + i) * 4;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not too fond of i4 and i3 being "out of sync" here and in the other decode methods. Ideally it remains a simple shorthand for i * 4. Perhaps using a slice of img0/1.rgba or computing a "base offset" base * 4 and using that alongside i4?

Comment thread rust/spark-lib/src/sogs.rs Outdated
let lz = build_lookup(mins[2], maxs[2]);
for i in 0..num_splats {
let i4 = i * 4;
/// v2: same codebook on all 3 axes; v1: per-axis min/max lookup

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need for this comment IMHO, it makes more sense at the call-site, though the fact that v2 has a single codebook for all three axes is pretty self-evident at that point in the code.

@mrxz

mrxz commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator

Changes look good for the most part, left some comments.

There is also #389, which hasn't been merged, but would change the memory characteristics of SOGs loading as well. Effectively moving the unzipping and image decoding to the JS side, which likewise would keep the wasm memory usage lower. Although it doesn't have the chunked processing, meaning peak memory usage would still spike somewhat.

Regardless it's a good idea to no keep the zip buffer in-memory when decompression has finished (or the raw image buffers after image decoding). Even with the above linked PR this would still be the case for non-web contexts like build-lod.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@n3ur0m4n

Copy link
Copy Markdown
Contributor Author

Thanks a lot for the careful review, and for the time you put into Spark in general, it's been a great base to build on. All points are addressed:

  • file_cache is now turned into a HashMap<String, ImageData> right after the zip buffer is dropped; each raw buffer is freed as soon as its image is decoded, so the uses tracking and cache mutation are gone. I decode after dropping the zip rather than inside the zip loop, so the zip and the decoded images are never held at the same time.
  • Renamed meta_files to meta_image_files.
  • Inlined the sh helper.
  • cnt -> count.
  • The decode functions now slice the image data at base * 4, so i4 is just i * 4 again, in sync with i3.
  • Removed the codebook comment.

Re #389: I see it as complementary. Moving unzip/decode to JS helps the web path, but chunked processing still bounds peak memory there, and build-lod and other non-web contexts still go through this decoder.

Thanks again, the suggestions made the code noticeably simpler.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Large non-paged SOG (39.5M splats) fails with wasm unreachable: decoder exceeds the 4 GiB wasm32 memory limit

2 participants